Files
foxhunt/docs/superpowers/plans/2026-05-15-phase1d-implementation.md
jgrusewski 5d79bf0b22 docs(phase1d): implementation plan for regime-gated tick reasoning + memory accumulator
26 tasks across 5 milestones (1d.0 through 1d.4) with decisive falsification
gates at each. Anchors to commit db874b184 (Phase 1c validation) and references
real APIs: ml::trainers::mamba2, ml-alpha::training, backtesting::strategies.

Each task is bite-sized (TDD steps + commit). Decisive gates:
- 1d.0: best calibrated Brier ≤ 0.250
- 1d.1: Mamba AUC > 0.72 at K=100
- 1d.2: Mamba AUC > 0.55 at K=6000 (DECISIVE for two-head architecture)
- 1d.3: regime-gated conditional accuracy > 0.65
- 1d.4: out-of-sample Sharpe > 1.5

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 01:13:42 +02:00

2589 lines
88 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FoxhuntQ-Δ Phase 1d: Regime-Gated Tick Reasoning + Memory Accumulator — 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:** Build a stateful, regime-gated, multi-minute alpha model on top of the validated 81-dim snapshot pipeline. Phase 1d converts the Phase 1c smoke result (AUC=0.685 stateless at K=100 snapshots) into a deployable trading signal: calibrated probability stream, multi-minute prediction horizon, explicit regime gating, with a coverage-gated backtest as the go/no-go gate.
**Architecture:** Snapshot stream → Mamba2 sequence encoder → two heads (edge estimator + regime classifier) → memory accumulator integrates `regime × edge` over a 1-5 min window → calibrated multi-minute direction probability → coverage-gated trading policy. Each milestone (1d.0 through 1d.4) ends with a falsification gate that decides whether to proceed; failing any decisive gate kills the architecture.
**Tech Stack:**
- Rust 1.85+, Edition 2021
- Tokio 1.40 (async runtime; only at I/O boundaries)
- CUDA 12.4 via `cudarc` 0.17 (CUDA 12.x)
- Apache Arrow IPC (`arrow` 56.x) — fxcache format
- `gbdt 0.1.3` — pure-Rust GBM corroboration
- Existing crates: `ml`, `ml-alpha`, `ml-features`, `ml-core` (cuda_autograd), `backtesting`
- Mamba2 reference: `crates/ml/src/trainers/mamba2.rs` + `crates/ml/src/cuda_pipeline/mamba2_temporal_kernel.cu`
**Anchor commit:** `db874b184` (Phase 1c validation: snapshot pipeline + variable-dim alpha + leakage fix)
---
## File Structure
New files in this plan:
| File | Responsibility |
|---|---|
| `crates/ml-alpha/src/calibration.rs` | Platt scaling + isotonic regression calibrators |
| `crates/ml-alpha/examples/phase1d_calibrate.rs` | Calibration smoke for milestone 1d.0 |
| `crates/ml-alpha/src/snapshot_sequence.rs` | Snapshot-stream batching (chunked, contiguous-in-time) |
| `crates/ml-alpha/src/mamba_encoder.rs` | Mamba2 wrapper for ml-alpha (adapter to ml::trainers::mamba2) |
| `crates/ml-alpha/examples/phase1d_mamba.rs` | Stateful-encoder smoke for milestone 1d.1 |
| `crates/ml-alpha/src/multi_horizon_labels.rs` | Multi-horizon label generator (K=6000 ≈ 1-5 min) |
| `crates/ml-alpha/examples/phase1d_long_horizon.rs` | Multi-minute label smoke for milestone 1d.2 |
| `crates/ml-alpha/src/regime_classifier.rs` | Regime classifier (binary head: P(in_alpha_regime)) |
| `crates/ml-alpha/src/dual_head_mlp.rs` | Two-head model (edge head + regime head, shared trunk) |
| `crates/ml-alpha/examples/phase1d_regime.rs` | Multi-task regime smoke for milestone 1d.3 |
| `crates/backtesting/src/snapshot_stream_replay.rs` | Snapshot-event replay for backtest |
| `crates/backtesting/src/strategies/regime_gated_alpha.rs` | Regime-gated trading policy |
| `crates/backtesting/examples/phase1d_backtest.rs` | Coverage-gated backtest for milestone 1d.4 |
Modified files:
| File | Change |
|---|---|
| `crates/ml-alpha/src/lib.rs` | Re-export new modules |
| `crates/ml-alpha/src/training.rs` | Add hooks for dual-head + sequence training |
| `crates/ml-alpha/Cargo.toml` | Add deps if any |
---
## Milestone 1d.0 — Calibration Baseline (~1 day)
**Hypothesis:** The AUC≫accuracy gap (0.685 vs 0.524) at K=100 is fixable miscalibration, not a model expressivity issue. Brier > chance baseline (0.371 > 0.250) supports this.
**Decisive gate:** After Platt + isotonic calibration on val, Brier score drops below the chance baseline (≤ 0.250). If both calibrators leave Brier > 0.25, the model's prediction distribution is too pathological for post-hoc fixes; abandon Phase 1d entirely.
---
### Task 1: Scaffold `crates/ml-alpha/src/calibration.rs`
**Files:**
- Create: `crates/ml-alpha/src/calibration.rs`
- Modify: `crates/ml-alpha/src/lib.rs`
- [ ] **Step 1: Write the failing test (scaffold compile + module-exists check)**
In a new file `crates/ml-alpha/src/calibration.rs`:
```rust
//! Phase 1d.0 — post-hoc probability calibration.
//!
//! Platt scaling: logistic regression `P_calib = σ(a·logit + b)` fit on a
//! held-out calibration set. Preserves rank order (AUC unchanged); fixes
//! the sigmoid threshold and prediction-distribution shape.
//!
//! Isotonic regression: monotonic non-parametric calibrator via PAV
//! (pool-adjacent-violators). Better when miscalibration isn't sigmoidal.
/// A fitted calibrator that maps raw logits → calibrated probabilities.
pub trait Calibrator {
fn transform(&self, logits: &[f32]) -> Vec<f32>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_module_compiles() {
let _ = std::any::type_name::<dyn Calibrator>();
}
}
```
- [ ] **Step 2: Wire into lib.rs**
In `crates/ml-alpha/src/lib.rs`, add the `pub mod calibration;` declaration directly under `pub mod metrics_detail;` and add `pub use calibration::Calibrator;` to the re-exports block.
- [ ] **Step 3: Run test to verify it passes**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib calibration -- --nocapture`
Expected: `test result: ok. 1 passed; 0 failed`
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/src/calibration.rs crates/ml-alpha/src/lib.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): scaffold calibration module for Phase 1d.0
Empty Calibrator trait + module wired. Platt + isotonic implementations
land in subsequent tasks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Implement Platt scaling
**Files:**
- Modify: `crates/ml-alpha/src/calibration.rs`
- [ ] **Step 1: Write the failing test**
Append to `crates/ml-alpha/src/calibration.rs` in the `tests` module:
```rust
#[test]
fn test_platt_perfect_separable() {
// Logits with clean ±2 separation should fit to a near-identity
// sigmoid mapping. After transform, prob(positive class) → ~1, prob(neg) → ~0.
let logits: Vec<f32> = (0..200)
.map(|i| if i % 2 == 0 { 2.0 } else { -2.0 })
.collect();
let labels: Vec<f32> = (0..200)
.map(|i| if i % 2 == 0 { 1.0 } else { 0.0 })
.collect();
let cal = PlattScaler::fit(&logits, &labels, 200, 1e-3).expect("fit");
let probs = cal.transform(&logits);
// Even-index samples are positive; their calibrated prob should be ≥ 0.9.
for (i, &p) in probs.iter().enumerate() {
if i % 2 == 0 {
assert!(p > 0.9, "expected pos sample i={i} prob>0.9, got {p}");
} else {
assert!(p < 0.1, "expected neg sample i={i} prob<0.1, got {p}");
}
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib calibration::tests::test_platt_perfect_separable -- --nocapture`
Expected: FAIL with `cannot find type 'PlattScaler' in this scope`
- [ ] **Step 3: Implement PlattScaler**
Append to `crates/ml-alpha/src/calibration.rs` (above the `tests` module):
```rust
/// Platt scaling — fit a logistic regression on (logit, label) pairs via
/// gradient descent on BCE loss. Two parameters: `a` (slope) and `b` (intercept).
///
/// `P_calibrated = σ(a · logit + b)`
#[derive(Debug, Clone)]
pub struct PlattScaler {
pub a: f32,
pub b: f32,
}
impl PlattScaler {
/// Fit `(a, b)` by minimising mean BCE over the calibration set.
/// Returns the fitted scaler. `max_iters` is the gradient-descent budget;
/// `lr` is the learning rate.
pub fn fit(
logits: &[f32],
labels: &[f32],
max_iters: usize,
lr: f32,
) -> Result<Self, &'static str> {
if logits.len() != labels.len() {
return Err("Platt: logits and labels length mismatch");
}
if logits.is_empty() {
return Err("Platt: empty calibration set");
}
let n = logits.len() as f32;
let mut a = 1.0_f32;
let mut b = 0.0_f32;
for _ in 0..max_iters {
let mut grad_a = 0.0_f32;
let mut grad_b = 0.0_f32;
for (&l, &y) in logits.iter().zip(labels.iter()) {
let z = (a * l + b).clamp(-50.0, 50.0);
let p = 1.0 / (1.0 + (-z).exp());
let err = p - y;
grad_a += err * l;
grad_b += err;
}
a -= lr * grad_a / n;
b -= lr * grad_b / n;
}
Ok(Self { a, b })
}
}
impl Calibrator for PlattScaler {
fn transform(&self, logits: &[f32]) -> Vec<f32> {
logits
.iter()
.map(|&l| {
let z = (self.a * l + self.b).clamp(-50.0, 50.0);
1.0 / (1.0 + (-z).exp())
})
.collect()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib calibration::tests::test_platt_perfect_separable -- --nocapture`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add crates/ml-alpha/src/calibration.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): Platt scaling calibrator (Phase 1d.0)
200-iter gradient descent on BCE loss; passes clean-separation test
(>0.9 prob for positive, <0.1 for negative).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Implement isotonic regression (PAV algorithm)
**Files:**
- Modify: `crates/ml-alpha/src/calibration.rs`
- [ ] **Step 1: Write the failing test**
Append to `tests` module in `crates/ml-alpha/src/calibration.rs`:
```rust
#[test]
fn test_isotonic_monotone_output() {
// Strict-monotone synthetic input → calibrator output must also be
// monotone-non-decreasing in input rank order.
let logits: Vec<f32> = (0..100).map(|i| i as f32 * 0.1).collect();
let labels: Vec<f32> = (0..100)
.map(|i| if i >= 50 { 1.0 } else { 0.0 })
.collect();
let cal = IsotonicCalibrator::fit(&logits, &labels).expect("fit");
let probs = cal.transform(&logits);
for w in probs.windows(2) {
assert!(w[1] >= w[0] - 1e-6, "monotonicity violated: {} → {}", w[0], w[1]);
}
// Sanity: prob at high logit should be ≥ 0.5; at low logit should be ≤ 0.5.
assert!(probs.last().unwrap() >= &0.5);
assert!(probs.first().unwrap() <= &0.5);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib calibration::tests::test_isotonic_monotone_output -- --nocapture`
Expected: FAIL with `cannot find type 'IsotonicCalibrator' in this scope`
- [ ] **Step 3: Implement isotonic regression via pool-adjacent-violators**
Append to `crates/ml-alpha/src/calibration.rs` (above the `tests` module):
```rust
/// Isotonic regression calibrator (pool-adjacent-violators algorithm).
///
/// Fits a monotone-non-decreasing step function from sorted-logit positions
/// to observed-positive-rate. For new inputs, linear interpolation between
/// the nearest two cut points.
#[derive(Debug, Clone)]
pub struct IsotonicCalibrator {
/// Sorted logit values (ascending).
cuts: Vec<f32>,
/// Calibrated probability at each cut (monotone-non-decreasing).
values: Vec<f32>,
}
impl IsotonicCalibrator {
pub fn fit(logits: &[f32], labels: &[f32]) -> Result<Self, &'static str> {
if logits.len() != labels.len() {
return Err("isotonic: logit/label length mismatch");
}
if logits.is_empty() {
return Err("isotonic: empty calibration set");
}
// Step 1: sort (logit, label) pairs by logit ascending.
let mut paired: Vec<(f32, f32)> =
logits.iter().copied().zip(labels.iter().copied()).collect();
paired.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// Step 2: PAV — iteratively pool adjacent decreasing blocks.
let n = paired.len();
let mut cuts: Vec<f32> = paired.iter().map(|p| p.0).collect();
let mut values: Vec<f32> = paired.iter().map(|p| p.1).collect();
let mut weights: Vec<f32> = vec![1.0; n];
let mut i = 0;
while i + 1 < values.len() {
if values[i] > values[i + 1] {
// Pool i and i+1: weighted average; remove i+1.
let new_val = (values[i] * weights[i] + values[i + 1] * weights[i + 1])
/ (weights[i] + weights[i + 1]);
let new_w = weights[i] + weights[i + 1];
values[i] = new_val;
weights[i] = new_w;
values.remove(i + 1);
weights.remove(i + 1);
cuts.remove(i + 1);
// Step back to re-check the now-merged block against its predecessor.
if i > 0 {
i -= 1;
}
} else {
i += 1;
}
}
Ok(Self { cuts, values })
}
}
impl Calibrator for IsotonicCalibrator {
fn transform(&self, logits: &[f32]) -> Vec<f32> {
logits
.iter()
.map(|&l| {
// Binary search for the cut just ≤ l, then linearly interpolate
// toward the next cut. Edge cases: below first cut → first value,
// above last cut → last value.
if self.cuts.is_empty() {
return 0.5;
}
if l <= self.cuts[0] {
return self.values[0];
}
if l >= *self.cuts.last().unwrap() {
return *self.values.last().unwrap();
}
let idx = self
.cuts
.partition_point(|&c| c <= l)
.saturating_sub(1);
if idx + 1 >= self.cuts.len() {
return self.values[idx];
}
let lo = self.cuts[idx];
let hi = self.cuts[idx + 1];
let lov = self.values[idx];
let hiv = self.values[idx + 1];
let frac = if hi > lo { (l - lo) / (hi - lo) } else { 0.0 };
lov + frac * (hiv - lov)
})
.collect()
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib calibration::tests::test_isotonic_monotone_output -- --nocapture`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add crates/ml-alpha/src/calibration.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): isotonic regression calibrator (Phase 1d.0)
Pool-adjacent-violators (PAV) implementation; passes monotonicity test
on synthetic strict-monotone input.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: Add calibration smoke example
**Files:**
- Create: `crates/ml-alpha/examples/phase1d_calibrate.rs`
- [ ] **Step 1: Write the calibration smoke example**
```rust
//! Phase 1d.0 — calibration smoke.
//!
//! Train the snapshot-level MLP exactly as `phase1a_detailed` does, then:
//! 1. Split val 80/20 into (calibration set, held-out test set).
//! 2. Fit Platt and isotonic on the calibration set.
//! 3. Apply each to the held-out test set; report Brier + log-loss for
//! uncalibrated / Platt / isotonic.
//! 4. Gate decision: if min(Platt_Brier, isotonic_Brier) ≤ chance baseline
//! `up_fraction × (1 - up_fraction)`, proceed to 1d.1; else falsify.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use tracing_subscriber::EnvFilter;
use ml_alpha::calibration::{Calibrator, IsotonicCalibrator, PlattScaler};
use ml_alpha::metrics_detail::{brier_score, log_loss};
use ml_alpha::training::{Phase1aConfig, Phase1aTrainer};
#[derive(Parser, Debug)]
#[command(name = "phase1d_calibrate", about = "FoxhuntQ-Δ Phase 1d.0 calibration smoke")]
struct Cli {
#[arg(long)]
fxcache_path: String,
#[arg(long, default_value_t = 100)]
horizon: usize,
#[arg(long, default_value_t = 5)]
epochs: usize,
#[arg(long, default_value_t = 0.5)]
cal_split_frac: f32,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
let mut config = Phase1aConfig::default();
config.fxcache_path = cli.fxcache_path.clone();
config.horizon = cli.horizon;
config.epochs = cli.epochs;
let mut trainer = Phase1aTrainer::from_config(config, stream)?;
let out = trainer.run_full()?;
// Sigmoid-pre-clip predictions for split-aware metrics.
let n_val = out.val_logits.len();
let n_cal = (n_val as f32 * cli.cal_split_frac) as usize;
let (cal_l, test_l) = out.val_logits.split_at(n_cal);
let (cal_y, test_y) = out.val_labels.split_at(n_cal);
let up_frac = out.report.up_fraction as f32;
let chance_brier = up_frac * (1.0 - up_frac);
let chance_logl = -(up_frac.ln() * up_frac + (1.0 - up_frac).ln() * (1.0 - up_frac));
let uncal_brier = brier_score(test_l, test_y);
let uncal_logl = log_loss(test_l, test_y);
let platt = PlattScaler::fit(cal_l, cal_y, 200, 1e-3).expect("platt fit");
let platt_probs = platt.transform(test_l);
let platt_logits: Vec<f32> = platt_probs.iter().map(|p| (p.clamp(1e-7, 1.0 - 1e-7) / (1.0 - p.clamp(1e-7, 1.0 - 1e-7))).ln()).collect();
let platt_brier = brier_score(&platt_logits, test_y);
let platt_logl = log_loss(&platt_logits, test_y);
let iso = IsotonicCalibrator::fit(cal_l, cal_y).expect("iso fit");
let iso_probs = iso.transform(test_l);
let iso_logits: Vec<f32> = iso_probs.iter().map(|p| (p.clamp(1e-7, 1.0 - 1e-7) / (1.0 - p.clamp(1e-7, 1.0 - 1e-7))).ln()).collect();
let iso_brier = brier_score(&iso_logits, test_y);
let iso_logl = log_loss(&iso_logits, test_y);
println!("\n=================================================");
println!("PHASE 1d.0 — CALIBRATION SMOKE");
println!("=================================================");
println!("Held-out test n = {}", test_l.len());
println!("Chance baselines: Brier = {:.5} log-loss = {:.5}", chance_brier, chance_logl);
println!();
println!("Uncalibrated: Brier = {:.5} log-loss = {:.5}", uncal_brier, uncal_logl);
println!("Platt scaling: Brier = {:.5} log-loss = {:.5}", platt_brier, platt_logl);
println!("Isotonic regression: Brier = {:.5} log-loss = {:.5}", iso_brier, iso_logl);
println!();
let best_brier = platt_brier.min(iso_brier);
if best_brier <= chance_brier {
println!("GATE PASS: best Brier ({:.5}) ≤ chance baseline ({:.5}); proceed to 1d.1.", best_brier, chance_brier);
} else {
println!("GATE FAIL: best Brier ({:.5}) > chance baseline ({:.5}); falsify 1d.0.", best_brier, chance_brier);
}
Ok(())
}
```
- [ ] **Step 2: Compile**
Run: `SQLX_OFFLINE=true cargo build -p ml-alpha --release --example phase1d_calibrate`
Expected: `Finished release` (no errors)
- [ ] **Step 3: Run the smoke against the snapshot fxcache**
```bash
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_calibrate \
--fxcache-path "$FXC" --horizon 100 --epochs 5
```
Expected: prints chance baselines, uncalibrated/Platt/iso metrics, then `GATE PASS` or `GATE FAIL`.
- [ ] **Step 4: Decide milestone outcome and commit**
If `GATE PASS`: proceed to Task 5. If `GATE FAIL`: stop here, write a short note in MEMORY.md and re-design Phase 1d before continuing.
```bash
git add crates/ml-alpha/examples/phase1d_calibrate.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): Phase 1d.0 calibration smoke example
Splits val 50/50 into calibration+test sets, fits Platt + isotonic on
the calibration half, reports Brier/log-loss on held-out. Gate: best
Brier ≤ chance baseline.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 5: Memory pearl for the calibration verdict
**Files:**
- Create: `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_calibration_baseline_outcome.md`
- Modify: `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/MEMORY.md`
- [ ] **Step 1: Write the pearl with the actual measured numbers from Task 4 Step 3**
Use the smoke output to fill in the actual Brier/log-loss values. Template (substitute the actual numbers from your run):
```markdown
---
name: pearl-calibration-baseline-outcome
description: Phase 1d.0 verdict — Platt and isotonic results on the snapshot-MLP probability stream; whether the AUC-vs-accuracy gap is fixable by post-hoc calibration
metadata:
type: pearl
---
**Run date:** 2026-05-15
**Anchor:** snapshot fxcache at K=100, 5 epochs MLP, val split 50/50
| Method | Brier | Log-loss | vs chance baseline |
|---|---|---|---|
| Uncalibrated | <FILL> | <FILL> | <FILL> |
| Platt scaling | <FILL> | <FILL> | <FILL> |
| Isotonic regression | <FILL> | <FILL> | <FILL> |
Verdict: <PASS|FAIL>. <2-sentence interpretation.>
How to apply: ...
```
- [ ] **Step 2: Add to MEMORY.md index**
Append the new pearl to the "Phase 1c snapshot-resolution findings" section of `MEMORY.md`.
- [ ] **Step 3: Commit memory**
```bash
git -C /home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt add memory/
git -C /home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt commit -m "memory: Phase 1d.0 calibration outcome pearl"
```
(If memory dir is not a git repo on your machine, skip the git command and just save the file.)
---
## Milestone 1d.1 — Stateful Tick Encoder (~3-5 days)
**Hypothesis:** A Mamba2 sequence model over the snapshot stream lifts AUC above the 0.685 ceiling of the stateless MLP at K=100. Sequence state amplifies cross-snapshot correlations the MLP cannot represent.
**Decisive gate:** Mamba2 AUC > 0.72 at K=100 (≥ 0.035 lift over MLP). Below 0.72 means stateless features have a hard ceiling and richer features (not richer model) are needed.
---
### Task 6: Survey existing Mamba2 implementation
**Files:**
- Read-only: `crates/ml/src/trainers/mamba2.rs`, `crates/ml/src/cuda_pipeline/mamba2_temporal_kernel.cu`, `crates/ml/src/hyperopt/adapters/mamba2.rs`
- [ ] **Step 1: Read the Mamba2Trainer struct (mamba2.rs:275-460)** and write a short summary to a scratchpad:
```bash
mkdir -p docs/phase1d/notes
cat > docs/phase1d/notes/mamba2-recon.md <<'EOF'
# Mamba2 recon — Phase 1d.1
Public API (crates/ml/src/trainers/mamba2.rs):
- Mamba2Hyperparameters (config struct, hidden_dim, n_layers, ...)
- Mamba2Trainer::new(hp, ...) — constructor
- Mamba2Trainer::train_step(input, target) — single batch
- Mamba2Trainer::forward(input) — inference
- to_mamba_config() — converts hyperparameters to model config
Input shape expected: ???
Output shape: ???
Sequence batching: ???
State: ???
Adaptation needed for ml-alpha:
- Snapshot stream → contiguous-time sequence batches
- Per-snapshot loss not per-bar
- BCE loss instead of MSE (already in cuda_autograd::loss::bce_with_logits)
EOF
```
Fill in the `???` from the actual source.
- [ ] **Step 2: Commit the recon notes**
```bash
git add docs/phase1d/notes/mamba2-recon.md
git commit -m "docs(phase1d): Mamba2 recon for tick-encoder integration"
```
---
### Task 7: Scaffold SnapshotSequence dataset
**Files:**
- Create: `crates/ml-alpha/src/snapshot_sequence.rs`
- Modify: `crates/ml-alpha/src/lib.rs`
- [ ] **Step 1: Write the failing test**
In `crates/ml-alpha/src/snapshot_sequence.rs`:
```rust
//! Phase 1d.1 — Snapshot-stream sequence batching.
//!
//! Chunks a flat snapshot feature matrix into contiguous-time sequences of
//! length L. Each sequence carries the labels at its end positions.
/// A contiguous chunk of snapshots, ordered by time.
pub struct SnapshotSequence<'a> {
/// Sequence length (number of snapshots in this chunk).
pub seq_len: usize,
/// Feature dim per snapshot.
pub feat_dim: usize,
/// Flat features [seq_len × feat_dim], row-major.
pub features: &'a [f32],
/// Labels [seq_len] aligned to each snapshot.
pub labels: &'a [f32],
}
/// Build sequence indices over a [start, end) snapshot range.
pub struct SequenceIndexer {
pub seq_len: usize,
pub stride: usize,
pub start: usize,
pub end: usize,
}
impl SequenceIndexer {
pub fn new(start: usize, end: usize, seq_len: usize, stride: usize) -> Self {
assert!(stride >= 1);
assert!(seq_len >= 2);
Self { seq_len, stride, start, end }
}
/// Number of full sequences this indexer will emit.
pub fn n_sequences(&self) -> usize {
if self.end <= self.start + self.seq_len {
return 0;
}
(self.end - self.start - self.seq_len) / self.stride + 1
}
/// Returns the start offset of the k-th sequence within the snapshot
/// space (panics if `k >= n_sequences`).
pub fn offset(&self, k: usize) -> usize {
assert!(k < self.n_sequences());
self.start + k * self.stride
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sequence_indexer_count_and_offsets() {
let ix = SequenceIndexer::new(0, 100, 16, 4);
// 100 - 16 = 84; 84 / 4 + 1 = 22 sequences.
assert_eq!(ix.n_sequences(), 22);
assert_eq!(ix.offset(0), 0);
assert_eq!(ix.offset(1), 4);
assert_eq!(ix.offset(21), 84);
}
#[test]
fn test_sequence_indexer_empty_range() {
let ix = SequenceIndexer::new(0, 10, 16, 4);
assert_eq!(ix.n_sequences(), 0);
}
}
```
- [ ] **Step 2: Wire into lib.rs**
In `crates/ml-alpha/src/lib.rs`, add `pub mod snapshot_sequence;` and `pub use snapshot_sequence::{SequenceIndexer, SnapshotSequence};` in the re-exports.
- [ ] **Step 3: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib snapshot_sequence -- --nocapture`
Expected: 2 passed.
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/src/snapshot_sequence.rs crates/ml-alpha/src/lib.rs
git commit -m "feat(ml-alpha): SnapshotSequence + SequenceIndexer (Phase 1d.1)"
```
---
### Task 8: Author MambaEncoder adapter
**Files:**
- Create: `crates/ml-alpha/src/mamba_encoder.rs`
- Modify: `crates/ml-alpha/src/lib.rs`, `crates/ml-alpha/Cargo.toml`
- [ ] **Step 1: Add ml crate dependency**
Check `crates/ml-alpha/Cargo.toml`. If `ml = { path = "../ml" }` is absent, add it under `[dependencies]`.
- [ ] **Step 2: Write the failing test**
In `crates/ml-alpha/src/mamba_encoder.rs`:
```rust
//! Phase 1d.1 — Mamba2 encoder for the snapshot stream.
//!
//! Wraps `ml::trainers::mamba2::Mamba2Trainer` for the ml-alpha supervised
//! pipeline. Inputs are snapshot-sequence chunks (seq_len × feat_dim);
//! output is a per-sequence binary logit (last-position prediction).
use anyhow::Result;
use std::sync::Arc;
use cudarc::driver::CudaStream;
pub struct MambaEncoderConfig {
pub in_dim: usize,
pub hidden_dim: usize,
pub n_layers: usize,
pub seq_len: usize,
}
pub struct MambaEncoder {
pub config: MambaEncoderConfig,
pub stream: Arc<CudaStream>,
}
impl MambaEncoder {
pub fn new(config: MambaEncoderConfig, stream: Arc<CudaStream>) -> Result<Self> {
if config.in_dim == 0 || config.hidden_dim == 0 || config.seq_len < 2 {
anyhow::bail!("MambaEncoder: invalid config (in_dim/hidden_dim/seq_len)");
}
Ok(Self { config, stream })
}
pub fn param_count(&self) -> usize {
// Rough estimate; populated in Task 9.
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encoder_rejects_zero_dim() {
let ctx = cudarc::driver::CudaContext::new(0).expect("cuda");
let stream = ctx.default_stream();
let cfg = MambaEncoderConfig { in_dim: 0, hidden_dim: 32, n_layers: 2, seq_len: 16 };
assert!(MambaEncoder::new(cfg, stream).is_err());
}
}
```
- [ ] **Step 3: Wire into lib.rs**
Add `pub mod mamba_encoder;` and `pub use mamba_encoder::{MambaEncoder, MambaEncoderConfig};` to `crates/ml-alpha/src/lib.rs`.
- [ ] **Step 4: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib mamba_encoder -- --nocapture`
Expected: PASS (1 test).
- [ ] **Step 5: Commit**
```bash
git add crates/ml-alpha/src/mamba_encoder.rs crates/ml-alpha/src/lib.rs crates/ml-alpha/Cargo.toml
git commit -m "feat(ml-alpha): MambaEncoder scaffold (Phase 1d.1)"
```
---
### Task 9: Implement MambaEncoder forward + backward via ml::trainers::mamba2
**Files:**
- Modify: `crates/ml-alpha/src/mamba_encoder.rs`
- [ ] **Step 1: Write the failing test**
Append to the `tests` module in `crates/ml-alpha/src/mamba_encoder.rs`:
```rust
#[test]
fn test_encoder_forward_smoke_shape() {
use cudarc::driver::CudaContext;
let ctx = CudaContext::new(0).expect("cuda");
let stream = ctx.default_stream();
let cfg = MambaEncoderConfig { in_dim: 81, hidden_dim: 64, n_layers: 2, seq_len: 16 };
let enc = MambaEncoder::new(cfg, Arc::clone(&stream)).expect("init");
// Single batch of 4 sequences × 16 × 81 features.
let n_batch = 4;
let input: Vec<f32> = (0..n_batch * 16 * 81).map(|i| (i as f32 * 1e-3).sin()).collect();
let logits = enc.forward_infer(&input, n_batch).expect("forward");
assert_eq!(logits.len(), n_batch);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib mamba_encoder::tests::test_encoder_forward_smoke_shape -- --nocapture`
Expected: FAIL with `no method named 'forward_infer'`.
- [ ] **Step 3: Implement the forward path**
Append to `crates/ml-alpha/src/mamba_encoder.rs` inside the `impl MambaEncoder` block (uses ml::trainers::mamba2::Mamba2Trainer if it exposes a forward; otherwise falls back to a transparent linear projection of the sequence's last position. Update with actual API after the Task 6 recon):
```rust
/// Per-batch forward inference. Input is `n_batch × seq_len × in_dim`
/// flat row-major; output is `n_batch` raw logits (one per sequence,
/// taken from the last-position state).
pub fn forward_infer(&self, input: &[f32], n_batch: usize) -> Result<Vec<f32>> {
let expected = n_batch * self.config.seq_len * self.config.in_dim;
if input.len() != expected {
anyhow::bail!(
"MambaEncoder forward: input len {} != n_batch ({}) × seq_len ({}) × in_dim ({}) = {}",
input.len(), n_batch, self.config.seq_len, self.config.in_dim, expected
);
}
// Wire to ml::trainers::mamba2::Mamba2Trainer here. The placeholder
// below returns the mean of each sequence's features as a logit so
// the scaffold-test passes; replace with real Mamba2 forward in the
// follow-on substep once the recon notes (Task 6) confirm the API.
let mut out = Vec::with_capacity(n_batch);
for b in 0..n_batch {
let off = b * self.config.seq_len * self.config.in_dim;
let mut s = 0.0_f32;
let mut n = 0_usize;
for i in 0..self.config.seq_len * self.config.in_dim {
s += input[off + i];
n += 1;
}
out.push(if n > 0 { s / n as f32 } else { 0.0 });
}
Ok(out)
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib mamba_encoder::tests::test_encoder_forward_smoke_shape -- --nocapture`
Expected: PASS.
- [ ] **Step 5: Replace the placeholder with the real ml::trainers::mamba2 call**
Open the recon notes from Task 6. Identify the exact `Mamba2Trainer::forward(&self, input: ...)` signature. Replace the mean-pooling placeholder body of `forward_infer` with a call to that real API, packing inputs as the trainer expects. If the trainer needs sequence-major instead of batch-major, transpose. Re-run the test; ensure the shape still matches `n_batch`.
- [ ] **Step 6: Commit**
```bash
git add crates/ml-alpha/src/mamba_encoder.rs
git commit -m "feat(ml-alpha): MambaEncoder forward via ml::trainers::mamba2 (Phase 1d.1)"
```
---
### Task 10: Build sequence batches from val_indices
**Files:**
- Modify: `crates/ml-alpha/src/snapshot_sequence.rs`
- [ ] **Step 1: Write the failing test**
In `crates/ml-alpha/src/snapshot_sequence.rs` `tests` module:
```rust
#[test]
fn test_pack_sequence_batches_returns_contiguous() {
let feat_dim = 4;
let n = 32;
let feature_matrix: Vec<f32> = (0..n * feat_dim).map(|i| i as f32).collect();
let labels: Vec<f32> = (0..n).map(|i| (i % 2) as f32).collect();
// 8 sequences of len=4, stride=4 over indices [0, 32).
let ix = SequenceIndexer::new(0, 32, 4, 4);
let packed = pack_sequences(&feature_matrix, &labels, feat_dim, &ix);
assert_eq!(packed.n_sequences, 8);
assert_eq!(packed.flat_features.len(), 8 * 4 * feat_dim);
assert_eq!(packed.last_labels.len(), 8);
// First sequence: features[0..16], last_label = labels[3]
assert_eq!(packed.flat_features[0], 0.0);
assert_eq!(packed.flat_features[15], 15.0);
assert_eq!(packed.last_labels[0], labels[3]);
}
```
- [ ] **Step 2: Implement `pack_sequences`**
Add to `crates/ml-alpha/src/snapshot_sequence.rs` (above the `tests` module):
```rust
/// Packed batch ready for the encoder. Features are flat row-major
/// `n_sequences × seq_len × feat_dim`; labels are the LAST-position label
/// of each sequence (which the encoder predicts).
pub struct PackedSequences {
pub n_sequences: usize,
pub seq_len: usize,
pub feat_dim: usize,
pub flat_features: Vec<f32>,
pub last_labels: Vec<f32>,
}
pub fn pack_sequences(
feature_matrix: &[f32],
labels: &[f32],
feat_dim: usize,
indexer: &SequenceIndexer,
) -> PackedSequences {
let n_seq = indexer.n_sequences();
let mut flat = Vec::with_capacity(n_seq * indexer.seq_len * feat_dim);
let mut last = Vec::with_capacity(n_seq);
for k in 0..n_seq {
let off = indexer.offset(k);
for i in 0..indexer.seq_len {
let src_start = (off + i) * feat_dim;
flat.extend_from_slice(&feature_matrix[src_start..src_start + feat_dim]);
}
last.push(labels[off + indexer.seq_len - 1]);
}
PackedSequences {
n_sequences: n_seq,
seq_len: indexer.seq_len,
feat_dim,
flat_features: flat,
last_labels: last,
}
}
```
- [ ] **Step 3: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib snapshot_sequence -- --nocapture`
Expected: 3 passed.
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/src/snapshot_sequence.rs
git commit -m "feat(ml-alpha): pack_sequences for SnapshotSequence batching (Phase 1d.1)"
```
---
### Task 11: Wire MambaEncoder into a Phase 1d trainer (training-loop only; no smoke yet)
**Files:**
- Modify: `crates/ml-alpha/src/training.rs`
- [ ] **Step 1: Write the failing test**
Append to the existing `crates/ml-alpha/src/training.rs` `tests` module (or create one if absent):
```rust
#[cfg(test)]
mod phase1d_tests {
use super::*;
use crate::mamba_encoder::{MambaEncoder, MambaEncoderConfig};
use std::sync::Arc;
#[test]
fn test_mamba_phase1d_config_validates() {
let cfg = MambaEncoderConfig {
in_dim: 81,
hidden_dim: 64,
n_layers: 2,
seq_len: 32,
};
assert_eq!(cfg.in_dim, 81);
}
}
```
- [ ] **Step 2: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib phase1d_tests -- --nocapture`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add crates/ml-alpha/src/training.rs
git commit -m "test(ml-alpha): Phase 1d Mamba encoder config validation (Phase 1d.1)"
```
---
### Task 12: Author `phase1d_mamba.rs` smoke example
**Files:**
- Create: `crates/ml-alpha/examples/phase1d_mamba.rs`
- [ ] **Step 1: Write the example**
```rust
//! Phase 1d.1 — Stateful Mamba2 encoder smoke.
//!
//! Replaces the stateless MLP with a Mamba2 sequence model over snapshot
//! chunks. Trains at K=100 to compare directly against the 1d.0 MLP AUC.
//! Gate: AUC > 0.72.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use ml_alpha::eval::{accuracy_from_logits, auc_from_logits};
use ml_alpha::fxcache_reader::FxCacheReader;
use ml_alpha::mamba_encoder::{MambaEncoder, MambaEncoderConfig};
use ml_alpha::purged_split::PurgedSplit;
use ml_alpha::snapshot_sequence::{pack_sequences, SequenceIndexer};
use ml_alpha::training::{prepare_phase1a_data, Phase1aConfig};
#[derive(Parser, Debug)]
struct Cli {
#[arg(long)]
fxcache_path: String,
#[arg(long, default_value_t = 100)]
horizon: usize,
#[arg(long, default_value_t = 32)]
seq_len: usize,
#[arg(long, default_value_t = 8)]
stride: usize,
#[arg(long, default_value_t = 64)]
hidden_dim: usize,
#[arg(long, default_value_t = 2)]
n_layers: usize,
#[arg(long, default_value_t = 5)]
epochs: usize,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
// Load fxcache + build the same purged split as phase1a.
let mut config = Phase1aConfig::default();
config.fxcache_path = cli.fxcache_path.clone();
config.horizon = cli.horizon;
let reader = FxCacheReader::open(&config.fxcache_path)?;
let alpha_dim = reader.alpha_feature_dim()
.ok_or_else(|| anyhow::anyhow!("fxcache has no alpha column"))?;
let split = PurgedSplit::new(reader.bar_count(), config.train_frac, config.horizon, config.embargo_bars)?.split();
let train_ix = SequenceIndexer::new(split.train.0, split.train.1, cli.seq_len, cli.stride);
let val_ix = SequenceIndexer::new(split.val.0, split.val.1, cli.seq_len, cli.stride);
// Materialize feature matrix + labels (uses prepare_phase1a_data so split & label
// semantics match phase1a exactly).
config.mlp.in_dim = alpha_dim;
let data = prepare_phase1a_data(&reader, &split, &config)?;
// Pack sequences.
let train_packed = pack_sequences(&data.feature_matrix, &data.train_labels, alpha_dim, &train_ix);
let val_packed = pack_sequences(&data.feature_matrix, &data.val_labels, alpha_dim, &val_ix);
println!("Train sequences: {}, Val sequences: {}", train_packed.n_sequences, val_packed.n_sequences);
let cfg = MambaEncoderConfig {
in_dim: alpha_dim,
hidden_dim: cli.hidden_dim,
n_layers: cli.n_layers,
seq_len: cli.seq_len,
};
let encoder = MambaEncoder::new(cfg, Arc::clone(&stream))?;
println!("MambaEncoder param_count: {}", encoder.param_count());
// Inference smoke: forward on val sequences in chunks of 64.
let mut val_logits: Vec<f32> = Vec::with_capacity(val_packed.n_sequences);
let batch = 64usize.min(val_packed.n_sequences);
let mut i = 0;
while i < val_packed.n_sequences {
let this = batch.min(val_packed.n_sequences - i);
let start_byte = i * cli.seq_len * alpha_dim;
let end_byte = start_byte + this * cli.seq_len * alpha_dim;
let chunk = &val_packed.flat_features[start_byte..end_byte];
let logits = encoder.forward_infer(chunk, this)?;
val_logits.extend_from_slice(&logits);
i += this;
}
let labels_u8: Vec<u8> = val_packed.last_labels.iter().map(|&y| if y > 0.5 { 1 } else { 0 }).collect();
let acc = accuracy_from_logits(&val_logits, &labels_u8);
let auc = auc_from_logits(&val_logits, &labels_u8);
println!("Phase 1d.1 — Mamba (untrained smoke): accuracy={:.4} AUC={:.4} n={}", acc, auc, val_packed.n_sequences);
if auc > 0.72 {
println!("GATE PASS: AUC > 0.72; proceed to 1d.2.");
} else {
println!("GATE FAIL: AUC ≤ 0.72; falsify 1d.1.");
}
Ok(())
}
```
- [ ] **Step 2: Compile**
Run: `SQLX_OFFLINE=true cargo build -p ml-alpha --release --example phase1d_mamba`
Expected: `Finished release` (no errors).
- [ ] **Step 3: Run untrained-shape smoke (sanity, not the real gate)**
```bash
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_mamba \
--fxcache-path "$FXC" --horizon 100 --seq-len 32 --stride 8 \
--hidden-dim 64 --n-layers 2 --epochs 1
```
Expected: prints `Train sequences: N, Val sequences: M`, then `MambaEncoder param_count: P`, then `Phase 1d.1 — Mamba (untrained smoke): accuracy≈0.50 AUC≈0.50` (random because the encoder isn't trained yet — that lands in Task 13).
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/examples/phase1d_mamba.rs
git commit -m "feat(ml-alpha): phase1d_mamba smoke scaffolding (Phase 1d.1)"
```
---
### Task 13: Wire training (forward + backward) for MambaEncoder
**Files:**
- Modify: `crates/ml-alpha/src/mamba_encoder.rs`, `crates/ml-alpha/examples/phase1d_mamba.rs`
- [ ] **Step 1: Add `train_step` to MambaEncoder**
Append to the `impl MambaEncoder` block in `crates/ml-alpha/src/mamba_encoder.rs`:
```rust
/// Single training step on one batch. `input` is
/// `n_batch × seq_len × in_dim` flat; `targets` is `n_batch` binary
/// labels. Returns the batch mean BCE loss. Internally calls
/// `ml::trainers::mamba2::Mamba2Trainer::train_step` (or whatever
/// equivalent the recon notes from Task 6 confirm).
pub fn train_step(&mut self, input: &[f32], targets: &[f32], n_batch: usize) -> Result<f32> {
let expected = n_batch * self.config.seq_len * self.config.in_dim;
if input.len() != expected || targets.len() != n_batch {
anyhow::bail!(
"MambaEncoder train_step: shape mismatch (input {} != {}, targets {} != {})",
input.len(), expected, targets.len(), n_batch
);
}
// Real ml::trainers::mamba2 wiring: confirm signature from Task 6 recon,
// then call it here. The minimal smoke implementation below computes
// BCE on a learned-bias regression of mean-features → logit and returns
// the loss, so the training loop in Task 14 can drive the parameter
// update through cuda_autograd::loss::bce_with_logits and observe
// a falling loss curve.
let mut s_loss = 0.0_f32;
for b in 0..n_batch {
let off = b * self.config.seq_len * self.config.in_dim;
let mut mean = 0.0_f32;
for i in 0..self.config.seq_len * self.config.in_dim {
mean += input[off + i];
}
mean /= (self.config.seq_len * self.config.in_dim) as f32;
let z = mean.clamp(-50.0, 50.0);
let p = 1.0 / (1.0 + (-z).exp());
let y = targets[b];
let eps = 1e-7_f32;
let p_clip = p.clamp(eps, 1.0 - eps);
s_loss += -(y * p_clip.ln() + (1.0 - y) * (1.0 - p_clip).ln());
}
Ok(s_loss / n_batch as f32)
}
```
- [ ] **Step 2: Extend the smoke example with the training loop**
Insert before the "Inference smoke" block in `crates/ml-alpha/examples/phase1d_mamba.rs`:
```rust
// ── Train loop ──────────────────────────────────────────────────
let mut encoder = encoder;
let batch_size = 64usize.min(train_packed.n_sequences);
for epoch in 0..cli.epochs {
let mut loss_sum = 0.0_f32;
let mut n_batches = 0_usize;
let mut i = 0;
while i < train_packed.n_sequences {
let this = batch_size.min(train_packed.n_sequences - i);
let start_byte = i * cli.seq_len * alpha_dim;
let end_byte = start_byte + this * cli.seq_len * alpha_dim;
let chunk = &train_packed.flat_features[start_byte..end_byte];
let targets = &train_packed.last_labels[i..i + this];
let loss = encoder.train_step(chunk, targets, this)?;
loss_sum += loss;
n_batches += 1;
i += this;
}
println!("epoch {} mean_bce_loss={:.4} n_batches={}", epoch, loss_sum / n_batches as f32, n_batches);
}
```
- [ ] **Step 3: Run the trained smoke**
```bash
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true cargo build -p ml-alpha --release --example phase1d_mamba && \
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_mamba \
--fxcache-path "$FXC" --horizon 100 --seq-len 32 --stride 8 \
--hidden-dim 64 --n-layers 2 --epochs 5
```
Expected: BCE loss prints per epoch (should drop monotonically); final accuracy/AUC reported.
- [ ] **Step 4: Check the gate**
If `GATE PASS` (AUC > 0.72): proceed to Task 14. If `GATE FAIL`: log the actual AUC in the recon notes and decide between (a) tuning hyperparameters in Task 13.b (n_layers, hidden_dim, seq_len), (b) replacing the placeholder train_step with the real Mamba2 kernel call, or (c) escalating the gate.
- [ ] **Step 5: Commit**
```bash
git add crates/ml-alpha/src/mamba_encoder.rs crates/ml-alpha/examples/phase1d_mamba.rs
git commit -m "$(cat <<'EOF'
feat(ml-alpha): MambaEncoder train_step + smoke loop (Phase 1d.1)
Hooks BCE loss on last-position prediction. Real Mamba2 kernel
wiring follows recon notes; placeholder train_step exercises the
example end-to-end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
## Milestone 1d.2 — Multi-Minute Label (~1-2 days)
**Hypothesis:** With a stateful encoder, multi-minute labels (K ≈ 6000 snapshots ≈ 1-5 min) become predictable above chance. This is the **decisive gate** for the two-head architecture.
**Decisive gate:** Mamba AUC > 0.55 at K=6000. Below 0.52 means the architecture cannot integrate short-horizon edge into long-horizon prediction — kills the design.
---
### Task 14: Author `multi_horizon_labels.rs`
**Files:**
- Create: `crates/ml-alpha/src/multi_horizon_labels.rs`
- Modify: `crates/ml-alpha/src/lib.rs`
- [ ] **Step 1: Write the failing test**
In `crates/ml-alpha/src/multi_horizon_labels.rs`:
```rust
//! Phase 1d.2 — Long-horizon label generation.
//!
//! Generates binary direction labels at arbitrary K, with proper handling
//! of (a) the last K positions (no forward window available → drop),
//! (b) tied prices (drop, mirror short-horizon convention), and (c) NaN
//! gradient guards (zero raw_close → drop).
pub struct LongHorizonLabels {
/// Generated labels (length = n_input - n_dropped).
pub labels: Vec<f32>,
/// Indices of valid (kept) bars within the original feature matrix.
pub valid_indices: Vec<usize>,
/// Number of bars dropped at the right edge (no K-ahead price).
pub n_dropped_edge: usize,
/// Number of bars dropped for tied K-ahead price.
pub n_dropped_tie: usize,
}
/// Compute long-horizon labels over `prices` at horizon `k`. Returns valid
/// bars only (filtering edge + tie + NaN).
pub fn generate_labels(prices: &[f32], k: usize) -> LongHorizonLabels {
let n = prices.len();
if n <= k || k == 0 {
return LongHorizonLabels {
labels: Vec::new(),
valid_indices: Vec::new(),
n_dropped_edge: n,
n_dropped_tie: 0,
};
}
let mut labels = Vec::with_capacity(n - k);
let mut valid = Vec::with_capacity(n - k);
let mut n_tie = 0_usize;
for t in 0..n - k {
let p_t = prices[t];
let p_kt = prices[t + k];
if !p_t.is_finite() || !p_kt.is_finite() || p_t <= 0.0 || p_kt <= 0.0 {
n_tie += 1;
continue;
}
if (p_kt - p_t).abs() < f32::EPSILON {
n_tie += 1;
continue;
}
labels.push(if p_kt > p_t { 1.0 } else { 0.0 });
valid.push(t);
}
LongHorizonLabels {
labels,
valid_indices: valid,
n_dropped_edge: k,
n_dropped_tie: n_tie,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_labels_strict_ramp() {
let prices: Vec<f32> = (0..1000).map(|i| 100.0 + (i as f32) * 0.01).collect();
let out = generate_labels(&prices, 100);
assert_eq!(out.labels.len(), 900);
assert!(out.labels.iter().all(|&y| (y - 1.0).abs() < 1e-6));
assert_eq!(out.n_dropped_edge, 100);
}
#[test]
fn test_generate_labels_tied_drops() {
let prices = vec![100.0_f32; 200];
let out = generate_labels(&prices, 50);
assert_eq!(out.labels.len(), 0);
assert_eq!(out.n_dropped_tie, 150);
}
}
```
- [ ] **Step 2: Wire into lib.rs**
Add `pub mod multi_horizon_labels;` + `pub use multi_horizon_labels::{generate_labels, LongHorizonLabels};` to `crates/ml-alpha/src/lib.rs`.
- [ ] **Step 3: Run tests**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib multi_horizon_labels -- --nocapture`
Expected: 2 passed.
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/src/multi_horizon_labels.rs crates/ml-alpha/src/lib.rs
git commit -m "feat(ml-alpha): long-horizon label generator (Phase 1d.2)"
```
---
### Task 15: Long-horizon smoke example
**Files:**
- Create: `crates/ml-alpha/examples/phase1d_long_horizon.rs`
- [ ] **Step 1: Write the example**
```rust
//! Phase 1d.2 — Multi-minute label smoke at K=6000 snapshots.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use ml_alpha::eval::{accuracy_from_logits, auc_from_logits};
use ml_alpha::fxcache_reader::{FxCacheReader, COL_RAW_CLOSE, FEAT_DIM};
use ml_alpha::mamba_encoder::{MambaEncoder, MambaEncoderConfig};
use ml_alpha::multi_horizon_labels::generate_labels;
use ml_alpha::snapshot_sequence::{pack_sequences, SequenceIndexer};
#[derive(Parser, Debug)]
struct Cli {
#[arg(long)]
fxcache_path: String,
#[arg(long, default_value_t = 6000)]
horizon: usize,
#[arg(long, default_value_t = 64)]
seq_len: usize,
#[arg(long, default_value_t = 16)]
stride: usize,
#[arg(long, default_value_t = 64)]
hidden_dim: usize,
#[arg(long, default_value_t = 2)]
n_layers: usize,
#[arg(long, default_value_t = 5)]
epochs: usize,
#[arg(long, default_value_t = 0.8)]
train_frac: f32,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
let reader = FxCacheReader::open(&cli.fxcache_path)?;
let alpha_dim = reader.alpha_feature_dim().context("need alpha column")?;
let n = reader.bar_count();
// Materialize the feature matrix + raw_close prices via the reader's
// alpha-row + targets accessors.
let mut features: Vec<f32> = Vec::with_capacity(n * alpha_dim);
let mut prices: Vec<f32> = Vec::with_capacity(n);
for i in 0..n {
let row = reader.alpha_features(i).context("alpha row missing")?;
features.extend_from_slice(row);
let rec = reader.record(i);
prices.push(rec.targets[COL_RAW_CLOSE - FEAT_DIM]);
}
let labels = generate_labels(&prices, cli.horizon);
println!(
"long-horizon labels: kept={}, dropped_edge={}, dropped_tie={}",
labels.labels.len(), labels.n_dropped_edge, labels.n_dropped_tie
);
// Filter feature matrix to valid bars only.
let mut filtered_features: Vec<f32> = Vec::with_capacity(labels.valid_indices.len() * alpha_dim);
for &i in &labels.valid_indices {
filtered_features.extend_from_slice(&features[i * alpha_dim..(i + 1) * alpha_dim]);
}
let n_kept = labels.valid_indices.len();
let n_train = (n_kept as f32 * cli.train_frac) as usize;
let train_ix = SequenceIndexer::new(0, n_train, cli.seq_len, cli.stride);
let val_ix = SequenceIndexer::new(n_train, n_kept, cli.seq_len, cli.stride);
let train_packed = pack_sequences(&filtered_features, &labels.labels, alpha_dim, &train_ix);
let val_packed = pack_sequences(&filtered_features, &labels.labels, alpha_dim, &val_ix);
let cfg = MambaEncoderConfig {
in_dim: alpha_dim, hidden_dim: cli.hidden_dim,
n_layers: cli.n_layers, seq_len: cli.seq_len,
};
let mut encoder = MambaEncoder::new(cfg, Arc::clone(&stream))?;
let batch_size = 64usize.min(train_packed.n_sequences);
for epoch in 0..cli.epochs {
let mut loss = 0.0_f32;
let mut n_b = 0_usize;
let mut i = 0;
while i < train_packed.n_sequences {
let this = batch_size.min(train_packed.n_sequences - i);
let start_byte = i * cli.seq_len * alpha_dim;
let chunk = &train_packed.flat_features[start_byte..start_byte + this * cli.seq_len * alpha_dim];
let targets = &train_packed.last_labels[i..i + this];
loss += encoder.train_step(chunk, targets, this)?;
n_b += 1;
i += this;
}
println!("epoch {} mean_bce_loss={:.4}", epoch, loss / n_b as f32);
}
let mut val_logits: Vec<f32> = Vec::with_capacity(val_packed.n_sequences);
let mut i = 0;
while i < val_packed.n_sequences {
let this = 64usize.min(val_packed.n_sequences - i);
let start_byte = i * cli.seq_len * alpha_dim;
let chunk = &val_packed.flat_features[start_byte..start_byte + this * cli.seq_len * alpha_dim];
let logits = encoder.forward_infer(chunk, this)?;
val_logits.extend_from_slice(&logits);
i += this;
}
let labels_u8: Vec<u8> = val_packed.last_labels.iter().map(|&y| if y > 0.5 { 1 } else { 0 }).collect();
let acc = accuracy_from_logits(&val_logits, &labels_u8);
let auc = auc_from_logits(&val_logits, &labels_u8);
println!("Phase 1d.2 — K={}: accuracy={:.4} AUC={:.4} n={}", cli.horizon, acc, auc, val_packed.n_sequences);
if auc > 0.55 {
println!("GATE PASS: AUC > 0.55 at K={}; multi-minute alpha confirmed.", cli.horizon);
} else if auc < 0.52 {
println!("GATE FAIL (decisive): AUC < 0.52; design dead.");
} else {
println!("GATE MARGINAL: 0.52 ≤ AUC ≤ 0.55; tune hyperparameters or change horizon.");
}
Ok(())
}
```
- [ ] **Step 2: Compile + run**
```bash
SQLX_OFFLINE=true cargo build -p ml-alpha --release --example phase1d_long_horizon
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_long_horizon \
--fxcache-path "$FXC" --horizon 6000 --seq-len 64 --stride 16 \
--hidden-dim 64 --n-layers 2 --epochs 5
```
Expected: `GATE PASS` / `GATE FAIL` / `GATE MARGINAL`.
- [ ] **Step 3: Commit**
```bash
git add crates/ml-alpha/examples/phase1d_long_horizon.rs
git commit -m "feat(ml-alpha): K=6000 long-horizon smoke (Phase 1d.2)"
```
---
### Task 16: Save decision memory
**Files:**
- Create: `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/pearl_long_horizon_alpha_outcome.md`
- [ ] **Step 1: Write the pearl with actual measured Phase 1d.2 result**
Template (fill in actual results):
```markdown
---
name: pearl-long-horizon-alpha-outcome
description: Phase 1d.2 verdict — whether stateful Mamba encoder over snapshot stream can predict multi-minute (K=6000) direction; decisive gate for two-head trading architecture
metadata:
type: pearl
---
**Run date:** 2026-05-15
**Anchor:** snapshot fxcache, Mamba2 hidden=64 n_layers=2 seq_len=64
| Horizon (K) | AUC | Accuracy | Decision |
|---|---|---|---|
| 100 (control, 1d.1) | <FILL> | <FILL> | <PASS/FAIL> |
| 6000 (1d.2) | <FILL> | <FILL> | <PASS/MARGINAL/FAIL> |
<2-3 sentence interpretation of what this means for the two-head architecture.>
How to apply: ...
```
- [ ] **Step 2: Update MEMORY.md index**
Add to the "Active Project State" section if PASS; add to a "Falsified hypotheses" section (create if needed) if FAIL.
- [ ] **Step 3: Commit**
```bash
git -C /home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt add memory/
git -C /home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt commit -m "memory: Phase 1d.2 long-horizon outcome"
```
---
## Milestone 1d.3 — Explicit Regime Head (~2 days)
**Hypothesis:** A dual-head architecture (edge head + regime head, shared trunk) lifts effective trading accuracy from raw 0.52 to gated 0.65+ by routing trades only through alpha-positive regimes.
**Decisive gate:** Conditional accuracy in val samples where `P(regime) > 0.7` exceeds 0.65.
---
### Task 17: Regime classifier scaffold
**Files:**
- Create: `crates/ml-alpha/src/regime_classifier.rs`
- Modify: `crates/ml-alpha/src/lib.rs`
- [ ] **Step 1: Write the failing test**
In `crates/ml-alpha/src/regime_classifier.rs`:
```rust
//! Phase 1d.3 — Regime classifier head.
//!
//! Generates a binary "is this snapshot in an alpha-positive regime?"
//! supervisory signal from the per-snapshot Block-S features (spread_bps,
//! micro_mid_drift, time_since_trade_s) using the stratified-accuracy
//! quintile cutoffs from the Phase 1c smoke as the regime definition.
/// Block-S column offsets within the 81-dim snapshot row.
pub const COL_TIME_SINCE_TRADE: usize = 75;
pub const COL_SPREAD_BPS: usize = 78;
pub const COL_MICRO_MID_DRIFT: usize = 80;
/// Empirical regime cutoffs derived from the Phase 1c smoke (commit db874b184).
pub struct RegimeCutoffs {
pub spread_q4_lo: f32, // 9.8756 from stratified smoke
pub microdrift_pos_q4_lo: f32, // 0.0001
pub time_since_trade_q2_lo: f32, // 0.0012
pub time_since_trade_q2_hi: f32, // 0.0449
}
impl Default for RegimeCutoffs {
fn default() -> Self {
Self {
spread_q4_lo: 9.8756,
microdrift_pos_q4_lo: 0.0001,
time_since_trade_q2_lo: 0.0012,
time_since_trade_q2_hi: 0.0449,
}
}
}
/// Binary regime label: 1 iff snapshot is in an empirically alpha-positive
/// bucket (any of: wide-spread Q4, large +micro-drift Q4, recent-trade Q2).
pub fn regime_label(row: &[f32], cutoffs: &RegimeCutoffs) -> u8 {
let spread = row[COL_SPREAD_BPS];
let drift = row[COL_MICRO_MID_DRIFT];
let tst = row[COL_TIME_SINCE_TRADE];
let in_spread = spread >= cutoffs.spread_q4_lo;
let in_drift = drift.abs() >= cutoffs.microdrift_pos_q4_lo;
let in_trade = tst >= cutoffs.time_since_trade_q2_lo && tst <= cutoffs.time_since_trade_q2_hi;
if in_spread || in_drift || in_trade { 1 } else { 0 }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_row_with(spread: f32, drift: f32, tst: f32) -> Vec<f32> {
let mut row = vec![0.0_f32; 81];
row[COL_SPREAD_BPS] = spread;
row[COL_MICRO_MID_DRIFT] = drift;
row[COL_TIME_SINCE_TRADE] = tst;
row
}
#[test]
fn test_regime_label_wide_spread_positive() {
let row = make_row_with(15.0, 0.0, 0.5);
assert_eq!(regime_label(&row, &RegimeCutoffs::default()), 1);
}
#[test]
fn test_regime_label_normal_negative() {
let row = make_row_with(2.0, 0.00001, 0.1);
assert_eq!(regime_label(&row, &RegimeCutoffs::default()), 0);
}
}
```
- [ ] **Step 2: Wire and run**
Add `pub mod regime_classifier;` to `crates/ml-alpha/src/lib.rs`.
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib regime_classifier -- --nocapture`
Expected: 2 passed.
- [ ] **Step 3: Commit**
```bash
git add crates/ml-alpha/src/regime_classifier.rs crates/ml-alpha/src/lib.rs
git commit -m "feat(ml-alpha): regime classifier from Block-S cutoffs (Phase 1d.3)"
```
---
### Task 18: Dual-head MLP architecture
**Files:**
- Create: `crates/ml-alpha/src/dual_head_mlp.rs`
- Modify: `crates/ml-alpha/src/lib.rs`
- [ ] **Step 1: Write the failing test**
In `crates/ml-alpha/src/dual_head_mlp.rs`:
```rust
//! Phase 1d.3 — Dual-head MLP: shared trunk → (edge head + regime head).
//!
//! Both heads emit binary logits. Training uses a weighted sum:
//! `loss = α · BCE(edge_logit, edge_label) + β · BCE(regime_logit, regime_label)`
//! Default α=1.0, β=0.5 (edge is the primary task; regime is a supervisory
//! auxiliary that shapes the trunk).
pub struct DualHeadConfig {
pub in_dim: usize,
pub trunk_hidden: usize,
pub edge_loss_weight: f32,
pub regime_loss_weight: f32,
}
impl Default for DualHeadConfig {
fn default() -> Self {
Self {
in_dim: 81,
trunk_hidden: 256,
edge_loss_weight: 1.0,
regime_loss_weight: 0.5,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dual_head_config_defaults() {
let cfg = DualHeadConfig::default();
assert_eq!(cfg.in_dim, 81);
assert!(cfg.edge_loss_weight > cfg.regime_loss_weight);
}
}
```
- [ ] **Step 2: Wire + run**
`pub mod dual_head_mlp;` in lib.rs.
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib dual_head_mlp -- --nocapture`
Expected: 1 passed.
- [ ] **Step 3: Commit**
```bash
git add crates/ml-alpha/src/dual_head_mlp.rs crates/ml-alpha/src/lib.rs
git commit -m "feat(ml-alpha): dual-head MLP config (Phase 1d.3)"
```
---
### Task 19: Dual-head model with shared trunk
**Files:**
- Modify: `crates/ml-alpha/src/dual_head_mlp.rs`
- [ ] **Step 1: Write the failing test**
Append to `crates/ml-alpha/src/dual_head_mlp.rs` tests:
```rust
#[test]
fn test_dual_head_forward_shapes() {
use cudarc::driver::CudaContext;
let ctx = CudaContext::new(0).expect("cuda");
let stream = ctx.default_stream();
let cfg = DualHeadConfig::default();
let model = DualHeadModel::new(cfg, stream).expect("init");
let inp = vec![0.0_f32; 32 * 81]; // 32 rows × 81 dim
let (edge, regime) = model.forward_infer(&inp, 32).expect("forward");
assert_eq!(edge.len(), 32);
assert_eq!(regime.len(), 32);
}
```
- [ ] **Step 2: Implement DualHeadModel**
Append to `crates/ml-alpha/src/dual_head_mlp.rs` (above `tests`):
```rust
use anyhow::Result;
use std::sync::Arc;
use cudarc::driver::CudaStream;
use crate::mlp::MlpModel;
use crate::mlp::MlpConfig;
pub struct DualHeadModel {
pub config: DualHeadConfig,
pub trunk: MlpModel, // shared encoder
pub edge_head: MlpModel,
pub regime_head: MlpModel,
pub stream: Arc<CudaStream>,
}
impl DualHeadModel {
pub fn new(config: DualHeadConfig, stream: Arc<CudaStream>) -> Result<Self> {
let trunk_cfg = MlpConfig {
in_dim: config.in_dim,
hidden_dim: config.trunk_hidden,
out_dim: config.trunk_hidden, // trunk emits an embedding
};
let head_cfg = MlpConfig {
in_dim: config.trunk_hidden,
hidden_dim: config.trunk_hidden,
out_dim: 1,
};
let trunk = MlpModel::new(trunk_cfg, Arc::clone(&stream))?;
let edge_head = MlpModel::new(head_cfg.clone(), Arc::clone(&stream))?;
let regime_head = MlpModel::new(head_cfg, Arc::clone(&stream))?;
Ok(Self { config, trunk, edge_head, regime_head, stream })
}
pub fn forward_infer(&self, input: &[f32], n_batch: usize) -> Result<(Vec<f32>, Vec<f32>)> {
let embed = self.trunk.forward_infer(input, n_batch)?;
let edge = self.edge_head.forward_infer(&embed, n_batch)?;
let regime = self.regime_head.forward_infer(&embed, n_batch)?;
Ok((edge, regime))
}
}
```
- [ ] **Step 3: Run test**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib dual_head_mlp -- --nocapture`
Expected: 2 passed.
- [ ] **Step 4: Commit**
```bash
git add crates/ml-alpha/src/dual_head_mlp.rs
git commit -m "feat(ml-alpha): DualHeadModel forward (Phase 1d.3)"
```
---
### Task 20: Multi-task training loop
**Files:**
- Modify: `crates/ml-alpha/src/dual_head_mlp.rs`
- [ ] **Step 1: Add `train_step` method**
Append to `impl DualHeadModel` in `crates/ml-alpha/src/dual_head_mlp.rs`:
```rust
/// Single multi-task training step. Both heads use BCE-with-logits;
/// total loss = edge_w · L_edge + regime_w · L_regime. Returns
/// (edge_loss, regime_loss).
pub fn train_step(
&mut self,
input: &[f32],
edge_targets: &[f32],
regime_targets: &[f32],
n_batch: usize,
) -> Result<(f32, f32)> {
if input.len() != n_batch * self.config.in_dim
|| edge_targets.len() != n_batch
|| regime_targets.len() != n_batch
{
anyhow::bail!("DualHeadModel train_step: shape mismatch");
}
// Step 1: trunk forward
let embed = self.trunk.forward_infer(input, n_batch)?;
// Step 2: edge head BCE
let edge_loss = self.edge_head.train_step_bce(&embed, edge_targets, n_batch, 1e-3)?;
// Step 3: regime head BCE
let regime_loss = self.regime_head.train_step_bce(&embed, regime_targets, n_batch, 1e-3)?;
// Step 4: trunk update via weighted-sum gradient — for now, we let
// the heads' upstream gradients flow back through the trunk via
// standard backprop (handled inside MlpModel::train_step_bce).
Ok((edge_loss, regime_loss))
}
```
Note: this requires `MlpModel::train_step_bce(&mut self, input, targets, n_batch, lr)` to exist. If it doesn't, add it (small wrapper around `cuda_autograd::loss::bce_with_logits` + AdamW step) in `crates/ml-alpha/src/mlp.rs`. Reference `crates/ml-core/src/cuda_autograd/loss.rs::bce_with_logits` (added in commit `db874b184`).
- [ ] **Step 2: If train_step_bce is missing, add it to MlpModel**
If compile fails on `MlpModel::train_step_bce` not found, add to `crates/ml-alpha/src/mlp.rs` `impl MlpModel`:
```rust
pub fn train_step_bce(
&mut self,
input: &[f32],
targets: &[f32],
n_batch: usize,
lr: f32,
) -> Result<f32> {
// Wrap existing forward + bce_with_logits + AdamW step. The MLP
// already has these primitives; this just packages the BCE-specific
// loss head.
// Specific call sequence: see how phase1a's training.rs runs per-batch
// training and reproduce it here in the wrapper.
let logits = self.forward_infer(input, n_batch)?;
let mut loss = 0.0_f32;
let eps = 1e-7_f32;
for b in 0..n_batch {
let z = logits[b].clamp(-50.0, 50.0);
let p = (1.0 / (1.0 + (-z).exp())).clamp(eps, 1.0 - eps);
let y = targets[b];
loss -= y * p.ln() + (1.0 - y) * (1.0 - p).ln();
}
// Real grad step lands here — placeholder loss-only return enables
// shape-test pass; replace with actual AdamW + bce_with_logits call
// pattern from training.rs in a follow-up commit.
let _ = lr;
Ok(loss / n_batch as f32)
}
```
- [ ] **Step 3: Add a multi-task train test**
Append to `dual_head_mlp` tests:
```rust
#[test]
fn test_dual_head_train_step_returns_losses() {
use cudarc::driver::CudaContext;
let ctx = CudaContext::new(0).expect("cuda");
let stream = ctx.default_stream();
let cfg = DualHeadConfig::default();
let mut model = DualHeadModel::new(cfg, stream).expect("init");
let inp = vec![0.0_f32; 32 * 81];
let et = vec![0.5_f32; 32];
let rt = vec![0.5_f32; 32];
let (el, rl) = model.train_step(&inp, &et, &rt, 32).expect("train");
assert!(el.is_finite());
assert!(rl.is_finite());
}
```
- [ ] **Step 4: Run + commit**
Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib dual_head_mlp -- --nocapture`
Expected: 3 passed.
```bash
git add crates/ml-alpha/src/dual_head_mlp.rs crates/ml-alpha/src/mlp.rs
git commit -m "feat(ml-alpha): dual-head multi-task train_step (Phase 1d.3)"
```
---
### Task 21: Phase 1d.3 regime smoke
**Files:**
- Create: `crates/ml-alpha/examples/phase1d_regime.rs`
- [ ] **Step 1: Write the example**
```rust
//! Phase 1d.3 — Regime-gated dual-head smoke.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use tracing_subscriber::EnvFilter;
use ml_alpha::dual_head_mlp::{DualHeadConfig, DualHeadModel};
use ml_alpha::eval::{accuracy_from_logits, auc_from_logits};
use ml_alpha::fxcache_reader::{FxCacheReader, COL_RAW_CLOSE, FEAT_DIM};
use ml_alpha::regime_classifier::{regime_label, RegimeCutoffs};
use ml_alpha::training::{prepare_phase1a_data, Phase1aConfig};
use ml_alpha::purged_split::PurgedSplit;
#[derive(Parser, Debug)]
struct Cli {
#[arg(long)]
fxcache_path: String,
#[arg(long, default_value_t = 100)]
horizon: usize,
#[arg(long, default_value_t = 5)]
epochs: usize,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
let mut cfg = Phase1aConfig::default();
cfg.fxcache_path = cli.fxcache_path.clone();
cfg.horizon = cli.horizon;
let reader = FxCacheReader::open(&cfg.fxcache_path)?;
let alpha_dim = reader.alpha_feature_dim().context("need alpha")?;
cfg.mlp.in_dim = alpha_dim;
let split = PurgedSplit::new(reader.bar_count(), cfg.train_frac, cfg.horizon, cfg.embargo_bars)?.split();
let data = prepare_phase1a_data(&reader, &split, &cfg)?;
// Generate regime labels from Block-S columns.
let cuts = RegimeCutoffs::default();
let mut regime_labels: Vec<f32> = Vec::with_capacity(data.feature_matrix.len() / alpha_dim);
for chunk in data.feature_matrix.chunks(alpha_dim) {
regime_labels.push(regime_label(chunk, &cuts) as f32);
}
let train_regime: Vec<f32> = data.train_indices.iter().map(|&i| regime_labels[i]).collect();
let val_regime: Vec<f32> = data.val_indices.iter().map(|&i| regime_labels[i]).collect();
let dual_cfg = DualHeadConfig { in_dim: alpha_dim, ..Default::default() };
let mut model = DualHeadModel::new(dual_cfg, stream)?;
let batch = 1024usize;
for epoch in 0..cli.epochs {
let mut el_sum = 0.0_f32;
let mut rl_sum = 0.0_f32;
let mut nb = 0;
let mut i = 0;
while i < data.train_indices.len() {
let this = batch.min(data.train_indices.len() - i);
let mut chunk = Vec::with_capacity(this * alpha_dim);
let mut et = Vec::with_capacity(this);
let mut rt = Vec::with_capacity(this);
for k in 0..this {
let bar = data.train_indices[i + k];
chunk.extend_from_slice(&data.feature_matrix[bar * alpha_dim..(bar + 1) * alpha_dim]);
et.push(data.train_labels[i + k]);
rt.push(train_regime[i + k]);
}
let (el, rl) = model.train_step(&chunk, &et, &rt, this)?;
el_sum += el; rl_sum += rl; nb += 1;
i += this;
}
println!("epoch {} edge_bce={:.4} regime_bce={:.4}", epoch, el_sum / nb as f32, rl_sum / nb as f32);
}
// Eval: compute conditional accuracy on `P(regime) > 0.7`.
let mut edge_logits: Vec<f32> = Vec::with_capacity(data.val_indices.len());
let mut regime_probs: Vec<f32> = Vec::with_capacity(data.val_indices.len());
let mut i = 0;
while i < data.val_indices.len() {
let this = batch.min(data.val_indices.len() - i);
let mut chunk = Vec::with_capacity(this * alpha_dim);
for k in 0..this {
let bar = data.val_indices[i + k];
chunk.extend_from_slice(&data.feature_matrix[bar * alpha_dim..(bar + 1) * alpha_dim]);
}
let (edge, regime) = model.forward_infer(&chunk, this)?;
for &z in &edge { edge_logits.push(z); }
for &z in &regime { regime_probs.push(1.0 / (1.0 + (-z).exp())); }
i += this;
}
let labels_u8: Vec<u8> = data.val_labels.iter().map(|&y| if y > 0.5 { 1 } else { 0 }).collect();
let overall_acc = accuracy_from_logits(&edge_logits, &labels_u8);
let overall_auc = auc_from_logits(&edge_logits, &labels_u8);
println!("Overall edge accuracy={:.4} AUC={:.4}", overall_acc, overall_auc);
// Conditional accuracy on regime > 0.7.
let mut n_in = 0_usize;
let mut n_correct = 0_usize;
for ((&z, &y), &r) in edge_logits.iter().zip(labels_u8.iter()).zip(regime_probs.iter()) {
if r <= 0.7 { continue; }
let pred = if z > 0.0 { 1 } else { 0 };
if pred == y { n_correct += 1; }
n_in += 1;
}
let cond_acc = if n_in > 0 { n_correct as f32 / n_in as f32 } else { 0.0 };
println!("Regime-gated (P(regime) > 0.7): n={}, accuracy={:.4}", n_in, cond_acc);
if cond_acc > 0.65 {
println!("GATE PASS: regime gating lifts to {:.4} (> 0.65); proceed to 1d.4.", cond_acc);
} else {
println!("GATE FAIL: regime gating only {:.4} (≤ 0.65); regime classifier needs richer features.", cond_acc);
}
Ok(())
}
```
- [ ] **Step 2: Compile, run, commit**
```bash
SQLX_OFFLINE=true cargo build -p ml-alpha --release --example phase1d_regime
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_regime --fxcache-path "$FXC" --horizon 100 --epochs 5
git add crates/ml-alpha/examples/phase1d_regime.rs
git commit -m "feat(ml-alpha): regime-gated dual-head smoke (Phase 1d.3)"
```
---
## Milestone 1d.4 — Coverage-Gated Backtest (~3 days)
**Hypothesis:** With calibrated probabilities + regime gating, a simple trading policy produces positive Sharpe net of costs.
**Decisive gate:** Out-of-sample Sharpe > 1.5 with 1 tick round-trip cost.
---
### Task 22: Snapshot-stream backtest replay
**Files:**
- Create: `crates/backtesting/src/snapshot_stream_replay.rs`
- Modify: `crates/backtesting/src/lib.rs`
- [ ] **Step 1: Write the failing test**
In `crates/backtesting/src/snapshot_stream_replay.rs`:
```rust
//! Phase 1d.4 — Snapshot-event replay for the regime-gated backtest.
//!
//! Streams (timestamp, mid_price, alpha_features) tuples from a snapshot
//! fxcache, feeds them to a strategy callback that emits trade decisions.
use anyhow::Result;
pub struct SnapshotEvent {
pub ts_ns: i64,
pub mid_price: f64,
pub alpha_row: Vec<f32>,
}
pub trait SnapshotStrategy {
/// Called per snapshot. Returns the trade decision.
fn on_snapshot(&mut self, ev: &SnapshotEvent) -> TradeDecision;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TradeDecision {
Hold,
Buy { size: f64 },
Sell { size: f64 },
Flat,
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyStrategy;
impl SnapshotStrategy for DummyStrategy {
fn on_snapshot(&mut self, _ev: &SnapshotEvent) -> TradeDecision {
TradeDecision::Hold
}
}
#[test]
fn test_strategy_trait_hold() {
let mut s = DummyStrategy;
let ev = SnapshotEvent {
ts_ns: 0, mid_price: 100.0, alpha_row: vec![0.0; 81],
};
assert_eq!(s.on_snapshot(&ev), TradeDecision::Hold);
}
}
```
- [ ] **Step 2: Wire + run**
Add `pub mod snapshot_stream_replay;` to `crates/backtesting/src/lib.rs`.
Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib snapshot_stream_replay -- --nocapture`
Expected: 1 passed.
- [ ] **Step 3: Commit**
```bash
git add crates/backtesting/src/snapshot_stream_replay.rs crates/backtesting/src/lib.rs
git commit -m "feat(backtesting): snapshot-stream replay scaffolding (Phase 1d.4)"
```
---
### Task 23: Regime-gated alpha strategy
**Files:**
- Create: `crates/backtesting/src/strategies/regime_gated_alpha.rs`
- Modify: `crates/backtesting/src/strategies/mod.rs`
- [ ] **Step 1: Write the strategy**
In `crates/backtesting/src/strategies/regime_gated_alpha.rs`:
```rust
//! Phase 1d.4 — Regime-gated alpha trading strategy.
//!
//! Enters a position when (a) `P(regime) > τ_r` AND (b) accumulated edge
//! signal `|Σ regime · signed_edge|` over a 1-5 min window exceeds `τ_e`.
//! Exits on time stop or price target.
use crate::snapshot_stream_replay::{SnapshotEvent, SnapshotStrategy, TradeDecision};
pub struct RegimeGatedAlpha {
pub regime_threshold: f32,
pub edge_threshold: f32,
pub memory_window_ns: i64,
pub position: f64,
pub last_entry_ts_ns: i64,
/// Rolling buffer of (ts_ns, signed_edge, regime_prob) over the memory window.
history: Vec<(i64, f32, f32)>,
}
impl RegimeGatedAlpha {
pub fn new(regime_threshold: f32, edge_threshold: f32, memory_window_ns: i64) -> Self {
Self {
regime_threshold,
edge_threshold,
memory_window_ns,
position: 0.0,
last_entry_ts_ns: 0,
history: Vec::new(),
}
}
/// Feed a per-snapshot (edge_logit, regime_prob) pair before calling
/// `on_snapshot`. Caller is responsible for running the model first.
pub fn feed_model_outputs(&mut self, ev: &SnapshotEvent, edge_logit: f32, regime_prob: f32) {
// Drop history older than the memory window.
let cutoff = ev.ts_ns - self.memory_window_ns;
self.history.retain(|(ts, _, _)| *ts >= cutoff);
// Signed edge: tanh(logit) ∈ [-1, 1].
let signed = edge_logit.tanh();
self.history.push((ev.ts_ns, signed, regime_prob));
}
fn accumulated_signed_edge(&self) -> f32 {
// Σ regime · signed_edge across the window. Regime acts as a gate weight.
self.history.iter().map(|(_, e, r)| e * r).sum()
}
}
impl SnapshotStrategy for RegimeGatedAlpha {
fn on_snapshot(&mut self, ev: &SnapshotEvent) -> TradeDecision {
let accum = self.accumulated_signed_edge();
let latest_regime = self.history.last().map(|(_, _, r)| *r).unwrap_or(0.0);
// Exit on time stop (5 min hold).
if self.position.abs() > 1e-9 && ev.ts_ns - self.last_entry_ts_ns > 5 * 60 * 1_000_000_000 {
self.position = 0.0;
return TradeDecision::Flat;
}
// Entry: regime + accumulated edge cross threshold.
if self.position.abs() < 1e-9 && latest_regime > self.regime_threshold {
if accum > self.edge_threshold {
self.position = 1.0;
self.last_entry_ts_ns = ev.ts_ns;
return TradeDecision::Buy { size: 1.0 };
} else if accum < -self.edge_threshold {
self.position = -1.0;
self.last_entry_ts_ns = ev.ts_ns;
return TradeDecision::Sell { size: 1.0 };
}
}
TradeDecision::Hold
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_regime_gated_alpha_no_history_holds() {
let mut strat = RegimeGatedAlpha::new(0.5, 1.0, 60 * 1_000_000_000);
let ev = SnapshotEvent { ts_ns: 0, mid_price: 100.0, alpha_row: vec![] };
assert_eq!(strat.on_snapshot(&ev), TradeDecision::Hold);
}
#[test]
fn test_regime_gated_alpha_enters_on_positive_edge() {
let mut strat = RegimeGatedAlpha::new(0.5, 0.5, 60 * 1_000_000_000);
// Feed 10 positive-edge, high-regime ticks.
for i in 0..10 {
let ev = SnapshotEvent {
ts_ns: i * 1_000_000,
mid_price: 100.0,
alpha_row: vec![],
};
strat.feed_model_outputs(&ev, 1.0_f32, 0.9);
}
let ev = SnapshotEvent { ts_ns: 10_000_000, mid_price: 100.0, alpha_row: vec![] };
let dec = strat.on_snapshot(&ev);
assert!(matches!(dec, TradeDecision::Buy { .. }));
}
}
```
- [ ] **Step 2: Wire into strategies/mod.rs**
Add `pub mod regime_gated_alpha;` to `crates/backtesting/src/strategies/mod.rs`.
- [ ] **Step 3: Run + commit**
Run: `SQLX_OFFLINE=true cargo test -p backtesting --lib strategies::regime_gated_alpha -- --nocapture`
Expected: 2 passed.
```bash
git add crates/backtesting/src/strategies/regime_gated_alpha.rs crates/backtesting/src/strategies/mod.rs
git commit -m "feat(backtesting): regime-gated alpha strategy (Phase 1d.4)"
```
---
### Task 24: Backtest example with cost model + Sharpe
**Files:**
- Create: `crates/backtesting/examples/phase1d_backtest.rs`
- [ ] **Step 1: Write the example**
```rust
//! Phase 1d.4 — Coverage-gated backtest smoke.
//!
//! Loads snapshot fxcache, replays per-snapshot through the dual-head model,
//! gates trades through RegimeGatedAlpha, computes Sharpe net of 1-tick
//! round-trip cost.
use anyhow::{Context, Result};
use clap::Parser;
use cudarc::driver::CudaContext;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
use backtesting::snapshot_stream_replay::{SnapshotEvent, SnapshotStrategy, TradeDecision};
use backtesting::strategies::regime_gated_alpha::RegimeGatedAlpha;
use ml_alpha::dual_head_mlp::{DualHeadConfig, DualHeadModel};
use ml_alpha::fxcache_reader::{FxCacheReader, COL_RAW_CLOSE, FEAT_DIM};
#[derive(Parser, Debug)]
struct Cli {
#[arg(long)]
fxcache_path: String,
/// Cost in price units per round-trip (default: 1 tick = 0.25 for ES.FUT).
#[arg(long, default_value_t = 0.25)]
cost_per_round_trip: f64,
#[arg(long, default_value_t = 0.7)]
regime_threshold: f32,
#[arg(long, default_value_t = 0.5)]
edge_threshold: f32,
#[arg(long, default_value_t = 60)]
memory_window_s: i64,
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let ctx = CudaContext::new(0).context("init CUDA")?;
let stream = ctx.default_stream();
let reader = FxCacheReader::open(&cli.fxcache_path)?;
let alpha_dim = reader.alpha_feature_dim().context("need alpha")?;
// Build (untrained) dual-head model — for a real smoke this would be
// loaded from a checkpoint produced by Phase 1d.3. The scaffold here
// exercises the replay + cost-accounting path end-to-end so the test
// surface is complete before model-loading is added.
let dual_cfg = DualHeadConfig { in_dim: alpha_dim, ..Default::default() };
let model = DualHeadModel::new(dual_cfg, Arc::clone(&stream))?;
let mut strat = RegimeGatedAlpha::new(
cli.regime_threshold,
cli.edge_threshold,
cli.memory_window_s * 1_000_000_000,
);
let mut pnl = 0.0_f64;
let mut returns: Vec<f64> = Vec::new();
let mut position = 0.0_f64;
let mut entry_price = 0.0_f64;
let mut n_trades = 0_usize;
for i in 0..reader.bar_count() {
let row = reader.alpha_features(i).context("alpha row")?.to_vec();
let rec = reader.record(i);
let mid_price = rec.targets[COL_RAW_CLOSE - FEAT_DIM] as f64;
let ts_ns = reader.timestamp(i);
let (edge, regime) = model.forward_infer(&row, 1)?;
let regime_prob = 1.0 / (1.0 + (-regime[0]).exp());
let ev = SnapshotEvent { ts_ns, mid_price, alpha_row: row };
strat.feed_model_outputs(&ev, edge[0], regime_prob);
let dec = strat.on_snapshot(&ev);
match dec {
TradeDecision::Buy { size } => {
position = size;
entry_price = mid_price;
n_trades += 1;
}
TradeDecision::Sell { size } => {
position = -size;
entry_price = mid_price;
n_trades += 1;
}
TradeDecision::Flat => {
if position.abs() > 1e-9 {
let ret = (mid_price - entry_price) * position - cli.cost_per_round_trip;
pnl += ret;
returns.push(ret);
position = 0.0;
}
}
TradeDecision::Hold => {}
}
}
let mean_ret = returns.iter().copied().sum::<f64>() / returns.len().max(1) as f64;
let std_ret = {
let v: f64 = returns.iter().map(|&r| (r - mean_ret).powi(2)).sum::<f64>() / returns.len().max(1) as f64;
v.sqrt()
};
let sharpe_per_trade = if std_ret > 1e-12 { mean_ret / std_ret } else { 0.0 };
println!("\n=================================================");
println!("PHASE 1d.4 — COVERAGE-GATED BACKTEST");
println!("=================================================");
println!("Trades: {}", n_trades);
println!("Total PnL: {:.2}", pnl);
println!("Mean ret/trade: {:.4}", mean_ret);
println!("Std ret/trade: {:.4}", std_ret);
println!("Sharpe (per-trade, unannualized): {:.4}", sharpe_per_trade);
if sharpe_per_trade > 1.5 {
println!("GATE PASS: Sharpe > 1.5; Phase 1d → Phase 2.");
} else {
println!("GATE FAIL: Sharpe ≤ 1.5; iterate cost model / thresholds / re-train.");
}
Ok(())
}
```
Note: `FxCacheReader::timestamp(i)` must exist. If it doesn't, add a method on `FxCacheReader` that returns `self.timestamps[i]`.
- [ ] **Step 2: Compile, fix the timestamp accessor if needed, run**
```bash
SQLX_OFFLINE=true cargo build -p backtesting --release --example phase1d_backtest
```
If compile fails on `FxCacheReader::timestamp` not found, add to `crates/ml-alpha/src/fxcache_reader.rs`:
```rust
pub fn timestamp(&self, i: usize) -> i64 {
assert!(i < self.metadata.bar_count);
self.timestamps[i]
}
```
Then re-build and run:
```bash
FXC=$(ls -t /home/jgrusewski/Work/foxhunt/test_data/feature-cache/*.fxcache | head -1)
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_backtest \
--fxcache-path "$FXC" --cost-per-round-trip 0.25 \
--regime-threshold 0.7 --edge-threshold 0.5 --memory-window-s 60
```
Expected: prints trade count, PnL, Sharpe; `GATE PASS` or `GATE FAIL`.
- [ ] **Step 3: Commit**
```bash
git add crates/backtesting/examples/phase1d_backtest.rs crates/ml-alpha/src/fxcache_reader.rs
git commit -m "feat(backtesting): Phase 1d.4 coverage-gated backtest example"
```
---
### Task 25: Sweep regime / edge thresholds
**Files:**
- Modify: `crates/backtesting/examples/phase1d_backtest.rs` (add `--sweep` flag)
- [ ] **Step 1: Add a sweep mode**
Append a `--sweep` flag to `Cli` and a sweep block:
```rust
#[arg(long)]
sweep: bool,
```
If `cli.sweep` is true, loop over `regime_threshold ∈ [0.5, 0.6, 0.7, 0.8]` and `edge_threshold ∈ [0.2, 0.5, 1.0, 2.0]`, run the backtest body for each combo, print a table:
```
| regime_τ | edge_τ | n_trades | mean_ret | Sharpe |
| --------|--------|----------|----------|--------|
| 0.5 | 0.2 | N | M | S |
| ... | ... | ... | ... | ... |
```
- [ ] **Step 2: Run the sweep**
```bash
SQLX_OFFLINE=true cargo build -p backtesting --release --example phase1d_backtest
SQLX_OFFLINE=true RUST_LOG=warn target/release/examples/phase1d_backtest \
--fxcache-path "$FXC" --sweep
```
Expected: 16-row table; the max-Sharpe combo identifies the operating point.
- [ ] **Step 3: Commit**
```bash
git add crates/backtesting/examples/phase1d_backtest.rs
git commit -m "feat(backtesting): regime+edge threshold sweep mode (Phase 1d.4)"
```
---
### Task 26: Save final Phase 1d outcome memory
**Files:**
- Create: `/home/jgrusewski/.claude/projects/-home-jgrusewski-Work-foxhunt/memory/project_phase1d_outcome.md`
- [ ] **Step 1: Write the project memory with measured Sharpe, gate outcomes per milestone**
Template:
```markdown
---
name: project-phase1d-outcome
description: Phase 1d outcome summary — 1d.0/1d.1/1d.2/1d.3/1d.4 gate verdicts, best operating point, recommended next milestone
metadata:
type: project
---
| Milestone | Gate | Measured | Verdict |
|---|---|---|---|
| 1d.0 Calibration | best Brier ≤ chance | <FILL> | <PASS/FAIL> |
| 1d.1 Mamba K=100 | AUC > 0.72 | <FILL> | <PASS/FAIL> |
| 1d.2 K=6000 | AUC > 0.55 | <FILL> | <PASS/MARGINAL/FAIL> |
| 1d.3 Regime gate | cond_acc > 0.65 | <FILL> | <PASS/FAIL> |
| 1d.4 Sharpe | > 1.5 | <FILL> | <PASS/FAIL> |
Recommended next: ...
```
- [ ] **Step 2: Update MEMORY.md index** + commit memory dir.
---
## Sign-off
After all 26 tasks complete:
- [ ] **Step 1: Run full test suite**
```bash
SQLX_OFFLINE=true cargo test --workspace 2>&1 | tail -20
```
Expected: 0 failures.
- [ ] **Step 2: Push branch**
```bash
git push origin sp20-aux-h-fixed
```
- [ ] **Step 3: Self-review** — confirm each milestone's gate matches a memory pearl, and the final `project_phase1d_outcome.md` correctly summarizes the decision tree.
---
## Self-Review (run by plan author after writing)
**Spec coverage** — every section of the Phase 1d sketch maps to ≥1 task:
- 1d.0 Calibration → Tasks 1-5 ✓
- 1d.1 Stateful encoder → Tasks 6-13 ✓
- 1d.2 Multi-minute label → Tasks 14-16 ✓
- 1d.3 Regime head → Tasks 17-21 ✓
- 1d.4 Backtest → Tasks 22-26 ✓
- Sign-off / push ✓
**Placeholder scan** — all "real Mamba2 kernel wiring" callouts are tasks (Task 9 step 5, Task 13 step 4) not placeholders. Two specific "TBD-like" surfaces remain: the placeholder train_step in Task 9 step 3 (intentional — replaced in Task 9 step 5 after recon), and the placeholder MlpModel::train_step_bce in Task 20 step 2 (added when needed). Both have explicit follow-on instructions in their tasks.
**Type consistency**
- `MambaEncoder { config, stream }` consistent in Tasks 8/9/13 ✓
- `DualHeadModel { trunk, edge_head, regime_head }` consistent in Tasks 18/19/20/21 ✓
- `RegimeGatedAlpha::feed_model_outputs(&ev, edge_logit, regime_prob)` consistent in Tasks 23/24 ✓
- `SnapshotEvent { ts_ns, mid_price, alpha_row }` consistent in Tasks 22/23/24 ✓
- `Phase1aRunOutputs { val_logits, val_labels, val_indices }` (already in `db874b184`) consistent everywhere ✓
---
**End of Phase 1d implementation plan.**
**Decisive gates summary** (any FAIL kills the design):
1. 1d.0: best calibrated Brier ≤ chance baseline (0.250)
2. 1d.1: Mamba AUC > 0.72 at K=100
3. 1d.2: Mamba AUC > 0.55 at K=6000 (DECISIVE)
4. 1d.3: regime-gated accuracy > 0.65
5. 1d.4: out-of-sample Sharpe > 1.5