# DQN v2 Plan 1 — Substrate & Refactor 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:** Lay the substrate that all subsequent DQN v2 work depends on: state reset registry, ISV slot contract, audit docs, named-dimension constants, and the AdaptiveMonitor unification refactor (GPU kernels compute; CPU observes, per spec §4.C.6 revision). Zero behavioural change to the trained policy — this plan only establishes scaffolding and refactors existing ad-hoc machinery into typed protocols.

**Architecture:** Three audit docs + one config file tracked by pre-commit hook. One `StateResetRegistry` struct categorising every piece of training state. One typed `IsvSlot<T>` contract with a compile-time **layout fingerprint** (structural hash of the slot list) at ISV[0..2) — fail-fast only, no migration path ever exists. One read-only `AdaptiveMonitor` trait with 6 GPU-kernel-plus-CPU-monitor pairs (atoms, gamma, kelly_cap, tau, epsilon, grad_balancer) and 3 static-config ISV writes (cql_alpha, conviction_floor, plan_threshold). **GPU drives, CPU reads** — CPU-side code never computes an adaptive value; all computation is in GPU kernels reading from and writing to ISV. Named constants for every semantic index across portfolio state, ISV slots, plan_isv dimensions, plan_params, state-vector offsets, branch indices, and action sub-indices.

**Tech Stack:** Rust (workspace crates: `ml`, `ml-core`, `ml-dqn`, `ml-supervised`), CUDA C++ via nvcc through `build.rs`, cudarc 0.19, Cargo 2021 edition, SQLx offline mode, sccache with `CARGO_INCREMENTAL=0`.

**Authority for this plan:** `docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md` — every task in this plan maps to a spec section. Every commit preserves all 9 invariants.

---

## Pre-plan: verify current tree state

Before starting any task, verify the baseline compiles and no in-flight validation run is active.

- [ ] **Step 0.1: Verify tree builds**

Run:
```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 RUSTC_WRAPPER=~/.local/bin/sccache cargo check -p ml
```
Expected: `Finished 'dev' profile [unoptimized + debuginfo] target(s) in Xs` with no errors.

- [ ] **Step 0.2: Verify no running training blocks the workflow**

Run:
```bash
kubectl get wf -n foxhunt --field-selector=status.phase=Running 2>&1 | grep -v ci-pipeline | head -5
```
Expected: empty or only `NAME STATUS AGE MESSAGE` header. If any `train-*` workflow is Running, stop it with `argo stop -n foxhunt <name>` before proceeding.

- [ ] **Step 0.3: Confirm spec version being implemented**

Run:
```bash
git log --oneline docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md | head -3
```
Expected: top commit `336ee40b9` or later. If the spec has newer commits, re-read before proceeding.

---

## Task 1: Create audit docs + pre-commit hook scaffolding

**Files:**
- Create: `docs/dqn-wire-up-audit.md`
- Create: `docs/isv-slots.md`
- Create: `docs/dqn-gpu-hot-path-audit.md`
- Create: `docs/dqn-named-dims.md`
- Create: `docs/ml-supervised-to-dqn-concept-audit.md`
- Create: `config/metric-bands.toml`
- Modify: `.claude/helpers/precommit-check.sh` (or wherever the pre-commit hook lives)

- [ ] **Step 1.1: Locate the existing pre-commit hook**

Run:
```bash
find .claude .git/hooks scripts -name "*precommit*" -o -name "*pre-commit*" 2>/dev/null | head
cat .git/hooks/pre-commit 2>/dev/null | head -30
```

Expected: locate the hook file that runs `.unwrap()` and stub checks during commits. This is the file we will extend.

- [ ] **Step 1.2: Write `docs/dqn-wire-up-audit.md` initial skeleton**

Content:
```markdown
# DQN v2 Wire-Up Audit

**Status:** Populated during task #95 (A.5 orphan audit). Updated on every commit per Invariant 7.

**Legend:**
- `Wired` — consumed by production training + val path.
- `Partial` — consumed on one side only (training OR val, or forward OR backward).
- `Orphan` — built but no production consumer.
- `Ghost` — consumer path is stubbed.

## Components

| Module / kernel | Consumer path | Classification | Notes |
|---|---|---|---|
| (populated during A.5 audit) | | | |
```

- [ ] **Step 1.3: Write `docs/isv-slots.md` initial skeleton**

Content:
```markdown
# ISV Slot Allocation Registry

**Source of truth for ISV bus slot allocations.** Every slot has a named constant in `gpu_dqn_trainer.rs`. This doc is the cross-reference.

**Current `ISV_TOTAL_DIM` as of this plan's start:** 37. Post-full DQN v2 rollout: 72.

| Index | Name constant | Type | Producer | Consumers | Reset-category | Notes |
|---|---|---|---|---|---|---|
| [0] | `ISV_LAYOUT_FINGERPRINT_INDEX` | u64 (as 2× f32) | construct | load | SchemaContract | Structural hash of (slot_name, slot_index) pairs; updates automatically when any slot changes; fail-fast mismatch only, no migration |
| [1..12) | (pre-existing) | f32 | varies | varies | varies | Legacy — audit during A.2 |
| [12] | `LEARNING_HEALTH_INDEX` | f32 | isv_signal_update | many | FoldReset | health score ∈ [0, 1] |
| [13..17) | `Q_MAG_MEAN_*_INDEX` | f32 | q_mag_means_reduce | c51 kernels | FoldReset | Quarter/Half/Full mag Q-mean EMAs + |Q| ref |
| [17..22) | `Q_DIR_MEAN_*_INDEX` | f32 | q_dir_means_reduce | c51 kernels | FoldReset | Short/Hold/Long/Flat dir Q-mean EMAs + |Q| ref |
| [22] | `SHARPE_EMA_INDEX` | f32 | training_loop host | isv_signal_update | FoldReset | Training Sharpe EMA |
| [23..31) | `V_{CENTER,HALF}_{DIR,MAG,ORD,URG}_INDEX` | f32 | update_eval_v_range | adaptive_atoms, warm_start | FoldReset | Per-branch Q-support |
| [31..35) | `GRAD_NORM_TARGET_*_INDEX` | f32 | grad_balance_isv_update | branch_grad_rescale | SoftReset(decay_bars=500) | Per-branch grad-norm target |
| [35] | `GRAD_SCALE_LIMIT_INDEX` | f32 | grad_balance_isv_update | branch_grad_rescale | SoftReset(decay_bars=500) | Scale clamp limit |
| [36] | `IQL_BRANCH_SCALE_FLOOR_INDEX` | f32 | construct (static) | iql_per_branch_advantage | SchemaContract | Per-sample branch_scales floor; safety bound |
| [37..72) | (reserved for DQN v2) | | | | | Allocated incrementally by Plans 2-5 |
```

- [ ] **Step 1.4: Write `docs/dqn-gpu-hot-path-audit.md` initial skeleton**

Content:
```markdown
# DQN GPU Hot-Path Audit

**Invariant 3 enforcement.** Every cross-boundary call (DtoH, HtoD, memcpy between host and device) in the DQN code path is classified below. No hot-path `memcpy` DtoH/HtoD is allowed — zero tolerance. Pinned + device-mapped memory is the ONLY permitted mechanism for per-step host-visible data.

**Hot path definition:** every function called from `fused_training.rs::step_fused` or its descendants that executes once per training step (178 times per epoch at batch size 8192), including kernel invocations inside the captured CUDA graph (`adam_grad_child`, `forward_child`, `aux_grad_child`) and host-side orchestration between them.

**Classification:**
- `OK-pinned` — uses the zero-copy pinned + device-mapped pattern (allowed).
- `MIGRATE` — uses `memcpy` but should be pinned. Fix during spec impl.
- `COLD-PATH` — acceptable placement (checkpoint save/load, fold transition, epoch-boundary validation), annotated with reason.

| File:line | Call signature | Path class | Annotation | Status |
|---|---|---|---|---|
| (populated during A.6 audit) | | | | |
```

- [ ] **Step 1.5: Write `docs/dqn-named-dims.md` initial skeleton**

Content:
```markdown
# DQN Named Dimensions Registry

**Invariant 8 enforcement.** Every semantic index has a named constant. Raw indices (`[0]`, `[6]`, `[23]`) appear ONLY at definition sites. Consumer code always uses the named constant.

## State vector offsets

Defined in `crates/ml/src/cuda_pipeline/state_layout.cuh` and mirrored in Rust via `ml-core::state_layout`.

| Constant | Value | Meaning |
|---|---|---|
| `SL_STATE_DIM` | 104 | Total state vector length |
| `SL_STATE_DIM_PADDED` | 128 | Padded for cuBLAS K-tile alignment |
| `SL_MARKET_DIM` | 42 | Market-feature block width |
| `SL_OFI_DIM` | 32 | OFI block width |
| `SL_MTF_DIM` | 16 | Multi-timeframe block width |
| `SL_PORTFOLIO_BASE_DIM` | 8 | Portfolio-base block width |
| `SL_PORTFOLIO_PLAN_DIM` | 6 | Plan-ISV block width (extended to 7 in Plan 2) |
| `SL_MARKET_START` | 0 | |
| `SL_OFI_START` | 42 | |
| `SL_MTF_START` | 74 | |
| `SL_PORTFOLIO_START` | 90 | |
| `SL_PLAN_ISV_START` | 98 | |

## Portfolio state slots (ps[0..30))

Defined in `crates/ml/src/cuda_pipeline/experience_kernels.cu` header comments. Migrated to named constants during Task 4 of this plan.

| Index | Constant | Meaning |
|---|---|---|
| 0 | `PS_VALUE` | Equity |
| 1 | `PS_POSITION` | Current position |
| 2 | `PS_CASH` | Available cash |
| 3 | `PS_ENTRY_PRICE` | Entry price |
| 4 | `PS_MAX_EQUITY` | Peak equity (for drawdown) |
| 5 | `PS_HOLD_TIME` | Bars held |
| 6 | `PS_CUM_RETURN` | Cumulative return |
| 7 | `PS_STEP_COUNT` | Bars in window |
| 8 | `PS_SPREAD_COST` | Current spread cost |
| 9 | `PS_PREV_EQUITY` | Equity at previous step |
| 10 | `PS_HOLD_TIME_TOTAL` | Total hold time |
| 11 | `PS_REALIZED_PNL` | Cumulative realised PnL |
| 12 | `PS_WIN_COUNT_UNUSED` | (slot retained, not currently used) |
| 13 | `PS_LOSS_COUNT_UNUSED` | (slot retained, not currently used) |
| 14 | `PS_KELLY_WIN_COUNT` | Kelly stats: winning trades |
| 15 | `PS_KELLY_LOSS_COUNT` | Kelly stats: losing trades |
| 16 | `PS_KELLY_SUM_WINS` | Kelly stats: sum of winning returns |
| 17 | `PS_KELLY_SUM_LOSSES` | Kelly stats: sum of \|losing returns\| |
| 18 | `PS_RESERVED_18` | reserved |
| 19 | `PS_RESERVED_19` | reserved |
| 20 | `PS_INTRA_TRADE_MAX_DD` | worst intra-trade drawdown (v8 reward) |
| 21 | `PS_INTRA_TRADE_MAX_PNL` | best intra-trade unrealised PnL (hindsight) |
| 22 | `PS_RESERVED_22` | (was interval_sum, slot retained) |
| 23 | `PS_PLAN_TARGET_BARS` | plan: max hold bars (>0.5 = plan active) |
| 24 | `PS_PLAN_PROFIT_TARGET` | plan: raw profit threshold |
| 25 | `PS_PLAN_STOP_LOSS` | plan: raw stop threshold |
| 26 | `PS_PLAN_SCALE_AGGRESSION` | plan: position ramp speed |
| 27 | `PS_PLAN_CONVICTION` | plan: conviction at entry ∈ [0,1] |
| 28 | `PS_PLAN_ASYMMETRY` | plan: profit/stop asymmetry |
| 29 | `PS_PLAN_ENTRY_REGIME` | plan: regime_stability at entry (for P12) |

## Plan_isv dimensions (plan_isv[0..6))

Will extend to 7 in Plan 2 (D.6 remaining_fraction). These constants live alongside `SL_PLAN_ISV_START` in `state_layout.cuh`.

| Index | Constant | Meaning |
|---|---|---|
| 0 | `PLAN_ISV_PROGRESS` | hold_time / target_bars (clamped 2.0) |
| 1 | `PLAN_ISV_PNL_VS_TARGET` | unrealized / (target × equity) |
| 2 | `PLAN_ISV_PNL_VS_STOP` | -unrealized / (stop × equity) |
| 3 | `PLAN_ISV_ENTRY_CONVICTION` | conviction at entry |
| 4 | `PLAN_ISV_CONVICTION_DRIFT` | current conviction − entry conviction |
| 5 | `PLAN_ISV_REGIME_SHIFT` | \|isv[11] − entry_stability\| |

## Plan_params output (plan_params[0..6))

From the trade_plan MLP output.

| Index | Constant | Meaning |
|---|---|---|
| 0 | `PLAN_PARAM_TARGET_BARS` | |
| 1 | `PLAN_PARAM_PROFIT_TARGET` | |
| 2 | `PLAN_PARAM_STOP_LOSS` | |
| 3 | `PLAN_PARAM_SCALE_AGGRESSION` | |
| 4 | `PLAN_PARAM_CONVICTION` | |
| 5 | `PLAN_PARAM_ASYMMETRY` | |

## Branch indices (4-branch factored action)

| Index | Constant | Meaning |
|---|---|---|
| 0 | `BRANCH_DIR` | Direction branch (4 actions) |
| 1 | `BRANCH_MAG` | Magnitude branch (3 actions) |
| 2 | `BRANCH_ORD` | Order-type branch (3 actions) |
| 3 | `BRANCH_URG` | Urgency branch (3 actions) |

## Direction action sub-indices

| Index | Constant | Meaning |
|---|---|---|
| 0 | `DIR_SHORT` | Open short (or close long + open short) |
| 1 | `DIR_HOLD` | Keep current position (no-op on position) |
| 2 | `DIR_LONG` | Open long (or close short + open long) |
| 3 | `DIR_FLAT` | Close any position to zero |

## Magnitude action sub-indices

| Index | Constant | Meaning |
|---|---|---|
| 0 | `MAG_QUARTER` | 0.25× max_position |
| 1 | `MAG_HALF` | 0.50× max_position |
| 2 | `MAG_FULL` | 1.00× max_position |
```

- [ ] **Step 1.6: Write `docs/ml-supervised-to-dqn-concept-audit.md` initial skeleton**

Content:
```markdown
# Supervised → DQN Concept Audit (Part E deliverable)

Authoritative decisions on which ml-supervised concepts the DQN adopts. Per Invariant 9 (no deferred work), every entry is either `IN`, `OUT`, or `AUDIT-THEN-DECIDE` with the audit producing one of the two terminal classifications during this spec's implementation.

| Concept | Supervised home | Classification | Action plan | Status |
|---|---|---|---|---|
| TFT Variable Selection Network | ml-supervised::tft | IN | E.1: extend vsn_mag/vsn_dir to full VSN over state feature groups | pending |
| Gated Residual Network | ml-supervised::tft | IN-IF-ABSENT | E.2: if A.5 audit finds absent, adopt; else mark existing as canonical | pending |
| TFT multi-quantile heads | ml-supervised::tft | IN | E.3: extend IQN to 5/25/50/75/95 quantile decomposition | pending |
| TLOB attention encoder | ml-supervised::tlob | IN | D.8 (Plan 2): wire to DQN trunk | pending |
| Mamba2 forward | ml-supervised::mamba2 | WIRED | already in DQN trunk | — |
| Mamba2 backward | ml-supervised::mamba2 | IN | D.1 (Plan 2): complete | pending |
| Liquid Time-constant | ml-supervised::liquid | AUDIT-THEN-WIRE-OR-DELETE | D.7 (Plan 2): audit decides | pending |
| xLSTM | ml-supervised::xlstm | OUT | Redundant with Mamba2 + TLOB; no DQN wiring | — |
| KAN | ml-supervised::kan | OUT | Function-approx not a bottleneck; no DQN wiring | — |
| Encoder-decoder separation | ml-supervised::tft | IN | E.4: extend D.3 horizon-decomposed V to full trunk/head separation | pending |
| Attention-weight interpretability | various | IN | E.5: ISV-expose attention weights from all temporal heads | pending |
| Multi-task auxiliary heads | ml-supervised | IN | E.6: next-return + regime-classification auxiliary heads | pending |
```

- [ ] **Step 1.7: Write `config/metric-bands.toml` initial content**

Content:
```toml
# Regression-detection bands for DQN training metrics.
# Invariant 7. Per A.4, bands populated from last 3 known-good runs.
# Band = [mean − 3σ, mean + 3σ] per metric; recomputed after baseline promotion.
# N consecutive out-of-band epochs → WARN; 2N consecutive → ERROR + SIGTERM-self.

[settings]
consecutive_epochs_for_warn = 3
consecutive_epochs_for_error = 6

# Placeholder bands — populated by A.4.1 nsys baseline run + populate_metric_bands.sh
# (populated during Plan 5 execution; this file lands with empty metric entries for Plan 1).

[bands]
```

- [ ] **Step 1.8: Extend pre-commit hook to reject commits missing audit-doc updates**

Modify the pre-commit hook (path found in Step 1.1; typical location `.claude/helpers/precommit-check.sh` or `.git/hooks/pre-commit`). Add this function block:

```bash
# DQN v2 Invariant 7 enforcement: component-adding commits must update audit docs.
check_audit_doc_updates() {
    local staged=$(git diff --cached --name-only)
    # Detect "component-adding" commits: new .rs or .cu files, or modifications to
    # files that define ISV slots (gpu_dqn_trainer.rs), kernels (cuda_pipeline/*.cu),
    # or state layout (state_layout.cuh).
    local component_changes=$(echo "$staged" | grep -E '(^crates/ml/src/(cuda_pipeline|trainers/dqn)/|^crates/ml/src/cuda_pipeline/state_layout\.cuh$)')
    if [ -z "$component_changes" ]; then return 0; fi

    local audit_docs_touched=$(echo "$staged" | grep -E '^docs/(dqn-wire-up-audit|isv-slots|dqn-gpu-hot-path-audit|dqn-named-dims|ml-supervised-to-dqn-concept-audit)\.md$')
    if [ -z "$audit_docs_touched" ]; then
        echo "❌ Invariant 7 violation: component changes without audit-doc update"
        echo "   Changed components:"
        echo "$component_changes" | sed 's/^/     /'
        echo "   You must update one of:"
        echo "     docs/dqn-wire-up-audit.md"
        echo "     docs/isv-slots.md"
        echo "     docs/dqn-gpu-hot-path-audit.md"
        echo "     docs/dqn-named-dims.md"
        echo "     docs/ml-supervised-to-dqn-concept-audit.md"
        return 1
    fi
}

# DQN v2 Invariant 9 enforcement: reject TODO/FIXME/XXX/HACK/TBD markers in added code.
check_no_todo_fixme() {
    local added_lines=$(git diff --cached --diff-filter=AM -U0 -- '*.rs' '*.cu' '*.cuh' \
        | grep -E '^\+' | grep -vE '^\+\+\+')
    local bad=$(echo "$added_lines" | grep -E '(TODO|FIXME|XXX|HACK|TBD|unimplemented!|todo!\()')
    if [ -n "$bad" ]; then
        echo "❌ Invariant 9 violation: deferred-work markers found in staged code"
        echo "$bad" | sed 's/^/     /'
        return 1
    fi
}

check_audit_doc_updates || exit 1
check_no_todo_fixme || exit 1
```

Insert these checks just before the hook's existing final `exit 0`.

- [ ] **Step 1.9: Test the pre-commit hook rejects a stub-marker commit**

Run:
```bash
cd /tmp && mkdir -p dqn-v2-hook-test && cd dqn-v2-hook-test
git init -q
# Copy the modified hook into a scratch repo
# (or test directly in the main repo after step 1.8 is in place)

cd ~/Work/foxhunt
echo "// TODO: implement me" > crates/ml/src/trainers/dqn/test_hook_rejection.rs
git add crates/ml/src/trainers/dqn/test_hook_rejection.rs
git commit -m "test hook" 2>&1
```

Expected: commit is rejected with `❌ Invariant 9 violation: deferred-work markers found in staged code`.

Clean up:
```bash
rm crates/ml/src/trainers/dqn/test_hook_rejection.rs
git reset HEAD -- crates/ml/src/trainers/dqn/test_hook_rejection.rs 2>/dev/null
```

- [ ] **Step 1.10: Commit the audit scaffolding**

```bash
git add 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 \
        config/metric-bands.toml
# add the pre-commit hook path that was modified in Step 1.8
git add <pre-commit-hook-path-from-step-1.1>
git commit -m "$(cat <<'EOF'
infra(dqn-v2): audit doc scaffolding + pre-commit enforcement

Plan 1 Task 1. Creates the five audit docs plus config/metric-bands.toml
that track Invariants 2, 7, 8 per the DQN v2 spec, and extends the
pre-commit hook with two checks:

  - component-adding commits must touch an audit doc (Invariant 7)
  - added code may not contain TODO/FIXME/XXX/HACK/TBD/unimplemented!/
    todo! markers (Invariant 9)

Tests: manually verified by staging a TODO-marked file; commit
rejected with the correct error message.

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

Expected: commit succeeds.

---

## Task 2: Define the StateResetRegistry (A.1)

**Files:**
- Create: `crates/ml/src/trainers/dqn/state_reset_registry.rs`
- Modify: `crates/ml/src/trainers/dqn/mod.rs` (export the new module)
- Test: `crates/ml/src/trainers/dqn/state_reset_registry.rs` (inline `#[cfg(test)]` block)

- [ ] **Step 2.1: Write the failing test for StateResetRegistry classification**

Create `crates/ml/src/trainers/dqn/state_reset_registry.rs` with only the test module first:

```rust
//! Formal classification of every piece of training-time state by reset lifecycle.
//! See `docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md` §4.A.1.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn registry_classifies_known_state() {
        let r = StateResetRegistry::new();
        assert_eq!(r.category("kelly_stats"), Some(ResetCategory::FoldReset));
        assert_eq!(r.category("adaptive_gamma"), Some(ResetCategory::SoftReset { decay_bars: 500 }));
        assert_eq!(r.category("network_params"), Some(ResetCategory::TrainingPersist));
        assert_eq!(r.category("ISV_LAYOUT_FINGERPRINT"), Some(ResetCategory::SchemaContract));
    }

    #[test]
    fn registry_fold_reset_iterates_only_fold_reset_entries() {
        let r = StateResetRegistry::new();
        let names: Vec<&str> = r.fold_reset_entries().map(|e| e.name).collect();
        assert!(names.contains(&"kelly_stats"));
        assert!(!names.contains(&"network_params"));
    }

    #[test]
    fn registry_unknown_name_returns_none() {
        let r = StateResetRegistry::new();
        assert_eq!(r.category("nonexistent_state"), None);
    }
}
```

- [ ] **Step 2.2: Run test to verify it fails**

Run:
```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml state_reset_registry 2>&1 | tail -20
```

Expected: compile errors `cannot find type StateResetRegistry` / `cannot find type ResetCategory`.

- [ ] **Step 2.3: Implement the StateResetRegistry**

Add to the top of `crates/ml/src/trainers/dqn/state_reset_registry.rs`, above the test module:

```rust
/// Reset lifecycle category for a piece of training-time state.
///
/// See `docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md` §4.A.1.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResetCategory {
    /// Cleared at fold boundary.
    FoldReset,
    /// Cleared at validation-window boundary.
    WindowReset,
    /// Anneals toward bootstrap value over `decay_bars` bars at fold boundary.
    SoftReset { decay_bars: u32 },
    /// Survives across folds (learned weights).
    TrainingPersist,
    /// Version-stable across runs; touched only by explicit migration.
    SchemaContract,
}

/// One entry in the reset registry.
#[derive(Debug, Clone, Copy)]
pub struct RegistryEntry {
    pub name: &'static str,
    pub category: ResetCategory,
    /// Short description — what this state represents. Used in diagnostic dumps.
    pub description: &'static str,
}

/// Typed registry classifying every piece of training-time state.
///
/// Populated at construction; read-only thereafter. Adding new state requires
/// adding a new entry here in the same commit that introduces the state
/// (per Invariant 2 Wire-It-Up).
pub struct StateResetRegistry {
    entries: Vec<RegistryEntry>,
}

impl StateResetRegistry {
    /// Build the registry with all currently-known training state.
    pub fn new() -> Self {
        let entries = vec![
            // ───── Fold-reset state ─────────────────────────────────────
            RegistryEntry {
                name: "kelly_stats",
                category: ResetCategory::FoldReset,
                description: "Per-window Kelly win/loss counters (ps[14..18])",
            },
            RegistryEntry {
                name: "plan_state",
                category: ResetCategory::FoldReset,
                description: "Val plan_state_buf [N, 7] — plan entry snapshots",
            },
            RegistryEntry {
                name: "eval_q_mean_ema",
                category: ResetCategory::FoldReset,
                description: "Per-branch Q-mean EMAs in update_eval_v_range",
            },
            RegistryEntry {
                name: "eval_q_std_ema",
                category: ResetCategory::FoldReset,
                description: "Per-branch Q-std EMAs in update_eval_v_range",
            },
            RegistryEntry {
                name: "isv_v_range_slots",
                category: ResetCategory::FoldReset,
                description: "ISV[23..31) — v_center/v_half per branch",
            },
            RegistryEntry {
                name: "isv_learning_health",
                category: ResetCategory::FoldReset,
                description: "ISV[12] — learning_health EMA",
            },
            RegistryEntry {
                name: "isv_sharpe_ema",
                category: ResetCategory::FoldReset,
                description: "ISV[22] — training Sharpe EMA",
            },
            RegistryEntry {
                name: "isv_q_means",
                category: ResetCategory::FoldReset,
                description: "ISV[13..22) — per-direction/magnitude Q-mean EMAs + |Q| refs",
            },
            // ───── Window-reset state ───────────────────────────────────
            RegistryEntry {
                name: "portfolio_state",
                category: ResetCategory::WindowReset,
                description: "Per-window portfolio (ps[0..8]) — reset at window start",
            },
            RegistryEntry {
                name: "hold_time",
                category: ResetCategory::WindowReset,
                description: "ps[5] / ps[10] — consecutive bars in position",
            },
            RegistryEntry {
                name: "entry_price",
                category: ResetCategory::WindowReset,
                description: "ps[3] — entry price of current trade",
            },
            // ───── Soft-reset state (anneals across folds) ──────────────
            RegistryEntry {
                name: "adaptive_gamma",
                category: ResetCategory::SoftReset { decay_bars: 500 },
                description: "training_loop.rs adaptive_gamma — anneals to 0.905 bootstrap",
            },
            RegistryEntry {
                name: "isv_grad_balance_targets",
                category: ResetCategory::SoftReset { decay_bars: 500 },
                description: "ISV[31..35) — per-branch grad-norm targets",
            },
            RegistryEntry {
                name: "isv_grad_scale_limit",
                category: ResetCategory::SoftReset { decay_bars: 500 },
                description: "ISV[35] — scale-clamp limit EMA",
            },
            // ───── Training-persist state ───────────────────────────────
            RegistryEntry {
                name: "network_params",
                category: ResetCategory::TrainingPersist,
                description: "Online network weights (all tensors)",
            },
            RegistryEntry {
                name: "target_network",
                category: ResetCategory::TrainingPersist,
                description: "Target network weights — Polyak EMA of online",
            },
            RegistryEntry {
                name: "adam_momentum",
                category: ResetCategory::TrainingPersist,
                description: "Adam optimizer first moment (m)",
            },
            RegistryEntry {
                name: "adam_variance",
                category: ResetCategory::TrainingPersist,
                description: "Adam optimizer second moment (v)",
            },
            RegistryEntry {
                name: "adam_step",
                category: ResetCategory::TrainingPersist,
                description: "Adam step counter t",
            },
            RegistryEntry {
                name: "replay_buffer",
                category: ResetCategory::TrainingPersist,
                description: "PER experience buffer — persists across folds",
            },
            // ───── Schema-contract state ────────────────────────────────
            RegistryEntry {
                name: "ISV_LAYOUT_FINGERPRINT",
                category: ResetCategory::SchemaContract,
                description: "ISV[0] — compile-time structural hash of slot layout; fail-fast only, no migration path",
            },
            RegistryEntry {
                name: "isv_iql_branch_scale_floor",
                category: ResetCategory::SchemaContract,
                description: "ISV[36] — IQL branch_scales floor (safety bound, bootstrap-only)",
            },
        ];
        Self { entries }
    }

    /// Look up a state's category by name. Returns `None` for unknown names
    /// (caller should assert Some before use to catch typos).
    pub fn category(&self, name: &str) -> Option<ResetCategory> {
        self.entries.iter().find(|e| e.name == name).map(|e| e.category)
    }

    /// Iterator over all entries with `FoldReset` category.
    /// Used by the fold-boundary reset logic.
    pub fn fold_reset_entries(&self) -> impl Iterator<Item = &RegistryEntry> {
        self.entries.iter().filter(|e| e.category == ResetCategory::FoldReset)
    }

    /// Iterator over all entries with `WindowReset` category.
    pub fn window_reset_entries(&self) -> impl Iterator<Item = &RegistryEntry> {
        self.entries.iter().filter(|e| e.category == ResetCategory::WindowReset)
    }

    /// Iterator over all entries with `SoftReset` category.
    pub fn soft_reset_entries(&self) -> impl Iterator<Item = &RegistryEntry> {
        self.entries.iter().filter(|e| matches!(e.category, ResetCategory::SoftReset { .. }))
    }

    /// All entries (debug/diagnostic dump).
    pub fn all(&self) -> &[RegistryEntry] {
        &self.entries
    }
}

impl Default for StateResetRegistry {
    fn default() -> Self {
        Self::new()
    }
}
```

- [ ] **Step 2.4: Export from the dqn module**

Modify `crates/ml/src/trainers/dqn/mod.rs`. Find the section with other `pub mod` declarations. Add:

```rust
pub mod state_reset_registry;
pub use state_reset_registry::{ResetCategory, RegistryEntry, StateResetRegistry};
```

- [ ] **Step 2.5: Run test to verify it passes**

Run:
```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml state_reset_registry 2>&1 | tail -10
```

Expected: `test result: ok. 3 passed; 0 failed`.

- [ ] **Step 2.6: Add an audit-doc entry for state_reset_registry**

Append to `docs/dqn-wire-up-audit.md` below the legend:

```markdown
| `crates/ml/src/trainers/dqn/state_reset_registry.rs` | consumers in Plan 1 Task 3+ (reset_fold_state call) | Wired (consumer added in same plan) | A.1 primary implementation |
```

- [ ] **Step 2.7: Commit**

```bash
git add crates/ml/src/trainers/dqn/state_reset_registry.rs \
        crates/ml/src/trainers/dqn/mod.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): A.1 state reset registry

Typed classification of every piece of training state by reset
lifecycle: FoldReset, WindowReset, SoftReset(decay_bars),
TrainingPersist, SchemaContract.

Replaces the previous vibes-driven fold-boundary reset logic with an
explicit registry. Adding new state requires adding an entry in the
same commit (Invariant 2 Wire-It-Up).

Consumer wiring (reset_fold_state, reset_window_state helpers that
iterate the registry) follows in Task 3 of Plan 1.

Tests: 3 unit tests covering category lookup, fold-reset filtering,
unknown-name handling.

Authority: docs/superpowers/specs/2026-04-24-dqn-v2-unified-design.md §4.A.1

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

Expected: commit succeeds.

---

## Task 3: Wire StateResetRegistry into the fold-boundary reset path

**Files:**
- Modify: `crates/ml/src/trainers/dqn/trainer/training_loop.rs` — find the fold-boundary reset block
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — `reset_eval_v_range_state` and related reset helpers

- [ ] **Step 3.1: Locate the existing fold-boundary reset call site**

Run:
```bash
grep -n "reset_eval_v_range_state\|fold_start\|fold boundary\|on_fold_start" \
    crates/ml/src/trainers/dqn/trainer/training_loop.rs | head -10
```

Expected: find the function / block that runs at the start of a new fold. Note the line number for Step 3.2.

- [ ] **Step 3.2: Add `reset_fold_state` using the registry**

At the top of `crates/ml/src/trainers/dqn/trainer/training_loop.rs`, add the import:
```rust
use crate::trainers::dqn::{StateResetRegistry, ResetCategory};
```

Find the fold-boundary block (Step 3.1) and replace the ad-hoc reset calls with a single iteration over the registry. Before:
```rust
// OLD (vibes-driven):
fused.reset_eval_v_range_state()?;
// other scattered reset calls...
```

After:
```rust
// NEW: registry-driven reset
let registry = StateResetRegistry::new();
for entry in registry.fold_reset_entries() {
    self.reset_named_state(entry.name, fold_idx)?;
}
// SoftReset handled separately (D.5 Plan 2); for this plan we keep the
// pre-existing ad-hoc handling and mark SoftReset entries as noop here.
```

- [ ] **Step 3.3: Implement `reset_named_state` dispatch**

In the same file, add a new method on the training_loop's struct:

```rust
/// Dispatch a named-state reset to its owning subsystem.
///
/// Invariant: every entry in the StateResetRegistry's FoldReset category
/// has a match arm here. Unknown names are a bug (the registry entry
/// should have been added in the same commit as the state it describes).
fn reset_named_state(&mut self, name: &str, fold_idx: u32) -> Result<(), MLError> {
    match name {
        "kelly_stats" => {
            // Kelly stats are held in portfolio state ps[14..18] — cleared
            // per-window by the env_step kernel's fold_start detection.
            // No-op here; the per-window reset logic handles it.
        }
        "plan_state" => {
            if let Some(ref mut fused) = self.fused_ctx {
                fused.zero_plan_state_buf()?;
            }
        }
        "eval_q_mean_ema" | "eval_q_std_ema" | "isv_v_range_slots" => {
            if let Some(ref mut fused) = self.fused_ctx {
                fused.reset_eval_v_range_state()?;
            }
        }
        "isv_learning_health" => {
            if let Some(ref mut fused) = self.fused_ctx {
                fused.write_isv_signal_at(crate::cuda_pipeline::gpu_dqn_trainer::LEARNING_HEALTH_INDEX, 0.5);
            }
        }
        "isv_sharpe_ema" => {
            if let Some(ref mut fused) = self.fused_ctx {
                fused.write_isv_signal_at(crate::cuda_pipeline::gpu_dqn_trainer::SHARPE_EMA_INDEX, 0.0);
            }
        }
        "isv_q_means" => {
            if let Some(ref mut fused) = self.fused_ctx {
                // Zero the 9 Q-mean EMA slots [13..22).
                for idx in 13..22 {
                    fused.write_isv_signal_at(idx, 0.0);
                }
            }
        }
        _ => {
            return Err(MLError::ModelError(format!(
                "StateResetRegistry fold-reset dispatch: unknown name '{}'. \
                 Every FoldReset entry must have a dispatch arm in reset_named_state.",
                name
            )));
        }
    }
    tracing::debug!(
        target: "state_reset_registry",
        fold = fold_idx,
        name = name,
        "fold-reset applied"
    );
    Ok(())
}
```

- [ ] **Step 3.4: Add `zero_plan_state_buf` accessor on the fused trainer**

Find `crates/ml/src/trainers/dqn/fused_training.rs` or `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` depending on where the backtest evaluator holds `plan_state_buf`. If in the evaluator only (which is val-side only), Task 3 does not need to clear it on fold boundary — skip this step and remove the `plan_state` arm from `reset_named_state` (also remove the registry entry). Note the discovery in the audit doc and the commit message.

Otherwise, add:

```rust
pub fn zero_plan_state_buf(&mut self) -> Result<(), MLError> {
    self.stream.memset_zeros(&mut self.plan_state_buf)
        .map_err(|e| MLError::ModelError(format!("zero plan_state_buf: {e}")))
}
```

- [ ] **Step 3.5: Run the DQN smoke tests to verify fold-reset still works**

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

Expected: smoke test passes. If it fails, the failure must be in a registry dispatch arm (compile-clean but behaviourally wrong) — fix in this step, not deferred.

- [ ] **Step 3.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 'dev' profile [unoptimized + debuginfo] target(s) in Xs` — no new warnings beyond the pre-existing 8.

- [ ] **Step 3.7: Update audit docs**

Append to `docs/dqn-wire-up-audit.md`:
```markdown
| `training_loop.rs::reset_named_state` | consumer of StateResetRegistry::fold_reset_entries | Wired | A.1 Task 3 dispatch |
```

- [ ] **Step 3.8: Commit**

```bash
git add crates/ml/src/trainers/dqn/trainer/training_loop.rs \
        crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/trainers/dqn/fused_training.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): A.1 wire StateResetRegistry into fold-boundary reset

Replaces the ad-hoc scattered fold-boundary reset calls with a single
registry-driven iteration. Adding new fold-reset state now requires
adding a registry entry AND a dispatch arm in the same commit.

Tests: multi_fold_convergence smoke passes; no behavioural change to
the reset semantics — only a refactor of the reset-dispatch layer.

Authority: spec §4.A.1. Plan 1 Task 3.

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

---

## Task 4: Named-dimension refactor (Invariant 8)

This is the largest task in Plan 1. Break into sub-tasks 4A–4F for portfolio state, plan_isv, plan_params, branch indices, direction sub-indices, magnitude sub-indices.

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/state_layout.cuh` — add all named constants
- Modify: `crates/ml/src/cuda_pipeline/experience_kernels.cu` — replace raw `ps[N]` with named macros
- Modify: `crates/ml/src/cuda_pipeline/backtest_env_kernel.cu` — same
- Modify: `crates/ml/src/cuda_pipeline/backtest_plan_kernel.cu` — same
- Modify: `crates/ml/src/cuda_pipeline/trade_physics.cuh` — same
- Modify: `crates/ml-core/src/state_layout.rs` — Rust mirror
- Create: `docs/dqn-named-dims.md` updates (populated per sub-task)

### Task 4A: Portfolio state slot constants (ps[0..30))

- [ ] **Step 4A.1: Add named `#define` constants in state_layout.cuh**

Modify `crates/ml/src/cuda_pipeline/state_layout.cuh`. Add a new section after the existing `SL_*` block:

```c
// ────────────────────────────────────────────────────────────────────────────
// Portfolio state slot indices (ps[0..30)).
// Invariant 8: every slot has a named constant. Raw ps[N] access outside this
// header or within a kernel that imports this header is a lint violation.
// ────────────────────────────────────────────────────────────────────────────
#define PS_VALUE                     0   // equity (mark-to-market)
#define PS_POSITION                  1
#define PS_CASH                      2
#define PS_ENTRY_PRICE               3
#define PS_MAX_EQUITY                4   // for drawdown tracking
#define PS_HOLD_TIME                 5   // consecutive bars in position
#define PS_CUM_RETURN                6
#define PS_STEP_COUNT                7
#define PS_SPREAD_COST               8
#define PS_PREV_EQUITY               9
#define PS_HOLD_TIME_TOTAL          10
#define PS_REALIZED_PNL             11
#define PS_RESERVED_12              12   // (slot retained)
#define PS_RESERVED_13              13   // (slot retained)
#define PS_KELLY_WIN_COUNT          14
#define PS_KELLY_LOSS_COUNT         15
#define PS_KELLY_SUM_WINS           16
#define PS_KELLY_SUM_LOSSES         17
#define PS_RESERVED_18              18
#define PS_RESERVED_19              19
#define PS_INTRA_TRADE_MAX_DD       20   // worst intra-trade drawdown (v8 reward)
#define PS_INTRA_TRADE_MAX_PNL      21   // best intra-trade unrealized PnL
#define PS_RESERVED_22              22   // (was interval_sum, slot retained)
#define PS_PLAN_TARGET_BARS         23   // > 0.5 == plan active
#define PS_PLAN_PROFIT_TARGET       24
#define PS_PLAN_STOP_LOSS           25
#define PS_PLAN_SCALE_AGGRESSION    26
#define PS_PLAN_CONVICTION          27   // conviction at entry [0, 1]
#define PS_PLAN_ASYMMETRY           28
#define PS_PLAN_ENTRY_REGIME        29   // regime_stability at entry (P12)
#define PS_DIM                      30   // sentinel: portfolio state size
```

- [ ] **Step 4A.2: Audit raw `ps[N]` access sites in kernels**

Run:
```bash
grep -rn 'ps\[[0-9]' crates/ml/src/cuda_pipeline/*.cu crates/ml/src/cuda_pipeline/*.cuh \
    | grep -vE 'state_layout\.cuh' | wc -l
```

Record the count. This is the migration scope.

- [ ] **Step 4A.3: Replace `ps[23]` with `PS_PLAN_TARGET_BARS` across all `.cu` / `.cuh` files**

Run (safe because `#include "state_layout.cuh"` is already standard):
```bash
for f in crates/ml/src/cuda_pipeline/*.cu crates/ml/src/cuda_pipeline/*.cuh; do
    sed -i 's/\bps\[23\]/ps[PS_PLAN_TARGET_BARS]/g' "$f"
done
```

- [ ] **Step 4A.4: Repeat for every ps[N] index**

Script a batched replacement (keep the exact mapping from Step 4A.1):
```bash
cd crates/ml/src/cuda_pipeline
declare -A MAP=(
    [0]=PS_VALUE
    [1]=PS_POSITION
    [2]=PS_CASH
    [3]=PS_ENTRY_PRICE
    [4]=PS_MAX_EQUITY
    [5]=PS_HOLD_TIME
    [6]=PS_CUM_RETURN
    [7]=PS_STEP_COUNT
    [8]=PS_SPREAD_COST
    [9]=PS_PREV_EQUITY
    [10]=PS_HOLD_TIME_TOTAL
    [11]=PS_REALIZED_PNL
    [12]=PS_RESERVED_12
    [13]=PS_RESERVED_13
    [14]=PS_KELLY_WIN_COUNT
    [15]=PS_KELLY_LOSS_COUNT
    [16]=PS_KELLY_SUM_WINS
    [17]=PS_KELLY_SUM_LOSSES
    [18]=PS_RESERVED_18
    [19]=PS_RESERVED_19
    [20]=PS_INTRA_TRADE_MAX_DD
    [21]=PS_INTRA_TRADE_MAX_PNL
    [22]=PS_RESERVED_22
    [23]=PS_PLAN_TARGET_BARS
    [24]=PS_PLAN_PROFIT_TARGET
    [25]=PS_PLAN_STOP_LOSS
    [26]=PS_PLAN_SCALE_AGGRESSION
    [27]=PS_PLAN_CONVICTION
    [28]=PS_PLAN_ASYMMETRY
    [29]=PS_PLAN_ENTRY_REGIME
)
for k in "${!MAP[@]}"; do
    name="${MAP[$k]}"
    for f in *.cu *.cuh; do
        sed -i "s/\bps\[$k\]/ps[$name]/g" "$f"
    done
done
cd -
```

- [ ] **Step 4A.5: Verify no stray `ps[digit]` remains**

Run:
```bash
grep -rn 'ps\[[0-9]' crates/ml/src/cuda_pipeline/*.cu crates/ml/src/cuda_pipeline/*.cuh \
    | grep -vE 'state_layout\.cuh|PS_DIM'
```

Expected: empty output. Any remaining matches indicate a missed case — fix inline.

- [ ] **Step 4A.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 warnings/errors.

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

```bash
git add crates/ml/src/cuda_pipeline/state_layout.cuh \
        crates/ml/src/cuda_pipeline/*.cu \
        crates/ml/src/cuda_pipeline/*.cuh \
        docs/dqn-named-dims.md
git commit -m "$(cat <<'EOF'
refactor(dqn-v2): Invariant 8 — named constants for portfolio state ps[0..30)

Replace raw ps[N] access across all CUDA kernels with PS_* named
constants defined in state_layout.cuh. 30 constants total; enforces
Invariant 8 for the portfolio-state block.

Raw ps[digit] access outside state_layout.cuh is now a lint violation
(grep -rn 'ps\[[0-9]' should return zero outside the header).

No behavioural change; pure refactor.

Plan 1 Task 4A. Spec §3 Invariant 8.

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

### Tasks 4B–4F: Remaining named-dimension sub-tasks

Follow the same pattern for:
- **4B: plan_isv dimensions (plan_isv[0..6))** — add `PLAN_ISV_*` constants; replace raw indices.
- **4C: plan_params (plan_params[0..6))** — add `PLAN_PARAM_*` constants.
- **4D: branch indices (0..4)** — add `BRANCH_DIR`, `BRANCH_MAG`, `BRANCH_ORD`, `BRANCH_URG`; replace raw 0/1/2/3 in branch-indexing code (carefully: not every 0..3 literal is a branch index).
- **4E: direction sub-indices** — add `DIR_SHORT`, `DIR_HOLD`, `DIR_LONG`, `DIR_FLAT`.
- **4F: magnitude sub-indices** — add `MAG_QUARTER`, `MAG_HALF`, `MAG_FULL`.

Each sub-task follows the Task 4A template: add constants → audit raw-index access → sed replacement → verify empty grep → compile-check → commit with identical message structure.

For 4D (branch indices), use more targeted sed patterns because raw `0..4` literals may appear in unrelated contexts. Replace only where context makes it a branch index:
```bash
# Example: in gpu_dqn_trainer.rs, &0_i32 vs &1_i32 in branch-arg positions
# Better: read the file and apply edits with explicit line numbers via Edit tool.
```

Record the final per-sub-task commit SHA in `docs/dqn-named-dims.md`.

---

## Task 5: A.2 ISV Slot Contract + Layout Fingerprint

**Design intent — READ FIRST:** The user rejected integer "schema version" as it semantically implies a family of coexisting versions and invites the forbidden backward-compat pattern (`feedback_no_legacy_aliases.md`, `feedback_no_partial_refactor.md`). Instead, ISV[0] holds a compile-time **structural fingerprint** hash of the slot layout. Any slot change automatically changes the fingerprint — no human decides "what version is this now". Checkpoint load is fail-fast only. No `migrate_*` functions are ever added; the ordered version space that invites them does not exist.

**Files:**
- Modify: `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` — add `ISV_LAYOUT_FINGERPRINT_INDEX`, `const fn compute_layout_fingerprint()`, constructor write, load check
- Modify: `crates/ml/src/cuda_pipeline/*.cu` — every `isv_signals[N]` reference shifts by 1, preferring named constants
- Modify: checkpoint load path (`crates/ml/src/trainers/dqn/trainer/` — locate during implementation)
- Modify: `crates/ml/src/trainers/dqn/state_reset_registry.rs` — rename `ISV_SCHEMA_VERSION` entry to `ISV_LAYOUT_FINGERPRINT` + updated description (Task 2 landed with the old name)
- Modify: `docs/isv-slots.md` — rewrite table with shifted indices, update [0] row

- [ ] **Step 5.1: Verify scope of ISV-slot shift**

```bash
# ISV slot constants and their values
grep -nE 'pub const [A-Z_]+INDEX\s*:\s*usize\s*=' crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs
grep -n "ISV_TOTAL_DIM" crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs

# Kernel literal-index sites
grep -rn 'isv_signals\[[0-9]' crates/ml/src/cuda_pipeline/
```

Record the count. All `*_INDEX` constants shift by +1. All kernel `isv_signals[N]` literal sites either switch to named constants (preferred) or shift to `N+1`.

- [ ] **Step 5.2: Add `ISV_LAYOUT_FINGERPRINT_INDEX` + `compute_layout_fingerprint()`**

In `crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs` at the top of the ISV constant block, add:

```rust
/// ISV slot [0] — compile-time structural hash of the slot layout.
///
/// Stored as a `u64` split across two `f32` lanes (ISV is f32-typed; the
/// fingerprint straddles ISV[0] and ISV[1] when materialised — see
/// `LAYOUT_FINGERPRINT_LO/HI_INDEX` below). The constructor writes
/// `LAYOUT_FINGERPRINT_CURRENT`; checkpoint load fails-fast on mismatch.
///
/// Design note: this is NOT a version number. There is no ordered version
/// space. The fingerprint is a structural hash that automatically changes
/// whenever any slot is added, removed, reordered, or renamed — so there
/// is no natural pairing "v1 → v2" and no place to write a migrator.
/// Per spec §4.A.2 and `feedback_no_legacy_aliases.md`: backward compat
/// is structurally unwritable, not merely discouraged.
pub const ISV_LAYOUT_FINGERPRINT_LO_INDEX: usize = 0;
pub const ISV_LAYOUT_FINGERPRINT_HI_INDEX: usize = 1;

/// Alias for the canonical fingerprint slot (the low 32 bits). Consumers
/// that only need to compare should use `layout_fingerprint_matches()` below.
pub const ISV_LAYOUT_FINGERPRINT_INDEX: usize = ISV_LAYOUT_FINGERPRINT_LO_INDEX;

/// FNV-1a 64-bit hash of a byte slice, usable as a `const fn`.
const fn fnv1a_64(bytes: &[u8]) -> u64 {
    const OFFSET: u64 = 0xcbf29ce484222325;
    const PRIME:  u64 = 0x00000100000001b3;
    let mut h: u64 = OFFSET;
    let mut i = 0;
    while i < bytes.len() {
        h ^= bytes[i] as u64;
        h = h.wrapping_mul(PRIME);
        i += 1;
    }
    h
}

/// Compile-time layout fingerprint. Concatenates (name, index) pairs for every
/// ISV slot; any change to the slot list changes the fingerprint.
///
/// When a slot is added/removed, extend this `const` data and the fingerprint
/// recomputes. The value is burned into the binary at compile time.
pub const LAYOUT_FINGERPRINT_CURRENT: u64 = fnv1a_64(layout_fingerprint_seed());

/// Source bytes for the layout fingerprint. Canonical format:
/// `b"<slot_name>=<index>;<slot_name>=<index>;...;ISV_TOTAL_DIM=<N>"`.
///
/// When adding/removing a slot, add/remove its entry here in the SAME commit
/// that changes the constant list. A mismatch between the fingerprint-seed
/// bytes and the actual constants is caught by `fingerprint_matches_layout`
/// (the debug-assert smoke test below).
const fn layout_fingerprint_seed() -> &'static [u8] {
    // The exact byte content below is what makes every change alter the hash.
    // Order matches the constant declaration order in this module.
    b"ISV_LAYOUT_FINGERPRINT_LO=0;ISV_LAYOUT_FINGERPRINT_HI=1;\
      LEARNING_HEALTH=13;\
      Q_MAG_MEAN_QUARTER=14;Q_MAG_MEAN_HALF=15;Q_MAG_MEAN_FULL=16;Q_MAG_ABS_REF=17;\
      Q_DIR_MEAN_SHORT=18;Q_DIR_MEAN_HOLD=19;Q_DIR_MEAN_LONG=20;Q_DIR_MEAN_FLAT=21;Q_DIR_ABS_REF=22;\
      SHARPE_EMA=23;\
      V_CENTER_DIR=24;V_HALF_DIR=25;V_CENTER_MAG=26;V_HALF_MAG=27;\
      V_CENTER_ORD=28;V_HALF_ORD=29;V_CENTER_URG=30;V_HALF_URG=31;\
      GRAD_NORM_TARGET_DIR=32;GRAD_NORM_TARGET_MAG=33;GRAD_NORM_TARGET_ORD=34;GRAD_NORM_TARGET_URG=35;\
      GRAD_SCALE_LIMIT=36;IQL_BRANCH_SCALE_FLOOR=37;\
      ISV_TOTAL_DIM=38"
}
```

(Adjust the seed bytes to match your actual constant list after the shift — the template above assumes the +1 shift. If you discover an existing constant not listed above, ADD it to the seed with its post-shift index in the SAME commit — that is the whole point: any layout change updates the seed bytes, which updates the hash.)

- [ ] **Step 5.3: Shift every ISV slot index up by 1**

Edit `gpu_dqn_trainer.rs`: every `pub const X_INDEX: usize = N;` becomes `pub const X_INDEX: usize = N + 1;` for N ≥ 0 (the new slots at [0] and [1] are the fingerprint lanes; every existing slot moves up by 1 collectively since the fingerprint occupies only ONE conceptual slot — the HI lane at [1] overlaps the old [0] slot if there was one, which there wasn't; so a shift-by-1 is sufficient even though the fingerprint is 64 bits).

Wait — think this through. The existing `[0..12)` range is "pre-existing legacy slots, audit during A.2" per the isv-slots.md table. Some of those may be active. If a pre-existing slot at [0] or [1] exists, we collide. Before implementation:

```bash
grep -rn 'isv_signals\[0\]\|isv_signals\[1\]' crates/ml/src/cuda_pipeline/
```

If both slots are unused (isv-slots.md confirms the [1..12) range is pre-existing legacy), then:
- Shift every active slot (LEARNING_HEALTH_INDEX and above) UP by 1. They were at [12..37); they become [13..38).
- Fingerprint occupies the new [0..2). `ISV_TOTAL_DIM` becomes 38.
- Legacy [2..12) range remains unaudited/unused — leave as-is for this task; A.5 orphan audit (Task 6) will flag whether those slots are actually consumed.

If `isv_signals[0]` OR `isv_signals[1]` IS used by something, document the finding and propose an alternative (e.g. fingerprint occupies [36..38] at the tail instead of [0..2]). The fingerprint's POSITION is negotiable; the fingerprint DESIGN (structural hash, fail-fast only) is not.

Update `ISV_TOTAL_DIM` appropriately.

**In kernel .cu files:** prefer named-constant replacement over literal shift. Use the `Edit` tool site-by-site — do NOT use sed.

- [ ] **Step 5.4: Write fingerprint at construction**

In the ISV bus construction block (around `std::ptr::write_bytes(host_ptr as *mut u8, 0, num_bytes)`), after the zero-init, write both fingerprint lanes:

```rust
// Layout fingerprint. Compile-time hash of the slot layout; checkpoint load
// fails-fast on mismatch. See LAYOUT_FINGERPRINT_CURRENT docs for why this
// is NOT a version number and why no migration path exists.
let fp = LAYOUT_FINGERPRINT_CURRENT;
*sig_ptr.add(ISV_LAYOUT_FINGERPRINT_LO_INDEX) = f32::from_bits(fp as u32);
*sig_ptr.add(ISV_LAYOUT_FINGERPRINT_HI_INDEX) = f32::from_bits((fp >> 32) as u32);
```

The cast `f32::from_bits(u32)` preserves the raw bits — no precision loss. On load we re-assemble via `f32::to_bits`.

- [ ] **Step 5.5: Fail-fast check at checkpoint load**

Locate the checkpoint load path. Read the existing restore logic before modifying. Add:

```rust
/// Read the layout fingerprint out of a checkpoint and compare to current.
/// Returns `Err` on mismatch. NEVER returns `Ok` on mismatch — that would
/// permit silent backward compat, which this design forbids.
fn check_checkpoint_layout_fingerprint(ckpt: &Checkpoint) -> Result<(), MLError> {
    let lo_f32 = read_isv_slot_from_checkpoint(ckpt, ISV_LAYOUT_FINGERPRINT_LO_INDEX)?;
    let hi_f32 = read_isv_slot_from_checkpoint(ckpt, ISV_LAYOUT_FINGERPRINT_HI_INDEX)?;
    let fp_checkpoint: u64 =
        (f32::to_bits(lo_f32) as u64) | ((f32::to_bits(hi_f32) as u64) << 32);
    if fp_checkpoint != LAYOUT_FINGERPRINT_CURRENT {
        return Err(MLError::ModelError(format!(
            "ISV layout fingerprint mismatch: checkpoint 0x{:016x}, current code 0x{:016x}. \
             Checkpoint layout does not match current code — retrain required. \
             (This is a structural fingerprint, not a version number. There is no migration path.)",
            fp_checkpoint, LAYOUT_FINGERPRINT_CURRENT
        )));
    }
    Ok(())
}
```

Call this from the existing restore function after reading the ISV buffer. If `read_isv_slot_from_checkpoint` doesn't exist, add a minimal implementation — it's a byte-offset read from the checkpoint's ISV section. If checkpoints don't currently persist ISV at all, the call must still land and must FAIL-FAST with the same mismatch error (treating "missing ISV" as a fingerprint of `0` which never matches any real fingerprint).

**Hook enforcement:** Add this one-line check to `scripts/pre-commit-hook.sh` (Task 1's hook extension infrastructure):

```bash
# Invariant: no migration-function names in ISV code.
if git diff --cached -U0 -- crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
   crates/ml/src/trainers/dqn/trainer/ 2>/dev/null | \
   grep -qE '^\+.*fn\s+migrate_isv|^\+.*fn\s+upgrade_isv'; then
    echo "❌ Spec §4.A.2 violation: ISV migration functions are forbidden (fingerprint is fail-fast only)"
    exit 1
fi
```

Add this inside the existing `check_no_todo_fixme`-adjacent block as a new function (mirror its structure). This makes the no-migration rule enforced by the same tooling that enforces Invariants 7 and 9.

- [ ] **Step 5.6: Rename StateResetRegistry entry**

Task 2 landed with `name: "ISV_SCHEMA_VERSION"`. Rename to match the new design:

```rust
RegistryEntry {
    name: "ISV_LAYOUT_FINGERPRINT",
    category: ResetCategory::SchemaContract,
    description: "ISV[0..2) — compile-time structural hash of slot layout; fail-fast only, no migration path",
},
```

- [ ] **Step 5.7: 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 -40
```

Expected: all pass. Also:

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml --lib state_reset_registry 2>&1 | tail -10
```

Expected: Task 2's 3 tests still pass (the rename to `ISV_LAYOUT_FINGERPRINT` updates the test expectation in the same commit).

- [ ] **Step 5.8: Update `docs/isv-slots.md`**

Rewrite the table:
- [0] `ISV_LAYOUT_FINGERPRINT_LO_INDEX` (u32 fragment of u64), construct-only, SchemaContract.
- [1] `ISV_LAYOUT_FINGERPRINT_HI_INDEX` (u32 fragment of u64), construct-only, SchemaContract.
- [2..13) legacy slots (pre-existing, may be unused — to be audited).
- [13..38) shifted from [12..37). Every named constant bumps its index by +1.
- `ISV_TOTAL_DIM` = 38.

Add a prominent design note at the top of the doc:

```markdown
**Design: layout fingerprint, not schema version.** ISV[0..2) carries a
compile-time structural hash of the slot layout. Checkpoint load is
fail-fast; no migration functions exist. Backward compat is structurally
unwritable (there is no ordered version space to pair migrations against).
See spec §4.A.2.
```

- [ ] **Step 5.9: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/cuda_pipeline/*.cu \
        crates/ml/src/cuda_pipeline/*.cuh \
        crates/ml/src/trainers/dqn/ \
        scripts/pre-commit-hook.sh \
        docs/isv-slots.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): A.2 ISV layout fingerprint at ISV[0..2)

Replaces the earlier "schema version" design after user review raised
a backward-compat concern. Integer version numbers semantically imply
a family of coexisting versions with migrations between them, inviting
the forbidden pattern (feedback_no_legacy_aliases.md). A structural
fingerprint has no ordered version space and no natural pairing, so
no migration code can be written.

Key changes:
- ISV_LAYOUT_FINGERPRINT_{LO,HI}_INDEX = 0, 1 (u64 split across 2×f32).
- LAYOUT_FINGERPRINT_CURRENT: u64 = FNV-1a of the slot-list seed bytes.
  Any slot change updates seed bytes, which updates the hash.
- Constructor writes fingerprint; checkpoint load fails-fast on mismatch.
- Error message does NOT mention migration as an option.
- Pre-commit hook rejects any `fn migrate_isv|upgrade_isv` names —
  makes the no-migration rule structurally enforced.
- Every pre-existing ISV slot index shifted +1 (LEARNING_HEALTH 12→13,
  etc.); ISV_TOTAL_DIM = 38 (was 37).
- StateResetRegistry entry renamed ISV_SCHEMA_VERSION → ISV_LAYOUT_FINGERPRINT.

Tests: all smoke tests pass; state_reset_registry 3 unit tests pass
with the renamed entry.

Plan 1 Task 5. Spec §4.A.2 (updated).

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

---

## Task 6: A.5 Orphan Audit — populate the wire-up doc

**Files:**
- Modify: `docs/dqn-wire-up-audit.md` — populate from grep

- [ ] **Step 6.1: Enumerate every pub module and kernel in the DQN path**

Run:
```bash
{
    find crates/ml/src -name '*.rs' -exec grep -l '^pub mod\|^pub fn\|^pub struct' {} \;
    find crates/ml/src/cuda_pipeline -name '*.cu'
} | sort -u
```

Pipe to a scratch file `/tmp/dqn-modules.txt`.

- [ ] **Step 6.2: For each module, determine consumer**

For each entry, grep for consumers:
```bash
while read -r file; do
    if [[ "$file" == *.rs ]]; then
        module_name=$(basename "$file" .rs)
        consumer_count=$(grep -rl "use.*${module_name}\|${module_name}::" crates/ml/src --include='*.rs' | grep -v "$file" | wc -l)
        echo "$file CONSUMERS=$consumer_count"
    else
        kernel_name=$(basename "$file" .cu)
        consumer_count=$(grep -rl "$kernel_name" crates/ml/src --include='*.rs' | wc -l)
        echo "$file CONSUMERS=$consumer_count"
    fi
done < /tmp/dqn-modules.txt > /tmp/dqn-modules-classified.txt
head -50 /tmp/dqn-modules-classified.txt
```

- [ ] **Step 6.3: Fill in the audit doc**

Open `docs/dqn-wire-up-audit.md` and, using `/tmp/dqn-modules-classified.txt` as input, populate the table with one row per module. Classification rules:
- `CONSUMERS=0` → `Orphan`
- `CONSUMERS≥1` → `Wired` if consumer is production path; `Partial` if only consumed by tests/benchmarks
- TFT-related, xLSTM, KAN, TLOB — copy decisions from `docs/ml-supervised-to-dqn-concept-audit.md`

Take as long as needed; this is the one-time audit that Invariant 2 builds upon.

- [ ] **Step 6.4: Resolve every `Orphan` discovered**

For each module classified `Orphan`:
- If in scope for this plan (Plan 1) — wire it in the same commit as Task 6 completion.
- If in scope for Plan 2 (temporal) — note explicitly which Task in Plan 2 wires it.
- If in scope for Plan 4 (E.1–E.6) — note which E sub-task wires it.
- If genuinely unused — delete it in the same commit (per `feedback_wire_everything_up.md`).

No orphan is left as "will decide later". Every one has a concrete action.

- [ ] **Step 6.5: Commit**

```bash
git add docs/dqn-wire-up-audit.md
# plus any deletion / wiring commits for orphans resolved inline
git commit -m "$(cat <<'EOF'
audit(dqn-v2): A.5 orphan audit complete

Populated docs/dqn-wire-up-audit.md with every pub module and kernel
in the DQN path. Each entry classified as Wired / Partial / Orphan /
Ghost with the action plan linking to the plan+task that resolves
any non-Wired status.

No Orphan left unclassified. Resolved orphans either wired in this
commit or explicitly linked to a later Plan's task.

Plan 1 Task 6. Spec §4.A.5, Invariant 2.

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

---

## Task 7: A.6 Hot-Path Purity Audit

**Files:**
- Modify: `docs/dqn-gpu-hot-path-audit.md` — populate

- [ ] **Step 7.1: Grep every cross-boundary call**

Run:
```bash
{
    grep -rn 'memcpy_dtoh\|memcpy_htod\|cudaMemcpy\|cuMemcpy' \
        crates/ml/src --include='*.rs' --include='*.cu' --include='*.cuh' 2>&1
    grep -rn '\.synchronize()\|stream_sync' \
        crates/ml/src --include='*.rs' 2>&1
} | tee /tmp/dqn-xboundary.txt | head -50
```

- [ ] **Step 7.2: Classify each call**

For each line in `/tmp/dqn-xboundary.txt`:
1. Read the surrounding 20 lines of the file to determine context.
2. Classify:
   - `OK-pinned` — the buffer is allocated with `cuMemAllocHost_v2 + cuMemHostGetDevicePointer_v2`. Check the allocation site.
   - `COLD-PATH` — the call runs per-epoch (in update_eval_v_range, at fold boundary), per-checkpoint (save/load), or per-window boundary.
   - `MIGRATE` — the call runs per-step and uses `memcpy` rather than pinned — this is an Invariant 3 violation.
3. Fill in `docs/dqn-gpu-hot-path-audit.md`.

- [ ] **Step 7.3: Fix every `MIGRATE` classification**

For each `MIGRATE` call, replace with pinned + device-mapped allocation in the same commit. This is per Invariant 9 — no deferred work.

Typical fix pattern:
```rust
// BEFORE:
self.stream.memcpy_dtoh(&self.some_dev_buf, &mut host_scratch)?;

// AFTER: allocate pinned + device-mapped ONCE in the constructor
// (host_scratch_pinned: *mut f32, host_scratch_dev_ptr: u64)
// Then in the hot path, just read from host_scratch_pinned — no call.
```

- [ ] **Step 7.4: Run smoke tests to verify no regression**

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

- [ ] **Step 7.5: Commit**

```bash
git add crates/ml/src/cuda_pipeline/gpu_dqn_trainer.rs \
        crates/ml/src/trainers/dqn/fused_training.rs \
        docs/dqn-gpu-hot-path-audit.md
git commit -m "$(cat <<'EOF'
audit(dqn-v2): A.6 hot-path purity — zero memcpy DtoH in step loop

Populated docs/dqn-gpu-hot-path-audit.md with every cross-boundary
call. Migrated N MIGRATE-classified calls to pinned + device-mapped
memory. Remaining calls are COLD-PATH (checkpoint/fold/epoch) and
annotated with their reason.

Invariant 3 now enforced across the DQN path.

Plan 1 Task 7. Spec §4.A.6, Invariant 3.

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

---

## Task 8: C.6 AdaptiveMonitor trait + test harness (GPU-drives architecture)

**DESIGN NOTE:** Task 8 replaces the earlier `AdaptiveController` trait with a read-only `AdaptiveMonitor` trait. See spec §4.C.6 revision (2026-04-24). The reason: adaptive values are computed by GPU kernels from ISV signals; CPU-side code is pure observation. The old `update()` / `write_output()` trait methods are removed — CPU never computes an adaptive value.

**Files:**
- Create: `crates/ml/src/trainers/dqn/adaptive_monitor.rs` (renamed from `adaptive_controller.rs`)

### Step 8.1: Write the failing test

Create `crates/ml/src/trainers/dqn/adaptive_monitor.rs` with ONLY the test module first:

```rust
//! Read-only observer for GPU-computed adaptive values in the DQN trainer.
//! Spec §4.C.6 (2026-04-24 revision: GPU computes, CPU reads).

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fire_rate_stats_compute() {
        let mut stats = FireRateStats::default();
        stats.record_fire(true);
        stats.record_fire(false);
        stats.record_fire(true);
        assert!((stats.fire_rate() - 2.0f32 / 3.0f32).abs() < 1e-6);
    }

    #[test]
    fn diag_snapshot_builder() {
        let snap = DiagSnapshot::new().with("v", 0.5).with("t", 1.0);
        assert_eq!(snap.fields.len(), 2);
        assert_eq!(snap.fields.get("v"), Some(&0.5f32));
    }
}
```

### Step 8.2: Run — expect FAIL

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml adaptive_monitor 2>&1 | tail -10
```

Expected: compile errors `cannot find type FireRateStats`.

### Step 8.3: Implement the trait

Prepend to `adaptive_monitor.rs` above the test module:

```rust
use std::collections::HashMap;

/// Thin read/write view over the ISV pinned-memory bus.
///
/// Monitors use `read()` exclusively. The constructor / schedule-input path
/// uses `write()` for CPU-born inputs (epoch_idx, static configs). GPU kernels
/// write via their device pointer — not via `IsvBus`.
pub(crate) struct IsvBus<'a> {
    signals: &'a mut [f32],
}

impl<'a> IsvBus<'a> {
    pub(crate) fn new(signals: &'a mut [f32]) -> Self {
        Self { signals }
    }
    pub(crate) fn read(&self, slot: usize) -> f32 { self.signals[slot] }
    pub(crate) fn write(&mut self, slot: usize, value: f32) { self.signals[slot] = value; }
    pub(crate) fn len(&self) -> usize { self.signals.len() }
}

/// Fire-rate statistics tracked by a monitor across observations.
///
/// A "fire" is a monitor observation where the value materially changed from
/// the previous observation. Used by `controller_activity` smoke to verify
/// load-bearing mechanisms do not fire in > 50% of epochs.
#[derive(Debug, Default, Clone)]
pub(crate) struct FireRateStats {
    fires: u32,
    epochs: u32,
}

impl FireRateStats {
    pub(crate) fn record_fire(&mut self, fired: bool) {
        self.epochs += 1;
        if fired { self.fires += 1; }
    }
    pub(crate) fn fire_rate(&self) -> f32 {
        if self.epochs == 0 { 0.0 } else { self.fires as f32 / self.epochs as f32 }
    }
    pub(crate) fn reset(&mut self) {
        self.fires = 0;
        self.epochs = 0;
    }
}

/// Diagnostic snapshot emitted by a monitor each epoch.
///
/// Uses `&'static str` keys (no allocation per key). Built via `.with()`.
#[derive(Debug, Default, Clone)]
pub(crate) struct DiagSnapshot {
    pub fields: HashMap<&'static str, f32>,
}

impl DiagSnapshot {
    pub(crate) fn new() -> Self { Self { fields: HashMap::new() } }
    pub(crate) fn with(mut self, key: &'static str, value: f32) -> Self {
        self.fields.insert(key, value);
        self
    }
}

/// Read-only observer for a GPU-computed adaptive value.
///
/// All adaptive values are computed by GPU kernels from ISV signals. CPU-side
/// code does NOT compute adaptive values; it only reads and reports. See
/// spec §4.C.6 (2026-04-24 revision).
pub(crate) trait AdaptiveMonitor {
    /// Read the GPU-computed current value from ISV.
    fn read(&self, isv: &IsvBus<'_>) -> f32;

    /// Emit diagnostic snapshot for HEALTH_DIAG.
    fn diagnose(&self, isv: &IsvBus<'_>) -> DiagSnapshot;

    /// Record one observation; drives fire-rate tracking at epoch boundary.
    fn observe(&mut self, current: f32);

    /// Fire-rate statistics across prior observations.
    fn fire_rate(&self) -> &FireRateStats;

    /// Short name used in diagnostic output (e.g. "gamma", "kelly_cap").
    fn name(&self) -> &'static str;
}
```

### Step 8.4: Run test — expect PASS

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml adaptive_monitor 2>&1 | tail -5
```

Expected: 2 passed.

### Step 8.5: Export from mod.rs

In `crates/ml/src/trainers/dqn/mod.rs`:
```rust
pub(crate) mod adaptive_monitor;
```

### Step 8.6: Compile-check

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo check -p ml 2>&1 | tail -3
```

Expected: `Finished` with the 8-warning baseline.

### Step 8.7: Audit doc

Append to `docs/dqn-wire-up-audit.md`:
```markdown
| `trainers/dqn/adaptive_monitor.rs` | Read-only observer trait + harness (FireRateStats, DiagSnapshot, IsvBus<'a>); consumers added in Plan 1 Tasks 9-17 (atoms/gamma/kelly_cap/tau/epsilon/grad_balancer monitors) | Wired (consumers added in same plan) | C.6 GPU-drives-CPU-reads | — |
```

### Step 8.8: Commit

```bash
git add crates/ml/src/trainers/dqn/adaptive_monitor.rs \
        crates/ml/src/trainers/dqn/mod.rs \
        docs/dqn-wire-up-audit.md
git commit -m "$(cat <<'EOF'
feat(dqn-v2): C.6 AdaptiveMonitor trait (GPU-drives, CPU-reads)

Read-only observer trait for GPU-computed adaptive values. Replaces
the earlier AdaptiveController trait which had CPU-side update() and
write_output() — those violated the newly-codified principle that
GPU kernels compute all adaptive decisions and CPU-side code only
reads from ISV.

FireRateStats for controller_activity smoke. DiagSnapshot built via
.with() chain. IsvBus read/write (write for CPU-born inputs only —
epoch_idx, static configs; NEVER for adaptive values).

Tests: 2 unit tests on FireRateStats and DiagSnapshot.

Plan 1 Task 8. Spec §4.C.6 (2026-04-24 revision).

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

---

## Tasks 9-17: Reactive GPU kernels + CPU monitors (new structure)

Each of Tasks 9-11, 13-14, 17 lands a GPU kernel + CPU `AdaptiveMonitor` pair. Tasks 12, 15, 16 are static-config ISV constructor writes (no monitor needed). All old CPU-compute scaffolding is removed in the same commit as the replacement lands (`feedback_no_partial_refactor.md`).

### Common pattern for GPU-kernel + monitor pairs

1. **Write failing CPU-monitor unit test** — monitor reads a fixture ISV slice, asserts value + diagnose fields.
2. **Write failing GPU-kernel smoke test** — launch kernel with known ISV inputs, read back, assert expected output.
3. **Implement kernel** in `crates/ml/src/cuda_pipeline/<name>_update_kernel.cu`. Reads ISV slots, writes to designated output slot.
4. **Wire kernel launch** in `fused_training.rs::process_epoch_boundary` (cold path, per-epoch).
5. **Implement monitor** in `crates/ml/src/trainers/dqn/monitors/<name>_monitor.rs`. Read-only.
6. **Delete old CPU-compute code** (the pre-revert leftover, if any).
7. **Run tests** — expect PASS.
8. **Audit doc + isv-slots.md** — add rows for new slots + new monitor.
9. **Commit.**

### Task 9: atoms kernel + monitor

- New kernel: `cuda_pipeline/atoms_update_kernel.cu`. Reads ISV v-range slots (`V_CENTER_*`, `V_HALF_*`); writes atom positions to device buffer (existing) + optional summary slot.
- New monitor: `trainers/dqn/monitors/atoms_monitor.rs`. Reads atom-summary ISV slots; emits diagnostic.
- Old CPU code deleted: `fused.recompute_atom_positions()` CPU path and `warm_start_atom_positions()` CPU path.

### Task 10: gamma kernel + monitor

- New ISV slot: `GAMMA_EFF_INDEX` (GPU-written).
- New kernel: `cuda_pipeline/gamma_update_kernel.cu`. Reads ISV Q-stats util signals; writes to `GAMMA_EFF_INDEX`.
- New monitor: `trainers/dqn/monitors/gamma_monitor.rs`.
- Old CPU code deleted: the `adaptive_gamma` update block in `training_loop.rs`.

### Task 11: kelly_cap kernel + monitor

- New ISV slot: `KELLY_CAP_EFF_INDEX`.
- New kernel: `cuda_pipeline/kelly_cap_update_kernel.cu`. Reads Kelly stats from ISV (via the already-ISV-exposed Kelly slot summary); writes to `KELLY_CAP_EFF_INDEX`.
- New monitor: `trainers/dqn/monitors/kelly_cap_monitor.rs`.
- Old CPU code deleted: Kelly f_mean / avg_win_ratio computation in `training_loop.rs`.

### Task 12: cql_alpha static ISV write (NO monitor, NO kernel)

- New ISV slot: `CQL_ALPHA_INDEX`.
- Constructor writes `hyperparams.cql_alpha` to `CQL_ALPHA_INDEX` once.
- CQL kernel launch reads from ISV slot instead of a config field.
- No monitor — value is static, identity read.
- Plan 3 B.3 will rewrite this into a kernel that updates `CQL_ALPHA_INDEX` from replay-seed-phase signals; at that point Task 12's "no kernel" becomes "GPU kernel". This plan stays static.

### Task 13: tau (Polyak EMA) kernel + monitor

- New ISV slots: `TAU_EFF_INDEX` (GPU-written), `EPOCH_IDX_INDEX` (CPU-written at epoch boundary), `TOTAL_EPOCHS_INDEX` (CPU-written at constructor).
- New kernel: `cuda_pipeline/tau_update_kernel.cu`. Reads `EPOCH_IDX_INDEX`, `TOTAL_EPOCHS_INDEX`, `LEARNING_HEALTH_INDEX`; computes cosine schedule + health floor; writes to `TAU_EFF_INDEX`.
- New monitor: `trainers/dqn/monitors/tau_monitor.rs`.
- `iqn.set_tau_host()` rewritten to read from `TAU_EFF_INDEX` (via pinned pointer — zero-copy).
- Old CPU code deleted: the cosine-schedule + `apply_health_coupled_tau_floor()` computation in `fused_training.rs`.

### Task 14: epsilon kernel + monitor

- New ISV slot: `EPSILON_EFF_INDEX` (GPU-written). Reuses `EPOCH_IDX_INDEX` / `TOTAL_EPOCHS_INDEX` from Task 13.
- New kernel: `cuda_pipeline/epsilon_update_kernel.cu`. Reads `EPOCH_IDX_INDEX`, `TOTAL_EPOCHS_INDEX`, and any volatility-reactive signal; writes to `EPSILON_EFF_INDEX`.
- New monitor: `trainers/dqn/monitors/epsilon_monitor.rs`.
- Old CPU code deleted: `base_floor * (0.5 + volatility)` block in `initialize_epoch_state`.

### Task 15: conviction_floor static ISV write (NO monitor, NO kernel)

- New ISV slot: `CONVICTION_FLOOR_INDEX`. Constructor writes the configured floor (typically 0.1). IQL kernel reads from `CONVICTION_FLOOR_INDEX`.
- No monitor.

### Task 16: plan_threshold static ISV write (NO monitor, NO kernel)

- New ISV slot: `PLAN_THRESHOLD_INDEX`. Constructor writes `0.5`. `backtest_plan_kernel.cu` and `experience_kernels.cu` read from `PLAN_THRESHOLD_INDEX`.
- No monitor. Plan 3 B.4 rewrites these kernels to read from `PLAN_PARAMS_0_EMA_INDEX` — transforming this slot to dynamic.

### Task 17: grad_balancer monitor only (kernel already exists)

- Kernel `grad_balance_isv_update` already GPU-driven — no kernel work.
- New monitor: `trainers/dqn/monitors/grad_balancer_monitor.rs`. Reads `GRAD_NORM_TARGET_*` and `GRAD_SCALE_LIMIT_INDEX`.

### Per-task-batch smoke validation

After each task's commit:

```bash
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo check -p ml 2>&1 | tail -3
SQLX_OFFLINE=true CARGO_INCREMENTAL=0 cargo test -p ml --lib -- monitors:: adaptive_monitor 2>&1 | tail -15
```

### End-of-block scaffolding removal

Task 17's commit also removes any shared CPU-side controller-dispatcher scaffolding, per §4.C.6's "Old scaffolding is removed in the SAME commit that migrates its last consumer". If the pre-revert `controllers/` module directory contains stale files, they are removed here. The final layout is:

- `crates/ml/src/trainers/dqn/adaptive_monitor.rs` — trait + harness.
- `crates/ml/src/trainers/dqn/monitors/` — 6 monitor impls (atoms, gamma, kelly_cap, tau, epsilon, grad_balancer).
- `crates/ml/src/cuda_pipeline/` — 5 new `*_update_kernel.cu` files (atoms, gamma, kelly_cap, tau, epsilon). `grad_balance_isv_update` kernel stays.
- ISV slots for 3 static configs (cql_alpha, conviction_floor, plan_threshold) written at constructor.


## Task 18: Plan 1 validation run

**Files:**
- None (runtime validation)

- [ ] **Step 18.1: Compile-check**

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

Expected: `Finished ... target(s) in Xs` — no new warnings beyond baseline.

- [ ] **Step 18.2: 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: every smoke test passes (magnitude_distribution, multi_fold_convergence, controller_activity, reward_component_audit, exploration_coverage, surrogate_noise_check).

- [ ] **Step 18.3: Push all Plan 1 commits to origin**

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

Expected: push succeeds. Plan 1 landing complete.

- [ ] **Step 18.4: Launch a short 3-epoch L40S training run to smoke-test the full pipeline**

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

Record the workflow name. Monitor for:
- No ERROR-level logs.
- HEALTH_DIAG emissions include new adaptive-controller diag fields.
- Per-epoch timing within 10% of the baseline (from train-bnz2r earlier this session).

- [ ] **Step 18.5: Commit "Plan 1 complete" note**

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

---

## Plan 1 exit criteria

Plan 1 is "done" when:

1. All 9 invariants hold across every commit (spot-checked via the pre-commit hook firing correctly).
2. `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`, `config/metric-bands.toml` all exist and are populated.
3. Every raw `ps[N]`, `plan_isv[N]`, `plan_params[N]` access has been replaced with a named constant.
4. `StateResetRegistry` is the sole fold-reset dispatch path.
5. `AdaptiveMonitor` trait has 6 concrete impls (atoms, gamma, kelly_cap, tau, epsilon, grad_balancer — all read-only observers of GPU-computed values). 3 static configs (cql_alpha, conviction_floor, plan_threshold) written to ISV at constructor. All adaptive computation happens in GPU kernels; CPU-side code never computes adaptive values (spec §4.C.6, 2026-04-24 revision).
6. ISV layout fingerprint at ISV[0] is populated at construct (via compile-time structural hash) and fail-fast-checked at checkpoint load. No `migrate_*` functions exist. No integer version sequence.
7. Hot-path purity audit has zero `MIGRATE` entries remaining.
8. All smoke tests pass.
9. A short 3-epoch L40S run produces the expected HEALTH_DIAG emissions and timing.

Plan 2 (Temporal core) begins only after Plan 1 passes exit criteria.

---

**End of Plan 1.**

---

## Plan 1 implementation complete (2026-04-24)

All Tasks 8-17 implemented across the following commits:

- `cf36091ee` — spec+plan: GPU drives, CPU reads (§4.C.6 revision)
- `0357c0391` — revert: Batch A+B CPU-compute controller migrations
- `6cd00ab02` — Task 8: AdaptiveMonitor trait replaces AdaptiveController
- `ac9bcab94` — Tasks 12, 15, 16: static ISV writes (cql_alpha, conviction_floor, plan_threshold)
- `4a48e99a4` — Task 17: grad_balancer CPU monitor
- `1032be389` — Task 13: tau_update GPU kernel + TauMonitor + delete CPU cosine-tau path
- `d346e03d4` — Task 14: epsilon_update GPU kernel + EpsilonMonitor + delete CPU epsilon path
- `c1b6848c2` — Task 10: gamma_update GPU kernel + GammaMonitor + delete adaptive_gamma field
- `1e90f74c8` — Task 11: kelly_cap_update GPU kernel + KellyCapMonitor + portfolio_states_dev_ptr
- `048c78d5f` — Task 9: atoms_update GPU kernel + AtomsMonitor + delete recompute/warm_start_atom_positions

Exit criteria status (1-7 met; 8-9 require GPU validation run):
1. All 9 invariants: pre-commit hooks pass on each commit.
2. All 6 audit docs present and populated.
3. Named constants: all ISV slots use pub const names; PS slots via state_layout.cuh macros.
4. StateResetRegistry: sole fold-reset dispatch path; all entries updated.
5. AdaptiveMonitor: 6 impls (atoms, gamma, kelly_cap, tau, epsilon, grad_balancer). 3 static configs (cql_alpha, conviction_floor, plan_threshold) at constructor. All adaptive computation GPU-side.
6. ISV fingerprint: LAYOUT_FINGERPRINT_CURRENT populated at construct; fail-fast on load mismatch.
7. Hot-path audit: zero MIGRATE entries.
8. Smoke tests: pending GPU validation run (Task 18).
9. L40S 3-epoch run: pending deploy (Task 18).
