diff --git a/docs/superpowers/plans/2026-05-22-multi-resolution-input.md b/docs/superpowers/plans/2026-05-22-multi-resolution-input.md new file mode 100644 index 000000000..e458fe57f --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-multi-resolution-input.md @@ -0,0 +1,1206 @@ +# 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 :[,:]...` + - `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, +} + +#[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 = "30:10,1:10".parse(); + assert!(res.is_err(), "scales must be strictly increasing"); + } + + #[test] + fn parse_rejects_zero_scale() { + let res: Result = "0:10".parse(); + assert!(res.is_err()); + } + + #[test] + fn parse_rejects_zero_count() { + let res: Result = "1:0".parse(); + assert!(res.is_err()); + } + + #[test] + fn parse_rejects_empty_string() { + let res: Result = "".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, +} + +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) -> Result { + 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 { + let mut scales: Vec = 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 = (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::() / 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::(&[]); + } +``` + +- [ ] **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>(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 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 = 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 = 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 = 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; 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: ,` 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: `:[,:]...`. + /// 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 Snapshots per sequence window (default: $SEQ_LEN) +``` + +REPLACE with: + +``` + --multi-resolution 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 = (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 = (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 = (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 +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 ` 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// 2>&1 | grep train | awk '{print $NF}' | tr -d '/') +mc cp "fh/argo-logs//$train_dir/main.log" /tmp/smoke-multi-res.log 2>&1 | tail -1 +``` + +Replace `` 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>` 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.