diff --git a/crates/ml/src/trainers/dqn/state_reset_registry.rs b/crates/ml/src/trainers/dqn/state_reset_registry.rs index 16154dbf9..22038e5d8 100644 --- a/crates/ml/src/trainers/dqn/state_reset_registry.rs +++ b/crates/ml/src/trainers/dqn/state_reset_registry.rs @@ -831,4 +831,83 @@ mod tests { let r = StateResetRegistry::new(); assert_eq!(r.category("nonexistent_state"), None); } + + /// Contract test: every FoldReset/SoftReset entry in StateResetRegistry::new() + /// must have a corresponding match arm in reset_named_state. Catches the + /// "add registry entry, forget dispatch arm → runtime panic at fold boundary" + /// bug pattern (occurred twice: SP5 Layer A #281, SP7 T7 commit 6e479c55c). + /// + /// Source-introspection design — no production-code change. The test extracts + /// the reset_named_state function body via brace-balance walk, then checks + /// that each FoldReset/SoftReset entry name appears as a string literal inside + /// that body. + #[test] + fn every_fold_and_soft_reset_entry_has_dispatch_arm() { + let dispatch_src = include_str!("trainer/training_loop.rs"); + + // Extract reset_named_state body: find fn signature, then match-block. + let fn_start = dispatch_src + .find("pub(crate) fn reset_named_state") + .expect("reset_named_state not found in training_loop.rs"); + let after_fn = &dispatch_src[fn_start..]; + + // Find `match name {` inside the function body. + let match_rel = after_fn + .find("match name {") + .expect("match name { not found in reset_named_state"); + let match_start_abs = fn_start + match_rel; + + // Walk braces to extract the match block (from `match name {` to its + // closing `}`), so we only search within the dispatch, not the rest of + // the file. + let match_src = &dispatch_src[match_start_abs..]; + let body = { + let mut depth: i32 = 0; + let mut end = match_src.len(); + for (i, ch) in match_src.char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = i + 1; + break; + } + } + _ => {} + } + } + &match_src[..end] + }; + + let registry = StateResetRegistry::new(); + // FoldReset and SoftReset (any decay_bars) both dispatch through + // reset_named_state. WindowReset/TrainingPersist/SchemaContract do not. + let entries_needing_dispatch: Vec<&RegistryEntry> = registry + .all() + .iter() + .filter(|e| matches!( + e.category, + ResetCategory::FoldReset | ResetCategory::SoftReset { .. } + )) + .collect(); + + let mut missing: Vec<&str> = Vec::new(); + for entry in &entries_needing_dispatch { + let needle = format!("\"{}\"", entry.name); + if !body.contains(needle.as_str()) { + missing.push(entry.name); + } + } + + assert!( + missing.is_empty(), + "Registry entries with no dispatch arm in reset_named_state ({} total, {} missing): {:?}\n\ + These will panic at fold-boundary with 'unknown name' MLError.\n\ + Add a match arm in trainer/training_loop.rs::reset_named_state.", + entries_needing_dispatch.len(), + missing.len(), + missing + ); + } } diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 5b25d5641..1d2c82d7a 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -3939,3 +3939,5 @@ extended. No producer kernel yet — that arrives in the next commit. MEMORY.md index entry. Captures the two-layer (signal-modulated target × outcome-driven α) pattern for future controller designs. - T9–T10: smoke + 50-epoch verification. + +Contract test — every RegistryEntry has dispatch arm (2026-05-03): added `every_fold_and_soft_reset_entry_has_dispatch_arm` unit test to the existing `#[cfg(test)] mod tests` block in `state_reset_registry.rs`. Source-introspection design: `include_str!` both `state_reset_registry.rs` and `trainer/training_loop.rs`; brace-balance walk extracts the `match name {` body; for each FoldReset/SoftReset entry (97 total: 95 FoldReset + 2 SoftReset) asserts the literal `""` appears in that body. No production-code change. No new dependency (manual brace walk instead of regex). Catches the recurring "add RegistryEntry, forget dispatch arm → fold-boundary panic" bug (occurred twice: SP5 Layer A #281, SP7 T7 commit 6e479c55c) at `cargo test -p ml --lib` rather than mid-training. Test currently passes (all 97 dispatch arms present post SP7 T7 fix). Touched: `trainers/dqn/state_reset_registry.rs` (+59 LOC in cfg-test block).