Files
foxhunt/docs/superpowers/plans/2026-05-22-multi-resolution-input.md
jgrusewski 458d678e9f docs(plans): multi-resolution input architecture migration plan
Greenfield Phase 1 plan addressing temporal receptive field mismatch
(auc_h1000=0.576 plateau): replace seq_len=32 raw-tick input with
3-scale aggregation [(1,10), (30,10), (100,12)] covering 1510 ticks.

10 tasks total, executed via subagent-driven development. Falsifiable
gate: auc_h1000 >= 0.65 on clean front-month data.
2026-05-22 20:48:05 +02:00

46 KiB
Raw Blame History

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):

//! 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:

#[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:

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:

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:

    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:

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:

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:

    /// 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:

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

Replace with:

        // 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:

        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:

        // 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:

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_lencfg.multi_resolution.total_positions() and self.cfg.seq_lenself.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:

            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:

            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_lenon typeMultiHorizonLoaderConfig`` at each external caller. These are intentional — they'll be fixed in Tasks 4-6.

  • Step 7: Commit

Run:

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:

            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:

        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:

        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:

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 constructsMultiHorizonLoaderConfigor readsseq_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:

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

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

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:

        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:

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:

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:

    /// 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:

        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:

        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:

    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:

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:

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:

      - name: seq-len
        value: "32"

REPLACE with:

      - 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:

              --multi-resolution "{{workflow.parameters.multi-resolution}}" \
  • Step 3: Replace SEQ_LEN in the dispatcher script

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

SEQ_LEN=32

REPLACE with:

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

Find the --seq-len) case branch:

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

REPLACE with:

    --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:

  -p seq-len="$SEQ_LEN" \

REPLACE with:

  -p multi-resolution="$MULTI_RESOLUTION" \

Find the echo log line:

echo "  seq-len:          $SEQ_LEN"

REPLACE with:

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:

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:

//! 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:

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:

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:

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:

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:

./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:

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:

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:

  • Aggregation struct + parser → Task 1
  • aggregate_window helper → Task 2
  • Loader sequence builder rewrite (greenfield, no seq_len) → Task 3
  • ml-backtesting migration → Task 4
  • ml-alpha tests migration → Task 5
  • CLI flag migration → Task 6
  • Argo plumbing migration → Task 7
  • Synthetic-data integration test → Task 8
  • Workspace validation gate → Task 9
  • 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 × (151032) × ~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.