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.
This commit is contained in:
jgrusewski
2026-05-22 19:59:53 +02:00
parent 20aa345a7c
commit bd60aa6afe
2 changed files with 183 additions and 0 deletions

View File

@@ -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<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(())
}
}
#[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);
}
}

View File

@@ -1,3 +1,4 @@
//! Phase A data path — predecoded MBP-10 -> Mbp10RawInput sequences + labels.
pub mod aggregation;
pub mod loader;