Files
foxhunt/docs/h_s2_consumers_audit.md
jgrusewski 4402dbaf3d audit(dqn-v2): h_s2 audit anchor corrections (post-2c.3+4 reconnaissance)
The 2c.3+4 dispatch agent surfaced two stale anchors in the
2026-04-25 h_s2 consumer audit:

1. Row 11 (IQN target trunk): the kernel referenced as
   `iqn_compute_target_h_s2` in `iqn_dual_head_kernel.cu:1031` is
   actually `iqn_trunk_forward_kernel`, and it is ALREADY ORPHANED
   (loaded into IqnHead::trunk_forward_kernel but never invoked).
   The real IQN target trunk runs through cuBLAS iqn_lt_matmul calls
   in gpu_iqn_head.rs::execute_training_pipeline:820-846 (with a
   cached fast-path at 803-847). Mitigation reduced to: delete dead
   kernel + extract target_encoder_forward_only + replace cuBLAS
   fallback path with that extraction.

2. Row 22 (relu_mask sites): line numbers drifted. Audit said
   5581/5798; current code is 5655/5697/5872. Crucially, 5697 is
   the h_s1 mask, not h_s2/IQN-aux — the audit's "3 sites" claim
   needs verification per site before editing. Updated to the
   current line numbers with a "verify before editing" note.

These are documentation-only corrections. The mitigation strategy
(GRN backward chain replaces relu_mask; target_encoder_forward_only
unifies trunk implementation) is unchanged in spirit.
2026-04-25 12:29:47 +02:00

78 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# h_s2 Consumer Audit (Plan 4 Task 2a)
Source of truth for which trunk-`h_s2` consumers tolerate signed input
(post-GRN swap) vs. which require non-negativity. Drives Task 2c
mitigation strategy.
## Methodology
For each consumer site:
1. Identify the kernel/file/line where `h_s2` (or its device pointer) is read.
2. Document the arithmetic applied to it (multiplication, division, log,
exp, sigmoid, comparison, etc.).
3. Classify: **safe-with-signed** / **assumes-non-negative** / **needs-verification**.
4. Propose mitigation if assumes-non-negative.
Today the trunk produces `h_s2 = ReLU(W_s2 · h_s1 + b_s2)` (line 488 of
`crates/ml/src/cuda_pipeline/batched_forward.rs`), so the buffer is
guaranteed `>= 0`. Post-Task-2c the trunk will produce the LayerNorm
output of a GRN block — zero-mean, signed, and roughly `O(1)` after
the LN's affine transform. Each row below evaluates whether the
consumer's arithmetic still does what the design intended once `h_s2`
can be negative.
## Inventory
| # | Consumer site | File:line | Operation | Classification | Mitigation if needed |
|---|---|---|---|---|---|
| 1 | Value head L1 GEMM `h_v = ReLU(h_s2 · W_v1^T + b_v1)` | `batched_forward.rs:609`, `batched_forward.rs:941` (cuBLAS `sgemm_f32_fused_relu_bias`) | Linear projection followed by ReLU on output. Sign of `h_s2` only matters insofar as W_v1 is randomly Xavier-initialised; ReLU on the output absorbs negatives downstream. | safe-with-signed | None — the Xavier-initialised linear layer is sign-agnostic; output ReLU keeps downstream features non-negative. |
| 2 | Direction branch FC `h_b0 = ReLU(h_s2 · W_b0_fc^T + b_b0_fc)` (when VSN/GLU disabled, legacy path) | `batched_forward.rs:554`, `batched_forward.rs:676`, `batched_forward.rs:720`, `batched_forward.rs:821`, `batched_forward.rs:862`, `batched_forward.rs:1063` (decoder forward) | Linear projection + bias + ReLU. Identical shape to value head. | safe-with-signed | None. |
| 3 | Magnitude branch FC: input is `[h_s2; Q_dir]` concat (mag_concat) | `batched_forward.rs:544` and `experience_kernels.cu:3653-3656` (`mag_concat_qdir`) | First copies `h_s2` row verbatim, then appends `Q_dir/Δz`. The concat is fed through a linear+ReLU. The kernel comment explicitly says "Normalize by delta_z so Q_dir ~ 1-10 (matches h_s2 post-ReLU scale)" — this is a *scale* assumption, not a sign assumption. LayerNorm output has magnitude ~1, so `Q_dir/Δz ~ 1-10` no longer matches. | needs-verification | Scale-only concern. After the swap, normalise `Q_dir` against the new `h_s2` scale (e.g. divide `Q_dir/Δz` by `~Δz·sqrt(SH2)` so its RMS matches LN's RMS=1). No sign issue. Confirm by RMS-matching in Task 2c. |
| 4 | Order/Urgency branch FC concat path: `[h_s2; OFI3]` | `batched_forward.rs:710` and the `concat_ofi` kernel (no h_s2 nonlinearity, just bytewise scatter) | Same as #3: linear + ReLU on a wider input. | safe-with-signed | None — OFI features are already signed, so the layer already handles signed input. |
| 5 | VSN bottleneck `h_masked[b,i] = h_s2[b,i] · sigmoid(W_vsn1·W_vsn2·h_s2)` | `experience_kernels.cu:3775-3814` (`variable_select_bottleneck`), launched at `batched_forward.rs:1167` | Two linear projections through a `[SH2,R]·[R,SH2]` bottleneck → sigmoid → element-wise multiply with `h_s2`. The projection is sign-agnostic; sigmoid produces `(0,1)`. The element-wise product `h_s2 * gate` simply preserves the sign of `h_s2`. No magnitude or sign assumption is broken. | safe-with-signed | None. The kernel header comment ("At W_vsn2=0 init: sigmoid(0)=0.5 → h_masked = 0.5·h_s2 (stable start)") describes a property that survives the sign change unchanged. |
| 6 | Mamba2 history insert: `h_history[K-1] = [h_s2; ofi_embed]` | `mamba2_temporal_kernel.cu:33`, launched at `gpu_dqn_trainer.rs:5131` (`mamba2_update_history`) | Plain copy of `h_s2[i,j]` into the history buffer slot. No arithmetic. Downstream consumer (mamba2 scan) feeds `h_history` through a learned linear projection `W_A`/`W_B`. | safe-with-signed | None. |
| 7 | Mamba2 enrich: `h_enriched[i,j] = h_s2[i,j] + tw[j] · ctx` | `mamba2_temporal_kernel.cu:142`, launched at `gpu_dqn_trainer.rs:5108` (`mamba2_scan_projected_fwd`) | Pure additive residual: temporal context `tw·ctx` (signed) added to `h_s2`. Result is then copied back into `save_h_s2` for downstream branches. | safe-with-signed | None — additive residuals are sign-agnostic. |
| 8 | Multi-head feature attention (post-graph): `attn_in = h_s2`, output overwrites `save_h_s2` | `gpu_dqn_trainer.rs:6712-6743` (`apply_attention_forward`); CUDA kernels `attention_backward_kernel.cu:140-167` (`attn_sdp_fwd`) and `attention_kernel.cu` | Linear Q/K/V projections (sign-agnostic), feature-level attention with sigmoid gate `1/(1+exp(-Q·K/√d))`, output GEMM, residual `state + projected`, LayerNorm. The `attn_concat_input` kernel that builds the [D,B] attention input is also a plain copy of states and OFI — no arithmetic on `h_s2`. The init comment says "Zero W_O makes attention start as identity: output = LN(h_s2 + 0 @ attn) ≈ h_s2" — this property holds for signed `h_s2` because LN of a signed vector is well-defined. | safe-with-signed | None. Attention's existing residual+LayerNorm idempotently absorbs signed input. |
| 9 | IQN dual-head forward (per-quantile): `combined = h_s2 ⊙ embed`, `combined ⊙ relu_embed` | `iqn_dual_head_kernel.cu:191`, `:236-274`, `:301-310` (forward) and `:411` (target path); launched from `gpu_iqn_head.rs::execute_training_pipeline`. The element-wise product is `h_s2 ⊙ ReLU(W_embed·cos(τ))`. | Element-wise product of `h_s2` with the ReLU'd embedding. ReLU makes the embedding non-negative, but multiplying by signed `h_s2` is mathematically fine — `combined` is signed, fed to a per-branch linear `q = W_b · combined + b_b`. No log/sqrt/sigmoid is applied to `h_s2` itself. Backward (`iqn_dual_head_kernel.cu:684`) computes `d_pre = dq · w_sum · h_s2 · (embed_pre > 0)` — multiplies by `h_s2`, fine when signed. | safe-with-signed | None. Note that the kernel today ingests `h_s2` as `float*` even though the header comment says "(bf16 from DQN trunk)" — that's a stale comment; the actual storage is f32 already. |
| 10 | IQN tile + Hadamard-sigmoid path: `out = h_s2_tiled · sigmoid(embed)` | `iqn_dual_head_kernel.cu:1262` (`iqn_tile_hadamard_sigmoid`), `:1284` (backward `d_h_s2_tiled = d_combined · sigmoid(embed)`); tile/reduce kernels at `:1361-1396` | Element-wise product of `h_s2_tiled` with `sigmoid(embed_pre) ∈ (0,1)`. Sign of `h_s2` propagates linearly through the sigmoid-gated multiply. Backward is symmetric. | safe-with-signed | None. |
| 11 | IQN target trunk — `iqn_trunk_forward_kernel` (audit-orig name `iqn_compute_target_h_s2`) | `iqn_dual_head_kernel.cu` kernel function. **CORRECTION (2026-04-25 post-2c.3+4 recon):** the kernel is **already orphaned** — loaded into `IqnHead::trunk_forward_kernel` but never invoked. The actual IQN target trunk runs through cuBLAS `iqn_lt_matmul` calls in `gpu_iqn_head.rs::execute_training_pipeline` lines 803-847 (cached fast-path) and 820-846 (cuBLAS fallback). | The cuBLAS fallback path reproduces the legacy `Linear → ReLU → Linear → ReLU` via `launch_trunk_bias_relu`. Once GRN lands, online and target diverge unless the cuBLAS path is replaced with a `target_encoder_forward_only` call (Task 4-style extraction on the target-net path). | **assumes-non-negative** *(via cuBLAS path's `launch_trunk_bias_relu`)* | (a) Delete the dead `iqn_trunk_forward_kernel` + its `load(...)` line + struct field. (b) Extract `target_encoder_forward_only` mirroring Task 4's online encoder split. (c) Replace the cuBLAS fallback path in `execute_training_pipeline:820-846` with `target_encoder_forward_only`. The cached fast-path stays valid as long as `cached_target_h_s2_ptr` is updated by whoever populates it. |
| 12 | Risk-budget head: SH2 cooperative load, hidden = ReLU(W_risk_fc · [h_s2; ISV; pred_err] + b) | `experience_kernels.cu:5867-5917` (`risk_budget_forward`), launched at `gpu_dqn_trainer.rs:4592-4626` | Concats `h_s2[B,SH2]` with `ISV[12]` and `predicted_error[1]`, runs linear + ReLU + sigmoid. Backward (`experience_kernels.cu:5954-6037`) reads `h_s2[i,k]` as `input_val` for `dW`. | safe-with-signed | None. |
| 13 | Recursive confidence head: `pred = sigmoid(W·h_s2 + b)` | `experience_kernels.cu:6447-6462` (forward) + `:6476-6543` (backward); Rust at `gpu_dqn_trainer.rs:4001-4027`/`:4443-4496` | Linear projection through sigmoid. Backward writes `d_h_s2 += d_sigmoid · w_conf` (signed contribution) and `dW += d_sigmoid · h_s2`. Both are sign-agnostic. | safe-with-signed | None. |
| 14 | Selectivity head: `sel = sigmoid(W_sel · h_s2 + b_sel)` | `experience_kernels.cu:3998-4014` (forward), `:4056-4068` (dW reduce) | Plain linear projection through sigmoid. Backward `dW += d_z · h_s2[b,k]` is sign-agnostic. | safe-with-signed | None. |
| 15 | ISV feature gate (in-place): `h_s2[i,j] *= sigmoid(W·isv_emb + b)` | `experience_kernels.cu:6580-6600` (`isv_feature_gate`); launched at `gpu_dqn_trainer.rs:3943-3969` | Multiplies each `h_s2` cell by a per-feature sigmoid gate `∈ (0,1)`. Sign of `h_s2` is preserved by the multiply. No other arithmetic on `h_s2`. | safe-with-signed | None. |
| 16 | ISV temporal route | `mamba2_temporal_kernel.cu:50-64` (`isv_temporal_route`); launched at `gpu_dqn_trainer.rs:3974-3997` | Reads `isv_embedding`, NOT `h_s2`. Misclassified in the spec's "12+" list — this kernel only consumes `h_s2` indirectly through the mamba2 enrich kernel (#7). | safe-with-signed (no direct read) | None. |
| 17 | Trade-plan / plan head L1 GEMM: `hidden = ReLU(W_fc · h_s2 + b_fc)` | `gpu_dqn_trainer.rs:4029-4097` (`launch_trade_plan_forward`); subsequent `trade_plan_activate` kernel at `experience_kernels.cu:6608-6627` operates on `pre_out`, not `h_s2` | Two cuBLAS GEMMs with bias+ReLU between them (and bias+sigmoid epilogue at the end). The first GEMM consumes `h_s2`. Sign-agnostic. The plan activations live downstream and don't read `h_s2`. | safe-with-signed | None. |
| 18 | Regime-aware dropout (in-place): `h_s2[idx] = 0` or `*= 1/(1-p)` | `experience_kernels.cu:5616-5644` (`regime_dropout`); launched at `gpu_dqn_trainer.rs:4707-4725` | Zeros a fraction of `h_s2` cells, scales survivors by `1/(1-p)`. Both ops are sign-preserving. | safe-with-signed | None. |
| 19 | Predictive coding loss: `L = λ · mean_j(h_s2[i] h_s2[i+1])²` | `experience_kernels.cu:5713-5736` (forward), `:5766-5800` (backward); launched at `gpu_dqn_trainer.rs:4781-4827` | Squared difference between consecutive samples; output is non-negative regardless of input sign. Backward computes `dL/dh = 2λ/SH2 · (h_self h_neighbour)`, also sign-symmetric. | safe-with-signed | None. |
| 20 | Mag-concat backward `strided_accumulate` extracts `d_h_s2 += d_mag_concat[:SH2]` | `experience_kernels.cu:3708-3726`; called at `gpu_dqn_trainer.rs:4831+` (`accumulate_d_h_s2_from_concat`) | Pure copy/accumulate; no nonlinearity. | safe-with-signed | None. |
| 21 | Branch-decoder VSN+GLU `launch_vsn_glu_branch` (per branch path) | `batched_forward.rs:1143-1306` calls `vsn_kernel`, then `kan_gate_combine_kernel` (KAN spline gate). The first stage is the VSN above (#5). | The KAN spline gate `kan_gate_combine` in this path operates on the *bottleneck* output of a per-branch FC, not on `h_s2` directly. Its input is `b1_proj` (after the per-branch linear+ReLU). KAN doesn't touch `h_s2`. | safe-with-signed (no direct read) | None — KAN sees only post-FC bottleneck features. |
| 22 | **Trunk-backward ReLU mask: `d_h_s2 *= (save_h_s2 > 0)`** | `batched_backward.rs:1778` (`relu_mask`), `gpu_dqn_trainer.rs:5655` (online trunk grad post-h_s2), `gpu_dqn_trainer.rs:5697` (h_s1 mask, NOT h_s2 — verify before editing), `gpu_dqn_trainer.rs:5872` (target-sync per-target-update). Kernels: `relu_mask_kernel.cu` and `backward_kernels.cu:18`. **Line numbers updated 2026-04-25 post-recon — original audit had stale 5581/5798 anchors.** | This is the **load-bearing ReLU assumption** in the entire pipeline. The mask zeros gradients where `save_h_s2 <= 0`, which is correct mathematics for `h = ReLU(z)`. With LayerNorm output (zero-mean, signed), this would zero ~50% of gradients corresponding to **valid** negative activations — a silently wrong gradient with no NaN/Inf. | **assumes-non-negative** | The `relu_mask` step **must be removed** (or replaced with the GRN's actual jacobian) on the post-Task-2c trunk-out. The 2c.1 GRN kernel module already provides the full LN+GLU+ELU backward; the 2c.2 wrapper sequences it. Each of the 3 sites has different save-buffer lifetime semantics (online per-step, IQN-aux per-step in different trainer, target-sync per-target-update) — verify save-buffer plumbing per site. |
| 23 | Target-network trunk forward: `tg_h_s2_buf` is produced/consumed by the same path as #1, #2, #4 | `batched_forward.rs:980-1090` (`forward_target_raw`); IQN reuses `tg_h_s2_buf` via `gpu_iqn_head.rs::set_cached_target_h_s2_ptr``cached_target_h_s2_ptr` | Same arithmetic as the online trunk: linear + ReLU(legacy) → branches via linear+ReLU. After Task 2c, target trunk MUST run the same GRN block as the online trunk. | **assumes-non-negative** *(via shared kernel reuse)* | Target trunk weight set must be migrated alongside online. The relu_mask in `apply_iqn_trunk_gradient` (`gpu_dqn_trainer.rs:5581`) currently masks against `save_h_s2`, the **online** trunk activation; once that buffer is LN-output the mask is wrong (see #22). |
| 24 | Experience-collector `exp_h_s2_f32` storage | `gpu_experience_collector.rs:559,690,1231-1299,3306` | Plain f32 storage of past `h_s2` for replay/episode-level data. Read back by replay/IQN as ordinary tensor data. No nonlinearity at the storage layer. | safe-with-signed | None — storage layout is sign-agnostic. |
| 25 | Q-stats / monitoring path (`tg_h_s2_ptr` exposed for monitoring) | `gpu_dqn_trainer.rs:4936-4938` accessor | The pointer is exposed but the call sites are restricted to monitoring readback. Monitoring kernels we audited do not perform sign-dependent ops on `h_s2`. | safe-with-signed | None. |
| 26 | Cross-branch Q-attention `cross_branch_q_attention` | `experience_kernels.cu:3894-3984` | Reads `Q_raw[B,12]` (per-branch Q-values), NOT `h_s2`. Listed here only to dispel the "12+" spec list; this attention is on Q-values, not features. | safe-with-signed (no `h_s2` read) | None. |
| 27 | Ensemble value head `forward_value_head_for_ensemble` | `gpu_dqn_trainer.rs:11787-11801` calls `cublas_forward.forward_value_head(h_s2, ...)` | Linear+ReLU same shape as #1. | safe-with-signed | None. |
| 28 | Direction-branch backward `bw_d_h_s2` accumulation by branch heads | `batched_backward.rs:1466-1470` — comment "Branch FC layer: `h_bd` gradient → `h_s2` (accumulated)" | Linear backward; all `dX = dY · W` ops are sign-agnostic. | safe-with-signed | None — but the accumulated buffer is masked by #22 downstream. |
## Summary
- **safe-with-signed: 24** consumers (rows 110, 1221, 2428, plus the indirect/no-direct-read rows 16, 21, 26)
- **assumes-non-negative: 3** consumers — rows **11** (`iqn_compute_target_h_s2` reproduces the legacy LeakyReLU trunk), **22** (the `relu_mask` step on `bw_d_h_s2` in trunk and IQN-aux backward), **23** (target trunk via the same kernel as online).
- **needs-verification: 1** — row **3** (mag_concat scale assumption: `Q_dir/Δz ~ 1-10 matches h_s2 post-ReLU scale`). This is a *scale* mismatch, not a sign mismatch, and is straightforward to fix once the new `h_s2` magnitude is measured.
### What was surprising
- **Most consumers are linear projections** (cuBLAS GEMMs into branch/value/risk/conf/selectivity/plan heads) and are inherently sign-agnostic. The audit confirms the design intuition: the trunk's ReLU was a feature of the trunk itself, not an externalised contract that downstream consumers depend on.
- **The two real failure modes both live in the backward pass**, not the forward pass: the `relu_mask` step (#22, #23) and the legacy LeakyReLU recompute kernel for the target trunk (#11). All forward-direction nonlinearities (sigmoid gates, element-wise products, additive residuals) are sign-tolerant.
- **VSN is safe** despite the kernel's "(0,1) gate × h_s2" framing — the comment about "softmax would give 1/SH2 collapse" describes a *training-init* property, not a sign assumption. Element-wise sigmoid × signed is well-defined.
- **The "12+ consumers" list in the spec partially miscounted**: ISV-temporal-route (#16), KAN-spline (#21), and cross-branch-Q-attention (#26) do **not** read `h_s2` directly. Plan-head, risk-budget, recursive-confidence, selectivity, and ISV-feature-gate **do**. The actual count of direct readers is closer to 22 once you split forward vs backward and online vs target.
## Recommended Task 2c shim strategy
The audit answers the strategic question definitively: **do not add a global `relu(h_s2)` shim post-LayerNorm and do not add per-consumer relu shims**. A global shim would re-introduce the dead-zone the GRN swap is meant to eliminate (negative LN values are *real signal*, not noise to be clamped); per-consumer shims would require auditing 24 sign-tolerant call sites for nothing. Instead, the 2c migration is a **backward-side surgical replacement** at exactly two points:
1. **Replace the `relu_mask(d_h_s2, save_h_s2)` call** in `batched_backward.rs:1778`, plus the two equivalent calls in `gpu_dqn_trainer.rs:5581-5599` and `:5798-5801`, with the actual GRN-block backward (LayerNorm Jacobian + GLU sigmoid backward + ELU' on `Linear_a(h_s1)`). The LayerNorm backward kernels already exist for the attention path (`attn_layer_norm_bwd_*`); the new GRN-block backward should reuse that and add the GLU/ELU stages. Everything upstream of `bw_d_h_s2` (branch heads, value head, risk, conf, selectivity, predictive coding) writes its share without any sign assumption, so the only change is *what comes after the accumulator is full*.
2. **Migrate `iqn_compute_target_h_s2`** (`iqn_dual_head_kernel.cu:1031-1092`) to the new GRN trunk in the same commit, or — preferably — delete the kernel and wire the IQN target path through `BatchedForward::forward_target_raw` so there is exactly one trunk implementation and no chance for online/target divergence. This is a "no-partial-refactor" obligation per `feedback_no_partial_refactor.md` — the trunk topology is a shared contract.
The single scale-tweak in row #3 (RMS-match `Q_dir/Δz` against the new `h_s2` RMS, since LN output has RMS ≈ 1 vs ReLU output ≈ 110) is the only forward-side adjustment needed and lives entirely inside the `mag_concat_qdir` kernel's normalisation constant — a one-line adaptive change driven by a measured ISV slot, not a tuned constant.