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.
46 KiB
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
-
No CUDA kernel changes. The existing snap-feature kernel already encodes
Δt = ts_ns - prev_ts_nsas 4-period sin/cos Fourier features (slots[32..40]). When the loader setsprev_ts_nsto 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. -
Greenfield. No legacy single-scale code path.
MultiHorizonLoaderConfigcarriesmulti_resolution: MultiResolutionConfig(mandatory field) and NOseq_len: usizefield — the sequence length ismulti_resolution.total_positions(). Everywhere code used to readseq_lenit now readstotal_positions(). This forces all call sites to think about the time-scale layout, not just the count. -
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 (socur.mid - prev_midequals 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_nscorrectly 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).
- Book prices (
-
Anchor sampling unchanged. The anchor picker still samples
total_positions + max_horizon + 1snapshots from the file. The change is in how we build the input window: instead offor 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. -
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(addpub mod aggregation;near the existingpub 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 theDisplayimpl, 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: replaceseq_len: usizewithmulti_resolution: MultiResolutionConfig) -
Modify:
crates/ml-alpha/src/data/loader.rs:280-310(min-snapshot guard usesrequired_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_lenreads
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_len → cfg.multi_resolution.total_positions() and self.cfg.seq_len → self.cfg.multi_resolution.total_positions(). Examples that should now match the new naming:
-
The
labels: [Vec<f32>; N_HORIZONS]initializer at the start of the per-anchor builder (search forVec::with_capacity(self.cfg.seq_len)): change each toVec::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 referencemulti_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 anyseq_lenreferences) -
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_lenreferences 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):
- The "multi-resolution config" log line shows
total_positions=32andlookback_ticks=1510. best_auc_h1000 >= 0.65in the final summary JSON (baseline 0.576; +0.074 lift required).- 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:
MultiResolutionConfigused throughout Tasks 1-8.aggregate_window<T: AsRef<Mbp10RawInput>>signature matches the call site in Task 3.MultiHorizonLoaderConfig.multi_resolutionfield type matches across Task 3 (struct definition) and Tasks 4-6 (construction sites).- The Argo workflow parameter name
multi-resolutionis consistent between Task 7's template + script. PerceptionTrainerConfig::seq_lenis still ausizebut its value is sourced frommulti_resolution.total_positions()(Tasks 5 + 6).
Greenfield commitment:
seq_lenfield is REMOVED fromMultiHorizonLoaderConfig— no legacy path remains.--seq-lenCLI flag is REMOVED fromalpha_trainandargo-alpha-perception.sh— no fallback grammar.seq-lenworkflow parameter is REMOVED fromalpha-perception-template.yaml.- All call sites construct
MultiResolutionConfigexplicitly viadefault_three_scale()(production) orfrom_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.