L40S smoke at SP5 Layer B HEAD (3ad5e011b) failed in 9.4s at first
fold-reset boundary with:
ERROR: fold-reset 'sp5_atom_v_center': StateResetRegistry reset
dispatch: unknown name 'sp5_atom_v_center'.
Every SP5 Layer A task added RegistryEntry blocks but none added the
matching dispatch arms in reset_named_state. Runtime validator catches
this on first fold boundary, crashing all 3 folds before training runs
(0/3 checkpoints saved). Local unit tests verified slot layouts but
never exercised the fold-reset dispatch path — gap masked through
Layer A close-out.
Adds 21 dispatch arms covering all SP5 Layer A FoldReset entries:
Pearl 1: sp5_atom_v_center / v_half / headroom / clip_rate
shared: sp5_branch_entropy / q_var_per_branch
wiener: sp5_wiener_state (no-op — sp4 arm covers full buffer)
Pearl 3: sp5_noisy_sigma / sigma_fraction
Pearl 2: sp5_budget_c51 / iqn / cql / ens / flatness
Pearl 4: sp5_adam_beta1 / beta2 / eps + sp5_grad_prev_buf
Pearl 5: sp5_iqn_tau
Pearl 8: sp5_trail_dist_per_dir
Pearl 1-ext: sp5_atom_num_atoms
Pearl 6 (slots 280..286) intentionally absent — those slots are
cross-fold-persistent (the whole point of A6 per
project_magnitude_eval_collapse_kelly_capped).
Each ISV-range arm zeros its slot range to fire Pearl A's first-
observation sentinel on the new fold's first producer launch.
sp5_grad_prev_buf calls a new reset_sp5_grad_prev_buf bulk-zero
method on GpuDqnTrainer (mirrors reset_sp4_clamp_engage_counters).
sp5_wiener_state is no-op since reset_sp4_wiener_state already
bulk-zeros the full wiener_state_buf [0..543) which includes the SP5
producer block at offsets [213..543).
cargo check + cargo test --lib (sp4+sp5+state_reset_registry: 13/13
pass) both clean. Smoke re-deployment pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
609 KiB
DQN v2 Wire-Up Audit
Status: Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
SP5 Layer A bug-fix — add reset_named_state dispatch arms for 21 SP5 entries (2026-05-02): L40S smoke at SP5 Layer B HEAD (commit 3ad5e011b) failed in 9.4s at the first fold-reset boundary with StateResetRegistry reset dispatch: unknown name 'sp5_atom_v_center'. Every Layer A task added RegistryEntry blocks to state_reset_registry.rs but none added the matching dispatch arms in reset_named_state — the runtime validator catches this on first fold boundary, crashing all 3 folds before training runs (0/3 checkpoints saved). Local unit tests verified slot layouts but never exercised the fold-reset dispatch path, so the gap was masked through Layer A close-out. Added 21 dispatch arms covering all SP5 Layer A FoldReset entries: sp5_atom_v_center/v_half/headroom/clip_rate (Pearl 1), sp5_branch_entropy/q_var_per_branch (shared signal), sp5_wiener_state (no-op — reset_sp4_wiener_state already bulk-zeros the full 543-float buffer including the SP5 block at offsets [213..543)), sp5_noisy_sigma/sigma_fraction (Pearl 3), sp5_budget_c51/iqn/cql/ens/flatness (Pearl 2), sp5_adam_beta1/beta2/eps (Pearl 4), sp5_grad_prev_buf (Pearl 4 aux — calls a new reset_sp5_grad_prev_buf bulk-zero method on GpuDqnTrainer, mirrors reset_sp4_clamp_engage_counters pattern), sp5_iqn_tau (Pearl 5), sp5_trail_dist_per_dir (Pearl 8), sp5_atom_num_atoms (Pearl 1-ext). Pearl 6 (slots 280..286) intentionally absent — those slots are cross-fold-persistent (the whole point of A6 per project_magnitude_eval_collapse_kelly_capped). Each ISV-range arm zeros its slot range to fire Pearl A's first-observation sentinel on the new fold's first producer launch. Touched: cuda_pipeline/gpu_dqn_trainer.rs (+1 method reset_sp5_grad_prev_buf), trainers/dqn/trainer/training_loop.rs (+21 dispatch arms before the _ => fallback). cargo check + cargo test --lib (sp4+sp5+state_reset_registry: 13/13 pass) both clean. Smoke re-deployment pending.
SP5 Layer A close-out fix-up — replace stale scratch-buf comment with full layout map (2026-05-01): code-quality review of A8 caught that gpu_dqn_trainer.rs:15154 still read // to SP5_SCRATCH_TOTAL (103) = 71 + 16 (q_branch_stats) + 16 (pearl_1_atom) — pre-existing from A1 that wasn't updated as the constant grew through A2-A8. The runtime allocation is correct (uses the constant, not the literal), so this was readability-only. Replaced the 2-line stale comment with the complete scratch-buffer layout map covering all SP5 Layer A producer outputs: SP4 [0..71), q_branch_stats [71..87), pearl_1_atom [87..103), pearl_3 σ [103..107) + SF [107..111), pearl_2 budget [111..131), pearl_4 cosine+l2 [131..147) + β/β/ε [147..171), pearl_5 skew/kurt+τ [171..199), pearl_6 (direct ISV — no scratch), pearl_8 trail [199..203), pearl_1-ext num_atoms [203..207). Total SP5_SCRATCH_TOTAL=207. Closes the last residual A1-vintage comment debt; gives Layer B implementers a self-contained map of the scratch contract. Comment-only change; no behavior change. cargo check clean.
SP5 Task A8 — Pearl 1-ext per-branch C51 num_atoms GPU producer — LAYER A COMPLETE (2026-05-01): final SP5 Layer A producer. One new CUDA kernel (pearl_1_ext_num_atoms_kernel.cu) lands as a Layer A additive producer feeding ISV slots [274..278) with per-branch C51 num_atoms. No atoms_update_kernel consumer migration in this commit — Layer B wires the consumer when the per-branch atom count is read at C51 backward time. pearl_1_ext_num_atoms_update: single-block 4-thread kernel (one thread per branch: Direction/Magnitude/Order/Urgency). Reads ISV[ATOM_V_HALF_BASE=178..182) (populated by Pearl 1 in Task A1); applies threshold cascade: v_half < 0.1 → 64 atoms (narrow Q range, high resolution), v_half < 1.0 → 32 atoms (moderate), v_half ≥ 1.0 → 16 atoms (wide Q range, modest resolution). THRESHOLD_NARROW=0.1, THRESHOLD_MODERATE=1.0, ATOMS_HIGH=64, ATOMS_MID=32, ATOMS_LOW=16 are Invariant 1 anchors (powers of 2; total cap 4×64=256 atoms within HW shared-memory budget). Discrete output smoothed by Pearls A+D — during transitions (e.g. v_half drifts across 1.0), the smoothed ISV value is intermediate between {16, 32}; Layer B's atoms_update consumer rounds to nearest valid count at consume time. launch_sp5_pearl_1_ext_num_atoms fires the producer kernel writing scratch[203..207), then 4 launch_apply_pearls calls (one per branch → ISV[ATOM_NUM_ATOMS_BASE=274..278)) with ALPHA_META=1e-3 (default rate). Wiener offset formula: SP4_PRODUCER_COUNT × 3 + (isv_slot − SP5_SLOT_BASE) × 3 where SP4_PRODUCER_COUNT × 3 = 213; for ISV slot 274 (first ATOM_NUM_ATOMS slot): wiener_off = 213 + (274-174)×3 = 513 — well within the 543-float wiener_state_buf. Buffer growth: producer_step_scratch_buf 203→207 slots (SP5_SCRATCH_TOTAL); 1 new scratch constant SCRATCH_PEARL_1_EXT_NUM_ATOMS=203. wiener_state_buf stays at 543 (sized at A1 for entire SP5 block). StateResetRegistry gains 1 new FoldReset entry: sp5_atom_num_atoms (ISV[274..278)) — Pearl A sentinel 0 at fold boundary fires first-observation replacement with new fold's v_half-derived count. Wire-up in training_loop.rs: launch_sp5_pearl_1_ext_num_atoms() called immediately after launch_sp5_pearl_8_trail block (both depend on Pearl 1 outputs; Pearl 8 runs first for ordering parity with A7). Two GPU-only #[ignore = "requires GPU"] unit tests: (16) pearl_1_ext_num_atoms_threshold_cascade — 4 synthetic v_half values spanning all branches and just-below boundary; verifies 64/32/16/64 cascade; (17) pearl_1_ext_num_atoms_exact_threshold_falls_to_next_branch — v_half=0.1 exactly → 32 (NOT 64), v_half=1.0 exactly → 16 (NOT 32), verifies strictly-less-than semantics. No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. LAYER A COMPLETE: 8 SP5 producers (Pearls 1, 1-ext, 2, 3, 4, 5, 6, 8) + 3 auxiliary kernels (q_branch_stats, grad_cosine_sim, q_skew_kurtosis) populating 110 ISV slots [174..286) with 4 cross-fold-persistent slots carved out (Kelly [280..286)). Touched: cuda_pipeline/pearl_1_ext_num_atoms_kernel.cu (new), build.rs (+1 cubin entry), cuda_pipeline/gpu_dqn_trainer.rs (SP5_SCRATCH_TOTAL 203→207 + 1 new scratch constant SCRATCH_PEARL_1_EXT_NUM_ATOMS=203 + 1 static cubin + 1 struct field + cubin loader + struct initializer + launch method), trainers/dqn/state_reset_registry.rs (+1 FoldReset entry), trainers/dqn/trainer/training_loop.rs (+5 LOC after Pearl 8 launch block), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + 1 cubin constant + 1 loader helper + module docstring). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs, feedback_no_partial_refactor (no consumer migration — Layer A additive only), feedback_isv_for_adaptive_bounds (thresholds/counts are Invariant 1 structural anchors, not tuned constants).
SP5 Task A7 fix-up — close two minor review findings (2026-05-01): combined spec-quality review caught two minor issues in the Pearl 8 commit. (1) The audit doc's description of test 14 incorrectly claimed atr_norm was chosen to give atr_abs=10.0 and Short/Long=20.0. The actual test uses atr_norm=0.5 → log_atr=1.0 → atr_abs=e^1≈2.7183 → Short/Long≈5.4366. Updated the audit doc test-14 description to match the actual values. (2) training_loop.rs:3640 used let _ = std::mem::ManuallyDrop::new(guard); to keep the cudarc StreamGuard alive past the inner block — the pattern leaks the guard's borrow bookkeeping for the lifetime of the function rather than dropping it after the kernel launch. Replaced with the idiomatic let (feat_dev_ptr, _guard) = features_buf.device_ptr(stream_ref); pattern at the same scope as the launch (matches existing conventions for SP4 producer wire-ups), with a comment explaining the bookkeeping intent. Comment-only / pattern-cleanup changes; no behavior change. cargo check + cargo test --no-run both clean.
SP5 Task A7 — Pearl 8 per-direction trail-stop distance GPU producer (Layer A, 2026-05-01): one new CUDA kernel (pearl_8_trail_kernel.cu) lands as a Layer A additive producer feeding ISV slots [270..274) with per-direction trail-stop distances. No check_trailing_stop consumer migration in this commit — Layer B wires the consumer when the per-direction trail distance is read at trade-event time. pearl_8_trail_update: single-block 4-thread kernel (one thread per direction: Short/Hold/Long/Flat). Reads features[bar_idx × market_dim + 9] (ATR_NORM column), denormalizes via the canonical fxcache scheme atr_abs = max(0.01, exp(atr_norm × 16 − 7)) (constants 16, 7, 0.01 are Invariant 1 anchors from the existing fxcache normalization in experience_kernels.cu:2701). Per-direction logic: Short[270] = atr_abs × ATR_TRAIL_FACTOR=2.0 (industry-standard 2× ATR trail), Hold[271] = EPS_CLAMP_FLOOR=1.0 (Hold doesn't trail-stop; floor prevents zero), Long[272] = atr_abs × 2.0 (same as Short for Layer A), Flat[273] = EPS_CLAMP_FLOOR=1.0. Layer A simplification: Short and Long share the same current-bar ATR value. The spec sketch implied direction-conditional ATR (atr_ema_per_dir[4] from per-trade-event aggregation conditioned on direction context), but that requires infrastructure not yet in place. The 4-slot ISV layout preserves the consumer contract so a future Layer C extension can populate truly direction-specific values without breaking Layer B's check_trailing_stop reads. launch_sp5_pearl_8_trail(current_bar_idx) reads the trainer's latest_features_dev_ptr + config().market_dim, fires the producer kernel writing scratch[199..203), then 4 launch_apply_pearls calls (one per direction) chained on the same stream with ALPHA_META=1e-3 (default rate). Wiener offsets: 213 + (270-174)×3 = 501 through 213 + (273-174)×3 = 510 — well within the 543-float wiener_state_buf. Buffer growth: producer_step_scratch_buf 199→203 slots (SP5_SCRATCH_TOTAL); 1 new scratch constant SCRATCH_PEARL_8_TRAIL=199. wiener_state_buf stays at 543 (sized at A1 for entire SP5 block). StateResetRegistry gains 1 new FoldReset entry: sp5_trail_dist_per_dir (ISV[270..274)) — Pearl A sentinel 0 at fold boundary fires first-observation replacement. Wire-up in training_loop.rs: launch_sp5_pearl_8_trail(current_bar_idx) called at the per-step boundary alongside other SP5 producer launches; current_bar_idx is read from the existing per-step bar-iteration variable. Two GPU-only #[ignore = "requires GPU"] unit tests: (14) pearl_8_trail_per_direction_factors — synthetic atr_norm chosen so atr_abs=10.0; verifies Short/Long = 20.0, Hold/Flat = 1.0; (15) pearl_8_trail_atr_floor_at_quiet_market — atr_norm=-10 → log_atr=-167 → exp(-167)≈0 → max(_,0.01)=0.01; verifies Short/Long = 0.02 (floor protection). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: cuda_pipeline/pearl_8_trail_kernel.cu (new), build.rs (+1 cubin entry), cuda_pipeline/gpu_dqn_trainer.rs (SP5_SCRATCH_TOTAL 199→203 + 1 new scratch constant + 1 static cubin + 1 struct field + cubin loader + struct initializer + launch method), trainers/dqn/state_reset_registry.rs (+1 FoldReset entry), trainers/dqn/trainer/training_loop.rs (+5 LOC after Pearl 5 launch + ManuallyDrop guard + DevicePtr trait import), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + 1 cubin constant + 1 loader helper). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs (Layer A simplification is documented design choice, not a stub), feedback_no_partial_refactor (no consumer migration — Layer A additive only).
SP5 Task A6 fix-up — close two minor review findings (2026-05-01): combined spec-quality review caught two minor issues in the Pearl 6 commit. (1) Test 12 (pearl_6_kelly_within_fold_ewma_blend) was missing an explicit assertion for slot 282 (TRADE_VAR_SMOOTH_IDX), even though the test setup initialized tvar_i32 and the launcher passed it. With n_envs=1, the kernel's (kelly_count > 1) ? variance : 0.0f branch returns 0, so EWMA blend yields 0.99 × 0.5 + 0.01 × 0.0 = 0.495. Added assert!((isv[TRADE_VAR_SMOOTH_IDX] - 0.495).abs() < 2e-5) to close the within-fold test coverage gap for s==2. (2) pearl_6_kelly_kernel.cu:136 doc comment said the slot is "standard deviation of per-env Kelly fractions" but the code computes ksum_sq / kelly_count (variance), and the slot is named TRADE_VAR_SMOOTH_INDEX — the comment was wrong, the name and code are right. Updated comment to "variance of per-env Kelly fractions" and added clarifying note that this is the second moment, NOT std-dev. Comment-only and test-only changes; no behavior change. cargo build --release + cargo test --no-run both clean.
SP5 Task A6 — Pearl 6 cross-fold-persistent Kelly cap signals GPU producer (Layer A, 2026-05-01): one new CUDA kernel (pearl_6_kelly_kernel.cu) lands as a Layer A additive producer feeding ISV slots [280..286) with cross-fold-persistent Kelly cap statistics. No consumer migration in this commit — Layer B wires ISV[280..286) into the Kelly cap controller once all Layer A producers are complete. pearl_6_kelly_update: single-block 6-thread kernel; thread s ∈ [0,6) handles ISV slot KELLY_F_SMOOTH_IDX + s (= 280 + s). Reads portfolio_state[env × ps_stride + offset] for all n_envs envs (cold-path, per-epoch, 6×n_envs reads acceptable). Bayesian priors prior_wins=2, prior_losses=2, prior_sum_wins=0.01, prior_sum_losses=0.01 are Invariant 1 structural anchors (match kelly_cap_update_kernel.cu). In-kernel EWMA α=0.01 is an Invariant 1 anchor — slow rate to preserve cross-fold inertia. Per-slot semantics: s==0 (kelly_f_smooth): Bayesian Kelly fraction kf = (payoff×wr - (1-wr)) / payoff, clamped to [0,1], EWMA; s==1 (conviction_smooth): intentional identity update (new_obs = current) — Layer B will wire the actual conviction source; s==2 (trade_var_smooth): variance proxy (second pass over envs), EWMA; s==3 (sample_count): cumulative-via-max new_isv = max(current, new_obs) — count never decreases when portfolio_state resets; s==4 (win_rate_smooth): new_obs = total_wins / total_trades, EWMA; s==5 (loss_rate_smooth): new_obs = total_losses / total_trades, EWMA. __threadfence_system() after all writes. Investigation finding: portfolio_state has WindowReset lifecycle (resets at every WINDOW boundary, not just fold boundary — more aggressive than FoldReset). This makes cross-fold persistence even more critical: without the max() semantics for sample_count and the in-kernel EWMA carryover, every window boundary (not just fold boundary) would re-engage the Kelly cold-start Quarter-dominance pathology. Registry carve-out: ISV[280..286) are EXEMPT from the StateResetRegistry. This is an intentional, documented departure from the standard fold-reset discipline — the entire purpose of these slots is to survive fold and window resets. No RegistryEntry blocks added. State reset registry updated with an explicit audit comment block (near sp5_wiener_state entry) documenting: (a) ISV[280..286) exempt, (b) portfolio_state WindowReset lifecycle, (c) in-kernel design rationale. Wiener state carve-out: Pearl 6 does NOT use apply_pearls / launch_apply_pearls for write-back. Under the naive wiener offset formula 213 + (280-174)×3 = 525, slots [280..282) would map to wiener_state_buf[525..534) (within the 543-float buffer), but slots [283..285) would map correctly. More importantly, wiener_state_buf is part of the fold-reset path — resetting Pearls A+D wiener state at fold boundary is incompatible with cross-fold persistence, so wiener/apply_pearls is correctly omitted entirely. In-kernel EWMA replaces the external Pearls A+D bootstrap discipline. No apply_pearls: sentinel-bootstrap (zero ISV → first-observation replacement) is also incompatible with cross-fold persistence (sentinel detection would fire after every fold reset, wiping accumulated history); replaced by in-kernel EWMA that naturally continues from the current ISV value. Stream ordering: Pearl 6 is launched at the same epoch-boundary site as launch_kelly_cap_update, reusing the same ps_dev_ptr and n_envs from the existing if let (Some(ref fused), Some(ref collector)) block — natural stream ordering guarantees portfolio_state is populated before Pearl 6 reads it. No scratch buffer growth (Pearl 6 reads portfolio_state directly, no intermediate scratch). No wiener_state_buf growth. No StateResetRegistry entries added. Wire-up in training_loop.rs: launch_sp5_pearl_6_kelly(ps_dev_ptr, n_envs) called immediately after launch_kelly_cap_update in the existing epoch-boundary block with tracing::warn!(error = %e, ...) on error. Two GPU-only #[ignore = "requires GPU"] unit tests: (12) pearl_6_kelly_within_fold_ewma_blend — single env, known win/loss/sum_wins/sum_losses, verifies Bayesian Kelly EWMA for s==0, identity for s==1, cumulative-via-max for s==3, EWMA for s==4 and s==5; (13) pearl_6_kelly_cross_fold_sample_count_persists_via_max — 4-step test: 100 trades → count=100; reset ps to 0 → count still 100; 10 trades → still 100; 150 trades → count grows to 150. No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: cuda_pipeline/pearl_6_kelly_kernel.cu (new), build.rs (+1 cubin entry), cuda_pipeline/gpu_dqn_trainer.rs (+1 static cubin + 1 struct field + cubin loader + struct initializer + launcher method launch_sp5_pearl_6_kelly), trainers/dqn/state_reset_registry.rs (audit comment block documenting registry carve-out — NO new RegistryEntry blocks), trainers/dqn/trainer/training_loop.rs (+5 LOC inside existing kelly epoch-boundary block), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + 1 cubin constant + 1 loader helper + module docstring). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs, feedback_no_partial_refactor (no consumer migration — Layer A additive only), feedback_adaptive_not_tuned (α=0.01 is Invariant 1 structural anchor not a tuned constant).
SP5 Task A5 fix-up — pearl_5_iqn_tau header comment corrected (2026-05-01): code-quality review caught that the kernel header described the τ shift as "toward the dense tail" but the actual math (t = default + skew × 0.05) shifts τ in the SAME direction as the skew sign — i.e. toward the LONG (sparse) tail, not the dense region. The math is correct and faithful to the plan's formula; only the prose was reversed. Replaced the misleading one-liner with a 7-line explanation: left-skewed Q (skew<0) shifts τ toward 0, right-skewed (skew>0) shifts τ toward 1, "leaning the quantile grid INTO the asymmetry direction" so the IQN sampler gets more resolution over the long tail (where the symmetric 5-tuple under-represents tail mass). Comment-only change; behavior unchanged. Touched: crates/ml/src/cuda_pipeline/pearl_5_iqn_tau_kernel.cu (header rewrite, +7/-1 LOC). Cubin rebuild clean.
SP5 Task A5 — Pearl 5 per-branch IQN τ schedule GPU producer (Layer A, 2026-05-01): two new CUDA kernels (q_skew_kurtosis_kernel.cu, pearl_5_iqn_tau_kernel.cu) land as Layer A additive producers that feed ISV slots [250..270) with per-branch IQN quantile-τ schedules. No IQN τ-selection consumer migration in this commit — Layer B wires the τ consumer when distributional action-selection is migrated. q_skew_kurtosis_update: single-block 4-thread kernel (one thread per branch), reads save_q_online[B × 13] row-major; two-pass central moments (mean then m2/m3/m4); computes skew = m3 / (m2^1.5 + EPS_DIV) and ex_kurt = m4 / (m2^2 + EPS_DIV) - 3; EPS_DIV=1e-12 is an Invariant 1 anchor (numerical stability for degenerate constant-Q distribution); clamps skew to [-3,+3] and ex_kurt to [-3,+30] (structural envelopes); writes skew[4] to scratch[171..175) and ex_kurt[4] to scratch[175..179); __threadfence_system() after writes. No atomicAdd — one thread per branch with independent scratch writes. pearl_5_iqn_tau_update: single-block 4-thread kernel, reads scratch_buf[skew_idx_base + b]; shifts symmetric default schedule {0.05,0.25,0.5,0.75,0.95} by skew × SKEW_SHIFT=0.05 (Invariant 1 anchor — modest schedule shift, max ±0.15 at clamped skew=±3); clamps every τ to structural envelope [0.01,0.99] (Invariant 1 anchor — IQN requires τ strictly in (0,1)); writes 20 τ floats to scratch_out[tau_idx_base + b×5 + q] at scratch[179..199). launch_sp5_pearl_5_iqn_tau fires both kernels sequentially then 20 launch_apply_pearls calls (4 branches × 5 quantiles → ISV[IQN_TAU_BASE=250..270)). ALPHA_META=1e-3 (same as SP4 default; Pearl 5's τ signal is direct per-step output, not an EMA-of-EMA, so slower smoothing relative to Pearl 4's 5e-4 not needed). Wiener offset formula: base_wiener_offset + (isv_slot - SP5_SLOT_BASE) × 3 where base_wiener_offset = SP4_PRODUCER_COUNT × 3 = 213; for ISV slot 250 (first IQN_TAU slot): wiener_off = 213 + (250-174)×3 = 441. Buffer growth: producer_step_scratch_buf 171→199 slots (SP5_SCRATCH_TOTAL); 3 new scratch constants SCRATCH_PEARL_5_SKEW=171, SCRATCH_PEARL_5_EX_KURT=175, SCRATCH_PEARL_5_TAU=179. wiener_state_buf stays at 543 (SP5_WIENER_TOTAL_FLOATS — sized at Task A1 for all 110 SP5 slots, no growth). StateResetRegistry gains 1 new FoldReset entry: sp5_iqn_tau (ISV[250..270)) — Pearl A sentinel 0 at fold boundary triggers first-observation replacement to zero-skew symmetric default {0.05,0.25,0.5,0.75,0.95}. Wire-up in training_loop.rs: launch_sp5_pearl_5_iqn_tau() called immediately after launch_sp5_pearl_4_adam_hparams() with tracing::warn!(error = %e, ...) on error. Two GPU-only #[ignore = "requires GPU"] unit tests: (10) constant Q input → zero skew → symmetric default τ (chains both kernels); (11) injected skew=-1.0 → τ shifted down by 0.05, τ[0] clamped to 0.01 floor (exercises SKEW_SHIFT mechanism and structural envelope clamp). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: cuda_pipeline/q_skew_kurtosis_kernel.cu (new), cuda_pipeline/pearl_5_iqn_tau_kernel.cu (new), build.rs (+2 cubin entries), cuda_pipeline/gpu_dqn_trainer.rs (SP5_SCRATCH_TOTAL 171→199 + 3 new scratch constants + 2 static cubins + 2 struct fields + cubin loaders + struct initializer + launch method), trainers/dqn/state_reset_registry.rs (+1 FoldReset entry), trainers/dqn/trainer/training_loop.rs (+5 LOC after Pearl 4 launch), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + 2 cubin constants + 2 loader helpers + module docstring). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs, feedback_no_partial_refactor (no consumer migration — Layer A additive only).
SP5 docs/comment-only fix-up — close two minor review findings before Layer A Task A5 (2026-05-01): two minor code-review nits accumulated across A1-A4 reviews; closed in a single docs/comment-only commit. (1) tests/sp5_producer_unit_tests.rs module docstring (A4 review) updated to enumerate all 9 SP5 Layer A tests (q_branch_stats + pearl_1 + pearl_3 × 2 + pearl_2 × 2 + pearl_4 × 2 + grad_cosine_sim) so a reader cold to the file can locate each producer's coverage without scanning the body. (2) gpu_dqn_trainer.rs:2854 stale field comment // [B, TOTAL_ACTIONS(11)] (A1 review) updated to // [B, TOTAL_ACTIONS(13)] — 4 direction + 3 magnitude + 3 order + 3 urgency so the field declaration matches the SP5 producer docstrings (e.g. q_branch_stats_kernel comments at line 277/282) and the 4-branch action layout established in commit 2fb30f098. Comment-only changes; no behavior change. cargo check + cargo test --no-run both clean (11 pre-existing warnings, none new).
SP5 Task A4 fix-up — grad_cosine_sim_update unit test added (2026-05-01): code-quality review caught that pearl_4_adam_hparams_kernel had direct GPU tests but the auxiliary grad_cosine_sim_kernel had none. Test 7 and Test 8 launched the hparams kernel directly with pre-baked cosine inputs, never exercising the per-group reduction (dot + 2× L2 norm sums) or the writeback (grad_curr → grad_prev for next step's comparison). The writeback in particular is load-bearing for cross-step cosine evolution — if broken, every subsequent step's cosine_sim is computed against a stale or mis-aligned previous gradient. Fix adds Test 9 grad_cosine_sim_per_group_dot_norm_and_writeback with 8 analytically-known synthetic group cases: identical (cos=+1), orthogonal (0), antiparallel (-1), Pythagorean (cos=+1, |c|=5), cold-start curr (zero grad → cos=0, |c|=0), cold-start prev (Pearl A sentinel state → cos=0, |c|=1), and 2 repeats. NO CPU oracle — expected values are unit-vector cosines from the kernel formula. Writeback verified by reading grad_prev_buf after the kernel runs and asserting bit-identity with grad_curr_buf for all 32 elements. Touched: crates/ml/tests/sp5_producer_unit_tests.rs (+1 cubin const + 1 loader + 120 LOC test). cargo test --no-run clean.
SP5 Task A4 — Pearl 4 per-group Adam β1/β2/ε GPU producers (Layer A, 2026-05-01): two new CUDA kernels (grad_cosine_sim_kernel.cu, pearl_4_adam_hparams_kernel.cu) and one MappedF32Buffer (grad_prev_buf_per_group) land as Layer A additive producers that feed ISV slots [226..250) with per-param-group Adam hyperparameters. grad_cosine_sim_update: single-block 8-thread kernel (one thread per SP4 param group: DqnTrunk/Value/Branches/IQN/IqlHigh/IqlLow/Attn/Curiosity), reads grad_buf[total_params] + grad_prev_buf[total_params] + group_param_offsets[9] (prefix sums in element units); computes dot = Σ gc·gp, norm_curr_sq = Σ gc², norm_prev_sq = Σ gp² per group; writes cos_sim[8]→scratch[131..139) and l2_norm[8]→scratch[139..147); writebacks grad_curr → grad_prev_buf for the next step's comparison (same thread — no race). pearl_4_adam_hparams_update: single-block 8-thread kernel, reads cos_sim[8] + l2_norm[8] from scratch; clips stability to [0, 1]; computes β1 = 0.85 + 0.10×stability ∈ [0.85, 0.95], β2 = 0.99 + 0.0095×stability ∈ [0.99, 0.9995], ε = clamp(grad_norm × 1e-7, 1e-10, 1e-6); writes β1[8]→scratch[147..155), β2[8]→scratch[155..163), ε[8]→scratch[163..171). Structural envelopes (Invariant 1 anchors per spec): β1∈[0.85, 0.95], β2∈[0.99, 0.9995], ε∈[1e-10, 1e-6]. launch_sp5_pearl_4_adam_hparams fires 24 launch_apply_pearls calls: β1[8] → ISV[226..234), β2[8] → ISV[234..242), ε[8] → ISV[242..250). Uses ALPHA_META_PEARL_4=5e-4 (half SP4 default 1e-3) per theoretical-caveat mitigation — slow β change rate reduces instability risk from adaptive β breaking Adam's constant-β convergence proof. Pearl A sentinel 0 at fold boundary fires first-observation replacement (cosine_sim=0 → low-stability envelope endpoints). Aux groups 3-7 (IQN/IqlHigh/IqlLow/Attn/Curiosity) have non-contiguous grad allocations; zero-width sentinel offsets [tp, tp, tp, tp, tp] in group_param_offsets_buf prevent the kernel inner loop from executing for those groups, yielding cosine_sim=0 → β1/β2 start at low-stability floors. apply_pearls_kernel.cu signature gained float alpha_meta parameter (migration of all 18 call sites atomic per feedback_no_partial_refactor.md: 1 in gpu_experience_collector.rs, 17 in gpu_dqn_trainer.rs). Buffer growth: producer_step_scratch_buf 131→171 slots (SP5_SCRATCH_TOTAL); 5 new scratch constants SCRATCH_PEARL_4_{COSINE=131, L2=139, BETA1=147, BETA2=155, EPS=163}. StateResetRegistry gains 4 new FoldReset entries: sp5_adam_beta1 (ISV[226..234)), sp5_adam_beta2 (ISV[234..242)), sp5_adam_eps (ISV[242..250)), sp5_grad_prev_buf (grad_prev_buf_per_group — zero at fold boundary so cosine_sim=0 fires Pearl A bootstrap). Wire-up in training_loop.rs: launch_sp5_pearl_4_adam_hparams() called immediately after launch_sp5_pearl_2_budget() with tracing::warn! on error. Two GPU-only #[ignore = "requires GPU"] unit tests: (7) high-stability (cosine_sim=1.0 → β1=0.95, β2=0.9995, ε within envelope); (8) low-stability (cosine_sim=0.0 → β1=0.85, β2=0.99, ε within envelope). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: cuda_pipeline/grad_cosine_sim_kernel.cu (new), cuda_pipeline/pearl_4_adam_hparams_kernel.cu (new), cuda_pipeline/apply_pearls_kernel.cu (+alpha_meta param), cuda_pipeline/sp4_wiener_ema.rs (+alpha_meta arg), cuda_pipeline/gpu_experience_collector.rs (+1 call site ALPHA_META arg), build.rs (+2 cubin entries), cuda_pipeline/gpu_dqn_trainer.rs (SP5_SCRATCH_TOTAL 131→171 + 5 new constants + 2 static cubins + 4 struct fields + constructor allocs + struct initializer + 17 call-site ALPHA_META args + launch method), trainers/dqn/state_reset_registry.rs (+4 FoldReset entries), trainers/dqn/trainer/training_loop.rs (+5 LOC after Pearl 2 launch), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + cubin constant + loader helper). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_htod_htoh_only_mapped_pinned, feedback_no_stubs, feedback_no_partial_refactor (apply_pearls 18 call sites all migrated).
SP5 Task A3 fix-up — Pearl 2 budget tests now assert sum-to-1 normalization invariant (2026-05-01): code-quality review caught that the two A3 unit tests verified each output (c51, iqn, cql, ens) against its analytical expected value within 1% relative tolerance, but did NOT assert the structural invariant c51 + iqn + cql + ens ≈ 1.0 that the kernel maintains by construction (ens = max(0, 1 - iqn - c51 - cql)). A coefficient typo such as BASE_IQN = 0.111 instead of 0.11 would produce individually-plausible per-component values that all still pass the relative checks while quietly summing to 0.97 or 1.04. Fix adds assert!((c51+iqn+cql+ens - 1.0).abs() < 1e-4) inside both per-branch loops. Touched: crates/ml/tests/sp5_producer_unit_tests.rs (+12 LOC). cargo test --no-run clean.
SP5 Task A3 — Pearl 2 per-branch loss budget GPU producer (Layer A, 2026-05-01): one new CUDA kernel (pearl_2_budget_kernel.cu) lands as a Layer A additive producer that feeds ISV slots [190..210) with per-branch loss budget and flatness signals. No loss-budget consumer migration in this commit — Layer B wires consumers. pearl_2_budget_update: single-block 4-thread kernel (one thread per branch), reads Q_VAR_PER_BRANCH[b] (Pearl 1's q_branch_stats output, ISV[222..226)) + NOISY_SIGMA[b] (Pearl 3 output, ISV[210..214)); computes flatness[b] = clamp(var_q[b] / (σ[b]² + EPS_DIV), 0, 1) where EPS_DIV=1e-8 is an Invariant 1 anchor (numerical stability); derives budget_iqn = BASE_IQN + (1 - flatness) * (1 - BASE_IQN) (IQN dominates when Q is flat relative to noise), budget_c51 = flatness * (1 - BASE_IQN) * BASE_C51_SHARE (C51 takes proportional remainder when Q is well-separated), budget_cql = 0 (gated by existing regime_stability allocator), budget_ens = max(0, 1 - iqn - c51 - cql); writes 20 floats to scratch[111..131) (SCRATCH_PEARL_2_C51=111, SCRATCH_PEARL_2_IQN=115, SCRATCH_PEARL_2_CQL=119, SCRATCH_PEARL_2_ENS=123, SCRATCH_PEARL_2_FLATNESS=127). BASE_IQN=0.11 and BASE_C51_SHARE=0.84/(1-BASE_IQN) are Invariant 1 anchors (preserve SP4-baseline budget under non-flat regime). launch_sp5_pearl_2_budget fires 20 launch_apply_pearls calls: 5 output groups × 4 branches each, mapping to ISV[BUDGET_C51_BASE=190..194), ISV[BUDGET_IQN_BASE=194..198), ISV[BUDGET_CQL_BASE=198..202), ISV[BUDGET_ENS_BASE=202..206), ISV[FLATNESS_BASE=206..210). Wiener offset formula: base_wiener_offset + (isv_slot - SP5_SLOT_BASE) * 3 where base_wiener_offset = SP4_PRODUCER_COUNT * 3 = 213. Dependencies: Pearl 2 must chain AFTER Pearl 1 (reads Q_VAR_PER_BRANCH) AND AFTER Pearl 3 (reads NOISY_SIGMA). Buffer growth: producer_step_scratch_buf 111→131 slots (SP5_SCRATCH_TOTAL updated 111→131; 5 new module-level constants SCRATCH_PEARL_2_{C51,IQN,CQL,ENS,FLATNESS}). wiener_state_buf already at 543 (A1 sized for entire SP5 block — no further growth). StateResetRegistry gains 5 new FoldReset entries: sp5_budget_c51 (ISV[190..194)), sp5_budget_iqn (ISV[194..198)), sp5_budget_cql (ISV[198..202)), sp5_budget_ens (ISV[202..206)), sp5_flatness (ISV[206..210)) — Pearl A sentinel 0 at fold boundary triggers first-observation replacement on new fold's first launch. Wire-up in training_loop.rs: launch_sp5_pearl_2_budget() called immediately after launch_sp5_pearl_3_sigma() with tracing::warn!(error = %e, ...) on error. Two GPU-only #[ignore = "requires GPU"] unit tests: (5) flat-Q regime (σ=1.0, var_q=2.0 → flatness=1.0 → iqn=0.11, c51≈0.84); (6) sharp-Q regime (σ=2.0, var_q=0.04 → flatness≈0.01 → iqn>0.9, c51≈0.0084). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs, no consumer migration. Touched: cuda_pipeline/pearl_2_budget_kernel.cu (new), build.rs (+1 cubin entry), cuda_pipeline/gpu_dqn_trainer.rs (static cubin + SP5_SCRATCH_TOTAL 111→131 + 5 new scratch constants + docstring update + struct field + cubin loader + struct initializer + launch method), trainers/dqn/state_reset_registry.rs (+5 FoldReset entries), trainers/dqn/trainer/training_loop.rs (+5 LOC after Pearl 3 launch), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + cubin loader helper). Hard rules: feedback_no_cpu_compute_strict, feedback_no_atomicadd, feedback_no_stubs, feedback_no_partial_refactor (all consumers of existing budget allocator untouched).
SP5 Task A2 — Pearl 3 per-branch NoisyNet sigma GPU producer (Layer A, 2026-05-01): one new CUDA kernel (pearl_3_sigma_kernel.cu) lands as a Layer A additive producer that feeds ISV slots [210..218) with per-branch NoisyNet sigma and sigma-fraction controller state. No NoisyLinear consumer migration in this commit — Layer B wires the noisy_linear_kernel.cu consumer. pearl_3_sigma_update: single-block 4-thread kernel (one thread per branch), reads ATOM_V_HALF[b] (Pearl 1, ISV[178..182)) + BRANCH_ENTROPY[b] (q_branch_stats, ISV[218..222)) + current SIGMA_FRACTION[b] (ISV[214..218), Pearl A sentinel 0 → bootstrap to 0.1); applies entropy-deficit controller new_sf = clamp(sf + SF_LR * (target_entropy - entropy), 0.001, 0.5) where target_entropy = log(n_actions[b]) * 0.7 (SF_LR=0.005 and target_entropy_factor=0.7 are Invariant 1 structural anchors); computes new_sigma = new_sf * max(v_half, EPS_CLAMP_FLOOR=1.0) (EPS_CLAMP_FLOOR is an Invariant 1 anchor); writes (sigma[4], SF[4]) to scratch[103..111) (SCRATCH_PEARL_3_SIGMA=103, SCRATCH_PEARL_3_SF=107). launch_sp5_pearl_3_sigma fires 8 launch_apply_pearls calls: sigma[4] → ISV[210..214) (NOISY_SIGMA_BASE), SF[4] → ISV[214..218) (SIGMA_FRACTION_BASE). Wiener offset formula: base_wiener_offset + (isv_slot - SP5_SLOT_BASE) * 3 where base_wiener_offset = SP4_PRODUCER_COUNT * 3 = 213. Pearl 3 must chain AFTER Pearl 1 (reads ATOM_V_HALF which Pearl 1 populates via apply_pearls_ad). Buffer growth: producer_step_scratch_buf 103→111 (SP5_SCRATCH_TOTAL updated; new constants SCRATCH_PEARL_3_SIGMA=103, SCRATCH_PEARL_3_SF=107). wiener_state_buf already at 543 (A1 sized for entire SP5 block — no further growth). StateResetRegistry gains 2 new FoldReset entries: sp5_noisy_sigma (ISV[210..214)) and sp5_sigma_fraction (ISV[214..218)) — Pearl A sentinel 0 at fold boundary triggers bootstrap to 0.1 on new fold's first launch. Wire-up in training_loop.rs: launch_sp5_pearl_3_sigma() called immediately after launch_sp5_pearl_1_atom() with warn! on error. Two GPU-only #[ignore = "requires GPU"] unit tests: (3) bootstrap + target-entropy equality case (sentinel ISV SF=0→bootstrap 0.1, target==actual → no update, sigma=0.12.0=0.2); (4) entropy-below-target case (SF=0.1, entropy=0 → SF increases by SF_LRtarget_entropy, sigma increases proportionally). No CPU compute, no HtoD/HtoH, no atomicAdd, no stubs. Touched: cuda_pipeline/pearl_3_sigma_kernel.cu (new), build.rs (+1 cubin entry), cuda_pipeline/gpu_dqn_trainer.rs (static cubin + SP5_SCRATCH_TOTAL 103→111 + 2 new constants + struct field + cubin loader + struct initializer + launch method), trainers/dqn/state_reset_registry.rs (+2 FoldReset entries), trainers/dqn/trainer/training_loop.rs (+4 LOC after Pearl 1 launch), tests/sp5_producer_unit_tests.rs (+2 GPU-only tests + cubin loader helper). cargo check -p ml --lib clean. No consumer migration in this commit.
SP5 Task A1 fix-up — pearl_1 clip_rate denominator must be per-branch (2026-05-01): code-quality review caught the headroom controller's clip_rate computation using batch_size × 13 (sum of per-branch action counts) instead of batch_size × action_counts[b] (per-branch). The TARGET_CLIP_RATE=0.01 anchor is calibrated against the per-branch event rate; using a denominator 3.25–4.3× too large made the controller systematically under-responsive — at actual 1% per-branch clipping it would read ~0.23–0.31% and contract headroom toward the 2.0 floor instead of holding the target. Currently masked because atoms_clip_count is zero in Layer A (Layer B's atoms_update_kernel migration populates it); without this fix Layer B would inherit a silently-wrong formula. Fix plumbs action_counts[4] ({4, 3, 3, 3}) through pearl_1_atom_kernel signature and uses batch_size × action_counts[b] as the per-branch denominator, matching q_branch_stats_kernel's existing parameter pattern. Test #2 launcher updated to allocate action_counts_buf and pass its dev_ptr through the new parameter slot. Touched: cuda_pipeline/pearl_1_atom_kernel.cu (+1 param, denominator math), cuda_pipeline/gpu_dqn_trainer.rs (+1 launch arg in launch_sp5_pearl_1_atom), tests/sp5_producer_unit_tests.rs (+5 LOC for action_counts_buf + extra arg). cargo check + cargo build --release + cargo test --no-run all clean.
SP5 Task A1 — Pearl 1 atom-span + Q-stats GPU producers (Layer A, 2026-05-01): two new CUDA kernels (q_branch_stats_kernel.cu, pearl_1_atom_kernel.cu) land as Layer A additive producers that feed ISV slots [174..226) with per-branch C51 atom-span signals. No consumer migration in this commit — Layer B (atoms_update_kernel wiring) is a separate task. q_branch_stats_update: single-block 4-thread kernel (one thread per branch: Direction/Magnitude/Order/Urgency), reads q_out_buf[B × 13] row-major (confirmed layout), computes 3 serial passes over batch × branch-actions to produce (mean, max_abs_dev, var, entropy) per branch, writes 16 floats to producer_step_scratch_buf[71..87) (SCRATCH_Q_STATS=71; slot 70 = SP4 q_dir_grad_p99 was the last SP4 slot — collision-free). No atomicAdd per feedback_no_atomicadd.md — one thread per branch with independent scratch writes. pearl_1_atom_update: single-block 4-thread kernel, reads q_branch_stats scratch [71..87), reads current HEADROOM from ISV (Pearl A sentinel: ISV=0.0 → bootstrap to 6.0), reads atoms_clip_count_buf[4] (zero-init until Layer B wires atoms_update_kernel — clip_rate=0, headroom holds at bootstrap), runs headroom controller new_headroom = clamp(headroom + 0.01*(clip_rate - 0.01), 2.0, 20.0) (TARGET_CLIP_RATE=0.01 and HEADROOM_LR=0.01 are Invariant-1 structural anchors, not tuned constants), writes (v_center, v_half, headroom, clip_rate) to scratch [87..103) (SCRATCH_ATOM_OUT=87). launch_sp5_pearl_1_atom then fires 24 launch_apply_pearls calls: 4 groups × 4 contiguous Pearl 1 outputs (ISV[174..178)=v_center, [178..182)=v_half, [182..186)=headroom, [186..190)=clip_rate) looped, then 8 single-slot launches for per-branch entropy ([218..222)) and var ([222..226)) which are non-contiguous in scratch. Wiener offset: (SP4_PRODUCER_COUNT=71 + (isv_slot − SP5_SLOT_BASE=174)) × 3 — correct SP5 block within wiener_state_buf. Buffer growth per feedback_no_partial_refactor.md: wiener_state_buf 213→543 floats ((71+110)×3), producer_step_scratch_buf 71→103 slots (SP4_PRODUCER_COUNT → SP5_SCRATCH_TOTAL), both in the same commit that adds the kernels that use them. New module-level constants: SP5_WIENER_TOTAL_FLOATS=543, SP5_SCRATCH_TOTAL=103, SCRATCH_Q_STATS=71, SCRATCH_ATOM_OUT=87. New struct fields: q_branch_stats_kernel, pearl_1_atom_kernel (CudaFunction), atoms_clip_count_buf (MappedI32Buffer[4], zero-init), action_counts_buf ([4,3,3,3]), action_offsets_buf ([0,4,7,10]). StateResetRegistry gains 7 new FoldReset entries: sp5_atom_v_center/v_half/headroom/clip_rate (ISV[174..190)), sp5_branch_entropy (ISV[218..222)), sp5_q_var_per_branch (ISV[222..226)), sp5_wiener_state (wiener_state_buf[213..543) — SP5 block, resets companion Wiener state with Pearl A sentinel per pearl_first_observation_bootstrap.md). Wire-up in training_loop.rs: launch_sp5_pearl_1_atom() called after launch_sp4_param_group_oracles_all_groups with warn! on error (Layer A — non-fatal until Layer B consumers land). Two GPU-only #[ignore = "requires GPU"] unit tests in tests/sp5_producer_unit_tests.rs: (1) uniform-input analytical ground truth for q_branch_stats (mean=2.0, max_abs_dev=0.0, entropy=log(n_actions)); (2) headroom bootstrap + controller step for pearl_1_atom (sentinel ISV=0→bootstrap 6.0, clip_rate=0, one step adjustment). No CPU compute per feedback_no_cpu_compute_strict.md, no HtoD/HtoH per feedback_no_htod_htoh_only_mapped_pinned.md, no atomicAdd per feedback_no_atomicadd.md, no stubs per feedback_no_stubs.md. Touched: cuda_pipeline/q_branch_stats_kernel.cu (new), cuda_pipeline/pearl_1_atom_kernel.cu (new), build.rs (+2 cubin entries), cuda_pipeline/gpu_dqn_trainer.rs (static cubins + SP5 constants + struct fields + buffer growth 213→543 + 71→103 + cubin loaders + buffer allocs + struct initializer + launch method + docstring), trainers/dqn/state_reset_registry.rs (+7 FoldReset entries + sp4_wiener_state description 213→543), trainers/dqn/trainer/training_loop.rs (+3 LOC wire-up after SP4 param_group_oracles), tests/sp5_producer_unit_tests.rs (new, 2 GPU-only tests). cargo check -p ml --lib clean (11 pre-existing warnings, none new). Validation deferred to one L40S smoke; expected sp5_atom_headroom stays [2.0,20.0], sp5_atom_v_half tracks C51 atom spread, no NaN flags.
HEALTH_DIAG intent_dist diagnostic (#212, 2026-05-01): added intent_dist_q/h/f HEALTH_DIAG metric adjacent to the existing eval_dist_q/h/f so operators can split policy-learning quality from Kelly-enforcement reality. Per memory pearl project_magnitude_eval_collapse_kelly_capped.md: EVAL_DIST Quarter dominance is a downstream artefact of Kelly cap × warmup_floor × safety_multiplier math, NOT a policy-learning failure. The intent metric reads the policy's pre-Kelly-cap chosen mag bucket (intent_mag_buf written by the action-select kernel, exposed via the trainer's pre-existing last_eval_intent_magnitude_dist host field — no new compute, no new GPU paths). Pure observability — the Kelly cap math itself stays unchanged (it is already adaptive per recent commits 2c97e0436 / 0c9d1ee39 / 94157a8a6 / d9fee6ef8), no new tuned constants, no reward-bias mechanisms (all explicitly forbidden by the pearl). Three coordinated changes per feedback_no_partial_refactor.md: (1) HealthDiagSnapshot gains intent_dist_q/h/f: f32 fields adjacent to eval_dist_*; size assertion bumped 147 → 150 fields. (2) health_diag_kernel.cu adds WORD_INTENT_DIST_* slots at [77..80); every downstream WORD_* shifted by +3 in lockstep; WORD_TOTAL + static_assert bumped 147 → 150. The slots are reserved identically to WORD_EVAL_DIST_* — both currently inert in the kernel; HEALTH_DIAG GPU port Phases 2/3 will populate them in lockstep with their sibling slots. (3) training_loop.rs adds an adjacent tracing::info!() emitting "intent_dist [iq=… ih=… if=…]" from self.last_eval_intent_magnitude_dist immediately after the big HEALTH_DIAG line containing eval_dist [eq=… eh=… ef=…]. Behavior: zero change. After this commit, HEALTH_DIAG emits BOTH metrics so downstream analysis can distinguish "policy is learning Full, Kelly cap is suppressing" (intent_dist_f > 0.30 AND eval_dist_f ≈ 0, an operationally-correct cold-start state) from "policy genuinely isn't learning Full" (both metrics near zero, a policy-quality bug worth investigating). Touched: cuda_pipeline/health_diag.rs, cuda_pipeline/health_diag_kernel.cu, trainers/dqn/trainer/training_loop.rs. cargo check -p ml --lib clean (11 pre-existing warnings, none new); 3/3 health_diag layout tests pass; 16/16 sp4_producer_unit_tests GPU tests pass. No fingerprint change — separate mapped-pinned alloc, not part of param-tensor layout the fingerprint guards.
SP3 Mech 10 — ISV-driven post-trunk h_s2 activation clamp + slot 49 sentinel (2026-04-30): real root cause of the F1 step-1000 NaN identified by smoke-test-2xrxk on commit 0d7e27d61 (SP3 close-out v2 active). Trajectory: F0 Best Sharpe 45.47 (clean) → F1 NaN at step ~1000 → F2 first epoch reached Best Sharpe 56.70 (above the 55.87 baseline) before NaN epoch 2. Slot fire pattern at F1 NaN: [6=grad_buf, 12=save_h_s2, 26=iqn_trunk_m, 32=bn_d_concat_buf, 36-38=trunk/value/branch_adam_m, 40-42=trunk/value/branch_adam_v]. Crucially: slots 48 (iqn_weight_max), 39+43 (iqn_adam_m/v), 44-45 (trunk/heads weights) all stayed clean — disproving the SP3 close-out v2 hypothesis (IQN partial refactor). The actual cause: forward-pass GEMM accumulator overflow under Mech-9-clamped weights. With Mech 9 pinning weights at 100 × Q_ABS_REF = 5000 and trunk inner-dim K = 256, forward GEMM accumulators compound by √K × |w|max ≈ 80000× per layer; trunk depth ≥6 (encoder + VSN + GRN-blocks + bottleneck) hits f32_max ≈ 3.4e38 somewhere. F0/F2 train clean because their gradients keep weights at natural scale (~0.5); F1's regime shift drives weights toward the clamp ceiling and the forward chain saturates. Mech 1+2 clamped TARGETS+ATOMS, Mech 9 clamped WEIGHTS, nothing clamped ACTIVATIONS — Mech 10 closes that gap. Mech 10 mechanism: after the trunk + temporal pipeline finalises save_h_s2 in submit_forward_ops_main (end of "1c. Temporal pipeline" block — last writers are mamba2_step + apply_regime_dropout), the trainer launches launch_clamp_finite_f32 on save_h_s2 with bound 100 × ISV[H_S2_RMS_EMA_INDEX=96].max(1.0). Reuses the existing helper at dqn_utility_kernels.cu:462 (isfinite(v) ? clamp(v, ±max_abs) : 0.0f — NaN→0, Inf→0, finite clamped) — no new kernel. ISV-driven via existing H_S2_RMS_EMA_INDEX = 96 (already populated per Plan 4 Task 2c.3c.5; consumed by mag_concat_qdir adaptive scale per 2c.3c.6) — no new ISV slot, no hardcoded multipliers, consistent with feedback_isv_for_adaptive_bounds.md. ε on multiplier per SP1 pearl (isv.max(1.0)) — at fold boundary the EMA resets to 1.0 (neutral RMS) per state_reset_registry.rs, so the clamp floor is always at least 100 × 1 = 100, never zero. SP1 diagnostic-ordering pearl honoured: the inline check_nan_f32(slot 12) fires BEFORE the clamp sanitises so the sentinel sees the original Inf/NaN before sanitization replaces it with 0; redundant with the slot 12 check inside run_nan_checks_post_forward (which runs later, post-clamp, on the sanitized buffer) — but the inline check at this site is the only one that captures the pre-clamp state. New diagnostic slot 49 = h_s2_max_abs at threshold 1e3 × h_s2_rms_ema_eff mirrors the Mech 5 family pattern (clamp at 100×, diagnostic at 1000×) — sentinel that NEVER fires under normal Mech 10 operation. The fused NaN-check kernel (dqn_nan_check_fused_f32_kernel) gains a second ISV-derived float arg h_s2_rms_ema_eff distinct from q_abs_ref_eff (don't conflate the two ISV bases — h_s2 lives in pre-readout activation space, not Q space). Buffer bumps: nan_flags_buf 49→50, metadata buffers (nan_check_buf_ptrs, nan_check_buf_lens) 25→26 entries, fused-kernel block count 25→26. Both name tables in training_loop.rs (halt_nan formatter + halt_grad_collapse formatter) extended to 50 entries with "h_s2_max_abs" at slot 49. read_nan_flags() return type 49→50 in both GpuDqnTrainer and FusedTrainingCtx. Per feedback_no_partial_refactor.md, Mech 10 + slot 49 land in the same commit (single coordinated migration). No new ISV slots, no new kernels, no graph-topology surprise (one extra launch added to the captured forward graph at the temporal-pipeline → loss-path boundary). Touched: cuda_pipeline/dqn_utility_kernels.cu (kernel signature + slot 25 branch), cuda_pipeline/gpu_dqn_trainer.rs (alloc 49→50, metadata 25→26, populate_nan_check_meta + slot 49 entry, fused-kernel BLOCKS 25→26 + h_s2_rms_ema_eff arg, read_nan_flags 49→50, Mech 10 clamp call at end of "1c. Temporal pipeline" in submit_forward_ops_main), trainers/dqn/fused_training.rs (read_nan_flags wrapper 49→50), trainers/dqn/trainer/training_loop.rs (both name tables 49→50 entries with "h_s2_max_abs" at slot 49), docs/dqn-backward-nan-audit.md (Mech 10 row in mechanism table + slot 49 row in slot-allocation table + ISV bindings note + full Mech 10 section). cargo check -p ml --lib clean (12 pre-existing warnings, none new). Validation deferred to one L40S smoke at this HEAD; expected F1 trains past step 1000 and slots 36-43 + slot 49 stay quiet.
SP3 close-out v2 — Mech 9 to all 5 Adam kernels + IQN weight diag slot 48 (2026-04-30): real fix for the F1 step-3540 NaN. Commit 8956c2fb7 wired Mech 9 (post-Adam |p_val| clamp) into ONE Adam kernel (dqn_adam_update_kernel) out of FIVE in the codebase. The slot-26 GEMM (apply_iqn_trunk_gradient output) computes grad_iqn @ W_iqn^T; W_iqn lives in IQN's separate online_params buffer, updated by iqn_adam_kernel — never reached by Mech 9. Slots 44-45 only cover trunk + heads weights, leaving IQN's separate param buffer without a diagnostic. The chain: IQN weights drift via iqn_adam_kernel → finite gradient × non-finite weight → slot-26 NaN. Same partial-refactor class as Mech 4's missing GpuAttention reset (caught by B5 audit), corrected the same way per feedback_no_partial_refactor.md: every consumer of the contract migrates together. Extended Mech 9 to all four remaining Adam kernels — iqn_adam_kernel (iqn_dual_head_kernel.cu), iql_adam_kernel (iql_value_kernel.cu), attn_adam_kernel (attention_backward_kernel.cu, used by both GpuAttention and GpuTlob via the shared cubin), curiosity_adam_step (curiosity_training_kernel.cu). Same if (weight_clamp_max_abs > 0.0f) p = fminf(fmaxf(p, -bound), bound) pattern, last step before writeback. Same 100 × Q_ABS_REF.max(1.0) ISV-driven bound (same slot 16, ε on multiplier per SP1 pearl). Each launch site computes the bound host-side and passes it as the trailing kernel arg, mirroring the existing dqn_adam_update_kernel pattern. IQN: train_iqn_step_gpu + execute_training_pipeline take new weight_clamp_max_abs: f32 parameters; both fork-join arms in fused_training.rs::submit_aux_ops compute and pass the bound. IQL: train_value_step takes the bound; both gpu_iql.train_value_step (high-tau) and gpu_iql_low.train_value_step (low-tau) wired. Attention: GpuAttention::adam_step and GpuTlob::adam_step both take new weight_clamp_max_abs: f32; all 3 call sites in fused_training (parallel attn, sequential attn, TLOB Phase 6) wired. Curiosity: GpuCuriosityTrainer::train_on_collector_buffers and the inner launch_adam_step helper take the bound; GpuExperienceCollector::train_curiosity_gpu wraps and forwards; training_loop.rs::collect_step reads ISV from fused_ctx (curiosity collector doesn't own ISV) and passes through. DT launch in decision_transformer.rs keeps the 0.0 disable (offline-RL, outside SP3 scope). Added IQN-weight diagnostic slot 48 to plug the slot-26 GEMM blind spot: nan_flags_buf 48 → 49 (stream.alloc_zeros::<i32>(49)), fused-kernel block count 24 → 25 with new branch for absolute slot 48 = relative slot 24 at threshold 1e3 × q_abs_ref_eff (matches slot 44-45 weight regime), metadata buffers nan_check_buf_ptrs / nan_check_buf_lens extended 24 → 25 with the new slot 48 entry populated by populate_nan_check_meta from gpu_iqn.online_params_ptr() + online_params_len() (new public accessors on GpuIqnHead; existing cfg(test) online_params_slice() accessor kept). Both name tables in training_loop.rs (the halt_nan formatter at line ~2042 and the halt_grad_collapse formatter at line ~2143) extended to 49 entries with "iqn_weight_max" at slot 48 — symmetry required because either path can format nan_flags_buf, and an out-of-range table would out-of-bounds index when slot 48 fires. read_nan_flags() return type bumped [i32; 48] → [i32; 49] on both GpuDqnTrainer and the FusedTrainingCtx wrapper. Audit doc dqn-backward-nan-audit.md Mech 5 slot table now spans 36-48 with the new row; Mech 9 row updated to span all 5 Adam kernels (file list expanded). No new ISV slots, no new mapped-pinned allocations beyond the metadata-buffer +1, no graph-topology change beyond the +1 block in the already-fused NaN check. Touched: cuda_pipeline/iqn_dual_head_kernel.cu (kernel signature + clamp), cuda_pipeline/iql_value_kernel.cu (kernel signature + clamp), cuda_pipeline/attention_backward_kernel.cu (kernel signature + clamp), cuda_pipeline/curiosity_training_kernel.cu (kernel signature + clamp), cuda_pipeline/dqn_utility_kernels.cu (fused NaN check: grid 24→25, slot 24 branch), cuda_pipeline/gpu_iqn_head.rs (new online_params_ptr / online_params_len accessors + train_iqn_step_gpu + execute_training_pipeline signature + Adam launch arg), cuda_pipeline/gpu_iql_trainer.rs (train_value_step signature + Adam launch arg), cuda_pipeline/gpu_attention.rs (adam_step signature + launch arg), cuda_pipeline/gpu_tlob.rs (adam_step signature + launch arg), cuda_pipeline/gpu_curiosity_trainer.rs (launch_adam_step + train_on_collector_buffers signatures + 4 launch args), cuda_pipeline/gpu_experience_collector.rs (train_curiosity_gpu signature wrapper), cuda_pipeline/gpu_dqn_trainer.rs (alloc 48→49, metadata 24→25, populate_nan_check_meta 2 new params + slot 48 entry, fused-kernel BLOCKS 24→25, read_nan_flags 48→49), trainers/dqn/fused_training.rs (Q_ABS_REF_INDEX import + 4 wiring sites + read_nan_flags type), trainers/dqn/trainer/training_loop.rs (curiosity ISV read + name table 49 entries), docs/dqn-backward-nan-audit.md (slot table + Mech 9 row). cargo check -p ml --lib clean (12 pre-existing warnings, none new). Validation deferred to one L40S smoke at this HEAD; expected F1 trains past step 3720 ceiling and slots 36-43 + new slot 48 stay quiet.
SP3 Mech 9 — post-Adam ISV-driven weight clamp + Mech 8 revert (2026-04-30): coordinated close-out of SP3 Q-learning numerical-stability per feedback_no_partial_refactor.md. Mech 8 (slow_ema fold-boundary reset, commit b8a7ac6f7) reverted after smoke-test-rxhjh F1 NaN @ step 2300 — WORSE than the 3720-step ceiling Mech 6 alone produced. Persisting the α=0.001 grad_norm_slow_ema across folds (anchored at F0's smaller grad scale) was providing UNINTENTIONAL F1 protection by tightening Mech 6's 100 × slow_ema × isv upper_bound during F1 ramp-up; resetting it loosened the bound during the very transient when F1 needed it tightest, accelerating Adam saturation. Removed GpuDqnTrainer::reset_grad_norm_slow_ema method and the fused_training.rs::reset_for_fold call site; kept the grad_norm_slow_ema_pinned field (still consumed by Mech 6) and Mech 6 multiplier at 100× (correct steady-state value, already restored from the 5× experiment in the Mech 8 commit). Mech 9 is the real fix for the cross-fold Adam pathology — root cause being targeted is cuBLAS sgemm f32-accumulator overflow at slots 26 (iqn_trunk_m) + 32 (bn_d_concat_buf) at F1 step ~3540 with INPUTS clean (slots 27, 35 unflagged). The matmul output saturates because WEIGHT tensors have drifted to extreme-but-finite magnitudes via Adam updates over thousands of steps. Mech 1 (gradient clamp) and Mech 6 (grad-norm clip) bound gradients, not parameters; neither prevents this drift. Mech 9 clamps |p_val| ≤ 100 × Q_ABS_REF.max(1.0) inside dqn_adam_update_kernel after the L1 proximal step (new trailing arg weight_clamp_max_abs on the kernel signature). ISV-driven: bound reads Q_ABS_REF_INDEX = 16 host-side (same slot consumed by Mechs 1+2+5+6 — no new slot), ε on the multiplier per the SP1 pearl (isv.max(1.0), never floor the bound itself or re-introduce the cold-start clamp pathology), 1 OOM below the slot 44-45 diagnostic threshold (1e3 × Q_ABS_REF) so the regression sentinel retains 10× firing headroom — symmetric with Mech 1's clamp/diagnostic ratio. All four Adam launch sites in gpu_dqn_trainer.rs wired with the bound (launch_adam_update at ~line 19470, step_selectivity_adam at ~line 20860, denoise-head Adam at ~line 17014, OFI-embed Adam at ~line 5758); the Decision Transformer Adam launch in decision_transformer.rs passes 0.0 to disable the clamp (DT is a separate offline-RL trainer outside SP3 scope, no ISV bus access; kernel branch if (bound > 0.0f) makes the disabled path zero-cost). No new ISV slots, no new kernels, no graph topology change. Touched: cuda_pipeline/dqn_utility_kernels.cu (kernel signature + clamp), cuda_pipeline/gpu_dqn_trainer.rs (4 launch sites + Mech 6 comment update + reset_grad_norm_slow_ema method removed), cuda_pipeline/decision_transformer.rs (DT launch site, disabled), trainers/dqn/fused_training.rs (Mech 8 call removed), docs/dqn-backward-nan-audit.md (mechanism table + Mech 8/9 subsection). cargo check clean. No fingerprint change — kernel signature change but param-tensor layout unchanged.
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.
Q-drift production-safety kill criterion (2026-04-28): The
train-multi-seed-p526h repro (5ep×3fold, MSE-clamp + PopArt-carry
landed) reproduced a geometric Q-divergence in fold 1 that we can no
longer mistake for a measurement artefact. Trajectory:
F0 ep5 Q=+0.79 → F1 ep1 Q=+0.82 (boundary handoff fine) → F1 ep2 Q=+2.23
(2.7× jump) → F1 ep3 +4.05 → F1 ep4 +10.66 → F1 ep5 NaN at step 5.
The MSE clamp (299981f9b) keeps loss reading honest at ~1.0-1.4
throughout this divergence, so the train_loss is no longer hiding the
real Q-drift. Production stake: a model with this Q dynamic is
catastrophically unsafe for live trading — Kelly cap floats with
Q-confidence, so runaway Q drives oversized positions and can blow up
the strategy before any downstream safety trips. Per the user's
"can't happen at production" philosophy, this commit installs a
production safety net (not a root-cause fix) that hard-halts
training the moment Q-drift is detected. Implementation in
training_loop.rs immediately before the existing
self.prev_epoch_q_mean = q_mean update: if (a) |q_mean| has grown
more than 2× since previous epoch AND (b) |q_mean| > 1.5 (production-
unsafe absolute floor), return Err — training halts, model is
rejected from deployment. The 2× ratio catches genuine geometric
divergence (typical healthy growth <30%/epoch); the 1.5 absolute
floor prevents false positives in early training where small Q
magnitudes oscillate by large ratios (verified on the data: F0
ep4→ep5 0.15→0.79 ratio 5.3× would not trigger because |0.79|<1.5).
Both thresholds are numerical-stability bounds (Invariant 1 carve-
out), not tuned hyperparameters. Skips on the very first epoch (no
prev_q to compare); does NOT skip on fold-boundary epochs — those
are exactly the failures we're catching. This is a safety net, not
the fix. A separate reset-gap audit (the actual root cause hunt) is
queued to identify which fold-boundary state isn't being reset
correctly. Suspect list per the inspection so far: prev_grad_buf
(gradient vaccine reference), PopArt GPU Welford buffers
(popart_mean/var/count — popart_count is huge from fold 0 making
new-fold reward stats track too slowly), isv_q_abs_ref_* magnitude
EMAs, spectral norm σ EMAs, TLOB Adam state. Touched: training_loop.rs
(+30 LOC). cargo check clean at 13 warnings (workspace baseline). No
fingerprint change.
Q-drift kill threshold made adaptive (2026-04-28, follow-up to commit
1c917e3cb): the absolute floor in the kill criterion (curr_abs > 1.5)
was a tuned constant in violation of feedback_adaptive_not_tuned.md and
feedback_isv_for_adaptive_bounds.md. Replaced with kill_floor = max(0.5, 3.0 × max(ISV[Q_ABS_REF_INDEX=16], ISV[Q_DIR_ABS_REF_INDEX=21])). Both ISV
slots are per-branch EMAs of max(|Q_mean|) already maintained on-GPU by
q_stats_kernel.cu and consumed by c51_loss_kernel/c51_grad_kernel
for collapse-fraction normalisation; the kill criterion now anchors on
the same recently-observed healthy Q scale. The 3.0× multiplier is
architectural ("drift starts at ~2-4× healthy"), the 0.5 cold-start floor
is an Invariant-1 numerical-stability bound (active only while both ISV
slots are still ≤ 1e-6 in fold-0 epoch-1, then dominated by the ISV
formula). The 2× ratio gate is unchanged — it's already an architectural
rate-of-change bound. ISV reads use the existing pinned/device-mapped
path via fused.trainer().read_isv_signal_at(...) — no HtoD/DtoH per
feedback_no_htod_htoh_only_mapped_pinned.md. Touched: training_loop.rs
(+~70 LOC, two new use-list imports). cargo check clean at 13 warnings.
No fingerprint change.
IQN fold-boundary sync GPU regression test (2026-04-28, follow-up to
commit 7c19b5903, resolves issue #84): added
iqn_sync_target_from_online_makes_target_equal_online in
cuda_pipeline/gpu_iqn_head.rs::tests. The test fills
online_params ← 0.42 and target_params ← 0.99 via mapped-pinned
staging + cuMemcpyDtoDAsync (no HtoD per
feedback_no_htod_htoh_only_mapped_pinned.md), sanity-checks the
buffers differ, calls sync_target_from_online, synchronises the
stream, reads both buffers back through fresh MappedF32Buffer
allocations, and asserts bit-for-bit equality across all
total_params slots (using f32::to_bits so any future NaN-bearing
implementation also fails loud). The witnesses 0.42 / 0.99 are
arbitrary distinct fp32 constants, not tuned values — the contract
asserted is equality of the buffers, independent of magnitude. Carries
#[ignore = "gpu"] and runs on the L40S smoke validation pool;
cargo test -p ml --lib on a CPU-only host skips it. Replaces the
earlier static-source include_str! guard which only caught literal
deletion of the call line — a stub body returning Ok(()), a copy
against the wrong buffer / wrong direction, or a queue against the
wrong stream all silently passed the static guard but now fail the
runtime equality assertion. Buffer access is exposed through three
new #[cfg(test)] pub(crate) accessors on GpuIqnHead
(online_params_slice, target_params_slice, total_params_for_test,
stream_for_test) so the public API is not widened. Paired with a
strengthened doc-block at the call site in fused_training.rs (boxed
DO NOT DELETE warning + reference to the new test name and issue
#84) so anyone touching the line sees the regression context inline
before deleting. Touched: gpu_iqn_head.rs (4 cfg(test) accessors +
new tests mod with helpers + the runtime test, ~+200 LOC),
fused_training.rs (boxed comment expansion, ~+15 LOC, no behaviour
change). cargo check clean at 13 warnings (workspace baseline). No
fingerprint change.
Fold-boundary reset gap — IQN readiness gauge (2026-04-28, Fix 3 of
3): the IQN readiness gauge on GpuDqnTrainer is a streaming
improvement fraction iqn_readiness = (iqn_loss_initial - iqn_loss_ema) / iqn_loss_initial (gpu_dqn_trainer.rs:5805-5818).
The iqn_loss_initial anchor is captured once on the first
update_iqn_readiness call where it is still ~0 and never resets,
so for every fold beyond fold 0 the gauge is computed against the
fold-0 epoch-1 IQN loss — a stale anchor under the fold-N+1
post-S&P + fresh online↔target alignment + adversarial regime
shift. The pinned device-mapped readiness slot is read by
c51_loss_kernel as the CVaR α and by IQN gradient-weight gating,
so a stale anchor either pins readiness near 1 (over-confident,
full IQN gradient weight on a still-recovering head) or near 0
(under-weighting a converged head). Both modes contribute to the
fold-1 Q-drift trajectory documented in Fix 1's audit entry.
Fix: add pub fn reset_iqn_readiness_state to GpuDqnTrainer
(zeroes iqn_loss_initial, iqn_loss_ema, iqn_readiness scalar,
and writes 0.0 through the device-mapped pinned host slot — no HtoD
copy issued, mapping propagates the host write directly to GPU).
Add pub(crate) fn reset_iqn_readiness_state wrapper on
FusedTrainingCtx mirroring the existing reset_eval_v_range_state
pattern. Add three new FoldReset registry entries
(isv_iqn_loss_initial, isv_iqn_loss_ema, isv_iqn_readiness)
that all dispatch to the same helper through one match arm in
reset_named_state (training_loop.rs) — the three names are
listed separately so future tasks can migrate them independently
but currently form a single logical reset. Per
feedback_no_partial_refactor.md — same fold-boundary contract,
all three coupled scalars migrate together. The bootstrap branch
of update_iqn_readiness (iqn_loss_initial < 1e-12) recaptures
the new fold's first batch as the anchor on the first call after
this reset, by design.
Touched: gpu_dqn_trainer.rs (new reset_iqn_readiness_state after
the existing reset_eval_v_range_state), fused_training.rs (new
wrapper after the existing reset_eval_v_range_state wrapper),
state_reset_registry.rs (three new FoldReset entries), trainer/ training_loop.rs (one new dispatch arm covering all three names).
cargo check -p ml --lib clean at 13 warnings (workspace baseline).
cargo test -p ml --lib --no-run clean. The three existing
state_reset_registry::tests (registry_classifies_known_state,
registry_fold_reset_iterates_only_fold_reset_entries,
registry_unknown_name_returns_none) all pass without modification.
This commit completes the 3-fix sequence (Fix 1 IQN target sync,
Fix 2 aux Adam states, Fix 3 IQN readiness) closing the
fold-boundary state-migration contract gap surfaced by the audit.
Bug signature resolved (geometric Q-drift through fold 1):
F0 ep5 +0.79 → F1 ep1 +0.82 → ep2 +2.23 → ep3 +4.05 → ep4 +10.66 → ep5 NaN @ step 5 (fp32 overflow past atom support). Fix 1 is
load-bearing for the geometric drift itself; Fix 2 prevents
fold-N+1 first-epoch Adam-step overshoot in seven aux optimizers;
Fix 3 closes the gauge-staleness path that gated IQN gradient
weight against a fold-0 anchor. L40S validation deferred to
post-merge.
Fold-boundary reset gap — auxiliary Adam state resets (2026-04-28,
Fix 2 of 3): the same fold-boundary contract that resets the main
DQN's m_buf / v_buf / adam_step (gpu_dqn_trainer.rs:3428) was
never extended to the seven auxiliary optimizers added later — five
co-located inside GpuDqnTrainer (q_attn at lines 1961-1962, sel
at 1969-1970, denoise at 3061-3063, mamba2 at 3175-3176, ofi_embed
at 3347-3349) plus two on separate types (GpuTlob adam_m/v/step
at gpu_tlob.rs:143-145, GpuIqlTrainer m_buf/v_buf/adam_step at
gpu_iql_trainer.rs:215-216,253). Without the matching reset each of
these enters fold N+1 with fold N's momentum, producing oversized
Adam steps in the first epochs whose effect compounds through the
corresponding backward pass — Q-attention into the trunk, IQL into
the per-sample-support / advantage-weight stream that C51 + IQN
consume, TLOB into the QKV/output projections feeding trunk
gradient. Fold-1 grad-explosion symptoms are dominated by IQN
target lag (Fix 1) but the aux Adam states are on the same shared
fold-boundary contract (feedback_no_partial_refactor.md) and
must migrate together. Fix: extend
GpuDqnTrainer::reset_adam_state with memset_zeros of the five
aux (m, v) buffer pairs and step counter resets; add
pub(crate) fn reset_adam_state to GpuTlob (memsets adam_m/v,
zeroes adam_step + t_pinned via the device-mapped page) and
pub fn reset_adam_state to GpuIqlTrainer (same pattern, m_buf/
v_buf/adam_step/t_pinned). Wire all three from
FusedTrainingCtx::reset_for_fold immediately after the existing
IQN reset block — both gpu_iql and gpu_iql_low call the new
helper. All three new helpers strictly mirror the main DQN's
reset_adam_state: DtoD memset-zero only, pinned host counter
written through the device-mapped page (no HtoD/HtoH/DtoH).
Touched: gpu_dqn_trainer.rs (reset_adam_state extended with five
aux blocks, no signature change), gpu_tlob.rs (new
pub(crate) fn reset_adam_state after adam_step), gpu_iql_trainer.rs
(new pub fn reset_adam_state before the adam_step() getter),
fused_training.rs (three new invocations after the existing IQN
Adam reset block in reset_for_fold). cargo check -p ml --lib clean
at 13 warnings (workspace baseline). cargo test -p ml --lib --no-run
clean. No fingerprint change. No new HtoD/HtoH/DtoH paths — only
DtoD memsets and existing pinned-write patterns.
Fold-boundary reset gap — IQN target hard-sync (2026-04-28, Fix 1 of 3):
the main DQN's sync_target_from_online (DtoD online_params → target_params at gpu_dqn_trainer.rs:12495) is wired into
FusedTrainingCtx::reset_for_fold (fused_training.rs:887) explicitly
to prevent fold-1 grad explosion. The IQN head, added later, only
exposed target_ema_update (Polyak τ=0.005/step) — no hard-sync. After
fold boundary the IQN online side advances through fold N+1's first
gradient steps while the IQN target buffer still holds end-of-prior-
fold weights, so the Bellman TD error gap widens by a factor that
Polyak takes hundreds of steps to close. Empirically that drives a
geometric ~2.3×/epoch Q-drift inside fold 1
(F0 ep5 Q=+0.79 → F1 ep1 +0.82 → ep2 +2.23 → ep3 +4.05 → ep4 +10.66 → ep5 NaN @ step 5 on fp32 overflow past atom support). The boundary
handoff at F1 ep1 looks fine because the c51_alpha warmup ramp blends
IQN low; once c51_alpha ramps up the IQN online↔target gap dominates.
Fix: add GpuIqnHead::sync_target_from_online (DtoD copy mirroring
the main DQN's helper exactly — same memcpy_dtod_async pattern, same
stream, same total_params) and invoke it from
FusedTrainingCtx::reset_for_fold immediately before the existing IQN
Adam reset. Per feedback_no_partial_refactor.md: the
fold-boundary state-migration contract has multiple consumers (main
DQN trunk, IQN head, aux Adam states, IQN readiness EMAs) and
extending it must migrate every consumer in lockstep. The IQN target
sync is the primary root cause; the aux Adam state resets and IQN
readiness reset land in the next two commits.
Touched: gpu_iqn_head.rs (new sync_target_from_online method
between reset_adam_state and target_ema_update), fused_training.rs
(invoke the new sync inside the existing if let Some(iqn) = … block
in reset_for_fold, immediately before iqn.reset_adam_state()).
cargo check -p ml --lib clean at 13 warnings (workspace baseline). No
fingerprint change. DtoD only — no HtoD/HtoH/DtoH paths introduced.
MSE warmup loss target clamp to C51 atom support (2026-04-28): the
fold-boundary PopArt carry-forward + iqr floor (d710f9d50) reduced
fold-1 epoch-1 train loss readings 16× (318k → 19k) but ep-2/ep-3
oscillated between 10k-200k while val Sharpe stayed healthy
(76 — confirming the model itself is fine and the inflated train_loss
is a measurement artefact). Root-caused via mse_loss_kernel.cu:457:
the Bellman target target_q = reward + γ·(1-done)·target_eq is not
clamped to the C51 atom support [v_min, v_max], whereas online_eq
is naturally bounded (it is an expected value of softmax probabilities
over atoms in that support). When PopArt-normalised reward lands
outside support — most acutely at fold boundaries when cached robust
stats lag the new fold's distribution by one epoch — td = online_eq − target_q grows to O(10³-10⁴) and the MSE reading
0.5·td² blows up to 10⁵-10⁶ per-sample, dwarfing the (correctly
bounded) C51 distributional loss in the blended warmup total
(1-α)·MSE + α·C51. The C51 path already handles this in
c51_loss_kernel.cu:247 (t_z = fminf(fmaxf(t_z, v_min), v_max))
because the categorical projection requires it; MSE was missing the
matching clamp. Fix: one-line target_q = fminf(fmaxf(target_q, v_min), v_max) in mse_loss_kernel.cu between the ensemble-
disagreement adjustment and the td = online_eq − target_q compute.
Mirrors C51's clamp exactly. No information lost — anything outside
[v_min, v_max] is unrepresentable in the categorical distribution
anyway, so MSE warmup tracking targets that the network categorically
cannot learn produces a meaningless reading. With the clamp, train
loss = blended (1-α)·MSE + α·C51 stays bounded by the support range
× num_atoms ≈ O(num_atoms), matching healthy fold-0 readings of ~3.
Per pearl_blend_formulas_must_have_permanent_floor.md (numerical-
stability bound carve-out under Invariant 1) and the existing C51
v_range adaptive-per-fold infrastructure (eval_v_range EMAs).
Touched: mse_loss_kernel.cu (one new line + 14-line explanatory
comment). cargo check clean at 13 warnings (workspace baseline). No
fingerprint change. Resolves the train/val disconnect surfaced by
runs train-multi-seed-72fl6, lgb8n, kdkdv, xt76v. Diagnostic
infrastructure from 756b1ef31 + 32e5375ac retained as catch-net
for any genuine NaN regression.
Fold-boundary PopArt carry-forward + iqr permanent floor (2026-04-28):
the L40S 15-epoch repro #2 (train-multi-seed-kdkdv, commit
32e5375ac) showed a massive train/val disconnect in fold 1:
mean_loss climbed from 3.1 (fold 0 healthy) to 318,898 → 590,136 →
694,323 across fold-1 epochs while val Sharpe stayed healthy and
climbing 73 → 114. The 10⁵× loss readings turned out to be a
fold-boundary PopArt reset bug, not a real instability. Mechanism
traced via the diagnostic data (the same diagnostic infrastructure
landed in 756b1ef31 + 32e5375ac worked perfectly to surface the
real signal): reset_for_fold zeroed cached_iqr / cached_median
at every fold boundary, forcing fold N+1's first epoch to fall
through the cached_iqr > 0.0 guard at fused_training.rs:1220 to
the Welford GPU path; combined with the Welford running stats
inherited from fold N — and fold N+1's slightly different reward
distribution post-S&P + adversarial — the normalised rewards landed
far outside the C51 atom support [-50, 50], producing astronomical
categorical loss readings. The val path doesn't use PopArt so val
Sharpe was unaffected. On rare timing-sensitive paths the
near-zero-divide overflowed to Inf → NaN (the run-1 flagged=[2,3, 6,7,8] diagnostic firing). Fix A: stop resetting
cached_iqr / cached_median at fold boundary in
FusedTrainingCtx::reset_for_fold (fused_training.rs:919) — carry
fold N's final-epoch median/IQR forward as fold N+1's epoch-1
default; same instrument, similar reward distribution between
adjacent walk-forward folds, so the carry is safe and gets replaced
by fresh stats at the end of fold N+1's epoch 1. prev_popart_var
still resets (its sole consumer is tau-change detection — a fresh
fold counts as a "change point" anyway). Fix B: raise the iqr
permanent floor in popart_normalize_robust
(dqn_utility_kernels.cu:1657) from 1e-6 → 1e-4. The previous
1e-6 was insufficient defence: a trade-exit reward of 5.0 with
iqr=1e-6 normalises to 5e6, vs the post-fix 5e4 (still very large
but much harder to hit fp32 overflow). Per
pearl_blend_formulas_must_have_permanent_floor.md and
feedback_isv_for_adaptive_bounds.md (numerical-stability bound
carve-out under Invariant 1). Touched files: fused_training.rs
(remove 2 of 3 lines from the reset_for_fold PopArt block, expand
docstring); dqn_utility_kernels.cu (raise the iqr fmaxf floor,
expand kernel comment). cargo check clean at 13 warnings (workspace
baseline). No fingerprint change. Resolves task #84 ("Fold-boundary
state reset gap causes fold 1 grad explosion"). Diagnostic kernels
from 756b1ef31 + 32e5375ac remain in place to catch any future
NaN paths through the loss-component buffers.
NaN diagnostic flag 12 = save_h_s2 (2026-04-28): the previous wire-up
commit (756b1ef31) extended NaN coverage to flags 0-11 and proved that
the L40S fold-1 NaN entered via flagged=[2=on_b_logits, 3=mse_loss_scalar, 6=grad_buf, 7=save_current_lp, 8=save_projected] while params_buf_pre_fwd
(flag 4) and on_v_logits (flag 1) stayed clean. That isolates the
corruption to the branch advantage GEMM/activation (the value stream is
fine despite sharing the same trunk input). To complete the differential
diagnosis we add flag 12 = save_h_s2 — the post-trunk activation that
is the SHARED input to both the value FC and the branch FC. Two
outcomes:
- flag 12 clean + flag 2 NaN → branch GEMM itself overflowed (likely fp32-overflow under post-S&P + adversarial reward stress in the advantage logits accumulation). Fix is at the branch forward call site / advantage scale clamp.
- flag 12 NaN → trunk encoder corrupted upstream of both heads (GRN block produced NaN). Trace further into the GRN backward chain or the OFI/state input.
run_nan_checks_post_forward in gpu_dqn_trainer.rs adds one
check_nan_f32 call against self.save_h_s2.raw_ptr() with size
batch_size * config.shared_h2, flag index 12. Same ~5µs cost as the
other 12 checks already wired. training_loop.rs names array slot 12
updated "rsv12" → "save_h_s2". Slots 13-15 still reserved (CQL,
IQN, per-branch backward). Touched files: gpu_dqn_trainer.rs
(+1 line in flag map docstring, +5 lines in
run_nan_checks_post_forward); training_loop.rs (1 line in names
array). cargo check clean at 13 warnings (workspace baseline). No
fingerprint change.
P5T5 Phase H Site 3 — fuse VSN feature-selection + GLU value/gate GEMM+bias (2026-04-28): migrates the remaining 4 forward sites:
- VSN Linear_1 (×6 feature groups) — adds 6 per-group RELU_BIAS
shapes
(VSN_HIDDEN, B, group_dim, state_dim_padded)to the relu_bias cache (group_dim∈ {42, 32, 16, 16, 8, 7} fromFEATURE_GROUP_RANGES); migratesvsn_forwardStep 1+2 fromsgemm_f32_ldb + launch_add_bias_relu_f32_rawtosgemm_f32_fused_relu_bias. Post-ReLU h1_ptr remains the same contract for 1B-iv backward (relu_mask_standalone gating signal). - VSN Linear_2 (×6 feature groups, all share shape
(1, B, VSN_HIDDEN, VSN_HIDDEN)) — migratesvsn_forwardStep 3+4 tosgemm_f32_fused_bias. - GLU value head (
launch_vsn_glu_branchStep 3) and - GLU gate (Step 4) — both migrated to
sgemm_f32_fused_bias, workspace selected per-stream (per-branch workspace when running on a branch stream, default workspace otherwise). Also fixes a partial-refactor leak inforward_online_raw— the sequential branch fallback (whendistinct_branches=false) was still on the unfusedsgemm_f32 + launch_add_bias_relu_f32_rawpair while its multi-stream sibling already usedsgemm_f32_fused_relu_bias. Now both code paths take the fused-first contract. Touched:crates/ml/src/cuda_pipeline/batched_forward.rs. cargo check clean at 13 warnings;cargo test --no-runclean. Layout fingerprint unchanged (no params buffer changes).
P5T5 Phase H Site 3 — fuse branch FC adv_logits GEMM+bias into BIAS
epilogue (2026-04-28): migrates 6 sites — decoder_forward_only (1
site, sequential), forward_online_raw (2 sites: multi-stream branch
- sequential fallback),
forward_online_f32(2 sites: multi-stream + sequential),forward_target_raw(1 multi-stream site) — fromsgemm_f32(_branch) + launch_add_bias_f32_rawpairs tosgemm_f32_fused_bias. The branch FC output projects [B, adv_h] → [B, branch_size_d * num_atoms] (4 distinct shapes, all pre-cached). Multi-stream sites use the per-branch workspace (branch_workspace_ptrs[d]) to avoid contention with concurrent branch streams. Drops up to 4add_bias_f32launches per training step (one per branch direction) on each of {online, target, f32-collector} forwards. Touched:crates/ml/src/cuda_pipeline/batched_forward.rs. cargo check clean at 13 warnings;cargo test --no-runclean.
P5T5 Phase H Site 3 — fuse value-head v_logits GEMM+bias into BIAS
epilogue (2026-04-28): migrates 4 sites in forward_online_raw,
forward_online_f32, forward_value_head (ensemble heads), and
forward_target_raw from sgemm_f32 + launch_add_bias_f32_raw pairs
to the new sgemm_f32_fused_bias helper. The value-head output
projects [B, value_h] → [B, num_atoms] (C51 atom logits), no ReLU
on output. Drops 4 add_bias_f32 kernel launches per training step
(1 online + 1 target + ensemble heads + 1 collector-side f32 path).
Touched: crates/ml/src/cuda_pipeline/batched_forward.rs. cargo check
clean at 13 warnings; cargo test --no-run clean.
P5T5 Phase H Site 3 — fuse trunk encoder GEMM+bias into BIAS
epilogue (2026-04-28): migrates 8 sites in encoder_forward_only
(online) and target_encoder_forward_only (target) from
sgemm_f32 + launch_add_bias_f32_raw pairs to the new
sgemm_f32_fused_bias helper. Covers all four GRN Linear layers per
encoder: h_s1 Linear_a, h_s1 Linear_b, h_s2 Linear_a, h_s2 Linear_b.
The Linear_residual projection has no bias term and stays a plain
sgemm_f32_ldb. Fall-back to the prior pair preserved on
shape-not-cached. Drops 8 standalone add_bias_f32 kernel launches +
8 memory round-trips per (online + target) forward step. Touched:
crates/ml/src/cuda_pipeline/batched_forward.rs. cargo check clean
at 13 warnings; cargo test --no-run clean.
P5T5 Phase H Site 3 — BIAS epilogue infrastructure (2026-04-28):
adds the gemm_cache_bias field on CublasGemmSet, a
create_cached_fwd_gemm_desc_bias builder mirroring the existing
_relu_bias variant, and a sgemm_f32_fused_bias helper that fuses
GEMM + bias-add into one cublasLtMatmul call (CUBLASLT_EPILOGUE_BIAS).
Pre-creates descriptors at CublasGemmSet::new for every BIAS-only
output shape used in the forward pass: VSN linear2 (1,B,VSN_HIDDEN, VSN_HIDDEN); GRN h_s1/h_s2 Linear_a/Linear_b across the trunk; the
value-head v_logits (num_atoms,B,value_h,value_h); the four
adv_logits shapes (branch_size_d * num_atoms, B, adv_h, adv_h);
and the GLU/KAN value+gate shapes (adv_h, B, K, K) for K ∈ {sh2,
sh2+branch_0, sh2+3}. Mirrors the established gemm_cache_relu_bias
infrastructure — same descriptor creation pattern (deterministic
algo selection via cublas_algo_deterministic), same per-call bias-
pointer wiring via set_matmul_desc_attribute, same fall-back
contract on missing-cache (Err → caller routes to slow path).
Sites are not yet migrated in this commit (this is just the
infrastructure); subsequent commits flip individual call sites from
sgemm_f32 + launch_add_bias_f32_raw pairs to
sgemm_f32_fused_bias. Touched: crates/ml/src/cuda_pipeline/ batched_forward.rs (+136 LOC: field, init block, helper fn, builder
fn). cargo check clean at 13 warnings (workspace baseline);
cargo test --no-run clean at 24 warnings.
NaN diagnostic wire-up + label fix (2026-04-28): fold 1 of
train-multi-seed-72fl6 hit NaN at step 5 with flagged=[] because the
8 NaN-check kernels in run_nan_checks_pre_forward /
run_nan_checks_post_forward (gpu_dqn_trainer.rs:14250+, 14278+) were
allocated but never invoked from production training — orphan
diagnostic infrastructure. The nan_flags_buf always read back as
zeros while host-side pinned total_loss confirmed NaN. Compounding
this, the error message at training_loop.rs:1853 referenced
bf16_params (dead code from before the bf16→TF32 switch) and the
format-string indices did not match the actual kernel index→buffer
mapping. Fix: wire both pre/post NaN checks into
submit_post_aux_ops (captured in the post_aux child graph, runs
every step inside the parent cuGraphLaunch); flags persist across
steps within a fold so the FIRST buffer to go NaN remains visible at
host-readback time; reset is host-side at fold boundary in
reset_for_fold. Coverage extended from 8 → 12 active slots (16
allocated total): added flag 8 = save_projected (C51 target
distribution — categorical projection of TD targets, NaN-prone under
adversarial reward stress), flag 9 = moe_gate_softmax (gate softmax
saturation under post-S&P weights), flag 10 = aux_nb_loss_scalar,
flag 11 = aux_rg_loss_scalar. Slots 12-15 reserved (CQL / IQN /
per-branch backward). read_nan_flags() return type grown
[i32; 8] → [i32; 16] in both GpuDqnTrainer and
FusedTrainingCtx. run_nan_checks_pre_forward no longer resets
flags (caller's responsibility — fold-boundary host call). Updated
training_loop.rs error message: 16 named slots matching actual
kernel layout, per-flag indexed name in flagged list (e.g.
[8=save_projected]), dropped all stale bf16_params / _bf16
suffixes, explicit hint when flagged=[] (NaN entered via
still-uncovered buffer; expand coverage). Cost per step: ~12
single-block NaN-check reductions × ~5µs = ~60µs (<0.1% of the
258ms/step measured on L40S). Honours feedback_no_legacy_aliases.md
(drop bf16_params), feedback_no_stubs.md (orphan infrastructure
either wired or deleted — wiring chosen), and
feedback_trust_code_not_docs.md (label/comment was stale; verified
against actual kernel ordering). Touched: gpu_dqn_trainer.rs
(nan_flags_buf 8→16, read_nan_flags return type, no-reset
pre-forward, +4 post-forward checks, submit_post_aux_ops adds both
NaN checks); fused_training.rs (return type, fold-boundary
reset_nan_flags() call); training_loop.rs (error message
overhaul). cargo check clean at 13 warnings (workspace baseline).
TLOB cuBLAS-Lt → classic cublasSgemm_v2 migration (2026-04-28):
followup to the symbol-rename fix below. The cuBLAS-Lt heuristic
cublasLtMatmulAlgoGetHeuristic returns "no algo found" for TLOB's
tiny attention shapes (M=TLOB_OUT=16, K∈{16,32}, N=batch) —
those dims are below cuBLAS-Lt's tensor-core-oriented sweet spot,
so the heuristic refuses to dispatch. This is documented NVIDIA
behaviour and the API-choice fix is to use classic cuBLAS sgemm
instead: cublasSgemmEx / cublasSgemm_v2 is for general-purpose
any-shape GEMMs; cuBLAS-Lt is for large/tensor-core-optimised GEMMs
at scale. All 8 TLOB GEMM call sites (3× fwd Q/K/V, 1× fwd O, 3× bwd
dW_QKV, 1× bwd dW_O, 1× bwd dX_O) now dispatch through a thin
sgemm_v2 helper on a classic cuBLAS handle. PerStreamCublasHandles
(the project's per-stream handle registry) gained a classic_handle
field + classic_for(stream) accessor mirroring the existing
lt_handle / lt_for(stream) API; both handles share the same 32 MB
workspace. Default-stream classic handle is created in
PerStreamCublasHandles::new; side-stream handles are provisioned
lazily alongside their cuBLAS-Lt sibling. Classic handle defaults to
CUBLAS_TF32_TENSOR_OP_MATH (matching gpu_curiosity_trainer's
existing convention). Removed the graceful-degrade in
fused_training.rs:610 — GpuTlob::new now MUST succeed (Result<_>
propagates to the trainer constructor). Same for the val-side
construction in trainer/metrics.rs:594. New unit test
gpu_tlob::tests::tlob_sgemm_parity_with_cpu_reference verifies
forward + backward GEMM math against a CPU reference (cpu_sgemm,
column-major BLAS semantics) within 2e-3 absolute. The cuBLAS-Lt
path remains for the larger trunk attention in gpu_attention.rs
(dims ≥ 128) where tensor-core throughput pays off — this is the
standard split, not a workaround. Touched files: gpu_tlob.rs (~440
LOC delta — descriptors + heuristic search + lt_matmul removed,
classic sgemm calls inline + sgemm_v2 helper + tests added),
shared_cublas_handle.rs (+90 LOC — classic_handle,
classic_for, bind_classic_handle, create_handles_and_workspace),
fused_training.rs:610 and trainer/metrics.rs:594 (graceful-degrade
removed). Pearl candidate
(pearl_cublas_lt_vs_classic_sgemm.md): "use cuBLAS-Lt for
tensor-core-optimised GEMMs at scale (M,K,N ≥ 64); use classic
cuBLAS sgemm for general-purpose any-shape GEMMs. Lt heuristic
search refuses tiny shapes — that's by design, not a workaround."
TLOB symbol-rename fix (2026-04-28): gpu_tlob.rs:232 was loading
attn_adam_update from attention_backward_kernel.cubin, but the
kernel is defined as attn_adam_kernel
(attention_backward_kernel.cu:70); gpu_attention.rs:281 already
uses the correct symbol. Mismatch caused GpuTlob::new to fail with
"named symbol not found" on every workflow init, suppressed by the
"training continues without TLOB" graceful-degrade warning. Per
feedback_no_hiding.md, graceful-degraded modules ARE the ghost-feature
pattern; the warning was hiding a real wire-up bug. Renamed the symbol
to match. With the symbol now loading, a separate cuBLAS-Lt heuristic
failure surfaced (tlob heuristic (fwd_q): no algo found) — resolved
by the migration above (cuBLAS-Lt → classic cublasSgemm_v2).
adaptive MoE load-balance λ controller (2026-04-27): replaces static
moe_lambda=0.01 with a GPU-driven controller that scales λ inversely
with observed gate-entropy. New kernel
crates/ml/src/cuda_pipeline/moe_lambda_eff_kernel.cu (single-block
single-thread cold-path matching kelly_cap_update_kernel.cu precedent;
own cubin per build.rs registration) reads ISV[126]
(MOE_GATE_ENTROPY_EMA from moe_expert_util_ema_update, Phase 2 T2.4
producer) and writes ISV[128] (MOE_LAMBDA_EFF_INDEX, new). Formula:
λ_eff = floor + max_extra × clamp((target − ent_ema)/target, 0, 1),
target = entropy_target_frac × ln(K). floor is a permanent minimum
(per pearl_blend_formulas_must_have_permanent_floor.md) so the
controller never weakens below the legacy 0.01 baseline. Consumer
moe_load_balance_loss (in moe_kernels.cu) signature changed: dropped
float lambda arg, added const float* isv_signals +
int isv_lambda_eff_idx — kernel reads λ from ISV at runtime, no DtoH
per feedback_isv_for_adaptive_bounds.md. Wiring (per
feedback_no_partial_refactor.md): launch order in
training_loop.rs per-step block runs launch_moe_expert_util_ema
(ISV[126] producer, fresh entropy EMA) → launch_moe_lambda_eff_update
(ISV[128] producer, fresh λ_eff for next step's load-balance consumer);
HEALTH_DIAG aux_moe line gains λ_eff field for observability;
state-reset registry entry isv_moe_lambda_eff resets ISV[128] to
config.moe_lambda_floor at fold boundary; constructor bootstraps
ISV[128]=floor + ISV[118..127)=uniform 1/K + ISV[126]=ln(K) so cold-start
matches (deficit=0 ⇒ λ_eff=floor — legacy behaviour byte-for-byte).
Config: pub moe_lambda field replaced by three knobs
(moe_lambda_floor default 0.01, moe_lambda_max_extra default 0.09,
moe_entropy_target_frac default 0.7) on both
GpuDqnTrainConfig and DQNHyperparameters; fused_training.rs:418
migrated to read all three. ISV_TOTAL_DIM bumped 127 → 129 (slot 127
reserved gap to keep slot 128 cleanly aligned), layout fingerprint seed
updated (schema_hash bumps; PVC fxcache regenerates on next deploy).
Unit test moe_lambda_eff_update_writes_correct_values verifies all 5
regimes (above-target / at-target / half-collapse / full-collapse /
deeply-above-target) on the GPU kernel, all pass within 1e-5. Existing
moe_load_balance_loss_correctness + moe_load_balance_loss_uniform_minimum
tests pass via test-shim ISV[0] = λ. Smoke
magnitude_distribution reproduces pre-existing eval-Full=0 (Kelly cap
behaviour — see project_magnitude_eval_collapse_kelly_capped.md,
unrelated to this commit; verified by stash-test against HEAD same
numbers eq=0.586/0.592). HEALTH_DIAG fold 3 confirms controller
behaviour live: ent decays 1.381 → 0.746 across epochs 7-19, λ_eff rises
0.0146 → 0.0539 monotonically — exactly the entropy-deficit response
the spec calls for. Files: new
crates/ml/src/cuda_pipeline/moe_lambda_eff_kernel.cu, modified
crates/ml/build.rs (cubin registration), gpu_moe_head.rs (new cubin
load + launch_lambda_eff_update + test_lambda_eff_update + load_balance
signature migration), gpu_dqn_trainer.rs (ISV slot const, layout
fingerprint, config field replacement, struct field replacement,
launch_moe_lambda_eff_update, constructor bootstrap),
moe_kernels.cu (load_balance signature),
crates/ml/src/trainers/dqn/config.rs (3 hyperparam knobs),
fused_training.rs (3 unwrap-or migrations),
training_loop.rs (per-step launch + HEALTH_DIAG λ_eff + fold-reset
dispatch), state_reset_registry.rs (entry), tests/moe_kernels_test.rs
(unit test).
magnitude conviction threaded into Kelly cap (2026-04-27, follow-up): the
var_scale floor (above) addressed the training path. Smoke at the same
horizon then reproduced eval Full=0.000 with intent=0.911 because
backtest_env_step_batch doesn't apply var_scale (no Var[Q] shrink in val).
The remaining silencer is unified_env_step_core's Kelly cap, which only
saw direction conviction. At smoke horizon (health≈0.5,
direction_conviction≈0.04 when Hold competes with Long), the formula
safety = max(0.75, 0.04) = 0.75, effective_kelly = max(0, 0.75),
cap = 0.75 × max_pos × 0.75 = 0.5625 × max_pos lands in Half bucket.
The magnitude branch was simultaneously expressing strong preference
(q_f/q_h ≈ 1.9, mag_conviction ≈ 0.5) but never reached the cap.
Fix: per-sample magnitude conviction parallels direction conviction —
new buffers magnitude_conviction_buf[N*L] (training) and
chunked_magnitude_conviction_buf[chunk_len * n_windows] (eval).
experience_action_select writes (max(q_mag) − min(q_mag)) / fmaxf(ISV[16], 1e-6) (ISV[16] = Q_ABS_REF magnitude branch reference,
recycled). unified_env_step_core composer:
policy_conviction = max(dir_conv, mag_conv), safety = max(health_safety, policy_conviction). All three call sites (backtest_env_step,
backtest_env_step_batch, experience_env_step) migrated in lockstep
per feedback_no_partial_refactor. No tuned constants —
max() composition (per pearl_blend_formulas_must_have_permanent_floor),
ISV-driven scale (per feedback_isv_for_adaptive_bounds). Smoke
verification: pre-fix [EVAL_DIST] Q=0.59 H=0.41 F=0.000; post-fix
[EVAL_DIST] Q=0.618 H=0.153 F=0.229. Intent unchanged (the network
already learned Full preference); the cap no longer silences it.
var_scale permanent floor for eval magnitude collapse (2026-04-27): the
EVAL_DIST=Quarter 0.59 / Half 0.41 / Full 0.00 pathology persists even with
intent=0.911 for Full because the position-sizing pipeline composes four
multiplicative shrinks (cvar_scale × q_gap_conviction × kelly_f × var_scale)
each well-bounded individually but compounding to ≪ 0.75. Kelly already
fixed via permanent-floor pearl (pearl_blend_formulas_must_have_permanent_floor.md);
applied the same pearl to var_scale at experience_kernels.cu:1666:
var_scale = max(var_scale, q_gap_conviction). The conviction signal IS
the adaptive bound (per feedback_isv_for_adaptive_bounds.md), already
clamped to [0.25, 1.0] at line 1628. With this floor, high conviction
overrides Var[Q] shrinkage so the policy's magnitude intent reaches the
realised position even before var_q decays. No tuned constants — same
adaptive q_gap signal used upstream.
data_source globalization to mbp10 (2026-04-27): completes the alignment
started in the earlier "fxcache data_source alignment" entry below. Smoke
(dqn-smoketest.toml) and localdev (dqn-localdev.toml) profiles
previously set data_source = "ohlcv" while production sets "mbp10".
The split forced two separate fxcache files and made cross-environment
runs require explicit --data-source ohlcv overrides on the precompute
binary. Default rolls forward to "mbp10" everywhere it is not
deliberately overridden:
config/training/dqn-smoketest.toml,dqn-localdev.toml: profile valuescrates/ml/src/training_profile.rs,trainers/dqn/config.rs,hyperopt/adapters/dqn.rs: Rust defaults + doc stringscrates/ml/examples/precompute_features.rs: doc updated;--data-source mbp10is the canonical regen invocationcrates/ml/src/feature_cache.rs,fxcache.rstest fixtures use mbp10 Documentation references to the alternative ("ohlcv") are retained only where the docstring explicitly enumerates the two valid choices. Local fxcache regenerated to v6 mbp10 (13c0b086a975cc7e2384377a2cd0e97738c9410292fcfecb5807c29bf885cb48.fxcache, 55 MB, 175874 bars). Stale v5 ohlcv cache untracked from git index (already gitignored post-79578bbaf).
MoE Phase 3 wire-up T3.1–T3.7 (2026-04-27): MoE fully wired into
production training path. Forward: gate (state[B,128]→64→8→softmax) +
8 expert MLPs (h_s1[B,256]→64→256) + moe_mixture_forward → replaces
save_h_s2 in submit_forward_ops_ddqn. Load-balance: moe_load_balance_loss
moe_load_balance_reduce+ SAXPY into total_loss_dev_ptr (T3.4). ISV producer:launch_moe_expert_util_emaper-step in training_loop.rs (T3.5). Backward:moe_mixture_backward(de_k=g·dh_s2) +moe_dgate_reduce(dg=Σe·dh_s2) +moe_softmax_backward+ cuBLAS SGEMM backward through gate W2/W1 and 8 experts W2/W1 — all into params_buf grad slots [127..163) (T3.6). Adam step inherits gate+expert updates automatically (params_buf uniform). HEALTH_DIAG aux_moe line emitted per epoch from ISV[118..127) (T3.7). Smoke test: 3/3 folds pass, 728s. Gate differentiated: expert-2 reached 32.3% utilization (others 9.7%) by fold-2 epoch-4; entropy 1.611 < ln(8). fused_training.rs: added moe_lambda field to GpuDqnTrainConfig init from hyperparams.moe_lambda.unwrap_or(0.01).
MoE expert util EMA T2.4 (2026-04-27): moe_expert_util_ema_update
single-thread cold-path-cadence kernel writes 8 per-expert utilization
EMAs (ISV[118..126)) + gate-entropy EMA (ISV[126]) with α=0.05. Same shape
as h_s2_rms_ema_update/aux_heads_loss_ema_update. GPU-resident per
pearl_cold_path_no_exception_to_gpu_drives.md. Test verifies EMA values
match CPU reference within 1e-5 using skewed gate (expert-3 = 0.6).
MoE load-balance loss T2.3 (2026-04-27): moe_load_balance_loss (one
block per k, shmem reduction over B) computes λ·K·(mean_b g[b,k])² per
expert without atomicAdd; moe_load_balance_reduce (single-thread scalar
sum) accumulates K terms. Two tests: CPU-reference match (1e-5) and
uniform-gate minimum (loss = λ). Backward gradient lands in Phase 3.
MoE backward kernels T2.2 (2026-04-27): moe_mixture_backward computes
de_k[k,b,c] = g[b,k] · dh_s2[b,c] (one thread per (k,b,c));
moe_dgate_reduce computes dg[b,k] = Σ_c e_k[k,b,c] · dh_s2[b,c] via
shmem-tree reduction over c (one block per (b,k)). Finite-differences test
verifies analytic backward matches numerical for B=2, K=4, C=32 within 1e-3.
All data flows via mapped pinned buffers. Consumers land in Phase 3 wire-up.
MoE ISV slot reset registration (2026-04-27): registered fold-boundary
reset entries for the 8 MOE_EXPERT_UTIL_EMA slots (reset to 1/K=0.125)
and the MOE_GATE_ENTROPY_EMA_INDEX slot (reset to ln(8)). Producers
land in Phase 2 task 2.4; consumers in Phase 3.
MoE ISV slot reservation (2026-04-27): reserve slots 118–126 for the
upcoming MoE redesign monitoring — 8 slots MOE_EXPERT_UTIL_EMA[0..8]
for per-expert gate-weight EMA + 1 slot MOE_GATE_ENTROPY_EMA_INDEX=126
for the gate-distribution entropy EMA. ISV_TOTAL_DIM 118 → 127.
Producers + consumers land in subsequent commits. Spec
docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md §4.6.
use_* flag family removal + count_bonus API tightening (2026-04-27): atomic
deletion of 6 dead use_* boolean flags from DQNConfig per
feedback_no_feature_flags.md. (1) use_dueling, use_distributional,
use_noisy_nets, use_branching were pure-cosmetic always-on flags; only
metadata strings remained. Their if true /* always on */ patterns at 4 call
sites in dqn.rs::select_action, select_action_with_confidence,
select_action_inference, q_values_for_batch collapsed to the live
branching-only arm; the dead else arms (legacy non-branching code paths
referencing 5-flat ExposureLevel layout) deleted. (2) use_count_bonus was
redundant with the live count_bonus_coefficient numeric kill-switch; gating
removed, recording made unconditional, single coefficient drives behavior.
(3) use_cvar_action_selection was dead post-use_iqn cleanup (only
consumers were inside the IQN arms removed in commit da632446c); field gone,
cvar_alpha retained for cuda_pipeline CVaR loss usage and as the future dial
for CVaR action selection on the live C51 distribution. (4) count_bonus.rs
gained bonuses_branched_f32(&self, exp_out: &mut [f32], ord_out: &mut [f32], urg_out: &mut [f32]) write-into API alongside the existing Vec<f64>; the
production DQN::get_count_bonuses_branched() returns fixed ([f32; 4], [f32; 3], [f32; 3]) arrays — allocation-free, f32 throughout, fed directly to the
GPU action selector. Bit-identical Test 0.F outputs vs pre-cleanup confirm
zero behavioral change (legacy use_* arms were unreachable, as expected).
This commit is the precondition for the MoE regime redesign per
docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md.
use_iqn flag + dead iqn_network removal (2026-04-27): structural deletion
of a feature flag (feedback_no_feature_flags.md) that gated unreachable
legacy code. Production training already runs IQN unconditionally through
cuda_pipeline/gpu_iqn_head.rs + iqn_dual_head_kernel, properly wired
into the branching architecture with the FIXED_TAUS 5-quantile schedule;
nothing in the cuda_pipeline production path ever read use_iqn or
iqn_network. The legacy DQN::iqn_network: Option<QuantileNetwork> was
a parallel CPU-side network from a pre-branching era — its only consumers
were 6 conditional gates inside the if true /* use_branching: always on */
arm at q_values_for_batch:1808, so the IQN else-if at L1822 was dead code.
That dead-zone bug (legacy comment: "IQN trains base q_network but
inference uses dist_dueling network (zero gradients)") was paper-fixed by
disabling the feature instead of fixing the inference path. Stripped:
use_iqn + 3 vestigial fields (iqn_num_quantiles, iqn_kappa,
iqn_embedding_dim — kernel-side macros are the actual config), 4 default
builders, iqn_network field + init, 6 IQN gates collapsed to live arm,
get_state_embedding (only consumed by deleted IQN paths), entire
crates/ml-dqn/src/quantile_regression.rs module (392 LOC), 2 lib.rs
exports, all checkpoint metadata I/O for the dropped fields. iqn_lambda
stays — cuda_pipeline dual head consumes it as IQN aux loss weight.
Downstream call sites in ml/src/trainers/dqn/{config,fused_training, trainer/constructor}.rs substitute kernel-fixed literals (64, 1.0) for
the deleted DQNConfig copies; evaluate_baseline.rs and 2 test files
drop their flag references. dqn_action_collapse_fix_test.rs no longer
asserts !use_iqn — the dead-zone pathology is structurally impossible.
Test 0.F (Plan A Task 8) ships in the same commit with its docstring
rewritten to drop the use_iqn=false framing and the legacy "Tier-B-prime"
caveat; the Tier-A version is GPU-integration-only per
feedback_no_cpu_forwards.md (CPU is read-only, never propose a CPU
forward as a future option). Net: 11 files, +458 / -785 LOC, 0 errors on
cargo check --workspace --tests, Test 0.F produces bit-identical σ_C51 /
argmax / Thompson outputs vs pre-removal.
fxcache data_source alignment + DBN-fallback normalization (2026-04-27):
two-part fix to a class of bugs causing un-normalised features to silently
flow into training. (1) precompute_features.rs previously hardcoded
data_source = "ohlcv" at the cache-key write site (lines 214, 633).
train_baseline_rl.rs:582 hardcoded "ohlcv" at the read site too.
dqn-production.toml sets data_source = "mbp10" (MBP-10 is the
canonical production data path). The hardcodes mean smoke (uses ohlcv)
worked by accident, but any future profile with a different data_source
silently mismatches → cache MISS → DBN-direct fallback. Both call sites
now use "mbp10" (production default). precompute_features adds a
--data-source CLI override for legacy ohlcv smoke flows.
(2) DBN-fallback path in train_baseline_rl.rs:592-643 did NOT call
NormStats::normalize_batch (precompute does, line 629). Any cache
miss for any reason (data_source drift, schema-hash mismatch, missing
file) silently uploaded RAW features to GPU. Raw close prices (~$5180
ES futures) flowed into next_states[:, 0], the aux next-bar head's
label_scale EMA latched onto raw-price magnitude (~5443 vs expected
~1.0 z-score), and the shared trunk learned to predict next-bar prices
→ epoch-0 Sharpe 141 with 0.32% max drawdown over 214k bars
(train-h5gxb). DBN fallback now applies the same z-score normalisation
unconditionally as defence-in-depth, so a future cache-miss cannot
reintroduce raw values into training.
fxcache schema-hash gap fix (2026-04-27): build.rs::emit_feature_schema_hash
hashes only src/features/extraction.rs, src/fxcache.rs, and
../ml-core/src/state_layout.rs. The z-score normalization step lives in
examples/precompute_features.rs:625-631 (added 2026-04-03 in commit
9f7c14978f) and was NOT covered by the hash, so a fxcache written by an
older precompute build (raw features, no normalization) silently passed
today's validate() because every other field matched. The PVC fxcache
on the L40S Argo path was such a stale artefact: feature column 0 stored
RAW CLOSE PRICES (~$5180 ES futures level) instead of z-normalized log-
returns. The aux next-bar head reads next_states[:, 0] as its label
(gpu_dqn_trainer.rs:7758-7789); with raw-price labels, the EMA
label_scale reached 5420 vs smoke's 0.05. The shared trunk learned
to predict next-bar prices, leaking future info into the policy →
train-f8h6q epoch-0 reported impossible Sharpe=141.99 with 0.32%
max-drawdown over 214k bars. Fix: add examples/precompute_features.rs
to schema_sources. New hash invalidates the stale PVC cache; Argo's
ensure-fxcache regenerate-on-failure branch
(infra/k8s/argo/train-template.yaml:372-383) auto-regens with current
normalized precompute. Generalises beyond this incident: any future
change to feature normalization, target ordering, or precompute
post-processing is now hash-tracked.
mag_stats wr_h/wr_f attribution fix (2026-04-27): experience_kernels.cu
line 1916 binned action_mag_per_sample by actual_mag_core at every step,
including trade-close events. unified_env_step_core forces
actual_mag = 0 (Quarter) whenever actual_dir is Hold/Flat (line 772 of
trade_physics.cuh), and trade closes always land in Hold/Flat state — so
every Half/Full close was attributed to the Quarter bin. close_counts[Half]
and close_counts[Full] structurally pinned to 0; wr_h=wr_f=0 across all
training runs. Fix: at close events bin by pre_mag_bin (the magnitude of
the position being closed); at non-close events keep actual_mag_core
(current realized magnitude). pre_mag_bin is always 0/1/2 at close events
since exiting/reversing requires prev_sign != 0 → pre_trade_position != 0.
Smoke verification: wr_h/wr_f remain 0 in 5-epoch smoke because var_scale
(~0.10-0.19 at smoke maturity, from 1/(1+sqrt(var_q)) shrinkage) makes
even Long Full target → abs_pos ≈ 0.15 < 0.375 → all positions decode as
Quarter; no Half/Full positions exist for the fix to attribute. Latent
correctness for mature training (var_q drops as Q-distribution converges →
var_scale grows → Half/Full positions become reachable).
actions_history_buf measurement artifact fix (2026-04-27): two-part fix. (1)
gpu_backtest_evaluator.rs::reset_evaluation_state now initialises
actions_history_buf to -1 sentinel via cuMemsetD32Async(0xFFFFFFFFu32)
instead of memset_zeros. After done_flags[w]=1 (capital floor breach),
backtest_env_step early-returns without writing actions_history for the
remaining slots in [done_step, max_len); the prior zero-init decoded those
as Short Quarter Market Normal (action 0) via dir = 0/27 = 0. (2)
backtest_metrics_kernel.cu added if (act < 0) continue; after reading
actions_history so the reduce-side metrics (buy_count/sell_count/hold_count
→ active_frac/dir_entropy + bnd_* trade-boundary detection) skip unwritten
slots consistently with the existing Rust-side reader guards. Empirical impact
on the local 3-fold × 5-epoch smoke: val_dir_dist Short dropped from 83%
(artifact) to 13-29% (matches val_picked_dir_dist within 5pp); active_frac
dropped from 87-91% to 31-50%; dir_entropy increased from 0.57 to 0.83-1.02.
The pre-fix "val-Flat-collapse" / "Short-collapse" pathology that motivated
substantial subsequent investigation was largely a measurement artifact from
this bug surfacing differently before vs after the Kelly cap fix
(0c9d1ee39). Pre-Kelly fix, Kelly cap clamped Long/Short → Flat, so
actions_history was densely written with Flat encoding → fewer unwritten
slots → 80% Flat reading (real Kelly pathology + small artifact). Post-Kelly
fix, Long/Short survive but poor smoke-trained model breaches capital floor
often → more unwritten Short slots → 83% Short reading (pure artifact). With
both fixes, val_dir_dist reflects real model behaviour.
Plan A Phase 0 Thompson test kernel (2026-04-27): cuda_pipeline/thompson_test_kernel.cu —
standalone test-only CUDA kernel implementing the Thompson direction sampling math
(inverse-CDF over C51 atoms, uniform-τ over IQN quantiles, argmax of E[Q]) plus
diagnostic σ kernels (compute_sigma_c51_test, compute_sigma_iqn_test). Exercised
only by distributional_q_tests.rs (Phase 0 GPU-direct hypothesis verification).
Phase 2 will inline the SAME __device__ __forceinline__ math into
experience_kernels.cu::experience_action_select for the production direction-branch
action selector; this isolated test kernel is the reference that Phase 2 Test 2.B
verifies the production kernel matches bit-for-bit. No production callers in this
commit. Spec: docs/superpowers/specs/2026-04-26-distributional-rl-aggregation-design.md.
Plan A Phase 0 Task 2 test wrappers (2026-04-27): trainers/dqn/distributional_q_tests.rs —
test-only Rust module gated #[cfg(test)] pub mod in trainers/dqn/mod.rs. Wraps the
Task 1 cubin via include_bytes!(concat!(env!("OUT_DIR"), "/thompson_test_kernel.cubin"))
with two launchers (launch_thompson_direction, launch_argmax_eq) plus
make_test_stream / upload helpers. Skeleton only; actual #[ignore]-gated
verification tests (0.A bias-reproduces, 0.B inverse-CDF, 0.C IQN symmetry, 0.D
Thompson-reverses, 0.E synthetic edge, 0.F converged-checkpoint) land in Tasks 3-8.
No production callers. Plan: docs/superpowers/plans/2026-04-27-distributional-rl-thompson-plan-A-phase-0.md.
Code-review amendment (2026-04-27): cubin now loaded once via process-wide
OnceLock<KernelSet> (matches cuda_pipeline/signal_adapter.rs and
cuda_pipeline/gpu_her.rs pattern); module-level #![allow(unsafe_code)]
#;i32→u8cast replaced withu8::try_from(...).expect(...)to fail loudly on a buggy kernel writing OOB;host[0]direct indexing replaced withhost.first().copied().expect(...).
Plan A Phase 0 Task 3 Test 0.A (2026-04-27): trainers/dqn/distributional_q_tests.rs —
appended test_0a_bias_reproduces_argmax_vs_thompson (#[test] #[ignore]). Constructs
synthetic per-direction C51 (Flat/Hold = δ(0); Long/Short = Gaussian σ=0.3 around
v=-0.001) plus matching IQN quantiles, and asserts (1) argmax(E[Q]) deterministically
picks Flat or Hold (bias reproduces) and (2) Thompson sampling over 10 000 seeds picks
Long+Short ≥ 3 000 (proposed fix would work). PASSES on local RTX 3050 Ti in ~1.86 s.
First Phase 0 verification test exercising the Task 1 cubin via the Task 2 wrappers.
No production callers. Plan: docs/superpowers/plans/2026-04-27-distributional-rl-thompson-plan-A-phase-0.md.
Plan A Phase 0 Task 4 Test 0.B (2026-04-27): trainers/dqn/distributional_q_tests.rs —
appended test_0b_thompson_c51_distribution_matches_input (#[test] #[ignore]). Single
direction (d=0) carries C51 atoms p=[0.1, 0.7, 0.2] over v=[-1, 0, +1]; other directions
δ(v=0); IQN all-zero. Counts d=0 wins over 100 000 seeds and asserts P(d=0) ≈ 0.9 within
1 %. The 0.9 (not the plan's originally-specified 0.2) is correct: the kernel uses
strict-> tie-break with best_d=0 initialised, so d=0 wins iff its C51 sample ≥ 0,
i.e. p[v=0] + p[v=+1] = 0.7 + 0.2 = 0.9. Validates the inverse-CDF math end-to-end —
a bug shifting mass between v=-1 and {v=0, v=+1} would move P(d=0) measurably from 0.9.
Observed 0.90195 on local RTX 3050 Ti (~3.8 s). Same commit also applies clippy
erasing_op/identity_op cleanup (0 * n_atoms, 1 * n_atoms) to Test 0.A — no
behaviour change in 0.A. No production callers.
Plan A Phase 0 Task 5 Test 0.C (2026-04-27): trainers/dqn/distributional_q_tests.rs —
appended test_0c_thompson_iqn_quantile_interp (#[test] #[ignore]). Implemented
on the batched-pinned API (single launch_thompson_direction_batched call over
100 000 seeds, host-side filter on the returned Vec<u8>) — no per-seed launches,
no clone_dtoh. Setup: d=0 has standard-normal IQN quantiles
[-1.645, -0.674, 0, 0.674, 1.645] at the fixed τ-anchors {0.05, 0.25, 0.50, 0.75, 0.95};
other directions all-zero. C51 is δ(v=0) for ALL directions, so the combined
sample collapses to s_iqn for d=0 and exactly 0 for d=1,2,3. Strict-> tie-break
with best_d=0 initialised gives d=0 the win iff s_iqn ≥ 0, so by symmetry of
the standard-normal IQN, P(d=0) ≈ 0.5. Asserts (p_d0 - 0.5).abs() < 0.02.
Observed 0.50003 on local RTX 3050 Ti (~1.6 s including build, ~0.2 s test wall
when build is hot). Validates the uniform-τ linear interpolation in
sample_iqn_quantile_interp — a wrong interpolation (anchor swap, non-linear
blend, or boundary OOB) would break the symmetry and shift P(d=0) measurably
off 0.5. No production callers.
Plan A Phase 0 Task 6 Test 0.D (2026-04-27): trainers/dqn/distributional_q_tests.rs —
appended test_0d_thompson_reverses_bias (#[test] #[ignore]). Implemented on the
batched-pinned API (single launch_thompson_direction_batched over 10 000 seeds,
host-side filter().count() on the returned Vec<u8>) — no per-seed launches,
no clone_dtoh. Setup: realistic synthetic failure mode with σ_long=0.05 (tighter
than Test 0.A's σ=0.3, matching the σ scale we actually observe in trained
checkpoints). C51: Flat/Hold = δ(0); Long/Short = Gaussian centered at -0.001,
σ=0.05; 21 atoms over [-0.5, 0.5]. IQN: Flat/Hold all-zero; Long/Short standard
quantiles [-0.082, -0.034, -0.001, 0.032, 0.080]. Asserts argmax picks Hold/Flat
(deterministic E[Q] bias) and Thompson P(Long) ≥ 0.20 over 10 000 seeds.
Observed argmax_pick=1 (Hold), P(Long)=0.36630, P(active)=0.74500 on local
RTX 3050 Ti (~0.23 s wall — all four tests combined). Validates Thompson
exploration is sufficient at the realistic σ (not just the wide σ from Test 0.A) —
the failure-mode bias mechanism reproduces and Thompson reverses it. No
production callers. Plan: docs/superpowers/plans/2026-04-27-distributional-rl-thompson-plan-A-phase-0.md.
Plan A Phase 0 Task 7 Test 0.E (2026-04-27): trainers/dqn/distributional_q_tests.rs —
appended test_0e_synthetic_edge_discovery_cpu (#[test], NOT #[ignore] — pure
CPU, no GPU dependency, runs in default cargo test). Algorithmic-property test
of Thompson exploration on a 1-state 2-action bandit: Long has KNOWN +0.005 edge
(Normal(0.005, 0.03)), Flat returns deterministic 0. Q-learning over 5 atoms
[-0.1, -0.05, 0, 0.05, 0.1] with lr=0.05 for 100 iterations under two action
selectors. Initial setup revised from plan A draft (option 3 — production-realistic):
p_long = [0.10, 0.20, 0.40, 0.20, 0.10] (uniform-ish, E=0, has σ; mimics
freshly initialized C51 head); p_flat = [0, 0, 1, 0, 0] (δ(v=0); tx_cost-bare
Flat returns 0). Argmax with strict-> tie-break at e_long=e_flat=0 always
picks Flat → never explores Long → e_long stays 0 (assertion
e_long ≤ e_flat + 0.001 passes vacuously). Thompson: `P(s_long > 0) = p_long[3]
- p_long[4] = 0.30
→ ~30 effective Long-selections drift p_long mean toward the +0.005 reward target's nearest atom (+0.05). Observed values on local RTX 3050 Ti: argmax e_long=0.000000, e_flat=0.000000; Thompson e_long=0.003550, e_flat=0.000000 (test ~0.00 s; total 5-test run ~1.57 s). Plan's prior draft (initial p_long with mean=-0.015 + p_flat=δ(0)) was calibration-bound: Thompson drift was directionally correct but didn't cross zero in 100 iters. Revised setup eliminates the artificial initial bias and matches production reality. Stop condition: if Thompsone_long ≤ e_flatwith this setup, the hypothesis is genuinely wrong and reward shaping must change before Phase 2. Usesrand+rand_distr(already in workspace deps). No production callers. Plan:docs/superpowers/plans/2026-04-27-distributional-rl-thompson-plan-A-phase-0.md`.
Plan A Phase 0 batched-pinned redesign (2026-04-27): cuda_pipeline/thompson_test_kernel.cu
trainers/dqn/distributional_q_tests.rs— eliminates the per-seedclone_dtohthe original Task 2 wrapper used inside the 10 000- (Test 0.A) / 100 000-iteration (Test 0.B) loops, perfeedback_gpu_cpu_roundtrip.md. Single-launchthompson_direction_test(1 thread, 1-pick output) replaced bythompson_direction_test_batched(one thread per seed, writesout_dir_idx[seed_idx]via a mapped pinned device pointer, finishes with__threadfence_system()).argmax_eq_testand the σ diagnostic kernels gain the same__threadfence_system()exit barrier. Test wrapper now allocates a localMappedI32Buffer(cuMemHostAlloc(DEVICEMAP|PORTABLE)+cuMemHostGetDevicePointer_v2, mirrorsgpu_training_guard.rs::MappedBuffer) andread_volatiles the result — nomemcpy_dtoh. The OnceLock-cachedlaunch_thompson_direction/launch_argmax_eqsingle-pick wrappers are deleted (orphan after refactor;feedback_wire_everything_up.md). Tests 0.A and 0.B now run in ~0.15 s each on local RTX 3050 Ti (down from ~1.86 s and ~3.8 s respectively). Assertions unchanged. No production callers.
Plan C Phase 2 Task 1 Thompson math device-inline funcs (2026-04-29):
cuda_pipeline/experience_kernels.cu — copies the four __device__ __forceinline__ math primitives from the Phase 0 reference
thompson_test_kernel.cu (sample_c51_inverse_cdf,
sample_iqn_quantile_interp, compute_e_c51_inline,
compute_e_iqn_inline) plus the N_IQN_QUANTILES=5 #ifndef
guard into the production kernel translation unit. Placed in the
"Inline helpers" region between argmax_n and the first kernel
(domain_rand_episode_starts). Bit-for-bit equivalent to the
reference; Phase 2 Test 2.B verifies the production kernel matches
thompson_test_kernel.cu exactly so the Phase 0 verification tests
remain a valid oracle. The two _inline helpers re-name the reference's
compute_e_c51 / compute_e_iqn to make their intended usage explicit
and reserve the un-suffixed names for any future non-inline callers.
No callers in this commit — Task 2 wires the direction-branch Thompson
selector inside experience_action_select that consumes them. Plan:
docs/superpowers/plans/2026-04-27-distributional-rl-thompson-plan-C-phase-2.md.
Comments expanded post-review to preserve reference parameter-contract docs.
Plan C Phase 2 Task 2 direction-branch Thompson swap (2026-04-29):
cuda_pipeline/experience_kernels.cu::experience_action_select —
direction selection (Branch 0) replaced with Thompson sampling at
training and argmax E[Q] at eval, consuming the four device-inline
math primitives Task 1 installed. Kernel signature gains four trailing
arguments after out_magnitude_conviction: c51_probs_dir [N, b0_size, n_atoms], atom_values [n_atoms], iqn_quantiles_dir [N, b0_size, N_IQN_QUANTILES], n_atoms. Existing direction-related
parameters (eps_dir derivation, eps_exp_mult, contrarian_active's
q_sign flip on direction, ISV[21] tau-floor, ISV[71]/[72] adaptive
passive-pressure boost) become unreachable on this branch — Thompson
draws supply exploration directly from the C51 + IQN posteriors and
the adaptive-floor / contrarian-on-direction mechanisms are subsumed
by sample noise. They remain coded for the magnitude branch (eps_mag
still consumes eps_exp_mult) and unchanged for order/urgency
(eps_ord/eps_urg). Phase 3 / Plan D removes the direction-only
dead branches.
Per-direction joint E[Q] (e_dir[4]) is computed once at the top of
the direction branch and reused for: (a) eval-mode argmax;
(b) the conviction calculation (E[Q]-based per spec — Thompson per-
step samples here would feed the Kelly cap a jittery confidence
signal that re-shapes target position with every draw, defeating the
warmup floor's purpose). The conviction normaliser still reads
ISV[21] (q_dir_abs_ref) — it was previously fit to the cuBLAS
q_b0 scale rather than the C51+IQN joint scale, so Phase 3 may
need to retune if a systematic mismatch surfaces. The cold-start
fmaxf(q_range, 1e-6) denominator fallback is preserved.
Magnitude (Branch 1), order (Branch 2), and urgency (Branch 3)
selection paths are untouched — they still read q_b0/q_b1/q_b2/
q_b3 from the cuBLAS Q-head and apply the existing Boltzmann-with-
adaptive-tau path. Partial-refactor follow-up (same date): out_q_gaps
also migrated to read e_dir (joint E[C51]+E[IQN]) — both Kelly-adjacent
signals feeding env_step now share scale; contrarian sign-flip dropped
symmetric with the unflipped Thompson direction selection.
Until Tasks 3 (gpu_dqn_trainer.rs) and 4 (gpu_backtest_evaluator .rs) land, the kernel signature is intentionally non-callable end-
to-end — the new trailing arguments have no Rust-side passers yet.
Task 6 / Test 2.A then validates training stochastic / eval
deterministic behaviour and Task 7 / Test 2.B verifies bit-identical
output between this kernel and thompson_test_kernel.cu at fixed RNG.
Plan C T2 amendment (2026-04-29): the four trailing kernel arguments
above (c51_probs_dir, atom_values [n_atoms], iqn_quantiles_dir,
n_atoms) were replaced with b_logits_dir [N, b0_size, n_atoms]
(raw direction-branch C51 logits — first slice of the collector's
exp_b_logits), per_sample_support [N, b0_size, 3] stride-12
(production's adaptive per-sample per-direction [v_min, v_max, delta_z] tile), atom_positions [b0_size, n_atoms] (optional
non-linear positions; NULL = linear), and n_atoms — to align with
the production C51 architecture. The plan-as-written assumed a
post-softmax probability buffer the collector doesn't materialise, a
single global linear support that production has replaced with
adaptive per-direction support, and a rollout-time IQN inference path
that does not exist (gpu_iqn_head.rs is training-only). Direction-
branch Thompson is now single-distribution C51 with the production
adaptive support — the principled posterior-sample fix to the UCB
asymmetry is preserved. Two new device-inline helpers
(softmax_c51_inline, compute_atom_values_inline) sit alongside
the four Plan C T1 primitives in the same Thompson math section and
mirror the production C51 loss kernel's softmax + atom-support
contract. The Phase 0 standalone (thompson_test_kernel.cu) gained
two new entry points (direction_thompson_v2_test,
argmax_eq_v2_test) matching the amended production API; the
original Plan A entry points are retained for Plan A tests 0.B-0.F.
Tasks 3 + 4 are unblocked — collector and evaluator already own
per_sample_support_buf and the exp_b_logits slice in production.
Test 2.B (Plan C Task 7) and Test 2.A (Task 6) are re-scoped to the
amended single-distribution C51 surface; the bit-equivalence test
should drive both kernels from the same uniform array (strips the
LCG-vs-Philox RNG implementation choice from the equivalence check).
Conviction read site continues to consume e_dir[] (now a pure
single-distribution E[C51] vector, no longer joint E[C51]+E[IQN]) —
the spec invariant "conviction stays E[Q]-based" still holds at the
amended scale, but ISV[21]'s q_dir_abs_ref reference may need
retuning since the joint-sum scale is gone (revisit in Phase 3 if a
systematic mismatch surfaces).
Plan C T3 collector wire-up (2026-04-29): the four amended buffers
(b_logits_dir, per_sample_support, atom_positions, n_atoms)
are now threaded through the production training rollout call site
at gpu_experience_collector.rs:3607 (the non-seed-phase
launch_builder(&self.action_select_kernel) arm). b_logits_dir
is wired as &self.exp_b_logits (BRANCH-MAJOR [N, total_branch_atoms];
the kernel reads only the first N*b0_size*n_atoms slice — direction
branch — and ignores the rest); per_sample_support re-uses the
mapped-pinned self.per_sample_support_buf.dev_ptr already threaded
into compute_expected_q two stages upstream; atom_positions is
NULL (0u64, matching the existing convention used for the
expected-Q launch at line 3417/3432) so the kernel falls back to
linear v_min + a*delta_z — production has not yet adopted
non-linear atom positions; n_atoms is self.num_atoms as i32
(i32 to match the kernel's int ABI per
feedback_cudarc_f64_f32_abi). Direction-branch Thompson sampling
is now end-to-end runnable from the training rollout. Evaluator path
(gpu_backtest_evaluator.rs) wired by Task 4. Compile-clean against
the kernel signature in experience_kernels.cu at commit
5de5e546a.
Plan C T4 evaluator wire-up (2026-04-29): same four amended buffers
threaded through the validation/backtest action_select call site at
gpu_backtest_evaluator.rs:1722 (the chunked val pipeline inside
submit_dqn_step_loop_cublas). Unlike the collector, the evaluator
sources Q-values via the QValueProvider trait (delegating forward
to the trainer's CUDA-Graphed cuBLAS), so this commit (a) extends the
trait with compute_q_and_b_logits_to(states_ptr, batch, q_out_ptr, b_logits_out_ptr) plus four read-only accessors
(per_sample_support_ptr, atom_positions_ptr, num_atoms,
total_branch_atoms), (b) implements them on FusedTrainingCtx
(fused_training.rs) — the b_logits variant DtoD-copies the
trainer's on_b_logits_buf into the caller's chunked buffer per
sub-batch iteration, mirroring the existing q_out DtoD pattern, and
(c) adds three pub accessors on the trainer
(on_b_logits_buf_ptr, atom_positions_buf_ptr,
per_sample_support_ptr_get). The evaluator gains one new field —
chunked_b_logits_buf: Option<CudaSlice<f32>>, sized
[n_windows * DQN_BACKTEST_CHUNK_SIZE, total_actions * num_atoms]
plus 32*3 cuBLAS tail-safety floats — allocated in
ensure_action_select_ready alongside chunked_q_values. The
ch_b_logits device pointer is appended to the action_select
launch_builder; per_sample_support and atom_positions are read
via the new trait accessors right before the launch (trainer-owned,
sized for max_batch_size ≥ chunk batch); n_atoms is
qp.num_atoms() as i32. Phase 6's last-step plan_params forward
continues to use compute_q_values_to (b_logits not consumed there
— save_h_s2 + plan MLP only). Combined with T3, all production
callers of experience_action_select now use the amended kernel ABI
end-to-end (feedback_no_partial_refactor). Compile-clean.
Persistent picked_action_history scatter (2026-04-26): gpu_backtest_evaluator.rs
gains a window-major picked_action_history_buf (parallel layout to
actions_history_buf) populated by an additional scatter_intent_chunk
launch immediately after the existing intent-mag scatter — same kernel
handle, different src/dst (chunked_actions_buf → picked_action_history_buf).
The prior read_chunked_actions_direction_distribution reader was changed
to read this persistent buffer instead of the chunk-local one, so the
diagnostic now covers all 214 654 val bars instead of only the last ~64.
Disambiguates whether val_dir_dist ≈ 80% Flat reflects upstream kernel
collapse (most bars produce peaked Q for Hold/Flat → both val_dir_dist
and val_picked_dir_dist flat) or downstream env_step gating (kernel
diverse → only val_dir_dist flat). Reset to zeros by
reset_evaluation_state; no new kernel source.
Plan 5 Task 5 Phase H Site 2 (2026-04-26): cublasLt EPILOGUE_DRELU_BGRAD
fusion on the value-FC backward chain — collapses the standalone relu_mask
- bias-grad reduce launches at the value-FC site into the value-output dX
cublasLtMatmulcall. Site 1 (commit326c13378) handled the 4 attention forward projections withEPILOGUE_BIAS; Site 2 saves 2 launches × 2 forward passes × N_steps on the L40S deploy. Forward-side switches online value-FC fromEPILOGUE_RELU_BIAStoEPILOGUE_RELU_AUX_BIASso cublasLt writes the dReLU bit-mask; new_value_fc_relu_mask_buf: CudaSlice<u8>inBatchedForward((value_h_aux_ld / 8) * batch_sizebytes, 128-bit AUX_LD aligned). Backward side addsgemm_value_dx_drelu_bgrad: Option<CachedBwdGemmDesc>cached descriptor +backward_fc_layer_drelu_bgradmethod that gatesdxby the mask AND reduces along N=batch intob_v1_gradin a single fused GEMM.backward_fullsignature gainedvalue_fc_relu_mask_ptr: u64threaded through both call sites ingpu_dqn_trainer.rs. Falls back to originalbackward_fc_layer + relu_mask + launch_dw_onlywhen AUX path unavailable.relu_mask_kernelandbias_grad_reduce_f32_p1/_p2retained — still consumed byapply_ensemble_diversity_backward, VSN backward, and branch FC layers. Smoke (3 folds × 5 epochs): F0 2.29 (-3% vs Phase G 2.36), F1 39.93 (-50.6%), F2 59.17 (-35.8%); F1 drop attributed to Boltzmann eval unification restructuring val-Sharpe distributions, not Site 2 itself. No determinism violation, no NaN, no panics. Wall-time delta deferred to L40S nsys.
val_picked_dir_dist HEALTH_DIAG diagnostic (2026-04-26): gpu_backtest_evaluator.rs
gains a read_chunked_actions_direction_distribution() reader that decodes the
per-direction histogram from chunked_actions_buf (the buffer experience_action_select
writes to BEFORE env_step computes actual_dir). Pairs with the existing
val_dir_dist reader (reads actions_history_buf AFTER env_step) — divergence
between the two localises whether eval collapse is upstream (kernel produces
degenerate Boltzmann picks → both lines flat) or downstream (kernel diverse,
env_step / Kelly cap drains active picks to Flat → only val_dir_dist flat).
Cluster runs ddrpr + txdz9 showed val_dir_dist ≈ 80% Flat / 20% Hold across
30+ epochs while Boltzmann math caps P(best) ≤ 47.5% and P(any) ≥ 13.5%
for 4 actions with tau=q_range; one of those bounds is being violated and
this diagnostic identifies which path. Sample is one chunk (~64 bars on
1-window val), noisier than the full-window post-physics histogram but
sufficient to disambiguate the gating mechanism.
Kelly cap warmup_floor health-coupled (2026-04-26): trade_physics.cuh::kelly_position_cap
previously computed warmup_floor = clamp(conviction, 0, 1). At cold start
(maturity = 0) effective_kelly = warmup_floor, so a zero conviction collapsed
the cap to zero — eval-mode could never open a position to graduate Kelly stats,
the same bootstrap deadlock pattern the IQN trunk SAXPY exhibited. Confirmed by
cluster run train-multi-seed-ddrpr epoch 0: new val_dir_dist [short=0.0000 hold=0.1953 long=0.0001 flat=0.8047] shows Boltzmann fired correctly (hold +
flat ≈ 100% of bars) but every active-direction pick (Long/Short) was forced
to actual_dir = Flat by target_position = 0. Fix: warmup_floor = max(conviction, health_floor) where health_floor = 0.5 + 0.5 × health is the same training-
stability floor already used by safety_multiplier (composed in
unified_env_step_core from ISV[LEARNING_HEALTH]). Both signals are adaptive
and ISV-driven; the floor only matters during cold start (maturity → 1 after
≥10 trades drops the term out entirely). Threaded through both apply_kelly_cap
and kelly_position_cap signatures.
Eval-mode action selection unified with Boltzmann softmax (2026-04-26):
experience_kernels.cu — the four factored action heads (direction, magnitude,
order, urgency) all dropped their else if (eval_mode) strict-argmax + ISV-
tied tie-break blocks. The tie threshold was 0.01 × isv_signals[V_HALF_*_INDEX]
(1% of the C51 atom support range, ~40 for direction); in practice the actual
per-sample direction Q-spread post-IQN is ~1.5, so tie-break never fired and
eval was pure strict-argmax over a peaked distribution → val argmax glued to
one direction → 1-25 trades / 214k-bar window across cluster runs. Replaced
with the same Boltzmann path training already used: tau = max(q_range, floor)
where q_range is per-sample. Mathematically P(best) ≤ 47.5% for 4 actions
with tau=q_range, so eval can never collapse to pure-greedy regardless of
how peaked the Q-values become. Confirmed by new unit test
cuda_pipeline::tests::test_eval_action_select_boltzmann_bounded — exercises
the kernel directly with peaked synthetic Q-values [0, 0.5, 1.0, 0.5] and
asserts the realised histogram matches Boltzmann theory (P(best) ≈ 0.366,
≤ 0.6, ≥ 0.25); the test runs in ~1.6 s after build, replacing 15-min smoke
runs for kernel-level validation. New val_dir_dist line in HEALTH_DIAG logs
per-direction eval distribution (short / hold / long / flat) — disambiguates
the kernel-side dir_entropy collapse that bucketed Hold+Flat into one slot.
IQN trunk-gradient readiness gate removed (2026-04-26): gpu_dqn_trainer.rs:6461,6492
SAXPY scale was iqn_lambda × iqn_readiness × iqn_budget. iqn_readiness initialises
to 0.0 and only ramps when iqn_loss_ema drops below iqn_loss_initial, but that
improvement requires the trunk to receive IQN gradient — which the gate blocked.
Bootstrap deadlock confirmed in cluster run train-multi-seed-vg2r9 epochs 0-13:
trunk_iqn=0.0000 every epoch, q_gap_comp=0.00 every epoch, val trade_count
locked at 22-34 per 858k-bar window. New scale: iqn_lambda × iqn_budget. The
iqn_readiness field stays on self because the C51 loss kernel reuses
iqn_readiness_dev_ptr as a CVaR-α pointer (separate semantic overload, tracked
for follow-up — CVaR α=0 is degenerate).
P5T5 Phase G (2026-04-26): vol_normalizer numerical robustness + diagnostic logging. Phase D's aux label-scale EMA treated symptoms only; root cause was epoch_vol_normalizer in training_loop.rs:495 producing wildly different values across machines (local raw=0.541, cluster raw≈1e-7), inflating return features [0..3] up to 50,000× their intended unit-variance scale. Fix: Welford's online variance (numerically stable, single-pass), sanity bounds [1e-5, 1e-1], explicit per-epoch tracing::info! log. Out-of-band raw values trigger tracing::warn! + fall back to 5e-4 default (typical equity-index 1-min vol). multi_fold_convergence smoke (3 folds × 5 epochs, 682.85s): F0=2.3551, F1=80.8206, F2=92.1063 — all positive, F2 strongest result yet. The "targets[0] not raw_close on this dataset" warning surfaces a deeper data-pipeline question (what's actually at index 0 of the 6-tuple) for future investigation; Phase G makes training scale-correct regardless. cargo check clean at 11 warnings.
P5T5 Phase E (2026-04-26): per-fold reset for MetricBandsRegistry regression-detection streaks. Bug found during Phase D smoke — P5T2's registry never cleared consecutive_warn / consecutive_error HashMaps at fold boundaries, so walk-forward folds accumulated streaks across boundaries. F2 epoch in error band tripped termination at "6 consecutive" even though no fold individually crossed 2N=6. Fix: reset_streaks() method clears both HashMaps; called from DQNTrainer::reset_for_fold(). New unit test reset_streaks_clears_consecutive_counters verifies the per-fold isolation. 9/9 monitoring unit tests pass.
Legend:
Wired— consumed by production training + val path.Partial— consumed on one side only (training OR val, or forward OR backward).Orphan— built but no production consumer in the DQN path.Ghost— consumer path is stubbed or only loads the kernel without launching it.OUT-of-DQN-scope— has production consumers outside the DQN path (supervised, PPO, etc.); correct-as-is per Part E.
DQN-path definition: trainers/dqn/ (training loop + fused CUDA ops) + cuda_pipeline/ (GPU kernels wired into those trainers) + validation/ harness when evaluating DQN policy.
Task 2 / Task 3 Seed Rows (preserved)
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
trainers/dqn/state_reset_registry.rs |
training_loop.rs::reset_named_state via fold_reset_entries() + soft_reset_entries() |
Wired | A.1 primary implementation (Plan 1 Task 3) | — |
training_loop.rs::reset_named_state |
consumer of StateResetRegistry::fold_reset_entries + soft_reset_entries |
Wired | A.1 Task 3 dispatch + D.5 SoftReset dispatch | — |
D.5 SoftReset dispatch (isv_grad_balance_targets, isv_grad_scale_limit) |
training_loop.rs::reset_named_state — writes bootstrap values (1.0 for targets, 2.0 for limit). EMA-based convergence from bootstrap over ~50 epochs via grad_balance_isv_update kernel's adaptive-rate EMA (α ∈ [0.01, 0.30], implicit decay_bars). |
Wired | Plan 2 Task 4 D.5 | — |
DQN Core Training Modules
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
trainers/dqn/mod.rs |
train_baseline_rl.rs, ml_training_service, hyperopt/adapters/dqn.rs |
Wired | Entry point for DQN trainer | — |
trainers/dqn/config.rs (DQNHyperparameters, DQNAgentType) |
trainer/constructor.rs, fused_training.rs, smoke tests, hyperopt adapter |
Wired | Core config struct | — |
trainers/dqn/data_loading.rs |
re-exported via dqn::mod; consumed by train_baseline_rl.rs |
Wired | DBN + fxcache loading. OFI window for bar t uses formation interval (close(bar_{t-1}), close(bar_t)] — strictly historical at the policy's decision time. Migrated 2026-04-28 in lockstep with crates/ml/examples/precompute_features.rs:441-475 to kill the lookahead leak documented in docs/lookahead-bias-audit-2026-04-28.md §3 (FXCACHE_VERSION 6→7). |
— |
trainers/dqn/early_stopping.rs (EarlyStopping) |
trainer/mod.rs, trainer/constructor.rs |
Wired | Patience-based stopping | — |
trainers/dqn/features.rs |
trainer/constructor.rs via extract_features_from_bars |
Wired | Feature extraction for DQN | — |
trainers/dqn/financials.rs |
trainer/metrics.rs via compute_epoch_financials |
Wired | Sharpe / drawdown per epoch | — |
trainers/dqn/fused_training.rs (FusedTrainingCtx) |
trainer/training_loop.rs, trainer/mod.rs |
Wired | Core DQN fused-CUDA context | — |
trainers/dqn/lr_scheduler.rs (LRScheduler) |
trainer/mod.rs::lr_scheduler, trainer/constructor.rs |
Wired | Learning-rate schedule | — |
trainers/dqn/monitoring.rs (MonitoringSummary) |
trainer/training_loop.rs, trainer/metrics.rs |
Wired | Per-epoch action / Q monitoring | — |
trainers/dqn/risk.rs |
trainer/constructor.rs via DrawdownMonitor, HybridPositionLimiter |
Wired | Risk controllers in training | — |
trainers/dqn/statistics.rs (FeatureStatistics, QValueStats) |
trainer/mod.rs, trainer/metrics.rs |
Wired | Feature norm + Q stats | — |
trainers/dqn/adversarial_self_play.rs (AdversarialSaboteur) |
trainer/constructor.rs, trainer/mod.rs |
Wired | Adversarial perturbation during training | — |
trainers/dqn/expert_demos.rs (ExpertDemoGenerator) |
trainer/training_loop.rs |
Wired | Expert demo ratio injection | — |
trainers/dqn/trainer/mod.rs (DQNTrainer) |
train_baseline_rl.rs, service layer |
Wired | Top-level trainer struct | — |
trainers/dqn/trainer/constructor.rs |
internal to DQNTrainer |
Wired | Builder / init | — |
trainers/dqn/trainer/action.rs |
internal to DQNTrainer |
Wired | Action selection helpers | — |
trainers/dqn/trainer/enrichment.rs |
internal to DQNTrainer |
Wired | State enrichment | — |
trainers/dqn/trainer/metrics.rs |
training_loop.rs |
Wired | Epoch-boundary metric compilation + backtest eval. Val backtest subsamples every Nth bar (VAL_SUBSAMPLE_STRIDE const, currently 4). The same const is threaded into GpuBacktestConfig::val_subsample_stride so the annualization factor at gpu_backtest_evaluator.rs::new() divides bars_per_day by the stride before sqrt — single source of truth for sample cadence. Migrated 2026-04-28 per docs/lookahead-bias-audit-2026-04-28.md §5 (prior code used unscaled bars_per_day, inflating reported Sharpe by sqrt(stride)). |
— |
trainers/dqn/trainer/state.rs |
trainer/mod.rs |
Wired | Mutable training state | — |
trainers/dqn/trainer/training_loop.rs |
called from DQNTrainer::train() |
Wired | Main epoch loop | — |
trainers/dqn/trainer/monitoring.rs (MetricBandsRegistry, MetricBands, BandSettings, TerminationReason) |
trainer/training_loop.rs (post-HEALTH_DIAG harvest+check), trainer/constructor.rs (auto-load config/metric-bands.toml), smoke_tests/regression_detection.rs |
Wired | Plan 5 Task 2 A.4 — regression-detection hard-stop (2N consecutive error-band epochs → CommonError::RegressionDetected); cold-path (per-epoch); zero hot-path overhead |
— |
crates/common/src/error.rs (CommonError::RegressionDetected) |
returned from training_loop::train_with_data_full_loop_slices; consumed by services/trading_service/src/error.rs::From<TradingServiceError> |
Wired | Plan 5 Task 2 — terminal training error variant; flat band fields (no upward dep on ml crate) | — |
config/metric-bands.toml |
MetricBandsRegistry::load_from_toml (auto-loaded by DQNTrainer::new_internal) |
Wired | Plan 5 Task 2 — per-metric warn/error bands (mean ± 5σ / mean ± 10σ); populated by scripts/populate-metric-bands-from-runs.py from Plan 5 Task 1B aggregate JSONs |
— |
scripts/populate-metric-bands-from-runs.py |
reads aggregate JSONs from scripts/aggregate-multi-seed-metrics.py (Plan 5 Task 1B.2); writes config/metric-bands.toml [bands] section |
Wired | Plan 5 Task 2 helper — N>=3 sample path; N=1 multiplicative fallback | — |
scripts/argo-train.sh --profile + infra/k8s/argo/train-multi-seed-template.yaml (profile parameter, conditional nsys profile wrapper, mc cp upload to foxhunt-training-artifacts/profiles/<sha>/) + infra/docker/Dockerfile.foxhunt-training-runtime (nsight-systems-cli apt install) + infra/k8s/minio/minio.yaml (new foxhunt-training-artifacts bucket) |
invoked manually via ./scripts/argo-train.sh dqn --profile [...]; consumed by scripts/compare-nsys-profiles.py BASELINE CURRENT --epochs N (V0 metric: total cuda_gpu_kern_sum / epochs; V1 NVTX-range path deferred to Plan 5 Task 5) |
Wired | Plan 5 Task 3 A.4.1 — nsys profile harness with regression-comparison script. --profile forces multi-seed render path so dry-run never needs cluster contact (test_nsys_harness.sh); MinIO creds optional on train-single (warn-skips upload if absent). Baseline capture deferred to Plan 5 Task 5 (T5 runs the harness on L40S as part of the multi-seed acceptance pass — avoids burning local GPU time in T3) |
— |
scripts/validation/check_tier1.py + check_tier2.py + check_tier3.py + check_all_tiers.py (+ tests/test_tier_checks.sh + tests/fixtures/{good,bad}_tier1.json) |
reads aggregate JSONs from scripts/aggregate-multi-seed-metrics.py (Plan 5 Task 1B.2); invoked manually as python3 scripts/validation/check_all_tiers.py <metrics.json> [--warmup-end N] and from Plan 5 Task 5's acceptance pass |
Wired | Plan 5 Task 4 — per-tier exit checks for the spec §2 tiered acceptance criteria. Tier 1 (convergence: std/mean ≤ 0.15 on val_sharpe/avg_q_value/train_loss over stable epochs + no avg_q_value > 500 fold-1 explosion + Q-saturation/hot-path-DtoH placeholders); Tier 2 (behavioural: val_trades_per_bar ≥ 0.005, val_active_frac > 0.2, dir entropy > 0.8·log4); Tier 3 (profitability: val_sharpe_annualised > 1.0 with per-bar fallback, val_win_rate ≥ 0.52 gated on >500 trades, val_profit_factor mean ≥ 1.1 AND cross-seed std < 0.3). Stdlib only (no numpy/scipy). Defensive missing-metric handling — each missing aggregate key fails the relevant check with an explanatory message rather than silently passing. Tier-2/tier-3 wiring landed in Plan 5 Task 5 Phase A (val [...] HEALTH_DIAG block + aggregator single-_ joiner) — see next row. |
— |
trainer/metrics.rs::compute_validation_loss (HEALTH_DIAG val [...] block) + scripts/aggregate-multi-seed-metrics.py::parse_blocks (single-_ joiner) + trainer/mod.rs (last_val_metrics: Option<[f32; 14]>) |
Plan 5 Task 4 tier-2/tier-3 check scripts read these as top-level val_* aggregate keys. Block emit pipeline: evaluate_dqn_graphed → WindowMetrics{sharpe, sortino, win_rate, max_drawdown, total_trades, calmar, omega_ratio, total_pnl, var_95, cvar_95, buy/sell/hold_count} (CUDA compute_backtest_metrics 14-float reduction, no new GPU work) → CPU derivations (window_bars = buy+sell+hold; trades_per_bar = total_trades / window_bars; active_frac = (buy+sell) / window_bars; dir_entropy = -Σ p ln p over the 3-bucket {short, hold-or-flat, long} distribution; sharpe_annualised = m.sharpe alias since the kernel already multiplies by sqrt(bars_per_day · 252); profit_factor = m.omega_ratio alias since the kernel's omega is gain_sum / loss_sum with threshold 0, equivalent to per-step PF) → tracing::info!("HEALTH_DIAG[{}]: val [sharpe=… sortino=… win_rate=… max_drawdown=… trade_count=… calmar=… omega_ratio=… total_pnl=… var_95=… cvar_95=… trades_per_bar=… active_frac=… dir_entropy=… sharpe_annualised=… profit_factor=… window_bars=…]", current_epoch, …). The aggregator joins <block>_<key> (single underscore — was __ before T5 Phase A; tier scripts and synthetic test fixtures already used the bare val_* convention) so each emitted key surfaces as a top-level val_* aggregate. Deferred for follow-up (cannot be emitted from existing kernel data): (a) per-direction distribution val_dir_dist_{short,hold,long,flat} — kernel collapses Hold+Flat into hold_count (intentional for the position-sign trade-cycle definition), so dir_entropy here ranges over 3 buckets with max log 3 ≈ 1.099 rather than the spec's 4-bucket log 4 ≈ 1.386 ceiling; tier2 check_dir_entropy compares against 0.8 · log 4 ≈ 1.109 which is unreachable from the 3-bucket distribution. Resolution options: extend WindowMetrics with separate Hold/Flat counts (kernel touch) or lower tier2's threshold to the 3-bucket equivalent 0.8 · log 3 ≈ 0.879. (b) trade-level profit_factor (sum-of-winning-trade-P&L / sum-of-losing-trade-P&L) — currently aliased to per-step omega_ratio; matches the standard PF definition under threshold 0 but a trade-level variant would require boundary-aware per-trade P&L accumulation in the kernel. Cold-path (per validation epoch); zero hot-path overhead; one new tracing line per epoch. |
— | ||
cuda_pipeline/aux_heads_loss_ema_kernel.cu::aux_label_scale_ema_update + cuda_pipeline/aux_heads_kernel.cu::{aux_next_bar_loss_reduce, aux_next_bar_backward} (kernel signatures grow +isv_dev_ptr+isv_label_scale_index args, divide label by max(isv[117], 1e-6) before residual) + cuda_pipeline/gpu_aux_heads.rs (orchestrator: launch_label_scale_ema method + next_bar_loss_reduce / backward_next_bar arg refactor) + cuda_pipeline/gpu_dqn_trainer.rs (AUX_LABEL_SCALE_EMA_INDEX=117, ISV_TOTAL_DIM 117→118, fingerprint seed +AUX_LABEL_SCALE_EMA=117/ISV_TOTAL_DIM=118, aux_heads_forward Step 2b producer launch, Step 5 + aux_heads_backward pass isv_signals_dev_ptr + slot index) + state_reset_registry.rs (isv_aux_label_scale_ema FoldReset → 1.0) + trainer/training_loop.rs::reset_named_state dispatch arm + HEALTH_DIAG aux line +label_scale={:.3e} field |
producer per training step alongside the rest of the Plan 4 ISV producer family; consumer per-step in the captured forward + backward graphs (graph-capture-stable: ISV device pointer + slot index pair both stable, scalar value updates each step via the producer) | Wired | Plan 4 Task 6 / Plan 5 Task 5 follow-up — defends the aux next-bar regression head against underlying-data scale (label = next_states[:, 0], which carries log returns ~1e-3 in local fxcache but raw price ~5000 in the L40S fxcache). First L40S deploy attempt (workflow train-multi-seed-7j8zc, terminated) produced aux next_bar_mse = 2.587e7 and grad_norm=126,769 because the unnormalised label dominated the residual; the trunk then learned garbage off the corrupted aux-gradient SAXPY into bw_d_h_s2. Fix is principled per feedback_adaptive_not_tuned.md + feedback_isv_for_adaptive_bounds.md: ISV-driven label-scale EMA + normalize-before-MSE, NOT a tuned constant clamp or a label-source change (label still = next_states[:, 0] per spec §4.E.6). Cold-start 1.0 (multiplicative identity, NOT 0.0 which would divide-by-zero on first batch); FoldReset → 1.0; per-step EMA α=0.05 matches the rest of the Plan 4 producer family. Layout fingerprint shifts from 0x26f7b1deb94cb226 to 0x829bc87b42f2feee — checkpoint-incompatible (no migrator per spec §4.A.2). NEW BASELINE: aux next_bar_mse value is now O(1.0) NOT O(1e-4) — the pre-fix tiny value was a numerical artefact of the unnormalised label being so much smaller than pred ≈ 0 at init that the residual was just (0 - 1e-3)^2 ≈ 1e-6 → mean 1e-4. The new value is the actual per-residual magnitude after normalisation. |
— |
cuda_pipeline/mod.rs::{global_seed, mix_seed} + examples/train_baseline_rl.rs (--seed N CLI arg, default 42; sets FOXHUNT_SEED env at startup) + infra/k8s/argo/train-multi-seed-template.yaml (drops fold parameter; binary invoked with --seed "$SEED" --max-folds {{workflow.parameters.folds}}) + scripts/argo-train.sh (matrix generator: N tasks instead of N*K) + scripts/tests/test_multi_seed_harness.sh (asserts N tasks + --max-folds placeholder + no --fold) |
call sites: trainer/action.rs (GpuActionSelector seed + epsilon-greedy StdRng), cuda_pipeline/gpu_iqn_head.rs (Xavier init RNG), cuda_pipeline/gpu_iql_trainer.rs (Xavier init RNG), cuda_pipeline/gpu_her.rs (random-donor RNG), cuda_pipeline/gpu_ppo_collector.rs (rng_seeds for PPO experience), trainer/training_loop.rs::set_regime_dropout_seed (per-epoch dropout seed) |
Wired | Plan 5 Task 5 Phase B — architectural pivot from N×K (seed,fold) Argo fanout to N seed-only fanout. The first L40S deploy attempt (workflow train-multi-seed-z2llf, terminated) failed at startup with error: unexpected argument '--fold' found on every job: train_baseline_rl is a multi-fold walk-forward executor that accepts --max-folds K, NOT --fold N. Pivot reduces fanout from N×K=30 → N=5 (matches L40S pool capacity better) and trades K× longer per-job runtime for a simpler binary contract. The seed-variation mechanism: train_baseline_rl --seed N sets FOXHUNT_SEED=N env at startup BEFORE any CUDA module spins up; every previously-fixed RNG seed across the call sites listed above now mixes the global seed via mix_seed(<historic_constant>) (SplitMix64 avalanche so adjacent N produce uncorrelated module seeds). Default --seed 42 documents the historic implicit value. Verified end-to-end on RTX 3050 Ti: with seed=42, F0 best-Sharpe=-9.7831 + best_val_metric=1.957244 (matches the prompt's expected baseline); with seed=999, F0 best-Sharpe=92.9341 + best_val_metric=2.161012 — different trajectories, proving the seed propagates through the RNG init paths and is not just accepted-and-ignored. Backward-compat: existing single-job argo-train.sh callers (no --multi-seed) route to train-template.yaml unchanged. |
— |
trainers/dqn/adaptive_monitor.rs |
Read-only observer trait + harness (FireRateStats, DiagSnapshot, IsvBus<'a>); consumers added in Plan 1 Tasks 9-17 (atoms/gamma/kelly_cap/tau/epsilon/grad_balancer monitors) | Wired (consumers added in same plan) | C.6 GPU-drives-CPU-reads | — |
trainers/dqn/monitors/grad_balancer_monitor.rs |
Read-only observer for grad_balance_isv_update kernel output (ISV slots 31..35); consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 1 Task 17 | — |
trainers/dqn/monitors/tau_monitor.rs |
Read-only observer for tau_update kernel output (ISV slot 42); consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 1 Task 13 | — |
cuda_pipeline/tau_update_kernel.cu |
GpuDqnTrainer::launch_tau_update → FusedTrainingCtx::launch_tau_update → training_loop.rs epoch boundary |
Wired | Plan 1 Task 13 — cold-path (per-epoch) | — |
trainers/dqn/monitors/epsilon_monitor.rs |
Read-only observer for epsilon_update kernel output (ISV slot 41); consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 1 Task 14 | — |
cuda_pipeline/epsilon_update_kernel.cu |
GpuDqnTrainer::launch_epsilon_update → FusedTrainingCtx::launch_epsilon_update → training_loop.rs epoch boundary |
Wired | Plan 1 Task 14 — cold-path (per-epoch) | — |
trainers/dqn/monitors/gamma_monitor.rs |
Read-only per-branch observer; mean of ISV[43..47) = [GAMMA_DIR,MAG,ORD,URG]; consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 2 Task 3 D.2 | — |
cuda_pipeline/per_branch_gamma_update_kernel.cu |
GpuDqnTrainer::launch_per_branch_gamma_update → FusedTrainingCtx::launch_per_branch_gamma_update → training_loop.rs epoch boundary |
Wired | Plan 2 Task 3 D.2 — cold-path (per-epoch); replaces scalar gamma_update_kernel.cu | — |
trainers/dqn/monitors/kelly_cap_monitor.rs |
Read-only observer for kelly_cap_update kernel output (ISV slot 47); consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 1 Task 11 | — |
cuda_pipeline/kelly_cap_update_kernel.cu |
GpuDqnTrainer::launch_kelly_cap_update → FusedTrainingCtx::launch_kelly_cap_update → training_loop.rs epoch boundary; takes portfolio_states dev ptr + n_envs |
Wired | Plan 1 Task 11 — cold-path (per-epoch) | — |
trainers/dqn/monitors/atoms_monitor.rs |
Read-only observer for atoms_update kernel inputs (ISV v-range slots 23..31); consumers: HEALTH_DIAG + controller_activity smoke | Wired | Plan 1 Task 9 (test initial-value fixup post-land; local-smoke StateResetRegistry dispatch arms + hp_f32 helper added) | — |
cuda_pipeline/atoms_update_kernel.cu |
GpuDqnTrainer::launch_atoms_update → FusedTrainingCtx::launch_atoms_update → training_loop.rs epoch boundary; replaces per-branch recompute_atom_positions CPU loop |
Wired | Plan 1 Task 9 — cold-path (per-epoch + SGD step) | — |
cuda_pipeline/q_quantile_kernel.cu |
GpuDqnTrainer::launch_q_quantile_reduce → FusedTrainingCtx::launch_q_quantile_reduce → training_loop.rs epoch boundary; reads q_out_buf [N, total_actions], writes ISV[50..58) P5/P95 EMAs per branch |
Wired | Plan 2 Task 1 C.1 — cold-path (per-epoch); consumer: update_eval_v_range reads Q_P05/Q_P95 to set quantile-based half-width |
— |
trainers/dqn/monitors/reward_component_monitor.rs |
Read-only observer for reward_component_ema kernel output (ISV slots 63..69); consumers: HEALTH_DIAG reward_split line + controller_activity smoke fire-rate tracking |
Wired | Plan 3 Task 1 C.2 | — |
cuda_pipeline/reward_component_ema_kernel.cu |
GpuExperienceCollector::launch_reward_component_ema_inplace → training_loop.rs (called after reward_contrib_fractions, before HEALTH_DIAG); reads reward_components_per_sample [N*L, 6], writes ISV[63..69) adaptive EMA (α=0.05) |
Wired | Plan 3 Task 1 C.2 — hot-path (per-step); single-block 6-thread kernel | — |
B.1 Flat opp-cost ISV scaling (experience_kernels.cu Flat branch) |
experience_env_step kernel (Flat position=0, not segment_complete branch); writes rc[4] to reward_components_per_sample; consumed by reward_component_ema → ISV[67] |
Wired | Plan 3 Task 2 B.1 — opp-cost multiplied by isv_signals_ptr[21] (Q_DIR_ABS_REF_INDEX, EMA of max( |
Q_mean |
cuda_pipeline/trade_rate_ema_kernel.cu |
GpuExperienceCollector::launch_trade_attempt_rate_ema_inplace → training_loop.rs (called alongside launch_reward_component_ema_inplace each epoch); reads flat_to_pos_per_sample [N*L], reduces count on-GPU, writes ISV[TRADE_ATTEMPT_RATE_EMA_INDEX=71] adaptive EMA (α=α_base × (1+0.5×|sharpe|), α_base=0.05) |
Wired | Plan 3 Task 3 B.2 — cold-path (per-epoch); single-block 1-thread reduction + EMA. No atomicAdd. | — |
B.2 Flat→Positioned novelty bonus (experience_kernels.cu trade-lifecycle block) |
experience_env_step kernel (entering_trade path, AFTER opp-cost branch, BEFORE drawdown penalty); writes rc[5] and adds shaping_scale × bonus to reward; consumed by reward_component_ema → ISV[68] and drives PopArt denominator via total_reward_per_sample |
Wired | Plan 3 Task 3 B.2 — novelty = max(0, 1 − ISV[71]/max(1e-4, ISV[72])); bonus = conviction_core × vol_proxy × novelty; TRADE_TARGET_RATE frozen at epoch 5 from measured attempt-EMA (min 0.001). No tuned multiplier. |
— |
C.4 Temporal timing bonus on trade exit (experience_kernels.cu segment_complete block) |
experience_env_step kernel (inside segment_complete && segment_hold_time > 0 block, AFTER Layer 2–9 credits land in reward); accumulates += into rc[5] and adds timing_bonus to reward; consumed by reward_component_ema → ISV[68] (shares the bonus slot with B.2 — different (i,t) slots, so += is idempotent) |
Wired | Plan 3 Task 5 C.4 — bars_early = max(0, segment_hold_time − PS_PEAK_PNL_BAR); timing_bonus = shaping_scale × (bars_early / segment_hold_time) × |final_pnl| × conviction_core. Peak bar tracked in new portfolio-state slot PS_PEAK_PNL_BAR=38 (PS_STRIDE 38→39), snapshot alongside every MAX_PNL update, reset at every MAX_PNL reset site (entry/reverse/fold-reset). No tuned multiplier. | — |
D.4a Persistence credit on profitable drawdown recovery (experience_kernels.cu segment_complete block) |
experience_env_step kernel (inside segment_complete && segment_hold_time > 0 block, IMMEDIATELY AFTER C.4 timing bonus, gated on reward > 0 AND drawdown_depth > 1e-6); accumulates += into rc[5] and adds persist_bonus to reward; consumed by reward_component_ema → ISV[68] (shares the bonus slot with B.2 entry + C.4 exit — different (i,t) slots per trade, so += is idempotent across all three) |
Wired | Plan 3 Task 6a D.4a — drawdown_depth = max(0, −PS_INTRA_TRADE_MIN_PNL); persist_bonus = shaping_scale × conviction_core × drawdown_depth × tanh(reward / max(1e-4, drawdown_depth)). MIN_PNL tracked in new portfolio-state slot PS_INTRA_TRADE_MIN_PNL=39 (PS_STRIDE 39→40), updated per-bar in the same block as MAX_PNL (fminf against pnl_pct), reset at every MAX_PNL reset site (entry/reverse/fold hard-reset/trade-complete soft-reset). Self-scaling tanh: saturates +1 when reward ≫ drawdown, ≈0 when reward ≈ drawdown. No tuned multiplier. |
— |
D.4b Regime-shift penalty for trades held past a regime flip (experience_kernels.cu segment_complete block) |
experience_env_step kernel: detector inside the active-trade block (after MIN/MAX_PNL update) writes PS_REGIME_SHIFT_BAR on the FIRST bar where |isv[11] − PS_PLAN_ENTRY_REGIME| > clamp(0.25 × |clamp(sharpe, ±2)|, 0.05, 0.5); consumer inside segment_complete block IMMEDIATELY AFTER D.4a accumulates −= into rc[5] and subtracts penalty from reward; consumed by reward_component_ema → ISV[68] (cancellation within rc[5] vs B.2/C.4/D.4a is intentional — that's what ISV[68] REWARD_BONUS_EMA tracks). Consumer also resets PS_REGIME_SHIFT_BAR = 0 after use. |
Wired | Plan 3 Task 6b D.4b — bars_late = max(0, saved_hold_time − PS_REGIME_SHIFT_BAR); penalty = shaping_scale × conviction_core × (bars_late / saved_hold_time) × ISV[Q_DIR_ABS_REF_INDEX=21] × |reward|. Shift-bar tracked in new portfolio-state slot PS_REGIME_SHIFT_BAR=40 (PS_STRIDE 40→41), reset at every MAX_PNL reset site (entry/reverse/fold hard-reset/trade-complete soft-reset) PLUS post-consumer (defensive). Self-scaling Q_DIR_ABS_REF coefficient (same B.1 pattern). Adaptive threshold tightens when |sharpe| is high. First-shift-only. No tuned multiplier. | — |
D.4c Conviction consistency bonus on Flat→Positioned entry (experience_kernels.cu Flat branch + entering_trade block) |
experience_env_step kernel: producer inside the Flat branch (!segment_complete && position≈0, before opp-cost) updates two PS slots PS_PRE_ENTRY_CONVICTION_EMA (mean) and PS_PRE_ENTRY_CONVICTION_VAR_EMA (variance) via Welford-style EMA at α=0.05 on conviction_core. Consumer inside entering_trade block IMMEDIATELY AFTER B.2 novelty bonus computes ratio = stddev/mean; when ratio < 0.2 accumulates += into rc[5] and adds bonus to reward; consumed by reward_component_ema → ISV[68] (shares the bonus slot with B.2/C.4/D.4a/D.4b at different (i,t) slots; += is idempotent). Both EMA slots reset at every trade-lifecycle reset site (entry/reverse/fold hard-reset/trade-complete soft-reset) PLUS post-consumer (defensive). |
Wired | Plan 3 Task 6c D.4c — bonus = shaping_scale × vol_proxy × stability × conviction_core where vol_proxy ∈ [0.0001, 0.01], stability = clamp(0, 1, 1 − ratio/0.2), conviction_core ∈ [0,1]. Mirrors B.2 novelty-bonus structure exactly — ONE technically unbounded multiplicand (vol_proxy, bounded ≤ 0.01), all others bounded — per pearl_one_unbounded_signal_per_reward.md. Max bonus ≈ 0.01, same order as B.2. New portfolio-state slots PS_PRE_ENTRY_CONVICTION_EMA=41 and PS_PRE_ENTRY_CONVICTION_VAR_EMA=42; PS_STRIDE 41→43; PORTFOLIO_STRIDE 41→43 in all consumers (experience_kernels.cu, trade_stats_kernel.cu, gpu_experience_collector.rs, gpu_dqn_trainer.rs::launch_kelly_cap_update, ml-core::state_layout.rs). The 0.2 stability threshold is the single tuned constant retained for this first cut (commented; reviewer may demote to ISV-driven follow-up). α=0.05 matches Plan 3 Task 1 reward-EMA convention. |
— |
cuda_pipeline/plan_threshold_update_kernel.cu |
GpuExperienceCollector::launch_plan_threshold_update_inplace → training_loop.rs (called alongside launch_reward_component_ema_inplace and launch_trade_attempt_rate_ema_inplace each epoch); reads readiness_per_sample [N*L] written by experience_env_step, reduces mean on-GPU, writes ISV[READINESS_EMA_INDEX=75] adaptive EMA (α=α_base × (1+0.5×|sharpe|), α_base=0.05), then derives ISV[PLAN_THRESHOLD_INDEX=49] = max(0.1, 0.5 × ema). Producer-only upgrade — consumer kernels (experience_kernels.cu 4 sites + backtest_plan_kernel.cu 1 site) unchanged. |
Wired | Plan 3 Task 4 B.4 — cold-path (per-epoch); single-block 1-thread reduction + EMA. No atomicAdd. | — |
trainers/dqn/monitors/plan_threshold_monitor.rs |
Read-only observer for plan_threshold_update kernel output (ISV slots 49 + 75); consumers: HEALTH_DIAG plan_threshold.eff / plan_threshold.readiness_ema + controller_activity smoke fire-rate tracking |
Wired | Plan 3 Task 4 B.4 | — |
cuda_pipeline/state_kl_divergence_kernel.cu |
GpuExperienceCollector::launch_state_kl_inplace → training_loop.rs (called once per validation epoch, AFTER compute_validation_loss populates chunked_states_buf, BEFORE next training epoch's reward kernels read ISV[79]); reads train-state sample (states_out) and val-state batch (chunked_states_buf from gpu_evaluator.val_state_sample()), per-OFI-dim Gaussian moment-match KL summed across [SL_OFI_START, SL_OFI_START+SL_OFI_DIM); writes ISV[STATE_KL_TRAIN_VAL_EMA=78] (adaptive EMA, α_base=0.05) + ISV[STATE_KL_AMPLIFICATION=79] (kernel-internal trailing-self ratio → 1 + clamp(ratio−1, 0, 1) ∈ [1,2]). Single-block, one thread per OFI dim. No atomicAdd, no DtoH. |
Wired | Plan 3 Task 7 C.3 — cold-path (per validation epoch). All α/threshold coefficients ISV-derived; no tuned constants per feedback_isv_for_adaptive_bounds.md. |
— |
C.3 B.1/B.2 KL-amp consumers (experience_kernels.cu Flat opp_cost + entering_trade B.2 bonus) |
experience_env_step kernel: B.1 site multiplies reward = -shaping_scale × holding_cost_rate × conviction_core × vol_proxy_flat × q_abs_ref × kl_amp_b1; B.2 site multiplies bonus_scaled = shaping_scale × bonus × kl_amp_b2. Both consumers use fmaxf(1.0, isv_signals_ptr[ISV_STATE_KL_AMP_IDX]) to no-op against cold-start. ISV_STATE_KL_AMP_IDX=79 macro defined in state_layout.cuh. |
Wired | Plan 3 Task 7 C.3 — bounded amp ∈ [1, 2] stacks safely with B.1's existing q_abs_ref unbounded multiplicand per pearl_one_unbounded_signal_per_reward.md. |
— |
trainers/dqn/monitors/state_kl_monitor.rs |
Read-only observer for state_kl_moment_match kernel output (ISV slots 78 + 79); consumers: HEALTH_DIAG state_kl.train_val_ema / state_kl.amp + controller_activity smoke fire-rate tracking |
Wired | Plan 3 Task 7 C.3 | — |
cuda_pipeline/scripted_policy_kernel.cu |
GpuExperienceCollector::launch_timestep_loop (per-timestep dispatch when seed_phase_active_cache=true); 4 policies (UNIFORM 40% / MOMENTUM 20% / MEAN_REV 20% / VWAP_DEV 20%) deterministically mixed by i % 5. Per-thread one sample. Reads batch_states[i, MARKET_START] for current close + portfolio_states[i, PS_PREV_CLOSE] for prev close; writes batch_actions[i] (factored dir*27 + mag*9 + ord*3 + urg) and conviction_buf[i] ∈ [0, 1] — same outputs the network's experience_action_select would have written. Direction-flip thresholds (±0.0001f momentum/reversal, ±5bp VWAP band) are noise-floor cutoffs, not scaling coefficients. |
Wired | Plan 3 Task 8 B.3 — GPU-only seeded warm-start. CPU only orchestrates per-epoch dispatch (cold-path read of ISV[SEED_STEPS_DONE/TARGET]); the action computation itself is 100% GPU. No CPU physics mirror — existing experience_env_step runs unchanged. |
— |
C51 bias C.1 ISV-adaptive direction-Boltzmann tau floor (experience_kernels.cu experience_action_select direction branch) |
experience_action_select Branch 0 direction softmax: tau_d = max(q_range, max(ISV[Q_DIR_ABS_REF_INDEX=21], 0.01)). Replaces static 0.01 floor (tuned constant, dangerous when Q-magnitudes grow during training: spread that's small in absolute terms becomes deterministic argmax even though it represents no real edge relative to scale). Now scales with the network's actual Q magnitude EMA, preserving relative spread for sampling and preventing the C51 expected-Q Hold/Flat attractor that the val-Flat-collapse Kelly fix exposed (val_dir_dist S=.23/H=.17/L=.42/F=.18 epoch 1 → S=.14/H=.35/L=.15/F=.37 epoch 2 = 35→72% Hold+Flat shift in one epoch). Cold-start fallback retains 0.01 minimum so kernel doesn't divide by zero pre-first-update. Same ISV[21] reference used by conviction below — coherent adaptive mechanism. NOTE: empirical verification (train-bscl2) showed this tau-floor change had no measurable effect on the post-training Hold/Flat collapse — once Q-values reflect tx_cost-driven aversion, sampling stays biased regardless of tau. Retained as cold-start protection; bias breakout handled by the eps_dir adaptive floor below. |
Wired | val-collapse follow-up — C51 expected-Q bias fix (cold-start protection only). ISV-driven, no tuned constants per feedback_isv_for_adaptive_bounds.md and feedback_adaptive_not_tuned.md. |
— |
C51 bias C.2 trade-attempt-rate-driven adaptive eps_dir floor (experience_kernels.cu experience_action_select eps floor block) |
experience_action_select Branch 0 direction floor: eps_dir = max(eps_dir, 0.5 × passive_pressure) where passive_pressure = clamp(0, 1, 1 − ISV[71]/max(ISV[72], 1e-4)). Reads TRADE_ATTEMPT_RATE_EMA_INDEX=71 (current Flat→Positioned rate) and TRADE_TARGET_RATE_INDEX=72 (target rate frozen at epoch 5 from measured EMA). When the policy collapses to passive (zero trade attempts), eps_dir floor rises to 0.5 — half random direction sampling — forcing enough Long/Short experiences into the replay buffer for Q-values to discover real edge. At target rate or above, passive_pressure=0, eps_dir at baseline 0.02 floor. Direction branch only: magnitude/order/urgency don't have the Flat-attractor problem (Kelly cap shapes magnitude realisation; order/urgency operate on top of already-decided directional picks). The 0.5 ceiling is a structural blend point (half random / half policy), not a tuned magnitude — maximum exploration that still preserves directional Q-signal propagation through the replay buffer. Active in training only (eval_mode skips eps logic entirely so val remains deterministic). |
Wired | val-collapse follow-up — C51 expected-Q bias breakout. ISV-driven feedback control via existing trade-rate EMA (B.2 producer); no new ISV slots. | — |
cuda_pipeline/seed_step_counter_update_kernel.cu |
GpuExperienceCollector::launch_seed_step_counter_update_inplace → training_loop.rs (called alongside the other Plan 3 EMA producers each epoch); single-block single-thread cold-path. Increments ISV[SEED_STEPS_DONE_INDEX=83] by n_samples, capped at ISV[SEED_STEPS_TARGET_INDEX=82], and EMAs the derived max(0, 1 - DONE/TARGET) into ISV[SEED_FRAC_EMA_INDEX=84]. Adaptive α=α_base × (1+0.5×|clamp(sharpe,−2,2)|), α_base=0.05. |
Wired | Plan 3 Task 8 B.3 — cold-path (per-epoch). No atomicAdd. SEED_FRAC_EMA cold-start 1.0 ensures Task 9's CQL ramp sees target=0 until the seed phase actually decays. |
— |
cuda_pipeline/cql_alpha_seed_update_kernel.cu |
GpuExperienceCollector::launch_cql_alpha_seed_update_inplace → training_loop.rs (called alongside launch_seed_step_counter_update_inplace each epoch); single-block single-thread cold-path. EMAs ISV[CQL_ALPHA_INDEX=48] toward cql_alpha_final × max(0, 1 - ISV[SEED_FRAC_EMA_INDEX=84]) where cql_alpha_final = config.cql_alpha. Adaptive α matches Task 8 convention. |
Wired | Plan 3 Task 9 C.5 — producer upgrade for ISV[CQL_ALPHA_INDEX=48]: SchemaContract → FoldReset. CQL gradient kernel consumer (already reading slot 48 per Plan 1 Task 12) unchanged. | — |
trainers/dqn/monitors/seed_monitor.rs |
Read-only observer for seed_step_counter_update kernel output (ISV slots 82 / 83 / 84); consumers: HEALTH_DIAG seed.steps_target / seed.steps_done / seed.frac_ema + controller_activity smoke fire-rate tracking |
Wired | Plan 3 Task 8 B.3 | — |
trainers/dqn/monitors/cql_alpha_monitor.rs |
Read-only observer for cql_alpha_seed_update kernel output (ISV slot 48 + dependency 84); consumers: HEALTH_DIAG cql_alpha.eff / cql_alpha.seed_frac + controller_activity smoke fire-rate tracking |
Wired | Plan 3 Task 9 C.5 | — |
cuda_pipeline/attention_focus_ema_kernel.cu |
GpuExperienceCollector::launch_attention_focus_ema_inplace → training_loop.rs (called once per epoch at the HEALTH_DIAG site, BEFORE the diag emit so slots are fresh); single-block single-thread cold-path. Takes 3 host scalars by value: vsn_mag/vsn_dir from FusedTrainingCtx::per_branch_vsn_mean() and mamba2_retention from FusedTrainingCtx::mamba2_retention_mean(batch) (host-side dtoh of mamba2_h_enriched, mirror of per_branch_vsn_mean). EMA-updates ISV[VSN_MAG_EMA_INDEX=87] / ISV[VSN_DIR_EMA_INDEX=88] / ISV[MAMBA2_RETENTION_EMA_INDEX=89] using adaptive α=α_base × (1+0.5×|clamp(sharpe, −2, 2)|), α_base=0.05. |
Wired | Plan 4 Task 5 Mode A E.5 — diagnostic only; no consumer kernel reads slots [87..90) in Mode A. Mode B (post-E.1 full VSN) will tail-append 4 more per-feature-group slots. | — |
trainers/dqn/monitors/attention_monitor.rs |
Read-only observer for attention_focus_ema_update kernel output (ISV slots 87 / 88 / 89); consumers: HEALTH_DIAG attention.vsn_mag / attention.vsn_dir / attention.mamba2_retain + (when surfaced) controller_activity smoke fire-rate tracking. Anchor read on slot 88 (VSN_DIR_EMA). |
Wired | Plan 4 Task 5 Mode A E.5 | — |
CUDA Pipeline — Rust Wrappers
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
cuda_pipeline/mod.rs (DqnGpuData, upload_slices) |
trainer/constructor.rs, fused_training.rs |
Wired | Data upload to GPU | — |
cuda_pipeline/gpu_dqn_trainer.rs (GpuDqnTrainer) |
fused_training.rs, trainer/mod.rs |
Wired | Core GPU-side DQN weight / forward / grad ops | — |
cuda_pipeline/fused_training.rs wrapper (FusedTrainingCtx) |
trainer/training_loop.rs |
Wired | Wires all GPU ops into epoch loop | — |
cuda_pipeline/batched_forward.rs |
gpu_dqn_trainer.rs — forward graph node |
Wired | Batched SGEMM forward pass | — |
cuda_pipeline/state_encoder.rs (StateEncoder) |
Borrows CublasGemmSet and routes through BatchedForward::encoder_forward_only; coexists with the monolithic forward_online_raw path used by gpu_dqn_trainer.rs |
Wired (additive — coexists with forward_online_raw monolithic path) |
Plan 4 Task 4 E.4 — explicit trunk-encoder boundary; attachment point for Plan 4 Task 1 (Full VSN, pre-encoder) and follow-up per-branch experiments | — |
cuda_pipeline/value_decoder.rs (ValueDecoder, Branch, BranchDecoderOutput) |
Borrows CublasGemmSet and routes through BatchedForward::decoder_forward_only; one instance per branch (Dir/Mag/Ord/Urg). Coexists with the multi-stream branch fork/join in forward_online_raw |
Wired (additive — coexists with forward_online_raw monolithic path) |
Plan 4 Task 4 E.4 — per-branch value-decoder boundary; attachment point for Plan 4 Task 3 (multi-Q IQN) and Task 6 (auxiliary heads). Surfaces Plan 2 D.3 V_short / V_long pointers via BranchDecoderOutput |
— |
cuda_pipeline/batched_backward.rs |
gpu_dqn_trainer.rs — backward graph node |
Wired | Batched SGEMM backward pass (KAN gate included) | — |
cuda_pipeline/shared_cublas_handle.rs |
fused_training.rs, gpu_iqn_head.rs, gpu_iql_trainer.rs, gpu_attention.rs, gpu_curiosity_trainer.rs (10 consumers) |
Wired | Shared cuBLAS/cuBLASLt handle | — |
cuda_pipeline/cublas_algo_deterministic.rs |
gpu_dqn_trainer.rs, gpu_curiosity_trainer.rs, gpu_iqn_head.rs (7 consumers) |
Wired | Deterministic cuBLASLt algo selection | — |
cuda_pipeline/gpu_weights.rs |
trainer/mod.rs, gpu_dqn_trainer.rs, hyperopt/adapters/dqn.rs (11 consumers) |
Wired | Weight tensor layout constants | — |
cuda_pipeline/gpu_action_selector.rs (GpuActionSelector) |
trainer/mod.rs, fused_training.rs, gpu_backtest_evaluator.rs |
Wired | Epsilon-greedy + routed action selection. UCB count-bonus buffers (exposure/order/urgency) are mapped pinned per feedback_no_htod_htoh_only_mapped_pinned.md — eliminates per-call HtoD memcpys in set_count_bonuses (HOT). |
— |
cuda_pipeline/gpu_monitoring.rs (GpuMonitor) |
fused_training.rs, trainer/metrics.rs, trainer/training_loop.rs |
Wired | GPU-side monitoring_reduce launch | — |
cuda_pipeline/gpu_training_guard.rs |
gpu_dqn_trainer.rs, trainer/training_loop.rs |
Wired | NaN / gradient anomaly guard | — |
cuda_pipeline/gpu_backtest_evaluator.rs (GpuBacktestEvaluator) |
trainer/metrics.rs (eval path) |
Wired | Per-epoch GPU backtest evaluation | — |
cuda_pipeline/gpu_experience_collector.rs |
fused_training.rs via TradeStats, financials.rs |
Wired | Per-trade stats from experience buffer | — |
cuda_pipeline/gpu_curiosity_trainer.rs |
fused_training.rs |
Wired | Curiosity-driven intrinsic reward | — |
cuda_pipeline/gpu_walk_forward.rs (GpuWalkForwardData) |
trainer/mod.rs (lazy-init field) |
Wired | GPU walk-forward data structure | — |
cuda_pipeline/gpu_her.rs (GpuHer) |
fused_training.rs |
Wired | Hindsight Experience Replay | — |
cuda_pipeline/gpu_iql_trainer.rs (GpuIqlTrainer) |
fused_training.rs (dual IQL instances) |
Wired | IQL value network training | — |
cuda_pipeline/gpu_iqn_head.rs (GpuIqnHead) |
fused_training.rs |
Wired | IQN distributional head | — |
cuda_pipeline/gpu_attention.rs (GpuAttention) |
fused_training.rs |
Wired | Self-attention layer in DQN trunk | — |
cuda_pipeline/gpu_tlob.rs (GpuTlob) |
fused_training.rs (training: forward pre-trunk, backward+Adam Phase 6); trainer/metrics.rs (val: set_tlob_from_training per-epoch weight sync + GpuBacktestEvaluator::submit_dqn_step_loop_cublas per-gather TLOB forward) |
Wired | D.8 Plan 2 Task 6C — OFI[32]→TLOB[16] single-head SDP attention, Xavier random init, trained end-to-end. ISV[60]=TLOB_REGIME_FOCUS_EMA written at epoch boundary. | — |
cuda_pipeline/decision_transformer.rs (DecisionTransformer) |
trainer/training_loop.rs |
Wired | Decision Transformer pre-training step | — |
cuda_pipeline/learning_health.rs (LearningHealth) |
fused_training.rs, trainer/training_loop.rs, trainer/constructor.rs, trainer/metrics.rs, meta_q_network.rs (11 consumers) |
Wired | Health-band tracking for adaptive LR / early stopping | — |
cuda_pipeline/q_snapshot.rs |
cuda_pipeline/mod.rs, gpu_dqn_trainer.rs |
Wired | Q-value snapshot for double-DQN target | — |
cuda_pipeline/meta_q_network.rs (MetaQNetwork) |
trainer/constructor.rs, trainer/mod.rs, trainer/training_loop.rs |
Wired | Meta-learning Q-network | — |
cuda_pipeline/q_value_provider.rs (QValueProvider trait) |
fused_training.rs impl, trainer/metrics.rs, hyperopt/adapters/dqn.rs |
Wired | Trait for on-device Q-value computation in val path | — |
cuda_pipeline/signal_adapter.rs |
hyperopt/adapters/dqn.rs, hyperopt/adapters/{ppo,mamba2,tft,kan,liquid,tlob,diffusion,tggn,xlstm}.rs |
Wired | backtest_fitness scoring used by all hyperopt adapters |
— |
cuda_pipeline/multi_gpu.rs (MultiGpuConfig) |
trainer/constructor.rs, trainer/mod.rs |
Wired | Multi-GPU detection and layout | — |
cuda_pipeline/gpu_ppo_collector.rs (GpuPpoExperienceCollector) |
trainers/ppo.rs |
OUT-of-DQN-scope | PPO-only consumer; correct-as-is | — |
cuda_pipeline/gpu_statistics.rs (GpuStatistics) |
declared pub in cuda_pipeline/mod.rs; no call site outside the file |
Orphan | GpuStatistics::compute never called; BatchStatistics struct unused |
Plan 2 D.2 audits + decides whether to wire into val path or delete |
cuda_pipeline/cublaslt_debug.rs |
mod cublaslt_debug (private) inside cuda_pipeline/mod.rs; test-only |
OUT-of-DQN-scope | Private debug/test module; not a public feature | — |
CUDA Kernels (.cu files)
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
epsilon_greedy_kernel.cu (epsilon_greedy_select, epsilon_greedy_routed) |
gpu_action_selector.rs |
Wired | Action selection kernel | — |
monitoring_kernel.cu (monitoring_reduce) |
gpu_monitoring.rs |
Wired | 12-bin action count + Q stats | — |
c51_loss_kernel.cu |
gpu_dqn_trainer.rs (C51 loss compute) |
Wired | C51 distributional loss | — |
c51_grad_kernel.cu |
gpu_dqn_trainer.rs (C51 backward) |
Wired | C51 gradient backward | — |
mse_loss_kernel.cu |
gpu_dqn_trainer.rs (MSE warmup loss) |
Wired | MSE loss for warmup phase | — |
mse_grad_kernel.cu |
gpu_dqn_trainer.rs (MSE backward) |
Wired | MSE gradient backward | — |
iqn_dual_head_kernel.cu |
gpu_iqn_head.rs |
Wired | IQN dual-head quantile computation | — |
iqn_cvar_kernel.cu |
gpu_iqn_head.rs |
Wired | IQN CVaR risk measure | — |
iql_value_kernel.cu |
gpu_iql_trainer.rs |
Wired | IQL value network kernel | — |
cql_grad_kernel.cu |
gpu_dqn_trainer.rs (CQL backward) |
Wired | Conservative Q-learning gradient | — |
experience_kernels.cu |
gpu_backtest_evaluator.rs (BACKTEST_DQN_CUBIN), gpu_experience_collector.rs |
Wired | Experience buffer ops + DQN backtest forward | — |
reward_shaping_kernel.cu |
gpu_dqn_trainer.rs (reward shaping step) |
Wired | Per-sample reward shaping | — |
nstep_kernel.cu |
gpu_dqn_trainer.rs (n-step return) |
Wired | N-step Bellman target | — |
backward_kernels.cu |
gpu_dqn_trainer.rs (weight backward) |
Wired | Weight gradient accumulation | — |
bias_kernels.cu |
gpu_dqn_trainer.rs (bias grad) |
Wired | Bias gradient kernels | — |
relu_mask_kernel.cu |
gpu_dqn_trainer.rs (activation backward) |
Wired | ReLU activation mask | — |
ema_kernel.cu |
gpu_dqn_trainer.rs (target-net soft update) |
Wired | EMA target-network update | — |
q_stats_kernel.cu |
gpu_dqn_trainer.rs (Q-value stats for ISV) |
Wired | Per-branch Q-value statistics | — |
dqn_utility_kernels.cu |
gpu_dqn_trainer.rs (multiple utility ops) |
Wired | Misc DQN GPU ops (advantage std, PopArt, etc.) | — |
graph_utility_kernels.cu |
gpu_dqn_trainer.rs (CUDA graph utility ops) |
Wired | Graph capture helper kernels | — |
grad_decomp_kernel.cu |
gpu_dqn_trainer.rs, called via fused_training.rs::grad_decomp_launch_c51 |
Wired | C51 gradient decomposition (Task 2.0 diagnostic) | — |
branch_grad_balance_kernel.cu |
gpu_dqn_trainer.rs, called via fused_training.rs::launch_branch_grad_balance |
Wired | Per-branch gradient L2-norm balancing | — |
mamba2_temporal_kernel.cu (mamba2_scan_projected_fwd, mamba2_scan_projected_bwd, isv_temporal_route, etc.) |
gpu_dqn_trainer.rs::mamba2_forward + mamba2_backward, called in fused training loop (adam_grad child graph) |
Wired (grad-check validated, Plan 2 Task 2) | Mamba2 selective SSM forward + backward (both paths implemented). D.1: kernel-level reference check + non-zero gradient propagation test confirm backward is not silently no-oping. | — |
attention_kernel.cu |
gpu_attention.rs |
Wired | Scaled dot-product attention forward | — |
attention_backward_kernel.cu |
gpu_attention.rs, gpu_tlob.rs (reused for grad_norm+Adam kernels) |
Wired | Attention backward pass | — |
tlob_kernel.cu (tlob_sdp_forward, tlob_sdp_backward, tlob_write_states, tlob_read_grad_from_states) |
gpu_tlob.rs |
Wired | D.8 Plan 2 Task 6C — TLOB SDP forward/backward, state scatter/read kernels | — |
training_guard_kernel.cu (training_guard_check_and_accumulate) |
gpu_training_guard.rs |
Wired | NaN / guard check kernel | — |
backtest_env_kernel.cu (backtest_env_step) |
gpu_backtest_evaluator.rs (ENV_CUBIN) |
Wired | Backtest environment step | — |
backtest_metrics_kernel.cu |
gpu_backtest_evaluator.rs (METRICS_CUBIN) |
Wired | Backtest metrics reduction | — |
backtest_plan_kernel.cu (backtest_plan_diag_reduce, backtest_plan_state_isv) |
gpu_backtest_evaluator.rs (BACKTEST_PLAN_CUBIN) |
Wired | Plan-ISV diagnostic reduction in val. D.6 (Plan 2 Task 6A): backtest_plan_state_isv now writes pisv[6] = remaining_fraction; stride updated to SL_PORTFOLIO_PLAN_DIM=7. |
— |
backtest_forward_ppo_kernel.cu (backtest_forward_ppo_kernel) |
gpu_backtest_evaluator.rs::evaluate_ppo (PPO_FORWARD_CUBIN) |
OUT-of-DQN-scope | PPO-path backtest only; correct-as-is | — |
backtest_forward_supervised_kernel.cu (signal_to_action_kernel) |
gpu_backtest_evaluator.rs::evaluate_supervised (SUPERVISED_SIGNAL_CUBIN) |
OUT-of-DQN-scope | Supervised-model backtest only; correct-as-is | — |
signal_adapter_kernel.cu |
signal_adapter.rs (loaded at construction) |
Wired | Signal-to-action ISV bus adapter | — |
statistics_kernel.cu (batch_statistics) |
gpu_statistics.rs (loaded but GpuStatistics has no call sites outside the file) |
Orphan | Kernel compiled, wrapper struct exists, but never instantiated by any consumer | Plan 2 D.2 — same resolution as gpu_statistics.rs |
ppo_experience_kernel.cu |
gpu_ppo_collector.rs |
OUT-of-DQN-scope | PPO collector; correct-as-is | — |
her_episode_kernel.cu |
gpu_her.rs |
Wired | HER episode boundary kernel | — |
her_relabel_kernel.cu |
gpu_her.rs |
Wired | HER goal relabelling kernel | — |
curiosity_training_kernel.cu |
gpu_curiosity_trainer.rs |
Wired | Curiosity intrinsic reward training | — |
curiosity_inference_kernel.cu |
gpu_curiosity_trainer.rs |
Wired | Curiosity inference forward | — |
ensemble_kernels.cu |
batched_forward.rs / batched_backward.rs (ensemble ops in fused trainer) |
Wired | Ensemble head combination | — |
dt_kernels.cu |
decision_transformer.rs (DT_CUBIN) |
Wired | Decision Transformer GPU ops | — |
trade_stats_kernel.cu |
gpu_experience_collector.rs |
Wired | Per-trade statistics accumulation | — |
backtest_env_kernel.cu (backtest_env_step) |
gpu_backtest_evaluator.rs |
Wired | Already listed above | — |
common_device_functions.cuh |
prepended to all kernels at compile time by build.rs |
Wired | Shared device helpers (BF16 wrappers, warp reduce) | — |
state_layout.cuh |
included by kernels that reference state buffer layout | Wired | Named index constants for state buffer | — |
trade_physics.cuh |
included by backtest_env_kernel.cu and others at compile time |
Wired | Kelly / physics helpers shared across kernels. kelly_position_cap warm-branch deadlock fixed: effective_kelly = max(kelly_f, warmup_floor) so the cap can never collapse to zero when balanced trades naturally yield kelly_f=0 at maturity=1; preserves design intent (Kelly drives sizing once stats mature with real edge) while preventing the bootstrap deadlock that produced 100% Long/Short→Flat conversion in val. |
— |
health_diag_kernel.cu (health_diag_isv_mirror) |
cuda_pipeline/gpu_health_diag.rs::GpuHealthDiag::launch_isv_mirror; trainer accessor launch_health_diag_isv_mirror. No production callers in 2026-04-28 commit — additive scaffolding for HEALTH_DIAG GPU port Phases 2B-2E + Phase 3 wire-up. Mirrors AuxHeadsForwardOps's Plan 4 Task 6 Commit A landing pattern. |
Pending wire-up (Phase 3) | Phase 2A — single-block single-thread kernel copies 16 ISV signal-bus slots (reward EMAs 63-68, VSN/drift focus 87-93, aux losses 113-117, MoE expert util 118-126, gate ent 126, λ_eff 128) into the corresponding HealthDiagSnapshot fields. Kernel WORD-index table inline + static_assert(WORD_TOTAL == 147) keeps the offset map in lockstep with the Rust struct's 147 × 4-byte size assertion. ISV indices passed as kernel args (single source of truth in gpu_dqn_trainer.rs). |
Phase 3 — wire launch_health_diag_isv_mirror into training_loop.rs::process_epoch_boundary next to launch_h_s2_rms_ema / launch_aux_heads_loss_ema; parallel-shadow assert host-CPU readback ≈ snapshot field. |
Model Modules — Supervised-Only (OUT-of-DQN-scope per Part E)
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
xlstm/mod.rs + xlstm/trainable.rs |
ensemble/adapters/xlstm.rs, hyperopt/adapters/xlstm.rs |
OUT-of-DQN-scope | Supervised consumers; DQN does not import this. Part E: keep. | — |
kan/mod.rs + kan/trainable.rs |
hyperopt/adapters/kan.rs only |
OUT-of-DQN-scope | KAN splines in DQN trunk (batched_forward/backward) are inline in gpu_dqn_trainer.rs and do not import crate::kan. kan/ wraps ml-supervised KAN for hyperopt. Part E: keep. |
— |
tgnn/mod.rs + tgnn/trainable_adapter.rs |
hyperopt/adapters/tggn.rs, risk/mod.rs |
OUT-of-DQN-scope | TGNN for supervised + risk path | — |
tlob/mod.rs + tlob/trainable_adapter.rs |
hyperopt/adapters/tlob.rs, data_loaders/tlob_loader.rs |
Partial / OUT-of-DQN-scope | TLOB transformer has hyperopt adapter; DQN trunk wiring scheduled for Plan 2 D.8 | Plan 2 D.8 wires TLOB trunk into DQN |
diffusion/mod.rs + diffusion/trainable.rs |
hyperopt/adapters/diffusion.rs, trainers/dqn/fused_training.rs (data aug only) |
Partial | Diffusion model used for data augmentation in fused training but lacks a gradient backward path through DQN trunk | Plan 2 tracks full integration |
ppo/mod.rs + ppo/trainable_adapter.rs |
trainers/ppo.rs, gpu_ppo_collector.rs |
OUT-of-DQN-scope | PPO training path; not DQN | — |
Temporal/Recurrent Modules — DQN Integration Status
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
mamba/mod.rs + mamba/trainable_adapter.rs |
trainers/mamba2.rs, model_factory.rs, inference_validator.rs; Mamba2 temporal kernel wired fully in gpu_dqn_trainer.rs |
Wired (DQN trunk) + OUT-of-DQN-scope (supervised trainers/mamba2.rs) |
Forward and backward both implemented in mamba2_temporal_kernel.cu | — |
liquid/mod.rs + liquid/adapter.rs |
trainers/liquid.rs supervised path only |
Deleted (DQN) | D.7 audit (2026-04-24): liquid_tau_rk4_step kernel was a mathematical identity — ODE f(x)=(1/tau)*(1-x) has fixed point x=1.0; initialised to 1.0, it never deviated. liquid_mod_buf always held [1.0,1.0,1.0,1.0], so velocity_mod in c51_grad_kernel multiplied spread_scale by 1.0 unconditionally. No ISV slot existed (violates §4.C.6). Kernel, buf, and call removed. LiquidTrainableAdapter (supervised path) unchanged. |
|
tft/mod.rs + tft/trainable_adapter.rs |
trainers/tft/trainer.rs has full forward + backward_step(); TrainableTFT::backward() returns error with explicit message that backward is via TFTTrainer::train() |
Partial | TFT backward available via dedicated trainer; UnifiedTrainable::backward() slot returns informational error (not a stub — documented routing) |
Plan 4 E.1/E.3 — DQN concept adoption; full backward path tracked in task #76 |
Data Pipeline Modules
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
fxcache.rs (discover_and_load, FxCacheData) |
train_baseline_rl.rs, trainers/dqn/data_loading.rs |
Wired | Primary training data format | — |
feature_cache.rs |
fxcache.rs (cache key), hyperopt/adapters/dqn.rs, smoke tests |
Wired | Cache key generation for .fxcache auto-discovery |
— |
data_pipeline/ (DatasetManager, PreparedDataset) |
lib.rs prelude re-export; integration/ modules |
Wired | Dataset management abstraction | — |
data_loaders/dbn_sequence_loader.rs |
data_loaders/mod.rs; internally used by dbn_tick_adapter.rs |
Wired | DBN sequence loading for supervised models | — |
data_loaders/dbn_tick_adapter.rs |
data_loaders/mod.rs |
Wired | DBN tick-to-bar adapter | — |
data_loaders/streaming_dbn_loader.rs |
data_loaders/mod.rs; tests/streaming_pipeline_edge_cases.rs (test consumer) |
Partial | Test-only consumer at tests/streaming_pipeline_edge_cases.rs; if streaming DBN load is genuinely needed for production (memory-efficient data loading), wire into production path in a follow-up task; otherwise schedule for deletion after test utility is moved to dbn_sequence_loader. | Reclassified Partial — test consumer confirmed |
data_loaders/tlob_loader.rs (TLOBDataLoader) |
data_loaders/mod.rs; no external call sites found |
Orphan | TLOB data loader built but not called outside its own module and mod.rs | Plan 2 D.8 may wire; surface for user review |
training/unified_trainer.rs (UnifiedTrainable trait) |
diffusion/trainable.rs, tlob/trainable_adapter.rs, mamba/trainable_adapter.rs, tft/trainable_adapter.rs, hyperopt adapters |
Wired | Unified training trait for supervised models | — |
training/unified_data_loader.rs |
— | — | Deleted (Task 6): zero production + zero test consumers confirmed. pub mod unified_data_loader removed from training.rs. |
DELETED |
training/orchestrator.rs (TrainingOrchestrator) |
— | — | Deleted (Task 6): zero production + zero test consumers confirmed. pub mod orchestrator removed from training.rs. |
DELETED |
training_pipeline.rs (ProductionTrainingError) |
lib.rs (two From impls: MLError + MLSafetyError); TrainingPipeline struct has no external instantiation |
Partial | ProductionTrainingError used via From impl in lib.rs; TrainingPipeline struct itself may be dead — follow-up to extract error enum and delete struct separately. | Reclassified Partial — error type wired, struct possibly unused |
training_profile.rs (DqnTrainingProfile) |
train_baseline_rl.rs, hyperopt/adapters/dqn.rs, smoke tests |
Wired | TOML-backed hyperparameter profiles | — |
walk_forward.rs (FoldRange, WalkForwardConfig) |
train_baseline_rl.rs, validation/harness.rs |
Wired | Walk-forward fold management | — |
Evaluation / Validation Modules
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
validation/mod.rs (ValidatableStrategy) |
validation/harness.rs, hyperopt/adapters/dqn.rs |
Wired | Validation trait + entry points | — |
validation/harness.rs |
hyperopt/adapters/dqn.rs, trainer/metrics.rs |
Wired | Walk-forward validation harness | — |
validation/financial.rs |
validation/harness.rs |
Wired | Financial metric computation for val | — |
validation/regime_analysis.rs |
validation/harness.rs |
Wired | Regime-conditional eval | — |
validation/ppo_adapter.rs |
validation/harness.rs |
OUT-of-DQN-scope | PPO-specific validation path | — |
evaluation/mod.rs |
hyperopt/adapters/dqn.rs, trainer/metrics.rs, gpu_backtest_evaluator.rs |
Wired | DQN evaluation engine | — |
backtesting/ (GpuBacktestEvaluator, BarrierBacktester) |
trainer/metrics.rs, hyperopt/adapters/* |
Wired | Backtesting framework | — |
Supporting Infrastructure Modules
| Module / kernel | Consumer path | Classification | Notes | Action |
|---|---|---|---|---|
checkpoint/mod.rs |
model_registry.rs |
Wired | Checkpoint save/load | — |
inference.rs |
multiple service consumers (67 files by grep) | Wired | Inference entry points | — |
inference_validator.rs (InferenceValidator) |
— | — | Deleted (Task 6): zero production + zero test consumers confirmed. No pub mod declaration found in any file. |
DELETED |
model_factory.rs |
mamba path, lib.rs |
Wired | Model construction factory | — |
model_loader_integration.rs (ModelLoaderTrait) |
— | — | Deleted (Task 6): zero production + zero test consumers confirmed. No pub mod declaration found in any file. |
DELETED |
model_registry.rs |
lib.rs, various consumers (3 by grep) |
Wired | Model lifecycle registry | — |
registry/mod.rs |
consumed by integration and service layer (10 consumers) | Wired | Operational model lifecycle | — |
hyperopt/ (campaign, adapters) |
trainer/training_loop.rs, hyperopt smoke test |
Wired | Bayesian hyperparameter optimisation | — |
ensemble/ |
integration path, service layer | Wired | Multi-model ensemble | — |
integration/ |
service layer | Wired | Service integration layer | — |
flash_attention/mod.rs (FlashAttention3) |
transformers/hft_transformer.rs, trainers/tft/config.rs, benchmark/tft_benchmark.rs |
OUT-of-DQN-scope | TFT / transformer path only; DQN uses gpu_attention.rs |
— |
features/ (FeatureVector, extract_ml_features) |
train_baseline_rl.rs, DQN trainer |
Wired | Feature extraction | — |
microstructure/ |
trainers/dqn/features.rs, OFI pipeline |
Wired | VPIN, Kyle lambda, order-flow imbalance | — |
regime/mod.rs |
DQN features, data pipeline (50 consumers) | Wired | Regime detection + signal | — |
regime_detection/mod.rs |
lib.rs declaration only; zero consumers of the facade; zero direct uses of ml_regime_detection:: anywhere else |
Orphan | Thin facade re-exporting ml-regime-detection crate. Zero consumers of the facade, zero direct uses of ml_regime_detection:: anywhere. The underlying ml-regime-detection/ workspace crate exists but appears unused. Deleting a whole workspace crate exceeds Task 6 scope — scheduled for a separate crate-cleanup task. Potentially superseded by ml_regime/ crate (50 consumers). | CRATE-LEVEL-FOLLOWUP |
risk/mod.rs (crate-level) |
DQN trainer constructor via DrawdownMonitor, KellyCriterionOptimizer |
Wired | Risk management in training | — |
preprocessing.rs |
features/, data pipeline |
Wired | Log-return normalisation, outlier clipping | — |
labeling/mod.rs |
features/, supervised training |
Wired | Triple-barrier labelling | — |
security/mod.rs |
lib.rs (1 consumer) |
Partial | ML security / prediction validation; no DQN consumer found | Surface for user review |
stress_testing/mod.rs |
dqn/stress_testing.rs, ppo/stress_testing.rs |
Wired | Stress-testing framework | — |
paper_trading/mod.rs |
lib.rs declaration; tests/paper_trading_integration_test.rs (test consumer) |
Partial | Test-only consumer at tests/paper_trading_integration_test.rs. Production trading-service uses its own paper_trading_executor (distinct module); this facade exists only for ML crate tests. | Reclassified Partial — test consumer confirmed |
observability/mod.rs |
integration/performance_monitor.rs |
Wired | ML observability hooks | — |
deployment/ |
lib.rs, integration layer |
Wired | Model deployment + A/B testing | — |
universe/mod.rs |
lib.rs, integration layer (5 consumers) |
Wired | Asset universe management | — |
asset_selection/mod.rs |
lib.rs, integration layer (referenced in universe path) |
Wired | Asset selection logic | — |
batch_processing.rs |
lib.rs, integration/ (multiple consumers) |
Wired | Batch ML operations | — |
bridge.rs |
lib.rs, trading-ML bridge |
Wired | ML-Financial type bridge | — |
data_loader.rs |
lib.rs, training service |
Wired | Legacy data loader shim | — |
data_validation/mod.rs |
lib.rs, data pipeline |
Wired | Input validation for data pipeline | — |
explainability/mod.rs |
lib.rs only (1 consumer) |
Partial | SHAP / attribution; no DQN call site found | Surface for user review |
benchmarks.rs |
lib.rs, benchmark harness |
OUT-of-DQN-scope | Benchmark entry point; not production training | — |
benchmark/ |
benchmark harness | OUT-of-DQN-scope | GPU / model benchmarks; not production DQN path | — |
portfolio_transformer.rs |
— | — | Deleted (Task 6): zero production + zero test consumers confirmed. pub mod portfolio_transformer removed from lib.rs. |
DELETED |
Summary
Updated after Task 6 cleanup (2026-04-24): 5 confirmed-orphan files deleted, 3 Orphan rows reclassified Partial, 1 Orphan reclassified with crate-level follow-up action. Plan 1 Task 8 (revised, 2026-04-24): adaptive_controller.rs renamed → adaptive_monitor.rs; AdaptiveController replaced with read-only AdaptiveMonitor per spec §4.C.6 (GPU drives, CPU reads).
Plan 3 Task 1 C.2 (2026-04-24): reward_component_ema_kernel.cu + RewardComponentMonitor added. 6 ISV reward-EMA slots [63..69) allocated; fingerprint shifted [61..63) → [69..71); ISV_TOTAL_DIM 63 → 71. experience_kernels.cu extended with reward_components_per_sample [N*L, 6] output parameter. 2 new Wired rows.
Plan 3 Task 3 B.2 (2026-04-24): trade_rate_ema_kernel.cu added. 2 new ISV slots [71] TRADE_ATTEMPT_RATE_EMA and [72] TRADE_TARGET_RATE; fingerprint shifted [69..71) → [73..75); ISV_TOTAL_DIM 71 → 75. experience_kernels.cu gains flat_to_pos_per_sample [N*L] output + 2 ISV slot-idx scalar parameters; a novelty-scaled bonus (conviction × vol_proxy × novelty) is added to reward and captured in rc[5] at every Flat→Positioned transition. TRADE_TARGET_RATE frozen in training_loop.rs at epoch 5 from measured attempt-EMA (min 0.001). 2 new Wired rows.
Plan 3 Task 5 C.4 (2026-04-24): Temporal timing bonus on trade exit. No new ISV slot — accumulates into rc[5] bonus slot via += (B.2 at entry, C.4 at exit: different (i,t) slots). New portfolio-state slot PS_PEAK_PNL_BAR=38; PS_STRIDE 38 → 39; PORTFOLIO_STRIDE 38 → 39 in all consumers (experience_kernels.cu, trade_stats_kernel.cu, gpu_experience_collector.rs, gpu_dqn_trainer.rs::launch_kelly_cap_update, ml-core::state_layout.rs). Peak bar snapshotted alongside every MAX_PNL update, reset at every MAX_PNL reset site (entry, reverse, fold hard-reset, trade-complete soft-reset). timing_bonus = shaping_scale × (bars_early / hold_time) × |final_pnl| × conviction_core where bars_early = max(0, segment_hold_time − PS_PEAK_PNL_BAR). No tuned multiplier. 1 new Wired row.
Plan 3 Task 6a D.4a (2026-04-24): Persistence credit on profitable drawdown recovery. No new ISV slot — accumulates into rc[5] bonus slot via += (now shared by B.2 entry, C.4 exit-timing, D.4a exit-persistence; all three fire at different (i,t) slots per trade, so += is idempotent). New portfolio-state slot PS_INTRA_TRADE_MIN_PNL=39; PS_STRIDE 39 → 40; PORTFOLIO_STRIDE 39 → 40 in all consumers (experience_kernels.cu, trade_stats_kernel.cu, gpu_experience_collector.rs, gpu_dqn_trainer.rs::launch_kelly_cap_update, ml-core::state_layout.rs). MIN_PNL tracked per-bar in the same block as MAX_PNL (fminf against pnl_pct), reset at every MAX_PNL reset site (entry, reverse, fold hard-reset, trade-complete soft-reset). persist_bonus = shaping_scale × conviction_core × drawdown_depth × tanh(reward / max(1e-4, drawdown_depth)) where drawdown_depth = max(0, −PS_INTRA_TRADE_MIN_PNL); gated on reward > 0 && drawdown_depth > 1e-6. Self-scaling tanh ratio: saturates +1 when reward ≫ drawdown (full credit for riding through), ≈0 when reward ≈ drawdown (trivial recovery). No tuned multiplier. 1 new Wired row.
Plan 3 Task 4 B.4 (2026-04-24): plan_threshold_update_kernel.cu + PlanThresholdMonitor added. PRODUCER-ONLY upgrade for ISV[PLAN_THRESHOLD_INDEX=49]: cold-start 0.5 constructor write preserved, but per-epoch GPU kernel now overwrites the slot with max(0.1, 0.5 × ISV[READINESS_EMA_INDEX=75]). New ISV slot [75] READINESS_EMA (cold-start 1.0 so the derived threshold matches the legacy 0.5 default until the EMA adapts). Fingerprint shifted [73..75) → [76..78); ISV_TOTAL_DIM 75 → 78. experience_kernels.cu gains readiness_per_sample [N*L] output parameter; the kernel writes the broadcast readiness_ptr[0] value into every reached (i,t) slot, race-free. isv_plan_threshold reset category flipped SchemaContract → FoldReset; isv_readiness_ema registered as FoldReset. Consumer kernels (4 sites in experience_kernels.cu + 1 in backtest_plan_kernel.cu) unchanged. 2 new Wired rows.
Plan 3 Task 6b D.4b (2026-04-24): Regime-shift penalty — punish trades held past a detected regime flip. No new ISV slot — subtracts from rc[5] bonus slot via −= (cancels against B.2/C.4/D.4a positive contributions within rc[5]; that cross-flow is exactly what ISV[68] REWARD_BONUS_EMA is meant to track). New portfolio-state slot PS_REGIME_SHIFT_BAR=40; PS_STRIDE 40 → 41; PORTFOLIO_STRIDE 40 → 41 in all consumers (experience_kernels.cu, trade_stats_kernel.cu, gpu_experience_collector.rs, gpu_dqn_trainer.rs::launch_kelly_cap_update, ml-core::state_layout.rs). New CUDA-side ISV macros ISV_Q_DIR_ABS_REF_IDX=21 and ISV_SHARPE_EMA_IDX=22 added to state_layout.cuh (mirror authoritative Q_DIR_ABS_REF_INDEX / SHARPE_EMA_INDEX in Rust trainer). Detector inside the active-trade block (after MIN/MAX_PNL update) writes PS_REGIME_SHIFT_BAR = hold_time on the FIRST bar where |isv[11] − PS_PLAN_ENTRY_REGIME| > clamp(0.25 × |clamp(sharpe, −2, 2)|, 0.05, 0.5); first-shift-only (short-circuits on non-zero PS_REGIME_SHIFT_BAR). Consumer in segment_complete block IMMEDIATELY AFTER D.4a: bars_late = max(0, saved_hold_time − PS_REGIME_SHIFT_BAR); penalty = shaping_scale × conviction_core × (bars_late / saved_hold_time) × ISV[21] × |reward|; reward −= penalty; rc[5] −= penalty; then defensively resets PS_REGIME_SHIFT_BAR = 0 (in addition to the 5 lifecycle resets at entry/reverse/fold hard-reset/trade-complete soft-reset). Adaptive threshold tightens when |sharpe| is high (confident trading warrants early detection); loosens under noisy training. Q_DIR_ABS_REF self-scaling matches B.1 opp-cost pattern. No tuned multiplier. 1 new Wired row.
Plan 1 Tasks 12/15/16 + pre-allocation (2026-04-24): No new modules added. Changes are ISV slot allocation + consumer migration only. Task 15 confirmed no-op (IQL_BRANCH_SCALE_FLOOR_INDEX already serves conviction-floor role). Tasks 12 and 16 migrate cql_alpha and plan-threshold consumers from config fields / hardcoded literals to ISV slots. 8 new ISV slots allocated ([39..47)); fingerprint tail moves from [37..39) to [47..49); ISV_TOTAL_DIM 39 → 49. GpuDqnTrainConfig gains total_epochs field (written to TOTAL_EPOCHS_INDEX at construction). write_isv_signal_at bound extended from ISV_DIM to ISV_TOTAL_DIM to allow writes beyond slot 22.
Plan 2 Task 6B D.3 (2026-04-24): IQL value head widened from 1 to 2 outputs (V_short + V_long). v_out_buf shape [B] → [B*2]. gemm_fwd_v M=1→2, gemm_bwd_dw3 M=1→2, gemm_bwd_dh2 K=1→2. W3 param block [H*1] → [H*2], b3 [1] → [2]. total_params += H+1. iql_expectile_loss kernel extended with num_heads argument. 4 consumer kernels in iql_value_kernel.cu updated to read v_out[b*2+0] + v_out[b*2+1]. Checkpoint compat break — retrain required.
Plan 4 Task 5 Mode A E.5 (2026-04-24): attention_focus_ema_kernel.cu + AttentionMonitor added. 3 new ISV slots [87] VSN_MAG_EMA, [88] VSN_DIR_EMA, [89] MAMBA2_RETENTION_EMA tail-appended; fingerprint shifted [85..87) → [90..92); ISV_TOTAL_DIM 87 → 92. Light, ISV-diagnostic-only; no model parameters added, no checkpoint break. Single-thread cold-path EMA kernel takes 3 host scalars by value at the per-epoch HEALTH_DIAG site. New mamba2_retention_mean(batch_size) accessor on GpuDqnTrainer/FusedTrainingCtx mirrors per_branch_vsn_mean (cold-path host-side dtoh + mean abs of mamba2_h_enriched). Adaptive α convention matches Plan 3 producers. All three slots FoldReset to 0.0 (mirror constructor cold-start). 2 new Wired rows. Mode B (full per-feature-group VSN ISV) blocks on Plan 4 Task 1 (E.1).
Plan 4 Task 4 E.4 (2026-04-24): state_encoder.rs + value_decoder.rs Rust API split. PURE Rust API refactor — no kernel changes, no parameter changes, no checkpoint break, no new ISV slot. New types: StateEncoder (wraps trunk encoder h_s1 + h_s2), ValueDecoder (per-branch FC + adv-logits, one instance per Branch::{Dir,Mag,Ord,Urg}), EncoderOutput, BranchDecoderOutput (carries q_per_action_dev_ptr + Plan 2 D.3 v_short_dev_ptr / v_long_dev_ptr pass-through fields). BatchedForward gains encoder_forward_only(...) and decoder_forward_only(branch_idx, ...) helpers, both lossless extractions of the existing dispatch sequence in forward_online_raw. The monolithic forward_online_raw stays callable and now routes through encoder_forward_only internally for the trunk portion (multi-stream branch fork/join unchanged). The new types are ADDITIVE attachment points for Plan 4 Task 1 (Full VSN, pre-encoder), Task 3 (multi-Q IQN, decoder), and Task 6 (auxiliary heads, decoder peer). 2 new Wired rows.
Plan 4 Task 2c.1 (2026-04-24): grn_kernel.cu added — Gated Residual Network forward + backward kernels (8 entry points: grn_elu_inplace, grn_glu_forward, grn_residual_layernorm_forward, grn_layernorm_backward_dx, grn_layernorm_backward_dgamma_dbeta_p1, grn_layernorm_backward_dgamma_dbeta_p2, grn_glu_backward, grn_elu_backward). Linear_a / Linear_b / Linear_residual remain cuBLAS GEMMs (no new kernels). Module is additive — ZERO production callers in this commit; classified Orphan-by-design. Task 2c.3+4 wires it into the trunk encoder forward + backward path. LayerNorm backward implements the FULL Jacobian (d_x = (1/H) * rstd * (H * d_out_g - s1 - normed * s2)), NOT the simplified element-wise form d_x = d_out * gamma * rstd from attn_layer_norm_bwd_dx — the simplified form is correct only when a parallel residual gradient absorbs the missing s1/s2 terms, which the GRN trunk-encoder backward does not have. d_gamma / d_beta use a two-phase per-block partial reduction + final-reduce pass (feedback_no_atomicadd.md compliant). Layout convention: row-major [B, H] (matches dt_layernorm_kernel, intentionally different from attention_kernel.cu's [D, B] col-major — trunk buffers downstream of GRN are row-major, so per-row LN avoids a transpose). All reductions GPU-side per pearl_cold_path_no_exception_to_gpu_drives.md. Kernel registered in build.rs (kernel count 57 → 58, all compile clean under nvcc sm_80). No checkpoint break (no new params, no new ISV slot, no consumer wired). 1 new Orphan-by-design row (will reclassify Wired at 2c.3+4).
Plan 4 Task 2c.2 (2026-04-24): cuda_pipeline/gpu_grn.rs added — Rust wrapper around the 2c.1 kernels. One GrnBlock instance owns 5 save-for-backward state buffers (elu_post, linear_b_out, sigmoid_b, ln_mean, ln_rstd, ln_normed) plus 2 per-block partial-reduction scratch buffers (dgamma_partials, dbeta_partials) plus 8 CudaFunction handles loaded from GRN_CUBIN. forward() sequences grn_elu_inplace → DtoD save post-ELU → DtoD save linear_b_out → grn_glu_forward → grn_residual_layernorm_forward. backward() sequences grn_layernorm_backward_dx → grn_layernorm_backward_dgamma_dbeta_p1 → _p2 → grn_glu_backward → (caller's Linear_b backward GEMM) → grn_elu_backward. cuBLAS GEMMs (Linear_a, Linear_b, optional Linear_residual) and the residual-split aliasing are caller-side, mirroring the contract used by gpu_tlob.rs and gpu_attention.rs. ELU backward consumes the POST-activation per the kernel's docstring (recovers f'(x_pre) via the identity exp(x_pre) = x_post + 1 for x_post < 0); the wrapper DtoD-copies the post-ELU buffer into state.elu_post immediately after the in-place ELU launch. has_residual_projection flag distinguishes h_s1 (SD→SH1, requires Linear_residual) from h_s2 (SH1→SH2, identity residual). LN dgamma/dbeta phase-1 partial-reduction grid sized at construction to ceil(max_batch/256) blocks; backward() verifies the runtime batch fits. GRN_CUBIN reference added in gpu_dqn_trainer.rs alongside the other Plan 4 kernel cubins; pub mod gpu_grn added to cuda_pipeline/mod.rs. Module is dead code in this commit — ZERO production callers; classified Orphan-by-design (will reclassify Wired at 2c.3+4 alongside the 2c.1 kernel entry above). No checkpoint break (no new params, no new ISV slot). cargo build clean at 11 warnings (unchanged), kernel cubin loads via the existing CudaFunction infrastructure. 1 new Orphan-by-design row.
Plan 4 Task 2b (2026-04-24): layout_fingerprint_seed() extended to cover param-tensor structural layout in addition to the existing ISV-slot section. 86 new entries PARAM_<NAME>=<idx> plus PARAM_TOTAL_TENSORS=86 appended to the seed string, mirroring compute_param_sizes() order one-for-one. Names match the in-code comments at each tensor index (PARAM_W_S1=0, PARAM_B_S1=1, … PARAM_W_PLAN_OUT=84, PARAM_B_PLAN_OUT=85). Pragmatic Option A scope: tensor names + positions only, NOT runtime sizes — shared_h1/shared_h2/adv_h/value_h/num_atoms/bottleneck_dim/market_dim are runtime config and can't be embedded in a const fn. Size mismatches between checkpoint and current binary are caught separately by safetensors deserialization. After this commit, ANY structural reshuffle (insert/delete/reorder/rename a tensor) changes LAYOUT_FINGERPRINT_CURRENT and triggers fail-fast at checkpoint load. Prerequisite for Plan 4 Task 2c (GRN ADOPT) — without 2b, GRN's tensor insertion would pass silently through checkpoint load and corrupt downstream state. ISV-slot portion of the seed unchanged. New fingerprint value: 0xa504d3c2f275b8af. Behavior change zero; this commit IS a checkpoint break by intent. No new module / kernel / ISV slot — pure contract-coverage extension.
Plan 4 Task 2c.3b (2026-04-25): encoder forward swap to GRN (forward path runtime restored). BatchedForward::encoder_forward_only panic gate replaced with the GRN composition: cuBLAS Linear_a (+ bias) → GrnBlock::forward_raw_phase1 (in-place ELU + DtoD save into state.elu_post) → cuBLAS Linear_b (+ bias) → cuBLAS Linear_residual (h_s1 only — h_s2 uses identity residual through h_s1_ptr directly) → GrnBlock::forward_raw_phase2 (DtoD save linear_b_out + GLU forward + residual+LN forward). Same sequence repeated for h_s2 with the appropriate weight indices ([7..13)). New BatchedForward::target_encoder_forward_only mirrors the online path but drives dedicated grn_h_s1_target / grn_h_s2_target instances so the target-network forward never clobbers the online's save-for-backward state (target writes to its own save buffers and forward_raw_phase{1,2} is called with save_for_backward=false so the DtoD copies are skipped — the buffers stay zero-initialised since target backward is never run). Two new dispatcher methods on GrnBlock (forward_raw_phase1/forward_raw_phase2) split the existing 5-step forward kernel sequence at the cuBLAS Linear_b boundary because the encoder must run Linear_a → ELU → Linear_b → Linear_residual → GLU+LN; no new GRN kernels. CublasGemmSet constructor allocates 4 GrnBlock instances and 14 GRN scratch buffers (linear_a, linear_b, residual_proj, glu × 2 GRN blocks × online + target — h_s2 omits residual_proj since residual is identity). All forward_* methods on CublasGemmSet migrated from &self to &mut self; trainer call sites adjusted (mostly disjoint-borrow refactors of let cublas = &self.cublas_forward to direct self.cublas_forward.method() calls so self.launch_* helpers can be invoked alongside). forward_target_raw and forward_online_f32 panic gates removed; bodies now delegate trunk encoding to target_encoder_forward_only / encoder_forward_only respectively. Three forward panic gates removed; three backward panic gates intentionally retained for Task 2c.3c (backward_full, apply_iqn_trunk_gradient, apply_ensemble_diversity_backward). Orphan kernel iqn_trunk_forward_kernel deleted from iqn_dual_head_kernel.cu along with its IqnHead::trunk_forward_kernel field, the load("iqn_trunk_forward_kernel") call, and the IqnKernels.trunk_fwd field; the cuBLAS fallback path in gpu_iqn_head.rs::execute_training_pipeline that produced ReLU activations on target_dueling weights is replaced with a hard error — IQN now requires set_cached_target_h_s2 to point at the buffer written by BatchedForward::target_encoder_forward_only, so online + target trunks share ONE GRN implementation. The dead cuBLAS scaffolding (trunk_l1_gemm, trunk_l2_gemm, trunk_h1_scratch, launch_trunk_bias_relu) marked #[allow(dead_code)] to keep the constructor wiring compact while making it explicit they are no longer reachable. cargo check clean at 11 warnings (baseline preserved); cargo build compiles all expected cubins (grn_kernel.cubin loads via the existing CublasGemmSet infrastructure and is consumed at runtime via the new GrnBlock instances). Smoke (cargo test … multi_fold_convergence --ignored) progresses through online + target encoder forward (logs 4× GrnBlock initialised confirming both online + DDQN sets allocate online + target instances) and panics in BatchedBackward::backward_full as expected — that gate is retained per task spec until 2c.3c swaps backward to the GRN backward chain. The audit row #11 (IQN target trunk orphan) is fully resolved by the deletion of iqn_trunk_forward_kernel and the cuBLAS-fallback removal; online and target now share BatchedForward::target_encoder_forward_only exclusively. 1 Orphan row retired; row count drops from 3 to 2 (h_s2_consumers_audit.md and dqn-wire-up-audit.md to be updated together when 2c.3c lands).
Plan 4 Task 2c.3c.1 (2026-04-24): GrnBlock::backward_raw_phase1 + backward_raw_phase2 added to gpu_grn.rs — Rust-side dispatcher split of the existing monolithic backward(). Same composition rationale as 2c.3b's forward phase split: the trunk encoder's cuBLAS Linear_b backward GEMM must run BETWEEN GLU backward and ELU backward (it produces the d_elu_out gradient that ELU backward consumes). Phase 1 sequences grn_layernorm_backward_dx → _dgamma_dbeta_p1 → _dgamma_dbeta_p2 → grn_glu_backward, writing d_pre_LN (= d_glu_out = d_residual via implicit pointer aliasing — pure pass-through, no kernel) and d_linear_b_out [B, 2*H] for the caller's Linear_b backward GEMM, plus accumulating d_gamma/d_beta. Phase 2 runs only grn_elu_backward, taking caller-provided d_elu_out (output of caller's Linear_b backward) and writing d_linear_a_out [B, H] for the caller's Linear_a backward GEMM. Both methods take raw u64 device pointers matching the forward phase methods' style (graph-capture friendly — bypasses cudarc's device_ptr event-tracking machinery). The existing monolithic backward() is left untouched (#[allow(dead_code)]) for surface-area minimization; will be deleted with 2c.5 cleanup if no production callers emerge. No CUDA kernel changes. Additive only — ZERO production callers in this commit; the three backward panic gates (backward_full, apply_iqn_trunk_gradient, apply_ensemble_diversity_backward) remain in place until 2c.3c.4 wires the new phase methods. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 80 cubins (unchanged — no new kernel). +223 LOC. No new module / kernel / ISV slot / Orphan row.
Plan 4 Task 2c.3c.2 (2026-04-24): backward infrastructure additions — two helper methods that 2c.3c.4's GRN trunk backward wire-up requires. (1) CublasBackwardSet::launch_dw_only_no_bias — variant of launch_dw_only that runs only the cuBLAS dW GEMM and skips the 2-phase bias-grad reduction. The h_s1 GRN block's Linear_residual projection has no bias term (matches gpu_grn.rs::has_residual_projection=true's 7-tensor layout: w_a, b_a, w_b, b_b, w_residual, gamma, beta — note no b_residual); calling the existing launch_dw_only with db=0u64 would dereference NULL inside bias_grad_reduce_f32_p1. Same row-major→col-major sgemm convention as launch_dw_only (label "dW_only_no_bias" for cublasLt diagnostics), pub(crate) visibility matching its sibling. +35 LOC. (2) CublasGemmSet::saxpy_inplace(stream, n, alpha, x_ptr, y_ptr) — element-wise y[i] += alpha * x[i] for the h_s2 GRN's identity-residual gradient accumulation: after Linear_a_h_s2's backward writes d_h_s1 = d_linear_a_h_s2 @ W_a_h_s2 (overwrite, β=0), the residual path needs d_h_s1 += d_pre_ln_h_s2 to capture the identity-residual gradient. Implementation chose option 2 (existing dqn_saxpy_f32_kernel from dqn_utility_kernels.cu) over option 1 (cuBLAS cublasSaxpy_v2 legacy handle): the legacy cuBLAS handle is per-stream-bound state on PerStreamCublasHandles, so adopting it for backward saxpy would require either re-binding (race risk vs forward's binding) or a new shared handle slot — versus the existing kernel which is already loaded by GpuExperienceCollector for IQR/ensemble-variance Q-bonus saxpy and is graph-capturable via the standard launch_builder path. New saxpy_kernel: CudaFunction field on CublasGemmSet, loaded in CublasGemmSet::new from DQN_UTILITY_CUBIN (same cubin already include_bytes!'d by gpu_dqn_trainer.rs); +66 LOC including field, init block, and the pub fn saxpy_inplace method (256 threads/block, ceil(n/256) blocks, no grid-stride needed — kernel is already bounded by if (i < n)). Additive only — ZERO production callers in this commit; both methods sit dead-code until 2c.3c.4's wire-up commit. cargo check clean at 11 warnings (baseline preserved); cargo build unchanged — no new .cu files (saxpy uses the existing dqn_saxpy_f32_kernel symbol). No new module / kernel / ISV slot / Orphan row.
Plan 4 Task 2c.3c.3 (2026-04-24): GRN trunk backward scratch buffer allocation on GpuDqnTrainer. 8 new CudaSlice<f32> device buffers added (4 per GRN block × 2 blocks for h_s1 + h_s2): bw_grn_h_s2_d_pre_ln [B, SH2], bw_grn_h_s2_d_linear_b_out [B, 2SH2], bw_grn_h_s2_d_elu_out [B, SH2], bw_grn_h_s2_d_linear_a_out [B, SH2], plus the four h_s1 mirrors at [B, SH1] (and 2SH1 for the linear_b_out scratch — GLU's pre-activation has 2× hidden width because value-path + gate_pre are concatenated). The d_pre_LN buffer aliases both d_glu_out and d_residual (pure pass-through under GrnBlock::backward_raw_phase1's pointer aliasing convention from 2c.3c.1); d_linear_b_out carries the phase1 → caller's Linear_b backward GEMM gradient; d_elu_out carries Linear_b backward's output into phase2; d_linear_a_out carries phase2's output to the caller's Linear_a backward GEMM. Allocated alongside the existing alloc_backward_scratch call in the trainer constructor (mirrors that helper's stream.alloc_zeros::<f32>(size).map_err(...)? pattern). Total footprint at SH1=SH2=256, B=512: 8 × 5 × 256 × 4B = 5.0 MB per fold (negligible against existing ~32 MB per-branch cuBLAS workspaces). Each field is #[allow(dead_code)] until 2c.3c.4's apply_grn_trunk_backward_raw helper consumes them — the comment wired by Task 2c.3c.4 annotates each suppression. Additive only — ZERO production callers in this commit; the three backward panic gates (backward_full, apply_iqn_trunk_gradient, apply_ensemble_diversity_backward) remain in place until 2c.3c.4. Adam state for the new GRN tensors is auto-allocated since m_buf/v_buf are sized to total_params + cutlass_tile_pad which already includes the 13 GRN tensors after 2c.3a. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 80 cubins (unchanged — no new kernel). +57 LOC. No new module / kernel / ISV slot / Orphan row.
Plan 4 Task 6 Commit B (2026-04-26): multi-task auxiliary heads behavioural wiring (E.6) — Commit A's dormant scaffolding becomes live. Aux-head forward + backward + loss-EMA producer + ISV-driven loss combination all dispatched in the production training step; the 8 aux param tensors at [119..127) train end-to-end from the next-bar MSE + 5-class regime CE losses, and aux gradient SAXPYs into the trunk's bw_d_h_s2 accumulator (ISV-scaled) so the trunk's GRN backward chain consumes a multi-task augmented signal. Wire-up sites (4 wire-points + 1 producer + 1 diag clause + 1 setter delegate): (1) Forward wire (launch_cublas_forward): aux_heads_forward() runs immediately AFTER forward_online_raw populates save_h_s2 and BEFORE stochastic depth scales it in-place — the aux backward re-reads save_h_s2 from the same buffer, so forward + backward see the pre-dropout activation symmetrically. Sequence: regime-label builder → next-bar label gather → next-bar forward → regime forward → next-bar MSE reduce → regime CE reduce. The next-bar label is extracted column-0 of next_states_buf (= log_return per pipeline.rs:684) via the existing strided_gather kernel into a DEDICATED aux_nb_label_buf [B] f32 field on GpuDqnTrainer — explicit fix for the WIP regression where aux_partial_nb_b2 was aliased here (the previous attempt regressed F1 74.56→65.12, F2 88.20→62.20). (2) Backward wire (launch_cublas_backward_to): aux_heads_backward(grad_base) runs AFTER backward_full + the three concat-accumulators fill bw_d_h_s2 and BEFORE encoder_backward_chain consumes it. Per-head backward kernels emit per-sample partials + per-sample dh_s2; for each of the 8 aux tensors the trailing aux_param_grad_reduce collapses partials → final, then dqn_saxpy_f32_kernel writes grad_base[tensor_idx] += aux_weight × final at offsets padded_byte_offset(119..127). grad_base is parameterized so the vaccine path's g_val_ptr scratch buffer also exercises aux backward correctly. Both per-head dh_s2 buffers SAXPY into bw_d_h_s2 with alpha = aux_weight. (3) ISV producer wire (training_loop.rs per-step block): launch_aux_heads_loss_ema(ema_alpha=0.05) runs alongside launch_h_s2_rms_ema / launch_vsn_mask_ema / launch_iqn_quantile_ema — single-thread single-block kernel reads the two scalar loss buffers populated by the captured forward graph and EMAs them into ISV[113] / ISV[114]. (4) Aux-weight refresh (training_loop.rs per-step block, AFTER ISV producer): CPU-side aux_weight = clamp(0.1 × ISV[LEARNING_HEALTH=12] × (1 - tanh(0.1 × training_sharpe_ema)), 0.05, 0.3). Pushed in via the new FusedTrainingCtx::set_aux_weight thin delegate to GpuDqnTrainer::set_aux_weight (same staleness contract as set_c51_alpha). The clamp [0.05, 0.3] is a numerical-stability bound (Invariant 1 carve-out per feedback_isv_for_adaptive_bounds.md) — 0.05 keeps gradient signal alive when learning_health == 0, 0.3 caps the share so aux can never dominate. The base coefficient 0.1 is the standard aux-loss base weight per spec §4.E.6 (only tuned multiplicand; SHARPE_SCALE = 0.1 matches Plan 3's controller convention). (5) HEALTH_DIAG aux clause (mid-HEALTH_DIAG block, AFTER reward_split clause, AFTER the producer launches per the post-a5f23b28f ordering invariant): emits HEALTH_DIAG[<epoch>]: aux [next_bar_mse=… regime_ce=… w=…] reading ISV[113] / ISV[114] / trainer.aux_weight(). Cold-start (epoch 0): both EMAs at 0.0 (FoldReset), aux_weight at 0.05 (constructor cold-start). (6) 24 new buffer fields + 2 ops handles on GpuDqnTrainer: aux_heads_fwd: AuxHeadsForwardOps, aux_heads_bwd: AuxHeadsBackwardOps, aux_nb_hidden_buf [B, H], aux_nb_pred_buf [B, 1], aux_nb_label_buf [B] f32 (DEDICATED — no aliasing), aux_nb_loss_scalar_buf [1], aux_rg_hidden_buf [B, H], aux_rg_logits_buf [B, K], aux_rg_label_buf [B] u8, aux_rg_loss_scalar_buf [1], aux_rg_correct_scalar_buf [1] (kernel ABI requires it; #[allow(dead_code)]), aux_dh_s2_nb_buf [B, SH2], aux_dh_s2_rg_buf [B, SH2], 8× per-tensor partial buffers sized B × P each, aux_param_grad_final_buf [max(P)] reusable per-tensor reduce target, aux_weight: f32 host-side scalar. Init logging: constructor emits tracing::info!("AuxHeadsForwardOps initialized: K_nb={K_nb} K_rg={K_rg} aux_h={H} max_aux_tensor_len={...}") so first-fold validation of ABI wiring is visible at startup (lesson from WIP: silent zero matches in HEALTH_DIAG grep meant the producer never fired). Constraints honoured: GPU-only (every reduce + SAXPY + EMA on-device, zero DtoH in hot path); no atomicAdd (per-tensor aux_param_grad_reduce shmem-tree, SAXPY uses per-element write); no stubs; ISV-driven aux_weight (LEARNING_HEALTH × sharpe_tanh from live signal bus); partial-refactor invariant honoured (no contract or buffer-layout migration is partial — bw_d_h_s2 accumulator semantics are EXTENDED, not replaced; dqn_saxpy_f32_kernel signature is unchanged); no // ok: band-aids; no buffer aliasing tricks; no tuned constants beyond the documented 0.1 aux-loss base + [0.05, 0.3] numerical-stability clamp. target_ema_update NOT extended for aux heads (Commit A design decision preserved): target_params_buf[119..127) remains at Xavier init, never overwritten — verified no consumer reads target's aux-head pointer (aux heads are online-only, never enter Bellman bootstrapping). Smoke: results pending — see Plan 4 Task 6 Commit B Smoke follow-up entry below for per-fold best train Sharpe + acceptance result. cargo check clean at 11 warnings (workspace baseline preserved); no fingerprint change (Commit A already shifted to 0x26f7b1deb94cb226); 2 Orphan-by-design rows from Commit A (AuxHeadsForwardOps + AuxHeadsBackwardOps) reclassify Wired with this commit.
Plan 4 Task 6 Commit A (2026-04-26): multi-task auxiliary heads scaffolding (E.6) — additive params + ISV slots + kernels + orchestrator + FoldReset entries with NO behavioural callers in this commit (Commit B wires forward/backward/training-loop loss accumulation). Two Linear(SH2 → 32) → ELU → Linear(32 → K) MLPs branch off the trunk's h_s2 post-GRN activation: next-bar return regression head (K=1, MSE) + 5-class regime classification head (K=5, cross-entropy). Hidden dim AUX_HIDDEN_DIM = 32 shared by both heads. (1) Two new CUDA kernel files: aux_heads_kernel.cu (8 entry points: aux_next_bar_forward, aux_regime_forward, aux_next_bar_loss_reduce, aux_regime_loss_reduce, aux_regime_label_from_states, aux_next_bar_backward, aux_regime_backward, aux_param_grad_reduce) — single-block-per-sample reductions, shmem-tree only, no atomicAdd; backward emits per-sample [B, P] partials and the trailing aux_param_grad_reduce collapses along the batch dim with one block per output element (P blocks, 256-thread shmem reduce). aux_heads_loss_ema_kernel.cu (1 entry point: aux_heads_loss_ema_update) — single-thread, single-block ISV producer mirroring h_s2_rms_ema_kernel.cu's footprint, EMAs both scalar loss buffers into ISV[113..115). Kernels registered in build.rs (kernel count 62 → 64, all compile clean under nvcc sm_80). (2) Rust orchestrator cuda_pipeline/gpu_aux_heads.rs: AuxHeadsForwardOps + AuxHeadsBackwardOps mirror the gpu_grn.rs pattern — pub(crate) wrappers around the 11 kernel handles, raw-u64-pointer ABIs for graph-capture safety. NO callers in this commit. (3) Param tensors at [119..127) — 8 new entries in compute_param_sizes() and xavier_init_params_buf(): aux_nb_w1 [32, SH2], aux_nb_b1 [32], aux_nb_w2 [1, 32], aux_nb_b2 [1], aux_rg_w1 [32, SH2], aux_rg_b1 [32], aux_rg_w2 [5, 32], aux_rg_b2 [5]. Per-config total = 2*32*SH2 + 262 floats. Xavier on weights, zero on biases (cold-start: pred ≈ 0; logits ≈ uniform softmax). NUM_WEIGHT_TENSORS = 119 → 127. Adam SAXPY iterates 0..total_params (count-driven, not tensor-loop) so the new param range gets covered automatically; with no producer for grad_buf[119..127) in this commit, Adam keeps the params at Xavier init (intended dormant state). (4) Two new ISV slots: AUX_NEXT_BAR_MSE_EMA_INDEX = 113 + AUX_REGIME_CE_EMA_INDEX = 114. Tail-appended after Plan 4 Task 1B-ii's VSN slots [105..111); gap at [111, 112] preserved (slots intentionally vacant — formerly held the fingerprint pair, now shifted). Producer: aux_heads_loss_ema_update. Cold-start 0.0; FoldReset → 0.0. Diagnostic only — no GPU consumer kernel reads slots [113..115) in either commit (Commit B's HEALTH_DIAG aux[next_bar_mse=… regime_ce=…] line is a CPU-side text emitter, not a kernel input). (5) Fingerprint pair shifted [111, 112] → [115, 116]; ISV_TOTAL_DIM = 113 → 117. layout_fingerprint_seed() extended with the 2 new ISV slot names + 8 new PARAM_AUX_* entries terminating at PARAM_TOTAL_TENSORS=127. New LAYOUT_FINGERPRINT_CURRENT = 0x26f7b1deb94cb226 (was 0x1b28321bb816f246). Checkpoint break by intent — fail-fast at constructor load on mismatch, no migration path per feedback_no_legacy_aliases.md. (6) Two new FoldReset entries: isv_aux_next_bar_mse_ema + isv_aux_regime_ce_ema in state_reset_registry.rs, with matching dispatch arm in training_loop.rs::reset_named_state (verified before commit — this is the wire that VSN-rc2 missed and that the chain-final fix made dispatch-mandatory). (7) Polyak EMA target sync NOT extended for aux heads — design decision: aux heads are online-only supervised heads, NOT used in Bellman bootstrapping. The existing target_ema_update covers [0..non_isv_params) (= [0..FIRST_ISV_TENSOR=77)) and a separate explicit launch covers VSN range [95..119) — neither launch touches [119..127). target_params_buf[119..127) is initialised by xavier_init_params_buf (same Xavier values as online) and never updated — which is the intended contract (target net's auxiliary-head pointers, if ever consulted, see the same parameters as online; but in practice no consumer reads target_params_buf[119..127) since the aux heads don't enter Bellman targets). Verified: no target_params_buf consumer indexes past offset padded_byte_offset(119). (8) Producer/consumer split: kernels and orchestrator landed but no callers; behavioural wiring lands in Commit B. Smoke not run for Commit A (no behaviour change — aux head params are dormant Xavier weights, no forward/backward dispatch, no loss accumulation; baseline geom-mean=79.31 unaffected within ±2% noise). Commit B will run multi_fold_convergence after wiring forward + backward + loss-add to validate. cargo check clean at 11 warnings (workspace baseline preserved); cargo build will compile 64/64 cubins clean (was 62) when CUDA toolchain is available.
Plan 4 Task 1B-iv chain final (2026-04-26, commit pending): actual root-cause fix for the F0=21.14 ceiling that the four prior remedies (1B-iv main-only, 1B-iv-ext aux coverage, 1B-iv-rc/rc2 gradient dilution, 1B-iv-rc3 dedicated Adam state) all chased symptoms of. Diagnostic finding from a focused HtoD/DtoD/pinned-memory audit: target_ema_update runs the EMA kernel only over non_isv_params (indices [0..FIRST_ISV_TENSOR=77)), skipping the 24 VSN tensors at [95..119) added in 1B-ii. Online VSN trains from the 1B-iv backward chain; target VSN stays at alloc_zeros (zeros) for the entire run. Bellman target uses softmax(zeros) = uniform 1/6 mask while online's mask drifts toward [market=0.13, portfolio=0.25] over training; the systematic Q-estimate divergence collapses fold-2 magnitude branch and pinned F0 best Sharpe at 21.14 across every prior magnitude-scaling / state-isolation attempt. Fix A: extend target_ema_update with a second dqn_ema_kernel launch covering params_buf[vsn_param_byte_offset..vsn_param_byte_offset + vsn_param_total] so target VSN tracks online VSN through the same Polyak EMA the rest of the network uses. Fix B: add the missed isv_vsn_aux_grad_scale dispatch arm in training_loop.rs::reset_named_state that the rc2 work added without its dispatch counterpart. Cleanup (Phase B): strip the rc2 ISV slot 113 + dqn_scale_f32_isv_scaled_kernel + aux_bottleneck_vsn_backward_dispatch indirection AND the rc3 split-Adam vsn_m_buf / vsn_v_buf — both were band-aids for the symptom Fix A actually addresses. VSN params return to the main Adam state; aux-path SAXPYs use the original direct-saxpy pattern from 1B-iv-ext. Fingerprint reverts to the 1B-ii value 0x1b28321bb816f246 (no checkpoint break beyond what 1B-ii already required). The chain ships as: 1B-i kernel module + 1B-ii params/ISV layout + 1B-iii forward orchestrator + 1B-iv backward at all 4 trunk-touching paths (main + CQL aux + IQN aux + ensemble aux) + Fix A Polyak-EMA-extension + Fix B dispatch-arm wire-up. Smoke (cargo test … multi_fold_convergence --ignored --profile=release-test, 3 folds × 5 epochs on RTX 3050 Ti, 671.68s wall clock, all 3 dqn_fold{N}_best.safetensors checkpoints written): per-fold best train Sharpe 93.4114 / 73.0430 / 73.0749 at epochs 2 / 2 / 2; geom-mean = (93.41 × 73.04 × 73.07)^(1/3) ≈ **79.31** — clears the 1B-iii floor of 71.24 by +11.3%, and exceeds the 1B-iii baseline on F0 by +36% and on F2 by +18% (F1 −14%, but the asymmetry favours the 60-epoch L40S regime where short-horizon overfit/underfit dynamics equilibrate). The rc/rc2/rc3 entries below are preserved as engineering record of the investigation but their code paths are stripped from this commit. Constraints honoured: GPU-only (target EMA runs on-device, no DtoH); no atomicAdd; no stubs; no // ok: band-aids; no tuned constants (the Polyak EMA tau is shared with the existing main-range launch); partial-refactor invariant honoured (the dqn_ema_kernel signature is unchanged — both launches use identical args). cargo check clean at 11 warnings (workspace baseline preserved). 0 panic gates added/removed. The 4 prior remedy attempts (rc, rc2, rc3, rc4) and 1 diagnostic run (E1) are documented in the entries below as engineering record — none of those code paths land. The lesson: 4 remedies all targeted downstream symptoms (gradient scale, Adam variance, kernel sync) when the upstream cause was a 1-line gap in the Polyak target sync that didn't include post-Plan-4 tensors. Same lesson as the 2c.3a follow-up bottleneck-Linear bug landed in commit f3e3ac347 (4 stale runtime indices missed in the GRN reshuffle): simple wire-up gaps in shared infrastructure cause inscrutable downstream behavior.
Plan 4 Task 1B-iv-rc3 (2026-04-25): VSN dedicated Adam state — fourth remedy attempt for the 1B-iv / 1B-iv-ext / 1B-iv-rc / 1B-iv-rc2 smoke regressions. Three prior remedies (1B-iv main-only at geom-mean 57.49, 1B-iv-ext 4-path coverage at 36.36, 1B-iv-rc constant 0.10 damp at ~34, 1B-iv-rc2 ISV-adaptive 0.0045 dilution at 36.71) all failed to clear the 1B-iii floor of 71.24. Decisive diagnostic: F0 pinned at exactly 21.14 across every magnitude variant — deterministic ceiling, not RNG. Mask destabilization in epoch 1 is structural, not gradient-magnitude-driven: gradient scaling reduces the current-step contribution but cannot fix the past-step accumulation in Adam's v (variance) buffer. With shared v_buf, CQL's high-magnitude noise contaminates VSN's variance estimate v[95..119); even after 0.0045× dilution, the EMA tail of past contamination dominates the per-param v_hat, so Adam's update direction m_hat / sqrt(v_hat) tracks CQL noise rather than VSN-specific feature-importance signal. Fix: give VSN its own Adam moment buffers, isolated from the trunk's m_buf / v_buf. The 1B-iv-rc2 ISV-adaptive scale (VSN_DW_DAMP × ISV[VSN_AUX_GRAD_SCALE_INDEX] ≈ 0.0045) is preserved — it dilutes the current-step aux-gradient magnitude in grad_buf[95..119). The rc3 isolation prevents past-step aux-gradient noise from contaminating the variance estimate. They compose: rc2 reduces what arrives, rc3 prevents what arrived from corrupting future updates. No fingerprint change — no new ISV slot, no new param tensor, no kernel-signature change; pure host-side Adam-launch dispatch split. No checkpoint break — params/ISV layouts unchanged from 1B-iv-rc2. Pattern precedent: iqn_trunk_m / iqn_trunk_v already exist as separate Adam moment buffers for IQN-aux trunk gradient (the comment on those fields documents the same anti-momentum-mismatch rationale: "IQN trunk Adam state — separate from C51 Adam — prevents momentum mismatch"). The rc3 implementation is the same pattern, applied to VSN. (1) Two new CudaSlice<f32> fields on GpuDqnTrainer: vsn_m_buf and vsn_v_buf, each sized to vsn_param_total = (padded_byte_offset(119) − padded_byte_offset(95)) / size_of::<f32>() (≈ 2134 floats, ≈ 8.5 KB each — negligible). Allocated in the constructor immediately after the existing m_buf / v_buf allocations; comment captures the variance-contamination diagnostic. Two cached scalars vsn_param_total (usize) and vsn_param_byte_offset (u64 = padded byte offset of tensor 95) on the trainer struct, populated at construction so the per-step Adam launch avoids recomputing compute_param_sizes + padded_byte_offset. (2) launch_adam_update modified — the single dqn_adam_update_kernel invocation is split into two sequential invocations of the SAME kernel signature (no kernel changes; no new CUmodule load): main-Adam covers [0..vsn_floats_offset) with shared m_buf / v_buf and total_params = vsn_floats_offset; VSN-Adam covers [vsn_floats_offset..total_params) with vsn_m_buf / vsn_v_buf (base 0) and offset pointers params_buf + vsn_param_byte_offset / grad_buf + vsn_param_byte_offset / weight_decay_mask + vsn_param_byte_offset and total_params = vsn_param_total. Both launches share lr_dev_ptr / β1 / β2 / ε / t_ptr / grad_norm_buf / adaptive_clip_buf — clipping is global (sum-of-squares over all 119 tensors), step counter is global (bias correction (1 − β^t) shared so the warmup behaviour is identical), learning rate is global. The L1 proximal step (l1_end = align4(param_sizes[0]), l1_lambda = 1e-3) is enabled only on the main launch (it targets w_s1 at index 0); the VSN launch passes l1_end = 0, l1_lambda = 0.0 to short-circuit. The VSN slice of weight_decay_mask is all 0.0 by construction (only indices [0..8) are set to 1.0), so passing the offset mask pointer keeps the existing "no decay outside trunk+value" semantics intact. A debug_assert! on vsn_floats_offset + vsn_param_total == total_params guards against silent layout drift. (3) reset_adam_state extended to zero vsn_m_buf and vsn_v_buf at fold reset, alongside the existing main m_buf / v_buf zeroing. Skipping VSN here would carry stale fold-N gradient history into fold N+1, breaking the cold-start guarantee that motivated reset_adam_state's existence. (4) scale_adam_momentum extended to also scale vsn_m_buf by the warm-restart factor. Skipping VSN would let it keep full pre-restart momentum while the trunk decays — defeating the warm-restart's "escape Adam fixed point" purpose for the VSN slice. The aux-only scale_f32_ungraphed handle is reused (same Hopper CUmodule isolation pattern that the existing main-side scale launch already documents). (5) What stays unchanged: dqn_adam_update_kernel signature, the post_aux_module's adam_update_post_aux handle (used only for selectivity / denoise / risk / multi-horizon-value heads — none of those touch VSN params), cql_grad_scratch plumbing (CQL still SAXPYs across the full total_params range; the 1B-iv-rc2 ISV-adaptive scale at the dispatcher entry already attenuated the [95..119) slice before the SAXPY), the IQN-trunk decoupled Adam state (iqn_trunk_m / iqn_trunk_v — independent precedent), and all 4 VSN backward wire sites (1B-iv main + 3 aux). (6) Why decoupled-Adam is the structurally correct fix (not "more dilution"): Adam's update direction is m_hat / sqrt(v_hat); v is the EMA of squared gradients with β2 = 0.999, so its half-life is ≈ 693 steps. With shared v_buf, even a single epoch of high-magnitude CQL aux gradient leaves a sqrt-of-EMA imprint that takes hundreds of subsequent VSN-only-low-magnitude steps to wash out. With vsn_v_buf isolated, the imprint never lands — VSN's variance estimate is immediately the right scale for its own gradient distribution. (7) Constraints honoured: GPU-only (both Adam launches on-device, no DtoH); no atomicAdd; no stubs (the split is two real launches, both reachable on every training step); no // ok: band-aids; no tuned constants (no new lr / β / ε / clip-threshold introduced — all hyperparams shared with main Adam); no functionality removal (the rc2 ISV scale and the 1B-iv main-backward + 1B-iv-ext aux-backward wiring all remain in place); the partial-refactor invariant is honoured (no contract or signature is partially migrated — dqn_adam_update_kernel's signature is unchanged, both call sites in launch_adam_update use the same arg layout). (8) Smoke (cargo test … multi_fold_convergence --ignored --release, 3 folds × 5 epochs on RTX 3050 Ti, 672.44s wall clock, all 3 dqn_fold{N}_best.safetensors checkpoints written): per-fold best train Sharpe 21.1421 / 57.6435 / 57.1107 at epochs 1 / 2 / 5; geom-mean = (21.1421 × 57.6435 × 57.1107)^(1/3) ≈ **41.1344**. Compared to: 1B-iii baseline 68.83 / 84.84 / 61.95 (geom-mean 71.24), 1B-iv-only-main-backward 73.68 / 73.97 / 34.86 (geom-mean 57.49), 1B-iv-ext 21.14 / 38.08 / 63.72 (geom-mean 36.36), 1B-iv-rc constant-damp ~34, 1B-iv-rc2 ISV-adaptive 36.71. F0 still pinned at 21.1421 — the deterministic ceiling persists across rc3, disproving the variance-contamination hypothesis: if shared-Adam contamination were the root cause, decoupling m/v would have moved F0 above 21.14 (or below it, in the worst case). That F0 lands on the same 5-decimal-place value as 1B-iv-ext means the failure mode is upstream of Adam's variance estimate — most plausibly in the bottleneck Linear's co-adaptation rate vs the VSN gate (or in the cold-start mask destabilisation pattern that the rc series has been chasing). Floor for accept was ≥71.24 — REGRESSED to 41.13 (−42% vs floor). Per the user's explicit guidance for the 4-remedy budget, the recommendation is Path C (revert all 1B-iv leaves and ship 1B-iii standalone — VSN frozen at Xavier, no backward). The rc3 dedicated Adam state code is structurally correct and architecturally clean (mirrors the existing iqn_trunk_m/v precedent), so the implementation can be preserved as a future-proof scaffold even if the current rc3 commit is reverted alongside iv/ext/rc/rc2 — but this commit's smoke says the variance-contamination diagnosis was wrong, so even with the dedicated buffers in place a separate fix would be needed for whatever IS pinning F0 at 21.14. cargo check clean at 11 warnings (workspace baseline preserved). 0 panic gates added/removed. 0 stubs. 0 new Orphan rows. Status: per the spec's Step H "If smoke STILL regresses below 71.24, STOP and report", this commit is NOT committed; the working tree retains the rc3 source edits + this audit-doc entry as a record of the fourth attempt for the user's revert decision.
Plan 4 Task 1B-iv-rc2 (2026-04-25): VSN aux-path adaptive dW dilution — third remedy attempt for the 1B-iv / 1B-iv-ext smoke regressions, after the first two (1B-iv-rc constant VSN_DW_DAMP=0.10, 1B-iv-rc earlier remedy ~34) both failed to clear the 1B-iii floor of 71.24. Diagnostic from the regressed-run HEALTH_DIAG: per-epoch CQL aux-path gradient magnitude (grad_split_bwd cql) was 782 → 915 → 3492 → 464 across 4 epochs while Sharpe collapsed +21 → −49. With VSN holding ~2134 trainable params (24 tensors × per-group MLPs) vs the rest-of-network at ~1M+ params, CQL gradient density on VSN is ~1000× higher per-param than on the trunk — Adam's variance estimate for VSN gets dominated by aux-path noise rather than actual policy improvement; VSN drifts under aux pressure regardless of the main C51/MSE signal. The previous "more gradient signal" hypothesis (1B-iv-ext: extend VSN backward to all 4 paths) AMPLIFIED the destabiliser; the previous "uniform 10× damp" hypothesis (1B-iv-rc constant) didn't dilute enough; an earlier remedy attempt (~34) didn't either. Fix: replace the constant VSN_DW_DAMP=0.10-only attenuation in the aux-path dispatcher with a config-derived adaptive dilution VSN_DW_DAMP × ISV[VSN_AUX_GRAD_SCALE_INDEX] where ISV[113] = sqrt(vsn_param_count / non_vsn_param_count). The square root preserves Adam's variance scaling; the ratio comes from compute_param_sizes(&config) so it's adaptive to actual config (shared_h1, shared_h2, market_dim, bottleneck_dim, etc.) rather than a tuned constant. Per feedback_isv_for_adaptive_bounds.md, the slot lives in the ISV bus rather than a host-side const so HEALTH_DIAG / monitoring sees it and a future runtime-EMA refinement (measured ||vsn_dW||_2 / ||trunk_dW||_2) can attach without a fingerprint shift. Fingerprint changes — new ISV slot at [113] shifts fingerprint LO/HI from [111/112] to [114/115]; ISV_TOTAL_DIM 113 → 116; layout fingerprint hash 0x1b28321bb816f246 → 0xdd5a6a3b5337f6f4. Checkpoint break is intentional (matches LAYOUT_FINGERPRINT_CURRENT's fail-fast semantics — no migration path; retrain required). (1) One new ISV slot VSN_AUX_GRAD_SCALE_INDEX = 113 defined in gpu_dqn_trainer.rs next to the existing VSN slot constants; doc-comment captures the diagnostic + design rationale. Producer: constructor cold-start (in the unsafe sig_ptr write block alongside VSN_MASK_GROUP_*) + FoldReset dispatch arm in training_loop.rs::reset_named_state (both compute from compute_param_sizes, byte-stable across reapplies because config does not vary across folds within a single training run). Consumer: new SAXPY-style scale kernel (point 2). The ISV slot table docstring (lines ~340-385) gains the [113] entry; layout_fingerprint_seed gains VSN_AUX_GRAD_SCALE=113; and the fingerprint indices shift to 114/115 + ISV_TOTAL_DIM=116;; constructor's fingerprint write at [114/115] uses the shifted constants. (2) One new GPU kernel dqn_scale_f32_isv_scaled_kernel in dqn_utility_kernels.cu, immediately after dqn_scale_f32_kernel. Signature: (float* y, float alpha, const float* isv_signals, int isv_idx, int n). Reads ISV[isv_idx] on-device (pinned device-mapped pointer, same path as dqn_distill_saxpy_kernel's ISV read — no DtoH, no atomicAdd, deterministic), combines with host-passed alpha as effective = alpha * isv_scale, applies the same NaN-safe y[i] = (effective==0.0f) ? 0.0f : y[i]*effective convention as dqn_scale_f32_kernel. Defensive null-guard on the ISV pointer falls back to alpha alone (matches the project's distill_saxpy convention). One new kernel handle scale_f32_isv_aux: CudaFunction on GpuDqnTrainer, loaded from aux_module next to the existing scale_f32_aux so it shares the aux_child graph-capture boundary (Hopper CUfunction-per-child isolation). Utility-kernel return tuple grows from 42 to 43 entries; loader info-line unchanged (no count log to update). The plain scale_f32_aux handle is preserved on the struct because it remains the canonical aux-child scale handle for any future aux-path scaling that does not need an ISV multiplier. (3) aux_bottleneck_vsn_backward_dispatch signature changes — replaces the scale_f32_kernel: &CudaFunction parameter with scale_f32_isv_kernel: &CudaFunction and adds an isv_signals_dev_ptr: u64 parameter. The IN-PLACE attenuation block at the top of the dispatcher (which scales vsn_d_gated_state_buf by VSN_DW_DAMP before vsn_backward consumes it) is rewritten to launch the new ISV-aware kernel with (buf, VSN_DW_DAMP, isv_dev_ptr, VSN_AUX_GRAD_SCALE_INDEX, n) — multiplying VSN_DW_DAMP × ISV[113] on-device. Because the softmax-and-gate Jacobian + downstream Linear_2 / Linear_1 backward GEMMs are all linear in d_gated_state, scaling the input uniformly attenuates every VSN dW/dB write that follows (whether it lands in per-aux scratch like vsn_dw_iqn_aux_scratch / vsn_dw_ensemble_aux_scratch, or directly in cql_grad_scratch[95..119) for the CQL path). All 3 aux-path callers (apply_iqn_trunk_gradient, apply_ensemble_diversity_backward, the CQL block in apply_cql_gradient) migrate in the same commit per the partial-refactor invariant; each passes &self.scale_f32_isv_aux and self.isv_signals_dev_ptr. Trunk SAXPYs (which do NOT route through this dispatcher) are unaffected — main-path VSN backward (launch_cublas_backward_to) keeps using the plain scale_f32_kernel with VSN_DW_DAMP alone, so the adaptive dilution affects ONLY aux paths per the spec. (4) One new FoldReset entry isv_vsn_aux_grad_scale in state_reset_registry.rs (after the 6 isv_vsn_mask_g{0..5}_ema entries) + dispatch arm in training_loop.rs::reset_named_state; both recompute the scale from compute_param_sizes(&config) so the value is byte-stable across reapplies. (5) Constraints honoured: GPU-only (the ISV scalar is read on-device by the kernel, no DtoH); no atomicAdd; no stubs (the if config.bottleneck_dim == 0 short-circuit in aux_bottleneck_vsn_backward_dispatch is the existing bottleneck-disabled gate, not a new silent skip); no // ok: band-aids; no tuned constants (the dilution scale is config-derived from compute_param_sizes); ISV-driven adaptive bound per feedback_isv_for_adaptive_bounds.md; partial-refactor invariant honoured (the aux_bottleneck_vsn_backward_dispatch signature change has all 3 callers migrate in the same commit). (6) Computed scale value at default config (shared_h1=64, shared_h2=64, market_dim=42, batch_size depends on config; compute_param_sizes evaluated): vsn_param_count = sum of [95..119) ≈ ~2134 floats; total_param_count = sum of [0..119) ≈ ~1.05M floats; non_vsn = total − vsn ≈ ~1.05M; ratio ≈ 0.00203; sqrt(0.00203) ≈ 0.0451. Effective aux-path VSN dilution = VSN_DW_DAMP × scale = 0.10 × 0.0451 ≈ 0.0045 (vs the prior constant 0.10). Smoke (cargo test … multi_fold_convergence --ignored --release, 3 folds × 5 epochs on RTX 3050 Ti): per-fold best train Sharpe {F0} / {F1} / {F2} vs the 1B-iii baseline 68.83 / 84.84 / 61.95 (geom-mean 71.24), the 1B-iv-only-main-backward intermediate 73.68 / 73.97 / 34.86 (geom-mean 57.49), the 1B-iv-ext stress baseline 21.14 / 38.08 / 63.72 (geom-mean 36.36), and the 1B-iv-rc constant-damp earlier attempt geom-mean ~34; this commit geom-mean = {TBD_GEOM}. Floor for accept: ≥71.24 (do not regress vs 1B-iii). cargo check clean at 11 warnings (workspace baseline preserved). 0 panic gates added/removed. 0 stubs. 0 new Orphan rows. The 1B-iv + 1B-iv-ext + 1B-iv-rc + 1B-iv-rc2 stack is committed as a single architectural unit because reverting any of the prior leaves regresses against the contracts of the lower leaves (1B-iv's bottleneck-dW correction is required by 1B-iii's vsn_gated_states_buf forward contract; 1B-iv-ext's aux-path coverage extension is required for the contract symmetry "VSN params receive gradients from the same 4 backward paths trunk weights do"; 1B-iv-rc's constant attenuation is the floor that 1B-iv-rc2's adaptive scale multiplies into; 1B-iv-rc2's adaptive scale is required to dilute aux-path VSN gradient density to match the trunk's). This is the third remedy attempt; if smoke regresses below 71.24, the recommendation is Path C (revert and ship 1B-iii standalone — VSN frozen at Xavier, no backward) per the user's explicit guidance.
Plan 4 Task 1B-iv-rc (2026-04-25): VSN dW attenuation rescue — recovers the 1B-iv / 1B-iv-ext smoke regressions by attenuating VSN gradient signal at all 4 trunk-touching backward paths. Re-diagnosis from the smoke pattern (1B-iii 71.24 → 1B-iv 57.49 → 1B-iv-ext 36.36 — monotonic regression in trainable VSN signal strength, not in the asymmetry of coverage) pivots the root cause from H3 (asymmetric coverage) to H1 (VSN-bottleneck coupling instability): the VSN gate is the multiplicative input distribution that the bottleneck Linear sees; a strong trainable VSN gate shifts that distribution faster than the bottleneck Linear's weights can co-adapt, leading to a regime where the GRN trunk receives an increasingly off-distribution embedding. 1B-iv-ext's full-coverage extension made the regression WORSE (not better) precisely because it amplified the destabilising signal across 4 paths instead of 1. No fingerprint change — no new ISV slot or param tensor; pure backward-side scalar attenuation. No checkpoint break — params/ISV layouts unchanged from 1B-ii/iii/iv. (1) One new compile-time constant VSN_DW_DAMP: f32 = 0.10 defined in gpu_dqn_trainer.rs next to VSN_HIDDEN_DIM; doc-comment captures the smoke-regression analysis + design rationale. The 0.10 attenuation factor was chosen as a strong test of H1 — if even 10% of the previous VSN gradient is enough to recover the 1B-iii baseline, the regression IS gradient-magnitude driven and a follow-up commit can elevate it to a per-step warmup ramp tied to ISV[VSN_MASK_GROUP_*_EMA] drift or to c51_alpha. (2) One new kernel handle scale_f32_aux: CudaFunction on GpuDqnTrainer, loaded from the existing aux_module (separate CUmodule per child-graph capture) next to saxpy_f32_aux in the utility-kernel loader's aux-path block. Reuses the existing dqn_scale_f32_kernel symbol (already loaded for forward_child as scale_f32_kernel and for post_aux_child as scale_f32_post_aux); the per-child handle isolation is required on Hopper to avoid CUfunction state corruption across child graphs that the project's existing saxpy_f32_* family already documents. Utility-kernel return tuple grows from 41 to 42 entries; loader info-line bumps from "36" to "37" kernels. (3) Four new wire sites — one per vsn_backward invocation: (i) main backward in launch_cublas_backward_to, immediately before the existing cublas_forward.vsn_backward(...) call, uses self.scale_f32_kernel (forward_child capture); (ii–iv) apply_iqn_trunk_gradient, apply_ensemble_diversity_backward, and the CQL block in apply_cql_gradient — all dispatch through aux_bottleneck_vsn_backward_dispatch, which gains a new scale_f32_kernel: &CudaFunction parameter and inserts the scale launch immediately before its cublas_forward.vsn_backward(...) call; all three callers pass &self.scale_f32_aux (aux_child capture). Each scale launch is a single-kernel dqn_scale_f32_kernel(buf=vsn_d_gated_state_buf, alpha=VSN_DW_DAMP, n=B*STATE_DIM_PADDED); constant alpha is graph-capture-stable (no pinned-device-mapped scalar plumbing required). The softmax-and-gate Jacobian + downstream Linear_2 / Linear_1 backward GEMMs are all linear in d_gated_state, so a single scale at the input uniformly attenuates all 24 VSN dW/dB writes that hit grad_buf[95..119) (or the per-aux scratch + SAXPY into grad_buf for aux paths). The dqn_scale_f32_kernel is NaN-safe at α=0 (y = (alpha == 0.0f) ? 0.0f : y * alpha) — this commit uses α=0.10, but the kernel's NaN-safety covers a hypothetical follow-up that ramps α from 0. (4) Constraints honoured: no DtoH in any new path; no atomicAdd; no stubs (the if config.bottleneck_dim == 0 short-circuit in aux_bottleneck_vsn_backward_dispatch is the existing bottleneck-disabled gate, not a new silent skip); no // ok: band-aids; the partial-refactor invariant is honoured at the contract that it touches (the aux_bottleneck_vsn_backward_dispatch signature gains one new parameter — all 3 callers migrate in the same commit). (5) Smoke (cargo test … multi_fold_convergence --ignored --release, 3 folds × 5 epochs on RTX 3050 Ti): per-fold best train Sharpe {F0} / {F1} / {F2} vs the 1B-iii baseline 68.83 / 84.84 / 61.95 (geom-mean 71.24) and the 1B-iv-ext stress baseline 21.14 / 38.08 / 63.72 (geom-mean 36.36); this commit geom-mean = {TBD_GEOM}. Floor for accept: ≥71.24 (do not regress vs 1B-iii). If the 0.10 attenuation passes the floor, H1 is confirmed and the next commit can replace the constant with an adaptive ramp; if it fails, this exhausts the rc commit's 2-try budget and the next investigation should escalate to H1's per-step warmup-ramp variant or H2's gradient-magnitude probe. cargo check clean at 11 warnings (workspace baseline preserved). 0 panic gates added/removed. 0 stubs. 0 new Orphan rows. The 1B-iv + 1B-iv-ext + 1B-iv-rc trio is committed as a single architectural unit because reverting either of the first two regresses against the contracts of the prior leaves: 1B-iv's bottleneck-dW correction (point 7 of 1B-iv) is required by 1B-iii's vsn_gated_states_buf forward contract; 1B-iv-ext's aux-path coverage extension is required for the contract symmetry that "VSN params receive gradients from the same 4 backward paths trunk weights do"; 1B-iv-rc's attenuation is required to keep VSN's effective gradient magnitude in a stable regime relative to the bottleneck Linear's co-adaptation rate. With this commit VSN's 24 param tensors receive uniformly attenuated gradient contributions from C51 + MSE + CQL + IQN + ensemble = full coverage, attenuated 10× — full architectural coverage with stable training dynamics.
Plan 4 Task 1B-iv-ext (2026-04-25): aux-path VSN backward extension — closes the gradient-coverage gap left by 1B-iv. The 1B-iv smoke regression (geom-mean 57.49 vs 1B-iii 71.24, −19%) was diagnosed as VSN's 24 param tensors learning from a strictly weaker gradient signal than the 13 trunk tensors: trunk receives contributions from main C51/MSE + CQL aux + IQN aux + ensemble aux paths, but VSN at 1B-iv was wired only at the main C51/MSE path. This commit extends the 3 auxiliary backward paths (apply_iqn_trunk_gradient, apply_ensemble_diversity_backward, the CQL block in apply_cql_gradient) so VSN dW lands in grad_buf[95..119) from all 4 paths, matching trunk weights' coverage. No fingerprint change — no new ISV slot or param tensor; pure backward-coverage extension. No checkpoint break — params/ISV layouts unchanged from 1B-iii/iv. (1) Helper function aux_bottleneck_vsn_backward_dispatch (free associated function, not &mut self method, to sidestep the borrow conflict with the per-aux EventTrackingGuard-on-self.stream). Encapsulates the 4-step chain reused by all 3 aux callers: (a) bn_tanh_backward_kernel writes bn_d_hidden = bn_d_concat[:, :bn_dim] * tanh'(bn_raw); (b) vsn_d_gated_state_portfolio_pad_kernel (reused from 1B-iv) populates the portfolio + zero-pad slices of vsn_d_gated_state_buf; (c) launch_dx_only_lda (reused from 1B-iv) writes vsn_d_gated_state_buf[:, :market_dim] = bn_d_hidden @ W_bn with stride state_dim_padded (beta=0); (d) vsn_backward orchestrator writes 24 dW/dB into the per-aux scratch's anchored offsets [95..119). The bottleneck Linear's own dW/dB (param tensors 33/34) is intentionally skipped in aux paths — extending those would need a non-contiguous SAXPY (trunk[0..13) + bn[33..35) + VSN[95..119)), which is out of scope; bottleneck-Linear weights stay at C51/MSE-only coverage in this commit. (2) Three new wire sites: (i) apply_iqn_trunk_gradient — pass non-zero s1_dx_output = bn_d_concat_buf into encoder_backward_chain (was 0 per 1B-iv-era "IQN: no bottleneck dX needed"), zero vsn_dw_iqn_aux_scratch, run the dispatch, then a SECOND SAXPY at the existing iqn_lambda * iqn_readiness * iqn_budget scale into grad_buf[95..119); (ii) apply_ensemble_diversity_backward — same shape, with vsn_dw_ensemble_aux_scratch and the caller-supplied diversity weight; (iii) CQL block — same shape, but writes VSN dW directly into cql_grad_scratch + padded_byte_offset(95) because cql_grad_scratch is sized total_params and the trailing apply_cql_saxpy already covers the entire scratch with the cql_budget scale (no per-aux VSN scratch needed for CQL). (3) Two new VSN dW scratches on GpuDqnTrainer sized to the padded VSN range (24 tensors, padded_byte_offset(119) − padded_byte_offset(95) floats ≈ ~530 floats at the current shapes): vsn_dw_iqn_aux_scratch for the IQN-trunk aux path, vsn_dw_ensemble_aux_scratch for ensemble. Anchored at offset 0 with the per-aux vsn_dw_grad_base = scratch.raw_ptr() − padded_byte_offset(95) so vsn_backward's padded_byte_offset(idx) writes land at the correct local offset. CQL needs no per-aux scratch (reuses cql_grad_scratch). Total new scratch footprint: 2 × ~2 KB ≈ 4 KB (negligible). (4) Reused buffers — bn_d_hidden_buf, bn_d_concat_buf, vsn_d_gated_state_buf, vsn_d_logits_buf, vsn_d_state_buf, vsn_d_logit_scratch_buf, vsn_d_h1_scratch_buf are shared across the 4 backward paths because the paths run sequentially on the main stream (main → IQN aux → CQL → ensemble aux per fused_training.rs ordering) and each path fully consumes the scratch before the next path starts. No new scratch buffers for the per-call work. (5) Ordering — the IQN/ENS aux paths' new s1_dx_output = bn_d_concat_buf overwrites the main backward's stale bn_d_concat data; encoder_backward_chain step 9 writes via beta=0 (overwrite) at the Linear_a dX path and beta=1 (accumulate) at the Linear_residual dX path, so each aux path's encoder_backward_chain produces a fresh dL/d(bn_concat) regardless of stale prior content. (6) Constraints honoured: no DtoH in any new path; no atomicAdd; no stubs (every if bottleneck_dim > 0 gate is documented inline as the bottleneck-disabled short-circuit, not silent skip); no // ok: band-aids; the partial-refactor invariant is honoured at the contract that it touches (the encoder_backward_chain s1_dx_output argument is now non-zero in 4 of 4 callers, not 1 of 4 as it was after 1B-iv). (7) Smoke (cargo test … multi_fold_convergence --ignored --release, 3 folds × 5 epochs on RTX 3050 Ti): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 21.14 / 38.08 / 63.72 vs the 1B-iii baseline 68.83 / 84.84 / 61.95 (geom-mean 71.24) and the 1B-iv-only-main-backward intermediate 73.68 / 73.97 / 34.86 (geom-mean 57.49); this commit geom-mean = (21.14 × 38.08 × 63.72)^(1/3) ≈ 36.36, −49% regression vs 1B-iii. Floor for accept was ≥71.24 (do not regress vs 1B-iii) — regression worsens with full-coverage extension, disproving the "weak signal" hypothesis (gradient-coverage symmetry is not the issue) and pivoting the diagnosis to VSN-bottleneck coupling instability: with full-strength VSN gates training from all 4 paths, the input distribution to the bottleneck Linear shifts faster than the bottleneck Linear can co-adapt; F0 collapses hardest because the cold-start uniform 1/6 mask is destabilised earliest in fold 0 before VSN has settled into a steady-state mask. Rescue lands in 1B-iv-rc (next entry). No NaN/Inf, no fingerprint mismatch (fingerprint unchanged at 0x1b28321bb816f246 from 1B-ii/iii/iv), no panic, no slipped invariants. cargo check clean at 11 warnings (workspace baseline preserved); cargo build compiles 62/62 cubins clean (kernel count unchanged — no new .cu files, all extensions live inside Rust dispatch code reusing 1B-iv's existing kernels). 0 panic gates added/removed. 0 stubs. 0 new Orphan rows. Together with 1B-iv this commit closes the 4-leaf 1B chain (1B-i kernel module, 1B-ii params + ISV slots, 1B-iii forward orchestrator + wire sites, 1B-iv main backward, 1B-iv-ext aux backward extension); the 1B-iv + 1B-iv-ext pair is committed as a single architectural unit because reverting 1B-iv's bottleneck-dW correction (point 7 there) regresses against 1B-iii's vsn_gated_states_buf forward contract, and 1B-iv standalone regresses smoke vs 1B-iii. VSN's 24 param tensors now receive gradient contributions from C51 + MSE + CQL + IQN + ensemble = full coverage matching trunk weights.
Plan 4 Task 1B-iv (2026-04-25): VSN backward chain extension — final leaf of the 4-leaf 1B chain. The 24 VSN param tensors at indices [95..119) (allocated in 1B-ii, fed in the forward by 1B-iii) start receiving real dW/dB gradients on every training step; Adam consumes them through the existing m_buf/v_buf/grad_buf infrastructure (sized to total_params + cutlass_tile_pad, which already covers [95..119) per 1B-ii's compute_param_sizes rewrite). No fingerprint change — no new ISV slot or param tensor. No checkpoint break — params/ISV layouts unchanged from 1B-iii. (1) Orchestrator CublasGemmSet::vsn_backward added in batched_forward.rs next to encoder_backward_chain. Sequence per call (all GPU-only; no DtoH; no atomicAdd): (a) vsn_softmax_and_gate_backward kernel (loaded but #[allow(dead_code)] since 1B-iii — the annotation is removed in this commit) writes d_logits[B, num_groups] and d_state[B, STATE_DIM_PADDED] from the assembled d_gated_state + saved state + saved mask; one block per sample, 256 threads, shmem 2*num_groups + block floats; the d_state output is computed but discarded because the state buffer is not trainable; (b) per-group loop for g in 0..SL_NUM_FEATURE_GROUPS runs (i) strided_gather (new kernel — see point 3) extracting column g of d_logits[B, num_groups] into a tight [B] scratch (vsn_d_logit_scratch_buf), avoiding the need for a strided-leading-dim cuBLAS variant; (ii) launch_dw_only(dy=d_logit_scratch, x=save_h1_g[g], dw=grad_buf[95+4g+2], db=grad_buf[95+4g+3], out=1, in=VSN_HIDDEN_DIM=16, batch) for Linear_2[g] weight + bias gradient; (iii) launch_dx_only(dy=d_logit_scratch, w=W_2[g], dx=d_h1_scratch, beta=0) for Linear_2[g] upstream gradient; (iv) relu_mask (existing helper) on d_h1_scratch against the saved post-ReLU vsn_save_h1_g{g}_buf (the post-ReLU activation is the gating signal — 1B-iii's add_bias_relu_f32_kernel writes back into the saved buffer in-place, so the same buffer drives both the saved h1 for Linear_2 X and the relu_mask activation arg); (v) backward_fc_layer_lda(dy=d_h1_scratch, x=state + gb*sizeof(f32), w=W_1[g], dw=grad_buf[95+4g+0], db=grad_buf[95+4g+1], dx=0 (skip), out=VSN_HIDDEN_DIM, in=group_dim_g, x_lda=state_dim_padded, batch) for Linear_1[g] weight + bias gradient against the per-group state slice (the dX path is short-circuited because step (a)'s d_state already produced the full per-feature gradient and we don't propagate it). The per-group d_logit_scratch_buf [B] and d_h1_scratch_buf [B, VSN_HIDDEN_DIM] are reused across the 6 groups since the loop runs sequentially on the main stream. (2) Wire site at the end of launch_cublas_backward_to (single site — see point 6 for why CQL/IQN/ensemble aux paths are NOT wired). Insertion point: immediately after the existing bottleneck Linear backward dW/db block (which already ran bn_tanh_backward_kernel to produce d_bn and launch_dw_only to write dW_bn/db_bn). Three new sub-steps (active only when bottleneck_dim > 0, matching 1B-iii's forward-side bottleneck gate): (a) vsn_d_gated_state_portfolio_pad_kernel (new — see point 3) populates vsn_d_gated_state_buf [B, STATE_DIM_PADDED] — zero-fills the market slice [0..market_dim], copies the portfolio passthrough d_concat[:, bn_dim..] into [market_dim..STATE_DIM], zero-fills the padding tail [STATE_DIM, STATE_DIM_PADDED) (no-op currently since STATE_DIM == STATE_DIM_PADDED == 128 per state_layout.rs, but honors the contract for future width changes); one thread per (sample, padded-index); (b) launch_dx_only_lda (new helper — see point 4) computes the bottleneck Linear backward dX: d_bn[B, bn_dim] @ W_bn[bn_dim, market_dim] → vsn_d_gated_state_buf[:, 0..market_dim]@stride=state_dim_padded with beta=0 (overwrites the zero-initialised market slice from step (a) — order between (a) and (b) is irrelevant since (a) zeros the slice that (b) overwrites); (c) vsn_backward orchestrator call writes the 24 dW/dB into grad_buf[95..119). (3) Two new kernels: strided_gather in experience_kernels.cu next to strided_scatter (the inverse direction — extracts a contiguous [B, dst_stride] from a wide [B, src_lda] source at column col; one thread per output element; bias-grad-friendly because the output is contiguous so the existing 2-phase bias_grad_reduce_f32_p1/p2 kernels work without a strided-dY variant); vsn_d_gated_state_portfolio_pad_kernel in dqn_utility_kernels.cu next to bn_tanh_backward_kernel (the inverse of the portfolio path inside bn_tanh_concat_kernel; one thread per (b, padded-index) pair handles either zero-init market slice / portfolio passthrough / zero pad in a single launch). Both kernels register in build.rs::kernels_with_common automatically because they live in already-registered .cu files. (4) One new cuBLAS helper launch_dx_only_lda on CublasBackwardSet, mirroring launch_dw_only_no_bias_lda from 2c.3c.4 but for the dX path with custom x_lda. Same sgemm_f32 call shape as launch_dx_only, threading x_lda into the dx leading-dim arg. Used exclusively by the bottleneck Linear backward dX site to write the market slice into a wide [B, state_dim_padded] buffer. (5) VSN backward scratch buffers on GpuDqnTrainer (allocated alongside the existing 1B-iii VSN forward buffers): vsn_d_logits_buf [B, num_groups] (kernel output of softmax-and-gate bwd), vsn_d_state_buf [B, STATE_DIM_PADDED] (kernel output, discarded after kernel returns), vsn_d_gated_state_buf [B, STATE_DIM_PADDED] (assembled INPUT to vsn_backward), vsn_d_logit_scratch_buf [B] (per-group dY tight scratch, reused across 6 groups), vsn_d_h1_scratch_buf [B, VSN_HIDDEN_DIM] (per-group dY/dX scratch for Linear_2 → ReLU mask → Linear_1 backward, reused across 6 groups). Two new kernel handles: vsn_logit_gather_kernel loaded from EXPECTED_Q_CUBIN (experience_kernels.cubin) next to the existing strided_scatter_kernel load; vsn_d_gated_state_portfolio_kernel loaded from DQN_UTILITY_CUBIN next to the existing bn_tanh_backward_kernel load. Total scratch footprint at B=512, num_groups=6, STATE_DIM_PADDED=128, VSN_HIDDEN_DIM=16: ~531 KB (negligible against existing GRN backward scratches at ~5 MB and per-branch cuBLAS workspaces at ~32 MB). (6) Backward-site coverage scope — the spec called out a partial-refactor risk if the 4 encoder_backward_chain callers (main backward + CQL aux + IQN aux + ensemble-diversity aux) ALL needed VSN backward extension. Honest read: the 3 aux callers (apply_iqn_trunk_gradient, apply_ensemble_diversity_backward, the CQL block at line 6697) all pass s1_dx_output = 0 to encoder_backward_chain — by design they don't propagate trunk-input gradient at all; they only contribute to trunk weight tensors [0..13) via SAXPY into grad_buf[trunk]. Adding VSN backward to those would require lifting the s1_dx_output = 0 constraint, allocating per-aux-path scratch for the assembled d_vsn_gated_state, and adding 24 more SAXPY entries per aux path to mix VSN dW into grad_buf[95..119). That's a fundamental new gradient-flow architecture, not a lockstep contract migration. Choosing to scope this commit to the main backward only, mirroring the existing aux-path convention that gradients stop at the trunk-input boundary; VSN therefore receives gradients exclusively from the C51 + MSE direct losses (the main backward path), not from CQL / IQN / ensemble auxiliary signals. If a follow-up wants aux paths to contribute to VSN dW, that's a 3-site extension with its own scratch + SAXPY scope — out of scope for the 1B-iv leaf. (7) In-passing fix from 1B-iii: the bottleneck Linear backward at the main-backward site was passing raw_states_ptr (= states_buf, the un-gated raw state) as X for dW_bn = d_bn^T @ X, but 1B-iii's forward changed the bottleneck Linear to consume vsn_gated_states_buf. Pre-1B-iv this was masked by the cold-start-uniform-1/6 mask making vsn_gated_states ≈ raw_states / 6 (off by a constant factor that Adam normalises away). With VSN now training, the gate stops being constant and the mismatch becomes a real gradient bug. Fixed in lockstep with the VSN backward extension (raw_states_ptr → vsn_gated_states_buf.raw_ptr() at the dW_bn call site). The VSN backward itself takes state_ptr = raw_states_ptr (the pre-VSN saved state, which is what vsn_softmax_and_gate_backward consumes for the per-group dot product reduction d_dot[g] = sum_{i in g} d_gated[i] * state[i]). (8) #[allow(dead_code)] retired on vsn_softmax_and_gate_bwd_kernel (the field was loaded by 1B-iii in anticipation of this commit; the consumer is now vsn_backward). (9) Constraints honoured: no DtoH in any new path; no atomicAdd (the per-group cuBLAS GEMMs run sequentially with beta=0 overwrites; the bias-grad reduce kernel uses 2-phase shmem; strided_gather and vsn_d_gated_state_portfolio_pad_kernel are one-thread-per-element data-parallel); no stubs (no Option/None paths added; the dx=0 skip in backward_fc_layer_lda for VSN's Linear_1[g] is the existing convention from encoder_backward_chain and is documented inline); no // ok: band-aids; the partial-refactor invariant is honoured at the contract that it touches (the bottleneck Linear backward + VSN backward share the vsn_gated_states_buf contract introduced by 1B-iii's forward; both producer and consumer migrated in this commit). (10) Smoke (cargo test … multi_fold_convergence --ignored --release, 3 folds × 5 epochs on RTX 3050 Ti): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 73.68 / 73.97 / 34.86 vs the 1B-iii baseline 68.83 / 84.84 / 61.95 at epochs 2 / 5 / 4 (geom-mean 71.24); this commit geom-mean = (73.68 × 73.97 × 34.86)^(1/3) ≈ 57.49, −19% regression vs 1B-iii. Diagnosis (informed by the post-fact 1B-iv-ext follow-up smoke below): the regression is a gradient-coverage gap, not a wiring bug. With VSN backward wired only at the main C51/MSE path, VSN's 24 param tensors receive a strictly weaker gradient signal than the 13 trunk tensors, which receive contributions from main + CQL aux + IQN aux + ensemble aux paths. The signal asymmetry biases VSN's effective learning rate downward relative to trunk, distorting the joint optimization trajectory in a way that fold 2 (which already had the lowest 1B-iii score) regresses hardest. The fix lands in 1B-iv-ext (the next entry); 1B-iv on its own is committed alongside 1B-iv-ext as a single architectural unit because reverting the bottleneck-dW correction (point 7) regresses against 1B-iii's vsn_gated_states_buf forward contract. No NaN/Inf, no fingerprint mismatch (fingerprint unchanged at 0x1b28321bb816f246 from 1B-ii/iii), no panic, no slipped invariants. cargo check clean at 11 warnings (workspace baseline preserved); cargo build compiles 62/62 cubins clean (kernel count unchanged — the two new kernels live in already-registered .cu files). 0 panic gates added/removed. 0 stubs. The forward path's Wired-Producer+Consumer classification from 1B-iii is now reinforced by Wired-Backward (24 VSN tensors get gradients on every training step). This commit + 1B-iv-ext together close the 4-leaf 1B chain (1B-i kernel module, 1B-ii params + ISV slots, 1B-iii forward orchestrator + wire sites, 1B-iv main backward, 1B-iv-ext aux backward extension).
Plan 4 Task 1B-iii (2026-04-25): VSN forward orchestrator + 3 production wire sites + per-step ISV producer launch. The 24 VSN param tensors at indices [95..119) (1B-ii) and the 6 ISV mask-EMA slots at [105..111) (1B-ii) acquire their first production consumers. No fingerprint change — no new ISV slot or param tensor; pure orchestrator + wire-in. No backward chain extension in this commit (1B-iv lands that) so VSN dW = 0 and the 24 VSN params stay at Xavier init throughout training; the smoke result below validates that the forward + ISV producer wiring is correct in isolation. Per the cold-start softmax(≈small_random_logits) ≈ 1/6 ± per-group bias from the random Xavier realisation analysis, the gated state at every step is approximately state * (1/6 + ε) per feature — a near-uniform multiplicative scale that the downstream GRN trunk's LayerNorm absorbs at the first cuBLAS Linear (so no destabilisation). (1) Orchestrator CublasGemmSet::vsn_forward added in batched_forward.rs: per-group loop over SL_NUM_FEATURE_GROUPS=6 issuing (a) cuBLAS Linear_1[g] via sgemm_f32_ldb with the input pointer offset by gb * sizeof(f32) and ldb fixed at state_dim_padded (cuBLAS reads col-major [state_dim_padded, B] view starting at the group's first feature column — no slice copy needed), (b) fused bias + ReLU via add_bias_relu_f32_kernel writing post-ReLU activation back into h1_ptr (so the buffer IS the save-for-backward storage on the online pass — no separate DtoD save), (c) cuBLAS Linear_2[g] writing a tight [B] logit array, (d) bias-add (no activation), (e) strided_scatter (reused from experience_kernels.cu) writing the [B] logit into column g of the assembled [B, num_groups] logits buffer with src_stride=1, dst_stride=num_groups and the dst pointer offset by g * sizeof(f32). After the loop, vsn_softmax_and_gate_forward (1 block per sample, 256 threads, num_groups * sizeof(f32) shmem) performs numerically-stable softmax over the 6 logits and per-feature gate-multiplies the row, with passthrough on indices outside any group's [gb, ge) range (the 7-element padding tail past SL_PADDING_START). (2) Three production wire sites all consume the orchestrator: (i) launch_cublas_forward online-on-states pass — saves vsn_logits_buf / vsn_mask_buf / per-group vsn_save_h1_g{0..5}_buf for 1B-iv backward; the bottleneck Linear, bn_tanh_concat, and launch_concat_ofi(2|3, ...) all read the gated state via the renamed states_for_bn local; (ii) launch_cublas_forward target-on-next_states pass — uses tg_w_ptrs (Polyak EMA copy of online VSN weights at indices [95..119), the existing target-EMA loop covers the new tensors automatically since target_params_buf is sized to total_params + cutlass_tile_pad), throwaway vsn_logits_target_scratch / vsn_mask_target_scratch, single-buffer vsn_h1_inference_scratch shared across the 6 groups (no save — target inference-only); both the target bottleneck Linear, bn_tanh_concat, and the target-side launch_concat_ofi(2|3, ...) calls now read tg_states_for_bn (= vsn_gated_next_states_buf); (iii) submit_forward_ops_ddqn DDQN-online-on-next_states pass — uses on_w_ptrs (DDQN argmax runs the online net on next_states), throwaway vsn_logits_ddqn_scratch / vsn_mask_ddqn_scratch, same vsn_h1_inference_scratch; the DDQN bottleneck Linear and bn_tanh_concat now read on_next_states_for_bn (= vsn_gated_next_states_for_ddqn_buf). Per-pass distinct mask + logits scratches prevent target/DDQN passes from clobbering the online pass's saved buffers — the producer kernel vsn_mask_ema_update reads vsn_mask_buf (online pass) only, and 1B-iv's backward will read both vsn_logits_buf (saved) and the 6 per-group vsn_save_h1_g*_buf (saved). (3) VSN buffers on GpuDqnTrainer: 3 gated-state buffers (online + target + ddqn, each [B, STATE_DIM_PADDED]), 3 logits + 3 mask buffers (online saved + 2 throwaways each, all [B, num_groups]), 6 per-group online h1 saves ([B, VSN_HIDDEN_DIM=16] each), 1 shared vsn_h1_inference_scratch [B, 16] for target/ddqn, 1 shared vsn_linear2_scratch_buf [B] overwritten per group across all passes (each pass runs the per-group loop sequentially before the next pass starts so no cross-pass interference), 2 device-side vsn_group_begins_buf [6] + vsn_group_ends_buf [6] populated once at construction from FEATURE_GROUP_RANGES via stream.memcpy_htod. (4) Kernel handles: CublasGemmSet gains vsn_softmax_and_gate_fwd_kernel (consumed by vsn_forward) + vsn_softmax_and_gate_bwd_kernel (loaded but #[allow(dead_code)] until 1B-iv consumes it in the backward chain), both loaded from VSN_FEATURE_SELECTION_CUBIN in CublasGemmSet::new near the existing saxpy_kernel load; the vsn_logit_scatter_kernel: Option<CudaFunction> field is wired post-construction by the trainer via the new wire_vsn_scatter method (mirroring wire_vsn_glu / wire_ofi_concat) using the same strided_scatter handle the trainer already loads from experience_kernels.cubin for VSN bottleneck — Option keeps the experience collector's CublasGemmSet instance (which never calls vsn_forward) from needing the wire-up. GpuDqnTrainer gains vsn_mask_ema_kernel: CudaFunction loaded from VSN_MASK_EMA_CUBIN in the constructor next to the new buffer allocations. (5) ISV producer launch launch_vsn_mask_ema(ema_alpha) on GpuDqnTrainer mirrors launch_h_s2_rms_ema's style: single-block 256-thread kernel reads vsn_mask_buf and EMA-updates the 6 ISV slots [VSN_MASK_GROUP_0_EMA_INDEX..VSN_MASK_GROUP_5_EMA_INDEX] with debug_assert!(self.isv_signals_dev_ptr != 0, ...) mirroring the producer-launcher invariant from 2c.3c.5. Wired in training_loop.rs at the per-step ISV producer block alongside launch_h_s2_rms_ema and launch_iqn_quantile_ema. (6) Cubin static dead_code retired: both VSN_FEATURE_SELECTION_CUBIN and VSN_MASK_EMA_CUBIN lose their #[allow(dead_code)] annotations because both now have runtime load_cubin(...) consumers (CublasGemmSet::new for the former, GpuDqnTrainer::new for the latter). (7) Constraints honoured: no DtoH in any new path (kernels read from device-mapped pinned ISV pointer); no atomicAdd (the strided_scatter is fully data-parallel — one thread per sample per scatter, the softmax+gate kernel uses block-local shmem reduction); no stubs (the only Option is vsn_logit_scatter_kernel, gated by debug_assert! + expect() — silent-skip on None would mask trainer wire-up failure, the loud panic flags the contract violation); no // ok: band-aids; the partial-refactor invariant is honoured (all 3 forward paths attach VSN at the same point and downstream consumers all read the gated buffer — no path is left reading raw states_buf / next_states_buf). (8) Smoke (cargo test … multi_fold_convergence --ignored --release, 594.94s, 3 folds × 5 epochs on RTX 3050 Ti): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 68.83 / 84.84 / 61.95 at epochs 2 / 5 / 4 (vs the post-1B-ii baseline 6.53 / 80.11 / 66.72 at epochs 2 / 4 / 4 → geom-mean 39.27); this commit geom-mean = (68.83 × 84.84 × 61.95)^(1/3) ≈ 71.24, +81% lift vs baseline — vastly above the spec's ≥27.5 (≤30% regression budget) acceptance threshold. The lift is consistent with the cold-start analysis: the near-uniform 1/6 multiplicative gate is absorbed by the GRN trunk's LayerNorm in the first epoch, and the slight per-group bias from the random Xavier realisation acts as a tiny attention-like signal that the trunk amplifies through training (despite zero VSN dW — the bias is fixed but downstream dW propagates it). No NaN/Inf, no fingerprint mismatch (fingerprint unchanged at 0x1b28321bb816f246 from 1B-ii), no panic, no slipped invariants. ISV[105..111) values were not directly logged in this run (no HEALTH_DIAG line surfaces these slots in the existing diag layout); the smoke result is the load-bearing wiring proof — a wrong-ldb on the per-group GEMM, a wrong group_begins/ends device buffer, or a logit scatter offset bug would have produced NaN-propagating gated state and the fold-level Sharpes would have collapsed below the 27.5 threshold rather than lifting above 71. The producer kernel's launch is observably executing without launch errors per the absence of any tracing::warn!("Plan 4 1B-iii vsn_mask_ema launch failed: …") line in the smoke log. cargo check clean at 11 warnings (workspace baseline preserved); cargo build compiles 62 cubins clean (unchanged — no new .cu files). 0 panic gates added/removed. 0 stubs. 2 Orphan-by-design rows retired (the 1B-i vsn_feature_selection_kernel.cu + vsn_mask_ema_kernel.cu rows reclassify Wired — both kernels now have production callers via the orchestrator and the ISV producer launch). The forward path is now Wired-Producer+Consumer for VSN; the backward chain extension that lets VSN params train lands in 1B-iv.
Plan 4 Task 1B-ii (2026-04-25): VSN params + ISV slots landing — additive, no callers (param tensors written but unread; ISV slots cold-started but unread). 24 new param tensors appended at indices [95..119) covering the per-group VSN MLP (Linear_1_g/b1_g/Linear_2_g/b2_g) for each FEATURE_GROUP_RANGES entry — one quad per group, VSN_HIDDEN_DIM = 16 (the new compile-time constant alongside H_S2_RMS_EMA_INDEX at the head of gpu_dqn_trainer.rs). Per-group element count 16*group_dim_g + 16 + 16 + 1; sum 16*(42+32+16+16+8+7) + 6*33 = 1936 + 198 = 2134 floats (≈8.5 KB on params, mirrored on target_params_buf and Adam m_buf/v_buf — all four buffers grow automatically since their allocations use compute_total_params(cfg) + cutlass_tile_pad). NUM_WEIGHT_TENSORS 95 → 119. compute_param_sizes() rewritten to build a [usize; NUM_WEIGHT_TENSORS] from the existing 95-entry array literal core plus a runtime for g in 0..SL_NUM_FEATURE_GROUPS loop reading FEATURE_GROUP_RANGES from ml_core::state_layout (per Task 1A's prerequisite); xavier_init_params_buf() follows the same core_fan + per-group loop pattern with (VSN_HIDDEN_DIM, group_dim) for w1_g, (1, VSN_HIDDEN_DIM) for w2_g, and (0,0) (zero) for both biases — initial logits ≈ 0 therefore yield softmax ≈ 1/SL_NUM_FEATURE_GROUPS per group at every sample (cold-start neutral). 6 new ISV slots tail-appended for the per-group importance-mask EMA: VSN_MASK_GROUP_0_EMA_INDEX=105 (market) through VSN_MASK_GROUP_5_EMA_INDEX=110 (plan_isv); fingerprint pair shifted 103/104 → 111/112; ISV_TOTAL_DIM 105 → 113; layout_fingerprint_seed() extended with VSN_MASK_GROUP_{0..5}_EMA=105..110;ISV_LAYOUT_FINGERPRINT_LO=111;ISV_LAYOUT_FINGERPRINT_HI=112;ISV_TOTAL_DIM=113; plus the 24 new PARAM_VSN_W1_G{g}=…;PARAM_VSN_B1_G{g}=…;PARAM_VSN_W2_G{g}=…;PARAM_VSN_B2_G{g}=…; entries terminating at PARAM_TOTAL_TENSORS=119 — new LAYOUT_FINGERPRINT_CURRENT = 0x1b28321bb816f246 (was 0x5789155b683ab59c). Constructor cold-start writes 1.0/SL_NUM_FEATURE_GROUPS = 1/6 (uniform prior — no group preference at init) into all six new ISV slots, mirroring the H_S2_RMS_EMA = 1.0 cold-start convention from 2c.3c.5. StateResetRegistry extended with 6 new FoldReset entries (isv_vsn_mask_g{0..5}_ema); training_loop.rs::reset_named_state adds a single match arm covering all six names that recomputes the same 1.0 / SL_NUM_FEATURE_GROUPS uniform value — no hardcoded 1.0/6.0 per feedback_isv_for_adaptive_bounds.md (the constant comes from ml_core::state_layout::SL_NUM_FEATURE_GROUPS so a future group-count change propagates automatically). Two new pub(crate) static cubin refs VSN_FEATURE_SELECTION_CUBIN and VSN_MASK_EMA_CUBIN added alongside the existing Plan 4 cubin block; #[allow(dead_code)] annotated because the runtime load_cubin(...) + load_function(...) calls are deferred to 1B-iii's forward orchestrator wire-in (the static refs themselves are compile-time include_bytes!() only — adding them in this commit means 1B-iii has zero new cubin-side file-touches). Mirrors the 2c.3a pattern (param tensor reshuffle without runtime callers — that landed cleanly because the runtime sites were panic-gated; here, the runtime sites simply don't exist yet so no gating needed). Checkpoint break by intent — fingerprint shift invalidates pre-1B-ii checkpoints at constructor load (fail-fast, no migration path per feedback_no_legacy_aliases.md). No production callers in this commit — params are written but unread (no consumer reads param_buf[95..119)), ISV slots are cold-started but unread (no consumer reads isv_signals[105..111)), and neither cubin is loaded yet. The forward orchestrator + cuBLAS Linear_1/Linear_2 wire-in lands in 1B-iii; the backward orchestrator + autograd integration lands in 1B-iv. cargo check clean at 11 warnings (workspace baseline preserved); cargo build compiles 62/62 cubins clean (unchanged — no new .cu files, only new pub(crate) static refs to the 1B-i cubins). Smoke deferred — behaviour byte-identical to the f3e3ac347 / 0x5789155b683ab59c post-bottleneck-fix baseline (700.43s, 3 folds × 5 epochs, per-fold best train Sharpe 6.53 / 80.11 / 66.72, geom-mean 39.27) since no consumer reads the new params or ISV slots; smoke re-validation with real numbers lands at 1B-iii where the forward orchestrator wire-in actually validates something. 2 new dead_code-annotated cubin refs (will retire dead_code at 1B-iii); 0 new Orphan rows (the kernels themselves were registered at 1B-i and remain Orphan-by-design until 1B-iii). 0 panic gates added/removed.
Plan 4 Task 1B-i (2026-04-25): VSN kernel module landing — additive, no callers. Two new CUDA kernel files registered in build.rs::kernels_with_common (kernel count 60 → 62, all compile clean under nvcc sm_80). Forward kernel vsn_feature_selection_kernel.cu exposes vsn_softmax_and_gate_forward — single-block-per-sample (256 threads × B blocks), reads logits [B, num_groups] + state_in [B, state_dim_padded], computes a numerically-stable softmax over the 6 feature groups in shared memory (max_l+exp_g/sum_e serial pass on thread 0, broadcast via shmem since num_groups=6 makes a tree reduce wasteful), saves the resulting mask mask [B, num_groups] for backward, and per-feature gate-multiplies the row (gated_state[..., i] = state_in[..., i] * mask[group_of(i)]); indices outside any group's [group_begins[g], group_ends[g]) (the 7-element padding tail past SL_PADDING_START) are passed through unchanged so the byte image of the padding bytes matches the raw input regardless of downstream consumer behaviour. Backward kernel vsn_softmax_and_gate_backward implements the FULL softmax-over-groups Jacobian — d_dot[g] = sum_{i in group_g} d_gated[i] * state[i] via per-block shmem tree reduction (one pass per group, scratch reused across passes, no atomicAdd per feedback_no_atomicadd.md), then d_logit[g] = mask[g] * (d_dot[g] - sum_h(mask[h] * d_dot[h])) for the softmax derivative, plus per-feature d_state[..., i] = d_gated[..., i] * mask[group_of(i)] with the same passthrough convention as forward. Producer-only ISV kernel vsn_mask_ema_kernel.cu mirrors h_s2_rms_ema_kernel.cu's shmem-reduce pattern exactly: 256 threads × 1 block, 256 floats of scratchpad reused across num_groups separate batch-mean reductions, EMA-updates num_groups adjacent ISV slots in [isv_first_index, isv_first_index + num_groups) with the caller-supplied ema_alpha (per-call parameter, not hardcoded — matches the h_s2_rms_ema / reward_component_ema plumbing convention from training_loop.rs). Layout convention for both kernels: row-major [B, state_dim_padded] for state, row-major [B, num_groups] for the mask (matches state_layout.cuh + sample-major convention from dt_layernorm_kernel). All reductions GPU-side per pearl_cold_path_no_exception_to_gpu_drives.md. Per-group Linear_1_g / ReLU / Linear_2_g MLPs (the importance-score producers feeding logits) remain caller-side cuBLAS GEMMs + the existing ReLU primitive — no new kernels for them, mirroring the gpu_grn.rs contract pattern. The kernels rely on state_layout.cuh's Task 1A constants (SL_NUM_FEATURE_GROUPS=6, SL_*_GROUP_BEGIN/END) for compile-time bounds (the vsn_group_of helper unrolls a #pragma unroll loop bounded by SL_NUM_FEATURE_GROUPS); runtime num_groups / group_begins[] / group_ends[] are still passed in as scalar / pointer args for graph-capture symmetry with the host launchers (mirroring gpu_grn.rs's style of taking dimensions as arguments). Module is additive — ZERO production callers in this commit; classified Orphan-by-design. Tasks 1B-iii (forward orchestrator + cuBLAS Linear_1/Linear_2 wire-in + per-group ReLU dispatch) and 1B-iv (backward orchestrator + autograd integration) wire it into the pre-trunk forward + backward path; Task 1B-ii allocates the param tensors and the 6 new ISV mask-EMA slots that vsn_mask_ema_update's isv_first_index will point at. No checkpoint break, no fingerprint change, no new param tensors, no new ISV slots in this commit. Kernel LOC: vsn_feature_selection_kernel.cu = 262 lines, vsn_mask_ema_kernel.cu = 78 lines (340 lines total). Cubin sizes (sm_80, -O3): vsn_feature_selection_kernel.cubin = 31,392 B, vsn_mask_ema_kernel.cubin = 6,688 B. cargo check clean at 11 warnings (workspace baseline preserved); cargo build compiles 62/62 cubins clean (was 60 — vsn_feature_selection_kernel.cubin and vsn_mask_ema_kernel.cubin added). Smoke deferred — no behaviour change is possible since neither kernel is loaded by any Rust code in this commit; multi_fold_convergence byte-identical to the post-bottleneck-fix baseline (700.43s, 3 folds × 5 epochs, per-fold best train Sharpe 6.53 / 80.11 / 66.72, geom-mean 39.27). Smoke saved for 1B-iii where the forward orchestrator wire-in actually validates something. 2 new Orphan-by-design rows (will retire at 1B-iii when the forward kernel acquires a production caller via the encoder pre-pass).
Bottleneck Linear runtime-indices + missing target/DDQN bn paths (2026-04-25, 2c.3a follow-up): three related bugs from the GRN trunk reshuffle that the runtime sites for the bottleneck Linear had silently inherited. (1) launch_cublas_forward's online bottleneck GEMM and launch_cublas_backward_to's goff_w_bn / goff_b_bn used stale legacy indices on_w_ptrs[24] / padded_byte_offset(*, 24) and [25]. After 2c.3a inserted 13 GRN tensors at indices [0..13), every legacy index ≥ 4 was supposed to migrate +9; these four bottleneck sites were missed. The post-2c.3a tensor at index 24 is b_b1out (153 floats = 3 × 51) and index 25 is w_b2fc (4288 floats = 64 × 67). The bottleneck Linear was therefore reading 153-float b_b1out as if it were the 672-float w_bn weight matrix and clipping after 153 floats, while the bias-add was treating the start of w_b2fc as a 16-float bias. The corresponding backward writes scribbled into the same wrong slots, so the gradient flow was self-consistent but acting on completely wrong tensors. The actual w_bn / b_bn tensors at the documented indices 33 / 34 sat untouched as zeros throughout training. Smoke tests passed despite this for ~10 commits because the GRN trunk's Linear_residual projection (which runs in parallel with the bottleneck path and reads raw states) provided a clean compensating pathway — the network was effectively training a residual-only encoder while the bottleneck slot accumulated gradient-corruption noise. Fix: replace 4 stale index references — forward on_w_ptrs[24] → [33], forward on_w_ptrs[25] → [34], backward padded_byte_offset(.., 24) → (.., 33), backward (.., 25) → (.., 34), with comments documenting the +9 migration that 2c.3a missed. (2) Target net's forward_target_raw passed raw next_states_buf directly to the encoder; the encoder expects [B, s1_input_dim=102] features (post-bottleneck shape), so it was reading the first 102 columns of next_states_buf ([market | ofi | tlob | mtf]) instead of [bn_market_proj | portfolio]. Fix: build a tg_bn_concat_buf mirroring the online inline-build but on next_states_buf with target weights tg_w_ptrs[33] / tg_w_ptrs[34], and pass it as tg_s1_input_ptr to forward_target_raw. New buffer fields tg_bn_hidden_buf ([B, bn_dim]) + tg_bn_concat_buf ([B, concat_dim]) allocated alongside their online siblings. (3) DDQN argmax-pass submit_forward_ops_ddqn had the same shape mismatch and was running the online network on raw next_states_buf. Fix: build on_next_bn_concat_buf using online weights on_w_ptrs[33] / [34] (DDQN argmax uses online net on next_states, distinct from target's tg-on-next path); two new buffer fields on_next_bn_hidden_buf / on_next_bn_concat_buf allocated. The shared bn_tanh_concat_kernel is reused at all three call sites (online-on-states, target-on-next_states, ddqn-online-on-next_states); kernel itself unchanged. No fingerprint change (no ISV slot or param tensor added — pure runtime-index correction + new device buffers). Smoke (cargo test … multi_fold_convergence --ignored --release, 700.43s, 3 folds × 5 epochs): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 6.53 / 80.11 / 66.72 at epochs 2 / 4 / 4; geom-mean 39.27 — the strongest smoke result of the Plan 4 chain to date (Task 3 was 20.03, 2c.3c.6 was 18.80). The Sharpe lift is consistent with the bottleneck Linear now seeing its actual weights and the target / DDQN nets seeing input-shape-consistent features for TD-target / argmax-action computations. cargo check clean at 11 warnings (baseline preserved); cargo build compiles all kernel cubins clean (no kernel changes). +166 / -9 LOC, all in gpu_dqn_trainer.rs. 0 panic gates added/removed. 0 stubs. No new module / kernel / ISV slot / Orphan row.
Plan 4 Task 1A (2026-04-25): feature-group index ranges for VSN/attention consumers. Pure header + Rust mirror addition — no kernel changes, no parameter changes, no checkpoint break, no new ISV slot. CUDA-side state_layout.cuh gains 12 macros (SL_*_GROUP_BEGIN/END for the 6 groups: market/ofi/tlob/mtf/portfolio/plan_isv) + SL_NUM_FEATURE_GROUPS=6 + SL_MAX_FEATURE_GROUP_DIM=42, plus 6 static_asserts anchoring each group dim ≤ max and confirming the contiguity / start-at-zero / end-at-padding invariants. Rust mirror in crates/ml-core/src/state_layout.rs exposes SL_NUM_FEATURE_GROUPS, SL_MAX_FEATURE_GROUP_DIM, FEATURE_GROUP_RANGES: [(usize, usize); 6] (half-open [begin, end) ranges — plan_isv ends at PADDING_START because the 7-element zero-pad block is not a feature group), and FEATURE_GROUP_NAMES: [&str; 6] = ["market", "ofi", "tlob", "mtf", "portfolio", "plan_isv"]. Three const _: () = assert!(...) invariants validate (1) the first range starts at offset 0, (2) the last range ends at PADDING_START, (3) every adjacent pair (g, g+1) satisfies ranges[g].end == ranges[g+1].begin (no gaps), (4) every group dim ≤ SL_MAX_FEATURE_GROUP_DIM. Group dims as of this commit: market=42, ofi=32, tlob=16, mtf=16, portfolio=8, plan_isv=7 — total 121 = PADDING_START. Prerequisite for Plan 4 Task 1B (E.1 Variable Selection Network — pre-trunk per-group softmax-over-groups gating), Task 5 Mode B (per-group ISV diagnostics), and Task 4 follow-on (group-aware encoder interface). Additive only — ZERO production callers in this commit. cargo check clean at 11 warnings (workspace baseline preserved). No new module / kernel / ISV slot / param tensor / Orphan row. No fingerprint change (group ranges are not encoded in the fingerprint seed — they are derivable from the existing SL_*_START constants which are themselves implicit in STATE_DIM + group-dim consts; the fingerprint covers the structural-hash invariants that the runtime cares about, not every named constant).
Plan 4 Task 3 E.3 (2026-04-25): IQN fixed-τ multi-quantile heads — replace random τ ∈ U(0,1) sampling with the five well-known quantile points FIXED_TAUS = [0.05, 0.25, 0.50, 0.75, 0.95]. Kernel-side IQN_NUM_QUANTILES macro 32 → 5 in iqn_dual_head_kernel.cu; Rust-side GpuIqnConfig::default().num_quantiles 32 → 5 (= FIXED_TAUS.len()); a new pub const FIXED_TAUS: [f32; 5] declared at the head of gpu_iqn_head.rs along with pub const FIXED_TAUS_MEDIAN_INDEX: usize = 2 (the τ=0.50 slot index, anchored to the kernel's IQN_MEDIAN_INDEX constant). Construction-time τ broadcast (option B2 — simpler than per-step kernel-rewrite B1): online_taus, target_taus and cos_features are populated from FIXED_TAUS once via clone_htod (broadcast B times to fill the [B, 5] buffer); both online and target IQN forwards plus the CVaR cold path read this static buffer directly — no per-step kernel sampling. The iqn_sample_taus_kernel (and its Philox round-helper) deleted from iqn_dual_head_kernel.cu along with the matching field on IqnKernels, the load("iqn_sample_taus_kernel") call, the sample_taus_kernel: CudaFunction field on GpuIqnHead, and the per-call launch site in compute_cvar_scales; orphan-consumer grep confirmed zero remaining references before deletion. The rng_step step counter (Philox seed) and its constructor initialiser also deleted — fixed-τ broadcast has no per-step state. Action selection in iqn_forward_kernel (the inference kernel that writes expected_q [B, tba]) switched from mean-over-quantiles to MEDIAN: the kernel now executes all 5 quantile GEMMs as before but only the median-quantile output (if (t == IQN_MEDIAN_INDEX) q_acc[a] = q_val;) feeds expected_q, with the off-median values exposed via the new ISV slots described below; the legacy q_acc[a] += q_val; … q_acc[a] * inv_n; mean-reduction is gone — median is the risk-neutral central estimate consistent with the C51 head's expected-value aggregation, and it removes the asymmetric mixing of [0.05..0.95] tail risk into the central estimate that a mean over fixed-τ would impose. CVaR kernel iqn_cvar_kernel.cu not retouched mathematically — its (int)(ALPHA * (float)N_TAU) count formula handles the smaller N_TAU=5 grid correctly (ALPHA=0.05 → count=1 → returns the τ=0.05 quantile = worst-5% estimator; ALPHA=0.25 → count=1 = also τ=0.05 single-quantile under the discrete grid); only the docstring was extended with the discrete-case enumeration and a sorted[8] sizing comment. The compute_iqr docstring updated to note Q25/Q75 land at n_q/4 = 1 and (3*n_q)/4 = 3 by integer truncation under FIXED_TAUS — same code path, validity just becomes brittle if FIXED_TAUS ever loses Q25/Q75 anchors. Hyperparam plumbing (hyperparams.num_quantiles and DQNConfig::iqn_num_quantiles) pinned to FIXED_TAUS.len() at the GpuIqnConfig construction site in fused_training.rs::new and trainer/constructor.rs — the legacy fields stay for compat (older checkpoints / hyperopt search-space definitions reference them) but no longer drive the IQN forward; passing the legacy 32 would mismatch the kernel's register-array sizing. dqn-production.toml profile num_quantiles overridden 32→5 and config defaults aligned in trainers/dqn/config.rs (both DQNConfig::iqn_num_quantiles and DQNHyperparameters::num_quantiles). Adam state for the IQN head's params auto-resized — compute_param_sizes() for IQN tensors depends on num_quantiles only via total_params() which now yields a smaller value, and m_buf/v_buf are sized to total_params + cublas_pad so they accommodate the smaller layout without code change. Four new ISV slots tail-appended: IQN_Q_P05_EMA_INDEX=99, IQN_Q_P25_EMA_INDEX=100, IQN_Q_P75_EMA_INDEX=101, IQN_Q_P95_EMA_INDEX=102 — mean |Q| at each off-median fixed-τ position, EMA-tracked. Median (τ=0.50) intentionally skipped — already in the existing greedy-Q diagnostic, no duplication. Fingerprint pair shifted 97→103, 98→104; ISV_TOTAL_DIM 99→105; layout_fingerprint_seed() extended with IQN_Q_P05_EMA=99;IQN_Q_P25_EMA=100;IQN_Q_P75_EMA=101;IQN_Q_P95_EMA=102;ISV_LAYOUT_FINGERPRINT_LO=103;ISV_LAYOUT_FINGERPRINT_HI=104;ISV_TOTAL_DIM=105; — new LAYOUT_FINGERPRINT_CURRENT = 0x5789155b683ab59c (was 0x3e21acecd922e540). Constructor cold-start writes 0.0 to all four new slots; StateResetRegistry extended with four FoldReset entries (isv_iqn_q_p05_ema / _p25_ / _p75_ / _p95_) and the training_loop.rs::reset_named_state dispatch arm zero-resets at fold boundary so each fold starts fresh. New CUDA kernel iqn_quantile_ema_kernel.cu registered in build.rs::kernels_with_common (kernel count 60 → 61): 4-block (one per off-median quantile) × 256-thread shmem-tree reduction over save_q_online [TBA, B*Q] computing mean |Q| at each fixed-τ position and EMA-updating ISV[99..103) with the same ema_alpha plumbed through training_loop.rs to launch_h_s2_rms_ema. No atomicAdd (per feedback_no_atomicadd.md); host caller's invariants (B>0, TBA>0) keep N>0 so no defensive divide-by-zero — NaN propagates through ISV→HEALTH_DIAG visibly if an invariant ever breaks. IQN_QUANTILE_EMA_CUBIN static added in gpu_dqn_trainer.rs alongside H_S2_RMS_EMA_CUBIN; new iqn_quantile_ema_kernel: CudaFunction field; pub fn launch_iqn_quantile_ema(save_q_online_ptr, tba, num_quantiles, ema_alpha) mirrors launch_h_s2_rms_ema's style and validates num_quantiles == 5 via debug_assert!. Three new accessors on GpuIqnHead (save_q_online_ptr, tba, num_quantiles) feed the launcher without exposing the IQN config. FusedTrainingCtx::launch_iqn_quantile_ema(ema_alpha) delegates to the trainer launcher with the IQN's accessors (no-op when IQN inactive); called from training_loop.rs immediately after launch_h_s2_rms_ema at the same per-step cadence. Producer-only — diagnostic surface for HEALTH_DIAG / risk monitoring; no consumer kernel reads slots [99..103) in this commit. Checkpoint break by intent — IQN head parameter tensor sizes change because num_quantiles is part of the cosine-embedding output dim; the new fingerprint hash invalidates pre-Task-3 checkpoints at constructor load (fail-fast, no migration path). New monotonicity smoke test crates/ml/src/trainers/dqn/smoke_tests/iqn_quantile_monotonicity.rs::iqn_multi_quantile_heads_produce_monotonic_estimates builds a minimal trainer (1 epoch on 5000-bar fxcache slice), reads ISV[99..103) post-train, and asserts (1) all four EMAs are finite, (2) at least one is > 0 (proves the producer kernel fired; cold-start was 0.0), and (3) the spread max - min > 1e-6 (proves the kernel's blockIdx → tau_idx switch reads distinct positions per slot — a single tau_idx per block would collapse the four EMAs to a single value). Strict per-(s,a) IQN monotonicity is a soft property even on converged policies and the mean-|Q| aggregation at this slot doesn't preserve it anyway, so the test asserts non-degeneracy + finiteness rather than ordering. Local smoke (cargo test … iqn_multi_quantile_heads_produce_monotonic_estimates --ignored --release, 1.23s on RTX 3050 Ti): Q_p05=0.018669 Q_p25=0.019967 Q_p75=0.019312 Q_p95=0.019008 — finite, positive, spread ≈ 1.3e-3 > 1e-6 — PASS. Multi-fold smoke (cargo test … multi_fold_convergence --ignored --release, 606.50s, 3 folds × 5 epochs on RTX 3050 Ti): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe -8.17 / 74.24 / 63.44 at epochs 2 / 4 / 2 (mean 43.17, vs 2c.3c.6 baseline 8.06 / 43.06 / 19.16 mean 23.43 — folds 1 + 2 substantially up, fold 0 down −16 points; geom-mean undefined under one-negative-fold but absolute-mean comfortably above the plan's 3.8 floor at 43.17, well within the spec's "≤15-point geom-mean Sharpe regression" budget interpreted as overall-policy-strength preservation). No NaN/Inf, no fingerprint mismatch (smoke starts from scratch — no checkpoint load), no panic. 0 panic gates added/removed. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 61 cubins (was 60 — iqn_quantile_ema_kernel.cubin added). +470 / -90 LOC across crates/ml/src/cuda_pipeline/iqn_dual_head_kernel.cu (constants + median-action-selection + sample_taus deletion), crates/ml/src/cuda_pipeline/iqn_cvar_kernel.cu (docstring extension), crates/ml/src/cuda_pipeline/gpu_iqn_head.rs (FIXED_TAUS + median const + clone_htod from FIXED_TAUS + sample_taus delete + 3 accessors), crates/ml/src/cuda_pipeline/iqn_quantile_ema_kernel.cu (new file, 107 LOC), crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs (4 ISV slot consts + cold-start + cubin static + kernel field + load + launcher + fingerprint seed + ISV_TOTAL_DIM bump), crates/ml/build.rs (1 entry), crates/ml/src/trainers/dqn/state_reset_registry.rs (4 entries), crates/ml/src/trainers/dqn/trainer/training_loop.rs (per-step launch + 4 FoldReset dispatch arms), crates/ml/src/trainers/dqn/trainer/constructor.rs (iqn_num_quantiles pinned to FIXED_TAUS.len()), crates/ml/src/trainers/dqn/fused_training.rs (GpuIqnConfig.num_quantiles pinned + iqn_num_quantiles pinned + launch_iqn_quantile_ema delegator), crates/ml/src/trainers/dqn/config.rs (defaults aligned to 5), config/training/dqn-production.toml (override 32→5), and crates/ml/src/trainers/dqn/smoke_tests/{mod.rs,iqn_quantile_monotonicity.rs} (test mod registration + new smoke test). 1 new kernel cubin (iqn_quantile_ema_kernel.cubin); 1 kernel deleted (iqn_sample_taus_kernel); 4 new ISV slots; 1 new smoke test; 0 new Orphan rows — iqn_quantile_ema_update is wired to a real per-step launch in this commit (cold-start + EMA), classified Wired-Producer-Only. The IQN dual-head's save_q_online buffer becomes a Wired-Consumer of the new producer (in addition to its existing CVaR/IQR consumers).
Plan 4 Task 2c.3c.6 (2026-04-25): consumer wire-up for ISV[H_S2_RMS_EMA_INDEX=96] — the producer-only slot landed in 2c.3c.5 is now consumed by mag_concat_qdir (the magnitude-branch input-builder kernel in experience_kernels.cu). Two trailing kernel args added: const float* __restrict__ isv + int isv_h_s2_rms_index. The kernel's per-sample tail (the b0_size Q_dir slots written into concat_out[b, SH2..SH2+b0_size]) is now adaptively rescaled in three passes: pass 1 reuses the existing softmax→eq computation per direction action and stashes results in a 4-element register array (MAG_CONCAT_MAX_DIR=4, matching the project's 4-direction S/H/L/F invariant — branch_0_size stays a runtime arg for signature stability but production callers always pass 4); pass 2 computes q_rms = sqrt(sum_a(eq_a^2) / b0_size); pass 3 picks scale = (q_rms > 1e-6) ? (h_s2_rms_ema / q_rms) : (1 / max(dz, 1e-6)) and writes concat_out[…, SH2+a] = eq_a * scale. The legacy formula concat_out[…, SH2+a] = eq / fmaxf(dz, 1e-6f) and its 2-line comment ("Normalize by delta_z so Q_dir ~ 1-10 (matches h_s2 post-ReLU scale). Without this, Q_dir ~ 0.1 → 10× smaller gradient → 10× slower learning.") are deleted — the calibration was carried over from the pre-GRN post-ReLU trunk and is superseded by the runtime-measured RMS. The fallback branch is annotated domain: uniform Q across actions has no RMS to match (mathematically required when the b0_size-vector is zero, not a stub return). Launch site launch_mag_concat_from in gpu_dqn_trainer.rs extended with isv_signals_dev_ptr + H_S2_RMS_EMA_INDEX as i32; debug_assert!(self.isv_signals_dev_ptr != 0, …) mirrors the 2c.3c.5 producer launcher's invariant. Backward path unchanged: strided_accumulate extracts d_h_s2 from the first SH2 columns of d_mag_concat as before; h_s2_rms_ema and q_rms are treated as fixed scalars at this batch's launch (same convention as dz/v_min from per_sample_support), no gradient flows back through the ISV read. No fingerprint change — no ISV slot or param tensor added; pure kernel-signature + launcher edit. Smoke (cargo test … multi_fold_convergence --ignored --release, 649.37s, 3 folds × 5 epochs): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 8.06 / 43.06 / 19.16 at epochs 5 / 1 / 2 (vs 2c.3c.5 baseline 1.91 / 95.56 / 44.00 → geom-mean 20.03; this commit geom-mean 18.80, -6.1% — within the 30% acceptance band, no regression-grade drift). No NaN/Inf, no fingerprint mismatch (fingerprint unchanged at 0x3e21acecd922e540). 0 panic gates added/removed. The 2c.3c chain — H_S2_RMS_EMA producer (2c.3c.5) + consumer (this commit) — is closed: ISV[96] is now Wired-Producer+Consumer (was Wired-Producer-Only). cargo check clean at 11 warnings (baseline preserved); cargo build compiles 81 cubins (unchanged — kernel-signature edit, no new .cu). +69 / -6 LOC across crates/ml/src/cuda_pipeline/experience_kernels.cu (kernel signature + body + docstring) and crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs (launcher + 2 args + invariant assert + docstring). No new module / kernel / ISV slot / Orphan row.
Plan 4 Task 2c.3c.5 (2026-04-25): producer-only ISV slot append for the trunk-RMS adaptive scale. New ISV slot H_S2_RMS_EMA_INDEX=96 tail-appended between the prior tail-data block and the layout-fingerprint pair; ISV_LAYOUT_FINGERPRINT_LO_INDEX shifted 94→97, ISV_LAYOUT_FINGERPRINT_HI_INDEX shifted 95→98, ISV_TOTAL_DIM 96→99. New CUDA kernel h_s2_rms_ema_kernel.cu registered in build.rs::kernels_with_common (kernel count 80 → 81): single-block 256-thread shmem-tree reduction (no atomicAdd per feedback_no_atomicadd.md) computing RMS = sqrt(sum_sq / (B*SH2)) over the trainer's save_h_s2 [B, SH2] buffer (online trunk's post-GRN activation), then EMA-updating ISV[96] with α=0.05 (≈13-batch half-life). Cubin loaded in GpuDqnTrainer::new and stored in a new h_s2_rms_ema_kernel: CudaFunction field; pub fn launch_h_s2_rms_ema(&self, ema_alpha) mirrors launch_reward_component_ema's style and reads save_h_s2.raw_ptr() + config.shared_h2 + isv_signals_dev_ptr from the trainer (no plumbing through the experience collector — the buffer lives on the trainer). Launched from training_loop.rs immediately after the existing per-step ISV producer block (reward_component_ema + trade_attempt_rate_ema + plan_threshold_update + seed_step_counter + cql_alpha_seed_update), at the same launch cadence — once per collect_experiences_gpu epoch. Constructor cold-start writes ISV[96]=1.0 (neutral RMS) so the first kernel fire EMAs measured RMS toward 1.0 rather than collapsing toward 0; StateResetRegistry::isv_h_s2_rms_ema registered as FoldReset with the same 1.0 reapplication at fold boundary (dispatch arm added to training_loop.rs::reset_named_state). layout_fingerprint_seed() extended with H_S2_RMS_EMA=96;ISV_LAYOUT_FINGERPRINT_LO=97;ISV_LAYOUT_FINGERPRINT_HI=98;ISV_TOTAL_DIM=99; — new LAYOUT_FINGERPRINT_CURRENT = 0x3e21acecd922e540 (was 0xcf3a24b0a1f70057). Producer-only — ZERO consumers in this commit. 2c.3c.6 wires the consumer in mag_concat_qdir's adaptive-scale path so the magnitude-branch decoder's residual stack is normalised by the trunk-output RMS regardless of GRN drift across training. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 81 cubins (was 80). +109 / -10 LOC across crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs (slot constants + docstring + CUBIN ref + field + load + launcher + cold-start + fingerprint seed entry), crates/ml/src/cuda_pipeline/h_s2_rms_ema_kernel.cu (new file, 60 LOC), crates/ml/build.rs (1 entry), crates/ml/src/trainers/dqn/state_reset_registry.rs (1 entry), crates/ml/src/trainers/dqn/trainer/training_loop.rs (per-step launch + FoldReset dispatch arm). Smoke (cargo test … multi_fold_convergence --ignored --release, 642.79s, 3 folds × 5 epochs): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 1.91 / 95.56 / 44.00 at epochs 2 / 4 / 2 (vs 2c.3c.4 baseline 7.52 / 60.94 / 10.40 — fold 0 down, folds 1+2 substantially up; cumulative geometric mean rises from 19.6 to 27.4). No NaN/Inf, no fingerprint mismatch (fingerprint shifted to 0x3e21acecd922e540 and re-validated at constructor as expected since the smoke starts from scratch — no checkpoint load). New producer kernel h_s2_rms_ema_kernel.cu launches cleanly per step alongside reward_component_ema; ISV[96] populated through training but unread (consumer wires up in 2c.3c.6). No new Orphan row — the kernel is wired to a real launch in this commit (cold-start + per-step EMA), classified Wired-Producer-Only until 2c.3c.6 promotes it to Wired-Producer+Consumer. 0 panic gates added/removed.
Plan 4 Task 2c.3c.4 followup (2026-04-25): stub-return defensive guards replaced with proper invariants + signal escalation in gpu_dqn_trainer.rs. Three classes of cleanup, prompted by the pre-commit stub-return heuristic flagging code paths exposed by the 2c.3c.4 commit's surrounding edits. (1) Unreachable null/bounds guards → debug_assert! + deletion. read_isv_signal_at, read_atom_utilization, compute_q_spectral_gap each guarded is_null() on isv_signals_pinned / q_readback_pinned, but both pointers are unconditionally allocated by the constructor (cuMemAllocHost_v2 + assert_eq!) and never reassigned after construction outside Drop — the guards masked the contract instead of enforcing it. Same for read_isv_signal_at's index >= ISV_TOTAL_DIM early-return (every caller passes a named ISV-slot constant) and compute_q_spectral_gap's n_cols == 0 early-return (total_actions() is the sum of branch sizes which config requires non-zero). All four converted to debug_assert!(...) so dev/test catches invariant violations loudly; release builds inherit the unsafe-deref crash on the broken precondition rather than a silent stub return. (2) Silent training-instability mask → warn-log + collapse sentinel. compute_q_spectral_gap previously returned 1.0 (= "balanced/healthy" per the downstream NormalizedComponents::from_raw calibration) on the first non-finite Q-value, masking NaN/Inf gradient corruption with the same value a healthy network produces. New behaviour: scan all samples, flag non-finite, then emit tracing::warn! and return 100.0 (the same collapse sentinel emitted when lambda_2 < 1e-12 or sigma2 < 1e-6). Operators see the failure in logs alongside HEALTH_DIAG rather than chasing a phantom balanced spectral gap. (3) Genuine domain encodings annotated with // ok: domain encoding + explanatory comment: lambda_2 < 1e-12 → 100.0 and sigma2 < 1e-6 → 100.0 (rank-1 collapse — sigma_1/sigma_2 diverges, calibrated collapse sentinel), power_iteration_largest's norm < 1e-20 → 0.0 (M·v vanishes ⇒ eigenvalue is zero). power_iteration_largest's n == 0 → 0.0 was unreachable from the spectral_gap caller; replaced with debug_assert!(n > 0) at the helper entry. No behaviour change in production runs: the unreachable guards weren't firing and the new invariants match the constructor's contract; the non-finite Q-value path strictly improves observability. cargo check clean at 11 warnings (baseline preserved). +57 / -14 LOC, all in gpu_dqn_trainer.rs. No new module / kernel / ISV slot / Orphan row. Tiny followup tightens the remaining .unwrap() in compute_q_spectral_gap to .expect("q_sample_history just received push_back; back() cannot be None") documenting the invariant that the immediately-prior push_back makes the back() return Some-by-construction.
Plan 4 Task 2c.3c.4 (2026-04-25): GRN backward chain wired through all 3 panic-gated trunk-backward sites — smoke validates. Three panic gates removed: BatchedBackward::backward_full, GpuDqnTrainer::apply_iqn_trunk_gradient, GpuDqnTrainer::apply_ensemble_diversity_backward. New helper BatchedForward::encoder_backward_chain orchestrates the full GRN trunk backward composition (h_s2 → h_s1) symmetrically with encoder_forward_only: backward_raw_phase1 (LN_dx + LN_dgamma/dbeta no-atomicAdd reduction + GLU_bwd) → cuBLAS Linear_b dW + dX → backward_raw_phase2 (ELU_bwd) → cuBLAS Linear_a dW + dX → for h_s1 only: Linear_residual dW (no_bias_lda) + dX accumulation when bottleneck active → for h_s2 only: saxpy_inplace(alpha=1.0) accumulates d_pre_ln_h_s2 into d_h_s1 (identity residual gradient). Used by 4 callers writing to 3 different gradient targets: main backward (writes to grad_buf for the 13 GRN trunk tensors at indices [0..13)), CQL backward (writes to cql_grad_scratch — same layout as grad_buf), and the two auxiliary paths (apply_iqn_trunk_gradient and apply_ensemble_diversity_backward write to iqn_trunk_m then SAXPY into grad_buf with their respective scale factors). iqn_trunk_m grown from legacy 4-tensor element count (sh1*sd + sh1 + sh2*sh1 + sh2) to padded_byte_offset(¶m_sizes, 13) / sizeof(f32) so the layout matches grad_buf's padded byte offsets exactly for the trunk portion — element-wise SAXPY across that span lands every per-tensor gradient at the correct location without needing per-tensor offset computation. apply_ensemble_diversity_backward additionally fixes value-head weight indices: w_v1 4→13, w_v2 6→15 (legacy indices were stale post-2c.3a — caught by the panic gate before they could write garbage). BatchedBackward::launch_dw_only_no_bias_lda added because Linear_residual (h_s1, no bias) needs the padded states stride that plain launch_dw_only_no_bias doesn't accept; launch_dw_only would dereference NULL inside the bias-grad kernel. ReLU masks on bw_d_h_s2 and bw_d_h_s1 are gone — the GRN trunk ends in LayerNorm which has no truncation derivative; the value-head FC retains its ReLU mask (value head still uses ReLU for its hidden activation). launch_cublas_backward_to migrated from &self to &mut self because the GRN backward needs to scribble per-block partial-reduction scratch inside grn_h_s{1,2}_online; the borrow split lets the trainer hand &mut BatchedForward + &BatchedBackward to encoder_backward_chain simultaneously. Smoke (cargo test … multi_fold_convergence --ignored, 599.73s, 3 folds × 5 epochs): all 3 dqn_fold{N}_best.safetensors checkpoints written; per-fold best train Sharpe 7.52 / 60.94 / 10.40 at epochs 2 / 4 / 2; per-fold val_Sharpe peaks 0.51 / 0.64 / 0.82; fold 2 epoch 5 hit PF=5.29 with +988% return — gradients flow cleanly through the GRN composition (no NaN, no Inf, no policy collapse, no fold-boundary explosion). Audit row #11 (IQN target trunk orphan) was already retired by 2c.3b; this commit retires the 3 backward panic gates that 2c.3a introduced. cargo check clean at 11 warnings (baseline preserved). +582 / -320 LOC across batched_backward.rs (+154/-154 — panic-gate removal + launch_dw_only_no_bias_lda), batched_forward.rs (+284/-0 — encoder_backward_chain), and gpu_dqn_trainer.rs (+221/-243 — 3 trunk-backward call sites rewritten + iqn_trunk_m grown + 4 main/CQL/IQN/ensemble backward call sites updated). No new module / kernel / ISV slot. Closes 3 of the 6 panic gates introduced by 2c.3a (3 forward gates were retired by 2c.3b); panic-gate count: 6 → 0.
Plan 4 Task 2c.3a (2026-04-24): GRN trunk param-tensor reshuffle (build-clean, runtime-broken intentionally). compute_param_sizes() flat layout reshuffled from 86 → 95 tensors: the 4 legacy trunk Linear tensors (w_s1, b_s1, w_s2, b_s2 at indices [0..4)) are replaced with 13 GRN tensors at indices [0..13) — 7 for h_s1 GRN block (w_a, b_a, w_b, b_b, w_residual, gamma, beta; SD→SH1 with Linear_residual GEMM since dimensions differ) plus 6 for h_s2 GRN block (w_a, b_a, w_b, b_b, gamma, beta; SH1→SH2 with identity residual since SH1==SH2). Every tensor index ≥ 4 in the OLD layout shifts +9 in the NEW layout. NUM_WEIGHT_TENSORS 86→95, FIRST_ISV_TENSOR 68→77. layout_fingerprint_seed() updated to mirror the new tensor names + positions; new LAYOUT_FINGERPRINT_CURRENT = 0xcf3a24b0a1f70057 (was 0xa504d3c2f275b8af). All 93 padded_byte_offset call sites + ancillary index references migrated in lockstep per feedback_no_partial_refactor.md. xavier_init_params_buf extended with init paths for the 13 new tensors: Linear matrices (w_a, w_b, w_residual) get Xavier; LayerNorm γ initialised to 1.0; β + biases stay zero. Trunk-forward / trunk-backward / IQN-trunk / ensemble-diversity-backward callers gated by explicit panic!("Plan 4 Task 2c.3a: trunk param layout migrated to GRN, …") at function entry — BatchedForward::encoder_forward_only, BatchedForward::forward_target_raw, BatchedForward::forward_online_f32, BatchedBackward::backward_full, GpuDqnTrainer::apply_iqn_trunk_gradient, GpuDqnTrainer::apply_ensemble_diversity_backward. This makes accidental runtime execution fail loudly rather than producing silent garbage from running legacy Linear→ReLU→Linear GEMMs against GRN-shaped param tensors. Spectral-norm descriptor (13 matrices) keeps slots [0]/[1] mapped to w_a_h_s1/w_a_h_s2 (shapes match legacy W_s1/W_s2 exactly — the GRN's first Linear has the same shape as the original Linear), so the existing spectral-norm constraint transfers cleanly to the GRN's first Linear; Linear_b / Linear_residual are NOT yet spectral-normed (Task 2c.3c follow-up). Smoke intentionally NOT run — build-clean is the validation; runtime would hit the panic at the first encoder forward (the desired behaviour, no need to verify explicitly). Task 2c.3b swaps in the GRN forward (gpu_grn::GrnBlock::forward) at all panic-gated forward callers in place; Task 2c.3c does the same for backward callers and runs the smoke test. cargo check clean at 11 warnings (baseline preserved); cargo build compiles all 58 cubins. No new module / kernel / ISV slot in this commit — pure structural reshuffle + assertion gates.
Plan 5 Task 5 Phase H (2026-04-26): cublasLt epilogue fusion for the 4 attention forward projections — collapses each cublasLtMatmul + add_bias_f32_kernel pair into a single fused cublasLtMatmul call via CUBLASLT_EPILOGUE_BIAS. Targets the L40S deploy hot-spot where the per-epoch training phase is 99.7% of wall time (25.8 s of 25.9 s); attention forward runs 4 projections (Q, K, V, O) per training step, so this saves 4 kernel launches × N_steps without changing the math. (1) crates/ml/src/cuda_pipeline/gpu_attention.rs::create_attn_gemm_desc extended with an epilogue: Option<cublasLtEpilogue_t> parameter — when Some(BIAS), the descriptor is configured with EPILOGUE = BIAS + BIAS_DATA_TYPE = CUDA_R_32F at creation time; when None, the descriptor stays at EPILOGUE_DEFAULT (the 8 backward dW/dX GEMMs are unchanged). The ShapeKey passed to get_matmul_algo_f32_tf32 is built via with_epilogue when set so the deterministic-algo cache keys on epilogue and produces an algo selection valid for the new descriptor — different epilogues for the same matmul shape get independent algo IDs. (2) New lt_matmul_with_bias_ex helper writes the per-call bias pointer via set_matmul_desc_attribute(BIAS_POINTER, …) immediately before each cublasLtMatmul (lightweight CPU write, no GPU sync; mirrors the existing pattern in batched_forward.rs::sgemm_f32_fused_relu_bias). The 4 forward projection sites in forward(...) switch from the prior lt_matmul_ex(...) + launch_bias_add_ex(...) pair to a single lt_matmul_with_bias_ex(...). (3) Orphans pruned: launch_bias_add_ex and launch_bias_add deleted from gpu_attention.rs (their only callers were the 4 fused-away sites). The shared add_bias_f32_kernel retained — still used by batched_forward.rs::launch_add_bias_f32_raw (VSN Linear_2 logit output, no activation). (4) Determinism preserved: the deterministic-algo cache (cublas_algo_deterministic.rs) handles arbitrary epilogue values transparently — ShapeKey already had an epilogue: i32 field, the with_epilogue constructor stamps it, and the AlgoCheck filter validates the descriptor against the epilogue before caching the algo, so first-call selection runs the full AlgoGetIds → AlgoInit → AlgoCheck loop with the new descriptor and subsequent calls reuse the cached (types, shape) algo. CUBLAS_WORKSPACE_CONFIG=:4096:8 is unchanged; epilogue fusion is bit-deterministic when the algo is fixed. Site #2 (DRELU_BGRAD on the trunk Linear→Bias→ReLU backward) deferred — the trunk's only Linear→ReLU layer that pattern-matches DRELU_BGRAD is the value-FC head (forward sgemm_f32_fused_relu_bias writes save_h_v, backward calls relu_mask on the output of the value-output-layer dX GEMM). Wiring DRELU_BGRAD requires (a) switching value-FC forward from EPILOGUE_RELU_BIAS to EPILOGUE_RELU_AUX_BIAS and allocating a uint8 mask aux buffer of shape [B, VH] packed at 8 elements/byte, (b) plumbing the aux pointer through BatchedForward → BatchedBackward's backward_fc_layer so the value-output-layer dX GEMM can set EPILOGUE_AUX_POINTER + BIAS_POINTER = b_v1_grad, (c) deleting the standalone relu_mask + bias-grad reduce kernels at the value-FC site. The forward-side aux-buffer plumbing crosses three modules and is non-trivial to wire deterministically alongside the GRN trunk's per-block partial-reduction scratch already plumbed through the same chain; landing it in the same commit as Site #1 risks regressing the determinism contract verified by the Phase G smoke. Defer to a follow-up phase that owns the aux-buffer wiring end-to-end. Site #1 alone is the bigger of the two opportunities — 4 launches saved per attention forward × every training step (target + online) — and is fully self-contained inside gpu_attention.rs. Validation: cargo check --workspace clean at 11 warnings (baseline preserved). Smoke (cargo test -p ml --release --lib -- multi_fold_convergence --ignored --nocapture, RTX 3050 Ti) completes all 3 folds; per-fold best train Sharpe within the noise band of the recent Phase G run (F0=2.36, F1=80.82, F2=92.11). Per-epoch wall-time deferred to next L40S deploy nsys profile (cluster log will show STEP_TIMING deltas; the 4-launch saving is the same on H100/L40S as on the local 3050 Ti, and cluster baseline at 25.8 s / epoch makes even a 5% saving worth ~1.3 s/epoch × 30 epochs × 6 folds × 5 seeds ≈ 1170 s total). No new module / kernel / ISV slot / param tensor / Orphan row. Files touched: crates/ml/src/cuda_pipeline/gpu_attention.rs (descriptor factory signature + 4 forward call sites + new lt_matmul_with_bias_ex helper + 2 orphan helpers deleted), docs/dqn-wire-up-audit.md (this entry). No fingerprint change.
Plan 5 Task 5 Phase F (2026-04-26): compile-time fxcache schema fingerprint — closes the L40S deploy-bug class where stale fxcache passed FXCACHE_VERSION validation despite incompatible feature semantics. Root cause of the original failure: extract_ohlcv_features column 0 changed from raw price → log-return without anyone bumping the manually-maintained FXCACHE_VERSION const, so the L40S PVC's older cache loaded clean and the trainer fed raw prices into the aux head expecting log-returns (aux_next_bar_mse=2.587e7). Fix has three pieces: (1) crates/ml/build.rs::emit_feature_schema_hash() runs unconditionally (before the existing CUDA-feature gate so non-CUDA builds also get the env var) and FNV-1a-hashes the raw bytes of the three schema-defining sources — crates/ml/src/features/extraction.rs, crates/ml/src/fxcache.rs, crates/ml-core/src/state_layout.rs — mixing in each file's relative path + length so renames / reorderings also bump the hash. Stable across rust versions and machines (FNV-1a, not std::hash::DefaultHasher). Emits cargo:rustc-env=FEATURE_SCHEMA_HASH=<decimal_u64> plus three cargo:rerun-if-changed= lines. (2) crates/ml/src/fxcache.rs::FEATURE_SCHEMA_HASH consumes the env var via env! + const u64::from_str_radix(_, 10) (const-stable since rust 1.83; workspace MSRV 1.85). FxCacheHeader grows a feature_schema_hash: u64 field; header size 64→72 bytes; wire-format FXCACHE_VERSION bumped 5→6 to flag the layout change. validate() strict-checks the hash alongside magic/version/dims; mismatch bails with a descriptive error pointing at "source files defining feature extraction / state layout / fxcache format have changed since this cache was built." precompute_features.rs:218 already deletes-and-regenerates on any load_fxcache Err, so the existing Argo ensure-fxcache step (and local users) recover automatically. (3) FXCACHE_VERSION doc comment now declares it tracks wire-format changes only — schema-level changes are tracked automatically by FEATURE_SCHEMA_HASH. Removes the manual ritual that broke the L40S deploy. Cost: cosmetic edits (whitespace, comments) to the three schema sources trigger one cache regen on next deploy (~5 min for full L40S dataset, ~40 s for local ES.FUT). Acceptable trade — false negatives (missed schema drift) are not. Validation: cargo check workspace clean at 11 warnings (baseline preserved). Local ES.FUT cache regen confirmed: existing v5 file rejected with "Stale FxCache version: 5 (expected 6). Delete and regenerate.", regenerated v6 cache loads clean on retry. Auto-detection verified: comment-only edit to extraction.rs line 1 changed emitted hash 5046469432341222878 → 7772630163018944575; revert returned the hash deterministically to 5046469432341222878. No new pip/cargo deps (FNV-1a is ~10 LOC stdlib). Files touched: crates/ml/build.rs, crates/ml/src/fxcache.rs, docs/dqn-wire-up-audit.md (this entry). No fingerprint change (LAYOUT_FINGERPRINT_CURRENT untouched — this is fxcache wire-format, not GPU param layout).
Plan 5 Task T5.1 (2026-04-27): Layer 3 gate-differentiation validation test — crates/ml/src/trainers/dqn/smoke_tests/moe_gate_differentiation.rs. Reads the most recent HEALTH_DIAG[N]: aux_moe [util=... ent=...] line from the smoke log and asserts three conditions: (1) no expert utilization < 2% (anti-collapse mechanism working — dead expert would indicate load-balancing aux loss is ineffective or gate temperature too low), (2) no expert utilization > 80% (no winner-takes-all snap-collapse to a single expert), (3) gate entropy < 0.9·ln(8) = 1.871 (gate is meaningfully peakier than uniform). Parser matches the exact tracing::info! format from training_loop.rs:3334: util=v0,v1,...,v7 ent=X.XXX — no brackets around the util values, space-separated from ent=. Compile-time const assert verifies the ISV layout invariant: MOE_EXPERT_UTIL_EMA_BASE + MOE_EXPERT_UTIL_EMA_COUNT <= MOE_GATE_ENTROPY_EMA_INDEX (i.e. 118 + 8 <= 126). Registered in smoke_tests/mod.rs. The test is #[ignore] and requires the smoke to be run first with log capture: cargo test -p ml --lib -- multi_fold_convergence --ignored --nocapture 2>&1 | tee /tmp/moe_phase3_smoke.log. Phase 4 smoke output showed expert 2 at 16.9% with others at ~11.9% — satisfies all three assertions. Failures are findings, not test infrastructure bugs — concrete numbers reported per feedback_kill_runs_on_anomaly_quickly.md. Spec: docs/superpowers/specs/2026-04-27-moe-regime-redesign-design.md §8.3. No new kernel / ISV slot / param tensor. Files touched: crates/ml/src/trainers/dqn/smoke_tests/moe_gate_differentiation.rs (new), crates/ml/src/trainers/dqn/smoke_tests/mod.rs (registration), docs/dqn-wire-up-audit.md (this entry).
Plan 5 Task T5.2 (2026-04-27): Layer 5 architecture-hash backward-incompat test — crates/ml-dqn/tests/architecture_hash_test.rs. Asserts that a synthetic "pre-MoE checkpoint" with a zeroed-out dqn.arch_hash field is rejected by DQNConfig::validate_checkpoint_metadata with a clear architecture-mismatch error. Uses a HashMap<String, String> populated with valid-looking field values except for arch_hash = "000...000" (64 hex zeros — guaranteed to not match any real config). The test calls config.validate_checkpoint_metadata(&Some(fake_metadata)), asserts it returns Err, and asserts the error message contains "Architecture mismatch" or "arch_hash" or "fingerprint" — matching the actual error text from dqn.rs:458. Purely synthetic: no CUDA, no file I/O, no smoke log required. Registered as [[test]] via Cargo's automatic integration test discovery (crates/ml-dqn/tests/ directory). Confirms the no-fallback contract per spec §6.4 — old checkpoints fail loudly, no silent loading of incompatible weights. Files touched: crates/ml-dqn/tests/architecture_hash_test.rs (new), docs/dqn-wire-up-audit.md (this entry). No fingerprint change.
| Classification | Count |
|---|---|
| Wired | 88 |
| Partial | 10 |
| Orphan (held for follow-up) | 3 |
| Ghost | 0 |
| OUT-of-DQN-scope | 17 |
| Total | 116 |
The 3 remaining Orphan rows are:
cuda_pipeline/gpu_statistics.rs+statistics_kernel.cu— held for Plan 2 D.2 wire-or-delete decision.data_loaders/tlob_loader.rs— held for Plan 2 D.8 TLOB trunk wiring.regime_detection/mod.rs— crate-level cleanup task (whole workspace crate, exceeds Task 6 scope).
Orphan Resolution Index
| Orphan | Resolution | Status |
|---|---|---|
cuda_pipeline/gpu_statistics.rs + statistics_kernel.cu |
Wire into val path or delete if superseded by gpu_monitoring.rs |
Plan 2 D.2 — held |
data_loaders/streaming_dbn_loader.rs |
Reclassified Partial: test consumer at tests/streaming_pipeline_edge_cases.rs | RECLASSIFIED |
data_loaders/tlob_loader.rs |
Plan 2 D.8 wires TLOB trunk — this loader may be needed then | Plan 2 D.8 — held |
training/unified_data_loader.rs |
Deleted (zero consumers) | DELETED |
training/orchestrator.rs |
Deleted (zero consumers) | DELETED |
training_pipeline.rs |
Reclassified Partial: ProductionTrainingError used via From impl in lib.rs | RECLASSIFIED |
inference_validator.rs |
Deleted (zero consumers) | DELETED |
model_loader_integration.rs |
Deleted (zero consumers) | DELETED |
paper_trading/mod.rs |
Reclassified Partial: test consumer at tests/paper_trading_integration_test.rs | RECLASSIFIED |
portfolio_transformer.rs |
Deleted (zero consumers) | DELETED |
regime_detection/mod.rs |
Crate-level follow-up scheduled (separate crate-cleanup task) | CRATE-LEVEL-FOLLOWUP |
Mapped pinned buffers promoted to shared module (2026-04-27): moved
MappedF32Buffer and MappedI32Buffer from
crates/ml/src/trainers/dqn/distributional_q_tests.rs local definitions
to a shared crates/ml/src/cuda_pipeline/mapped_pinned.rs module so
all kernel test wrappers (Test 0.F + upcoming MoE kernel tests) share
one implementation. Adds write_from_slice helper for direct host_ptr
writes (no memcpy). Test 0.F bit-identical post-move.
Per feedback_no_htod_htoh_only_mapped_pinned.md.
Mapped pinned types extended (2026-04-28): MappedU32Buffer and
MappedU64Buffer added to cuda_pipeline/mapped_pinned.rs to cover RNG
state arrays (u32) and descriptor/pointer tables (u64). First consumers:
gpu_action_selector::rng_states migration (eliminates HtoD seed upload
in constructor; kernel reads/writes via dev_ptr) and the upcoming
gpu_dqn_trainer::new constructor block (spectral-norm host_desc[78]
u64 table). All four mapped pinned variants share the same
new/write_from_slice/read_all API.
gpu_dqn_trainer::new HtoD elimination (2026-04-28): added module-level
upload_via_mapped_{f32,i32,u32,u64} helpers and an in-place
update_via_mapped_f32. Each stages CPU bytes through a transient
mapped pinned buffer and DtoD-copies into the destination
CudaSlice<T>, then stream-syncs so the staging buffer is safe to
drop. Migrated 11 COLD ctor sites: weight_decay_mask,
branch_slice_starts/lens, branch_grad_scales, per_branch_gamma_base/max,
q_quantile_branch_offsets/sizes, spectral_norm_descriptors (78 u64),
stochastic_depth_scale (3 f32), sd_rng_state (1 u32), vsn_group_begins,
vsn_group_ends, mamba2_params Xavier init. All consumer surfaces
unchanged — destination remains CudaSlice<T> with all subsequent
kernel-arg call sites intact. Per
feedback_no_htod_htoh_only_mapped_pinned.md and
feedback_no_partial_refactor.md (no consumer migration needed).
upload_params / upload_target_params WARM migration (2026-04-28):
both methods are called at fold boundaries / external weight loads.
Previously did memcpy_htod of TOTAL_PARAMS f32 (~MB). Now route
through update_via_mapped_f32 — mapped pinned staging + DtoD copy
into the existing params_buf / target_params_buf
CudaSlice<f32>. No API change for callers, no HtoD.
fused_training.rs HER block + training_loop.rs curriculum WARM
migration (2026-04-28): both sites previously did stream.memcpy_htod
on small Vec/Vec. Migrated to mapped pinned staging + DtoD
copy via local helpers in fused_training.rs
(staging_upload_{i32,f32}) and an inline pattern in
training_loop.rs. Destination buffer types unchanged
(CudaSlice<i32> / CudaSlice<f32>) so the relabel_batch_with_strategy
kernel-arg surface in gpu_her.rs (out of scope) is untouched. The
training_loop.rs:470 site goes through
collector.episode_starts_buf_mut() -> &mut CudaSlice<i32>; if Agent 2's
merge changes that accessor type to a mapped pinned buffer, the inline
staging block can be simplified to a direct write_from_slice.
MoE moe_mixture_forward kernel + Rust wrapper (2026-04-27): first MoE
CUDA kernel landed. Single-thread-per-(b,c) kernel computes h_s2[b,c] =
Σ_k g[b,k]·expert_outputs[k,b,c]. No atomicAdd, capture-friendly. Rust
wrapper GpuMoeHead in crates/ml/src/cuda_pipeline/gpu_moe_head.rs
loads cubin, exposes test_mixture_forward using mapped pinned buffers
exclusively (no HtoD/HtoH per feedback_no_htod_htoh_only_mapped_pinned.md).
Unit test in crates/ml/tests/moe_kernels_test.rs verifies CPU
reference match within 1e-6 for B=4, K=8, C=256.
RegimeConditionalDQN deletion (2026-04-27): atomic removal per
feedback_no_partial_refactor.md. Deleted regime_adx_idx, regime_cusum_idx,
regime_adx_threshold, regime_cusum_threshold fields from DQNConfig (both
struct definition and Default + aggressive() builder entries). DQNAgentType
rewritten as thin wrapper over single DQN directly: no per-regime delegation
methods, no multi-head epsilon/epsilon_all calls, no get_trending_head /
primary_head indirection, no get_trending_head_memory accessor. The ghost
feature get_count_bonuses_branched (previously hardcoded None) now delegates
to DQN::get_count_bonuses_branched() — UCB count bonuses reach the GPU
action selector for the first time. action.rs caller simplified to direct
destructure of fixed arrays (no Option match). serialize_model in mod.rs
rewritten to serialize the single DQN branching network directly (no
trending__/ranging__/volatile__ prefix namespace). Constructor drops
RegimeConditionalDQN::new_on_device and calls DQN::new_on_device directly.
curriculum.rs import fixed from crate::dqn::regime_conditional::RegimeType
to crate::dqn::RegimeType (re-exported from regime_classifier). Phase 3 MoE
(commit a52d99613) provides the regime-conditioned behavior the legacy 3-head
architecture pretended to do. Workspace clean: 0 errors across all crates, tests, and examples.
Smoke validates no regression: 3/3 folds passed (405s), gate utilization
preserved (util=0.119,0.119,0.169,...), HEALTH_DIAG aux_moe line emits,
all fold checkpoints written.
q_var_buf_trainer stride 12 → total_actions migration (2026-04-27): same
class as the d625ca28e q_readback fix. compute_expected_q writes
q_variance[i*total_actions + a] (stride total_actions = 4+3+3+3 = 13 in
the 4-direction factored layout) but the buffer was sized at b12 from the
legacy 3+3+3+3 exposure layout. compute-sanitizer caught 4 OOB writes at
threads 60..63 of block 0 in the magnitude_distribution smoke (RTX 3050 Ti,
batch=64), surfacing in production as
bias_grad_reduce_f32_p1: DriverError(CUDA_ERROR_LAUNCH_FAILED) on the
next launch. Per feedback_no_partial_refactor: shared contract migrated in
lockstep — alloc q_var_buf_trainer b12 → btotal_actions; q_denoise_step,
q_denoise_backward, denoise_build_input kernels each gain a var_stride
parameter and read var_q[b*var_stride + i] instead of b*D + i; Rust
launch sites pass total_actions as var_stride; launch_loss_reduce's
b_qvar argument migrated b12 → b*total_actions. Denoiser FC layer
internal D=12 unchanged — only the variance buffer's per-sample stride
moves; the trailing variance slot per sample is computed by the C51 kernel
but unused by the denoiser (epistemic_gate already reads at stride
total_actions and now sees full per-action variance). Sanitizer confirms
compute_expected_q OOB count 4 → 0. Latent magma_sgemmEx_kernel OOB reads
appeared in the post-fix run (16 errors in cuBLAS GEMMs along an
unrelated path); they are pre-existing — only surfaced because q_var
growing reshuffled allocation layout, exposing a separate cuBLAS
leading-dim mismatch that previously aliased into q_var's old footprint.
Filed for separate diagnosis.
DuelingWeightSet/BranchingWeightSet stale GRN-pre-expansion indices
(2026-04-28): root cause of the 16 latent magma_sgemmEx_kernel OOB reads
that surfaced after the q_var stride fix above. from_flat_buffer on both
weight sets (gpu_weights.rs) used hard-coded layout indices [0..23] from
before the GRN trunk expansion (Plan 4 Task 2c.3a inserted 9 trunk
tensors at indices 1..12). After the expansion, params_buf followed the
163-tensor GRN layout (compute_param_sizes), but from_flat_buffer's
24-index sequential walk silently slid every dueling-weight pointer
forward into adjacent tensors. The most pathological alias was
online_dueling.b_v1 → gamma_h_s1, sized SH1=64 floats = 256 bytes
instead of VALUE_H=128 floats; the forward_value_head_for_ensemble
cuBLAS RELU_BIAS epilogue read bias[0..128] from a 64-float buffer,
producing the 16 thread (0..7,2..3) reads exactly 1..61 bytes past the
nearest 256-byte allocation (compute-sanitizer confirms). The bug only
surfaced for the ensemble clone path because flatten_online_weights
and unflatten_online_weights did matched-stale self-copies (no-op when
online_d.w_s1 == params_buf_ptr) — production GEMMs never read the
DuelingWeightSet pointers directly; they used f32_weight_ptrs_from_base
which has always been on the correct GRN layout. Per
feedback_no_partial_refactor: every consumer of the weight-set/flat-buffer
contract migrated in lockstep:
- Added
DUELING_FLAT_INDICES = [0,1,2,3, 13,14,15,16, 17,18,19,20]andBRANCHING_FLAT_INDICES = [21..32](gpu_weights.rs) DuelingWeightSet::from_flat_buffer+BranchingWeightSet::from_flat_buffernow use these mappings with a full prefix-sum byte-offsets table (matches f32_weight_ptrs_from_base byte layout)flatten_online_weights/unflatten_online_weights/flatten_target_weights/unflatten_target_weights(gpu_dqn_trainer.rs) rewritten to keyed[(ptr, layout_idx); 24]pairs and write atbyte_offsets[layout_idx]instead of sequential prefix-sum oversizes[0..23]. The no-op zero-copy checkonline_d.w_s1 == src_baseis preserved (DUELING_FLAT_INDICES[0] == 0).
Sanitizer confirms 16 → 0 OOB errors in mamba2_backward path. Non-sanitizer smoke completes 20 epochs without LAUNCH_FAILED (was crashing on epoch 1 prior to fix). The ensemble multi-head value path now reads correctly-sized W_v1 (8192 floats) and b_v1 (128 floats).
Async-validation overlap on dedicated stream + mapped-pinned readback (Plan A, 2026-04-28): per-epoch L40S wall time was inflated by ~30-40s of validation backtest serialised on the training stream. Two coupled bugs:
-
crates/ml/src/trainers/dqn/trainer/metrics.rs:496-503passedself.cuda_streamasparent_streamintoGpuBacktestEvaluator::new, which forks its own internal stream from the parent. The forked stream does not auto-synchronise with the training stream — so eval kernels and training kernels submitted concurrently to independent streams compete on the GPU SMs without ordering. The prior comment claimed this was "for cuBLAS determinism (single-stream reproducibility)" — incorrect: cuBLAS bit-wise reproducibility caveats apply per-handle, not across the process. Each stream owns its ownPerStreamCublasHandles(training has one infused_ctx; validation has one viaPerStreamCublasHandles::new(&eval_stream)for the val TLOB at metrics.rs:638; the evaluator forks its own cuBLAS handle internally ingpu_backtest_evaluator.rs::newfromparent_stream). Running the eval through the training stream simply forced a serial schedule. Fix: route eval throughself.validation_stream(already forked at constructor.rs:547 but unused on the eval path). Thevalidation_streamwaits on a freshcuda_stream-recordedtrain_done_eventbefore launching eval; the evaluator's internal forked stream is then walled off viaevaluator.stream().wait(&ev). Both waits are GPU-sidecuStreamWaitEvent— no CPU sync. Ordering is guaranteed: training writes weights → training records event → val stream + evaluator stream wait on event → eval reads weights. Updated the misleading comment to explain the per-stream-handle determinism guard. -
crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs:631(alloc), :2143 (clone_dtoh(&self.metrics_buf)), and :1075 (memcpy_dtohforplan_diag_buf) — the metrics + plan-diag readbacks went throughcudarcmemcpy_dtoh/clone_dtohwhich is plain DtoH and forbidden perfeedback_no_htod_htoh_only_mapped_pinned.md: only mapped pinned memory (cuMemHostAlloc DEVICEMAP) is allowed for CPU↔GPU paths. Even on the val stream the DtoH triggers an implicit stream sync that on a single-stream pipeline serialised behind the training stream's pending work. Fix: replacedmetrics_buf: CudaSlice<f32>andplan_diag_buf: CudaSlice<f32>withMappedF32Buffer(cuda_pipeline/mapped_pinned.rs). Themetrics_kernelandbacktest_plan_diag_reducenow write through the buffer'sdev_ptr(passed as a u64 kernel arg, same pattern asgpu_iqn_head.rs::total_loss_dev_ptr). The host reads via volatilehost_ptrafter a singleeval_stream.synchronize()per evaluation — one sync replaces N implicit syncs from the priorclone_dtohcalls. The sync blocks only the eval stream; training continues oncuda_streamuninterrupted. The plan_diag readout adopts the same one-epoch lag pattern aspending_val_loss(zero-init host buffer means epoch 0 logs zeros, subsequent epochs see the previous epoch's diag — acceptable for a diagnostic-only readout that already lived inside the existing one-epoch-lag pipeline).
Per feedback_no_partial_refactor: every consumer of the
metrics_buf/plan_diag_buf contract migrated in lockstep — struct
fields, alloc sites in new(), kernel-arg passing in
launch_metrics_and_download and launch_plan_diag_and_log, host
readout (was clone_dtoh → now read_all() on MappedF32Buffer).
The cross-stream event ordering in metrics.rs is the only consumer
of the new validation_stream-as-parent contract; the legacy
single-stream fallback (when validation_stream is None, e.g. CPU
device) routes through cuda_stream exactly as before. cargo check
clean at 13 warnings (workspace baseline). cargo test --no-run
clean. No fingerprint change — buffer layouts unchanged from the
kernel's perspective (mapped-pinned is allocation-method orthogonal
to layout).
Async best-checkpoint serialize via spawn_blocking + mapped-pinned
param snapshot (Plan B, 2026-04-28): on epochs where val Sharpe
improves (~30% of epochs in convergent runs) the trainer DtoD-snapshots
best GPU params (save_best_gpu_params, fast — kept synchronous as the
source of truth for restore_best_gpu_params) AND synchronously
serialised the full BranchingDuelingQNetwork to safetensors bytes via
N per-tensor DtoH downloads (serialize_model at mod.rs:1318 →
GpuTensor::to_host at ml-core/cuda_autograd/gpu_tensor.rs:142 →
memcpy_dtoh). Each DtoH forced an implicit stream sync; on a busy
stream the cumulative wall time was 20-40s/improvement, blocking the
epoch loop AND violating
feedback_no_htod_htoh_only_mapped_pinned.md (only
cuMemHostAlloc DEVICEMAP is allowed for CPU↔GPU).
Fix (two-part — they ride together because changing the F bound on the public API forces Arc-wrapping at the worker boundary):
-
mod.rs::serialize_modelrewritten to delegate to:snapshot_model_to_pinned(): allocates oneMappedF32Buffersized for all named weight slices concatenated, iteratesbranching_q_network.named_weight_slices()and submits acuMemcpyDtoDAsync(mapped.dev_ptr + offset_bytes, slice.raw_ptr(), n_bytes, cuda_stream)per tensor, syncs the stream ONCE (stream.synchronize()), and copies the resulting bytes out of the pinned host page into aSend + 'static Vec<u8>. The read lock is held across the kernel-submit + sync block to prevent a concurrent target-network update from reallocating the source slices mid-DtoD. ReturnsCheckpointSnapshot { tensors: Vec<TensorSnapshotEntry>, host_bytes: Vec<u8>, arch_metadata }.serialize_snapshot_bytes(&snap): pure CPU. Buildssafetensors::TensorViews per metadata entry pointing intosnap.host_bytes[byte_offset..byte_offset + byte_len]and callssafetensors::serialize. Static fn — callable without&self, so the worker can invoke it after moving the snapshot across thread boundary. This single-sync path replaces N stream syncs (one perto_hostin the prior chain). cost: O(slowest pending kernel) once instead of N times.
-
F bound (
FnMut(...) -> Result<String> + Send) extended to include+ 'staticontrain,train_walk_forward,train_fold_from_slices(mod.rs). This is required so the worker (tokio::task::spawn_blocking) can own a clone of the callback. All production callers usemoveclosures over owned data (TempDir paths cloned out, trial_id by-value, fold_idx by-value). Test fixtures with shared mutable state through the closure (dqn_inference_test.rs::best_checkpoint_path,dqn_training_pipeline_test.rs::checkpoint_saved,final_checkpoint_path,saved_checkpoint_path) migrated toArc<Mutex<T>>shared-state pattern; six other test fixtures (dqn_long_training_test,dqn_gradient_accumulation_test,dqn_accumulation_convergence_test,production_training_smoke_test,smoke_test_real_data,dqn_training_pipeline_test) gainedmove+ apath = checkpoint_dir.path().to_path_buf()clone.
The actual async wiring uses
Arc<std::sync::Mutex<Box<dyn FnMut + Send + 'static>>>
(CheckpointCallbackHandle, mod.rs) so the same callback is shared
across multi-fold runs (a callback is move-d into Arc once at the
top of train_walk_forward, then Arc::clone-d into each fold's
train_with_data_full_loop_slices invocation, which Arc::clone-s
once more into the worker). The Mutex is std::sync::Mutex (not
tokio) because the worker is a tokio::task::spawn_blocking
synchronous closure — async locks are unusable there.
handle_epoch_checkpoints_and_early_stopping
(training_loop.rs:4214-4343) on val-Sharpe improvement now:
- Invokes
save_best_gpu_params(DtoD, fast) — unchanged. - Calls
snapshot_model_to_pinned()to capture the bytes. - Spawns
tokio::task::spawn_blocking(move || { let bytes = serialize_snapshot_bytes(&snap)?; cb_clone.lock()?(epoch, bytes, true) })and parks theJoinHandleonself.pending_checkpoint_handles: Vec<JoinHandle>. - Returns immediately — the next epoch's training kernels start without waiting for safetensors construction or disk write.
Synchronous callsites (cold paths — periodic / plateau-exhausted /
early-stop) lock the same Arc inline and call. Each cold-path
synchronous call is preceded by await_pending_checkpoint_handles
to drain in-flight workers and keep disk write ordering deterministic.
At training end (success branch in
train_with_data_full_loop_slices at line ~995, plus early-stop
returns at lines ~4296 and ~4318), the trainer awaits all
outstanding pending_checkpoint_handles before returning the final
TrainingMetrics. Without the drain, dropping the trainer would
abort in-flight tokio::spawn_blocking tasks via runtime shutdown,
losing the latest best ckpt write.
The audit's spec called for a mpsc::channel(1) with try_send +
drop-old, but with a multi-fold train_walk_forward + &mut F API
that pre-existed, channel-with-worker would require a redesigned
public API. The fire-and-forget spawn_blocking pattern achieves the
same overlap (training continues while serialize+disk run on a
blocking thread) without a long-lived worker; the
Vec<JoinHandle> upper-bounds in-flight work to one-per-improved-epoch
rather than the spec's "1" but that's structurally equivalent (the
Mutex serialises any concurrent invocations). The "drop the previous
job" semantic isn't preserved — instead each improvement's job runs
to completion. For the realistic case of ≤1 improvement per N
epochs, in-flight depth stays at 1; for pathological many-improvements
runs the queue grows but every byte set still hits disk (no data
loss).
Per feedback_no_partial_refactor: every site that constructs a
checkpoint payload migrated in lockstep — best-improvement
(line :4234) goes through the worker; the periodic
(:982-995), plateau-exhausted (:962-973), and early-stop
(:4307-4322 + :4329-4342) callsites still serialise via
serialize_model (which now goes through the mapped-pinned
snapshot path, so the rule violation is resolved everywhere) and
invoke the callback inline via cb.lock(). The pre-existing
GpuTensor::to_host-based path is no longer reachable from the
DQN trainer in any of these branches.
Touched files for the combined commit pair:
crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs— Plan A:MappedF32Bufferformetrics_buf+plan_diag_buf, kernel-arg swap todev_ptru64, single sync per eval.crates/ml/src/trainers/dqn/trainer/metrics.rs— Plan A:validation_streamparent +train_done_eventcross-stream barrier + evaluator-stream wait.crates/ml/src/trainers/dqn/trainer/training_loop.rs— Plan B: spawn_blocking worker enqueue at :4234-4275, drain helpers + sync callsite mutex locks, F-bound removed (concrete Arc handle replaces it). Plan B drain at end of train loop and before each cold-path checkpoint.crates/ml/src/trainers/dqn/trainer/mod.rs— Plan B:BoxedCheckpointCallback+CheckpointCallbackHandletype aliases,TensorSnapshotEntry+CheckpointSnapshotsnapshot types,snapshot_model_to_pinned+serialize_snapshot_byteshelpers,serialize_modelrewritten via the snapshot path,await_pending_checkpoint_handleshelper, F-bound+ 'staticontrain/train_walk_forward/train_fold_from_slices,train_walk_forwardwraps callback once + passesArc::cloneper fold.crates/ml/src/trainers/dqn/trainer/constructor.rs— Plan B:pending_checkpoint_handles: Vec::new()initialisation.crates/ml/src/trainers/dqn/trainer/tests.rs— Plan B: test harness migrates to the Arc-wrapped callback handle.crates/ml/src/trainers/dqn/smoke_tests/regression_detection.rs— Plan B: smoke harness migrates to the Arc-wrapped handle.crates/ml/tests/dqn_accumulation_convergence_test.rs,dqn_gradient_accumulation_test.rs,dqn_inference_test.rs,dqn_long_training_test.rs,dqn_training_pipeline_test.rs,production_training_smoke_test.rs,smoke_test_real_data.rs— Plan B: closures gainmove+ clone owned PathBufs / wrap shared mutable state inArc<Mutex<T>>to satisfy the new+ 'staticbound on F.
Verification: SQLX_OFFLINE=true cargo check --workspace --tests
clean (warnings unchanged from baseline). cargo test -p ml --lib --no-run clean. No fingerprint change — buffer layouts and ISV
slots unchanged from the kernel's perspective.
Plan C Task 5 (2026-04-29) — train_active_frac HEALTH_DIAG metric
Added the training-rollout counterpart to the existing
val_active_frac metric so Plan D's L3 verification gate has a
direct signal that Thompson is generating directional exploration
during training (target: train_active_frac > 0.40, i.e. at least
40% of training-rollout actions land on Short or Long rather than
Hold/Flat). val_active_frac is sourced from the eval-backtest
kernel's per-direction buy_count/sell_count/hold_count
reduction; train_active_frac is sourced from the in-memory
monitor.action_counts aggregator (12-bin layout, dir*3 + mag,
direction ∈ {Short=0, Hold=1, Long=2, Flat=3}). Active fraction =
(Σ bins[0..3] + Σ bins[6..9]) / Σ bins[0..12].
Wire-up:
crates/ml/src/trainers/dqn/trainer/mod.rs— addedlast_train_active_frac: f32field onDQNTrainer(cached each epoch from the in-loop monitor; consumed by the next epoch'sconsume_validation_lossso the two*_active_fraclines are emitted adjacent in HEALTH_DIAG output).crates/ml/src/trainers/dqn/trainer/constructor.rs—last_train_active_frac: 0.0_f32initialisation (zero until the first epoch finishes its training rollout).crates/ml/src/trainers/dqn/trainer/training_loop.rs— populatesself.last_train_active_fracin the per-epoch metrics block immediately after the existingdist_q/dist_h/dist_f+ent_dir/ent_magcalculations, reusing the already-computeddir0(Short) anddir2(Long) per-direction shares (full-12-bin denominator, identical tomonitor.action_counts.iter().sum()), with the sameaction_total_epoch > 0guard. Clamped to[0.0, 1.0]defensively.crates/ml/src/trainers/dqn/trainer/metrics.rs— emitsHEALTH_DIAG[{}]: train_active_frac=X.XXXX (Long+Short during training rollout)fromconsume_validation_lossimmediately after the existingval [...]block (which containsval_active_frac). The two metrics now appear next to each other in every epoch's HEALTH_DIAG output:val_active_fracmeasures direction commitment during the eval backtest whiletrain_active_fracmeasures it during training rollout — divergence localises whether Thompson is stochastic enough at experience-collection time even when eval (deterministic argmax) collapses to Hold/Flat.
No fingerprint change — buffer layouts, ISV slots, and kernel
signatures are untouched. Verification:
SQLX_OFFLINE=true cargo check -p ml --lib clean.
Plan C Task 10 (2026-04-29) — Distributional Q-head Aggregation Contract
This is a project-wide invariant covering action selection across all distributional and ensemble Q-heads. Any new module that produces a per-(state, action) Q estimate MUST appear in the table below with its aggregation properties declared. The pre-commit hook (Invariant 7) will refuse merges of new distributional or ensemble Q-heads that fail this table.
| Q-head | Produces E[Q]? | Sampleable? | Wired to training Thompson? | Wired to eval argmax? |
|---|---|---|---|---|
C51 atom kernel (with adaptive per_sample_support [N, 4, 3]) |
yes — via compute_e_c51_inline |
yes — via sample_c51_inverse_cdf |
yes — direction only (Plan C T2 amendment 5de5e546a) |
yes — direction only (Plan C T2) |
| IQN quantile kernel | training-only — via compute_expected_q IQN path |
yes — via sample_iqn_quantile_interp (Phase 0 reference) |
no — training-only, no rollout-side IQN inference; deferred to v2 if rollout IQN is ever added | no — same as left |
Ensemble Q-head (currently only ens_agree metric) |
needs verification | yes — pick member uniformly | v2.1 enhancement | v2.1 enhancement |
| Future Q-head additions | required | required | required | required |
Note: TFT is a Variable Selection Network for trunk feature processing — not a Q-head. The contract applies specifically to modules that produce per-(state, action) Q estimates.
Why single-distribution C51 (not joint C51+IQN) for production rollout
direction Thompson: the IQN quantile kernel runs only during training
(loss computation); the rollout pipeline does not have an IQN forward
pass on the action-select hot path. Plan C Phase 2 T2 amendment (commit
5de5e546a) adapted to this reality: production direction Thompson
samples from C51's adaptive per-direction posterior alone. This still
eliminates the UCB selector/target asymmetry that motivated Plan C —
what matters is sampling from a posterior that matches what the trainer
will bootstrap against; the C51 distribution already plays that role at
rollout. If rollout-side IQN inference is added later, joint Thompson
can be reintroduced under the same Aggregation Contract.
The pre-commit hook (Invariant 7) will refuse merges of new distributional or ensemble Q-heads that fail this table.
Plan C Phase 2 Tests 2.A–2.D scaffolding (2026-04-29)
The test_eval_action_select_boltzmann_bounded direct-kernel test in
crates/ml/src/cuda_pipeline/mod.rs was migrated to the Plan C T2
amended ABI. It is renamed to test_eval_action_select_eval_argmax_picks_best
and now passes the four new args (b_logits_dir, per_sample_support,
atom_positions, n_atoms), constructs per-direction peaked C51 logits
with distinct E[Q] values, and asserts that eval mode deterministically
picks argmax-E[Q] (P(Long)=1.0). The prior Boltzmann theoretical bound
is invalidated by the T2 amendment — direction is no longer Boltzmann
over q_values, it's Thompson/argmax over per-direction C51 posteriors.
Test 2.A — Production kernel mode behavior (Task T6)
crates/ml/src/trainers/dqn/distributional_q_tests.rs::test_2a_production_kernel_modes
adds an #[ignore]-gated GPU test exercising the production
experience_action_select kernel directly via an inline cudarc fixture
(ProdActionSelectFixture). The fixture allocates the full output set
(actions, q_gaps, intent, conviction, magnitude_conviction) and
launches the kernel with the Plan C T2 amended ABI.
The test reproduces the Phase 0 Test 0.D failure-mode setup (Flat/Hold = δ(v=0); Long/Short = log-Gaussian centred at v=-0.001, σ=0.05) and asserts:
- Eval mode (eps_start=eps_end=0): 10 successive launches at different
timestepvalues produce identical per-sample dir_idx — argmax E[Q] must be deterministic. Every sample picks Hold (1) or Flat (3). - Training mode (eps_start=0.5): across 10 000 samples, the fraction of Long+Short picks is ≥ 30% — Thompson sampling must explore directional alternatives despite their lower E[Q].
Test 2.B — Production matches Phase 0 standalone (Task T7)
crates/ml/src/trainers/dqn/distributional_q_tests.rs::test_2b_production_matches_standalone
launches BOTH the production experience_action_select kernel AND the
standalone direction_thompson_v2_test kernel (added by commit
5de5e546a) on identical Phase 0 Test 0.D failure-mode inputs and
verifies the resulting direction histograms are statistically
indistinguishable.
Bit-equivalence path (a) — drive both kernels from a shared pre-computed uniform array — was deferred (would require modifying the standalone v2 kernel's signature). Path (b) — KS-style histogram comparison — is used: 100 000 samples / seeds per kernel; assert KS distance ≤ 0.02 (well above the empirical CDF noise floor of ~4·sqrt(2/n) ≈ 0.018 at n=100k for matching distributions). Algorithm divergences (different inverse-CDF math, different softmax normalisation) would shift mass between bins by O(10%), giving KS ≈ O(0.1) — easily detected. RNG implementation differences alone (Philox vs LCG, both good random sources) contribute < 0.005 noise.
A sanity assertion also verifies P(Long+Short) ≥ 0.20 in both histograms — without this a coincidental shared-mode collapse would produce KS ≈ 0 without actually exercising Thompson behaviour.
Test 2.C — Mag/ord/urg branches unaffected (Task T8)
crates/ml/src/trainers/dqn/distributional_q_tests.rs::test_2c_mag_ord_urg_branches_unaffected
asserts that the magnitude/order/urgency branches of the production
kernel still follow the unchanged Boltzmann softmax with adaptive tau.
The plan-prescribed pre-T2 snapshot approach was impossible (T2 had
already landed); replacement strategy (ii) — behavioral parity vs an
analytical Boltzmann reference — is used.
Setup forces direction = Long (d=2) deterministically via peaked C51 logits (Long peaked at v=+0.8 atom; other directions at v=-0.5), so the kernel's Hold/Flat → mag_idx=0 short-circuit doesn't mask the magnitude branch's Boltzmann sampling. q_values are crafted with each branch (mag/ord/urg) peaked at a single bin with magnitude 1.0; with q_range=1.0 in all three branches, tau collapses to 1.0 and the analytical Boltzmann probabilities are P(best) = 1/(1 + 2/e) ≈ 0.5767 and P(other) = 1/e/(1 + 2/e) ≈ 0.2117.
At batch=8192 the 1-σ Bernoulli noise is ~0.0055 for p≈0.58; ±5% absolute tolerance covers ~9σ — safely above any finite-sample noise. Algorithmic divergence in mag/ord/urg branches (e.g. an inadvertent strict-argmax substitution) would shift P(best) to 1.0, which the ±5% tolerance trivially detects.
Test 2.D — Real-batch e2e on synthetic non-degenerate logits (Task T9)
crates/ml/src/trainers/dqn/distributional_q_tests.rs::test_2d_real_batch_e2e
verifies the production kernel matches a Rust ground-truth E[Q] argmax
on a synthetic batch with realistic non-degenerate per-direction C51
distributions. The plan-prescribed approach (reuse Phase 0 Test 0.F's
converged-checkpoint loader) was deferred — wiring the safetensors
load + branching forward path is heavyweight and Test 0.F itself
already exercises that code path. Replacement: synthetic batch via
direct buffer write.
Setup:
- 256 distinct samples; per-(sample, direction, atom) C51 logits drawn from a deterministic hash → uniform [-1, 1] (matches post-Xavier-init scale of fresh C51 head outputs).
- Per-sample adaptive support drawn from realistic ranges: v_min
∈ [-1.0, -0.2], v_max ∈ [0.2, 1.0]. Each sample's (v_min, v_max,
delta_z) varies; this is the layout
update_per_sample_supportproduces in the live trainer.
Assertions:
- Eval mode: per-sample dir_idx EXACTLY matches the argmax over a Rust ground-truth E[Q]_d = sum_a softmax(b_logits[i, d])[a] · atom_vals[i, d, a]. The ground-truth softmax mirrors the kernel's numerically-stable subtract-max path.
- Train mode: count(d ∈ {Long=2, Short=0}) / batch > 0.40 — Thompson on realistic non-degenerate distributions explores actively. Per spec Task 9.
Plan C T11 follow-up A.1 (2026-04-29): reset prev_epoch_q_mean and adaptive_tau in DQNTrainer::reset_for_fold (crates/ml/src/trainers/dqn/trainer/mod.rs). Q-drift kill criterion's prev-baseline carried across fold boundaries because q_value_history was cleared but its derived prev_epoch_q_mean was not — the ratio at fold-N epoch 0 was computed against fold-(N-1) epoch 5's q_mean. Companion adaptive_tau modulation state also reset to hyperparams.tau baseline. Independent bug fix; applies regardless of Plan C Thompson outcome. Identified by the Goal-1 q-drift research (researcher report 2026-04-29) when reconciling a52d99613's q_mean=5.003 fold-1 spike (no kill — criterion was added later in 1c917e3cb) against Plan C's fold-0 ep2 trigger.
Plan C Phase 2 follow-up A.2 (2026-04-29): adaptive Polyak-tau coupled to per-epoch q-drift rate. Two-commit landing per feedback_no_partial_refactor.md — kernel + Rust wrapper first (additive, no callers), then ISV slot + reset registry + producer launch + tau_eff consumer wired together.
-
New CUDA kernel
crates/ml/src/cuda_pipeline/q_drift_rate_ema_kernel.cu(single-block single-thread cold-path producer mirroringmoe_lambda_eff_kernel.cu/kelly_cap_update_kernel.cushape). Reads ISV[Q_ABS_REF_INDEX=16] + ISV[Q_DIR_ABS_REF_INDEX=21] plus host-passedq_mean_curr/q_mean_prevscalars; writesclip(|q_curr − q_prev| / max(|q_prev|, |Q|_mag + |Q|_dir, 1e-6), 0, 4)to ISV[Q_DRIFT_RATE_INDEX=129]. Registered incrates/ml/build.rskernel list (count grew by 1). -
New ISV slot
Q_DRIFT_RATE_INDEX = 129(tail-appended afterMOE_LAMBDA_EFF_INDEX = 128, raisingISV_TOTAL_DIM129→130). Cold-start 0.0 (constructor*sig_ptr.add(Q_DRIFT_RATE_INDEX) = 0.0_f32); FoldReset 0.0 via newisv_q_drift_rateregistry entry instate_reset_registry.rs+ dispatch arm intraining_loop.rs::reset_named_state. Layout fingerprint shifts (one slot added + ISV_TOTAL_DIM bump) — checkpoint-incompatible perfeedback_no_legacy_aliases.md, expected for a real architecture change. -
Rust orchestrator wrapper
GpuDqnTrainer::launch_q_drift_rate_ema(q_mean_curr: f32, q_mean_prev: f32)ingpu_dqn_trainer.rs— same pattern aslaunch_h_s2_rms_ema(debug_assert non-zeroisv_signals_dev_ptr, single-thread launch config, kernel-launch error mapped toMLError::ModelError). -
Per-epoch producer launch in
training_loop.rsdirectly between the Q-drift kill criterion and theprev_epoch_q_meanupdate (so bothq_mean_currandq_mean_prevare in scope, AND the launch is gated on the sameprev_epoch_q_mean.abs() > 1e-6cold-start guard the kill check uses). Fires once per epoch boundary; the next epoch'slaunch_tau_update(top of loop iteration) reads the freshly-written ISV[129]. -
Tau consumer wire-up in
tau_update_kernel.cu— after the existing cosine-schedule + health-coupled-floor clamp, the kernel multipliestau_effby1 / (1 + clip(ISV[129], 0, 4))so the effective Polyak rate dampens monotonically as q-drift rises (range[tau_base/5, tau_base]). Defensivefminf(..., 4.0f)in the kernel guards against producer-side bugs even though the producer already clips to[0, 4](Invariant 1 carve-out, belt-and-braces).
Predicted impact: Plan C fold 0 ep2 with q_mean(t)=0.82, q_mean(t-1)=-0.018, ISV[16]+ISV[21]≈0.1 → drift_rate ≈ |0.84|/max(0.018, 0.1, ε) = 8.4 → clipped to 4 → tau_eff = tau_base × 0.2 (5× slower target sync, dampens Q-target optimism through inflation spike). a52d99613 fold 0 with q_mean staying ~0.21 → drift_rate ≈ 0 → tau_eff = tau_base (unchanged, healthy run unaffected).
Per pearl_adaptive_moe_lambda.md — canonical "EMA-tracked diagnostic drives a controller" pattern: kernel + ISV slot + bootstrap (0.0 cold-start) + reset (0.0 fold boundary) + observability. Per pearl_cold_path_no_exception_to_gpu_drives.md — even cold-path scalar arithmetic stays on GPU when its reduction inputs already live on GPU (ISV[16,21] both produced by q_stats_kernel.cu). Per feedback_adaptive_not_tuned.md — tau decay is ISV-driven, not a constant. Per feedback_isv_for_adaptive_bounds.md — the 4.0 upper clip and 1e-6 denom floor are structural bounds (drift saturation matching the kill-criterion's 3.0× ratio threshold; numerical-stability floor) carried in ISV slot semantics.
Plan C A.2 wire-up (2026-04-29, second commit): tau_update_kernel.cu, state_reset_registry.rs, training_loop.rs::reset_named_state + per-epoch launch site, and trainer/mod.rs::reset_for_fold comment all updated in lockstep with the kernel+slot landed by the previous commit. Producer launch lives between the existing Q-drift kill criterion and the prev_epoch_q_mean = q_mean update — both prev and curr q_means are in scope, and the prev_epoch_q_mean.abs() > 1e-6 cold-start gate matches the kill check's gate. Tau consumer applies 1/(1+clip(ISV[129],0,4)) AFTER the existing [tau_final, tau_base] clamp so dampening can drive tau below tau_final on runaway drift (intent: tau_final is the design floor for healthy runs, but a runaway requires further suppression). Defensive fminf(...,4.0f) in the consumer guards against producer-side bugs even though the producer already clips (Invariant 1 carve-out, belt-and-braces).
Plan C Phase 2 follow-up H (2026-04-29): replace brittle ratio = |q_mean| / |prev_q_mean| Q-drift criterion with a robust 5-epoch rolling-window MAD-deviation criterion in training_loop.rs::process_epoch_boundary. The prior prev_epoch_q_mean.abs() > 1e-6 gate caused the ratio to explode at zero crossings — smoke-test-n9xzr fired with prev=−0.0795 → curr=0.7647 giving ratio=9.62×, far above the 2.0× threshold even though this is natural cold-start growth, not geometric runaway. New criterion: maintain q_mean_window: VecDeque<f64> (bounded Q_DRIFT_WINDOW_SIZE=5), compute median(window) + MAD(window) = median(|x_i − median|), and trip on |q_mean − median| > Q_DRIFT_DEVIATION_THRESHOLD × max(MAD, Q_DRIFT_MIN_DEVIATION) AND the existing adaptive floor. Constants are declared const near the kill block: Q_DRIFT_WINDOW_SIZE=5 (matches smoke fold length), Q_DRIFT_WARMUP_SAMPLES=3 (skip until ≥ 3 priors — smaller window degenerates to half-range MAD that trips on monotonic trajectories), Q_DRIFT_DEVIATION_THRESHOLD=4.0 (4 MADs ≈ 2.7σ Gaussian-equivalent — a clear outlier without firing on every legitimate dip; literal MAD count rather than σ because q_mean is non-Gaussian during cold-start), Q_DRIFT_MIN_DEVIATION=0.01 (numerical-stability floor when window is constant). Why robust: median + MAD are insensitive to a single outlier (50%-breakdown estimators), so legitimate cold-start growth (q_mean transitioning from near-zero to non-zero with each step tracking the median) keeps deviation < 4. Pathological geometric runaway (q_mean → 50 from 0.5 within one epoch) makes the new sample sit O(100×) above the median while MAD stays O(0.1), pushing deviation >> 4 and tripping the kill correctly. Window reset at fold boundary: q_mean_window.clear() lives next to prev_epoch_q_mean = 0.0 and adaptive_tau reset in reset_for_fold (matches A.1 pattern; cross-fold q-stats are independent training runs, mixing them creates spurious deviations or masked runaway). Both conditions ANDed: floor (adaptive) AND deviation (robust) must both fire, preserving the production-safety semantics of the original criterion. Behavioral hand-trace for smoke-test-n9xzr (epoch 2 trigger from researcher report a9e6e876): ep0 q_mean = q0 (window=[]→[q0], len<3 skip); ep1 q_mean=−0.0795 (window=[q0]→[q0, −0.0795], len<3 skip); ep2 q_mean=0.7647 (window=[q0, −0.0795]→[q0, −0.0795, 0.7647], len<3 skip — kill stays silent because the warmup gate has not been satisfied, exactly the desired smoke outcome). At ep3 with the next q_mean the test first fires: with q0 ≈ 0 (cold-start reasonable assumption) median = q0 ≈ 0, MAD = median(|0|, |−0.0795|, |0.7647|) ≈ 0.08, denom = 0.08. If ep3 q_mean continues healthily near 0.7, deviation = |0.7 − 0|/0.08 ≈ 8.75 (> 4) — but by ep3 the F fix (per-step q_mag_bin_means_reduce / q_dir_bin_means_reduce) has the floor at ~3 × 0.5 = 1.5, and |0.7| < 1.5 so the floor branch fails → kill stays silent. Both conditions must fire for runaway; only the floor failing prevents false positives during cold-start. Pathological geometric runaway (ep0=0.5, ep1=2.5, ep2=12.5, ep3=62.5): ep3 test (after ep2 append, window=[0.5, 2.5, 12.5], len=3 ≥ warmup): median=2.5, MAD=median(2.0, 0.0, 10.0)=2.0, deviation=|62.5−2.5|/2.0=30 >> 4; ISV[16,21] would be ~12 by then with F fix in place, floor=3×12=36, |62.5|>36 — trips correctly. Per feedback_no_quickfixes.md: this is a principled robust-statistics replacement, not a threshold relaxation. Per feedback_no_partial_refactor.md: window field, constants, criterion site, and fold-boundary reset all land together in this commit (the kill criterion's contract is internally consistent across trainer/mod.rs, trainer/constructor.rs, and trainer/training_loop.rs). Per pearl_adaptive_moe_lambda.md: rolling-window deviation is the canonical replacement for ratio-thresholds against drifting baselines.
Plan C Phase 2 follow-up F (2026-04-29): wire q_mag_bin_means_reduce and q_dir_bin_means_reduce per-step alongside update_isv_signals in fused_training.rs (both the captured adam_update child graph at ~line 2228 AND the ungraphed step-0 fallback at ~line 1465). Root cause for the smoke-test-n9xzr ISV[16]=0.0500, ISV[21]=0.0500 floor stuck at the cold-start max(0.5, 3 × 0.05) = 0.5 value: the producers ran ONLY inside reduce_current_q_stats (epoch boundary cadence) at gpu_dqn_trainer.rs:15223, while the per-step captured update_isv_signals consumed scratch buffers that were therefore zero-initialized for all of epoch 0 and stale-by-one-epoch thereafter. With α=0.05 EMA and one effective producer fire per epoch, ISV[16,21] climbed glacially (0.05 → 0.0975 → 0.143 over three epochs vs the q_mean=0.7647 magnitude actually being observed), so the adaptive floor never graduated above the 0.5 cold-start hard-floor. The kill criterion's kill_floor = max(0.5, 3 × max(ISV[16], ISV[21])) collapsed to the literal 0.5 constant for the entire smoke. The fix runs both launch_q_mag_bin_means_reduce and launch_q_dir_bin_means_reduce immediately before update_isv_signals in BOTH paths so per-step graph replays read fresh q_out_buf (already populated by forward_child earlier in the parent-graph topological order) and feed live scalars into the per-step ISV EMA. Epoch 1 onward then sees ISV[16,21] saturate to the policy's actual q_abs_ref scale within ~60 steps (95% saturation at α=0.05). Per pearl_cold_path_no_exception_to_gpu_drives.md: even cold-path EMAs stay on GPU and fire alongside their consumers — once-per-epoch was the wrong cadence. Per feedback_no_partial_refactor.md: graphed and ungraphed paths both migrate in this commit (same producer-consumer contract). Layout fingerprint unchanged — no kernel signature change, no ISV slot add, no param-tensor change.
Plan C Phase 2 follow-up (2026-04-29): bonus reward optimism-coupling break in experience_kernels.cu. The C.4 timing bonus (~line 2521) and D.4b regime penalty (~line 2581) both used the trade-cumulative |final_pnl| / |reward| as their unbounded multiplicand. By the time bonus shaping runs in experience_env_step, reward is the Layer 2 vol-normalised return PLUS Layer 4-9 credits PLUS the C.4 timing bonus PLUS the D.4a persistence bonus — i.e. it is itself a function of all prior shaping inflation. Across trades this compounds into a multi-trade optimism feedback loop: bonus inflates → Q-target inflates (Bellman bootstraps off shaped reward) → next trade's reward inflates → bonus inflates further. Researcher reproduction (a25f669e9df953174, 2026-04-29) found the a52d99613 baseline ALSO hits bonus EMA=235 by F2 ep2 — the pathology is structural and pre-dates Plan C Phase 2. Per pearl_one_unbounded_signal_per_reward.md the rule is exactly ONE unbounded multiplicand per reward term, and per feedback_isv_for_adaptive_bounds.md adaptive bounds live in the ISV signal bus. Surgical fix: replace |final_pnl| (C.4) and |reward| (D.4b) with min(|x|/ISV[Q_DIR_ABS_REF_INDEX=21], 1) × ISV[21] — the multiplicand is now the direction-branch Q-scale EMA (already produced gradient-decoupled by q_stats_kernel.cu since Plan 1) rather than the trade-cumulative shaping output. Cap is adaptive (matches Q magnitude as it evolves) and breaks cross-trade compounding; |reward| can still drive the cap to its max (= ISV[21]) so directional information is preserved, just bounded. Kernel signature: experience_env_step gains a final int q_dir_abs_ref_idx parameter (after the existing trade_target_rate_idx); single Rust call site gpu_experience_collector.rs:3800 passes Q_DIR_ABS_REF_INDEX as i32. experience_action_select not affected (it never read |reward|/|final_pnl| as a multiplicand). Other shaping sites NOT touched because they were already bounded — D.4a persist (multiplicand drawdown_depth ≤ ~0.1 gates the term, tanh(reward/dd) is bounded), B.2 novelty (vol_proxy ≤ 0.01 gates, kl_amp ≤ 1.0), D.4c stable (vol_proxy gates + bounded stability/conviction). Per feedback_no_partial_refactor.md consistency: both fixed sites use the SAME ISV[21] bound and the SAME (unit, capped) two-step formula — symmetric migration. Predicted impact: bonus EMA O(100-256) → O(0.05-2.0); rc[5] → Bellman-target optimism loop broken; Plan C smoke F0 ep2 Q-drift kill should no longer fire on this pathway; a52d99613 baseline gets the same fix automatically (validates pre-existing pathology, not Plan-C-specific). Layout fingerprint unchanged — kernel signature change but no ISV slot add and no param-tensor change.
Plan C T11 follow-up A.3 (2026-04-29): added DQN::reset_for_fold zeroing gradient_collapse_counter + training_steps. Wired from DQNTrainer::reset_for_fold next to the A.1 prev_epoch_q_mean reset. Discovered when smoke-test-vh9bj's fold-0 succeeded (F+H fix worked) but fold-1 failed immediately at epoch 1 with "Gradient collapse detected for 5 consecutive" — the counter had carried over from fold 0's near-zero-grad late-epoch steps, plus the past_warmup gate was always-true in fold 1+ because training_steps accumulated. Resetting both restores per-fold warmup semantics. Same fold-boundary state-reset gap pattern as A.1.
Plan C Phase 2 follow-up K (2026-04-29): combined Adam shrink-and-perturb + adaptive fold-boundary warmup factor. Closes the F1 ep1 catastrophic-overshoot gap exposed by smoke-test-s9h4h (F0 successful with Best Sharpe = 36.03; F1 ep1 grad_norm = 355,009 — 5 OoM larger than F0 steady-state ~10 — leading to NaN propagation and grad-clamp-to-zero).
-
Adam shrink-and-perturb in
GpuDqnTrainer::reset_adam_statereplaces the prior full-zero reset ofm_buf/v_bufwith multiplicative shrink (m *= 0.1,v *= 0.01). Preserves directional information (sign+ratio across tensors) while damping magnitude; composes with the existing param shrink-and-perturb (alpha=0.8inFusedTrainingCtx::reset_for_fold). Shrink launches use the existingdqn_scale_f32_kernel(loaded asscale_f32_ungraphed— same module / same launch path asscale_adam_momentum).t_pinned(Adam step counter) IS still zeroed so the bias correction1 - β^trestarts properly. Perfeedback_isv_for_adaptive_bounds.mdInvariant 1 carve-out: shrink factors0.1/0.01are STRUCTURAL ("preserve direction / lose magnitude history") not tuned constants. Root cause for the F1 overshoot:m=0, v=0→ first Adam step islr × m̂ / (sqrt(v̂) + ε) ≈ lr × g / ε(a 6 OoM amplification whenε = 1e-8). -
New CUDA kernel
crates/ml/src/cuda_pipeline/fold_warmup_factor_kernel.cu(single-block single-thread cold-path producer mirroringq_drift_rate_ema_kernel.cu/moe_lambda_eff_kernel.cushape). Reads two grad-norm EMAs (fast α=0.1 ~10-step horizon, slow α=0.001 ~1000-step horizon) plus a host-passed step counter; writesclamp(fast/slow, 0, 1)toISV[FOLD_WARMUP_FACTOR_INDEX=130]. Bootstrap branches for the cold-start window (steps_observed < WARMUP_FACTOR_MIN_STEPS=200) and the slow-EMA-near-zero edge case both emitfactor = 1.0(no damping). Registered incrates/ml/build.rskernel list (count grew by 1). -
New ISV slot
FOLD_WARMUP_FACTOR_INDEX = 130(tail-appended afterQ_DRIFT_RATE_INDEX = 129, raisingISV_TOTAL_DIM130→131). Cold-start 0.0 (constructor*sig_ptr.add(FOLD_WARMUP_FACTOR_INDEX) = 0.0_f32is satisfied by the existing zero-init of the mapped-pinned ISV bus); FoldReset 0.0 via newisv_fold_warmup_factorregistry entry instate_reset_registry.rs+ dispatch arm intraining_loop.rs::reset_named_state. Companion FoldReset entryisv_grad_norm_fast_emaresets the kernel's NUMERATOR (host-side mapped-pinned scalar) so the ratio starts at 0 (fast=0 over slow≈baseline) → factor=0 → heavy damping for the new fold's first steps. Slow EMAisv_grad_norm_slow_emadeliberately persists across folds (cross-fold steady-state baseline). Layout fingerprint shifts (one slot added + ISV_TOTAL_DIM bump) — checkpoint-incompatible perfeedback_no_legacy_aliases.md, expected for a real architecture change. -
Two new mapped-pinned scalars on
GpuDqnTrainer:grad_norm_fast_ema_pinned(α=0.1) andgrad_norm_slow_ema_pinned(α=0.001). Both updated byupdate_adaptive_clip(the samegr.raw_grad_normobservation source that already feeds the legacygrad_norm_emafield driving the existing adaptive clip). Step countergrad_norm_emas_step_countgates the kernel's bootstrap window. Mapped-pinned viacuMemHostAlloc DEVICEMAPperfeedback_no_htod_htoh_only_mapped_pinned.md.Dropimpl frees both alongsideadaptive_clip_pinned. -
Rust orchestrator wrapper
GpuDqnTrainer::launch_fold_warmup_factor()ingpu_dqn_trainer.rs— same pattern aslaunch_q_drift_rate_ema/launch_h_s2_rms_ema(debug_assert non-zeroisv_signals_dev_ptr, single-thread launch config, kernel-launch error mapped toMLError::ModelError). -
Per-step producer launch in
training_loop.rsalongsidelaunch_h_s2_rms_ema— fires once per training step, afterupdate_adaptive_cliphas fed the fast/slow EMAs. Cold-path cadence; no atomicAdd; no DtoH. -
LR consumer wire-up in
training_loop.rsper-step block: readISV[FOLD_WARMUP_FACTOR_INDEX], derivelr_eff = cosine_effective_lr_base × max(MIN_WARMUP_LR_FRAC=0.05, factor), write viaset_lr. Newcosine_effective_lr_base: f32field onDQNTrainerrecords the cosine-scheduled per-epoch baseline so the per-step warmup damping multiplies the cosine schedule rather than overriding it. Floor0.05is a numerical-stability bound (Invariant 1 carve-out perfeedback_isv_for_adaptive_bounds.md). -
Clip consumer wire-up in same block: after
update_adaptive_clipwritesclip_base = grad_norm_ema × 2(floored at 1.0) to the pinned slot, scale byclip_warmup_scale = MIN_CLIP_FRAC=0.1 + (1 - MIN_CLIP_FRAC) × factorand write the result via the newFusedTrainingCtx::set_active_clipsetter (forwarded toGpuDqnTrainer::set_active_clip, which writes*adaptive_clip_pinneddirectly — same zero-graph-recapture contract asset_lr). Floor0.1is a numerical-stability bound. -
Both consumers are MONOTONE (factor ∈ [0, 1] structurally, so
lr_eff ≤ lr_baseandclip_eff ≤ clip_basealways). Healthy runs (steady-state factor=1) are unaffected —lr_eff = lr_base × max(0.05, 1) = lr_baseandclip_eff = clip_base × (0.1 + 0.9) = clip_base. Perpearl_blend_formulas_must_have_permanent_floor.md:MIN_WARMUP_LR_FRACandMIN_CLIP_FRACare permanent floors — even at factor=0 the consumers never reach zero lr/clip.
Predicted impact on Plan C smoke fold 1: factor starts at 0 → lr_eff = 0.05 × lr_base, clip_eff = 0.1 × clip_base ≈ 1.0 (vs clip_base = 10). The 355,009-magnitude transient grad gets clipped tightly to ~1, Adam state shrunk (m × 0.1, v × 0.01) instead of zeroed → first step update is bounded by 0.05 × lr × m̂/(sqrt(v̂) + ε) on a non-degenerate denominator. Over ~50 steps the fast EMA catches up to the slow steady-state baseline → factor → 1 → full lr/clip restore. Steady-state (mid-fold) behavior unchanged.
Per pearl_adaptive_moe_lambda.md — canonical "EMA-tracked diagnostic drives a controller" pattern: kernel + ISV slot + bootstrap (0.0 cold-start) + reset (0.0 fold boundary, fast EMA) + observability (HEALTH_DIAG via slot 130 mirror, plus grad_norm_fast_ema_value / grad_norm_slow_ema_value accessors on the trainer). Per pearl_cold_path_no_exception_to_gpu_drives.md — even a cold-path scalar arithmetic stays on GPU when its inputs already live in mapped-pinned host memory the device can read directly (no DtoH for the EMAs). Per feedback_adaptive_not_tuned.md — both lr_eff and clip_eff are now ISV-driven, not constant. Per feedback_isv_for_adaptive_bounds.md — the warmup factor IS the bound; consumers read ISV[130] at runtime and compose with their respective architectural floors. Per feedback_no_partial_refactor.md — both halves of the warmup-factor input contract (factor slot + fast EMA companion) reset together; both consumers (lr + clip) wire together; producer launch + consumer reads land in the same commit.
Plan C T11 follow-up N (2026-04-29): Winsorize the adaptive_clip EMA input — clamp single-sample raw_grad_norm to GRAD_CLIP_OUTLIER_K × previous_adaptive_clip before EMA update. K = 100 is a numerical-stability bound. Discovered when smoke-test-xw4c6 showed F1 ep2 grad_norm=8.4B polluting adaptive_clip's EMA → subsequent extreme grads pass unclipped → NaN propagation. After N, single outlier → EMA absorbs at most 100× growth → next adaptive_clip caps at ~1000 (vs 30 steady-state) → subsequent F1 outliers get clipped → bounded grads pass to Adam → no NaN. Companion observability log emits GRAD_CLIP_OUTLIER warning per clamp event. Fast/slow grad_norm EMAs (warmup factor inputs) intentionally NOT winsorized — they're a stability signal that should respond to outliers (further dampens warmup_factor → extra defense). Composes with K+warmup (4ef1d8ebb), A.2 adaptive tau, A.1/A.3 fold-boundary state resets, F+H Q-drift kill robustness.
Plan C T11 diagnostic extension (2026-04-29): the existing nan_flags_buf [16] system covers 13 buffers and runs NaN checks every step in the captured graph, but its read_nan_flags() readout was previously only triggered on halt_nan (which checks pinned readback grad_norm — always 0 after NaN-clamp because downstream gradient sanitization clamps NaN to zero before the pinned scalar copy, so the clamp-to-zero case never fires halt_nan). Extend the readout to also fire when halt_grad_collapse triggers AND gr.raw_grad_norm < 1e-6 — the collapse-with-zero-grad case is the NaN-clamped path. Logs NaN-CLAMPED-TO-ZERO at step N (grad collapse path): flagged=[buf_idx=name, ...] identifying the kernel responsible. Genuine near-zero gradients (legitimate collapse with no NaN flags set) emit a separate Genuine grad collapse at step N (no NaN flags set; legitimate near-zero gradients) warn log distinguishing the two cases. Diagnostic-only — keep the path even after the F1 ep2 root cause fix lands; it's the right gate for future regressions of the same NaN-clamped-to-zero class. Companion infrastructure (fused_training.rs::read_nan_flags pub(crate) accessor and reset_nan_flags at fold boundary in FusedTrainingCtx::reset_for_fold) was already in place from the earlier halt_nan-only readout — only the new halt_grad_collapse consumer site landed in this commit. Per feedback_kill_runs_on_anomaly_quickly.md: the diagnostic enables fast root-cause identification of the F1 ep2 explosion source (which kernel produced the first NaN — C51 KL projection? IQN aux? CQL? aux heads?). Per feedback_no_partial_refactor.md: error propagation preserved (early-stop still fires after diagnostic emits); the diagnostic is strictly additive to the existing collapse-halt contract.
Plan C T11 diagnostic (2026-04-29): per-step FOLD_EXPLOSION_DIAG log emits when fold >= 1 and grad_norm > 1000 or jumps 5x from the prior guard step. Captures loss decomposition (c51, mse, iqn from already-pinned device-mapped scalars + total from gr.raw_loss), Q range (q_min/q_mean/q_max via reduce_current_q_stats cold-path reduce) and atom positions (per_sample_support[sample=0, dir=0] and [sample=0, dir=2] triples (v_min, v_max, delta_z) via 12-element cold-path DtoH). After the trigger fires, continues emitting unconditionally for STEPS_AFTER_EXPLOSION = 5 guard steps so the explosion trajectory is captured. Plus an unconditional FOLD_EXPLOSION_DIAG[F1_END_EP1] baseline log emitted once at the end of fold 1 epoch 0 — the healthy state immediately preceding the F1 ep2 explosion. Removed after root cause identified — not for production. Companion fields current_fold, last_logged_grad_norm, explosion_diag_steps_remaining on DQNTrainer (init (0, None, 0) in constructor; last_logged_grad_norm/explosion_diag_steps_remaining reset in reset_for_fold; current_fold overwritten unconditionally at fold-loop entry). Companion accessors FusedTrainingCtx::explosion_diag_{loss_components,atom_range,q_range} and GpuDqnTrainer::{c51_loss_pinned_value, mse_loss_pinned_value, stream_for_diag}. CQL and ensemble losses are not pinned-readback (they live in transient device buffers consumed inside the training graph) and are explicitly absent from the decomposition: if none of {c51, iqn, mse} is the runaway driver but total_loss still explodes, that implicates the unexposed CQL/ens path — the absence is itself diagnostic information.
Plan C T11 follow-up R1+P (2026-04-29):
R1 — eliminated K's Adam shrink-and-perturb at fold boundary (m_buf and v_buf back to memset_zeros, K's hardcoded ADAM_M_SHRINK=0.1 / ADAM_V_SHRINK=0.01 constants removed). K was a stepping-stone fix introduced before fold_warmup_factor existed (commit 4ef1d8ebb). fold_warmup_factor — ISV-driven, drives lr+clip dampening — already provides principled first-step protection via the cold-start lr_eff = lr_base × max(MIN_WARMUP_LR_FRAC, fold_warmup_factor) formula. K's hardcoded shrink ratios violated feedback_adaptive_not_tuned AND created a downstream pathology: tiny v_hat denominator → oversized Adam updates ~50+ steps post-reset → trunk param overshoot → save_h_s2 NaN at F1 ~step 1745 (smoke-test-bkdx5 diagnostic). With K removed, Adam restarts cleanly at m=0, v=0 and warmup_factor handles the cold-start window via lr/clip damping — single mechanism, ISV-driven, no hardcoded constants. R1.B verification (no edits): state_reset_registry.rs already lists both isv_fold_warmup_factor and isv_grad_norm_fast_ema as FoldReset entries with matching dispatch arms in training_loop.rs::reset_named_state — the K+warmup commit landed both halves of the warmup-factor input contract atomically per feedback_no_partial_refactor.md.
P — expanded nan_flags_buf 16→24 with 5 new GRN h_s2 sub-stage NaN checks (grn_h_s2_elu_post=16, grn_h_s2_linear_b_out=17, grn_h_s2_glu_sigmoid=18, grn_h_s2_ln_rstd=19, grn_h_s2_ln_normed=20) for finer-grained source identification if R1 alone doesn't fix F1. Slot 19 (ln_rstd) is the divide-by-zero diagnostic (variance→0 ⇒ rstd→∞ ⇒ NaN downstream). New accessors: GrnBlock::{elu_post_ptr, linear_b_out_ptr, sigmoid_b_ptr, ln_rstd_ptr, ln_normed_ptr} (additive, reads-only) and CublasGemmSet::{grn_h_s2_online, grn_h_s2_linear_b_ptr}. h_s2 only — pragmatic instrumentation scope: that's the GRN block where save_h_s2 went NaN at F1 step 1745, and instrumenting only h_s2 covers the failing stage without doubling per-step kernel-launch cost. h_s1 stages can be added by name table slots 21-23 if the F1 explosion turns out to originate above h_s2. The K-removal + GRN-stage checks combined: if the F1 explosion was due to K's tiny-v_hat pathology, R1 alone fixes it. If a different mechanism, the new flags pinpoint which GRN sub-stage produces NaN first. Per feedback_no_partial_refactor.md: R1 and P land together because both touch the fold-boundary contract (K-removal changes the post-reset Adam state; GRN-stage NaN checks observe the consequences of that state on the trunk encoder's first forward). read_nan_flags() signature update [16]→[24] propagates through GpuDqnTrainer, FusedTrainingCtx, and both name-table sites in training_loop.rs (halt_nan + halt_grad_collapse).
SP1 Phase A audit (2026-04-29): produced docs/dqn-backward-nan-audit.md — read-only γ inventory of every backward-path kernel writing to bw_d_h_s2 / grad_buf / save_h_s2 accumulators, cross-referenced against session_2026-04-05_nan_investigation.md's residual 8% step-2 NaN in apply_iqn_trunk_gradient. Per-kernel sections include: identified unsafe patterns (sqrtf-neg, 1/0, logf-≤0, expf-large, EMA variance, atomicAdd/saxpy NaN-propagation), proposed guard form, ISV bound option (existing slot leverage or new slot or Invariant 1 ε carve-out), F0 risk assessment (low/medium/high used as paper-review gate before smoke), and Phase B flag-slot allocation. Drives SP1 Phase B instrumentation (12 new slots in nan_flags_buf 24→48) and Phase C surgical fix decisions. Becomes durable input artifact for SP2 (framework codification) and SP3 (structural-fix scoping).
SP1 Phase B foundation (2026-04-29): expanded nan_flags_buf 24→48 (allocation size in gpu_dqn_trainer.rs; read_nan_flags signature [i32; 24] → [i32; 48] in both gpu_dqn_trainer.rs and fused_training.rs; name tables updated in both training_loop.rs consumer sites — halt_nan block + halt_grad_collapse block from commit d1808df14). Slot names per docs/dqn-backward-nan-audit.md per-slot accessor table (audit supersedes plan placeholder names): slots 24-25 are post-c51_grad d_value_logits_buf / d_adv_logits_buf; slot 26 is iqn_trunk_m; slot 27 is iqn_d_h_s2_ptr; slot 28 is d_branch_logits_buf (production IQN backward, iqn_quantile_huber_loss); slot 29 is cql_d_value_logits; slot 30 is aux_dh_s2_nb_buf; slot 31 is ensemble_d_logits_buf (cross-struct on FusedDqnTraining); slot 32 is bn_d_concat_buf; slots 33-35 are bw_d_h_s2 at three different backward call sites; slots 36-47 reserved as headroom for SP2/SP3. No behavioral change in this commit (new slots stay at zero until Task 4 wires the check call sites). Buffer size reviewable by SP2 framework codification — if right-size differs (e.g., 36 with no headroom or 64 for more coverage), SP2 may resize.
SP1 Phase B foundation — stale-doc cleanup + Task 4 prep (2026-04-29): doc-only follow-up to commit 53bc0bc50. Replaced the 24-slot index map docstring on GpuDqnTrainer::run_nan_checks_post_forward with a 48-slot range summary that defers per-slot semantics to docs/dqn-backward-nan-audit.md per-slot accessor table (DRY — audit is the source of truth). Updated the halt_grad_collapse diagnostic comment in training_loop.rs from [24] system (13 base + 5 GRN-stage) to the post-expansion 48-slot layout (slots 0-23 forward / 24-35 backward / 36-47 reserved). Fixed the stale 0..11 tracing message (kept from before the 16→24 GRN expansion) to 0..47. Annotated slot 31 (ensemble_d_logits_buf) in BOTH training_loop.rs name tables as DEFERRED with cross-struct ownership note (FusedDqnTraining) — this prevents Task 4 from blanket-launching check_nan_f32 on slot 31's null GpuDqnTrainer accessor before the ensemble Phase B saxpy guards are verified. Pre-emptively updated both name-table header comments to reference the future run_nan_checks_post_backward method (Task 4) plus the audit's per-slot table — drops the 3-way name-table contract drift risk when Task 4 lands. Both name tables remain byte-identical (modulo indentation). Per feedback_no_partial_refactor.md: the 24→48 expansion shipped without doc-coverage on the consumer side; this cleanup commit closes that residue before Task 4 starts.
SP1 Phase B accessors (2026-04-29): added 5 new accessor methods exposing backward-path buffer device pointers for Task 4's per-step NaN checks. Scope diverged from plan — the plan's "delegate accessors on GpuDqnTrainer for slots 27 + 28" is structurally invalid: GpuDqnTrainer does NOT own GpuIqnHead (the IQN head is owned by FusedTrainingCtx at fused_training.rs:289, alongside the trainer at line 234). The audit's per-slot accessor table (lines 535-536) is correct: slot 27/28 accessors land on GpuIqnHead, and Task 4's run_nan_checks_post_backward(batch_size, iqn_d_h_s2_ptr, d_branch_logits_ptr, ...) will receive the IQN pointers as u64 arguments from the FusedTrainingCtx call site — same pattern already in use at gpu_dqn_trainer.rs:6843 (apply_iqn_trunk_gradient(&mut self, iqn_d_h_s2_ptr: u64, ...)). Per feedback_no_legacy_aliases.md: no delegate wrappers — Task 4 either calls the IQN accessor directly via the gpu_iqn field on FusedTrainingCtx or receives the pointer via method args; no shadow accessor on GpuDqnTrainer. New accessors landed: GpuDqnTrainer::{d_value_logits_buf_ptr, d_adv_logits_buf_ptr, cql_d_value_logits_ptr, aux_dh_s2_nb_buf_ptr} (slots 24, 25, 29, 30 — 4 methods) + GpuIqnHead::d_branch_logits_buf_ptr (slot 28). Slot 27 (iqn_d_h_s2_buf) reuses the existing GpuIqnHead::d_h_s2_raw_ptr() accessor at gpu_iqn_head.rs:1660 — no new method (per feedback_no_legacy_aliases.md, the existing accessor is sufficient; renaming + chasing one call site adds churn without value). Slots 26, 32, 33-35 reuse pre-existing handles (self.ptrs.iqn_trunk_m, self.bn_d_concat_buf() accessor, self.ptrs.bw_d_h_s2). Slot 31 (ensemble_d_logits_buf) deferred per Task 2 commit 387335e2b (cross-struct on FusedDqnTraining). NO new scratch buffer added — the plan's bw_d_h_s2_pre_saxpy scratch + DtoD-copy approach was superseded by the audit's 3-call-site reformulation (slots 33/34/35 are post-main / post-aux / post-iqn snapshots of the same bw_d_h_s2, taken via inline call sites in Task 4). Pattern follows commit e9096c7be's GRN-block accessor style (concise pub(crate) fn name_ptr(&self) -> u64, doc-comment referencing slot number + audit doc + buffer semantics). Additive — no behavioral change; new accessors consumed by Task 4's NaN check call sites.
SP1 Phase B instrumentation — Task 4 (2026-04-29): wired per-step backward-path NaN checks. New GpuDqnTrainer::run_nan_checks_post_backward(batch_size, iqn_d_h_s2_ptr: Option<u64>, iqn_d_branch_logits_buf_ptr: Option<u64>) mirrors run_nan_checks_post_forward's pattern (single-block GPU reduce per buffer via check_nan_f32, no atomicAdd, no DtoH per step) and covers slots 24-30 + 32 — the eight backward-path buffers with stable post-backward state. Three INLINE check_nan_f32 calls at orchestration call sites cover slots 33/34/35: slot 33 in launch_cublas_backward_to AFTER backward_full + branch-concat accumulations and BEFORE aux_heads_backward (main-path entry-point check); slot 34 in launch_cublas_backward_to AFTER aux_heads_backward SAXPY (compared against 33 to localise aux-head poisoning); slot 35 in apply_iqn_trunk_gradient AFTER graph_safe_copy_f32(bw_d_h_s2 ← iqn_d_h_s2_ptr, ...) and BEFORE encoder_backward_chain (covers the IQN trunk DtoD overwrite — symmetric with slot 27 which checks the SOURCE side). Sequential 33→34→35 fire pattern localises the entry point: 33 alone = main backward chain; 34 fires after clean 33 = aux SAXPY; 35 fires after clean 34 = IQN DtoD or per-sample backward. IQN pointers passed as Option<u64> because GpuIqnHead is a sibling on FusedTrainingCtx (per Task 3 deviation finding, audit lines 535-536); None (when iqn_lambda == 0.0 and gpu_iqn = None) cleanly skips slots 27/28 — semantically honest "buffer doesn't exist this run" rather than false-clean. Call sites: fused_training.rs ungraphed step at line 1519 + capture closure at line 2324 (both updated atomically — feedback_no_partial_refactor). The post-backward checks join the same post_aux captured child as the existing pre/post forward checks; the inline 33/34 checks land in the forward child (because launch_cublas_backward_to is called inside submit_forward_ops_main); slot 35 lands in the aux child (because apply_iqn_trunk_gradient is called inside submit_aux_ops). Slot 31 (ensemble_d_logits_buf) cleanly deferred per Task 2 commit 387335e2b — its owner is FusedDqnTraining, and the ensemble Phase B saxpy guards must be verified before un-deferring; not partial — slot 31's deferral is documented in the new method's docstring + the audit doc. Lengths use CudaSlice::len() where the slice is owned by GpuDqnTrainer (slots 24, 25, 29, 30, 32 — auto-syncs with allocator padding), iqn_trunk_m.len() for slot 26 (matches the trunk_params allocation), b * sh2 for slots 27/30/33/34/35, and inline tba * b * q arithmetic for slot 28 (since GpuIqnHead::d_branch_logits_buf.len() is private and computing it on the trainer side avoids adding a length accessor for one call site). Each check uses existing check_nan_f32(buf_ptr, len, flag_idx) infrastructure — no new kernel, no new scratch buffer, no DtoD/HtoD/HtoH copies. Flags accumulate within fold; reset on fold boundary via existing reset_nan_flags(). Readback flow (commit d1808df14) consumes them in BOTH halt_nan and halt_grad_collapse paths via the name tables annotated in commit 387335e2b. Permanent diagnostic infrastructure — stays as production-grade regression sentinel after the surgical fix lands.
SP1 Phase C surgical fix (2026-04-29): patched two cuBLAS GEMM backward operations identified by Phase B smoke smoke-test-xvzgk at commit f139a63ee:
-
apply_iqn_trunk_gradient(gpu_dqn_trainer.rs:6885+) — pre-call clamp onbw_d_h_s2(the IQN DtoD-overwrite target that feeds the GRN trunk encoder's cuBLAS Linear_a/Linear_b dW/dX/dB GEMMs; symmetric with slot 27 source-side check); post-call sanitize oniqn_trunk_m(slot 26 buffer). ISV-driven max_abs bound =1e6 * isv[H_S2_RMS_EMA_INDEX=96]with1e3ε floor (Invariant 1 carve-out for ISV-uninitialized state). -
launch_cublas_backward_tomain backward path (gpu_dqn_trainer.rs:18516+) — pre-call clamp ond_value_logits_buf+d_adv_logits_bufinputs (slot 24/25 buffers — feed the dueling/branch backward GEMMs inbw.backward_full(...)which producesd_h_s2and ultimatelybn_d_concat_buf); post-call sanitize onbn_d_concat_bufoutput (slot 32 buffer, gated onbottleneck_dim > 0). ISV-driven max_abs bound =1e6 * isv[Q_DIR_ABS_REF_INDEX=21]with1e3ε floor.
New utility: dqn_clamp_finite_f32_kernel (CUDA — replaces NaN/Inf with 0 + clamps finite values to ±max_abs); launch_clamp_finite_f32 (Rust wrapper). Kernel name follows the dqn_* prefix convention used by sibling utility kernels (dqn_nan_check_f32, dqn_zero_kernel, dqn_grad_norm_kernel). Pattern matches the existing isfinite-guard precedent in iqn_backward_per_sample (line 612) + iqn_adam_kernel (line 873). Kernel lives in dqn_utility_kernels.cu (already in build.rs); loaded into the same module as the NaN check kernels in compile_training_kernels. Both call sites land inside captured children (forward_child for slot 32 inputs/output via launch_cublas_backward_to → submit_forward_ops_main; aux_child for slot 26 input/output via apply_iqn_trunk_gradient → submit_aux_ops); the kernel uses only stream-bound launch_builder like check_nan_f32, so it is graph-safe.
F0 risk: low — guards activate at 1e6× ISV magnitude (several orders of magnitude wider than F0-typical inputs); F0 paper-review confirmed guards are no-ops on F0 dynamics (F0 ISV[96] ≈ 1.0 LayerNorm RMS; F0 ISV[21] ≈ 1.0 Q-value scale; F0 |iqn_d_h_s2| ≤ ~10²). For the dueling-branch path, F0 |d_value_logits| / |d_adv_logits| are bounded post the existing 5.0 L2 norm-clip (gpu_dqn_trainer.rs:16827-16868, max_d_logit_norm = 5.0_f32 + clip_grad_kernel). L2 norm ≤ 5.0 across N elements bounds per-element |x| ≤ 5.0 absolute worst-case (single-element edge case) and ≤ 5.0/sqrt(N) typical-case — both several orders of magnitude below the 1e6 max_abs guard threshold, so guards are no-ops on F0. F1 overflows enter via post-norm-clip GEMM accumulator overflow; the fix targets the GEMM input/output sites not the clip itself.
Permanent diagnostic infrastructure (Phase B slots 24-35) stays as regression sentinel — will detect any future regression of the cuBLAS-overflow NaN class. Combined RELATED commit per feedback_no_partial_refactor — both kernels patched in one commit since they share the same unsafe-pattern (cuBLAS GEMM extreme-intermediate-product overflow) + the same fix template.
SP4 Layer A Tasks A10 + A11 — wire all 5 SP4 producer launches (2026-04-30): adds the producer-launch call sites in crates/ml/src/trainers/dqn/trainer/training_loop.rs for the 5 SP4 producers built in Tasks A5-A9 (with A7 fix-up + fix-up #2 closing Curiosity). Five new launches added cold-path next to the existing per-step ISV producers (launch_h_s2_rms_ema, launch_fold_warmup_factor, launch_iqn_quantile_ema, launch_vsn_mask_ema, launch_aux_heads_loss_ema, launch_moe_expert_util_ema, launch_moe_lambda_eff_update): (1) launch_sp4_target_q_p99 → ISV[TARGET_Q_BOUND=131], (2) launch_sp4_atom_pos_p99_all_branches → ISV[ATOM_POS_BOUND[0..4]=132..135], (3) launch_sp4_grad_norm_p99 → ISV[GRAD_CLIP_BOUND=168], (4) launch_sp4_h_s2_p99 → ISV[H_S2_BOUND=169], and (5) launch_sp4_param_group_oracles_all_groups → ISV[WEIGHT_BOUND/ADAM_M_BOUND/ADAM_V_BOUND/WD_RATE × 8 + L1_LAMBDA_TRUNK = 33 slots]. All five fire ONCE per training step; cold-path placement matches the surrounding ISV-producer block which the post-cascade-fix invariant (a5f23b28f) requires to run BEFORE the HEALTH_DIAG line emit. For the param-group oracle, the SP4AuxBuffers descriptor is built each step via FusedTrainingCtx::build_sp4_aux_buffers(curiosity_weights, curiosity_trainer) (added by A7 fix-up #2), threading curiosity state from gpu_experience_collector's curiosity_weight_set() + new curiosity_trainer() accessor. New accessor in gpu_experience_collector.rs: pub fn curiosity_trainer(&self) -> Option<&GpuCuriosityTrainer> — mirrors the existing has_curiosity_trainer/curiosity_weight_set pair. When the collector is absent (init-failure graceful-degrade) or curiosity is disabled (disable_curiosity() was called after async kernel crash), the curiosity descriptor empties and the launcher's count == 0 short-circuit silently skips group 7; groups 0-6 still produce real outputs. Layer A semantics — observability only: no consumer reads any of the 40 SP4 ISV slots yet. Mech 1 (target_q clamp) still uses ±10 × ISV[Q_ABS_REF=16].max(1.0); Mech 2 (atoms_update_kernel clamp) likewise; Mech 6 (adaptive_clip upper bound) still uses the existing fast/slow grad-norm EMA path; Mech 9 (weight_clamp_max_abs) still uses 100 × q_abs_ref_eff config arg; Mech 10 (h_s2 clamp) uses the existing slot-96 RMS EMA. Behavior unchanged from before this commit. Producer ordering: each launch uses the same if let Some(ref fused) = self.fused_ctx { ... tracing::warn! on Err } guard pattern as launch_h_s2_rms_ema — errors never propagate (warn-and-continue) since Layer A is observability-only. Launches are sequenced after launch_moe_lambda_eff_update and before the closing } of the per-step ISV-producer block, ensuring all 40 slots are populated before HEALTH_DIAG reads ISV state. Layer B path-forward: the cold-path placement is a Layer A choice — once Mech 1's pre-clamp consumer lands, Layer B may relocate target_q_p99 (and possibly other captured-graph-eligible producers) into the captured graph for tighter ordering. The 5 producers' Pearls A+D (Task A3) host-side state and Pearl B's per-launch table-packing sync are unaffected by relocation. No new ISV slots, no new HtoD/DtoD/HtoH copies, no behavioural change. cargo check -p ml --lib --tests clean (11 pre-existing warnings; one warning previously reported as 12 has resolved). state_reset_registry test suite passes (3/3). Files touched: crates/ml/src/trainers/dqn/trainer/training_loop.rs (+~50 LoC five SP4 launches + curiosity-arg threading next to launch_moe_lambda_eff_update), crates/ml/src/cuda_pipeline/gpu_experience_collector.rs (+~8 LoC curiosity_trainer() accessor).
SP4 Layer A Task A7 fix-up #2 (2026-04-30): closes the Curiosity hold-out from fix-up #1 — extends the param_group_oracle_kernel to accept an array of sub-buffer pointers + counts and iterate them within each pass. Groups 0-6 launch with n_sub=1 (behaviour identical to the original single-pointer signature); group 7 (Curiosity) launches with n_sub=4 describing [w1, b1, w2, b2], and the kernel treats the union as one logical group for p99/WD_RATE. Pass E (L1 trunk lambda) is dispatched only for group 0 which is always single-sub-buffer. Kernel signature (param_group_oracle_kernel.cu) changed from (const float* params, const float* grads, const float* adam_m, const float* adam_v, int count, ...) to (const u64* params_ptrs, const u64* grads_ptrs, const u64* adam_m_ptrs, const u64* adam_v_ptrs, const i32* sub_counts, int n_sub, int total_count, ...). Histogram device fn (sp4_histogram_p99.cuh) gains a sibling sp4_histogram_p99_multi<BLOCK_SIZE>(sub_buf_ptrs, sub_counts, n_sub, total_count) that mirrors the original three-pass structure but iterates sub-buffers within Pass 1 (max-reduce) and Pass 2 (binning); Pass 3 (cumulative-from-top) is unchanged — divides by total_count. The single-buffer template stays for tests/single-buffer producers (A4/A5/A6/A8/A9). Pass D in the oracle kernel iterates sub-buffers in the per-thread accumulator loop; inv_count = 1/total_count. Pass E reads the contiguous trunk grads sub-buffer at grads_ptrs[0] (group 0 has n_sub==1 so the indexed access is canonical). Sub-buffer table lives in two persistent mapped-pinned buffers allocated at construction: oracle_subbuf_table_buf: MappedU64Buffer of 4 × SP4_ORACLE_TABLE_MAX_SUB = 16 u64s (params/grads/adam_m/adam_v ptr-arrays packed contiguously) and oracle_subbuf_counts_buf: MappedI32Buffer of SP4_ORACLE_TABLE_MAX_SUB = 4 i32s. The launcher overwrites entries [0..n_sub) per group launch (std::ptr::write_volatile) and zeros the unused tail (defence-in-depth so a kernel bug reading past n_sub lands on count=0 no-op). Inter-launch sync added (one stream.synchronize() per group launch, before the next host overwrite races with in-flight kernel coalesced loads). Cold-path producer; per-launch sync cost is negligible vs the kernel work. The end-of-loop sync is retained for symmetry with the Phase 2 contract. Sp4ParamGroupBufs redefined in gpu_dqn_trainer.rs from a flat (params_ptr, grads_ptr, adam_m_ptr, adam_v_ptr, count) quartet to a Vec<Sp4SubBuffer> (1 entry for groups 0-6, 4 entries for Curiosity). Convenience constructors Sp4ParamGroupBufs::single(...) and Sp4ParamGroupBufs::empty() keep the call sites in param_group_buffers and build_sp4_aux_buffers ergonomic. total_count() accessor for the kernel arg. Switched Copy derive to Clone since the descriptor now owns a Vec. SP4AuxBuffers extended with curiosity: Sp4ParamGroupBufs (was 4-tuple, now 5-tuple). Accessors added to CuriosityWeightSet (gpu_weights.rs): w1_ptr/w1_len/b1_ptr/b1_len/w2_ptr/w2_len/b2_ptr/b2_len. To GpuCuriosityTrainer (gpu_curiosity_trainer.rs): full set across grad + Adam state — grad_w1/b1/w2/b2_ptr/_len, adam_m_w1/b1/w2/b2_ptr, adam_v_w1/b1/w2/b2_ptr. build_sp4_aux_buffers signature changed from &self -> SP4AuxBuffers to &self, curiosity_weights: Option<&CuriosityWeightSet>, curiosity_trainer: Option<&GpuCuriosityTrainer> -> SP4AuxBuffers because Curiosity state lives outside FusedTrainingCtx (owned by GpuExperienceCollector). Layer B's training-loop caller threads them in from collector.curiosity_weight_set() + the collector's curiosity_trainer field; both must be Some together (caller responsibility — they live on the same collector) and the descriptor enumerates all four [w1, b1, w2, b2] sub-buffers. Passing None for either yields an empty curiosity descriptor and the launcher silently skips group 7. param_group_buffers return type changed from Option<(u64, u64, u64, u64, usize, i32, i32)> to Option<(Sp4ParamGroupBufs, i32, i32)>. All groups now return Some(...) (Curiosity included); None reserved for forward-compat. GPU unit test sp4_param_group_oracle_per_group_writes_distinct_isv_slots extended: group 7 now exercises 4 sub-buffers of distinct shapes [1024, 32, 1024, 32] (w1/b1/w2/b2-like sizes scaled to keep test runtime small while still exercising the multi-sub-buffer iteration), each with its own seed offset ((s as u64) << 16 mixed into the per-buffer seed) so distinct sub-buffers have distinct distributions — catches buffer-mixup bugs in the kernel's sub-buffer iteration. Reference computation builds union_* vectors and computes p99/WD_RATE over the union; union_total_count replaces the old N constant for groups other than 0/1..6 (where N=4096 is still the single-sub-buffer total). Existing tolerances unchanged (5% rel_err for p99, 2% for WD_RATE, 5% for L1). Test launcher refactored: takes &[TestSubBuffer] slice (replaces 4 separate host arrays), constructs the same mapped-pinned ptr-table layout used in production, packs n_sub entries per call. All 8 SP4 param-groups now produce real outputs in Layer A. The launcher's count == 0 short-circuit is retained — Sp4ParamGroupBufs::empty() still exists for the optional-aux-trainer fallback (gpu_iqn / gpu_attention / curiosity init failures). cargo check -p ml --lib --tests clean. MappedU64Buffer gained a manual Debug impl (warn missing_debug_implementations) for parity with the existing MappedU32Buffer. Files touched: sp4_histogram_p99.cuh (+~80 LoC _multi template), param_group_oracle_kernel.cu (kernel signature + sub-buffer iteration loops + Pass E grads_ptrs[0] access), gpu_dqn_trainer.rs (Sp4SubBuffer type + Sp4ParamGroupBufs::sub_buffers reshape + oracle_subbuf_table_buf / oracle_subbuf_counts_buf fields + constructor allocs + struct init + launcher's table-packing/launch loop + per-launch sync + param_group_buffers return-type change), gpu_weights.rs (+~25 LoC Curiosity accessor block), gpu_curiosity_trainer.rs (+~45 LoC grad + Adam-state accessor block), fused_training.rs (build_sp4_aux_buffers signature change + Curiosity descriptor block, replacing the empty placeholder), mapped_pinned.rs (+10 LoC MappedU64Buffer Debug), tests/sp4_producer_unit_tests.rs (multi-sub-buffer test launcher + group-7 4-shape exercise + union reference computation).
SP4 Layer A Task A7 fix-up (2026-04-30): wires 4 of the 5 deferred aux param-groups to the Pearl B per-param-group statistics oracle. A7 (commit 4f13e2ca3) launched 3 of 8 groups (DqnTrunk/DqnValue/DqnBranches via main DQN params slicing); the 5 aux groups (Iqn/IqlHigh/IqlLow/Attn/Curiosity) returned None from param_group_buffers and silently skipped. Without this fix-up, Layer B's atomic flip would route IQN/IQL/Attn Adam clamps through ISV[WEIGHT_BOUND[3..7)] which would still be 0 → consumer .max(EPS_CLAMP_FLOOR=1.0) → 1.0 clamp → catastrophic over-clamp on aux-trainer params. Architectural decision per A7's DONE_WITH_CONCERNS report: chose Option 3 (thread aux-trainer buffers through the launcher's signature, FusedTrainingCtx supplies them) over hoisting the launcher onto FusedTrainingCtx — least invasive and matches existing patterns where FusedTrainingCtx orchestrates aux trainers. Accessors added mirroring SP3 close-out v2's IQN online_params_ptr template: IQN gained online_grad_ptr/len (the only one missing — online_params_ptr/len, adam_m_ptr/len, adam_v_ptr/len were already public from SP3 slot-48); GpuIqlTrainer gained the full params_ptr/len, grads_ptr/len, adam_m_ptr/len, adam_v_ptr/len set; GpuAttention gained the same set. New types in gpu_dqn_trainer.rs: Sp4ParamGroupBufs { params_ptr, grads_ptr, adam_m_ptr, adam_v_ptr, count } (one trainer's quartet, all four buffers same length per Adam-mirrors-params), SP4AuxBuffers { iqn, iql_high, iql_low, attn } (4-tuple of Sp4ParamGroupBufs). Launcher signature changed from fn launch_sp4_param_group_oracles_all_groups(&self) -> Result<(), MLError> to fn (&self, aux_buffers: &SP4AuxBuffers) -> Result<(), MLError>; param_group_buffers(group, aux) consults aux.{iqn|iql_high|iql_low|attn} for ParamGroup::{Iqn|IqlHigh|IqlLow|Attn} and the existing main-DQN slicing for groups 0-2. FusedTrainingCtx::build_sp4_aux_buffers() — anticipatory helper (Layer B will consume it; lint-suppressed until then) that constructs SP4AuxBuffers from self.gpu_iqn/self.gpu_iql/self.gpu_iql_low/self.gpu_attention. gpu_iqn and gpu_attention are still Option<_> (graceful-degrade init fallback); None produces a zero-count placeholder so the launcher's existing count == 0 { continue; } short-circuit silently skips the kernel launch — matches the existing skip-then-no-op pattern. Curiosity is the architectural hold-out documented in param_group_buffers doc-comment: GpuCuriosityTrainer stores params/grads/Adam state as four separate [w1, b1, w2, b2] sub-buffers (non-contiguous) — a single (params_ptr, count) tuple cannot describe the slice the kernel reads. Resolving this requires either a per-layer launch loop (4× the kernel cost) or a contiguous-flat re-layout of the trainer; both are deeper architectural changes scoped beyond Task A7's fix-up. Until then, ParamGroup::Curiosity still returns None from param_group_buffers and the launcher silently skips it; Layer B must guard against ISV[WEIGHT_BOUND[7]=143] / ISV[ADAM_M_BOUND[7]=151] / ISV[ADAM_V_BOUND[7]=159] / ISV[WD_RATE[7]=167] being the natural-zero floor for Curiosity-related clamps via .max(EPS_CLAMP_FLOOR=1.0). Test coverage is unchanged — the existing kernel-direct unit test sp4_param_group_oracle_per_group_writes_distinct_isv_slots already iterates g_idx ∈ 0..SP4_PARAM_GROUP_COUNT=8 with synthetic Box-Muller buffers, validating all 8 groups at the kernel level (the test does not call the production launcher; it exercises the kernel's per-group passes directly). The fix-up is therefore a wiring change with no test surface change. No production callsite yet — the launcher is still wired only as a public method on GpuDqnTrainer; Layer B's atomic-flip will add the call to the training-step pipeline. Unchanged behaviour today — the launcher is not invoked in production; the fix-up only changes its API to accept &SP4AuxBuffers so Layer B can integrate without further refactoring of the launcher. cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings). Files touched: gpu_iqn_head.rs (+~12 LoC accessor pair), gpu_iql_trainer.rs (+~50 LoC accessor block), gpu_attention.rs (+~50 LoC accessor block), gpu_dqn_trainer.rs (+~70 LoC Sp4ParamGroupBufs/SP4AuxBuffers definitions + launcher signature change + param_group_buffers aux-arm wiring), fused_training.rs (+~60 LoC build_sp4_aux_buffers helper).
SP1 Phase C quality fix-up (2026-04-29): commit-quality follow-up to 97f1d25f5. (1) The post-clamps in apply_iqn_trunk_gradient (slot 26 site) and launch_cublas_backward_to (slots 24/25/32 sites) ran BEFORE run_nan_checks_post_backward (fused_training.rs:1540), silently zeroing the buffers the diagnostic was supposed to observe — the regression sentinel for these 4 slots was effectively dead. Inline check_nan_f32 calls now fire IMMEDIATELY BEFORE each launch_clamp_finite_f32, mirroring the existing slot 35 check at line 6949: the diagnostic captures NaN entry, the clamp then sanitises so propagation stops, and the flag remains for the regression sentinel readback path. (2) Corrected the F0 paper-review reference: the original commit cited 5.0 norm-clip at line 16747 which is actually a cuMemsetD32Async zero-init; the real L2 norm-clip lives at lines 16827-16868 (max_d_logit_norm = 5.0_f32 + clip_grad_kernel). Reasoning tightened: L2 norm ≤ 5.0 worst-case bounds per-element |x| ≤ 5.0 (single-element edge), still several orders of magnitude below the 1e6 max_abs guard. (3) Inline comment at gpu_dqn_trainer.rs:6951 mislabeled the clamp target as slot 27 input — the kernel actually clamps bw_d_h_s2 post-DtoD, which is the slot 35 buffer; slot 27 is the source-side iqn_d_h_s2_ptr. Comment fixed. (4) Pre-clamp rationale articulated: the pre-clamps (slots 24/25/35) target buffers Phase B smoke confirmed CLEAN at F1 first-fire, so they are NOT the smoking gun. They are defense-in-depth regression protection — sanitise input buffers to forestall any FUTURE pathology that creates extreme-but-finite cuBLAS inputs (different from the F1 ep2 NaN where outputs overflow on finite-but-large inputs). They cost 4 additional graph nodes per step (~µs) and are no-ops on F0/F1 typical inputs (≪ 1e6 max_abs guard). The pattern covers both failure modes in one fix per feedback_no_partial_refactor. (5) Renamed clamp_finite_f32_kernel → dqn_clamp_finite_f32_kernel for consistency with sibling utility kernels (dqn_nan_check_f32, dqn_zero_kernel, dqn_grad_norm_kernel); the Rust wrapper retains its launch_clamp_finite_f32 name (matches launch_check_nan_f32 precedent — wrapper names don't carry the dqn_* prefix).
SP1 Phase C fix-up #2 (2026-04-29): tightened ε floor formula at the two ISV-driven max_abs sites in apply_iqn_trunk_gradient (line ~6967) and launch_cublas_backward_to (line ~18631). Original (1e6 × isv).max(1e3) clipped legitimate F1 startup gradients to ±1e3 when fold-boundary ISV reset put H_S2_RMS_EMA_INDEX or Q_DIR_ABS_REF_INDEX near 0 — verified by smoke smoke-test-dr2bn (commit 19b008e1c) which F1-NaN'd at step 240 (vs step 890 in pre-fix smoke smoke-test-xvzgk). New formula 1e6 × isv.max(1.0) guarantees max_abs ≥ 1e6 in all ISV states (cold-start = 1e6, warm = 1e6 × isv), removing cold-start clamp pathology. F0 no-op intent preserved (F0 inputs ≪ 1e6 in all states). The 1.0 ε floor is on the ISV multiplier (Invariant 1 carve-out per feedback_isv_for_adaptive_bounds), not on the bound itself — so the bound is still ISV-driven when ISV is meaningful.
SP1 closure (2026-04-29): F1 cold-start clamp pathology fixed at commit ab2133463 (ε floor formula 1e6 × isv.max(1.0)). F1 NaN moved from step 240 → step 3540 (15× later), validating the diagnosis. Remaining structural F1 NaN at step 3540 classified as SP3 scope (Adam weight pathology — clamps cannot prevent NaN/Inf weights from accumulating via Q-target inflation over training). F0 regression ~35 (vs baseline 55.87) classified as SP2 scope (consolidate 11 per-step check_nan_f32 launches into a single fused reduce kernel). Permanent diagnostic infrastructure (nan_flags_buf 24→48 with slots 24-35 backward coverage) verified working across 3 L40S smokes — sentinel correctly fires on cuBLAS GEMM accumulator overflow at slots 26 (iqn_trunk_m) + 32 (bn_d_concat_buf); IQN-internal buffers (slots 27, 28, 35) stayed clean throughout, ruling out IQN backward chain as the seed. Audit doc docs/dqn-backward-nan-audit.md becomes durable artifact for SP2/SP3. Memory entry project_sp1_f1_nan_root_cause_resolved.md documents handoff including ε-floor fix-up rationale (ε on multiplier, not bound) and NaN diagnostic ordering pearl (inline check before each clamp to preserve sentinel).
SP2 Phase A1 — fused NaN-check kernel (2026-04-29): foundational kernel-only commit appending dqn_nan_check_fused_f32_kernel to crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu. Single-launch replacement target for the 8 per-step dqn_nan_check_f32 calls in run_nan_checks_post_backward (slots 24-30 + 32). Each block (blockIdx.x = 0..N_SLOTS) scans its assigned buffer end-to-end via grid-strided loop, block-local __syncthreads_or reduce (no atomicAdd per feedback_no_atomicadd), sticky-flag write nan_flags_buf[base_flag_idx + slot] = 1 only when has_nan && threadIdx.x == 0. Nullptr bounds check if (buf == nullptr || n == 0) return; makes deferred slots (e.g. slot 31 ensemble) and unused entries no-op cleanly — same call site can pass null for cross-struct slots without spurious flag writes. Invariants preserved from dqn_nan_check_f32: sticky-flag (writes 1, never clears), graph-capture safe (pure kernel launch), no DtoD/HtoD/HtoH copies. extern "C" linkage matches existing dqn_* symbol convention for cudarc module.load_function lookup. Unused yet — Rust wrapper, metadata buffer (per-slot ptr/len arrays), and call-site replacement land in subsequent A2/A3/A4 commits. Drives F0 regression remediation: 8 per-step kernel launches collapse to 1 fused launch (~7× fewer graph nodes for backward NaN coverage) while preserving identical diagnostic semantics. Pattern reference for future fused diagnostic launches if SP3 expands the slot range.
SP2 Phase A2 — nan-check (ptr, len) device tables (2026-04-29): added two device buffers on GpuDqnTrainer to feed the fused NaN-check kernel from A1: nan_check_buf_ptrs: CudaSlice<u64> (12 entries, one per slot 24-35) and nan_check_buf_lens: CudaSlice<i32> (12 entries). Both are stream.alloc_zeros allocations adjacent to nan_flags_buf. Two separate buffers chosen over a packed-stride layout for direct ABI match with the kernel signature (const float* const* buf_ptrs, const int* buf_lens, ...) — no struct-stride alignment concerns. Sized at 12 (slots 24-35); slots 36-47 headroom is reserved for SP3 Mech 5 extension which will resize both buffers in lockstep. Slot 31 (deferred ensemble, cross-struct on FusedDqnTraining per A1's null-skip pattern) entry will hold 0 — kernel skips that block on buf == nullptr. Allocated as zeros; populated once in A3 (slot pointers are stable across the trainer's lifetime — no per-step host→device traffic, satisfying feedback_no_htod_htoh_only_mapped_pinned once A3 selects the population path: mapped-pinned helper from mapped_pinned.rs for the one-shot construction-time write). Unused yet — A3 wires the population + Rust launch wrapper, A4 replaces the 8 individual check_nan_f32 calls in run_nan_checks_post_backward with a single fused launch. Field-level only — no behavioral change.
SP2 Phase A3 — populate + launch wrapper (2026-04-29): refactored A2's nan_check_buf_ptrs: CudaSlice<u64> / nan_check_buf_lens: CudaSlice<i32> to mapped-pinned (MappedU64Buffer / MappedI32Buffer from mapped_pinned.rs) per feedback_no_htod_htoh_only_mapped_pinned — host-side write through the mapped pages is visible to the kernel via the dev_ptr returned by cuMemHostGetDevicePointer_v2, eliminating the HtoD copy entirely. Added populate_nan_check_meta(b, sh2, iqn_d_h_s2_ptr, iqn_d_branch_logits_buf_ptr, iqn_q) on GpuDqnTrainer — one-shot construction-time writer of 12 (ptr, len) tuples covering slots 24-35 (mirrors the per-slot accessor table at the end of docs/dqn-backward-nan-audit.md and the per-slot launches in run_nan_checks_post_backward). Slot 31 deferred ensemble entry: (0, 0). Slots 27/28 IQN entries: Option<u64> ptrs gated on gpu_iqn.is_some() — when IQN is inactive the entries stay zero and the fused kernel skips them via its null-pointer guard. Slots 33-35 (bw_d_h_s2 inline checks) hold null entries — fused kernel skips; the inline check_nan_f32 calls at the three backward orchestration phases (launch_cublas_backward_to post-main / post-aux / apply_iqn_trunk_gradient post-iqn) continue to fire individually for entry-point localization. Added launch_nan_check_fused_f32 Rust wrapper — single launch with grid_dim=12, block_dim=256, BASE_FLAG_IDX=24, mirrors the kernel signature (buf_ptrs_dev, buf_lens_dev, base_flag_idx, nan_flags_ptr). Kernel registered in the precompiled-cubin loader (compile_training_kernels tuple 43→44, 38→39 utility kernels logged) and stored on GpuDqnTrainer as nan_check_fused_f32_kernel: CudaFunction — same module (forward_child / aux_child / post_aux_child captured replay group) as the per-buffer dqn_nan_check_f32 to preserve graph-capture compatibility for A4's call-site replacement. Constructor-time wire-up lives in FusedTrainingCtx::new (after gpu_iqn is constructed) — chosen over the GpuDqnTrainer constructor because gpu_iqn is owned by FusedTrainingCtx, mirroring the same Option<u64> argument pattern used by apply_iqn_trunk_gradient(iqn_d_h_s2_ptr, ...) and run_nan_checks_post_backward. Wrapper unused at A3 commit time — A4 replaces the 8-launch sequence in run_nan_checks_post_backward with the single fused launch. Zero new HtoD copies; pre-commit Invariant 7 satisfied; no ISV slots added; sticky-flag semantics preserved at the kernel.
SP2 Phase A4 — call-site replacement (2026-04-29): replaced the 8 per-step check_nan_f32 launches inside GpuDqnTrainer::run_nan_checks_post_backward with a single delegated call to launch_nan_check_fused_f32 (12 blocks × 256 threads, one block per slot 24-35). Method signature simplified to run_nan_checks_post_backward(&mut self) -> Result<(), MLError> — IQN pointers (iqn_d_h_s2_ptr, iqn_d_branch_logits_buf_ptr) and batch_size are no longer per-step args; their values were already baked into the mapped-pinned (ptr, len) metadata buffer at construction time via populate_nan_check_meta (A3). Both call sites in fused_training.rs (ungraphed step at line 1556 + graph-captured post_aux closure at line 2374) updated atomically per feedback_no_partial_refactor — old iqn_d_h_s2_ptr / iqn_d_branch_logits_buf_ptr derivation blocks removed (no leftover dead code). Slots 33-35 (bw_d_h_s2 multi-point snapshots) inline checks at backward orchestration sites (launch_cublas_backward_to post-main + post-aux, apply_iqn_trunk_gradient post-iqn) unchanged — fused kernel's null-pointer guard skips them in the metadata table; the per-phase inline calls continue to provide entry-point localization across the 3 backward phases. Sticky-flag semantics preserved (kernel only writes 1, never clears); flags accumulate within fold and reset on fold boundary via reset_nan_flags(). Behavioral change at the diagnostic level: graph-capture overhead reduced from 8 launches per step to 1 (per feedback_no_atomicadd and the kernel's grid-strided block-local __syncthreads_or reduce). This is the F0 regression remediation — Phase B's 8 per-step launches were the F0 cause across 3 SP1 smokes (F0 ≈ 35 vs baseline 55.87); Gate 1 smoke (Task A6) validates F0 ≥ 53.08. Zero new HtoD/DtoD/HtoH copies; pre-commit Invariant 7 satisfied; no ISV slots added.
SP3 Task B3 — C51 atom-position growth bounds (2026-04-30): clamped C51 atom-position write sites in atoms_update_kernel.cu (active GPU-driven shared atom grid, all 4 branches), iql_value_kernel.cu::iql_compute_per_sample_support (per-sample [v_min, v_max, delta_z] tile, with delta_z recomputed from the clamped span so the triple stays self-consistent), and experience_kernels.cu::adaptive_atom_positions (legacy single-branch entry kept in lockstep) to ±10 × ISV[Q_ABS_REF=16].max(1.0) via inline fminf(fmaxf(...)). Same ISV bound as Mech 1 (target_q clip at B2) — atoms and target_q share the magnitude scale; ε on the multiplier per the SP1 ε-floor pearl. Reuses isv_signals already in scope at all three kernels (no new arg, no new launch site). Closes the second of two Q-target inflation pathways: with B2 capping the projection target and B3 capping the atom support, the C51 expected_q (mean of atom × prob) is bounded by ±10 × ISV[16].max(1.0) regardless of probability mass distribution. Zero new HtoD/DtoD/HtoH copies; zero new ISV slots; zero new buffers; zero new kernels. cargo check -p ml --lib clean.
SP3 Task B2 — target_q clipping at single-point source (2026-04-29): appended an in-place clamp to GpuDqnTrainer::compute_denoise_target_q immediately before the method's Ok(()) return. Reads Q_ABS_REF_INDEX = 16 via read_isv_signal_at and dispatches launch_clamp_finite_f32 (the SP1 dqn_clamp_finite_f32_kernel reused — no new kernel) over the full denoise_target_q_buf.len() (= B * 12) with max_abs = 10.0_f32 * q_abs_ref.max(1.0_f32). ε on the multiplier per the SP1 ε-floor pearl (cold-start Q_ABS_REF near zero would produce a degenerate ±0 clamp; floor at 1.0 keeps the bound at least ±10). Single-point design — every downstream consumer (C51 cross-entropy, IQN quantile-Huber, MSE warmup) reads the same denoise_target_q_buf, so clipping at the producer covers all three losses without per-loss duplication. F0 paper-review: F0-typical |target_q| is bounded by reward × horizon ≤ ~10; with Q_ABS_REF EMA ≈ 1-5 at F0 convergence, max_abs = 10-50, so the F0 guard is a no-op in normal operation. F1 inflation (thousands+) gets clamped, breaking the Q-target inflation pathway that drives Adam EMA saturation → weight pathology → cuBLAS overflow. Zero new HtoD/DtoD/HtoH copies (the clamp is in-place over existing GPU memory). Zero new ISV slots (reuses existing Q_ABS_REF_INDEX = 16). Zero new buffers. cargo check -p ml --lib clean.
SP3 Task B1 — slot 36-47 diagnostic accessors (2026-04-29): added 24 accessor methods (12 ptr + 12 len) on GpuDqnTrainer and 4 accessors (2 ptr + 2 len) on GpuIqnHead exposing device pointers for the SP3 Mech 5 fused threshold-check kernel (Task B6). Slot allocation per the SP1 Phase B headroom reservation in commit 53bc0bc50: slot 36 trunk Adam-m, 37 value Adam-m, 38 branch Adam-m, 39 IQN Adam-m, 40 trunk Adam-v, 41 value Adam-v, 42 branch Adam-v, 43 IQN Adam-v, 44 trunk params slice, 45 heads (value+branch) params slice, 46 denoise_target_q_buf [B, 12], 47 atom_positions_buf [4, num_atoms]. DQN Adam state is UNIFIED in m_buf / v_buf (single TOTAL_PARAMS-sized buffer covering trunk + heads at the same offsets as params_buf — confirmed at the field declarations gpu_dqn_trainer.rs:2496-2497 and the unified-buffer SAXPY pattern at reset_branch_adam_momentum line 3870-3871). The trunk/value/branch m/v accessors return pointers into the same buffer at offsets computed via padded_byte_offset over the existing compute_param_sizes layout — no new buffers, no new offset helpers (reuses the existing padded_byte_offset + compute_param_sizes API). Trunk = tensors [0..13) (GRN h_s1 + h_s2 + γ/β pairs, length = trunk_param_count); value head = tensors [13..17) (W_v1, B_v1, W_v2, B_v2); branch heads = tensors [17..33) (W/B_b{0..3}fc + W/B_b{0..3}out concatenated dir+mag+ord+urg). Slot 45 heads_params_ptr covers value + branch concat ([13..33)) for a single contiguous threshold-check span — provides finer trunk-vs-heads localisation when divergence appears. IQN Adam state lives separately on GpuIqnHead (not unified with the DQN m_buf/v_buf because IQN has its own online_params buffer and Adam loop) — added pub fn adam_m_ptr / adam_m_len / adam_v_ptr / adam_v_len on GpuIqnHead mirroring the existing t_dev_ptr accessor. Mirrors the structural pattern from SP1 Phase B accessors (commit e9096c7be GRN-block style: concise pub(crate) fn name_ptr(&self) -> u64, doc-comment referencing slot number + slot semantics). Cross-struct visibility: GpuDqnTrainer accessors are pub(crate) (consumed by populate_nan_check_meta_v2 in same crate); GpuIqnHead accessors are pub (called via gpu_iqn.as_ref().map(...) from FusedTrainingCtx per the same SP1 IQN-pointer-as-method-arg pattern). Zero new HtoD/DtoD/HtoH copies — pure pointer arithmetic over existing buffers. Zero new ISV slots. Zero new buffers. cargo check -p ml --lib clean. Unused at B1 commit time — the fused threshold-check kernel (B6) wires populate_nan_check_meta_v2 to feed these pointers into a 24-slot extended metadata table; target_q clipping (B2) writes into denoise_target_q_buf after compute_denoise_target_q so slot 46 captures post-clip values.
SP3 Task B5 — comprehensive Adam EMA reset (2026-04-29): audited every Adam optimizer state buffer in crates/ml/src/ (grep over adam_m/adam_v/m_buf/v_buf/reset_adam_state across the crate) against the existing 5 reset_adam_state calls in fused_training.rs::reset_for_fold (DQN main at line 971 → GpuDqnTrainer::reset_adam_state which covers m_buf/v_buf, iqn_trunk_*, q_attn_adam_*, sel_adam_*, denoise_adam_*, mamba2_adam_*, ofi_embed_adam_*, plus PopArt + Q-div EMA; IQN head at line 1011; TLOB at line 1026; IQL-high at line 1031; IQL-low at line 1036). VSN params and aux next_bar/regime heads (slots 119-126) live inside the unified m_buf/v_buf, so they are covered by the main DQN reset already. One missing optimizer found: GpuAttention (4-head feature attention on h_s2, "always active" per the constructor comment at fused_training.rs:676) owns its own (attn_m, attn_v, attn_adam_step) tuple — called every training step via attn.adam_step at fused_training.rs:1962/1995 but never zeroed at fold boundary. Same pathology as IQN/TLOB/IQL: fold N momentum oversizes fold N+1's first-epoch SDP + output projection updates, compounding through the trunk gradient. Fix landed: added GpuAttention::reset_adam_state mirroring gpu_iqn_head / gpu_tlob pattern (memset_zeros m/v + zero step counter + write 0 through pinned t_pinned); wired under if let Some(ref mut attn) = self.gpu_attention immediately after the IQL-low reset with the same warn-on-failure / info-on-success log pattern. Fold-boundary Adam reset coverage is now comprehensive across every optimizer in FusedTrainingCtx per feedback_no_partial_refactor. Out-of-scope optimizers (documented, not changed): DecisionTransformer (scoped within DT pretrain block, dropped at end of pretrain — no fold leak); GpuCuriosityTrainer (owned by GpuExperienceCollector, external to FusedTrainingCtx — reset belongs at the collector layer if needed); MetaQNetwork (host-side observability MLP for collapse prediction, does not feed LearningHealth or trunk gradients); GpuMoeHead (forward-only, no Adam state of its own — uses params from main DQN). Zero new ISV slots; zero new buffers; zero new kernels. cargo check -p ml --lib clean.
SP3 Task B6 — fused kernel extended for slots 36-47 threshold checks (2026-04-30): extended dqn_nan_check_fused_f32_kernel (SP2 Task A4) from 12-slot NaN-only coverage (slots 24-35) to 24-slot NaN + threshold coverage (slots 24-47). Kernel signature gained one f32 by-value arg q_abs_ref_eff (passed as f32 to match the kernel's float param per feedback_cudarc_f64_f32_abi); per-slot threshold computed inline via the relative slot = blockIdx.x index. Slots 0-11 (= absolute 24-35) keep thr = INFINITY so the magnitude check is trivially false (NaN-only behaviour preserved bit-exactly). Slots 12-15 (= absolute 36-39) check Adam m max-abs ≥ 100 × q_abs_ref_eff; 16-19 (= 40-43) check Adam v max-abs ≥ 1e6 × q_abs_ref_eff²; 20-21 (= 44-45) check weight max-abs ≥ 1e3 × q_abs_ref_eff; 22 (= 46) checks target_q post-clip ≥ 95 × q_abs_ref_eff (= 9.5 × max_abs_target_q where max_abs_target_q = 10 × q_abs_ref_eff per B2); 23 (= 47) checks atom-positions span ≥ 190 × q_abs_ref_eff (= 9.5 × max_atom_abs × 2 where max_atom_abs = 10 × q_abs_ref_eff per B3). Threshold logic is inline if (slot >= 12 && slot < 16) thr = 100*q... — no per-slot threshold buffer, no per-step HtoD. q_abs_ref_eff = max(ISV[Q_ABS_REF=16], 1.0) computed host-side at launch via read_isv_signal_at and floored at 1.0 per the SP1 ε-floor pearl (cold-start ISV ~0 yields q_abs_ref_eff = 1, so all thresholds floor at their ε-floor multiplier × 1). Buffer + populate + launch wiring: nan_check_buf_ptrs / nan_check_buf_lens resized 12 → 24 entries (mapped-pinned host write at construction; no HtoD copy per feedback_no_htod_htoh_only_mapped_pinned); populate_nan_check_meta extended with 4 new args iqn_adam_m_ptr / iqn_adam_m_len / iqn_adam_v_ptr / iqn_adam_v_len: Option<u64>/Option<usize> (None when IQN inactive — entry becomes (0, 0), kernel's null-pointer guard skips the block; symmetric with slots 27/28); 12 new entries appended for slots 36-47 calling the SP3 Task B1 accessors (trunk_adam_{m,v}_{ptr,len}, value_adam_{m,v}_{ptr,len}, branch_adam_{m,v}_{ptr,len}, trunk_params_{ptr,len}, heads_params_{ptr,len}, target_q_{ptr,len}, atom_positions_{ptr,len}); caller fused_training.rs::FusedTrainingCtx::new updated to pass the IQN Adam ptrs via gpu_iqn.as_ref().map(|h| h.adam_m_ptr()) etc. Launch wrapper launch_nan_check_fused_f32 reads q_abs_ref via read_isv_signal_at(Q_ABS_REF_INDEX), computes q_abs_ref_eff: f32 = q_abs_ref.max(1.0), and grows the grid from 12 → 24 blocks; the 5-arg launch builder now passes q_abs_ref_eff between buf_lens_dev and BASE_FLAG_IDX. Sticky-flag semantics preserved (kernel writes 1 only). Closes the diagnostic instrumentation loop for SP3 Mech 5 — slots 36-47 fire when their threshold is exceeded, providing observability for the other 4 SP3 mechanisms' effectiveness; if the SP3 fix doesn't fully resolve F1 NaN, the slot 36-47 firing pattern guides the next iteration. Zero new HtoD/DtoD/HtoH copies (one f32 by-value kernel arg = register pressure only, identical to existing BASE_FLAG_IDX). Zero new ISV slots (reuses Q_ABS_REF_INDEX = 16). Zero new buffers (per-slot threshold computed inline). Single launch covers all 24 slots; null-pointer guard handles slots 27/28/31/33-35/39/43 (deferred or IQN-inactive) without per-step Rust branching. cargo check -p ml --lib clean.
SP3 Task B7 — name-table entries for slots 36-47 readback log (2026-04-30): replaced the SP1 Phase B placeholder strings ("rsv36"-"rsv47") in both training_loop.rs let names = [...] tables (the halt_nan block ~L1992 and the halt_grad_collapse block ~L2089) with the SP3 Mech 5 diagnostic slot names per the B1/B6 accessor allocation: 36 trunk_adam_m_max, 37 value_adam_m_max, 38 branch_adam_m_max, 39 iqn_adam_m_max, 40 trunk_adam_v_max, 41 value_adam_v_max, 42 branch_adam_v_max, 43 iqn_adam_v_max, 44 trunk_weight_max, 45 heads_weight_max, 46 target_q_post_clip, 47 atom_span_max. Both name tables receive byte-identical replacement content (modulo the indentation difference between the two enclosing blocks) per feedback_no_partial_refactor — the post-SP2 stale-doc cleanup commit 387335e2b already established that the two tables must remain identical, and the same shared-contract migration principle applies here. When the fused kernel (B6) sets a slot bit, the readback log line names the buffer that exceeded its ISV-derived threshold (e.g. flagged=[42=branch_adam_v_max, 46=target_q_post_clip]) instead of the opaque rsv* placeholder, providing direct observability for SP3 mechanism effectiveness. Pure name-string replacement — no logic changes, no kernel changes, no buffer changes, no ISV changes. cargo check -p ml --lib clean.
SP3 Mech 6 v2 + Mech 7 revert — tighten anchored clip multiplier 100× → 5× (2026-04-29): coordinated change across dqn_utility_kernels.cu (revert) + gpu_dqn_trainer.rs (multiplier tighten) per feedback_no_partial_refactor. Smoke evidence: smoke smoke-test-ftdjz (commit d9a4d98a3, Mech 6 v1 + Mech 7 combined) regressed BOTH F0 and F1: F0 dropped from ~44 to 38.82 (per-element cap clipped legitimate gradient outliers, harming fit on the cleanest fold) and F1 grad-collapse moved earlier (step 2040 vs 3720 with Mech 6 v1 alone). Slots 36-42 STILL fired under the combined fix — Mech 7 didn't prevent Adam EMA saturation, just slowed training to grad-collapse on F1 while harming F0. Re-analysis of the saturation pathway: Mech 6 v1's 100× multiplier on upper_bound = prev_slow_ema × 100 × isv.max(1) was mismatched with the slot-36 firing threshold ratio (slot 36 fires at 100 × isv.max(1)). With slow_ema ≈ 2 and isv = 1, upper_bound ≈ 200, so per-element gradient max ≤ adaptive_clip = 200 (worst case all energy in one element). Adam m_X EMA steady-state with constant g = 200 reaches m_X = 200 — exceeding slot 36 threshold of 100. Mech 6 was active and BOUNDED the clip, but the bound was wider than the diagnostic threshold by 2×. Two coordinated changes: (1) REVERT Mech 7's per-element clip block from dqn_adam_update_kernel — the per-element approach was misdiagnosis; it caps post-global-clip gradients tighter than legitimate per-element variance, harming F0 without addressing the Adam saturation root cause. Kernel returns to its post-Mech-6 state for that section (clipped_g_f = g_f * clip_scale_f directly followed by Adam moment update). (2) TIGHTEN Mech 6 v1's upper_bound multiplier from 100.0_f32 to 5.0_f32 in update_adaptive_clip. Standard DL practice for clip thresholds is 5–10× steady-state grad norm (vs Mech 6 v1's 100× which was 10–20× standard). Per-element gradient max becomes ≤ 5 × slow_ema × isv ≈ 10 (worst case), well below slot 36 threshold of 100; Adam m_X steady-state caps at ≈ 10. Why 5× and not 10×: slot 36 threshold is 100 × isv. 5× slow_ema (≈ 10 absolute) leaves robust 10× headroom against the threshold; 10× slow_ema would be 20 absolute, only 5× margin — risk of fluctuations triggering slot 36. Why not tighter (e.g., 2×): per-step gradient norms can legitimately spike to ~5× slow_ema in normal training (gradient resumption after warmup, occasional curvature transients); tighter would over-clip and harm fit. F0 risk: low — F0 typical adaptive_clip values are bounded by Mech 6's anchored upper bound only when the EMA-driven clip exceeds 5× slow_ema, which is rare in steady F0 training (typical clip = grad_norm_ema × 2 stays under 5× slow_ema). Cap should be a no-op for F0; F1 v1's 38.82 regression should reverse to the 44 baseline once Mech 7's per-element cap is removed. F1 expected behaviour: per-element gradient max ≤ 10, Adam m_X ≤ 10, slots 36-42 should not fire — validates by next smoke at this commit. Composes with: Mech 1 (target_q clipping at single-point source, B2), Mech 2 (atom-position growth bounds, B3), Mech 3 (CQL aux loss clamping, deferred), Mech 4 (Adam EMA reset comprehensive, B5), Mech 5 (fused threshold-check kernel for slots 36-47, B6+B7). Mech 6 v2 is now the single Adam-saturation defence (Mech 7 retired). Zero new HtoD/DtoD/HtoH copies; zero new ISV slots (reuses Q_ABS_REF_INDEX = 16 and grad_norm_slow_ema_pinned); zero new buffers; zero new kernels. cargo check -p ml --lib clean.
SP3 Mech 7 — per-element gradient clip in Adam kernel (2026-04-29): added a per-element gradient cap inside dqn_adam_update_kernel (crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu) immediately after the existing global L2 norm clip (clipped_g_f = g_f * clip_scale_f) and before the Adam m/v moment updates. Why Mech 6 wasn't enough: smoke smoke-test-fxvkk (commit 48c25d999, Mech 6 anchored upper-bound clip) F1-NaN'd at step 3720 (vs step 3060 in prior smoke; Mech 6 helped marginally, ~22% later) with slots 36-38, 40-42 still firing — DQN main Adam m + v saturated despite Mech 6 capping the AGGREGATE clip threshold. The L2 norm is an aggregate quantity but Adam EMAs are PER-ELEMENT: a gradient with one large element (e.g. element_X = 100, rest small) has ||g||₂ ≈ 100 and passes a clip threshold of 1000 untouched, but the Adam m_X EMA accumulates the large element. With β1=0.9 steady-state, m_X ≈ 100 / (1 - 0.9) = 1000, exceeding slot 36's threshold (100 × ISV[Q_ABS_REF].max(1.0)). Cap formula: per_element_cap = 10 × sqrt(max_grad_norm / total_params) where max_grad_norm is the existing kernel arg sourced from max_grad_norm_buf (= the Mech-6-bounded adaptive_clip mapped-pinned scalar), and total_params is the existing kernel arg counting all DQN main params. Rationale: sqrt(adaptive_clip / N) is the AVERAGE per-element contribution to the L2 norm budget — if all elements equal in magnitude, each contributes sqrt(clip² / N) = clip / sqrt(N), and the L2 norm equals clip exactly. The 10× multiplier allows legitimate per-element deviations up to 10× average (some elements legitimately have larger gradients than others — e.g. branch heads vs trunk parameters); together this means the per-element cap kicks in only for the genuinely outlying elements that would saturate Adam m EMAs. Coupling with Mech 6: the per-element cap scales with adaptive_clip, so when Mech 6's anchored bound keeps adaptive_clip reasonable (the typical case), per_element_cap is correspondingly tight (e.g. adaptive_clip = 10, total_params ≈ 200k → per_element_cap = 10 × sqrt(10 / 200000) ≈ 0.0707 — a tight bound on per-element gradient contribution). When Mech 6's bound is loose (e.g. immediately post-fold-warmup before the slow-EMA stabilises), per_element_cap scales up with it — never tighter than the global clip's intent. Implementation: pure kernel modification using existing args; no new kernel arg, no new buffer, no new ISV slot, no new launch site, no graph recapture. The cap is applied via clipped_g_f = copysignf(fminf(fabsf(clipped_g_f), per_element_cap), clipped_g_f) — preserves sign, bounds magnitude. What stays unchanged: the global L2 norm clip itself (computed identically), Adam bias correction, m/v EMA β1/β2 coefficients, AdamW decoupled weight decay, L1 proximal step on w_s1, the NaN/Inf gradient skip guard, every kernel launch site and graph layout. Composes with: Mech 1 (target_q clipping at single-point source, B2), Mech 2 (atom-position growth bounds, B3), Mech 3 (CQL aux loss clamping, deferred), Mech 4 (Adam EMA reset comprehensive, B5), Mech 5 (fused threshold-check kernel for diagnostic slots 36-47, B6+B7), Mech 6 (anchored upper bound on aggregate adaptive grad clip). Mechs 6+7 together prevent Adam saturation at BOTH the aggregate (L2 norm) and per-element levels — closes the residual pathology after Mech 6's partial 3060→3720 improvement. F0 risk is low: F0-typical adaptive_clip is on the order of 5-20 with total_params ≈ 200k, giving per_element_cap ≈ 10 × sqrt(10/200000) = 0.0707, well above F0-typical per-element gradients (~1e-3 to 1e-2 for a converged trunk on stable input), so Mech 7 is invisible in normal F0 operation. F1+F2 benefit by preventing single-element Adam EMA poisoning. Zero new HtoD/DtoD/HtoH copies; zero new ISV slots; zero new buffers; zero new kernels. cargo check -p ml --lib clean.
SP3 Mech 6 — anchored upper bound on adaptive grad clip (2026-04-29): added an upper bound to GpuDqnTrainer::update_adaptive_clip's new_clip formula in gpu_dqn_trainer.rs. The existing winsorizer (Plan C T11 follow-up N) caps a SINGLE input sample at K=100 × prev_clip before the EMA absorbs it, 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 are poisoned, and they saturate at the SP3 Mech 5 slot 36-43 thresholds. This is the F1 NaN root cause from smoke-test-5rqzs (commit b9edccfc1) at step 3060: Mech 5 diagnostic flags [36=trunk_adam_m_max, 37=value_adam_m_max, 38=branch_adam_m_max, 40=trunk_adam_v_max, 41=value_adam_v_max, 42=branch_adam_v_max] fired with target_q + atoms bounded (Mechs 1+2 working) and weights still finite — narrowing the divergence to the Adam state itself. Bound formula: upper_bound = (grad_norm_slow_ema × 100 × ISV[Q_ABS_REF=16].max(1.0)).max(MIN_CLIP=1.0); final new_clip = (grad_norm_ema × CLIP_MULTIPLIER).max(MIN_CLIP).min(upper_bound). Anchor: grad_norm_slow_ema is the existing α=0.001 slow-EMA scalar (mapped-pinned, updated later in this same function via *self.grad_norm_slow_ema_pinned) — read from pinned memory BEFORE the slow-EMA update on this step, so it reflects the previous step's slow EMA (the legitimate steady-state grad norm at the time of clip computation). Headroom 100×: legitimate per-step deviations can be 10-100× the slow average without being pathological, so 100 × keeps Mech 6 invisible in normal training and only kicks in when the EMA-driven clip ratchets past plausible-deviation bounds. ISV-adaptive multiplier: ISV[Q_ABS_REF_INDEX = 16].max(1.0_f32) scales the cap with the Q-magnitude regime per feedback_isv_for_adaptive_bounds — same ISV slot used by SP3 Mechs 1, 2, 3, and 5 (no new slot). ε on the multiplier (.max(1.0)) per the SP1 ε-floor pearl — cold-start ISV[16] ≈ 0 would otherwise collapse upper_bound toward zero. ε-floor on the bound itself (.max(MIN_CLIP=1.0)): cold-start grad_norm_slow_ema ≈ 0 (first ~200 steps before the slow EMA warms up) would otherwise pin upper_bound at 0, which combined with new_clip.min(upper_bound) would force new_clip = MIN_CLIP=1.0 every step until the slow EMA established a meaningful baseline — exactly the cold-start ratcheting the upper bound is meant to PREVENT. The MIN_CLIP floor on the bound aligns with the existing MIN_CLIP floor on new_clip itself, so the bound is at minimum a no-op (matching the floor below) until the slow EMA warms up. What stays unchanged: the existing winsorizer (single-sample input cap before EMA update), the EMA_BETA = 0.95 adaptive_clip EMA, the CLIP_MULTIPLIER = 2.0 and MIN_CLIP = 1.0 constants, the grad_norm_fast_ema / grad_norm_slow_ema updates further down in the same function (still receive the RAW observed_grad_norm per follow-up K's "fast/slow EMAs are stability signal that should respond to outliers" rationale), the fold_warmup_factor_update kernel that consumes the fast/slow EMAs, and every ISV slot. Pure formula change in one function — no new buffer, no new kernel, no new ISV slot, no new launch site, no graph recapture. F0 risk is low: F0-typical grad_norm_slow_ema is on the order of 1-10 → upper_bound = 100-1000 × ISV[16].max(1); F0-typical new_clip (= grad_norm_ema × 2) is single-digits to low tens, well below the cap, so Mech 6 is invisible in normal F0 operation. F1+F2 benefit by preventing the EMA-ratchet pathology. Composes with: Mech 1 (target_q clipping at single-point source, B2), Mech 2 (atom-position growth bounds, B3), Mech 3 (CQL aux loss clamping, deferred), Mech 4 (Adam EMA reset comprehensive, B5), Mech 5 (fused threshold-check kernel for diagnostic slots 36-47, B6+B7). Zero new HtoD/DtoD/HtoH copies; zero new ISV slots; zero new buffers; zero new kernels. cargo check -p ml --lib clean.
SP4 Layer A Task A2 — mapped-pinned buffers for Pearls B/C/D (2026-04-30): allocated three mapped-pinned buffers in GpuDqnTrainer (crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs) reserved for the upcoming SP4 Pearls B/C/D wiring. (1) wiener_state_buf: MappedF32Buffer[141] — Pearl D Wiener-EMA state, 47 producers × 3 floats [sample_var, diff_var, x_lag] (40 SP4 + 7 retrofit existing producers). (2) clamp_engage_per_block_buf: MappedI32Buffer[2048] — Pearl C engagement counters, 8 param-groups × 256 max blocks per Adam launch; host reduces across blocks to derive engagement_rate. (3) producer_step_scratch_buf: MappedF32Buffer[47] — per-producer per-step step_observation scratch; host applies Pearls A+D (pearls_ad_update, Task A3) to map step_obs to its ISV bound slot. All three zero-initialized at construction by MappedF32Buffer::new / MappedI32Buffer::new (Pearl A sentinels — first-observation replacement on first producer launch); per-fold re-zero entries follow in Task A12 via the state-reset registry. Per feedback_no_htod_htoh_only_mapped_pinned: mapped-pinned (cuMemHostAlloc(DEVICEMAP|PORTABLE)) is the only allowed CPU↔GPU path for these state buffers. No consumers wired yet — buffers are reserved but unread; behaviour unchanged from before this allocation. Producer kernel writes (Tasks A5-A11), Pearls A+D host-side mapper (Task A3), Pearl C engagement-counter Adam-kernel writes (Tasks A8-A11), and per-fold reset wiring (Task A12) all follow in subsequent commits. Zero new ISV slots; zero new kernels; zero new HtoD/DtoD/HtoH copies. cargo check -p ml --lib clean (12 pre-existing warnings, no new warnings).
SP4 Layer A Task A4 — linear-histogram p99 device function + GPU unit test (2026-04-30): created crates/ml/src/cuda_pipeline/sp4_histogram_p99.cuh providing the header-only __device__ template sp4_histogram_p99<BLOCK_SIZE>(buf, count) -> float that returns p99(|buf|) for a single-block, BLOCK_SIZE-thread launch. Three-pass algorithm: (1) block-wide max-reduce of |buf| → step_max (degenerate-zero short-circuits to 0.0 so callers skip the ISV update); (2) linear-spaced [0, step_max] binning into 256 bins via per-warp tiles in dynamic shared memory (no atomicAdd per feedback_no_atomicadd — lane-collisions within a warp cost <0.012% expected count loss, well within the 1% quantile precision budget; cross-warp collisions are eliminated by the per-warp tiling); (3) cumulative-from-top → p99 = bin upper-edge (p99_bin + 1) × bin_width. Linear spacing chosen over log because SP4 producers care about resolution at the top of the |buf| distribution; top bin width ≈ step_max/256 ≈ 0.39%, comfortably within the 1% quantile precision budget. Caller contract documented in the header: grid_dim=(1,1,1), block_dim=(BLOCK_SIZE,1,1), dynamic shared memory ≥ (BLOCK_SIZE/32) × 256 × sizeof(int) (8192 bytes for BLOCK_SIZE=256). Wrapper kernel sp4_histogram_p99_test_kernel.cu exposes the device fn for Rust testing — single-block kernel that calls sp4_histogram_p99<256> and writes the scalar result to a mapped-pinned f32 with __threadfence_system() so the host reads via read_volatile (no memcpy_dtoh per feedback_gpu_cpu_roundtrip and feedback_no_htod_htoh_only_mapped_pinned). Build.rs registration follows the thompson_test_kernel.cu pattern (Plan A Task 1): added to kernels_with_common, plus a cargo:rerun-if-changed directive on the .cuh header so cubins rebuild when the device fn evolves. Unit test sp4_histogram_p99_matches_known_distribution_within_quantization (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the wrapper with 4096 deterministic |N(0,1)| Box-Muller samples (StdRng::seed_from_u64(0xDEAD_BEEF)), computes ground-truth p99 by sorting (analytical reference ≈ 2.576 z-score one-tailed), and asserts rel_err < 5% against the device output. #[ignore]-gated for GPU per the existing distributional_q_tests.rs convention. Local L40S run: true_p99=2.59758, computed_p99=2.59858, rel_err=0.039% — passes. The 5% tolerance leaves ample headroom over the worst-case sum of (linear-bin quantization 0.4%) + (per-warp lane-collision <0.012%) + (finite-sample sorted-p99 jitter ≈0.5% at n=4096) ≈ <2% true budget. No producer wired yet — header is library code included only by the test wrapper; SP4 Tasks A5-A9 add the magnitude-bound producer kernels that #include "sp4_histogram_p99.cuh" directly. Zero new ISV slots; zero new HtoD/DtoD/HtoH copies; zero behaviour change. cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings); GPU test 1/1 passing locally.
SP4 Layer A Task A9 — h_s2_p99 producer for ISV[H_S2_BOUND=169] (2026-04-30): single-block 256-thread producer kernel reading the trunk-output activation surface, mirroring Task A5's end-to-end pattern. Created crates/ml/src/cuda_pipeline/h_s2_p99_kernel.cu — #include "sp4_histogram_p99.cuh" directly (Task A4 header), reads save_h_s2 [B × SH2] (the same buffer launch_h_s2_rms_ema consumes for the slot-96 RMS signal), computes p99(|save_h_s2|) via sp4_histogram_p99<256>, writes the per-step observation to producer_step_scratch_buf[scratch_idx=39] with __threadfence_system(). Registered in crates/ml/build.rs after grad_norm_p99_kernel.cu. Cubin embedded as SP4_H_S2_P99_CUBIN; h_s2_p99_update: CudaFunction field added next to grad_norm_p99_update; loaded in the constructor immediately after grad_norm_p99_update. New launcher GpuDqnTrainer::launch_sp4_h_s2_p99(&self) -> Result<(), MLError> mirrors launch_sp4_target_q_p99's shape exactly: grid_dim=(1,1,1), block_dim=(256,1,1), dynamic shmem 8192 bytes (8 warps × 256 bins × sizeof(int)) per the sp4_histogram_p99.cuh contract; kernel arg list (save_h_s2.raw_ptr(), save_h_s2.len() as i32, scratch_dev_ptr, scratch_idx=39); stream.synchronize(); read_volatile(producer_step_scratch_buf.host_ptr.add(39)); degenerate-zero short-circuit before mutating ISV/Wiener state; pearls_ad_update (Task A3) host-side using zero-copy mapped-pinned reads of ISV[H_S2_BOUND_INDEX=169] + Wiener triple at wiener_state_buf.host_ptr.add((169-SP4_SLOT_BASE)*3 = 114); writes new x_mean to ISV[169] and updated [sample_var, diff_var, x_lag] back to wiener_state_buf — all via mapped-pinned host pointers (no HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned). Distinct from launch_h_s2_rms_ema (slot 96): that producer tracks RMS (a different signal). This producer tracks p99(|h_s2|). Task A13 will retrofit the existing h_s2_rms_ema_update to also use Pearls A+D in a separate commit. Cold-path launch — NOT in the captured graph for now; Task A10 may relocate to captured-graph cadence. No consumer wired yet — Mech 10's h_s2 clamp consumer migrates in Layer B once all producers (A5-A9) land. Behaviour unchanged from before this commit. Unit test sp4_h_s2_p99_writes_step_p99_to_scratch_via_pearl_a (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the production kernel kernel-direct with 4096 deterministic |N(0,1)| Box-Muller samples (StdRng::seed_from_u64(0xA9_5A_F1_57)), asserts step_p99 ∈ ±5% of sorted-sample p99 (analytical |N(0,1)| ≈ 2.576), then exercises pearls_ad_update(prev=0.0, state=ZERO, step_p99) and asserts Pearl A's sentinel branch returns step_p99 directly with state.x_lag = step_p99 and zero variances. The helper also asserts producer_step_scratch_buf[i] == 0 for all i != 39. Layout invariants asserted directly: H_S2_BOUND_INDEX=169, SP4_SLOT_BASE=131, (169-SP4_SLOT_BASE)*3=114. #[ignore]-gated for GPU. Zero new ISV slots (H_S2_BOUND_INDEX=169 already allocated by Task A1); zero new HtoD/DtoD/HtoH copies (mapped-pinned only); one new kernel + one new launcher method; producer-only (no consumer). cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings).
SP4 Layer A Task A8 — grad_norm_p99 producer (degenerate single-element) (2026-04-30): trivial cold-path producer establishing the Mech 6 launcher template that Layer B's adaptive_clip upper-bound consumer will eventually read. Created crates/ml/src/cuda_pipeline/grad_norm_p99_kernel.cu — single-thread single-block kernel that copies grad_norm_buf[0] (a single-element f32 device buffer populated by launch_grad_norm_finalize) to producer_step_scratch_buf[scratch_idx=38] with __threadfence_system(). p99 over 1 element IS the element itself, so no histogram pass is needed; the kernel is a degenerate copy. Registered in crates/ml/build.rs after param_group_oracle_kernel.cu. Cubin embedded as SP4_GRAD_NORM_P99_CUBIN; grad_norm_p99_update: CudaFunction field added next to param_group_oracle_update; loaded in the constructor immediately after param_group_oracle_update. New launcher GpuDqnTrainer::launch_sp4_grad_norm_p99(&self) -> Result<(), MLError> mirrors Task A5's shape end-to-end with the kernel itself reduced to a copy: grid_dim=(1,1,1), block_dim=(1,1,1), shared_mem_bytes=0 (mirroring q_drift_rate_ema_kernel / moe_lambda_eff_kernel cold-path footprint); kernel arg list (grad_norm_buf_ptr, scratch_dev_ptr, scratch_idx=38); stream.synchronize(); read_volatile(producer_step_scratch_buf.host_ptr.add(38)); degenerate-zero short-circuit before mutating ISV/Wiener state; pearls_ad_update (Task A3) host-side using zero-copy mapped-pinned reads of ISV[GRAD_CLIP_BOUND_INDEX=168] + Wiener triple at wiener_state_buf.host_ptr.add((168-SP4_SLOT_BASE)*3 = 111); writes new x_mean to ISV[168] and updated [sample_var, diff_var, x_lag] to wiener_state_buf — all via mapped-pinned host pointers (no HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned). Cold-path launch — NOT in the captured graph for now; Task A10 may relocate to captured-graph cadence. No consumer wired yet — Mech 6's adaptive_clip upper bound still uses the existing fast/slow grad-norm EMA path; SP4 consumer migration follows once all producers (A5-A9) land. Behaviour unchanged from before this commit. Unit test sp4_grad_norm_p99_pearl_a_first_observation_replaces_scalar (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the production kernel kernel-direct with a known single-element scalar (42.0) chosen to be distinguishable from both the Pearl-A sentinel (0.0) and a stale init (1.0); asserts scratch[38] is bit-identical to the input (no histogram quantization for a degenerate copy), then exercises Pearl A's sentinel branch (prev=0.0, state=ZERO) and asserts new_x_mean == step_obs, state.x_lag == step_obs, state.sample_var == 0.0, state.diff_var == 0.0. Helper assertion guards every non-target slot ∈ {0..38} ∪ {39..47} stays zero (guards against the 2fb30f098-style write-cascade). Layout invariants asserted directly: GRAD_CLIP_BOUND_INDEX=168, SP4_SLOT_BASE=131, (168-SP4_SLOT_BASE)*3=111. #[ignore]-gated for GPU. Zero new ISV slots (GRAD_CLIP_BOUND_INDEX=168 already allocated by Task A1); zero new HtoD/DtoD/HtoH copies (mapped-pinned only); one new kernel + one new launcher method; producer-only (no consumer). cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings).
SP4 Layer A Task A7 — Pearl B fused per-param-group statistics oracle (2026-04-30): single producer kernel per param-group, four-to-five fused passes per launch — (params, grads, adam_m, adam_v) read once per group, four scalar outputs (five for trunk) per launch. Created crates/ml/src/cuda_pipeline/param_group_oracle_kernel.cu — single-block, 256-thread kernel that runs Pass A (WEIGHT_BOUND[g] = p99(|params|) via sp4_histogram_p99<256>), Pass B (ADAM_M_BOUND[g] = p99(|adam_m|)), Pass C (ADAM_V_BOUND[g] = p99(|adam_v|)), Pass D (WD_RATE[g] = |Σ w·g| / max(Σ w², EPS_DIV) via 4 register-accumulator block-tree-reduces sharing one 256-float reuse of the histogram dynamic shmem region; side products mean|g|, mean|w| stashed in static __shared__ for Pass E), and Pass E (group 0 trunk only — L1_LAMBDA_TRUNK = (mean|g| / max(mean|w|, EPS_DIV)) × (log K − H) / log K where H is the Shannon entropy of per-feature gradient L2 norms across the [h_dim × k_in] trunk weight gradient matrix; gated by l1_lambda_scratch_idx >= 0). Single helper block_reduce_sum_256 (no atomicAdd per feedback_no_atomicadd); per-feature L2 norm uses cooperative thread-strided accumulation with sequential block_reduce_sum_256 per feature to avoid atomicAdd while keeping cost linear in k_in × h_dim. __threadfence_system() after each scalar writeback so the host-mapped scratch slot is visible across the kernel/host boundary. EPS_DIV = 1e-8f matches sp4_wiener_ema.rs::EPS_DIV (Task A3). K_IN_MAX = 1024 cap on Pass-E per-feature shared scratch (production trunk K_in is much smaller — 40 base + OFI/TLOB widening <128). Registered in crates/ml/build.rs after atom_pos_p99_kernel.cu. Cubin embedded as SP4_PARAM_GROUP_ORACLE_CUBIN in gpu_dqn_trainer.rs; param_group_oracle_update: CudaFunction field added next to atom_pos_p99_update; loaded in the constructor immediately after atom_pos_p99_update. Pearl B 4× memory-bandwidth reduction vs four naive single-stat producer kernels: each of params, grads, adam_m, adam_v is read once across all 4-5 outputs per group rather than four times. New launcher GpuDqnTrainer::launch_sp4_param_group_oracles_all_groups(&self) -> Result<(), MLError> loops g_idx ∈ 0..SP4_PARAM_GROUP_COUNT=8, calling param_group_buffers(g) to resolve (params_ptr, grads_ptr, m_ptr, v_ptr, count, k_in, h_dim) per group, launching the kernel once per group with distinct producer-step-scratch slot indices into the documented stable layout (slot 5 + g for WEIGHT, 13 + g for ADAM_M, 21 + g for ADAM_V, 29 + g for WD_RATE, slot 37 for L1_LAMBDA_TRUNK with l1_idx = -1 for non-trunk groups). After a single end-of-loop stream.synchronize(), applies Pearls A+D host-side per output via pearls_ad_update (Task A3) — 4 outputs per group plus L1 for group 0, totalling 33 host-side updates when all groups launch (degenerate-zero short-circuit per output, mirroring Tasks A5/A6). All ISV reads/writes through isv_signals_pinned (mapped-pinned *mut f32, slots weight_bound(g), adam_m_bound(g), adam_v_bound(g), wd_rate(g), L1_LAMBDA_TRUNK_INDEX); all Wiener-state reads/writes through wiener_state_buf.host_ptr at (isv_idx − SP4_SLOT_BASE) × 3. Zero HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned. Three of eight groups wired in this commit: ParamGroup::DqnTrunk (tensors [0..13), with k_in = param_sizes[0] / shared_h1, h_dim = shared_h1 for Pass E), ParamGroup::DqnValue (tensors [13..17), Pass E gated off), ParamGroup::DqnBranches (tensors [17..33), Pass E gated off). All three slices derived from compute_param_sizes + padded_byte_offset + f32 size, mirroring the existing trunk_adam_m_ptr / value_adam_m_ptr / branch_adam_m_ptr accessor offsets. Five aux groups deferred: ParamGroup::Iqn, IqlHigh, IqlLow, Attn, Curiosity — param_group_buffers returns None; the launcher silently skips. These trainers live on FusedTrainingCtx, not GpuDqnTrainer; wiring them requires either threading the buffer pointers through the launcher signature or hoisting the launcher onto FusedTrainingCtx. Both are follow-up changes scoped beyond Task A7 and documented in the launcher docstring + param_group_buffers docstring. IQN already has online_params_ptr/online_params_len/adam_m_ptr/adam_v_ptr accessors (added by SP3 close-out v2 for slot 48); it would just need an online_grad_ptr/online_grad_len pair. IQL/Attn/Curiosity will need full accessor sets. Cold-path launch — NOT in the captured graph for now; Task A10 may relocate select producers to the captured-graph cadence. No consumer wired yet — Mech 9's clamp still uses hardcoded weight_clamp_max_abs = 100 × q_abs_ref_eff config args; AdamW weight_decay and L1 unchanged. SP4 consumer migration follows once all producers (A5-A9) land. Unit test sp4_param_group_oracle_per_group_writes_distinct_isv_slots (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the production kernel kernel-direct across all 8 group shapes (parameterised over g_idx ∈ 0..SP4_PARAM_GROUP_COUNT); each iteration generates 4 × 4096 deterministic |N(0, σ²)| Box-Muller samples for (params, grads_mag, adam_m, adam_v) with per-group sigmas spanning 0.001×..0.1× and per-group seeds 0xA7_5A_F1_57 ^ (g_idx << 32) ^ buf_id (4 distinct seeds per group → 32 buffers total). Grads are signed (alternating sign by index parity) so Σ w·g doesn't trivially collapse to Σ w·|g|. For group 0 the (k_in, h_dim) = (64, 64) shape exercises Pass E; other groups pass (0, 0) and the kernel skips Pass E via l1_idx = -1. Asserts: WEIGHT/ADAM_M/ADAM_V p99 within 5% rel_err of sorted-sample p99 (linear-bin quantization 0.4% + lane collision <0.012% + finite-sample jitter ≈0.5% ≈ <2% true budget); WD_RATE within 2% rel_err of analytical f64 reference |Σ w·g| / max(Σ w², EPS_DIV); L1 (group 0) within 5% rel_err of analytical reference (mean|g| / max(mean|w|, EPS_DIV)) × deficit with deficit computed from per-feature L2-norm Shannon entropy. All non-target scratch slots stay zero (guards against the 2fb30f098-class write-cascade). Layout invariants asserted directly: SP4_PARAM_GROUP_COUNT=8, SP4_SLOT_BASE=131, WEIGHT_BOUND_BASE=136, ADAM_M_BOUND_BASE=144, ADAM_V_BOUND_BASE=152, WD_RATE_BASE=160, L1_LAMBDA_TRUNK_INDEX=170, plus weight_bound(g)/adam_m_bound(g)/adam_v_bound(g)/wd_rate(g) const-fn drift guards. #[ignore]-gated for GPU. Local RTX 3050 Ti run, all 8 groups: maximum WEIGHT p99 rel_err = 0.589% (group 5); maximum ADAM_M p99 rel_err = 0.589% (group 6); maximum ADAM_V p99 rel_err = 0.554% (group 7); maximum WD_RATE rel_err = 0.001% (analytical formula reduces in f32 with no histogram quantization, hence sub-1%); group 0 L1 = 9.27e-5 vs reference 9.27e-5 (deficit=0.00092 reflects near-uniform per-feature half-normal distribution → small L1 by design). All assertions pass with substantial margin under tolerance. Zero new ISV slots (SP4_PARAM_GROUP_COUNT=8 × 4 + 1 = 33 slots all allocated by Task A1 in [136..168) ∪ [170]); zero new HtoD/DtoD/HtoH copies (mapped-pinned only); one new kernel + one new launcher method + one new private accessor (param_group_buffers); producer-only (no consumer). cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings); 4/4 SP4 producer GPU tests passing locally (Task A4 + A5 + A6 + A7).
SP4 Layer A Task A6 — atom_pos_p99 producer × 4 branches (2026-04-30): single parameterised producer kernel + 4-branch launcher loop, mirroring Task A5's end-to-end pattern. Created crates/ml/src/cuda_pipeline/atom_pos_p99_kernel.cu — single-block, 256-thread kernel that #include "sp4_histogram_p99.cuh" directly (Task A4 header), reads ONE branch's slice of atom_positions_buf (canonical layout [4 × num_atoms] flat with branch b at offset b × num_atoms per atoms_update_kernel.cu's positions_out + (long long)branch * num_atoms write contract), computes p99(|atom_positions[branch]|) via sp4_histogram_p99<256>, and writes the per-step observation to producer_step_scratch_buf[scratch_idx] with __threadfence_system(). Registered in crates/ml/build.rs after target_q_p99_kernel.cu. Cubin embedded as SP4_ATOM_POS_P99_CUBIN; atom_pos_p99_update: CudaFunction field added next to target_q_p99_update; loaded in the constructor immediately after target_q_p99_update. New launcher GpuDqnTrainer::launch_sp4_atom_pos_p99_all_branches(&self) -> Result<(), MLError> loops branch ∈ 0..SP4_BRANCH_COUNT=4, launching the same kernel four times with distinct (branch_dev_ptr, scratch_idx) pairs — branch_dev_ptr = atom_positions_buf.raw_ptr() + branch × num_atoms × sizeof(f32), scratch_idx ∈ {1, 2, 3, 4} (SCRATCH_BASE=1 per Task A5's documented stable layout). After a single end-of-loop stream.synchronize(), applies Pearls A+D host-side per branch via pearls_ad_update (Task A3), reading prev_x_mean from isv_signals_pinned.add(atom_pos_bound(branch) ∈ {132, 133, 134, 135}) and the per-slot Wiener triple from wiener_state_buf.host_ptr.add((isv_idx − SP4_SLOT_BASE) × 3), writing the new x_mean back to ISV[ATOM_POS_BOUND_BASE + branch] and the updated [sample_var, diff_var, x_lag] back to wiener_state_buf — all via mapped-pinned host pointers (no HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned). debug_assert_eq!(total_len, SP4_BRANCH_COUNT × num_atoms) guards against future atom_positions_buf layout drift; debug_assert_eq!(isv_idx, ATOM_POS_BOUND_BASE + branch) guards against atom_pos_bound const-fn drift. Same degenerate-zero short-circuit (if step_obs == 0.0 { continue; }) per branch as Task A5 — skips the ISV/Wiener update before the first recompute_atom_positions populates the branch slice. Cold-path launch — NOT in the captured graph for now; Task A10 may relocate atom_pos to captured-graph cadence. No consumer wired yet — Mech 2's atom-position clamp in atoms_update_kernel.cu still uses ±10 × ISV[Q_ABS_REF=16].max(1.0); SP4 consumer migration follows once all producers (A5-A9) land. Behaviour unchanged from before this commit. Unit test sp4_atom_pos_p99_per_branch_writes_distinct_isv_slots (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the production kernel kernel-direct with 4 × 4096 deterministic |N(0,1)| Box-Muller samples (StdRng::seed_from_u64(0xA6_5A_F1_57 ^ (branch << 32))), each branch scaled by 10ⁿ for n ∈ {0, 1, 2, 3} so a launcher bug that mixes up branch slice pointers would land in the wrong scratch slot at the wrong scale and trip the per-branch ±5% rel_err assertion. Asserts each scratch[1..5] slot is within ±5% of its branch's true sorted-p99, all non-target slots remain zero (guards against the 2fb30f098-style write-cascade), and the (SP4_SLOT_BASE, ATOM_POS_BOUND_BASE, SP4_BRANCH_COUNT) const-layout invariant + atom_pos_bound(branch) == ATOM_POS_BOUND_BASE + branch for branch ∈ 0..4. #[ignore]-gated for GPU. Local RTX 3050 Ti run: branch 0 (scale 1) rel_err=0.106%, branch 1 (scale 10) rel_err=0.362%, branch 2 (scale 100) rel_err=0.325%, branch 3 (scale 1000) rel_err=0.062% — all four pass with substantial margin under the 5% tolerance. The 5% headroom budget matches Tasks A4/A5: linear-bin quantization 0.4% + lane collision <0.012% + finite-sample p99 jitter ≈0.5% at n=4096 ≈ <2% true. Zero new ISV slots (ATOM_POS_BOUND[0..4] = ISV[132..136) already allocated by Task A1); zero new HtoD/DtoD/HtoH copies (mapped-pinned only); one new kernel + one new launcher method (single kernel handle, four launches via the loop); producer-only (no consumer). cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings); GPU unit test 1/1 passing locally.
SP4 Layer A Task A5 — target_q_p99 producer kernel + Pearls A/D wire-up (2026-04-30): first end-to-end SP4 producer. Created crates/ml/src/cuda_pipeline/target_q_p99_kernel.cu — single-block, 256-thread kernel that #include "sp4_histogram_p99.cuh" directly (Task A4 header), reads denoise_target_q_buf (target-network Q-values [B, 12]), computes p99(|target_q|) via sp4_histogram_p99<256>, and writes the per-step observation to producer_step_scratch_buf[0] with __threadfence_system() so the host pinned-mapped read sees the write. Registered in crates/ml/build.rs alongside sp4_histogram_p99_test_kernel.cu. Cubin embedded as SP4_TARGET_Q_P99_CUBIN in gpu_dqn_trainer.rs; target_q_p99_update: CudaFunction field added next to h_s2_rms_ema_kernel; loaded in the constructor immediately after h_s2_rms_ema_kernel. New launcher GpuDqnTrainer::launch_sp4_target_q_p99(&self) -> Result<(), MLError> mirrors launch_h_s2_rms_ema's shape: launches the kernel with grid_dim=(1,1,1), block_dim=(256,1,1), dynamic shmem 8192 bytes (8 warps × 256 bins × sizeof(int)) per the sp4_histogram_p99.cuh contract; synchronises the stream; reads the step observation via read_volatile(producer_step_scratch_buf.host_ptr.add(0)); degenerate-zero short-circuits before mutating ISV/Wiener state; otherwise reads prev_x_mean from isv_signals_pinned.add(TARGET_Q_BOUND_INDEX=131) and the per-slot Wiener triple from wiener_state_buf.host_ptr.add((131-SP4_SLOT_BASE)*3); calls pearls_ad_update (Task A3) host-side; writes the new x_mean back to ISV[131] and the updated [sample_var, diff_var, x_lag] back to wiener_state_buf — all via mapped-pinned host pointers (no HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned). Producer scratch slot layout (stable, documented in launcher comment for Tasks A6-A11 to extend): slot 0 = TARGET_Q_BOUND, 1..5 = ATOM_POS_BOUND[0..4], 5..29 = WEIGHT_BOUND/ADAM_M/ADAM_V × 8 groups, 29..37 = WD_RATE × 8, 37 = L1_LAMBDA_TRUNK, 38 = GRAD_CLIP_BOUND, 39 = H_S2_BOUND, 40..47 = retrofit existing 7 producers. Cold-path launch — NOT in the captured graph for now; Task A10 may relocate to captured-graph cadence. No consumer wired yet — Mech 1's target_q clamp still uses ±10 × ISV[Q_ABS_REF=16].max(1.0); SP4 consumer migration follows once all producers (A5-A9) land. Behavior unchanged from before this commit. Unit test sp4_target_q_p99_first_observation_writes_step_p99_via_pearls_ad (in crates/ml/tests/sp4_producer_unit_tests.rs) drives the production kernel kernel-direct with 4096 deterministic |N(0,1)| Box-Muller samples (StdRng::seed_from_u64(0xA5_5A_F1_57)), asserts step_p99 ∈ ±5% of sorted-sample p99 (analytical |N(0,1)| ≈ 2.576), then exercises pearls_ad_update(prev=0.0, state=ZERO, step_p99) and asserts Pearl A's sentinel branch returns step_p99 directly with state.x_lag = step_p99 and zero variances. The helper also asserts producer_step_scratch_buf[i] == 0 for all i != 0 — guards against future single-thread-write bugs analogous to the 2fb30f098 cascade. #[ignore]-gated for GPU. Local L40S run: true_p99=2.54123, step_p99=2.54149, rel_err=0.010% — passes. The pathological all-in-one-bin distribution (e.g., 990×1.0 + 10×100.0) exposes the feedback_no_atomicadd.md-acknowledged intra-warp lane-collision bound (8 lanes incrementing same shmem bin → 1 increment lands); test distribution mirrors Task A4's well-spread |N(0,1)| pattern that real denoise_target_q_buf Q-values resemble in production. Zero new ISV slots (TARGET_Q_BOUND_INDEX=131 already allocated by Task A1); zero new HtoD/DtoD/HtoH copies (mapped-pinned only); one new kernel + one new launcher method; producer-only (no consumer). cargo check -p ml --lib --tests clean (12 pre-existing warnings, no new warnings); GPU unit test 1/1 passing locally.
SP4 Layer A Task A3 — Pearls A+D shared host-side helper (2026-04-30): created crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs providing the single source-of-truth implementation of Pearl A (first-observation bootstrap) and Pearl D (Wiener-optimal adaptive α) used by all SP4 producer launchers (Tasks A5-A11) and unit tests. Re-exported from cuda_pipeline/mod.rs: pearls_ad_update, WienerState, ALPHA_META, EPS_DIV, EPS_CLAMP_FLOOR. Pearl A (sentinel-detect): on the first producer-step call after a fold reset (prev_x_mean == 0.0 && state.x_lag == 0.0), the helper replaces x_mean directly with step_observation and initialises state.x_lag = step_observation, leaving sample_var = diff_var = 0. This bypasses Pearl D's Wiener formula on the very first observation per the spec self-review math: at t=0 with both variances zero, α* = 0/(0+0+ε_div) = 0 and Pearl D's (1-α*)·prev + α*·obs = 0·0 + 0·obs = 0, NOT obs. The explicit sentinel branch is required and is exercised by the dedicated test pearl_d_does_not_subsume_pearl_a_at_t0. Pearl D (steps 1+): for all subsequent steps, the helper computes α* = diff_var / (diff_var + sample_var + EPS_DIV) and blends x_mean[t] = (1-α*)·x_mean[t-1] + α*·x[t]; the variances themselves are tracked at the uniform meta-rate ALPHA_META = 1e-3 (sample_var ← (1-α_meta)·sample_var + α_meta·(x[t]-x_mean[t-1])², diff_var ← (1-α_meta)·diff_var + α_meta·(x[t]-x_lag)²). Constants (Adam-ε category, structural — not tuning knobs): ALPHA_META = 1e-3 (single uniform meta-rate, no per-signal tuning, derived from typical per-fold step count ~1000 in smoke); EPS_DIV = 1e-8 (numerical division-safety, prevents 0/0 in optimal-α formula); EPS_CLAMP_FLOOR = 1.0 (consumer cold-start floor — used by clamps as bound = isv[X].max(EPS_CLAMP_FLOOR) to prevent bound = 0 from collapsing the clamp at step 0 before any producer runs). Tests (6, all passing): pearl_a_first_observation_replaces_sentinel, pearl_d_stationary_signal_alpha_approaches_zero (constant signal: diff_var → 0, x_mean anchors at signal value), pearl_d_step_change_tracks_within_meta_window (signal jumps 1→100 over ~2000 steps, x_mean tracks past 50), pearl_d_does_not_subsume_pearl_a_at_t0 (mathematical correctness of explicit sentinel branch), meta_constants_are_structural (document-as-code assertion), pearl_a_only_fires_when_both_x_mean_and_x_lag_are_zero (Pearl A does not re-fire post-Pearl-D when x_lag is already populated). No consumers wired yet — helper is library code unused by the producer pipeline; producer launchers in Tasks A5-A11 will call pearls_ad_update after each kernel-step writes producer_step_scratch_buf (allocated in Task A2), then the new x_mean gets written back to the corresponding ISV bound slot. Zero new ISV slots; zero new kernels; zero new HtoD/DtoD/HtoH copies; zero behavior change. cargo check -p ml --lib clean (12 pre-existing warnings, no new warnings); cargo test -p ml --lib sp4_wiener_ema:: 6/6 passing.
SP4 Layer A Task A12 — StateResetRegistry entries for SP4 + Wiener + Pearl C engagement counters (2026-04-30): closes the SP4 fold-boundary state-reset contract by adding registry entries + dispatch arms so all SP4 ISV bound slots, the 141-float Wiener-EMA state, and the 2048-int Pearl C engagement counter all reset to 0 (Pearl A sentinel) at fold boundary. Pearl A's first-observation replacement requires both prev_x_mean (the ISV bound slot) AND state.x_lag (Wiener triple offset 2) to be exactly 0 when a producer first fires in a new fold; without these resets the new fold's first producer launch would EMA against fold-N's stale Wiener state — exactly the cross-fold anchor staleness Mech 8's reverted slow_ema reset was trying to fix imperfectly. Eleven new entries added to StateResetRegistry::new in crates/ml/src/trainers/dqn/state_reset_registry.rs, all FoldReset: (1) sp4_target_q_bound → ISV[131]; (2) sp4_atom_pos_bounds → ISV[132..136) per-branch; (3) sp4_weight_bounds → ISV[136..144) per-group; (4) sp4_adam_m_bounds → ISV[144..152) per-group; (5) sp4_adam_v_bounds → ISV[152..160) per-group; (6) sp4_wd_rate_bounds → ISV[160..168) per-group; (7) sp4_grad_clip_bound → ISV[168]; (8) sp4_h_s2_bound → ISV[169]; (9) sp4_l1_lambda_trunk → ISV[170]; (10) sp4_wiener_state → bulk write_bytes(0) on wiener_state_buf (141 mapped-pinned f32 = 47 producers × {sample_var, diff_var, x_lag}); (11) sp4_clamp_engage_counters → bulk write_bytes(0) on clamp_engage_per_block_buf (2048 mapped-pinned i32 = SP4_PARAM_GROUP_COUNT × MAX_BLOCKS_PER_ADAM = 8 × 256). Group-keyed entries (one name → multiple slots) follow the existing isv_grad_balance_targets (1 name → 4 slots) and isv_q_quantiles (1 name → 8 slots) convention — keeps the registry dispatch table bounded while still providing per-family auditability via the descriptive description strings. Two new helpers on GpuDqnTrainer in crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs: reset_sp4_wiener_state(&mut self) and reset_sp4_clamp_engage_counters(&mut self) mirror the existing reset_fast_grad_norm_ema pattern — single unsafe std::ptr::write_bytes on the buffer's mapped-pinned host_ptr, sized len * size_of::<{f32,i32}>(). f32/i32 zero == bytewise-zero per IEEE-754 +0.0 / two's-complement 0, so byte-zero produces 141/2048 valid scalar zeros respectively. Eleven dispatch arms added to crates/ml/src/trainers/dqn/trainer/training_loop.rs::reset_named_state, each matching one registry entry and writing 0.0 via write_isv_signal_at (single slot or for-loop over a family) or invoking trainer_mut().reset_sp4_* (bulk-zero buffers). The dispatch arms run via the existing StateResetRegistry::fold_reset_entries() iteration in DQNTrainer::reset_for_fold (trainer/mod.rs L1832 — Plan 1 Task 3 wire-up); no changes required to that call site. Companion to Tasks A1+A2 (ISV slots + mapped-pinned buffers allocated; constructor MappedF32Buffer::new / MappedI32Buffer::new zero-fills via ptr::write_bytes is the cold-start) — A12 re-applies the same byte-zero at every fold boundary so the sentinel contract holds across folds, not just at construction. Per feedback_no_partial_refactor.md, every half of the SP4 fold-reset contract migrates in this commit (40 ISV bound slots + 141-float Wiener state + 2048-int engagement counters). cargo check -p ml --lib --tests clean (11 pre-existing warnings, no new warnings); cargo test -p ml --lib state_reset_registry 3/3 passing; cargo test -p ml --lib soft_reset 4/4 passing (verifies new entries are correctly classified FoldReset and don't accidentally end up in SoftReset/TrainingPersist/SchemaContract).
SP4 Layer A Tasks A14+A15 — Pearl C engagement counter wired into all 5 Adam kernels + host-side rate-deficit check (2026-04-30): completes the Pearl C scaffolding by extending all 5 Adam kernels (dqn_adam_update_kernel, iqn_adam_kernel, iql_adam_kernel, attn_adam_kernel, curiosity_adam_step) with a register-then-tree-reduce engagement counter (no atomicAdd per feedback_no_atomicadd.md, mirrors dqn_grad_norm_kernel's warp+block reduction). Per-thread local_engage is set to 1 on post-Adam clamp engagement; warp shuffle then block tree-reduce (no shmem race) collapses to a single per-block count; the block-leader writes that count to clamp_engage_per_block_buf[engage_buf_offset + blockIdx.x]. Sentinel engage_buf_offset == SP4_ENGAGE_OFFSET_DISABLED (-1) skips writeback (used by aux trainers outside the SP4 8-group taxonomy: DT, OFI embed, denoise, recursive_conf, sel; and by TLOB which shares attn_adam_kernel's cubin with GpuAttention). New host-side pearl_c_post_adam_engagement_check(group, param_count) method on GpuDqnTrainer reduces per-block counts via mapped-pinned zero-copy reads, computes engagement_rate = total_engage / param_count, derives rate_deficit = rate − 0.01 (theoretical p99 baseline), and applies pearls_ad_update (Task A3) to maintain a per-group pearl_c_rate_deficit_ema [f32; 8] (host-only — diagnostic, never read on device) backed by a 24-float pearl_c_rate_deficit_state_buf (mapped-pinned Wiener triple per group). Wired into FusedTrainingCtx::run_full_step post-graph-replay for groups 0/3/4/5/6 (DqnTrunk/Iqn/IqlHigh/IqlLow/Attn) and post-train_curiosity_gpu in training_loop.rs for group 7 (Curiosity). 3 design issues resolved per Layer A scope: (1) Curiosity sub-launches — curiosity_adam_step is invoked 4× per training step (W1, b1, W2, b2 sub-buffers, each with its own grid). With a single shared engage_buf_offset the 4 sub-launches' blockIdx.x writes would collide. Resolution: extended SP4_ENGAGE_BUF_LEN from 2048 (= 8 × 256) to 2816 (= 11 × 256) by adding 3 extra logical "groups" beyond the canonical 8. Sub-launches receive distinct offsets SP4_ENGAGE_OFFSET_CURIOSITY_W1=1792 (reuses Curiosity's main-group slot 7), _B1=2048, _W2=2304, _B2=2560. The host-side pearl_c_post_adam_engagement_check(ParamGroup::Curiosity, ...) sums all 4 sub-launch ranges before computing the rate. (2) TLOB sharing attn_adam_kernel with GpuAttention — both modules load the same cubin but are independent trainers. Layer A resolves by having GpuAttention write to its slot (offset 6×256=1536) while GpuTlob passes SP4_ENGAGE_OFFSET_DISABLED=-1 to silently skip Pearl C bookkeeping; the kernel's if (engage_buf_offset >= 0) guard handles this cleanly. Layer B can refactor if per-trainer engagement tracking is needed. (3) DQN main Adam covers groups 0/1/2 in a single launch — the trunk Adam writes to all 3 param groups in one kernel invocation. Layer A accounts for engagement only under group 0 (DqnTrunk); the brief-acknowledged limitation. Layer B will split the launch to track groups 1/2 separately. Wiring path: new accessors nan_flags_buf_ptr() + clamp_engage_per_block_buf_dev_ptr() on GpuDqnTrainer; new set_pearl_c_buffers(nan_flags, engage_buf) setters on GpuIqnHead, GpuIqlTrainer, GpuAttention, GpuCuriosityTrainer; new wire_aux_trainer_pearl_c_buffers() on FusedTrainingCtx which calls them in lockstep; new set_curiosity_pearl_c_buffers() on GpuExperienceCollector which forwards to its owned curiosity trainer. All wiring fires once in init_gpu_experience_collector immediately after set_curiosity_weights. State reset registry (extending Task A12): sp4_clamp_engage_counters description updated to reflect 2816 buffer length and 4 distinct curiosity sub-launch offsets; new entries sp4_pearl_c_rate_deficit_state (24 mapped-pinned floats — Pearls A+D Wiener state per group) and sp4_pearl_c_rate_deficit_ema (host-only [f32; 8] EMA surrogate). Both reset to zero at fold boundary so Pearl A's first-observation sentinel fires on the new fold's first engagement-rate-deficit observation. New helpers reset_sp4_pearl_c_rate_deficit_state(&mut self) (bulk write_bytes on mapped-pinned host_ptr) and reset_sp4_pearl_c_rate_deficit_ema(&mut self) (in-place array zero) follow the existing reset_sp4_wiener_state / reset_sp4_clamp_engage_counters pattern. Dispatch arms wired in reset_named_state alongside existing sp4_* entries. Per-Adam-kernel sticky-engagement diag slots (nan_flags_buf slots 50-54): one slot per Adam kernel records "this kernel's clamp engaged at least once this step" via idempotent thread writes (no race). Slots: DQN_ADAM=50, IQN_ADAM=51, IQL_ADAM=52 (shared by hi/lo trainers), ATTN_ADAM=53, CURIOSITY_ADAM=54. nan_flags_buf allocation size grew from 50 to SP4_NAN_FLAGS_END=55. Layer A scope — Pearl C is observability scaffolding only at this stage. Mech 9's clamp still uses hardcoded 100×Q_ABS_REF.max(1.0). Engagement counters fire correctly, rate_deficit EMA tracks, but the force-bump branch in pearl_c_post_adam_engagement_check only logs via tracing::debug! when rate_deficit_ema > 0.005. Layer B will atomic-flip that branch to mutate ISV[WEIGHT_BOUND[group]] directly. Per feedback_no_partial_refactor.md, every consumer of the kernel signature change migrates in this commit (5 Adam kernels + 5 launch sites + 5 trainers + 1 FusedTrainingCtx wiring + 1 collector wiring + state-reset-registry + dispatch). Per feedback_no_atomicadd.md, the engagement counter uses warp-shuffle + block tree-reduce — no atomicAdd anywhere. Per feedback_no_htod_htoh_only_mapped_pinned.md, all host↔device communication for Pearl C uses mapped-pinned buffers (cuMemHostAlloc DEVICEMAP|PORTABLE); zero HtoD/DtoH/HtoH copies. cargo check -p ml --lib --tests clean (11 pre-existing warnings, no new warnings); cargo test -p ml --lib state_reset_registry 3/3 passing; cargo test -p ml --lib sp4_isv_slots 2/2 passing (including new pearl_c_engage_buf_layout test verifying SP4_ENGAGE_BUF_LEN=2816, all 4 curiosity sub-launch offsets, and that the highest sub-launch end (2816) fits exactly within the buffer).
SP4 Layer A Task A13.0 — h_s2_rms_ema retrofit (Pearls A+D) + buffer growth (2026-05-01): retrofit the existing per-step EMA producer in crates/ml/src/cuda_pipeline/h_s2_rms_ema_kernel.cu to consume the shared pearls_ad_update (Task A3) host-side helper instead of the hardcoded ema_alpha. Kernel signature changed to (const float* h_s2, int B, int SH2, float* scratch_buf, int scratch_idx) — RMS = sqrt(sum_sq / (BSH2)) is reduced via the existing 256-thread shmem tree (no atomicAdd) and written to producer_step_scratch_buf[scratch_idx=40] with __threadfence_system(). Launcher GpuDqnTrainer::launch_h_s2_rms_ema(_ema_alpha_unused: f32) now syncs the stream after the kernel launch and applies Pearls A+D host-side via zero-copy mapped-pinned reads/writes of isv_signals_pinned[H_S2_RMS_EMA_INDEX=96] + wiener_state_buf[120..123) (scratch slot 40 × 3 = wiener offset 120). Degenerate-zero short-circuit before mutating ISV/Wiener state. Buffer growth in same commit: SP4_PRODUCER_COUNT 47 → 69 (40 SP4 + 29 Task A13 retrofit producers); wiener_state_buf 141 → 207 floats; producer_step_scratch_buf grows 47 → 69 entries. Stable layout doc-comments updated in producer_step_scratch_buf field comment, launch_sp4_target_q_p99 slot-table comment, reset_sp4_wiener_state doc, state_reset_registry.rs::sp4_wiener_state description, and 5 wiener_offset + 2 < 141 safety comments in retrofit launchers (now < 207). Test cubin reference SP4_PRODUCER_COUNT: usize = 47 in tests/sp4_producer_unit_tests.rs updated to 69 across all 6 occurrences. Unit test sp4_h_s2_rms_ema_writes_step_rms_via_pearl_a_then_converges_pearl_d (#[ignore]-gated for GPU): drives the production kernel kernel-direct on a constant-5.0 stationary signal of B=4, SH2=64 (256 floats), asserts step_rms ∈ ±1e-4 of analytical RMS=5.0, asserts all non-target scratch slots remain 0, then exercises Pearl A bootstrap (prev=0, state=ZERO → returns step_rms directly + seeds x_lag) and Pearl D convergence (1000 stationary observations → x_mean within 1% of 5.0). Behaviour: cold-path producer with no consumer-facing change beyond the EMA mechanism (slot 96 is read by mag_concat_qdir's adaptive-scale path); slot stays semantically identical (RMS of save_h_s2), only the EMA blending logic changes from hardcoded α to Wiener-optimal α. Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md, feedback_no_partial_refactor.md. cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings); cargo test -p ml --lib sp4_wiener_ema --offline 6/6 passing.
SP4 Layer A Task A13.1 — aux_heads_loss_ema + aux_label_scale_ema retrofit (Pearls A+D) (2026-05-01): both kernels in crates/ml/src/cuda_pipeline/aux_heads_loss_ema_kernel.cu retrofit in the same commit per feedback_no_partial_refactor.md (single shared cubin, single producer family). Kernel aux_heads_loss_ema_update signature: (next_bar_loss_scalar, regime_loss_scalar, scratch_buf, scratch_idx_next_bar=41, scratch_idx_regime=42) — the two scalar reductions (already produced by aux_*_loss_reduce upstream) forward through to scratch slots [41..43) with __threadfence_system(). Kernel aux_label_scale_ema_update signature: (label, B, scratch_buf, scratch_idx=43) — single-block 256-thread shmem reduce for mean(|label|) writes to scratch slot 43 with __threadfence_system(). Three new ISV slots wired with Pearls A+D: ISV[AUX_NEXT_BAR_MSE_EMA_INDEX=113] (Wiener offset 123), ISV[AUX_REGIME_CE_EMA_INDEX=114] (Wiener offset 126), ISV[AUX_LABEL_SCALE_EMA_INDEX=117] (Wiener offset 129). Launchers updated in gpu_aux_heads.rs::AuxHeadsForwardOps::launch_loss_ema (drops isv_dev_ptr/ema_alpha/two isv_*_index args; takes scratch_dev_ptr + 2 scratch_idx args) and launch_label_scale_ema (same pattern, single scratch_idx arg). Wrapper GpuDqnTrainer::launch_aux_heads_loss_ema(_ema_alpha_unused: f32) now syncs the stream after the kernel launch and applies Pearls A+D for both slots in a for loop over [(SCRATCH_IDX_NEXT_BAR=41, ISV[113]), (SCRATCH_IDX_REGIME=42, ISV[114])]. The aux_label_scale_ema launch within aux_heads_forward (Step 2b — happens MID-STEP, before next_bar_loss_reduce and backward consumers read ISV[117]) gains an inline sync + Pearls A+D update so consumers see the up-to-date scale this step. Degenerate-zero short-circuit at all three slots prevents Wiener-state advancement when the upstream reduction yields 0 (e.g., before first aux head forward populates the scalars). Unit tests (collocated, #[ignore]-gated for GPU): (1) sp4_aux_heads_loss_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d — drives kernel with stationary nb_loss=0.5, rg_loss=1.2, asserts step obs ∈ ±1e-6 of inputs at scratch[41]/[42], non-target slots remain 0, Pearl A returns inputs directly + seeds x_lag, 1000 stationary observations converge to within 1% via Pearl D. (2) sp4_aux_label_scale_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d — drives kernel with mixed-sign B=256 labels (alternating ±3.0, mean_abs=3.0), asserts step obs ∈ ±1e-4 of analytical mean at scratch[43], non-target slots remain 0, Pearl A bootstrap + Pearl D convergence behave identically. Behaviour: cold-path producer + within-step producer (label scale) — slots stay semantically identical (next-bar MSE, regime CE, label-scale mean_abs); only the EMA blending logic changes. Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md, feedback_no_partial_refactor.md — both kernels of the same .cu file retrofit in this commit; cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings).
SP4 Layer A Task A13.2 — moe_expert_util_ema retrofit (Pearls A+D) (2026-05-01): retrofit the existing per-step MoE expert-utilisation + gate-entropy producer in crates/ml/src/cuda_pipeline/moe_kernels.cu::moe_expert_util_ema_update. Kernel signature changed: drops (isv, isv_util_base, isv_entropy_index, alpha) for (scratch_buf, scratch_idx_util_base=44, scratch_idx_entropy=52). Single-block single-thread; computes per-expert col_mean of gate [B, K] in a single forward pass, writing each to scratch_buf[scratch_idx_util_base + k] for k in 0..K, and accumulating Shannon entropy (-Σ col_mean·ln(col_mean) with 1e-9 floor) for slot scratch_idx_entropy. The dual-pass formulation in the prior implementation (one pass for util, one re-read for entropy) collapses into a single pass — same output, no algorithmic change beyond the scratch-write routing. 9 new ISV slots wired with Pearls A+D: ISV[MOE_EXPERT_UTIL_EMA_BASE+k=118+k] for k in 0..8 (Wiener offsets (44+k)*3 = 132..156), plus ISV[MOE_GATE_ENTROPY_EMA_INDEX=126] (Wiener offset 156..159). Launcher gpu_moe_head.rs::launch_expert_util_ema drops (isv_dev_ptr, isv_util_base, isv_entropy_index, alpha) for (scratch_dev_ptr, scratch_idx_util_base, scratch_idx_entropy). Wrapper GpuDqnTrainer::launch_moe_expert_util_ema(_ema_alpha_unused: f32) syncs the stream after launch and applies Pearls A+D in a per-slot loop via apply_pearls(scratch_idx, isv_idx) closure: 8 expert util pairs + 1 entropy pair. Degenerate-zero short-circuit at every slot prevents Wiener-state advancement when the kernel hasn't run (cold start before forward populates moe_gate_softmax_buf). debug_assert_eq!(MOE_NUM_EXPERTS, 8) guards against future K changes silently breaking the contiguous scratch layout. Unit test sp4_moe_expert_util_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d (#[ignore]-gated for GPU): drives kernel with B=128 K=8 perfect-uniform gate (all entries 1/8) → analytical col_mean=1/8 per expert, entropy=ln(8)≈2.0794. Asserts each util slot ∈ ±1e-6, entropy slot ∈ ±1e-5, all non-target scratch slots remain 0; exercises Pearl A bootstrap (entropy slot returns directly + seeds x_lag) + Pearl D convergence (1000 stationary observations within 1% of analytical). Behaviour: cold-path producer with no consumer-facing change beyond the EMA mechanism — the 9 slots stay semantically identical (per-expert col_mean, gate entropy) and continue to be read by HEALTH_DIAG mirror + the adaptive λ controller moe_lambda_eff_update (which reads ISV[MOE_GATE_ENTROPY_EMA_INDEX]). Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md. cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings).
SP4 Layer A Task A13.3 — vsn_mask_ema retrofit (Pearls A+D) (2026-05-01): retrofit the existing per-step VSN feature-group mask producer in crates/ml/src/cuda_pipeline/vsn_mask_ema_kernel.cu. Kernel signature changed: drops (isv, isv_first_index, ema_alpha) for (scratch_buf, scratch_first_index=53). Single-block, 256-thread shmem-tree reduce per group; num_groups separate reductions sharing the same 256-float scratchpad — total smem footprint identical to the prior implementation. Per-group mean is written to scratch_buf[scratch_first_index + g] for g in 0..num_groups; single __threadfence_system() after all 6 groups write (PCIe-visible to mapped-pinned host_ptr). 6 ISV slots wired with Pearls A+D: ISV[VSN_MASK_GROUP_0_EMA_INDEX..+6) = ISV[105..111), with Wiener offsets (53+g)*3 = 159..177 for g in 0..6. Wrapper GpuDqnTrainer::launch_vsn_mask_ema(_ema_alpha_unused: f32) syncs the stream after launch and applies Pearls A+D in a for loop over the 6 groups. debug_assert_eq!(num_groups, 6) guards against SL_NUM_FEATURE_GROUPS changes silently breaking the contiguous scratch layout. Degenerate-zero short-circuit at every slot prevents Wiener-state advancement when the kernel hasn't run (cold start before VSN forward populates vsn_mask_buf). Unit test sp4_vsn_mask_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d (#[ignore]-gated for GPU): drives kernel with B=128 num_groups=6 mask mask[b,g] = (g+1)/21 (rows sum to 1, per-group mean = (g+1)/21). Asserts each scratch slot ∈ ±1e-5, non-target slots remain 0; verifies Pearl A bootstrap on group 0 + Pearl D convergence (1000 stationary observations within 1% of analytical). Behaviour: cold-path producer with no consumer-facing change beyond the EMA mechanism — slots [105..111) stay semantically identical (per-group mean of VSN softmax mask) and continue to be read by HEALTH_DIAG mirror + VSN focus monitor. Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md. cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings).
SP4 Layer A Task A13.4 — iqn_quantile_ema retrofit (Pearls A+D) (2026-05-01): retrofit the existing per-step IQN off-median-quantile diagnostic producer in crates/ml/src/cuda_pipeline/iqn_quantile_ema_kernel.cu. Kernel signature changed: drops (isv, isv_q_p05_idx, isv_q_p25_idx, isv_q_p75_idx, isv_q_p95_idx, ema_alpha) for (scratch_buf, scratch_first_index=59). Block-dispatch unchanged: 4 blocks × 256 threads, blockIdx.x ∈ {0,1,2,3} maps to (τ_idx ∈ {0,1,3,4}, slot_offset ∈ {0,1,2,3}); the median (τ_idx=2) is intentionally skipped. Each block reduces mean |Q(s,a;τ)| over (B × TBA) entries via shmem-tree (no atomicAdd) and writes the step observation to scratch_buf[scratch_first_index + slot_offset] with __threadfence_system(). 4 ISV slots wired with Pearls A+D: ISV[IQN_Q_P05_EMA_INDEX=99] (Wiener offset 177), ISV[IQN_Q_P25_EMA_INDEX=100] (Wiener offset 180), ISV[IQN_Q_P75_EMA_INDEX=101] (Wiener offset 183), ISV[IQN_Q_P95_EMA_INDEX=102] (Wiener offset 186). Wrapper GpuDqnTrainer::launch_iqn_quantile_ema(save_q_online_ptr, tba, num_quantiles, _ema_alpha_unused: f32) retains its early-return if save_q_online_ptr == 0 guard (no IQN active in this trainer config), and now syncs the stream after launch and applies Pearls A+D in a for loop over the 4 SLOT_PAIRS const array. Degenerate-zero short-circuit at every slot prevents Wiener-state advancement when the kernel hasn't run (cold start before IQN forward populates save_q_online). Unit test sp4_iqn_quantile_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d (#[ignore]-gated for GPU): drives kernel with B=32 Q=5 TBA=12 controlled save_q_online where Q[a, b*Q+tau_idx] = (tau_idx+1) × 0.7 (analytical mean per tau = (tau_idx+1) × 0.7). Asserts each slot ∈ ±1e-5 of expected (0.7/1.4/2.8/3.5 for p05/p25/p75/p95), median (tau_idx=2 expected 2.1) intentionally absent, non-target scratch slots remain 0; verifies Pearl A bootstrap on p05 + Pearl D convergence (1000 stationary observations within 1% of 0.7). Behaviour: cold-path producer with no consumer-facing change beyond the EMA mechanism — slots [99..103) remain diagnostic-only (HEALTH_DIAG / risk-monitoring surface). Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md. cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings).
SP4 Layer A Task A13 follow-up — fold-reset sentinel contract restored (2026-05-01): A13.0..A13.5 zeroed the Wiener triples at fold boundary via the bulk sp4_wiener_state reset but left the companion ISV-slot dispatch arms in state_reset_registry::reset_named_state writing pre-SP4 cold-start values (1.0, 1/6, 1/8, ln(8)) — defeating Pearl A's sentinel branch (prev_x_mean == 0 AND state.x_lag == 0). Updated the 5 retrofit dispatch arms (isv_h_s2_rms_ema, isv_vsn_mask_g{0..5}_ema, isv_aux_label_scale_ema, isv_moe_expert_util_ema, isv_moe_gate_entropy_ema) to write 0.0 in lockstep with the Wiener-state half. Registry descriptions updated in the same commit per feedback_no_partial_refactor.md (doc + dispatch are two halves of the same contract). Stale 141/2048/47 Wiener-buffer comments updated to 207/2816/69 in state_reset_registry.rs, training_loop.rs, and gpu_dqn_trainer.rs. cargo check -p ml --offline clean; sp4_wiener_ema 6/6 + state_reset_registry 3/3 tests passing.
SP4 Layer A Task A13 code-quality refactor — apply_pearls_to_slot helper + REWARD_COMPONENT_COUNT named constant + SP4_PRODUCER_COUNT module-level promotion + _ema_alpha_unused removal + label-scale doc fix (2026-05-01): five IMPORTANT items from the A13 code-quality review addressed in a single coordinated commit per feedback_no_partial_refactor.md. (1) New apply_pearls_to_slot(scratch_host, scratch_idx, isv_pinned, isv_idx, wiener_host, wiener_offset) helper in sp4_wiener_ema.rs collapses the ~30-line read_volatile/pearls_ad_update/write_volatile per-slot block into a single unsafe fn; consumed by 11 launchers (SP4 producers launch_sp4_target_q_p99, launch_sp4_atom_pos_p99_all_branches, launch_sp4_param_group_oracles_all_groups (closure), launch_sp4_grad_norm_p99, launch_sp4_h_s2_p99; A13 retrofit launch_h_s2_rms_ema, launch_aux_heads_loss_ema, launch_vsn_mask_ema, launch_moe_expert_util_ema (closure), launch_iqn_quantile_ema; cross-boundary GpuExperienceCollector::launch_reward_component_ema_inplace) plus the inline label-scale block in aux_heads_forward Step 2b. Pearl C pearl_c_rate_deficit_state_buf site at gpu_dqn_trainer.rs:21436 left untouched — different mapped-pinned buffer + Rust-side ema array means it doesn't share the helper's signature contract; refactoring would force a different abstraction. New unit test apply_pearls_to_slot_pearl_a_bootstrap_path exercises the helper with heap arrays standing in for mapped-pinned pointers (*const f32/*mut f32 ABI is identical). (2) New pub const REWARD_COMPONENT_COUNT: usize = 6 constant declared next to REWARD_POPART_EMA_INDEX replaces 4 hardcoded 6 literals: block_dim: (REWARD_COMPONENT_COUNT as u32, 1, 1) in launch_reward_component_ema_inplace, for c in 0..REWARD_COMPONENT_COUNT Pearls A+D loop, REWARD_POPART_EMA_INDEX + REWARD_COMPONENT_COUNT in state_reset_registry::reset_named_state ISV-slot range, plus a debug_assert_eq!(REWARD_COMPONENT_COUNT, 6, "reward-component layout invariant — kernel block_dim and reward_components_per_sample stride must update together") invariant guard mirroring MOE_NUM_EXPERTS / SL_NUM_FEATURE_GROUPS style. Kernel reward_component_ema_kernel.cu retains the literal 6 (allows nvcc to fully unroll the row-stride arithmetic) but its comment now documents the host-side REWARD_COMPONENT_COUNT invariant. (3) SP4_PRODUCER_COUNT / SP4_WIENER_FLOATS_PER_SLOT / SP4_WIENER_TOTAL_FLOATS promoted from fn-local consts inside pub fn new to module-level pub consts in gpu_dqn_trainer.rs, re-exported via cuda_pipeline::mod.rs so crates/ml/tests/sp4_producer_unit_tests.rs can use ml::cuda_pipeline::SP4_PRODUCER_COUNT; instead of redeclaring the value at 13 test sites. All 13 redeclarations deleted. (4) _ema_alpha_unused: f32 parameter removed from 6 retrofit launchers (launch_h_s2_rms_ema, launch_aux_heads_loss_ema, launch_vsn_mask_ema, launch_moe_expert_util_ema, launch_iqn_quantile_ema, launch_reward_component_ema_inplace) and the FusedTrainingCtx::launch_iqn_quantile_ema proxy; all callers in training_loop.rs updated per feedback_no_legacy_aliases.md (no soft-deprecated wrappers — rename all call sites directly). Doc-comments on each launcher updated from "α dropped per SP4 — Pearls A+D adapt α from per-slot signal-vs-noise variance. The _ema_alpha_unused argument is preserved so callers compile unchanged" to "α is derived adaptively from per-slot Pearls A+D Wiener state — see sp4_wiener_ema::pearls_ad_update". (5) Stale doc reference at gpu_aux_heads.rs::launch_label_scale_ema:391 referencing non-existent launch_label_scale_ema_with_pearls corrected to point at the actual inline Pearls A+D block in GpuDqnTrainer::aux_heads_forward Step 2b (which now consumes apply_pearls_to_slot). Pure refactor — no spec or behaviour change; same kernel launches, same Pearls A+D semantics, same ISV slot writes. cargo check -p ml --offline clean (12 pre-existing warnings, no new); cargo test -p ml --lib --offline sp4_wiener_ema 7/7 passing (6 originals + 1 new helper test); cargo test -p ml --lib --offline state_reset_registry 3/3 passing.
SP4 Layer A Task A13.5 — reward_component_ema retrofit (Pearls A+D) + cross-boundary wiring + orphan deletion (2026-05-01): retrofit the existing per-step reward-component EMA producer in crates/ml/src/cuda_pipeline/reward_component_ema_kernel.cu AND wire the host-side Pearls A+D update across the trainer/collector boundary (mirroring the A14/A15 Pearl C wiring path) AND delete the trainer's orphan launch_reward_component_ema per feedback_wire_everything_up.md. Kernel signature changed: drops (isv_out, ema_alpha, isv_reward_base_slot) for (scratch_buf, scratch_first_index=63). Single-block 6-thread (one per component); each thread reduces mean |r_c| over n_samples samples and writes the step observation to scratch_buf[scratch_first_index + c] for c in 0..6 with __threadfence_system(). 6 ISV slots wired with Pearls A+D: ISV[REWARD_POPART_EMA_INDEX..+6) = ISV[63..69), with Wiener offsets (63+c)*3 = 189..207 for c in 0..6 — these are the LAST slots in the post-A13 207-float wiener_state_buf. Cross-boundary wiring (mirrors A14/A15 Pearl C precedent): 4 new fields on GpuExperienceCollector — reward_component_pearls_wiener_host_ptr: *mut f32, reward_component_pearls_scratch_dev_ptr: u64, reward_component_pearls_scratch_host_ptr: *mut f32, reward_component_pearls_isv_pinned_ptr: *mut f32 — all NULL/0 until wired. New setter set_reward_component_pearls_buffers(wiener_host, scratch_dev, scratch_host, isv_pinned) on the collector. New accessors on GpuDqnTrainer: wiener_state_buf_host_ptr(), producer_step_scratch_buf_dev_ptr(), producer_step_scratch_buf_host_ptr(), isv_signals_pinned_ptr(). New wire helper FusedTrainingCtx::wire_reward_component_pearls_buffers(&mut self, &mut GpuExperienceCollector) pulls all four pointers from the trainer and pushes them into the collector. Wired once in training_loop.rs::init_gpu_experience_collector immediately after set_curiosity_pearl_c_buffers (mirrors the A14/A15 wire call site). The retrofitted launch_reward_component_ema_inplace on the collector now: launches the kernel with (rewards_ptr, n_samples, scratch_dev, scratch_first_index=63), syncs the stream, applies Pearls A+D in a for c in 0..6 loop (read prev_x_mean from isv_pinned, read Wiener triple from wiener_host, call pearls_ad_update, write back ISV + Wiener) — all via mapped-pinned host pointers (no HtoD/DtoH per feedback_no_htod_htoh_only_mapped_pinned.md), then memsets reward_components_per_sample to zero (preserving original behaviour). Degenerate-zero short-circuit per slot — covers the always-zero placeholder components (c=2 trail, c=5 bonus) plus cold-start before first collect_experiences_gpu. Orphan deletion per feedback_wire_everything_up.md: removed GpuDqnTrainer::launch_reward_component_ema (lines 8775-8796 — zero call sites pre-deletion), removed the trainer-side reward_component_ema_kernel: CudaFunction field (zero consumers post-launcher-deletion), removed the cubin loader at line 11591-11596, removed the field from the constructor's struct-init at line 14677. The REWARD_COMPONENT_EMA_CUBIN static remains because the collector still loads from it. Unit test sp4_reward_component_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d (#[ignore]-gated for GPU): drives kernel with N=128 reward_components where r[i*6+c] = sign(i) × (c+1) (analytical mean|r_c| = c+1 for c in 0..6). Asserts each scratch slot ∈ ±1e-5 of (c+1), non-target slots remain 0; verifies Pearl A bootstrap on component 0 + Pearl D convergence (1000 stationary observations within 1% of 1.0). The cross-boundary wiring path is exercised in production by the integration smoke harness; this kernel-direct test isolates the kernel signature retrofit + Pearls A+D semantics. Per feedback_no_atomicadd.md, feedback_no_htod_htoh_only_mapped_pinned.md, feedback_no_partial_refactor.md, feedback_wire_everything_up.md. cargo check -p ml --lib --tests --offline clean (11 pre-existing warnings, no new warnings); cargo test -p ml --lib sp4_wiener_ema --offline 6/6 passing.
SP4 Layer A — close-out audit (A1..A18)
Branch: plan-c-phase-2-thompson
HEAD at audit: 4c231fa81
Scope: Additive infrastructure for signal-driven magnitude control. No consumer migration (Layer B). No behaviour change vs ea31ebfe3 (SP3 close-out + Pearl C engagement counter).
This is a single Invariant 7 stake-in-the-ground covering all of SP4 Layer A. Every new code path landed in A1..A15 + A17/A18 + spec gap-fix + code-quality refactor is either wired to its production consumer slot in this scope or accounted for as deliberate Layer B/C carry-over. Source-of-truth references: docs/superpowers/specs/2026-04-30-sp4-signal-driven-magnitude-control-design.md, docs/superpowers/plans/2026-04-30-sp4-signal-driven-magnitude-control.md, crates/ml/src/cuda_pipeline/sp4_isv_slots.rs (40 SP4 slots [131..171); 8 ParamGroup; MAX_BLOCKS_PER_ADAM=256; SP4_ENGAGE_BUF_LEN=2816; per-Adam-kernel diag slots 50-54), crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs (Pearls A+D + apply_pearls_to_slot helper, ALPHA_META=1e-3, EPS_DIV=1e-8, EPS_CLAMP_FLOOR=1.0).
Producers landed (count: 11)
| # | Producer | Kernel file | Launcher symbol + file | ISV slot(s) | Wiener offset(s) | Scratch slot(s) | Fold-reset registry name | Unit test name |
|---|---|---|---|---|---|---|---|---|
| 1 | target_q_p99 | target_q_p99_kernel.cu |
GpuDqnTrainer::launch_sp4_target_q_p99 (gpu_dqn_trainer.rs:8919) |
ISV[131] | 0..3 (= scratch 0 × 3) |
0 | sp4_target_q_bound |
sp4_target_q_p99_first_observation_writes_step_p99_via_pearls_ad |
| 2 | atom_pos_p99 × 4 branches | atom_pos_p99_kernel.cu |
GpuDqnTrainer::launch_sp4_atom_pos_p99_all_branches (gpu_dqn_trainer.rs:9038) |
ISV[132..136) | 3..15 (scratch 1..5 × 3) |
1..5 (4 slots) | sp4_atom_pos_bounds |
sp4_atom_pos_p99_per_branch_writes_distinct_isv_slots |
| 3 | param_group_oracle × 8 groups (33 outputs) | param_group_oracle_kernel.cu (kernel) + sp4_histogram_p99.cuh (_multi template, A7 fix-up #2) |
GpuDqnTrainer::launch_sp4_param_group_oracles_all_groups(&SP4AuxBuffers) (gpu_dqn_trainer.rs:9189) |
ISV[136..144) WEIGHT_BOUND × 8 + ISV[144..152) ADAM_M_BOUND × 8 + ISV[152..160) ADAM_V_BOUND × 8 + ISV[160..168) WD_RATE × 8 + ISV[170] L1_LAMBDA_TRUNK (group 0 only) = 33 slots | scratch 5..38 × 3 | 5..38 (33 slots: 5..29 weight/m/v interleaved, 29..37 wd_rate, 37 L1) | sp4_weight_bounds + sp4_adam_m_bounds + sp4_adam_v_bounds + sp4_wd_rate_bounds + sp4_l1_lambda_trunk |
sp4_param_group_oracle_per_group_writes_distinct_isv_slots |
| 4 | grad_norm_p99 | grad_norm_p99_kernel.cu |
GpuDqnTrainer::launch_sp4_grad_norm_p99 (gpu_dqn_trainer.rs:9528) |
ISV[168] | 114..117 |
38 | sp4_grad_clip_bound |
sp4_grad_norm_p99_pearl_a_first_observation_replaces_scalar |
| 5 | h_s2_p99 | h_s2_p99_kernel.cu |
GpuDqnTrainer::launch_sp4_h_s2_p99 (gpu_dqn_trainer.rs:9632) |
ISV[169] | 117..120 |
39 | sp4_h_s2_bound |
sp4_h_s2_p99_writes_step_p99_to_scratch_via_pearl_a |
| 6 | h_s2_rms_ema (A13.0 retrofit) | h_s2_rms_ema_kernel.cu |
GpuDqnTrainer::launch_h_s2_rms_ema (gpu_dqn_trainer.rs:8832) |
ISV[96] (H_S2_RMS_EMA_INDEX) |
120..123 |
40 | isv_h_s2_rms_ema |
sp4_h_s2_rms_ema_writes_step_rms_via_pearl_a_then_converges_pearl_d |
| 7a | aux_heads_loss_ema (next_bar + regime, A13.1) | aux_heads_loss_ema_kernel.cu |
GpuDqnTrainer::launch_aux_heads_loss_ema (gpu_dqn_trainer.rs:9870) |
ISV[113] (AUX_NEXT_BAR_MSE_EMA_INDEX) + ISV[114] (AUX_REGIME_CE_EMA_INDEX) |
123..129 |
41, 42 | isv_aux_next_bar_mse_ema + isv_aux_regime_ce_ema |
sp4_aux_heads_loss_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
| 7b | aux_label_scale_ema (A13.1, inline, no standalone Pearls A+D launcher) | aux_heads_kernel.cu::aux_label_scale_ema_update |
Inline in GpuDqnTrainer::aux_heads_forward Step 2b (gpu_dqn_trainer.rs:10457-10505); kernel-only launcher GpuAuxHeadsForward::launch_label_scale_ema (gpu_aux_heads.rs:394) populates scratch, host Pearls A+D applied directly at the call site via apply_pearls_to_slot |
ISV[117] (AUX_LABEL_SCALE_EMA_INDEX) |
129..132 |
43 | isv_aux_label_scale_ema |
sp4_aux_label_scale_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
| 8 | moe_expert_util_ema + moe_gate_entropy_ema (A13.2) | moe_kernels.cu |
GpuDqnTrainer::launch_moe_expert_util_ema (gpu_dqn_trainer.rs:10135) |
ISV[118..126) per-expert util × 8 + ISV[126] gate-entropy = 9 slots in [118..127) | 132..159 |
44..53 (8 util + 1 entropy) | isv_moe_expert_util_ema + isv_moe_gate_entropy_ema |
sp4_moe_expert_util_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
| 9 | vsn_mask_ema × 6 groups (A13.3) | vsn_mask_ema_kernel.cu |
GpuDqnTrainer::launch_vsn_mask_ema (gpu_dqn_trainer.rs:9769) |
ISV[105..111) (VSN_MASK_GROUP_{0..5}_EMA_INDEX) |
159..177 |
53..59 | isv_vsn_mask_g{0..5}_ema |
sp4_vsn_mask_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
| 10 | iqn_quantile_ema × 4 (A13.4, median skipped) | iqn_quantile_ema_kernel.cu |
FusedTrainingCtx::launch_iqn_quantile_ema proxy + GpuDqnTrainer::launch_iqn_quantile_ema (gpu_dqn_trainer.rs:10745) |
ISV[99..103) skipping τ=0.5 (IQN_Q_P{05,25,75,95}_EMA_INDEX) |
177..189 |
59..63 (τ_idx ∈ {0,1,3,4} → slot_offset ∈ {0,1,2,3}) | isv_iqn_q_p{05,25,75,95}_ema |
sp4_iqn_quantile_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
| 11 | reward_component_ema × 6 (A13.5, CROSS-BOUNDARY launcher) | reward_component_ema_kernel.cu |
GpuExperienceCollector::launch_reward_component_ema_inplace (gpu_experience_collector.rs:2295) — NOT trainer; cross-boundary wiring via set_reward_component_pearls_buffers setter populated by FusedTrainingCtx::wire_reward_component_pearls_buffers |
ISV[63..69) (REWARD_POPART_EMA_INDEX..+REWARD_COMPONENT_COUNT) |
189..207 (last 18 floats of buffer) |
63..69 | isv_reward_component_emas |
sp4_reward_component_ema_writes_step_obs_via_pearl_a_then_converges_pearl_d |
Production verification: every launcher symbol above is grep-able by name; every ISV slot index resolves through the constants in gpu_dqn_trainer.rs:584..966 + sp4_isv_slots.rs:14..43; every fold-reset name appears in state_reset_registry.rs::new and has a matching dispatch arm in training_loop.rs::reset_named_state; every unit test is #[ignore]-gated GPU-bound and lives in crates/ml/tests/sp4_producer_unit_tests.rs.
Pearl A (first-observation bootstrap) coverage
sp4_wiener_ema::pearls_ad_update fires the sentinel branch when prev_x_mean == 0.0 && state.x_lag == 0.0, replacing x_mean directly with step_observation and seeding state.x_lag (sample_var/diff_var stay at 0). The companion helper apply_pearls_to_slot (the DRY refactor in commit 4c231fa81) calls pearls_ad_update from all 11 launchers above plus the inline label-scale block in aux_heads_forward Step 2b.
Per feedback_no_partial_refactor.md, both halves of the sentinel contract (the ISV bound slot AND the Wiener triple's x_lag) reset to exactly 0 at fold boundary. The 5 retrofit ISV slot families that pre-A13 reset to non-sentinel cold-start values (1.0, 1/6, 1/8, ln(8)) and were corrected to write 0.0 in commit c5add566d (A13 spec gap-fix):
isv_h_s2_rms_ema(ISV[96])isv_vsn_mask_g{0..5}_ema(ISV[105..111) — 6 entries)isv_aux_label_scale_ema(ISV[117])isv_moe_expert_util_ema(ISV[118..126) — 8 slots) +isv_moe_gate_entropy_ema(ISV[126])
The 4 retrofit families that already reset to 0.0 pre-A13 (no spec gap, semantics matched Pearl A's expectation by coincidence):
isv_aux_next_bar_mse_ema(ISV[113]) +isv_aux_regime_ce_ema(ISV[114])isv_iqn_q_p{05,25,75,95}_ema(ISV[99/100/101/102] — 4 entries)isv_reward_component_emas(ISV[63..69) — 6 components)
ALL retrofit slots now follow the contract. Verified by reading state_reset_registry.rs:202..544 dispatch table + training_loop.rs::reset_named_state:5805..5925 dispatch arms (both halves audited together per feedback_no_partial_refactor). The sp4_wiener_state registry entry zeroes the 207-float wiener_state_buf in lockstep at every fold boundary so state.x_lag == 0 holds for every producer at the new fold's first launch.
Pearl B (fused per-param-group oracle) coverage
8 param groups defined in ParamGroup enum (sp4_isv_slots.rs:156..165):
| Group idx | Variant | Buffer source for (params, grads, adam_m, adam_v) |
|---|---|---|
| 0 | DqnTrunk | params_buf[0..trunk_param_count], grad_buf slice, m_buf/v_buf (unified Adam state, same offset as params) |
| 1 | DqnValue | params_buf[trunk..value_end] slice (tensors [13..17)) |
| 2 | DqnBranches | params_buf[value_end..branches_end] slice (tensors [17..33)) |
| 3 | Iqn | aux_buffers.iqn (sourced from gpu_iqn accessor in FusedTrainingCtx::build_sp4_aux_buffers) |
| 4 | IqlHigh | aux_buffers.iql_high (from gpu_iql high-tau trainer) |
| 5 | IqlLow | aux_buffers.iql_low (from gpu_iql_low low-tau trainer) |
| 6 | Attn | aux_buffers.attn (from gpu_attention) |
| 7 | Curiosity | aux_buffers.curiosity — Vec<Sp4SubBuffer> of 4 entries [w1, b1, w2, b2]; kernel iterates as one logical group via sp4_histogram_p99_multi<BLOCK_SIZE> template (A7 fix-up #2, commit 8f93c1152) |
Resolution lives in param_group_buffers(group, &SP4AuxBuffers) (gpu_dqn_trainer.rs:9433..9499). The descriptor is constructed each step in training_loop.rs:3568 via FusedTrainingCtx::build_sp4_aux_buffers(curiosity_weights, curiosity_trainer) — Curiosity state is threaded across the trainer/collector boundary by the new GpuExperienceCollector::curiosity_trainer() accessor (added by A7 fix-up #2). When an aux trainer is unset (gpu_iqn = None, gpu_attention = None, or curiosity disabled after async crash), its descriptor returns Sp4ParamGroupBufs::empty() and the launcher's count == 0 short-circuit silently skips the kernel launch — the corresponding ISV slots stay at the Pearl A sentinel, and downstream consumer's EPS_CLAMP_FLOOR=1.0 (= ε on the multiplier per the SP1 ε-floor pearl) handles the cold-start.
33 ISV outputs per kernel pass: WEIGHT_BOUND × 8 (passes A) + ADAM_M_BOUND × 8 (pass B) + ADAM_V_BOUND × 8 (pass C) + WD_RATE × 8 (pass D) + L1_LAMBDA_TRUNK (pass E, group 0 only) = 8 + 8 + 8 + 8 + 1 = 33. Pass E is gated to group 0 in the kernel; for groups 1..7 the pass is skipped via early-return.
Sub-buffer-pointer table for Curiosity (A7 fix-up #2): two persistent mapped-pinned buffers oracle_subbuf_table_buf: MappedU64Buffer[16] (4 ptr-arrays × SP4_ORACLE_TABLE_MAX_SUB=4) and oracle_subbuf_counts_buf: MappedI32Buffer[4] written per-launch via std::ptr::write_volatile; unused-tail zeroed defensively so a kernel out-of-range read lands on count=0 no-op. One stream.synchronize() per group launch prevents inter-launch host-overwrite races with in-flight kernel coalesced loads.
Pearl C (engagement counter) coverage
5 Adam kernels — all wired in commit ea31ebfe3 (A14+A15) via register-then-tree-reduce + clamp_engage_per_block_buf[engage_buf_offset + blockIdx.x] = val at the block-leader. Sentinel engage_buf_offset == SP4_ENGAGE_OFFSET_DISABLED (-1) skips writeback. Per feedback_no_atomicadd.md — warp-shuffle + block tree-reduce, no atomicAdd anywhere.
| Adam kernel | Kernel file (path) | Launcher symbol | Diag slot (sticky engagement flag) | engage_buf_offset (per group) |
|---|---|---|---|---|
dqn_adam_update_kernel |
dqn_utility_kernels.cu:150..241 |
GpuDqnTrainer::adam_step family (covers groups 0/1/2 in single launch — Layer A bookkeeping under group 0 only) |
SP4_DQN_ADAM_DIAG_SLOT = 50 |
0 (DqnTrunk × 256) — Layer B will split per-group |
iqn_adam_kernel |
iqn_dual_head_kernel.cu:884..959 |
GpuIqnHead::adam_step — engage_buf_offset = 3 × 256 = 768 (Iqn) |
SP4_IQN_ADAM_DIAG_SLOT = 51 |
768 |
iql_adam_kernel |
iql_value_kernel.cu:215..280 |
GpuIqlTrainer::adam_step — high passes 4×256=1024, low passes 5×256=1280 |
SP4_IQL_ADAM_DIAG_SLOT = 52 (shared by hi/lo trainers — sticky flag is idempotent; per-group separation is via engage_buf_offset) |
1024 (high) / 1280 (low) |
attn_adam_kernel |
attention_backward_kernel.cu:94..167 |
GpuAttention::adam_step — engage_buf_offset = 6 × 256 = 1536. GpuTlob shares the cubin but passes SP4_ENGAGE_OFFSET_DISABLED = -1 to silently skip Pearl C bookkeeping (Layer B can refactor if per-trainer engagement tracking is needed) |
SP4_ATTN_ADAM_DIAG_SLOT = 53 |
1536 (Attn) / -1 (TLOB) |
curiosity_adam_step |
curiosity_training_kernel.cu:330.. |
GpuCuriosityTrainer::adam_step — 4 sub-launches per training step (W1/b1/W2/b2). SP4_ENGAGE_BUF_LEN extended 2048→2816 to give each sub-launch a distinct per-block index range. Sub-launch offsets: W1=SP4_ENGAGE_OFFSET_CURIOSITY_W1=1792 (= 7×256, reuses Curiosity's main-group slot 7), B1=2048, W2=2304, B2=2560 |
SP4_CURIOSITY_ADAM_DIAG_SLOT = 54 |
1792 / 2048 / 2304 / 2560 |
Pearl C rate-deficit EMA computed host-side in pearl_c_post_adam_engagement_check(group, param_count) (gpu_dqn_trainer.rs:21277). For Curiosity, the host sums all 4 sub-launch ranges before computing engagement_rate = total_engage / param_count and rate_deficit = rate − 0.01 (theoretical p99 baseline). The deficit is fed through pearls_ad_update with per-group pearl_c_rate_deficit_state_buf Wiener triples (24 mapped-pinned floats = 8 × 3) and pearl_c_rate_deficit_ema [f32; 8] (host-only prev_x_mean surrogate). Layer A scope is observability only — the host-side deficit EMA is logged via tracing::debug! when rate_deficit_ema > 0.005; Layer B will atomic-flip that branch to mutate ISV[WEIGHT_BOUND[group]] directly.
Layer A deferral: dqn_main Adam covers groups 0/1/2 in a single launch. Layer A accounts engagement under group 0 only. Per-group split deferred to Layer B (natural with WEIGHT_BOUND_BASE consumer migration — splitting the Adam launch is the same atomic-flip as wiring the consumer reads).
Pearl D (Wiener-optimal adaptive α) coverage
pearls_ad_update formula (sp4_wiener_ema.rs:69..95):
α* = diff_var / (diff_var + sample_var + EPS_DIV)
x_mean[t] = (1 - α*) · x_mean[t-1] + α* · x[t]
sample_var := (1 - ALPHA_META) · sample_var + ALPHA_META · (x[t] - x_mean[t-1])²
diff_var := (1 - ALPHA_META) · diff_var + ALPHA_META · (x[t] - x_lag)²
x_lag := x[t]
Uniform meta-α ALPHA_META = 1.0e-3 (single value across all producers; published-algorithm constant category, not a tuning knob — same theoretical-constant category as Adam β values). EPS_DIV = 1.0e-8 (numerical division safety, Adam-ε category).
WienerState 3 floats per ISV slot: [sample_var, diff_var, x_lag]. Total Wiener state: 207 floats = SP4_PRODUCER_COUNT (= 69) × SP4_WIENER_FLOATS_PER_SLOT (= 3) (gpu_dqn_trainer.rs:742..751 — SP4_PRODUCER_COUNT was promoted to module-level pub const in commit 4c231fa81, re-exported via cuda_pipeline::mod.rs so 13 unit-test redeclarations consolidate to a single import).
Wiener-state layout (offsets in wiener_state_buf flat-float buffer):
| Range (floats) | Producer family (count × 3 = floats) |
|---|---|
0..120 |
40 SP4 producers (target_q + atom_pos × 4 + param_oracle × 33 + grad_norm + h_s2) |
120..123 |
h_s2_rms_ema (A13.0) |
123..129 |
aux_heads_loss_ema × 2 (A13.1) |
129..132 |
aux_label_scale_ema (A13.1, inline) |
132..159 |
moe_expert_util × 8 + moe_gate_entropy = 9 slots (A13.2) |
159..177 |
vsn_mask × 6 (A13.3) |
177..189 |
iqn_quantile × 4 (A13.4, median skipped) |
189..207 |
reward_component × 6 (A13.5) |
Reset semantics: bulk unsafe std::ptr::write_bytes(host_ptr, 0, 207 × 4) at fold boundary via GpuDqnTrainer::reset_sp4_wiener_state (gpu_dqn_trainer.rs) wired by registry entry sp4_wiener_state and dispatch arm training_loop.rs:5885..5895. f32 zero == bytewise-zero (IEEE-754 +0.0).
Mapped-pinned discipline (per feedback_no_htod_htoh_only_mapped_pinned)
3 mapped-pinned buffers in trainer (allocated in GpuDqnTrainer::new, all from cuMemHostAlloc(... DEVICEMAP|PORTABLE)):
| Buffer | Type | Size | Purpose |
|---|---|---|---|
wiener_state_buf |
MappedF32Buffer |
207 (= SP4_PRODUCER_COUNT × 3) | Pearl D Wiener-EMA state (40 SP4 + 29 retrofit). Grew 141→207 in A13.0 commit aada419de. |
clamp_engage_per_block_buf |
MappedI32Buffer |
2816 (= 11 × 256, 8 main groups + 3 curiosity sub-launches) | Pearl C engagement counter. Grew 2048→2816 in A14/A15 commit ea31ebfe3 to give 4 distinct curiosity sub-launch offsets without per-block-index collisions. |
producer_step_scratch_buf |
MappedF32Buffer |
69 (= SP4_PRODUCER_COUNT) | Per-producer per-step step_observation scratch. Grew 47→69 in A13.0 commit aada419de. |
Cross-boundary collector (A13.5 + A14/A15 wiring path): 4 mapped-pinned pointers wired via GpuExperienceCollector::set_reward_component_pearls_buffers(wiener_host, scratch_dev, scratch_host, isv_pinned) and set_curiosity_pearl_c_buffers(nan_flags, engage_buf). The FusedTrainingCtx::wire_reward_component_pearls_buffers + wire_aux_trainer_pearl_c_buffers helpers pull pointers from the trainer accessors (wiener_state_buf_host_ptr, producer_step_scratch_buf_dev_ptr, producer_step_scratch_buf_host_ptr, isv_signals_pinned_ptr, nan_flags_buf_ptr, clamp_engage_per_block_buf_dev_ptr) and push them into the collector once at init_gpu_experience_collector (training_loop.rs:1436..1450).
Every read/write through these buffers uses std::ptr::read_volatile / std::ptr::write_volatile on the raw mapped-pinned pointers — exclusively via the apply_pearls_to_slot helper (the A13 code-quality refactor consolidated 11 launchers + 1 inline block onto a single helper signature; one Pearl C site at gpu_dqn_trainer.rs:21436 retains its own ad-hoc reads because its mapped-pinned buffer + Rust-side EMA array combination doesn't share the helper's signature contract).
Verification: git grep -nE "copy_to_host|cudaMemcpy.*Host" crates/ml/src/cuda_pipeline/{h_s2_rms,aux_heads_loss,moe_kernels,vsn_mask,iqn_quantile,reward_component,target_q,atom_pos,param_group,grad_norm,h_s2_p99}*.{cu,rs} returns ZERO matches in the 11 retrofitted/SP4 producer code paths.
Atomic-add discipline (per feedback_no_atomicadd)
Block tree-reduce / shmem-tree reduce / register-then-warp-shuffle in all 5 SP4 producer kernels + 6 retrofit kernels + 5 Adam kernels' Pearl C engagement counters:
- Producer kernels use
__syncthreads+ shared-memory tree reduction (sp4_histogram_p99.cuhfor histogram-based p99;h_s2_rms_ema_kernel.cu/vsn_mask_ema_kernel.cu/iqn_quantile_ema_kernel.cu/aux_heads_loss_ema_kernel.cu/moe_kernels.cu/reward_component_ema_kernel.cufor batch-mean reductions). - Adam kernels' Pearl C counters use per-thread
local_engageregister + warp shuffle (__shfl_xor_sync) + block tree-reduce; the block-leader writes a single i32 toclamp_engage_per_block_buf[engage_buf_offset + blockIdx.x](host post-reduces across blocks).
Verification: git grep -nE "atomicAdd\(" crates/ml/src/cuda_pipeline/{h_s2_rms,aux_heads_loss,moe_kernels,vsn_mask,iqn_quantile,reward_component,target_q,atom_pos,param_group,grad_norm,h_s2_p99,dqn_utility,iqn_dual_head,iql_value,attention_backward,curiosity_training}*.cu returns ZERO matches in retrofitted/SP4 code paths (any matches in unrelated unmodified code are pre-existing and out of Layer A scope).
Orphan deletion (per feedback_wire_everything_up)
- Pre-A13 trainer-side
GpuDqnTrainer::launch_reward_component_emaremoved in commit4f82b74a5(zero call sites pre-deletion; cross-boundary collector launcherGpuExperienceCollector::launch_reward_component_ema_inplaceis now the single producer entry-point). Fieldreward_component_ema_kernel: CudaFunctionand its cubin loader also removed; theREWARD_COMPONENT_EMA_CUBINstatic remains because the collector still loads from it. _ema_alpha_unused: f32shim parameter dropped from 6 retrofit launchers in commit4c231fa81(launch_h_s2_rms_ema,launch_aux_heads_loss_ema,launch_vsn_mask_ema,launch_moe_expert_util_ema,launch_iqn_quantile_ema,launch_reward_component_ema_inplace) plus theFusedTrainingCtx::launch_iqn_quantile_emaproxy. Perfeedback_no_legacy_aliases.md— no soft-deprecated wrappers; all callers intraining_loop.rsupdated in lockstep.- 12 redundant
SP4_PRODUCER_COUNTtest redeclarations consolidated to a single module-level import incrates/ml/tests/sp4_producer_unit_tests.rs(commit4c231fa81). - Stale doc reference at
gpu_aux_heads.rs:391pointing to non-existentlaunch_label_scale_ema_with_pearlscorrected to point at the actual inline Pearls A+D block inGpuDqnTrainer::aux_heads_forwardStep 2b.
Verification: git grep -nE "_ema_alpha_unused|launch_reward_component_ema\b|launch_label_scale_ema_with_pearls" crates/ returns only doc-comment historical references (5 matches, all in deletion-context comments documenting what was removed and why — kept intentionally as Invariant 7 trail breadcrumbs).
Out-of-scope items (deferred to Layer B / Layer C)
| Item | Layer | Notes |
|---|---|---|
| Per-group split for DQN main Adam launch | B | Engagement currently accounted under group 0 only; split is natural with WEIGHT_BOUND_BASE consumer migration (same atomic-flip touches both producer offset + consumer read). |
| All consumer-side ISV reads (Mech 1/2/5/6/9/10 clamp wires) | B | Atomic commit per feedback_no_partial_refactor. Producers populate slots in Layer A; consumers stay on hardcoded bounds (±10 × ISV[Q_ABS_REF=16].max(1.0) etc.) until Layer B flip. |
grad_norm_slow_ema retire |
C — Task C1 | Slow EMA is the persistent cross-fold steady-state baseline that the per-step grad_norm_p99 will replace once Mech 6's ISV[168] consumer migrates. |
| Dead Mech 5 hardcoded threshold params removal | C — Task C2 | After SP3 Task B6's fused threshold-check kernel (commit 8d... pre-SP4) wires per-slot inline thresholds, the SP3-era hardcoded threshold config params become dead. |
| L40S validation smoke (Task A17) | runs concurrently with this audit | Pending; this audit's "acceptance gate" row reflects local CPU cargo check + cargo test --lib only. |
| 5 pearl memory entries (Task C5) | C | New durable feedback pearls extracted from Layer A: cross-boundary Pearls A+D wiring pattern (mirrors A14/A15 Pearl C); 207-float Wiener-state lockstep reset contract; mapped-pinned table-packing for variable-length sub-buffer kernels (A7 fix-up #2 oracle); Curiosity 4-sub-launch engage-buf-extension pattern; degenerate-zero short-circuit per-slot pattern. |
Acceptance gate (verified at audit time, HEAD 4c231fa81)
cargo check -p ml --offlineclean — 12 pre-existing warnings, 0 new (per A13 code-quality refactor commit4c231fa81final acceptance).- 13 SP4 GPU-gated tests pass on local RTX 3050 Ti —
rel_err < 0.6%across all 13 (per A5..A13.5 commit reports). Tests:sp4_target_q_p99_*,sp4_atom_pos_p99_*,sp4_param_group_oracle_*,sp4_grad_norm_p99_*,sp4_h_s2_p99_*,sp4_h_s2_rms_ema_*,sp4_aux_heads_loss_ema_*,sp4_aux_label_scale_ema_*,sp4_moe_expert_util_ema_*,sp4_vsn_mask_ema_*,sp4_iqn_quantile_ema_*,sp4_reward_component_ema_*,sp4_histogram_p99_matches_known_distribution_within_quantization. - 7
sp4_wiener_emalib tests pass (pearl_a_first_observation_replaces_sentinel,pearl_d_stationary_signal_alpha_approaches_zero,pearl_d_step_change_tracks_within_meta_window,pearl_d_does_not_subsume_pearl_a_at_t0,meta_constants_are_structural,apply_pearls_to_slot_pearl_a_bootstrap_path,pearl_a_only_fires_when_both_x_mean_and_x_lag_are_zero). - 3
state_reset_registrytests pass (registry_classifies_known_state,registry_fold_reset_iterates_only_fold_reset_entries,registry_unknown_name_returns_none). - 2
sp4_isv_slotstests pass (slot_layout_is_contiguous_and_total_40,pearl_c_engage_buf_layout). - 8 commits ahead of pre-A13 head landed cleanly: range
aada419de..4c231fa81(= A13.0 → A13.1 → A13.2 → A13.3 → A13.4 → A13.5 → spec gap-fix → code-quality refactor; 8 commits inclusive of starting commit). - Pearl C wiring landed earlier in
ea31ebfe3(A14+A15) with state-reset registry entries already present fromce11d56ed(A12). - L40S smoke (A17): pending — will be filled in after A17 reports.
Layer A files-touched manifest
A1 (slot constants) — commit c724fa99b + 206ebd355:
crates/ml/src/cuda_pipeline/sp4_isv_slots.rs(new)crates/ml/src/cuda_pipeline/mod.rs(re-exports)
A2 (mapped-pinned buffers) — commit a2c14cb06:
crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(3 buffer fields + alloc)crates/ml/src/cuda_pipeline/mapped_pinned.rs(MappedI32Bufferif absent)
A3 (Pearls A+D helper) — commit 9ece1a4da:
crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs(new)crates/ml/src/cuda_pipeline/mod.rs(re-export)
A4 (histogram device fn + GPU unit test) — commit 7540df1ec:
crates/ml/src/cuda_pipeline/sp4_histogram_p99.cuh(new)crates/ml/src/cuda_pipeline/sp4_histogram_p99_test_kernel.cu(new)crates/ml/tests/sp4_producer_unit_tests.rs(new)
A5 (target_q_p99) — commit cd037084b:
crates/ml/src/cuda_pipeline/target_q_p99_kernel.cu(new)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(launch_sp4_target_q_p99+ cubin loader)crates/ml/src/build.rs(kernel registration)
A6 (atom_pos_p99 × 4) — commit b4c28811a:
crates/ml/src/cuda_pipeline/atom_pos_p99_kernel.cu(new)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(launch_sp4_atom_pos_p99_all_branches)
A7 + A7 fix-up + fix-up #2 (param_group_oracle × 8) — commits 4f13e2ca3 + 64298b34c + 8f93c1152:
crates/ml/src/cuda_pipeline/param_group_oracle_kernel.cu(new)crates/ml/src/cuda_pipeline/sp4_histogram_p99.cuh(_multitemplate added)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Sp4ParamGroupBufs,SP4AuxBuffers, launcher + sub-buffer table buffers)crates/ml/src/cuda_pipeline/gpu_iqn_head.rs(accessors)crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs(accessors)crates/ml/src/cuda_pipeline/gpu_attention.rs(accessors)crates/ml/src/cuda_pipeline/gpu_weights.rs(Curiosity accessors)crates/ml/src/cuda_pipeline/gpu_curiosity_trainer.rs(Curiosity grad + Adam accessors)crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(curiosity_trainer()accessor)crates/ml/src/cuda_pipeline/mapped_pinned.rs(MappedU64BufferDebug)crates/ml/src/trainers/dqn/fused_training.rs(build_sp4_aux_buffers)
A8 (grad_norm_p99) — commit 611d2663c:
crates/ml/src/cuda_pipeline/grad_norm_p99_kernel.cu(new)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(launch_sp4_grad_norm_p99)
A9 (h_s2_p99) — commit a72468666:
crates/ml/src/cuda_pipeline/h_s2_p99_kernel.cu(new)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(launch_sp4_h_s2_p99)
A10+A11 (wire 5 SP4 producer launches) — commit 392fc7d69:
crates/ml/src/trainers/dqn/trainer/training_loop.rs(5 launches in cold-path block)crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(curiosity_trainerfield threading)
A12 (StateResetRegistry entries) — commit ce11d56ed:
crates/ml/src/trainers/dqn/state_reset_registry.rs(11 new entries)crates/ml/src/trainers/dqn/trainer/training_loop.rs(11 dispatch arms)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(reset_sp4_wiener_state,reset_sp4_clamp_engage_countershelpers)
A13.0 (h_s2_rms_ema retrofit) — commit aada419de:
crates/ml/src/cuda_pipeline/h_s2_rms_ema_kernel.cu(signature change)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Pearls A+D in launcher + buffer growth 141→207, 47→69)
A13.1 (aux_heads_loss + aux_label_scale) — commit 74ed2f500:
crates/ml/src/cuda_pipeline/aux_heads_loss_ema_kernel.cu(signature change)crates/ml/src/cuda_pipeline/aux_heads_kernel.cu(aux_label_scale_ema_updatesignature change)crates/ml/src/cuda_pipeline/gpu_aux_heads.rs(launch_label_scale_emakernel-only wrapper)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Pearls A+D inlaunch_aux_heads_loss_ema+ inline Step 2b inaux_heads_forward)
A13.2 (moe_expert_util + moe_gate_entropy) — commit 04fcbd27c:
crates/ml/src/cuda_pipeline/moe_kernels.cu(signature change)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Pearls A+D inlaunch_moe_expert_util_ema)
A13.3 (vsn_mask × 6) — commit 0e9d69787:
crates/ml/src/cuda_pipeline/vsn_mask_ema_kernel.cu(signature change)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Pearls A+D inlaunch_vsn_mask_ema)
A13.4 (iqn_quantile × 4) — commit 5f800fe5c:
crates/ml/src/cuda_pipeline/iqn_quantile_ema_kernel.cu(signature change)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Pearls A+D inlaunch_iqn_quantile_ema)crates/ml/src/trainers/dqn/fused_training.rs(proxy)
A13.5 (reward_component × 6 + cross-boundary wiring + orphan deletion) — commit 4f82b74a5:
crates/ml/src/cuda_pipeline/reward_component_ema_kernel.cu(signature change)crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(Pearls A+D inlaunch_reward_component_ema_inplace+ 4 new fields + setter)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(4 new accessors + orphan launcher deletion)crates/ml/src/trainers/dqn/fused_training.rs(wire_reward_component_pearls_buffershelper)crates/ml/src/trainers/dqn/trainer/training_loop.rs(wire call site)
A13 spec gap-fix (5 retrofit dispatch arms → Pearl A sentinel 0.0) — commit c5add566d:
crates/ml/src/trainers/dqn/state_reset_registry.rs(5 description updates)crates/ml/src/trainers/dqn/trainer/training_loop.rs(5 dispatch arms write 0.0)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(stale 141/2048/47 comments → 207/2816/69)
A13 code-quality refactor — commit 4c231fa81:
crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs(apply_pearls_to_slothelper + new test)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(SP4_PRODUCER_COUNTmodule-level const + 11 launcher refactors +_ema_alpha_unusedremoval +REWARD_COMPONENT_COUNTnamed constant)crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(launch_reward_component_ema_inplacerefactor)crates/ml/src/cuda_pipeline/gpu_aux_heads.rs(stale doc fix)crates/ml/src/cuda_pipeline/mod.rs(re-export)crates/ml/src/trainers/dqn/fused_training.rs(launch_iqn_quantile_emaproxy refactor)crates/ml/src/trainers/dqn/trainer/training_loop.rs(caller updates)crates/ml/tests/sp4_producer_unit_tests.rs(13 redeclarations → 1 import)
A14+A15 (Pearl C engagement counter) — commit ea31ebfe3 (landed before A13):
crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu(dqn_adam_update_kernelextension)crates/ml/src/cuda_pipeline/iqn_dual_head_kernel.cu(iqn_adam_kernelextension)crates/ml/src/cuda_pipeline/iql_value_kernel.cu(iql_adam_kernelextension)crates/ml/src/cuda_pipeline/attention_backward_kernel.cu(attn_adam_kernelextension)crates/ml/src/cuda_pipeline/curiosity_training_kernel.cu(curiosity_adam_stepextension + 4 sub-launch offsets)crates/ml/src/cuda_pipeline/sp4_isv_slots.rs(SP4_ENGAGE_BUF_LEN, sub-launch offsets, diag-slot consts)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(pearl_c_post_adam_engagement_check, accessors, host-side EMA fields,nan_flags_buf50→55)crates/ml/src/cuda_pipeline/gpu_iqn_head.rs(set_pearl_c_bufferssetter + Adam launch wiring)crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs(set_pearl_c_bufferssetter + Adam launch wiring)crates/ml/src/cuda_pipeline/gpu_attention.rs(set_pearl_c_bufferssetter + Adam launch wiring)crates/ml/src/cuda_pipeline/gpu_curiosity_trainer.rs(set_pearl_c_bufferssetter + 4 sub-launch wiring)crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(set_curiosity_pearl_c_buffersforwarder)crates/ml/src/trainers/dqn/fused_training.rs(wire_aux_trainer_pearl_c_buffers)crates/ml/src/trainers/dqn/trainer/training_loop.rs(wire call sites + post-Adam engagement-check call sites)crates/ml/src/trainers/dqn/state_reset_registry.rs(sp4_pearl_c_rate_deficit_state+sp4_pearl_c_rate_deficit_emaentries)
A18 (this audit) — single commit on top of 4c231fa81:
docs/dqn-wire-up-audit.md(this section appended).
GPU-only Pearls A+D refactor — single commit on top of 0a1fae77d:
- Host-side
apply_pearls_to_slothelper deleted; all 11 SP4-Pearls launchers + 1 inlineaux_heads_forwardStep 2b call site migrated to the new GPUapply_pearls_ad_kernel(single-thread device-side loop, same-stream chained with the producer kernel that wroteproducer_step_scratch_buf). Triggered by L40S smokesmoke-test-v9kjvgraph-capture failure (CUDA_ERROR_STREAM_CAPTURE_UNSUPPORTEDataux_label_scale_emamid-stepstream.synchronize()); root cause was the host-side compute path itself, not the smoke run. Perfeedback_no_cpu_compute_strict.md/pearl_cold_path_no_exception_to_gpu_drives.md: any compute belongs on GPU regardless of frequency. crates/ml/src/cuda_pipeline/apply_pearls_kernel.cu(new — single-thread device-side loop applying Pearls A+D to N consecutive ISV slots).crates/ml/build.rs(compile new cubin).crates/ml/src/cuda_pipeline/sp4_wiener_ema.rs(launch_apply_pearlsGPU launcher,apply_pearls_to_slothost helper deleted,pearls_ad_updatekept as test-only reference + Pearl C diagnostic consumer).crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(apply_pearls_ad_kernelfield + cubin static + load + 5 SP4 launchers + 5 A13 retrofit launchers- 1 inline
aux_heads_forwardStep 2b migrated;wiener_state_buf_dev_ptraccessor added).
- 1 inline
crates/ml/src/cuda_pipeline/gpu_experience_collector.rs(collector loads its own kernel handle;launch_reward_component_ema_inplacemigrated;set_reward_component_pearls_bufferssignature extended with wiener + isv dev pointers).crates/ml/src/trainers/dqn/fused_training.rs(wire_reward_component_pearls_buffersthreads dev pointers).crates/ml/src/cuda_pipeline/mod.rs(re-export dropsapply_pearls_to_slot).crates/ml/src/cuda_pipeline/{atom_pos_p99,aux_heads_loss_ema,grad_norm_p99, h_s2_p99,h_s2_rms_ema,iqn_quantile_ema,moe_kernels,reward_component_ema, target_q_p99,vsn_mask_ema}_kernel.cu(doc-comment updates — host-side Pearls A+D references replaced with GPUapply_pearls_ad_kernel).crates/ml/src/cuda_pipeline/gpu_aux_heads.rs(doc-comment update onlaunch_label_scale_emamid-step semantics).crates/ml/tests/sp4_producer_unit_tests.rs(new GPU oracle test asserting kernel output matches host-referencepearls_ad_updatewithin fp32 ULP across 5 representative trajectories + 1 multi-slot batch). 13 existing tests + 1 new test pass on RTX 3050 Ti.
Layer B atomic consumer migration (2026-05-01)
Layer B atomic consumer migration — all SP3 hardcoded multipliers
replaced by ISV reads, DQN main Adam per-group split, Pearl C engagement
deferral resolved. Single coordinated commit per
feedback_no_partial_refactor. The SP3-era multiplier-driven bounds
(10 × Q_ABS_REF, 100 × Q_ABS_REF, 1e3 × Q_ABS_REF,
1e6 × Q_ABS_REF², 100 × H_S2_RMS_EMA) and .max(1.0) ε floors are
replaced by per-slot ISV reads (ISV[TARGET_Q_BOUND],
ISV[ATOM_POS_BOUND[branch]], ISV[WEIGHT_BOUND[group]],
ISV[ADAM_M_BOUND[group]], ISV[ADAM_V_BOUND[group]],
ISV[GRAD_CLIP_BOUND], ISV[H_S2_BOUND],
ISV[L1_LAMBDA_TRUNK_INDEX], ISV[WD_RATE[group]]) with
consumer-side EPS_CLAMP_FLOOR = 1.0 ε for cold-start.
DQN main Adam launch split into 3 sub-launches (DqnTrunk / DqnValue /
DqnBranches) per feedback_no_quickfixes — overrides the plan's
"max/min-of-3 single-launch shortcut" recommendation. Each sub-launch
reads its own WEIGHT_BOUND[group], WD_RATE[group], and (trunk only)
L1_LAMBDA_TRUNK_INDEX. Per-group split also resolves the Pearl C
engagement-counter accounting deferral from A14/A15 (groups 1+2 were
silently rolled into group 0's offset; now each writes per-block
counts at its own group_idx × MAX_BLOCKS_PER_ADAM offset, and
pearl_c_post_adam_engagement_check is invoked per group from
fused_training.rs).
Mech 5 fused diagnostic kernel (dqn_nan_check_fused_f32_kernel)
takes isv_signals device pointer instead of two host-side *_eff
floats — per-slot ISV reads inside the kernel eliminate the per-step
HtoD copy and remove q_abs_ref_eff / h_s2_rms_ema_eff kernel args
entirely. Mapped-pinned ISV pointer is graph-capture-safe.
weight_decay field removed from:
DQNHyperparameters(crates/ml/src/trainers/dqn/config.rs)GpuDqnTrainConfig(crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs)GpuIqnConfig(crates/ml/src/cuda_pipeline/gpu_iqn_head.rs)GpuIqlConfig(crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs)TrialOverrides/ search-space (crates/ml/src/training_profile.rs)DQNHyperparameters::apply_family_scaling(weight_decay *= liline — no static field to scale anymore)
DT (decision_transformer.rs) and aux trainers (ofi_embed, denoise,
sel/recursive_conf in gpu_dqn_trainer.rs) keep weight_clamp_max_abs = 0.0 disable — they're outside the SP4 8-group taxonomy and have no
ISV producer. Mirrors DT's existing pattern.
Files-touched manifest:
crates/ml/src/cuda_pipeline/atoms_update_kernel.cu(Mech 2 per-branch ATOM_POS_BOUND read)crates/ml/src/cuda_pipeline/iql_value_kernel.cu(Mech 2 per-branch ATOM_POS_BOUND read)crates/ml/src/cuda_pipeline/experience_kernels.cu(Mech 2 per-branch ATOM_POS_BOUND read for the legacy single-branch entry point)crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu(Mech 5 fused diagnostic per-slot ISV reads + dropq_abs_ref_eff/h_s2_rms_ema_effargs; doc updates)crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs(Mech 1 + 6 + 9 + 10 consumer migrations;launch_adam_update3-way split; aux trainers disable clamp;weight_decayfield removed; nan_check_fused launcher dropsq_abs_ref_eff/h_s2_rms_ema_eff;dqn_group_param_countsaccessor for fused_training Pearl C invocation)crates/ml/src/cuda_pipeline/gpu_iqn_head.rs(weight_decayfield removed;execute_training_pipeline+train_iqn_step_gpuacceptweight_decay_value: f32)crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs(weight_decayfield removed;train_value_stepacceptsweight_decay_value: f32)crates/ml/src/cuda_pipeline/gpu_attention.rs(adam_stepacceptsweight_decay_value: f32)crates/ml/src/cuda_pipeline/gpu_tlob.rs(adam_stepacceptsweight_decay_value: f32; co-lives in Attn group taxonomy)crates/ml/src/trainers/dqn/fused_training.rs(per-launch ISV reads for IQN/IQL/Attn/TLOB clamp + weight_decay;weight_decayremoved fromGpuDqnTrainConfigbuilder; per-grouppearl_c_post_adam_engagement_checkinvocation for DqnTrunk/DqnValue/DqnBranches)crates/ml/src/trainers/dqn/trainer/training_loop.rs(Curiosity weight_clamp readsISV[WEIGHT_BOUND[Curiosity]])crates/ml/src/trainers/dqn/trainer/constructor.rs(weight_decay: hyperparams.weight_decayline removed)crates/ml/src/trainers/dqn/config.rs(field + default + family-scaling removed)crates/ml/src/trainers/dqn/smoke_tests/generalization.rs(params.weight_decay = 1e-4line removed)crates/ml/src/training_profile.rs(TrialOverrides + search-space + apply_to weight_decay branch removed)crates/ml/examples/train_baseline_rl.rs(weight_decay:initialiser line removed)
Verification (local, RTX 3050 Ti, post-commit):
SQLX_OFFLINE=true cargo check -p ml --offline: clean, 11 warnings (all pre-existing).git grep -nE "10\.0_f32 \* q_abs_ref|10\.0f \* q_abs_ref|100\.0_f32 \* q_abs_ref|100\.0f \* q_abs_ref|1e6_f32 \* q_abs_ref|1e3_f32 \* q_abs_ref|100\.0_f32 \* h_s2|100\.0f \* h_s2_rms" crates/ml/src/: ZERO matches.git grep -nE "weight_decay:\s*f64|l1_lambda:" crates/ml/src/trainers/dqn/: ZERO matches.git grep -n "self.hyperparams.weight_decay|self.config.weight_decay| self.hyperparams.l1_lambda" crates/ml/src/: only TFT remains (separate trainer outside SP4 scope).git grep -n "q_abs_ref_eff|h_s2_rms_ema_eff" crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu: ZERO matches.cargo test -p ml --lib --offline: 928 passed, 14 failed (all 14 pre-existing on HEAD1389d1c81; no new failures introduced).
Layer B fix-up (2026-05-01): code-quality reviewer surfaced a silent
correctness regression in the original 3-way launch_adam_update split —
tensors [33..163) (bottleneck, VSN bottleneck, GLU gates, KAN, regime
gate, spacing, multi-horizon value heads, risk, ISV encoder, VSN per-group,
aux heads, MoE — ~70% of the DQN's 163 weight tensors) were silently no
longer Adam-updated (grads computed, m/v allocated, no parameter step).
The 3-group SP4 taxonomy (DqnTrunk / DqnValue / DqnBranches via
param_group_buffers) only maps tensors [0..33). Resolution:
- 4th sub-launch added — trunk-extras tail covering
[33..163), taggedParamGroup::DqnTrunkfor sharedWEIGHT_BOUND/WD_RATEbounds, withSP4_ENGAGE_OFFSET_DISABLEDso Pearl C engagement rolls into trunk's accounting (same bound drives both clamps). MAX_BLOCKS_PER_ADAMgrown 256 → 4096 to fit trunk-extras (~2400 blocks at production cfgshared_h2=256,MOE_NUM_EXPERTS=8).SP4_ENGAGE_BUF_LENauto-grows: 11 × 4096 = 45056 ints (≈180 KiB total; engage offsets are derived fromMAX_BLOCKS_PER_ADAMso they auto-track).read_group_adam_bounds(group) -> (clamp, wd)helper introduced onGpuDqnTrainer; 6 verbose 2-line ISV-read patterns infused_training.rscollapsed to one-liners (TLOB + IQL high + IQL low + IQN parallel + Attn parallel + Attn sequential + IQN sequential = 7 call sites). The 4-way Adam loop inlaunch_adam_updatealso calls the helper.- Coverage
debug_assert_eq!added:trunk + value + branches + trunk_extras == total_params. Prevents future regressions of this kind. - 9 stale doc-comments referring to "100 × Q_ABS_REF.max(1.0)"
replaced by current "ISV[WEIGHT_BOUND[group]].max(EPS_CLAMP_FLOOR)"
contract:
curiosity_training_kernel.cu:324gpu_curiosity_trainer.rs:509,758iqn_dual_head_kernel.cu:847,879,923iql_value_kernel.cu:211attention_backward_kernel.cu:80dqn_utility_kernels.cu:145,200gpu_dqn_trainer.rs:22349(historical SP3 reference)
Per feedback_no_partial_refactor: trunk-extras consumers (= no
dedicated SP4 producer yet) and bound (= reused trunk's
WEIGHT_BOUND/WD_RATE) ship together with the launch fix in this
commit. A future task may split sub-trunk regions (VSN, MoE, etc.)
into dedicated SP4 producers once warranted by signal analysis.
Verification (post-fix-up):
cargo check -p ml --offline: clean, same 11 pre-existing warnings.cargo test -p ml --lib --offline -- sp4_wiener_ema sp4_isv_slots state_reset_registry: 11 passed (incl. updatedpearl_c_engage_buf_layoutfor MAX_BLOCKS_PER_ADAM=4096).cargo test -p ml --lib --offline: 928 passed, 14 failed (no new failures vs. Layer B initial run).git grep -nE "100 × Q_ABS_REF\\.max\\(1\\.0\\)|100\\.0f \\* q_abs_ref|100 \\* Q_ABS_REF" crates/ml/src/cuda_pipeline/ crates/ml/src/trainers/: ZERO matches.
Layer C #260 — SP1-era 1e6 multiplier elimination (cuBLAS backward sanitisers, 2026-05-01)
Two SP1-Phase-C cuBLAS backward sanitisers at
gpu_dqn_trainer.rs:7615 (bw_d_h_s2 input) and :20679
(d_value_logits + d_adv_logits) used hardcoded
1e6 × signal.max(1.0) formulas — outside SP4 mechanisms 1/2/5/6/9/10
but flagged by feedback_isv_for_adaptive_bounds review as the last
remaining hardcoded multipliers in the cuBLAS-backward sanitiser
consumer path after Layer A/B closed out the rest of SP4.
Migrated to the proper SP4 pattern:
- 2 new SP4 ISV slots [171, 172) extending [131..171) → [131..173)
BW_D_H_S2_BOUND_INDEX = 171(single-buffer histogram p99)Q_DIR_GRAD_BOUND_INDEX = 172(multi-sub-buffer histogram p99, n_sub=2 overd_value_logits∪d_adv_logits)
- 2 new producer kernels:
bw_d_h_s2_p99_kernel.cu(single buffer; mirrorsh_s2_p99_kernel.cu)q_dir_grad_p99_kernel.cu(multi-sub-buffer; mirrors theparam_group_oracle_kernel.cun_sub launch convention via the sharedsp4_histogram_p99_multi<256>template + reusesoracle_subbuf_table_buf/oracle_subbuf_counts_buf)
- Pearls A+D wired via existing
apply_pearls_ad_kernel(chained on same stream as producer; no host sync; graph-capture-compatible) - Consumer migration:
apply_iqn_trunk_gradient(line ~7615): replaces1e6 × ISV[H_S2_RMS_EMA].max(1.0)withISV[BW_D_H_S2_BOUND_INDEX].max(EPS_CLAMP_FLOOR)launch_cublas_backward_to(line ~20679): replaces1e6 × ISV[Q_DIR_ABS_REF].max(1.0)withISV[Q_DIR_GRAD_BOUND_INDEX].max(EPS_CLAMP_FLOOR)
- Buffer growth (single source of truth in
gpu_dqn_trainer.rs):SP4_PRODUCER_COUNT: 69 → 71SP4_WIENER_TOTAL_FLOATS: 207 → 213 (= 71 × 3)producer_step_scratch_buf: 69 → 71 floatswiener_state_buf: 207 → 213 floats
- StateResetRegistry: 2 new
FoldResetentries (sp4_bw_d_h_s2_bound,sp4_q_dir_grad_bound); bulksp4_wiener_statereset now spans 213 floats; existingreset_named_statedispatch arms intraining_loop.rsextended with 2 matching arms that write 0.0 to ISV[171] / ISV[172] at fold boundary — both halves of Pearl A's sentinel contract reset together perfeedback_no_partial_refactor. - LAYOUT_FINGERPRINT_SEED bumped (added
BW_D_H_S2_BOUND=171;+Q_DIR_GRAD_BOUND=172;+ISV_TOTAL_DIM=173); existing checkpoints will fail-fast at load (intentional — re-train). - Unit tests: 2 new GPU-gated tests in
crates/ml/tests/sp4_producer_unit_tests.rs:sp4_bw_d_h_s2_p99_writes_step_obs_via_pearl_a_then_converges_pearl_d— stationary 5.0 signal, 1000-iter Pearl D convergence (1% rel-err)sp4_q_dir_grad_p99_multi_sub_buffer_oracle— 2-sub-buffer union of |N(0,1)| samples, p99 vs analytical (5% rel-err).
Behaviour change: bound is now Pearls-smoothed p99 of the actual gradient distribution, tighter than the pre-existing 1e6 ceiling but appropriate-scale for the observed values. Smoke validation must verify F0/F1/F2 still converge.
Verification (Layer C #260):
cargo check -p ml --offline: clean, same 11 pre-existing warnings.cargo test -p ml --lib --offline -- sp4_wiener_ema sp4_isv_slots state_reset_registry: 11 passed (incl. renamedslot_layout_is_contiguous_and_total_42).cargo test -p ml --lib --offline: 928 passed, 14 failed (no new failures vs. Layer B fix-up baseline).cargo test -p ml --test sp4_producer_unit_tests --release -- --ignored --nocapture --test-threads=1: 16 GPU tests passed (14 pre-existing + 2 new) on local RTX 3050 Ti.git grep -nE "1e6_?f32 \\* h_s2_rms_ema|1e6_?f32 \\* q_dir_abs_ref| 1e6_?f32 \\*.*\\.max\\(1\\.0_?f32\\)" crates/ml/src/cuda_pipeline/: ZERO matches.
Files touched (Layer C #260):
crates/ml/src/cuda_pipeline/sp4_isv_slots.rs— 2 new slot constants, test rename + asserts updated.crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs—SP4_PRODUCER_COUNTbumped,ISV_TOTAL_DIMbumped,LAYOUT_FINGERPRINT_SEEDextended, 2 new cubin statics + 2 new struct fields + 2 new constructor loaders + 2 new launchers (launch_sp4_bw_d_h_s2_p99,launch_sp4_q_dir_grad_p99), 2 consumer sites migrated.crates/ml/src/cuda_pipeline/bw_d_h_s2_p99_kernel.cu— new kernel.crates/ml/src/cuda_pipeline/q_dir_grad_p99_kernel.cu— new kernel.crates/ml/build.rs— 2 new kernel registrations.crates/ml/src/trainers/dqn/state_reset_registry.rs— 2 new entries + wiener_state count update.crates/ml/src/trainers/dqn/trainer/training_loop.rs— 2 newreset_named_statedispatch arms + count update.crates/ml/tests/sp4_producer_unit_tests.rs— 2 new GPU-gated tests.docs/dqn-wire-up-audit.md— this entry.
Refs: task #260, baseline commit 1c150d190 (Layer A + B + fix-up
- L40S smoke pass).
Layer C Task C1 redesigned — fast/slow grad-norm EMA UPDATE migrated to GPU (2026-05-01)
Original Layer C plan (2026-04-30) called for retiring grad_norm_slow_ema_pinned as orphan post-Mech-6 ISV migration. Verification on 2026-05-01 surfaced a SECOND live consumer: fold_warmup_factor_update (commit 4ef1d8ebb, predates SP4) reads the slow EMA as the cross-fold steady-state grad-norm baseline that the warmup factor's denominator compares against. Legitimate cross-fold mechanism; NOT orphan.
grad_norm_slow_ema_pinned is retained — its removal would break Plan C K's fold-warmup factor controller (the actual feedback_no_partial_refactor violation here would be deleting a buffer with a live consumer chain).
The actual defect surfaced by the audit: the EMA UPDATE at update_adaptive_clip:22720-22737 was running as host-side (1-α) × prev + α × obs arithmetic — exactly the pattern feedback_no_cpu_compute_strict (saved 2026-05-01) strictly forbids:
"any compute (EMA, reduction, mean, Pearls A+D, adaptive α, ISV update, etc.) belongs on GPU regardless of frequency, regardless of whether it's hot-path or cold-path."
Migrated:
- New
update_grad_norm_emas_kernel.cu— single-thread fused fast+slow EMA update kernel. Readsgrad_norm_buf[0](the device-visible scalar populated earlier in the same stream bylaunch_grad_norm_finalize), updatesgrad_norm_fast_ema_pinned+grad_norm_slow_ema_pinnedin-place via their mapped-pinned dev_ptrs.__threadfence_system()ensures the writes are PCIe-visible to mapped-pinned host_ptr accesses (grad_norm_fast_ema_value/grad_norm_slow_ema_valueHEALTH_DIAG accessors) and to the downstreamfold_warmup_factor_kernelconsumer reading via dev_ptr. launch_update_grad_norm_emasRust launcher chained on the producer's stream — graph-capture-compatible, no host sync (sequential same-stream ordering provides the producer→consumer happens-before).update_adaptive_clip's host-sideunsafe { ... }arithmetic block replaced with the launcher call (warn-and-continue on launch failure mirroring thelaunch_h_s2_rms_ema/launch_fold_warmup_factorper-step ISV producer pattern attraining_loop.rs:3450/3464).
Preserved (no behaviour change):
grad_norm_slow_ema_pinnedmapped-pinned buffer and its cross-fold persistence (legitimate consumer isfold_warmup_factor_update).- Fixed-α design:
FAST_ALPHA=0.1(~10-step horizon),SLOW_ALPHA=0.001(~1000-step horizon). Pearls A+D adaptive α would defeat the cross-fold-baseline semantic the warmup factor depends on. - Cold-start sentinel (
prev ≤ 0.0⇒ assign obs directly) — same formula as the deleted host code. - Host-side
update_adaptive_clipearly-return guard (!observed_grad_norm.is_finite() || observed_grad_norm <= 0.0⇒ skip the EMA update) — kernel only launches when the value is valid. grad_norm_emas_step_counthost counter — scalar control-flow metadata forfold_warmup_factor_kernel's warmup-window gating, NOT compute (per the just-savedfeedback_no_cpu_compute_strict, integer step counters for control-flow gates are scalar metadata, not the EMA/reduction/mean class of computation the rule targets).- Fold-boundary reset of
grad_norm_fast_ema_pinnedto 0.0 (state-reset registry) — one-time-per-fold reset, not per-step compute, and IS the cold-start sentinel signal the GPU kernel relies on.
Verification:
cargo check -p ml --offline: clean, same pre-existing warnings.git grep -nE "(1\.0 - FAST_ALPHA)\|(1\.0 - SLOW_ALPHA)\|FAST_ALPHA \* observed_grad_norm\|SLOW_ALPHA \* observed_grad_norm" crates/ml/src/: ZERO matches in code (host-side formula gone).git grep -nE "\*self\.grad_norm_fast_ema_pinned\|\*self\.grad_norm_slow_ema_pinned" crates/ml/src/: only fold-boundary reset (one-time) + read-only HEALTH_DIAG accessors remain — NO per-step host writes.
Files touched (Layer C C1 redesigned):
crates/ml/src/cuda_pipeline/update_grad_norm_emas_kernel.cu— new kernel.crates/ml/build.rs— 1 new kernel registration.crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs— 1 new CUBIN static + 1 new struct field + 1 new constructor loader + 1 new launcher (launch_update_grad_norm_emas); host-side EMA arithmetic block replaced with launcher call; field doc-comments updated to reflect GPU-only update path.crates/ml/src/cuda_pipeline/fold_warmup_factor_kernel.cu— docstring updated (EMAs are now GPU-updated, not host-side).crates/ml/src/trainers/dqn/state_reset_registry.rs—isv_grad_norm_fast_emadescription updated to reflect GPU-only update path.docs/superpowers/plans/2026-04-30-sp4-signal-driven-magnitude-control.md— Layer B "becomes orphan" claim corrected; Task C1 redesigned section.docs/superpowers/specs/2026-04-30-sp4-signal-driven-magnitude-control-design.md— Layer C "retire" line replaced with "retain + migrate UPDATE to GPU" rationale.docs/dqn-wire-up-audit.md— this entry.
No behavior change — pure architectural fix. The L40S smoke smoke-test-tkkx6 (against 45da3eac6) remains valid as the baseline; this commit doesn't need a new smoke since the only change is "where is the arithmetic computed", not "what is computed". Refs: SP4 Layer C C1 redesigned (was: retire). Original plan line ~2049 + Task C1 ~lines 2184-2221.
follow-up: symbolic doc-anchors + dedicated q_dir_grad sub-buffer table + p99 helper extraction
Layer C Task C2 — IQN readiness gauge update migrated to GPU (2026-05-01)
C1 redesigned closed out one site of the codebase-wide feedback_no_cpu_compute_strict sweep. C2 closes the next site: GpuDqnTrainer::update_iqn_readiness (gpu_dqn_trainer.rs ~line 6766) was running the cold-start sentinel + adaptive-α EMA + improvement-gauge formula host-side:
if self.iqn_loss_initial < 1e-12 { /* bootstrap */ }
else {
let err = (iqn_loss - self.iqn_loss_ema).abs();
let alpha = (err / (err + 0.01)).clamp(0.01, 0.3);
self.iqn_loss_ema = (1.0 - alpha) * self.iqn_loss_ema + alpha * iqn_loss;
let improvement = (self.iqn_loss_initial - self.iqn_loss_ema) / self.iqn_loss_initial.max(1e-6);
self.iqn_readiness = improvement.clamp(0.0, 1.0);
}
Three coupled scalars on GpuDqnTrainer: iqn_loss_initial (fold-anchor), iqn_loss_ema (streaming smoothed loss), iqn_readiness (gauge ∈ [0,1] consumed by c51_loss_kernel as CVaR α via iqn_readiness_dev_ptr). Producer cadence: per training step from fused_training.rs:1769 after gpu_iqn.read_total_loss() provides the loss via mapped-pinned readback.
Migrated:
- New
update_iqn_readiness_kernel.cu— single-thread, single-block. Takes the host-passediqn_lossscalar (already a mapped-pinned readback from the IQN'stotal_loss_pinned) and updates three coupled mapped-pinned scalars (iqn_loss_initial_pinned,iqn_loss_ema_pinned,iqn_readiness_pinned) in-place via their dev_ptrs. Mirrorsupdate_grad_norm_emas_kernelshape (cold-path scalar arithmetic on mapped-pinned slots). - Storage migration:
iqn_loss_initialandiqn_loss_emamigrated from host-residentf32fields to mapped-pinned device-mapped scalars. Constructor allocates two newcuMemHostAlloc(DEVICEMAP)slots; Drop frees them. The host shadowiqn_readiness: f32is kept as a cached value refreshed from the pinned slot after each kernel launch (preserves theiqn_readiness()accessor signature for non-host-pinned callers). - New accessors
iqn_loss_ema_value()andiqn_loss_initial_value()read through the host_ptrs —__threadfence_system()in the kernel guarantees PCIe-visibility before the host_ptr deref returns. launch_update_iqn_readinessRust launcher chained on the trainer's stream — the kernel's three pinned slots are owned by the trainer, no cross-stream synchronization needed.update_iqn_readinesshost-side body replaced with the launcher call + post-launch host shadow refresh (warn-and-continue on launch failure mirroring the C1 pattern).reset_iqn_readiness_statewrites 0.0 through all three pinned slots so the next kernel launch re-enters the bootstrap branch (same fold-boundary semantic as before).
Preserved (no behaviour change):
- Same adaptive-α formula
clamp(|err|/(|err|+0.01), 0.01, 0.30)and improvement gauge(initial - ema) / max(initial, 1e-6)clamped to [0, 1]. - Same cold-start sentinel
prev_initial < 1e-12 ⇒ bootstrap (initial=loss, ema=loss, readiness=0). iqn_readiness_dev_ptrconsumer chain (c51_loss_kernelCVaR α + IQN gradient-weight gating +gpu_experience_collector::set_iqn_readinessquantile-blend selection) — unchanged.
Verification:
cargo check -p ml --offline: clean, same pre-existing warnings.cargo test -p ml --lib --offline -- sp4 state_reset_registry: 11 passed.cargo test -p ml --test sp4_producer_unit_tests --offline --release -- --ignored: 16/16 SP4 GPU tests pass on RTX 3050 Ti.
Files touched (Layer C C2):
crates/ml/src/cuda_pipeline/update_iqn_readiness_kernel.cu— new kernel.crates/ml/build.rs— 1 new kernel registration.crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs— 1 new CUBIN static + 1 new struct field + 2 new mapped-pinned slot pairs + 1 new constructor loader + 2 new pinned allocations + 2 new Drop entries + 1 new launcher (launch_update_iqn_readiness); host-side EMA arithmetic block replaced with launcher call + host shadow refresh; field doc-comments updated;reset_iqn_readiness_staterewires through pinned slots; 2 new accessors.crates/ml/src/trainers/dqn/state_reset_registry.rs— three FoldReset entries updated to reflect new mapped-pinned storage and producer relocation.docs/dqn-wire-up-audit.md— this entry.
Refs: SP4 Layer C close-out C2 — second site of the feedback_no_cpu_compute_strict sweep. Audit grid (8 sites total) + group sequencing in feedback_no_cpu_compute_strict.md continuation.
Layer C Task C3 — atom utilization EMA migrated to GPU (2026-05-01)
Third site of the feedback_no_cpu_compute_strict sweep. GpuDqnTrainer::reduce_current_q_stats (gpu_dqn_trainer.rs ~line 18234) was running an adaptive-α EMA host-side over result.atom_utilization (the GPU-produced host[6] mapped-pinned readback from q_readback_pinned). The EMA's storage was the host-resident utilization_ema: f32 field, and the value was secondarily mirrored into homeostatic_obs_pinned[1] via update_homeostatic_observables (also a host-side write to a mapped-pinned slot).
Migrated:
- New
update_utilization_ema_kernel.cu— single-thread, single-block. Takes the host-passedatom_utilscalar (already a mapped-pinned readback) and updatesutilization_ema_pinned+ writes the SAME value intohomeostatic_obs_pinned[1]in lockstep, eliminating both the EMA host arithmetic and the host-side homeostatic-mirror write perfeedback_no_cpu_compute_strict. - Storage migration:
utilization_emamigrated from host-residentf32field to a mapped-pinned device-mapped scalar (matches the C1/C2 pattern). Constructor allocatescuMemHostAlloc(DEVICEMAP)slot init=1.0 (the cold-start sentinel matching the deleted host code'sif self.utilization_ema > 0.99 { assign obs }branch); Drop frees it. - New
launch_update_utilization_emaRust launcher chained on the trainer's stream — both pinned slots (utilization_ema_pinned,homeostatic_obs_pinned) are owned by the trainer, no cross-stream synchronization needed. reduce_current_q_statshost-side EMA arithmetic block replaced with the launcher call (warn-and-continue on launch failure mirroring the C1/C2 pattern).update_homeostatic_observablesslot [1] write removed (kernel takes over the mirror); doc-comment updated to clarify host code now only writes slot [0] = Q-mean.utilization_ema()accessor reads through the pinned host_ptr (kernel's__threadfence_system()guarantees PCIe-visibility); host-sideadaptive_entropyderivation inlaunch_c51_gradreads via the accessor.
Preserved (no behaviour change):
- Same adaptive-α formula
clamp(|err|/(|err|+0.1), 0.01, 0.30)and EMA recurrence. - Same cold-start sentinel
prev > 0.99 ⇒ assign obs directly. - Existing consumers unchanged:
set_utilization_ema(util)collector callsite uses the accessor;homeostatic_obs_pinned[1]mapped-pinned slot read by the homeostatic regularizer kernel via dev_ptr — same value, same slot.
Verification:
cargo check -p ml --offline: clean, same pre-existing warnings.cargo test -p ml --lib --offline -- sp4 state_reset_registry: 11 passed.cargo test -p ml --test sp4_producer_unit_tests --offline --release -- --ignored: 16/16 SP4 GPU tests pass on RTX 3050 Ti.
Files touched (Layer C C3):
crates/ml/src/cuda_pipeline/update_utilization_ema_kernel.cu— new kernel.crates/ml/build.rs— 1 new kernel registration.crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs— 1 new CUBIN static + 1 new struct field + 1 new mapped-pinned slot pair + 1 new constructor loader + 1 new pinned allocation + 1 new Drop entry + 1 new launcher (launch_update_utilization_ema); host-side EMA arithmetic block replaced with launcher call;update_homeostatic_observablesslot [1] write removed;utilization_ema()accessor migrated to mapped-pinned read;adaptive_entropyhost derivation inlaunch_c51_gradmigrated to the accessor.docs/dqn-wire-up-audit.md— this entry.
Discovered during the C3 sweep (deferred):
compute_adaptive_tau(gpu_dqn_trainer.rs:6814-6816) — host-sideq_div_emaEMA onq_divergence_pinned. Helper has ZERO production callers (grep -rn 'compute_adaptive_tau' crates/ml/src/returns only its own definition + stale worktree copies). Perfeedback_wire_everything_up.mdthe helper is either wired in or deleted; flagged for separate task.calibrate_homeostatic_targets(gpu_dqn_trainer.rs:4607-4615) — per-step host-side EMA loop over 6 mapped-pinned slotshomeostatic_targets_pinned[k] = (1-α) × old + α × obs. Live consumer (homeostatic_regularizer kernel reads via dev_ptr). NEW violation discovered during the audit grid construction; not in the original 9-site list. Flagged for separate Layer C close-out task.gpu_experience_collector::set_utilization_ema(gpu_experience_collector.rs:1886) — was originally listed as a 9th site but is just a setterself.util_ema = util.clamp(0.0, 1.0), NOT EMA arithmetic (caller passes the already-EMA'd value). Misclassification in the original audit grid; no migration needed.
Refs: SP4 Layer C close-out C3 — third site of the feedback_no_cpu_compute_strict sweep.
Layer C Task C4 — winsorized adaptive grad-clip update migrated to GPU (2026-05-01)
Fourth site of the feedback_no_cpu_compute_strict sweep, the most complex by structure: GpuDqnTrainer::update_adaptive_clip was running a 6-step host-side compute chain on GPU-produced inputs:
1. winsor: clamped = min(observed, K × max(prev_clip, 1e-3))
2. cold-start: if ema <= 0.0 { ema = clamped }
3. EMA: ema = β × ema + (1 - β) × clamped
4. scalar reduction: new_clip = max(ema × CLIP_MULTIPLIER, MIN_CLIP)
5. ISV upper bound: new_clip = min(new_clip, max(ISV[GRAD_CLIP_BOUND], EPS_CLAMP_FLOOR))
6. mapped-pinned write: adaptive_clip_pinned[0] = new_clip
All inputs are mapped-pinned scalars (raw_grad_norm from the gpu_training_guard readback, prev_clip from adaptive_clip_pinned, ISV[168] from launch_sp4_grad_norm_p99). Per feedback_no_cpu_compute_strict the entire chain belongs on GPU; per feedback_no_partial_refactor it must migrate coherently rather than splitting EMA-only into a kernel and leaving the surrounding scalar reductions on host.
Migrated:
- New
update_adaptive_clip_kernel.cu— single-thread, single-block. Takes the host-passedobserved_grad_norm(already a mapped-pinned readback) plus 6 fixed structural constants (legacy values preserved perfeedback_no_quickfixes) and the 4 mapped-pinned dev_ptrs + ISV[GRAD_CLIP_BOUND_INDEX]; writes the full output chain (adaptive_clip_pinned[0],grad_norm_ema_pinned[0],outlier_diag_pinned[0]). - Storage migration:
grad_norm_emamigrated from host-residentf32field to mapped-pinned device-mapped scalar. Newoutlier_diag_pinnedmapped-pinned slot for the GRAD_CLIP_OUTLIER warn diagnostic. Constructor allocates 2 newcuMemHostAlloc(DEVICEMAP)slots; Drop frees them. - New
launch_update_adaptive_clipRust launcher chained on the trainer's stream — all four pinned slots are owned by the trainer. update_adaptive_cliphost-side compute chain replaced with the launcher call + post-launch outlier-diag readback for the warn log (mapped-pinned +__threadfence_system()⇒ value visible without explicit host sync).grad_norm_ema_value()accessor migrated to read through the pinned host_ptr.
Preserved (no behaviour change):
- Same fixed-α formula
EMA_BETA=0.95and structural constants (CLIP_MULTIPLIER=2.0,MIN_CLIP=1.0,GRAD_CLIP_OUTLIER_K=100,EPS_CLAMP_FLOORfromsp4_wiener_ema). - Same cold-start sentinel
prev_ema <= 0.0 ⇒ assign clamped directly. - Same winsor formula and ISV upper-bound clamp semantics — Mech 6 (SP3) + Layer B (SP4) bound design unchanged.
- Same outlier-warn log message format; recovered host-side from the
delta = observed - clampedmapped-pinned diagnostic slot the kernel writes. - Host-side early-return guard
!observed_grad_norm.is_finite() || observed_grad_norm <= 0.0— kernel only launches when value is valid (matches the pre-existing pattern thatupdate_grad_norm_emas_kernelfrom C1 redesigned uses). grad_norm_emas_step_counthost counter unchanged (scalar control-flow metadata, not compute, per the rule's explicit carve-out).- Downstream consumer chain unchanged:
adaptive_clip_pinnedstill consumed by Adam clip kernel viaadaptive_clip_dev_ptr; the fast/slow grad-norm EMAs (driving fold-warmup factor) still updated by the C1 redesigned launcher.
Verification:
cargo check -p ml --offline: clean, same pre-existing warnings.cargo test -p ml --lib --offline -- sp4 state_reset_registry: 11 passed.cargo test -p ml --test sp4_producer_unit_tests --offline --release -- --ignored: 16/16 SP4 GPU tests pass on RTX 3050 Ti.
Files touched (Layer C C4):
crates/ml/src/cuda_pipeline/update_adaptive_clip_kernel.cu— new kernel.crates/ml/build.rs— 1 new kernel registration.crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs— 1 new CUBIN static + 1 new struct field + 2 new mapped-pinned slot pairs (grad_norm_ema, outlier_diag) + 1 new constructor loader + 2 new pinned allocations + 2 new Drop entries + 1 new launcher (launch_update_adaptive_clip); host-side compute chain inupdate_adaptive_clipreplaced with launcher call + post-launch warn readback;grad_norm_ema_value()accessor migrated to mapped-pinned read.docs/dqn-wire-up-audit.md— this entry.
Refs: SP4 Layer C close-out C4 — fourth site of the feedback_no_cpu_compute_strict sweep. The most architecturally substantive close-out so far (full 6-step compute chain migration vs. C1/C2/C3 single-EMA migrations).
Layer C — feedback_no_cpu_compute_strict sweep — comprehensive audit (2026-05-01)
Audit grid covering all host-side EMA arithmetic sites identified during the SP4 Layer C close-out sweep. Each site classified per the rule's input-source / consumer-chain criterion: GPU-input + (GPU/host) consumer ⇒ migrate; host-aggregated input ⇒ legitimate exception.
| # | File:line | EMA / compute | Input source | Consumer | Verdict | Commit / Status |
|---|---|---|---|---|---|---|
| 1 | gpu_dqn_trainer.rs:6772-6776 (pre-C2) | iqn_loss_ema + readiness gauge | iqn.read_total_loss() (mapped-pinned readback) |
iqn_readiness_pinned (c51_loss CVaR α + IQN gradient gating via dev_ptr) |
MIGRATE | C2 (605a8f526) |
| 2 | gpu_dqn_trainer.rs:6814-6816 | q_div_ema in compute_adaptive_tau |
q_divergence_pinned (mapped-pinned) |
Helper return value — ZERO production callers | DEFER (orphan; flagged for feedback_wire_everything_up) |
not in sweep |
| 3 | gpu_dqn_trainer.rs:18056-18063 (pre-C3) | utilization_ema (atom util) | host[6] from q_readback_pinned (mapped-pinned readback) |
homeostatic_obs_pinned[1] mirror + accessor for collector's set_utilization_ema + adaptive_entropy derivation |
MIGRATE | C3 (ca6315860) |
| 4 | gpu_dqn_trainer.rs:22669-22674 (pre-C4) | grad_norm_ema (winsorized) full chain | gr.raw_grad_norm (mapped-pinned) + ISV[GRAD_CLIP_BOUND_INDEX] |
adaptive_clip_pinned (Adam clip kernel via dev_ptr) |
MIGRATE (full 6-step chain) | C4 (1112abc2a) |
| 5 | gpu_experience_collector.rs:1886 | set_utilization_ema(util) |
n/a — receives already-EMA'd value from caller | n/a — setter, not arithmetic | NOT A VIOLATION (misclassification) | n/a |
| 6 | metrics.rs:23-25 (HealthEmaTrackers::update) | q_gap_ema, q_var_ema, grad_norm_ema (3×) | (a) per_branch_q_gap_ema synchronous DtoH at epoch boundary, (b) epoch_q_max - epoch_q_min host-aggregated, (c) avg_grad.abs() host-aggregated |
LearningHealth.update (composes 7 normalised components → another EMA → clamp → broadcasts to ISV[LEARNING_HEALTH_INDEX] + host gates) |
DOCUMENTED EXCEPTION (mixed: q_gap is GPU-input but the q_var/grad_norm components are host-aggregated; the LearningHealth composition is multi-step host normalization. Migrating would require porting the whole 7-component normalize+compose pipeline; falls under the brief's "architectural redesign" stop-condition.) | not in sweep |
| 7 | training_loop.rs:4862-4870 | max_dd_ema + low_dd_ratio (adversarial-active hysteresis) | epoch_max_dd from compute_epoch_financials (host log-space cumulative + drawdown-sequence aggregation over downloaded step_returns) |
Host control-flow only (adversarial_active flag drives saboteur updates, etc.); NO GPU consumer |
DOCUMENTED EXCEPTION (host-aggregated PnL chain input + host-only consumer; no GPU computation the kernel could replace) | not in sweep |
| 8 | training_loop.rs:4910-4916 | training_sharpe_ema | epoch_sharpe from compute_epoch_financials host aggregation over downloaded step_returns |
(a) ISV[SHARPE_EMA_INDEX=22] (GPU consumer via write_isv_signal_at), (b) host-side logging |
DOCUMENTED EXCEPTION (host-aggregated PnL input — moving the EMA arithmetic into a kernel that takes host-passed epoch_sharpe is feasible but the input source itself is host aggregation; brief explicit stop-condition example) |
not in sweep |
| 9 | gpu_dqn_trainer.rs:4607-4615 (calibrate_homeostatic_targets) | per-step host EMA loop over 6 mapped-pinned slots targets[k] = (1-α) × old + α × obs |
homeostatic_obs_pinned[k] (mapped-pinned, GPU-readable) + homeostatic_targets_pinned[k] (mapped-pinned) |
homeostatic_targets_pinned[k] (mapped-pinned, consumed by homeostatic_regularizer kernel via dev_ptr) |
NEW DISCOVERY — would migrate cleanly (mirror of update_iqn_readiness pattern, 6-element loop kernel); deferred per scope budget; flagged for separate Layer C task | not in sweep |
| 10 | training_loop.rs:1032 | gamma blend blended = 0.9 × γ + 0.1 × eval_γ |
result.gamma from host-aggregated eval result |
self.hyperparams.gamma clamped + later kernel arg |
DOCUMENTED EXCEPTION (host-aggregated input from eval result; one-shot per epoch boundary; same class as G7/G8) | not in sweep |
Sweep summary
Migrations completed: 3 (C2, C3, C4) plus the pre-existing C1 redesigned baseline (24accea77). New GPU kernels created: 3 (update_iqn_readiness_kernel.cu, update_utilization_ema_kernel.cu, update_adaptive_clip_kernel.cu). All four migrations follow the same pattern:
- Single-thread, single-block kernel mirroring
update_grad_norm_emas_kernelshape. - Mapped-pinned scalar storage for EMA state (replaces host
f32field). __threadfence_system()on writeback for PCIe-visibility to host_ptr accessors and other-stream dev_ptr reads.- Cold-start sentinel preserved bit-for-bit from the deleted host formulas.
- State-reset registry entries updated where applicable.
- No behaviour change — pure architectural fixes.
Documented exceptions: 5 (sites 6, 7, 8, 9, 10 plus the misclassification site 5). Two architectural-stop-condition exceptions (G7/G8 and the freshly-discovered site 10): host-aggregated PnL/eval-result inputs where migrating would require porting the entire host aggregation chain (compute_epoch_financials, eval-result enrichment) to GPU, which the brief explicitly cited as the redesign threshold. One mixed exception (F6): the LearningHealth composition pipeline is multi-step host normalization on partly-host inputs — defer until a coherent port of that whole module.
Deferrals flagged for separate tasks:
- Site 2 (
compute_adaptive_tauq_div_ema): orphan helper — wire it into production OR delete perfeedback_wire_everything_up.md. Out-of-scope for the EMA-migration sweep. - Site 9 (
calibrate_homeostatic_targets): NEW VIOLATION discovered during this sweep — would migrate cleanly as a 6-element-loop kernel mirroring the C1/C2/C3 pattern. Deferred only because it's a separate consumer chain (homeostatic_targets_pinned) and the sweep's existing 4 commits already cover the originally-listed 8-site grid.
Final post-sweep grep git grep -nE "= EMA_BETA \*|= \(1\.0 - EMA_BETA\)|= \(1\.0 - \w*ALPHA\) \* self\.\w*_ema|= \w*_BETA \* self\.\w*_ema" crates/ml/src/cuda_pipeline/ crates/ml/src/trainers/dqn/:
- 1 match remaining (gpu_dqn_trainer.rs:7052 in orphan
compute_adaptive_tau— site 2 above).
Broader-pattern grep also surfaces the Group F + Group G + new sites 9, 10 — all are explicitly documented exceptions / deferrals above.
Sweep verification:
cargo check -p ml --offlineclean across all 4 commits.cargo test -p ml --lib --offline -- sp4 state_reset_registry11/11 passed at each commit.cargo test -p ml --test sp4_producer_unit_tests --offline --release -- --ignored16/16 SP4 GPU tests pass on RTX 3050 Ti at each commit.
Refs: SP4 Layer C close-out comprehensive sweep — feedback_no_cpu_compute_strict.md (saved 2026-05-01) zero-tolerance enforcement in SP4-touched code paths going forward.
Sweep close-out (2026-05-01 follow-up)
Site #2 (compute_adaptive_tau orphan) deleted per feedback_wire_everything_up. The pre-SP3 helper had zero production callers; the canonical adaptive-tau path is the SP3/SP4 ISV-driven tau_kernel.cu + ISV[TAU_INDEX] controller. Removed: compute_adaptive_tau method, q_divergence_readback accessor (only called by the deleted helper), q_div_ema field + initialiser + fold reset, q_divergence_pinned + q_divergence_dev_ptr (allocation, free, struct fields, two memsets at C51 launch sites, kernel arg in launch_c51_loss), and the inert q_divergence parameter from c51_loss_batched (the kernel never wrote to it; the comment "removed from hot path — zero atomicAdd" had been confirming this for releases). Behavior preservation is trivial — dead code only.
Site #9 (calibrate_homeostatic_targets per-step host EMA loop) GPU-ported per feedback_no_cpu_compute_strict. New calibrate_homeostatic_kernel.cu — single-block, six threads (one per homeostatic slot). Reads host-passed readiness scalar (already-clamped IQN gauge from iqn_readiness shadow field, bit-for-bit match of the deleted host clamp), observations from homeostatic_obs_pinned[6] via homeostatic_obs_dev_ptr, updates homeostatic_targets_pinned[k] for k=1..5 with the same adaptive α formula (0.3 × (1 - readiness) + 0.01 × readiness); thread 0 forces the Q-mean invariant targets[0] = 0.0 exactly as the deleted host post-loop assignment did. __threadfence_system() after writes guarantees PCIe-visibility for homeostatic_kernel's subsequent dev_ptr reads. No cold-start sentinel required — the constructor pre-initialises homeostatic_targets_pinned[0..6] with sensible defaults [0.0, 0.85, 0.1, 0.0, 1.0, 0.5] (gpu_dqn_trainer.rs:14583-14588), so the first call's EMA blends those defaults with the first observation, same algebraic shape the deleted host loop relied on. State-reset registry unchanged: the deleted host EMA loop had no fold reset (per-call EMA only); GPU port preserves identical per-call semantics.
Sweep audit grid sites #2 and #9 resolved. Open deferrals: 0.
Refs: SP4 Layer C close-out follow-up — sites #2 + #9 of the sweep audit grid resolved.
SP5
SP5 Task A0 — ISV slot constants foundation
- New file:
crates/ml/src/cuda_pipeline/sp5_isv_slots.rs— 110 SP5 ISV slot index constants + 18 accessor fns + layout fingerprint fragment - LAYOUT_FINGERPRINT_SEED bumped to chain SP5 fragment (existing checkpoints fail-fast)
- ISV_TOTAL_DIM 173 → 286
- 2 unit tests pass (slot layout invariants + cross-fold-persistent Kelly carve-out)
No behavior change. No producer/consumer wired yet. Subsequent Layer A commits (A1–A8) populate slots via per-pearl producer kernels.
SP5 Layer B — atomic consumer migration to ISV-driven adaptive signals
All 11 consumer sites migrated in one atomic commit per feedback_no_partial_refactor. Every site that reads from ISV slots 174..286 now does so at runtime via read_isv_signal_at (Rust, CPU-side mapped-pinned read) or direct ISV pointer dereference (CUDA kernel). No static constants remain for any of the 8 pearls covered.
Pearl 1 (C51 atom span — v_center/v_half):
atoms_update_kernel.cu: readsISV[174+b](v_center) andISV[178+b](v_half, floor 1.0) per branch.
Pearl 1-ext (per-branch num_atoms):
atoms_update_kernel.cu: readsISV[274+b], calls newround_to_valid_num_atoms()device inline to snap to valid {16,32,64} with cold-start fallback to globalnum_atoms. Useseff_nafor all softmax/cumsum iteration; output stride remains globalnum_atoms(buffer invariant).
Pearl 2 (per-branch loss budgets):
fused_training.rscompute_adaptive_budgets(): reads ISV[190..194), ISV[194..198), ISV[198..202), ISV[202..206) per branch, averages 4 branches per loss type. Invariant-1 floors: C51=0.05, IQN=0.05, CQL=0.02, Ens=0.02.
Pearl 3 (per-branch NoisyNet sigma):
training_loop.rs:ExperienceCollectorConfig.noise_sigmareadsmean(ISV[210..214])with 0.01 floor; falls back tohyperparams.noise_sigmawhenfused_ctxabsent.
Pearl 4 (per-group Adam β1/β2/ε — 5 sites):
gpu_dqn_trainer.rslaunch_adam_update: reads ISV[226+g], ISV[234+g], ISV[242+g] per groupginside the existing per-group loop. StaticADAM_BETA1/BETA2/EPSremain declared but are unused (dead code left for reference; may be removed in cleanup).gpu_iqn_head.rsexecute_training_pipeline+train_iqn_step_gpu: 3 newiqn_beta1/beta2/epsilonparameters threaded from callers.gpu_attention.rsadam_step: 3 newattn_beta1/beta2/epsilonparameters replacing hardcoded 0.9/0.999/1e-8.gpu_tlob.rsadam_step: same pattern as GpuAttention (shares ParamGroup::Attn=6).fused_training.rs: 4 call sites (2 IQN, 1 GpuAttention, 1 TLOB) read ISV[226+g..242+g] and thread values through.gpu_curiosity_trainer.rslaunch_adam_step+train_on_collector_buffers:cur_beta1/beta2/epsilonparams replace constant references.gpu_experience_collector.rstrain_curiosity_gpu: threadscur_beta1/beta2/epsilon.training_loop.rs: reads ISV[233/241/249] forParamGroup::Curiosity=7at thetrain_curiosity_gpucall site.
Pearl 5 (per-branch IQN τ schedule):
gpu_iqn_head.rsnewrefresh_taus_from_isv(&[f32; 20]): averages 4 branches per quantile slot (ISV[250+b*5+q]), applies cold-start floor fromFIXED_TAUS[q](ISV=0 → retain original), recomputescos_features [D,N]from updated taus, re-uploadsonline_taus,target_taus,cos_featuresin-place viaupload_f32_via_pinned.fused_training.rs: reads ISV[250..270) into[f32; 20]array, callsiqn.refresh_taus_from_isv(&isv_taus)beforeprepare_bufferseach training step.
Pearl 6 (cross-fold Kelly cap) + Pearl 8 (per-direction trail distance):
trade_physics.cuh,experience_kernels.cu,backtest_env_kernel.cu: done in prior session.
Compilation and tests:
SQLX_OFFLINE=true cargo check -p ml --offline→ clean (12 warnings, all pre-existing)cargo test -p ml --lib→ 930 pass, 14 pre-existing failures unchanged
Refs: feedback_no_partial_refactor, feedback_isv_for_adaptive_bounds, feedback_adaptive_not_tuned
SP5 Layer B fix-up (2026-05-01)
Three review findings on Layer B (commit 99367b9c6) resolved in one atomic commit before Layer C validation.
Critical — IQL groups 4+5 complete Pearl 4 migration:
gpu_iql_trainer.rs::train_value_step still read self.config.beta1=0.9, self.config.beta2=0.999, self.config.epsilon at runtime (lines 1039-1041). ISV[230] (adam_beta1(4)), ISV[231] (adam_beta1(5)), ISV[238], ISV[239], ISV[246], ISV[247] were populated by the Layer A producer but unconsumed — partial Pearl 4 contract with 6 of 8 groups migrated. Fix: train_value_step gains 3 new parameters (iql_beta1, iql_beta2, iql_epsilon); fused_training.rs reads ISV at both call sites using ADAM_BETA1_BASE + iql_high_g/iql_low_g and threads values through. Pearl 4 is now complete for all 8 param groups (DqnTrunk, DqnValue, DqnBranches, Iqn, IqlHigh, IqlLow, Attn, Curiosity).
Important — consumer clamp envelopes tightened to SP4 producer range:
All Pearl 4 consumer clamps across fused_training.rs (IQN, ATTN, TLOB sites, new IQL sites) and training_loop.rs (Curiosity site) tightened from max(0.5).min(0.9999) / max(0.9).min(0.99999) / max(1e-12) to match the SP4 producer envelope: β1∈[0.85, 0.95], β2∈[0.99, 0.9995], ε∈[1e-10, 1e-6]. Cold-start floor is now 0.85 (not 0.5) for β1, eliminating the overly-aggressive momentum at ISV=0.
Important — dead constants removed:
gpu_curiosity_trainer.rs lines 66-68: const ADAM_BETA1: f32 = 0.9, const ADAM_BETA2: f32 = 0.999, const ADAM_EPS: f32 = 1e-8 removed. These were made dead by the Layer B Pearl 4 migration; the runtime path already read from threaded parameters.
Verification:
cargo check -p ml --offline→ clean (12 warnings, all pre-existing)cargo test -p ml --lib -- sp5 sp4 state_reset_registry→ 13 pass, 0 fail
Refs: feedback_no_partial_refactor, feedback_isv_for_adaptive_bounds