# CRT.train Intervention B (MTER) 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:** Replace intervention A's training-time output-smoothness regularizer with a structural Multi-Timescale EMA Readout (MTER) that delivers inference-time horizon stratification by giving each horizon its own EMA-smoothed view of the trunk's hidden state, paired with stateful CfC + stateful MTER + sequential loader so training and inference share the same continuous-trajectory geometry.

**Architecture:** After the trunk's CfC step produces `trunk_h [B, 128]`, insert MTER which produces `h_view [B, 5, 128]` via per-horizon EMAs with τ[h] = K_h = [30, 100, 300, 1000, 6000]. Heads now read `concat(h_view[h], trunk_h)` (256-d input) instead of a single shared trunk vector. CfC's h_old carries continuously across sequences within a file (attn_pool used only at file boundaries). MTER's h_view persists across sequences AND epochs (sentinel-bootstrap on construction + file boundaries only). Loader serves sequences in temporal order (sequential mode). B=1 enforced. Same kernel runs training + inference; training distribution matches inference distribution. Smoothness machinery deleted in the final commit.

**Tech Stack:** Rust 1.85+, cudarc 0.19, CUDA 12.4 (sm_89 L40S, sm_80 local), tokio for trainer driving, pre-compiled cubins via build.rs, anyhow, clap, tracing, serde_json for the diagnostic JSONL.

**Predecessor spec:** `docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md` (v5)

---

## File Structure (locked-in decisions)

### NEW files

| File | Responsibility |
|------|---------------|
| `crates/ml-alpha/cuda/mter_readout.cu` | MTER forward + backward kernels; per-horizon EMA + grad propagation |
| `crates/ml-alpha/cuda/mter_tau_update.cu` | Tiny optimizer kernel for τ_raw (used only in learnable mode) |
| `crates/ml-alpha/tests/mter_invariants.rs` | 7 invariant tests for MTER kernels (sentinel, equilibrium, timescale separation, grad finite-diff, λ interaction, stateful sequences, stateful CfC) |
| `crates/ml-alpha/tests/forward_step_mter_symmetry.rs` | Training-time `step_batched` vs inference-time `forward_step_into` numerical symmetry check |

### MODIFIED files

| File | Change |
|------|--------|
| `crates/ml-alpha/src/data/loader.rs` | `LoaderMode::Sequential` enum variant + sequential cursor + `is_file_boundary()` |
| `crates/ml-alpha/cuda/multi_horizon_heads.cu` | Heads kernels accept per-horizon `h_view [B, 5, 128]` + `trunk_h [B, 128]` (256-d concat per horizon) |
| `crates/ml-alpha/cuda/gpu_log_ids.h` | `KID_MTER_READOUT` reclaims slot 0; smoothness IDs removed |
| `crates/ml-alpha/build.rs` | Register `mter_readout`, `mter_tau_update`; deregister `output_smoothness`, `smoothness_lambda_controller`; cache-bust v15 |
| `crates/ml-alpha/src/heads.rs` | `TAU_INIT_PER_HORIZON`, `HEAD_INPUT_DIM`, `MTerTauMode` enum; remove `SMOOTHNESS_LAMBDA_RATIO` |
| `crates/ml-alpha/src/trainer/perception.rs` | New MTER state buffers (train + val + inference); MTER fwd/bwd kernel calls; stateful CfC; B=1 enforcement; post-warmup AUC; `reset_for_new_fold()`; `reset_inference_state()`; remove smoothness wiring |
| `crates/ml-alpha/src/gpu_log.rs` | Replace smoothness decoders with MTER decoders |
| `crates/ml-alpha/examples/alpha_train.rs` | New CLI flags: `--loader-mode`, `--mter-tau-mode`, `--mter-tau-init`; drop `--smoothness-base-lambda` |
| `infra/k8s/argo/alpha-perception-template.yaml` | New params: `loader-mode`, `mter-tau-mode`, `mter-tau-init`; drop `smoothness-base-lambda` |

### DELETED files (in commit 8)

| File | Reason |
|------|--------|
| `crates/ml-alpha/cuda/output_smoothness.cu` | Superseded by MTER |
| `crates/ml-alpha/cuda/smoothness_lambda_controller.cu` | Superseded by MTER |
| `crates/ml-alpha/tests/output_smoothness_grad_finite_diff.rs` | Tests for deleted kernel |
| `crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs` | Tests for deleted kernel |

### REGENERATED test goldens (during commits 3 + 7)

| Test | Reason |
|------|--------|
| `crates/ml-alpha/tests/heads_bit_equiv.rs` | Heads kernel input shape changes |
| `crates/ml-alpha/tests/forward_step_golden.rs` | Inference path adds MTER kernel |
| `crates/ml-alpha/tests/perception_forward_golden.rs` | Training forward chain adds MTER |
| `crates/ml-alpha/tests/forward_graph_capture.rs` | Captured-inference graph topology changes |

---

## Task 1: Loader sequential mode + commit-1 smoke validation

**Files:**
- Modify: `crates/ml-alpha/src/data/loader.rs`
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (only the attn_pool-reset-skip when sequential + not file-boundary)
- Modify: `crates/ml-alpha/examples/alpha_train.rs` (new `--loader-mode` flag)
- Modify: `infra/k8s/argo/alpha-perception-template.yaml` (new `loader-mode` workflow param)

- [ ] **Step 1.1: Add `LoaderMode` enum**

Open `crates/ml-alpha/src/data/loader.rs` and add at the top, after the existing imports:

```rust
/// Sequence-sampling mode for the loader.
///
/// - `Random`: original behaviour. `next_sequence()` picks a random file and random anchor.
/// - `Sequential`: each file is consumed as consecutive K-position chunks in temporal order.
///   Required for MTER (per `docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md`)
///   so that training-time h_view distribution matches inference-time geometry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LoaderMode {
    Random,
    Sequential,
}

impl Default for LoaderMode {
    fn default() -> Self {
        Self::Random
    }
}

impl std::str::FromStr for LoaderMode {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "random" => Ok(Self::Random),
            "sequential" => Ok(Self::Sequential),
            other => Err(format!("unknown loader mode: {other} (expected 'random' or 'sequential')")),
        }
    }
}
```

- [ ] **Step 1.2: Add `mode` field to `MultiHorizonLoaderConfig`**

In the existing `MultiHorizonLoaderConfig` struct, add the field at the END:

```rust
    /// Sequence-sampling mode. Default `Random` for backward compat.
    /// MTER training requires `Sequential`.
    pub mode: LoaderMode,
```

In any places where `MultiHorizonLoaderConfig` is constructed (search via `grep -n "MultiHorizonLoaderConfig {" crates/ml-alpha/`), add `mode: LoaderMode::default(),` to preserve existing behavior. The fixup is mechanical; per `feedback_no_partial_refactor` we ship this in the same commit.

- [ ] **Step 1.3: Add sequential-mode state to the `MultiHorizonLoader` struct**

In the `MultiHorizonLoader` struct (find via grep), add these fields:

```rust
    /// Sequential-mode cursor: current file index in `files_loaded`.
    sequential_file_idx: usize,
    /// Sequential-mode cursor: current anchor position WITHIN the current file.
    sequential_anchor_idx: usize,
    /// `true` if the most recent `next_sequence()` call crossed a file boundary.
    /// Cleared on each `next_sequence()` call; consulted by trainer via `is_file_boundary()`.
    last_call_was_file_boundary: bool,
```

In the constructor `MultiHorizonLoader::new`, initialise:

```rust
            sequential_file_idx: 0,
            sequential_anchor_idx: 0,
            last_call_was_file_boundary: false,
```

- [ ] **Step 1.4: Implement `next_sequence_sequential`**

Add a new method on `MultiHorizonLoader`:

```rust
    fn next_sequence_sequential(&mut self) -> Result<Option<LabeledSequence>> {
        if self.yielded >= self.cfg.n_max_sequences {
            return Ok(None);
        }
        let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
        let needed = self.cfg.seq_len + max_horizon;

        // Advance cursor: find the next file that has enough remaining snapshots
        // from the current anchor.
        let mut file_boundary = false;
        loop {
            if self.sequential_file_idx >= self.files_loaded.len() {
                // Wrap around to the start of the file list. Treat the wrap as a file boundary
                // so the trainer re-bootstraps state.
                self.sequential_file_idx = 0;
                self.sequential_anchor_idx = 0;
                file_boundary = true;
            }
            let lf = &self.files_loaded[self.sequential_file_idx];
            if self.sequential_anchor_idx + needed <= lf.snapshots.len() {
                break;
            }
            // This file has no more room; advance.
            self.sequential_file_idx += 1;
            self.sequential_anchor_idx = 0;
            file_boundary = true;
        }
        self.last_call_was_file_boundary = file_boundary;

        let lf = &self.files_loaded[self.sequential_file_idx];
        let anchor = self.sequential_anchor_idx;

        // Build the labelled sequence (same logic as `next_sequence`).
        let mut labels: [Vec<f32>; 5] = Default::default();
        for h in 0..5 {
            let mut row = Vec::with_capacity(self.cfg.seq_len);
            for k in 0..self.cfg.seq_len {
                row.push(lf.labels_full[h][anchor + k]);
            }
            labels[h] = row;
        }
        let mut sequence = Vec::with_capacity(self.cfg.seq_len);
        for k in 0..self.cfg.seq_len {
            let idx = anchor + k;
            let cur = &lf.snapshots[idx];
            let prev_idx = if idx == 0 { 0 } else { idx - 1 };
            let prev = &lf.snapshots[prev_idx];
            sequence.push(convert(cur, prev, lf.regime_full[idx]));
        }

        // Advance cursor by seq_len (no overlap between sequences).
        self.sequential_anchor_idx += self.cfg.seq_len;
        self.yielded += 1;
        Ok(Some(LabeledSequence { snapshots: sequence, labels }))
    }
```

- [ ] **Step 1.5: Route `next_sequence` through mode dispatch**

Modify the existing `pub fn next_sequence(&mut self) -> Result<Option<LabeledSequence>>` to dispatch:

```rust
    pub fn next_sequence(&mut self) -> Result<Option<LabeledSequence>> {
        match self.cfg.mode {
            LoaderMode::Random => self.next_sequence_random(),
            LoaderMode::Sequential => self.next_sequence_sequential(),
        }
    }

    /// `true` if the most recent `next_sequence()` call crossed a file boundary.
    /// Trainer consults this to decide whether to re-bootstrap stateful buffers.
    /// Always returns `false` in `Random` mode (sequence boundaries are random anchors,
    /// not temporal jumps — but the trainer's state-management discipline treats every
    /// sequence as a fresh boundary in Random mode anyway).
    pub fn is_file_boundary(&self) -> bool {
        self.last_call_was_file_boundary
    }
```

Rename the existing implementation of `next_sequence()` to `next_sequence_random(&mut self) -> Result<Option<LabeledSequence>>` and keep its body unchanged.

- [ ] **Step 1.6: Add CLI flag in `alpha_train.rs`**

In `crates/ml-alpha/examples/alpha_train.rs`, find the `Cli` struct (search for `#[derive(Parser)]`). Add a new field after `cv_train_window`:

```rust
    /// Sequence-sampling mode. `random` = legacy; `sequential` = required for MTER (CRT.train intervention B).
    /// See `docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md`.
    #[arg(long, default_value = "random")]
    loader_mode: String,
```

In the `main()` function, parse it and thread into the loader config (search for where `MultiHorizonLoaderConfig { ... }` is constructed):

```rust
    let loader_mode: LoaderMode = cli.loader_mode.parse()
        .with_context(|| format!("invalid --loader-mode={}", cli.loader_mode))?;
```

Pass `mode: loader_mode` to the train + val `MultiHorizonLoaderConfig` constructors.

Also add at the top of `alpha_train.rs`:

```rust
use ml_alpha::data::loader::LoaderMode;
```

- [ ] **Step 1.7: Skip attn_pool reset at non-file-boundary sequence boundaries (sequential mode)**

In `crates/ml-alpha/src/trainer/perception.rs`, find where `attn_context_d` is recomputed each `step_batched` call (search for `attention_pool` or `attn_q`). Add a guard:

```rust
        let need_attn_pool_reset = match self.cfg.loader_mode {
            LoaderMode::Random => true,
            LoaderMode::Sequential => self.last_seen_file_boundary,  // see Step 1.8
        };
        if need_attn_pool_reset {
            // ... existing attention_pool launch ...
        }
        // else: keep existing self.attn_context_d (warm-started from previous sequence's CfC h_new at k=K-1)
```

This requires the trainer to know whether the loader's last call was a file boundary. See Step 1.8.

- [ ] **Step 1.8: Add `loader_mode` + `last_seen_file_boundary` fields to `PerceptionTrainerConfig` + `PerceptionTrainer`**

In `PerceptionTrainerConfig`:

```rust
    /// Sequence-sampling mode. Affects whether attn_pool resets at every sequence
    /// (Random) or only at file boundaries (Sequential).
    pub loader_mode: LoaderMode,
```

In `Default::default()`:

```rust
            loader_mode: LoaderMode::Random,
```

In `PerceptionTrainer` struct:

```rust
    /// Set by the caller via `notify_file_boundary()` BEFORE each `step_batched` call
    /// when in sequential mode. Trainer uses this to gate attn_pool reset (and later,
    /// stateful CfC + MTER bootstrap). Always false in Random mode (all sequences treated as boundaries).
    last_seen_file_boundary: bool,
```

Add the method:

```rust
    /// Trainer-orchestrator API. Call this BEFORE `step_batched()` to signal whether
    /// the sequence we're about to consume crosses a file boundary in sequential mode.
    /// Loaders provide this via `MultiHorizonLoader::is_file_boundary()`.
    pub fn notify_file_boundary(&mut self, is_boundary: bool) {
        self.last_seen_file_boundary = is_boundary;
    }
```

Initialise `last_seen_file_boundary: true` at trainer construction so the first sequence is treated as a boundary (attn_pool runs).

- [ ] **Step 1.9: Use `notify_file_boundary` in `alpha_train.rs` training loop**

In the training loop in `alpha_train.rs`, before each `trainer.step(...)` call, set:

```rust
    let is_boundary = train_loader.is_file_boundary();
    trainer.notify_file_boundary(is_boundary);
```

In Random mode, `is_file_boundary()` always returns false. The trainer must internally default to "every sequence is a boundary" in Random mode. Update the gate in Step 1.7 to:

```rust
        let need_attn_pool_reset = match self.cfg.loader_mode {
            LoaderMode::Random => true,
            LoaderMode::Sequential => self.last_seen_file_boundary,
        };
```

- [ ] **Step 1.10: Add Argo workflow parameter**

In `infra/k8s/argo/alpha-perception-template.yaml`, add to the `parameters:` block (after `cv-train-window`):

```yaml
      - name: loader-mode
        value: "random"
```

In the train-step's `args:` block (the bash heredoc), add immediately before the existing `--cv-train-window` line:

```bash
              --loader-mode      "{{workflow.parameters.loader-mode}}" \
```

- [ ] **Step 1.11: Build + cargo check**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo check -p ml-alpha --all-targets 2>&1 | grep -E "^error" | head
SQLX_OFFLINE=true cargo build --release -p ml-alpha --example alpha_train 2>&1 | tail -3
./target/release/examples/alpha_train --help 2>&1 | grep -A2 loader-mode
```

Expected: clean check; clean build; `--loader-mode <LOADER_MODE>` appears with `[default: random]`.

- [ ] **Step 1.12: Commit loader change**

```bash
git add crates/ml-alpha/src/data/loader.rs \
        crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/examples/alpha_train.rs \
        infra/k8s/argo/alpha-perception-template.yaml
git commit -m "$(cat <<'EOF'
feat(intervention-b): add LoaderMode::Sequential + attn_pool gate

Loader can now serve sequences in temporal order (Sequential mode);
when active, the trainer skips attn_pool reset at non-file-boundary
sequence boundaries (next commit will use this for stateful CfC).

Random mode preserved as default; no behavioral change for existing
runs. Phased commit 1/8 of intervention B per
docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md
EOF
)"
```

- [ ] **Step 1.13: Update cluster Argo template + push**

```bash
kubectl apply -f infra/k8s/argo/alpha-perception-template.yaml 2>&1 | tail -2
argo template get -n foxhunt alpha-perception -o yaml 2>&1 | grep "name: loader-mode" | head -1
git push origin ml-alpha-phase-a 2>&1 | tail -3
```

Expected: `workflowtemplate.argoproj.io/alpha-perception configured`; `loader-mode` parameter present.

- [ ] **Step 1.14: COMMIT-1 SMOKE — sequential-vs-random trunk validation**

This is the embedded pre-validation gate per spec §9.1. Submit a sequential-mode retrain at the SAME commit, default everything else:

```bash
SHA=$(git rev-parse HEAD)
argo submit -n foxhunt --from=wftmpl/alpha-perception \
  -p commit-sha=$SHA \
  -p git-branch=ml-alpha-phase-a \
  -p gpu-pool=ci-training-l40s \
  -p loader-mode=sequential \
  -p n-train-seqs=8000 \
  -p n-val-seqs=1000 \
  -p epochs=12
```

Wait for completion (~25 min). Inspect `alpha_train_summary.json` from the run (pull via fc-debug pod or argo logs).

**Gate**: each `final_val_auc[h]` must be within `lnfwd_baseline_AUC[h] - 0.02`. The lnfwd random-mode baseline for h6000 is 0.7157. PASS = h6000 sequential ≥ 0.6957. FAIL = h6000 sequential < 0.6957.

- **PASS**: trunk training is robust to sequential sampling. **Proceed to Task 2.**
- **FAIL**: trunk training depends on random sampling. **STOP.** Diagnose via JSONL trace (which we still get from kernel-step-trace if enabled). Alternative path: per-horizon CfC branches with random sampling. Write a new spec.

Record the outcome in `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/project_mter_commit1_smoke_outcome.md` (project-type memory).

---

## Task 2: MTER forward kernel + Rust wiring + invariant tests 1-3

**Files:**
- Create: `crates/ml-alpha/cuda/mter_readout.cu` (forward only this commit)
- Modify: `crates/ml-alpha/cuda/gpu_log_ids.h` (add `KID_MTER_READOUT` as a NEW slot 7; defer slot-0 reclaim to commit 8 to avoid breaking smoothness wiring mid-flight)
- Modify: `crates/ml-alpha/build.rs` (register new kernel; cache-bust v15)
- Modify: `crates/ml-alpha/src/heads.rs` (`TAU_INIT_PER_HORIZON`, `HEAD_INPUT_DIM`, `MTerTauMode`)
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (new MTER state buffers + forward kernel launch)
- Create: `crates/ml-alpha/tests/mter_invariants.rs` (tests 1-3)

- [ ] **Step 2.1: Create the MTER kernel file (forward only)**

Create `crates/ml-alpha/cuda/mter_readout.cu`:

```cuda
// mter_readout.cu — Multi-Timescale EMA Readout kernels.
//
// Forward: per-(batch, horizon) EMA update of trunk_h into h_view[h].
//   α[h] = 1 / τ[h]
//   h_view[b, h, :] = (1 - α[h]) · h_view_prev[b, h, :] + α[h] · trunk_h[b, :]
// Sentinel bootstrap: first_obs[b, h] == 0 → h_view[b, h, :] := trunk_h[b, :].
//
// Backward: chain rule from heads' d_h_view to d_trunk_h + d_h_view_carry (+ d_tau in
// learnable mode; deferred to commit 4).
//
// Per `feedback_no_atomicadd`: grid is (B, N_HORIZONS, 1); each block is sole writer of
// its `[b, h, :]` slice. No atomicAdd; no warp-shuffle needed (single block per slice).
// Per `feedback_no_htod_htoh_only_mapped_pinned`: τ_raw uploaded once via MappedF32Buffer.
// Per `pearl_no_host_branches_in_captured_graph`: branching is per-thread sentinel only.

#define MTER_HIDDEN_DIM   128
#define MTER_N_HORIZONS   5
#define MTER_TAU_MIN      1.0f

extern "C" __global__ void mter_forward_batched(
    const float* __restrict__ trunk_h,        // [B, HIDDEN_DIM]
    const float* __restrict__ tau_raw,        // [N_HORIZONS]
    float*       __restrict__ h_view_state,    // [B, N_HORIZONS, HIDDEN_DIM] in/out
    int*         __restrict__ first_obs,       // [B, N_HORIZONS] in/out
    int n_batch,
    float*       __restrict__ h_view_out       // [B, N_HORIZONS, HIDDEN_DIM] this step
) {
    const int b = blockIdx.x;
    const int h = blockIdx.y;
    const int i = threadIdx.x;
    if (b >= n_batch || h >= MTER_N_HORIZONS || i >= MTER_HIDDEN_DIM) return;

    // Compute α[h] = 1 / τ[h]. τ[h] = softplus(τ_raw[h]) + TAU_MIN.
    // Read τ_raw[h] once per block via thread 0 + shared mem, OR each thread reads
    // (HIDDEN_DIM threads × 1 read = 128 redundant reads; coalesced + cached, trivial).
    const float tau_raw_h = tau_raw[h];
    // softplus(x) = log1p(exp(x))
    const float softplus = log1pf(expf(tau_raw_h));
    const float tau = softplus + MTER_TAU_MIN;
    const float alpha = 1.0f / tau;

    const long long state_idx = ((long long)b * MTER_N_HORIZONS + h) * MTER_HIDDEN_DIM + i;
    const long long trunk_idx = (long long)b * MTER_HIDDEN_DIM + i;

    const float trunk_val = trunk_h[trunk_idx];
    float new_val;

    // Sentinel: read first_obs[b, h] once per block via thread 0 + shared mem.
    // Cheap to read per-thread; coalesced.
    const int sentinel = first_obs[(long long)b * MTER_N_HORIZONS + h];
    if (sentinel == 0) {
        new_val = trunk_val;
    } else {
        const float prev_val = h_view_state[state_idx];
        new_val = (1.0f - alpha) * prev_val + alpha * trunk_val;
    }

    h_view_state[state_idx] = new_val;
    h_view_out[state_idx] = new_val;

    // Thread 0 flips the sentinel (single writer per [b, h]).
    if (i == 0 && sentinel == 0) {
        first_obs[(long long)b * MTER_N_HORIZONS + h] = 1;
    }
}
```

- [ ] **Step 2.2: Register kernel in build.rs + bump cache-bust**

In `crates/ml-alpha/build.rs`, find the `KERNELS` array. Add `"mter_readout"` after `"smoothness_lambda_controller"`:

```rust
    "smoothness_lambda_controller",  // CRT.train: ISV-driven λ controller anchored on h30 jitter
    "mter_readout",                  // CRT.train B: multi-timescale EMA readout (per-horizon h_view)
];
```

Replace the existing cache-bust comment with:

```rust
// Cache bust v15 (2026-05-21): mter_readout.cu added — intervention B (multi-timescale EMA readout).
```

- [ ] **Step 2.3: Verify cubin compiles**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo build -p ml-alpha 2>&1 | grep -E "mter_readout|^error" | head
```

Expected: `compiled cuda/mter_readout.cu -> .../mter_readout.cubin (sm_<cap>)`. No errors.

- [ ] **Step 2.4: Add constants in `heads.rs`**

In `crates/ml-alpha/src/heads.rs`, after the existing horizon constants:

```rust
/// Per-horizon EMA time constant (events) for MTER readout (CRT.train intervention B).
/// τ[h] = K_h: the integration timescale matches the prediction horizon. With stateful
/// training, h_view equilibrates over the full epoch (~3M events at 12 epochs × 8000
/// seqs × 32 events); τ_max = 6000 is reachable.
///
/// See `docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md`.
pub const TAU_INIT_PER_HORIZON: [f32; N_HORIZONS] = [30.0, 100.0, 300.0, 1000.0, 6000.0];

/// Heads input dim = `HIDDEN_DIM + HIDDEN_DIM` (concat of `h_view[h]` and `trunk_h`).
pub const HEAD_INPUT_DIM: usize = HIDDEN_DIM + HIDDEN_DIM;  // 256

/// τ parameterization mode. `Frozen` = τ_raw is read-only after construction (default
/// for safety: BCE loss has no pressure favoring slow EMAs, so a learnable τ collapses
/// toward TAU_MIN). `Learnable` = τ_raw is updated by `mter_tau_update.cu`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MTerTauMode {
    Frozen,
    Learnable,
}

impl Default for MTerTauMode {
    fn default() -> Self { Self::Frozen }
}

impl std::str::FromStr for MTerTauMode {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "frozen" => Ok(Self::Frozen),
            "learnable" => Ok(Self::Learnable),
            other => Err(format!("unknown mter-tau-mode: {other} (expected 'frozen' or 'learnable')")),
        }
    }
}
```

- [ ] **Step 2.5: Add MTER state buffers to `PerceptionTrainer`**

In `crates/ml-alpha/src/trainer/perception.rs`, near the existing smoothness fields (search for `smoothness_lambda_d`), add:

```rust
    // ─── MTER (CRT.train intervention B) state ───
    /// τ_raw [N_HORIZONS]. softplus(τ_raw) + TAU_MIN = effective τ[h].
    /// In Frozen mode: set once at construction, never updated.
    /// In Learnable mode: updated by `mter_tau_update.cu` outside captured graph (commit 4).
    mter_tau_raw_d: CudaSlice<f32>,
    /// MTER state buffer [B=1, N_HORIZONS, HIDDEN_DIM]. Persistent across K-loop,
    /// sequences (within file), AND epochs. Reset on construction + file boundary.
    mter_h_view_state_d: CudaSlice<f32>,
    /// Sentinel [B=1, N_HORIZONS] i32: 0 = next forward bootstraps, 1 = EMA update.
    mter_first_obs_d: CudaSlice<i32>,
    /// Per-K-position output buffer [K, B, N_HORIZONS, HIDDEN_DIM] for the heads kernel
    /// to consume. Allocated at construction; rewritten by each MTER forward call.
    mter_h_view_per_k_d: CudaSlice<f32>,

    /// MTER forward kernel function handle.
    mter_forward_fn: CudaFunction,
    _mter_module: Arc<CudaModule>,
```

- [ ] **Step 2.6: Add `MTerTauMode` + `loader_mode` to PerceptionTrainerConfig** (loader_mode was added in Task 1; ensure tau_mode is added here)

In `PerceptionTrainerConfig`:

```rust
    /// MTER τ mode. Default `Frozen`; `Learnable` is opt-in for post-WIN ablation.
    pub mter_tau_mode: MTerTauMode,
    /// Optional override of `TAU_INIT_PER_HORIZON`. Default `None` = use the constant.
    pub mter_tau_init: Option<[f32; N_HORIZONS]>,
```

In `Default::default()`:

```rust
            mter_tau_mode: MTerTauMode::default(),
            mter_tau_init: None,
```

- [ ] **Step 2.7: Allocate + initialise MTER state in trainer constructor**

In `PerceptionTrainer::new` (find by `grep -n "impl PerceptionTrainer" crates/ml-alpha/src/trainer/perception.rs`), after the existing smoothness-state allocations:

```rust
        // ── MTER (CRT.train intervention B) state allocation ──
        let mter_module = ctx.load_cubin(include_bytes!(concat!(env!("OUT_DIR"), "/mter_readout.cubin")).to_vec())
            .context("load mter_readout cubin")?;
        let mter_forward_fn = mter_module.load_function("mter_forward_batched")
            .context("load mter_forward_batched")?;

        // τ_raw initial values: invert softplus to recover the raw param from τ_init.
        // softplus(x) = log1p(exp(x)) → for τ_init = K_h, τ_raw[h] = log(exp(K_h - TAU_MIN) - 1).
        // For numerically safe init at large K_h, we use τ_raw[h] = K_h - TAU_MIN (since
        // softplus(x) ≈ x for x >> 1, this is accurate to <1e-6 for K_h ≥ 5).
        let tau_init_vec = cfg.mter_tau_init.unwrap_or(TAU_INIT_PER_HORIZON);
        let tau_raw_host: Vec<f32> = tau_init_vec.iter()
            .map(|&t| t - 1.0)  // softplus(x) + 1.0 → so τ_raw = τ - 1 for τ ≥ ~5
            .collect();
        let tau_raw_buf = unsafe { MappedF32Buffer::new(N_HORIZONS) }
            .map_err(|e| anyhow::anyhow!("mter_tau_raw staging: {e}"))?;
        tau_raw_buf.write_from_slice(&tau_raw_host);
        let mut mter_tau_raw_d = stream.alloc_zeros::<f32>(N_HORIZONS)
            .context("mter_tau_raw_d alloc")?;
        unsafe {
            let (dst_ptr, _g) = mter_tau_raw_d.device_ptr_mut(&stream);
            let nbytes = N_HORIZONS * std::mem::size_of::<f32>();
            cudarc::driver::result::memcpy_dtod_async(
                dst_ptr, tau_raw_buf.dev_ptr, nbytes, stream.cu_stream(),
            ).context("mter_tau_raw_d DtoD")?;
        }

        let mter_h_view_state_d = stream.alloc_zeros::<f32>(cfg.n_batch * N_HORIZONS * HIDDEN_DIM)
            .context("mter_h_view_state_d alloc")?;
        let mter_first_obs_d = stream.alloc_zeros::<i32>(cfg.n_batch * N_HORIZONS)
            .context("mter_first_obs_d alloc")?;
        let mter_h_view_per_k_d = stream.alloc_zeros::<f32>(cfg.seq_len * cfg.n_batch * N_HORIZONS * HIDDEN_DIM)
            .context("mter_h_view_per_k_d alloc")?;
```

In the constructor's final `Ok(Self { ... })` block, add (preserving comma style):

```rust
            mter_tau_raw_d,
            mter_h_view_state_d,
            mter_first_obs_d,
            mter_h_view_per_k_d,
            mter_forward_fn,
            _mter_module: mter_module,
```

Also import `TAU_INIT_PER_HORIZON, HEAD_INPUT_DIM, MTerTauMode` from `crate::heads`.

- [ ] **Step 2.8: Launch MTER forward in `step_batched` between CfC and heads**

In `step_batched` (inside the captured-graph region), find where the CfC kernel writes `h_new_per_k_d[k]` (search `cfc_step_batched`). Immediately AFTER the CfC launch for position k and BEFORE the heads launch for the same k, add:

```rust
            // ── MTER forward at position k ──
            // Reads h_new[k] (= trunk_h at this position), updates h_view_state in place,
            // writes per-K output for the heads kernel to consume.
            let trunk_h_k_ptr = h_new_per_k_d.device_ptr().offset(k * b_sz * HIDDEN_DIM * std::mem::size_of::<f32>() as i64);
            let h_view_out_k_ptr = mter_h_view_per_k_d.device_ptr().offset(k * b_sz * N_HORIZONS * HIDDEN_DIM * std::mem::size_of::<f32>() as i64);

            unsafe {
                self.stream.launch_builder(&self.mter_forward_fn)
                    .arg(&trunk_h_k_ptr)
                    .arg(&self.mter_tau_raw_d)
                    .arg(&mut self.mter_h_view_state_d)
                    .arg(&mut self.mter_first_obs_d)
                    .arg(&(b_sz as i32))
                    .arg(&mut h_view_out_k_ptr_mut)
                    .launch(LaunchConfig {
                        grid_dim: (b_sz as u32, N_HORIZONS as u32, 1),
                        block_dim: (HIDDEN_DIM as u32, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .context("mter_forward_batched launch")?;
            }
```

Note: this implementation will need adjustment to actual pointer arithmetic patterns in the file. The existing K-loop uses pointer offsets via `device_ptr().offset(...)`; use the same style. If `h_new_per_k_d` is indexed differently, mirror that.

- [ ] **Step 2.9: Add MTER state reset on construction + file boundary**

In the trainer constructor (after Step 2.7), set sentinel to 0 (alloc_zeros already does that). Add a helper method:

```rust
    /// Reset MTER state (sentinel-bootstrap on next forward + zero h_view).
    /// Called at trainer construction and at file boundaries in sequential mode.
    fn reset_mter_state(&mut self) -> Result<()> {
        // Zero h_view_state.
        self.stream.memset_zeros(&mut self.mter_h_view_state_d)
            .map_err(|e| anyhow::anyhow!("reset mter_h_view_state: {e}"))?;
        // Zero sentinel.
        self.stream.memset_zeros(&mut self.mter_first_obs_d)
            .map_err(|e| anyhow::anyhow!("reset mter_first_obs: {e}"))?;
        Ok(())
    }
```

In `step_batched`, BEFORE the forward chain, call `self.reset_mter_state()` if `self.last_seen_file_boundary` is true AND `self.cfg.loader_mode == LoaderMode::Sequential`. In Random mode, reset every step (matches existing per-sequence reset discipline).

- [ ] **Step 2.10: Create `mter_invariants.rs` with tests 1-3**

Create `crates/ml-alpha/tests/mter_invariants.rs`:

```rust
//! MTER kernel invariant tests (CRT.train intervention B).
//!
//! Per `feedback_no_cpu_test_fallbacks`: GPU oracle only. Each test computes the
//! expected result via the kernel's own forward pass at perturbed inputs (the
//! kernel is the reference; we only verify INVARIANTS hold).
//!
//! See `docs/superpowers/specs/2026-05-21-crt-train-intervention-b-multi-timescale-readout.md`
//! §9.2.

use std::sync::Arc;

use anyhow::{Context, Result};
use approx::assert_relative_eq;
use cudarc::driver::{CudaSlice, CudaStream, DevicePtr, DevicePtrMut, LaunchConfig, PushKernelArg};
use ml_alpha::heads::{HIDDEN_DIM, N_HORIZONS, TAU_INIT_PER_HORIZON};
use ml_alpha::pinned_mem::MappedF32Buffer;
use ml_core::device::MlDevice;

const CUBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/mter_readout.cubin"));

fn test_device() -> MlDevice {
    MlDevice::cuda(0).expect("CUDA 0 required for ml-alpha tests")
}

struct MTerOut {
    h_view_out: Vec<f32>,     // [B * N_HORIZONS * HIDDEN_DIM]
    h_view_state: Vec<f32>,    // [B * N_HORIZONS * HIDDEN_DIM]
    first_obs: Vec<i32>,       // [B * N_HORIZONS]
}

fn run_mter_forward(
    dev: &MlDevice,
    trunk_h: &[f32],            // [B, HIDDEN_DIM]
    h_view_state_in: &[f32],    // [B, N_HORIZONS, HIDDEN_DIM]
    first_obs_in: &[i32],       // [B, N_HORIZONS]
    tau_init: &[f32; N_HORIZONS],
    b: usize,
) -> Result<MTerOut> {
    let stream: &Arc<CudaStream> = dev.cuda_stream().context("stream")?;
    let ctx = dev.cuda_context().context("ctx")?;
    let module = ctx.load_cubin(CUBIN.to_vec()).context("load mter cubin")?;
    let func = module.load_function("mter_forward_batched").context("load fn")?;

    let tau_raw: Vec<f32> = tau_init.iter().map(|&t| t - 1.0).collect();
    let tau_d = upload(stream, &tau_raw)?;
    let trunk_h_d = upload(stream, trunk_h)?;
    let mut h_view_state_d = upload(stream, h_view_state_in)?;
    let mut first_obs_d = upload_i32(stream, first_obs_in)?;
    let mut h_view_out_d = stream.alloc_zeros::<f32>(b * N_HORIZONS * HIDDEN_DIM)
        .context("h_view_out alloc")?;
    let b_i = b as i32;

    let cfg = LaunchConfig {
        grid_dim: (b as u32, N_HORIZONS as u32, 1),
        block_dim: (HIDDEN_DIM as u32, 1, 1),
        shared_mem_bytes: 0,
    };
    let mut launch = stream.launch_builder(&func);
    launch
        .arg(&trunk_h_d).arg(&tau_d)
        .arg(&mut h_view_state_d).arg(&mut first_obs_d)
        .arg(&b_i)
        .arg(&mut h_view_out_d);
    unsafe { launch.launch(cfg).context("mter_forward launch")?; }
    stream.synchronize().context("sync")?;

    Ok(MTerOut {
        h_view_out: download(stream, &h_view_out_d)?,
        h_view_state: download(stream, &h_view_state_d)?,
        first_obs: download_i32(stream, &first_obs_d)?,
    })
}

fn upload(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
    let n = host.len();
    let staging = unsafe { MappedF32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("upload staging: {e}"))?;
    staging.write_from_slice(host);
    let mut dst = stream.alloc_zeros::<f32>(n).context("upload alloc")?;
    if n > 0 {
        let nbytes = n * std::mem::size_of::<f32>();
        unsafe {
            let (dst_ptr, _g) = dst.device_ptr_mut(stream);
            cudarc::driver::result::memcpy_dtod_async(dst_ptr, staging.dev_ptr, nbytes, stream.cu_stream())
                .context("upload DtoD")?;
        }
    }
    Ok(dst)
}

fn upload_i32(stream: &Arc<CudaStream>, host: &[i32]) -> Result<CudaSlice<i32>> {
    let n = host.len();
    let mut dst = stream.alloc_zeros::<i32>(n).context("i32 upload alloc")?;
    if n > 0 {
        dst = stream.memcpy_stod(&host.to_vec()).context("memcpy_stod i32")?;
    }
    Ok(dst)
}

fn download(stream: &Arc<CudaStream>, src: &CudaSlice<f32>) -> Result<Vec<f32>> {
    let n = src.len();
    let staging = unsafe { MappedF32Buffer::new(n) }
        .map_err(|e| anyhow::anyhow!("download staging: {e}"))?;
    let nbytes = n * std::mem::size_of::<f32>();
    unsafe {
        let (src_ptr, _g) = src.device_ptr(stream);
        cudarc::driver::result::memcpy_dtod_async(staging.dev_ptr, src_ptr, nbytes, stream.cu_stream())
            .context("download DtoD")?;
    }
    stream.synchronize().context("download sync")?;
    Ok(staging.read_all())
}

fn download_i32(stream: &Arc<CudaStream>, src: &CudaSlice<i32>) -> Result<Vec<i32>> {
    let host = stream.memcpy_dtov(src).context("memcpy_dtov i32")?;
    Ok(host)
}

// ── Test 1: Sentinel bootstrap ──
#[test]
fn mter_sentinel_bootstraps_to_trunk_h() {
    let dev = test_device();
    let b = 1;
    let trunk_h: Vec<f32> = (0..HIDDEN_DIM).map(|i| 0.1 + 0.01 * (i as f32)).collect();
    let h_view_state_in = vec![0.0_f32; b * N_HORIZONS * HIDDEN_DIM];   // arbitrary; should be overwritten
    let first_obs_in = vec![0_i32; b * N_HORIZONS];                      // all sentinel
    let out = run_mter_forward(&dev, &trunk_h, &h_view_state_in, &first_obs_in, &TAU_INIT_PER_HORIZON, b).unwrap();

    // Every horizon's h_view should equal trunk_h.
    for h in 0..N_HORIZONS {
        for i in 0..HIDDEN_DIM {
            let view_idx = h * HIDDEN_DIM + i;
            let trunk_val = trunk_h[i];
            assert_relative_eq!(out.h_view_out[view_idx], trunk_val, epsilon = 1e-6);
            assert_relative_eq!(out.h_view_state[view_idx], trunk_val, epsilon = 1e-6);
        }
        // Sentinel flipped to 1.
        assert_eq!(out.first_obs[h], 1, "sentinel for h={h} should flip to 1");
    }
}

// ── Test 2: EMA equilibrium ──
#[test]
fn mter_ema_equilibrates_under_constant_input() {
    let dev = test_device();
    let b = 1;
    let trunk_h: Vec<f32> = (0..HIDDEN_DIM).map(|i| 0.5 + 0.01 * (i as f32)).collect();
    let mut h_view_state = vec![0.0_f32; b * N_HORIZONS * HIDDEN_DIM];
    let mut first_obs = vec![0_i32; b * N_HORIZONS];

    // For each horizon, run for 10*τ steps with constant trunk_h. h_view should approach trunk_h.
    let n_steps = 10 * (*TAU_INIT_PER_HORIZON.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap() as usize);
    for _ in 0..n_steps.min(1000) {  // cap for test runtime; τ_max=6000 × 10 = 60000 too slow
        let out = run_mter_forward(&dev, &trunk_h, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
        h_view_state = out.h_view_state;
        first_obs = out.first_obs;
    }

    // For the fastest horizon (h30), expect convergence within 1e-3 after 1000 ≫ 10τ steps.
    let h = 0;
    for i in 0..HIDDEN_DIM {
        let view_idx = h * HIDDEN_DIM + i;
        assert!((h_view_state[view_idx] - trunk_h[i]).abs() < 1e-3,
                "h30 h_view[{i}] = {} did not converge to trunk_h[{i}] = {} after 1000 steps",
                h_view_state[view_idx], trunk_h[i]);
    }
    // For h6000, expect substantial but incomplete convergence (1000 steps / 6000 τ ≈ 15%
    // absorbed). State should be moving toward trunk_h but not equal yet.
    let h = 4;
    let mut sum_diff = 0.0_f32;
    for i in 0..HIDDEN_DIM {
        let view_idx = h * HIDDEN_DIM + i;
        sum_diff += (h_view_state[view_idx] - trunk_h[i]).abs();
    }
    let mean_diff = sum_diff / HIDDEN_DIM as f32;
    assert!(mean_diff < 0.5, "h6000 should be moving toward trunk_h but mean diff is {mean_diff}");
    assert!(mean_diff > 1e-4, "h6000 should NOT yet be fully equilibrated; mean diff = {mean_diff}");
}

// ── Test 3: Per-horizon timescale separation ──
#[test]
fn mter_horizons_settle_at_different_rates() {
    let dev = test_device();
    let b = 1;
    let trunk_h0: Vec<f32> = vec![0.5; HIDDEN_DIM];
    let trunk_h1: Vec<f32> = vec![1.5; HIDDEN_DIM];  // step-change input

    // Bootstrap with trunk_h0.
    let mut h_view_state = vec![0.0_f32; b * N_HORIZONS * HIDDEN_DIM];
    let mut first_obs = vec![0_i32; b * N_HORIZONS];
    let out = run_mter_forward(&dev, &trunk_h0, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
    h_view_state = out.h_view_state;
    first_obs = out.first_obs;

    // Apply trunk_h1 for 30 steps (= τ[h30]). Measure how close each horizon's h_view is to trunk_h1.
    for _ in 0..30 {
        let out = run_mter_forward(&dev, &trunk_h1, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
        h_view_state = out.h_view_state;
    }

    // h30 should be ~63% settled (1 - 1/e = 0.632) toward 1.5.
    let h = 0;
    let approx_h30 = h_view_state[h * HIDDEN_DIM];
    assert!(approx_h30 > 0.8 && approx_h30 < 1.3,
            "h30 after 30 steps should be ~63% settled toward 1.5; got {approx_h30}");

    // h6000 should be barely moved (30 / 6000 = 0.5% absorbed).
    let h = 4;
    let approx_h6000 = h_view_state[h * HIDDEN_DIM];
    assert!(approx_h6000 > 0.5 && approx_h6000 < 0.6,
            "h6000 after 30 steps should be barely moved from 0.5; got {approx_h6000}");
}
```

- [ ] **Step 2.11: Run invariant tests 1-3**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test mter_invariants -- --nocapture 2>&1 | tail -30
```

Expected: 3 tests pass on local CUDA.

- [ ] **Step 2.12: Commit MTER forward + invariant tests 1-3**

```bash
git add crates/ml-alpha/cuda/mter_readout.cu \
        crates/ml-alpha/build.rs \
        crates/ml-alpha/src/heads.rs \
        crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/tests/mter_invariants.rs
git commit -m "feat(intervention-b): add MTER forward kernel + invariant tests 1-3"
```

---

## Task 3: Heads kernel forward reshape + heads_bit_equiv golden regeneration

**Files:**
- Modify: `crates/ml-alpha/cuda/multi_horizon_heads.cu` (forward Pass 1 + Pass 3 reshape)
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (launch heads with new input shape)
- Regenerate: `crates/ml-alpha/tests/heads_bit_equiv.rs` goldens

- [ ] **Step 3.1: Reshape heads forward kernel inputs**

In `crates/ml-alpha/cuda/multi_horizon_heads.cu`, find `multi_horizon_heads_grn_fwd_batched`. The current signature takes `h [B, HIDDEN]`. Change to accept `h_view [B, 5, HIDDEN]` (per-horizon) AND `trunk_h [B, HIDDEN]` (the original trunk hidden state used for the skip-concat half).

Each head h now reads input as `concat(h_view[b, h, :HIDDEN], trunk_h[b, :HIDDEN])` of total dim `HIDDEN_DIM*2 = 256`.

The kernel's Pass 1 (w1 @ input + b1) must:
- Load 256 floats (128 from h_view, 128 from trunk_h) into shared
- w1 dim changes from `[N_HORIZONS, HEAD_MID, HIDDEN]` to `[N_HORIZONS, HEAD_MID, HIDDEN*2]`
- Inner loop bounds change from `HIDDEN` to `HIDDEN*2`

The kernel's Pass 3 (w_skip @ input + b_skip → skip scalar) must:
- w_skip dim changes from `[N_HORIZONS, HIDDEN]` to `[N_HORIZONS, HIDDEN*2]`
- Skip is now over the full concat input

Pass 2 (w2 @ eta_2 + b2) is unaffected (operates on HEAD_MID dim, not HIDDEN).

The implementer should adapt the kernel by:
1. Renaming the existing `h [B, HIDDEN]` arg to `trunk_h [B, HIDDEN]`
2. Adding a new arg `h_view [B, N_HORIZONS, HIDDEN]`
3. In Pass 1 + Pass 3, change inner loops to load h_view[k,b,i] for i < HIDDEN, then trunk_h[b, i-HIDDEN] for HIDDEN ≤ i < HIDDEN*2

This is a substantial rewrite. Refer to spec §3.8 for full details.

- [ ] **Step 3.2: Update heads forward launch in trainer**

In `crates/ml-alpha/src/trainer/perception.rs`, find the existing heads_grn_fwd_batched launch (search `multi_horizon_heads_grn_fwd_batched`). Update its arguments:

Before:
```rust
.arg(&self.heads_w1_d).arg(&self.heads_b1_d)
// ... other args ...
.arg(&h_new_per_k_d_ptr_at_k)
.arg(&b_sz_i)
.arg(&probs_per_k_d_ptr_at_k)
// ... saved activations ...
```

After:
```rust
.arg(&self.heads_w1_d).arg(&self.heads_b1_d)
// ... other args ...
.arg(&h_view_per_k_d_ptr_at_k)      // [B, N_HORIZONS, HIDDEN]
.arg(&trunk_h_per_k_d_ptr_at_k)      // [B, HIDDEN]
.arg(&b_sz_i)
.arg(&probs_per_k_d_ptr_at_k)
// ... saved activations ...
```

Where `h_view_per_k_d_ptr_at_k` is the pointer into `mter_h_view_per_k_d` at K-position k.

- [ ] **Step 3.3: Resize heads weight tensors**

In the trainer constructor, find `self.heads_w1_d` (search `heads_w1_d`). Its current shape is `[N_HORIZONS, HEAD_MID, HIDDEN_DIM]` = `5 * 64 * 128 = 40960`. Resize to `[N_HORIZONS, HEAD_MID, HIDDEN_DIM*2]` = `5 * 64 * 256 = 81920`. Same for `self.heads_w_skip_d` from `[N_HORIZONS, HIDDEN_DIM]` to `[N_HORIZONS, HIDDEN_DIM*2]`.

Re-initialise with Xavier (use the existing init function, just pass the new shapes). Search for the existing `xavier_uniform_init` calls.

- [ ] **Step 3.4: Regenerate heads_bit_equiv goldens**

Run the existing `tests/heads_bit_equiv.rs` test:

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test heads_bit_equiv -- --nocapture 2>&1 | tail -20
```

Expected: tests FAIL with golden mismatches (input shape changed).

Open `tests/heads_bit_equiv.rs`. Find where golden values are baked in. Manually update by:
1. Run a deterministic test call with debug printing of the new outputs
2. Replace the golden constants in the test with the new values
3. Re-run; tests now pass

Capture the new goldens via:

```bash
RUST_LOG=trace cargo test -p ml-alpha --test heads_bit_equiv -- --nocapture 2>&1 | grep -A3 "actual:" | head -30
```

Use that output to update the golden constants in the test file.

- [ ] **Step 3.5: Verify heads_bit_equiv passes**

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test heads_bit_equiv -- --nocapture 2>&1 | tail
```

Expected: all heads_bit_equiv tests pass.

- [ ] **Step 3.6: Commit heads kernel forward reshape**

```bash
git add crates/ml-alpha/cuda/multi_horizon_heads.cu \
        crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/tests/heads_bit_equiv.rs
git commit -m "feat(intervention-b): reshape heads forward for concat input + regen goldens"
```

---

## Task 4: MTER backward kernel + Rust wiring + invariant tests 4-7

**Files:**
- Modify: `crates/ml-alpha/cuda/mter_readout.cu` (add backward kernel)
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (backward kernel handle + launch)
- Modify: `crates/ml-alpha/tests/mter_invariants.rs` (add tests 4-7)

- [ ] **Step 4.1: Add backward kernel to mter_readout.cu**

Append to `crates/ml-alpha/cuda/mter_readout.cu`:

```cuda
// mter_backward_batched — chain rule from d_h_view to d_trunk_h + d_h_view_carry.
//
// Frozen mode: d_tau_raw is not computed (kernel signature has it for API consistency
// but kernel skips the per-tau accumulation if a sentinel flag is set; in Learnable mode
// the τ controller invokes a separate kernel pass that does write d_tau_raw).
//
// d_trunk_h[b, :] += (1/τ[h]) · d_h_view[b, h, :]                  (summed over h)
// d_h_view_carry[b, h, :] = (1 - 1/τ[h]) · d_h_view[b, h, :]
//
// Note: d_trunk_h is +=. Caller pre-zeroes if it wants a fresh accumulator.

extern "C" __global__ void mter_backward_batched(
    const float* __restrict__ tau_raw,            // [N_HORIZONS]
    const float* __restrict__ d_h_view,           // [B, N_HORIZONS, HIDDEN_DIM]
    int n_batch,
    float* __restrict__ d_trunk_h,                // [B, HIDDEN_DIM] (+=)
    float* __restrict__ d_h_view_carry            // [B, N_HORIZONS, HIDDEN_DIM] (overwrite)
) {
    const int b = blockIdx.x;
    const int h = blockIdx.y;
    const int i = threadIdx.x;
    if (b >= n_batch || h >= MTER_N_HORIZONS || i >= MTER_HIDDEN_DIM) return;

    const float tau_raw_h = tau_raw[h];
    const float softplus = log1pf(expf(tau_raw_h));
    const float tau = softplus + MTER_TAU_MIN;
    const float alpha = 1.0f / tau;

    const long long view_idx = ((long long)b * MTER_N_HORIZONS + h) * MTER_HIDDEN_DIM + i;
    const long long trunk_idx = (long long)b * MTER_HIDDEN_DIM + i;

    const float d_h_view_val = d_h_view[view_idx];

    // d_trunk_h += α · d_h_view (accumulated across horizons → use atomic-free
    // technique: each thread writes its own slice via single-writer per (b, i),
    // BUT this kernel has grid (B, N_HORIZONS, ...) — multiple horizons write to
    // same d_trunk_h[b, i] slot. We need a reduce.
    //
    // SOLUTION: launch this kernel for ONE horizon at a time, OR use a separate
    // reduce kernel. Simplest: this kernel writes per-(b, h) contribution to a
    // per-horizon scratch [B, N_HORIZONS, HIDDEN], then a reduce kernel sums to
    // d_trunk_h. Equivalent to the per-batch reduce pattern in heads_grn_bwd.
    //
    // For simplicity here: write a scratch and let a follow-up kernel reduce.
    // This is the SAME single-writer-per-slot pattern used by heads_grn_bwd's
    // per-batch grad scratches.
    //
    // For now, this kernel writes to d_trunk_h_per_horizon_scratch [B, N_HORIZONS, HIDDEN_DIM].
    // The trainer follow-up calls reduce_axis1 to collapse the N_HORIZONS axis.

    d_trunk_h[view_idx] = alpha * d_h_view_val;                     // OVERWRITE — caller summed externally
    d_h_view_carry[view_idx] = (1.0f - alpha) * d_h_view_val;
}
```

Note: `d_trunk_h` in this kernel signature is actually `d_trunk_h_per_horizon_scratch [B, N_HORIZONS, HIDDEN]`. The caller (trainer) reduces it to `d_trunk_h [B, HIDDEN]` via the existing `reduce_axis0`-style reduce kernel (adapted to reduce the N_HORIZONS axis).

- [ ] **Step 4.2: Add backward kernel handle in trainer**

In `crates/ml-alpha/src/trainer/perception.rs`, in the constructor after loading `mter_forward_fn`:

```rust
        let mter_backward_fn = mter_module.load_function("mter_backward_batched")
            .context("load mter_backward_batched")?;
```

Add field to struct:

```rust
    mter_backward_fn: CudaFunction,
    /// Scratch buffer [B, N_HORIZONS, HIDDEN_DIM] for per-horizon d_trunk_h contributions.
    /// Reduced to d_trunk_h via reduce kernel.
    mter_d_trunk_h_scratch_d: CudaSlice<f32>,
    /// Carry buffer [B, N_HORIZONS, HIDDEN_DIM] for d_h_view back-propagation across K-loop.
    mter_d_h_view_carry_d: CudaSlice<f32>,
```

Allocate in constructor:

```rust
        let mter_d_trunk_h_scratch_d = stream.alloc_zeros::<f32>(cfg.n_batch * N_HORIZONS * HIDDEN_DIM)
            .context("mter_d_trunk_h_scratch alloc")?;
        let mter_d_h_view_carry_d = stream.alloc_zeros::<f32>(cfg.n_batch * N_HORIZONS * HIDDEN_DIM)
            .context("mter_d_h_view_carry alloc")?;
```

Wire into `Ok(Self { ... })`.

- [ ] **Step 4.3: Add MTER backward call in `step_batched`'s backward chain**

In `step_batched`, find the existing backward K-loop (search `for k in (0..k_seq).rev()`). After the heads_grn_bwd kernel produces `d_h_view [B, N_HORIZONS, HIDDEN_DIM]` (we'll wire that in Task 5), invoke MTER backward:

```rust
                // ── MTER backward at position k ──
                // Consumes d_h_view [B, 5, HIDDEN], produces:
                //   - mter_d_trunk_h_scratch_d [B, 5, HIDDEN]  (per-horizon contribution to d_trunk_h)
                //   - mter_d_h_view_carry_d [B, 5, HIDDEN]     (carry to previous step's h_view)
                unsafe {
                    self.stream.launch_builder(&self.mter_backward_fn)
                        .arg(&self.mter_tau_raw_d)
                        .arg(&d_h_view_k_ptr)
                        .arg(&(b_sz as i32))
                        .arg(&mut self.mter_d_trunk_h_scratch_d)
                        .arg(&mut self.mter_d_h_view_carry_d)
                        .launch(LaunchConfig {
                            grid_dim: (b_sz as u32, N_HORIZONS as u32, 1),
                            block_dim: (HIDDEN_DIM as u32, 1, 1),
                            shared_mem_bytes: 0,
                        })
                        .context("mter_backward_batched launch")?;
                }

                // Reduce mter_d_trunk_h_scratch_d [B, 5, HIDDEN] → d_trunk_h [B, HIDDEN] (+=)
                // via reduce_axis1 kernel. The existing reduce_axis0 reduces along batch;
                // we need reduce along the horizon axis. If reduce_axis1 doesn't exist,
                // a 5-element sum per HIDDEN unit is cheap to do via a tiny kernel —
                // OR write the reduction inline in MTER backward (per-thread sum).
```

The reduction can be folded into MTER backward by having each thread compute the sum over horizons: change the kernel grid from `(B, N_HORIZONS, 1)` to `(B, 1, 1)` and the kernel does the per-horizon loop internally. This avoids a separate reduce kernel. Adjust per implementation detail.

- [ ] **Step 4.4: Add invariant tests 4-7 to mter_invariants.rs**

Append to `crates/ml-alpha/tests/mter_invariants.rs`:

```rust
// ── Test 4: Backward finite-diff ──
#[test]
fn mter_backward_matches_finite_difference() {
    let dev = test_device();
    let b = 1;
    let trunk_h: Vec<f32> = (0..HIDDEN_DIM).map(|i| 0.3 + 0.05 * (i as f32 % 7.0)).collect();
    let h_view_state_in: Vec<f32> = (0..b * N_HORIZONS * HIDDEN_DIM).map(|i| 0.4 + 0.01 * (i as f32 % 11.0)).collect();
    let first_obs_in = vec![1_i32; b * N_HORIZONS];   // not bootstrap; exercise the EMA path

    // Run forward.
    let out = run_mter_forward(&dev, &trunk_h, &h_view_state_in, &first_obs_in, &TAU_INIT_PER_HORIZON, b).unwrap();

    // Synthetic d_h_view (some loss derivative).
    let d_h_view: Vec<f32> = (0..b * N_HORIZONS * HIDDEN_DIM).map(|i| 0.01 * (i as f32 % 5.0 - 2.0)).collect();

    // Run backward (TODO: implement run_mter_backward helper similar to run_mter_forward).
    // Then verify:
    // (a) d_trunk_h[b, i] ≈ Σ_h (1/τ[h]) · d_h_view[b, h, i]
    // (b) d_h_view_carry[b, h, i] ≈ (1 - 1/τ[h]) · d_h_view[b, h, i]
    // (c) Finite-diff at a few probe indices: perturb trunk_h[b, i] by ±ε; recompute forward;
    //     observe d(scalar_loss)/d(trunk_h[b, i]) where scalar_loss = Σ d_h_view ⋅ h_view.

    // [Implementer: write the run_mter_backward helper + assertions per the math contract above.]
}

// ── Test 5: horizon_lambda × MTER ──
// (deferred to Task 5 since requires the full heads-MTER backward chain)

// ── Test 6: Stateful training across sequences ──
#[test]
fn mter_state_persists_across_K_loop_iterations() {
    let dev = test_device();
    let b = 1;
    let mut h_view_state = vec![0.0_f32; b * N_HORIZONS * HIDDEN_DIM];
    let mut first_obs = vec![0_i32; b * N_HORIZONS];

    // Simulate stateful training: 100 sequences of K=32 with the same trunk_h trajectory.
    // h_view should evolve continuously across the entire 3200 events.
    for seq_idx in 0..100 {
        for k in 0..32 {
            // trunk_h evolves slowly (e.g., sinusoidal at period 100 events)
            let event = seq_idx * 32 + k;
            let trunk_h: Vec<f32> = (0..HIDDEN_DIM).map(|i| {
                (0.5 + 0.1 * (event as f32 * 0.001).sin()) + 0.01 * (i as f32)
            }).collect();
            let out = run_mter_forward(&dev, &trunk_h, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
            h_view_state = out.h_view_state;
            first_obs = out.first_obs;
        }
    }
    // After 3200 events, h_view[h6000] should reflect a true ~3200-event EMA of trunk_h.
    // For sinusoidal input with period 1000 events, h_view[h6000] should be near the
    // sinusoidal mean (i.e., 0.5).
    let h = 4;
    let mean_h6000_view: f32 = (0..HIDDEN_DIM)
        .map(|i| h_view_state[h * HIDDEN_DIM + i])
        .sum::<f32>() / HIDDEN_DIM as f32;
    assert!((mean_h6000_view - 0.5).abs() < 0.05,
            "h6000 EMA over 3200 events of sinusoidal trunk_h should be near mean 0.5; got {mean_h6000_view}");
}

// ── Test 7: First-observation flag flips correctly ──
#[test]
fn mter_sentinel_flips_only_on_first_call() {
    let dev = test_device();
    let b = 1;
    let trunk_h = vec![0.7_f32; HIDDEN_DIM];
    let mut h_view_state = vec![0.0_f32; b * N_HORIZONS * HIDDEN_DIM];
    let mut first_obs = vec![0_i32; b * N_HORIZONS];

    let out = run_mter_forward(&dev, &trunk_h, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
    // After first call: all sentinels flipped.
    for h in 0..N_HORIZONS {
        assert_eq!(out.first_obs[h], 1);
    }
    h_view_state = out.h_view_state;
    first_obs = out.first_obs;

    // Second call: sentinels remain 1, EMA path engaged.
    let out2 = run_mter_forward(&dev, &trunk_h, &h_view_state, &first_obs, &TAU_INIT_PER_HORIZON, b).unwrap();
    for h in 0..N_HORIZONS {
        assert_eq!(out2.first_obs[h], 1);
    }
    // h_view should be unchanged (constant input → EMA at equilibrium).
    for i in 0..HIDDEN_DIM {
        assert_relative_eq!(out2.h_view_state[i], h_view_state[i], epsilon = 1e-6);
    }
}
```

- [ ] **Step 4.5: Run all invariant tests**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test mter_invariants -- --nocapture 2>&1 | tail -30
```

Expected: 6 of 7 tests pass (test 5 is gated until Task 5 wires the heads-MTER backward chain).

- [ ] **Step 4.6: Commit MTER backward + invariant tests 4 + 6 + 7**

```bash
git add crates/ml-alpha/cuda/mter_readout.cu \
        crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/tests/mter_invariants.rs
git commit -m "feat(intervention-b): add MTER backward kernel + invariant tests 4, 6, 7"
```

---

## Task 5: Heads kernel backward reshape + MTER backward integration + invariant test 5

**Files:**
- Modify: `crates/ml-alpha/cuda/multi_horizon_heads.cu` (backward Pass 5 + Pass 6 reshape)
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (heads backward output now per-horizon; wire into MTER backward)
- Modify: `crates/ml-alpha/tests/mter_invariants.rs` (add test 5)

- [ ] **Step 5.1: Reshape heads backward kernel outputs**

In `crates/ml-alpha/cuda/multi_horizon_heads.cu`, find `multi_horizon_heads_grn_bwd_batched`. Modify outputs:

- Old: `grad_h [B, HIDDEN_DIM]` (summed across horizons)
- New: `grad_h_view [B, N_HORIZONS, HIDDEN_DIM]` (per-horizon) + `grad_trunk_h_from_skip [B, HIDDEN_DIM]` (summed across horizons from the trunk_h half of the concat input)

In backward Pass 5 (per-horizon grad_w1):
- w1's input is now 256-d concat. Per-horizon-per-output-dim per-input-dim:
  - For input i < HIDDEN: contributes to `grad_w1[h, m, i]` via `d_z1[h, m] × h_view[b, h, i]`
  - For input i ≥ HIDDEN: contributes via `d_z1[h, m] × trunk_h[b, i-HIDDEN]`
- Same per-batch scratch pattern as before.

In backward Pass 6 (per-thread grad_h_view + grad_trunk_h):
- For each thread (column i ∈ [0, HIDDEN)):
  - For each horizon h: compute `grad_h_view[b, h, i] = sum_m d_z1[h, m] × w1[h, m, i]` + skip contribution from horizon h's `w_skip[h, i] × d_skip[h]`
  - Also accumulate `grad_trunk_h_from_skip[b, i] = Σ_h (w1[h, m, HIDDEN+i] × d_z1[h, m] terms + w_skip[h, HIDDEN+i] × d_skip[h])`
- The lambda factor is applied during Pass 6 to both grad_h_view AND grad_trunk_h_from_skip contributions

This is the largest kernel change in the plan. Refer to spec §3.9 for full specification of the backward chain.

- [ ] **Step 5.2: Wire heads backward output into MTER backward**

In `step_batched` backward loop, the new flow:

1. heads_grn_bwd writes `grad_h_view_per_k_d [K, B, N_HORIZONS, HIDDEN]` + `grad_trunk_h_from_skip_per_k_d [K, B, HIDDEN]`
2. MTER backward (already written in Task 4) consumes `grad_h_view` and writes per-horizon scratch + carry
3. Reduce: `d_trunk_h_total [B, HIDDEN] = sum_h(scratch[b, h, :]) + grad_trunk_h_from_skip[b, :]`
4. CfC backward consumes `d_trunk_h_total`

Allocate the new buffers (in constructor):

```rust
    /// [K, B, N_HORIZONS, HIDDEN_DIM] grad output from heads_grn_bwd, consumed by MTER backward.
    mter_grad_h_view_per_k_d: CudaSlice<f32>,
    /// [K, B, HIDDEN_DIM] grad output from heads_grn_bwd's skip path; summed with MTER's
    /// scratch to form d_trunk_h.
    mter_grad_trunk_h_from_skip_per_k_d: CudaSlice<f32>,
```

```rust
        let mter_grad_h_view_per_k_d = stream.alloc_zeros::<f32>(k * cfg.n_batch * N_HORIZONS * HIDDEN_DIM)
            .context("mter_grad_h_view_per_k_d alloc")?;
        let mter_grad_trunk_h_from_skip_per_k_d = stream.alloc_zeros::<f32>(k * cfg.n_batch * HIDDEN_DIM)
            .context("mter_grad_trunk_h_from_skip_per_k_d alloc")?;
```

- [ ] **Step 5.3: Add invariant test 5 (horizon_lambda × MTER)**

In `crates/ml-alpha/tests/mter_invariants.rs`, add:

```rust
// ── Test 5: horizon_lambda × MTER interaction ──
#[test]
#[ignore = "requires full heads-MTER backward chain (Task 5); enables after wiring"]
fn mter_lambda_h6000_doubling_doubles_d_trunk_h() {
    // λ = [1, 1, 1, 1, 2] — boost h6000's trunk-gradient contribution 2×.
    // Setup forward + backward, then verify that d_trunk_h from h6000's path is 2×
    // the unweighted case. Verifies the lambda scaling applies BEFORE the MTER split.
    //
    // [Implementer: write the helper to run a full step_batched with arbitrary λ and
    // measure d_trunk_h contributions per horizon.]
}
```

Lift the `#[ignore]` once Task 5 is wired. Run the test:

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test mter_invariants -- --ignored --nocapture 2>&1 | tail -10
```

- [ ] **Step 5.4: Commit heads backward reshape + MTER backward integration**

```bash
git add crates/ml-alpha/cuda/multi_horizon_heads.cu \
        crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/tests/mter_invariants.rs
git commit -m "feat(intervention-b): heads backward reshape + MTER backward integration + test 5"
```

---

## Task 6: Stateful CfC + stateful MTER across sequences/epochs + val state isolation + B=1 + post-warmup AUC + reset_for_new_fold

**Files:**
- Modify: `crates/ml-alpha/src/trainer/perception.rs`

- [ ] **Step 6.1: Add validation-side state buffers**

In `PerceptionTrainer` struct:

```rust
    /// Separate MTER state for the validation loop. Reset at val-start; never polluted
    /// by training state. See spec §4.5.
    val_mter_h_view_state_d: CudaSlice<f32>,
    val_mter_first_obs_d: CudaSlice<i32>,
    /// Separate CfC state for validation (parallel to training's `cfc_h_state_d`).
    val_cfc_h_state_d: CudaSlice<f32>,
```

Allocate in constructor; mirror sizes from the training-side equivalents.

- [ ] **Step 6.2: Drop attn_pool reset at non-file-boundary sequence boundaries**

In `step_batched`, find the attn_pool launch. Wrap in:

```rust
        // Per spec §3.3: in Sequential mode, CfC's h_old carries across sequence boundaries
        // within a file. attn_pool runs only at file boundaries.
        let need_attn_pool_bootstrap = match self.cfg.loader_mode {
            LoaderMode::Random => true,
            LoaderMode::Sequential => self.last_seen_file_boundary,
        };
        if need_attn_pool_bootstrap {
            // ... existing attention_pool launch + h_old initialization ...
        } else {
            // Use CfC h_new from previous sequence's k=K-1 as h_old at k=0.
            // This is just leaving cfc_h_state_d unchanged from the previous step_batched call.
        }
```

- [ ] **Step 6.3: Drop MTER reset at non-file-boundary sequence boundaries**

In `step_batched`, find the MTER reset added in Task 2 (Step 2.9):

```rust
        // Per spec §3.4: in Sequential mode, MTER's h_view persists across sequences AND epochs.
        // Reset only at trainer construction (already done) and file boundaries.
        let need_mter_reset = match self.cfg.loader_mode {
            LoaderMode::Random => true,  // Random mode: reset each sequence
            LoaderMode::Sequential => self.last_seen_file_boundary,
        };
        if need_mter_reset {
            self.reset_mter_state()?;
        }
```

- [ ] **Step 6.4: B=1 enforcement**

In the trainer constructor:

```rust
        if cfg.n_batch != 1 {
            return Err(anyhow::anyhow!(
                "MTER (CRT.train intervention B) requires n_batch=1; got n_batch={}. \
                 Multi-batch sequential training is out of scope for v5.",
                cfg.n_batch
            ));
        }
```

- [ ] **Step 6.5: Add `reset_for_new_fold` + `reset_inference_state` methods**

```rust
impl PerceptionTrainer {
    /// Reset all stateful buffers for a new walk-forward CV fold.
    /// Per spec §3.8: each Argo run is one fold = one trainer, so this method exists
    /// to support future multi-fold workflows without silent state-leakage bugs.
    pub fn reset_for_new_fold(&mut self) -> Result<()> {
        self.stream.memset_zeros(&mut self.mter_h_view_state_d)
            .map_err(|e| anyhow::anyhow!("reset mter_h_view_state: {e}"))?;
        self.stream.memset_zeros(&mut self.mter_first_obs_d)
            .map_err(|e| anyhow::anyhow!("reset mter_first_obs: {e}"))?;
        self.stream.memset_zeros(&mut self.cfc_h_state_d)
            .map_err(|e| anyhow::anyhow!("reset cfc_h_state: {e}"))?;
        // Trigger attn_pool re-bootstrap on next sequence.
        self.last_seen_file_boundary = true;
        Ok(())
    }

    /// Reset inference-side state buffers for a new prediction session.
    /// Bootstraps the CfC h_state via attn_pool on the next event; MTER h_view via
    /// sentinel on the next event.
    pub fn reset_inference_state(&mut self) -> Result<()> {
        // Inference-side buffers are added in Task 7.
        // [Stubbed here; filled in Task 7.]
        Ok(())
    }
}
```

- [ ] **Step 6.6: Post-warmup AUC accumulator in validation**

Find the validation loop (search `fn evaluate_validation` or `auc_inputs.push`). Add a per-horizon warmup counter:

```rust
    let warmup_events_per_horizon = TAU_INIT_PER_HORIZON;  // [30, 100, 300, 1000, 6000]
    let mut events_seen_in_val: usize = 0;
    let mut auc_inputs: [Vec<AucInput>; N_HORIZONS] = Default::default();

    for seq_idx in 0..n_val_seqs {
        let (seq, labels) = val_loader.next_sequence()?.expect("val sequence");
        // ... existing forward path ...
        events_seen_in_val += cfg.seq_len;

        // Append AUC samples ONLY for horizons whose warmup is complete.
        for h in 0..N_HORIZONS {
            if events_seen_in_val >= warmup_events_per_horizon[h] as usize {
                auc_inputs[h].push(AucInput { /* this sequence's val output for horizon h */ });
            }
        }
    }
```

This implements the "post-warmup AUC accumulator" per spec §3.5.

- [ ] **Step 6.7: Verify training MUST gate locally with random mode (baseline)**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo build --release -p ml-alpha --example alpha_train 2>&1 | tail -3
# Local smoke at random mode with a small fixture (won't exercise MTER's stateful path
# meaningfully but verifies the build is clean).
SQLX_OFFLINE=true cargo test -p ml-alpha --lib -- smoke_tests --ignored --nocapture 2>&1 | tail -10
```

Expected: clean build; smoke runs without panic.

- [ ] **Step 6.8: Commit stateful CfC + stateful MTER + val isolation + B=1 + post-warmup AUC + reset methods**

```bash
git add crates/ml-alpha/src/trainer/perception.rs
git commit -m "feat(intervention-b): stateful CfC + MTER across sequences/epochs + val isolation + B=1 + post-warmup AUC"
```

---

## Task 7: Inference path MTER + LOB backtest harness + symmetry test + golden regenerations

**Files:**
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (`forward_step_into` + inference-side state buffers)
- Modify: LOB backtest harness wherever it instantiates trainer (search via grep)
- Create: `crates/ml-alpha/tests/forward_step_mter_symmetry.rs`
- Regenerate: `crates/ml-alpha/tests/forward_step_golden.rs`
- Regenerate: `crates/ml-alpha/tests/perception_forward_golden.rs`
- Regenerate: `crates/ml-alpha/tests/forward_graph_capture.rs`

- [ ] **Step 7.1: Add inference-side state buffers**

In `PerceptionTrainer` struct:

```rust
    // ─── MTER inference-side state ───
    /// Per-instance MTER state for inference. Bootstrapped on first call to
    /// `forward_step_into` (sentinel pattern). Persistent across events within
    /// a single inference session.
    mter_h_view_state_step_d: CudaSlice<f32>,
    mter_first_obs_step_d: CudaSlice<i32>,
```

Allocate in constructor with shape `[1, N_HORIZONS, HIDDEN_DIM]` (B=1 enforced).

- [ ] **Step 7.2: Add MTER kernel launch to `forward_step_into`**

In `forward_step_into`, find where the existing heads_grn_fwd kernel is launched (per-event single-step path). Insert the MTER forward launch BEFORE the heads launch (i.e., AFTER the CfC step kernel):

```rust
            // ── MTER forward (per-event inference) ──
            unsafe {
                self.stream.launch_builder(&self.mter_forward_fn)
                    .arg(&self.cfc_h_state_step_d)        // [1, HIDDEN_DIM]; treat as trunk_h
                    .arg(&self.mter_tau_raw_d)
                    .arg(&mut self.mter_h_view_state_step_d)
                    .arg(&mut self.mter_first_obs_step_d)
                    .arg(&1_i32)
                    .arg(&mut h_view_step_out_d)
                    .launch(LaunchConfig {
                        grid_dim: (1, N_HORIZONS as u32, 1),
                        block_dim: (HIDDEN_DIM as u32, 1, 1),
                        shared_mem_bytes: 0,
                    })
                    .context("inference mter_forward launch")?;
            }
```

Then the heads launch consumes `h_view_step_out_d` + `cfc_h_state_step_d`.

- [ ] **Step 7.3: Implement `reset_inference_state` properly**

Replace the stubbed Step 6.5 implementation:

```rust
    pub fn reset_inference_state(&mut self) -> Result<()> {
        self.stream.memset_zeros(&mut self.mter_h_view_state_step_d)
            .map_err(|e| anyhow::anyhow!("reset mter_h_view_state_step: {e}"))?;
        self.stream.memset_zeros(&mut self.mter_first_obs_step_d)
            .map_err(|e| anyhow::anyhow!("reset mter_first_obs_step: {e}"))?;
        self.stream.memset_zeros(&mut self.cfc_h_state_step_d)
            .map_err(|e| anyhow::anyhow!("reset cfc_h_state_step: {e}"))?;
        Ok(())
    }
```

- [ ] **Step 7.4: LOB backtest harness integration**

Search for the LOB backtest harness's trainer instantiation:

```bash
grep -rn "PerceptionTrainer::new\|forward_step_into" crates/ml-backtesting/ 2>/dev/null | head -10
```

If found, ensure it calls `trainer.reset_inference_state()` at the start of each backtest session AND doesn't allocate its own MTER state (the trainer owns it).

If the harness has its own kernel-launch sequence (not via `PerceptionTrainer::forward_step_into`), search for `heads_grn_fwd` in that codepath and add the MTER launch one step upstream.

- [ ] **Step 7.5: Create symmetry test**

Create `crates/ml-alpha/tests/forward_step_mter_symmetry.rs`:

```rust
//! Verify training-time `step_batched` and inference-time `forward_step_into` produce
//! numerically identical h_view + heads input given identical inputs (CRT.train
//! intervention B requirement per spec §4.4).

use anyhow::Result;
use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig};
use ml_alpha::heads::N_HORIZONS;
use ml_core::device::MlDevice;

fn test_device() -> MlDevice {
    MlDevice::cuda(0).expect("CUDA 0 required")
}

#[test]
fn step_batched_and_forward_step_into_produce_identical_h_view() {
    let dev = test_device();
    let cfg = PerceptionTrainerConfig {
        seq_len: 32,
        n_batch: 1,
        ..Default::default()
    };
    let mut trainer = PerceptionTrainer::new(&dev, &cfg).unwrap();

    // [Implementer: 
    //   1. Construct a deterministic test sequence (32 events, fixed snapshots).
    //   2. Run trainer.step(...) on the full sequence; capture h_view at k=K-1.
    //   3. Reset trainer state; replay the same 32 events one-by-one via forward_step_into;
    //      capture h_view after the last event.
    //   4. Assert the two h_view values match within 1e-5 (numerical noise tolerance).
    // ]
}
```

The actual test body requires careful sequencing of the trainer's APIs. Implementer fills in based on the existing `forward_step_golden.rs` pattern.

- [ ] **Step 7.6: Regenerate forward_step_golden + perception_forward_golden + forward_graph_capture**

Run each:

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo test -p ml-alpha --test forward_step_golden -- --nocapture 2>&1 | tail
SQLX_OFFLINE=true cargo test -p ml-alpha --test perception_forward_golden -- --nocapture 2>&1 | tail
SQLX_OFFLINE=true cargo test -p ml-alpha --test forward_graph_capture -- --nocapture 2>&1 | tail
```

Expected: each FAILS with golden mismatch. Update each test's baked-in golden constants by capturing the new output (use the same approach as Step 3.4). Re-run; tests pass.

- [ ] **Step 7.7: Run all 8 invariant + symmetry tests**

```bash
SQLX_OFFLINE=true cargo test -p ml-alpha --test mter_invariants -- --nocapture 2>&1 | tail -10
SQLX_OFFLINE=true cargo test -p ml-alpha --test forward_step_mter_symmetry -- --nocapture 2>&1 | tail
```

Expected: 7 mter_invariants tests pass; symmetry test passes.

- [ ] **Step 7.8: Commit inference path + symmetry test + golden regenerations**

```bash
git add crates/ml-alpha/src/trainer/perception.rs \
        crates/ml-alpha/tests/forward_step_mter_symmetry.rs \
        crates/ml-alpha/tests/forward_step_golden.rs \
        crates/ml-alpha/tests/perception_forward_golden.rs \
        crates/ml-alpha/tests/forward_graph_capture.rs
# Plus any LOB backtest harness files touched in 7.4:
# git add crates/ml-backtesting/... (as applicable)
git commit -m "feat(intervention-b): inference path MTER + symmetry test + golden regen"
```

---

## Task 8: Smoothness deletion + slot 0 reclaim + decoder rename + CLI/Argo cleanup

**Files:**
- Delete: `crates/ml-alpha/cuda/output_smoothness.cu`
- Delete: `crates/ml-alpha/cuda/smoothness_lambda_controller.cu`
- Delete: `crates/ml-alpha/tests/output_smoothness_grad_finite_diff.rs`
- Delete: `crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs`
- Modify: `crates/ml-alpha/cuda/gpu_log_ids.h` (reclaim slot 0 for `KID_MTER_READOUT`)
- Modify: `crates/ml-alpha/src/gpu_log.rs` (rename decoders)
- Modify: `crates/ml-alpha/build.rs` (remove smoothness kernels from KERNELS)
- Modify: `crates/ml-alpha/src/heads.rs` (remove `SMOOTHNESS_LAMBDA_RATIO`)
- Modify: `crates/ml-alpha/src/trainer/perception.rs` (remove smoothness wiring)
- Modify: `crates/ml-alpha/examples/alpha_train.rs` (drop `--smoothness-base-lambda` CLI flag)
- Modify: `infra/k8s/argo/alpha-perception-template.yaml` (drop `smoothness-base-lambda` workflow param)

- [ ] **Step 8.1: Delete smoothness kernel files**

```bash
cd /home/jgrusewski/Work/foxhunt
git rm crates/ml-alpha/cuda/output_smoothness.cu \
       crates/ml-alpha/cuda/smoothness_lambda_controller.cu \
       crates/ml-alpha/tests/output_smoothness_grad_finite_diff.rs \
       crates/ml-alpha/tests/smoothness_lambda_controller_invariants.rs
```

- [ ] **Step 8.2: Reclaim slot 0 for `KID_MTER_READOUT`**

In `crates/ml-alpha/cuda/gpu_log_ids.h`, replace:

```c
#define KID_SMOOTHNESS_CONTROLLER 0
#define KID_OUTPUT_SMOOTHNESS     1
#define KID_BCE_MULTI_HORIZON     2
// ... etc
```

with:

```c
#define KID_MTER_READOUT          0      // CRT.train B: multi-timescale EMA readout
#define KID_BCE_MULTI_HORIZON     1
// ... shift others down by 2 ...
```

Update RT enum if needed for MTER's record types (RT_INPUT for trunk_h+h_view_prev, RT_STATE for h_view, RT_OUTPUT for τ).

- [ ] **Step 8.3: Rename smoothness decoders in gpu_log.rs**

In `crates/ml-alpha/src/gpu_log.rs`, find the decoder match arms for smoothness records:

```rust
            (KID_SMOOTHNESS_CONTROLLER, RT_INPUT)  => smoothness_input(...),
            (KID_SMOOTHNESS_CONTROLLER, RT_STATE)  => smoothness_state(...),
            (KID_SMOOTHNESS_CONTROLLER, RT_OUTPUT) => smoothness_output(...),
```

Replace with MTER decoders:

```rust
            (KID_MTER_READOUT, RT_INPUT)  => mter_input(...),
            (KID_MTER_READOUT, RT_STATE)  => mter_state(...),
            (KID_MTER_READOUT, RT_OUTPUT) => mter_output(...),
```

Rename the decoder functions accordingly. Update the field names in the tracing emit (e.g., `ema_h30` → `h_view_norm_h30`).

Delete the now-unused KID_SMOOTHNESS_CONTROLLER constant.

- [ ] **Step 8.4: Remove smoothness kernels from build.rs**

In `crates/ml-alpha/build.rs`, remove entries for `output_smoothness` and `smoothness_lambda_controller` from the `KERNELS` array. The cache-bust comment was already bumped to v15 in Task 2.

- [ ] **Step 8.5: Remove SMOOTHNESS_LAMBDA_RATIO from heads.rs**

In `crates/ml-alpha/src/heads.rs`, delete the `SMOOTHNESS_LAMBDA_RATIO` constant and its doc-comment.

- [ ] **Step 8.6: Remove smoothness wiring from trainer**

In `crates/ml-alpha/src/trainer/perception.rs`:
1. Delete `smoothness_*` struct fields (kernel handles, state buffers, host shadows).
2. Delete the smoothness forward + backward kernel launches in `step_batched`.
3. Delete the `last_smoothness_loss_per_horizon` accessor.
4. Delete smoothness-related telemetry buffers (smoothness_loss_d, smoothness_loss_host_d, etc.).
5. Delete `cfg.smoothness_base_lambda` from PerceptionTrainerConfig.

- [ ] **Step 8.7: Drop `--smoothness-base-lambda` CLI flag**

In `crates/ml-alpha/examples/alpha_train.rs`:
1. Remove the `smoothness_base_lambda: f32` field from `Cli`.
2. Remove the field from `AlphaTrainSummary` (and its population at summary-write time).
3. Remove the `cli.smoothness_base_lambda` use in PerceptionTrainerConfig construction.
4. Add new MTER CLI flags:

```rust
    /// MTER τ parameterization mode (CRT.train intervention B).
    #[arg(long, default_value = "frozen")]
    mter_tau_mode: String,
    /// Optional per-horizon τ override (comma-separated f32). Default `None` = use `TAU_INIT_PER_HORIZON`.
    #[arg(long)]
    mter_tau_init: Option<String>,
```

Parse these and thread into PerceptionTrainerConfig.

- [ ] **Step 8.8: Drop `smoothness-base-lambda` Argo workflow parameter**

In `infra/k8s/argo/alpha-perception-template.yaml`:
1. Delete the `- name: smoothness-base-lambda` block from `parameters:`.
2. Delete the `--smoothness-base-lambda {{workflow.parameters.smoothness-base-lambda}} \` line from the train args.
3. Add new MTER params:

```yaml
      - name: mter-tau-mode
        value: "frozen"
      - name: mter-tau-init
        value: ""
```

4. Add to train args:

```bash
              --mter-tau-mode    "{{workflow.parameters.mter-tau-mode}}" \
              --mter-tau-init    "{{workflow.parameters.mter-tau-init}}" \
```

(Skip the `--mter-tau-init` line if the workflow param is empty.)

- [ ] **Step 8.9: Verify build is clean and all remaining tests pass**

```bash
cd /home/jgrusewski/Work/foxhunt
SQLX_OFFLINE=true cargo check -p ml-alpha --all-targets 2>&1 | grep -E "^error" | head
SQLX_OFFLINE=true cargo build --release -p ml-alpha --example alpha_train 2>&1 | tail -3
SQLX_OFFLINE=true cargo test -p ml-alpha --tests 2>&1 | tail -20
```

Expected: clean check; clean build; all tests pass.

- [ ] **Step 8.10: Update Argo template on cluster + commit + push**

```bash
kubectl apply -f infra/k8s/argo/alpha-perception-template.yaml 2>&1 | tail -2
argo template get -n foxhunt alpha-perception -o yaml 2>&1 | grep -E "mter-tau-mode|smoothness-base-lambda" | head

git add -A
git commit -m "$(cat <<'EOF'
feat(intervention-b): delete smoothness machinery + reclaim slot 0 for MTER

Per spec §6.3 + §3.8: smoothness machinery superseded by MTER. Removed:
- output_smoothness.cu + smoothness_lambda_controller.cu (kernels)
- output_smoothness_grad_finite_diff.rs + smoothness_lambda_controller_invariants.rs (tests)
- SMOOTHNESS_LAMBDA_RATIO constant
- All smoothness wiring in trainer (state buffers, kernel handles, launches, telemetry)
- --smoothness-base-lambda CLI flag + smoothness-base-lambda Argo param

Added:
- --mter-tau-mode CLI flag (default frozen) + mter-tau-mode Argo param
- --mter-tau-init CLI flag + mter-tau-init Argo param
- KID_MTER_READOUT reclaims slot 0; KID_SMOOTHNESS_CONTROLLER removed
- gpu_log.rs decoders renamed from smoothness_* to mter_*

Rollback: git revert this commit chain restores intervention A; diagnostic
JSONL from prior runs preserved on MinIO.
EOF
)"
git push origin ml-alpha-phase-a 2>&1 | tail -3
```

---

## Task 9: Validation L40S retrain + CRT.diag + WIN-gate evaluation

**Files:** No file modifications; this is the operator-driven validation step.

- [ ] **Step 9.1: Submit L40S retrain at HEAD**

```bash
SHA=$(git rev-parse HEAD)
argo submit -n foxhunt --from=wftmpl/alpha-perception \
  -p commit-sha=$SHA \
  -p git-branch=ml-alpha-phase-a \
  -p gpu-pool=ci-training-l40s \
  -p loader-mode=sequential \
  -p mter-tau-mode=frozen \
  -p kernel-step-trace-enable=true \
  -p kernel-step-trace-path=/feature-cache/alpha-perception-runs/${SHA:0:9}/step_trace.jsonl \
  -p epochs=12 \
  -p n-train-seqs=8000 -p n-val-seqs=1000
```

Expected wall: ~36 min (build cache miss because the binary depends on the post-MTER source).

- [ ] **Step 9.2: Set up monitor + wait for completion**

Pattern from prior runs:
```bash
WF=alpha-perception-<id>
argo logs -f -n foxhunt $WF > /tmp/wf-monitor/$WF.log 2>&1 &
argo wait -n foxhunt $WF &
```

- [ ] **Step 9.3: Verify training MUST gate from summary JSON**

After completion, pull `alpha_train_summary.json` from MinIO PVC (via fc-debug pod). Check:

```bash
jq '{final_val_auc, best_h6000: .best_auc_h6000}' summary.json
```

Gate (spec §9.3): per-horizon `final_val_auc[h] ≥ lnfwd_baseline[h] - 0.02`. h6000 baseline = 0.7157; threshold = 0.6957.

- [ ] **Step 9.4: Pull JSONL trace + inspect MTER trajectory**

```bash
kubectl cp -n foxhunt fc-debug:/feature-cache/alpha-perception-runs/${SHA:0:9}/step_trace.jsonl /tmp/wf-monitor/mter-trace.jsonl
wc -l /tmp/wf-monitor/mter-trace.jsonl

# Inspect h_view evolution per horizon
jq -c 'select(.kid == 0 and .rt == 1)' /tmp/wf-monitor/mter-trace.jsonl | head -5

# Check τ trajectory (in frozen mode, should be flat at TAU_INIT values)
jq -c 'select(.kid == 0 and .rt == 2)' /tmp/wf-monitor/mter-trace.jsonl | head -5
```

Expected: ~120k records (40k steps × 3 record types). τ trajectory flat at [30, 100, 300, 1000, 6000].

- [ ] **Step 9.5: Submit CRT.diag against the new checkpoint**

```bash
cp config/ml/sweep_smoke.yaml /tmp/wf-monitor/sweep_crt_diag_mter.yaml
sed -i "s|/feature-cache/alpha-perception-runs/[^/]*/trunk_best_h6000.bin|/feature-cache/alpha-perception-runs/${SHA:0:9}/trunk_best_h6000.bin|" /tmp/wf-monitor/sweep_crt_diag_mter.yaml

./scripts/argo-lob-sweep.sh \
  --grid /tmp/wf-monitor/sweep_crt_diag_mter.yaml \
  --sweep-tag crt-diag-mter-${SHA:0:9} \
  --sha $SHA \
  --branch ml-alpha-phase-a \
  --gpu-pool ci-training-l40s
```

Expected wall: ~13 min.

- [ ] **Step 9.6: Apply inference WIN gate**

From CRT.diag output, extract mean_run_len:
- **MUST**: h6000 mean_run_len ≥ 100 events
- **MUST**: h6000/h30 ratio ≥ 10×

If WIN PASSES:
- Document outcome in `project_mter_win_outcome.md` memory file
- Consider next steps: CRT.1 controller resume, learnable τ ablation, etc.

If WIN FAILS:
- Document failure mode (which horizon, what mean_run_len)
- Diagnose via JSONL trace (per spec §13 successor paths)
- Possible next actions:
  - Zero-init the trunk_h half of heads' w1 (force reliance on h_view)
  - Switch to per-horizon CfC branches (Option 1 from earlier reviews)
  - Redefine inference WIN gate (conviction × outcome instead of mean_run_len)

- [ ] **Step 9.7: Record outcome in memory + final commit if needed**

Create `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/project_mter_outcome.md` with the empirical results. If diagnostic findings warrant a code fix, write the followup spec/plan and execute.

---

## Self-Review

### Spec coverage check

| Spec section | Plan task |
|---|---|
| §3.1 MTER forward math | Task 2 |
| §3.2 Concatenated head input | Task 3 |
| §3.3 Stateful CfC trunk | Task 6 |
| §3.4 Stateful MTER | Task 6 |
| §3.5 Validation state isolation | Task 6 |
| §3.6 B=1 enforcement | Task 6 |
| §3.7 Loader mode | Task 1 |
| §3.8 Multi-fold trainer state | Task 6 (reset_for_new_fold) |
| §3.9 Heads kernel signature changes | Task 3 (fwd) + Task 5 (bwd) |
| §3.10 τ_raw update | Task 2 (frozen alloc) + (learnable mode out of scope for v5 first cut) |
| §4 Inference path design | Task 7 |
| §5 Math contract | Tasks 2, 4 |
| §6.4 Golden regenerations | Tasks 3, 7 |
| §7 Commit boundaries | Tasks 1-8 (mapped 1:1) |
| §8 Memory rules + horizon_lambda | Implicit throughout |
| §9 Validation gates | Tasks 1.14 (commit-1 smoke), 2.11/4.5/7.7 (invariant tests), 9.3 (training MUST), 9.6 (inference WIN) |

All spec sections covered.

### Placeholder scan

- Step 4.4 test 5 marked `#[ignore]` — placeholder content noted as "Implementer: write the helper". This IS a placeholder. Should clarify: the spec says the test exists; the plan acknowledges it needs the implementer to write the helper logic with the math contract. Acceptable because the math contract is provided in spec §5.
- Step 5.1 says "Refer to spec §3.8 for full details" for the heads backward Pass 5/6 reshape — this is appropriate for an >150 LOC kernel rewrite; the plan provides the structural changes + math, and the implementer follows spec for full kernel.
- Step 7.5 symmetry test has "[Implementer: ...]" placeholder. Same justification — the math is in spec §4.4; the implementer follows.

These are acceptable per the writing-plans guidance ("Document everything they need to know") because the engineering content IS in the spec; the plan points to it.

### Type consistency

- `MTerTauMode` enum used consistently across Task 2 (declaration in heads.rs) + Task 8 (CLI parsing in alpha_train.rs).
- `LoaderMode` enum used consistently across Task 1 (declaration + CLI) + Task 6 (trainer dispatch).
- `mter_h_view_state_d` named consistently across Tasks 2, 4, 5, 6.
- `mter_first_obs_d` named consistently.
- `reset_for_new_fold` + `reset_inference_state` method names defined in Task 6, referenced in Task 7.

Consistent.

### Frequent commits + buildable per-task discipline

Each of the 8 tasks ends with a commit. Per spec §7, each commit is buildable + testable. The plan's task boundaries mirror the spec's commit boundaries. ✓

---

**End of Implementation Plan.**

**Plan complete and saved to** `docs/superpowers/plans/2026-05-21-crt-train-intervention-b-multi-timescale-readout-plan.md`. Two execution options:

**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, two-stage review per task, fast iteration

**2. Inline Execution** — Execute tasks in this session using executing-plans, batch with checkpoints

Which approach?
