feat(dqn-v2): A.1 wire StateResetRegistry into fold-boundary reset

Replaces the ad-hoc scattered fold-boundary reset calls with a single
registry-driven iteration. Adding new fold-reset state now requires
adding a registry entry AND a dispatch arm in reset_named_state in the
same commit (Invariant 2 Wire-It-Up).

Step 3.4 correction: plan_state entry removed from the registry —
plan_state_buf exists only in GpuBacktestEvaluator (val path), not in
the training-path fused ctx. No training-side fold reset is applicable.

New behaviour: isv_learning_health, isv_sharpe_ema, isv_q_means are
now properly reset to baseline at each fold boundary (previously unset,
which allowed signals from fold N to bias fold N+1 initialisation).

Tests: 3 registry unit tests pass; cargo check -p ml clean (8 pre-existing
warnings only, no new).

Authority: spec §4.A.1. Plan 1 Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-24 10:51:52 +02:00
parent b688827d66
commit 46e99c5ccc
5 changed files with 97 additions and 14 deletions

View File

@@ -858,14 +858,11 @@ impl FusedTrainingCtx {
}
}
// Reset the adaptive C51 v_range EMAs and the pinned range buffer.
// Without this, the new fold inherits the prior fold's tight atom
// support; if the new fold's Q-distribution falls outside, TD errors
// saturate the atom bins and gradients blow up (observed: 50-epoch
// smoke train-92xbj Fold 1 Epoch 12 -> grad_norm=inf -> NaN loss).
// After this reset, the first update_eval_v_range call in Fold N+1
// reinitialises from the newly observed Q-statistics.
self.trainer.reset_eval_v_range_state();
// FoldReset state (eval_q_mean_ema, eval_q_std_ema, isv_v_range_slots,
// isv_learning_health, isv_sharpe_ema, isv_q_means) is now handled by
// StateResetRegistry-driven dispatch in DQNTrainer::reset_for_fold
// (trainer/mod.rs). reset_eval_v_range_state is called via the
// FusedTrainingCtx::reset_eval_v_range_state wrapper from there.
// Clear replay buffer — stale experiences from previous fold have wrong
// reward distributions (different data window, different policy).
// NOTE: the replay buffer clear is handled by DQNTrainer::reset_for_fold()
@@ -882,6 +879,17 @@ impl FusedTrainingCtx {
Ok(())
}
/// Reset the adaptive C51 v_range EMAs and the pinned range buffer.
///
/// Called from DQNTrainer::reset_named_state for the "eval_q_mean_ema",
/// "eval_q_std_ema", and "isv_v_range_slots" FoldReset entries.
/// These three states form a single logical unit — the EMA state that
/// drives the ISV v-range slots. Separating them in the registry allows
/// future tasks to migrate them independently; for now one call resets all.
pub(crate) fn reset_eval_v_range_state(&mut self) {
self.trainer.reset_eval_v_range_state();
}
/// Steps since last flat buffer sync.
pub(crate) fn steps_since_varmap_sync(&self) -> usize {
self.steps_since_varmap_sync

View File

@@ -47,11 +47,6 @@ impl StateResetRegistry {
category: ResetCategory::FoldReset,
description: "Per-window Kelly win/loss counters (ps[14..18])",
},
RegistryEntry {
name: "plan_state",
category: ResetCategory::FoldReset,
description: "Val plan_state_buf [N, 7] — plan entry snapshots",
},
RegistryEntry {
name: "eval_q_mean_ema",
category: ResetCategory::FoldReset,

View File

@@ -36,6 +36,7 @@ use crate::labeling::triple_barrier::TripleBarrierEngine;
use super::config::{DQNAgentType, DQNHyperparameters};
use super::statistics::{FeatureStatistics, QValueStats};
use super::StateResetRegistry;
mod action;
mod metrics;
@@ -1472,7 +1473,23 @@ impl DQNTrainer {
self.best_epoch = 0;
self.best_val_loss = f64::INFINITY;
// Reset fused training context state (keep graphs, reset step counters)
// Plan 1 Task 3: registry-driven fold-boundary reset. Replaces scattered
// ad-hoc reset calls. Adding a new FoldReset state requires a registry
// entry AND a dispatch arm in reset_named_state (Invariant 2 Wire-It-Up).
// SoftReset category is handled separately (Plan 2); the pre-existing
// ad-hoc handling for adaptive_gamma and grad_balance targets is
// preserved in the training loop and is not replaced here.
let registry = StateResetRegistry::new();
for entry in registry.fold_reset_entries() {
self.reset_named_state(entry.name, 0u32)
.map_err(|e| anyhow::anyhow!("fold-reset '{}': {}", entry.name, e))?;
}
// Reset fused training context state (keep graphs, reset step counters).
// This handles Adam state, IQN Adam, shrink-and-perturb, CUDA graph
// invalidation, and PopArt — none of which are FoldReset registry entries
// (Adam/network are TrainingPersist; Adam is reset explicitly here as a
// special-case stabilisation, not a lifecycle category).
if let Some(ref mut fused) = self.fused_ctx {
fused.reset_for_fold()?;
}

View File

@@ -3980,5 +3980,67 @@ impl DQNTrainer {
Ok(false)
}
/// Dispatch a named-state reset to its owning subsystem.
///
/// Invariant: every entry in the StateResetRegistry's FoldReset category
/// has a match arm here. Unknown names are a bug (the registry entry
/// should have been added in the same commit as the state it describes).
pub(crate) fn reset_named_state(&mut self, name: &str, fold_idx: u32) -> Result<(), crate::MLError> {
match name {
"kelly_stats" => {
// Kelly stats are held in portfolio state ps[14..18] — cleared
// per-window by the env_step kernel's fold_start detection.
// No-op here; the per-window reset logic handles it.
}
"eval_q_mean_ema" | "eval_q_std_ema" | "isv_v_range_slots" => {
// All three names map to the same reset — the EMA slots and ISV
// v-range slots are a single logical unit. The registry lists them
// separately so future tasks can migrate them independently;
// for now one call covers all three.
if let Some(ref mut fused) = self.fused_ctx {
fused.reset_eval_v_range_state();
}
}
"isv_learning_health" => {
if let Some(ref fused) = self.fused_ctx {
fused.trainer().write_isv_signal_at(
crate::cuda_pipeline::gpu_dqn_trainer::LEARNING_HEALTH_INDEX,
0.5,
);
}
}
"isv_sharpe_ema" => {
if let Some(ref fused) = self.fused_ctx {
fused.trainer().write_isv_signal_at(
crate::cuda_pipeline::gpu_dqn_trainer::SHARPE_EMA_INDEX,
0.0,
);
}
}
"isv_q_means" => {
if let Some(ref fused) = self.fused_ctx {
// Zero the 9 Q-mean EMA slots [13..22).
for idx in 13..22 {
fused.trainer().write_isv_signal_at(idx, 0.0);
}
}
}
_ => {
return Err(crate::MLError::ModelError(format!(
"StateResetRegistry fold-reset dispatch: unknown name '{}'. \
Every FoldReset entry must have a dispatch arm in reset_named_state.",
name
)));
}
}
tracing::debug!(
target: "state_reset_registry",
fold = fold_idx,
name = name,
"fold-reset applied"
);
Ok(())
}
}

View File

@@ -14,3 +14,4 @@
|---|---|---|---|
| (populated during A.5 audit) | | | |
| `crates/ml/src/trainers/dqn/state_reset_registry.rs` | consumers in Plan 1 Task 3+ (reset_fold_state call) | Wired (consumer added in same plan) | A.1 primary implementation |
| `training_loop.rs::reset_named_state` | consumer of StateResetRegistry::fold_reset_entries | Wired | A.1 Task 3 dispatch |