fix(dqn): SP3 Mech 8 — slow_ema fold-boundary reset (real root cause)

User-identified root cause: grad_norm_slow_ema (α=0.001, half-life
~693 steps) persists across fold boundaries while every other
distribution-tracking signal (Adam m/v, target nets, atom positions)
gets reset. Mech 6's upper_bound formula (100 × slow_ema × isv) was
anchored to the WRONG scale during F1 ramp-up, driving the
non-monotonic multiplier-tuning dance across smokes:
  - smoke-test-fxvkk (mult=100×): F0=44, F1 NaN @ 3720
  - smoke-test-ftdjz (mult=100×, +Mech7): F0=38, F1 collapse @ 2040
  - smoke-test-d25vq (mult=5×): F0=21, F1 collapse @ 2820
The multiplier was searching the wrong dimension — the anchor itself
was stale.

Three coordinated changes (per feedback_no_partial_refactor):

1. RESTORE Mech 6 multiplier 5× → 100×. The original 100× was correct
   for steady-state; the F1 saturation was driven by anchor staleness,
   not multiplier looseness. Tightening it harmed F0 (over-clip during
   ramp-up when slow_ema lagged grad_norm_ema).

2. ADD reset_grad_norm_slow_ema method on GpuDqnTrainer. Zeroes the
   mapped-pinned scalar. First step of new fold builds up slow_ema
   fresh, with MIN_CLIP=1.0 floor active during the brief transient.

3. WIRE Mech 8 call in fused_training.rs::reset_for_fold, alongside
   Mech 4's existing Adam resets. Mech 6's anchor now aligns with
   the new fold's grad scale from step 1 — same philosophy as Mech 3
   (target net hard-sync) and Mech 4 (Adam EMA reset).

Net SP3 design: Mech 6 stays at 100× multiplier (broad, principled
headroom), Mech 8 keeps the anchor honest. The pair is more robust
than either change alone:
  - Without Mech 8: anchor is stale, multiplier tuning has no winning
    setting (5× hurts F0 ramp, 100× lets F1 saturate at slot 36).
  - With Mech 8: anchor is fresh per fold; 100× multiplier provides
    legitimate per-step headroom over CURRENT fold's grad norm.

Mech 7 stays reverted (per-element clip was misdiagnosis — over-clipped
legitimate gradient outliers without addressing the saturation root
cause).

F0 risk: low — F0 starts with slow_ema=0 anyway (cold start), so Mech 8
is a no-op on F0. Only changes F1+F2 fold-boundary behavior.

F1+F2 expectation: Mech 6's upper_bound now scales with the CURRENT
fold's grad norm, providing legitimate ~10× headroom per step without
allowing Adam EMA saturation. Slot 36-42 should stay quiet.
This commit is contained in:
jgrusewski
2026-04-30 12:10:46 +02:00
parent f67ede94fb
commit b8a7ac6f70
3 changed files with 73 additions and 37 deletions

View File

@@ -3855,6 +3855,25 @@ impl GpuDqnTrainer {
Ok(())
}
/// SP3 Mech 8: hard-reset grad_norm_slow_ema at fold boundary. Without
/// this reset, the α=0.001 EMA persists across folds with the prior
/// fold's value (half-life ~693 steps). Mech 6's upper_bound formula
/// (`100 × slow_ema × isv`) is then anchored to the wrong scale during
/// fold-N+1 ramp-up — either over-clipping (if slow_ema is too small
/// relative to current grads) or under-clipping (after slow_ema catches
/// up to the larger F1 grad scale, allowing Adam EMA saturation).
///
/// Reset philosophy matches Mech 4 (Adam state reset) and Mech 3 (hard
/// target sync): fold boundaries are distribution-shift events; ALL
/// running EMAs of training dynamics should reinitialize, not just
/// some of them. Without Mech 8, Mech 6's anchor was the lone outlier
/// — every other distribution-tracking signal already resets per
/// existing infrastructure (`reset_for_fold` in fused_training.rs).
pub fn reset_grad_norm_slow_ema(&mut self) -> Result<(), MLError> {
unsafe { *self.grad_norm_slow_ema_pinned = 0.0_f32; }
Ok(())
}
/// Reset Adam momentum (m/v) for branch weights only (indices 8-49).
/// Trunk momentum (indices 0-7) preserved — carries valuable convergence history.
/// Lighter than full rewind — tries to break Adam fixed point without changing weights.
@@ -20317,57 +20336,62 @@ impl GpuDqnTrainer {
}
let new_clip = (self.grad_norm_ema * CLIP_MULTIPLIER).max(MIN_CLIP);
// SP3 Mech 6 v2 (2026-04-29): anchored upper bound on `new_clip`.
// The winsorizer above caps a SINGLE sample at K × prev_clip but
// does NOT prevent CONSECUTIVE elevated samples from compounding
// the EMA upward without bound. Over hundreds of steps the clip
// threshold ratchets to thousands while actual grad_norm tracks
// it from below → clipping becomes a no-op against in-distribution
// drift → Adam m/v EMAs poisoned → saturate. This is the F1 NaN
// root cause from smoke-test-5rqzs (commit b9edccfc1) at step 3060
// (Mech 5 diag slots 3638, 4042 firing).
// SP3 Mech 6 (anchored) + Mech 8 (anchor reset, 2026-04-29):
// anchored upper bound on `new_clip`. The winsorizer above caps a
// SINGLE sample at K × prev_clip but does NOT prevent CONSECUTIVE
// elevated samples from compounding the EMA upward without bound.
// Over hundreds of steps the clip threshold ratchets to thousands
// while actual grad_norm tracks it from below → clipping becomes
// a no-op against in-distribution drift → Adam m/v EMAs poisoned
// → saturate. This is the F1 NaN root cause from smoke-test-5rqzs
// (commit b9edccfc1) at step 3060 (Mech 5 diag slots 3638, 4042
// firing).
//
// Bound: 5 × grad_norm_slow_ema × ISV[Q_ABS_REF=16].max(1.0)
// Bound: 100 × grad_norm_slow_ema × ISV[Q_ABS_REF=16].max(1.0)
// - `grad_norm_slow_ema` (existing α=0.001 slow EMA, updated below
// in this same function) anchors against the legitimate
// steady-state grad norm; here we read the PREVIOUS step's slow
// EMA from pinned memory (it gets overwritten further down).
// - 5× multiplier (revised from 100× — see "v2" rationale below):
// matches standard DL practice (510× steady-state grad norm).
// At 5×, per-element gradient max ≤ adaptive_clip = 5 × slow_ema
// × isv ≈ 10 (typical slow_ema ≈ 2, isv-floor = 1), well below
// slot 36 firing threshold of 100 × isv = 100. Adam m_X EMA
// steady-state with worst-case constant g = 10 reaches m_X = 10
// — slots 3642 should not fire.
// Mech 8 (`reset_grad_norm_slow_ema`) hard-resets this scalar at
// every fold boundary so the anchor tracks the CURRENT fold's
// grad-norm regime from step 1 instead of lagging the prior
// fold's value via the α=0.001 EMA half-life (~693 steps). The
// pair Mech 6 + Mech 8 is what makes the 100× multiplier safe;
// Mech 6 alone with a stale anchor was the F1-saturation
// pathology from smoke-test-fxvkk / -ftdjz.
// - 100× multiplier: matches the principled per-step headroom
// over CURRENT-fold steady-state grad norm. Earlier v2 of this
// bound used 5× to compensate for the stale anchor — but that
// over-clipped F0 ramp-up gradients (where slow_ema lags
// grad_norm_ema during cold-start) and still let F1 saturate
// once the EMA caught up. With Mech 8 keeping the anchor fresh,
// 100× provides legitimate 10× headroom over the slot-36
// diagnostic threshold of 100 × isv without enabling the Adam-EMA
// saturation pathology.
// - ISV[Q_ABS_REF=16] multiplier: scales the cap with the
// Q-magnitude regime per `feedback_isv_for_adaptive_bounds.md`.
// Same ISV slot used by SP3 Mechs 1+2+5 — no new slot.
// - `.max(1.0_f32)` ε on the multiplier per the SP1 ε-floor pearl:
// cold-start ISV[16] ≈ 0 must not collapse the bound.
// - `.max(MIN_CLIP)` ε-floor on the bound itself: cold-start
// `grad_norm_slow_ema` ≈ 0 must not pin upper_bound at 0 (which
// would force `new_clip` down to MIN_CLIP every step until the
// slow EMA warms up).
// `grad_norm_slow_ema` ≈ 0 (true on F0 step 1 AND on every
// post-Mech-8 fold-boundary first step) must not pin upper_bound
// at 0; MIN_CLIP=1.0 floor stays active for the brief transient
// while the slow EMA warms back up over the new fold.
//
// v2 rationale (multiplier 100 → 5):
// - Smoke smoke-test-fxvkk (Mech 6 v1, 100×) F1-NaN'd at step 3720
// with slots 36-42 still firing. Per-element gradient max under
// v1 reached ≤ 100 × slow_ema × isv ≈ 200, and Adam m_X EMA
// steady-state matched, exceeding slot 36 threshold of 100. The
// 100× bound was wider than the diagnostic threshold — Mech 6
// was active but the cap was the wrong width.
// - Why 5× and not tighter (e.g., 2×): per-step gradient norms can
// legitimately spike to ~5× slow_ema in normal training (e.g.,
// gradient resumption after warmup, occasional curvature
// transients); tighter bounds would over-clip and harm fit.
// - Why not wider (e.g., 10× or 20×): >10× restores the slot 36
// threshold-overshoot pathology with diminishing margin. 5×
// leaves robust 10× headroom against the 100× threshold.
// - F0 risk: low — F0 typical adaptive_clip stays under 5×
// slow_ema in steady training; the cap should be a no-op for F0.
// F1+ history that motivated Mech 8 (anchor reset):
// - smoke-test-fxvkk (Mech 6 only, 100×): F0=44, F1 NaN @ 3720
// - smoke-test-ftdjz (Mech 6 + Mech 7, 100×): F0=38, F1 collapse
// @ 2040
// - smoke-test-d25vq (Mech 6 v2, 5×): F0=21, F1 collapse @ 2820
// The multiplier was searching the wrong dimension — the anchor
// itself was stale across fold boundaries, while every other
// distribution-tracking signal (Adam m/v, target nets, atom
// positions) reset per existing infrastructure. Mech 8 closes the
// partial-refactor gap; Mech 6 stays at the principled 100×.
let abs_mult = self.read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0_f32);
let prev_slow_ema = unsafe { *self.grad_norm_slow_ema_pinned };
let upper_bound = (prev_slow_ema * 5.0_f32 * abs_mult).max(MIN_CLIP);
let upper_bound = (prev_slow_ema * 100.0_f32 * abs_mult).max(MIN_CLIP);
let new_clip = new_clip.min(upper_bound);
// Write directly to pinned memory — GPU sees it on next kernel read

View File

@@ -1064,6 +1064,16 @@ impl FusedTrainingCtx {
}
}
// SP3 Mech 8: reset grad_norm_slow_ema at fold boundary. Aligns
// Mech 6's anchor with the new fold's grad scale from step 1
// (instead of lagging the prior fold's value via α=0.001 EMA).
// Without this, Mech 6's upper_bound was either over-clipping
// (slow_ema too small) or under-clipping (slow_ema caught up but
// F1 grad scale already too large for slot 36 threshold).
// Resetting here lets Mech 6 re-anchor to the new fold's scale
// properly while preserving the 100× multiplier.
self.trainer.reset_grad_norm_slow_ema()?;
// 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

View File

@@ -2,6 +2,8 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
SP3 Mech 8 — `grad_norm_slow_ema` fold-boundary reset (2026-04-29): closed the partial-refactor gap where the α=0.001 grad-norm slow EMA (driving Mech 6's anchored upper bound on `adaptive_clip`) persisted across fold boundaries while every other distribution-tracking signal — Adam m/v (`reset_adam_state`), main DQN target params (`sync_target_from_online`), IQN target + Adam (`iqn.sync_target_from_online` + `iqn.reset_adam_state`), TLOB / IQL / IQL-low / 4-head attention Adam (`reset_adam_state` on each), C51 atom-position EMAs, replay buffer — already reset per existing `reset_for_fold` infrastructure. The α=0.001 half-life (~693 steps) meant `grad_norm_slow_ema` lagged the new fold's grad-norm regime by hundreds of steps; Mech 6's `100 × slow_ema × isv` upper bound was anchored to the WRONG fold's scale during F1 ramp-up, producing the non-monotonic multiplier-tuning dance observed across smokes (smoke-test-fxvkk 100× F1-NaN @ 3720 / smoke-test-ftdjz 100×+Mech7 F1-collapse @ 2040 / smoke-test-d25vq 5× F1-collapse @ 2820 — the multiplier was searching the wrong dimension). Three coordinated changes per `feedback_no_partial_refactor.md`: (1) Mech 6 multiplier restored 5× → 100× in `gpu_dqn_trainer.rs::update_adaptive_clip` (the original principled setting; v2's 5× was over-clipping legitimate F0-ramp gradients while still allowing F1-anchor saturation), (2) new `GpuDqnTrainer::reset_grad_norm_slow_ema` method zeroes the existing mapped-pinned scalar (no new buffer, no new ISV slot — reuses Plan C Phase 2 follow-up K's grad_norm_slow_ema_pinned at `gpu_dqn_trainer.rs:3337`), (3) wired into `fused_training.rs::reset_for_fold` immediately after the SP3 Mech 4 attention Adam reset block (line ~1066) — same fold-boundary contract consumer chain as Mech 3+4. F0 unaffected (cold-starts at slow_ema=0 already, so Mech 8 is a no-op on F0); F1+F2 first step transient sees `upper_bound = 100 × 0 × isv → MIN_CLIP=1.0` floor for ~10-50 steps as the EMA warms, then converges to legitimate 100× headroom over CURRENT fold's grad norm. Mech 7 stays reverted (per-element clip was misdiagnosis). Touched: `cuda_pipeline/gpu_dqn_trainer.rs` (Mech 6 comment + multiplier 5.0→100.0 line ~20370; new `reset_grad_norm_slow_ema` method line ~3858), `trainers/dqn/fused_training.rs` (call wired line ~1067). cargo check clean. No fingerprint change — touches an already-allocated mapped-pinned scalar, not param-tensor layout.
HEALTH_DIAG GPU port — Phase 1 (2026-04-28, follow-up to commit `333ea7184`'s Phase 0 inventory): added `HealthDiagSnapshot` `#[repr(C)]` POD struct and `MappedHealthDiagSnapshot` mapped-pinned wrapper in new `crates/ml/src/cuda_pipeline/health_diag.rs`. The struct holds 147 fields (every numeric value emitted across the 7 HEALTH_DIAG log sites) as `f32` or `u32` so CPU and GPU agree byte-for-byte; controller fire booleans (`fire_lr`/`fire_tau`/etc.) promoted from `u8``u32` per the design review to avoid `#[repr(C)]` padding mismatch when followed by `f32` fields. `MappedHealthDiagSnapshot::new()` wraps `cuMemHostAlloc(DEVICEMAP|PORTABLE)` of `sizeof(HealthDiagSnapshot)` + `cuMemHostGetDevicePointer_v2` — the same pattern as `MappedF32Buffer` but typed for the snapshot. Re-exported from `cuda_pipeline/mod.rs` for downstream consumers. **No producer kernel and no consumer wiring in this commit** — Phase 2 lands the kernel family (`health_diag_per_sample_reduce`, `health_diag_q_mag_reduce`, `health_diag_eval_histogram`, `health_diag_isv_mirror`, `health_diag_finalise`) and the `gpu_health_diag.rs` orchestrator; Phase 3 wires the launch into the captured graph alongside `launch_h_s2_rms_ema`; Phase 4 rewrites the CPU emit path and deletes the 12 CPU-bound feeder methods identified in the Phase 0 inventory. Three unit tests (`snapshot_size_is_stable`, `default_is_zeroed`, `alignment_is_4_bytes`) pin the layout so any future field add/reorder requires an explicit test update — guards the kernel-side offset table from silent drift. Touched: `cuda_pipeline/health_diag.rs` (+339 LOC, new), `cuda_pipeline/mod.rs` (+8 LOC re-exports). cargo check clean at 12 warnings (workspace baseline). No fingerprint change — separate mapped-pinned alloc, not part of param-tensor layout the fingerprint guards.
multi_fold_convergence smoke MBP-10 wiring (2026-04-28): the test now forwards `--mbp10-data-dir` and `--trades-data-dir` to its spawned `train_baseline_rl` subprocess, reading paths from `FOXHUNT_MBP10_DATA` / `FOXHUNT_TRADES_DATA` env vars (set by the L40S sanitizer-test + nsys-test Argo templates to `/data/test-data/{mbp10,trades}`) with fallback to `<data_dir>/../futures-baseline-{mbp10,trades}` for local repo runs. The test hard-fails fast if either path is missing, citing `feedback_mbp10_mandatory.md`. Without this, OFI features (state slots [18..26)) silently fell back to tick-rule proxy and the smoke validated a degraded model variant — a `feedback_no_partial_refactor.md` violation now closed.