Files
foxhunt/docs/dqn-backward-nan-audit.md
jgrusewski 55576d047b fix(dqn): SP3 Mech 10 — ISV-driven h_s2 activation clamp + slot 49 diag
Real root cause of the F1 step-1000 NaN, identified by smoke-test-2xrxk
diagnostic. With Mech 9 clamping weights at 100×Q_ABS_REF=5000, F0/F2
trained clean (weights stay at natural scale ~0.5) but F1's regime
shift drove weights toward the clamp ceiling. With weights pinned at
5000 and K=256 inner-dim, forward GEMM accumulators compound by
~80000× per layer; trunk depth ≥6 (encoder + VSN + GRN-blocks +
bottleneck) hits f32_max ≈ 3.4e38 in the chain.

Slot 12 (save_h_s2) firing was the smoking gun — h_s2 itself goes
non-finite mid-forward. Slots 39+43+48 (IQN-side) stayed clean,
disproving the close-out v2 hypothesis (IQN partial refactor) and
confirming the surface that wasn't protected: ACTIVATIONS.

Mech 1+2 clamp targets+atoms. Mech 9 clamps weights. Nothing clamped
the trunk's intermediate hidden state. Mech 10 closes that gap by
clamping h_s2 at 100 × H_S2_RMS_EMA.max(1.0) immediately after the
trunk + temporal pipeline finalises it, before any downstream consumer
reads it (branches, IQN, value head, replay save).

Implementation:
- Reuses existing launch_clamp_finite_f32 (dqn_utility_kernels.cu:462)
  which does isfinite(v) ? clamp(v) : 0.0f — NaN→0, Inf→0, finite
  clamped. No new kernel.
- ISV-driven via H_S2_RMS_EMA_INDEX=96 (already consumed by mag_concat
  adaptive scale per P4.T2c.3c.6 — no new slot).
- ε on multiplier per SP1 pearl: isv[96].max(1.0). Cold-start RMS
  near 0 floors the bound at 100×1=100, not at 0.
- Inline NaN check (slot 12) preserved BEFORE the clamp per SP1
  diagnostic-ordering pearl — sentinel sees the original Inf/NaN
  before sanitization replaces it with 0.
- Insertion site: end of submit_forward_ops_main 1c temporal-pipeline
  block (after mamba2_step + apply_regime_dropout — the last writers
  to save_h_s2), immediately before launch_curiosity_inference and the
  loss path. Captured in the same forward child graph.

New diagnostic slot 49 = h_s2_max_abs at threshold 1e3 × H_S2_RMS_EMA.max(1.0).
Mirrors Mech 5 family pattern (clamp at 100×, diagnostic at 1000×) —
sentinel that NEVER fires under normal Mech 10 operation. If it does
fire, the activation clamp is disabled or H_S2_RMS_EMA itself drifted.

Buffer bumps: nan_flags_buf 49→50, metadata 25→26, fused-kernel block
count 25→26. Kernel signature gains second ISV-derived float param
h_s2_rms_ema_eff (distinct from q_abs_ref_eff — h_s2 lives in
activation space, not Q space; don't conflate the two ISV bases).
Both name tables in training_loop.rs (halt_nan + halt_grad_collapse)
extended to 50 entries.

No new ISV slots, no new kernels, no graph-topology surprise (one
extra launch in captured graph). cargo check clean. Audit docs
docs/dqn-wire-up-audit.md (Mech 10 entry prepended) and
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) updated per Invariant 7.

Validation: deferred to one L40S smoke. Expected: F1 trains past
step 1000; slots 36-43 + slot 49 stay quiet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:26:25 +02:00

93 KiB
Raw Blame History

DQN Backward-Path NaN Audit — SP1 Phase A (γ)

Status: ACTIVE — drives SP1 Phase B instrumentation + Phase C fix decisions. Spec: docs/superpowers/specs/2026-04-29-numerical-stability-investigation-design.md Plan: docs/superpowers/plans/2026-04-29-numerical-stability-sp1-f1-nan-root-cause.md Method: read-only inventory of every backward-path kernel that writes to bw_d_h_s2 / grad_buf / save_h_s2 accumulators. Each kernel evaluated against unsafe-pattern checklist. Findings drive Phase B flag-slot allocation and Phase C surgical fix(es).

Unsafe-Pattern Checklist

For each kernel, scan source for:

  • sqrtf(x) where x may be negative (e.g., variance computed via mean(x²) - mean(x)² can be slightly negative due to fp roundoff)
  • 1/x or x / y where divisor may approach 0 (no ε floor present)
  • logf(x) where x may be ≤ 0 (no fmaxf(x, ε) clamp)
  • expf(x) where x may exceed ~88 (single-precision overflow → +inf)
  • EMA-tracked variance denominators that may approach 0 across folds
  • atomicAdd / saxpy accumulators receiving NaN inputs (no isfinite() guard at producer)
  • cuBLAS GEMM with extreme intermediate products (M,N,K combinations producing intermediate overflow)
  • fmaxf / fminf on potentially-NaN inputs (NaN propagates per IEEE-754 → masked-zero in downstream)

Cross-references

  • ~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/session_2026-04-05_nan_investigation.md — prior NaN root-cause investigation; identifies residual 8% step-2 NaN in apply_iqn_trunk_gradient's cuBLAS backward (NEVER CLOSED — strong candidate).
  • Smoke smoke-test-m5gxx (commit e9096c7be) confirmed: save_h_s2 slot 12 + grad_buf slot 6 flag at F1 step ~1140; ALL 5 GRN forward sub-stages (slots 16-20) read clean. NaN enters via backward path, not forward.

Architecture-drift / scope notes

  • Confirmed kernel→file inventory:
    • c51_grad_kernelc51_grad_kernel.cu
    • c51_loss_batchedc51_loss_kernel.cu
    • iqn_backward_per_sample + iqn_weight_grad_reduce + iqn_grad_norm_phase{1,2} + iqn_adam_kerneliqn_dual_head_kernel.cu
    • cql_logit_grad_kernel (only) — cql_grad_kernel.cu (no separate cql_logit_grad_kernel.cu)
    • aux_next_bar_backward + aux_regime_backward + aux_param_grad_reduceaux_heads_kernel.cu
    • ensemble_aggregate_kernel + ensemble_kl_gradient_kernelensemble_kernels.cu (note: legacy ensemble_diversity_kernel was REMOVED 2026-04-21; ensemble path is NOT dormant — apply_ensemble_diversity_backward is live, see gpu_dqn_trainer.rs:7084)
    • mse_grad_kernelmse_grad_kernel.cu (gradient kernel, separate from forward mse_loss_batched in mse_loss_kernel.cu)
    • Bottleneck Linear backward — orchestrated in Rust (gpu_dqn_trainer.rs:18063 aux_bottleneck_vsn_backward_dispatch, gpu_dqn_trainer.rs:18560); reuses launch_dx_only_lda + launch_dw_only_no_bias_lda from batched_backward.rs. No standalone .cu file.
    • relu_mask_kernel + bias_grad_reduce_f32_p1/_p2backward_kernels.cu
    • dqn_saxpy_f32_kerneldqn_utility_kernels.cu:197
  • The plan's Kernel #11 ("bw_d_h_s2 accumulator path") is a Rust orchestration concern, not a kernel; it is treated below as a saxpy-stack inventory.

Per-kernel sections

Kernel: backward_full (Rust orchestrator, dispatch graph)

File: crates/ml/src/cuda_pipeline/batched_backward.rs:1665-2050 Output buffers: writes branch dW/dB + value-head dW/dB into grad_buf_base; accumulates value-head + 4 branch dX into scratch_d_h_s2 [B, SH2] (the buffer threaded through gpu_dqn_trainer::ptrs.bw_d_h_s2). Accumulator targets: grad_buf_base (cuBLAS dW with beta=1.0); scratch_d_h_s2 (cuBLAS dX with beta=0.0 for branch 0 then beta=1.0 for branches 1..4 + value head); per-branch scratch_d_h_b (cuBLAS dX beta=0.0). Caller: gpu_dqn_trainer::submit_dqn_step_loop_cublas and friends — runs in the main forward/backward child graph after c51_grad_kernel populates d_value_logits + d_adv_logits[4].

Sequence the orchestrator runs (every training step):

  1. Branch decoder dW/dB on branch_streams[0..3] in parallel — backward_branch_dw per branch (KAN coeff/resid grads + GLU value/gate dW/dB + FC out dW/dB).
  2. Stream-join on branch_done_events[0..3].
  3. Branch decoder dX (sequential on main stream, beta=0 then beta=1) — backward_branch_dx accumulates into scratch_d_h_s2 and into the per-branch concat-dX buffers (d_mag_concat_ptr, d_ord_concat_ptr, d_urg_concat_ptr).
  4. Value head dY → dX into scratch_d_h_v (fused DRELU_BGRAD path or fallback relu_mask_kernel + launch_bias_grad).
  5. Value-FC dW (launch_dw_only / launch_dw_only_no_bias) writes goff_w_v1 / goff_b_v1.
  6. Value-FC dX (launch_dx_only, beta=1.0) accumulates ONTO branch contributions in scratch_d_h_s2.
  7. (Caller, after backward_full returns) encoder_backward_chain consumes scratch_d_h_s2 and writes h_s2 + h_s1 GRN dW/dB/dgamma/dbeta + bottleneck dX. The chain is NOT inside this function — it lives on BatchedForward (see gpu_dqn_trainer::6916).

Identified unsafe patterns:

  • backward_full itself is dispatch-only — no math. Safety properties pass through to its constituent helpers. The two structural points where corruption could originate inside this function:
    • sgemm_f32 "fc_dW" / "fc_dX" / branch dW/dX with the wide reduction K=batch_size (256). Severity: suspect — extreme intermediate products possible only if d_value_logits / d_adv_logits carry NaN or ±~1e20 (which would already be caught at slot 6 grad_buf check). Per session_2026-04-05 finding #5, the analogous IQN trunk path showed residual 8% NaN at exactly this step.
    • The branch-stream dispatch order (4 streams parallel for dW; sequential for dX with beta=0/1 chain) — semantically sound but means a stale branch_done_events[d] skew could cause the dX accumulation to read uninitialised dW. Reviewed: trunk_done_event and 4 branch_done_events[d] are recorded/awaited symmetrically. Severity: clean.

Proposed guard form: N/A at this level — guards belong in producer kernels (C51 grad, IQN trunk grad, CQL grad, etc.) and at saxpy producers writing into grad_buf / scratch_d_h_s2.

ISV bound option: N/A.

F0 risk: low — this function does no math; all guards land on its callers/callees.

Phase B flag-slot allocation:

  • No new slot here. Slot 6 (existing — grad_buf) and slot 12 (existing — save_h_s2) already cover the function's outputs. The diagnostic gap is INSIDE the function: which sub-step within steps 1-7 first writes NaN. That gap is filled by the per-kernel slots below.

Kernel: c51_grad_kernel

File: crates/ml/src/cuda_pipeline/c51_grad_kernel.cu (full file, lines 1-307) Output buffers: d_value_logits [B, NA] (f32), d_adv_logits [B, total_branch_atoms × NA] (f32). Writes are unique-per-thread (no atomicAdd). Accumulator targets: none (plain assignment; downstream consumer is backward_full's value-FC GEMM and branch dW/dX GEMMs, which read d_value_logits / d_adv_logits as dy). Caller: Loss-graph kernel launched immediately before backward_full (gpu_dqn_trainer::submit_dqn_step_loop_cublas).

Identified unsafe patterns:

  • Line 81 — expf(lp) of log-prob lp in d_combined = inv_batch * isw * (expf(lp) - proj)lp comes from current_lp (saved log-softmax). For numerically-stable log-softmax lp ≤ 0, so expf(lp) ≤ 1, no overflow. Severity: clean.
  • Lines 116, 162 — bias_gap / fmaxf(q_abs_ref, 1e-6f) — divisor floored. Severity: clean (Invariant 1 ε carve-out).
  • Lines 115-116, 161-162 — isv_signals[...] reads — slot reads for Q-mean EMAs / |Q| references. If any ISV slot carries NaN (e.g. Q_ABS_REF_INDEX = 16 not yet bootstrapped at fold boundary), fmaxf(NaN, 1e-6f) returns 1e-6f (CUDA fmaxf is "non-NaN preferred"), but bias_gap = fmaxf(0, max_mean means[a1]) propagates NaN if means[a1] is NaN, then frac_bin = NaN / 1e-6 = NaN, then collapse_frac = fminf(1, NaN) = NaN, then d_combined *= bin_weight = NaN. Severity: suspect. Cross-fold ISV reset audit needed.
  • Line 125 — bin_weight = 1 + collapse_frac * mag_bias_signal — bounded ∈ [1, 2] when collapse_frac is finite; NaN-passthrough as above.
  • Line 188-191 — compression_boost = 1 + compression — same pattern with q_abs_ref_magq_dir_abs_ref ratio. NaN-passthrough.
  • Line 274 — a_std = sqrtf(sq_sum / n_d + 1e-12f) — argument is mean(diff²) + 1e-12 ≥ 1e-12 > 0. Severity: clean (Invariant 1 ε carve-out).
  • Line 275 — inv_a_std = 1 / (a_std + 1e-6f) — divisor floored. Severity: clean.
  • Line 81 — 1 / batch_size — fixed integer. Severity: clean.
  • Line 86 — lp_clamped = fmaxf(lp, -10.0f) — explicit floor on log-prob entropy term. Severity: clean.
  • No atomicAdd anywhere (the kernel is explicitly DETERMINISTIC). One thread per (b, j); writes are single per slot.
  • Line 226-228 — (z_val - z_min) / fmaxf(z_max - z_min, 1e-6f) — divisor floored. Severity: clean.

Net: the kernel is well-guarded for direct numerical hazards. The one residual risk surface is NaN propagation from ISV slots that have not been re-initialised at fold boundaries, since this kernel reads slots 12-21 every step.

Proposed guard form (only if ISV-NaN regression confirmed): wrap each ISV read with an isfinite guard, e.g. q_abs_ref = isfinite(isv_signals[16]) ? isv_signals[16] : 0.0f. With q_abs_ref = 0, frac_bin short-circuits to 0 and the bin-weight reduces to identity. Location: lines 105-108, 150-154, 183-188.

ISV bound option:

  • Option 1: existing slots Q_ABS_REF_INDEX = 16 + Q_DIR_ABS_REF_INDEX = 21 already provide the |Q|-scale references. The proposed guard does not change the bound — it preserves identity behaviour when the slot is uninitialised.
  • Option 3 (Invariant 1 carve-out): the isfinite short-circuit is a numerical-stability check, not a dynamic bound. No new slot needed.

F0 risk: low — F0 trains cleanly with the existing kernel; isfinite-passthrough preserves F0 behaviour bit-identically when the slot already holds finite values.

Phase B flag-slot allocation:

  • Slot 24: d_value_logits (size B × NA) — first NaN sink in the gradient chain after C51 forward; high-priority instrumentation. Catches both the kernel itself and any upstream ISV-NaN propagation.
  • Slot 25: d_adv_logits[*] (size B × total_branch_atoms × NA) — companion to slot 24; will fire if and only if a per-branch path corrupted independently (e.g. magnitude-only inv_a_std divide). Both slots are needed because they are independent producers.

Kernel: c51_loss_batched

File: crates/ml/src/cuda_pipeline/c51_loss_kernel.cu:520-1141 (main kernel; supporting helpers block_log_softmax_f / block_bellman_project_f / block_expected_q_f at lines 87-283; c51_loss_reduce at 1152-1162; barrier_gradient_direction at 1191-1310; ib_gradient_direction at 1342-1447; c51_mixup_ce at 1463-1548) Output buffers: per_sample_loss [B] (f32), td_errors [B], total_loss [1], save_current_lp [B, 4, NA], save_projected [B, 4, NA], q_divergence [1]. The barrier_gradient_direction and ib_gradient_direction kernels write directly into d_adv_logits + d_v_logits (the same buffers c51_grad_kernel produces) using plain += (no atomicAdd; one thread per sample). Accumulator targets: d_adv_logits / d_v_logits via += (in barrier_gradient_direction + ib_gradient_direction); total_loss reduced by separate single-block c51_loss_reduce. Caller: gpu_dqn_trainer::submit_dqn_step_loop_cublas (loss kernel runs before c51_grad_kernel; barrier_gradient_direction + ib_gradient_direction may run after, depending on barrier_weight / health).

Identified unsafe patterns:

In the forward loss kernel (c51_loss_batched):

  • Lines 779 / 804 — a_std = sqrtf(sq_sum / (float)n_d + 1e-12f) with + 1e-12f floor. Severity: clean. (Citation correction 2026-04-29: the earlier "Line 274" reference belonged to c51_grad_kernel.cu:274 — line 274 of c51_loss_kernel.cu is __syncthreads() inside the projection-reduction warp loop, not a sqrtf site.)
  • Line 720 — n_steps_f = __logf(fmaxf(gamma_buf_base, 1e-9f)) / log_dir, then line 721 — gamma_eff = __powf(fmaxf(gamma_b_raw, 1e-6f), n_steps_f) — both bases floored. __logf of a small positive value is large-negative finite. __powf(small, n_steps_f) is bounded by n_steps_f ≤ ~10 so __powf(1e-6, 10) = 1e-60 — finite-but-tiny, harmless. Severity: clean.
  • Line 245 — t_z = -10.0f * (1.0f - expf(t_z / 10.0f)) — Huber compression on negative tail. expf(t_z/10) with t_z ∈ [v_min, v_max] and v_min ~ -50 gives expf(-5) ≈ 6.7e-3 — finite. Severity: clean.
  • Line 222-224 — lead_scale = fmaxf(fmaxf(q_dir_abs_ref, q_mag_abs_ref), 0.1 * v_range) — passes NaN through if either q_*_ref is NaN (CUDA fmaxf(NaN, x) returns x; fmaxf(x, NaN) returns x — non-NaN-preferred — so NaN here is masked). Severity: clean (the NaN-mask actually helps).
  • Line 247 — t_z = fminf(fmaxf(t_z, v_min), v_max) — IEEE non-NaN-preferred; clamp if NaN propagates.
  • Line 249-253 — b_pos = (t_z - v_min) / delta_z — if delta_z ≤ 1e-7f the per-branch path is skipped earlier (line 737, 751 branch_degenerate continue). Severity: clean.
  • Line 894 — alpha = fminf(fmaxf(cvar_alpha_buf[sample_id], 0.05f), 0.95f) — clamped. Severity: clean.
  • Line 905 — cvar = (cvar_weight > 1e-8f) ? (cvar_sum / cvar_weight) : eq — guarded divide. Severity: clean.
  • Line 941 — tau_floor = fmaxf(fabsf(mean_q) * 0.01f, 1e-6f) then line 942 — tau_base = fmaxf(q_gap_local, tau_floor) — finite. Severity: clean.
  • Line 952 — expf((eq_per_action[a] - max_eq) / tau) — argument ≤ 0 (max-shift), so expf ≤ 1. Severity: clean.
  • Line 1019 — expf(shmem_lp[j]) of log-softmax, ≤ 1. Severity: clean.
  • Line 1114 — logf(fmaxf(shmem_lp[j], 1e-10f)) — argument floored. Severity: clean (Invariant 1 ε carve-out).
  • Lines 1131, 1538 — avg_ce *= (1 + dd*dd*asymmetric_dd_weight) — all factors finite. Severity: clean.

In c51_loss_reduce (lines 1152-1162):

  • Line 1161 — total_loss[0] = sum / batch_size — single-block sequential sum, finite when per_sample_loss is finite.

In barrier_gradient_direction (lines 1191-1310):

  • Line 1233, 1237 — expf(v_row[z] + adv_a[z] - max_l) with max-shift — bounded ≤ 1. Severity: clean.
  • Line 1237 — p = expf(...) / (sum + 1e-8f) — guarded. Severity: clean.
  • Plain += writes uniquely per (i, a, z) and (i, z) — confirmed deterministic. Severity: clean.

In ib_gradient_direction (lines 1342-1447): mirror structure to barrier_gradient_direction; same guards; clean.

In c51_mixup_ce (lines 1463-1548):

  • Line 1515 — v1 = powf(u1, inv_a), v2 = powf(u2, inv_a) with u1,u2 ∈ [1e-7, 1) and inv_a = 1/mixup_alpha. If mixup_alpha → 0, inv_a → ∞powf(1e-7, ∞) = 0 (slow); powf(small, large) = 0; finite. Severity: clean.
  • Line 1516 — lambda = v1 / (v1 + v2 + 1e-7f) — guarded. Severity: clean.

Net: this kernel is the most heavily-guarded numerical surface in the pipeline. The only failure mode would be save_current_lp / save_projected being read with NaN inputs, which would propagate to total_ce and td_errors. That input-NaN pathway is captured at slot 7 (save_current_lp) + slot 8 (save_projected) by the existing post-forward checks.

Proposed guard form: none required. Existing instrumentation covers the input pathways.

ISV bound option: N/A.

F0 risk: N/A (no fix proposed).

Phase B flag-slot allocation:

  • No new slot. Existing slots 7 (save_current_lp) + 8 (save_projected) cover the loss-kernel outputs that downstream consumers depend on. The per-sample-loss / total-loss intermediate is captured at slot 3 (mse_loss_buf)... NB: slot 3 currently watches MSE; there is no equivalent slot for c51_loss total. Recommend slot reservation (Phase B optional) for total_loss if subsequent investigation needs it, but NOT in the SP1 12-slot budget.

Kernel: apply_iqn_trunk_gradient (HIGH PRIORITY — prior-investigation top suspect)

File: crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:6839-7067 (Rust orchestrator, not a .cu file) Output buffers: writes into iqn_trunk_m (scratch buffer, param_sizes[0..13) total f32 elements — same padded layout as grad_buf for trunk tensors); then SAXPYs into grad_buf [trunk] and grad_buf [VSN range, indices 95..119). Also writes bw_d_h_s2 [B, SH2] (DtoD copy from iqn_d_h_s2_ptr). Accumulator targets: grad_buf via dqn_saxpy_f32_kernel with scale = config.iqn_lambda × iqn_budget; bw_d_h_s2 via graph_safe_copy_f32 (DtoD overwrite). Caller: gpu_dqn_trainer::run_aux_pass / apply_aux_gradients — fires after the main c51_grad_kernel + backward_full chain has populated grad_buf for the main loss path. iqn_d_h_s2_ptr comes from iqn_backward_per_sample's d_h_s2_out.

Sequence (verbatim from code):

  1. memset_d8_async zeros iqn_trunk_m (lines 6864-6873).
  2. graph_safe_copy_f32 copies iqn_d_h_s2_ptrbw_d_h_s2 (lines 6875-6881).
  3. cublas_forward.encoder_backward_chain(..., d_h_s2 = bw_d_h_s2, ...) — runs the FULL GRN h_s2/h_s1 backward + Linear_residual + bottleneck dX; writes dW/dB/dgamma/dbeta into iqn_trunk_m (NOT into grad_buf directly).
  4. (Optional, bottleneck_dim > 0) aux_bottleneck_vsn_backward_dispatch writes 24 VSN dW/dB into vsn_dw_iqn_aux_scratch.
  5. dqn_saxpy_f32_kernel adds scale * iqn_trunk_m into grad_buf [0..trunk_grad_total) (lines 7013-7033).
  6. (Optional) saxpy vsn_dw_iqn_aux_scratch into grad_buf [VSN range].

Identified unsafe patterns:

  • Step 3 — encoder_backward_chain cuBLAS GEMMs: this is the path session_2026-04-05 finding identifies as the top residual-NaN suspect. The chain runs trunk h_s2 GRN backward → h_s1 GRN backward → optional bottleneck Linear backward, all via cuBLAS SGEMM with extreme dimensions (SH2=128, B=batch, state_dim=128). Per session_2026-04-05: "needs GPU-side printf in backward_fc_layer dW GEMM or compute-sanitizer numerical mode to trace exact values."
    • Severity: suspect — confirmed open from prior investigation.
  • Step 5 — dqn_saxpy_f32_kernel (dqn_utility_kernels.cu:197): plain y[i] = y[i] + alpha * x[i], no isfinite() guard. If any iqn_trunk_m[i] element is NaN, grad_buf[i] becomes NaN. Subsequently consumed by dqn_adam_kernel, which DOES have an isfinite(g) guard (so would zero the gradient and skip the update), but grad_buf[i] itself remains NaN until the Adam pass. The slot-6 NaN-flag at F1 step ~1140 is consistent with this: NaN visible in grad_buf post-saxpy, before Adam sanitises.
    • Severity: suspect — open.
  • Step 2 (DtoD copy) — pure copy, no math; if the copy source iqn_d_h_s2_ptr (= IqnDualHead::d_h_s2_out) is finite, the destination is finite. The input-NaN pathway into this function is exactly what the slot-12 save_h_s2 flag would NOT catch (because save_h_s2 is the FORWARD activation; bw_d_h_s2 is the BACKWARD gradient buffer of the same name). The plan's slot 12 is labelled save_h_s2 (forward) — so a separate slot is needed to instrument bw_d_h_s2 post-IQN-DtoD.
  • Step 4 VSN backwardaux_bottleneck_vsn_backward_dispatch runs bn_tanh_backward + vsn_d_gated_state_portfolio_kernel + vsn_logit_gather_kernel + 3 cuBLAS dW/dX + a scale_f32_aux damping kernel. The damping kernel reads from a scalar (VSN_DW_DAMP); the per-group MLP backward kernels were audited at task #154/#157 closure. Severity: suspect for the cuBLAS legs (same class as Step 3); clean for the kernel-resident math.

Proposed guard form:

  • (a) Add isfinite() guard to dqn_saxpy_f32_kernel — modify kernel to read float xv = x[i]; if (isfinite(xv)) y[i] = y[i] + alpha * xv;. This idempotently masks NaN-input from any saxpy producer (IQN trunk, IQN VSN, ensemble trunk, ensemble VSN, distillation, MoE — all consumers of this saxpy primitive). Mirrors the IQN/DQN/Attention/Curiosity Adam isfinite pattern (existing defense-in-depth, see session_2026-04-05).
  • (b) Add NaN-check on iqn_trunk_m immediately after encoder_backward_chain returns (before the saxpy). This is the diagnostic instrumentation step (slot 26 below); it locates whether NaN originates inside the GRN backward chain or downstream. Cite location: insert check_nan_f32(self.ptrs.iqn_trunk_m, trunk_grad_total, 26) between line 6935 and line 7013.
  • (c) Add NaN-check on iqn_d_h_s2_ptr immediately after step 2's DtoD copy (slot 27 below) — locates whether the IQN per-sample backward kernel produced the NaN seed.

ISV bound option:

  • Guard (a) is a numerical-stability isfinite guard (Invariant 1 carve-out).
  • Guards (b)/(c) are diagnostic-only — no bound needed.

F0 risk: medium for guard (a). Reasoning: dqn_saxpy_f32_kernel is on the hot path (every IQN trunk SAXPY, ensemble SAXPY, distillation SAXPY, MoE SAXPY — ~8 call sites). Adding a per-element isfinite check adds one branch per element. F0 currently trains cleanly, so the guard would be a no-op on F0-typical inputs (all finite). Risk vector: if any of those 8 callers depends on NaN propagating into Adam (which currently zeros the gradient), masking earlier could break their semantics. Verification before deployment: confirm Adam zero behaves identically to saxpy-skip behaviour in steady state. Likely identical — Adam reads grads[tid] and on !isfinite(g) writes grads[tid] = 0 and returns; the saxpy-skip variant just leaves grad_buf[i] unchanged from its previous value (which Adam's prior-zero-out makes 0). Decision: F0 risk LOW after this verification, MEDIUM until verified.

Phase B flag-slot allocation:

  • Slot 26: iqn_trunk_m post-encoder_backward_chain (size trunk_grad_total ≈ 50k floats) — locates whether GRN backward chain is the NaN source.
  • Slot 27: iqn_d_h_s2_ptr post-DtoD into bw_d_h_s2 (size B × SH2) — locates whether IQN per-sample backward seeded NaN. NB: instrument the IQN-source side (iqn_d_h_s2_ptr), not the destination (bw_d_h_s2), because the distinction is the diagnostic value.
  • (Optional, slot 28): vsn_dw_iqn_aux_scratch post-aux_bottleneck_vsn_backward_dispatch — only meaningful if slot 26 fires AND bottleneck_dim > 0. Defer until slot 26 fires.

Kernel: iqn_backward_per_sample + iqn_weight_grad_reduce + iqn_grad_norm_phase{1,2} + iqn_adam_kernel

File: crates/ml/src/cuda_pipeline/iqn_dual_head_kernel.cu:504-621 (per-sample), :638-769 (weight grad reduce), :802-844 (grad norm 2-phase), :847-899 (adam). Output buffers:

  • iqn_backward_per_samplesave_dL_dq [B, N], d_h_s2_out [B, hidden_dim] (this is the source for apply_iqn_trunk_gradient's step 2).
  • iqn_weight_grad_reducegrad_buf [total_iqn_params].
  • iqn_grad_norm_phase1/2block_sums [num_blocks] then norm_out [1].
  • iqn_adam_kernel → updates params, m, v, zeros grads. Accumulator targets: none of the per-sample / weight-grad / grad-norm kernels use atomicAdd (per file header: "Zero atomicAdd"). Adam zeros grads[tid] after consuming. Caller: gpu_iqn_head orchestration; sequenced as iqn_forward_loss_kerneliqn_backward_per_sampleiqn_weight_grad_reduceiqn_grad_norm_phase1/2iqn_adam_kernel.

Identified unsafe patterns:

In iqn_backward_per_sample:

  • Lines 612 — if (isfinite(d_trunk)) d_h_s2_acc[...] += d_trunk — inline isfinite guard already present. Severity: clean (existing defense-in-depth from session_2026-04-05 finding).
  • quantile_huber_grad (lines 146-155) — copysignf(kappa, delta) and finite arithmetic — clean.
  • Line 591 — dL_dq = iqn_block_sum(...) * inv_n_sq — finite reduce of finite inputs. Clean.

In iqn_weight_grad_reduce:

  • Lines 689, 712 — if (isfinite(dL_dpre)) acc += dL_dpre * cos_features[...] — inline isfinite guards. Severity: clean.
  • Lines 753-764 — branch-weight outer-product accumulation — no isfinite guard inside loop. If save_dL_dq[b * N + ti] carries NaN AND save_combined[(b*N+ti)*hidden_dim + h] is finite, acc becomes NaN, then grad_buf[idx] = acc * inv_batch is NaN. Severity: suspect.
    • Note: save_dL_dq is written by iqn_backward_per_sample lines 591-595 with no isfinite guard on dL_dq itself. If online_q[ti] or target_q[ti] carry NaN at the backward call, quantile_huber_grad produces NaN; the per-sample kernel happily writes NaN to save_dL_dq. The reduce then propagates.
    • Severity ELEVATED: suspect — open path through save_dL_dq.

In iqn_grad_norm_phase1:

  • Lines 811-813 — partial += g * g — no isfinite guard. NaN squared is NaN; reduces to NaN. Then iqn_adam_kernel reads norm_sq[0] = NaN, sqrtf(fmaxf(NaN, 0)) = NaN (CUDA fmaxf NaN-passthrough on the second arg), clip_scale = NaN / NaN = NaN, g *= clip_scale = NaN — but Adam's line 873 if (!isfinite(g)) catches this and zeros the gradient + returns. Severity: clean (Adam guard catches downstream).

In iqn_adam_kernel:

  • Line 873 — if (!isfinite(g)) { grads[tid] = 0.0f; return; } — global isfinite guard. Severity: clean.
  • Line 876 — sqrtf(fmaxf(norm_sq[0], 0.0f)) — argument floored at 0. Severity: clean.
  • Line 878 — clip_scale = (grad_norm_val > max_grad_norm && grad_norm_val > 0) ? max_grad_norm / grad_norm_val : 1 — compound guard. Severity: clean.
  • Lines 889-890 — m_hat = mi / (1 - powf(beta1, adam_t)) — denominator approaches 0 only as beta1^adam_t → 1, which requires adam_t = 0. If the kernel runs with adam_t = 0, denominator = 0 → division by zero → ±inf, propagates. Severity: suspect (but only at literal step 0). Adam's adam_t_buf is incremented before the kernel reads it (per existing infrastructure); confirm via launcher.
  • Line 894 — sqrtf(v_hat) + epsv_hat ≥ 0, eps is positive. Severity: clean.

Proposed guard form:

  • For the save_dL_dq propagation pathway: add if (isfinite(dq)) guard to lines 762 and 749-751 of iqn_weight_grad_reduce. Mirrors the existing isfinite pattern at lines 689/712.
    • Location: lines 750-762 (branch-weight inner loop and bias inner loop).

ISV bound option: Invariant 1 ε-carve-out (isfinite check is numerical-stability, not a dynamic bound).

F0 risk: low — F0-typical dL_dq values are finite; the guard is identity for those. Pattern is identical to existing isfinite guards at lines 689/712 of the same file.

Phase B flag-slot allocation:

  • Slot 28: save_dL_dq [B × IQN_NUM_QUANTILES] (B × 5 floats, very small) — exact intermediate that connects per-sample backward → weight-grad reduce. Slot 26 (iqn_trunk_m) and slot 27 (iqn_d_h_s2_ptr) cover the trunk side; slot 28 covers the weight-grad-reduce side.

Kernel: cql_logit_grad_kernel

File: crates/ml/src/cuda_pipeline/cql_grad_kernel.cu (full file, 1-212) Output buffers: d_v_logits [N, num_atoms], d_adv_logits [N, total_actions × num_atoms]. Plain assignment per slot (no atomicAdd) — one thread per sample. Accumulator targets: d_adv_logits (sub-slice for branch d via branch_base arithmetic); d_v_logits via per-thread d_val_accum[] register array then assignment. Caller: CQL gradient launch from gpu_dqn_trainer::apply_cql_loss_backward (separate aux pass). Same destination buffers as c51_grad_kernel and barrier_gradient_direction — they all SHARE d_v_logits / d_adv_logits. In the production code, c51_grad_kernel writes first (overwrite); cql_logit_grad_kernel writes its own slice (overwrite, line 200 d_adv[j] = d_combined). Note: this OVERWRITES the C51 contribution at the (i, branch d, action a, atom j) slot; the consumer downstream (CQL aux-pass-only backward_full) reads the CQL-overwritten values. This is correct semantically — CQL has its own backward path — but should be flagged for NaN source instrumentation.

Identified unsafe patterns:

  • Line 41 — dz = (num_atoms > 1) ? (v_max - v_min) / (num_atoms - 1) : 0.0f — guarded.
  • Line 100 — max_logit = fmaxf(max_logit, c) — NaN-passthrough on second arg only (CUDA non-NaN-preferred). If c is NaN once, max_logit stays at last-finite max. NaN can still leak if all c values are NaN. Severity: suspect (low).
  • Line 111 — expf(val[j] + adv[j] - a_mean_j - max_logit) — argument ≤ 0 after max-shift. Severity: clean.
  • Line 113 — log_sum = logf(sum_exp + 1e-8f) + max_logitsum_exp ≥ 0, ε floor. Severity: clean.
  • Line 142 — softmax_q = expf(q_values_branch[a] - max_q) / (sum_exp_q + 1e-8f) — guarded. Severity: clean.
  • Line 198 — d_combined = inv_batch * d_cql_dq[a] * p * (z - eq) — finite arithmetic. Severity: clean.
  • Line 71 — float d_val_accum[256] — register-allocated array; num_atoms = 51 for default config so safely under bound; d_val_accum index loop has j < 256 guard.
  • No atomicAdd — clean determinism.

Net: cql_logit_grad_kernel is well-guarded. The only suspect path is input-NaN propagation from v_logits / adv_logits (which are on_v_logits_buf / on_b_logits_buf — already covered at slots 1 + 2 by run_nan_checks_post_forward).

Proposed guard form: none required.

ISV bound option: N/A.

F0 risk: N/A.

Phase B flag-slot allocation:

  • Slot 29 (deferred; assign only if SP1 Phase B has spare slots after the higher-priority IQN/saxpy/aux instrumentation): d_v_logits post-CQL (1 of 2 OVERWRITE producers) — useful only as a tiebreaker if slot 24 (d_value_logits post-C51) is clean but the post-CQL aux-backward goes NaN.
    • Decision: DEFER. Slot 24 already covers the C51 producer; if CQL is the source, slot 6 (grad_buf) post-CQL-aux-backward will fire alone (without slot 24/25). That delta is sufficient diagnostic separation.

Kernel: aux_next_bar_backward + aux_regime_backward + aux_param_grad_reduce

File: crates/ml/src/cuda_pipeline/aux_heads_kernel.cu:391-482 (next-bar backward), :502-611 (regime backward), :624-652 (param-grad reduce). Output buffers:

  • aux_next_bar_backwarddW1_partial [B, H, SH2], db1_partial [B, H], dW2_partial [B, H], db2_partial [B], dh_s2_out [B, SH2].
  • aux_regime_backward → similar partials with K-shape on the second linear; dh_s2_out [B, SH2].
  • aux_param_grad_reducefinal_out [P] — the reduced param-grad slice. Accumulator targets: the dh_s2_out outputs are SAXPY'd into bw_d_h_s2 by the caller (per the kernel docstring: "no atomicAdd, just a SAXPY in the caller"). The reduced final_out is then SAXPY'd into grad_buf at the aux head's slot range. Both saxpys go through dqn_saxpy_f32_kernel (same kernel as the IQN trunk path). Caller: gpu_dqn_trainer::apply_aux_heads_backward (search not done in this audit; orchestrator entry point inferred from kernel docstring).

Identified unsafe patterns:

In aux_next_bar_backward:

  • Line 428 — inv_scale = 1 / fmaxf(label_scale, 1e-6f) — divisor floored. Severity: clean.
  • Line 430 — d_pred_b = (2/B) * (pred[b] - label[b] * inv_scale) — finite if pred is finite and label_scale is finite. Severity: clean assuming label_scale is bootstrapped (per ISV slot 117 AUX_LABEL_SCALE_EMA_INDEX, EMA-tracked).
  • Line 437 — aux_elu_bwd_from_post(h_post) — branchless on sign; finite. Severity: clean.
  • *No atomicAdd. Per-block partials are unique-per-(b, ).

In aux_regime_backward:

  • Line 537 — lmax = row[k] then sum_e += expf(row[k] - lmax) — max-shift CE; finite. Severity: clean.
  • Line 540 — inv_sum = 1 / sum_esum_e ≥ K * exp(-something) after max-shift; bounded below by 1.0 (since the max-element contributes expf(0) = 1). Severity: clean.
  • Line 542 — inv_B = 1 / B — fixed divisor. Severity: clean.

In aux_param_grad_reduce: pure shared-memory reduce; clean.

Net: aux backward kernels are well-guarded. One residual concern: when label_scale (ISV slot 117) is uninitialised at fold boundary, inv_scale = 1 / 1e-6 = 1e6, and d_pred_b = (2/B) * (pred - label * 1e6) could produce values up to ~1e9 per sample — large but finite, no NaN. Fold-boundary reset of slot 117 is therefore a CORRECTNESS concern but not a NaN concern.

Proposed guard form: none required at the kernel level.

ISV bound option: N/A.

F0 risk: N/A.

Phase B flag-slot allocation:

  • Slot 30: dh_s2_out (size B × SH2) — instrument only the next-bar variant; regime-CE has the same shape and same caller, so slot-coverage is identical. This catches whether the aux saxpy seeded the bw_d_h_s2 NaN at F1 step ~1140. Suspect-of-record: low (label_scale is finite by construction); but cheap to check, and the buffer is the upstream source for one of the 8 saxpy producers into bw_d_h_s2.

Kernel: apply_ensemble_diversity_backward + ensemble_kl_gradient_kernel

File: crates/ml/src/cuda_pipeline/ensemble_kernels.cu:77-119 (KL gradient kernel), gpu_dqn_trainer.rs:7084 (orchestrator). Output buffers:

  • ensemble_kl_gradient_kerneld_logits_0 [B × NA].
  • apply_ensemble_diversity_backward (Rust) → iqn_trunk_m (REUSED scratch), then SAXPY into grad_buf. Accumulator targets: SAXPY into grad_buf via dqn_saxpy_f32_kernel. SHARED scratch buffer iqn_trunk_m with apply_iqn_trunk_gradient — caller must zero between uses (orchestrator does, line 7104-7113). Caller: gpu_dqn_trainer::apply_ensemble_diversity_backward after main backward chain.

Identified unsafe patterns:

In ensemble_kl_gradient_kernel:

  • Lines 92-94 — max0 = fmaxf(max0, v) — NaN-passthrough on second arg only. Severity: clean (NaN-mask).
  • Line 97 — sum0 += expf(v - max0) — max-shifted, ≤ 1. Severity: clean.
  • Line 98 — p0 = expf(...) / (sum0 + 1e-8f) — guarded. Severity: clean.
  • Same pattern for pk (per-head softmax) at lines 103-111.
  • Line 115 — grad /= (K - 1)K = NUM_ENSEMBLE_HEADS ≥ 2 by construction. Severity: clean.

In apply_ensemble_diversity_backward (orchestrator):

  • Same structural pattern as apply_iqn_trunk_gradientencoder_backward_chain cuBLAS GEMMs + SAXPY. Same suspect-class as IQN trunk path.

Proposed guard form:

  • Same isfinite guard on dqn_saxpy_f32_kernel (per IQN section above) covers ensemble too.

ISV bound option: Invariant 1 carve-out.

F0 risk: low (ensemble path is currently DORMANT in production smoke runs — ensemble_diversity_weight = 0 by default; kernel is only fired when the weight > 0). Per session_2026-04-05 the residual NaN was at F1 step 2, well before any ensemble firing in current configs. The instrumentation is low-priority but worth the slot for completeness.

Phase B flag-slot allocation:

  • Slot 31 (deferred; instrument only if SP1 Phase B observes the saxpy guard catching ensemble-source NaN): d_logits_0 post-ensemble_kl_gradient_kernel. Likely never fires in current production config; defer.

Kernel: mse_grad_kernel + mse_loss_batched

File: crates/ml/src/cuda_pipeline/mse_grad_kernel.cu (gradient, full file 1-93), crates/ml/src/cuda_pipeline/mse_loss_kernel.cu (forward; lines 89, 91, 96, 305, 330 are the relevant log/exp/sqrt sites). Output buffers: d_value_logits / d_adv_logits (same buffers as C51 grad — when MSE is fired, it overwrites; both kernels never run in the same step). Accumulator targets: none (plain assignment). Caller: gpu_dqn_trainer MSE warmup path (early epochs blend MSE with C51 loss per c51_warmup_epochs config).

Identified unsafe patterns:

In mse_grad_kernel:

  • Line 39 — delta_z = (num_atoms > 1) ? (v_max - v_min) / (num_atoms - 1) : 0.0f — guarded.
  • Line 64 — d_combined = inv_batch * isw * td_error * p_j * (z_j - e_q) — products of finites; clean.
  • Line 69 — lp_approx = fmaxf(logf(fmaxf(p_j, 1e-8f)), -10.0f) — both ε-floored and floor-clamped. Severity: clean.
  • No atomicAdd. Plain assignment per (b, j) for d_value_logits, per (b, d, a, j) for d_adv_logits.

In mse_loss_batched (used by warmup path):

  • Lines 305 / 330 — a_std = sqrtf(sq_sum / n_d + 1e-12f) — ε-floored. Severity: clean.
  • Line 91 — log_sum_exp = logf(bsum + 1e-10f) — ε-floored. Severity: clean.

Net: MSE path is well-guarded. The MSE warmup blend is a SHORT cold-start window; F1 step 1140 is well past warmup so MSE is contributing zero or near-zero to the blended loss.

Proposed guard form: none.

ISV bound option: N/A.

F0 risk: N/A.

Phase B flag-slot allocation:

  • No new slot. Slots 24/25 (d_value_logits / d_adv_logits) cover both C51 and MSE producers since they OVERWRITE the same buffers.

Kernel: Bottleneck Linear backward (Rust orchestrator only, no .cu)

File: crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs:18063 (aux_bottleneck_vsn_backward_dispatch), :18560 (main-path bottleneck dX into market slice via launch_dx_only_lda); crates/ml/src/cuda_pipeline/batched_backward.rs:2132 (launch_dw_only_no_bias_lda). Output buffers: grad_buf [bottleneck slot 33] (W) + [slot 34] (B); bn_d_concat_buf [B, market_dim + portfolio_dim] (the dX flowing into bottleneck's input — i.e. into the market features + portfolio features pre-bottleneck). Accumulator targets: grad_buf slots 33/34 via launch_dw_only / launch_dw_only_no_bias_lda cuBLAS dW (beta=1.0). bn_d_concat_buf via launch_dx_only_lda (beta=0.0 for market slice; portfolio slice padded with zeros). Caller: encoder_backward_chain (final stage); also aux_bottleneck_vsn_backward_dispatch for VSN aux paths.

Identified unsafe patterns:

  • All compute via cuBLAS SGEMM with lda overrides for padded states (state stride = pad128(state_dim) = 128, market stride = state_dim_padded). Per session_2026-04-05 finding #3, this exact pattern (states_buf stride mismatch) caused root cause #3 in the fixed bug list. The fix landed in backward_fc_layer_lda and launch_dw_only_no_bias_lda. Severity: clean (fix is in place). No additional guards required at this level.
  • The dX GEMM uses beta=0.0 (overwrite) so does not propagate stale NaN; the dW GEMM uses beta=1.0 (accumulate into grad_buf) — and grad_buf is the buffer slot-6 already watches.

Proposed guard form: none required (fix is in place; existing slot-6 watches the accumulator).

ISV bound option: N/A.

F0 risk: N/A.

Phase B flag-slot allocation:

  • Slot 32: bn_d_concat_buf [B × (market_dim + portfolio_dim)] post-launch_dx_only_lda — locates whether the bottleneck Linear's dX path produces NaN before flowing back into the VSN backward extension. Useful diagnostic separator between "GRN backward chain" (slot 26) and "bottleneck Linear backward" (slot 32).

Kernel: bw_d_h_s2 accumulator path (Rust orchestrator inventory, not a kernel)

File: crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs — multiple sites; the buffer is self.bw_d_h_s2 : CudaSlice<f32> (line 3140 declaration) with raw pointer self.ptrs.bw_d_h_s2. Output / accumulator semantics: bw_d_h_s2 [B, SH2] is the "main backward dX into the trunk h_s2 output" — i.e. the entry point gradient that encoder_backward_chain consumes when running the GRN h_s2 / h_s1 backward. It receives contributions from MULTIPLE producers:

Inventory of producers that WRITE or ACCUMULATE INTO bw_d_h_s2:

  1. backward_full value-head + 4 branch dX (batched_backward.rs:1903-1925 for branches; batched_backward.rs:2014-2023 for value head) — populates scratch_d_h_s2 (which is the same allocation as self.bw_d_h_s2.raw_ptr()). Branch 0 uses beta=0 (overwrite); branches 1-3 use beta=1 (accumulate); value head uses beta=1 (accumulate).
  2. apply_iqn_trunk_gradient step 2 (gpu_dqn_trainer.rs:6880) — graph_safe_copy_f32(bw_d_h_s2, iqn_d_h_s2_ptr, ...) — REPLACES the contents (DtoD overwrite). Subsequently fed into encoder_backward_chain for the IQN aux backward chain.
  3. apply_ensemble_diversity_backward step 2 (gpu_dqn_trainer.rs:7155-7180) — launch_dx_only(beta=0) followed by relu_mask then launch_dx_only(...) — analogous to value head, populates bw_d_h_s2 for the ensemble aux backward chain.
  4. apply_aux_heads_backward — saxpys dh_s2_out from aux_next_bar_backward + aux_regime_backward into bw_d_h_s2 via dqn_saxpy_f32_kernel. (Per kernel docstring lines 384-386 and orchestrator naming in the file.)
  5. apply_state_kl_backward (line 2277 docstring: "predictive_coding_loss into bw_d_h_s2 via plain += (no atomicAdd)") — KL-divergence gradient into trunk.
  6. apply_curiosity_loss_backward — curiosity training gradient (cross-reference with curiosity_training_kernel.cu).
  7. apply_moe_backward (line 8597-8702 — "DtoD-copy moe_dh_s1_scratch → bw_d_h_s2") — REPLACES bw_d_h_s2 with MoE-side trunk gradient.
  8. Attention-focus ISV backward (lines 2141-2143: "f32 [B * SHARED_H2] for copying bw_d_h_s2 (f32) → attention scratch").

The MAIN backward chain (backward_full + value-FC + main encoder_backward_chain) consumes bw_d_h_s2 ONCE per step. The AUX backward chains (apply_iqn_trunk_gradient, apply_ensemble_diversity_backward, etc.) each REZERO + REPLACE bw_d_h_s2 and re-run encoder_backward_chain to extract trunk gradients for their respective contributions, which are accumulated into iqn_trunk_m (scratch) and then SAXPY'd into grad_buf.

Identified unsafe patterns:

  • The DtoD replace pattern (steps 2/3/7) means the buffer is read by encoder_backward_chain immediately after the replace. If the SOURCE is NaN, the chain consumes NaN and writes NaN dW/dB into iqn_trunk_m, which then SAXPYs NaN into grad_buf (current behaviour — slot 6 fires).
  • Steps 4 / 5 / 6 use SAXPY accumulation with no isfinite guard on the saxpy itself (per dqn_saxpy_f32_kernel audit above).

Proposed guard form:

  • (a) isfinite guard on dqn_saxpy_f32_kernel (covers all SAXPY sites).
  • (b) NaN check on bw_d_h_s2 before each encoder_backward_chain call — this is the single point that determines whether NaN enters the trunk backward path. One slot, two consumers (main chain + IQN aux chain).
  • (c) NaN check on bw_d_h_s2 after each producer that REPLACES contents (steps 2, 3, 7).

ISV bound option: Invariant 1 carve-out for guard (a).

F0 risk: low for guards (b)/(c) (diagnostic only); medium for guard (a) until the saxpy verification is done.

Phase B flag-slot allocation:

  • Slot 33: bw_d_h_s2 AFTER backward_full returns, BEFORE encoder_backward_chain begins (the "main path" check). Distinct from slot 12 (save_h_s2, the FORWARD activation): slot 33 watches the BACKWARD gradient at the same shape [B, SH2].
  • Slot 34: bw_d_h_s2 AFTER apply_aux_heads_backward SAXPY but BEFORE apply_state_kl_backward / apply_moe_backward — locates whether aux-head SAXPYs poisoned the buffer.
  • Slot 35: bw_d_h_s2 AFTER apply_iqn_trunk_gradient's DtoD-copy from iqn_d_h_s2_ptr — covers the IQN trunk replace path. NB: this is symmetric with slot 27 (which checks the SOURCE side iqn_d_h_s2_ptr); both are needed because the DtoD might be a graph-capture artefact (cudarc cuMemcpy in graph capture has historic edge cases).

Prior-investigation status (cross-reference with session_2026-04-05_nan_investigation.md)

Root causes (5) — current code state

  1. CUDA graph warmup corruption (gpu_dqn_trainer.rs) — fix: removed warmup launches. STATUS: still in place per current submit_dqn_step_loop_cublas structure (no warmup launches). Verification: grep confirms no warmup launches in graphed paths.
  2. f32/bf16 type mismatch in pad_states_kernel (dqn_utility_kernels.cu) — fix: changed to const float*. STATUS: still in place — dqn_utility_kernels.cu shows f32 source pointer. Verified: lines 124-180 of dqn_utility_kernels.cu use float source.
  3. States buffer stride mismatch (batched_backward.rs, gpu_dqn_trainer.rs) — fix: backward_fc_layer_lda variant. STATUS: still in place — backward_fc_layer_lda at batched_backward.rs:916, launch_dw_only_no_bias_lda at :2132. Both consumed by apply_iqn_trunk_gradient's encoder_backward_chain and bottleneck backward.
  4. IQN trunk forward stride mismatch (iqn_dual_head_kernel.cu) — fix: padded_sd = (state_dim + 127) & ~127. STATUS: the IQN dual-head kernel no longer has its own trunk forward — h_s2 is consumed directly from the DQN trunk per kernel docstring lines 1-39. The fix is no longer needed because the path no longer exists.
  5. IQN grad_norm sqrt-per-block bug (iqn_dual_head_kernel.cu) — fix: accumulate raw sum-of-squares. STATUS: still in place — iqn_grad_norm_phase1 (lines 802-829) accumulates raw g*g partials; iqn_grad_norm_phase2 (lines 832-844) sums partials; iqn_adam_kernel (line 876) takes sqrtf(fmaxf(norm_sq[0], 0.0f)) at point of use.

Defense-in-depth guards (6) — current code state

  • f32_to_bf16_kernel NaN→0 + clamp ±500 (common_device_functions.cuh) — NOT VERIFIED IN THIS AUDIT (file scope was backward-path kernels). bf16 cast is upstream of all backward-path producers.
  • DQN Adam isfinite(g) guard (dqn_utility_kernels.cu) — NOT INSPECTED IN THIS AUDIT (file in scope but the kernel of interest is the saxpy and adam — Adam was separately verified). The DQN saxpy itself does NOT have an isfinite guard (Phase C proposal).
  • IQN Adam isfinite(g) guard (iqn_dual_head_kernel.cu) — STILL ACTIVE. Line 873 of current file: if (!isfinite(g)) { grads[tid] = 0.0f; return; }.
  • Attention Adam isfinite guard (attention_backward_kernel.cu) — NOT INSPECTED (out of scope; attention is forward-path consumer).
  • Curiosity Adam isfinite guard (curiosity_training_kernel.cu) — NOT INSPECTED (curiosity is dormant in current production config).
  • IQN backward d_h_s2 isfinite guard (iqn_dual_head_kernel.cu) — STILL ACTIVE. Line 612: if (isfinite(d_trunk)) d_h_s2_acc[h / IQN_BLOCK_SIZE] += d_trunk;.

Residual: 8% NaN in grad_buf (NEVER CLOSED)

  • Prior diagnosis: NaN enters during apply_iqn_trunk_gradient's cuBLAS backward (specifically backward_fc_layer dW GEMM); IQN d_h_s2 is CLEAN.
  • Architectural drift since 2026-04-05: the legacy ReLU+Linear→Linear chain has been replaced with encoder_backward_chain (Plan 4 Task 2c.3c.4), which is a GRN-aware chain (LayerNorm + ELU + Linear_a + Linear_b + Linear_residual + GLU). The cuBLAS dW GEMMs are STILL in this chain (via launch_dw_only / launch_dw_only_no_bias / launch_dw_only_no_bias_lda), so the suspect class is unchanged but the specific kernel path is different.
  • The smoke smoke-test-m5gxx slot-6 + slot-12 fire pattern is CONSISTENT with this prior-investigation residual: NaN visible in grad_buf post-saxpy, before Adam catches it; save_h_s2 is the FORWARD activation buffer (not the BACKWARD bw_d_h_s2), so its slot-12 NaN at the same step suggests the AUX-pass apply_iqn_trunk_gradient (which DtoD-copies into bw_d_h_s2 and re-uses save_h_s2's overlay buffer per gpu_dqn_trainer.rs:6849 "bw_d_h_s2 is gone — h_s2 GRN ends in LayerNorm") is creating a step 2 + step 3 sequence that contaminates both buffers within one aux pass.
  • Verdict: the prior investigation's open question is RECONFIRMED as the top SP1 suspect. Phase B slots 26-28 (IQN trunk path) directly target this hypothesis.

Summary — kernel suspicion ranking

Ranked by cumulative severity (number of unsafe patterns × severity), with apply_iqn_trunk_gradient weighted by prior-investigation note as inherently high-priority regardless of pattern count.

Rank Kernel # confirmed # suspect F0 risk of fix Notes
1 apply_iqn_trunk_gradient (orchestrator + encoder_backward_chain cuBLAS leg + saxpy producer) 0 3 lowmedium Prior NEVER-CLOSED 8% step-2 NaN; dqn_saxpy_f32_kernel has no isfinite guard; F1 slot-6 fire pattern matches.
2 bw_d_h_s2 accumulator path (Rust, 8 producers SAXPY/DtoD) 0 2 low (diagnostic) Buffer received by encoder_backward_chain; one bad producer poisons the whole aux pass.
3 iqn_quantile_huber_loss (production IQN backward) 0 1 low DORMANCY RECONCILIATION (2026-04-29): the previously-listed iqn_backward_per_sample + iqn_weight_grad_reduce pair is NOT loaded by any Rust caller (verified via grep -rn "iqn_backward_per_sample" crates/ml/src/ — only the .cu definition matches; gpu_iqn_head.rs:2099-2120 loads iqn_loss_reduce, iqn_grad_norm_phase{1,2}, iqn_adam_kernel, iqn_quantile_huber_loss, etc., but never iqn_backward_per_sample). The production IQN backward path is iqn_quantile_huber_loss (iqn_dual_head_kernel.cu:1346-1413), which writes d_q_online[idx] = qw * d_huber / Q at line 1410 with NO isfinite guard on d_huber, qw, or the inputs online_val/target_val. This f32 buffer binds Rust-side to d_branch_logits_buf (slot 28). The same unsafe-write pattern applies (no inline isfinite), now correctly attributed to the live kernel. The dormant-kernel save_dL_dq discussion in the kernel section is retained as historical documentation of a non-loaded path. Rank #1 (apply_iqn_trunk_gradient) still stands — its reasoning (orchestrator consuming iqn_d_h_s2_ptr = gpu_iqn_head::d_h_s2_buf) is unchanged by which specific kernel writes that buffer; the orchestrator consumes whatever the production path produced.
4 c51_grad_kernel 0 1 low Reads ISV slots 12-21 every step; if any slot is NaN at fold boundary, the multiplicative bin_weight propagates NaN.
5 apply_ensemble_diversity_backward 0 1 low Same suspect-class as IQN trunk (cuBLAS GEMMs + saxpy); currently dormant in production config.
6 Bottleneck Linear backward 0 0 N/A Stride-padded SGEMM bug is FIXED (session_2026-04-05 finding #3); slot-6 + lda variants in place. Diagnostic slot 32 still useful.
7 aux_next_bar_backward + aux_regime_backward + aux_param_grad_reduce 0 0 N/A Well-guarded; only concern is upstream label_scale (ISV slot 117) bootstrapping.
8 cql_logit_grad_kernel 0 1 (low) N/A NaN-passthrough in fmaxf; in practice masked by max-shift softmax. Not a likely seed.
9 c51_loss_batched 0 0 N/A Heavily guarded; only concern is input-NaN propagation, already covered by slots 7/8.
10 mse_grad_kernel 0 0 N/A Not active at F1 step 1140 (post-warmup).
11 backward_full (orchestrator) 0 0 N/A Pure dispatch; safety properties pass through to producers.

Phase B priority slot order

Based on the suspicion ranking above, instrument these slots first (descending priority).

NB: this requires expanding nan_flags_buf from [i32; 24] to [i32; 48] — Phase B Task 2 expands to 48 (to host slots 24-47). The 12 listed slots use indices 24-35; indices 36-47 are headroom for SP2/SP3.

Per feedback_no_partial_refactor, the 24→48 migration must touch ALL FOUR sites in crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs atomically (one commit, not staged):

  1. Field type declarationgpu_dqn_trainer.rs:2659: pub(crate) nan_flags_buf: CudaSlice<i32>, (the docstring at line 2658 also says NaN flags buffer [8] — stale, must be updated to 48 in the same edit).
  2. Allocationgpu_dqn_trainer.rs:11178: let nan_flags_buf = stream.alloc_zeros::<i32>(24) → bump literal to 48.
  3. Constructor wire-upgpu_dqn_trainer.rs:12563: nan_flags_buf, (struct-literal — type-inferred from declaration so no edit needed at this line, but call out as a verification anchor).
  4. read_nan_flags() return typegpu_dqn_trainer.rs:14982: pub fn read_nan_flags(&self) -> Result<[i32; 24], MLError> plus the [0_i32; 24] initialiser at line 14983.

Plus the consumer + name-table sites already enumerated in Task 2's plan body:

  • crates/ml/src/trainers/dqn/fused_training.rs — call site of read_nan_flags().
  • crates/ml/src/trainers/dqn/trainer/training_loop.rs — two name-table sites (the slot-name array used to format the per-slot fire log).
  1. Slot 26iqn_trunk_m post-encoder_backward_chain in apply_iqn_trunk_gradient (highest suspicion — prior investigation).
  2. Slot 27iqn_d_h_s2_ptr post-DtoD copy into bw_d_h_s2 (companion to slot 26; locates per-sample IQN backward as source).
  3. Slot 33bw_d_h_s2 AFTER backward_full returns, BEFORE encoder_backward_chain begins (the main-path entry-point check).
  4. Slot 24d_value_logits_buf post-c51_grad_kernel (the f32 atomicAdd buffer at gpu_dqn_trainer.rs:3123, NOT the staging d_value_logits at :3127).
  5. Slot 25d_adv_logits_buf post-c51_grad_kernel (the f32 atomicAdd buffer at gpu_dqn_trainer.rs:3125, NOT the staging d_adv_logits at :3129).
  6. Slot 34bw_d_h_s2 AFTER apply_aux_heads_backward SAXPY (separates aux-head poisoning from main-path).
  7. Slot 35bw_d_h_s2 AFTER apply_iqn_trunk_gradient DtoD copy (symmetric with slot 27 — distinguishes graph-capture DtoD edge cases).
  8. Slot 28save_dL_dq post-iqn_backward_per_sample (locates IQN per-sample backward as NaN seed for the weight-grad reduce path).
  9. Slot 32bn_d_concat_buf post-launch_dx_only_lda (bottleneck Linear backward dX into market slice).
  10. Slot 30dh_s2_out post-aux_next_bar_backward (aux head backward; lower suspicion but cheap).
  11. Slot 29 — DEFERRED: d_v_logits post-cql_logit_grad_kernel (covered by deltas between slot 24 fire and slot 6 fire).
  12. Slot 31 — DEFERRED: d_logits_0 post-ensemble_kl_gradient_kernel (currently dormant in production).

If any of slots 2435 are pre-empted by Phase B's actual implementation budget (e.g. cudarc wrapper limits or nan_flags_buf sizing constraints), the deferred two slots (29, 31) can be returned to the budget as the lowest-priority drops. Slots 26, 27, 33 are NON-NEGOTIABLE — without those three the prior-investigation hypothesis cannot be confirmed or refuted from one smoke run.

Plan supersession note

The SP1 plan's Task 2 step 6 name table at docs/superpowers/plans/2026-04-29-numerical-stability-sp1-f1-nan-root-cause.md:354-366 predates this audit's per-kernel inspection and uses placeholder names (iqn_trunk_grad_out, iqn_aux_grad_out, c51_d_current_lp, c51_d_projected, cql_grad, ens_grad_out, aux_nb_dh_s2, aux_rg_dh_s2, bw_d_h_s2_pre_saxpy, bw_d_h_s2_post_saxpy, mse_grad, bottleneck_grad). This audit's per-slot table below supersedes the plan's name table for slots 2435. When Task 2 lands the nan_flags_buf 24→48 expansion, the slot-name strings should be the audit's names (the actual production buffer names: d_value_logits_buf, d_adv_logits_buf, iqn_trunk_m, iqn_d_h_s2_ptr, d_branch_logits_buf, cql_d_value_logits, aux_dh_s2_nb_buf, ensemble_d_logits_buf, bn_d_concat_buf, bw_d_h_s2 ×3 call-sites), not the plan's. The plan's slot purposes are conceptually right but the buffer-name mapping was wrong; the audit's allocation maps each slot to the actual production buffer that the live code path writes.

Per-slot Rust buffer-pointer expressions (Task 4 inputs)

Concrete handle to pass to check_nan_f32(buf_ptr, len, flag_idx) for each of slots 2435. The "Owner struct" column is the Rust struct that owns the buffer (GpuDqnTrainer for everything except slot 31 which lives on FusedDqnTraining). Verified 2026-04-29 against crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs, crates/ml/src/cuda_pipeline/gpu_iqn_head.rs, crates/ml/src/cuda_pipeline/gpu_aux_heads.rs, and crates/ml/src/trainers/dqn/fused_training.rs.

Slot Buffer name (Rust) Owner struct Handle status Rust expression
24 d_value_logits_buf (NOT d_value_logits — see note) GpuDqnTrainer accessor optional (pointer expression already constructed at launch site) private field at gpu_dqn_trainer.rs:3123 (CudaSlice<f32>, "f32 for native atomicAdd"). This is the f32 atomicAdd buffer that c51_grad_kernel writes via the launch site at gpu_dqn_trainer.rs:17707 (let d_value_logits_buf_ptr = self.d_value_logits_buf.raw_ptr();). The check can therefore be inlined into launch_c51_grad immediately after the kernel launch (reusing the existing d_value_logits_buf_ptr local), OR — symmetric with the other slots — pub(crate) fn d_value_logits_buf_ptr(&self) -> u64 { self.d_value_logits_buf.raw_ptr() } can be added. Length: self.d_value_logits_buf.len() (= B × NA). NOTE: d_value_logits at line 3127 is an unrelated staging buffer for the backward pass; c51_grad_kernel does not write to it.
25 d_adv_logits_buf (NOT d_adv_logits — see note) GpuDqnTrainer accessor optional (pointer expression already constructed at launch site) private field at gpu_dqn_trainer.rs:3125 (CudaSlice<f32>, "f32 for native atomicAdd"). This is the f32 atomicAdd buffer that c51_grad_kernel writes via the launch site at gpu_dqn_trainer.rs:17708 (let d_adv_logits_buf_ptr = self.d_adv_logits_buf.raw_ptr();). Same inline-vs-accessor choice as slot 24. Length: self.d_adv_logits_buf.len() (= B × (b0+b1+b2+b3) × NA). NOTE: d_adv_logits at line 3129 is an unrelated staging buffer for the backward pass; c51_grad_kernel does not write to it.
26 iqn_trunk_m GpuDqnTrainer already exists in ptrs self.ptrs.iqn_trunk_m. Length: trunk_grad_total (per-call computed as the sum of param_sizes[0..13); same value used at gpu_dqn_trainer.rs:7014 for the SAXPY count).
27 iqn_d_h_s2_ptr (= gpu_iqn_head::d_h_s2_buf) GpuIqnHead needs new accessor already passed in as a u64 arg at gpu_dqn_trainer.rs:6841; the source field is d_h_s2_buf allocated at gpu_iqn_head.rs:467. Add pub(crate) fn d_h_s2_buf_ptr(&self) -> u64 { self.d_h_s2_buf.raw_ptr() } on GpuIqnHead, OR — simpler — call check_nan_f32(iqn_d_h_s2_ptr, b * SH2, 27) inside apply_iqn_trunk_gradient between line 6880 (the DtoD copy) and line 6891 (the consume). Length: B × hidden_dim = B × SH2 = B × 128.
28 d_branch_logits_buf (NOT save_dL_dq — see note) GpuIqnHead needs new accessor private field at gpu_iqn_head.rs:305; add pub(crate) fn d_branch_logits_buf_ptr(&self) -> u64 { self.d_branch_logits_buf.raw_ptr() }. Length: tba * bq (= total_branch_atoms × (B * IQN_NUM_QUANTILES)). NOTE: the audit's earlier "save_dL_dq" reference is the kernel-internal name used inside iqn_dual_head_kernel.cu:514 of the dormant iqn_backward_per_sample kernel. The PRODUCTION IQN backward path is iqn_quantile_huber_loss (kernel at iqn_dual_head_kernel.cu:1346), whose Rust-side dL/dq output is d_branch_logits_buf. Phase B Task 4 must instrument this buffer; the audit-text reference to iqn_backward_per_sample is documenting a non-loaded kernel.
29 (deferred) cql_d_value_logits GpuDqnTrainer needs new accessor private field at gpu_dqn_trainer.rs:3224 (CudaSlice<f32>); add pub(crate) fn cql_d_value_logits_ptr(&self) -> u64 { self.cql_d_value_logits.raw_ptr() }. Length: self.cql_d_value_logits.len(). (Slot is deferred per priority list — accessor only needed if the slot is later un-deferred.)
30 aux_dh_s2_nb_buf GpuDqnTrainer needs new accessor private field at gpu_dqn_trainer.rs:2935 (CudaSlice<f32>, shape [B, SH2]); add pub(crate) fn aux_dh_s2_nb_buf_ptr(&self) -> u64 { self.aux_dh_s2_nb_buf.raw_ptr() }. Length: B × SH2. The companion aux_dh_s2_rg_buf at gpu_dqn_trainer.rs:2938 has the same shape and same caller — slot 30 instruments only the next-bar variant per the audit.
31 (deferred) ensemble_d_logits_buf FusedDqnTraining (crates/ml/src/trainers/dqn/fused_training.rs:347) needs new accessor private Option<CudaSlice<f32>> field; add `pub(crate) fn ensemble_d_logits_buf_ptr(&self) -> Option { self.ensemble_d_logits_buf.as_ref().map(
32 bn_d_concat_buf GpuDqnTrainer already accessible accessor exists at gpu_dqn_trainer.rs:6753 (pub(crate) fn bn_d_concat_buf(&self) -> &CudaSlice<f32>). Use self.bn_d_concat_buf().raw_ptr() and self.bn_d_concat_buf().len(). Or read the field directly from inside the same struct: self.bn_d_concat_buf.raw_ptr(). Length: B × (market_dim + portfolio_dim).
33 bw_d_h_s2 GpuDqnTrainer already exists in ptrs self.ptrs.bw_d_h_s2. Length: B × SH2.
34 bw_d_h_s2 (post-aux-head SAXPY) GpuDqnTrainer already exists in ptrs self.ptrs.bw_d_h_s2. Same buffer as slot 33; the distinction is the call site (different timing in the orchestrator). Length: B × SH2.
35 bw_d_h_s2 (post-IQN DtoD) GpuDqnTrainer already exists in ptrs self.ptrs.bw_d_h_s2. Same buffer as slots 33/34; distinction is again call site. Length: B × SH2.

Summary for Task 3 (accessor additions):

  • GpuDqnTrainer (in gpu_dqn_trainer.rs): add aux_dh_s2_nb_buf_ptr accessor (slot 30); plus cql_d_value_logits_ptr if slot 29 is un-deferred. Slots 24/25 (d_value_logits_buf / d_adv_logits_buf) do NOT need new accessors — the pointer expressions already exist as locals (d_value_logits_buf_ptr / d_adv_logits_buf_ptr) inside launch_c51_grad at gpu_dqn_trainer.rs:17707-17708, so the slot-24/25 NaN check can be inlined post-launch in that function. (If symmetric accessors are preferred for orchestrator-side checks, add d_value_logits_buf_ptr / d_adv_logits_buf_ptr reading the _buf fields at lines 3123/3125, NOT the staging fields at 3127/3129.)
  • GpuIqnHead (in gpu_iqn_head.rs): add up to 2 accessors — d_h_s2_buf_ptr (slot 27, optional if the check is inlined into apply_iqn_trunk_gradient) and d_branch_logits_buf_ptr (slot 28, required).
  • FusedDqnTraining (in fused_training.rs): add ensemble_d_logits_buf_ptr if slot 31 is un-deferred.
  • No accessor needed for slots 26, 32, 33, 34, 35 — already accessible via self.ptrs.* or an existing pub(crate) accessor.

Phase B smoke result: smoke-test-xvzgk (commit f139a63ee)

Workflow: smoke-test-xvzgk (L40S, plain — no nsys, no compute-sanitizer) Commit: f139a63ee (Task 4 — backward NaN check call sites) Outcome: F1 + F2 both hit grad-collapse early-stop with NaN signature; F0 trained 5 epochs but Best Sharpe regressed from 55.87 baseline to 34.55.

Per-fold outcome

Fold Status Best Sharpe Notes
F0 trained 5 epochs (not converged) 34.55 (val_loss=3.82) REGRESSION from 55.87 baseline — investigation needed
F1 early-stopped (grad collapse 5 epochs) n/a first NaN-CLAMPED at step 890
F2 early-stopped (grad collapse 5 epochs) n/a NaN cascade from F1-corrupted state

Flagged buffer topology

F1 first-fire (steps 890-910): flagged=[6=grad_buf, 12=save_h_s2, 26=iqn_trunk_m, 32=bn_d_concat_buf]

Slots 27 (iqn_d_h_s2_buf), 28 (d_branch_logits_buf), 33/34/35 (bw_d_h_s2 post-main/post-aux/post-iqn) ALL CLEAN at this point. Slots 24/25 (post-c51_grad), 29 (cql), 30 (aux) also clean.

F2 cascade (steps 5-25 onwards): flagged=[2=on_b_logits, 3=mse_loss_scalar, 6=grad_buf, 7=save_current_lp, 8=save_projected, 12=save_h_s2, 24=d_value_logits_buf, 25=d_adv_logits_buf, 26=iqn_trunk_m, 29=cql_d_value_logits, 32=bn_d_concat_buf, 33=bw_d_h_s2_post_main, 34=bw_d_h_s2_post_aux]

Slots 27, 28, 35 STILL CLEAN — confirms IQN-internal backward chain is NOT the seed; the cascade enters the IQN consumer (slot 26 = apply_iqn_trunk_gradient cuBLAS bwd output) but never the IQN producer (slot 28 = iqn_quantile_huber_loss output) or the IQN trunk-gradient input (slot 27 = d_h_s2_buf).

Backward dependency interpretation

The F1 first-fire signature [6, 12, 26, 32] decodes as:

  • Slot 26 (iqn_trunk_m) — apply_iqn_trunk_gradient's cuBLAS bwd output is NaN.
  • Slot 27 (iqn_d_h_s2_buf, the input to apply_iqn_trunk_gradient) — CLEAN.
  • Slot 32 (bn_d_concat_buf) — Bottleneck Linear backward dy is NaN.
  • Slots 24, 25 (the upstream gradients into bn_d_concat: d_value_logits_buf, d_adv_logits_buf) — CLEAN.
  • Slots 6, 12 (grad_buf, save_h_s2) — downstream propagation of slot 26 + 32 contamination.

Two concurrent cuBLAS GEMM backward operations are producing NaN from clean inputs:

  1. apply_iqn_trunk_gradient's cuBLAS sgemm (gpu_dqn_trainer.rs:6843+) — Phase A audit ranked #1 suspect; matches session_2026-04-05_nan_investigation.md's residual-8% step-2 NaN finding (NEVER CLOSED).
  2. Bottleneck Linear backward sgemm — NEW finding not in the original audit's top-3 ranking. The cuBLAS backward GEMM dW/dX path produces NaN despite clean upstream gradients.

This is consistent with the unsafe-pattern checklist's category "cuBLAS GEMM with extreme intermediate products (M,N,K combinations producing intermediate overflow)". The F0 training is healthy; F1's Bellman target distribution shifts after fold-boundary, exposing the cuBLAS GEMM to extreme intermediate values that overflow f32.

Phase C surgical fix scope (drives Task 6)

Combined RELATED fixes per feedback_no_partial_refactor:

  1. Wrap apply_iqn_trunk_gradient's cuBLAS bwd with input range guard + output sanitization (Template f from plan):
    • Pre-call: bound input iqn_d_h_s2_ptr magnitude via ISV-driven adaptive clamp using H_S2_RMS_EMA_INDEX = 96 and Q_DIR_ABS_REF_INDEX = 21 to derive a magnitude floor/ceiling that's wide on F0's data and narrows as the trunk's gradient distribution evolves.
    • Post-call: sanitize output iqn_trunk_m via isfinite clamp; non-finite values → 0 (defense-in-depth).
  2. Wrap Bottleneck Linear backward cuBLAS bwd with the same pattern:
    • Pre-call: bound input d_value_logits_buf + d_adv_logits_buf magnitudes via ISV-driven clamp.
    • Post-call: sanitize output bn_d_concat_buf.

ISV bound source: Q_ABS_REF_INDEX = 16 for the value-stream dimension, Q_DIR_ABS_REF_INDEX = 21 for the branch dimension, H_S2_RMS_EMA_INDEX = 96 for the trunk-state magnitude. All three are existing ISV slots — no new slot required (per audit's no-new-slot principle).

F0 risk: low — F0 trained 5 epochs without firing slot 26 OR 32; ISV slots 16/21/96 will be at their F0-learned values when the guards activate, so guards are no-ops on F0-typical inputs. Paper-review pre-smoke OK.

Note on F0 Sharpe regression

F0's Best Sharpe dropped from 55.87 (prior smoke smoke-test-m5gxx) to 34.55 in this smoke. Possible causes:

  1. Stochastic variance — F0 Sharpe varies run-to-run; 55.87 may have been an upper-tail observation. Need to re-run F0 with the surgical fix to calibrate.
  2. Phase B instrumentation timing impact — 11 new check_nan_f32 launches per step add ~50-100us latency; could affect graph-capture timing.
  3. Buffer-allocation impact — the nan_flags_buf 24→48 expansion allocates more pinned memory, could compete with other allocations.

If F0 Sharpe stays below 53.08 (95% floor) after Task 6 fix, this becomes a separate investigation thread within SP1 (no deferral per operating principles). Most likely (1) — stochastic variance — but verify after Task 6.

Phase D — Multi-fold validation smoke (commit 19b008e1c) — FAIL

Workflow: smoke-test-dr2bn (L40S, plain, 16m run) Commit: 19b008e1c (Task 6 surgical fix + quality follow-up) Outcome: ALL 7 SP1 pass criteria FAIL.

Per-criterion results

Criterion Result Value
1. zero NaN-CLAMPED-TO-ZERO FAIL 5 lines (F2 cascade)
2. zero NaN/Inf at step FAIL 2 lines (F1 step 240, loss=32.12, grad=inf)
3. F0 ≥ 53.08 FAIL F0 = 35.24 (similar to pre-fix 34.55; Phase B regression unchanged by Phase C)
4. F1 monotone FAIL F1 hard-failed at step 240 (halt_nan path)
5. F2 monotone FAIL F2 grad-collapse cascade, weights inherited from F1 corruption
6. zero flagged slots FAIL 6 flagged lines (F1 [6,12,26,32]; F2 cascade adds 2,3,7,8,24,25,29,33,34)
7. 3 folds complete 5 epochs FAIL only F0

F1 first-fire signature (step 240)

flagged=[6=grad_buf, 12=save_h_s2, 26=iqn_trunk_m, 32=bn_d_concat_buf] — IDENTICAL to pre-fix smoke smoke-test-xvzgk (commit f139a63ee), but at much earlier step (240 vs 890).

Diagnosis: the surgical fix DESTABILIZES F1 STARTUP, not prevents NaN

The earlier-step NaN proves the surgical fix is causing problems, not just failing to address them. Hypothesis chain:

  1. Pre-clamp formula (1e6 × ISV).max(1e3) — at fold boundary, ISV[96] (H_S2_RMS_EMA) and ISV[21] (Q_DIR_ABS_REF) likely RESET to 0 (per feedback_isv_dynamic and the StateResetRegistry behavior at fold boundary).
  2. With ISV = 0, formula yields max(1e6 × 0, 1e3) = 1e3.
  3. F1 startup gradients can have natural magnitudes > 1e3 (post-fold-shift in Bellman target distribution).
  4. Clamp aggressively clips legitimate gradients down to ±1e3 — significant signal loss.
  5. Clipped gradients destabilize Adam EMAs → unstable weight updates → cuBLAS GEMM intermediate products overflow → NaN at slot 26 + 32.
  6. Post-clamps catch the NaN (regression sentinel works) and sanitize, but the damage to weights is already done.

The 1e3 ε floor was intended as a "numerical-stability ε for uninitialized state" (Invariant 1 carve-out), but it's actively destructive when fold-boundary ISV reset puts the bound into a regime where it clips legitimate signal.

Evidence — F0 regression unchanged by Phase C

F0 Best Sharpe in three smokes:

  • smoke-test-m5gxx (commit e9096c7be, baseline): 55.87
  • smoke-test-xvzgk (commit f139a63ee, Phase B alone): 34.55
  • smoke-test-dr2bn (commit 19b008e1c, Phase B + C): 35.24

Phase C surgical fix did NOT change F0 (within stochastic variance), confirming the Phase B instrumentation is the F0 regression source. F0 doesn't trigger the clamp (ISV[96] = ~1.0 by the time F0 starts; max_abs = 1e6, way above F0 inputs). So F0 regression is from a DIFFERENT mechanism in Phase B (e.g., 11 new check_nan_f32 kernel launches per step interfering with graph-capture timing or memory ordering).

Next iteration (Task 6 fix-up #2)

Fix proposal: change ε floor formula from (1e6 * isv).max(1e3) to (1e6 * isv.max(1.0_f32)).max(1e6_f32) — equivalent to 1e6 * isv.max(1.0). This guarantees max_abs ≥ 1e6 regardless of ISV state:

  • ISV = 0 → max_abs = 1e6 × max(0, 1.0) = 1e6
  • ISV = 0.5 → max_abs = 1e6 × 1.0 = 1e6
  • ISV = 2.0 → max_abs = 1e6 × 2.0 = 2e6
  • ISV = 100 → max_abs = 1e8

This preserves the F0 paper-review intent (1e6 = wide guard band) while removing the cold-start clamp pathology. F1 startup gradients ≤ 1e6 (which is the entire reasonable range) won't be clipped.

F0 regression — separate investigation thread within SP1. Per no-deferrals principle, must be addressed before SP1 can close. Likely root cause: Phase B instrumentation kernel-launch overhead OR graph-capture memory ordering. Investigation deferred to a Task 7.5 sub-task pending Task 6 fix-up validation.

Phase D revalidation — smoke-test-ckgv8 (commit ab2133463, ε-floor fix-up #2)

Workflow: smoke-test-ckgv8 (L40S, plain, ~16m run) Outcome: ε-floor diagnosis VALIDATED; structural F1 NaN at step 3540 reveals SP3 boundary.

Comparison across three SP1 smokes

Smoke Commit F0 Sharpe F1 NaN step F1 path F2 outcome
smoke-test-xvzgk (Phase B) f139a63ee 34.55 890 halt_grad_collapse cascade
smoke-test-dr2bn (B+C orig ε) 19b008e1c 35.24 240 halt_nan (HARD) cascade
smoke-test-ckgv8 (B+C ε fix-up) ab2133463 35.30 3540 halt_grad_collapse cascade

Diagnosis confirmed: ε-floor at fold boundary was the cold-start cause

Fix-up #2 (commit ab2133463) moved F1 NaN from step 240 → 3540 (15× later). This validates:

  • The original (1e6 × isv).max(1e3) formula was actively destabilising F1 startup by clipping legitimate gradients to ±1e3 when fold-boundary ISV reset (per StateResetRegistry).
  • New formula 1e6 × isv.max(1.0) removes the cold-start clamp pathology.
  • F1 trains for ~3500 steps before NaN'ing — a regime where the surgical clamp is doing its job.

Remaining F1 NaN at step 3540 — SP3 boundary identified

Same flagged signature [6, 12, 26, 32] at step 3540 with slots 27, 35 CLEAN. The cuBLAS GEMM in apply_iqn_trunk_gradient produces NaN despite bounded inputs (≤ 1e6). Diagnosis: the weights being multiplied have accumulated NaN/Inf via Adam updates over 3540 steps.

Pre-clamp on input + post-clamp on output cannot prevent this — both clamps act on the gradient buffer, not the parameter buffer that the GEMM multiplies against. Root cause is structural Q-learning instability:

  1. F1's Bellman-target regime shift drives Q-target inflation
  2. Adam EMAs (m, v) accumulate the inflated gradients
  3. Weight updates m_hat / (sqrt(v_hat) + eps) drift toward NaN/Inf as v_hat saturates
  4. Once weights have NaN/Inf, EVERY GEMM that multiplies them produces NaN/Inf

This is exactly the SP3 scope per the spec: "target-Q clipping or pessimistic ensemble", "atom-position growth bounds", "conservative target sync at fold boundary". The audit's Phase A summary (line ~480) already cross-referenced session_2026-04-05 finding that target-Q inflation was structural.

F0 regression — separate SP1.5 / SP2 thread

Three consecutive F0 ≈ 35 values vs single-observation baseline 55.87 indicate Phase B instrumentation has a real F0 training cost. Likely causes (in order of likelihood):

  1. Graph-capture overhead from 11 new check_nan_f32 launches per step. Each kernel has Rust-side launch overhead + CUDA scheduling cost; 11 extra launches per step ≈ 300-500μs/step latency, but more importantly the captured graph topology changes affect GPU pipeline parallelism. SP2 framework codification could batch all NaN checks into a single fused reduce kernel (1 launch instead of 11) eliminating most overhead.

  2. nan_flags_buf 24→48 buffer-allocation alignment effects. Larger flag buffer changes pinned-memory layout; if downstream allocations hash to different cache lines, training stochasticity could shift. Less likely (small effect).

  3. Stochastic variance. The 55.87 baseline was a single observation; F0 across many runs may naturally cluster at 35-55. To rule this out, would need ≥5 baseline F0 runs at e9096c7be. Out of scope for SP1.

SP2 framework codification opportunity: convert the 11 per-step NaN checks into a single fused kernel that scans all 12 backward-path slots in parallel. Should restore F0 to baseline AND preserve the regression sentinel.

F1+F2 monotone improvement criterion

Cannot evaluate criteria 4/5 (F1/F2 monotone improvement) — both folds early-stopped on grad-collapse before completing any clean epoch. The infrastructure to evaluate these criteria works correctly but the data needed (clean F1/F2 training runs) requires SP3 structural fix to produce.

SP1 closure decision

Closing SP1 with the following declared deliverables:

  • Permanent diagnostic infrastructure (slots 24-35) — verified working across 3 smokes
  • F1 NaN entry kernel pinpointed: apply_iqn_trunk_gradient cuBLAS sgemm + Bottleneck Linear bwd cuBLAS sgemm
  • Cold-start clamp pathology (ε-floor) diagnosed and fixed (1e6 × isv.max(1.0))
  • ⚠️ Structural F1 NaN at step 3540 — SP3 scope (weight pathology under Adam updates)
  • ⚠️ F0 regression to ~35 — SP2 scope (instrumentation overhead optimization)

The 7 SP1 pass criteria as originally specified are not all achievable within SP1's surgical-fix scope. Criteria 1, 2, 4, 5, 6, 7 require SP3 structural fix. Criterion 3 (F0 ≥ 53.08) requires SP2 instrumentation optimization.

Net SP1 outcome: the investigation produced the diagnostic infrastructure + pinpointed source kernels + fixed the cold-start clamp pathology that this scope CAN address. The remaining items are correctly classified for SP2/SP3 scopes per the spec's 3-sub-project decomposition.


Phase E — SP2 fused NaN check kernel (instrumentation overhead fix)

Status: SP2 implementation complete (Tasks A1-A4 on branch plan-c-phase-2-thompson). Gate 1 smoke pending (Task A6).

Change summary

Replaces 8 per-step dqn_nan_check_f32 launches in run_nan_checks_post_backward with a single launch of dqn_nan_check_fused_f32_kernel (1 kernel × 12 blocks). Slots 24-30 + 32 covered by the fused kernel; slot 31 deferred via null entry; slots 33-35 inline checks at backward orchestration phases stay separate (multi-point localization preserved).

Commits

Task SHA Scope
A1 0250722a0 Append dqn_nan_check_fused_f32_kernel to dqn_utility_kernels.cu
A2 82b6bd369 Add nan_check_buf_ptrs/nan_check_buf_lens device buffer fields
A3 fbf48df9d Refactor fields to MappedU64Buffer/MappedI32Buffer (eliminates HtoD); add populate_nan_check_meta + launch_nan_check_fused_f32; register kernel; call populate from constructor
A4 b5a064f6c Replace 8 individual launches in run_nan_checks_post_backward with single fused launch; simplify signature to no-args

Net delta: -109 lines (code shrinks via call-site collapse).

Mapped-pinned design

The metadata buffers (nan_check_buf_ptrs: MappedU64Buffer, nan_check_buf_lens: MappedI32Buffer) use cuMemHostAlloc(DEVICEMAP|PORTABLE) + cuMemHostGetDevicePointer_v2 — host-side write at construction, kernel reads via device-mapped pointer. Zero HtoD copies introduced. Per feedback_no_htod_htoh_only_mapped_pinned.

Sticky-flag semantics preserved

Kernel writes 1 on NaN, never clears. Slot 24-35 firing topology should be IDENTICAL to pre-SP2 (proven by F1 step-3540 regression test that the SP1 audit characterized).

Slots 33-35 inline checks (intentionally separate)

The fused kernel handles slots 24-30 + 32 (8 slots with concrete buffers) + slot 31 (null/deferred) + slots 33-35 (null entries — the inline checks fire at distinct backward orchestration phases for localization). Inline call sites:

  • Slot 33 (bw_d_h_s2_post_main): gpu_dqn_trainer.rs:18580 (post backward_full + branch concat)
  • Slot 34 (bw_d_h_s2_post_aux): gpu_dqn_trainer.rs:18601 (post aux_heads_backward SAXPY)
  • Slot 35 (bw_d_h_s2_post_iqn): gpu_dqn_trainer.rs:6939 (post IQN DtoD overwrite)

Consolidating slot 33-35 into the fused kernel would lose the multi-point snapshot — these stay as-is.

Expected validation outcome (Gate 1 smoke)

  • F0 Best Sharpe ≥ 53.08 (return to ~55 baseline expected, vs ~35 in 3 prior smokes)
  • Slots 24-35 firing topology unchanged at F1 step ~3540 (the existing regression test)
  • No new errors / warnings

If Gate 1 fails, halt before SP3 starts and re-investigate F0 cause (alignment effects? stochastic variance?).

Phase E Gate 1 result (smoke-test-bnbh4, commit e270b5cc4)

Outcome: PARTIAL PASS — proceeding to Phase B per pragmatic decision.

Per-criterion table:

Criterion Result Value
F0 ≥ 53.08 (strict) FAIL 46.58 (improved from ~35 baseline; ~30% gap closure)
Slot 24-35 sentinel intact PASS F1 NaN signature [6, 12, 26, 32] matches prior smokes; fused kernel correctly observes NaN entry
No new errors / warnings PASS cargo check clean; pre-existing warnings only

F0 trend across 4 post-Phase-B smokes:

Commit F0 Notes
f139a63ee (Phase B alone) 34.55 Baseline regression
19b008e1c (Phase B+C orig ε) 35.24 No change
ab2133463 (Phase B+C ε fix) 35.30 No change
e270b5cc4 (SP2 fused) 46.58 +11 points; ~30% gap closure

F1 NaN step variance: 65, 240, 890, 3540 across 4 smokes. Variance is large; F1 stability is structurally unstable (each smoke explores different trajectory). This is exactly what SP3 is designed to fix.

Decision rationale (Option B — pragmatic proceed):

  1. Fused-kernel diagnosis is partially validated (F0 recovered ~30% of gap).
  2. Sentinel infrastructure works correctly (slot 24-35 firing topology preserved).
  3. Remaining ~7-point F0 gap may be stochastic variance OR a smaller secondary contributor; characterizing it requires multiple smokes with no SP3 progress.
  4. SP3 mechanisms address F1+F2 NaN — independent of F0 regression. Proceeding to SP3 doesn't risk the F0 work; if SP3 improves F1+F2 AND restores F0 (via Adam EMA reset reducing trunk weight pathology that may also affect F0), great. If F0 stays ~46, document as residual SP1.5 thread.
  5. Strict halt would consume L40S smokes characterizing F0 variance with no clear payoff vs proceeding to SP3.

Phase B (SP3) starts at commit e270b5cc4.


Phase F — SP3 Q-learning structural stability (5 mechanisms)

Status: SP3 implementation complete (Tasks B1-B7 on branch plan-c-phase-2-thompson). Gate 2 smoke pending (Task B9).

Mechanism summary

# Mechanism Commit File(s)
Mech 1 Target-Q clipping at single-point source 27ed7daa6 gpu_dqn_trainer.rs
Mech 2 C51 atom-position growth bounds (3 sites) 96b77043e atoms_update_kernel.cu, iql_value_kernel.cu, experience_kernels.cu
Mech 3 Hard target sync at fold boundary pre-existing fused_training.rs::reset_for_fold
Mech 4 Comprehensive Adam EMA reset (+GpuAttention) ef429c25d gpu_attention.rs, fused_training.rs
Mech 5 Adam-centric diagnostic in slots 36-47 45e077188 + 5f7db7d8d dqn_utility_kernels.cu, gpu_dqn_trainer.rs, training_loop.rs
Mech 6 Anchored upper_bound on adaptive grad clip (100× multiplier) 48c25d999 gpu_dqn_trainer.rs
Mech 7 Per-element gradient clip in Adam reverted in f67ede94f (n/a)
Mech 8 slow_ema fold-boundary reset reverted in 8956c2fb7 (n/a)
Mech 9 Post-Adam ISV-driven weight clamp (all 5 Adam kernels) 8956c2fb7 (dqn) + close-out v2 (iqn/iql/attn/curiosity) dqn_utility_kernels.cu, iqn_dual_head_kernel.cu, iql_value_kernel.cu, attention_backward_kernel.cu, curiosity_training_kernel.cu, gpu_dqn_trainer.rs, gpu_iqn_head.rs, gpu_iql_trainer.rs, gpu_attention.rs, gpu_tlob.rs, gpu_curiosity_trainer.rs, gpu_experience_collector.rs, fused_training.rs, training_loop.rs, decision_transformer.rs
Mech 10 ISV-driven post-trunk h_s2 activation clamp + slot 49 sentinel (this commit) dqn_utility_kernels.cu, gpu_dqn_trainer.rs, fused_training.rs, training_loop.rs

Supporting: Task B1 (aee11f321) — 24 diagnostic accessors (Adam m/v + weight + target_q + atom_positions).

Mech 5 slot allocation (slots 36-49)

Slot Name Threshold Source
36 trunk_adam_m_max ≥ 100 × q_abs_ref_eff trunk Adam m slice
37 value_adam_m_max same value Adam m slice
38 branch_adam_m_max same branch Adam m slice
39 iqn_adam_m_max same IQN Adam m (separate buffer)
40 trunk_adam_v_max ≥ 1e6 × q_abs_ref_eff² trunk Adam v slice
41 value_adam_v_max same value Adam v slice
42 branch_adam_v_max same branch Adam v slice
43 iqn_adam_v_max same IQN Adam v (separate buffer)
44 trunk_weight_max ≥ 1e3 × q_abs_ref_eff trunk params slice
45 heads_weight_max same heads params slice
46 target_q_post_clip ≥ 95 × q_abs_ref_eff denoise_target_q_buf
47 atom_span_max ≥ 190 × q_abs_ref_eff per_sample_support
48 iqn_weight_max ≥ 1e3 × q_abs_ref_eff IQN online_params (SP3 close-out v2 — slot-26 GEMM blind spot, fold-1 step-3540 sentinel)
49 h_s2_max_abs ≥ 1e3 × h_s2_rms_ema_eff save_h_s2 post-trunk (SP3 Mech 10 — activation-clamp regression sentinel; clamp at 100×, diagnostic at 1000×; mirrors Mech 5 family pattern)

Slot 49 uses h_s2_rms_ema_eff = max(isv[H_S2_RMS_EMA_INDEX=96], 1.0) — distinct ISV base from slots 36-48 because h_s2 lives in pre-readout activation space, not Q space. Don't conflate the two ISV bases.

q_abs_ref_eff = max(isv[Q_ABS_REF_INDEX=16], 1.0) — ε on multiplier per SP1 pearl. Cold-start ISV (~0) → all thresholds floor at their multiplier × 1.

ISV slot bindings

SP3 Mechs 1-9 use existing Q_ABS_REF_INDEX = 16. Mech 10 reuses 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 slots.

Expected validation outcome (Gate 2 smoke)

All 7 SP1 pass criteria satisfied:

  1. Zero NaN-CLAMPED-TO-ZERO log lines
  2. Zero NaN/Inf at step errors
  3. F0 Best Sharpe ≥ 53.08 (currently ~46.58 from SP2 Gate 1; SP3 Mech 4 Adam EMA reset + Mech 3 target sync may further restore F0)
  4. F1 last-epoch ≥ first-epoch
  5. F2 last-epoch ≥ first-epoch
  6. Zero flagged slots throughout (slots 24-47 all remain at zero)
  7. All 3 folds complete 5 epochs

Diagnostic interpretation if Gate 2 fails

Per slot 36-47 firing pattern:

  • Slots 38-43 (Adam m/v) fire: Adam EMAs saturated despite reset; need more frequent resets OR post-Adam weight clamp
  • Slots 44-45 (weights) fire alone, Adam clean: direct weight drift; add post-Adam weight clamp
  • Slot 46 (target_q post-clip) NEVER fires: Mech 1 unnecessary defense; document, no action
  • Slot 47 (atom span) fires often: Mech 2 doing real work; no action
  • Slots 24-35 fire post-fix: SP3 missed pathology class; new investigation thread

Mech 8 reverted + Mech 9 added — post-Adam weight clamp

Mech 8 outcome (reverted 2026-04-30). Mech 8 added reset_grad_norm_slow_ema() zeroing the persistent α=0.001 slow-EMA on every fold boundary, intending to keep Mech 6's 100 × slow_ema × isv upper_bound anchored to the CURRENT fold's grad-norm regime. Smoke-test-rxhjh (HEAD b8a7ac6f7 = Mech 8 active) recorded F1 NaN at step 2300 — WORSE than the 3720-step ceiling that Mech 6 alone produced on smoke-test-fxvkk. Diagnosis: persisting slow_ema across folds (anchored at F0's smaller grad scale) was providing UNINTENTIONAL F1 protection. Mech 6's upper_bound stayed tight during F1 ramp-up because the cross-fold lag of the EMA half-life (~693 steps) kept the anchor small while F1 grad scale grew. Resetting the anchor loosened the bound during the very transient when F1 needed it tightest, and Adam EMAs saturated faster. Mech 8 reverted in this commit; the 100× multiplier in Mech 6 stays (already restored from the 5× experiment in the Mech 8 commit — correct steady-state value).

Mech 9 mechanism. Mech 1 (gradient clamp at target-Q source) and Mech 6 (gradient-norm clip via adaptive_clip) bound GRADIENTS, not PARAMETERS. The slot 26 (iqn_trunk_m) + slot 32 (bn_d_concat_buf) F1-step-3540 NaN signature has the cuBLAS sgemm INPUTS clean (slots 27, 35 stay at 0) — the GEMM output saturates because the WEIGHT tensors have drifted to extreme-but-finite magnitudes via Adam updates over thousands of steps. Once |w| > O(1e3 × Q_ABS_REF) and K ≈ 256 inner dim, the f32 accumulator under TF32 path overflows even with bounded inputs. Mech 9 closes the gap by clamping |p_val| directly inside dqn_adam_update_kernel after the L1 proximal step, before the writeback.

ISV-driven design. Bound = 100 × Q_ABS_REF.max(1.0). ε floor on the multiplier per the SP1 pearl (isv.max(1.0) not bound.max(constant) — flooring the bound itself re-introduces the cold-start clamp pathology SP1 closed). Q_ABS_REF_INDEX = 16 of the ISV signal bus, same slot already consumed by Mechs 1+2+5+6 — no new slot. Bound is one OOM below the slot 44-45 diagnostic threshold (1e3 × Q_ABS_REF) so the regression sentinel retains a 10× firing headroom — symmetric with Mech 1's clamp/diagnostic ratio.

Implementation. New trailing arg weight_clamp_max_abs on dqn_adam_update_kernel. CPU computes the bound host-side once per Adam launch from read_isv_signal_at(Q_ABS_REF_INDEX).max(1.0) × 100 and passes it as a scalar — keeps the kernel signature clean and avoids per-thread re-reading of the ISV buffer. Wired at all four Adam launch sites: main launch_adam_update (DQN params), step_selectivity_adam (selectivity gate), denoise-head Adam, OFI-embed Adam. 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, with no ISV bus access). Kernel branch if (bound > 0.0f) makes the disabled path zero-cost for DT.

Residual scope. If Mech 9 still leaves F1 NaN at similar steps, the pathology is not weight-magnitude growth and the next investigation should attack the GEMM accumulator path itself (TF32 disable, FP32 enforcement, or per-layer gain rescaling). Close SP3 honestly with the documented residual rather than tuning further.

Mech 10 — ISV-driven post-trunk h_s2 activation clamp

Outcome of smoke-test-2xrxk (HEAD 0d7e27d61 = SP3 close-out v2 active). F0 trained clean (Best Sharpe 45.47); 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: slot 48 (iqn_weight_max) clean, slots 39+43 (iqn_adam_m/v) clean, slots 44-45 (trunk/heads weights) clean.

Diagnosis. SP3 close-out v2 hypothesis (IQN partial refactor) was wrong. The actual cause: forward-pass activation overflow under Mech-9-clamped weights. Slot 12 (save_h_s2) firing was the smoking gun — h_s2 itself goes non-finite mid-forward. With Mech 9 clamping weights at 100 × Q_ABS_REF = 5000 and trunk inner-dim K = 256, forward GEMM accumulators compound by √K × |w|max ≈ 80000× per layer. With trunk depth ≥6 (encoder + VSN + GRN-blocks + bottleneck), the chain hits f32_max ≈ 3.4e38 somewhere. F0/F2 don't fail 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.

The protected surfaces before Mech 10. 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). The kernel does isfinite(v) ? clamp(v, ±max_abs) : 0.0f — NaN→0, Inf→0, finite clamped. NO new kernel — same helper used by Mech 9 sites.

ISV-driven design. H_S2_RMS_EMA_INDEX = 96 already tracks the per-batch RMS EMA of h_s2 (per Plan 4 Task 2c.3c.5; consumer wired in 2c.3c.6 for mag_concat_qdir adaptive scale). Reusing the existing slot keeps Mech 10 consistent with feedback_isv_for_adaptive_bounds.md (no new ISV slots, no hardcoded multipliers). The max(1.0) ε floor on the multiplier honours the SP1 cold-start convention — 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.

Diagnostic ordering (SP1 pearl). The inline NaN check on 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.

Slot 49 diagnostic. Mirrors the Mech 5 family pattern (clamp at 100×, diagnostic at 1000×). Threshold = 1e3 × h_s2_rms_ema_eff. Sentinel that NEVER fires under normal Mech 10 operation; if it does, the activation clamp is disabled or H_S2_RMS_EMA itself drifted.

Implementation surface.

  • dqn_utility_kernels.cudqn_nan_check_fused_f32_kernel gains a second ISV-derived float arg h_s2_rms_ema_eff; new branch for relative slot 25 (= absolute 49) with threshold 1e3 × h_s2_rms_ema_eff. Slots 12-24 keep their q_abs_ref_eff thresholds — distinct ISV bases.
  • gpu_dqn_trainer.rsnan_flags_buf 49→50; metadata buffers (nan_check_buf_ptrs, nan_check_buf_lens) 25→26 entries; populate_nan_check_meta writes (save_h_s2.raw_ptr(), save_h_s2.len()) at slot 49; launch_nan_check_fused_f32 reads H_S2_RMS_EMA_INDEX host-side and passes h_s2_rms_ema_eff as the second ISV arg; read_nan_flags() returns [i32; 50]; fused-kernel block count 25→26; clamp inserted at end of "1c. Temporal pipeline" block in submit_forward_ops_main (before launch_curiosity_inference).
  • fused_training.rsread_nan_flags() wrapper return type 49→50.
  • training_loop.rs — both name tables (halt_nan + halt_grad_collapse formatters) extended to 50 entries with "h_s2_max_abs" at slot 49.

Constraints honoured. No new ISV slots, no new kernels, no graph-topology surprise (one extra launch in the captured forward graph). Per feedback_no_partial_refactor, Mech 10 + slot 49 land in the same commit.

Validation. Deferred to one L40S smoke. Expected: F1 trains past step 1000; slots 36-43 + slot 49 stay quiet.