From 216cc8c4170abe43f371024f137b5bbae6008fd6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 25 Jun 2026 14:27:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(0072):=20stage1=20mean-reversion=20candida?= =?UTF-8?q?te=20=E2=80=94=20EWMA=20Bollinger=20fade=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third price-only Stage-1 R strategy: aura sweep --strategy stage1-meanrev fades deviation from a rolling mean (price above mean+k*sigma -> short, below -> long, latched +-1), feeding the unchanged bias -> RiskExecutor (vol-stop = R) -> flat-1R seam. sigma = Sqrt(Ema((price-Ema(price))^2)) reuses the vol_stop deviation- squared-then-smoothed shape, so the band is built from existing aura-std nodes (Ema/Sub/Mul/Sqrt/LinComb/Add/Gt/Latch) -> ZERO new nodes. No Delay: the current bar legitimately belongs to its own band (causal, C2). Swept knobs: --window (one Ema length ganged across mean+variance) and --band-k; downstream vol-stop R knobs unchanged. An in-file test reads the SHIPPED graph's exposure tap to pin the fade polarity (short above the band, long below) — a latch-set/reset copy-paste from breakout would invert the signal yet leave the fold-vs-raw and window-grid CLI tests green. All 588 workspace tests pass (existing R/sma/momentum/breakout goldens byte-identical); clippy clean. refs #137 --- crates/aura-cli/src/main.rs | 270 ++++++++++++++++++++++++++++++- crates/aura-cli/tests/cli_run.rs | 93 +++++++++++ 2 files changed, 357 insertions(+), 6 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index acb2092..05cab93 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -29,8 +29,8 @@ use aura_registry::{ WriteKind, }; use aura_std::{ - Bias, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Recorder, RollingMax, - RollingMin, SeriesReducer, SimBroker, Sma, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS, + Add, Bias, Delay, Ema, GatedRecorder, Gt, Latch, LinComb, LongOnly, Mul, Recorder, RollingMax, + RollingMin, SeriesReducer, SimBroker, Sma, Sqrt, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS, }; use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; @@ -1128,6 +1128,8 @@ struct Stage1RGrid { stop_length: Vec, stop_k: Vec, channel: Vec, + window: Vec, + band_k: Vec, } impl Default for Stage1RGrid { @@ -1138,6 +1140,8 @@ impl Default for Stage1RGrid { stop_length: vec![STAGE1_R_STOP_LENGTH], stop_k: vec![STAGE1_R_STOP_K], channel: vec![1920], + window: vec![1920], + band_k: vec![2.0], } } } @@ -1337,6 +1341,79 @@ fn stage1_breakout_sweep_family(trace: Option<&str>, data: &DataSource, grid: &S SweepFamily { space: vec![], points } } +fn stage1_meanrev_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RGrid) -> SweepFamily { + let pip = data.pip_size(); + let window = data.full_window(); + let mut varying: HashSet = HashSet::new(); + if grid.window.len() > 1 { + varying.insert("window".to_string()); + } + if grid.band_k.len() > 1 { + varying.insert("band_k".to_string()); + } + if grid.stop_length.len() > 1 { + varying.insert("stop_length".to_string()); + } + if grid.stop_k.len() > 1 { + varying.insert("stop_k".to_string()); + } + let mut points = Vec::new(); + for &n in &grid.window { + for &bk in &grid.band_k { + for &sl in &grid.stop_length { + for &sk in &grid.stop_k { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, rx_req) = mpsc::channel(); + let reduce = trace.is_none(); + let flat = + stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(n), bk, sl, sk, reduce) + .compile_with_params(&[]) + .expect("valid stage1-meanrev blueprint"); + let mut h = Harness::bootstrap(flat).expect("valid stage1-meanrev harness"); + h.run(data.run_sources()); + let named: Vec<(String, Scalar)> = vec![ + ("window".to_string(), Scalar::i64(n)), + ("band_k".to_string(), Scalar::f64(bk)), + ("stop_length".to_string(), Scalar::i64(sl)), + ("stop_k".to_string(), Scalar::f64(sk)), + ]; + let key = member_key(&named, &varying); + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + let metrics = if reduce { + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let (total_pips, max_drawdown) = rx_eq + .try_iter() + .next() + .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) + .unwrap_or((0.0, 0.0)); + let bias_sign_flips = + rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); + let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; + m.r = Some(summarize_r(&r_rows, 0.0)); + m + } else { + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); + if let Some(name) = trace { + persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows); + } + let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); + m.r = Some(summarize_r(&r_rows, 0.0)); + m + }; + points.push(SweepPoint { params: vec![], report: RunReport { manifest, metrics } }); + } + } + } + } + SweepFamily { space: vec![], points } +} + /// Render a sweep family as one `RunReport` JSON line per point. Test helper: /// production (`run_sweep`) renders *and* persists per point. #[cfg(test)] @@ -1364,6 +1441,7 @@ enum Strategy { Momentum, Stage1R, Stage1Breakout, + Stage1MeanRev, } /// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting @@ -1383,7 +1461,7 @@ fn parse_csv_list(s: &str) -> Result, ()> { } /// Parse the `sweep` tail: -/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ]`. +/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]`. /// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura /// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms, /// `i64`) require `--real` (there is no synthetic window knob). The four optional grid @@ -1396,7 +1474,7 @@ fn parse_csv_list(s: &str) -> Result, ()> { fn parse_sweep_args( rest: &[&str], ) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> { - let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ]".to_string(); + let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]".to_string(); let mut strategy = Strategy::SmaCross; let mut name: Option<(String, bool)> = None; // (name, persist) let mut real = RealWindowGrammar::default(); @@ -1415,6 +1493,7 @@ fn parse_sweep_args( "momentum" => Strategy::Momentum, "stage1-r" => Strategy::Stage1R, "stage1-breakout" => Strategy::Stage1Breakout, + "stage1-meanrev" => Strategy::Stage1MeanRev, _ => return Err(usage()), }; } @@ -1425,6 +1504,8 @@ fn parse_sweep_args( "--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?, "--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?, "--channel" => grid.channel = parse_csv_list(value).map_err(|()| usage())?, + "--window" => grid.window = parse_csv_list(value).map_err(|()| usage())?, + "--band-k" => grid.band_k = parse_csv_list(value).map_err(|()| usage())?, _ => return Err(usage()), } tail = t; @@ -1483,7 +1564,7 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { ) } -/// `aura sweep [--strategy ] [--name |--trace ]`: run the +/// `aura sweep [--strategy ] [--name |--trace ]`: run the /// selected built-in sweep, persist it as a *family* (related records sharing one /// `family_id`, C18/C21) via `append_family`, and print each point's record line /// carrying the assigned id. With `--trace`, every strategy @@ -1503,6 +1584,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, gr Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data), Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data, grid), Strategy::Stage1Breakout => stage1_breakout_sweep_family(persist.then_some(name), &data, grid), + Strategy::Stage1MeanRev => stage1_meanrev_sweep_family(persist.then_some(name), &data, grid), }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, @@ -2204,6 +2286,121 @@ fn stage1_breakout_graph( g.build().expect("stage1_breakout wiring resolves") } +/// The Stage-1 EWMA Bollinger-band mean-reversion candidate, mirroring +/// `stage1_breakout_graph` but swapping the signal leg: fade deviation from a +/// rolling mean (price above `mean + k*sigma` -> short; below `mean - k*sigma` -> +/// long), latched +-1. sigma = `Sqrt(Ema((price-mean)^2))` (deviation squared +/// then smoothed, the vol_stop shape — no catastrophic cancellation, no NaN). +/// No Delay: the current bar legitimately belongs to its own band (causal, C2). +/// `window` gangs the mean Ema and the variance Ema (one Bollinger window); +/// `band_k` is the band half-width in sigma. Everything below the signal leg is +/// byte-identical to `stage1_breakout_graph`. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +fn stage1_meanrev_graph( + tx_eq: mpsc::Sender<(Timestamp, Vec)>, + tx_ex: mpsc::Sender<(Timestamp, Vec)>, + tx_r: mpsc::Sender<(Timestamp, Vec)>, + tx_req: mpsc::Sender<(Timestamp, Vec)>, + window: Option, + band_k: f64, + stop_length: i64, + stop_k: f64, + reduce: bool, +) -> Composite { + let mut g = GraphBuilder::new("stage1_meanrev"); + // EWMA Bollinger-band mean-reversion signal leg (the ONLY change vs breakout). + let (mut mean_b, mut var_b) = + (Ema::builder().named("mean_window"), Ema::builder().named("var_window")); + if let Some(n) = window { + mean_b = mean_b.bind("length", Scalar::i64(n)); + var_b = var_b.bind("length", Scalar::i64(n)); + } + let mean = g.add(mean_b); + let dev = g.add(Sub::builder()); // price - mean + let sq = g.add(Mul::builder()); // dev * dev + let var = g.add(var_b); // EWMA variance + let sigma = g.add(Sqrt::builder()); // sigma (price units) + let band = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(band_k))); // k*sigma + let upper = g.add(Add::builder()); // mean + k*sigma + let lower = g.add(Sub::builder()); // mean - k*sigma + let gt_hi = g.add(Gt::builder()); // price > upper -> overextended up -> fade short + let gt_lo = g.add(Gt::builder()); // lower > price -> overextended down -> fade long + let short_latch = g.add(Latch::builder()); + let long_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias in {-1,0,+1} + // pip branch (VERBATIM from stage1_breakout_graph). + let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); + let gate_col = PM_FIELD_NAMES + .iter() + .position(|&n| n == "closed_this_cycle") + .expect("PM record has a closed_this_cycle column"); + let eq = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_eq)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)) + }; + let ex = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_ex)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) + }; + let exec = g.add(risk_executor(StopRule::Vol { length: stop_length, k: stop_k }, 1.0)); + let rrec = if reduce { + g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) + } else { + g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) + }; + let price = g.source_role("price", ScalarKind::F64); + g.feed( + price, + [ + mean.input("series"), + dev.input("lhs"), + gt_hi.input("a"), + gt_lo.input("b"), + broker.input("price"), + exec.input("price"), + ], + ); + g.connect(mean.output("value"), dev.input("rhs")); + g.connect(dev.output("value"), sq.input("lhs")); + g.connect(dev.output("value"), sq.input("rhs")); // square: feed dev to both legs + g.connect(sq.output("value"), var.input("series")); + g.connect(var.output("value"), sigma.input("value")); + g.connect(sigma.output("value"), band.input("term[0]")); + g.connect(mean.output("value"), upper.input("lhs")); + g.connect(band.output("value"), upper.input("rhs")); // upper = mean + k*sigma + g.connect(mean.output("value"), lower.input("lhs")); + g.connect(band.output("value"), lower.input("rhs")); // lower = mean - k*sigma + g.connect(upper.output("value"), gt_hi.input("b")); + g.connect(lower.output("value"), gt_lo.input("a")); + g.connect(gt_hi.output("value"), short_latch.input("set")); + g.connect(gt_lo.output("value"), short_latch.input("reset")); + g.connect(gt_lo.output("value"), long_latch.input("set")); + g.connect(gt_hi.output("value"), long_latch.input("reset")); + g.connect(long_latch.output("value"), exposure.input("lhs")); + g.connect(short_latch.output("value"), exposure.input("rhs")); + g.connect(exposure.output("value"), broker.input("exposure")); + g.connect(exposure.output("value"), ex.input("col[0]")); + g.connect(exposure.output("value"), exec.input("bias")); + g.connect(broker.output("equity"), eq.input("col[0]")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str())); + } + if !reduce { + let r_equity = g.add( + LinComb::builder(2) + .bind("weights[0]", Scalar::f64(1.0)) + .bind("weights[1]", Scalar::f64(1.0)), + ); + let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); + g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); + g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); + g.connect(r_equity.output("value"), req.input("col[0]")); + } + g.build().expect("stage1_meanrev wiring resolves") +} + /// `aura run --harness stage1-r [--real [--from][--to]] [--trace ]`: build the /// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via /// `summarize` and the dense R-record via `summarize_r`, and attach the R block as @@ -2386,7 +2583,7 @@ fn run_dispatch(args: RunArgs) -> Result { } const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -3354,6 +3551,67 @@ mod tests { assert!(parse_sweep_args(&["--channel", ""]).is_err()); // empty item } + /// Property: the meanrev `--window` / `--band-k` flags parse comma-separated + /// lists onto `Stage1RGrid.{window,band_k}` (the meanrev family's signal axes), + /// with the same strictness as the other grid flags — absent flags keep the + /// historical defaults, a malformed list is the usage error. + #[test] + fn parse_sweep_args_parses_the_meanrev_grid_flags() { + let parsed = parse_sweep_args(&[ + "--strategy", "stage1-meanrev", "--window", "120,240,480", "--band-k", "1.5,2.5", + ]) + .expect("the meanrev grid flags parse"); + assert_eq!(parsed.0, Strategy::Stage1MeanRev); + assert_eq!(parsed.4.window, vec![120, 240, 480]); + assert_eq!(parsed.4.band_k, vec![1.5, 2.5]); + // absent flags keep the historical defaults. + assert_eq!(parse_sweep_args(&[]).unwrap().4.window, vec![1920]); + assert_eq!(parse_sweep_args(&[]).unwrap().4.band_k, vec![2.0]); + // a malformed list is the strict usage error. + assert!(parse_sweep_args(&["--window", "120,x"]).is_err()); + assert!(parse_sweep_args(&["--band-k", ""]).is_err()); + } + + /// Property: the *shipped* `stage1_meanrev_graph` (the CLI compile unit, not + /// the hand-rebuilt subgraph in `stage1_meanrev_e2e.rs`) FADES against the + /// move — its exposure tap reads short (-1) above the band and long (+1) + /// below, the sign-inverted-vs-breakout polarity that defines mean-reversion. + /// A latch-polarity copy-paste from `stage1_breakout_graph` (swapping the + /// `set`/`reset` legs) would leave the fold-vs-raw and window-grid CLI tests + /// green yet silently invert the signal; only an observable read of this + /// function's bias catches it. `k = 0` collapses the band to the lagging EWMA + /// mean, isolating direction + latch from the sigma threshold; window 3 + /// (alpha = 0.5) lags the level clearly. The exposure Recorder (`tx_ex`, + /// `reduce = false`) carries the bias in col[0]. + #[test] + fn stage1_meanrev_graph_fades_short_above_the_band_and_long_below() { + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, _rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let flat = stage1_meanrev_graph(tx_eq, tx_ex, tx_r, tx_req, Some(3), 0.0, 3, 2.0, false) + .compile_with_params(&[]) + .expect("stage1-meanrev blueprint compiles"); + let mut h = Harness::bootstrap(flat).expect("stage1-meanrev harness bootstraps"); + // calm (price == lagging mean -> no fade) | sustained UP (price > mean -> + // fade SHORT) | sustained DOWN (price < mean -> fade LONG). + let closes = [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, + ]; + let prices: Vec<(Timestamp, Scalar)> = + closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); + let src: Vec> = vec![Box::new(VecSource::new(prices))]; + h.run(src); + let bias: Vec = + rx_ex.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect(); + assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up"); + assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}"); + let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); + let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); + assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}"); + assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}"); + } + /// `parse_sweep_args` admits a real symbol with an optional `--from`/`--to` /// window (yielding `DataChoice::Real`), still honouring `--strategy`/`--trace`; /// a `--real` without its symbol, or a window flag without `--real`, is rejected. diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 7ab354b..35ea2a3 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -2148,3 +2148,96 @@ fn sweep_strategy_stage1_breakout_grids_one_member_per_channel() { ); let _ = std::fs::remove_dir_all(&cwd); } + +/// The mean-reversion strategy reaches the CLI seam: `aura sweep --strategy +/// stage1-meanrev` emits an R-bearing member, and the folded no-trace path equals +/// the raw --trace path byte-for-byte (parity with the stage1-r / breakout +/// fold-vs-raw guard). window 3 warms up on the synthetic stream; one window × +/// one band_k × the single default stop = one member. +#[test] +fn sweep_strategy_stage1_meanrev_folded_no_trace_metrics_equal_raw_trace_metrics() { + let cwd = temp_cwd("sweep-stage1-meanrev-fold-vs-raw"); + + let folded = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3"]) + .current_dir(&cwd) + .output() + .expect("spawn folded (no-trace) stage1-meanrev sweep"); + assert!( + folded.status.success(), + "folded no-trace meanrev sweep exit: {:?}; stderr: {}", + folded.status, + String::from_utf8_lossy(&folded.stderr) + ); + let folded_out = String::from_utf8(folded.stdout).expect("utf-8"); + + let raw = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3", "--trace", "m1"]) + .current_dir(&cwd) + .output() + .expect("spawn raw (--trace) stage1-meanrev sweep"); + assert!( + raw.status.success(), + "raw --trace meanrev sweep exit: {:?}; stderr: {}", + raw.status, + String::from_utf8_lossy(&raw.stderr) + ); + let raw_out = String::from_utf8(raw.stdout).expect("utf-8"); + + let folded_lines: Vec<&str> = folded_out.lines().collect(); + let raw_lines: Vec<&str> = raw_out.lines().collect(); + assert_eq!(folded_lines.len(), 1, "folded meanrev sweep must print 1 member: {folded_out:?}"); + assert_eq!( + raw_lines.len(), + folded_lines.len(), + "member count must match: folded {} vs raw {}", + folded_lines.len(), + raw_lines.len() + ); + + for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() { + let fm = metrics_object(f); + let rm = metrics_object(r); + assert!(fm.contains("\"r\":{"), "folded meanrev member {i} must carry an r block: {fm}"); + assert_eq!( + fm, rm, + "folded vs raw meanrev metrics diverge at member {i}\n folded: {fm}\n raw: {rm}" + ); + } + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: the meanrev family is the cartesian product of its grid axes — a +/// multi-value `--window` list yields exactly one member per window value, each +/// carrying its own bound window length in the manifest params. Guards the manual +/// cartesian loop in `stage1_meanrev_sweep_family`. windows 3 and 4 both warm up +/// on the 18-bar synthetic stream, so two members must appear, in grid order. +#[test] +fn sweep_strategy_stage1_meanrev_grids_one_member_per_window() { + let cwd = temp_cwd("sweep-stage1-meanrev-window-grid"); + let out = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-meanrev", "--window", "3,4"]) + .current_dir(&cwd) + .output() + .expect("spawn 2-window stage1-meanrev sweep"); + assert!( + out.status.success(), + "window-grid meanrev sweep exit: {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 2, "2-window grid must print 2 members: {stdout:?}"); + assert!( + lines[0].contains("[\"window\",{\"I64\":3}]"), + "first member must bind window=3: {}", + lines[0] + ); + assert!( + lines[1].contains("[\"window\",{\"I64\":4}]"), + "second member must bind window=4: {}", + lines[1] + ); + let _ = std::fs::remove_dir_all(&cwd); +}