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, _| Ok(-100.0 + advantage(c)),
525 |_, _| Ok(-100.0),
526 |c, _| 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 /// Death is vetoed for a certified atom (Ville permanence) and demotes a
587 /// never-certified one; a later proposal touching the demoted atom is
588 /// stale.
589 #[test]
590 fn death_vetoes_certified_demotes_contested_and_staleness_propagates() {
591 let mut ledger = StructureLedger::new();
592 let certified = ledger.register(ClaimKind::AtomExists { atom: 0 });
593 ledger.absorb_log(certified, 5.0).unwrap(); // > ln 20 ⇒ certified at 0.05
594 let weak = ledger.register(ClaimKind::AtomExists { atom: 1 });
595 ledger.absorb_log(weak, -1.0).unwrap();
596
597 let budget = MoveBudget {
598 max_moves: 8,
599 alpha: 0.05,
600 };
601 let proposals = vec![
602 MoveProposal {
603 mv: StructureMove::Death { atom: 0 },
604 trigger: 9.0,
605 structure_hash: 21,
606 claim: ClaimKind::AtomExists { atom: 0 },
607 },
608 MoveProposal {
609 mv: StructureMove::Death { atom: 1 },
610 trigger: 8.0,
611 structure_hash: 22,
612 claim: ClaimKind::AtomExists { atom: 1 },
613 },
614 MoveProposal {
615 mv: StructureMove::Fusion { a: 1, b: 2 },
616 trigger: 0.9,
617 structure_hash: 23,
618 claim: ClaimKind::BindingEdge { a: 1, b: 2 },
619 },
620 ];
621 let out = run(vec!["a", "b", "c"], proposals, 4, &budget, &mut ledger);
622
623 assert!(matches!(
624 out.ledger.moves[0].verdict,
625 MoveVerdict::Vetoed { .. }
626 ));
627 match out.ledger.moves[1].verdict {
628 MoveVerdict::Demoted { log_e } => assert!((log_e - (-1.0)).abs() < 1e-12),
629 ref v => panic!("contested atom must demote, got {v:?}"),
630 }
631 assert_eq!(out.state[1], "dead");
632 assert_eq!(out.state[0], "a", "vetoed death must not touch the atom");
633 // Fusion references the demoted atom ⇒ stale, not gated.
634 assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Stale));
635 }
636
637 /// Budget exhaustion defers (records, never silently drops), and duplicate
638 /// structural hashes are deduplicated.
639 #[test]
640 fn budget_defers_and_hash_dedups() {
641 let mut ledger = StructureLedger::new();
642 let budget = MoveBudget {
643 max_moves: 1,
644 alpha: 0.05,
645 };
646 let proposals = vec![
647 birth(0, 1.0, 31),
648 birth(0, 0.9, 31), // same structure, lower trigger ⇒ dedup
649 birth(1, 0.5, 32), // budget exhausted by then ⇒ deferred
650 ];
651 let out = run(vec!["a"], proposals, 10, &budget, &mut ledger);
652 assert!(matches!(
653 out.ledger.moves[0].verdict,
654 MoveVerdict::Accepted { .. }
655 ));
656 assert!(matches!(
657 out.ledger.moves[1].verdict,
658 MoveVerdict::Deduplicated
659 ));
660 assert!(matches!(out.ledger.moves[2].verdict, MoveVerdict::Deferred));
661 }
662
663 /// Identical inputs ⇒ byte-identical serialized ledger (the replicate-null
664 /// validity requirement). Proposals are supplied in scrambled orders.
665 #[test]
666 fn ledger_is_deterministic_across_runs() {
667 let props = || {
668 vec![
669 birth(0, 1.0, 41),
670 MoveProposal {
671 mv: StructureMove::Death { atom: 1 },
672 trigger: 3.0,
673 structure_hash: 42,
674 claim: ClaimKind::AtomExists { atom: 1 },
675 },
676 MoveProposal {
677 mv: StructureMove::Fusion { a: 0, b: 2 },
678 trigger: 0.7,
679 structure_hash: 43,
680 claim: ClaimKind::BindingEdge { a: 0, b: 2 },
681 },
682 ]
683 };
684 let budget = MoveBudget {
685 max_moves: 8,
686 alpha: 0.05,
687 };
688 let mut scrambled = props();
689 scrambled.reverse();
690
691 let mut ledger_a = StructureLedger::new();
692 let out_a = run(vec!["a", "b", "c"], props(), 6, &budget, &mut ledger_a);
693 let mut ledger_b = StructureLedger::new();
694 let out_b = run(vec!["a", "b", "c"], scrambled, 6, &budget, &mut ledger_b);
695
696 let ser_a = serde_json::to_string(&out_a.ledger).expect("serialize");
697 let ser_b = serde_json::to_string(&out_b.ledger).expect("serialize");
698 assert_eq!(ser_a, ser_b);
699 assert_eq!(out_a.state, out_b.state);
700 }
701
702 /// Non-finite triggers and degenerate α are rejected loudly.
703 #[test]
704 fn invalid_inputs_error() {
705 let mut ledger = StructureLedger::new();
706 let shards: Vec<f64> = vec![1.0];
707 let bad_alpha = search(
708 vec!["a"],
709 Vec::<MoveProposal>::new(),
710 &shards,
711 &MoveBudget {
712 max_moves: 1,
713 alpha: 1.0,
714 },
715 &mut ledger,
716 apply,
717 |_: &Dict, _| Ok(0.0),
718 |_, _| Ok(0.0),
719 |c, _| Ok(c),
720 );
721 assert!(bad_alpha.is_err());
722
723 let bad_trigger = search(
724 vec!["a"],
725 vec![birth(0, f64::NAN, 1)],
726 &shards,
727 &MoveBudget {
728 max_moves: 1,
729 alpha: 0.05,
730 },
731 &mut ledger,
732 apply,
733 |_: &Dict, _| Ok(0.0),
734 |_, _| Ok(0.0),
735 |c, _| Ok(c),
736 );
737 assert!(bad_trigger.is_err());
738 }
739
740 #[test]
741 fn likelihood_and_refit_failures_abort_the_search() {
742 let shards = vec![1.0_f64];
743 let budget = MoveBudget {
744 max_moves: 1,
745 alpha: 0.05,
746 };
747
748 for failing_stage in 0..3 {
749 let mut ledger = StructureLedger::new();
750 let result = search(
751 vec!["a"],
752 vec![birth(0, 1.0, 99)],
753 &shards,
754 &budget,
755 &mut ledger,
756 apply,
757 |_, _| {
758 if failing_stage == 0 {
759 Err("candidate likelihood failed".to_string())
760 } else {
761 Ok(-1.0)
762 }
763 },
764 |_, _| {
765 if failing_stage == 1 {
766 Err("null fit failed".to_string())
767 } else {
768 Ok(-2.0)
769 }
770 },
771 |state, _| {
772 if failing_stage == 2 {
773 Err("alternative refit failed".to_string())
774 } else {
775 Ok(state)
776 }
777 },
778 );
779 assert!(
780 result.is_err(),
781 "failure stage {failing_stage} must abort rather than mint evidence"
782 );
783 }
784 }
785}