use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use crate::inference::structure_evidence::{
ClaimKind, GateVerdict, StructureLedger, run_atom_birth_gate,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StructureMove {
Birth { candidate: usize },
Death { atom: usize },
Fission { atom: usize },
Fusion { a: usize, b: usize },
}
impl StructureMove {
fn touches(&self) -> Vec<usize> {
match self {
StructureMove::Birth { .. } => Vec::new(),
StructureMove::Death { atom } | StructureMove::Fission { atom } => vec![*atom],
StructureMove::Fusion { a, b } => vec![*a, *b],
}
}
fn kind_rank(&self) -> u8 {
match self {
StructureMove::Death { .. } => 0,
StructureMove::Fission { .. } => 1,
StructureMove::Fusion { .. } => 2,
StructureMove::Birth { .. } => 3,
}
}
fn trigger_ascending(&self) -> bool {
matches!(self, StructureMove::Fission { .. })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MoveProposal {
pub mv: StructureMove,
pub trigger: f64,
pub structure_hash: u64,
pub claim: ClaimKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct MoveBudget {
pub max_moves: usize,
pub alpha: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum MoveVerdict {
Accepted { log_e: f64 },
Contested { log_e: f64 },
Demoted { log_e: f64 },
Vetoed { log_e: f64 },
Deduplicated,
Stale,
Deferred,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MoveRecord {
pub mv: StructureMove,
pub trigger: f64,
pub structure_hash: u64,
pub claim: ClaimKind,
pub verdict: MoveVerdict,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct CollapseEvent {
pub iteration: usize,
pub atom: usize,
pub max_active_mass: f64,
pub floor: f64,
pub action: CollapseAction,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CollapseAction {
Reseeded,
Terminal,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SearchLedger {
pub alpha: f64,
pub moves: Vec<MoveRecord>,
pub collapse_events: Vec<CollapseEvent>,
}
pub struct SearchOutcome<S> {
pub state: S,
pub ledger: SearchLedger,
}
pub fn canonical_order(proposals: &mut [MoveProposal]) {
proposals.sort_by(|x, y| {
let xr = x.mv.kind_rank();
let yr = y.mv.kind_rank();
xr.cmp(&yr)
.then_with(|| {
let (xt, yt) = if x.mv.trigger_ascending() {
(x.trigger, y.trigger)
} else {
(-x.trigger, -y.trigger)
};
xt.total_cmp(&yt)
})
.then_with(|| x.structure_hash.cmp(&y.structure_hash))
});
}
pub fn search<S, Sh>(
mut state: S,
mut proposals: Vec<MoveProposal>,
shards: &[Sh],
budget: &MoveBudget,
ledger: &mut StructureLedger,
mut apply_move: impl FnMut(&S, &StructureMove) -> Result<S, String>,
mut eval_log_lik: impl FnMut(&S, &Sh) -> f64,
mut null_sup_log_lik: impl FnMut(&S, &Sh) -> f64,
mut refit: impl FnMut(S, &Sh) -> S,
) -> Result<SearchOutcome<S>, String> {
if !(budget.alpha > 0.0 && budget.alpha < 1.0) {
return Err(format!(
"structure_search: alpha must be in (0,1), got {}",
budget.alpha
));
}
if let Some(bad) = proposals.iter().find(|p| !p.trigger.is_finite()) {
return Err(format!(
"structure_search: non-finite trigger {} on {:?}",
bad.trigger, bad.mv
));
}
canonical_order(&mut proposals);
let mut seen_hashes: HashSet<u64> = HashSet::new();
let mut touched: Vec<usize> = Vec::new();
let mut moves_applied = 0usize;
let mut records: Vec<MoveRecord> = Vec::with_capacity(proposals.len());
for prop in proposals {
let verdict = if !seen_hashes.insert(prop.structure_hash) {
MoveVerdict::Deduplicated
} else if moves_applied >= budget.max_moves {
MoveVerdict::Deferred
} else if prop.mv.touches().iter().any(|a| touched.contains(a)) {
MoveVerdict::Stale
} else {
match &prop.mv {
StructureMove::Death { atom } => {
let idx = ledger.register(prop.claim.clone());
let evidence = &ledger.claims()[idx].evidence;
let log_e = evidence.log_evidence();
if evidence.rejects_at(budget.alpha) {
MoveVerdict::Vetoed { log_e }
} else {
state = apply_move(&state, &prop.mv)?;
touched.push(*atom);
moves_applied += 1;
MoveVerdict::Demoted { log_e }
}
}
mv @ (StructureMove::Birth { .. }
| StructureMove::Fission { .. }
| StructureMove::Fusion { .. }) => {
let candidate = apply_move(&state, mv)?;
let (gate, folded) = run_atom_birth_gate(
budget.alpha,
candidate,
shards.iter(),
|c, sh| eval_log_lik(c, *sh),
|sh| null_sup_log_lik(&state, *sh),
|c, sh| refit(c, *sh),
)?;
let idx = ledger.register(prop.claim.clone());
match gate.verdict() {
GateVerdict::Certified { log_e } => {
ledger.absorb_log(idx, log_e)?;
state = folded;
touched.extend(mv.touches());
moves_applied += 1;
MoveVerdict::Accepted { log_e }
}
GateVerdict::Contested { log_e } => {
ledger.absorb_log(idx, log_e)?;
MoveVerdict::Contested { log_e }
}
}
}
}
};
records.push(MoveRecord {
mv: prop.mv,
trigger: prop.trigger,
structure_hash: prop.structure_hash,
claim: prop.claim,
verdict,
});
}
Ok(SearchOutcome {
state,
ledger: SearchLedger {
alpha: budget.alpha,
moves: records,
collapse_events: Vec::new(),
},
})
}
#[cfg(test)]
mod tests {
use super::*;
type Dict = Vec<&'static str>;
fn advantage(state: &Dict) -> f64 {
let mut adv = 0.0;
if state.contains(&"real") {
adv += 0.8;
}
if state.contains(&"fake") {
adv -= 0.2;
}
adv
}
fn apply(state: &Dict, mv: &StructureMove) -> Result<Dict, String> {
let mut next = state.clone();
match mv {
StructureMove::Birth { candidate } => {
next.push(if *candidate == 0 { "real" } else { "extra" });
}
StructureMove::Death { atom } => {
if *atom < next.len() {
next[*atom] = "dead";
}
}
StructureMove::Fusion { .. } => next.push("fake"),
StructureMove::Fission { .. } => next.push("split"),
}
Ok(next)
}
fn run(
state: Dict,
proposals: Vec<MoveProposal>,
n_shards: usize,
budget: &MoveBudget,
ledger: &mut StructureLedger,
) -> SearchOutcome<Dict> {
let shards: Vec<f64> = vec![1.0; n_shards];
search(
state,
proposals,
&shards,
budget,
ledger,
apply,
|c, _sh| -100.0 + advantage(c),
|_s, _sh| -100.0,
|c, _sh| c,
)
.expect("search runs")
}
fn birth(candidate: usize, trigger: f64, hash: u64) -> MoveProposal {
MoveProposal {
mv: StructureMove::Birth { candidate },
trigger,
structure_hash: hash,
claim: ClaimKind::AtomExists {
atom: 100 + candidate,
},
}
}
#[test]
fn canonical_order_ranks_kinds_and_triggers() {
let mut props = vec![
birth(0, 0.5, 7),
MoveProposal {
mv: StructureMove::Fusion { a: 1, b: 2 },
trigger: 0.9,
structure_hash: 3,
claim: ClaimKind::BindingEdge { a: 1, b: 2 },
},
MoveProposal {
mv: StructureMove::Death { atom: 4 },
trigger: 1e6,
structure_hash: 1,
claim: ClaimKind::AtomExists { atom: 4 },
},
MoveProposal {
mv: StructureMove::Fission { atom: 3 },
trigger: 0.01,
structure_hash: 2,
claim: ClaimKind::Custom {
label: "fission:3".to_string(),
},
},
MoveProposal {
mv: StructureMove::Fission { atom: 5 },
trigger: 0.001,
structure_hash: 9,
claim: ClaimKind::Custom {
label: "fission:5".to_string(),
},
},
];
canonical_order(&mut props);
assert!(matches!(props[0].mv, StructureMove::Death { atom: 4 }));
assert!(matches!(props[1].mv, StructureMove::Fission { atom: 5 }));
assert!(matches!(props[2].mv, StructureMove::Fission { atom: 3 }));
assert!(matches!(props[3].mv, StructureMove::Fusion { .. }));
assert!(matches!(props[4].mv, StructureMove::Birth { .. }));
}
#[test]
fn birth_certifies_and_null_fusion_stays_contested() {
let mut ledger = StructureLedger::new();
let budget = MoveBudget {
max_moves: 8,
alpha: 0.05,
};
let proposals = vec![
birth(0, 1.0, 11),
MoveProposal {
mv: StructureMove::Fusion { a: 0, b: 1 },
trigger: 0.8,
structure_hash: 12,
claim: ClaimKind::BindingEdge { a: 0, b: 1 },
},
];
let out = run(vec!["a", "b"], proposals, 10, &budget, &mut ledger);
let fusion_rec = &out.ledger.moves[0];
assert!(matches!(fusion_rec.mv, StructureMove::Fusion { .. }));
match fusion_rec.verdict {
MoveVerdict::Contested { log_e } => assert!(log_e < 0.0),
ref v => panic!("spurious fusion must stay contested, got {v:?}"),
}
let birth_rec = &out.ledger.moves[1];
match birth_rec.verdict {
MoveVerdict::Accepted { log_e } => assert!(log_e >= -(0.05f64.ln())),
ref v => panic!("planted birth must certify, got {v:?}"),
}
assert!(out.state.contains(&"real"));
assert!(!out.state.contains(&"fake"));
let cert = ledger.certify(0.05);
let confirmed: Vec<_> = cert.confirmed().map(|e| e.kind.clone()).collect();
assert!(confirmed.contains(&ClaimKind::AtomExists { atom: 100 }));
assert!(
cert.contested()
.any(|e| e.kind == ClaimKind::BindingEdge { a: 0, b: 1 } && e.log_e < 0.0)
);
}
#[test]
fn death_vetoes_certified_demotes_contested_and_staleness_propagates() {
let mut ledger = StructureLedger::new();
let certified = ledger.register(ClaimKind::AtomExists { atom: 0 });
ledger.absorb_log(certified, 5.0).unwrap(); let weak = ledger.register(ClaimKind::AtomExists { atom: 1 });
ledger.absorb_log(weak, -1.0).unwrap();
let budget = MoveBudget {
max_moves: 8,
alpha: 0.05,
};
let proposals = vec![
MoveProposal {
mv: StructureMove::Death { atom: 0 },
trigger: 9.0,
structure_hash: 21,
claim: ClaimKind::AtomExists { atom: 0 },
},
MoveProposal {
mv: StructureMove::Death { atom: 1 },
trigger: 8.0,
structure_hash: 22,
claim: ClaimKind::AtomExists { atom: 1 },
},
MoveProposal {
mv: StructureMove::Fusion { a: 1, b: 2 },
trigger: 0.9,
structure_hash: 23,
claim: ClaimKind::BindingEdge { a: 1, b: 2 },
},
];
let out = run(vec!["a", "b", "c"], proposals, 4, &budget, &mut ledger);
assert!(matches!(
out.ledger.moves[0].verdict,
MoveVerdict::Vetoed { .. }
));
match out.ledger.moves[1].verdict {
MoveVerdict::Demoted { log_e } => assert!((log_e - (-1.0)).abs() < 1e-12),
ref v => panic!("contested atom must demote, got {v:?}"),
}
assert_eq!(out.state[1], "dead");
assert_eq!(out.state[0], "a", "vetoed death must not touch the atom");
assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Stale));
}
#[test]
fn budget_defers_and_hash_dedups() {
let mut ledger = StructureLedger::new();
let budget = MoveBudget {
max_moves: 1,
alpha: 0.05,
};
let proposals = vec![
birth(0, 1.0, 31),
birth(0, 0.9, 31), birth(1, 0.5, 32), ];
let out = run(vec!["a"], proposals, 10, &budget, &mut ledger);
assert!(matches!(
out.ledger.moves[0].verdict,
MoveVerdict::Accepted { .. }
));
assert!(matches!(
out.ledger.moves[1].verdict,
MoveVerdict::Deduplicated
));
assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Deferred));
}
#[test]
fn ledger_is_deterministic_across_runs() {
let props = || {
vec![
birth(0, 1.0, 41),
MoveProposal {
mv: StructureMove::Death { atom: 1 },
trigger: 3.0,
structure_hash: 42,
claim: ClaimKind::AtomExists { atom: 1 },
},
MoveProposal {
mv: StructureMove::Fusion { a: 0, b: 2 },
trigger: 0.7,
structure_hash: 43,
claim: ClaimKind::BindingEdge { a: 0, b: 2 },
},
]
};
let budget = MoveBudget {
max_moves: 8,
alpha: 0.05,
};
let mut scrambled = props();
scrambled.reverse();
let mut ledger_a = StructureLedger::new();
let out_a = run(vec!["a", "b", "c"], props(), 6, &budget, &mut ledger_a);
let mut ledger_b = StructureLedger::new();
let out_b = run(vec!["a", "b", "c"], scrambled, 6, &budget, &mut ledger_b);
let ser_a = serde_json::to_string(&out_a.ledger).expect("serialize");
let ser_b = serde_json::to_string(&out_b.ledger).expect("serialize");
assert_eq!(ser_a, ser_b);
assert_eq!(out_a.state, out_b.state);
}
#[test]
fn invalid_inputs_error() {
let mut ledger = StructureLedger::new();
let shards: Vec<f64> = vec![1.0];
let bad_alpha = search(
vec!["a"],
Vec::<MoveProposal>::new(),
&shards,
&MoveBudget {
max_moves: 1,
alpha: 1.0,
},
&mut ledger,
apply,
|_c: &Dict, _sh| 0.0,
|_s, _sh| 0.0,
|c, _sh| c,
);
assert!(bad_alpha.is_err());
let bad_trigger = search(
vec!["a"],
vec![birth(0, f64::NAN, 1)],
&shards,
&MoveBudget {
max_moves: 1,
alpha: 0.05,
},
&mut ledger,
apply,
|_c: &Dict, _sh| 0.0,
|_s, _sh| 0.0,
|c, _sh| c,
);
assert!(bad_trigger.is_err());
}
}