Skip to main content

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}