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:
182
crates/ml-alpha/src/data/aggregation.rs
Normal file
182
crates/ml-alpha/src/data/aggregation.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
//! Phase A data path — predecoded MBP-10 -> Mbp10RawInput sequences + labels.
|
||||
|
||||
pub mod aggregation;
|
||||
pub mod loader;
|
||||
|
||||
Reference in New Issue
Block a user