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}