gam_solve/structure_search.rs
1//! #976 — evidence-guarded dictionary structure search: atom birth / death /
2//! fission / fusion as anytime-valid hypothesis tests, with a deterministic,
3//! serializable [`SearchLedger`] as the honesty surface.
4//!
5//! # What this is
6//!
7//! The two documented SAE pathologies, restated statistically:
8//!
9//! * **Feature absorption** (an A⇒B hierarchy makes sparsity fold B's content
10//! into A's direction): an absorbing atom's code distribution carries
11//! substructure — detectable misspecification, found by a within-atom audit
12//! and corrected by a FISSION move.
13//! * **Feature shattering** (one curved family smeared across many
14//! near-duplicate flat atoms): shattered atoms have dependent codes
15//! (`gam_sae::atom_codes::CoactivationStats::dependence`) and joint
16//! structure when refit together — corrected by a FUSION move.
17//!
18//! This module owns the MOVE ENGINE: canonical deterministic proposal order,
19//! structural-hash deduplication, e-process-gated acceptance, and the ledger.
20//! It is generic over the fitter — the caller supplies the state type and four
21//! closures (apply / evaluate / null-sup / refit), exactly the surface
22//! [`run_atom_birth_gate`] already pins down. Warm structure inheritance is
23//! enforced by construction: a candidate state is built FROM the parent state
24//! (`apply_move(&parent, &mv)`), never from scratch — cold restarts after
25//! structure moves are both slow and collapse-prone, so the API gives them no
26//! entry point.
27//!
28//! # Acceptance is a hypothesis test, not a threshold (#984)
29//!
30//! The original #976 design accepted a move when
31//! `Δ(neg log evidence) < −margin` under the Laplace normalizer. That is the
32//! K vs K+1 boundary / Davies-regime comparison where likelihood-ratio
33//! thresholds are invalid (the null sits on the boundary of the alternative;
34//! the new atom's parameters vanish under the null). Acceptance here is
35//! therefore routed through the universal-inference e-process gates of
36//! [`gam_terms::inference::structure_evidence`]:
37//!
38//! * **Birth / fission / fusion** each assert structure BEYOND what the
39//! current dictionary class expresses, so each runs an [`AtomBirthGate`]
40//! (the mechanics are claim-generic: predictable alternative, honest
41//! null sup, Ville threshold at the α fixed in [`MoveBudget`]). A move is
42//! applied only when its claim is **Certified**; otherwise the structure is
43//! unchanged and the claim stays **Contested** in the [`StructureLedger`]
44//! with its banked evidence — the input to the #984 probe-design loop.
45//! * **Death is never certifiable, by construction.** The K−1 class is nested
46//! inside the current class, so the split-likelihood e-value satisfies
47//! `E ≤ 1` pointwise (the null sup dominates any sub-model fit): no amount
48//! of data can *prove* an atom unnecessary — only fail to prove it
49//! necessary. The demote-never-reject philosophy is therefore not a policy
50//! choice here, it is what the math leaves: a death proposal DEMOTES an atom
51//! whose `AtomExists` claim has never certified (trigger: diverged ARD
52//! precision), and is VETOED for a certified atom (a Ville crossing is
53//! permanent — later evidence retreat cannot un-prove existence).
54//!
55//! # Determinism
56//!
57//! No RNG, no clock. Proposals are sorted by the canonical order (deaths by
58//! ARD precision descending, fissions by audit significance ascending, fusions
59//! by code dependence descending, births last by proposal mass descending; ties
60//! broken by structural hash), deduplicated by the caller-computed structural
61//! hash (the `TermCollectionSpec` hash machinery, #869), and processed
62//! sequentially. Identical inputs ⇒ identical serialized [`SearchLedger`] —
63//! which is what keeps replicate-null comparisons (#910/#943) valid across
64//! structure changes.
65//!
66//! The ledger reports a certified **local** mode: the moves explored, the
67//! evidence for accepted ones, and the evidence gaps to rejected alternatives.
68//! No global-optimality theater.
69
70use serde::{Deserialize, Serialize};
71use std::collections::HashSet;
72
73use gam_terms::inference::structure_evidence::{
74 ClaimKind, GateVerdict, StructureLedger, run_atom_birth_gate,
75};
76
77/// One proposed structural move. Atom indices are STABLE IDENTIFIERS for the
78/// duration of one [`search`] round: the caller's `apply_move` must not
79/// reindex surviving atoms (mark dead atoms inactive, append born atoms) —
80/// the engine relies on this to detect conflicting proposals.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub enum StructureMove {
83 /// Add a new atom. `candidate` indexes the caller's proposal list (e.g.
84 /// scaffold clusters on first build; whitened residual-factor directions
85 /// thereafter — see #974's rescope: proposals must come from the WHITENED
86 /// residual subspace, raw-Euclidean Λ skews loud-but-inert).
87 Birth { candidate: usize },
88 /// Demote an atom whose existence was never certified (ARD precision
89 /// diverged). Never applies to a certified atom.
90 Death { atom: usize },
91 /// Split an atom along detected substructure (within-atom audit / #975
92 /// vanished-interaction carve).
93 Fission { atom: usize },
94 /// Merge two atoms into one joint structure (dependent codes + joint
95 /// interaction evidence — #975's binding, in reverse).
96 Fusion { a: usize, b: usize },
97}
98
99impl StructureMove {
100 /// Atoms whose state this move modifies (births create, so touch none).
101 fn touches(&self) -> Vec<usize> {
102 match self {
103 StructureMove::Birth { .. } => Vec::new(),
104 StructureMove::Death { atom } | StructureMove::Fission { atom } => vec![*atom],
105 StructureMove::Fusion { a, b } => vec![*a, *b],
106 }
107 }
108
109 /// Canonical kind rank: deaths, fissions, fusions, births.
110 fn kind_rank(&self) -> u8 {
111 match self {
112 StructureMove::Death { .. } => 0,
113 StructureMove::Fission { .. } => 1,
114 StructureMove::Fusion { .. } => 2,
115 StructureMove::Birth { .. } => 3,
116 }
117 }
118
119 /// Whether the canonical order sorts this kind's trigger ascending
120 /// (fission audits report significance levels — smaller is more urgent)
121 /// or descending (ARD precision, code dependence, proposal mass).
122 fn trigger_ascending(&self) -> bool {
123 matches!(self, StructureMove::Fission { .. })
124 }
125}
126
127/// One proposal: the move, its trigger statistic (the canonical-order key,
128/// kind-specific — see [`StructureMove`] docs), the caller-computed structural
129/// hash of the POST-move specification (dedup key), and the structural claim
130/// the move asserts (registered in the [`StructureLedger`] so the dictionary
131/// certificate covers it).
132#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
133pub struct MoveProposal {
134 pub mv: StructureMove,
135 /// Canonical-order key. Deaths: ARD amplitude precision (descending).
136 /// Fissions: within-atom audit significance (ascending). Fusions: code
137 /// dependence (descending). Births: explained proposal mass (descending).
138 /// Must be finite.
139 pub trigger: f64,
140 /// Structural hash of the specification the move produces (#869
141 /// `TermCollectionSpec` machinery). Two proposals with the same hash are
142 /// the same structure; only the canonically-first is gated.
143 pub structure_hash: u64,
144 /// The claim this move asserts. Births: `AtomExists`. Fusions:
145 /// `BindingEdge`. Fissions: a `Custom`/`GeometryKind` substructure claim.
146 /// Deaths: the `AtomExists` claim CONSULTED for the veto/demote decision.
147 pub claim: ClaimKind,
148}
149
150/// The search round's budget and error level.
151#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
152pub struct MoveBudget {
153 /// Maximum structure-changing moves (accepted + demoted) applied this
154 /// round; remaining proposals are recorded as `Deferred`, never silently
155 /// dropped.
156 pub max_moves: usize,
157 /// The level every gate certifies at; fixed for the round so verdicts
158 /// cannot be shopped.
159 pub alpha: f64,
160}
161
162/// The per-proposal outcome. Every proposal handed to [`search`] gets exactly
163/// one record — the no-silent-caps rule.
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165pub enum MoveVerdict {
166 /// Gate certified at α; the move was applied and the claim's evidence
167 /// banked in the ledger.
168 Accepted { log_e: f64 },
169 /// Gate did not certify; structure unchanged, claim stays contested in
170 /// the ledger with this evidence (the probe loop's input).
171 Contested { log_e: f64 },
172 /// Death applied to a never-certified atom (its contested evidence at the
173 /// time of demotion is recorded).
174 Demoted { log_e: f64 },
175 /// Death proposal on a CERTIFIED atom — refused; Ville crossings are
176 /// permanent.
177 Vetoed { log_e: f64 },
178 /// Same structural hash as a canonically-earlier proposal this round.
179 Deduplicated,
180 /// References an atom already modified this round; triggers are stale —
181 /// re-propose next round against the new structure.
182 Stale,
183 /// Move budget exhausted before this proposal was reached.
184 Deferred,
185}
186
187/// One ledger line: the proposal exactly as ranked, plus its verdict.
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
189pub struct MoveRecord {
190 pub mv: StructureMove,
191 pub trigger: f64,
192 pub structure_hash: u64,
193 pub claim: ClaimKind,
194 pub verdict: MoveVerdict,
195}
196
197/// An assignment-collapse event from the joint fit (#976 Layer-1 guard): an
198/// atom's support fell below the active-mass floor and was either re-seeded
199/// (bounded budget) or recorded as terminally collapsed — an observable event,
200/// never a silent death and never a fit error. Terminal collapses are the
201/// natural death-proposal feed for the next [`search`] round.
202#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
203pub struct CollapseEvent {
204 /// Outer iteration of the joint fit at which the breach was observed.
205 pub iteration: usize,
206 /// The collapsed atom.
207 pub atom: usize,
208 /// The atom's maximum active mass over rows at the breach (the collapse
209 /// statistic: a legitimately sparse atom has small MEAN mass but high
210 /// mass on its rows; only an atom with no material support anywhere has a
211 /// small MAX).
212 pub max_active_mass: f64,
213 /// The floor breached.
214 pub floor: f64,
215 /// What the guard did.
216 pub action: CollapseAction,
217}
218
219/// The guard's response to an active-mass breach.
220#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
221pub enum CollapseAction {
222 /// The atom's gate logits were re-seeded to a mode-appropriate neutral
223 /// (one second chance from a fresh basin; bounded budget per atom).
224 Reseeded,
225 /// Re-seed budget exhausted and the atom collapsed again: the collapse is
226 /// (locally) the objective's verdict. Recorded once; the structure-search
227 /// death move owns the decision from here.
228 Terminal,
229}
230
231/// The serialized honesty surface of one search round: every proposal in
232/// canonical order with its verdict, plus any collapse events the joint fit
233/// recorded. Identical inputs produce a byte-identical serialization.
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct SearchLedger {
236 /// The α every verdict in this round was gated at.
237 pub alpha: f64,
238 /// One record per proposal, in canonical processing order.
239 pub moves: Vec<MoveRecord>,
240 /// Layer-1 guard events carried from the joint fit (see
241 /// [`CollapseEvent`]); attached by the caller.
242 pub collapse_events: Vec<CollapseEvent>,
243}
244
245/// Result of one search round: the (possibly restructured) state and the
246/// ledger.
247pub struct SearchOutcome<S> {
248 pub state: S,
249 pub ledger: SearchLedger,
250}
251
252/// Sort proposals into the canonical deterministic order: kind rank (deaths,
253/// fissions, fusions, births), then the kind's trigger direction, then
254/// structural hash. Pure — no RNG, no clock — so the search path, and with it
255/// the ledger, is a function of the inputs alone.
256pub fn canonical_order(proposals: &mut [MoveProposal]) {
257 proposals.sort_by(|x, y| {
258 let xr = x.mv.kind_rank();
259 let yr = y.mv.kind_rank();
260 xr.cmp(&yr)
261 .then_with(|| {
262 let (xt, yt) = if x.mv.trigger_ascending() {
263 (x.trigger, y.trigger)
264 } else {
265 (-x.trigger, -y.trigger)
266 };
267 xt.total_cmp(&yt)
268 })
269 .then_with(|| x.structure_hash.cmp(&y.structure_hash))
270 });
271}
272
273/// Run one evidence-guarded structure-search round.
274///
275/// * `state` — the current fitted structure (dictionary). Moves are applied
276/// sequentially; later gates run against the updated state.
277/// * `proposals` — trigger-ranked candidate moves (any order; the engine
278/// canonicalizes). Triggers must be finite.
279/// * `shards` — the evaluation stream for the gates. Each certifiable move
280/// streams over ALL shards (or until certified) under the universal-
281/// inference contract of [`run_atom_birth_gate`]: the candidate is evaluated
282/// on a shard strictly before being refit with it, so the plug-in is
283/// predictable and the e-process valid under optional stopping. Validity
284/// requires these shards be data the TRIGGERS were not tuned on (the same
285/// estimation/evaluation split discipline as every e-value here).
286/// * `ledger` — the dictionary's claim ledger, carried ACROSS rounds: claims
287/// keep their banked evidence (idempotent registration), so a structure
288/// contested this round resumes from its evidence next round, and the death
289/// veto sees certifications from any earlier round.
290/// * `apply_move` — build the candidate state from the PARENT state (warm
291/// inheritance by construction). For deaths this is the demotion itself.
292/// * `eval_log_lik(candidate, shard)` — evaluation log-likelihood of a shard
293/// under the candidate as currently fit (prior shards only — the engine
294/// guarantees the call order).
295/// * `null_sup_log_lik(state, shard)` — the HONEST sup: the current structure
296/// refit on the shard. Under-maximizing this side inflates every e-value
297/// and voids validity; it is the one closure that must genuinely optimize.
298/// * `refit(candidate, shard)` — fold the shard into the candidate (any
299/// fitter, warm starts, GPU; no conditions).
300pub fn search<S, Sh>(
301 mut state: S,
302 mut proposals: Vec<MoveProposal>,
303 shards: &[Sh],
304 budget: &MoveBudget,
305 ledger: &mut StructureLedger,
306 mut apply_move: impl FnMut(&S, &StructureMove) -> Result<S, String>,
307 mut eval_log_lik: impl FnMut(&S, &Sh) -> f64,
308 mut null_sup_log_lik: impl FnMut(&S, &Sh) -> f64,
309 mut refit: impl FnMut(S, &Sh) -> S,
310) -> Result<SearchOutcome<S>, String> {
311 if !(budget.alpha > 0.0 && budget.alpha < 1.0) {
312 return Err(format!(
313 "structure_search: alpha must be in (0,1), got {}",
314 budget.alpha
315 ));
316 }
317 if let Some(bad) = proposals.iter().find(|p| !p.trigger.is_finite()) {
318 return Err(format!(
319 "structure_search: non-finite trigger {} on {:?}",
320 bad.trigger, bad.mv
321 ));
322 }
323 canonical_order(&mut proposals);
324
325 let mut seen_hashes: HashSet<u64> = HashSet::new();
326 let mut touched: Vec<usize> = Vec::new();
327 let mut moves_applied = 0usize;
328 let mut records: Vec<MoveRecord> = Vec::with_capacity(proposals.len());
329
330 for prop in proposals {
331 // Dedup is a property of the proposal stream (a duplicate structural
332 // hash describes a proposal that the engine has already considered),
333 // so it is decided BEFORE the budget gate: a duplicate of an
334 // already-applied move stays a duplicate even when the budget is
335 // exhausted. Reversing this order mislabels duplicates as deferred,
336 // which breaks the dedup-vs-defer accounting downstream (a deferred
337 // record is replayed by the next round; a deduplicated one is not).
338 let verdict = if !seen_hashes.insert(prop.structure_hash) {
339 MoveVerdict::Deduplicated
340 } else if moves_applied >= budget.max_moves {
341 MoveVerdict::Deferred
342 } else if prop.mv.touches().iter().any(|a| touched.contains(a)) {
343 MoveVerdict::Stale
344 } else {
345 match &prop.mv {
346 StructureMove::Death { atom } => {
347 let idx = ledger.register(prop.claim.clone());
348 let evidence = &ledger.claims()[idx].evidence;
349 let log_e = evidence.log_evidence();
350 if evidence.rejects_at(budget.alpha) {
351 MoveVerdict::Vetoed { log_e }
352 } else {
353 state = apply_move(&state, &prop.mv)?;
354 touched.push(*atom);
355 moves_applied += 1;
356 MoveVerdict::Demoted { log_e }
357 }
358 }
359 mv @ (StructureMove::Birth { .. }
360 | StructureMove::Fission { .. }
361 | StructureMove::Fusion { .. }) => {
362 let candidate = apply_move(&state, mv)?;
363 // `shards.iter()` makes the gate's shard item `&Sh`, so the
364 // closures receive `&&Sh`; deref once back to the caller's
365 // `&Sh` surface.
366 let (gate, folded) = run_atom_birth_gate(
367 budget.alpha,
368 candidate,
369 shards.iter(),
370 |c, sh| eval_log_lik(c, *sh),
371 |sh| null_sup_log_lik(&state, *sh),
372 |c, sh| refit(c, *sh),
373 )?;
374 let idx = ledger.register(prop.claim.clone());
375 match gate.verdict() {
376 GateVerdict::Certified { log_e } => {
377 ledger.absorb_log(idx, log_e)?;
378 state = folded;
379 touched.extend(mv.touches());
380 moves_applied += 1;
381 MoveVerdict::Accepted { log_e }
382 }
383 GateVerdict::Contested { log_e } => {
384 ledger.absorb_log(idx, log_e)?;
385 MoveVerdict::Contested { log_e }
386 }
387 }
388 }
389 }
390 };
391 records.push(MoveRecord {
392 mv: prop.mv,
393 trigger: prop.trigger,
394 structure_hash: prop.structure_hash,
395 claim: prop.claim,
396 verdict,
397 });
398 }
399
400 Ok(SearchOutcome {
401 state,
402 ledger: SearchLedger {
403 alpha: budget.alpha,
404 moves: records,
405 collapse_events: Vec::new(),
406 },
407 })
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 /// Test fixture: a "dictionary" is a sorted set of atom labels; the
415 /// per-shard log-likelihood advantage of a candidate over the honest null
416 /// sup is scripted per label, so the statistics are exact and the tests
417 /// exercise the ENGINE (ordering, gating, veto, budget, determinism) —
418 /// the e-process statistics themselves are pinned in structure_evidence.
419 type Dict = Vec<&'static str>;
420
421 /// Per-shard advantage of a state over the null sup: +0.8 nats/shard when
422 /// the planted "real" atom is present, −0.2 when the spurious "fake" fused
423 /// atom is present, 0 otherwise.
424 fn advantage(state: &Dict) -> f64 {
425 let mut adv = 0.0;
426 if state.contains(&"real") {
427 adv += 0.8;
428 }
429 if state.contains(&"fake") {
430 adv -= 0.2;
431 }
432 adv
433 }
434
435 fn apply(state: &Dict, mv: &StructureMove) -> Result<Dict, String> {
436 let mut next = state.clone();
437 match mv {
438 StructureMove::Birth { candidate } => {
439 next.push(if *candidate == 0 { "real" } else { "extra" });
440 }
441 StructureMove::Death { atom } => {
442 if *atom < next.len() {
443 next[*atom] = "dead";
444 }
445 }
446 StructureMove::Fusion { .. } => next.push("fake"),
447 StructureMove::Fission { .. } => next.push("split"),
448 }
449 Ok(next)
450 }
451
452 fn run(
453 state: Dict,
454 proposals: Vec<MoveProposal>,
455 n_shards: usize,
456 budget: &MoveBudget,
457 ledger: &mut StructureLedger,
458 ) -> SearchOutcome<Dict> {
459 let shards: Vec<f64> = vec![1.0; n_shards];
460 search(
461 state,
462 proposals,
463 &shards,
464 budget,
465 ledger,
466 apply,
467 |c, _sh| -100.0 + advantage(c),
468 |_s, _sh| -100.0,
469 |c, _sh| c,
470 )
471 .expect("search runs")
472 }
473
474 fn birth(candidate: usize, trigger: f64, hash: u64) -> MoveProposal {
475 MoveProposal {
476 mv: StructureMove::Birth { candidate },
477 trigger,
478 structure_hash: hash,
479 claim: ClaimKind::AtomExists {
480 atom: 100 + candidate,
481 },
482 }
483 }
484
485 /// Canonical order: deaths → fissions → fusions → births, direction-aware
486 /// triggers, hash tiebreak.
487 #[test]
488 fn canonical_order_ranks_kinds_and_triggers() {
489 let mut props = vec![
490 birth(0, 0.5, 7),
491 MoveProposal {
492 mv: StructureMove::Fusion { a: 1, b: 2 },
493 trigger: 0.9,
494 structure_hash: 3,
495 claim: ClaimKind::BindingEdge { a: 1, b: 2 },
496 },
497 MoveProposal {
498 mv: StructureMove::Death { atom: 4 },
499 trigger: 1e6,
500 structure_hash: 1,
501 claim: ClaimKind::AtomExists { atom: 4 },
502 },
503 MoveProposal {
504 mv: StructureMove::Fission { atom: 3 },
505 trigger: 0.01,
506 structure_hash: 2,
507 claim: ClaimKind::Custom {
508 label: "fission:3".to_string(),
509 },
510 },
511 MoveProposal {
512 mv: StructureMove::Fission { atom: 5 },
513 trigger: 0.001,
514 structure_hash: 9,
515 claim: ClaimKind::Custom {
516 label: "fission:5".to_string(),
517 },
518 },
519 ];
520 canonical_order(&mut props);
521 assert!(matches!(props[0].mv, StructureMove::Death { atom: 4 }));
522 // Fissions ascending by significance: 0.001 before 0.01.
523 assert!(matches!(props[1].mv, StructureMove::Fission { atom: 5 }));
524 assert!(matches!(props[2].mv, StructureMove::Fission { atom: 3 }));
525 assert!(matches!(props[3].mv, StructureMove::Fusion { .. }));
526 assert!(matches!(props[4].mv, StructureMove::Birth { .. }));
527 }
528
529 /// A planted birth certifies (0.8 nats/shard × 10 shards crosses ln 20),
530 /// updates the state, and banks certified evidence in the claim ledger; a
531 /// spurious fusion stays contested, leaves the state unchanged, and its
532 /// claim keeps (negative) evidence for the probe loop.
533 #[test]
534 fn birth_certifies_and_null_fusion_stays_contested() {
535 let mut ledger = StructureLedger::new();
536 let budget = MoveBudget {
537 max_moves: 8,
538 alpha: 0.05,
539 };
540 let proposals = vec![
541 birth(0, 1.0, 11),
542 MoveProposal {
543 mv: StructureMove::Fusion { a: 0, b: 1 },
544 trigger: 0.8,
545 structure_hash: 12,
546 claim: ClaimKind::BindingEdge { a: 0, b: 1 },
547 },
548 ];
549 let out = run(vec!["a", "b"], proposals, 10, &budget, &mut ledger);
550
551 // Fusion is gated first (canonical order) and must NOT certify.
552 let fusion_rec = &out.ledger.moves[0];
553 assert!(matches!(fusion_rec.mv, StructureMove::Fusion { .. }));
554 match fusion_rec.verdict {
555 MoveVerdict::Contested { log_e } => assert!(log_e < 0.0),
556 ref v => panic!("spurious fusion must stay contested, got {v:?}"),
557 }
558 // Birth certifies and the atom is in the final state.
559 let birth_rec = &out.ledger.moves[1];
560 match birth_rec.verdict {
561 MoveVerdict::Accepted { log_e } => assert!(log_e >= -(0.05f64.ln())),
562 ref v => panic!("planted birth must certify, got {v:?}"),
563 }
564 assert!(out.state.contains(&"real"));
565 assert!(!out.state.contains(&"fake"));
566
567 // Ledger: birth claim certified, fusion claim contested with evidence.
568 let cert = ledger.certify(0.05);
569 let confirmed: Vec<_> = cert.confirmed().map(|e| e.kind.clone()).collect();
570 assert!(confirmed.contains(&ClaimKind::AtomExists { atom: 100 }));
571 assert!(
572 cert.contested()
573 .any(|e| e.kind == ClaimKind::BindingEdge { a: 0, b: 1 } && e.log_e < 0.0)
574 );
575 }
576
577 /// Death is vetoed for a certified atom (Ville permanence) and demotes a
578 /// never-certified one; a later proposal touching the demoted atom is
579 /// stale.
580 #[test]
581 fn death_vetoes_certified_demotes_contested_and_staleness_propagates() {
582 let mut ledger = StructureLedger::new();
583 let certified = ledger.register(ClaimKind::AtomExists { atom: 0 });
584 ledger.absorb_log(certified, 5.0).unwrap(); // > ln 20 ⇒ certified at 0.05
585 let weak = ledger.register(ClaimKind::AtomExists { atom: 1 });
586 ledger.absorb_log(weak, -1.0).unwrap();
587
588 let budget = MoveBudget {
589 max_moves: 8,
590 alpha: 0.05,
591 };
592 let proposals = vec![
593 MoveProposal {
594 mv: StructureMove::Death { atom: 0 },
595 trigger: 9.0,
596 structure_hash: 21,
597 claim: ClaimKind::AtomExists { atom: 0 },
598 },
599 MoveProposal {
600 mv: StructureMove::Death { atom: 1 },
601 trigger: 8.0,
602 structure_hash: 22,
603 claim: ClaimKind::AtomExists { atom: 1 },
604 },
605 MoveProposal {
606 mv: StructureMove::Fusion { a: 1, b: 2 },
607 trigger: 0.9,
608 structure_hash: 23,
609 claim: ClaimKind::BindingEdge { a: 1, b: 2 },
610 },
611 ];
612 let out = run(vec!["a", "b", "c"], proposals, 4, &budget, &mut ledger);
613
614 assert!(matches!(
615 out.ledger.moves[0].verdict,
616 MoveVerdict::Vetoed { .. }
617 ));
618 match out.ledger.moves[1].verdict {
619 MoveVerdict::Demoted { log_e } => assert!((log_e - (-1.0)).abs() < 1e-12),
620 ref v => panic!("contested atom must demote, got {v:?}"),
621 }
622 assert_eq!(out.state[1], "dead");
623 assert_eq!(out.state[0], "a", "vetoed death must not touch the atom");
624 // Fusion references the demoted atom ⇒ stale, not gated.
625 assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Stale));
626 }
627
628 /// Budget exhaustion defers (records, never silently drops), and duplicate
629 /// structural hashes are deduplicated.
630 #[test]
631 fn budget_defers_and_hash_dedups() {
632 let mut ledger = StructureLedger::new();
633 let budget = MoveBudget {
634 max_moves: 1,
635 alpha: 0.05,
636 };
637 let proposals = vec![
638 birth(0, 1.0, 31),
639 birth(0, 0.9, 31), // same structure, lower trigger ⇒ dedup
640 birth(1, 0.5, 32), // budget exhausted by then ⇒ deferred
641 ];
642 let out = run(vec!["a"], proposals, 10, &budget, &mut ledger);
643 assert!(matches!(
644 out.ledger.moves[0].verdict,
645 MoveVerdict::Accepted { .. }
646 ));
647 assert!(matches!(
648 out.ledger.moves[1].verdict,
649 MoveVerdict::Deduplicated
650 ));
651 assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Deferred));
652 }
653
654 /// Identical inputs ⇒ byte-identical serialized ledger (the replicate-null
655 /// validity requirement). Proposals are supplied in scrambled orders.
656 #[test]
657 fn ledger_is_deterministic_across_runs() {
658 let props = || {
659 vec![
660 birth(0, 1.0, 41),
661 MoveProposal {
662 mv: StructureMove::Death { atom: 1 },
663 trigger: 3.0,
664 structure_hash: 42,
665 claim: ClaimKind::AtomExists { atom: 1 },
666 },
667 MoveProposal {
668 mv: StructureMove::Fusion { a: 0, b: 2 },
669 trigger: 0.7,
670 structure_hash: 43,
671 claim: ClaimKind::BindingEdge { a: 0, b: 2 },
672 },
673 ]
674 };
675 let budget = MoveBudget {
676 max_moves: 8,
677 alpha: 0.05,
678 };
679 let mut scrambled = props();
680 scrambled.reverse();
681
682 let mut ledger_a = StructureLedger::new();
683 let out_a = run(vec!["a", "b", "c"], props(), 6, &budget, &mut ledger_a);
684 let mut ledger_b = StructureLedger::new();
685 let out_b = run(vec!["a", "b", "c"], scrambled, 6, &budget, &mut ledger_b);
686
687 let ser_a = serde_json::to_string(&out_a.ledger).expect("serialize");
688 let ser_b = serde_json::to_string(&out_b.ledger).expect("serialize");
689 assert_eq!(ser_a, ser_b);
690 assert_eq!(out_a.state, out_b.state);
691 }
692
693 /// Non-finite triggers and degenerate α are rejected loudly.
694 #[test]
695 fn invalid_inputs_error() {
696 let mut ledger = StructureLedger::new();
697 let shards: Vec<f64> = vec![1.0];
698 let bad_alpha = search(
699 vec!["a"],
700 Vec::<MoveProposal>::new(),
701 &shards,
702 &MoveBudget {
703 max_moves: 1,
704 alpha: 1.0,
705 },
706 &mut ledger,
707 apply,
708 |_c: &Dict, _sh| 0.0,
709 |_s, _sh| 0.0,
710 |c, _sh| c,
711 );
712 assert!(bad_alpha.is_err());
713
714 let bad_trigger = search(
715 vec!["a"],
716 vec![birth(0, f64::NAN, 1)],
717 &shards,
718 &MoveBudget {
719 max_moves: 1,
720 alpha: 0.05,
721 },
722 &mut ledger,
723 apply,
724 |_c: &Dict, _sh| 0.0,
725 |_s, _sh| 0.0,
726 |c, _sh| c,
727 );
728 assert!(bad_trigger.is_err());
729 }
730}