feat(0072): stage1 mean-reversion candidate — EWMA Bollinger fade (#137)

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
This commit is contained in:
2026-06-25 14:27:30 +02:00
parent f0949578a3
commit 216cc8c417
2 changed files with 357 additions and 6 deletions
+264 -6
View File
@@ -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<i64>,
stop_k: Vec<f64>,
channel: Vec<i64>,
window: Vec<i64>,
band_k: Vec<f64>,
}
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<String> = 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<Scalar>)> = 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<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = 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<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
}
/// Parse the `sweep` tail:
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>]`.
/// `[--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]`.
/// 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<T: std::str::FromStr>(s: &str) -> Result<Vec<T>, ()> {
fn parse_sweep_args(
rest: &[&str],
) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> {
let usage = || "sweep [--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>]".to_string();
let usage = || "sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n> | --trace <n>] [--fast <csv>] [--slow <csv>] [--stop-length <csv>] [--stop-k <csv>] [--channel <csv>] [--window <csv>] [--band-k <csv>]".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 <sma|momentum|stage1-r|stage1-breakout>] [--name <n>|--trace <n>]`: run the
/// `aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--name <n>|--trace <n>]`: 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<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
window: Option<i64>,
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 <SYM> [--from][--to]] [--trace <n>]`: 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<RunReport, String> {
}
const USAGE: &str =
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
"usage: aura run [--harness <sma|macd|stage1-r>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>] | aura chart <name> [--tap <t>] [--panels] | aura graph | aura sweep [--strategy <sma|momentum|stage1-r|stage1-breakout|stage1-meanrev>] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
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<Box<dyn aura_engine::Source>> = vec![Box::new(VecSource::new(prices))];
h.run(src);
let bias: Vec<f64> =
rx_ex.try_iter().map(|(_, row): (Timestamp, Vec<Scalar>)| 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.
+93
View File
@@ -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);
}