# SP7: Loss-Balance Controller Implementation Plan (v2)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a GPU controller kernel that adapts per-branch CQL and C51 budgets via flatness-modulated target ratios + Wiener-optimal EMA, so magnitude differentiation is no longer pinned by hardcoded loss-budget floors.

**Architecture:** Single-block 8-thread CUDA kernel reads three 3-float views into the existing `grad_decomp_result_pinned` buffer (IQN at offset 0, CQL_SX at 6, C51 at 9), reads `FLATNESS_BASE` and prior `BUDGET_{CQL,C51}_BASE` from ISV, and writes new per-branch budgets + Wiener observations to the producer scratch buffer for `apply_pearls_ad_kernel` smoothing. Pearl 2 stops touching the same slots; consumer `compute_adaptive_budgets` swaps hardcoded floors for sentinel-aware bootstrap.

**Tech Stack:** Rust 1.85, CUDA 12.4, cudarc 0.19, sccache (MinIO), Argo Workflows (L40S validation), pre-commit hooks (`check_no_dtod_via_pinned`, `check_audit_doc_updates`).

**Branch:** `sp5-magnitude-differentiation`. Predecessor commit: `30f7032d1` (plan v1). HEAD: `8f484f331` (spec v2). Working tree clean at start.

**Spec reference:** `docs/superpowers/specs/2026-05-03-sp7-loss-balance-controller-design.md`. Re-read the spec at the start of each task — the plan repeats the load-bearing parts but the spec is the source of truth on math and ordering.

**Pre-commit gate awareness:** every commit that stages files under `crates/ml/src/cuda_pipeline/` MUST also stage a matching `docs/dqn-wire-up-audit.md` update or `check_audit_doc_updates` will reject. Tasks 1, 3, 5, 6, 7 each include the audit-doc edit as part of the same commit.

---

## File Structure

| Path | Responsibility |
|---|---|
| **NEW** `crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu` | The controller kernel: per-branch flatness-gated target → multiplicative correction → Wiener-α EMA → scratch + Wiener observation writes. |
| `crates/ml/src/cuda_pipeline/sp5_isv_slots.rs` | Adds 16 new ISV slots `LB_DIFF_VAR_CQL_BASE`, `LB_SAMPLE_VAR_CQL_BASE`, `LB_DIFF_VAR_C51_BASE`, `LB_SAMPLE_VAR_C51_BASE` (4 each). Bumps `SP5_SLOT_END` 297→313 and `SP5_PRODUCER_COUNT` 123→139. Layout fingerprint extended. |
| `crates/ml/src/cuda_pipeline/pearl_2_budget_kernel.cu` | **Full contract change** — kernel signature drops `c51_idx_base`/`cql_idx_base`/`ens_idx_base` args; only IQN + flatness writes remain. |
| `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` | (a) New kernel slot in trainer struct + load + capture; (b) Pearl 2 launch site updated to new 6-arg signature; (c) **delete orphan** `SCRATCH_PEARL_2_C51`/`_CQL`/`_ENS` constants; (d) new SP7 scratch indices + apply_pearls smoothing chain; (e) new `launch_loss_balance_controller` fn. |
| `crates/ml/src/trainers/dqn/state_reset_registry.rs` | (a) Register the 16 new SP7 slots; (b) update the stale `sp5_budget_cql` description ("regime_stability allocator" → SP7 controller). |
| `crates/ml/src/trainers/dqn/fused_training.rs` | (a) Replaces hard floor in `compute_adaptive_budgets:3384-3387` with sentinel-aware bootstrap; (b) deletes stale "B4/G5: 0.10×(1−regime)×health" docstring at line 3409. |
| `crates/ml/src/trainers/dqn/trainer/training_loop.rs` | Adds the SP7 launch call to the per-step pipeline (after Pearl 2 + grad_decomp). |
| `crates/ml/build.rs` | Cubin manifest entry for the new kernel. |
| `docs/dqn-wire-up-audit.md` | Fix 31 entry (initial stub in Task 1, extended through Tasks 3, 5, 6, 7, 9). |
| `~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_loss_balance_controller.md` | New memory pearl — the two-layer (signal-modulated target × outcome-driven α) controller pattern. Created in Task 8. |
| `~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/MEMORY.md` | One-line index entry. Updated in Task 8. |

---

## Task 1: Allocate the 16 new ISV slots + initial Fix 31 stub

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/sp5_isv_slots.rs:170, 205, 236-247, 295-310`
- Modify: `docs/dqn-wire-up-audit.md` (append Fix 31 stub)

- [ ] **Step 1: Add the 16 SP7 slot constants**

In `crates/ml/src/cuda_pipeline/sp5_isv_slots.rs`, find:

```rust
pub const SP5_SLOT_END: usize = 297;
```

Replace with:

```rust
// ── SP7: Loss-balance controller state-tracker EMAs ──────────────────
//
// Per-branch (4 branches each: dir, mag, ord, urg) Wiener-α state for
// the 2 managed loss heads (CQL and C51). The kernel writes raw
// observations of (candidate − old_budget)² and (h_norm)² to the
// producer scratch buffer; `apply_pearls_ad_kernel` smooths them into
// these slots — same Pearl A sentinel-bootstrap + Pearl D Wiener-EMA
// as every other SP5 producer.
pub const LB_DIFF_VAR_CQL_BASE:   usize = 297;   // [4] (candidate_cql − old_cql)² EMA
pub const LB_SAMPLE_VAR_CQL_BASE: usize = 301;   // [4] (cql_norm)² EMA
pub const LB_DIFF_VAR_C51_BASE:   usize = 305;   // [4] (candidate_c51 − old_c51)² EMA
pub const LB_SAMPLE_VAR_C51_BASE: usize = 309;   // [4] (c51_norm)² EMA

pub const SP5_SLOT_END: usize = 313;
```

- [ ] **Step 2: Bump `SP5_PRODUCER_COUNT`**

Find:

```rust
pub const SP5_PRODUCER_COUNT: usize = 123;
// linear span = SP5_SLOT_END - SP5_SLOT_BASE = 297 - 174 = 123 wiener triples
```

Replace with:

```rust
pub const SP5_PRODUCER_COUNT: usize = 139;
// linear span = SP5_SLOT_END - SP5_SLOT_BASE = 313 - 174 = 139 wiener triples
```

- [ ] **Step 3: Extend the layout fingerprint string**

Find the `SP5_LAYOUT_FINGERPRINT_FRAGMENT` constant (lines 236-247). The last segment currently reads:

```rust
     TRAINING_SHARPE_EMA=294;MAX_DD_EMA=295;LOW_DD_RATIO=296;\
     ISV_TOTAL_DIM=297";
```

Replace with:

```rust
     TRAINING_SHARPE_EMA=294;MAX_DD_EMA=295;LOW_DD_RATIO=296;\
     LB_DIFF_VAR_CQL_BASE=297;LB_SAMPLE_VAR_CQL_BASE=301;\
     LB_DIFF_VAR_C51_BASE=305;LB_SAMPLE_VAR_C51_BASE=309;\
     ISV_TOTAL_DIM=313";
```

- [ ] **Step 4: Add four helper fns**

Find the trailing helper-fn block (search for `pub const fn budget_iqn`). Add four new helpers next to the existing ones:

```rust
#[inline] pub const fn lb_diff_var_cql(b: usize)   -> usize { LB_DIFF_VAR_CQL_BASE + b }
#[inline] pub const fn lb_sample_var_cql(b: usize) -> usize { LB_SAMPLE_VAR_CQL_BASE + b }
#[inline] pub const fn lb_diff_var_c51(b: usize)   -> usize { LB_DIFF_VAR_C51_BASE + b }
#[inline] pub const fn lb_sample_var_c51(b: usize) -> usize { LB_SAMPLE_VAR_C51_BASE + b }
```

- [ ] **Step 5: Update the slot-overlap test**

In `mod tests`, the `slot_layout_no_overlaps_and_total_correct` test populates a HashSet of all known slots. Find the last block (after `// Pearl 1-ext` and the Pearl 6 inserts) and BEFORE the loop that asserts the count:

```rust
        // SP7: loss-balance controller state-tracker EMAs (b in 0..4)
        for b in 0..4 {
            slots.insert(lb_diff_var_cql(b));
            slots.insert(lb_sample_var_cql(b));
            slots.insert(lb_diff_var_c51(b));
            slots.insert(lb_sample_var_c51(b));
        }
```

Then update the three `assert_eq!(SP5_SLOT_END, 297)` and `assert_eq!(SP5_PRODUCER_COUNT, 123)` assertions to `313` and `139` respectively (search the file for those literals — there are 3 occurrences of each).

- [ ] **Step 6: Append the Fix 31 stub to the audit doc**

In `docs/dqn-wire-up-audit.md`, find the most recent Fix entry (`### Fix 30:`). Append AFTER all Fix 30 close-out paragraphs:

```markdown
### Fix 31: SP7 loss-balance controller — adaptive CQL/C51 budget ratio (2026-05-03, IN PROGRESS)

**The bug.** At production scale (50-epoch baseline at HEAD `2fb7d7f57`,
smoke `train-multi-seed-n4qv2` ep4-7) `grad_split_bwd cql=4.13` vs
`iqn=0.07` (≈60×); magnitude Q-values within 0.003 of each other;
`eval_dist eq=1.000` (full eval-collapse to Quarter via argmax). Root
cause: per-branch CQL budget hardcoded to 0 by Pearl 2 and floored to
0.02 in `compute_adaptive_budgets`. The "regime_stability allocator"
docstring at `fused_training.rs:3409` was never implemented.

**The fix.** New GPU controller `loss_balance_controller_kernel` writes
adaptive per-branch budgets to the same ISV slots Pearl 2 used to
zero. Math and rationale in
`docs/superpowers/specs/2026-05-03-sp7-loss-balance-controller-design.md`.

**Layer A landing (this commit, 2026-05-03)**: 16 new ISV slots
`LB_DIFF_VAR_CQL_BASE` (297..301), `LB_SAMPLE_VAR_CQL_BASE` (301..305),
`LB_DIFF_VAR_C51_BASE` (305..309), `LB_SAMPLE_VAR_C51_BASE` (309..313).
`SP5_SLOT_END` 297→313; `SP5_PRODUCER_COUNT` 123→139. Wiener-buffer
linear-span test asserts the new total. Layout fingerprint string
extended. No producer kernel yet — that arrives in the next commit.

**Subsequent landings** (this entry will be extended commit-by-commit):
- T2: state-reset registry entries.
- T3: kernel `.cu` file.
- T4: build.rs cubin manifest.
- T5: trainer struct + launcher fn.
- T6: Pearl 2 contract change (drop CQL/C51/ENS args).
- T7: launch site + sentinel-aware consumer + stale doc.
- T9–T10: smoke + 50-epoch verification.
```

- [ ] **Step 7: Build to confirm constants resolve + tests pass**

```bash
SQLX_OFFLINE=true cargo check -p ml --offline 2>&1 | tail -10
SQLX_OFFLINE=true cargo test -p ml --lib --offline sp5_isv_slots -- --nocapture 2>&1 | tail -15
```

Expected: clean check; `slot_layout_no_overlaps_and_total_correct` passes with the new asserts.

- [ ] **Step 8: Commit**

```bash
git add crates/ml/src/cuda_pipeline/sp5_isv_slots.rs docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(isv): allocate 16 ISV slots for loss-balance controller state

LB_DIFF_VAR_CQL_BASE=297, LB_SAMPLE_VAR_CQL_BASE=301,
LB_DIFF_VAR_C51_BASE=305, LB_SAMPLE_VAR_C51_BASE=309. Bumps SP5_SLOT_END
297 → 313 and SP5_PRODUCER_COUNT 123 → 139. Helper fns mirror the
existing budget_*/flatness/q_var_per_branch pattern. Layout fingerprint
extended. slot_layout_no_overlaps_and_total_correct asserts the new
total.

Audit doc Fix 31 stub added; will be extended commit-by-commit through
Tasks 2-9 of the SP7 plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Register the 16 new slots in StateResetRegistry + fix stale CQL description

**Files:**
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs:585-595` (description fix)
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs` (append 4 new entries)
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T2 row)

- [ ] **Step 1: Add the import for the 4 new constants**

In `state_reset_registry.rs`, find the existing `use crate::cuda_pipeline::sp5_isv_slots::{...};` import block and add the four new constants:

```rust
use crate::cuda_pipeline::sp5_isv_slots::{
    /* ...existing constants... */
    LB_DIFF_VAR_CQL_BASE, LB_SAMPLE_VAR_CQL_BASE,
    LB_DIFF_VAR_C51_BASE, LB_SAMPLE_VAR_C51_BASE,
};
```

If there are several `use` lines for `sp5_isv_slots`, append to whichever already imports `BUDGET_CQL_BASE` (it will be the one feeding the registry).

- [ ] **Step 2: Update the stale `sp5_budget_cql` description**

Find lines 585-589 (`name: "sp5_budget_cql"`). The current description claims "CQL gating remains under the existing regime_stability allocator" — that allocator never existed. Replace the description with:

```rust
            RegistryEntry {
                name: "sp5_budget_cql",
                category: ResetCategory::FoldReset,
                description: "ISV[BUDGET_CQL_BASE=198..202) — per-branch CQL loss budget share. SP7 (2026-05-03): driven by the loss-balance controller (`loss_balance_controller_kernel`) using flatness-modulated target ratio (CQL/IQN ≤ 2.0×(1−flatness)). Pearl A sentinel 0 at fold boundary; consumer-side bootstrap = CQL_BOOTSTRAP_BUDGET = 0.02 in fused_training.rs::compute_adaptive_budgets.",
            },
```

While here, also clarify the `sp5_budget_c51` description (just above at line 575) — same controller now manages it. Replace its description with:

```rust
            RegistryEntry {
                name: "sp5_budget_c51",
                category: ResetCategory::FoldReset,
                description: "ISV[BUDGET_C51_BASE=190..194) — per-branch C51 loss budget share. SP7 (2026-05-03): driven by the loss-balance controller using flatness-modulated target ratio (C51/IQN ≤ 1.0×flatness). Pearl A sentinel 0 at fold boundary; consumer-side bootstrap = C51_BOOTSTRAP_BUDGET = 0.05 in fused_training.rs::compute_adaptive_budgets. (Pearl 2 no longer writes this slot post-SP7.)",
            },
```

And `sp5_budget_ens` (around line 590-594):

```rust
            RegistryEntry {
                name: "sp5_budget_ens",
                category: ResetCategory::FoldReset,
                description: "ISV[BUDGET_ENS_BASE=202..206) — per-branch ensemble loss budget share. SP7 (2026-05-03): consumer reads with sentinel-aware bootstrap = ENS_BOOTSTRAP_BUDGET = 0.02; Ens is currently not actively driven (no loss-balance target ratio defined for Ens vs IQN). Pearl A sentinel 0 at fold boundary. (Pearl 2 no longer writes this slot post-SP7.)",
            },
```

- [ ] **Step 3: Append four new entries for the SP7 state slots**

Find the END of the SP5 fold-reset block (right before the "Pearl 6 cross-fold-persistent" comment block at line 630). Insert four new entries:

```rust
            // SP7 (2026-05-03): loss-balance controller per-branch
            // Wiener-state EMAs. Both halves of the Pearl A contract
            // (sentinel 0 at fold boundary so the controller's
            // `old_budget < EPS_DIV` cold-start branch fires).
            RegistryEntry {
                name: "sp7_lb_diff_var_cql",
                category: ResetCategory::FoldReset,
                description: "ISV[LB_DIFF_VAR_CQL_BASE=297..301) — per-branch EMA of (candidate_cql − old_cql)² used as Wiener α numerator for the CQL budget controller. SP7 Pearl A sentinel 0 at fold boundary so α resets cleanly when budgets re-bootstrap.",
            },
            RegistryEntry {
                name: "sp7_lb_sample_var_cql",
                category: ResetCategory::FoldReset,
                description: "ISV[LB_SAMPLE_VAR_CQL_BASE=301..305) — per-branch EMA of (cql_norm)² used as Wiener α denominator for the CQL budget controller. SP7 Pearl A sentinel 0 at fold boundary.",
            },
            RegistryEntry {
                name: "sp7_lb_diff_var_c51",
                category: ResetCategory::FoldReset,
                description: "ISV[LB_DIFF_VAR_C51_BASE=305..309) — per-branch EMA of (candidate_c51 − old_c51)² used as Wiener α numerator for the C51 budget controller. SP7 Pearl A sentinel 0 at fold boundary.",
            },
            RegistryEntry {
                name: "sp7_lb_sample_var_c51",
                category: ResetCategory::FoldReset,
                description: "ISV[LB_SAMPLE_VAR_C51_BASE=309..313) — per-branch EMA of (c51_norm)² used as Wiener α denominator for the C51 budget controller. SP7 Pearl A sentinel 0 at fold boundary.",
            },
```

- [ ] **Step 4: Run the registry's existing tests**

```bash
SQLX_OFFLINE=true cargo test -p ml --lib --offline state_reset_registry -- --nocapture 2>&1 | tail -15
```

Expected: existing coverage tests pass — the registry now covers slots 174..313 (less the Pearl 6 carve-out) with no overlaps.

- [ ] **Step 5: Extend Fix 31 in the audit doc**

In `docs/dqn-wire-up-audit.md`, find the Fix 31 entry's "Subsequent landings" bullet for T2. Replace `- T2: state-reset registry entries.` with:

```markdown
- T2 (commit ⟨pending⟩): 4 SP7 ResetEntries + corrected `sp5_budget_cql`
  description (was claiming a non-existent "regime_stability allocator")
  + clarified `sp5_budget_c51`/`sp5_budget_ens` to reflect SP7 ownership.
```

(Leave `⟨pending⟩` literal — it gets the SHA replaced post-commit at the close-out task. The repo's audit-doc convention is to fill SHAs on close-out.)

- [ ] **Step 6: Commit**

```bash
git add crates/ml/src/trainers/dqn/state_reset_registry.rs docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(state): register loss-balance controller state slots + fix stale doc

4 new ResetEntries × 4 slots each cover ISV[297..313). Sentinel 0
triggers Pearl A first-observation bootstrap on fold boundary, matching
the existing Pearl 2/3/4/5/6/8 pattern.

Also: corrected the stale sp5_budget_cql description claiming a
non-existent "regime_stability allocator" (per
feedback_trust_code_not_docs); clarified sp5_budget_c51 / sp5_budget_ens
to reflect SP7 ownership.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Write the loss-balance controller kernel

**Files:**
- Create: `crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu`
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T3 row)

- [ ] **Step 1: Create the kernel file**

Path: `/home/jgrusewski/Work/foxhunt/crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu`

```cu
// crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu
//
// SP7 (2026-05-03): per-branch loss-balance controller for CQL and C51 budgets.
//
// Reads per-loss decomp pinned slots [mag_norm, dir_norm, trunk_norm] for
// IQN (reference), CQL_SX (managed, post-budget), C51 (managed, pre-budget).
// Reads per-branch flatness from ISV[FLATNESS_BASE..+4]. Reads prior
// budgets from ISV[BUDGET_{CQL,C51}_BASE..+4] and prior Wiener state from
// ISV[LB_*_VAR_{CQL,C51}_BASE..+4]. Writes new budgets + new Wiener
// observations to the producer scratch buffer (downstream
// `apply_pearls_ad_kernel` smooths into ISV).
//
// Math (per branch b ∈ {dir, mag, ord, urg}, per head h ∈ {CQL, C51}):
//   target_ratio[CQL][b] = ANCHOR_CQL_RATIO * (1 - flatness[b])
//   target_ratio[C51][b] = ANCHOR_C51_RATIO * flatness[b]
//   slice_idx = (b == 0) ? 1 : (b == 1) ? 0 : 2  // dir, mag, trunk
//   actual_ratio = h_norm[slice] / max(iqn_norm[slice], EPS_DIV)
//   correction   = target_ratio[h][b] / max(actual_ratio, EPS_DIV)
//   candidate    = old_budget * correction
//
//   diff       = candidate - old_budget
//   sample_sq  = h_norm[slice]^2
//   alpha_eff  = old_diff_var / (old_diff_var + old_sample_var + EPS_DIV)
//   alpha_eff  = clamp(alpha_eff, ALPHA_FLOOR, ALPHA_CEIL)
//   new_budget = clamp(old_budget + alpha_eff * diff, EPS_DIV, MAX_BUDGET)
//
// Cold-start (per spec section "Cold start"):
//   if h_norm[slice] < EPS_DIV || iqn_norm[slice] < EPS_DIV:
//       new_budget = cold_start_basis (matches consumer bootstrap)
//   else if old_budget < EPS_DIV:
//       new_budget = clamp(cold_start_basis * correction, EPS_DIV, MAX_BUDGET)
//
// Single block, 8 threads (2 heads × 4 branches). No atomicAdd.
// __threadfence_system() after writes.

#include <cuda_runtime.h>

extern "C" __global__ void loss_balance_controller_update(
    // Pointers into the shared `grad_decomp_result_pinned` buffer at the
    // 3-float component offsets (the launcher computes these by adding
    // (offset_elems * sizeof(float)) to grad_decomp_result_dev_ptr):
    //   iqn_decomp = base + 0  (slot iqn,    layout [mag, dir, trunk])
    //   cql_decomp = base + 24 (slot cql_sx, layout [mag, dir, trunk])
    //   c51_decomp = base + 36 (slot c51,    layout [mag, dir, trunk])
    const float* __restrict__ iqn_decomp,
    const float* __restrict__ cql_decomp,
    const float* __restrict__ c51_decomp,
    // ISV signal bus (read-only).
    const float* __restrict__ isv_signals,
    int   flatness_isv_base,                 // FLATNESS_BASE = 206
    int   budget_cql_isv_base,               // BUDGET_CQL_BASE = 198
    int   budget_c51_isv_base,               // BUDGET_C51_BASE = 190
    int   diff_var_cql_isv_base,             // LB_DIFF_VAR_CQL_BASE = 297
    int   sample_var_cql_isv_base,           // LB_SAMPLE_VAR_CQL_BASE = 301
    int   diff_var_c51_isv_base,             // LB_DIFF_VAR_C51_BASE = 305
    int   sample_var_c51_isv_base,           // LB_SAMPLE_VAR_C51_BASE = 309
    // Producer scratch buffer (kernel writes here; apply_pearls_ad_kernel
    // smooths these into ISV downstream).
    float* __restrict__ scratch_out,
    int   scratch_budget_cql_base,
    int   scratch_budget_c51_base,
    int   scratch_diff_var_cql_base,
    int   scratch_sample_var_cql_base,
    int   scratch_diff_var_c51_base,
    int   scratch_sample_var_c51_base
) {
    // ── Invariant 1 anchors ──────────────────────────────────────────
    // Modulated per-branch by flatness (spec section "Math").
    const float ANCHOR_CQL_RATIO    = 2.0f;
    const float ANCHOR_C51_RATIO    = 1.0f;
    // Numerical-stability anchors (Wiener α can mathematically span [0,1]).
    const float ALPHA_FLOOR         = 1e-4f;
    const float ALPHA_CEIL          = 0.5f;
    const float EPS_DIV             = 1e-8f;
    const float MAX_BUDGET          = 1.0f;
    // Cold-start basis — must match consumer-side bootstrap in
    // fused_training.rs (CQL_BOOTSTRAP_BUDGET, C51_BOOTSTRAP_BUDGET).
    const float COLD_START_FLOOR_CQL = 0.02f;
    const float COLD_START_FLOOR_C51 = 0.05f;

    // ── Thread layout: head × branch ─────────────────────────────────
    int tid = threadIdx.x;
    if (blockIdx.x != 0 || tid >= 8) return;

    int head   = tid / 4;       // 0 = CQL, 1 = C51
    int branch = tid % 4;       // 0..3 (dir, mag, ord, urg)

    // ── Slice mapping (spec "Slice→branch mapping") ──────────────────
    // grad_decomp pinned layout is [mag, dir, trunk] per component.
    // branch=0 → dir slice [1]; branch=1 → mag slice [0];
    // branch=2,3 → trunk slice [2].
    int slice_idx = (branch == 0) ? 1 : (branch == 1) ? 0 : 2;

    // ── Read the slice norms once ────────────────────────────────────
    float iqn_n   = iqn_decomp[slice_idx];
    float h_n     = (head == 0) ? cql_decomp[slice_idx] : c51_decomp[slice_idx];

    // ── Read flatness for this branch ────────────────────────────────
    float flat_b  = isv_signals[flatness_isv_base + branch];
    flat_b        = fminf(1.0f, fmaxf(0.0f, flat_b));   // safety clamp

    // ── Compute per-branch flatness-modulated target ratio ───────────
    float target_ratio = (head == 0)
        ? ANCHOR_CQL_RATIO * (1.0f - flat_b)
        : ANCHOR_C51_RATIO * flat_b;

    // ── Read prior budget + per-branch Wiener state ──────────────────
    int budget_isv_base       = (head == 0) ? budget_cql_isv_base       : budget_c51_isv_base;
    int diff_var_isv_base     = (head == 0) ? diff_var_cql_isv_base     : diff_var_c51_isv_base;
    int sample_var_isv_base   = (head == 0) ? sample_var_cql_isv_base   : sample_var_c51_isv_base;
    int scratch_budget_base   = (head == 0) ? scratch_budget_cql_base   : scratch_budget_c51_base;
    int scratch_diff_base     = (head == 0) ? scratch_diff_var_cql_base : scratch_diff_var_c51_base;
    int scratch_sample_base   = (head == 0) ? scratch_sample_var_cql_base : scratch_sample_var_c51_base;
    float cold_start_basis    = (head == 0) ? COLD_START_FLOOR_CQL      : COLD_START_FLOOR_C51;

    float old_budget     = isv_signals[budget_isv_base + branch];
    float old_diff_var   = isv_signals[diff_var_isv_base + branch];
    float old_sample_var = isv_signals[sample_var_isv_base + branch];

    // ── Branch on cold-start regime ──────────────────────────────────
    float new_budget;
    float diff;
    float sample_sq = h_n * h_n;

    if (h_n < EPS_DIV || iqn_n < EPS_DIV) {
        // Warmup hasn't fired or the loss head is gated off. Seed budget
        // at the consumer-side bootstrap value (effectively a no-op vs
        // the existing behavior on the very first step).
        new_budget = cold_start_basis;
        diff = 0.0f;
    } else {
        float actual_ratio = h_n / iqn_n;
        float correction   = target_ratio / fmaxf(actual_ratio, EPS_DIV);

        if (old_budget < EPS_DIV) {
            // Sentinel-0 read (cold start or fold boundary). The norm
            // we observed was produced under cql/c51_budget = cold_start_basis
            // (consumer-side bootstrap). Seed the controller at the value
            // that achieves target_ratio next step.
            float seed = cold_start_basis * correction;
            new_budget = fminf(MAX_BUDGET, fmaxf(EPS_DIV, seed));
            diff = new_budget - cold_start_basis;
        } else {
            float candidate = old_budget * correction;
            diff = candidate - old_budget;

            // Wiener α from prior state (no in-step state mutation —
            // we update the EMAs via scratch_out + apply_pearls_ad).
            float wiener_num = old_diff_var;
            float wiener_den = old_diff_var + old_sample_var + EPS_DIV;
            float alpha_eff  = wiener_num / wiener_den;
            alpha_eff = fminf(ALPHA_CEIL, fmaxf(ALPHA_FLOOR, alpha_eff));

            new_budget = old_budget + alpha_eff * diff;
            new_budget = fminf(MAX_BUDGET, fmaxf(EPS_DIV, new_budget));
        }
    }

    // ── Write the three outputs to scratch (apply_pearls_ad smooths) ──
    scratch_out[scratch_budget_base + branch] = new_budget;
    scratch_out[scratch_diff_base + branch]   = diff * diff;        // raw observation
    scratch_out[scratch_sample_base + branch] = sample_sq;          // raw observation

    __threadfence_system();
}
```

- [ ] **Step 2: Eyeball-syntax-check by reading the file back**

```bash
wc -l crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu
```

Expected: ~155-170 lines. If the file is empty or far off, re-run Step 1.

- [ ] **Step 3: Extend Fix 31 in the audit doc**

In `docs/dqn-wire-up-audit.md`, find the Fix 31 "Subsequent landings" line for T3. Replace with:

```markdown
- T3 (commit ⟨pending⟩): kernel `loss_balance_controller_kernel.cu`
  (~165 LOC). 8-thread single block (2 heads × 4 branches). Reads three
  3-float views into `grad_decomp_result_pinned`, FLATNESS_BASE, prior
  budgets, prior Wiener state. Writes new budget + raw Wiener observations
  to producer scratch. Cold-start sentinel-aware. Producer-only — no
  call site yet.
```

- [ ] **Step 4: Commit**

```bash
git add crates/ml/src/cuda_pipeline/loss_balance_controller_kernel.cu docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(cuda): loss-balance controller kernel (producer-only)

Single-block 8-thread kernel: 2 heads (CQL, C51) × 4 branches. Reads
3-float views into grad_decomp_result_pinned for IQN/CQL_SX/C51, plus
FLATNESS_BASE for the per-branch target modulator. Writes new budgets +
raw Wiener observations (diff² and sample²) to producer scratch.

Per-branch flatness-modulated target ratio (CQL → ANCHOR×(1-flatness),
C51 → ANCHOR×flatness). Wiener-optimal α from per-branch EMA state
slots, clamped to [1e-4, 0.5]. Cold-start seeds at consumer-side
bootstrap (0.02 CQL, 0.05 C51) on first observation; sentinel-aware.

Producer-only commit; build.rs entry, kernel slot, launch site land in
subsequent tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Add the kernel to build.rs cubin manifest

**Files:**
- Modify: `crates/ml/build.rs:475` (insert after `pearl_8_trail_kernel.cu`)
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T4 row)

The build.rs uses a flat `&[&str]` array of kernel filenames in the order they're documented (search for `"pearl_2_budget_kernel.cu"` at line 432).

- [ ] **Step 1: Insert the kernel name into the manifest**

In `crates/ml/build.rs`, find:

```rust
        "pearl_1_ext_num_atoms_kernel.cu",
        // SP5 Layer D Task D1 (2026-05-02): PnL aggregation producer.
```

(line 482-483 area). Insert BEFORE the SP5 Layer D block, AFTER the SP5 Layer A pearl entries:

```rust
        "pearl_1_ext_num_atoms_kernel.cu",
        // SP7 Task 4 (2026-05-03): per-branch loss-balance controller for
        // CQL and C51 budgets. Single-block 8-thread kernel reads
        // grad_decomp pinned slots (IQN/CQL_SX/C51 at offsets 0/24/36 bytes),
        // FLATNESS_BASE (206..210), prior budgets (190..194 + 198..202),
        // and prior Wiener state (297..313). Writes 24 floats to scratch
        // (budgets[8] + diff_var[8] + sample_var[8]). apply_pearls_ad_kernel
        // then smooths via Pearls A+D (Wiener-α derived per-step from the
        // state slots themselves) into ISV[BUDGET_{CQL,C51}_BASE] and
        // ISV[LB_*_VAR_*_BASE]. Replaces Pearl 2's hardcoded-zero CQL
        // budget output and floor-pinned C51 budget.
        "loss_balance_controller_kernel.cu",
        // SP5 Layer D Task D1 (2026-05-02): PnL aggregation producer.
```

- [ ] **Step 2: Run the build to confirm nvcc emits the cubin**

```bash
SQLX_OFFLINE=true cargo build -p ml --release --offline --features cuda 2>&1 | tail -15
```

Expected: clean build. The nvcc invocations should include `loss_balance_controller_kernel.cu` and the cubin should appear at `$OUT_DIR/loss_balance_controller_kernel.cubin`.

If nvcc fails with a syntax error, fix the kernel source from Task 3 and rebuild before continuing.

- [ ] **Step 3: Extend Fix 31 in the audit doc**

Replace the `T4` line in the Subsequent landings section:

```markdown
- T4 (commit ⟨pending⟩): build.rs cubin manifest entry. nvcc compiles
  loss_balance_controller_kernel.cu to $OUT_DIR/...cubin; consumed by
  gpu_dqn_trainer.rs in T5.
```

- [ ] **Step 4: Commit**

```bash
git add crates/ml/build.rs docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(build): compile loss_balance_controller_kernel cubin

Adds the kernel to the build.rs manifest after pearl_1_ext_num_atoms_kernel
and before the SP5 Layer D producers (matching the chronological SP5/SP7
producer ordering). nvcc-compiled cubin lands under
$OUT_DIR/loss_balance_controller_kernel.cubin and is loaded by
gpu_dqn_trainer.rs in T5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: Load + launcher fn in `gpu_dqn_trainer.rs`

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (multiple regions)
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T5 row)

- [ ] **Step 1: Add the cubin static**

Find the line:

```rust
static GRAD_DECOMP_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/grad_decomp_kernel.cubin"));
```

(around line 93). Add immediately after:

```rust
static LOSS_BALANCE_CONTROLLER_CUBIN: &[u8] =
    include_bytes!(concat!(env!("OUT_DIR"), "/loss_balance_controller_kernel.cubin"));
```

- [ ] **Step 2: Add scratch index constants for the new kernel**

Find the `SCRATCH_PEARL_2_FLATNESS` constant (~line 1197). The next contiguous block in the producer scratch buffer is for SP5 Layer D producers (`SCRATCH_PNL_AGG_BASE` etc.). Per the build-time documentation comments, the SP7 controller writes 24 floats (4 budgets × 2 heads + 4 diff_var × 2 heads + 4 sample_var × 2 heads).

Locate the next-free scratch index by searching for the highest existing constant:

```bash
grep -n "SCRATCH_.*BASE\|SCRATCH_.*: usize" crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs | head -30
```

Append the SP7 scratch indices at the end of that block (immediately after the SP5 Layer D scratch base — likely `SCRATCH_TRAINING_METRICS_EMA_BASE`):

```rust
/// SP7 Task 5 (2026-05-03): scratch index base for loss-balance controller
/// new_budget_cql[4]. Smoothed by apply_pearls_ad_kernel into
/// ISV[BUDGET_CQL_BASE..+4).
const SCRATCH_LB_BUDGET_CQL: usize = /* ⟨next free, e.g. 218⟩ */;
/// SP7: new_budget_c51[4] → ISV[BUDGET_C51_BASE..+4).
const SCRATCH_LB_BUDGET_C51: usize = SCRATCH_LB_BUDGET_CQL + 4;
/// SP7: diff_var_cql[4] → ISV[LB_DIFF_VAR_CQL_BASE..+4).
const SCRATCH_LB_DIFF_VAR_CQL: usize = SCRATCH_LB_BUDGET_CQL + 8;
/// SP7: sample_var_cql[4] → ISV[LB_SAMPLE_VAR_CQL_BASE..+4).
const SCRATCH_LB_SAMPLE_VAR_CQL: usize = SCRATCH_LB_BUDGET_CQL + 12;
/// SP7: diff_var_c51[4] → ISV[LB_DIFF_VAR_C51_BASE..+4).
const SCRATCH_LB_DIFF_VAR_C51: usize = SCRATCH_LB_BUDGET_CQL + 16;
/// SP7: sample_var_c51[4] → ISV[LB_SAMPLE_VAR_C51_BASE..+4).
const SCRATCH_LB_SAMPLE_VAR_C51: usize = SCRATCH_LB_BUDGET_CQL + 20;
```

Replace `⟨next free, e.g. 218⟩` with the actual next-free slot from the grep output. There's a comment block in `gpu_dqn_trainer.rs` (search for "20 floats for pearl_2_budget output (5 outputs × 4 branches at [111..131))") that documents the producer scratch layout — extend it to cover the new range.

If the trainer also exposes a hardcoded `producer_step_scratch_buf` length (search `producer_step_scratch_buf.*new\|num_scratch_slots\|PRODUCER_SCRATCH_LEN` near constructor calls), bump that constant by 24.

- [ ] **Step 3: Add the kernel slot in the GpuDqnTrainer struct**

Find the `pearl_2_budget_kernel: CudaFunction,` field (around line 4386). Add immediately after:

```rust
/// SP7 Task 5 (2026-05-03): loss-balance controller (CQL + C51 per-branch
/// budget adapter). Loaded from `loss_balance_controller_kernel.cubin`.
loss_balance_controller_kernel: CudaFunction,
```

- [ ] **Step 4: Load the kernel in the constructor**

Find the block where existing kernels are loaded — search for `pearl_2_budget_kernel = {` in the constructor (~line 14205). Mirror the pattern. Insert nearby:

```rust
let loss_balance_controller_kernel = {
    let module = ctx.load_module(Ptx::from_src(
        std::str::from_utf8(LOSS_BALANCE_CONTROLLER_CUBIN)
            .map_err(|e| MLError::ModelError(format!("loss_balance_controller_kernel utf8: {e}")))?
    ))
    .map_err(|e| MLError::ModelError(format!("loss_balance_controller_kernel load_module: {e}")))?;
    module.load_function("loss_balance_controller_update")
        .map_err(|e| MLError::ModelError(format!("loss_balance_controller_update load: {e}")))?
};
```

(Match the actual loading idiom used by `pearl_2_budget_kernel` in this file — if it uses `cudarc::nvrtc::compile_ptx` or `Module::load_data` instead, copy that exactly.)

Add `loss_balance_controller_kernel,` to the struct-init block at the end of the constructor where other kernels are populated.

- [ ] **Step 5: Add the launcher method**

Locate `pub(crate) fn launch_sp5_pearl_2_budget(&self) -> Result<(), MLError> {` (line 11202). Insert a new method **immediately after** its closing `}` (right after `Ok(())` returns):

```rust
/// SP7 Task 5 (2026-05-03): Launch the loss-balance controller producer +
/// apply_pearls_ad_kernel chain. Must run AFTER:
///   * `launch_sp5_pearl_2_budget`        (FLATNESS_BASE populated)
///   * `grad_decomp_launch_iqn`            (pinned slot at offset 0)
///   * `grad_decomp_launch_cql_sx`         (pinned slot at offset 6)
///   * `grad_decomp_launch_c51`            (pinned slot at offset 9)
///
/// Writes: scratch[SCRATCH_LB_*..]; `apply_pearls_ad_kernel` then smooths
/// these into ISV[BUDGET_{CQL,C51}_BASE] and ISV[LB_*_VAR_{CQL,C51}_BASE].
pub(crate) fn launch_loss_balance_controller(&self) -> Result<(), MLError> {
    use crate::cuda_pipeline::sp4_wiener_ema::launch_apply_pearls;
    use crate::cuda_pipeline::sp5_isv_slots::{
        SP5_SLOT_BASE,
        BUDGET_CQL_BASE, BUDGET_C51_BASE, FLATNESS_BASE,
        LB_DIFF_VAR_CQL_BASE, LB_SAMPLE_VAR_CQL_BASE,
        LB_DIFF_VAR_C51_BASE, LB_SAMPLE_VAR_C51_BASE,
    };

    debug_assert!(self.isv_signals_dev_ptr != 0,
        "launch_loss_balance_controller: isv_signals_dev_ptr must be allocated");
    debug_assert!(self.grad_decomp_result_dev_ptr != 0,
        "launch_loss_balance_controller: grad_decomp_result_dev_ptr must be allocated");

    let isv_dev     = self.isv_signals_dev_ptr;
    let scratch_dev = self.producer_step_scratch_buf.dev_ptr;
    let wiener_dev  = self.wiener_state_buf.dev_ptr;

    // grad_decomp pinned layout: 27 floats total, 9 components × 3 floats
    // each ([mag, dir, trunk]). Component offsets per the launch_grad_decomp
    // documentation: iqn=0, cql=3, cql_sx=6, c51=9, c51_bs=12, distill=15, …
    // We use cql_sx (post-budget delta) for ratio parity with what landed
    // in grad_buf. C51 uses its primary slot (no _bs needed because the
    // controller balances pre-budget signal vs IQN's pre-budget signal).
    let f32_size = std::mem::size_of::<f32>() as u64;
    let iqn_dev = self.grad_decomp_result_dev_ptr + 0 * f32_size;
    let cql_dev = self.grad_decomp_result_dev_ptr + 6 * f32_size;
    let c51_dev = self.grad_decomp_result_dev_ptr + 9 * f32_size;

    // Step 1: producer kernel.
    let flatness_isv_base_i32           = FLATNESS_BASE as i32;            // 206
    let budget_cql_isv_base_i32         = BUDGET_CQL_BASE as i32;          // 198
    let budget_c51_isv_base_i32         = BUDGET_C51_BASE as i32;          // 190
    let diff_var_cql_isv_base_i32       = LB_DIFF_VAR_CQL_BASE as i32;     // 297
    let sample_var_cql_isv_base_i32     = LB_SAMPLE_VAR_CQL_BASE as i32;   // 301
    let diff_var_c51_isv_base_i32       = LB_DIFF_VAR_C51_BASE as i32;     // 305
    let sample_var_c51_isv_base_i32     = LB_SAMPLE_VAR_C51_BASE as i32;   // 309
    let sb_budget_cql_i32               = SCRATCH_LB_BUDGET_CQL as i32;
    let sb_budget_c51_i32               = SCRATCH_LB_BUDGET_C51 as i32;
    let sb_diff_var_cql_i32             = SCRATCH_LB_DIFF_VAR_CQL as i32;
    let sb_sample_var_cql_i32           = SCRATCH_LB_SAMPLE_VAR_CQL as i32;
    let sb_diff_var_c51_i32             = SCRATCH_LB_DIFF_VAR_C51 as i32;
    let sb_sample_var_c51_i32           = SCRATCH_LB_SAMPLE_VAR_C51 as i32;

    unsafe {
        self.stream
            .launch_builder(&self.loss_balance_controller_kernel)
            .arg(&iqn_dev)
            .arg(&cql_dev)
            .arg(&c51_dev)
            .arg(&isv_dev)
            .arg(&flatness_isv_base_i32)
            .arg(&budget_cql_isv_base_i32)
            .arg(&budget_c51_isv_base_i32)
            .arg(&diff_var_cql_isv_base_i32)
            .arg(&sample_var_cql_isv_base_i32)
            .arg(&diff_var_c51_isv_base_i32)
            .arg(&sample_var_c51_isv_base_i32)
            .arg(&scratch_dev)
            .arg(&sb_budget_cql_i32)
            .arg(&sb_budget_c51_i32)
            .arg(&sb_diff_var_cql_i32)
            .arg(&sb_sample_var_cql_i32)
            .arg(&sb_diff_var_c51_i32)
            .arg(&sb_sample_var_c51_i32)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (8, 1, 1),
                shared_mem_bytes: 0,
            })
            .map_err(|e| MLError::ModelError(format!("loss_balance_controller_update: {e}")))?;
    }

    // Step 2: apply_pearls_ad_kernel × 24 — one per ISV output slot.
    // Wiener offset: (SP4_PRODUCER_COUNT + (isv_slot - SP5_SLOT_BASE)) * 3.
    let base_wiener_offset = SP4_PRODUCER_COUNT as i32 * 3;

    for (isv_base, scratch_base) in [
        (BUDGET_CQL_BASE,         SCRATCH_LB_BUDGET_CQL),
        (BUDGET_C51_BASE,         SCRATCH_LB_BUDGET_C51),
        (LB_DIFF_VAR_CQL_BASE,    SCRATCH_LB_DIFF_VAR_CQL),
        (LB_SAMPLE_VAR_CQL_BASE,  SCRATCH_LB_SAMPLE_VAR_CQL),
        (LB_DIFF_VAR_C51_BASE,    SCRATCH_LB_DIFF_VAR_C51),
        (LB_SAMPLE_VAR_C51_BASE,  SCRATCH_LB_SAMPLE_VAR_C51),
    ] {
        for b in 0..4_usize {
            let scratch_idx = (scratch_base + b) as i32;
            let isv_idx     = (isv_base + b) as i32;
            let wiener_off  = base_wiener_offset + (isv_idx - SP5_SLOT_BASE as i32) * 3;
            unsafe {
                launch_apply_pearls(
                    &self.stream,
                    &self.apply_pearls_ad_kernel,
                    scratch_dev, scratch_idx,
                    isv_dev,     isv_idx,
                    wiener_dev,  wiener_off,
                    1,
                    crate::cuda_pipeline::sp4_wiener_ema::ALPHA_META,
                )?;
            }
        }
    }

    Ok(())
}
```

- [ ] **Step 6: cargo check + cuda build**

```bash
SQLX_OFFLINE=true cargo check -p ml --offline 2>&1 | tail -10
SQLX_OFFLINE=true cargo build -p ml --release --offline --features cuda 2>&1 | tail -10
```

Expected: clean. The new method is `pub(crate)` and not yet called from outside, so no "unused" warnings.

- [ ] **Step 7: Extend Fix 31 in the audit doc**

Replace the `T5` line in Subsequent landings:

```markdown
- T5 (commit ⟨pending⟩): trainer struct + cubin static + 6 SCRATCH_LB_*
  constants + `launch_loss_balance_controller(&self)` fn (the producer
  kernel launch + 24 apply_pearls_ad chain). Producer-only — call site
  in training_loop.rs lands at T7 (atomic with consumer + stale doc).
```

- [ ] **Step 8: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(trainer): load loss-balance controller kernel + launcher fn

LOSS_BALANCE_CONTROLLER_CUBIN static, kernel slot in GpuDqnTrainer,
6 SCRATCH_LB_* index constants, pub(crate) launch_loss_balance_controller
that runs the producer + 24 apply_pearls_ad smoothing launches.

Component pointers into grad_decomp_result_pinned: IQN at offset 0,
CQL_SX at offset 6, C51 at offset 9 (3-float layout per
launch_grad_decomp docstring).

Producer-only — call site in training_loop.rs lands at T7 atomically
with the consumer floor change and stale-doc deletion per
feedback_no_partial_refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Pearl 2 contract change — drop CQL/C51/ENS args (full refactor)

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/pearl_2_budget_kernel.cu` (kernel signature + body)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` (launcher: drop args + delete orphan SCRATCH_PEARL_2_C51/_CQL/_ENS constants + shrink apply_pearls loop)
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T6 row)

**Why a full refactor:** per `feedback_no_partial_refactor`, "when a change touches a shared contract (kernel signature), EVERY consumer must migrate in the same commit". The kernel and its single launcher are the two halves; SCRATCH_PEARL_2_C51/_CQL/_ENS become orphan constants per `feedback_wire_everything_up`. All four changes ship together.

- [ ] **Step 1: Replace pearl_2_budget_kernel.cu with the slimmed version**

Open `crates/ml/src/cuda_pipeline/pearl_2_budget_kernel.cu` and replace the entire file contents with:

```cu
// crates/ml/src/cuda_pipeline/pearl_2_budget_kernel.cu
//
// SP5 Task A3 + SP7 (2026-05-03): Pearl 2 IQN budget + flatness producer.
//
// Reads Q_VAR_PER_BRANCH[b] (ISV[222..226), Pearl 1's q_branch_stats output)
// and NOISY_SIGMA[b] (ISV[210..214), Pearl 3 output). Writes:
//
//   flatness[b]   = clamp(var_q[b] / (σ[b]² + EPS_DIV), 0, 1)
//   budget_iqn[b] = BASE_IQN + (1 - flatness[b]) × (1 - BASE_IQN)
//
// SP7 takeover: BUDGET_C51_BASE, BUDGET_CQL_BASE, BUDGET_ENS_BASE are
// now driven by `loss_balance_controller_kernel.cu` (outcome-driven
// ratio controller using flatness as the target modulator). Pearl 2's
// kernel signature is reduced to drop the c51/cql/ens scratch-index
// arguments — the launcher in gpu_dqn_trainer.rs is updated atomically.
//
// 4-thread single-block (one thread per branch). No atomicAdd.
// __threadfence_system() after writes.
//
// BASE_IQN=0.11 and EPS_DIV=1e-8 are Invariant 1 structural anchors.

#include <cuda_runtime.h>

extern "C" __global__ void pearl_2_budget_update(
    const float* __restrict__ isv_signals,
    int   q_var_isv_base,         // Q_VAR_PER_BRANCH_BASE = 222
    int   sigma_isv_base,         // NOISY_SIGMA_BASE = 210
    float* __restrict__ scratch_out,
    int   iqn_idx_base,
    int   flatness_idx_base
) {
    int b = threadIdx.x;
    if (blockIdx.x != 0 || b >= 4) return;

    const float BASE_IQN = 0.11f;
    const float EPS_DIV  = 1e-8f;

    float var_q     = isv_signals[q_var_isv_base + b];
    float sigma     = isv_signals[sigma_isv_base + b];
    float sigma_sq  = sigma * sigma + EPS_DIV;
    float flatness  = fminf(1.0f, fmaxf(0.0f, var_q / sigma_sq));

    float budget_iqn = BASE_IQN + (1.0f - flatness) * (1.0f - BASE_IQN);

    scratch_out[iqn_idx_base + b]      = budget_iqn;
    scratch_out[flatness_idx_base + b] = flatness;

    __threadfence_system();
}
```

- [ ] **Step 2: Update the Pearl 2 launcher in gpu_dqn_trainer.rs**

Find `pub(crate) fn launch_sp5_pearl_2_budget(&self)` (line 11202). Replace the entire function body (between the `{` and matching `}`) with the new shape:

```rust
pub(crate) fn launch_sp5_pearl_2_budget(&self) -> Result<(), MLError> {
    use crate::cuda_pipeline::sp4_wiener_ema::launch_apply_pearls;
    use crate::cuda_pipeline::sp5_isv_slots::{
        SP5_SLOT_BASE,
        BUDGET_IQN_BASE, FLATNESS_BASE,
        NOISY_SIGMA_BASE, Q_VAR_PER_BRANCH_BASE,
    };

    debug_assert!(self.isv_signals_dev_ptr != 0,
        "launch_sp5_pearl_2_budget: isv_signals_dev_ptr must be allocated by constructor");

    let isv_dev     = self.isv_signals_dev_ptr;
    let scratch_dev = self.producer_step_scratch_buf.dev_ptr;
    let wiener_dev  = self.wiener_state_buf.dev_ptr;

    // SP7 (2026-05-03): kernel signature reduced to (isv, q_var, sigma,
    // scratch, iqn_idx, flatness_idx). The c51/cql/ens scratch slots
    // and apply_pearls smoothing for those slots are owned by the SP7
    // loss-balance controller; Pearl 2 only writes IQN + flatness now.
    let q_var_base_i32        = Q_VAR_PER_BRANCH_BASE as i32;   // 222
    let sigma_base_i32        = NOISY_SIGMA_BASE as i32;         // 210
    let iqn_base_i32          = SCRATCH_PEARL_2_IQN as i32;
    let flatness_base_i32     = SCRATCH_PEARL_2_FLATNESS as i32;

    unsafe {
        self.stream
            .launch_builder(&self.pearl_2_budget_kernel)
            .arg(&isv_dev)
            .arg(&q_var_base_i32)
            .arg(&sigma_base_i32)
            .arg(&scratch_dev)
            .arg(&iqn_base_i32)
            .arg(&flatness_base_i32)
            .launch(LaunchConfig {
                grid_dim:         (1, 1, 1),
                block_dim:        (4, 1, 1),
                shared_mem_bytes: 0,
            })
            .map_err(|e| MLError::ModelError(format!("pearl_2_budget_update: {e}")))?;
    }

    // SP7: smoothing chain reduced from 5 slot-blocks to 2 (IQN +
    // flatness). The SP7 controller owns the BUDGET_{C51,CQL,ENS}_BASE
    // smoothing chain.
    let base_wiener_offset = SP4_PRODUCER_COUNT as i32 * 3;

    for (isv_base, scratch_base) in [
        (BUDGET_IQN_BASE,  SCRATCH_PEARL_2_IQN),
        (FLATNESS_BASE,    SCRATCH_PEARL_2_FLATNESS),
    ] {
        for b in 0..4_usize {
            let scratch_idx = (scratch_base + b) as i32;
            let isv_idx     = (isv_base + b) as i32;
            let wiener_off  = base_wiener_offset + (isv_idx - SP5_SLOT_BASE as i32) * 3;
            unsafe {
                launch_apply_pearls(
                    &self.stream,
                    &self.apply_pearls_ad_kernel,
                    scratch_dev, scratch_idx,
                    isv_dev,     isv_idx,
                    wiener_dev,  wiener_off,
                    1,
                    crate::cuda_pipeline::sp4_wiener_ema::ALPHA_META,
                )?;
            }
        }
    }

    Ok(())
}
```

- [ ] **Step 3: Delete the now-orphan scratch index constants**

Find:

```rust
/// SP5 Task A3: scratch index base for pearl_2_budget budget_c51[4] output block.
const SCRATCH_PEARL_2_C51: usize = 111;       // 111..115
/// SP5 Task A3: scratch index base for pearl_2_budget budget_iqn[4] output block.
const SCRATCH_PEARL_2_IQN: usize = 115;       // 115..119
/// SP5 Task A3: scratch index base for pearl_2_budget budget_cql[4] output block.
const SCRATCH_PEARL_2_CQL: usize = 119;       // 119..123
/// SP5 Task A3: scratch index base for pearl_2_budget budget_ens[4] output block.
const SCRATCH_PEARL_2_ENS: usize = 123;       // 123..127
/// SP5 Task A3: scratch index base for pearl_2_budget flatness[4] output block.
const SCRATCH_PEARL_2_FLATNESS: usize = 127;  // 127..131
```

Replace with the slimmed set (SCRATCH_PEARL_2_C51, _CQL, _ENS deleted as orphans per `feedback_wire_everything_up`):

```rust
/// SP5 Task A3 + SP7 (2026-05-03): scratch index base for pearl_2_budget
/// budget_iqn[4] output block. C51/CQL/ENS scratch slots were retired in
/// SP7 — the loss-balance controller owns those budgets directly.
const SCRATCH_PEARL_2_IQN: usize = 115;       // 115..119
/// SP5 Task A3: scratch index base for pearl_2_budget flatness[4] output
/// block.
const SCRATCH_PEARL_2_FLATNESS: usize = 127;  // 127..131
```

The slots 111..115, 119..123, 123..127 in the producer scratch buffer become unused. Note in the comment that they're reserved for future producers.

If there's a docstring on the producer_step_scratch_buf field (search for `producer_step_scratch_buf:` and trace upward to its docstring), update it to mention the SP7 retirement.

- [ ] **Step 4: cargo check + build**

```bash
SQLX_OFFLINE=true cargo check -p ml --offline 2>&1 | tail -10
SQLX_OFFLINE=true cargo build -p ml --release --offline --features cuda 2>&1 | tail -10
```

Expected: clean. The deleted SCRATCH_PEARL_2_C51/_CQL/_ENS constants must have zero remaining references — if any code outside `launch_sp5_pearl_2_budget` referenced them, the compiler will catch it. If references appear in tests or other launch sites, those must be migrated too (drop the references — they're stale).

- [ ] **Step 5: Run pearl_2 unit tests**

```bash
SQLX_OFFLINE=true cargo test -p ml --lib --offline pearl_2 -- --nocapture 2>&1 | tail -20
```

Expected: existing tests pass. The kernel still computes IQN + flatness identically; the SP7 takeover is structurally invisible to those tests.

- [ ] **Step 6: Extend Fix 31 in the audit doc**

Replace the `T6` line:

```markdown
- T6 (commit ⟨pending⟩): Pearl 2 contract change. Kernel signature
  reduced from 9-arg to 6-arg (drops c51/cql/ens scratch idx args).
  Launcher migrated atomically — kernel-arg list shrinks from 9 to 6,
  apply_pearls smoothing loop shrinks from 5 slot-blocks to 2 (IQN +
  flatness). SCRATCH_PEARL_2_C51/_CQL/_ENS deleted as orphans per
  feedback_wire_everything_up. CQL/C51/ENS budget ownership now lives
  entirely in the SP7 controller.
```

- [ ] **Step 7: Commit**

```bash
git add crates/ml/src/cuda_pipeline/pearl_2_budget_kernel.cu \
        crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(pearl_2): contract change — drop CQL/C51/ENS args (atomic)

Kernel signature reduced from 9 to 6 args; pearl_2_budget_update now
writes only budget_iqn[4] and flatness[4]. Launcher migrated atomically:
arg list shrinks, apply_pearls smoothing loop shrinks from 5 slot-blocks
to 2.

SCRATCH_PEARL_2_C51, SCRATCH_PEARL_2_CQL, SCRATCH_PEARL_2_ENS deleted as
orphan constants per feedback_wire_everything_up. The corresponding
producer scratch slots (111..115, 119..127) become reserved-for-future
inside the buffer.

CQL/C51/ENS budget ownership now lives in the SP7 loss-balance
controller (loaded in T5; wired in T7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Atomic — wire launch site, replace consumer floor, delete stale docstring

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs:3704-3712`
- Modify: `crates/ml/src/trainers/dqn/fused_training.rs:3380-3415`
- Modify: `docs/dqn-wire-up-audit.md` (extend Fix 31 — T7 row)

**Why atomic:** per `feedback_no_partial_refactor`, the launch wiring + the consumer floor change are two halves of the same contract. Land them together so a bisect of HEAD never observes the controller writing without the consumer reading correctly (or vice versa).

- [ ] **Step 1: Add the SP7 launch site**

In `crates/ml/src/trainers/dqn/trainer/training_loop.rs`, find:

```rust
                    if let Err(e) = fused.trainer().launch_sp5_pearl_2_budget() {
                        tracing::warn!(error = %e, "SP5 Pearl 2 producer launch failed");
                    }
```

(line 3708-3710). Insert immediately after this block (and BEFORE `launch_sp5_pearl_4_adam_hparams`):

```rust
                    // SP7 Task 7 (2026-05-03): loss-balance controller —
                    // drives BUDGET_CQL_BASE and BUDGET_C51_BASE outcome-style
                    // based on grad-norm ratio relative to IQN, flatness-
                    // modulated per branch. Must run AFTER:
                    //   * launch_sp5_pearl_2_budget (FLATNESS_BASE populated)
                    //   * grad_decomp_launch_iqn / cql_sx / c51 (pinned norm
                    //     slots populated by the in-step backward pass)
                    if let Err(e) = fused.trainer().launch_loss_balance_controller() {
                        tracing::warn!(error = %e, "SP7 loss-balance controller launch failed");
                    }
```

- [ ] **Step 2: Replace the consumer floor block in compute_adaptive_budgets**

In `crates/ml/src/trainers/dqn/fused_training.rs`, find the block:

```rust
        for b in 0..4_usize {
            c51[b] = self.read_isv_signal_at(BUDGET_C51_BASE + b).max(0.05_f32);
            iqn[b] = self.read_isv_signal_at(BUDGET_IQN_BASE + b).max(0.05_f32);
            cql[b] = self.read_isv_signal_at(BUDGET_CQL_BASE + b).max(0.02_f32);
            ens[b] = self.read_isv_signal_at(BUDGET_ENS_BASE + b).max(0.02_f32);
        }
```

Replace with:

```rust
        // SP7 Task 7 (2026-05-03): sentinel-aware bootstrap. The SP7
        // loss-balance controller writes adaptive per-branch budgets
        // to BUDGET_CQL_BASE and BUDGET_C51_BASE; the controller may
        // legitimately drive budgets near zero when the structural
        // target says so (CQL when Q is flat, C51 when Q is sharp).
        // A hard floor here would clamp the controller's intent.
        // Instead, on a sentinel-0 read (cold start / fold boundary)
        // we use the bootstrap value; otherwise we take the
        // controller's value verbatim.
        //
        // Bootstrap values must match the cold-start basis in
        // loss_balance_controller_kernel.cu (CQL_BOOTSTRAP=0.02,
        // C51_BOOTSTRAP=0.05). IQN keeps the structural BASE_IQN
        // floor (reference budget — can't be 0 without breaking
        // the ratio compute in the controller).
        const SP7_EPS_DIV: f32 = 1e-8;
        const CQL_BOOTSTRAP_BUDGET: f32 = 0.02;
        const C51_BOOTSTRAP_BUDGET: f32 = 0.05;
        const ENS_BOOTSTRAP_BUDGET: f32 = 0.02;
        const BASE_IQN: f32 = 0.11;
        let budget_or_bootstrap = |raw: f32, bootstrap: f32| -> f32 {
            if raw < SP7_EPS_DIV { bootstrap } else { raw }
        };
        for b in 0..4_usize {
            c51[b] = budget_or_bootstrap(self.read_isv_signal_at(BUDGET_C51_BASE + b),
                                         C51_BOOTSTRAP_BUDGET);
            iqn[b] = self.read_isv_signal_at(BUDGET_IQN_BASE + b).max(BASE_IQN);
            cql[b] = budget_or_bootstrap(self.read_isv_signal_at(BUDGET_CQL_BASE + b),
                                         CQL_BOOTSTRAP_BUDGET);
            ens[b] = budget_or_bootstrap(self.read_isv_signal_at(BUDGET_ENS_BASE + b),
                                         ENS_BOOTSTRAP_BUDGET);
        }
```

- [ ] **Step 3: Delete the stale "regime_stability allocator" docstring**

Find:

```rust
    /// B4/G5: Last adaptive CQL gradient budget (0.10×(1−regime)×health).
    pub(crate) fn last_cql_budget_eff(&self) -> f32 { self.trainer.last_cql_budget_eff }
```

(line 3409). Replace with:

```rust
    /// SP7 (2026-05-03): Last per-step CQL budget (mean of 4 per-branch
    /// values driven by `loss_balance_controller_kernel`). The previous
    /// docstring claimed a "0.10×(1−regime)×health" formula that was
    /// never implemented; per `feedback_trust_code_not_docs`, code wins
    /// over docs.
    pub(crate) fn last_cql_budget_eff(&self) -> f32 { self.trainer.last_cql_budget_eff }
```

If the same kind of "B4/G5" stale phrasing appears on `last_iqn_budget_eff` or `last_c51_budget_eff` (search the surrounding region — typically lines 3407-3415), update them analogously: IQN owned by Pearl 2's flatness logic, C51 owned by SP7 controller.

- [ ] **Step 4: cargo build with cuda**

```bash
SQLX_OFFLINE=true cargo build -p ml --release --offline --features cuda 2>&1 | tail -10
```

Expected: clean.

- [ ] **Step 5: Local sanity smoke (RTX 3050 Ti)**

The local laptop's MBP-10 data is too short for `multi_fold_convergence` to complete a full fold (per `feedback_mbp10_mandatory`), but the controller's first-step behavior is observable in any 1-step training test. Use `magnitude_distribution`:

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline \
FOXHUNT_MBP10_DATA=test_data/futures-baseline-mbp10 \
FOXHUNT_TRADES_DATA=test_data/futures-baseline-trades \
SQLX_OFFLINE=true cargo test -p ml --release --offline --features cuda \
  smoke_tests::magnitude_distribution::test_magnitude_distribution \
  -- --ignored --nocapture 2>&1 | tail -30
```

Expected: PASS. The acceptance criterion at this stage is just "doesn't crash" — meaningful budget drift requires the full L40S smoke (Task 9).

- [ ] **Step 6: Extend Fix 31 in the audit doc**

Replace the `T7` line:

```markdown
- T7 (commit ⟨pending⟩): atomic wire-up. (a) launch_loss_balance_controller
  added to the per-step pipeline after Pearl 2 + backward; (b) consumer
  compute_adaptive_budgets replaces hard floors with sentinel-aware
  bootstrap (CQL_BOOTSTRAP=0.02, C51_BOOTSTRAP=0.05, ENS_BOOTSTRAP=0.02);
  IQN keeps BASE_IQN=0.11 floor as reference; (c) stale "B4/G5" docstring
  on last_cql_budget_eff replaced with SP7 owner reference. Layer A
  complete: producer + consumer + observability all wired and consistent.
```

- [ ] **Step 7: Commit (atomic — three changes)**

```bash
git add crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        crates/ml/src/trainers/dqn/fused_training.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(wire): launch loss-balance controller + sentinel-aware consumer (atomic)

Atomic per feedback_no_partial_refactor — launch site + consumer floor
change + stale doc are three halves of the same contract:

(a) launch_loss_balance_controller wired into the per-step pipeline
    after launch_sp5_pearl_2_budget (FLATNESS_BASE populated) and after
    backward (grad_decomp pinned slots populated).

(b) compute_adaptive_budgets replaces hard floors (0.02/0.05) with
    sentinel-aware bootstrap. The controller may now drive budgets near
    zero when the structural target says so. IQN keeps BASE_IQN=0.11
    structural floor (reference, can't be 0).

(c) Delete stale "B4/G5: 0.10×(1−regime)×health" docstring at
    last_cql_budget_eff — formula was never implemented; per
    feedback_trust_code_not_docs, code wins over docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Memory pearl + index entry (out-of-tree)

**Files:**
- Create: `~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_loss_balance_controller.md`
- Modify: `~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/MEMORY.md`

These files are NOT under git. They live in the user-memory subsystem so future sessions can pattern-match.

- [ ] **Step 1: Create the memory pearl file**

Path (literal): `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_loss_balance_controller.md`

```markdown
---
name: pearl_loss_balance_controller
description: Two-layer controller for balancing multiple loss heads against a reference; flatness-modulated target × Wiener-α outcome control. Generalises pearl_adaptive_moe_lambda. Canonical SP7 (2026-05-03).
type: feedback
---

When two loss heads contribute to the same parameter buffer at structurally
different magnitudes (e.g., CQL's softmax-of-logsumexp emits gradient on
all actions × atoms while IQN only emits on the action taken), a budget
floor in the consumer pins them apart from any controller's intent. The
fix is a closed-loop ratio controller with two layers:

1. **Outer layer (signal-modulated target)**: per-branch target ratio is
   modulated by a structural signal — flatness, regime_stability,
   gradient_disagreement. Canonical: `target_cql/iqn = ANCHOR × (1 − flatness)`
   (CQL turns off when Q is flat); `target_c51/iqn = ANCHOR × flatness`
   (C51 turns off when Q is sharp). Realises
   `pearl_q_flatness_gates_conservatism`.

2. **Inner layer (outcome-driven control)**: multiplicative correction
   `new_budget = old × target/actual`, with Wiener-optimal α tracked in
   dedicated per-branch EMA state slots, replacing the canonical fixed
   α used in earlier controller pearls.

The consumer floor must be sentinel-aware (`if raw < EPS_DIV use bootstrap
else use raw verbatim`) — a hard floor that floors EVERY read pins the
budget against the controller's intent. Generalises
`pearl_adaptive_moe_lambda` to multi-head loss balancing and lifts the
α to Wiener-optimal per `pearl_wiener_optimal_adaptive_alpha`.

**Why**: future-me dispatching a similar adaptive-coefficient task should
reach for two-layer (signal-modulated target × outcome-driven controller)
designs when there's a known structural asymmetry between loss heads, AND
should grep for hard floors in consumers that would silently fight the
controller.

**How to apply**: when a new loss head is added to the training stack:
1. Decide whether it's a reference (drives own budget independently) or
   managed (driven by ratio to a reference).
2. Identify the structural signal that should modulate the target —
   flatness, regime_stability, gradient_disagreement, etc.
3. Add the controller's launch site after the head's `grad_decomp_launch_*`
   so the grad-norm is populated.
4. Verify the consumer-side budget read is sentinel-aware bootstrap, not
   hardcoded floor.

**Canonical implementation**: SP7 (2026-05-03) — `loss_balance_controller_kernel.cu`,
`docs/superpowers/specs/2026-05-03-sp7-loss-balance-controller-design.md`,
audit doc Fix 31.
```

- [ ] **Step 2: Add an index line to MEMORY.md**

Path: `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/MEMORY.md`

Find the `pearl_adaptive_moe_lambda.md` index line (it appears in the index list near the SP4 pearl entries). Add a sibling line:

```markdown
- [pearl_loss_balance_controller.md](pearl_loss_balance_controller.md) — SP7: two-layer signal-modulated target × Wiener-α controller for balancing CQL/C51 against IQN; replaces dormant Pearl 2 budget output. Fixes magnitude eval-collapse.
```

- [ ] **Step 3: Verify the files landed**

```bash
ls -la ~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_loss_balance_controller.md
grep -c "pearl_loss_balance_controller" ~/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/MEMORY.md
```

Expected: file present; grep returns ≥ 1.

No commit — these files are managed by the user-memory subsystem and not under git.

---

## Task 9: L40S 5-epoch smoke verification

**Files:** none — pure verification.

The controller's behavior is only visible at production data scale (the local laptop's 1-quarter MBP-10 data isn't long enough to reach a fold boundary in `multi_fold_convergence`). The 5-epoch smoke is the cheapest way to verify the controller actually drives budgets and converges toward the flatness-gated target.

- [ ] **Step 1: Push to origin**

```bash
git log --oneline origin/sp5-magnitude-differentiation..HEAD
git push 2>&1 | tail -3
```

Expected: 7 new commits visible (Tasks 1-7). Push succeeds.

- [ ] **Step 2: Submit the smoke**

```bash
./scripts/argo-smoke.sh --ref $(git rev-parse HEAD) 2>&1 | tail -10
```

Expected output: `Submitted workflow ... smoke-test-XXXXX`. Note the workflow name.

- [ ] **Step 3: Stream HEALTH_DIAG output**

```bash
kubectl logs -f -n foxhunt smoke-test-XXXXX --since=5s 2>&1 | \
  grep -E --line-buffered "HEALTH_DIAG|cql_budget_per_branch|c51_budget_per_branch|grad_split_bwd|panicked|panic|FAILED|Killed|OOM|test result"
```

(Substitute the workflow name from Step 2.)

- [ ] **Step 4: Acceptance check at HEALTH_DIAG[4]**

When HEALTH_DIAG[4] for the first fold appears:

a) `cql_budget_per_branch [dir=X mag=Y ord=Z urg=W]` shows at least one
   of X/Y/Z/W differing from `0.02` by more than `EPS_DIV=1e-8`.
   Confirms the controller is writing, not just relying on bootstrap.

b) Compute `actual_ratio_cql_mag = (cql_grad / iqn_grad)` from the
   `grad_split_bwd` line, and `target_ratio_cql_mag = 2.0 × (1 - flatness[mag])`
   from `flatness` (read from HEALTH_DIAG). Confirm
   `actual_ratio_cql_mag / target_ratio_cql_mag` is in `[0.5, 2.0]`.

c) Same for C51: `c51_budget_per_branch` should differ from `0.05`,
   and `actual_ratio_c51_mag / (1.0 × flatness[mag])` in `[0.5, 2.0]`.

d) Smoke completes with `test result: ok. 1 passed`.

If any check fails, STOP and diagnose. Common failure modes:
- (a) fails → controller isn't being launched. Re-check Task 7 Step 1.
- (b) fails → kernel math bug or wrong grad_decomp offset. Re-check
  Task 3 (kernel) and Task 5 (cql/c51 dev_ptr offsets).
- (d) NaN or panic → kernel out-of-bounds or division by zero.
  Re-check `slice_idx` mapping in Task 3 and the EPS_DIV guards.

- [ ] **Step 5: Update audit doc with smoke result**

If acceptance passed, edit `docs/dqn-wire-up-audit.md` Fix 31 entry — add a "Verification (T9 smoke)" subsection at the bottom of Fix 31:

```markdown
**Verification (T9 — L40S 5-epoch smoke ⟨smoke-test-XXXXX⟩)**: PASS.
At HEALTH_DIAG[4]:
- `cql_budget_per_branch` = ⟨values⟩ (drift from 0.02 confirmed for ≥1 branch).
- `actual_ratio_cql_mag / target_ratio_cql_mag` = ⟨ratio⟩ (within [0.5, 2.0]).
- `c51_budget_per_branch` = ⟨values⟩ (drift from 0.05 confirmed).
- Test result: ok. 1 passed.
```

(Substitute the actual workflow name + observed values.)

```bash
git add docs/dqn-wire-up-audit.md
git commit -m "sp7(verify): L40S 5-epoch smoke PASS — controller drives budgets"
git push 2>&1 | tail -3
```

---

## Task 10: L40S 50-epoch full validation

**Files:** none — pure verification.

**Gating:** Task 9 PASS is required.

- [ ] **Step 1: Submit the multi-seed validation (single seed, 3 folds, baseline)**

```bash
./scripts/argo-train.sh dqn --sha $(git rev-parse HEAD) \
  --gpu-pool ci-training-l40s --multi-seed 1 --folds 3 --epochs 50 --baseline 2>&1 | tail -15
```

Note: `--multi-seed 1` (not 3) — only one L40S is currently online; multi-seed with no autoscaler-provisioned nodes wastes scheduling slots.

Expected output: workflow name `train-multi-seed-XXXXX`.

- [ ] **Step 2: Stream milestone-epoch logs**

```bash
kubectl logs -f -n foxhunt train-multi-seed-XXXXX --since=5s 2>&1 | \
  grep -E --line-buffered "HEALTH_DIAG\[(0|9|19|29|39|49)\]|cql_budget_per_branch|c51_budget_per_branch|q_full|q_quarter|eval_dist|panicked|panic|Killed|OOM|FAILED|test result"
```

(Substitute workflow name.)

- [ ] **Step 3: Acceptance check at HEALTH_DIAG[19] (epoch 20)**

a) `(q_full − q_quarter) > 2 × NOISY_SIGMA[mag]` — magnitude Q-spread
   exceeds NoisyNet σ. From the HEALTH_DIAG line, extract `q_full`,
   `q_quarter`, `sigma_mag`. Compute spread vs threshold.

b) `eval_dist[ef] > eval_dist[eq] / 4` — Full magnitude is at least
   25% of Quarter mass. Read from `eval_dist [eq=… eh=… ef=…]`.

c) `label_scale ∈ [0.05 × NOISY_SIGMA[mag], 5 × NOISY_SIGMA[mag]]` —
   Bug-1 invariant preserved.

If (a)-(c) all hold by ep20, the controller is structurally working.
Continue letting the run finish to confirm ep20 isn't a transient.

If (a) or (b) fails by ep30, the controller works but anchors are
wrong. Stop the run, capture HEALTH_DIAG state, revisit
`ANCHOR_CQL_RATIO` / `ANCHOR_C51_RATIO` in Task 3 (Step 1).

- [ ] **Step 4: When the validation completes (~110min)**

Confirm `test result: ok` for at least 2/3 folds. Workflow status in
Argo UI shows Succeeded.

- [ ] **Step 5: Final close-out commit**

Edit `docs/dqn-wire-up-audit.md` Fix 31 entry:

(a) Replace each `commit ⟨pending⟩` with the actual SHA from `git log`
    (use `git log --pretty=format:'%h %s' origin/sp5-magnitude-differentiation..HEAD`
    to list them).

(b) Append a "Verification (T10 — L40S 50-epoch full validation)"
    subsection with the actual ep20 numbers:

```markdown
**Verification (T10 — L40S 50-epoch ⟨train-multi-seed-XXXXX⟩)**: PASS.
At HEALTH_DIAG[19]:
- q_full=⟨X⟩, q_quarter=⟨Y⟩, spread=⟨Z⟩ vs 2×σ=⟨T⟩ (ratio: ⟨R⟩).
- eval_dist[eq]=⟨A⟩ eval_dist[ef]=⟨B⟩ (ratio: ⟨B/A⟩).
- label_scale=⟨L⟩ within [0.05×σ, 5×σ] band.
Folds 0,1,2 all completed with `test result: ok`.
```

(c) Mark the Fix 31 entry status `(IN PROGRESS)` → `(COMPLETE)` at the top.

```bash
git add docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
sp7(verify): L40S 50-epoch full validation PASS — close out Fix 31

Magnitude differentiation restored: q_full − q_quarter > 2 × σ_mag at
ep20; eval_dist[ef] > eval_dist[eq]/4. Bug-1 invariant preserved
(label_scale within band). All 3 folds completed cleanly.

Audit doc Fix 31 status flipped to COMPLETE; commit SHAs for T1-T7
filled in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push 2>&1 | tail -3
```

If the validation reveals a regression (label_scale out of band, NaN,
Q-drift), the structural assumption that the controller is the right
fix is wrong. STOP — file a follow-up brainstorm task; do NOT push the
results commit.

---

## Self-review

**1. Spec coverage:**

| Spec section | Plan task |
|---|---|
| Goal / problem | Task 1 (audit doc Fix 31 stub captures the bug) |
| Architecture diagram | Task 3 (kernel) + Task 5 (launcher) + Task 7 (wire) |
| Inputs (3 component pinned views) | Task 5 (offsets 0/24/36 bytes from grad_decomp_result_dev_ptr) |
| Outputs (BUDGET_CQL_BASE, BUDGET_C51_BASE) | Task 3 (kernel writes scratch) + Task 5 (apply_pearls_ad smooths) |
| Math (flatness-gated target, Wiener-α) | Task 3 (kernel body) |
| Cold start / sentinel bootstrap | Task 3 (kernel) + Task 7 (consumer) — contract documented in both |
| Fold-boundary reset | Task 2 (StateResetRegistry) |
| Smoothing (Pearl D Wiener-α) | Task 5 (apply_pearls_ad chain × 24) |
| State-tracker slot block (16 slots) | Task 1 |
| Pearl 2 changes (drop CQL/C51/ENS args) | Task 6 (full contract change, not partial) |
| Floor cleanup (consumer-side) | Task 7 |
| Stale doc cleanup | Task 7 |
| File list | All tasks combined |
| Invariant-1 anchors | Task 3 (kernel constants), Task 7 (consumer constants) |
| Acceptance criteria 1 (build clean) | Tasks 1, 4, 5, 6, 7 each commit with build verification |
| Acceptance criteria 2 (local smoke) | Task 7 Step 5 |
| Acceptance criteria 3 (L40S 5-epoch smoke) | Task 9 |
| Acceptance criteria 4 (50-epoch full validation) | Task 10 |
| Acceptance criteria 5 (no regressions) | Task 10 |
| Memory pearl | Task 8 |

All spec sections covered.

**2. Placeholder scan:** Checked. The only `⟨…⟩` placeholders are runtime values that don't exist yet (commit SHAs, workflow names, observed values) — not implementation gaps. They get filled at the close-out task. No "TBD"/"TODO"/"appropriate"/"either-or" deferrals.

**3. Type consistency:**
- `BUDGET_CQL_BASE` / `BUDGET_C51_BASE` referenced consistently across Tasks 1, 3, 5, 6, 7
- `LB_*_VAR_*_BASE` consistent across Tasks 1, 2, 3, 5
- `SCRATCH_LB_*` consistent across Task 5
- `SCRATCH_PEARL_2_C51`/`_CQL`/`_ENS` deleted in Task 6 — no later task references them
- `loss_balance_controller_update` (kernel name) consistent: Task 3 declares, Task 4 builds, Task 5 loads
- `launch_loss_balance_controller` (Rust method) consistent: Task 5 declares, Task 7 calls
- `CQL_BOOTSTRAP_BUDGET=0.02` / `C51_BOOTSTRAP_BUDGET=0.05` match between kernel (Task 3) and consumer (Task 7) — contract tracked by header comments in both files
- Pearl 2 kernel signature: 9-arg in pre-state, 6-arg post Task 6; launcher migrates atomically in same commit

**4. Audit-doc gate:** every commit that stages a file under `crates/ml/src/cuda_pipeline/` also stages `docs/dqn-wire-up-audit.md` with a Fix 31 update. Tasks 1-7 each include the audit-doc edit. Task 8 is out-of-tree (memory pearl). Tasks 9-10 are pure verification + close-out audit-doc updates.

**5. Layer A pattern:** Tasks 1-6 are infrastructure-only (no behavior change) — match the SP4/SP5 Layer A producer-only landing pattern. Task 7 is the atomic Layer A→B transition that flips the consumer. Tasks 9-10 verify.

---

## Plan complete

Saved to `docs/superpowers/plans/2026-05-03-sp7-loss-balance-controller.md`.

Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, two-stage review (spec compliance + code quality) between tasks, fast iteration in this session.

**2. Inline Execution** — Execute tasks in this session via `superpowers:executing-plans`, batch with checkpoints for review.

Which approach?
