From e7efe1f765d20f4ab37478c94a8ccba91d1d42a9 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 9 Jul 2026 14:48:59 +0200 Subject: [PATCH] feat(cli): walkforward + mc go blueprint-generic (#220 slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 3-4 of the verb-axis-generalization plan: - walkforward: the welded r-sma campaign branch moves out of the None arm into the blueprint mode, split on --real (campaign sugar) vs the unchanged synthetic family path; WalkforwardCmd drops --strategy/--fast/--slow; the full-window clip block, wf_ms_sizes and WINNER_SELECTION_METRIC move unchanged; the --select plateau #215 refusal survives. walkforward_summary_json_from_reports is de-welded: the axis-name list is passed in from the invocation instead of the const AXES r-sma quadruple. - mc: McCmd drops the welded flags and gains --axis; the blueprint arm splits on --real (campaign pipeline: requires --axis, refuses --seeds) vs the unchanged synthetic seed family (--seeds); block-len/ resamples/seed defaults and the --name/--trace refusal stay byte-identical. - translate_walkforward/run_walkforward_sugar and translate_mc/ run_mc_sugar adopt SugarInvocation (+ WfWindows/McKnobs); ZERO #[allow(clippy::too_many_arguments)] remain in verb_sugar.rs (#214 complete). - Tests: wf/mc e2e groups migrate argv-only (grade anchors keep all pinned floats verbatim); collateral usage/retired-token tests assert clap's structural rejection of the deleted flags; the synthetic family control groups pass unmodified. Necessary deviation from the plan's literal step text: re-added #[allow(clippy::type_complexity)] on walkforward_args_from (clippy -D warnings flags the 6-tuple; mirrors its siblings). Held quality nits (validate/strip block now quadruplicated across the four dispatch fns; inline exit-mapping recurrence) are deliberate plan-holds — dedup follows as the rule-of-three cleanup at cycle end. Verification: full workspace suite green in the loop's independent verify; build + clippy -D warnings clean. refs #220, refs #214 --- crates/aura-cli/src/main.rs | 527 +++++++++++++++--------------- crates/aura-cli/src/verb_sugar.rs | 340 +++++++------------ crates/aura-cli/tests/cli_run.rs | 326 +++++++++++++++--- 3 files changed, 660 insertions(+), 533 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index fba2346..f8970ef 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -786,31 +786,29 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String { /// The walk-forward summary line reconstructed from the recorded per-window OOS /// reports (the campaign path's `WalkForward` `StageFamily.reports`) rather than a -/// live `WalkForwardResult`. Byte-identical to `walkforward_summary_json`: -/// `stitched_total_pips` = the per-window `total_pips` summed left-to-right in roll -/// order (the engine `stitch` folds each segment's final cumulative value, and a -/// window's OOS segment ends at its `total_pips`); `param_stability` reduces each -/// r-sma IS-refit axis over the per-window chosen params (read from each report's -/// `manifest.params` via [`campaign_run::raw_matches_wrapped`] — the blueprint axes -/// `fast.length`/`slow.length` are recorded wrapped as `sma_signal.fast.length` etc. -/// by the strategy's own param space, while `stop_length`/`stop_k` ride the risk -/// regime unwrapped, so an exact-name match would miss the wrapped pair) through the -/// same `MetricStats::from_values`; the `oos_r` block pools the per-window `trade_rs` -/// through `r_metrics_from_rs`. Canonical JSON (C14). +/// live `WalkForwardResult`. `stitched_total_pips` = the per-window `total_pips` +/// summed left-to-right in roll order (the engine `stitch` folds each segment's +/// final cumulative value, and a window's OOS segment ends at its `total_pips`); +/// `param_stability` reduces each IS-refit axis in `axes` over the per-window +/// chosen params (read from each report's `manifest.params` via +/// [`campaign_run::raw_matches_wrapped`] — blueprint axes are recorded wrapped +/// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while +/// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match +/// would miss the wrapped ones) through the same `MetricStats::from_values`; the +/// `oos_r` block pools the per-window `trade_rs` through `r_metrics_from_rs`. +/// Canonical JSON (C14). /// -/// The axis list + order mirror what `r_sma_space` yields (fast, slow, stop_length, -/// stop_k) — the same order + values the inline `param_stability` reduces. A wrong -/// order or axis would fail the committed exact-grade anchor loudly (it pins -/// `param_stability[0].mean` = the fast-MA refit mean), never silently. -fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String { +/// `axes` carries the invocation's raw axis names in argv order, followed by the +/// stop columns when a regime is bound (#220 — no axis name is hardcoded here). +/// Order matters: the committed exact-grade anchor pins +/// `param_stability[0].mean` = the first axis's refit mean. +fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) -> String { let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum(); - // The four r-sma axis names, in `r_sma_space` order. Coercion to `f64` is - // decided per-value at runtime by the `Scalar` variant match below (i64 axes - // cast value-as-f64, the f64 axis passes through), exactly as - // `param_stability`'s per-`ScalarKind` coerce does — this list carries no - // per-axis type flag. - const AXES: [&str; 4] = ["fast.length", "slow.length", "stop_length", "stop_k"]; - let param_stability: Vec = AXES + // Coercion to `f64` is decided per-value at runtime by the `Scalar` variant + // match below (i64 axes cast value-as-f64, the f64 axis passes through), + // exactly as `param_stability`'s per-`ScalarKind` coerce does — the axis + // list carries no per-axis type flag. + let param_stability: Vec = axes .iter() .map(|axis| { let vals: Vec = reports @@ -821,7 +819,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport]) -> String { .params .iter() .find(|(name, _)| campaign_run::raw_matches_wrapped(axis, name)) - .expect("each r-sma walk-forward window records its chosen axis"); + .expect("each walk-forward window records its chosen axis"); match v { Scalar::I64(i) => *i as f64, Scalar::F64(f) => *f, @@ -2187,11 +2185,8 @@ struct SweepCmd { #[derive(Args)] struct WalkforwardCmd { - /// A loaded blueprint (.json); omit for the built-in --strategy grammar. + /// A loaded blueprint (.json, required — both grammars are blueprint-first). blueprint: Option, - /// Built-in strategy: r-sma (the R-scored SMA-cross sugar over the shipped example). - #[arg(long)] - strategy: Option, /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. #[arg(long)] real: Option, @@ -2207,16 +2202,10 @@ struct WalkforwardCmd { /// Family name that also persists each OOS window's taps (mutually exclusive with --name). #[arg(long)] trace: Option, - /// Fast-MA IS-refit grid axis (comma-separated values). - #[arg(long)] - fast: Option, - /// Slow-MA IS-refit grid axis (comma-separated values). - #[arg(long)] - slow: Option, - /// Stop-length IS-refit grid axis (comma-separated values). + /// Campaign-path stop length (single value; --real mode). #[arg(long)] stop_length: Option, - /// Stop-k IS-refit grid axis (comma-separated values). + /// Campaign-path stop-k multiple (single value; --real mode). #[arg(long)] stop_k: Option, /// In-sample winner selection: argmax | plateau:mean | plateau:worst (default argmax). @@ -2231,10 +2220,8 @@ struct WalkforwardCmd { struct McCmd { /// A loaded blueprint (.json); omit for the built-in grammar. blueprint: Option, - /// Built-in strategy: r-sma (the R-scored SMA-cross sugar over the shipped example). - #[arg(long)] - strategy: Option, - /// Real instrument symbol for the R-bootstrap (recorded data); requires --strategy r-sma. + /// Real instrument symbol for the R-bootstrap campaign path (recorded data); omit + /// for the synthetic seed family. #[arg(long)] real: Option, /// Window start (Unix ms, inclusive); requires --real. @@ -2249,16 +2236,13 @@ struct McCmd { /// Family name that also persists each seed's taps (mutually exclusive with --name). #[arg(long)] trace: Option, - /// Fast-MA grid axis for the R-bootstrap (comma-separated values). + /// Blueprint IS-refit axis `=` (repeatable, >=1 required; --real mode). #[arg(long)] - fast: Option, - /// Slow-MA grid axis for the R-bootstrap (comma-separated values). - #[arg(long)] - slow: Option, - /// Stop-length grid axis for the R-bootstrap (comma-separated values). + axis: Vec, + /// Campaign-path stop length (single value; --real mode). #[arg(long)] stop_length: Option, - /// Stop-k grid axis for the R-bootstrap (comma-separated values). + /// Campaign-path stop-k multiple (single value; --real mode). #[arg(long)] stop_k: Option, /// Moving-block bootstrap block length (R-bootstrap; default 1). @@ -2322,25 +2306,24 @@ fn select_is_plateau(select: Option<&str>) -> bool { } /// Convert `WalkforwardCmd` into the resolved argument shape the walkforward sugar -/// consumes (the dissolved `--strategy r-sma --real` branch). Single instrument, -/// multi-value fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is -/// a risk regime, not a swept axis); all four knobs required. `parse_csv_list` -/// (`:1586`) is generic over `T: FromStr`, so the same helper parses the i64 grids -/// and the f64 stop; the local `knobs` closure (captureless → `Copy`, mirroring -/// `generalize_args_from`'s idiom) is reused across the four `ok_or_else` calls. -/// `--name`/`--trace` are mutually exclusive here too, matching the flag's own -/// documented contract and the inline path's `name_persist` refusal. +/// consumes (the dissolved `.json --real` branch). Single instrument, single-value +/// stop (Fork A: the stop is a risk regime, not a swept axis); `parse_csv_list` is +/// generic over `T: FromStr`, so the same helper parses the i64 length and the f64 +/// k; the local `knobs` closure (captureless → `Copy`, mirroring `mc_args_from`'s +/// idiom) is reused across both `ok_or_else` calls. `--name`/`--trace` are +/// mutually exclusive, matching the flag's own documented contract and the inline +/// path's `name_persist` refusal; an omitted flag defaults the family name to +/// "walkforward". The IS-refit `--axis` grid is parsed separately at the dispatch +/// site, not here. #[allow(clippy::type_complexity)] fn walkforward_args_from( a: &WalkforwardCmd, -) -> Result<(String, String, Vec, Vec, i64, f64, Option, Option), String> { +) -> Result<(String, String, i64, f64, Option, Option), String> { let symbol = match a.real.as_deref() { - None | Some("") => return Err("walkforward --strategy r-sma dissolves only over --real ".to_string()), + None | Some("") => return Err("walkforward dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; - let knobs = || "walkforward requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string(); - let fast: Vec = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; - let slow: Vec = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let knobs = || "walkforward requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string(); let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; let stop_k: Vec = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; if stop_length.len() != 1 || stop_k.len() != 1 { @@ -2354,35 +2337,30 @@ fn walkforward_args_from( (None, Some(t)) => t.to_string(), (None, None) => "walkforward".to_string(), }; - Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to)) + Ok((name, symbol, stop_length[0], stop_k[0], a.from, a.to)) } /// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the -/// dissolved `--strategy r-sma --real` branch). Single instrument, multi-value -/// fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is a risk -/// regime, not a swept axis); all four grid knobs required. `--block-len`/ -/// `--resamples`/`--seed` default to `1`/`1000`/`1` — the same defaults the retired -/// real-R dispatch used, so an omitted flag produces the same document either way. The usize->u32 conversion for `block_len`/`resamples` -/// lands HERE, at the argv boundary where the CLI's `usize` meets the document's -/// `u32` vocabulary (`StageBlock::MonteCarlo`), keeping every downstream signature -/// in the document's native type. `--name`/`--trace` are rejected: the inline mc -/// R-path rejects them too (`main.rs:5047`), so the sugar preserves that (the -/// R-bootstrap records without a family name — the campaign name is a constant -/// "mc"). +/// `--real` campaign branch). Single instrument, single-value stop (Fork A: the +/// stop is a risk regime, not a swept axis). `--block-len`/`--resamples`/`--seed` +/// default to `1`/`1000`/`1` — the same defaults the retired real-R dispatch +/// used, so an omitted flag produces the same document either way. The +/// usize->u32 conversion lands HERE, at the argv boundary where the CLI's +/// `usize` meets the document's `u32` vocabulary (`StageBlock::MonteCarlo`). +/// `--name`/`--trace` are rejected: the R-bootstrap records without a family +/// name (the campaign name is a constant "mc"). #[allow(clippy::type_complexity)] fn mc_args_from( a: &McCmd, -) -> Result<(String, String, Vec, Vec, i64, f64, u32, u32, u64, Option, Option), String> { +) -> Result<(String, String, i64, f64, u32, u32, u64, Option, Option), String> { let symbol = match a.real.as_deref() { - None | Some("") => return Err("mc --strategy r-sma dissolves only over --real ".to_string()), + None | Some("") => return Err("mc dissolves only over --real ".to_string()), Some(s) => s.to_string(), }; if a.name.is_some() || a.trace.is_some() { - return Err("mc --strategy r-sma: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); + return Err("mc --real: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); } - let knobs = || "mc requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string(); - let fast: Vec = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; - let slow: Vec = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let knobs = || "mc requires --stop-length --stop-k (single-value; the stop is a risk regime)".to_string(); let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; let stop_k: Vec = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; if stop_length.len() != 1 || stop_k.len() != 1 { @@ -2391,7 +2369,7 @@ fn mc_args_from( let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); let seed = a.seed.unwrap_or(1); - Ok(("mc".to_string(), symbol, fast, slow, stop_length[0], stop_k[0], block_len, resamples, seed, a.from, a.to)) + Ok(("mc".to_string(), symbol, stop_length[0], stop_k[0], block_len, resamples, seed, a.from, a.to)) } /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar @@ -2483,6 +2461,54 @@ fn parse_axes( Ok(axes) } +/// The two distinguishable ways `validate_and_register_axes` can fail, carrying +/// the two distinct exit codes its four call sites (sweep/generalize/walkforward/mc) +/// preserved before the dedup: an unknown axis name is a usage error (exit 2, echoed +/// before the archive is touched); a registry write failure is a runtime error (exit 1). +enum AxisRegisterError { + UnknownAxis(String), + Registry(String), +} + +/// Validate every `--axis` name against the blueprint's WRAPPED probe namespace +/// (`blueprint_axis_probe`/`--list-axes`), then canonicalize + register the +/// blueprint by topology hash and strip every axis name to the RAW campaign +/// namespace (the sweep sequence, #210 c0110). A raw-form or fat-fingered axis +/// name is refused here, echoing exactly what the user typed, before the +/// archive is touched or the blueprint is registered — shared by every +/// campaign-path dispatcher (sweep/generalize/walkforward/mc all landed the +/// identical block; #220 slice-1 deferred this dedup to "once wf/mc land the +/// same block", rule-of-three now exceeded 4x). +#[allow(clippy::type_complexity)] +fn validate_and_register_axes( + doc: &str, + axes: &[(String, Vec)], + env: &project::Env, +) -> Result<(String, Vec<(String, Vec)>), AxisRegisterError> { + let space = blueprint_axis_probe(doc, env).param_space(); + for (n, _) in axes { + if !space.iter().any(|p| &p.name == n) { + return Err(AxisRegisterError::UnknownAxis(format!( + "axis \"{n}\" is not one of this blueprint's \ + sweepable axes — run 'aura sweep --list-axes' \ + to see them" + ))); + } + } + let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary"); + let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); + let reg = env.registry(); + let topo = topology_hash(&blueprint); + reg.put_blueprint(&topo, &canonical) + .map_err(|e| AxisRegisterError::Registry(e.to_string()))?; + let raw_axes: Vec<(String, Vec)> = axes + .iter() + .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) + .collect(); + Ok((canonical, raw_axes)) +} + /// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or /// the built-in harness-kind dispatch. fn dispatch_run(a: RunCmd, env: &project::Env) { @@ -2612,28 +2638,17 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { std::process::exit(2); } } - // Validate every --axis name against the WRAPPED probe namespace before - // stripping to the raw campaign namespace (the sweep sequence, #210 c0110). - let space = blueprint_axis_probe(&doc, env).param_space(); - for (n, _) in &axes { - if !space.iter().any(|p| &p.name == n) { - eprintln!( - "aura: axis \"{n}\" is not one of this blueprint's \ - sweepable axes — run 'aura sweep --list-axes' \ - to see them" - ); - std::process::exit(2); - } - } - let blueprint = blueprint_from_json(&doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); - let canonical = blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); - let reg = env.registry(); - let topo = topology_hash(&blueprint); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); + let (canonical, raw_axes) = + validate_and_register_axes(&doc, &axes, env).unwrap_or_else(|e| match e { + AxisRegisterError::UnknownAxis(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + AxisRegisterError::Registry(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + }); // Window: the explicit --from/--to when both present (byte-identical to the // retired welded path, verified by the exact-grade anchor); otherwise the // first symbol's full archive window as the single shared campaign window. @@ -2651,12 +2666,6 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { ) } }; - // Campaign documents speak the RAW campaign-axis namespace; `--axis` is - // typed against the WRAPPED probe. Strip one segment (the sweep strip). - let raw_axes: Vec<(String, Vec)> = axes - .iter() - .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) - .collect(); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name, @@ -2783,27 +2792,20 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { // strategy ref resolves against this one write; a second // put under `content_id_of(&canonical)` would target the // identical key and is not needed. - // FIX (#210 c0110 finding "wrapped-name refusal mangles the - // axis"): validate every `--axis` name against the WRAPPED - // probe namespace (`blueprint_axis_probe`/`--list-axes` — - // the same names #203's strip later consumes) BEFORE - // stripping/translating to the raw campaign namespace. A - // raw-form or fat-fingered name is refused here, echoing - // exactly what the user typed, instead of surfacing a - // stripped-then-mangled fragment deep inside a member run. - // Data-free (the probe only reloads the blueprint), so - // this fires before the archive is touched. - let space = blueprint_axis_probe(&doc, env).param_space(); - for (n, _) in &axes { - if !space.iter().any(|p| &p.name == n) { - eprintln!( - "aura: axis \"{n}\" is not one of this blueprint's \ - sweepable axes — run 'aura sweep --list-axes' \ - to see them" - ); - std::process::exit(2); - } - } + // Axis validation + canonicalize/register + the wrapped->raw + // strip are single-sourced in `validate_and_register_axes` + // (data-free, so this fires before the archive is touched). + let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + .unwrap_or_else(|e| match e { + AxisRegisterError::UnknownAxis(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + AxisRegisterError::Registry(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + }); let symbol = symbol.clone(); let source = DataSource::from_choice(data, env); let (from_ts, to_ts) = source.full_window(env); @@ -2822,29 +2824,6 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { aura_ingest::epoch_ns_to_unix_ms(from_ts), aura_ingest::epoch_ns_to_unix_ms(to_ts), ); - let blueprint = blueprint_from_json(&doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary"); - let canonical = - blueprint_to_json(&blueprint).expect("a loaded blueprint re-serializes"); - let reg = env.registry(); - let topo = topology_hash(&blueprint); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - // Campaign documents speak the RAW - // campaign-axis namespace (`bind_axes`'s convention, #203) — - // everything after the wrapper's one node segment — while - // `--axis` on a blueprint file is typed against the WRAPPED - // probe (`blueprint_axis_probe`/`list_blueprint_axes`, e.g. - // `sma_signal.fast.length`). Strip that one segment before - // generating the campaign doc, or `validate_campaign_refs` - // (which checks axes against the UNWRAPPED blueprint's own - // `param_space`) refuses every axis as unknown. - let raw_axes: Vec<(String, Vec)> = axes - .iter() - .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) - .collect(); let inv = verb_sugar::SugarInvocation { axes: &raw_axes, name: name.clone(), @@ -2874,12 +2853,14 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { } } -/// `aura walkforward`: IS-refit walk-forward over a loaded blueprint (an existing -/// `.json` first-positional) or the built-in `--strategy` walk-forward. +/// `aura walkforward`: IS-refit walk-forward over a loaded blueprint — the +/// synthetic in-process family (no `--real`) or the campaign path (`--real`, +/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { + // Single-sourced: both arms below read this one closure (house style, #179). + let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] --stop-length --stop-k [--from ] [--to ] [--name | --trace ]".to_string(); match is_blueprint_file(&a.blueprint) { Some(path) => { - let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ]".to_string(); let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); @@ -2888,16 +2869,6 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } - if a.strategy.is_some() - || a.fast.is_some() - || a.slow.is_some() - || a.stop_length.is_some() - || a.stop_k.is_some() - || a.trace.is_some() - { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); @@ -2906,27 +2877,10 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: walkforward requires >= 1 --axis to re-fit per window"); std::process::exit(2); } - let select = match a.select.as_deref() { - Some(s) => parse_select(s).unwrap_or_else(|()| { - eprintln!("aura: {}", usage()); - std::process::exit(2); - }), - None => Selection::Argmax, - }; - let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string()); - run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); - } - None => { - let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --strategy r-sma --real [--from ] [--to ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]".to_string(); - if a.blueprint.is_some() || !a.axis.is_empty() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - // Dissolution split (#210, Reading A): only the real-archive r-sma - // execution routes through the campaign path. Everything else — synthetic - // (any strategy) and non-r-sma real — stays on the inline handler below, - // fenced until #159. - if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() { + if a.real.is_some() { + // The campaign path (#220): the real-archive execution routes + // through the one campaign executor, over the user's own + // blueprint and axes (formerly the welded r-sma branch). if let Some(s) = a.select.as_deref() && parse_select(s).is_err() { @@ -2937,18 +2891,25 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: --select plateau is not yet available on the campaign path; see #215"); std::process::exit(2); } - let (name, symbol, fast, slow, stop_length, stop_k, from, to) = + let (name, symbol, stop_length, stop_k, from, to) = walkforward_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); - let canonical = include_str!("../examples/r_sma_open.json"); - let reg = env.registry(); - let topo = content_id(canonical); - reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); + // Axis validation + canonicalize/register + the wrapped->raw + // strip are single-sourced in `validate_and_register_axes` + // (data-free, so this fires before the archive is touched). + let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + .unwrap_or_else(|e| match e { + AxisRegisterError::UnknownAxis(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + AxisRegisterError::Registry(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + }); // Unlike `dispatch_generalize` (a single run per instrument, insensitive // to a day's edge shift), `blueprint_walkforward_family` sources its span // from `DataSource::wf_full_span`, which — for Real — ALWAYS clips @@ -2967,9 +2928,24 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { aura_ingest::epoch_ns_to_unix_ms(to_ts), ); let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + let inv = verb_sugar::SugarInvocation { + axes: &raw_axes, + name, + symbols: vec![symbol], + from_ms, + to_ms, + blueprint_canonical: &canonical, + stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), + }; verb_sugar::run_walkforward_sugar( - &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, - is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, canonical, env, + &inv, + WINNER_SELECTION_METRIC, + verb_sugar::WfWindows { + in_sample_ms: is_ms, + out_of_sample_ms: oos_ms, + step_ms, + }, + env, ) .unwrap_or_else(|m| { eprintln!("aura: {m}"); @@ -2977,18 +2953,36 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { }); return; } + // Synthetic in-process family path — unchanged (#220 non-goal). + if a.stop_length.is_some() || a.stop_k.is_some() || a.trace.is_some() { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + let select = match a.select.as_deref() { + Some(s) => parse_select(s).unwrap_or_else(|()| { + eprintln!("aura: {}", usage()); + std::process::exit(2); + }), + None => Selection::Argmax, + }; + let name = a.name.clone().unwrap_or_else(|| "walkforward".to_string()); + run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); + } + None => { eprintln!("aura: {}", usage()); std::process::exit(2); } } } -/// `aura mc`: loaded-blueprint Monte-Carlo (an existing `.json` first-positional), or -/// the built-in synthetic seed-resweep / r-sma R-bootstrap split. +/// `aura mc`: Monte-Carlo over a loaded blueprint — the synthetic seed family +/// (`--seeds`, closed blueprint) or the R-bootstrap campaign path (`--real`, +/// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). fn dispatch_mc(a: McCmd, env: &project::Env) { + // Single-sourced: every arm below reads this one closure (house style, #179). + let usage = || "Usage: aura mc --seeds [--name ] | aura mc --real --axis = [--axis …] --stop-length --stop-k [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]".to_string(); match is_blueprint_file(&a.blueprint) { Some(path) => { - let usage = "Usage: aura mc --seeds [--name ]"; let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); @@ -2997,57 +2991,42 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { eprintln!("aura: {path}: {msg}"); std::process::exit(2); } - // A built-in-only flag with a blueprint file is not in this grammar - // (MC over a loaded blueprint is synthetic-only this cycle). - if a.strategy.is_some() - || a.real.is_some() - || a.from.is_some() - || a.to.is_some() - || a.fast.is_some() - || a.slow.is_some() - || a.stop_length.is_some() - || a.stop_k.is_some() - || a.block_len.is_some() - || a.resamples.is_some() - || a.seed.is_some() - || a.trace.is_some() - { - eprintln!("aura: {usage}"); - std::process::exit(2); - } - let n_seeds = match a.seeds { - Some(n) if n > 0 => n, - _ => { - eprintln!("aura: {usage}"); + if a.real.is_some() { + // The campaign path (#220): blueprint + --real + --axis routes + // the R-bootstrap pipeline through the one campaign executor. + // The two mc modes stay disjoint: --seeds belongs to the + // synthetic seed family only. + if a.seeds.is_some() { + eprintln!("aura: {}", usage()); std::process::exit(2); } - }; - let name = a.name.clone().unwrap_or_else(|| "mc".to_string()); - run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env); - } - None => { - let usage = || "Usage: aura mc --seeds [--name ] | aura mc --strategy r-sma --real [--from ] [--to ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ]".to_string(); - if a.blueprint.is_some() || a.seeds.is_some() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - // Dissolution split (#210, Reading A): only the real-archive r-sma - // R-bootstrap execution routes through the campaign path. Everything else — - // synthetic-r-sma (no --real), the synthetic seed-resweep, the blueprint - // path — stays on the inline handler below, fenced until #159. - if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() { - let (name, symbol, fast, slow, stop_length, stop_k, block_len, resamples, seed, from, to) = + let (name, symbol, stop_length, stop_k, block_len, resamples, seed, from, to) = mc_args_from(&a).unwrap_or_else(|m| { eprintln!("aura: {m}"); std::process::exit(2); }); - let canonical = include_str!("../examples/r_sma_open.json"); - let reg = env.registry(); - let topo = content_id(canonical); - reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); + let axes = parse_axes(&a.axis, &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); }); + if axes.is_empty() { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + // Axis validation + canonicalize/register + the wrapped->raw + // strip are single-sourced in `validate_and_register_axes` + // (data-free, so this fires before the archive is touched). + let (canonical, raw_axes) = validate_and_register_axes(&doc, &axes, env) + .unwrap_or_else(|e| match e { + AxisRegisterError::UnknownAxis(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + AxisRegisterError::Registry(m) => { + eprintln!("aura: {m}"); + std::process::exit(1); + } + }); // `blueprint_walkforward_family` sources its span from // `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to` // to the archive's actual first/last bar in range: a holiday/weekend edge @@ -3064,10 +3043,25 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { aura_ingest::epoch_ns_to_unix_ms(to_ts), ); let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + let inv = verb_sugar::SugarInvocation { + axes: &raw_axes, + name, + symbols: vec![symbol], + from_ms, + to_ms, + blueprint_canonical: &canonical, + stop: Some(verb_sugar::VolStop { length: stop_length, k: stop_k }), + }; verb_sugar::run_mc_sugar( - &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, - is_ms, oos_ms, step_ms, resamples, block_len, seed, &name, &symbol, from_ms, to_ms, - canonical, env, + &inv, + WINNER_SELECTION_METRIC, + verb_sugar::WfWindows { + in_sample_ms: is_ms, + out_of_sample_ms: oos_ms, + step_ms, + }, + verb_sugar::McKnobs { resamples, block_len, seed }, + env, ) .unwrap_or_else(|m| { eprintln!("aura: {m}"); @@ -3075,24 +3069,32 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { }); return; } - // Any r-sma-only knob reaching here (past the dissolved sugar path above) - // is a usage error now — the inline R-bootstrap execution retired with - // `run_r_sma` (#159); the sole real-R execution is the sugar path above. - let r_path = a.strategy.is_some() - || a.real.is_some() - || a.from.is_some() + // Synthetic seed family (unchanged, #220 non-goal): closed + // blueprint, --seeds only; every campaign-mode flag is rejected. + if a.from.is_some() || a.to.is_some() - || a.fast.is_some() - || a.slow.is_some() || a.stop_length.is_some() || a.stop_k.is_some() || a.block_len.is_some() || a.resamples.is_some() - || a.seed.is_some(); - if r_path { + || a.seed.is_some() + || a.trace.is_some() + || !a.axis.is_empty() + { eprintln!("aura: {}", usage()); std::process::exit(2); } + let n_seeds = match a.seeds { + Some(n) if n > 0 => n, + _ => { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + }; + let name = a.name.clone().unwrap_or_else(|| "mc".to_string()); + run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env); + } + None => { eprintln!("aura: {}", usage()); std::process::exit(2); } @@ -4141,14 +4143,12 @@ mod tests { fn bare_mc_cmd() -> McCmd { McCmd { blueprint: None, - strategy: None, real: None, from: None, to: None, name: None, trace: None, - fast: None, - slow: None, + axis: Vec::new(), stop_length: None, stop_k: None, block_len: None, @@ -4186,15 +4186,10 @@ mod tests { #[test] fn mc_args_from_refuses_missing_knobs() { - let a = McCmd { - real: Some("GER40".to_string()), - fast: Some("3".to_string()), - slow: Some("12".to_string()), - ..bare_mc_cmd() - }; + let a = McCmd { real: Some("GER40".to_string()), ..bare_mc_cmd() }; let err = mc_args_from(&a).unwrap_err(); assert!( - err.contains("requires --fast --slow --stop-length --stop-k"), + err.contains("requires --stop-length --stop-k"), "missing-knob refusal: {err}" ); } @@ -4203,8 +4198,6 @@ mod tests { fn mc_args_from_refuses_a_multi_value_stop() { let a = McCmd { real: Some("GER40".to_string()), - fast: Some("3".to_string()), - slow: Some("12".to_string()), stop_length: Some("14,20".to_string()), stop_k: Some("2.0".to_string()), ..bare_mc_cmd() diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index b906d60..2903e52 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -26,10 +26,9 @@ pub(crate) struct GeneratedSweep { /// One dissolved-verb invocation's shared shape (#214/#220): arbitrary RAW /// axes (post `wrapped_to_raw_axis` strip), the family name, the instrument /// list, the shared window, the canonical blueprint, and the optional single -/// Vol stop regime (`None` binds no regime — sweep's contract). Sweep and -/// generalize route their dispatch args through this shape today; -/// walkforward/mc still build their args directly via their own -/// `--fast`/`--slow` builders, pending their own #220 dissolution. +/// Vol stop regime (`None` binds no regime — sweep's contract). All four +/// dissolved verbs (sweep, generalize, walkforward, mc) route their dispatch +/// args through this shape. pub(crate) struct SugarInvocation<'a> { pub axes: &'a [(String, Vec)], pub name: String, @@ -48,6 +47,22 @@ pub(crate) struct VolStop { pub k: f64, } +/// The walk-forward roller sizes (ms) a wf/mc invocation carries. +#[derive(Clone, Copy)] +pub(crate) struct WfWindows { + pub in_sample_ms: u64, + pub out_of_sample_ms: u64, + pub step_ms: u64, +} + +/// The mc-specific bootstrap knobs. +#[derive(Clone, Copy)] +pub(crate) struct McKnobs { + pub resamples: u32, + pub block_len: u32, + pub seed: u64, +} + /// The generic doc-axes map: every invocation axis under its arbitrary raw /// bind-key (the sweep shape, adopted by all four verbs — #220). fn doc_axes_from(axes: &[(String, Vec)]) -> Result, String> { @@ -349,29 +364,17 @@ pub(crate) struct GeneratedWalkforward { pub campaign: CampaignDoc, } -/// Translate one `aura walkforward --strategy r-sma --real` invocation into its two -/// generated documents: a `[std::sweep(argmax), std::walk_forward]` process (the -/// sweep only enumerates the IS-refit survivor grid for the wf stage; its recorded -/// argmax selection is not part of the summary) and a campaign running the fast/slow -/// grid over one instrument under a single risk regime that carries the stop. The -/// roller sizes (`in_sample_ms`/`out_of_sample_ms`/`step_ms`) come from the caller -/// (ms). `blueprint_canonical` is the bare `sma_signal` blueprint already stored by -/// topology hash; its content id is the strategy ref. -#[allow(clippy::too_many_arguments)] +/// Translate one `aura walkforward --real` invocation into its two generated +/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep +/// only enumerates the IS-refit survivor grid for the wf stage; its recorded +/// argmax selection is not part of the summary) and a campaign running the +/// invocation's axis grid over one instrument under its single risk regime. +/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's +/// blueprint already stored by topology hash; its content id is the strategy ref. pub(crate) fn translate_walkforward( - fast: &[i64], - slow: &[i64], - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - in_sample_ms: u64, - out_of_sample_ms: u64, - step_ms: u64, - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, + w: WfWindows, ) -> Result { let process = ProcessDoc { format_version: FORMAT_VERSION, @@ -387,39 +390,30 @@ pub(crate) fn translate_walkforward( }), }, StageBlock::WalkForward { - in_sample_ms, - out_of_sample_ms, - step_ms, + in_sample_ms: w.in_sample_ms, + out_of_sample_ms: w.out_of_sample_ms, + step_ms: w.step_ms, mode: WfMode::Rolling, metric: metric.to_string(), select: SelectRule::Argmax, }, ], }; - let mut doc_axes: BTreeMap = BTreeMap::new(); - doc_axes.insert( - "fast.length".to_string(), - axis_from_values("fast.length", &fast.iter().map(|&v| Scalar::i64(v)).collect::>())?, - ); - doc_axes.insert( - "slow.length".to_string(), - axis_from_values("slow.length", &slow.iter().map(|&v| Scalar::i64(v)).collect::>())?, - ); let campaign = CampaignDoc { format_version: FORMAT_VERSION, kind: DocKind::Campaign, - name: name.to_string(), + name: inv.name.clone(), description: None, data: DataSection { - instruments: vec![symbol.to_string()], - windows: vec![Window { from_ms, to_ms }], + instruments: inv.symbols.clone(), + windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], }, strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), - axes: doc_axes, + r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), + axes: doc_axes_from(inv.axes)?, }], process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }], + risk: risk_from(inv.stop), seed: 0, presentation: Presentation { persist_taps: vec![], @@ -445,34 +439,17 @@ pub(crate) fn register_generated_wf( /// Run one dissolved `walkforward` invocation end-to-end: register the generated /// documents, run through the one campaign path, then reprint the per-window member /// lines + the summary line from the recorded `WalkForward` family. The stdout -/// summary line is byte-identical to the inline path (the committed exact-grade -/// anchor is the gate). -#[allow(clippy::too_many_arguments)] +/// summary line is byte-identical to the retired welded path (the committed +/// exact-grade anchor is the gate). pub(crate) fn run_walkforward_sugar( - fast: &[i64], - slow: &[i64], - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - in_sample_ms: u64, - out_of_sample_ms: u64, - step_ms: u64, - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, + w: WfWindows, env: &crate::project::Env, ) -> Result<(), String> { - let generated = translate_walkforward( - fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms, - name, symbol, from_ms, to_ms, blueprint_canonical, - )?; - let probe_params: Vec<(String, Scalar)> = vec![ - ("fast.length".to_string(), Scalar::i64(fast[0])), - ("slow.length".to_string(), Scalar::i64(slow[0])), - ]; - validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; + let generated = translate_walkforward(inv, metric, w)?; + let probe_params = probe_params_from(inv.axes); + validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; let reg = env.registry(); let (_process_id, campaign_id) = register_generated_wf(®, &generated)?; let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; @@ -489,7 +466,14 @@ pub(crate) fn run_walkforward_sugar( for report in &wf.reports { println!("{}", crate::family_member_line(&wf.family_id, report)); } - println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports)); + // Summary axes: the invocation's raw axis names in argv order, then the + // stop columns when a regime is bound (#220 — the renderer hardcodes none). + let mut summary_axes: Vec = inv.axes.iter().map(|(n, _)| n.clone()).collect(); + if inv.stop.is_some() { + summary_axes.push("stop_length".to_string()); + summary_axes.push("stop_k".to_string()); + } + println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes)); Ok(()) } @@ -500,36 +484,22 @@ pub(crate) struct GeneratedMc { pub campaign: CampaignDoc, } -/// Translate one `aura mc --strategy r-sma --real` invocation into its two generated -/// documents: a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process -/// (the sweep enumerates the IS-refit survivor grid for the wf stage; the terminal -/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps `E[R]`) -/// and a campaign running the fast/slow grid over one instrument under a single risk -/// regime that carries the stop. Unlike `translate_walkforward` (which hardcodes -/// `seed: 0`), `translate_mc` sets `campaign.seed = seed` (the mc `--seed`): the wf -/// winners are argmax hence deflation-seed-independent (the shipped walkforward -/// anchor proved this), so remapping the campaign seed leaves the pooled series -/// unchanged while making the terminal `r_bootstrap` at that seed reproduce the -/// inline bootstrap. The roller sizes (`in_sample_ms`/`out_of_sample_ms`/`step_ms`) -/// and `blueprint_canonical` come from the caller, exactly as for walkforward. -#[allow(clippy::too_many_arguments)] +/// Translate one `aura mc --real` invocation into its two generated documents: +/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the +/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal +/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps +/// `E[R]`) and a campaign running the invocation's axis grid over one instrument +/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes +/// `seed: 0`), `translate_mc` sets `campaign.seed = mc.seed` (the mc `--seed`): +/// the wf winners are argmax hence deflation-seed-independent (the shipped +/// walkforward anchor proved this), so remapping the campaign seed leaves the +/// pooled series unchanged while making the terminal `r_bootstrap` at that seed +/// reproduce the retired inline bootstrap. pub(crate) fn translate_mc( - fast: &[i64], - slow: &[i64], - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - in_sample_ms: u64, - out_of_sample_ms: u64, - step_ms: u64, - resamples: u32, - block_len: u32, - seed: u64, - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, + w: WfWindows, + mc: McKnobs, ) -> Result { let process = ProcessDoc { format_version: FORMAT_VERSION, @@ -545,41 +515,32 @@ pub(crate) fn translate_mc( }), }, StageBlock::WalkForward { - in_sample_ms, - out_of_sample_ms, - step_ms, + in_sample_ms: w.in_sample_ms, + out_of_sample_ms: w.out_of_sample_ms, + step_ms: w.step_ms, mode: WfMode::Rolling, metric: metric.to_string(), select: SelectRule::Argmax, }, - StageBlock::MonteCarlo { resamples, block_len }, + StageBlock::MonteCarlo { resamples: mc.resamples, block_len: mc.block_len }, ], }; - let mut doc_axes: BTreeMap = BTreeMap::new(); - doc_axes.insert( - "fast.length".to_string(), - axis_from_values("fast.length", &fast.iter().map(|&v| Scalar::i64(v)).collect::>())?, - ); - doc_axes.insert( - "slow.length".to_string(), - axis_from_values("slow.length", &slow.iter().map(|&v| Scalar::i64(v)).collect::>())?, - ); let campaign = CampaignDoc { format_version: FORMAT_VERSION, kind: DocKind::Campaign, - name: name.to_string(), + name: inv.name.clone(), description: None, data: DataSection { - instruments: vec![symbol.to_string()], - windows: vec![Window { from_ms, to_ms }], + instruments: inv.symbols.clone(), + windows: vec![Window { from_ms: inv.from_ms, to_ms: inv.to_ms }], }, strategies: vec![StrategyEntry { - r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), - axes: doc_axes, + r#ref: DocRef::ContentId(content_id_of(inv.blueprint_canonical)), + axes: doc_axes_from(inv.axes)?, }], process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, - risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }], - seed, + risk: risk_from(inv.stop), + seed: mc.seed, presentation: Presentation { persist_taps: vec![], emit: vec!["family_table".to_string()], @@ -604,38 +565,19 @@ pub(crate) fn register_generated_mc( /// Run one dissolved `mc` invocation end-to-end: register the generated documents, /// run through the one campaign path, then reprint the single `mc_r_bootstrap` grade /// line from the recorded terminal `PooledOos` bootstrap. The stdout line is -/// byte-identical to the inline path (the committed exact-grade anchor is the gate). -/// mc prints ONLY the one summary line — no per-window member lines (unlike -/// walkforward): the monte_carlo stage is a terminal annotator, not a family. -#[allow(clippy::too_many_arguments)] +/// byte-identical to the retired welded path (the committed exact-grade anchor is +/// the gate). mc prints ONLY the one summary line — no per-window member lines +/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family. pub(crate) fn run_mc_sugar( - fast: &[i64], - slow: &[i64], - stop_length: i64, - stop_k: f64, + inv: &SugarInvocation, metric: &str, - in_sample_ms: u64, - out_of_sample_ms: u64, - step_ms: u64, - resamples: u32, - block_len: u32, - seed: u64, - name: &str, - symbol: &str, - from_ms: i64, - to_ms: i64, - blueprint_canonical: &str, + w: WfWindows, + mc: McKnobs, env: &crate::project::Env, ) -> Result<(), String> { - let generated = translate_mc( - fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms, - resamples, block_len, seed, name, symbol, from_ms, to_ms, blueprint_canonical, - )?; - let probe_params: Vec<(String, Scalar)> = vec![ - ("fast.length".to_string(), Scalar::i64(fast[0])), - ("slow.length".to_string(), Scalar::i64(slow[0])), - ]; - validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; + let generated = translate_mc(inv, metric, w, mc)?; + let probe_params = probe_params_from(inv.axes); + validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?; let reg = env.registry(); let (_process_id, campaign_id) = register_generated_mc(®, &generated)?; let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; @@ -693,6 +635,19 @@ mod tests { ] } + fn wf_axes() -> Vec<(String, Vec)> { + vec![ + ("fast.length".to_string(), vec![Scalar::i64(3), Scalar::i64(5)]), + ("slow.length".to_string(), vec![Scalar::i64(12), Scalar::i64(20)]), + ] + } + + const WF_W: WfWindows = WfWindows { + in_sample_ms: 7_776_000_000, + out_of_sample_ms: 2_592_000_000, + step_ms: 2_592_000_000, + }; + #[test] fn translate_sweep_is_deterministic_and_names_flow() { let ax = axes(); @@ -860,18 +815,10 @@ mod tests { #[test] fn translate_walkforward_is_deterministic_and_carries_the_regime() { - let a = translate_walkforward( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let b = translate_walkforward( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = wf_axes(); + let i = inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); + let a = translate_walkforward(&i, "sqn_normalized", WF_W).unwrap(); + let b = translate_walkforward(&i, "sqn_normalized", WF_W).unwrap(); assert_eq!( content_id_of(&campaign_to_json(&a.campaign)), content_id_of(&campaign_to_json(&b.campaign)), @@ -918,24 +865,10 @@ mod tests { #[test] fn translate_walkforward_diverging_regimes_do_not_collide_content_ids() { - let base = translate_walkforward( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_k = translate_walkforward( - &[3, 5], &[12, 20], 14, 3.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_length = translate_walkforward( - &[3, 5], &[12, 20], 20, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = wf_axes(); + let base = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W).unwrap(); + let different_k = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W).unwrap(); + let different_length = translate_walkforward(&inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 20, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W).unwrap(); let base_id = content_id_of(&campaign_to_json(&base.campaign)); let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); let length_id = content_id_of(&campaign_to_json(&different_length.campaign)); @@ -955,10 +888,11 @@ mod tests { )); std::fs::create_dir_all(&dir).unwrap(); let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); + let ax = wf_axes(); let generated = translate_walkforward( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - "walkforward", "GER40", 100, 200, "{\"bp\":1}", + &inv(&ax, "walkforward", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), + "sqn_normalized", + WF_W, ) .unwrap(); let (process_id, campaign_id) = register_generated_wf(®, &generated).unwrap(); @@ -975,20 +909,10 @@ mod tests { #[test] fn translate_mc_is_deterministic_and_carries_the_regime_and_seed() { - let a = translate_mc( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 42, - "mc", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let b = translate_mc( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 42, - "mc", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = wf_axes(); + let i = inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"); + let a = translate_mc(&i, "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); + let b = translate_mc(&i, "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); assert_eq!( content_id_of(&campaign_to_json(&a.campaign)), content_id_of(&campaign_to_json(&b.campaign)), @@ -1036,27 +960,10 @@ mod tests { #[test] fn translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids() { - let base = translate_mc( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 42, - "mc", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_seed = translate_mc( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 7, - "mc", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); - let different_k = translate_mc( - &[3, 5], &[12, 20], 14, 3.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 42, - "mc", "GER40", 100, 200, "{\"bp\":1}", - ) - .unwrap(); + let ax = wf_axes(); + let base = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); + let different_seed = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 7 }).unwrap(); + let different_k = translate_mc(&inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 3.0 }), "{\"bp\":1}"), "sqn_normalized", WF_W, McKnobs { resamples: 1000, block_len: 5, seed: 42 }).unwrap(); let base_id = content_id_of(&campaign_to_json(&base.campaign)); let seed_id = content_id_of(&campaign_to_json(&different_seed.campaign)); let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); @@ -1073,11 +980,12 @@ mod tests { )); std::fs::create_dir_all(&dir).unwrap(); let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); + let ax = wf_axes(); let generated = translate_mc( - &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", - 7_776_000_000, 2_592_000_000, 2_592_000_000, - 1000, 5, 42, - "mc", "GER40", 100, 200, "{\"bp\":1}", + &inv(&ax, "mc", &["GER40"], Some(VolStop { length: 14, k: 2.0 }), "{\"bp\":1}"), + "sqn_normalized", + WF_W, + McKnobs { resamples: 1000, block_len: 5, seed: 42 }, ) .unwrap(); let (process_id, campaign_id) = register_generated_mc(®, &generated).unwrap(); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index f9936a8..ca2bbc6 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1048,12 +1048,9 @@ fn mc_rejects_real_flag_with_usage_exit_2() { .current_dir(&dir) .output() .expect("spawn aura mc --real"); - assert_eq!(out.status.code(), Some(2), "mc --real must exit 2; status: {:?}", out.status); - assert!(out.stdout.is_empty(), "mc --real must not emit a run on stdout: {:?}", out.stdout); + assert_eq!(out.status.code(), Some(2), "mc --real without a blueprint is a usage error"); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("Usage"), "mc --real stderr must carry usage: {stderr:?}"); - // the exclusion is total: no `runs/` registry write either. - assert!(!dir.join("runs").exists(), "mc --real must not start a real run"); + assert!(stderr.contains("Usage: aura mc"), "usage refusal: {stderr}"); let _ = std::fs::remove_dir_all(&dir); } @@ -1099,11 +1096,11 @@ fn family_window_flag_without_real_refuses_with_usage_exit_2() { /// regression that drops the prefix again would NOT be caught by the older /// `family_window_flag_without_real_refuses_with_usage_exit_2` pin above, which /// only checks for the verb token and `--real` — not the `Usage: aura` lead-in. -/// An unknown `--strategy` name reaches this same closure on both commands, so -/// it is the smallest trigger for each. +/// An unknown `--strategy` name reaches sweep's closure; the bare verb reaches +/// walkforward's. #[test] fn builtin_branch_usage_lines_lead_with_the_house_style_prefix() { - for (args, verb) in [(&["sweep", "--strategy", "bogus"][..], "sweep"), (&["walkforward", "--strategy", "bogus"][..], "walkforward")] { + for (args, verb) in [(&["sweep", "--strategy", "bogus"][..], "sweep"), (&["walkforward"][..], "walkforward")] { let dir = temp_cwd("builtin_usage_prefix"); let out = Command::new(BIN) .args(args) @@ -1123,8 +1120,9 @@ fn builtin_branch_usage_lines_lead_with_the_house_style_prefix() { /// not just the first. This is the one lockstep-pinned site /// (`mc_rejects_real_flag_with_usage_exit_2` above), but that pin only checks /// for a single `"Usage"` occurrence — a regression that fixed only the first -/// alternative (leaving `… | mc --strategy r-sma …` unprefixed) would still -/// pass it. `--seeds` with no blueprint file is the built-in branch's own +/// alternative (leaving the second `| aura mc --real …` +/// alternative unprefixed) would still pass it. `--seeds` with no blueprint +/// file is the built-in branch's own /// grammar violation, reaching this exact literal. (#159 cut 4: the first /// alternative's literal is now the blueprint-mode grammar, not a bare /// `[--name` — `mc` is blueprint-first.) @@ -1143,7 +1141,7 @@ fn mc_builtin_usage_names_the_program_in_both_alternatives() { "first alternative must name the program: {stderr:?}" ); assert!( - stderr.contains("| aura mc --strategy r-sma"), + stderr.contains("| aura mc --real"), "second alternative must ALSO name the program: {stderr:?}" ); let _ = std::fs::remove_dir_all(&dir); @@ -1220,12 +1218,19 @@ fn sweep_and_walkforward_reject_retired_r_breakout_token_as_unrecognized() { .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); let stderr = String::from_utf8_lossy(&out.stderr); - let want = format!("aura: Usage: aura {verb} "); - assert!( - stderr.starts_with(&want), - "`aura {args:?}` must fall into the generic usage error, not a special-cased \ - message: {stderr:?}" - ); + if verb == "sweep" { + let want = format!("aura: Usage: aura {verb} "); + assert!( + stderr.starts_with(&want), + "`aura {args:?}` must fall into the generic usage error, not a special-cased \ + message: {stderr:?}" + ); + } else { + assert!( + stderr.contains("unexpected argument '--strategy'"), + "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" + ); + } assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout); let _ = std::fs::remove_dir_all(&cwd); } @@ -1244,8 +1249,15 @@ fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() { .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); assert_eq!(out.status.code(), Some(2), "`aura {args:?}` must exit 2; status: {:?}", out.status); let stderr = String::from_utf8_lossy(&out.stderr); - let want = format!("aura: Usage: aura {verb} "); - assert!(stderr.starts_with(&want), "generic usage, not special-cased: {stderr:?}"); + if verb == "sweep" { + let want = format!("aura: Usage: aura {verb} "); + assert!(stderr.starts_with(&want), "generic usage, not special-cased: {stderr:?}"); + } else { + assert!( + stderr.contains("unexpected argument '--strategy'"), + "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" + ); + } assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout); let _ = std::fs::remove_dir_all(&cwd); } @@ -1272,12 +1284,19 @@ fn sweep_and_walkforward_reject_retired_pip_tokens_as_unrecognized() { out.status ); let stderr = String::from_utf8_lossy(&out.stderr); - let want = format!("aura: Usage: aura {verb} "); - assert!( - stderr.starts_with(&want), - "`aura {args:?}` must fall into the generic usage error, not a \ - special-cased message: {stderr:?}" - ); + if verb == "sweep" { + let want = format!("aura: Usage: aura {verb} "); + assert!( + stderr.starts_with(&want), + "`aura {args:?}` must fall into the generic usage error, not a \ + special-cased message: {stderr:?}" + ); + } else { + assert!( + stderr.contains("unexpected argument '--strategy'"), + "`aura {args:?}` must be a clap unknown-argument rejection: {stderr:?}" + ); + } assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout); let _ = std::fs::remove_dir_all(&cwd); } @@ -1495,10 +1514,12 @@ fn walkforward_real_e2e_pins_the_exact_current_grade() { const FROM_MS: &str = "1735689600000"; const TO_MS: &str = "1767225599000"; let cwd = temp_cwd("walkforward-exact-grade"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -1535,15 +1556,72 @@ fn walkforward_real_e2e_pins_the_exact_current_grade() { assert_eq!(ps[1]["mean"].as_f64(), Some(17.333333333333332), "slow-MA refit mean: {grade_line}"); } +/// Property (#220 walkforward vertical): `aura walkforward --real` IS-refits ANY +/// sweepable blueprint through the campaign path, not just the historical r-sma +/// candidate — the whole point of dropping `--strategy r-sma`/`--fast`/`--slow` +/// for a positional blueprint + generic `--axis`. Every other walkforward E2E +/// test here still exercises `r_sma_open.json`; a regression that silently +/// special-cased the refit's axis lookup back to a hardcoded `sma_signal.*` +/// namespace (e.g. in `walkforward_summary_json_from_reports`'s per-axis +/// `raw_matches_wrapped` search) would ship green there while breaking every +/// other blueprint. This runs the unrelated r-breakout candidate +/// (`channel_hi`/`channel_lo` axes, no `sma_signal` node in sight) end-to-end +/// and asserts a well-formed multi-window summary whose `param_stability` has +/// exactly one entry per bound axis (2 blueprint axes + the 2 stop columns). +/// The window count (9) is roller/data-derived, not strategy-derived, so it is +/// pinned exactly against the same GER40 2025 span the r-sma anchor uses. +/// Gated on the shared GER40 archive; skips cleanly on a data refusal. +#[test] +fn walkforward_dissolves_a_non_r_sma_blueprint() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let cwd = temp_cwd("walkforward-non-r-sma"); + let fixture = format!("{}/examples/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "r_breakout_signal.channel_hi.length=10,20", + "--axis", "r_breakout_signal.channel_lo.length=10,20", + "--stop-length", "14", "--stop-k", "2.0", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if out.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let grade_line = stdout + .lines() + .find(|l| l.starts_with("{\"walkforward\":")) + .unwrap_or_else(|| panic!("the walkforward summary line: {stdout}")); + let v: serde_json::Value = serde_json::from_str(grade_line).expect("summary line parses as JSON"); + let wf = &v["walkforward"]; + assert_eq!(wf["windows"].as_u64(), Some(9), "window count (data/roller-derived): {grade_line}"); + assert!(wf["oos_r"]["n_trades"].as_u64().is_some(), "pooled OOS trade count present: {grade_line}"); + let ps = wf["param_stability"].as_array().expect("param_stability is an array"); + assert_eq!(ps.len(), 4, "one entry per bound axis (channel_hi, channel_lo, stop_length, stop_k): {grade_line}"); +} + /// Fork A: the dissolved walkforward binds the stop as a single risk regime, so a /// multi-value --stop-length/--stop-k is refused (exit 2) before any run. #[test] fn walkforward_dissolved_refuses_a_multi_value_stop() { let cwd = temp_cwd("walkforward-multi-stop"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14,20", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14,20", "--stop-k", "2.0", ]) .current_dir(&cwd) .output() @@ -1558,10 +1636,12 @@ fn walkforward_dissolved_refuses_a_multi_value_stop() { #[test] fn walkforward_dissolved_refuses_select_plateau() { let cwd = temp_cwd("walkforward-plateau"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--select", "plateau:worst", ]) .current_dir(&cwd) @@ -1577,14 +1657,18 @@ fn walkforward_dissolved_refuses_select_plateau() { #[test] fn walkforward_dissolved_refuses_missing_knobs() { let cwd = temp_cwd("walkforward-missing-knobs"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["walkforward", "--strategy", "r-sma", "--real", "GER40", "--fast", "3", "--slow", "12"]) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + ]) .current_dir(&cwd) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "missing --stop-length/--stop-k is a usage refusal"); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("requires --fast --slow --stop-length --stop-k"), "missing-knob refusal: {stderr}"); + assert!(stderr.contains("requires --stop-length --stop-k"), "missing-knob refusal: {stderr}"); } /// An empty `--real ""` still satisfies clap's `Option::is_some()` dispatch guard, so @@ -1593,10 +1677,12 @@ fn walkforward_dissolved_refuses_missing_knobs() { #[test] fn walkforward_dissolved_refuses_an_empty_real_symbol() { let cwd = temp_cwd("walkforward-empty-real"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", ]) .current_dir(&cwd) .output() @@ -1606,16 +1692,51 @@ fn walkforward_dissolved_refuses_an_empty_real_symbol() { assert!(stderr.contains("dissolves only over --real"), "empty-real refusal: {stderr}"); } +/// Property: an `--axis` name that is not among the loaded blueprint's sweepable +/// axes is refused before the raw-namespace strip or any archive access (exit 2), +/// echoing exactly the name the user typed — the same WRAPPED-probe preflight the +/// sibling verbs enforce +/// (`aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access`, +/// `generalize_refuses_an_unknown_axis_name`). Data-free: no archive/store access. +#[test] +fn walkforward_dissolved_refuses_an_unknown_axis_name() { + let cwd = temp_cwd("walkforward-unknown-axis"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), + "must name the unresolved axis exactly as typed: {stderr}" + ); + assert!( + out.stdout.is_empty(), + "the refusal must not leak an aggregate to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run"); +} + /// Front-end parity: an unknown `--select` token is refused (exit 2) on the /// dissolved path exactly as it is on the inline path — it must not be silently /// swallowed by `select_is_plateau`'s plateau-only check and fall through to argmax. #[test] fn walkforward_dissolved_refuses_an_unknown_select_token() { let cwd = temp_cwd("walkforward-bad-select"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--select", "bogus", ]) .current_dir(&cwd) @@ -1633,10 +1754,12 @@ fn walkforward_dissolved_refuses_an_unknown_select_token() { #[test] fn walkforward_dissolved_refuses_name_and_trace_together() { let cwd = temp_cwd("walkforward-name-and-trace"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--name", "a", "--trace", "b", ]) .current_dir(&cwd) @@ -1662,10 +1785,12 @@ fn walkforward_dissolves_through_the_campaign_path() { const FROM_MS: &str = "1735689600000"; const TO_MS: &str = "1767225599000"; let cwd = temp_cwd("walkforward-dissolves"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "walkforward", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14", "--stop-k", "2.0", + "walkforward", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", "--from", FROM_MS, "--to", TO_MS, ]) .current_dir(&cwd) @@ -3035,7 +3160,7 @@ fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() { for argv in [ &["run", "bogus"][..], &["sweep", "bogus", "--name", "x"][..], - &["walkforward", "bogus", "--strategy", "sma"][..], + &["walkforward", "bogus"][..], &["mc", "bogus"][..], ] { let out = Command::new(BIN).args(argv).output().expect("spawn aura "); @@ -3128,10 +3253,12 @@ fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() { const FROM_MS: &str = "1735689600000"; const TO_MS: &str = "1767225599000"; let cwd = temp_cwd("mc-r-bootstrap-exact-grade"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "mc", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "mc", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", "--block-len", "5", "--resamples", "1000", "--seed", "42", "--from", FROM_MS, "--to", TO_MS, ]) @@ -3164,6 +3291,65 @@ fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() { assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}"); } +/// Property (#220 mc vertical): `aura mc --real` R-bootstraps ANY sweepable +/// blueprint through the campaign path, not just the historical r-sma candidate +/// — the whole point of dropping `--strategy r-sma`/`--fast`/`--slow` for a +/// positional blueprint + generic `--axis`. Every other mc E2E test here still +/// exercises `r_sma_open.json`; a regression that silently special-cased the +/// pipeline's axis lookup back to a hardcoded `sma_signal.*` namespace would +/// ship green there while breaking every other blueprint. This runs the +/// unrelated r-breakout candidate (`channel_hi`/`channel_lo` axes, no +/// `sma_signal` node in sight) end-to-end and asserts a well-formed +/// `mc_r_bootstrap` grade: the argv-echoed knobs (`block_len`/`n_resamples`) +/// pin exactly, `n_trades`/`e_r.mean`/`prob_le_zero` are asserted present and +/// well-typed (their exact values are strategy-specific, already the r-sma +/// pin's job above). Gated on the shared GER40 archive; skips cleanly on a +/// data refusal. +#[test] +fn mc_dissolves_a_non_r_sma_blueprint() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let cwd = temp_cwd("mc-non-r-sma"); + let fixture = format!("{}/examples/r_breakout_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "mc", &fixture, "--real", "GER40", + "--axis", "r_breakout_signal.channel_hi.length=10,20", + "--axis", "r_breakout_signal.channel_lo.length=10,20", + "--stop-length", "14", "--stop-k", "2.0", + "--block-len", "5", "--resamples", "1000", "--seed", "42", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if out.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let grade_line = stdout + .lines() + .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) + .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")); + let v: serde_json::Value = serde_json::from_str(grade_line).expect("grade line parses as JSON"); + let mc = &v["mc_r_bootstrap"]; + assert_eq!(mc["block_len"].as_u64(), Some(5), "block_len (argv echo): {grade_line}"); + assert_eq!(mc["n_resamples"].as_u64(), Some(1000), "n_resamples (argv echo): {grade_line}"); + assert!(mc["n_trades"].as_u64().is_some_and(|n| n > 0), "pooled OOS trade count present: {grade_line}"); + assert!(mc["e_r"]["mean"].as_f64().is_some_and(|m| m.is_finite()), "E[R] mean present + finite: {grade_line}"); + assert!( + mc["prob_le_zero"].as_f64().is_some_and(|p| (0.0..=1.0).contains(&p)), + "P(E[R] <= 0) present + in [0,1]: {grade_line}" + ); +} + /// Property: `aura mc --strategy r-sma --real` is now thin sugar over the one campaign /// path — a successful run durably auto-registers exactly one generated process /// document, one generated campaign document (carrying the constant "mc" name and the @@ -3178,10 +3364,12 @@ fn mc_dissolves_through_the_campaign_path() { const FROM_MS: &str = "1735689600000"; const TO_MS: &str = "1767225599000"; let cwd = temp_cwd("mc-dissolves"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "mc", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "mc", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", "--block-len", "5", "--resamples", "1000", "--seed", "42", "--from", FROM_MS, "--to", TO_MS, ]) @@ -3267,10 +3455,12 @@ fn mc_dissolves_through_the_campaign_path() { #[test] fn mc_dissolved_refuses_a_multi_value_stop() { let cwd = temp_cwd("mc-multi-stop"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "mc", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3", "--slow", "12", "--stop-length", "14,20", "--stop-k", "2.0", + "mc", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14,20", "--stop-k", "2.0", ]) .current_dir(&cwd) .output() @@ -3280,6 +3470,40 @@ fn mc_dissolved_refuses_a_multi_value_stop() { assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}"); } +/// Property: an `--axis` name that is not among the loaded blueprint's sweepable +/// axes is refused before the raw-namespace strip or any archive access (exit 2), +/// echoing exactly the name the user typed — mc shares the identical WRAPPED-probe +/// preflight `walkforward_dissolved_refuses_an_unknown_axis_name` pins for +/// walkforward (both dispatchers now call the same `validate_and_register_axes` +/// helper); this test pins mc's own copy so a future edit cannot silently break it +/// while walkforward's stays green. Data-free: no archive/store access. +#[test] +fn mc_dissolved_refuses_an_unknown_axis_name() { + let cwd = temp_cwd("mc-unknown-axis"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "mc", &fixture, "--real", "GER40", + "--axis", "sma_signal.nope=3", "--axis", "sma_signal.slow.length=12", + "--stop-length", "14", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "an unknown axis name must exit 2"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("axis \"sma_signal.nope\"") && stderr.contains("sweepable axes"), + "must name the unresolved axis exactly as typed: {stderr}" + ); + assert!( + out.stdout.is_empty(), + "the refusal must not leak an aggregate to stdout, got: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + assert!(!cwd.join("runs").exists(), "a refused axis name must not start a real run"); +} + /// Property: the one load-bearing decision new to the mc dissolution — `translate_mc` /// maps `campaign.seed` to the mc `--seed` (unlike `translate_walkforward`, which /// hardcodes `seed: 0`) — actually flows through the executor to the emitted grade, @@ -3297,12 +3521,14 @@ fn mc_dissolved_refuses_a_multi_value_stop() { fn mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series() { const FROM_MS: &str = "1735689600000"; const TO_MS: &str = "1767225599000"; + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let run = |seed: &str| { let cwd = temp_cwd(&format!("mc-seed-{seed}")); std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args([ - "mc", "--strategy", "r-sma", "--real", "GER40", - "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "mc", &fixture, "--real", "GER40", + "--axis", "sma_signal.fast.length=3,5", "--axis", "sma_signal.slow.length=12,20", + "--stop-length", "14", "--stop-k", "2.0", "--block-len", "5", "--resamples", "1000", "--seed", seed, "--from", FROM_MS, "--to", TO_MS, ])