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    /// Glue two CHARTS of one manifold into one atom (#1890). Distinct from
98    /// [`StructureMove::Fusion`]: the co-activation fusion lane fires on
99    /// DEPENDENT (co-firing) codes, but atoms over-tiling a single manifold have
100    /// DISJOINT supports (each owns its own arc/patch) and therefore
101    /// anti-correlated codes — invisible to fusion. The glue lane proposes such a
102    /// pair on a GEOMETRIC pre-screen (decoder-frame principal angles × latent-
103    /// support adjacency) and its acceptance is an EQUIVALENCE e-value on the
104    /// seam (the two decoded charts coincide within an isometry tolerance,
105    /// against the churn null) — a pre-computed e-value carried on the proposal's
106    /// `trigger`, not the held-out fit-improvement gate the other moves use (a
107    /// clean glue leaves EV tied, so a likelihood-ratio gate could never accept
108    /// it). [`ChartGlueOutcome::Fuse`] folds an ordinary over-tile;
109    /// [`ChartGlueOutcome::RegisterAtlas`] preserves a seam whose local charts
110    /// cannot be replaced by one global chart (orientation reversal / pole).
111    Glue {
112        a: usize,
113        b: usize,
114        outcome: ChartGlueOutcome,
115    },
116}
117
118/// Structural outcome certified by a chart-gluing seam.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120pub enum ChartGlueOutcome {
121    /// A single orientable chart covers the union, so the redundant chart is
122    /// physically folded and removed.
123    Fuse,
124    /// Both local charts are required.  Keep them as the partition-of-unity
125    /// cover of one semantic atlas atom and persist their transition map.
126    RegisterAtlas,
127}
128
129impl StructureMove {
130    /// Atoms whose state this move modifies (births create, so touch none).
131    fn touches(&self) -> Vec<usize> {
132        match self {
133            StructureMove::Birth { .. } => Vec::new(),
134            StructureMove::Death { atom } | StructureMove::Fission { atom } => vec![*atom],
135            StructureMove::Fusion { a, b } | StructureMove::Glue { a, b, .. } => vec![*a, *b],
136        }
137    }
138
139    /// Canonical kind rank: deaths, fissions, fusions, glues, births. Glue sorts
140    /// after fusion (both merge a pair) and before birth (#1890).
141    fn kind_rank(&self) -> u8 {
142        match self {
143            StructureMove::Death { .. } => 0,
144            StructureMove::Fission { .. } => 1,
145            StructureMove::Fusion { .. } => 2,
146            StructureMove::Glue { .. } => 3,
147            StructureMove::Birth { .. } => 4,
148        }
149    }
150
151    /// Whether the canonical order sorts this kind's trigger ascending
152    /// (fission audits report significance levels — smaller is more urgent)
153    /// or descending (ARD precision, code dependence, proposal mass).
154    fn trigger_ascending(&self) -> bool {
155        matches!(self, StructureMove::Fission { .. })
156    }
157}
158
159/// One proposal: the move, its trigger statistic (the canonical-order key,
160/// kind-specific — see [`StructureMove`] docs), the caller-computed structural
161/// hash of the POST-move specification (dedup key), and the structural claim
162/// the move asserts (registered in the [`StructureLedger`] so the dictionary
163/// certificate covers it).
164#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165pub struct MoveProposal {
166    pub mv: StructureMove,
167    /// Canonical-order key. Deaths: ARD amplitude precision (descending).
168    /// Fissions: within-atom audit significance (ascending). Fusions: code
169    /// dependence (descending). Births: explained proposal mass (descending).
170    /// Must be finite.
171    pub trigger: f64,
172    /// Structural hash of the specification the move produces (#869
173    /// `TermCollectionSpec` machinery). Two proposals with the same hash are
174    /// the same structure; only the canonically-first is gated.
175    pub structure_hash: u64,
176    /// The claim this move asserts. Births: `AtomExists`. Fusions:
177    /// `BindingEdge`. Fissions: a `Custom`/`GeometryKind` substructure claim.
178    /// Deaths: the `AtomExists` claim CONSULTED for the veto/demote decision.
179    pub claim: ClaimKind,
180}
181
182/// The search round's budget and error level.
183#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
184pub struct MoveBudget {
185    /// Maximum structure-changing moves (accepted + demoted) applied this
186    /// round; remaining proposals are recorded as `Deferred`, never silently
187    /// dropped.
188    pub max_moves: usize,
189    /// The level every gate certifies at; fixed for the round so verdicts
190    /// cannot be shopped.
191    pub alpha: f64,
192}
193
194/// The per-proposal outcome. Every proposal handed to [`search`] gets exactly
195/// one record — the no-silent-caps rule.
196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
197pub enum MoveVerdict {
198    /// Gate certified at α; the move was applied and the claim's evidence
199    /// banked in the ledger.
200    Accepted { log_e: f64 },
201    /// Gate did not certify; structure unchanged, claim stays contested in
202    /// the ledger with this evidence (the probe loop's input).
203    Contested { log_e: f64 },
204    /// Death applied to a never-certified atom (its contested evidence at the
205    /// time of demotion is recorded).
206    Demoted { log_e: f64 },
207    /// Death proposal on a CERTIFIED atom — refused; Ville crossings are
208    /// permanent.
209    Vetoed { log_e: f64 },
210    /// Same structural hash as a canonically-earlier proposal this round.
211    Deduplicated,
212    /// References an atom already modified this round; triggers are stale —
213    /// re-propose next round against the new structure.
214    Stale,
215    /// Move budget exhausted before this proposal was reached.
216    Deferred,
217}
218
219/// One ledger line: the proposal exactly as ranked, plus its verdict.
220#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221pub struct MoveRecord {
222    pub mv: StructureMove,
223    pub trigger: f64,
224    pub structure_hash: u64,
225    pub claim: ClaimKind,
226    pub verdict: MoveVerdict,
227}
228
229/// An assignment-collapse event from the joint fit (#976 Layer-1 guard): an
230/// atom's support fell below the active-mass floor and was either re-seeded
231/// (bounded budget) or recorded as terminally collapsed — an observable event,
232/// never a silent death and never a fit error. Terminal collapses are the
233/// natural death-proposal feed for the next [`search`] round.
234#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
235pub struct CollapseEvent {
236    /// Outer iteration of the joint fit at which the breach was observed.
237    pub iteration: usize,
238    /// The collapsed atom.
239    pub atom: usize,
240    /// The atom's maximum active mass over rows at the breach (the collapse
241    /// statistic: a legitimately sparse atom has small MEAN mass but high
242    /// mass on its rows; only an atom with no material support anywhere has a
243    /// small MAX).
244    pub max_active_mass: f64,
245    /// The floor breached.
246    pub floor: f64,
247    /// What the guard did.
248    pub action: CollapseAction,
249}
250
251/// The guard's response to an active-mass breach.
252#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
253pub enum CollapseAction {
254    /// The atom's gate logits were re-seeded to a mode-appropriate neutral
255    /// (one second chance from a fresh basin; bounded budget per atom).
256    Reseeded,
257    /// Re-seed budget exhausted and the atom collapsed again: the collapse is
258    /// (locally) the objective's verdict. Recorded once; the structure-search
259    /// death move owns the decision from here.
260    Terminal,
261}
262
263/// The serialized honesty surface of one search round: every proposal in
264/// canonical order with its verdict, plus any collapse events the joint fit
265/// recorded. Identical inputs produce a byte-identical serialization.
266#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
267pub struct SearchLedger {
268    /// The α every verdict in this round was gated at.
269    pub alpha: f64,
270    /// One record per proposal, in canonical processing order.
271    pub moves: Vec<MoveRecord>,
272    /// Layer-1 guard events carried from the joint fit (see
273    /// [`CollapseEvent`]); attached by the caller.
274    pub collapse_events: Vec<CollapseEvent>,
275}
276
277/// Result of one search round: the (possibly restructured) state and the
278/// ledger.
279pub struct SearchOutcome<S> {
280    pub state: S,
281    pub ledger: SearchLedger,
282}
283
284/// Sort proposals into the canonical deterministic order: kind rank (deaths,
285/// fissions, fusions, births), then the kind's trigger direction, then
286/// structural hash. Pure — no RNG, no clock — so the search path, and with it
287/// the ledger, is a function of the inputs alone.
288pub fn canonical_order(proposals: &mut [MoveProposal]) {
289    proposals.sort_by(|x, y| {
290        let xr = x.mv.kind_rank();
291        let yr = y.mv.kind_rank();
292        xr.cmp(&yr)
293            .then_with(|| {
294                let (xt, yt) = if x.mv.trigger_ascending() {
295                    (x.trigger, y.trigger)
296                } else {
297                    (-x.trigger, -y.trigger)
298                };
299                xt.total_cmp(&yt)
300            })
301            .then_with(|| x.structure_hash.cmp(&y.structure_hash))
302    });
303}
304
305/// Run one evidence-guarded structure-search round.
306///
307/// * `state` — the current fitted structure (dictionary). Moves are applied
308///   sequentially; later gates run against the updated state.
309/// * `proposals` — trigger-ranked candidate moves (any order; the engine
310///   canonicalizes). Triggers must be finite.
311/// * `shards` — the evaluation stream for the gates. Each certifiable move
312///   streams over ALL shards (or until certified) under the universal-
313///   inference contract of [`run_atom_birth_gate`]: the candidate is evaluated
314///   on a shard strictly before being refit with it, so the plug-in is
315///   predictable and the e-process valid under optional stopping. Validity
316///   requires these shards be data the TRIGGERS were not tuned on (the same
317///   estimation/evaluation split discipline as every e-value here).
318/// * `ledger` — the dictionary's claim ledger, carried ACROSS rounds: claims
319///   keep their banked evidence (idempotent registration), so a structure
320///   contested this round resumes from its evidence next round, and the death
321///   veto sees certifications from any earlier round.
322/// * `apply_move` — build the candidate state from the PARENT state (warm
323///   inheritance by construction). For deaths this is the demotion itself.
324/// * `eval_log_lik(candidate, shard)` — evaluation log-likelihood of a shard
325///   under the candidate as currently fit (prior shards only — the engine
326///   guarantees the call order).
327/// * `null_sup_log_lik(state, shard)` — the HONEST sup: the current structure
328///   refit on the shard. Under-maximizing this side inflates every e-value
329///   and voids validity; it is the one closure that must genuinely optimize.
330/// * `refit(candidate, shard)` — fold the shard into the candidate. Likelihood
331///   evaluation and refitting are fallible: undefined scores or non-convergence
332///   abort the round rather than becoming neutral evidence.
333pub fn search<S, Sh>(
334    mut state: S,
335    mut proposals: Vec<MoveProposal>,
336    shards: &[Sh],
337    budget: &MoveBudget,
338    ledger: &mut StructureLedger,
339    mut apply_move: impl FnMut(&S, &StructureMove) -> Result<S, String>,
340    mut eval_log_lik: impl FnMut(&S, &Sh) -> Result<f64, String>,
341    mut null_sup_log_lik: impl FnMut(&S, &Sh) -> Result<f64, String>,
342    mut refit: impl FnMut(S, &Sh) -> Result<S, String>,
343) -> Result<SearchOutcome<S>, String> {
344    if !(budget.alpha > 0.0 && budget.alpha < 1.0) {
345        return Err(format!(
346            "structure_search: alpha must be in (0,1), got {}",
347            budget.alpha
348        ));
349    }
350    if let Some(bad) = proposals.iter().find(|p| !p.trigger.is_finite()) {
351        return Err(format!(
352            "structure_search: non-finite trigger {} on {:?}",
353            bad.trigger, bad.mv
354        ));
355    }
356    canonical_order(&mut proposals);
357
358    let mut seen_hashes: HashSet<u64> = HashSet::new();
359    let mut touched: Vec<usize> = Vec::new();
360    let mut moves_applied = 0usize;
361    let mut records: Vec<MoveRecord> = Vec::with_capacity(proposals.len());
362
363    for prop in proposals {
364        // Dedup is a property of the proposal stream (a duplicate structural
365        // hash describes a proposal that the engine has already considered),
366        // so it is decided BEFORE the budget gate: a duplicate of an
367        // already-applied move stays a duplicate even when the budget is
368        // exhausted. Reversing this order mislabels duplicates as deferred,
369        // which breaks the dedup-vs-defer accounting downstream (a deferred
370        // record is replayed by the next round; a deduplicated one is not).
371        let verdict = if !seen_hashes.insert(prop.structure_hash) {
372            MoveVerdict::Deduplicated
373        } else if moves_applied >= budget.max_moves {
374            MoveVerdict::Deferred
375        } else if prop.mv.touches().iter().any(|a| touched.contains(a)) {
376            MoveVerdict::Stale
377        } else {
378            match &prop.mv {
379                StructureMove::Death { atom } => {
380                    let idx = ledger.register(prop.claim.clone());
381                    let evidence = &ledger.claims()[idx].evidence;
382                    let log_e = evidence.log_evidence();
383                    if evidence.rejects_at(budget.alpha) {
384                        MoveVerdict::Vetoed { log_e }
385                    } else {
386                        state = apply_move(&state, &prop.mv)?;
387                        touched.push(*atom);
388                        moves_applied += 1;
389                        MoveVerdict::Demoted { log_e }
390                    }
391                }
392                StructureMove::Glue { a, b, .. } => {
393                    // Equivalence acceptance (#1890): the seam e-value was
394                    // computed at harvest against the churn null (the two charts
395                    // coincide within an isometry tolerance) and carried on
396                    // `trigger`. Bank it and glue when the accumulated evidence
397                    // certifies at α — never the held-out fit-improvement gate
398                    // the merge/split/birth moves use, which a clean glue's tied
399                    // EV could never clear. Composes with the e-BH ledger like
400                    // every other claim.
401                    let idx = ledger.register(prop.claim.clone());
402                    ledger.absorb_log(idx, prop.trigger)?;
403                    let evidence = &ledger.claims()[idx].evidence;
404                    let log_e = evidence.log_evidence();
405                    if evidence.rejects_at(budget.alpha) {
406                        state = apply_move(&state, &prop.mv)?;
407                        touched.push(*a);
408                        touched.push(*b);
409                        moves_applied += 1;
410                        MoveVerdict::Accepted { log_e }
411                    } else {
412                        MoveVerdict::Contested { log_e }
413                    }
414                }
415                mv @ (StructureMove::Birth { .. }
416                | StructureMove::Fission { .. }
417                | StructureMove::Fusion { .. }) => {
418                    let candidate = apply_move(&state, mv)?;
419                    // `shards.iter()` makes the gate's shard item `&Sh`, so the
420                    // closures receive `&&Sh`; deref once back to the caller's
421                    // `&Sh` surface.
422                    let (gate, folded) = run_atom_birth_gate(
423                        budget.alpha,
424                        candidate,
425                        shards.iter(),
426                        |c, sh| eval_log_lik(c, *sh),
427                        |sh| null_sup_log_lik(&state, *sh),
428                        |c, sh| refit(c, *sh),
429                    )?;
430                    let idx = ledger.register(prop.claim.clone());
431                    match gate.verdict() {
432                        GateVerdict::Certified { log_e } => {
433                            ledger.absorb_log(idx, log_e)?;
434                            state = folded;
435                            touched.extend(mv.touches());
436                            moves_applied += 1;
437                            MoveVerdict::Accepted { log_e }
438                        }
439                        GateVerdict::Contested { log_e } => {
440                            ledger.absorb_log(idx, log_e)?;
441                            MoveVerdict::Contested { log_e }
442                        }
443                    }
444                }
445            }
446        };
447        records.push(MoveRecord {
448            mv: prop.mv,
449            trigger: prop.trigger,
450            structure_hash: prop.structure_hash,
451            claim: prop.claim,
452            verdict,
453        });
454    }
455
456    Ok(SearchOutcome {
457        state,
458        ledger: SearchLedger {
459            alpha: budget.alpha,
460            moves: records,
461            collapse_events: Vec::new(),
462        },
463    })
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// Test fixture: a "dictionary" is a sorted set of atom labels; the
471    /// per-shard log-likelihood advantage of a candidate over the honest null
472    /// sup is scripted per label, so the statistics are exact and the tests
473    /// exercise the ENGINE (ordering, gating, veto, budget, determinism) —
474    /// the e-process statistics themselves are pinned in structure_evidence.
475    type Dict = Vec<&'static str>;
476
477    /// Per-shard advantage of a state over the null sup: +0.8 nats/shard when
478    /// the planted "real" atom is present, −0.2 when the spurious "fake" fused
479    /// atom is present, 0 otherwise.
480    fn advantage(state: &Dict) -> f64 {
481        let mut adv = 0.0;
482        if state.contains(&"real") {
483            adv += 0.8;
484        }
485        if state.contains(&"fake") {
486            adv -= 0.2;
487        }
488        adv
489    }
490
491    fn apply(state: &Dict, mv: &StructureMove) -> Result<Dict, String> {
492        let mut next = state.clone();
493        match mv {
494            StructureMove::Birth { candidate } => {
495                next.push(if *candidate == 0 { "real" } else { "extra" });
496            }
497            StructureMove::Death { atom } => {
498                if *atom < next.len() {
499                    next[*atom] = "dead";
500                }
501            }
502            StructureMove::Fusion { .. } => next.push("fake"),
503            StructureMove::Glue { .. } => next.push("glued"),
504            StructureMove::Fission { .. } => next.push("split"),
505        }
506        Ok(next)
507    }
508
509    fn run(
510        state: Dict,
511        proposals: Vec<MoveProposal>,
512        n_shards: usize,
513        budget: &MoveBudget,
514        ledger: &mut StructureLedger,
515    ) -> SearchOutcome<Dict> {
516        let shards: Vec<f64> = vec![1.0; n_shards];
517        search(
518            state,
519            proposals,
520            &shards,
521            budget,
522            ledger,
523            apply,
524            |c, _sh| Ok(-100.0 + advantage(c)),
525            |_s, _sh| Ok(-100.0),
526            |c, _sh| Ok(c),
527        )
528        .expect("search runs")
529    }
530
531    fn birth(candidate: usize, trigger: f64, hash: u64) -> MoveProposal {
532        MoveProposal {
533            mv: StructureMove::Birth { candidate },
534            trigger,
535            structure_hash: hash,
536            claim: ClaimKind::AtomExists {
537                atom: 100 + candidate,
538            },
539        }
540    }
541
542    /// Canonical order: deaths → fissions → fusions → births, direction-aware
543    /// triggers, hash tiebreak.
544    #[test]
545    fn canonical_order_ranks_kinds_and_triggers() {
546        let mut props = vec![
547            birth(0, 0.5, 7),
548            MoveProposal {
549                mv: StructureMove::Fusion { a: 1, b: 2 },
550                trigger: 0.9,
551                structure_hash: 3,
552                claim: ClaimKind::BindingEdge { a: 1, b: 2 },
553            },
554            MoveProposal {
555                mv: StructureMove::Death { atom: 4 },
556                trigger: 1e6,
557                structure_hash: 1,
558                claim: ClaimKind::AtomExists { atom: 4 },
559            },
560            MoveProposal {
561                mv: StructureMove::Fission { atom: 3 },
562                trigger: 0.01,
563                structure_hash: 2,
564                claim: ClaimKind::Custom {
565                    label: "fission:3".to_string(),
566                },
567            },
568            MoveProposal {
569                mv: StructureMove::Fission { atom: 5 },
570                trigger: 0.001,
571                structure_hash: 9,
572                claim: ClaimKind::Custom {
573                    label: "fission:5".to_string(),
574                },
575            },
576        ];
577        canonical_order(&mut props);
578        assert!(matches!(props[0].mv, StructureMove::Death { atom: 4 }));
579        // Fissions ascending by significance: 0.001 before 0.01.
580        assert!(matches!(props[1].mv, StructureMove::Fission { atom: 5 }));
581        assert!(matches!(props[2].mv, StructureMove::Fission { atom: 3 }));
582        assert!(matches!(props[3].mv, StructureMove::Fusion { .. }));
583        assert!(matches!(props[4].mv, StructureMove::Birth { .. }));
584    }
585
586    /// A planted birth certifies (0.8 nats/shard × 10 shards crosses ln 20),
587    /// updates the state, and banks certified evidence in the claim ledger; a
588    /// spurious fusion stays contested, leaves the state unchanged, and its
589    /// claim keeps (negative) evidence for the probe loop.
590    #[test]
591    fn birth_certifies_and_null_fusion_stays_contested() {
592        let mut ledger = StructureLedger::new();
593        let budget = MoveBudget {
594            max_moves: 8,
595            alpha: 0.05,
596        };
597        let proposals = vec![
598            birth(0, 1.0, 11),
599            MoveProposal {
600                mv: StructureMove::Fusion { a: 0, b: 1 },
601                trigger: 0.8,
602                structure_hash: 12,
603                claim: ClaimKind::BindingEdge { a: 0, b: 1 },
604            },
605        ];
606        let out = run(vec!["a", "b"], proposals, 10, &budget, &mut ledger);
607
608        // Fusion is gated first (canonical order) and must NOT certify.
609        let fusion_rec = &out.ledger.moves[0];
610        assert!(matches!(fusion_rec.mv, StructureMove::Fusion { .. }));
611        match fusion_rec.verdict {
612            MoveVerdict::Contested { log_e } => assert!(log_e < 0.0),
613            ref v => panic!("spurious fusion must stay contested, got {v:?}"),
614        }
615        // Birth certifies and the atom is in the final state.
616        let birth_rec = &out.ledger.moves[1];
617        match birth_rec.verdict {
618            MoveVerdict::Accepted { log_e } => assert!(log_e >= -(0.05f64.ln())),
619            ref v => panic!("planted birth must certify, got {v:?}"),
620        }
621        assert!(out.state.contains(&"real"));
622        assert!(!out.state.contains(&"fake"));
623
624        // Ledger: birth claim certified, fusion claim contested with evidence.
625        let cert = ledger.certify(0.05).unwrap();
626        let confirmed: Vec<_> = cert.confirmed().map(|e| e.kind.clone()).collect();
627        assert!(confirmed.contains(&ClaimKind::AtomExists { atom: 100 }));
628        assert!(
629            cert.contested()
630                .any(|e| e.kind == ClaimKind::BindingEdge { a: 0, b: 1 } && e.log_e < 0.0)
631        );
632    }
633
634    /// Death is vetoed for a certified atom (Ville permanence) and demotes a
635    /// never-certified one; a later proposal touching the demoted atom is
636    /// stale.
637    #[test]
638    fn death_vetoes_certified_demotes_contested_and_staleness_propagates() {
639        let mut ledger = StructureLedger::new();
640        let certified = ledger.register(ClaimKind::AtomExists { atom: 0 });
641        ledger.absorb_log(certified, 5.0).unwrap(); // > ln 20 ⇒ certified at 0.05
642        let weak = ledger.register(ClaimKind::AtomExists { atom: 1 });
643        ledger.absorb_log(weak, -1.0).unwrap();
644
645        let budget = MoveBudget {
646            max_moves: 8,
647            alpha: 0.05,
648        };
649        let proposals = vec![
650            MoveProposal {
651                mv: StructureMove::Death { atom: 0 },
652                trigger: 9.0,
653                structure_hash: 21,
654                claim: ClaimKind::AtomExists { atom: 0 },
655            },
656            MoveProposal {
657                mv: StructureMove::Death { atom: 1 },
658                trigger: 8.0,
659                structure_hash: 22,
660                claim: ClaimKind::AtomExists { atom: 1 },
661            },
662            MoveProposal {
663                mv: StructureMove::Fusion { a: 1, b: 2 },
664                trigger: 0.9,
665                structure_hash: 23,
666                claim: ClaimKind::BindingEdge { a: 1, b: 2 },
667            },
668        ];
669        let out = run(vec!["a", "b", "c"], proposals, 4, &budget, &mut ledger);
670
671        assert!(matches!(
672            out.ledger.moves[0].verdict,
673            MoveVerdict::Vetoed { .. }
674        ));
675        match out.ledger.moves[1].verdict {
676            MoveVerdict::Demoted { log_e } => assert!((log_e - (-1.0)).abs() < 1e-12),
677            ref v => panic!("contested atom must demote, got {v:?}"),
678        }
679        assert_eq!(out.state[1], "dead");
680        assert_eq!(out.state[0], "a", "vetoed death must not touch the atom");
681        // Fusion references the demoted atom ⇒ stale, not gated.
682        assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Stale));
683    }
684
685    /// Budget exhaustion defers (records, never silently drops), and duplicate
686    /// structural hashes are deduplicated.
687    #[test]
688    fn budget_defers_and_hash_dedups() {
689        let mut ledger = StructureLedger::new();
690        let budget = MoveBudget {
691            max_moves: 1,
692            alpha: 0.05,
693        };
694        let proposals = vec![
695            birth(0, 1.0, 31),
696            birth(0, 0.9, 31), // same structure, lower trigger ⇒ dedup
697            birth(1, 0.5, 32), // budget exhausted by then ⇒ deferred
698        ];
699        let out = run(vec!["a"], proposals, 10, &budget, &mut ledger);
700        assert!(matches!(
701            out.ledger.moves[0].verdict,
702            MoveVerdict::Accepted { .. }
703        ));
704        assert!(matches!(
705            out.ledger.moves[1].verdict,
706            MoveVerdict::Deduplicated
707        ));
708        assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Deferred));
709    }
710
711    /// Identical inputs ⇒ byte-identical serialized ledger (the replicate-null
712    /// validity requirement). Proposals are supplied in scrambled orders.
713    #[test]
714    fn ledger_is_deterministic_across_runs() {
715        let props = || {
716            vec![
717                birth(0, 1.0, 41),
718                MoveProposal {
719                    mv: StructureMove::Death { atom: 1 },
720                    trigger: 3.0,
721                    structure_hash: 42,
722                    claim: ClaimKind::AtomExists { atom: 1 },
723                },
724                MoveProposal {
725                    mv: StructureMove::Fusion { a: 0, b: 2 },
726                    trigger: 0.7,
727                    structure_hash: 43,
728                    claim: ClaimKind::BindingEdge { a: 0, b: 2 },
729                },
730            ]
731        };
732        let budget = MoveBudget {
733            max_moves: 8,
734            alpha: 0.05,
735        };
736        let mut scrambled = props();
737        scrambled.reverse();
738
739        let mut ledger_a = StructureLedger::new();
740        let out_a = run(vec!["a", "b", "c"], props(), 6, &budget, &mut ledger_a);
741        let mut ledger_b = StructureLedger::new();
742        let out_b = run(vec!["a", "b", "c"], scrambled, 6, &budget, &mut ledger_b);
743
744        let ser_a = serde_json::to_string(&out_a.ledger).expect("serialize");
745        let ser_b = serde_json::to_string(&out_b.ledger).expect("serialize");
746        assert_eq!(ser_a, ser_b);
747        assert_eq!(out_a.state, out_b.state);
748    }
749
750    /// Non-finite triggers and degenerate α are rejected loudly.
751    #[test]
752    fn invalid_inputs_error() {
753        let mut ledger = StructureLedger::new();
754        let shards: Vec<f64> = vec![1.0];
755        let bad_alpha = search(
756            vec!["a"],
757            Vec::<MoveProposal>::new(),
758            &shards,
759            &MoveBudget {
760                max_moves: 1,
761                alpha: 1.0,
762            },
763            &mut ledger,
764            apply,
765            |_c: &Dict, _sh| Ok(0.0),
766            |_s, _sh| Ok(0.0),
767            |c, _sh| Ok(c),
768        );
769        assert!(bad_alpha.is_err());
770
771        let bad_trigger = search(
772            vec!["a"],
773            vec![birth(0, f64::NAN, 1)],
774            &shards,
775            &MoveBudget {
776                max_moves: 1,
777                alpha: 0.05,
778            },
779            &mut ledger,
780            apply,
781            |_c: &Dict, _sh| Ok(0.0),
782            |_s, _sh| Ok(0.0),
783            |c, _sh| Ok(c),
784        );
785        assert!(bad_trigger.is_err());
786    }
787
788    #[test]
789    fn likelihood_and_refit_failures_abort_the_search() {
790        let shards = vec![1.0_f64];
791        let budget = MoveBudget {
792            max_moves: 1,
793            alpha: 0.05,
794        };
795
796        for failing_stage in 0..3 {
797            let mut ledger = StructureLedger::new();
798            let result = search(
799                vec!["a"],
800                vec![birth(0, 1.0, 99)],
801                &shards,
802                &budget,
803                &mut ledger,
804                apply,
805                |_candidate, _shard| {
806                    if failing_stage == 0 {
807                        Err("candidate likelihood failed".to_string())
808                    } else {
809                        Ok(-1.0)
810                    }
811                },
812                |_null, _shard| {
813                    if failing_stage == 1 {
814                        Err("null fit failed".to_string())
815                    } else {
816                        Ok(-2.0)
817                    }
818                },
819                |state, _shard| {
820                    if failing_stage == 2 {
821                        Err("alternative refit failed".to_string())
822                    } else {
823                        Ok(state)
824                    }
825                },
826            );
827            assert!(
828                result.is_err(),
829                "failure stage {failing_stage} must abort rather than mint evidence"
830            );
831        }
832    }
833}