# DQN v2 Plan 2 — Temporal Core Implementation Plan

> **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:** Complete the temporal substrate for the DQN v2: quantile-based atom support, Mamba2 backward validation (already wired — discovered during Plan 1 A.5 audit), per-branch gamma, soft fold transitions, Liquid Time-constant audit, and the coordinated state-layout migration that lands horizon-decomposed V, plan_isv[6], and TLOB features in one coordinated commit.

**Architecture:** ~14 new ISV slots added (8 Q-quantiles + 4 per-branch γ replacing 1 scalar γ + 2 horizon-V aggregates, net ~13 plus small ones). Two new GPU update kernels (`q_quantile_reduce`, `per_branch_gamma_update`). Four+ new read-only `AdaptiveMonitor` observers (per-branch γ, possibly liquid_mod). Mamba2 backward already implemented — this plan only validates. State vector grows from 104 to `104 + 1 + D_tlob` (plan_isv[6] adds 1; TLOB feature block adds D_tlob). Layout fingerprint updates automatically as seed bytes change (no "schema version bump" — structural hash recomputes at compile-time per spec §4.A.2).

**Tech Stack:** Rust workspace + CUDA C++ via nvcc, cudarc 0.19, sccache with `CARGO_INCREMENTAL=0`. New dependencies: none. New CUDA kernel files: `q_quantile_reduce_kernel.cu`, `per_branch_gamma_update_kernel.cu`.

**Architectural principle (from Plan 1 §4.C.6 revision, commit cf36091ee):** **GPU drives, CPU reads.** All adaptive values in Plan 2 are computed by GPU kernels from ISV signals and written to ISV slots; CPU-side `AdaptiveMonitor` impls are READ-ONLY observers. No CPU-side adaptive computation.

**Authority:** `docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md` §4.C.1, §4.D.1–D.8. Dependencies on Plan 1: StateResetRegistry, AdaptiveMonitor trait (read-only), audit docs, ISV layout fingerprint, current ISV_TOTAL_DIM=49, named monitors at `crates/ml/src/trainers/dqn/monitors/`.

**Current ISV layout (post-Plan-1, commit 536eea20b or later):**

| Index range | Purpose |
|---|---|
| [0..12) | (legacy slots) |
| [12] | LEARNING_HEALTH_INDEX |
| [13..22) | Q_MAG_MEAN_*, Q_DIR_MEAN_* |
| [22] | SHARPE_EMA_INDEX |
| [23..31) | V_CENTER_*, V_HALF_* (8 slots) |
| [31..35) | GRAD_NORM_TARGET_* (4 slots) |
| [35] | GRAD_SCALE_LIMIT_INDEX |
| [36] | IQL_BRANCH_SCALE_FLOOR_INDEX |
| [39] | EPOCH_IDX_INDEX (CPU-written, per epoch) |
| [40] | TOTAL_EPOCHS_INDEX (CPU-written, constructor) |
| [41] | EPSILON_EFF_INDEX (GPU-written) |
| [42] | TAU_EFF_INDEX (GPU-written) |
| [43] | GAMMA_EFF_INDEX (GPU-written, scalar — Plan 2 Task 3 replaces with 4 per-branch slots) |
| [44] | KELLY_CAP_EFF_INDEX (GPU-written) |
| [45] | CQL_ALPHA_INDEX (static) |
| [46] | PLAN_THRESHOLD_INDEX (static) |
| [47..49) | ISV_LAYOUT_FINGERPRINT_{LO,HI}_INDEX (tail; shifts as new slots added) |
| ISV_TOTAL_DIM | 49 |

New slots added by Plan 2 fit in the range `[43..43+N)` by REPLACING `GAMMA_EFF_INDEX` with 4 per-branch slots (Task 3), and at the tail (ISV_TOTAL_DIM grows) for the remaining new slots. Fingerprint re-tails automatically.

**Prerequisites:** Plan 1 landed on main. Verify:
```bash
git log --oneline | grep -E "(plan-1-substrate|Plan 1)" | head -5
```
Expected: Plan 1 commits present on current branch.

---

## Pre-plan: verify Plan 1 state

- [ ] **Step 0.1: Verify Plan 1 exit criteria satisfied**

Run:
```bash
# Verify audit docs exist and are populated
for d in docs/dqn-wire-up-audit.md docs/isv-slots.md docs/dqn-gpu-hot-path-audit.md docs/dqn-named-dims.md docs/ml-supervised-to-dqn-concept-audit.md; do
    if [ ! -s "$d" ]; then echo "MISSING: $d"; exit 1; fi
done

# Verify ISV layout fingerprint exists in gpu_dqn_trainer.rs
grep -q "ISV_LAYOUT_FINGERPRINT_LO_INDEX\s*:\s*usize\s*=\s*0" crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs || {
    echo "Plan 1 A.2 not complete — ISV_LAYOUT_FINGERPRINT_LO_INDEX missing"; exit 1
}
grep -q "LAYOUT_FINGERPRINT_CURRENT" crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs || {
    echo "Plan 1 A.2 not complete — LAYOUT_FINGERPRINT_CURRENT missing"; exit 1
}

# Verify StateResetRegistry exists
grep -q "pub struct StateResetRegistry" crates/ml/src/trainers/dqn/state_reset_registry.rs || {
    echo "Plan 1 A.1 not complete — StateResetRegistry missing"; exit 1
}

# Verify AdaptiveMonitor trait exists (read-only observer, spec §4.C.6 2026-04-24 revision)
grep -q "pub(crate) trait AdaptiveMonitor" crates/ml/src/trainers/dqn/adaptive_monitor.rs || {
    echo "Plan 1 C.6 not complete — AdaptiveMonitor trait missing"; exit 1
}
# Verify Plan 1 monitor consumers exist (6 monitors wired)
for m in grad_balancer_monitor tau_monitor epsilon_monitor gamma_monitor kelly_cap_monitor atoms_monitor; do
    [ -f "crates/ml/src/trainers/dqn/monitors/${m}.rs" ] || {
        echo "Plan 1 monitor missing: ${m}.rs"; exit 1
    }
done

echo "Plan 1 exit criteria satisfied. Plan 2 may begin."
```

Expected: `Plan 1 exit criteria satisfied. Plan 2 may begin.`

- [ ] **Step 0.2: Verify baseline compiles**

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 RUSTC_WRAPPER=~/.local/bin/sccache cargo check -p ml 2>&1 | tail -5
```

Expected: Finished with no new errors/warnings beyond baseline (pre-existing 8 warnings).

---

## Task 1: C.1 Quantile-based atom support

**Files:**
- Create: `crates/ml/src/cuda_pipeline/q_quantile_kernel.cu` — new CUDA kernel for per-branch Q-quantile reduction
- Modify: `crates/ml/build.rs` — register new kernel for compilation
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — add 8 new ISV slots, quantile reduction call
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — refactor `update_eval_v_range` to use quantile-based `half`
- Test: inline unit tests in `q_quantile_kernel.cu` via a Rust smoke harness

### Subtask 1A: Allocate 8 new ISV slots for Q quantiles

- [ ] **Step 1A.1: Add new ISV slot constants**

In `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`, near the existing ISV slot block (post Plan 1 shifts, after `IQL_BRANCH_SCALE_FLOOR_INDEX`), add:

```rust
// ─── C.1 Quantile-based atom support ─────────────────────────────────
// Plan 2 Task 1. Spec §4.C.1.
// Per-branch 5th percentile of observed Q-values via adaptive-rate EMA.
// Producer: q_quantile_reduce (called at epoch boundary — cold path).
// Consumer: update_eval_v_range (computes half from |q_p95 − v_center| / |v_center − q_p5|).
pub const Q_P05_DIR_INDEX: usize = 38;
pub const Q_P05_MAG_INDEX: usize = 39;
pub const Q_P05_ORD_INDEX: usize = 40;
pub const Q_P05_URG_INDEX: usize = 41;
pub const Q_P95_DIR_INDEX: usize = 42;
pub const Q_P95_MAG_INDEX: usize = 43;
pub const Q_P95_ORD_INDEX: usize = 44;
pub const Q_P95_URG_INDEX: usize = 45;
```

Increment `ISV_TOTAL_DIM` from 38 → 46.

- [ ] **Step 1A.2: Update allocation + bootstrap in constructor**

Find the ISV construction block (around line 8069 post-Plan-1 shifts). After the `IQL_BRANCH_SCALE_FLOOR_INDEX` bootstrap, add:

```rust
// C.1 bootstrap: initialise Q quantile EMAs to match the v-range bootstrap.
// q_p5 starts at v_center − 0.5 × (v_max − v_min) = v_min
// q_p95 starts at v_center + 0.5 × (v_max − v_min) = v_max
// These decay toward observed quantiles via adaptive-rate EMA in update_eval_v_range.
let v_min_f = config.v_min as f32;
let v_max_f = config.v_max as f32;
for b in 0..4usize {
    *sig_ptr.add(Q_P05_DIR_INDEX + b) = v_min_f;
    *sig_ptr.add(Q_P95_DIR_INDEX + b) = v_max_f;
}
```

- [ ] **Step 1A.3: Update `docs/isv-slots.md`**

Append to the ISV slots table:
```markdown
| [38..42) | `Q_P05_{DIR,MAG,ORD,URG}_INDEX` | f32 | q_quantile_reduce | update_eval_v_range | FoldReset | Per-branch Q 5th percentile EMA |
| [42..46) | `Q_P95_{DIR,MAG,ORD,URG}_INDEX` | f32 | q_quantile_reduce | update_eval_v_range | FoldReset | Per-branch Q 95th percentile EMA |
```

Update the "Current `ISV_TOTAL_DIM`" header line from 38 → 46.

- [ ] **Step 1A.4: Update `docs/dqn-named-dims.md` ISV section**

Add the 8 new slot names with their indices and meanings.

- [ ] **Step 1A.5: Register in StateResetRegistry**

Modify `crates/ml/src/trainers/dqn/state_reset_registry.rs`. In the `new()` constructor's `entries` vec, add:

```rust
RegistryEntry {
    name: "isv_q_quantiles",
    category: ResetCategory::FoldReset,
    description: "ISV[38..46) — per-branch Q P5/P95 EMAs (C.1)",
},
```

In `training_loop.rs::reset_named_state`, add a dispatch arm:

```rust
"isv_q_quantiles" => {
    if let Some(ref mut fused) = self.fused_ctx {
        // Reset to v_min/v_max bootstrap per §4.C.1.
        let v_min_f = self.config.v_min as f32;
        let v_max_f = self.config.v_max as f32;
        for b in 0..4 {
            fused.write_isv_signal_at(Q_P05_DIR_INDEX + b, v_min_f);
            fused.write_isv_signal_at(Q_P95_DIR_INDEX + b, v_max_f);
        }
    }
}
```

- [ ] **Step 1A.6: Compile-check**

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 RUSTC_WRAPPER=~/.local/bin/sccache cargo check -p ml 2>&1 | tail -5
```

Expected: Finished with no new errors/warnings.

- [ ] **Step 1A.7: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/trainers/dqn/state_reset_registry.rs \
        crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        docs/isv-slots.md \
        docs/dqn-named-dims.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): C.1 allocate ISV slots for per-branch Q quantiles

8 new ISV slots: Q_P05_{DIR,MAG,ORD,URG}_INDEX (38..42),
Q_P95_{DIR,MAG,ORD,URG}_INDEX (42..46). ISV_TOTAL_DIM 38 → 46.

Bootstrap to v_min/v_max in constructor. FoldReset category in
StateResetRegistry. Consumer (quantile-based update_eval_v_range) and
producer (q_quantile_reduce kernel) follow in Task 1 subtasks 1B, 1C.

Plan 2 Task 1A. Spec §4.C.1.

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

### Subtask 1B: Implement the q_quantile_reduce CUDA kernel

- [ ] **Step 1B.1: Create `crates/ml/src/cuda_pipeline/q_quantile_kernel.cu`**

Content:
```c
/**
 * q_quantile_kernel — per-branch Q-value quantile reduction.
 *
 * Spec §4.C.1. Produces per-branch P5 and P95 values from observed Q over
 * a batch, EMA-updated into ISV slots [38..46). Replaces the `10 × q_gap`
 * hardcoded multiplier in update_eval_v_range with a principled
 * quantile-based support width.
 *
 * Algorithm:
 *   1. Per-branch: collect all per-sample Q-values for that branch's actions.
 *      For direction: 4 Q values per sample × B samples.
 *      For magnitude: 3 × B.
 *   2. Approximate P5 and P95 via bitonic-sort in shared memory (small B
 *      per block) followed by index pick at floor(0.05 * N) and ceil(0.95 * N).
 *   3. Apply adaptive-rate EMA: alpha = clamp(err / (err + baseline), 0.01, 0.3)
 *      matching the existing per-branch mean/std EMA pattern.
 *
 * Launch config: grid=(4, 1, 1) — one block per branch; block=(256, 1, 1).
 * Reads q_out_buf [B × total_actions] and per-branch offsets. Writes ISV.
 * Cold path (epoch boundary, not per step) — uses pinned device-mapped ISV
 * buffer. No host-side memcpy.
 */

extern "C" __global__ void q_quantile_reduce(
    const float* __restrict__ q_out,           // [B, total_actions]
    float*       __restrict__ isv_signals,     // device-mapped pinned ISV
    int B,
    int total_actions,
    int b0_size, int b1_size, int b2_size, int b3_size,
    int q_p05_dir_index,    // = Q_P05_DIR_INDEX (38)
    int q_p95_dir_index     // = Q_P95_DIR_INDEX (42)
) {
    const int branch = blockIdx.x;
    if (branch >= 4) return;

    // Branch size and offset into q_out rows.
    int branch_sizes[4] = { b0_size, b1_size, b2_size, b3_size };
    int branch_offsets[4];
    branch_offsets[0] = 0;
    branch_offsets[1] = b0_size;
    branch_offsets[2] = b0_size + b1_size;
    branch_offsets[3] = b0_size + b1_size + b2_size;

    int A = branch_sizes[branch];
    int off = branch_offsets[branch];

    // Collect all Q-values for this branch's actions across all samples.
    // Size upper bound: B × 4 (max branch size). Use shared memory.
    extern __shared__ float sh_vals[];  // dynamic shared: sized by host
    int tid = threadIdx.x;
    int total_vals = B * A;

    // Load phase: each thread loads total_vals/blockDim.x values.
    for (int i = tid; i < total_vals; i += blockDim.x) {
        int sample = i / A;
        int a = i % A;
        sh_vals[i] = q_out[sample * total_actions + off + a];
    }
    __syncthreads();

    // Bitonic sort in-place. Only thread 0 drives; for small total_vals
    // (typically 8192×4 = 32K at max B), this is fine at epoch cadence.
    // For simplicity and graph-safety, do a single-thread sort at tid=0.
    // Epoch-boundary cold path — perf is not critical.
    if (tid == 0) {
        // Naive O(N log N) sort via std::partial_sort-equivalent.
        // For the quantile pick we only need the P5 and P95 indices to be
        // correct; full sort is simpler and bug-proof at this scale.
        for (int i = 1; i < total_vals; i++) {
            float key = sh_vals[i];
            int j = i - 1;
            while (j >= 0 && sh_vals[j] > key) {
                sh_vals[j + 1] = sh_vals[j];
                j--;
            }
            sh_vals[j + 1] = key;
        }

        int p05_idx = (int)(0.05f * (float)total_vals);
        int p95_idx = (int)(0.95f * (float)total_vals);
        if (p05_idx < 0) p05_idx = 0;
        if (p95_idx >= total_vals) p95_idx = total_vals - 1;
        float p05 = sh_vals[p05_idx];
        float p95 = sh_vals[p95_idx];

        // Adaptive-rate EMA into ISV.
        int p05_slot = q_p05_dir_index + branch;
        int p95_slot = q_p95_dir_index + branch;
        float cur_p05 = isv_signals[p05_slot];
        float cur_p95 = isv_signals[p95_slot];

        float err05 = fabsf(p05 - cur_p05);
        float baseline05 = fmaxf(fabsf(cur_p05), 1e-6f);
        float alpha05 = err05 / (err05 + baseline05);
        alpha05 = fminf(0.3f, fmaxf(0.01f, alpha05));
        isv_signals[p05_slot] = (1.0f - alpha05) * cur_p05 + alpha05 * p05;

        float err95 = fabsf(p95 - cur_p95);
        float baseline95 = fmaxf(fabsf(cur_p95), 1e-6f);
        float alpha95 = err95 / (err95 + baseline95);
        alpha95 = fminf(0.3f, fmaxf(0.01f, alpha95));
        isv_signals[p95_slot] = (1.0f - alpha95) * cur_p95 + alpha95 * p95;
    }
    // No __syncthreads() needed at exit — thread 0 has published the ISV writes
    // via pinned device-mapped memory; other threads are done.
}
```

- [ ] **Step 1B.2: Register in build.rs**

Modify `crates/ml/build.rs`. Find the kernel list. Add:
```rust
        "q_quantile_kernel.cu",
```

- [ ] **Step 1B.3: Compile-check — kernel must build**

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo check -p ml 2>&1 | tail -5
ls target/debug/build/ml-*/out/q_quantile_kernel.cubin 2>&1 | head
```

Expected: cargo check passes; cubin file exists.

- [ ] **Step 1B.4: Load the kernel in the trainer constructor**

Modify `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs`. Near other cubin `static`s at the top:
```rust
static Q_QUANTILE_CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/q_quantile_kernel.cubin"));
```

In the `struct GpuDqnTrainer` definition, add field:
```rust
q_quantile_reduce_kernel: CudaFunction,
```

In the constructor, after other cubin loads:
```rust
let q_quantile_reduce_kernel = {
    let module = stream.context().load_cubin(Q_QUANTILE_CUBIN.to_vec())
        .map_err(|e| MLError::ModelError(format!("q_quantile cubin: {e}")))?;
    module.load_function("q_quantile_reduce")
        .map_err(|e| MLError::ModelError(format!("q_quantile_reduce load: {e}")))?
};
```

In the `Self { ... }` struct initializer:
```rust
q_quantile_reduce_kernel,
```

- [ ] **Step 1B.5: Add launch method**

```rust
/// Reduce per-branch Q quantiles into ISV EMA slots. Cold path (epoch boundary).
pub fn launch_q_quantile_reduce(&self) -> Result<(), MLError> {
    let b = self.config.batch_size as i32;
    let total_actions = self.config.total_actions() as i32;
    let b0 = self.config.branch_0_size as i32;
    let b1 = self.config.branch_1_size as i32;
    let b2 = self.config.branch_2_size as i32;
    let b3 = self.config.branch_3_size as i32;
    let q_out_ptr = self.q_out_buf.raw_ptr();
    let isv_ptr = self.isv_signals_dev_ptr;
    let p05_idx = Q_P05_DIR_INDEX as i32;
    let p95_idx = Q_P95_DIR_INDEX as i32;

    // Shared-mem bytes = max(B × A) × sizeof(float). Max A is b0_size (4).
    let shmem = (self.config.batch_size * self.config.branch_0_size * 4) as u32;

    unsafe {
        self.stream.launch_builder(&self.q_quantile_reduce_kernel)
            .arg(&q_out_ptr)
            .arg(&isv_ptr)
            .arg(&b)
            .arg(&total_actions)
            .arg(&b0).arg(&b1).arg(&b2).arg(&b3)
            .arg(&p05_idx)
            .arg(&p95_idx)
            .launch(LaunchConfig {
                grid_dim: (4, 1, 1),
                block_dim: (256, 1, 1),
                shared_mem_bytes: shmem,
            })
            .map_err(|e| MLError::ModelError(format!("q_quantile_reduce launch: {e}")))?;
    }
    Ok(())
}
```

- [ ] **Step 1B.6: Wire into epoch-boundary path**

Find `update_eval_v_range` call site in `training_loop.rs`. Immediately before it, add:
```rust
if let Some(ref mut fused) = self.fused_ctx {
    fused.launch_q_quantile_reduce()
        .map_err(|e| warn!("q_quantile_reduce (non-fatal): {e}"))?;
}
```

- [ ] **Step 1B.7: Compile-check**

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 RUSTC_WRAPPER=~/.local/bin/sccache cargo check -p ml 2>&1 | tail -5
```

Expected: Finished with no new warnings.

- [ ] **Step 1B.8: Commit**

```bash
git add crates/ml/build.rs \
        crates/ml/src/cuda_pipeline/q_quantile_kernel.cu \
        crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): C.1 q_quantile_reduce kernel + wiring

Per-branch Q P5/P95 quantile reduction with adaptive-rate EMA into
ISV[38..46). Cold path (epoch boundary), single-threaded insertion
sort inside shared memory (N ≤ ~32K at max batch — simple and correct
at this cadence).

Consumer (update_eval_v_range quantile-based half) in subtask 1C.

Plan 2 Task 1B. Spec §4.C.1.

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

### Subtask 1C: Refactor `update_eval_v_range` to use quantile-based half

- [ ] **Step 1C.1: Locate the current formula**

```bash
grep -n 'gap_width\|10.0 \* q_gap\|half = gap_width' crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs | head -10
```

Expected: find the formula block (around the `update_eval_v_range` method body).

- [ ] **Step 1C.2: Replace with quantile-based computation**

In `update_eval_v_range`, inside the per-branch loop, replace:
```rust
let gap_width = (10.0 * q_gap)
    .max(3.0 * self.eval_q_std_ema[branch_idx])
    .max(min_half_floor);
let half = gap_width.min(abs_half).max(min_half_floor);
```

With:
```rust
// C.1 quantile-based atom support (Plan 2 Task 1).
// Spec §4.C.1: replaces hardcoded `10 × q_gap` multiplier with
// direct coverage of observed Q distribution via P5/P95 EMAs.
let p05_slot = Q_P05_DIR_INDEX + branch_idx;
let p95_slot = Q_P95_DIR_INDEX + branch_idx;
let q_p05 = unsafe { *self.isv_signals_pinned.add(p05_slot) };
let q_p95 = unsafe { *self.isv_signals_pinned.add(p95_slot) };

// Distance from centre to the farther quantile.
let half_from_p05 = (center - q_p05).abs();
let half_from_p95 = (q_p95 - center).abs();
let gap_width = half_from_p05.max(half_from_p95).max(min_half_floor);
let half = gap_width.min(abs_half).max(min_half_floor);
```

- [ ] **Step 1C.3: Run multi_fold_convergence smoke test**

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true \
    cargo test -p ml --lib -- multi_fold_convergence --ignored --nocapture 2>&1 | tail -20
```

Expected: smoke passes. Atom util should be similar-or-better than baseline, since quantiles cover actual distribution.

- [ ] **Step 1C.4: Remove now-dead `q_gap` parameter plumbing if nothing else uses it**

Check if `per_branch_q_gaps` is still needed elsewhere:
```bash
grep -rn "per_branch_q_gaps\b" crates/ml/src --include='*.rs' | head -10
```

If only `update_eval_v_range` used it and that reference is now removed, remove the parameter + its plumbing in the same commit per `feedback_wire_everything_up.md`. If another consumer exists, leave the plumbing.

- [ ] **Step 1C.5: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
git commit -m "$(cat <<'EOF'
fix(dqn-v2): C.1 replace 10*q_gap with quantile-based half

Task #91 resolved via spec §4.C.1. half = max(|q_p95 − v_center|,
|v_center − q_p5|, min_half_floor), clamped to abs_half. Covers the
observed Q distribution directly — no hardcoded multiplier, no
distributional assumption.

Tests: multi_fold_convergence smoke passes with atom_util at comparable
level to pre-fix baseline. Runtime validation (atom_util trajectory
across 60 epochs) deferred to Plan 5 validation harness.

Plan 2 Task 1C. Spec §4.C.1.

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

---

## Task 2: D.1 Mamba2 backward — validate existing implementation

**REVISED SCOPE (Plan 1 A.5 audit finding):** Mamba2 backward is ALREADY fully wired on the DQN path. The `mamba2_scan_projected_bwd` CUDA kernel exists in `crates/ml/src/cuda_pipeline/mamba2_temporal_kernel.cu` and is called from `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs::mamba2_backward` at the expected call site. The original plan assumed "backward stubbed" based on stale task #76 notes; the audit falsified that assumption. Task 2 narrows from "implement backward" to "validate the existing backward via grad-check and integration test".

If grad-check fails or reveals a half-wired path, escalate and restore the original "implement" scope. Don't silently paper over a real gap.

**Files:**
- Create: `crates/ml/src/trainers/dqn/smoke_tests/mamba2_backward.rs` — grad-check + integration smoke

- [ ] **Step 2.1: Confirm the wiring with a direct trace**

```bash
grep -n "mamba2_scan_projected_bwd\|mamba2_backward\b" \
    crates/ml/src/cuda_pipeline/mamba2_temporal_kernel.cu \
    crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
```

Expected: both files reference the kernel + host call. Record line numbers. If not present, STOP — the A.5 audit's finding was wrong and Task 2 reverts to original "implement" scope.

- [ ] **Step 2.2: Write a finite-difference grad-check smoke**

Create `crates/ml/src/trainers/dqn/smoke_tests/mamba2_backward.rs`:

```rust
//! Smoke test: Mamba2 backward finite-difference grad check.
//!
//! Verifies that `mamba2_backward` in `gpu_dqn_trainer.rs` produces
//! gradients matching a finite-difference approximation to within numerical
//! tolerance. Exercises the entire wired path: forward → loss → backward →
//! weight-gradient readback.
//!
//! Plan 2 Task 2 (D.1 validation).

use super::helpers::*;
use anyhow::Result;

#[test]
#[ignore = "gpu"]
fn mamba2_backward_grad_check() -> Result<()> {
    let mut trainer = smoke_trainer_with(smoke_params())?;
    // Small fixture: one batch of known inputs, perturb one Mamba2 weight,
    // compare analytical grad (from mamba2_backward) to numerical grad
    // (from two forward passes ±epsilon).
    let analytical_grad = trainer.extract_mamba2_weight_gradient_for_test(/*weight_idx=*/ 0)?;
    let numerical_grad  = trainer.finite_difference_mamba2_weight_grad(/*weight_idx=*/ 0, /*eps=*/ 1e-3)?;
    let rel_err = (analytical_grad - numerical_grad).abs() / numerical_grad.abs().max(1e-9);
    assert!(rel_err < 0.05,
        "Mamba2 backward grad-check failed: analytical={analytical_grad:.6e} numerical={numerical_grad:.6e} rel_err={rel_err:.3e}");
    Ok(())
}

#[test]
#[ignore = "gpu"]
fn mamba2_backward_gradients_propagate() -> Result<()> {
    // Integration: after N training steps, Mamba2 weight gradients are non-zero
    // (confirms the backward pass IS writing to the weight buffer, not silently
    // no-oping like compute_iqr did before Task A.6 fix).
    let mut trainer = smoke_trainer_with(smoke_params())?;
    let grad_norm_before = trainer.mamba2_weight_grad_norm()?;
    trainer.run_n_training_steps(10)?;
    let grad_norm_after = trainer.mamba2_weight_grad_norm()?;
    assert!(grad_norm_after > grad_norm_before + 1e-9,
        "Mamba2 weight gradients did not change after 10 training steps — backward may be silently no-oping");
    Ok(())
}
```

The helper methods (`extract_mamba2_weight_gradient_for_test`, `finite_difference_mamba2_weight_grad`, `mamba2_weight_grad_norm`) are added to the trainer in a `#[cfg(test)]` impl block — they are test-only accessors that expose existing state. NO production code change.

- [ ] **Step 2.3: Run the smoke**

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true \
    cargo test -p ml --lib -- mamba2_backward --ignored --nocapture 2>&1 | tail -20
```

Expected outcomes:
- **Both tests PASS**: existing implementation is correct. Task 2 complete. Update `docs/dqn-wire-up-audit.md` to upgrade Mamba2 backward classification from "Wired (unvalidated)" to "Wired (grad-check validated)".
- **`mamba2_backward_grad_check` fails with small rel_err > 5%**: likely a numerical precision issue at the finite-difference boundary. Try tightening `eps` or allow up to 10% rel_err with explanatory comment.
- **`mamba2_backward_gradients_propagate` fails (grad_norm unchanged)**: the backward IS silently no-oping. Escalate — Task 2 scope reverts to "implement fully", which is substantial CUDA kernel work.

- [ ] **Step 2.4: Commit**

```bash
git add crates/ml/src/trainers/dqn/smoke_tests/mamba2_backward.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
test(dqn-v2): D.1 Mamba2 backward — grad-check validation (already wired)

Plan 1 A.5 audit found mamba2_scan_projected_bwd kernel + host call at
gpu_dqn_trainer.rs::mamba2_backward are ALREADY fully wired. Plan 2
Task 2 narrows from "implement" to "validate": two smoke tests confirm
analytical gradient matches finite-difference within 5% rel_err, and
10 training steps produce non-zero gradient norm change (rules out
silent no-op like compute_iqr had pre-A.6).

No production code change — test-only accessors exposed via #[cfg(test)]
impl block.

Plan 2 Task 2. Spec §4.D.1.

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

---

## Task 3: D.2 Per-branch gamma (GPU kernel + 4 monitors)

**Architecture:** REPLACES the scalar `GAMMA_EFF_INDEX = 43` landed in Plan 1 with 4 per-branch slots `GAMMA_DIR_EFF_INDEX`, `GAMMA_MAG_EFF_INDEX`, `GAMMA_ORD_EFF_INDEX`, `GAMMA_URG_EFF_INDEX`. A new `per_branch_gamma_update_kernel.cu` replaces `gamma_update_kernel.cu`. Each branch reads its own utilization signal (per-branch Q-stats spread) and health; writes its own γ. Four read-only `*GammaMonitor`s — one per branch.

Direction's γ can run ~0.99 (trend horizon). Urgency's γ can run ~0.80 (execution horizon). These trajectories diverge under GPU kernel control based on per-branch Q-stats.

**GPU-drives invariant (§4.C.6 2026-04-24 revision):** CPU does not compute γ. The per-branch γ kernel reads ISV, computes 4 values, writes 4 slots. CPU-side monitors only read.

**Files:**
- Create: `crates/ml/src/cuda_pipeline/per_branch_gamma_update_kernel.cu`
- Delete: `crates/ml/src/cuda_pipeline/gamma_update_kernel.cu` (superseded by the per-branch version)
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — replace `GAMMA_EFF_INDEX = 43` with 4 per-branch slots; update launcher
- Modify: `crates/ml/src/trainers/dqn/monitors/gamma_monitor.rs` — split into `dir_gamma_monitor.rs`, `mag_gamma_monitor.rs`, `ord_gamma_monitor.rs`, `urg_gamma_monitor.rs` (or keep a single `PerBranchGammaMonitor` that exposes all 4 slots via diagnose — decide during impl)
- Modify: `crates/ml/src/cuda_pipeline/c51_loss_kernel.cu` — read per-branch γ from ISV (per-sample inside kernel)
- Modify: `crates/ml/src/cuda_pipeline/iql_value_kernel.cu` — same

- [ ] **Step 3.1: Allocate 4 new ISV slots, retire the scalar GAMMA_EFF_INDEX**

In `gpu_dqn_trainer.rs`:

```rust
// Remove: pub const GAMMA_EFF_INDEX: usize = 43;

// Replace with per-branch slots at the same starting position; everything
// after shifts up by 3.
pub const GAMMA_DIR_EFF_INDEX: usize = 43;
pub const GAMMA_MAG_EFF_INDEX: usize = 44;
pub const GAMMA_ORD_EFF_INDEX: usize = 45;
pub const GAMMA_URG_EFF_INDEX: usize = 46;

// Shift down-stream slots (was 44→47, 45→48, 46→49; fingerprint was 47..49):
pub const KELLY_CAP_EFF_INDEX:   usize = 47;  // was 44
pub const CQL_ALPHA_INDEX:       usize = 48;  // was 45
pub const PLAN_THRESHOLD_INDEX:  usize = 49;  // was 46

// Fingerprint re-tails:
pub const ISV_LAYOUT_FINGERPRINT_LO_INDEX: usize = 50;  // was 47
pub const ISV_LAYOUT_FINGERPRINT_HI_INDEX: usize = 51;  // was 48

pub const ISV_TOTAL_DIM: usize = 52;  // was 49
```

Update `layout_fingerprint_seed()` seed bytes to reflect the new layout (remove `GAMMA_EFF=43`, add `GAMMA_DIR_EFF=43;GAMMA_MAG_EFF=44;GAMMA_ORD_EFF=45;GAMMA_URG_EFF=46`, update downstream indices + `ISV_TOTAL_DIM=52`). The `LAYOUT_FINGERPRINT_CURRENT` u64 recomputes at compile time; any existing checkpoint fails-fast at load with the retrain-required error.

Constructor: bootstrap all 4 per-branch γ slots to the same initial value (0.905 matching the pre-Plan-1 `adaptive_gamma`) so training starts with uniform γ and the kernel diverges per-branch from there.

- [ ] **Step 3.2: Write the per-branch gamma kernel**

Create `crates/ml/src/cuda_pipeline/per_branch_gamma_update_kernel.cu`:

```cuda
/* per_branch_gamma_update — GPU-driven per-branch discount factor update.
 *
 * Reads per-branch Q-stats from ISV (existing V_CENTER/V_HALF per-branch
 * signals + LEARNING_HEALTH) and writes 4 per-branch γ values. Single
 * block, 4 threads (one per branch). Cold-path per-epoch kernel.
 *
 * Formula (per branch b):
 *   q_spread_b = ISV[V_HALF_{DIR,MAG,ORD,URG}_INDEX] / max(ISV[V_CENTER_b], 1e-3)
 *   health     = ISV[LEARNING_HEALTH_INDEX]
 *   gamma_b    = gamma_base[b] + (gamma_per_branch_max[b] - gamma_base[b]) * health
 *                * (1 - q_spread_b_clamped)
 *   clamp gamma_b to [gamma_min[b], gamma_max[b]]
 *   ISV[GAMMA_{DIR,MAG,ORD,URG}_EFF_INDEX] = gamma_b
 *
 * Per-branch base/max values (passed as kernel params):
 *   direction: [0.92, 0.99]  (trend horizon, slower decay)
 *   magnitude: [0.88, 0.95]
 *   order:     [0.85, 0.93]
 *   urgency:   [0.80, 0.90]  (execution horizon, faster decay)
 */

extern "C" __global__ void per_branch_gamma_update(
    const float* __restrict__ isv,
    float*       __restrict__ isv_out,
    const float* __restrict__ gamma_base,   // [4]
    const float* __restrict__ gamma_max     // [4]
) {
    int b = threadIdx.x;
    if (b >= 4) return;

    // Per-branch ISV indices (stride 2: V_CENTER then V_HALF per branch).
    int v_center_idx = 23 + b * 2;
    int v_half_idx   = 24 + b * 2;
    int gamma_out_idx = 43 + b;   // GAMMA_{DIR,MAG,ORD,URG}_EFF_INDEX

    float v_center = isv[v_center_idx];
    float v_half   = isv[v_half_idx];
    float health   = fminf(fmaxf(isv[12], 0.0f), 1.0f);

    float denom     = fmaxf(fabsf(v_center), 1e-3f);
    float q_spread  = fminf(fmaxf(v_half / denom, 0.0f), 1.0f);

    float gamma_b = gamma_base[b]
                  + (gamma_max[b] - gamma_base[b]) * health * (1.0f - q_spread);
    gamma_b = fminf(fmaxf(gamma_b, gamma_base[b]), gamma_max[b]);

    isv_out[gamma_out_idx] = gamma_b;
}
```

4 threads, 1 block — per-branch parallel. Single-warp kernel.

- [ ] **Step 3.3: Launch per_branch_gamma_update from process_epoch_boundary**

Replace the existing `gamma_update` launch (which wrote slot 43) with `per_branch_gamma_update` (writes slots 43-46). Pass `gamma_base = [0.92, 0.88, 0.85, 0.80]` and `gamma_max = [0.99, 0.95, 0.93, 0.90]` as constant device arrays (allocated once at construction).

Delete `gamma_update_kernel.cu` entirely per `feedback_no_partial_refactor.md`.

- [ ] **Step 3.4: Update c51_loss_kernel.cu to read per-branch γ**

Current code reads `γ` from a single kernel parameter (or existing `GAMMA_EFF_INDEX`). Change to read `isv[GAMMA_{DIR,MAG,ORD,URG}_EFF_INDEX]` based on which branch is being processed in that sample. The branch-index dispatch in the kernel already exists.

Same change in `iql_value_kernel.cu`.

- [ ] **Step 3.5: Write per-branch gamma monitor**

Replace `crates/ml/src/trainers/dqn/monitors/gamma_monitor.rs` with `PerBranchGammaMonitor` that reads all 4 per-branch slots. `read()` returns the mean; `diagnose()` exposes all 4 + mean + per-branch spread. `observe()` fires when mean changes materially OR when any branch's γ diverges > 0.005 from its previous value.

Alternative: 4 separate monitor files `{dir,mag,ord,urg}_gamma_monitor.rs`, each observing 1 slot. More verbose, easier to reason about. Pick during implementation.

- [ ] **Step 3.6: Register StateResetRegistry entries**

Replace the single `isv_gamma_eff` entry with 4 per-branch entries (all `FoldReset`, each describing its branch).

- [ ] **Step 3.7: Update reset_named_state dispatch**

Add match arms for the 4 new names (or consolidate into a pattern that covers all 4).

- [ ] **Step 3.8: Tests + commit**

Mini-kernel unit test: seed ISV with known v-range and health signals, launch `per_branch_gamma_update`, read back all 4 slots, assert values match the formula.

Commit:

```bash
git add crates/ml/src/cuda_pipeline/per_branch_gamma_update_kernel.cu \
        crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/cuda_pipeline/c51_loss_kernel.cu \
        crates/ml/src/cuda_pipeline/iql_value_kernel.cu \
        crates/ml/src/trainers/dqn/monitors/ \
        crates/ml/src/trainers/dqn/state_reset_registry.rs \
        crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        docs/isv-slots.md docs/dqn-wire-up-audit.md
# plus the deletion:
git rm crates/ml/src/cuda_pipeline/gamma_update_kernel.cu
git commit -m "$(cat <<'EOF'
feat(dqn-v2): D.2 per-branch gamma GPU kernel + monitor (§4.C.6 compliant)

Replaces scalar GAMMA_EFF_INDEX=43 with 4 per-branch slots 43-46
(DIR, MAG, ORD, URG). New per_branch_gamma_update_kernel.cu computes
per-branch γ from ISV v-range spread + health; gamma_update_kernel.cu
deleted. CPU-side PerBranchGammaMonitor is read-only observer.

Per-branch γ base/max: direction [0.92, 0.99] (trend), urgency
[0.80, 0.90] (execution). GPU kernel diverges each branch's γ from
uniform bootstrap based on per-branch Q-stats.

ISV slots downstream of slot 43 shift: KELLY_CAP (44→47), CQL_ALPHA
(45→48), PLAN_THRESHOLD (46→49), fingerprint (47-48 → 50-51).
ISV_TOTAL_DIM grows 49 → 52. Layout fingerprint auto-updates.

No CPU-side γ computation anywhere (spec §4.C.6 revision).

c51_loss_kernel and iql_value_kernel read per-branch γ from ISV
per-sample.

Plan 2 Task 3. Spec §4.D.2 + §4.C.6.

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

---

## Task 4: D.5 Soft fold-boundary transitions

**Files:**
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs` — implement soft-reset helper
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` — wire SoftReset dispatch

- [ ] **Step 4.1: Extend the registry helpers**

Add to `state_reset_registry.rs`:
```rust
impl StateResetRegistry {
    /// Iterator over entries with SoftReset category, yielding (entry, decay_bars).
    pub fn soft_reset_entries_with_decay(&self) -> impl Iterator<Item = (&RegistryEntry, u32)> {
        self.entries.iter().filter_map(|e| {
            match e.category {
                ResetCategory::SoftReset { decay_bars } => Some((e, decay_bars)),
                _ => None,
            }
        })
    }
}
```

- [ ] **Step 4.2: Add anneal-state tracking to the training loop**

In `training_loop.rs`, add a field:
```rust
/// For each SoftReset entry, remaining-anneal-bars counter.
/// Decremented per bar after fold start; zero means fully annealed.
soft_reset_counters: HashMap<&'static str, u32>,
```

At fold boundary, populate:
```rust
let registry = StateResetRegistry::new();
for (entry, decay_bars) in registry.soft_reset_entries_with_decay() {
    self.soft_reset_counters.insert(entry.name, decay_bars);
}
```

Per step, if `soft_reset_counters[name] > 0`:
```rust
let progress = 1.0 - (counter / decay_bars) as f32;  // 0.0 at fold start, 1.0 at fully annealed
// Apply annealed blend: new_value = progress * current_value + (1 - progress) * bootstrap_value
// Decrement counter.
```

The exact anneal logic per SoftReset entry is:

For `adaptive_gamma`:
```rust
let bootstrap_gamma = 0.905_f32;
let current = self.adaptive_gamma;
let annealed = progress * current + (1.0 - progress) * bootstrap_gamma;
self.adaptive_gamma = annealed;
```

Similar for `isv_grad_balance_targets` (blend current ISV[31..35) toward 1.0 bootstrap) and `isv_grad_scale_limit` (blend toward 2.0 bootstrap).

- [ ] **Step 4.3: Smoke test — fold boundary with SoftReset does not discontinuously jump**

Add to a new smoke test file:
```rust
#[test]
#[ignore]
fn soft_reset_adaptive_gamma_anneals_not_jumps() {
    // Run training for 2 folds. Record adaptive_gamma at fold 1 end and
    // fold 2 start and fold 2 end. Assert fold 2 start is < 5% below
    // fold 1 end (slow anneal, not hard reset).
    // ... specific implementation ...
}
```

- [ ] **Step 4.4: Commit**

```bash
git add crates/ml/src/trainers/dqn/state_reset_registry.rs \
        crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        crates/ml/src/trainers/dqn/smoke_tests/soft_reset.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): D.5 soft fold-boundary transitions

SoftReset(decay_bars) registry category anneals toward bootstrap
values over decay_bars. Applies to adaptive_gamma (bootstrap 0.905),
ISV grad-balance targets (bootstrap 1.0), and ISV grad-scale limit
(bootstrap 2.0).

Smoother fold transitions preserve learned temporal signals without
breaking fold-independence of weight-related state.

Plan 2 Task 4. Spec §4.D.5.

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

---

## Task 5: D.7 Liquid Time-constant audit

**Files:**
- Modify: `docs/ml-supervised-to-dqn-concept-audit.md` — record audit outcome
- Conditionally: wire `liquid_mod` via GPU kernel + `AdaptiveMonitor` observer, OR delete

- [ ] **Step 5.1: Trace liquid_mod through the training path**

```bash
grep -rn 'liquid_mod\|launch_liquid_tau' crates/ml/src --include='*.rs' --include='*.cu' --include='*.cuh' | head -30
```

Document every consumer: where is `liquid_mod_buf` read? What does it modulate?

- [ ] **Step 5.2: Measure fire rate of liquid_mod**

Add a debug printf in the liquid kernel (or read the liquid_mod_buf via the pinned pointer) and verify whether liquid_mod values deviate meaningfully from 1.0 during training.

Run a short 2-epoch training with that diagnostic:
```bash
./scripts/argo-train.sh dqn --gpu-pool ci-training-l40s --baseline --epochs 2 2>&1 | grep "^Name:"
# Monitor its output for liquid_mod deviations
```

- [ ] **Step 5.3: Decision point**

Two outcomes:

**(a) liquid_mod is effective** (deviates from 1.0 meaningfully during training, modulates c51 gradients proportionally): wire it properly per spec §4.C.6 — GPU kernel computes liquid_mod from ISV signals and writes to a dedicated ISV slot; CPU-side `LiquidModMonitor` implements read-only `AdaptiveMonitor` for diagnostics only. Update audit doc classification to `Wired`.

**(b) liquid_mod is a no-op** (values stay near 1.0, modulation has no detectable effect on Q-update): delete the controller and its ISV slots per `feedback_wire_everything_up.md`. Remove the `liquid_tau_rk4_kernel`, its pinned buffer, and all call sites. Update audit doc classification from `Orphan` → deleted-in-commit-<SHA>.

- [ ] **Step 5.4: Execute the decision** (no deferral per Invariant 9)

Whichever path from Step 5.3 is chosen, execute in this task. No "evaluate later".

- [ ] **Step 5.5: Commit**

```bash
git add <files per chosen path>
git commit -m "$(cat <<'EOF'
feat(dqn-v2): D.7 liquid_mod audit — <wired | deleted>

Audit per spec §4.D.7 + feedback_wire_everything_up.md. Outcome:
<chosen path>. <One-paragraph summary of measurement + rationale>.

Plan 2 Task 5.

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

---

## Task 6: D.3 + D.6 + D.8 — Coordinated state-layout migration

This is the most substantive task in Plan 2. One atomic commit changes the ISV slot layout (horizon-decomposed V, plan_isv[6], TLOB features) — the layout fingerprint (Plan 1 A.2) updates AUTOMATICALLY because the seed bytes change with the constant list. There is no manual bump. Fail-fast at checkpoint load means any old checkpoint is invalidated; retrain is required. Lands:
- D.3 horizon-decomposed V (adds `V_SHORT` and `V_LONG` outputs to IQL)
- D.6 plan_isv[6] (remaining_fraction, new state dimension)
- D.8 TLOB integration (adds D_tlob dimensions to state vector)

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/state_layout.cuh` — bump dims, add new offsets
- Modify: `crates/ml-core/src/state_layout.rs` — Rust mirror
- Modify: all kernels that use state vector offsets — update for new layout
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — add D_tlob feature path, IQL V_short/V_long heads
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` — extend plan_isv to 7 dims
- Modify: `crates/ml/src/cuda_pipeline/backtest_plan_kernel.cu` — same
- Modify: `crates/ml/src/tlob/` — expose forward kernel for DQN trunk integration
- Test: `crates/ml/src/trainers/dqn/smoke_tests/state_layout_v2.rs` — verify new layout dims + consumer reads

### Subtask 6A: D.6 Plan_isv[6] remaining_fraction (smallest slice, do first)

- [ ] **Step 6A.1: Extend SL_PORTFOLIO_PLAN_DIM from 6 to 7**

In `state_layout.cuh`:
```c
#define SL_PORTFOLIO_PLAN_DIM   7   // was 6; added PLAN_ISV_REMAINING_FRACTION (D.6)
#define PLAN_ISV_REMAINING_FRACTION 6
```

Re-derive `SL_STATE_DIM`:
```c
#define SL_STATE_DIM          105   // was 104; +1 for new plan_isv dim
```

Verify `static_assert(SL_STATE_DIM % 8 == 0, ...)` still holds. 105 is NOT % 8 == 0 → need padding:
```c
#define SL_PADDING_DIM         3    // was 0; pad to next multiple of 8 (105 → 112)
```
Wait: 105 + 3 = 108 ≠ 112. Correct: pad to 112 → PADDING_DIM = 7. OR bump `SL_STATE_DIM` to 112 and adjust. Choose simplest: keep dims = 105 + 7 padding = 112, which is 8-aligned.

Actually the cleanest: use `(104 + 1 + 7) = 112`. Define:
```c
#define SL_PORTFOLIO_PLAN_DIM   7
#define SL_PADDING_DIM          7    // 104 + 1 (new plan_isv) + 7 = 112 (8-aligned)
#define SL_STATE_DIM          112
```

- [ ] **Step 6A.2: Implement the remaining-fraction computation in experience_kernels.cu**

In `experience_env_step`, where plan_isv values are built, add:
```c
// D.6 remaining-fraction. Plan 2 Task 6A.
plan_isv[PLAN_ISV_REMAINING_FRACTION] = (plan_tgt_bars > 0.5f)
    ? fmaxf(0.0f, fminf(1.0f, (plan_tgt_bars - hold_time) / plan_tgt_bars))
    : 0.0f;
```

Same in `backtest_plan_kernel.cu::backtest_plan_state_isv`:
```c
pisv[6] = (plan_tgt_bars > 0.5f)
    ? fmaxf(0.0f, fminf(1.0f, (plan_tgt_bars - hold_time) / plan_tgt_bars))
    : 0.0f;
```

- [ ] **Step 6A.3: State_layout.cuh static_assert maintains**

Verify:
```c
static_assert(SL_PADDING_START + SL_PADDING_DIM == SL_STATE_DIM, ...);
static_assert(SL_STATE_DIM % 8 == 0, ...);
```

Both must pass. If not, adjust padding.

### Subtask 6B: D.3 Horizon-decomposed V function

- [ ] **Step 6B.1: Expand IQL value buffer to 2 components**

Currently IQL's `v_out_buf: CudaSlice<f32>` is `[B]`. Now `[B, 2]` for V_short and V_long.

In `gpu_iql_trainer.rs`, change allocation:
```rust
let v_out_buf = stream.alloc_zeros::<f32>(b * 2)?;  // was b
```

- [ ] **Step 6B.2: Expand IQL value head weights**

The IQL head currently emits 1 scalar per sample. Now 2 scalars. This requires:
- Doubling the output dimension of the IQL value-head FC layer.
- Adjusting IQL_V_OUTPUT_DIM constant (if present) and the param-size tables.

This is a parameter-count change. Checkpoint compat: per A.2, the layout fingerprint changes automatically (new slot constants → new seed bytes → new hash) and fail-fast on any mismatch. Old checkpoints are invalidated; retrain required.

- [ ] **Step 6B.3: Update IQL forward to produce V_short + V_long**

In `iql_value_kernel.cu::iql_forward`:
```c
// Previously: v_out[b] = FC(h_s2[b])
// Now: v_out[b, 0] = V_short head, v_out[b, 1] = V_long head
// Each head has its own W and b (separate 2× FC) OR one 2-output FC layer.
// Choose one-layer-two-outputs for simplicity.
```

- [ ] **Step 6B.4: Sum v_out[0] + v_out[1] wherever single V is consumed**

Any existing consumer that reads `v_out_buf[b]` as a scalar V now reads `v_out_buf[b*2 + 0] + v_out_buf[b*2 + 1]`.

Update `iql_per_branch_advantage`, `iql_compute_per_sample_support`, and the IQL expectile-gap kernels.

### Subtask 6C: D.8 TLOB temporal integration

- [ ] **Step 6C.1: Define D_tlob constant**

In `state_layout.cuh`:
```c
#define SL_TLOB_DIM            16   // D_tlob — chosen to match TLOB output head width
#define SL_TLOB_START          <computed>
```

Re-derive `SL_STATE_DIM`, `SL_PADDING_DIM` to maintain 8-alignment. Example new layout:
- Market: 42
- TLOB: 16
- OFI: 32
- MTF: 16
- Portfolio base: 8
- Plan ISV: 7
- Padding: <whatever>
- Total: 8-aligned

- [ ] **Step 6C.2: Load TLOB pretrained weights**

Add a constructor step to `GpuDqnTrainer::new` that loads TLOB weights from the supervised-pretrained checkpoint path specified in config. If config doesn't specify a path, abort with a clear error (per Invariant 9, no silent fallback).

- [ ] **Step 6C.3: Forward pass: run TLOB on MBP-10 at state-assembly time**

In the state-assembly kernel path, add a call to `tlob_forward` producing `tlob_features[B, D_tlob]`. These then get concatenated into the state vector at position `SL_TLOB_START`.

- [ ] **Step 6C.4: Backward**

If D.1 infrastructure generalizes to TLOB attention — measure per-step cost. If ≤ 5ms incremental, use end-to-end trainable TLOB. Otherwise freeze TLOB and schedule periodic supervised side-stream retraining.

Per §8 decision criterion: measure during this task, commit result in commit message.

### Subtask 6D: Atomic commit landing all three

- [ ] **Step 6D.1: Update layout fingerprint seed bytes**

In `gpu_dqn_trainer.rs`, extend `layout_fingerprint_seed()` with entries for every slot added in this task (Plan 2 C.1 `Q_P05/Q_P95_*` slots, D.2 `GAMMA_*` slots, etc.). The `const fn` recomputes automatically. DO NOT add an integer version number — per spec §4.A.2, layout fingerprints have no ordered version space.

Example (adjust indices to the post-Plan-2 shifted layout):
```rust
const fn layout_fingerprint_seed() -> &'static [u8] {
    b"...existing Plan 1 slots...;\
      Q_P05_DIR=N;Q_P05_MAG=N+1;Q_P05_ORD=N+2;Q_P05_URG=N+3;\
      Q_P95_DIR=N+4;Q_P95_MAG=N+5;Q_P95_ORD=N+6;Q_P95_URG=N+7;\
      GAMMA_DIR=N+8;GAMMA_MAG=N+9;GAMMA_ORD=N+10;GAMMA_URG=N+11;\
      ...new Plan 2 slots continue...;\
      ISV_TOTAL_DIM=<new>"
}
```

The `LAYOUT_FINGERPRINT_CURRENT` value changes automatically. Any old checkpoint fails fast at load with the structural-mismatch error from Plan 1 Task 5.

- [ ] **Step 6D.2: Run full smoke test sweep**

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true \
    cargo test -p ml --lib -- smoke_tests --ignored --nocapture 2>&1 | tail -60
```

Expected: all pass. Any test that hard-codes `SL_STATE_DIM = 104` breaks — fix inline.

- [ ] **Step 6D.3: Verify state-layout static_asserts hold**

Compile — any static_assert failure halts the build.

- [ ] **Step 6D.4: Commit — ONE atomic commit**

```bash
git add \
  crates/ml/src/cuda_pipeline/state_layout.cuh \
  crates/ml-core/src/state_layout.rs \
  crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
  crates/ml/src/cuda_pipeline/gpu_iql_trainer.rs \
  crates/ml/src/cuda_pipeline/iql_value_kernel.cu \
  crates/ml/src/cuda_pipeline/experience_kernels.cu \
  crates/ml/src/cuda_pipeline/backtest_plan_kernel.cu \
  crates/ml/src/cuda_pipeline/gpu_backtest_evaluator.rs \
  crates/ml/src/tlob/ \
  crates/ml/src/trainers/dqn/ \
  docs/
git commit -m "$(cat <<'EOF'
feat(dqn-v2): D.3 + D.6 + D.8 — coordinated state-layout migration

Schema version 1 → 2. Three coupled changes landing in one atomic
commit (per spec §5 "state-layout changes as ONE coordinated commit,
not three").

D.3 horizon-decomposed V:
  IQL value head output expanded from [B] → [B, 2] (V_short + V_long).
  Consumers sum the two heads where single V was previously expected.

D.6 plan_isv[6] remaining_fraction:
  SL_PORTFOLIO_PLAN_DIM 6 → 7. PLAN_ISV_REMAINING_FRACTION = 6 named
  constant. Computed in both experience_env_step (training) and
  backtest_plan_state_isv (val).

D.8 TLOB integration:
  TLOB transformer output concatenated into state vector at
  SL_TLOB_START (D_tlob = 16 dims). Pretrained weights loaded from
  supervised checkpoint. Training mode (frozen vs end-to-end): chosen
  based on measured per-step cost (<see commit data>).

Layout: SL_STATE_DIM <old 104 → new N>, 8-alignment preserved via
SL_PADDING_DIM adjustment. Schema version bump causes fail-fast on
checkpoint mismatch per A.2.

Tests: all smoke pass. State-layout static_asserts verified.

Plan 2 Task 6. Spec §4.D.3, D.6, D.8.

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

---

## Task 7: Plan 2 validation run

- [ ] **Step 7.1: Full smoke test sweep**

```bash
FOXHUNT_TEST_DATA=test_data/futures-baseline SQLX_OFFLINE=true \
    cargo test -p ml --lib -- smoke_tests --ignored --nocapture 2>&1 | tail -60
```

Expected: all pass.

- [ ] **Step 7.2: Launch a 5-epoch L40S run with all Plan 2 features**

```bash
./scripts/argo-train.sh dqn --gpu-pool ci-training-l40s --baseline --epochs 5 2>&1 | grep "^Name:"
```

Monitor for:
- No ERROR-level logs.
- `per_branch_gamma` diagnostic in HEALTH_DIAG showing gamma_dir, gamma_mag, gamma_ord, gamma_urg diverging from 0.905.
- Atom util using quantile-based support.
- Mamba2 weight norms moving epoch-over-epoch (backward now propagates).
- Plan_isv in val shows remaining_fraction dimension populated when plans active.
- TLOB features appearing in state vector (verify via a debug print of state[SL_TLOB_START]).

- [ ] **Step 7.3: Push all Plan 2 commits**

```bash
git push origin main 2>&1 | tail -3
```

- [ ] **Step 7.4: Commit "Plan 2 complete" note**

```bash
echo "Plan 2 complete: <list of commit SHAs>" >> docs/superpowers/plans/2026-04-24-dqn-v2-plan-2-temporal-core.md
git add docs/superpowers/plans/2026-04-24-dqn-v2-plan-2-temporal-core.md
git commit -m "plan(dqn-v2): Plan 2 temporal core complete"
git push origin main
```

---

## Plan 2 exit criteria

1. All 9 invariants hold across every commit.
2. Q_P05/Q_P95 ISV slots populated and consumed by update_eval_v_range.
3. Mamba2 backward propagates real gradients (grad-check smoke passes).
4. PerBranchGammaController emits per-branch gamma values diverging from bootstrap 0.905.
5. SoftReset handling produces smooth fold-boundary transitions (smoke test passes).
6. Liquid_mod audit completed with either wired or deleted outcome.
7. State layout fingerprint updated (via seed-byte edit) when D.3 + D.6 + D.8 land. `LAYOUT_FINGERPRINT_CURRENT` differs from pre-Plan-2; all old checkpoints invalidated; no migration path added.
8. All smoke tests pass.
9. A 5-epoch L40S run shows expected HEALTH_DIAG emissions and no regressions vs Plan 1 baseline.

Plan 3 (Behavioural) begins only after Plan 2 passes exit criteria.

---

**End of Plan 2.**
