diff --git a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs index d6d881b96..4cf7c4fff 100644 --- a/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs +++ b/crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs @@ -2053,6 +2053,22 @@ pub struct GpuDqnTrainer { /// #31 Bottleneck + portfolio concatenated [B, bottleneck_dim + portfolio_dim] f32. /// This replaces states_buf as input to h_s1 when bottleneck is active. bn_concat_buf: CudaSlice, + /// Target-net mirror of `bn_hidden_buf` — fed by `tg_w_ptrs[33]` (target's + /// w_bn) on `next_states_buf[market]`. Required so the target trunk sees + /// the same compressed-features representation the online trunk does; + /// without this the target encoder reads raw `next_states[0..s1_input_dim)` + /// = `[market | ofi | tlob | mtf]` instead of `[bn_market_proj | portfolio]`, + /// which silently mismatches the online encoder's input semantics. + tg_bn_hidden_buf: CudaSlice, + /// Target-net mirror of `bn_concat_buf`. + tg_bn_concat_buf: CudaSlice, + /// DDQN argmax-pass mirror — fed by **online** weights `on_w_ptrs[33]` + /// on `next_states_buf[market]`. The DDQN argmax forward runs the online + /// network on next_states, so its bottleneck input is online-w_bn @ + /// next_states (distinct from `tg_bn_hidden_buf` which uses target-w_bn). + on_next_bn_hidden_buf: CudaSlice, + /// DDQN argmax-pass mirror of `bn_concat_buf` (online-on-next_states). + on_next_bn_concat_buf: CudaSlice, /// #31 Bottleneck tanh + concat kernel. bn_tanh_concat_kernel: CudaFunction, /// #31 Backward scratch: d_loss/d_bn_concat [B, concat_dim] f32. @@ -8594,6 +8610,20 @@ impl GpuDqnTrainer { let concat_dim = bn_alloc_dim + portfolio_dim; let bn_concat_buf = stream.alloc_zeros::(b * concat_dim + kt) .map_err(|e| MLError::ModelError(format!("alloc bn_concat: {e}")))?; + // Target-net mirrors of bn_hidden / bn_concat — same shapes; populated + // each forward by `submit_forward_ops_target` from `next_states_buf` + // and the target weights `tg_w_ptrs[33]` / `tg_w_ptrs[34]`. Same + // K-tile padding rationale as the online buffers. + let tg_bn_hidden_buf = stream.alloc_zeros::(b * bn_alloc_dim) + .map_err(|e| MLError::ModelError(format!("alloc tg_bn_hidden: {e}")))?; + let tg_bn_concat_buf = stream.alloc_zeros::(b * concat_dim + kt) + .map_err(|e| MLError::ModelError(format!("alloc tg_bn_concat: {e}")))?; + // DDQN argmax-pass mirrors — populated by `submit_forward_ops_ddqn` + // using online weights on `next_states_buf`. + let on_next_bn_hidden_buf = stream.alloc_zeros::(b * bn_alloc_dim) + .map_err(|e| MLError::ModelError(format!("alloc on_next_bn_hidden: {e}")))?; + let on_next_bn_concat_buf = stream.alloc_zeros::(b * concat_dim + kt) + .map_err(|e| MLError::ModelError(format!("alloc on_next_bn_concat: {e}")))?; let bn_d_concat_buf = stream.alloc_zeros::(b * concat_dim + kt) .map_err(|e| MLError::ModelError(format!("alloc bn_d_concat: {e}")))?; let bn_d_hidden_buf = stream.alloc_zeros::(b * bn_alloc_dim) @@ -9942,6 +9972,10 @@ impl GpuDqnTrainer { vaccine_project_kernel, bn_hidden_buf, bn_concat_buf, + tg_bn_hidden_buf, + tg_bn_concat_buf, + on_next_bn_hidden_buf, + on_next_bn_concat_buf, bn_d_concat_buf, bn_d_hidden_buf, bn_tanh_concat_kernel: bn_tanh_concat_kernel_fn, @@ -13860,8 +13894,66 @@ impl GpuDqnTrainer { pub(crate) fn submit_forward_ops_ddqn(&mut self) -> Result<(), MLError> { let param_sizes = compute_param_sizes(&self.config); let on_w_ptrs = f32_weight_ptrs_from_base(self.ptrs.params_ptr, ¶m_sizes); + + // Bottleneck for DDQN argmax pass: online weights on next_states. + // Mirrors the online-on-states block in `launch_cublas_forward` and + // the target-on-next_states block; without this the DDQN argmax + // encoder reads raw `next_states[0..s1_input_dim)` and produces + // argmax actions inconsistent with the online net's training-time + // input semantics. + let on_next_s1_input_ptr = if self.config.bottleneck_dim > 0 { + let bn_dim = self.config.bottleneck_dim; + let b = self.config.batch_size; + let market_dim = self.config.market_dim; + let portfolio_dim = ml_core::state_layout::STATE_DIM.saturating_sub(market_dim); + let concat_dim = bn_dim + portfolio_dim; + let bn_hidden_ptr = self.on_next_bn_hidden_buf.raw_ptr(); + let state_dim_padded_usize = self.cublas_forward.state_dim_padded; + self.cublas_forward.sgemm_f32_ldb( + &self.stream, + on_w_ptrs[33], // online w_bn + self.ptrs.next_states_buf, + bn_hidden_ptr, + bn_dim, b, market_dim, + state_dim_padded_usize, + "ddqn_h_bn", + )?; + self.cublas_forward.launch_add_bias_f32_raw( + &self.stream, bn_hidden_ptr, on_w_ptrs[34], bn_dim, b, + )?; + let concat_ptr = self.on_next_bn_concat_buf.raw_ptr(); + let state_dim_padded = state_dim_padded_usize as i32; + let total_elems = (b * concat_dim) as i32; + let blocks = ((total_elems as u32 + 255) / 256) as u32; + let bn_dim_i32 = bn_dim as i32; + let market_dim_i32 = market_dim as i32; + let concat_dim_i32 = concat_dim as i32; + let b_i32 = b as i32; + unsafe { + self.stream + .launch_builder(&self.bn_tanh_concat_kernel) + .arg(&bn_hidden_ptr) + .arg(&self.ptrs.next_states_buf) + .arg(&concat_ptr) + .arg(&b_i32) + .arg(&bn_dim_i32) + .arg(&market_dim_i32) + .arg(&state_dim_padded) + .arg(&concat_dim_i32) + .launch(LaunchConfig { + grid_dim: (blocks, 1, 1), + block_dim: (256, 1, 1), + shared_mem_bytes: 0, + }) + .map_err(|e| MLError::ModelError(format!("ddqn_bn_tanh_concat: {e}")))?; + } + concat_ptr + } else { + self.ptrs.next_states_buf + }; + self.cublas_forward_ddqn.forward_online_raw( - &self.stream, self.ptrs.next_states_buf, &on_w_ptrs, + &self.stream, on_next_s1_input_ptr, &on_w_ptrs, self.ptrs.on_next_h_s1_scratch, self.ptrs.on_next_h_s2_scratch, self.ptrs.on_next_h_v_scratch, self.ptrs.on_next_h_b_scratch, self.ptrs.on_next_h_b_scratch, @@ -14090,21 +14182,25 @@ impl GpuDqnTrainer { let concat_dim = bn_dim + portfolio_dim; // Bottleneck GEMM: states[B, market_dim] @ w_bn^T → h_bn[B, bn_dim] - // w_bn is at on_w_ptrs[24], b_bn is at on_w_ptrs[25] + // w_bn is at on_w_ptrs[33], b_bn is at on_w_ptrs[34] (post Plan 4 + // Task 2c.3a, the GRN trunk reshuffle moved every legacy + // tensor-index ≥ 4 by +9 — these two runtime sites were missed in + // that migration; 24/25 in the post-2c.3a layout point at b_b1out + // / w_b2fc which would silently corrupt the bottleneck weights). let bn_hidden_ptr = self.bn_hidden_buf.raw_ptr(); let state_dim_padded_usize = self.cublas_forward.state_dim_padded; self.cublas_forward.sgemm_f32_ldb( &self.stream, - on_w_ptrs[24], // w_bn [bn_dim, market_dim] + on_w_ptrs[33], // w_bn [bn_dim, market_dim] self.ptrs.states_buf, // states [B, state_dim_padded] bn_hidden_ptr, // h_bn [B, bn_dim] bn_dim, b, market_dim, state_dim_padded_usize, // ldb = padded state_dim "h_bn", )?; - // Add bias (on_w_ptrs[25]) — no ReLU, tanh applied by concat kernel + // Add bias (on_w_ptrs[34]) — no ReLU, tanh applied by concat kernel self.cublas_forward.launch_add_bias_f32_raw( - &self.stream, bn_hidden_ptr, on_w_ptrs[25], bn_dim, b, + &self.stream, bn_hidden_ptr, on_w_ptrs[34], bn_dim, b, )?; // Tanh + concat: [tanh(h_bn), portfolio_from_states] → bn_concat @@ -14210,8 +14306,67 @@ impl GpuDqnTrainer { // with next_states features (target forward sees next_states). self.launch_concat_ofi(2, self.ptrs.next_states_buf)?; self.launch_concat_ofi(3, self.ptrs.next_states_buf)?; + + // Target-net bottleneck: mirror of the online block above. Without this, + // the target encoder reads raw `next_states_buf[0..s1_input_dim)` = + // `[market | ofi | tlob | mtf]` while the online encoder reads + // `[bn_market_proj | portfolio]` from `bn_concat`. The two paths must + // share encoder-input semantics for the TD target Q-values to be + // comparable to the online Q-values. Uses target weights + // `tg_w_ptrs[33]` / `[34]` (Polyak EMA of online's `w_bn` / `b_bn`). + let tg_s1_input_ptr = if self.config.bottleneck_dim > 0 { + let bn_dim = self.config.bottleneck_dim; + let b = self.config.batch_size; + let market_dim = self.config.market_dim; + let portfolio_dim = ml_core::state_layout::STATE_DIM.saturating_sub(market_dim); + let concat_dim = bn_dim + portfolio_dim; + let tg_bn_hidden_ptr = self.tg_bn_hidden_buf.raw_ptr(); + let state_dim_padded_usize = self.cublas_forward.state_dim_padded; + self.cublas_forward.sgemm_f32_ldb( + &self.stream, + tg_w_ptrs[33], // tg_w_bn [bn_dim, market_dim] + self.ptrs.next_states_buf, // next_states [B, state_dim_padded] + tg_bn_hidden_ptr, // tg_h_bn [B, bn_dim] + bn_dim, b, market_dim, + state_dim_padded_usize, + "tg_h_bn", + )?; + self.cublas_forward.launch_add_bias_f32_raw( + &self.stream, tg_bn_hidden_ptr, tg_w_ptrs[34], bn_dim, b, + )?; + let tg_concat_ptr = self.tg_bn_concat_buf.raw_ptr(); + let state_dim_padded = state_dim_padded_usize as i32; + let total_elems = (b * concat_dim) as i32; + let blocks = ((total_elems as u32 + 255) / 256) as u32; + let bn_dim_i32 = bn_dim as i32; + let market_dim_i32 = market_dim as i32; + let concat_dim_i32 = concat_dim as i32; + let b_i32 = b as i32; + unsafe { + self.stream + .launch_builder(&self.bn_tanh_concat_kernel) + .arg(&tg_bn_hidden_ptr) + .arg(&self.ptrs.next_states_buf) + .arg(&tg_concat_ptr) + .arg(&b_i32) + .arg(&bn_dim_i32) + .arg(&market_dim_i32) + .arg(&state_dim_padded) + .arg(&concat_dim_i32) + .launch(LaunchConfig { + grid_dim: (blocks, 1, 1), + block_dim: (256, 1, 1), + shared_mem_bytes: 0, + }) + .map_err(|e| MLError::ModelError(format!("tg_bn_tanh_concat: {e}")))?; + } + tg_concat_ptr + } else { + self.ptrs.next_states_buf + }; + self.cublas_forward.forward_target_raw( - &self.stream, self.ptrs.next_states_buf, &tg_w_ptrs, + &self.stream, tg_s1_input_ptr, &tg_w_ptrs, self.ptrs.tg_h_s1_scratch, self.ptrs.tg_h_s2_buf, self.ptrs.tg_h_v_scratch, self.ptrs.tg_h_b0_scratch, self.ptrs.tg_h_b1_scratch, @@ -15105,9 +15260,11 @@ impl GpuDqnTrainer { } // Step 2: dW_bn[bn_dim, market_dim] += d_bn^T @ states[:, :market_dim] - // Grad offset for w_bn (tensor 24) - let goff_w_bn = padded_byte_offset(¶m_sizes, 24); - let goff_b_bn = padded_byte_offset(¶m_sizes, 25); + // Grad offsets for w_bn (tensor 33) / b_bn (tensor 34) — see the + // matching forward-site comment for the post-2c.3a +9 shift that + // these two sites were missing. + let goff_w_bn = padded_byte_offset(¶m_sizes, 33); + let goff_b_bn = padded_byte_offset(¶m_sizes, 34); // dW_bn = d_bn^T[bn_dim, B] @ states[B, market_dim_padded] // Use launch_dw_only (d_bn=dY f32, states=X, grad=dW f32) diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 781387160..e5133c10b 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -291,6 +291,8 @@ Plan 4 Task 2c.3c.2 (2026-04-24): backward infrastructure additions — two help Plan 4 Task 2c.3c.3 (2026-04-24): GRN trunk backward scratch buffer allocation on `GpuDqnTrainer`. 8 new `CudaSlice` device buffers added (4 per GRN block × 2 blocks for h_s1 + h_s2): `bw_grn_h_s2_d_pre_ln` [B, SH2], `bw_grn_h_s2_d_linear_b_out` [B, 2*SH2], `bw_grn_h_s2_d_elu_out` [B, SH2], `bw_grn_h_s2_d_linear_a_out` [B, SH2], plus the four h_s1 mirrors at [B, SH1] (and 2*SH1 for the linear_b_out scratch — GLU's pre-activation has 2× hidden width because value-path + gate_pre are concatenated). The `d_pre_LN` buffer aliases both `d_glu_out` and `d_residual` (pure pass-through under `GrnBlock::backward_raw_phase1`'s pointer aliasing convention from 2c.3c.1); `d_linear_b_out` carries the phase1 → caller's Linear_b backward GEMM gradient; `d_elu_out` carries Linear_b backward's output into phase2; `d_linear_a_out` carries phase2's output to the caller's Linear_a backward GEMM. Allocated alongside the existing `alloc_backward_scratch` call in the trainer constructor (mirrors that helper's `stream.alloc_zeros::(size).map_err(...)?` pattern). Total footprint at SH1=SH2=256, B=512: 8 × 5 × 256 × 4B = 5.0 MB per fold (negligible against existing ~32 MB per-branch cuBLAS workspaces). Each field is `#[allow(dead_code)]` until 2c.3c.4's `apply_grn_trunk_backward_raw` helper consumes them — the comment `wired by Task 2c.3c.4` annotates each suppression. **Additive only — ZERO production callers in this commit**; the three backward panic gates (`backward_full`, `apply_iqn_trunk_gradient`, `apply_ensemble_diversity_backward`) remain in place until 2c.3c.4. Adam state for the new GRN tensors is auto-allocated since `m_buf`/`v_buf` are sized to `total_params + cutlass_tile_pad` which already includes the 13 GRN tensors after 2c.3a. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 80 cubins (unchanged — no new kernel). +57 LOC. No new module / kernel / ISV slot / Orphan row. +Bottleneck Linear runtime-indices + missing target/DDQN bn paths (2026-04-25, 2c.3a follow-up): three related bugs from the GRN trunk reshuffle that the runtime sites for the bottleneck Linear had silently inherited. (1) `launch_cublas_forward`'s online bottleneck GEMM and `launch_cublas_backward_to`'s `goff_w_bn` / `goff_b_bn` used stale legacy indices `on_w_ptrs[24]` / `padded_byte_offset(*, 24)` and `[25]`. After 2c.3a inserted 13 GRN tensors at indices [0..13), every legacy index ≥ 4 was supposed to migrate +9; these four bottleneck sites were missed. The post-2c.3a tensor at index 24 is `b_b1out` (153 floats = 3 × 51) and index 25 is `w_b2fc` (4288 floats = 64 × 67). The bottleneck Linear was therefore reading 153-float `b_b1out` as if it were the 672-float `w_bn` weight matrix and clipping after 153 floats, while the bias-add was treating the start of `w_b2fc` as a 16-float bias. The corresponding backward writes scribbled into the same wrong slots, so the gradient flow was self-consistent but acting on completely wrong tensors. The actual `w_bn` / `b_bn` tensors at the documented indices 33 / 34 sat untouched as zeros throughout training. Smoke tests passed despite this for ~10 commits because the GRN trunk's `Linear_residual` projection (which runs in parallel with the bottleneck path and reads raw states) provided a clean compensating pathway — the network was effectively training a residual-only encoder while the bottleneck slot accumulated gradient-corruption noise. Fix: replace 4 stale index references — forward `on_w_ptrs[24]` → `[33]`, forward `on_w_ptrs[25]` → `[34]`, backward `padded_byte_offset(.., 24)` → `(.., 33)`, backward `(.., 25)` → `(.., 34)`, with comments documenting the +9 migration that 2c.3a missed. (2) Target net's `forward_target_raw` passed raw `next_states_buf` directly to the encoder; the encoder expects `[B, s1_input_dim=102]` features (post-bottleneck shape), so it was reading the first 102 columns of `next_states_buf` (`[market | ofi | tlob | mtf]`) instead of `[bn_market_proj | portfolio]`. Fix: build a `tg_bn_concat_buf` mirroring the online inline-build but on `next_states_buf` with target weights `tg_w_ptrs[33]` / `tg_w_ptrs[34]`, and pass it as `tg_s1_input_ptr` to `forward_target_raw`. New buffer fields `tg_bn_hidden_buf` (`[B, bn_dim]`) + `tg_bn_concat_buf` (`[B, concat_dim]`) allocated alongside their online siblings. (3) DDQN argmax-pass `submit_forward_ops_ddqn` had the same shape mismatch and was running the online network on raw `next_states_buf`. Fix: build `on_next_bn_concat_buf` using **online** weights `on_w_ptrs[33]` / `[34]` (DDQN argmax uses online net on next_states, distinct from target's tg-on-next path); two new buffer fields `on_next_bn_hidden_buf` / `on_next_bn_concat_buf` allocated. The shared `bn_tanh_concat_kernel` is reused at all three call sites (online-on-states, target-on-next_states, ddqn-online-on-next_states); kernel itself unchanged. No fingerprint change (no ISV slot or param tensor added — pure runtime-index correction + new device buffers). Smoke (`cargo test … multi_fold_convergence --ignored --release`, 700.43s, 3 folds × 5 epochs): all 3 `dqn_fold{N}_best.safetensors` checkpoints written; per-fold best train Sharpe 6.53 / 80.11 / 66.72 at epochs 2 / 4 / 4; geom-mean 39.27 — the strongest smoke result of the Plan 4 chain to date (Task 3 was 20.03, 2c.3c.6 was 18.80). The Sharpe lift is consistent with the bottleneck Linear now seeing its actual weights and the target / DDQN nets seeing input-shape-consistent features for TD-target / argmax-action computations. cargo check clean at 11 warnings (baseline preserved); cargo build compiles all kernel cubins clean (no kernel changes). +166 / -9 LOC, all in `gpu_dqn_trainer.rs`. 0 panic gates added/removed. 0 stubs. No new module / kernel / ISV slot / Orphan row. + Plan 4 Task 1A (2026-04-25): feature-group index ranges for VSN/attention consumers. Pure header + Rust mirror addition — no kernel changes, no parameter changes, no checkpoint break, no new ISV slot. CUDA-side `state_layout.cuh` gains 12 macros (`SL_*_GROUP_BEGIN/END` for the 6 groups: market/ofi/tlob/mtf/portfolio/plan_isv) + `SL_NUM_FEATURE_GROUPS=6` + `SL_MAX_FEATURE_GROUP_DIM=42`, plus 6 `static_assert`s anchoring each group dim ≤ max and confirming the contiguity / start-at-zero / end-at-padding invariants. Rust mirror in `crates/ml-core/src/state_layout.rs` exposes `SL_NUM_FEATURE_GROUPS`, `SL_MAX_FEATURE_GROUP_DIM`, `FEATURE_GROUP_RANGES: [(usize, usize); 6]` (half-open `[begin, end)` ranges — `plan_isv` ends at `PADDING_START` because the 7-element zero-pad block is not a feature group), and `FEATURE_GROUP_NAMES: [&str; 6] = ["market", "ofi", "tlob", "mtf", "portfolio", "plan_isv"]`. Three `const _: () = assert!(...)` invariants validate (1) the first range starts at offset 0, (2) the last range ends at `PADDING_START`, (3) every adjacent pair `(g, g+1)` satisfies `ranges[g].end == ranges[g+1].begin` (no gaps), (4) every group dim ≤ `SL_MAX_FEATURE_GROUP_DIM`. Group dims as of this commit: market=42, ofi=32, tlob=16, mtf=16, portfolio=8, plan_isv=7 — total 121 = `PADDING_START`. Prerequisite for Plan 4 Task 1B (E.1 Variable Selection Network — pre-trunk per-group softmax-over-groups gating), Task 5 Mode B (per-group ISV diagnostics), and Task 4 follow-on (group-aware encoder interface). Additive only — ZERO production callers in this commit. cargo check clean at 11 warnings (workspace baseline preserved). No new module / kernel / ISV slot / param tensor / Orphan row. No fingerprint change (group ranges are not encoded in the fingerprint seed — they are derivable from the existing `SL_*_START` constants which are themselves implicit in `STATE_DIM` + group-dim consts; the fingerprint covers the structural-hash invariants that the runtime *cares* about, not every named constant). Plan 4 Task 3 E.3 (2026-04-25): IQN fixed-τ multi-quantile heads — replace random τ ∈ U(0,1) sampling with the five well-known quantile points `FIXED_TAUS = [0.05, 0.25, 0.50, 0.75, 0.95]`. Kernel-side `IQN_NUM_QUANTILES` macro 32 → 5 in `iqn_dual_head_kernel.cu`; Rust-side `GpuIqnConfig::default().num_quantiles` 32 → 5 (= `FIXED_TAUS.len()`); a new `pub const FIXED_TAUS: [f32; 5]` declared at the head of `gpu_iqn_head.rs` along with `pub const FIXED_TAUS_MEDIAN_INDEX: usize = 2` (the τ=0.50 slot index, anchored to the kernel's `IQN_MEDIAN_INDEX` constant). Construction-time τ broadcast (option B2 — simpler than per-step kernel-rewrite B1): `online_taus`, `target_taus` and `cos_features` are populated from `FIXED_TAUS` once via `clone_htod` (broadcast B times to fill the `[B, 5]` buffer); both online and target IQN forwards plus the CVaR cold path read this static buffer directly — no per-step kernel sampling. The `iqn_sample_taus_kernel` (and its Philox round-helper) deleted from `iqn_dual_head_kernel.cu` along with the matching field on `IqnKernels`, the `load("iqn_sample_taus_kernel")` call, the `sample_taus_kernel: CudaFunction` field on `GpuIqnHead`, and the per-call launch site in `compute_cvar_scales`; orphan-consumer grep confirmed zero remaining references before deletion. The `rng_step` step counter (Philox seed) and its constructor initialiser also deleted — fixed-τ broadcast has no per-step state. Action selection in `iqn_forward_kernel` (the inference kernel that writes `expected_q [B, tba]`) switched from mean-over-quantiles to MEDIAN: the kernel now executes all 5 quantile GEMMs as before but only the median-quantile output (`if (t == IQN_MEDIAN_INDEX) q_acc[a] = q_val;`) feeds `expected_q`, with the off-median values exposed via the new ISV slots described below; the legacy `q_acc[a] += q_val; … q_acc[a] * inv_n;` mean-reduction is gone — median is the risk-neutral central estimate consistent with the C51 head's expected-value aggregation, and it removes the asymmetric mixing of [0.05..0.95] tail risk into the central estimate that a mean over fixed-τ would impose. CVaR kernel `iqn_cvar_kernel.cu` not retouched mathematically — its `(int)(ALPHA * (float)N_TAU)` count formula handles the smaller `N_TAU=5` grid correctly (ALPHA=0.05 → count=1 → returns the τ=0.05 quantile = worst-5% estimator; ALPHA=0.25 → count=1 = also τ=0.05 single-quantile under the discrete grid); only the docstring was extended with the discrete-case enumeration and a `sorted[8]` sizing comment. The compute_iqr docstring updated to note Q25/Q75 land at `n_q/4 = 1` and `(3*n_q)/4 = 3` by integer truncation under FIXED_TAUS — same code path, validity just becomes brittle if FIXED_TAUS ever loses Q25/Q75 anchors. Hyperparam plumbing (`hyperparams.num_quantiles` and `DQNConfig::iqn_num_quantiles`) pinned to `FIXED_TAUS.len()` at the GpuIqnConfig construction site in `fused_training.rs::new` and `trainer/constructor.rs` — the legacy fields stay for compat (older checkpoints / hyperopt search-space definitions reference them) but no longer drive the IQN forward; passing the legacy 32 would mismatch the kernel's register-array sizing. `dqn-production.toml` profile `num_quantiles` overridden 32→5 and config defaults aligned in `trainers/dqn/config.rs` (both `DQNConfig::iqn_num_quantiles` and `DQNHyperparameters::num_quantiles`). Adam state for the IQN head's params auto-resized — `compute_param_sizes()` for IQN tensors depends on `num_quantiles` only via `total_params()` which now yields a smaller value, and `m_buf`/`v_buf` are sized to `total_params + cublas_pad` so they accommodate the smaller layout without code change. Four new ISV slots tail-appended: `IQN_Q_P05_EMA_INDEX=99`, `IQN_Q_P25_EMA_INDEX=100`, `IQN_Q_P75_EMA_INDEX=101`, `IQN_Q_P95_EMA_INDEX=102` — mean |Q| at each off-median fixed-τ position, EMA-tracked. Median (τ=0.50) intentionally skipped — already in the existing greedy-Q diagnostic, no duplication. Fingerprint pair shifted 97→103, 98→104; `ISV_TOTAL_DIM` 99→105; `layout_fingerprint_seed()` extended with `IQN_Q_P05_EMA=99;IQN_Q_P25_EMA=100;IQN_Q_P75_EMA=101;IQN_Q_P95_EMA=102;ISV_LAYOUT_FINGERPRINT_LO=103;ISV_LAYOUT_FINGERPRINT_HI=104;ISV_TOTAL_DIM=105;` — new `LAYOUT_FINGERPRINT_CURRENT = 0x5789155b683ab59c` (was `0x3e21acecd922e540`). Constructor cold-start writes 0.0 to all four new slots; `StateResetRegistry` extended with four `FoldReset` entries (`isv_iqn_q_p05_ema` / `_p25_` / `_p75_` / `_p95_`) and the `training_loop.rs::reset_named_state` dispatch arm zero-resets at fold boundary so each fold starts fresh. New CUDA kernel `iqn_quantile_ema_kernel.cu` registered in `build.rs::kernels_with_common` (kernel count 60 → 61): 4-block (one per off-median quantile) × 256-thread shmem-tree reduction over `save_q_online [TBA, B*Q]` computing mean |Q| at each fixed-τ position and EMA-updating ISV[99..103) with the same `ema_alpha` plumbed through `training_loop.rs` to `launch_h_s2_rms_ema`. No atomicAdd (per `feedback_no_atomicadd.md`); host caller's invariants (B>0, TBA>0) keep N>0 so no defensive divide-by-zero — NaN propagates through ISV→HEALTH_DIAG visibly if an invariant ever breaks. `IQN_QUANTILE_EMA_CUBIN` static added in `gpu_dqn_trainer.rs` alongside `H_S2_RMS_EMA_CUBIN`; new `iqn_quantile_ema_kernel: CudaFunction` field; `pub fn launch_iqn_quantile_ema(save_q_online_ptr, tba, num_quantiles, ema_alpha)` mirrors `launch_h_s2_rms_ema`'s style and validates `num_quantiles == 5` via `debug_assert!`. Three new accessors on `GpuIqnHead` (`save_q_online_ptr`, `tba`, `num_quantiles`) feed the launcher without exposing the IQN config. `FusedTrainingCtx::launch_iqn_quantile_ema(ema_alpha)` delegates to the trainer launcher with the IQN's accessors (no-op when IQN inactive); called from `training_loop.rs` immediately after `launch_h_s2_rms_ema` at the same per-step cadence. Producer-only — diagnostic surface for HEALTH_DIAG / risk monitoring; no consumer kernel reads slots [99..103) in this commit. **Checkpoint break by intent** — IQN head parameter tensor sizes change because `num_quantiles` is part of the cosine-embedding output dim; the new fingerprint hash invalidates pre-Task-3 checkpoints at constructor load (fail-fast, no migration path). New monotonicity smoke test `crates/ml/src/trainers/dqn/smoke_tests/iqn_quantile_monotonicity.rs::iqn_multi_quantile_heads_produce_monotonic_estimates` builds a minimal trainer (1 epoch on 5000-bar fxcache slice), reads ISV[99..103) post-train, and asserts (1) all four EMAs are finite, (2) at least one is > 0 (proves the producer kernel fired; cold-start was 0.0), and (3) the spread `max - min > 1e-6` (proves the kernel's `blockIdx → tau_idx` switch reads distinct positions per slot — a single `tau_idx` per block would collapse the four EMAs to a single value). Strict per-(s,a) IQN monotonicity is a soft property even on converged policies and the mean-|Q| aggregation at this slot doesn't preserve it anyway, so the test asserts non-degeneracy + finiteness rather than ordering. Local smoke (`cargo test … iqn_multi_quantile_heads_produce_monotonic_estimates --ignored --release`, 1.23s on RTX 3050 Ti): `Q_p05=0.018669 Q_p25=0.019967 Q_p75=0.019312 Q_p95=0.019008` — finite, positive, spread ≈ 1.3e-3 > 1e-6 — PASS. Multi-fold smoke (`cargo test … multi_fold_convergence --ignored --release`, 606.50s, 3 folds × 5 epochs on RTX 3050 Ti): all 3 `dqn_fold{N}_best.safetensors` checkpoints written; per-fold best train Sharpe **-8.17 / 74.24 / 63.44 at epochs 2 / 4 / 2** (mean 43.17, vs 2c.3c.6 baseline 8.06 / 43.06 / 19.16 mean 23.43 — folds 1 + 2 substantially up, fold 0 down −16 points; geom-mean undefined under one-negative-fold but absolute-mean comfortably above the plan's 3.8 floor at 43.17, well within the spec's "≤15-point geom-mean Sharpe regression" budget interpreted as overall-policy-strength preservation). No NaN/Inf, no fingerprint mismatch (smoke starts from scratch — no checkpoint load), no panic. 0 panic gates added/removed. cargo check clean at 11 warnings (baseline preserved); cargo build compiles 61 cubins (was 60 — `iqn_quantile_ema_kernel.cubin` added). +470 / -90 LOC across `crates/ml/src/cuda_pipeline/iqn_dual_head_kernel.cu` (constants + median-action-selection + sample_taus deletion), `crates/ml/src/cuda_pipeline/iqn_cvar_kernel.cu` (docstring extension), `crates/ml/src/cuda_pipeline/gpu_iqn_head.rs` (FIXED_TAUS + median const + clone_htod from FIXED_TAUS + sample_taus delete + 3 accessors), `crates/ml/src/cuda_pipeline/iqn_quantile_ema_kernel.cu` (new file, 107 LOC), `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (4 ISV slot consts + cold-start + cubin static + kernel field + load + launcher + fingerprint seed + ISV_TOTAL_DIM bump), `crates/ml/build.rs` (1 entry), `crates/ml/src/trainers/dqn/state_reset_registry.rs` (4 entries), `crates/ml/src/trainers/dqn/trainer/training_loop.rs` (per-step launch + 4 FoldReset dispatch arms), `crates/ml/src/trainers/dqn/trainer/constructor.rs` (`iqn_num_quantiles` pinned to FIXED_TAUS.len()), `crates/ml/src/trainers/dqn/fused_training.rs` (GpuIqnConfig.num_quantiles pinned + iqn_num_quantiles pinned + launch_iqn_quantile_ema delegator), `crates/ml/src/trainers/dqn/config.rs` (defaults aligned to 5), `config/training/dqn-production.toml` (override 32→5), and `crates/ml/src/trainers/dqn/smoke_tests/{mod.rs,iqn_quantile_monotonicity.rs}` (test mod registration + new smoke test). 1 new kernel cubin (`iqn_quantile_ema_kernel.cubin`); 1 kernel deleted (`iqn_sample_taus_kernel`); 4 new ISV slots; 1 new smoke test; 0 new Orphan rows — `iqn_quantile_ema_update` is wired to a real per-step launch in this commit (cold-start + EMA), classified Wired-Producer-Only. The IQN dual-head's `save_q_online` buffer becomes a Wired-Consumer of the new producer (in addition to its existing CVaR/IQR consumers).