# Multi-Resolution Input Architecture 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 the current 32 consecutive raw MBP-10 snapshots with a 3-scale multi-resolution input window (10 raw + 10 aggregated@30 + 12 aggregated@100) so the encoder can see ~1500 ticks of context — enough to predict K=1000 horizon outcomes.

**Architecture:** Loader builds each position in the 32-position window from a different time-scale aggregation: positions 0-9 are raw snapshots (1 tick each), positions 10-19 are aggregates over 30-tick windows (mean of book state, sum of OFI), positions 20-31 are aggregates over 100-tick windows. The aggregated `prev_ts_ns` correctly spans the window so the existing snap-feature Δt Fourier encoder (slots [32..40]) already gives the model the per-position dt. No CUDA kernel changes. **Greenfield**: multi-resolution is the only path. The legacy `seq_len`-of-raw-ticks builder is deleted; the configuration is mandatory, not optional.

**Tech Stack:** Rust 1.85+, Edition 2021, Tokio 1.40, ML stack: Mamba2 SSM + CfC continuous-time recurrence with τ-bucketing, MBP-10 features, sccache, CUDA 12.4 L40S, Argo Workflows.

---

## Problem Statement

The clean-data Smoke 1 (`alpha-perception-jvv7d`, commit `20aa345a7`, 2026-05-22) revealed:

- `auc_h100 = 0.681`, `auc_h300 = 0.617`, `auc_h1000 = 0.576` — monotonic decay with horizon.
- `aux_dir_acc_h1000 = 0.167` — model is wrong 83% of the time at the longest horizon.

Gemini-3-pro deep analysis (continuation_id `40978ea6-8332-4f81-9fde-f8339cd20b42`) identified the root cause as **Temporal Receptive Field Mismatch**: `seq_len=32` ticks of context cannot encode the 1000-step macrostructure needed to predict K=1000 outcomes. The model fits noise at long horizons.

## Falsifiable Gate

**Pass criterion**: re-run smoke on Argo and confirm `auc_h1000 ≥ 0.65` (up from 0.576). If the gate fails by >0.03 AUC the hypothesis is falsified; we revert and try Phase 2 (open stop-grad / multi-class main task) first.

## Files Touched

| File | Responsibility | Action |
|------|---------------|--------|
| `crates/ml-alpha/src/data/aggregation.rs` | New module: scale config + window-aggregation helper | Create |
| `crates/ml-alpha/src/data/mod.rs` | Module registration | Modify |
| `crates/ml-alpha/src/data/loader.rs` | Replace `seq_len`-of-raw-ticks builder with multi-resolution walker; replace `seq_len: usize` field with `multi_resolution: MultiResolutionConfig` | Modify |
| `crates/ml-alpha/examples/alpha_train.rs` | Replace `--seq-len` CLI flag with `--multi-resolution` (default `1:10,30:10,100:12`) | Modify |
| `crates/ml-alpha/tests/multi_horizon_loader.rs` | Update existing loader-construction tests to use the new config | Modify |
| `crates/ml-alpha/tests/perception_overfit.rs` | Same | Modify |
| `crates/ml-alpha/tests/multi_resolution_pipeline.rs` | New: synthetic-data unit test for aggregation correctness | Create |
| `crates/ml-backtesting/src/harness.rs` | Replace `seq_len` with `multi_resolution`; harness picks `default_three_scale()` | Modify |
| `crates/ml-backtesting/tests/trainer_parity.rs` | Same | Modify |
| `crates/ml-backtesting/tests/ring3_replay.rs` | Same | Modify |
| `infra/k8s/argo/alpha-perception-template.yaml` | Replace `seq-len` workflow parameter with `multi-resolution` (default `1:10,30:10,100:12`) | Modify |
| `scripts/argo-alpha-perception.sh` | Replace `--seq-len` flag with `--multi-resolution` flag | Modify |

## Design Decisions Locked

1. **No CUDA kernel changes.** The existing snap-feature kernel already encodes `Δt = ts_ns - prev_ts_ns` as 4-period sin/cos Fourier features (slots `[32..40]`). When the loader sets `prev_ts_ns` to the timestamp of the snapshot 30 ticks ago for an aggregated position, the kernel automatically passes the larger dt to the CfC. Zero kernel work = zero Argo image rebuild.

2. **Greenfield.** No legacy single-scale code path. `MultiHorizonLoaderConfig` carries `multi_resolution: MultiResolutionConfig` (mandatory field) and NO `seq_len: usize` field — the sequence length is `multi_resolution.total_positions()`. Everywhere code used to read `seq_len` it now reads `total_positions()`. This forces all call sites to think about the time-scale layout, not just the count.

3. **Aggregation per field type** (locked at design time):
   - Book prices (`bid_px[0..10]`, `ask_px[0..10]`): **mean** over the window.
   - Book sizes (`bid_sz[0..10]`, `ask_sz[0..10]`): **mean** over the window.
   - `prev_mid`: mid of the *first* snapshot in the window (so `cur.mid - prev_mid` equals the return over the window).
   - `trade_signed_vol`: **sum** over the window (flow accumulator).
   - `trade_count`: **sum** over the window.
   - `ts_ns`: timestamp of the *last* snapshot in the window.
   - `prev_ts_ns`: timestamp of the *first* snapshot in the window. Makes `Δt = ts_ns - prev_ts_ns` correctly span the window.
   - `regime[0..6]`: pre-existing EMA cascade values from the *last* snapshot in the window (regime EMAs are already smoothed over file-spanning time scales; further aggregation would double-smooth).

4. **Anchor sampling unchanged.** The anchor picker still samples `total_positions + max_horizon + 1` snapshots from the file. The change is in *how* we build the input window: instead of `for k in 0..32 { positions.push(snapshots[anchor + k]) }`, we walk three nested scales relative to the anchor's *forward edge*. The last position (index 31) is still the snapshot at the anchor's forward edge, so forward-label computation is unchanged.

5. **CLI grammar.** `--multi-resolution <SCALE>:<COUNT>[,<SCALE>:<COUNT>]...`
   - `1:10,30:10,100:12` — three-scale Phase 1 design (total 32 positions covering 1510 ticks). **This is the default for alpha_train.**
   - `1:32` — degenerate single-scale (debugging / parity checks). Still valid via the grammar.
   - Parser validates: scales strictly increasing, scales positive, counts positive.

---

## Tasks

### Task 1: AggregationScale struct + parser

**Files:**
- Create: `crates/ml-alpha/src/data/aggregation.rs`
- Modify: `crates/ml-alpha/src/data/mod.rs` (add `pub mod aggregation;` near the existing `pub mod loader;`)

- [ ] **Step 1: Write the failing test**

Create `crates/ml-alpha/src/data/aggregation.rs` with the test scaffold ONLY (the impl lands in Step 3):

```rust
//! Multi-resolution input aggregation config.
//!
//! Builds each input position in a fixed-length window from a different
//! time-scale aggregation: e.g. 10 raw ticks + 10 aggregates over 30 ticks
//! each + 12 aggregates over 100 ticks each. Total positions = sum of counts.
//!
//! See `docs/superpowers/plans/2026-05-22-multi-resolution-input.md` for
//! design rationale: the seq_len=32 raw-tick window cannot encode the
//! K=1000 horizon's macrostructure (1:31 context-to-horizon ratio).

use std::str::FromStr;

use anyhow::{bail, Result};

/// One aggregation scale: `(window_ticks, position_count)`.
/// `window_ticks == 1` means raw (one snapshot per position).
pub type AggregationScale = (usize, usize);

/// Multi-resolution input window. Scales are stored fine-to-coarse,
/// matching the CLI grammar `1:10,30:10,100:12`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MultiResolutionConfig {
    scales: Vec<AggregationScale>,
}

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

    #[test]
    fn three_scale_default_total_32_lookback_1510() {
        let cfg = MultiResolutionConfig::default_three_scale();
        assert_eq!(cfg.total_positions(), 32);
        assert_eq!(cfg.required_lookback_ticks(), 1510);
        assert_eq!(cfg.scales(), &[(1, 10), (30, 10), (100, 12)]);
    }

    #[test]
    fn parse_three_scale_phase1() {
        let cfg: MultiResolutionConfig = "1:10,30:10,100:12".parse().unwrap();
        assert_eq!(cfg.scales(), &[(1, 10), (30, 10), (100, 12)]);
        assert_eq!(cfg.total_positions(), 32);
    }

    #[test]
    fn parse_single_scale_debug() {
        let cfg: MultiResolutionConfig = "1:32".parse().unwrap();
        assert_eq!(cfg.scales(), &[(1, 32)]);
        assert_eq!(cfg.total_positions(), 32);
    }

    #[test]
    fn parse_rejects_non_increasing_scales() {
        let res: Result<MultiResolutionConfig> = "30:10,1:10".parse();
        assert!(res.is_err(), "scales must be strictly increasing");
    }

    #[test]
    fn parse_rejects_zero_scale() {
        let res: Result<MultiResolutionConfig> = "0:10".parse();
        assert!(res.is_err());
    }

    #[test]
    fn parse_rejects_zero_count() {
        let res: Result<MultiResolutionConfig> = "1:0".parse();
        assert!(res.is_err());
    }

    #[test]
    fn parse_rejects_empty_string() {
        let res: Result<MultiResolutionConfig> = "".parse();
        assert!(res.is_err());
    }

    #[test]
    fn display_round_trips() {
        let cfg = MultiResolutionConfig::default_three_scale();
        let s = format!("{cfg}");
        let parsed: MultiResolutionConfig = s.parse().unwrap();
        assert_eq!(cfg, parsed);
    }
}
```

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

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib aggregation 2>&1 | tail -15`
Expected: FAIL with errors like "no method named `default_three_scale`" — no impl yet.

- [ ] **Step 3: Implement MultiResolutionConfig**

Edit `crates/ml-alpha/src/data/aggregation.rs`. Replace the struct line with:

```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MultiResolutionConfig {
    scales: Vec<AggregationScale>,
}

impl MultiResolutionConfig {
    /// Phase 1 design: 10 raw + 10 aggregated@30 + 12 aggregated@100.
    /// Total 32 positions covering 1510 ticks. This is the default for
    /// the alpha_train binary; tests use it unless they need a degenerate
    /// single-scale config for diagnostic comparisons.
    pub fn default_three_scale() -> Self {
        Self { scales: vec![(1, 10), (30, 10), (100, 12)] }
    }

    /// Construct from an arbitrary scales list. Validates the same
    /// invariants as `FromStr::from_str`. Most call sites should use
    /// `default_three_scale()` or `FromStr`; this exists for tests that
    /// pin specific layouts.
    pub fn from_scales(scales: Vec<AggregationScale>) -> Result<Self> {
        validate_scales(&scales)?;
        Ok(Self { scales })
    }

    /// Total number of input positions across all scales. The trainer's
    /// CfC + heads + Adam scratch are sized for this.
    pub fn total_positions(&self) -> usize {
        self.scales.iter().map(|(_, c)| c).sum()
    }

    /// Number of source snapshots needed before the anchor's forward
    /// edge to fill the entire window. Used by the loader's min-snapshot
    /// guard.
    pub fn required_lookback_ticks(&self) -> usize {
        self.scales.iter().map(|(w, c)| w * c).sum()
    }

    /// Scales in fine-to-coarse order (matches CLI grammar).
    pub fn scales(&self) -> &[AggregationScale] {
        &self.scales
    }
}

fn validate_scales(scales: &[AggregationScale]) -> Result<()> {
    if scales.is_empty() {
        bail!("multi-resolution: at least one scale required");
    }
    let mut prev_scale = 0_usize;
    for &(scale, count) in scales {
        if scale == 0 {
            bail!("multi-resolution: scale must be > 0");
        }
        if count == 0 {
            bail!("multi-resolution: count must be > 0");
        }
        if scale <= prev_scale {
            bail!(
                "multi-resolution: scales must be strictly increasing; \
                 got scale={scale} after prev_scale={prev_scale}"
            );
        }
        prev_scale = scale;
    }
    Ok(())
}

impl FromStr for MultiResolutionConfig {
    type Err = anyhow::Error;

    /// Parse `--multi-resolution` CLI value: `scale:count[,scale:count]...`.
    fn from_str(s: &str) -> Result<Self> {
        let mut scales: Vec<AggregationScale> = Vec::new();
        for raw in s.split(',') {
            let raw = raw.trim();
            if raw.is_empty() {
                bail!("multi-resolution: empty segment in '{s}'");
            }
            let (scale_s, count_s) = raw
                .split_once(':')
                .ok_or_else(|| anyhow::anyhow!("multi-resolution: missing ':' in '{raw}'"))?;
            let scale: usize = scale_s.trim().parse().map_err(|e| {
                anyhow::anyhow!("multi-resolution: scale '{scale_s}' not a usize: {e}")
            })?;
            let count: usize = count_s.trim().parse().map_err(|e| {
                anyhow::anyhow!("multi-resolution: count '{count_s}' not a usize: {e}")
            })?;
            scales.push((scale, count));
        }
        validate_scales(&scales)?;
        Ok(Self { scales })
    }
}

impl std::fmt::Display for MultiResolutionConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        for (i, (scale, count)) in self.scales.iter().enumerate() {
            if i > 0 {
                write!(f, ",")?;
            }
            write!(f, "{scale}:{count}")?;
        }
        Ok(())
    }
}
```

- [ ] **Step 4: Register module**

Edit `crates/ml-alpha/src/data/mod.rs`. Find the existing `pub mod loader;` line and add immediately above it:

```rust
pub mod aggregation;
```

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

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib aggregation 2>&1 | tail -10`
Expected: `test result: ok. 8 passed; 0 failed`.

- [ ] **Step 6: Commit**

Run:
```bash
git add crates/ml-alpha/src/data/aggregation.rs crates/ml-alpha/src/data/mod.rs
git commit -m "feat(ml-alpha): MultiResolutionConfig for multi-scale input aggregation

Adds a config struct + CLI grammar for stacking multiple time-scale
aggregations into the input window. default_three_scale() returns
(1,10) + (30,10) + (100,12) = 32 positions covering 1510 ticks of
context. Greenfield design — no legacy single-scale default."
```

---

### Task 2: aggregate_window helper

**Files:**
- Modify: `crates/ml-alpha/src/data/aggregation.rs` (append after the `Display` impl, before `#[cfg(test)]`)

- [ ] **Step 1: Write the failing test**

Append to the existing `#[cfg(test)] mod tests` block at the end of `crates/ml-alpha/src/data/aggregation.rs`:

```rust
    use crate::cfc::snap_features::Mbp10RawInput;

    fn synth_input(ts_ns: u64, mid: f32) -> Mbp10RawInput {
        let mut x = Mbp10RawInput::default();
        x.bid_px[0] = mid - 0.125;
        x.ask_px[0] = mid + 0.125;
        x.bid_sz[0] = 100.0;
        x.ask_sz[0] = 100.0;
        x.prev_mid = mid;
        x.trade_signed_vol = 1.0;
        x.trade_count = 1;
        x.ts_ns = ts_ns;
        x.prev_ts_ns = ts_ns.saturating_sub(100);
        x
    }

    #[test]
    fn aggregate_window_single_tick_passthrough_fields() {
        let s = vec![synth_input(1000, 5000.0)];
        let agg = aggregate_window(&s);
        assert_eq!(agg.ts_ns, 1000);
        assert_eq!(agg.prev_ts_ns, 1000); // first.ts_ns == last.ts_ns for n=1
        assert!((agg.prev_mid - 5000.0).abs() < 1e-5);
        assert!((agg.bid_px[0] - (5000.0 - 0.125)).abs() < 1e-5);
        assert!((agg.trade_signed_vol - 1.0).abs() < 1e-5);
        assert_eq!(agg.trade_count, 1);
    }

    #[test]
    fn aggregate_window_30_ticks_means_and_sums() {
        let snaps: Vec<Mbp10RawInput> = (0..30u64)
            .map(|i| synth_input(1000 + i * 100, 5000.0 + i as f32))
            .collect();
        let agg = aggregate_window(&snaps);

        // ts spans the window: prev_ts_ns = first.ts_ns, ts_ns = last.ts_ns.
        assert_eq!(agg.prev_ts_ns, 1000);
        assert_eq!(agg.ts_ns, 1000 + 29 * 100);

        // prev_mid is mid of FIRST tick so cur.mid - prev_mid spans the return.
        assert!((agg.prev_mid - 5000.0).abs() < 1e-5);

        // bid_px[0] is MEAN.
        let expected = (0..30).map(|i| 5000.0 + i as f32).sum::<f32>() / 30.0 - 0.125;
        assert!((agg.bid_px[0] - expected).abs() < 1e-3);

        // trade_signed_vol is SUM (flow).
        assert!((agg.trade_signed_vol - 30.0).abs() < 1e-5);
        assert_eq!(agg.trade_count, 30);
    }

    #[test]
    #[should_panic(expected = "window cannot be empty")]
    fn aggregate_window_empty_panics() {
        let _ = aggregate_window::<Mbp10RawInput>(&[]);
    }
```

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

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib aggregation::tests::aggregate 2>&1 | tail -10`
Expected: FAIL with "cannot find function `aggregate_window`".

- [ ] **Step 3: Implement aggregate_window**

In `crates/ml-alpha/src/data/aggregation.rs`, AFTER the `Display` impl block and BEFORE the `#[cfg(test)] mod tests` block, add:

```rust
use crate::cfc::snap_features::Mbp10RawInput;

/// Aggregate a slice of consecutive `Mbp10RawInput`s into one pseudo-snapshot.
/// Field semantics:
/// - Book prices/sizes: arithmetic mean.
/// - `prev_mid`: mid of first snapshot — `cur.mid - agg.prev_mid` is window return.
/// - `trade_signed_vol`, `trade_count`: sum (flow accumulators).
/// - `ts_ns`: timestamp of last snapshot (as-of time).
/// - `prev_ts_ns`: timestamp of first snapshot — gives `Δt = ts - prev_ts`
///   spanning the window, which the snap-feature kernel's Fourier dt encoder
///   reads as the position's time scale.
/// - `regime`: copy from last snapshot — EMAs already smoothed by the loader.
///
/// # Panics
///
/// Panics if `window` is empty. The loader's sequence builder guarantees
/// non-empty windows by construction; an empty slice indicates a bug.
pub fn aggregate_window<T: AsRef<Mbp10RawInput>>(window: &[T]) -> Mbp10RawInput {
    assert!(!window.is_empty(), "aggregate_window: window cannot be empty");
    let n = window.len() as f32;
    let first = window.first().unwrap().as_ref();
    let last = window.last().unwrap().as_ref();

    let mut out = Mbp10RawInput::default();

    for lvl in 0..10 {
        let mut sum_bid_px = 0.0_f32;
        let mut sum_bid_sz = 0.0_f32;
        let mut sum_ask_px = 0.0_f32;
        let mut sum_ask_sz = 0.0_f32;
        for w in window {
            let x = w.as_ref();
            sum_bid_px += x.bid_px[lvl];
            sum_bid_sz += x.bid_sz[lvl];
            sum_ask_px += x.ask_px[lvl];
            sum_ask_sz += x.ask_sz[lvl];
        }
        out.bid_px[lvl] = sum_bid_px / n;
        out.bid_sz[lvl] = sum_bid_sz / n;
        out.ask_px[lvl] = sum_ask_px / n;
        out.ask_sz[lvl] = sum_ask_sz / n;
    }

    out.prev_mid = first.prev_mid;

    let mut sum_signed: f32 = 0.0;
    let mut sum_count: u32 = 0;
    for w in window {
        let x = w.as_ref();
        sum_signed += x.trade_signed_vol;
        sum_count = sum_count.saturating_add(x.trade_count);
    }
    out.trade_signed_vol = sum_signed;
    out.trade_count = sum_count;

    out.ts_ns = last.ts_ns;
    out.prev_ts_ns = first.ts_ns;
    out.regime = last.regime;

    out
}

impl AsRef<Mbp10RawInput> for Mbp10RawInput {
    fn as_ref(&self) -> &Mbp10RawInput {
        self
    }
}
```

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

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib aggregation 2>&1 | tail -10`
Expected: `test result: ok. 11 passed; 0 failed`.

- [ ] **Step 5: Commit**

Run:
```bash
git add crates/ml-alpha/src/data/aggregation.rs
git commit -m "feat(ml-alpha): aggregate_window helper

Builds one Mbp10RawInput from a window of consecutive snapshots.
Book levels meaned, flows summed, ts_ns spans the window so the
snap-feature Δt Fourier encoder gets correct dt without any CUDA
kernel changes."
```

---

### Task 3: Loader integration — replace seq_len with multi_resolution

**Files:**
- Modify: `crates/ml-alpha/src/data/loader.rs:109-158` (config: replace `seq_len: usize` with `multi_resolution: MultiResolutionConfig`)
- Modify: `crates/ml-alpha/src/data/loader.rs:280-310` (min-snapshot guard uses `required_lookback_ticks()`)
- Modify: `crates/ml-alpha/src/data/loader.rs:505-512` (sequence-building loop)
- Modify: `crates/ml-alpha/src/data/loader.rs:686-705, 770-790` (in-file test helpers)
- Note: all callers updated in Tasks 4-6.

- [ ] **Step 1: Replace seq_len field with multi_resolution**

In `crates/ml-alpha/src/data/loader.rs`, locate the `MultiHorizonLoaderConfig` struct definition (around line 109). Find the existing `pub seq_len: usize,` line and REPLACE it with:

```rust
    /// Multi-resolution input window. `total_positions()` defines the
    /// sequence length the trainer sees; `required_lookback_ticks()` is
    /// the per-anchor pre-edge snapshot count the loader needs from each
    /// source file.
    pub multi_resolution: crate::data::aggregation::MultiResolutionConfig,
```

Also update the doc comment block above the struct if it mentions `seq_len`. Search the file for the strings `seq_len` and `cfg.seq_len`; we'll do a coordinated rewrite next.

- [ ] **Step 2: Update min-snapshot guard**

In `crates/ml-alpha/src/data/loader.rs`, find the block around lines 285-290:

```rust
        let min_snaps = if cfg.inference_only {
            cfg.seq_len.max(1)
        } else {
            cfg.seq_len + max_horizon + 1
        };
```

Replace with:

```rust
        // Multi-resolution lookback: each anchor needs `required_lookback_ticks`
        // raw snapshots BEFORE the forward edge to fill all aggregation scales.
        let lookback = cfg.multi_resolution.required_lookback_ticks();
        let min_snaps = if cfg.inference_only {
            lookback.max(1)
        } else {
            lookback + max_horizon + 1
        };
```

- [ ] **Step 3: Rewrite sequence-building loop**

In `crates/ml-alpha/src/data/loader.rs`, find the block around lines 505-512:

```rust
        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]));
        }
```

Replace with the multi-resolution walker:

```rust
        // Multi-resolution window construction. The forward edge is
        // `anchor + total_positions` exclusive (the snapshot AT
        // `anchor + total_positions - 1` is the newest position in the
        // emitted sequence). We walk fine→coarse from the edge backward
        // so each scale's positions cover earlier-in-time windows than
        // the previous scale, then reverse so the emitted sequence is
        // chronological (oldest-first).
        let scales = self.cfg.multi_resolution.scales();
        let total = self.cfg.multi_resolution.total_positions();
        let edge = anchor + total; // exclusive upper bound

        let mut sequence_rev: Vec<Mbp10RawInput> = Vec::with_capacity(total);
        let mut cursor = edge;
        for &(scale, count) in scales {
            for _ in 0..count {
                let window_end = cursor;            // exclusive
                let window_start = window_end - scale; // inclusive
                let mut window_raws: Vec<Mbp10RawInput> = Vec::with_capacity(scale);
                for idx in window_start..window_end {
                    let cur = &lf.snapshots[idx];
                    let prev_idx = if idx == 0 { 0 } else { idx - 1 };
                    let prev = &lf.snapshots[prev_idx];
                    window_raws.push(convert(cur, prev, lf.regime_full[idx]));
                }
                if window_raws.len() == 1 {
                    sequence_rev.push(window_raws.into_iter().next().unwrap());
                } else {
                    sequence_rev.push(
                        crate::data::aggregation::aggregate_window(window_raws.as_slice()),
                    );
                }
                cursor = window_start;
            }
        }
        let sequence: Vec<Mbp10RawInput> = sequence_rev.into_iter().rev().collect();
        debug_assert_eq!(sequence.len(), total);
```

- [ ] **Step 4: Replace remaining `cfg.seq_len` reads**

Search the file:

```bash
grep -n "cfg.seq_len\|self.cfg.seq_len\|self.seq_len" /home/jgrusewski/Work/foxhunt/crates/ml-alpha/src/data/loader.rs
```

For every remaining reference outside the rewritten block, replace `cfg.seq_len` → `cfg.multi_resolution.total_positions()` and `self.cfg.seq_len` → `self.cfg.multi_resolution.total_positions()`. Examples that should now match the new naming:

- The `labels: [Vec<f32>; N_HORIZONS]` initializer at the start of the per-anchor builder (search for `Vec::with_capacity(self.cfg.seq_len)`): change each to `Vec::with_capacity(self.cfg.multi_resolution.total_positions())`.
- Any doc comments referencing `seq_len` (they're now describing the total-position count): keep the wording but reference `multi_resolution.total_positions()`.

- [ ] **Step 5: Update the two in-file test helpers**

In `crates/ml-alpha/src/data/loader.rs`, find each `MultiHorizonLoaderConfig { ... }` literal in the same file (~line 686 and ~line 770). For EACH, REPLACE the `seq_len: <N>,` line with:

```rust
            multi_resolution: crate::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

If a particular in-file helper specifically needs a small window (e.g., the placeholder-fixtures path that says `seq_len: 1`), use:

```rust
            multi_resolution: crate::data::aggregation::MultiResolutionConfig::from_scales(vec![(1, 1)]).unwrap(),
```

- [ ] **Step 6: Run cargo check (will surface every external caller still using `seq_len`)**

Run: `SQLX_OFFLINE=true cargo check -p ml-alpha --tests --examples 2>&1 | tail -30`
Expected: errors like `no field `seq_len` on type `MultiHorizonLoaderConfig`` at each external caller. These are intentional — they'll be fixed in Tasks 4-6.

- [ ] **Step 7: Commit**

Run:
```bash
git add crates/ml-alpha/src/data/loader.rs
git commit -m "feat(ml-alpha): replace seq_len with MultiResolutionConfig in loader

Sequence builder now walks fine→coarse scales from the anchor's
forward edge, aggregating each window into one pseudo-snapshot
via aggregate_window. seq_len field deleted (total_positions()
replaces all reads). Greenfield — no legacy single-scale path.
External callers updated in subsequent tasks."
```

---

### Task 4: Migrate ml-backtesting harness + tests

**Files:**
- Modify: `crates/ml-backtesting/src/harness.rs:247-280`
- Modify: `crates/ml-backtesting/tests/trainer_parity.rs:30-50`
- Modify: `crates/ml-backtesting/tests/ring3_replay.rs:43-60`

- [ ] **Step 1: Surface the broken sites**

Run: `SQLX_OFFLINE=true cargo check --workspace --tests --exclude cudarc 2>&1 | grep "no field \`seq_len\`\|missing structure fields"`
Expected: 3-4 sites flagged (one per file above).

- [ ] **Step 2: Patch ml-backtesting/src/harness.rs**

Open `crates/ml-backtesting/src/harness.rs`. Find the `MultiHorizonLoaderConfig {` literal (around line 247). Locate the `seq_len: ...,` line in that literal. REPLACE with:

```rust
            multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

If the file does not already import `ml_alpha::data::aggregation`, you don't need to add a `use` — the fully-qualified path inline above is sufficient and matches the existing pattern (the file already uses `ml_alpha::data::loader::...` inline).

- [ ] **Step 3: Patch ml-backtesting/tests/trainer_parity.rs**

Open `crates/ml-backtesting/tests/trainer_parity.rs`. Find the `MultiHorizonLoaderConfig { ... }` literal. REPLACE the `seq_len: ...,` line with:

```rust
        multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

- [ ] **Step 4: Patch ml-backtesting/tests/ring3_replay.rs**

Open `crates/ml-backtesting/tests/ring3_replay.rs`. Find the `MultiHorizonLoaderConfig { ... }` literal. REPLACE the `seq_len: ...,` line with:

```rust
        multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

- [ ] **Step 5: Run workspace check**

Run: `SQLX_OFFLINE=true cargo check --workspace --tests --exclude cudarc 2>&1 | tail -10`
Expected: `Finished ...` with only pre-existing warnings (none for `seq_len` or `multi_resolution`).

- [ ] **Step 6: Commit**

Run:
```bash
git add crates/ml-backtesting/src/harness.rs crates/ml-backtesting/tests/trainer_parity.rs crates/ml-backtesting/tests/ring3_replay.rs
git commit -m "chore(ml-backtesting): migrate to MultiResolutionConfig

Backtest harness + tests use default_three_scale config (1:10,30:10,
100:12 = 1510 ticks of context). Replaces the previous seq_len=32
single-scale path which is now deleted."
```

---

### Task 5: Migrate ml-alpha tests

**Files:**
- Modify: `crates/ml-alpha/tests/multi_horizon_loader.rs` (TWO existing constructors)
- Modify: `crates/ml-alpha/tests/perception_overfit.rs` (find and migrate any `seq_len` references)

- [ ] **Step 1: Surface the broken sites in ml-alpha tests**

Run: `SQLX_OFFLINE=true cargo check -p ml-alpha --tests 2>&1 | grep "no field \`seq_len\`\|missing structure fields\|seq_len"`
Expected: errors at each test that constructs `MultiHorizonLoaderConfig` or reads `seq_len`.

- [ ] **Step 2: Patch crates/ml-alpha/tests/multi_horizon_loader.rs**

Open the file. Find each `MultiHorizonLoaderConfig { ... }` literal (TWO sites). For EACH, REPLACE the `seq_len: ...,` line with:

```rust
        multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

Add at the top of the file (if not already present):

```rust
use ml_alpha::data::aggregation::MultiResolutionConfig;
```

If the test files reference `cfg.seq_len` or `loader_cfg.seq_len` anywhere in their assertions, replace each with `cfg.multi_resolution.total_positions()` (or `loader_cfg.multi_resolution.total_positions()`).

- [ ] **Step 3: Patch crates/ml-alpha/tests/perception_overfit.rs**

Open the file. Locate any `MultiHorizonLoaderConfig` constructions or references to `seq_len` in struct literals related to the perception trainer (use `grep -n "seq_len\|MultiHorizonLoaderConfig" /home/jgrusewski/Work/foxhunt/crates/ml-alpha/tests/perception_overfit.rs`).

For each `MultiHorizonLoaderConfig` literal, replace `seq_len: ...,` with:

```rust
        multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
```

For `PerceptionTrainerConfig` or other types that take their OWN `seq_len: usize` parameter (the trainer config carries its own copy used to size GPU buffers), check whether they should now derive it from the multi-resolution config. If the trainer config has a `seq_len: usize` field that's separate from the loader's, leave it as-is for now — the trainer's `seq_len` parameter is meant to match `loader_cfg.multi_resolution.total_positions()`, and callers should pass that value through explicitly:

```rust
let trainer_seq_len = loader_cfg.multi_resolution.total_positions();
// ...then use trainer_seq_len wherever PerceptionTrainerConfig::seq_len is set.
```

This pattern keeps the trainer's GPU-buffer sizing independent of the loader's input layout, which is correct: the trainer only needs to know how many positions the input has, not what scales they come from.

- [ ] **Step 4: Run ml-alpha test build**

Run: `SQLX_OFFLINE=true cargo check -p ml-alpha --tests --examples 2>&1 | tail -10`
Expected: clean compile.

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

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib 2>&1 | tail -15`
Expected: aggregation tests pass (11 tests). Other ml-alpha lib tests pass (count depends on existing).

- [ ] **Step 6: Commit**

Run:
```bash
git add crates/ml-alpha/tests/multi_horizon_loader.rs crates/ml-alpha/tests/perception_overfit.rs
git commit -m "chore(ml-alpha-tests): migrate test fixtures to MultiResolutionConfig

Tests construct MultiHorizonLoaderConfig with default_three_scale
(matching alpha_train's production default). Trainer seq_len pulled
from loader_cfg.multi_resolution.total_positions() so trainer buffer
sizing tracks the input layout."
```

---

### Task 6: alpha_train CLI flag `--multi-resolution`

**Files:**
- Modify: `crates/ml-alpha/examples/alpha_train.rs`

- [ ] **Step 1: Replace the --seq-len flag definition**

Open `crates/ml-alpha/examples/alpha_train.rs`. Find the existing `--seq-len` clap arg (search for `seq_len:` or `#[arg(long, default_value_t = 32)]` near the top of the `Cli` struct). REPLACE the field with:

```rust
    /// Multi-resolution input window: `<scale>:<count>[,<scale>:<count>]...`.
    /// Default `1:10,30:10,100:12` gives 32 positions covering 1510 ticks
    /// (10 raw + 10 aggregated@30 + 12 aggregated@100). Use `1:32` for the
    /// degenerate single-scale parity-check config.
    ///
    /// See `docs/superpowers/plans/2026-05-22-multi-resolution-input.md`.
    #[arg(long, default_value = "1:10,30:10,100:12")]
    multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig,
```

The clap `default_value` calls `MultiResolutionConfig::from_str("1:10,30:10,100:12")` automatically (FromStr is in scope from Task 1).

- [ ] **Step 2: Wire the config into both loader constructors**

Find the two `MultiHorizonLoaderConfig { ... }` literals (around lines 466 and 478 — one for `train_loader`, one for `val_loader`). For EACH, REPLACE the `seq_len: cli.seq_len,` line with:

```rust
        multi_resolution: cli.multi_resolution.clone(),
```

- [ ] **Step 3: Update PerceptionTrainerConfig seq_len source**

In the same file, locate where `PerceptionTrainerConfig` is constructed (search for `PerceptionTrainerConfig {`). If it has a `seq_len:` field, change its source from `cli.seq_len` to:

```rust
        seq_len: cli.multi_resolution.total_positions(),
```

If the trainer config doesn't have its own seq_len (sized from the loader), skip this step.

- [ ] **Step 4: Add the visibility log line**

Find the `tracing::info!("preloading train + val MBP-10 files into RAM…");` line (around line 464). Immediately BEFORE it add:

```rust
    tracing::info!(
        multi_resolution = %cli.multi_resolution,
        total_positions = cli.multi_resolution.total_positions(),
        lookback_ticks = cli.multi_resolution.required_lookback_ticks(),
        "multi-resolution config",
    );
```

- [ ] **Step 5: Run local cargo check**

Run: `SQLX_OFFLINE=true cargo check -p ml-alpha --example alpha_train 2>&1 | tail -10`
Expected: clean build.

- [ ] **Step 6: Verify --help shows the new flag**

Run:
```bash
SQLX_OFFLINE=true cargo run -p ml-alpha --example alpha_train -- --help 2>&1 | grep -A 4 "multi-resolution"
```
Expected: the flag appears with the default value `1:10,30:10,100:12` and doc text.

- [ ] **Step 7: Commit**

Run:
```bash
git add crates/ml-alpha/examples/alpha_train.rs
git commit -m "feat(alpha_train): --multi-resolution CLI flag

Replaces --seq-len. Default '1:10,30:10,100:12' = 32 positions
covering 1510 ticks of context, the Phase 1 fix for temporal
receptive field mismatch. Logs the active config at preload start
so Argo logs surface the per-run choice."
```

---

### Task 7: Argo template + dispatcher script

**Files:**
- Modify: `infra/k8s/argo/alpha-perception-template.yaml`
- Modify: `scripts/argo-alpha-perception.sh`

- [ ] **Step 1: Replace the seq-len workflow parameter**

Edit `infra/k8s/argo/alpha-perception-template.yaml`. Find the existing parameter:

```yaml
      - name: seq-len
        value: "32"
```

REPLACE with:

```yaml
      - name: multi-resolution
        value: "1:10,30:10,100:12"
```

- [ ] **Step 2: Replace the train-step's --seq-len flag**

In the same file, find the line passing `--seq-len {{workflow.parameters.seq-len}}` in the bash block that runs alpha_train (around line 461). REPLACE with:

```yaml
              --multi-resolution "{{workflow.parameters.multi-resolution}}" \
```

- [ ] **Step 3: Replace SEQ_LEN in the dispatcher script**

Edit `scripts/argo-alpha-perception.sh`. Find:

```bash
SEQ_LEN=32
```

REPLACE with:

```bash
MULTI_RESOLUTION="1:10,30:10,100:12"
```

Find the `--seq-len)` case branch:

```bash
    --seq-len)           SEQ_LEN="$2"; shift 2 ;;
```

REPLACE with:

```bash
    --multi-resolution)  MULTI_RESOLUTION="$2"; shift 2 ;;
```

Find the `--seq-len` usage line in the `usage()` heredoc:

```
  --seq-len <n>             Snapshots per sequence window (default: $SEQ_LEN)
```

REPLACE with:

```
  --multi-resolution <S>    Multi-scale input: scale:count[,...] (default: $MULTI_RESOLUTION)
```

Find the `argo submit ...` block. Find:

```bash
  -p seq-len="$SEQ_LEN" \
```

REPLACE with:

```bash
  -p multi-resolution="$MULTI_RESOLUTION" \
```

Find the echo log line:

```bash
echo "  seq-len:          $SEQ_LEN"
```

REPLACE with:

```bash
echo "  multi-resolution: $MULTI_RESOLUTION"
```

- [ ] **Step 4: Validate the bash script syntactically**

Run: `bash -n scripts/argo-alpha-perception.sh`
Expected: no output.

- [ ] **Step 5: Commit**

Run:
```bash
git add infra/k8s/argo/alpha-perception-template.yaml scripts/argo-alpha-perception.sh
git commit -m "feat(argo): replace --seq-len with --multi-resolution in alpha-perception

Default '1:10,30:10,100:12' (32 positions, 1510-tick context).
Greenfield migration — no legacy seq-len fallback. Operators
override per-run via './scripts/argo-alpha-perception.sh --multi-resolution 1:32'
for the parity-check config."
```

---

### Task 8: Synthetic-data integration test

**Files:**
- Create: `crates/ml-alpha/tests/multi_resolution_pipeline.rs`

- [ ] **Step 1: Write the test**

Write `crates/ml-alpha/tests/multi_resolution_pipeline.rs`:

```rust
//! End-to-end pipeline test using synthetic Mbp10RawInput data.
//!
//! Pure-CPU test (no GPU, no FOXHUNT_TEST_DATA gate). Confirms that
//! `aggregate_window` emits ts_ns / prev_ts_ns spans matching the design,
//! so the snap-feature Δt Fourier encoder receives the correct per-position
//! dt for each scale.

use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::aggregation::{aggregate_window, MultiResolutionConfig};

#[test]
fn default_three_scale_position_layout() {
    let cfg = MultiResolutionConfig::default_three_scale();
    assert_eq!(cfg.scales(), &[(1, 10), (30, 10), (100, 12)]);
    assert_eq!(cfg.total_positions(), 32);
    assert_eq!(cfg.required_lookback_ticks(), 1510);
}

#[test]
fn aggregate_window_30_tick_dt_spans_window() {
    let snaps: Vec<Mbp10RawInput> = (0..30u64)
        .map(|i| {
            let mut x = Mbp10RawInput::default();
            x.ts_ns = 1000 + i * 100;
            x.prev_ts_ns = x.ts_ns.saturating_sub(100);
            x
        })
        .collect();
    let agg = aggregate_window(&snaps);
    let dt = agg.ts_ns - agg.prev_ts_ns;
    assert_eq!(dt, 29 * 100, "30-tick window @ 100ns spacing -> dt = 2900ns");
}

#[test]
fn aggregate_window_100_tick_dt_much_larger_than_raw_dt() {
    // 100-tick window @ 100ns spacing -> dt = 99 * 100 = 9900ns
    let snaps_100: Vec<Mbp10RawInput> = (0..100u64)
        .map(|i| {
            let mut x = Mbp10RawInput::default();
            x.ts_ns = 5000 + i * 100;
            x.prev_ts_ns = x.ts_ns.saturating_sub(100);
            x
        })
        .collect();
    let agg_100 = aggregate_window(&snaps_100);
    let dt_100 = agg_100.ts_ns - agg_100.prev_ts_ns;

    // 1-tick raw position -> dt = 100ns (the input's own prev_ts_ns offset).
    let raw = Mbp10RawInput {
        ts_ns: 5000,
        prev_ts_ns: 4900,
        ..Default::default()
    };
    let dt_raw = raw.ts_ns - raw.prev_ts_ns;

    // 9900 vs 100 -> 99x ratio. Confirms the snap-feature Δt encoder will
    // see a clearly distinct time-scale signature per position.
    assert!(
        dt_100 as f32 / dt_raw as f32 > 50.0,
        "coarse-scale dt={dt_100} should be >50x raw dt={dt_raw}"
    );
}

#[test]
fn aggregate_window_prev_mid_is_first_snapshot_mid() {
    // Walking returns calc: cur.mid (last) - agg.prev_mid (first) should
    // equal the realized return across the window.
    let snaps: Vec<Mbp10RawInput> = (0..30u64)
        .map(|i| {
            let mut x = Mbp10RawInput::default();
            x.prev_mid = 5000.0 + i as f32;
            x.bid_px[0] = x.prev_mid - 0.125;
            x.ask_px[0] = x.prev_mid + 0.125;
            x.ts_ns = 1000 + i * 100;
            x.prev_ts_ns = x.ts_ns.saturating_sub(100);
            x
        })
        .collect();
    let agg = aggregate_window(&snaps);
    assert!(
        (agg.prev_mid - 5000.0).abs() < 1e-5,
        "agg.prev_mid should equal first snapshot's prev_mid"
    );
}
```

- [ ] **Step 2: Run the test**

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --test multi_resolution_pipeline 2>&1 | tail -10`
Expected: `test result: ok. 4 passed; 0 failed`.

- [ ] **Step 3: Commit**

Run:
```bash
git add crates/ml-alpha/tests/multi_resolution_pipeline.rs
git commit -m "test(ml-alpha): synthetic multi-resolution pipeline test

Four pure-CPU tests confirming aggregate_window emits the right
ts_ns - prev_ts_ns span per scale, so the existing snap-feature Δt
Fourier encoder gets correctly-scaled inputs without any CUDA kernel
changes."
```

---

### Task 9: Final workspace validation + commit gate

**Files:**
- (No file changes — verification gate)

- [ ] **Step 1: Workspace check**

Run: `SQLX_OFFLINE=true cargo check --workspace --tests --examples --exclude cudarc 2>&1 | tail -10`
Expected: clean build with only pre-existing unrelated warnings.

- [ ] **Step 2: Run all unit tests**

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --lib 2>&1 | tail -10`
Expected: all aggregation tests pass, no regressions in other ml-alpha lib tests.

Run: `SQLX_OFFLINE=true cargo test -p ml-alpha --test multi_resolution_pipeline 2>&1 | tail -10`
Expected: 4 passed.

- [ ] **Step 3: Verify no stale `seq_len` references in alpha-perception path**

Run:
```bash
grep -rn "seq_len\|seq-len" \
  /home/jgrusewski/Work/foxhunt/crates/ml-alpha \
  /home/jgrusewski/Work/foxhunt/crates/ml-backtesting \
  /home/jgrusewski/Work/foxhunt/scripts/argo-alpha-perception.sh \
  /home/jgrusewski/Work/foxhunt/infra/k8s/argo/alpha-perception-template.yaml \
  2>&1 | grep -v ".git" | grep -v target | grep -v "PerceptionTrainerConfig" | head -20
```
Expected: only `PerceptionTrainerConfig::seq_len` references (the trainer's internal field, set via `cfg.multi_resolution.total_positions()`). No loader-config `seq_len`, no `--seq-len` CLI, no workflow parameter `seq-len`.

- [ ] **Step 4: Commit if any cleanup was needed**

If Step 3 surfaced stale references and you've cleaned them up:

```bash
git add <whatever-needed-cleanup>
git commit -m "chore: remove remaining stale seq_len references after multi-resolution migration"
```

Otherwise skip this step.

---

### Task 10: Argo smoke dispatch + result analysis

**Files:**
- (No file changes — operational task)

- [ ] **Step 1: Push the branch**

Run: `git push origin ml-alpha-phase-a 2>&1 | tail -5`
Expected: branch pushed.

- [ ] **Step 2: Apply the updated Argo template**

Run:
```bash
kubectl apply -n foxhunt -f infra/k8s/argo/alpha-perception-template.yaml 2>&1 | tail -5
```
Expected: `workflowtemplate.argoproj.io/alpha-perception configured`.

- [ ] **Step 3: Dispatch the smoke**

Run:
```bash
./scripts/argo-alpha-perception.sh --instrument-mode front-month 2>&1 | tail -10
```
(`--multi-resolution 1:10,30:10,100:12` is the default; no flag needed.)
Expected: workflow name printed. Capture it for Step 4.

- [ ] **Step 4: Wait + pull results**

Wait until `argo get -n foxhunt <workflow-name>` shows `Status: Succeeded` (~1.5h on first run — front-month sidecars exist; aggregation adds ~10-15% loader overhead). Then:

```bash
kubectl port-forward -n foxhunt svc/minio 19001:9000 > /tmp/pf-minio.log 2>&1 &
sleep 4
train_dir=$(mc ls fh/argo-logs/<workflow-name>/ 2>&1 | grep train | awk '{print $NF}' | tr -d '/')
mc cp "fh/argo-logs/<workflow-name>/$train_dir/main.log" /tmp/smoke-multi-res.log 2>&1 | tail -1
```

Replace `<workflow-name>` with the actual name from Step 3.

- [ ] **Step 5: Verify the gate**

Run:
```bash
grep -E "multi-resolution config|preload complete|validation epoch|best_auc_h1000|alpha_train_summary" /tmp/smoke-multi-res.log
grep -A 40 "alpha_train_summary.json ---" /tmp/smoke-multi-res.log | head -50
```

Pass conditions (ALL must hold):
1. The "multi-resolution config" log line shows `total_positions=32` and `lookback_ticks=1510`.
2. `best_auc_h1000 >= 0.65` in the final summary JSON (baseline 0.576; +0.074 lift required).
3. No NaN/Inf in val_loss across epochs.

If pass: this task is done; the falsifiable gate confirms Phase 1. Move on to drafting the Phase 2 plan (open stop-grad gate for aux supervision).

If fail (auc_h1000 < 0.62 after 5 epochs): hypothesis falsified for ES microstructure data. Document the result in `docs/superpowers/specs/2026-05-22-multi-resolution-falsification.md` and re-plan around Phase 2 (open stop-grad / multi-class main task) as the primary lever.

---

## Self-review checklist

**Spec coverage:**
- [x] Aggregation struct + parser → Task 1
- [x] aggregate_window helper → Task 2
- [x] Loader sequence builder rewrite (greenfield, no seq_len) → Task 3
- [x] ml-backtesting migration → Task 4
- [x] ml-alpha tests migration → Task 5
- [x] CLI flag migration → Task 6
- [x] Argo plumbing migration → Task 7
- [x] Synthetic-data integration test → Task 8
- [x] Workspace validation gate → Task 9
- [x] Argo smoke + falsifiable gate → Task 10

**Placeholder scan:**
- No "TBD" / "TODO" / "implement later" tokens.
- Every code change shows exact text.
- Every command shows expected output.

**Type consistency:**
- `MultiResolutionConfig` used throughout Tasks 1-8.
- `aggregate_window<T: AsRef<Mbp10RawInput>>` signature matches the call site in Task 3.
- `MultiHorizonLoaderConfig.multi_resolution` field type matches across Task 3 (struct definition) and Tasks 4-6 (construction sites).
- The Argo workflow parameter name `multi-resolution` is consistent between Task 7's template + script.
- `PerceptionTrainerConfig::seq_len` is still a `usize` but its value is sourced from `multi_resolution.total_positions()` (Tasks 5 + 6).

**Greenfield commitment:**
- `seq_len` field is REMOVED from `MultiHorizonLoaderConfig` — no legacy path remains.
- `--seq-len` CLI flag is REMOVED from `alpha_train` and `argo-alpha-perception.sh` — no fallback grammar.
- `seq-len` workflow parameter is REMOVED from `alpha-perception-template.yaml`.
- All call sites construct `MultiResolutionConfig` explicitly via `default_three_scale()` (production) or `from_scales(...)` (single-position fixture path).

**Risk / cost:**
- Loader per-anchor cost grows from O(seq_len) to O(required_lookback_ticks) raw-snapshot conversions. For default `(1,10) + (30,10) + (100,12)`, that's 1510 conversions vs the previous 32 — a 47× factor. But conversion is sub-microsecond per snapshot and only runs per anchor (not per training step), and our 8000 anchors per smoke = 8000 × (1510−32) × ~50ns ≈ 0.6 seconds of additional loader work per epoch. Negligible.
- No GPU memory increase. No kernel rebuilds. No Argo image push required.
- Backtest harness behavior changes (aggregation enabled) — this is intentional in a greenfield migration; the harness's parity tests cover the runtime invariants, not the input layout.

**Falsifiability:**
- Quantitative gate: `auc_h1000 ≥ 0.65` (up from 0.576). Falsification threshold: `< 0.62`. Time-boxed to 5 epochs ≈ ~1.5h on Argo.
