diff --git a/crates/ml-core/src/state_layout.rs b/crates/ml-core/src/state_layout.rs index 7e7f624f5..e0d1bbe98 100644 --- a/crates/ml-core/src/state_layout.rs +++ b/crates/ml-core/src/state_layout.rs @@ -162,3 +162,83 @@ pub const MAG_QUARTER: usize = 0; // 0.25× max_position pub const MAG_HALF: usize = 1; // 0.50× max_position pub const MAG_FULL: usize = 2; // 1.00× max_position pub const NUM_MAGNITUDES: usize = 3; + +// ──────────────────────────────────────────────────────────────────────────── +// Feature-group ranges — Plan 4 Task 1A (E.1 VSN prerequisite). +// +// Mirrors the `SL_*_GROUP_BEGIN/END` macros in +// `crates/ml/src/cuda_pipeline/state_layout.cuh`. Ranges are half-open +// `[begin, end)`. The last group (`plan_isv`) ends at `PADDING_START` — +// the 7-element zero-pad block is *not* a feature group. +// +// Consumers: +// * Plan 4 Task 1 (E.1) Variable Selection Network — gates each group's +// features by a learned softmax-over-groups mask before the trunk. +// * Plan 4 Task 5 (E.5) attention-weight ISV diagnostics. +// * Plan 4 Task 4 (E.4) encoder-decoder separation — group-aware +// `StateEncoder` interface. +// ──────────────────────────────────────────────────────────────────────────── + +pub const SL_NUM_FEATURE_GROUPS: usize = 6; + +/// Largest per-group dimension (currently `MARKET_DIM = 42`). Used for kernel- +/// side fixed-size scratch arrays so every group fits a uniform stride. +pub const SL_MAX_FEATURE_GROUP_DIM: usize = MARKET_DIM; + +/// `[begin, end)` ranges per feature group, in declaration order. +/// Order matches `FEATURE_GROUP_NAMES`. +pub const FEATURE_GROUP_RANGES: [(usize, usize); SL_NUM_FEATURE_GROUPS] = [ + (MARKET_START, OFI_START), // [0] market — 42 dims + (OFI_START, TLOB_START), // [1] ofi — 32 dims + (TLOB_START, MTF_START), // [2] tlob — 16 dims + (MTF_START, PORTFOLIO_START), // [3] mtf — 16 dims + (PORTFOLIO_START, PLAN_ISV_START), // [4] portfolio — 8 dims + (PLAN_ISV_START, PADDING_START), // [5] plan_isv — 7 dims +]; + +/// Group names — index-aligned with `FEATURE_GROUP_RANGES`. Used for ISV +/// diagnostic labels (`VSN_MASK_GROUP__EMA`) and audit-doc rows. +pub const FEATURE_GROUP_NAMES: [&str; SL_NUM_FEATURE_GROUPS] = [ + "market", + "ofi", + "tlob", + "mtf", + "portfolio", + "plan_isv", +]; + +// ── Compile-time invariants ─────────────────────────────────────────────── +const _: () = assert!( + FEATURE_GROUP_RANGES[0].0 == 0, + "feature groups must start at offset 0" +); +const _: () = assert!( + FEATURE_GROUP_RANGES[SL_NUM_FEATURE_GROUPS - 1].1 == PADDING_START, + "feature groups must end at PADDING_START — padding is not a group" +); +const _: () = { + // Every adjacent pair (g, g+1) must satisfy ranges[g].end == ranges[g+1].begin + // so the groups contiguously cover [0, PADDING_START) with no gaps. + let mut g = 0; + while g + 1 < SL_NUM_FEATURE_GROUPS { + assert!( + FEATURE_GROUP_RANGES[g].1 == FEATURE_GROUP_RANGES[g + 1].0, + "feature group ranges must be contiguous (no gaps)" + ); + g += 1; + } +}; +const _: () = { + // Each group's dim must fit within SL_MAX_FEATURE_GROUP_DIM so kernel + // scratch arrays sized to the maximum can hold any group. + let mut g = 0; + while g < SL_NUM_FEATURE_GROUPS { + let (b, e) = FEATURE_GROUP_RANGES[g]; + assert!(e >= b, "feature group end must not precede begin"); + assert!( + e - b <= SL_MAX_FEATURE_GROUP_DIM, + "feature group dim must not exceed SL_MAX_FEATURE_GROUP_DIM" + ); + g += 1; + } +}; diff --git a/crates/ml/src/cuda_pipeline/state_layout.cuh b/crates/ml/src/cuda_pipeline/state_layout.cuh index 8d27cf567..ed81d4fe3 100644 --- a/crates/ml/src/cuda_pipeline/state_layout.cuh +++ b/crates/ml/src/cuda_pipeline/state_layout.cuh @@ -151,6 +151,31 @@ #define ISV_SEED_STEPS_DONE_IDX 83 // == SEED_STEPS_DONE_INDEX — cumulative seed-phase steps completed (Plan 3 Task 8 B.3) #define ISV_SEED_FRAC_EMA_IDX 84 // == SEED_FRAC_EMA_INDEX — adaptive EMA of (1 - done/target) ∈ [0, 1] (Plan 3 Task 8 B.3; consumed by Task 9 CQL ramp) +// ──────────────────────────────────────────────────────────────────────────── +// Feature-group ranges — Plan 4 Task 1A (E.1 VSN prerequisite). +// Referenced by: VSN feature selection (E.1), attention diagnostics (E.5), +// encoder-decoder separation (E.4). Ranges are half-open `[BEGIN, END)`. +// `SL_PLAN_ISV_GROUP_END` aliases `SL_PADDING_START` because the 7-element +// plan-ISV block is the last group before the 8-alignment padding. +// Invariant 8: every range has a named constant; raw `SL_*_START` arithmetic +// for group bounds outside this header is a lint violation. +// ──────────────────────────────────────────────────────────────────────────── +#define SL_MARKET_GROUP_BEGIN SL_MARKET_START +#define SL_MARKET_GROUP_END SL_OFI_START +#define SL_OFI_GROUP_BEGIN SL_OFI_START +#define SL_OFI_GROUP_END SL_TLOB_START +#define SL_TLOB_GROUP_BEGIN SL_TLOB_START +#define SL_TLOB_GROUP_END SL_MTF_START +#define SL_MTF_GROUP_BEGIN SL_MTF_START +#define SL_MTF_GROUP_END SL_PORTFOLIO_START +#define SL_PORTFOLIO_GROUP_BEGIN SL_PORTFOLIO_START +#define SL_PORTFOLIO_GROUP_END SL_PLAN_ISV_START +#define SL_PLAN_ISV_GROUP_BEGIN SL_PLAN_ISV_START +#define SL_PLAN_ISV_GROUP_END SL_PADDING_START + +#define SL_NUM_FEATURE_GROUPS 6 +#define SL_MAX_FEATURE_GROUP_DIM 42 // == SL_MARKET_DIM (largest group) + // ── Compile-time checks ── static_assert(SL_PADDING_START + SL_PADDING_DIM == SL_STATE_DIM, "State layout dimensions must sum to SL_STATE_DIM"); @@ -158,6 +183,23 @@ static_assert(SL_STATE_DIM % 8 == 0, "SL_STATE_DIM must be 8-aligned for tensor core cuBLAS"); static_assert(SL_STATE_DIM <= SL_STATE_DIM_PADDED, "SL_STATE_DIM must fit within SL_STATE_DIM_PADDED"); +// Feature groups must contiguously cover the non-padding state range. +static_assert(SL_MARKET_GROUP_BEGIN == 0, + "feature groups must start at offset 0"); +static_assert(SL_PLAN_ISV_GROUP_END == SL_PADDING_START, + "feature groups must end at SL_PADDING_START — padding is not a group"); +static_assert(SL_MARKET_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "Market group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); +static_assert(SL_OFI_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "OFI group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); +static_assert(SL_TLOB_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "TLOB group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); +static_assert(SL_MTF_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "MTF group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); +static_assert(SL_PORTFOLIO_BASE_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "Portfolio group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); +static_assert(SL_PORTFOLIO_PLAN_DIM <= SL_MAX_FEATURE_GROUP_DIM, + "Plan-ISV group dim must not exceed SL_MAX_FEATURE_GROUP_DIM"); // ── Shared state assembly function ── // Called by both experience_state_gather (training) and backtest_state_gather (validation). diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 6b6a17cd3..781387160 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. +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). Plan 4 Task 2c.3c.6 (2026-04-25): consumer wire-up for ISV[H_S2_RMS_EMA_INDEX=96] — the producer-only slot landed in 2c.3c.5 is now consumed by `mag_concat_qdir` (the magnitude-branch input-builder kernel in `experience_kernels.cu`). Two trailing kernel args added: `const float* __restrict__ isv` + `int isv_h_s2_rms_index`. The kernel's per-sample tail (the `b0_size` Q_dir slots written into `concat_out[b, SH2..SH2+b0_size]`) is now adaptively rescaled in three passes: pass 1 reuses the existing softmax→eq computation per direction action and stashes results in a 4-element register array (`MAG_CONCAT_MAX_DIR=4`, matching the project's 4-direction `S/H/L/F` invariant — `branch_0_size` stays a runtime arg for signature stability but production callers always pass 4); pass 2 computes `q_rms = sqrt(sum_a(eq_a^2) / b0_size)`; pass 3 picks `scale = (q_rms > 1e-6) ? (h_s2_rms_ema / q_rms) : (1 / max(dz, 1e-6))` and writes `concat_out[…, SH2+a] = eq_a * scale`. The legacy formula `concat_out[…, SH2+a] = eq / fmaxf(dz, 1e-6f)` and its 2-line comment ("Normalize by delta_z so Q_dir ~ 1-10 (matches h_s2 post-ReLU scale). Without this, Q_dir ~ 0.1 → 10× smaller gradient → 10× slower learning.") are deleted — the calibration was carried over from the pre-GRN post-ReLU trunk and is superseded by the runtime-measured RMS. The fallback branch is annotated `domain: uniform Q across actions has no RMS to match` (mathematically required when the b0_size-vector is zero, not a stub return). Launch site `launch_mag_concat_from` in `gpu_dqn_trainer.rs` extended with `isv_signals_dev_ptr` + `H_S2_RMS_EMA_INDEX as i32`; `debug_assert!(self.isv_signals_dev_ptr != 0, …)` mirrors the 2c.3c.5 producer launcher's invariant. Backward path unchanged: `strided_accumulate` extracts `d_h_s2` from the first SH2 columns of `d_mag_concat` as before; `h_s2_rms_ema` and `q_rms` are treated as fixed scalars at this batch's launch (same convention as `dz`/`v_min` from `per_sample_support`), no gradient flows back through the ISV read. **No fingerprint change** — no ISV slot or param tensor added; pure kernel-signature + launcher edit. Smoke (`cargo test … multi_fold_convergence --ignored --release`, 649.37s, 3 folds × 5 epochs): all 3 `dqn_fold{N}_best.safetensors` checkpoints written; per-fold best train Sharpe 8.06 / 43.06 / 19.16 at epochs 5 / 1 / 2 (vs 2c.3c.5 baseline 1.91 / 95.56 / 44.00 → geom-mean 20.03; this commit geom-mean 18.80, -6.1% — within the 30% acceptance band, no regression-grade drift). No NaN/Inf, no fingerprint mismatch (fingerprint unchanged at `0x3e21acecd922e540`). 0 panic gates added/removed. The 2c.3c chain — H_S2_RMS_EMA producer (2c.3c.5) + consumer (this commit) — is closed: ISV[96] is now Wired-Producer+Consumer (was Wired-Producer-Only). cargo check clean at 11 warnings (baseline preserved); cargo build compiles 81 cubins (unchanged — kernel-signature edit, no new `.cu`). +69 / -6 LOC across `crates/ml/src/cuda_pipeline/experience_kernels.cu` (kernel signature + body + docstring) and `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (launcher + 2 args + invariant assert + docstring). No new module / kernel / ISV slot / Orphan row.