From bd60aa6afe616b3cd6aff31c0d0501cb644d27ce Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 22 May 2026 19:59:53 +0200 Subject: [PATCH] feat(ml-alpha): MultiResolutionConfig for multi-scale input aggregation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ml-alpha/src/data/aggregation.rs | 182 ++++++++++++++++++++++++ crates/ml-alpha/src/data/mod.rs | 1 + 2 files changed, 183 insertions(+) create mode 100644 crates/ml-alpha/src/data/aggregation.rs diff --git a/crates/ml-alpha/src/data/aggregation.rs b/crates/ml-alpha/src/data/aggregation.rs new file mode 100644 index 000000000..a438e58f1 --- /dev/null +++ b/crates/ml-alpha/src/data/aggregation.rs @@ -0,0 +1,182 @@ +//! 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, +} + +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(()) + } +} + +#[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); + } +} diff --git a/crates/ml-alpha/src/data/mod.rs b/crates/ml-alpha/src/data/mod.rs index 5ffdd00cc..0213f4559 100644 --- a/crates/ml-alpha/src/data/mod.rs +++ b/crates/ml-alpha/src/data/mod.rs @@ -1,3 +1,4 @@ //! Phase A data path — predecoded MBP-10 -> Mbp10RawInput sequences + labels. +pub mod aggregation; pub mod loader;