From 366170aeab61a6674a4ab653111fbf57159946c2 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 18:56:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(0098):=20CLI=20clap=20migration=20(iterati?= =?UTF-8?q?on=201)=20=E2=80=94=20scoped=20help,=20--version,=20GNU=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled aura-cli argv parser with a clap derive parser. Delivers, all from one declarative source: - scoped `aura --help` (stdout, exit 0) — each subcommand's own Options section; retires the #131 uniform-global-blob help - `--version` / `-V` → `aura 0.1.0` (stdout, exit 0) - GNU `--flag=value`, the `--` end-of-options terminator, and long-option abbreviation (`--harn` → `--harness`) Architecture: one root Cli/Command enum, one `*Cmd` struct per subcommand. The four dual-grammar subcommands (run/sweep/walkforward/mc) map the loaded-blueprint branch as an optional `[blueprint]` positional + a post-parse `is_file()` dispatch on a single-sourced predicate; each built-in handler re-asserts a stray-positional guard (refuse-don't-guess) so clap's optional positional cannot silently swallow a typo (pinned by two new E2E tests). The execution layer (run_*/emit_*/runs_*/reproduce_*) is untouched — only arg-plumbing changes, via thin `*_from` adapters reusing the existing value helpers. Exit codes behaviour-preserved: clap parse errors exit 2 (matching the old usage-error=2); domain refusals stay exit 2; reproduce-diverged stays exit 1. The exit-code split (2=usage / 1=runtime) is iteration 2. clap is admitted under the C16 per-case review (research-side CLI, invariant 8 untouched — a dev-loop compile tax, not a frozen-artifact tax). Forced plan corrections (implementer; verified against the diff): - Step 6's deletion of the `parse_*_args` fns orphaned ~34 grammar unit tests that called them; those were deleted and that grammar coverage moved to the renegotiated cli_run.rs E2E pins (`parse_select` kept + retested). - run_malformed_cost_value_...: a bad cost value is now a clap value-parse error (no "Usage:" line); renegotiated to pin exit 2 + empty stdout + the flag name "--cost-per-trade"; the units note stays pinned via `run --help`. - `allow_hyphen_values` on the three cost flags so `--cost-per-trade -0.5` reaches the non-negativity guard (else clap rejects -0.5 as an unknown option). - Deleted the now-dead `RealWindowGrammar` (clippy -D warnings). Orchestrator additions after the loop: - Enabled long-option abbreviation (`infer_long_args` on the root — one attribute, it propagates to subcommands) + a test, closing spec acceptance criterion 4 which the plan's RED tests did not cover. Decided to deliver rather than amend the spec: the two ratified decisions conflict on this low-tier item — Fork B (GNU compliance, getopt_long abbreviates) vs F5 (LLM/automation-first, human-only niceties deprioritized) — and the cost tiebreaker (one attribute, not per-struct work) resolves to deliver. Carry-on debt (not this iteration): the error-message "Usage:"/"usage:"/bare casing is now mixed (clap's capitalized "Usage:" beside preserved lowercase aura messages); a deliberate quality-hold since the casings are pinned by renegotiated/preserved tests. Cosmetic; a candidate for the iteration-2 exit-code-split cleanup. Verified green (orchestrator, this session): cargo build --workspace, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace all clean (0 failed across every test binary, including the renegotiated cli_run pins and the new --version / scoped-help / GNU-flag / dual-grammar tests). refs #175 --- Cargo.lock | 136 ++ crates/aura-cli/Cargo.toml | 6 + crates/aura-cli/src/graph_construct.rs | 95 +- crates/aura-cli/src/main.rs | 2274 ++++++++++-------------- crates/aura-cli/tests/cli_run.rs | 239 ++- 5 files changed, 1303 insertions(+), 1447 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ecebf2..5ce6004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -65,6 +115,7 @@ dependencies = [ "aura-ingest", "aura-registry", "aura-std", + "clap", "data-server", "libc", "serde", @@ -231,6 +282,52 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -427,6 +524,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -479,6 +582,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -577,6 +686,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -771,6 +886,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -839,6 +960,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -958,6 +1085,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index f908009..3b33f9c 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -40,3 +40,9 @@ libc = "0.2" # SHA256 user-settled); lives in the research-side CLI, off the frozen engine # (invariant 8). sha2 = "0.10" +# clap: the vetted standard argument parser. Adopted (C16 per-case review) to +# replace the hand-rolled argv parser + ten duplicated help surfaces with one +# declarative source, giving scoped --help, --version, --flag=value, and +# long-option abbreviation. Research-side CLI only (invariant 8): a dev-loop +# compile tax, never a frozen-artifact tax. +clap = { version = "4", features = ["derive"] } diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index ceda7e9..4571cd9 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -178,61 +178,64 @@ pub fn introspect_unwired(doc: &str) -> Result { Ok(out) } -/// `aura graph introspect`: dispatch the three read-only queries. -pub fn introspect_cmd(rest: &[&str]) { - match rest { - ["--vocabulary"] => { - for t in std_vocabulary_types() { - println!("{t}"); - } +/// `aura graph introspect`: dispatch the read-only queries. Exactly one of +/// `--vocabulary` / `--node ` / `--unwired` / `--content-id` must be set; +/// zero or more than one is the usage error (exit 2). +pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { + let count = cmd.vocabulary as usize + + cmd.node.is_some() as usize + + cmd.unwired as usize + + cmd.content_id as usize; + if count != 1 { + eprintln!( + "aura: usage: aura graph introspect --vocabulary | --node | --unwired | --content-id" + ); + std::process::exit(2); + } + if cmd.vocabulary { + for t in std_vocabulary_types() { + println!("{t}"); } - ["--node", type_id] => match introspect_node(type_id) { + } else if let Some(type_id) = cmd.node.as_deref() { + match introspect_node(type_id) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(2); } - }, - ["--unwired"] => { - use std::io::Read; - let mut doc = String::new(); - if let Err(e) = std::io::stdin().read_to_string(&mut doc) { - eprintln!("aura: reading stdin: {e}"); - std::process::exit(2); - } - match introspect_unwired(&doc) { - Ok(s) => print!("{s}"), - Err(m) => { - eprintln!("aura: {m}"); - std::process::exit(2); - } - } } - ["--content-id"] => { - // the content id (#158) of the op-list's canonical blueprint: SHA256 (hex) - // of the same `blueprint_to_json` bytes `graph build` emits, via the one - // shared `crate::content_id` primitive `topology_hash` also uses — so this - // surface and hashing a `graph build` output agree by construction. - use std::io::Read; - let mut doc = String::new(); - if let Err(e) = std::io::stdin().read_to_string(&mut doc) { - eprintln!("aura: reading stdin: {e}"); - std::process::exit(2); - } - match build_from_str(&doc) { - Ok(json) => println!("{}", crate::content_id(&json)), - Err(m) => { - eprintln!("aura: {m}"); - std::process::exit(2); - } - } - } - _ => { - eprintln!( - "aura: usage: aura graph introspect --vocabulary | --node | --unwired | --content-id" - ); + } else if cmd.unwired { + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); std::process::exit(2); } + match introspect_unwired(&doc) { + Ok(s) => print!("{s}"), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } + } else { + // --content-id: the content id (#158) of the op-list's canonical blueprint: + // SHA256 (hex) of the same `blueprint_to_json` bytes `graph build` emits, via + // the one shared `crate::content_id` primitive `topology_hash` also uses — so + // this surface and hashing a `graph build` output agree by construction. + use std::io::Read; + let mut doc = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut doc) { + eprintln!("aura: reading stdin: {e}"); + std::process::exit(2); + } + match build_from_str(&doc) { + Ok(json) => println!("{}", crate::content_id(&json)), + Err(m) => { + eprintln!("aura: {m}"); + std::process::exit(2); + } + } } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 18214c0..7feccec 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -38,6 +38,7 @@ use aura_std::{ use std::sync::mpsc::{self, Receiver}; use std::sync::LazyLock; use std::collections::HashSet; +use clap::{Args, Parser, Subcommand}; /// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major /// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no @@ -622,82 +623,6 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option, trace RunReport { manifest, metrics } } -/// Parse `aura chart` args: ` [--tap ] [--panels]` in any order. -fn parse_chart_args(rest: &[&str]) -> Result<(String, Option, ChartMode), String> { - let mut name: Option = None; - let mut tap: Option = None; - let mut mode = ChartMode::Overlay; - let mut i = 0; - while i < rest.len() { - match rest[i] { - "--panels" => { - mode = ChartMode::Panels; - i += 1; - } - "--tap" => { - let t = rest.get(i + 1).ok_or("--tap needs a value")?; - tap = Some((*t).to_string()); - i += 2; - } - other if !other.starts_with("--") && name.is_none() => { - name = Some(other.to_string()); - i += 1; - } - other => return Err(format!("unexpected chart argument '{other}'")), - } - } - let name = name.ok_or("chart needs a ")?; - Ok((name, tap, mode)) -} - -/// The shared `--real [--from ] [--to ]` accumulator for the -/// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the -/// one home of the real/window grammar — flag-repeat strictness, the empty-symbol -/// rejection, and the "window flags require `--real`" rule — so the two siblings -/// agree with each other (the `run` parser inlines the same strictness rather than -/// sharing this accumulator). Each parser owns its other -/// flags (strategy / name / trace); on a `--real`/`--from`/`--to` token it calls -/// `accept`, and at the end calls `finish` to validate and build the `DataChoice`. -#[derive(Default)] -struct RealWindowGrammar { - symbol: Option, - from_ms: Option, - to_ms: Option, -} - -impl RealWindowGrammar { - /// Consume one `--real`/`--from`/`--to` flag and its already-split value. - /// Returns `Ok(true)` if the flag was a real/window flag (consumed), - /// `Ok(false)` if it is not ours (the caller handles it), `Err` on a malformed - /// real/window flag: a repeated flag (the `if from.is_none()` strictness) or - /// an empty `--real` symbol. - fn accept(&mut self, flag: &str, value: &str, usage: &impl Fn() -> String) -> Result { - match flag { - "--real" if self.symbol.is_none() && !value.is_empty() => { - self.symbol = Some(value.to_string()); - } - "--from" if self.from_ms.is_none() => { - self.from_ms = Some(value.parse().map_err(|_| usage())?); - } - "--to" if self.to_ms.is_none() => { - self.to_ms = Some(value.parse().map_err(|_| usage())?); - } - "--real" | "--from" | "--to" => return Err(usage()), // repeated / empty symbol - _ => return Ok(false), - } - Ok(true) - } - - /// Validate (window flags require `--real`) and assemble the `DataChoice`. - fn finish(self, usage: &impl Fn() -> String) -> Result { - match self.symbol { - Some(symbol) => Ok(DataChoice::Real { symbol, from_ms: self.from_ms, to_ms: self.to_ms }), - None if self.from_ms.is_some() || self.to_ms.is_some() => Err(usage()), - None => Ok(DataChoice::Synthetic), - } - } -} - /// What `--real` parsing yields: the synthetic default, or a real symbol + an /// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable. #[derive(Debug, Clone, PartialEq)] @@ -1651,181 +1576,6 @@ fn parse_csv_list(s: &str) -> Result, ()> { Ok(out) } -/// Parse the `sweep` tail: -/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]`. -/// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura -/// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms, -/// `i64`) require `--real` (there is no synthetic window knob). The four optional grid -/// flags (#137) carry comma-separated value lists that become the stage1-r sweep's -/// grid axes (sma fast/slow length, vol-stop length/`k`); an absent flag keeps the -/// historical default list. Pure (no I/O / exit) so the grammar is unit-testable; -/// `main` does the side effects. An unknown token, a flag without its value, an -/// unknown strategy, both name flags, a window flag without `--real`, or a malformed -/// grid list rejects. -fn parse_sweep_args( - rest: &[&str], -) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid), String> { - let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]".to_string(); - let mut strategy = Strategy::SmaCross; - let mut name: Option<(String, bool)> = None; // (name, persist) - let mut real = RealWindowGrammar::default(); - let mut grid = Stage1RGrid::default(); - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - if real.accept(flag, value, &usage)? { - tail = t; - continue; - } - match *flag { - "--strategy" => { - strategy = match *value { - "sma" => Strategy::SmaCross, - "momentum" => Strategy::Momentum, - "stage1-r" => Strategy::Stage1R, - "stage1-breakout" => Strategy::Stage1Breakout, - "stage1-meanrev" => Strategy::Stage1MeanRev, - _ => return Err(usage()), - }; - } - "--name" if name.is_none() => name = Some(((*value).to_string(), false)), - "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), - "--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?, - "--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?, - "--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; - } - let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false)); - Ok((strategy, name, persist, real.finish(&usage)?, grid)) -} - -/// Parse the `generalize` tail: -/// `[--strategy stage1-r] --real --fast --slow --stop-length -/// --stop-k [--from ] [--to ] [--metric ] [--name ]`. -/// The candidate is a single cell: each grid knob takes exactly one value and all -/// four are required. `--real` is a comma list of >= 2 distinct non-empty symbols. -/// Pure (no I/O / exit) so the grammar is unit-testable. -#[allow(clippy::type_complexity)] -fn parse_generalize_args( - rest: &[&str], -) -> Result<(String, Vec, Stage1RGrid, String, Option, Option), String> { - let usage = || "generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ]".to_string(); - let mut symbols: Option> = None; - let mut from_ms: Option = None; - let mut to_ms: Option = None; - let mut fast: Option = None; - let mut slow: Option = None; - let mut stop_length: Option = None; - let mut stop_k: Option = None; - let mut metric = "expectancy_r".to_string(); - let mut name = "generalize".to_string(); - // generalize validates ONE candidate cell, so each grid knob takes a single value; - // refuse a multi-value flag with a reason, not a bare usage dump (fieldtest friction). - let one = |value: &str| -> Result<(), String> { - let items: Vec<&str> = value.split(',').collect(); - if items.len() != 1 || items[0].is_empty() { - return Err("generalize validates a single candidate: --fast / --slow / --stop-length / --stop-k each take exactly one value".to_string()); - } - Ok(()) - }; - let knobs = || "generalize requires all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string(); - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - match *flag { - "--strategy" => { if *value != "stage1-r" { return Err("generalize requires --strategy stage1-r (the candidate must produce R)".to_string()); } } - "--real" => { - let parts: Vec = value.split(',').map(|s| s.to_string()).collect(); - if parts.iter().any(|s| s.is_empty()) { return Err("generalize: --real takes a comma list of non-empty symbols (e.g. GER40,USDJPY)".to_string()); } - symbols = Some(parts); - } - "--from" => from_ms = Some(value.parse().map_err(|_| usage())?), - "--to" => to_ms = Some(value.parse().map_err(|_| usage())?), - "--fast" => { one(value)?; fast = Some(value.parse().map_err(|_| usage())?); } - "--slow" => { one(value)?; slow = Some(value.parse().map_err(|_| usage())?); } - "--stop-length" => { one(value)?; stop_length = Some(value.parse().map_err(|_| usage())?); } - "--stop-k" => { one(value)?; stop_k = Some(value.parse().map_err(|_| usage())?); } - "--metric" => metric = (*value).to_string(), - "--name" => name = (*value).to_string(), - _ => return Err(usage()), - } - tail = t; - } - let symbols = symbols - .ok_or_else(|| "generalize requires --real — a comma list of two or more instruments".to_string())?; - if symbols.len() < 2 { - return Err(format!( - "generalize needs at least two instruments to compare across; got {} (--real takes a comma list of >=2 symbols)", - symbols.len() - )); - } - // distinct: a duplicate would double-count one instrument in the floor / sign count - let mut seen = std::collections::HashSet::new(); - if !symbols.iter().all(|s| seen.insert(s.clone())) { - return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string()); - } - let grid = Stage1RGrid { - fast: vec![fast.ok_or_else(knobs)?], - slow: vec![slow.ok_or_else(knobs)?], - stop_length: vec![stop_length.ok_or_else(knobs)?], - stop_k: vec![stop_k.ok_or_else(knobs)?], - ..Stage1RGrid::default() - }; - Ok((name, symbols, grid, metric, from_ms, to_ms)) -} - -/// Parse the `walkforward` tail: -/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]`. -/// Defaults: SMA-cross, synthetic, name "walkforward", no persist. `--name`/`--trace` -/// mutually exclusive; `--from`/`--to` require `--real`. Pure (no I/O / exit). -fn parse_walkforward_args( - rest: &[&str], -) -> Result<(Strategy, String, bool, DataChoice, Stage1RGrid, Selection), String> { - let usage = || "walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); - let mut strategy = Strategy::SmaCross; - let mut name: Option<(String, bool)> = None; - let mut real = RealWindowGrammar::default(); - let mut grid = Stage1RGrid::default(); - let mut select = Selection::Argmax; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - if real.accept(flag, value, &usage)? { - tail = t; - continue; - } - match *flag { - "--strategy" => { - strategy = match *value { - "sma" => Strategy::SmaCross, - "momentum" => Strategy::Momentum, - "stage1-r" => Strategy::Stage1R, - "stage1-breakout" => Strategy::Stage1Breakout, - "stage1-meanrev" => Strategy::Stage1MeanRev, - _ => return Err(usage()), - }; - } - "--name" if name.is_none() => name = Some(((*value).to_string(), false)), - "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), - "--fast" => grid.fast = parse_csv_list(value).map_err(|()| usage())?, - "--slow" => grid.slow = parse_csv_list(value).map_err(|()| usage())?, - "--stop-length" => grid.stop_length = parse_csv_list(value).map_err(|()| usage())?, - "--stop-k" => grid.stop_k = parse_csv_list(value).map_err(|()| usage())?, - "--select" => select = parse_select(value).map_err(|()| usage())?, - _ => return Err(usage()), - } - tail = t; - } - let (name, persist) = name.unwrap_or_else(|| ("walkforward".to_string(), false)); - Ok((strategy, name, persist, real.finish(&usage)?, grid, select)) -} - /// Render a family-member stdout line: the assigned `family_id` plus the embedded /// `RunReport`. The report is emitted in its own declaration key order (manifest /// leads with `commit`, C18) so the line is byte-identical to the stored @@ -2290,60 +2040,6 @@ enum McArgs { RealR { choice: DataChoice, grid: Stage1RGrid, block_len: usize, n_resamples: usize, seed: u64 }, } -fn parse_mc_args(rest: &[&str]) -> Result { - let usage = || "usage: mc [--name |--trace ] | mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ]".to_string(); - let mut strategy: Option = None; - let mut name: Option<(String, bool)> = None; - let mut real = RealWindowGrammar::default(); - let mut grid = Stage1RGrid::default(); - let mut block_len: usize = 1; - let mut n_resamples: usize = 1000; - let mut seed: u64 = 1; - let mut r_path = false; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - if real.accept(flag, value, &usage)? { - r_path = true; - tail = t; - continue; - } - match *flag { - "--strategy" => { - strategy = Some(match *value { - "stage1-r" => Strategy::Stage1R, - _ => return Err(usage()), - }); - r_path = true; - } - "--name" if name.is_none() => name = Some(((*value).to_string(), false)), - "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), - "--fast" => { grid.fast = parse_csv_list(value).map_err(|()| usage())?; r_path = true; } - "--slow" => { grid.slow = parse_csv_list(value).map_err(|()| usage())?; r_path = true; } - "--stop-length" => { grid.stop_length = parse_csv_list(value).map_err(|()| usage())?; r_path = true; } - "--stop-k" => { grid.stop_k = parse_csv_list(value).map_err(|()| usage())?; r_path = true; } - "--block-len" => { block_len = value.parse().map_err(|_| usage())?; r_path = true; } - "--resamples" => { n_resamples = value.parse().map_err(|_| usage())?; r_path = true; } - "--seed" => { seed = value.parse().map_err(|_| usage())?; r_path = true; } - _ => return Err(usage()), - } - tail = t; - } - if r_path { - if strategy != Some(Strategy::Stage1R) { - return Err(usage()); - } - if name.is_some() { - return Err(usage()); - } - let choice = real.finish(&usage)?; - Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed }) - } else { - let (name, persist) = name.unwrap_or_else(|| ("mc".to_string(), false)); - Ok(McArgs::Synthetic { name, persist }) - } -} - /// `aura mc --strategy stage1-r [--real ]`: run the stage1-r walk-forward, pool /// every OOS window's per-trade R series in roll order, and print one moving-block /// bootstrap `mc_r_bootstrap` line (`E[R]` distribution + P(`E[R]` <= 0)). Frictionless @@ -3851,127 +3547,6 @@ struct RunArgs { carry_per_cycle: Option, } -/// Parse a cost-rate flag value and enforce non-negativity, naming the offending -/// flag so a sign typo is distinguishable from a mistyped flag name. A *malformed* -/// value stays a grammar usage error (unchanged); only a parsed-but-negative value -/// yields the named message. Pure — no I/O, no exit. -fn parse_nonneg_rate(flag: &str, value: &str, usage: &impl Fn() -> String) -> Result { - let v: f64 = value.parse().map_err(|_| usage())?; - if v < 0.0 { - return Err(format!("{flag} must be non-negative, got {v}")); - } - Ok(v) -} - -/// Parse the `run` tail: `[--harness ] [--real [--from ] [--to ]] -/// [--trace ] [--cost-per-trade ] [--slip-vol-mult ]` in any order, each flag at most once. -/// `--harness sma` is the default (`--harness macd` / `--harness stage1-r` select the -/// others); `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the -/// grammar is unit-testable; `main` does the side effects. -fn parse_run_args(rest: &[&str]) -> Result { - let usage = || { - format!( - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ]{COST_FLAGS_NOTE}" - ) - }; - let mut harness: Option = None; - let mut symbol: Option = None; - let mut from: Option = None; - let mut to: Option = None; - let mut trace: Option = None; - let mut cost: Option = None; - let mut slip_vol_mult: Option = None; - let mut carry_per_cycle: Option = None; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - match *flag { - "--harness" if harness.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - harness = Some(match *value { - "sma" => HarnessKind::Sma, - "macd" => HarnessKind::Macd, - "stage1-r" => HarnessKind::Stage1R, - _ => return Err(usage()), - }); - tail = t; - } - "--real" if symbol.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - if value.is_empty() { - return Err(usage()); - } - symbol = Some((*value).to_string()); - tail = t; - } - "--from" if from.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - from = Some(value.parse().map_err(|_| usage())?); - tail = t; - } - "--to" if to.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - to = Some(value.parse().map_err(|_| usage())?); - tail = t; - } - "--trace" if trace.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - trace = Some((*value).to_string()); - tail = t; - } - "--cost-per-trade" if cost.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - cost = Some(parse_nonneg_rate("--cost-per-trade", value, &usage)?); - tail = t; - } - "--slip-vol-mult" if slip_vol_mult.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - slip_vol_mult = Some(parse_nonneg_rate("--slip-vol-mult", value, &usage)?); - tail = t; - } - "--carry-per-cycle" if carry_per_cycle.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - carry_per_cycle = Some(parse_nonneg_rate("--carry-per-cycle", value, &usage)?); - tail = t; - } - _ => return Err(usage()), - } - } - let harness = harness.unwrap_or(HarnessKind::Sma); - // Cost flags are defined only against the R chain (gross-R -> net-R); a non-R - // harness produces no R to charge against, so a cost flag there is a usage error, - // not a silent no-op (refuse-don't-guess, C10). - if !matches!(harness, HarnessKind::Stage1R) - && (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some()) - { - return Err( - "cost flags require an R-evaluator harness (stage1-r); \ - --harness sma/macd produces no R to charge against" - .to_string(), - ); - } - // --from/--to require --real. - if symbol.is_none() && (from.is_some() || to.is_some()) { - return Err(usage()); - } - let data = match symbol { - Some(s) => RunData::Real { symbol: s, from, to }, - None => RunData::Synthetic, - }; - Ok(RunArgs { harness, data, trace, cost, slip_vol_mult, carry_per_cycle }) -} - -/// The parsed tail of `aura run [flags]`: the typed param cells bound -/// at `compile_with_params` (in `param_space` order), the data source, and the manifest -/// seed. The blueprint file itself is the topology selector, so there is no `--harness` -/// on this path (the `.json` IS the harness). Pure — `main` reads the file, loads the -/// blueprint, and runs. -#[derive(Debug)] -struct BlueprintRunArgs { - params: Vec, - data: RunData, - seed: u64, -} - /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells /// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells /// bind positionally against the loaded signal's `param_space`. A malformed array is @@ -3981,199 +3556,6 @@ fn parse_param_cells(json: &str) -> Result, String> { serde_json::from_str(json).map_err(|e| format!("--params: {e}")) } -/// Parse the blueprint-run tail: `[--params ] [--seed ] [--real -/// [--from ] [--to ]]` in any order, each flag at most once. `--from`/`--to` are -/// legal only with `--real`; absent `--real` the run drives the built-in synthetic -/// stream. Pure (no I/O, no exit) so the grammar is unit-testable; `main` does the side -/// effects. -fn parse_blueprint_run_args(rest: &[&str]) -> Result { - let usage = || { - "usage: aura run [--params ] [--seed ] \ - [--real [--from ] [--to ]]" - .to_string() - }; - let mut params: Option> = None; - let mut seed: Option = None; - let mut symbol: Option = None; - let mut from: Option = None; - let mut to: Option = None; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - match *flag { - "--params" if params.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - params = Some(parse_param_cells(value)?); - tail = t; - } - "--seed" if seed.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - seed = Some(value.parse().map_err(|_| usage())?); - tail = t; - } - "--real" if symbol.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - if value.is_empty() { - return Err(usage()); - } - symbol = Some((*value).to_string()); - tail = t; - } - "--from" if from.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - from = Some(value.parse().map_err(|_| usage())?); - tail = t; - } - "--to" if to.is_none() => { - let (value, t) = t.split_first().ok_or_else(usage)?; - to = Some(value.parse().map_err(|_| usage())?); - tail = t; - } - _ => return Err(usage()), - } - } - // --from/--to require --real (mirrors the harness-kind run path's data-window guard). - if symbol.is_none() && (from.is_some() || to.is_some()) { - return Err(usage()); - } - let data = match symbol { - Some(s) => RunData::Real { symbol: s, from, to }, - None => RunData::Synthetic, - }; - Ok(BlueprintRunArgs { params: params.unwrap_or_default(), data, seed: seed.unwrap_or(0) }) -} - -/// The parsed `aura sweep …` tail: the by-name grid axes, the -/// family name + persist flag, and the shared data choice. Pure — the arm converts -/// the `DataChoice` into a real source. -struct SweepBlueprintArgs { - axes: Vec<(String, Vec)>, - name: String, - persist: bool, - data: DataChoice, - list_axes: bool, -} - -/// Parse the `aura sweep …` tail: repeatable `--axis =` -/// (the by-name grid), `--name|--trace `, and the shared `--real/--from/--to` -/// data accumulator. Pure — no DataServer I/O (the arm converts the DataChoice). -fn parse_sweep_blueprint_args(rest: &[&str]) -> Result { - let usage = || "sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); - let mut axes: Vec<(String, Vec)> = Vec::new(); - let mut name: Option<(String, bool)> = None; // (name, persist) - let mut data = RealWindowGrammar::default(); - let mut list_axes = false; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - if *flag == "--list-axes" { - list_axes = true; - tail = t; - continue; - } - let (value, t) = t.split_first().ok_or_else(usage)?; - if data.accept(flag, value, &usage)? { - tail = t; - continue; - } - match *flag { - "--axis" => { - let (n, csv) = value.split_once('=').ok_or_else(usage)?; - if n.is_empty() || axes.iter().any(|(a, _)| a == n) { return Err(usage()); } // empty / duplicate axis - let vals = parse_scalar_csv(csv).ok_or_else(usage)?; - axes.push((n.to_string(), vals)); - } - "--name" if name.is_none() => name = Some(((*value).to_string(), false)), - "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), - _ => return Err(usage()), - } - tail = t; - } - if list_axes { - // A query, not a sweep: it must stand alone (the flags tail is only itself). - if rest.len() != 1 { - return Err("--list-axes lists axes and takes no other flags".to_string()); - } - return Ok(SweepBlueprintArgs { - axes, - name: "sweep".to_string(), - persist: false, - data: data.finish(&usage)?, - list_axes: true, - }); - } - if axes.is_empty() { return Err(usage()); } // a sweep needs at least one axis - let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false)); - Ok(SweepBlueprintArgs { axes, name, persist, data: data.finish(&usage)?, list_axes: false }) -} - -struct WfBlueprintArgs { - axes: Vec<(String, Vec)>, - name: String, - select: Selection, -} - -/// Parse the `aura walkforward …` tail: repeatable `--axis -/// =` (the per-window re-fit grid, >= 1 required), `--select -/// ` (default argmax), `--name ` (default -/// "walkforward"). Reduce-mode + content-addressed, so no `--trace`. -fn parse_walkforward_blueprint_args(rest: &[&str]) -> Result { - let usage = || "walkforward --axis = [--axis …] [--select ] [--name ]".to_string(); - let mut axes: Vec<(String, Vec)> = Vec::new(); - let mut name: Option = None; - let mut select = Selection::Argmax; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - match *flag { - "--axis" => { - let (n, csv) = value.split_once('=').ok_or_else(usage)?; - if n.is_empty() || axes.iter().any(|(a, _)| a == n) { return Err(usage()); } - let vals = parse_scalar_csv(csv).ok_or_else(usage)?; - axes.push((n.to_string(), vals)); - } - "--select" => select = parse_select(value).map_err(|()| usage())?, - "--name" if name.is_none() => name = Some((*value).to_string()), - _ => return Err(usage()), - } - tail = t; - } - if axes.is_empty() { - return Err("walkforward requires >= 1 --axis to re-fit per window".to_string()); - } - Ok(WfBlueprintArgs { axes, name: name.unwrap_or_else(|| "walkforward".to_string()), select }) -} - -/// Parsed tail of `aura mc …`: the seed count + the family name. -/// Synthetic-only this cycle (real-data MC rides #172), so `--real`/`--from`/`--to` -/// are rejected as unknown flags. Mirrors `parse_sweep_blueprint_args`' shape. -struct McBlueprintArgs { - n_seeds: u64, - name: String, -} - -fn parse_mc_blueprint_args(rest: &[&str]) -> Result { - let usage = || "usage: mc --seeds [--name ]".to_string(); - let mut n_seeds: Option = None; - let mut name: Option = None; - let mut tail = rest; - while let Some((flag, t)) = tail.split_first() { - let (value, t) = t.split_first().ok_or_else(usage)?; - match *flag { - "--seeds" => { - let n: u64 = value.parse().map_err(|_| usage())?; - if n == 0 { - return Err(usage()); - } - n_seeds = Some(n); - } - "--name" if name.is_none() => name = Some((*value).to_string()), - _ => return Err(usage()), - } - tail = t; - } - let n_seeds = n_seeds.ok_or_else(usage)?; // --seeds is required - Ok(McBlueprintArgs { n_seeds, name: name.unwrap_or_else(|| "mc".to_string()) }) -} - /// Lex a `--axis` CSV into typed Scalars by shape: an integer-shaped token is i64, /// otherwise f64. `resolve_axes` kind-checks each value against the param's declared /// kind afterwards (a mismatch is a named error, not a panic). @@ -4205,16 +3587,898 @@ fn run_dispatch(args: RunArgs) -> Result { }) } -/// One-line note appended to the `aura run` usage surfaces (the grammar-error -/// usage closure and `--help`) so the cost-flag units, scale, per-engine-cycle -/// semantics, non-negativity, and R-harness requirement are discoverable at the -/// point of use (#153). A leading newline puts it on its own line under the usage. -const COST_FLAGS_NOTE: &str = "\ncost flags (stage1-r only; all >= 0, charged in R as cost/|entry−stop|): \ ---cost-per-trade = per-trade cost in price units; --slip-vol-mult = slippage multiplier on realized vol; \ ---carry-per-cycle = carry in price units per ENGINE cycle (not per day, not an overnight swap)"; +// ============================== clap parser surface ============================== +// The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, +// `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` +// handlers below convert each `*Cmd` into the argument shapes the existing execution +// fns accept, reusing the value helpers (`Strategy`, `Stage1RGrid`, `Selection`, +// `DataSource::from_choice`, `parse_scalar_csv`, `parse_csv_list`, `parse_select`, +// `parse_param_cells`). The four dual-grammar subcommands carry an optional +// `[blueprint]` positional; a first-positional that names an existing `.json` file +// (`is_blueprint_file`) selects the loaded-blueprint branch, otherwise the built-in +// grammar. Exit codes are behaviour-preserving (usage errors stay exit 2). -const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura mc --strategy stage1-r [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ] | aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ] | aura generalize [--strategy stage1-r] --real --fast --slow --stop-length --stop-k [--from ] [--to ] [--metric ] [--name ] | aura runs families | aura runs family [rank ] | aura reproduce "; +/// The `aura` root parser. `#[command(version)]` reads `CARGO_PKG_VERSION` +/// (the workspace `0.1.0`), so `aura --version` prints `aura 0.1.0`. +#[derive(Parser)] +#[command(name = "aura", version, about = "A game engine for traders — research CLI", infer_long_args = true)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run a single backtest (built-in harness or a loaded blueprint). + Run(RunCmd), + /// Render a recorded run's trace to an HTML chart. + Chart(ChartCmd), + /// Emit / construct / introspect a graph. + Graph(GraphCmd), + /// Sweep a parameter grid over a strategy or a loaded blueprint. + Sweep(SweepCmd), + /// Walk-forward validation over a strategy or a loaded blueprint. + Walkforward(WalkforwardCmd), + /// Grade one candidate across multiple instruments. + Generalize(GeneralizeCmd), + /// Monte-Carlo over synthetic draws, an R-bootstrap, or a loaded blueprint. + Mc(McCmd), + /// List or inspect recorded run families. + Runs(RunsCmd), + /// Reproduce a recorded family by content id. + Reproduce(ReproduceCmd), +} + +#[derive(Args)] +struct ChartCmd { + /// The recorded run or family name to chart. + name: String, + /// Chart only the given tap. + #[arg(long)] + tap: Option, + /// Render stacked panels instead of an overlay. + #[arg(long)] + panels: bool, +} + +#[derive(Args)] +struct GraphCmd { + #[command(subcommand)] + sub: Option, +} + +#[derive(Subcommand)] +enum GraphSub { + /// Construct a graph from a stdin op-list. + Build, + /// Introspect a graph. + Introspect(GraphIntrospectCmd), +} + +#[derive(Args)] +struct GraphIntrospectCmd { + /// List the closed node vocabulary (one node type per line). + #[arg(long)] + vocabulary: bool, + /// Describe one node type's ports by name. + #[arg(long)] + node: Option, + /// List the graph's unwired (unbound) ports. + #[arg(long)] + unwired: bool, + /// Print the graph's content id (topology hash). + #[arg(long)] + content_id: bool, +} + +#[derive(Args)] +struct GeneralizeCmd { + /// The candidate strategy; must be stage1-r (the candidate must produce R). + #[arg(long)] + strategy: Option, + /// Comma-separated instrument list (>=2 distinct, required). + #[arg(long)] + real: Option, + /// Candidate fast-MA length (single value; required). + #[arg(long)] + fast: Option, + /// Candidate slow-MA length (single value; required). + #[arg(long)] + slow: Option, + /// Candidate stop length (single value; required). + #[arg(long)] + stop_length: Option, + /// Candidate stop-k multiple (single value; required). + #[arg(long)] + stop_k: Option, + /// Window start (Unix ms, inclusive). + #[arg(long)] + from: Option, + /// Window end (Unix ms, inclusive). + #[arg(long)] + to: Option, + /// Grading metric (default expectancy_r). + #[arg(long)] + metric: Option, + /// Family name (default generalize). + #[arg(long)] + name: Option, +} + +#[derive(Args)] +struct RunsCmd { + #[command(subcommand)] + sub: RunsSub, +} + +#[derive(Subcommand)] +enum RunsSub { + /// List recorded families. + Families, + /// Inspect one family, optionally ranked by a metric. + Family { + id: String, + /// The literal keyword `rank`, if a ranking metric follows. + rank_kw: Option, + /// The metric to rank by (only valid after `rank`). + metric: Option, + }, +} + +#[derive(Args)] +struct ReproduceCmd { + /// The family content id to reproduce. + id: String, +} + +#[derive(Args)] +struct RunCmd { + /// A serialized signal blueprint (.json). An existing file selects the + /// loaded-blueprint grammar; otherwise the built-in harness grammar. + blueprint: Option, + /// Built-in harness: sma | macd | stage1-r (default sma). + #[arg(long)] + harness: Option, + /// Blueprint params (JSON scalar-cell array; .json mode). + #[arg(long)] + params: Option, + /// Blueprint seed (.json mode). + #[arg(long)] + seed: Option, + /// Real instrument symbol to backtest over (recorded data); omit for the synthetic stream. + #[arg(long)] + real: Option, + /// Window start (Unix ms, inclusive); requires --real. + #[arg(long)] + from: Option, + /// Window end (Unix ms, inclusive); requires --real. + #[arg(long)] + to: Option, + /// Persist the run's taps under `runs/traces/` and still print the report. + #[arg(long)] + trace: Option, + // `allow_hyphen_values`: the cost rates accept a negative value (e.g. `-0.5`) as + // the flag value so the non-negativity guard (`run_args_from`) can surface the + // named `must be non-negative` refusal, rather than clap rejecting `-0.5` as an + // unknown option before the value is parsed. + #[arg(long, allow_hyphen_values = true, long_help = "per-trade cost in price units. stage1-r only; charged in R as cost/|entry-stop|.")] + cost_per_trade: Option, + #[arg(long, allow_hyphen_values = true, long_help = "slippage multiplier on realized vol. stage1-r only.")] + slip_vol_mult: Option, + #[arg(long, allow_hyphen_values = true, long_help = "carry in price units per ENGINE cycle (not per day, not an overnight swap). stage1-r only.")] + carry_per_cycle: Option, +} + +#[derive(Args)] +struct SweepCmd { + /// A loaded blueprint (.json); omit for the built-in --strategy grammar. + blueprint: Option, + /// Built-in strategy: sma | momentum | stage1-r | stage1-breakout | stage1-meanrev (default sma). + #[arg(long)] + strategy: Option, + /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. + #[arg(long)] + real: Option, + /// Window start (Unix ms, inclusive); requires --real. + #[arg(long)] + from: Option, + /// Window end (Unix ms, inclusive); requires --real. + #[arg(long)] + to: Option, + /// Family name (records to the registry without persisting per-member traces). + #[arg(long)] + name: Option, + /// Family name that also persists each member's taps (mutually exclusive with --name). + #[arg(long)] + trace: Option, + /// Fast-MA grid axis (comma-separated values). + #[arg(long)] + fast: Option, + /// Slow-MA grid axis (comma-separated values). + #[arg(long)] + slow: Option, + /// Stop-length grid axis (comma-separated values). + #[arg(long)] + stop_length: Option, + /// Stop-k grid axis (comma-separated values). + #[arg(long)] + stop_k: Option, + /// Breakout-channel grid axis (comma-separated values; stage1-breakout). + #[arg(long)] + channel: Option, + /// Mean-reversion window grid axis (comma-separated values; stage1-meanrev). + #[arg(long)] + window: Option, + /// Mean-reversion band-k grid axis (comma-separated values; stage1-meanrev). + #[arg(long)] + band_k: Option, + /// Blueprint sweep axis `=` (repeatable; .json mode). + #[arg(long)] + axis: Vec, + /// List a loaded blueprint's sweepable axes and exit (.json mode, stands alone). + #[arg(long)] + list_axes: bool, +} + +#[derive(Args)] +struct WalkforwardCmd { + /// A loaded blueprint (.json); omit for the built-in --strategy grammar. + blueprint: Option, + /// Built-in strategy: sma | momentum | stage1-r | stage1-breakout | stage1-meanrev (default sma). + #[arg(long)] + strategy: Option, + /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. + #[arg(long)] + real: Option, + /// Window start (Unix ms, inclusive); requires --real. + #[arg(long)] + from: Option, + /// Window end (Unix ms, inclusive); requires --real. + #[arg(long)] + to: Option, + /// Family name (records to the registry without persisting per-member traces). + #[arg(long)] + name: Option, + /// 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). + #[arg(long)] + stop_length: Option, + /// Stop-k IS-refit grid axis (comma-separated values). + #[arg(long)] + stop_k: Option, + /// In-sample winner selection: argmax | plateau:mean | plateau:worst (default argmax). + #[arg(long)] + select: Option, + /// Blueprint IS-refit axis `=` (repeatable, >=1 required; .json mode). + #[arg(long)] + axis: Vec, +} + +#[derive(Args)] +struct McCmd { + /// A loaded blueprint (.json); omit for the built-in grammar. + blueprint: Option, + /// Built-in strategy: stage1-r selects the R-bootstrap path (else the synthetic seed-resweep). + #[arg(long)] + strategy: Option, + /// Real instrument symbol for the R-bootstrap (recorded data); requires --strategy stage1-r. + #[arg(long)] + real: Option, + /// Window start (Unix ms, inclusive); requires --real. + #[arg(long)] + from: Option, + /// Window end (Unix ms, inclusive); requires --real. + #[arg(long)] + to: Option, + /// Family name for the synthetic seed-resweep (records without persisting traces). + #[arg(long)] + name: Option, + /// 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). + #[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). + #[arg(long)] + stop_length: Option, + /// Stop-k grid axis for the R-bootstrap (comma-separated values). + #[arg(long)] + stop_k: Option, + /// Moving-block bootstrap block length (R-bootstrap; default 1). + #[arg(long)] + block_len: Option, + /// Number of bootstrap resamples (R-bootstrap; default 1000). + #[arg(long)] + resamples: Option, + /// Bootstrap RNG seed (R-bootstrap; default 1). + #[arg(long)] + seed: Option, + /// Number of synthetic draws (required; .json mode). + #[arg(long)] + seeds: Option, +} + +/// The dual-grammar discriminator: a first-positional that names an existing +/// `.json` file selects the loaded-blueprint branch. Single-sourced so the +/// four dual-grammar subcommands stay in lockstep. +fn is_blueprint_file(arg: &Option) -> Option<&str> { + arg.as_deref() + .filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) +} + +/// Resolve a `[blueprint].json`-branch `--real`/`--from`/`--to` into a `RunData`, +/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to` +/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2). +fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunData { + let usage = "usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]]"; + match real { + Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from, to }, + Some(_) => { + eprintln!("aura: {usage}"); + std::process::exit(2); + } + None if from.is_some() || to.is_some() => { + eprintln!("aura: {usage}"); + std::process::exit(2); + } + None => RunData::Synthetic, + } +} + +/// Build the existing `RunArgs` (the type `run_dispatch` consumes) from the +/// built-in-branch `RunCmd` fields — the old `parse_run_args` body minus the argv +/// tokenizing clap now owns. A stray positional (a non-`.json`-file `[blueprint]`) +/// is an unexpected token; the harness-enum map, the cost-flags-require-R-harness +/// guard, and the non-negative-rate checks reuse the existing message strings. +fn run_args_from(a: &RunCmd) -> Result { + let usage = || "Usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] [--cost-per-trade ] [--slip-vol-mult ] [--carry-per-cycle ]".to_string(); + // A positional that is not an existing `.json` blueprint is an unexpected token + // (the built-in run grammar takes only flags) — the #16 strict reading. + if a.blueprint.is_some() { + return Err(usage()); + } + let harness = match a.harness.as_deref() { + None | Some("sma") => HarnessKind::Sma, + Some("macd") => HarnessKind::Macd, + Some("stage1-r") => HarnessKind::Stage1R, + Some(_) => return Err(usage()), + }; + // A parsed-but-negative rate is a named refusal (a sign typo distinguished from a + // mistyped flag), matching the old `parse_nonneg_rate` message. Checked before the + // R-harness guard so the precedence matches the old per-flag parse order. + let nonneg = |flag: &str, v: Option| -> Result, String> { + match v { + Some(x) if x < 0.0 => Err(format!("{flag} must be non-negative, got {x}")), + other => Ok(other), + } + }; + let cost = nonneg("--cost-per-trade", a.cost_per_trade)?; + let slip_vol_mult = nonneg("--slip-vol-mult", a.slip_vol_mult)?; + let carry_per_cycle = nonneg("--carry-per-cycle", a.carry_per_cycle)?; + if !matches!(harness, HarnessKind::Stage1R) + && (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some()) + { + return Err( + "cost flags require an R-evaluator harness (stage1-r); \ + --harness sma/macd produces no R to charge against" + .to_string(), + ); + } + if a.real.is_none() && (a.from.is_some() || a.to.is_some()) { + return Err(usage()); + } + let data = match a.real.as_deref() { + Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from: a.from, to: a.to }, + Some(_) => return Err(usage()), + None => RunData::Synthetic, + }; + Ok(RunArgs { harness, data, trace: a.trace.clone(), cost, slip_vol_mult, carry_per_cycle }) +} + +/// The old `parse_generalize_args` body minus tokenizing: convert `GeneralizeCmd` +/// into the `run_generalize` argument shape. The candidate is a single cell (clap +/// already types `--fast`/etc. as one `i64`/`f64`), all four knobs required, `--real` +/// a `>=2`-distinct comma list; every refusal reuses the old message string. +#[allow(clippy::type_complexity)] +fn generalize_args_from( + a: &GeneralizeCmd, +) -> Result<(String, Vec, Stage1RGrid, String, Option, Option), String> { + if let Some(s) = a.strategy.as_deref() + && s != "stage1-r" + { + return Err("generalize requires --strategy stage1-r (the candidate must produce R)".to_string()); + } + let symbols: Vec = match a.real.as_deref() { + None => return Err("generalize requires --real — a comma list of two or more instruments".to_string()), + Some(v) => { + let parts: Vec = v.split(',').map(|s| s.to_string()).collect(); + if parts.iter().any(|s| s.is_empty()) { + return Err("generalize: --real takes a comma list of non-empty symbols (e.g. GER40,USDJPY)".to_string()); + } + parts + } + }; + if symbols.len() < 2 { + return Err(format!( + "generalize needs at least two instruments to compare across; got {} (--real takes a comma list of >=2 symbols)", + symbols.len() + )); + } + let mut seen = HashSet::new(); + if !symbols.iter().all(|s| seen.insert(s.clone())) { + return Err("generalize: each instrument may appear once; --real has a duplicate symbol".to_string()); + } + let knobs = || "generalize requires all four candidate knobs: --fast --slow --stop-length --stop-k, each a single value".to_string(); + let grid = Stage1RGrid { + fast: vec![a.fast.ok_or_else(knobs)?], + slow: vec![a.slow.ok_or_else(knobs)?], + stop_length: vec![a.stop_length.ok_or_else(knobs)?], + stop_k: vec![a.stop_k.ok_or_else(knobs)?], + ..Stage1RGrid::default() + }; + let metric = a.metric.clone().unwrap_or_else(|| "expectancy_r".to_string()); + let name = a.name.clone().unwrap_or_else(|| "generalize".to_string()); + Ok((name, symbols, grid, metric, a.from, a.to)) +} + +/// The shared `--real`/`--from`/`--to` resolution for the family subcommands: a +/// non-empty symbol yields `DataChoice::Real`, a window flag without `--real` is a +/// usage error, absence is synthetic. Single-sourced (the old `RealWindowGrammar` +/// finish logic) so the family subcommands agree. +fn data_choice_from( + real: Option<&str>, + from: Option, + to: Option, + usage: &impl Fn() -> String, +) -> Result { + match real { + Some("") => Err(usage()), + Some(s) => Ok(DataChoice::Real { symbol: s.to_string(), from_ms: from, to_ms: to }), + None if from.is_some() || to.is_some() => Err(usage()), + None => Ok(DataChoice::Synthetic), + } +} + +/// Map a built-in `--strategy` token to a `Strategy` (default sma), reusing the old +/// five-way parse arms; an unknown token is a usage error. +fn strategy_from(s: Option<&str>, usage: &impl Fn() -> String) -> Result { + Ok(match s { + None | Some("sma") => Strategy::SmaCross, + Some("momentum") => Strategy::Momentum, + Some("stage1-r") => Strategy::Stage1R, + Some("stage1-breakout") => Strategy::Stage1Breakout, + Some("stage1-meanrev") => Strategy::Stage1MeanRev, + Some(_) => return Err(usage()), + }) +} + +/// Resolve `--name`/`--trace` (mutually exclusive) into `(family_name, persist)`, +/// defaulting the name when neither is given. +fn name_persist( + name: Option<&str>, + trace: Option<&str>, + default: &str, + usage: &impl Fn() -> String, +) -> Result<(String, bool), String> { + match (name, trace) { + (Some(_), Some(_)) => Err(usage()), + (Some(n), None) => Ok((n.to_string(), false)), + (None, Some(t)) => Ok((t.to_string(), true)), + (None, None) => Ok((default.to_string(), false)), + } +} + +/// Parse the repeatable `--axis =` list into by-name grid axes, mirroring +/// the old blueprint-sweep axis grammar: an empty/duplicate name or a malformed csv +/// is a usage error. +fn parse_axes( + raw: &[String], + usage: &impl Fn() -> String, +) -> Result)>, String> { + let mut axes: Vec<(String, Vec)> = Vec::new(); + for item in raw { + let (n, csv) = item.split_once('=').ok_or_else(usage)?; + if n.is_empty() || axes.iter().any(|(a, _)| a == n) { + return Err(usage()); + } + let vals = parse_scalar_csv(csv).ok_or_else(usage)?; + axes.push((n.to_string(), vals)); + } + Ok(axes) +} + +/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or +/// the built-in harness-kind dispatch. +fn dispatch_run(a: RunCmd) { + match is_blueprint_file(&a.blueprint) { + Some(path) => { + if a.harness.is_some() { + eprintln!("aura: --harness is not valid with a blueprint file"); + std::process::exit(2); + } + // The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to; + // the built-in-only flags are rejected here (exit 2), never silently dropped — + // mirroring the sweep/mc blueprint branches, which reject their non-branch flags + // exhaustively (refuse-don't-guess). clap's optional `[blueprint]` positional + // makes these structurally parseable, so the guard is re-asserted at dispatch. + if a.trace.is_some() + || a.cost_per_trade.is_some() + || a.slip_vol_mult.is_some() + || a.carry_per_cycle.is_some() + { + eprintln!("aura: usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]]"); + std::process::exit(2); + } + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e}"); + std::process::exit(2); + }); + let signal = blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e:?}"); + std::process::exit(2); + }); + let params = match a.params.as_deref() { + Some(j) => parse_param_cells(j).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }), + None => Vec::new(), + }; + let data = run_data_from(a.real.as_deref(), a.from, a.to); + let report = run_signal_stage1r(signal, ¶ms, data, a.seed.unwrap_or(0)); + println!("{}", report.to_json()); + } + None => { + if a.params.is_some() || a.seed.is_some() { + eprintln!("aura: --params/--seed require a blueprint file"); + std::process::exit(2); + } + let run_args = run_args_from(&a).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + match run_dispatch(run_args) { + Ok(report) => println!("{}", report.to_json()), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + } + } + } +} + +fn dispatch_chart(a: ChartCmd) { + emit_chart( + &a.name, + a.tap.as_deref(), + if a.panels { ChartMode::Panels } else { ChartMode::Overlay }, + ); +} + +fn dispatch_graph(a: GraphCmd) { + match a.sub { + None => print!("{}", render::render_html(&sample_blueprint())), + Some(GraphSub::Build) => graph_construct::build_cmd(), + Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i), + } +} + +fn dispatch_generalize(a: GeneralizeCmd) { + let (name, symbols, grid, metric, from_ms, to_ms) = generalize_args_from(&a).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms); +} + +fn dispatch_runs(a: RunsCmd) { + match a.sub { + RunsSub::Families => runs_families(), + RunsSub::Family { id, rank_kw, metric } => match (rank_kw.as_deref(), metric) { + (None, None) => runs_family(&id, None), + (Some("rank"), Some(m)) => runs_family(&id, Some(&m)), + _ => { + eprintln!("aura: usage: aura runs family [rank ]"); + std::process::exit(2); + } + }, + } +} + +fn dispatch_reproduce(a: ReproduceCmd) { + reproduce_family(&a.id); +} + +/// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the +/// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. +fn dispatch_sweep(a: SweepCmd) { + match is_blueprint_file(&a.blueprint) { + Some(path) => { + let usage = || "sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e}"); + std::process::exit(2); + }); + // Parse-validate the blueprint once at the boundary (with file-path context). + if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + eprintln!("aura: {path}: {e:?}"); + std::process::exit(2); + } + // A built-in-only flag with a blueprint file is not in this grammar. + if a.strategy.is_some() + || a.fast.is_some() + || a.slow.is_some() + || a.stop_length.is_some() + || a.stop_k.is_some() + || a.channel.is_some() + || a.window.is_some() + || a.band_k.is_some() + { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + if a.list_axes { + // A query, not a sweep: it must stand alone. + if !a.axis.is_empty() + || a.name.is_some() + || a.trace.is_some() + || a.real.is_some() + || a.from.is_some() + || a.to.is_some() + { + eprintln!("aura: --list-axes lists axes and takes no other flags"); + std::process::exit(2); + } + list_blueprint_axes(&doc); + return; + } + 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); + } + let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data)); + } + None => { + let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--channel ] [--window ] [--band-k ]".to_string(); + if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let mut grid = Stage1RGrid::default(); + let bad = |m: String| -> ! { + eprintln!("aura: {m}"); + std::process::exit(2) + }; + if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.channel.as_deref() { grid.channel = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.window.as_deref() { grid.window = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.band_k.as_deref() { grid.band_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + run_sweep(strategy, &name, persist, DataSource::from_choice(data), &grid); + } + } +} + +/// `aura walkforward`: IS-refit walk-forward over a loaded blueprint (an existing +/// `.json` first-positional) or the built-in `--strategy` walk-forward. +fn dispatch_walkforward(a: WalkforwardCmd) { + match is_blueprint_file(&a.blueprint) { + Some(path) => { + let usage = || "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); + }); + if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + eprintln!("aura: {path}: {e:?}"); + 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); + }); + if axes.is_empty() { + 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); + } + None => { + let usage = || "walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); + if a.blueprint.is_some() || !a.axis.is_empty() { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let mut grid = Stage1RGrid::default(); + let bad = |m: String| -> ! { + eprintln!("aura: {m}"); + std::process::exit(2) + }; + if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + let select = match a.select.as_deref() { + Some(s) => parse_select(s).unwrap_or_else(|()| bad(usage())), + None => Selection::Argmax, + }; + let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "walkforward", &usage) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + run_walkforward(strategy, &name, persist, DataSource::from_choice(data), &grid, select); + } + } +} + +/// `aura mc`: loaded-blueprint Monte-Carlo (an existing `.json` first-positional), or +/// the built-in synthetic seed-resweep / stage1-r R-bootstrap split. +fn dispatch_mc(a: McCmd) { + match is_blueprint_file(&a.blueprint) { + Some(path) => { + let usage = "usage: mc --seeds [--name ]"; + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e}"); + std::process::exit(2); + }); + if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + eprintln!("aura: {path}: {e:?}"); + 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}"); + std::process::exit(2); + } + }; + let name = a.name.clone().unwrap_or_else(|| "mc".to_string()); + run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic); + } + None => { + let usage = || "usage: mc [--name |--trace ] | mc --strategy stage1-r [--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); + } + // The R-bootstrap path is selected by any stage1-r knob; otherwise the + // synthetic seed-resweep family. Name flags are invalid on the R path. + let r_path = 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(); + let mc_args = if r_path { + if a.strategy.as_deref() != Some("stage1-r") || a.name.is_some() || a.trace.is_some() { + eprintln!("aura: {}", usage()); + std::process::exit(2); + } + let mut grid = Stage1RGrid::default(); + let bad = |m: String| -> ! { + eprintln!("aura: {m}"); + std::process::exit(2) + }; + if let Some(v) = a.fast.as_deref() { grid.fast = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.slow.as_deref() { grid.slow = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_length.as_deref() { grid.stop_length = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + if let Some(v) = a.stop_k.as_deref() { grid.stop_k = parse_csv_list(v).unwrap_or_else(|()| bad(usage())); } + let choice = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| bad(m)); + McArgs::RealR { + choice, + grid, + block_len: a.block_len.unwrap_or(1), + n_resamples: a.resamples.unwrap_or(1000), + seed: a.seed.unwrap_or(1), + } + } else { + let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "mc", &usage) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + McArgs::Synthetic { name, persist } + }; + match mc_args { + McArgs::Synthetic { name, persist } => run_mc(&name, persist), + McArgs::RealR { choice, grid, block_len, n_resamples, seed } => { + run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed) + } + } + } + } +} fn main() { // Restore the default SIGPIPE disposition. Rust's runtime sets SIGPIPE to SIG_IGN @@ -4226,190 +4490,16 @@ fn main() { unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL); } - // Collect argv and match the whole vector: every accepted form is exhaustive, - // so an unexpected trailing token falls through to the usage-error path rather - // than masquerading as a successful run (#16 strict reading). - let args: Vec = std::env::args().skip(1).collect(); - // A `--help` / `-h` anywhere prints usage to stdout and exits 0, uniformly across - // subcommands (#131): `aura chart --help` shows usage instead of erroring - // "unexpected argument", and the bare `aura --help` is the same affordance. - if args.iter().any(|a| a == "--help" || a == "-h") { - println!("{USAGE}{COST_FLAGS_NOTE}"); - return; - } - match args.iter().map(String::as_str).collect::>().as_slice() { - ["run", rest @ ..] => { - // `aura run [--params ] [--seed ] [--real ...]`: if the - // first positional is an existing `.json` file, load it as a serialized C24 signal - // blueprint (a `price`→`bias` leg; sinks/brokers/data are out of the round-trippable - // set), wrap it in the shared Stage-1-R scaffolding, and run. Otherwise fall through - // to the built-in harness-kind dispatch (#159 stays). The `.json`-file discriminator - // keeps the two paths disjoint — a bare `--harness` token never ends in `.json`. - if let Some(path) = - rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) - { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - let signal = blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e:?}"); - std::process::exit(2); - }); - let BlueprintRunArgs { params, data, seed } = parse_blueprint_run_args(&rest[1..]) - .unwrap_or_else(|msg| { - eprintln!("aura: {msg}"); - std::process::exit(2); - }); - let report = run_signal_stage1r(signal, ¶ms, data, seed); - println!("{}", report.to_json()); - return; - } - match parse_run_args(rest) { - Ok(args) => match run_dispatch(args) { - Ok(report) => println!("{}", report.to_json()), - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - }, - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - } - } - ["chart", rest @ ..] => match parse_chart_args(rest) { - Ok((name, tap, mode)) => emit_chart(&name, tap.as_deref(), mode), - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - }, - ["graph"] => print!("{}", render::render_html(&sample_blueprint())), - ["graph", "build"] => graph_construct::build_cmd(), - ["graph", "introspect", rest @ ..] => graph_construct::introspect_cmd(rest), - ["sweep", rest @ ..] => { - // A `.json`-file first argument selects the loaded-blueprint sweep (cycle 2, - // #166), mirroring the cycle-1 `aura run ` discriminator; a - // bare `--strategy` token never ends in `.json`, so the two paths stay disjoint. - if let Some(path) = - rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) - { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - // Parse-validate the blueprint once at the boundary (with file-path - // context), mirroring the cycle-1 `aura run ` arm; the - // per-member reloads inside `run_blueprint_sweep` are then infallible. - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { - eprintln!("aura: {path}: {e:?}"); - std::process::exit(2); - } - let SweepBlueprintArgs { axes, name, persist, data, list_axes } = - parse_sweep_blueprint_args(&rest[1..]).unwrap_or_else(|msg| { - eprintln!("aura: {msg}"); - std::process::exit(2); - }); - if list_axes { - list_blueprint_axes(&doc); - return; - } - run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data)); - return; - } - match parse_sweep_args(rest) { - Ok((strategy, name, persist, choice, grid)) => { - run_sweep(strategy, &name, persist, DataSource::from_choice(choice), &grid) - } - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - } - } - ["walkforward", rest @ ..] => { - if let Some(path) = - rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) - { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { - eprintln!("aura: {path}: {e:?}"); - std::process::exit(2); - } - let WfBlueprintArgs { axes, name, select } = - parse_walkforward_blueprint_args(&rest[1..]).unwrap_or_else(|msg| { - eprintln!("aura: {msg}"); - std::process::exit(2); - }); - run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select); - return; - } - match parse_walkforward_args(rest) { - Ok((strategy, name, persist, choice, grid, select)) => { - run_walkforward(strategy, &name, persist, DataSource::from_choice(choice), &grid, select) - } - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - } - } - ["generalize", rest @ ..] => match parse_generalize_args(rest) { - Ok((name, symbols, grid, metric, from_ms, to_ms)) => { - run_generalize(&name, &symbols, &grid, &metric, from_ms, to_ms) - } - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - }, - ["mc", rest @ ..] => { - // A `.json`-file first argument selects the loaded-blueprint Monte-Carlo (the - // World/C21 verb), mirroring the `["sweep", ..]` discriminator; a bare - // `--strategy`/`--name` token never ends in `.json`, so the paths stay disjoint. - if let Some(path) = - rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) - { - let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("aura: {path}: {e}"); - std::process::exit(2); - }); - if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { - eprintln!("aura: {path}: {e:?}"); - std::process::exit(2); - } - let McBlueprintArgs { n_seeds, name } = - parse_mc_blueprint_args(&rest[1..]).unwrap_or_else(|msg| { - eprintln!("aura: {msg}"); - std::process::exit(2); - }); - run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic); - return; - } - match parse_mc_args(rest) { - Ok(McArgs::Synthetic { name, persist }) => run_mc(&name, persist), - Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed }) => { - run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed) - } - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - } - } - ["runs", "families"] => runs_families(), - ["runs", "family", id] => runs_family(id, None), - ["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)), - ["reproduce", id] => reproduce_family(id), - _ => { - eprintln!("aura: {USAGE}"); - std::process::exit(2); - } + match Cli::parse().command { + Command::Run(a) => dispatch_run(a), + Command::Chart(a) => dispatch_chart(a), + Command::Graph(a) => dispatch_graph(a), + Command::Sweep(a) => dispatch_sweep(a), + Command::Walkforward(a) => dispatch_walkforward(a), + Command::Generalize(a) => dispatch_generalize(a), + Command::Mc(a) => dispatch_mc(a), + Command::Runs(a) => dispatch_runs(a), + Command::Reproduce(a) => dispatch_reproduce(a), } } @@ -4428,16 +4518,15 @@ mod tests { assert!(err.contains("requires a grid sweep"), "refuse message: {err}"); } + /// The `--select` token grammar (`parse_select`) maps argmax / plateau:mean / + /// plateau:worst and rejects an unknown token — the pure selector clap's + /// `--select` value feeds on both the built-in and blueprint walk-forward paths. #[test] - fn parse_walkforward_select_flag() { - let argmax = parse_walkforward_args(&["--strategy", "stage1-r"]).unwrap(); - assert!(matches!(argmax.5, Selection::Argmax), "default is argmax"); - let mean = parse_walkforward_args(&["--select", "plateau:mean"]).unwrap(); - assert!(matches!(mean.5, Selection::Plateau(PlateauMode::Mean))); - let worst = parse_walkforward_args(&["--select", "plateau:worst"]).unwrap(); - assert!(matches!(worst.5, Selection::Plateau(PlateauMode::Worst))); - assert!(parse_walkforward_args(&["--select", "bogus"]).is_err(), - "unknown --select token is a usage error"); + fn parse_select_token_grammar() { + assert!(matches!(parse_select("argmax").unwrap(), Selection::Argmax), "default is argmax"); + assert!(matches!(parse_select("plateau:mean").unwrap(), Selection::Plateau(PlateauMode::Mean))); + assert!(matches!(parse_select("plateau:worst").unwrap(), Selection::Plateau(PlateauMode::Worst))); + assert!(parse_select("bogus").is_err(), "unknown --select token is a usage error"); } fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember { @@ -5075,51 +5164,6 @@ mod tests { assert_eq!(report.metrics.total_pips, again.metrics.total_pips); } - /// `parse_chart_args` parses ``, `--tap `, and `--panels` in any order - /// (the loop grammar's reason for being — the name need not lead), and surfaces - /// each of its three distinct errors: `--tap` missing its value, an unexpected - /// extra argument, and a missing ``. The any-order contract is the only - /// reason `main` runs a loop here instead of an exhaustive argv match, so it is - /// asserted as a property, not just exercised. - #[test] - fn parse_chart_args_accepts_any_order_and_rejects_three_ways() { - // bare name -> default tap (None) + Overlay mode - let (name, tap, mode) = parse_chart_args(&["fam"]).expect("bare name parses"); - assert_eq!(name, "fam"); - assert_eq!(tap, None); - assert!(matches!(mode, ChartMode::Overlay)); - - // name + --tap + --panels in canonical order - let (name, tap, mode) = - parse_chart_args(&["fam", "--tap", "equity", "--panels"]).expect("full parses"); - assert_eq!(name, "fam"); - assert_eq!(tap.as_deref(), Some("equity")); - assert!(matches!(mode, ChartMode::Panels)); - - // any order: flags before the name, and --panels between name and --tap - let (name, tap, mode) = - parse_chart_args(&["--tap", "equity", "fam"]).expect("flag-first parses"); - assert_eq!(name, "fam"); - assert_eq!(tap.as_deref(), Some("equity")); - assert!(matches!(mode, ChartMode::Overlay)); - - let (name, tap, mode) = - parse_chart_args(&["--panels", "fam", "--tap", "exposure"]).expect("interleaved parses"); - assert_eq!(name, "fam"); - assert_eq!(tap.as_deref(), Some("exposure")); - assert!(matches!(mode, ChartMode::Panels)); - - // three distinct error branches, by message (the Ok tuple holds a - // non-Debug ChartMode, so match the Err arm instead of unwrapping). - let err = |args: &[&str]| match parse_chart_args(args) { - Err(e) => e, - Ok(_) => panic!("expected an error for {args:?}"), - }; - assert_eq!(err(&["fam", "--tap"]), "--tap needs a value"); - assert_eq!(err(&["fam", "extra"]), "unexpected chart argument 'extra'"); - assert_eq!(err(&["--panels"]), "chart needs a "); - } - #[test] fn run_sample_is_deterministic_and_non_trivial() { let r1 = run_sample(None); @@ -5268,157 +5312,6 @@ mod tests { assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); } - #[test] - fn parse_sweep_args_defaults_selects_and_rejects() { - let g = Stage1RGrid::default(); - assert_eq!( - parse_sweep_args(&[]), - Ok((Strategy::SmaCross, "sweep".to_string(), false, DataChoice::Synthetic, g.clone())) - ); - assert_eq!( - parse_sweep_args(&["--trace", "swp"]), - Ok((Strategy::SmaCross, "swp".to_string(), true, DataChoice::Synthetic, g.clone())) - ); - assert_eq!( - parse_sweep_args(&["--name", "s"]), - Ok((Strategy::SmaCross, "s".to_string(), false, DataChoice::Synthetic, g.clone())) - ); - assert_eq!( - parse_sweep_args(&["--strategy", "momentum", "--trace", "mom"]), - Ok((Strategy::Momentum, "mom".to_string(), true, DataChoice::Synthetic, g.clone())) - ); - assert!(parse_sweep_args(&["--strategy", "bogus"]).is_err()); - assert!(parse_sweep_args(&["--name", "a", "--trace", "b"]).is_err()); // mutually exclusive - assert!(parse_sweep_args(&["--trace"]).is_err()); // flag missing its value - } - - /// #137: the four optional grid flags parse comma-separated lists onto the - /// stage1-r grid axes; an absent flag keeps the historical default list, and a - /// malformed list (empty / non-numeric item) is the strict usage error. - #[test] - fn parse_sweep_args_parses_the_four_grid_flags() { - let parsed = parse_sweep_args(&[ - "--strategy", "stage1-r", - "--fast", "240", - "--slow", "960,1200", - "--stop-length", "240", - "--stop-k", "2.0,3.5", - ]) - .expect("the four grid flags parse"); - let grid = parsed.4; - assert_eq!(grid.fast, vec![240]); - assert_eq!(grid.slow, vec![960, 1200]); - assert_eq!(grid.stop_length, vec![240]); - assert_eq!(grid.stop_k, vec![2.0, 3.5]); - // absent flags fall back to the historical defaults. - assert_eq!(parse_sweep_args(&[]).unwrap().4, Stage1RGrid::default()); - // a malformed list is the strict usage error (not a downstream panic). - assert!(parse_sweep_args(&["--fast", "2,x"]).is_err()); // non-numeric item - assert!(parse_sweep_args(&["--fast", ""]).is_err()); // empty item - assert!(parse_sweep_args(&["--stop-k", "abc"]).is_err()); // non-float - } - - /// Property: the breakout `--channel` flag parses a comma-separated list onto - /// `Stage1RGrid.channel` (the breakout family's sole signal axis), with the same - /// strictness as the stage1-r grid flags — an absent flag keeps the historical - /// default `[1920]`, a malformed list is the usage error (not a downstream panic). - /// Guards the new flag added alongside the breakout family; without it the - /// `--channel` arm could silently drift (e.g. last-write-wins, lenient parse). - #[test] - fn parse_sweep_args_parses_the_channel_grid_flag() { - let parsed = parse_sweep_args(&["--strategy", "stage1-breakout", "--channel", "20,55,100"]) - .expect("the breakout channel flag parses"); - assert_eq!(parsed.0, Strategy::Stage1Breakout); - assert_eq!(parsed.4.channel, vec![20, 55, 100]); - // absent --channel keeps the historical default list. - assert_eq!(parse_sweep_args(&[]).unwrap().4.channel, vec![1920]); - // a malformed list is the strict usage error. - assert!(parse_sweep_args(&["--channel", "20,x"]).is_err()); // non-numeric item - assert!(parse_sweep_args(&["--channel", ""]).is_err()); // empty item - } - - #[test] - fn parse_generalize_requires_a_single_value_candidate() { - // a candidate is one cell: a multi-value grid flag is refused WITH A REASON, - // not a bare usage dump (fieldtest friction). - let err = parse_generalize_args(&[ - "--real", "GER40,USDJPY", "--fast", "2,3", "--slow", "12", - "--stop-length", "14", "--stop-k", "2.0", - ]).unwrap_err(); - assert!(err.contains("single candidate"), "refusal must say why: {err}"); - } - - #[test] - fn parse_generalize_requires_all_four_knobs() { - // --slow absent -> refuse (no defaulting to the multi-value sweep grid) - assert!(parse_generalize_args(&[ - "--real", "GER40,USDJPY", "--fast", "3", - "--stop-length", "14", "--stop-k", "2.0", - ]).is_err()); - } - - #[test] - fn parse_generalize_refuses_fewer_than_two_instruments() { - // refused WITH A REASON naming the >=2 requirement (fieldtest friction). - let err = parse_generalize_args(&[ - "--real", "GER40", "--fast", "3", "--slow", "12", - "--stop-length", "14", "--stop-k", "2.0", - ]).unwrap_err(); - assert!(err.contains("at least two instruments"), "refusal must say why: {err}"); - } - - #[test] - fn parse_generalize_refuses_a_duplicate_instrument() { - assert!(parse_generalize_args(&[ - "--real", "GER40,GER40", "--fast", "3", "--slow", "12", - "--stop-length", "14", "--stop-k", "2.0", - ]).is_err()); - } - - #[test] - fn parse_generalize_refuses_a_non_stage1r_strategy() { - assert!(parse_generalize_args(&[ - "--strategy", "sma", "--real", "GER40,USDJPY", "--fast", "3", "--slow", "12", - "--stop-length", "14", "--stop-k", "2.0", - ]).is_err()); - } - - #[test] - fn parse_generalize_defaults_name_and_metric() { - let (name, symbols, grid, metric, _from, _to) = parse_generalize_args(&[ - "--real", "GER40,USDJPY", "--fast", "3", "--slow", "12", - "--stop-length", "14", "--stop-k", "2.0", - ]).expect("valid generalize args"); - assert_eq!(name, "generalize"); - assert_eq!(metric, "expectancy_r"); - assert_eq!(symbols, vec!["GER40".to_string(), "USDJPY".to_string()]); - assert_eq!(grid.fast, vec![3]); - assert_eq!(grid.slow, vec![12]); - assert_eq!(grid.stop_length, vec![14]); - assert_eq!(grid.stop_k, vec![2.0]); - } - - /// 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) @@ -5459,141 +5352,6 @@ mod tests { 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. - #[test] - fn parse_sweep_args_accepts_real_symbol_and_window() { - assert_eq!( - parse_sweep_args(&["--real", "EURUSD", "--from", "100", "--to", "200", "--trace", "s"]), - Ok((Strategy::SmaCross, "s".to_string(), true, - DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) }, - Stage1RGrid::default())) - ); - assert!(parse_sweep_args(&["--real"]).is_err()); // --real needs a symbol - assert!(parse_sweep_args(&["--from", "100"]).is_err()); // --from without --real - } - - /// `parse_sweep_args` mirrors the shared `RealWindowGrammar` real/window strictness: an - /// empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage - /// errors (not last-write-wins, not a downstream refusal) — so sibling commands - /// reject the same malformed real-grammar the same way. - #[test] - fn parse_sweep_args_rejects_empty_symbol_and_repeated_real_window() { - assert!(parse_sweep_args(&["--real", ""]).is_err()); // empty symbol - assert!(parse_sweep_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err()); // repeated --real - assert!(parse_sweep_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err()); // repeated --from - assert!(parse_sweep_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err()); // repeated --to - } - - /// Property: the `aura sweep ` grammar accepts repeatable - /// by-name `--axis =` axes (lexed integer-then-float per value), - /// the mutually-exclusive `--name`/`--trace` family selector, and rejects a - /// sweep with no axis, a duplicate axis name, an empty CSV, a non-numeric axis - /// value (neither i64 nor f64), or a `--axis` token missing its `=` — each a - /// strict usage error, never a silent default. - #[test] - fn parse_sweep_blueprint_args_grammar() { - let a = parse_sweep_blueprint_args(&["--axis", "fast.length=2,3,4", "--axis", "slow.length=8,16", "--name", "f"]).expect("ok"); - assert_eq!(a.axes.len(), 2); - assert_eq!(a.axes[0], ("fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(3), Scalar::I64(4)])); - assert_eq!(a.name, "f"); - assert!(!a.persist); - assert!(parse_sweep_blueprint_args(&["--name", "f"]).is_err()); // no axis - assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--axis", "x=2"]).is_err()); // duplicate axis - assert!(parse_sweep_blueprint_args(&["--axis", "x="]).is_err()); // empty csv - assert!(parse_sweep_blueprint_args(&["--axis", "x=abc"]).is_err()); // non-numeric value (neither i64 nor f64) - assert!(parse_sweep_blueprint_args(&["--axis", "bad"]).is_err()); // no '=' - // f64 axis lexes to F64: - let b = parse_sweep_blueprint_args(&["--axis", "bias.scale=0.5,0.75"]).expect("f64 axis"); - assert_eq!(b.axes[0].1, vec![Scalar::F64(0.5), Scalar::F64(0.75)]); - // --trace names the family AND sets the persist flag; --name does not: - let t = parse_sweep_blueprint_args(&["--axis", "x=1", "--trace", "t"]).expect("trace ok"); - assert_eq!(t.name, "t"); - assert!(t.persist); - // --name/--trace are mutually exclusive and each non-repeatable (no last-write-wins): - assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--name", "a", "--name", "b"]).is_err()); // repeated --name - assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--trace", "a", "--trace", "b"]).is_err()); // repeated --trace - assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--name", "a", "--trace", "b"]).is_err()); // name + trace together - } - - /// Property: the `aura walkforward ` grammar requires >= 1 - /// repeatable `--axis =` re-fit axis, parses the `--select` - /// argmax/plateau selector (default argmax), names the family via `--name` - /// (default "walkforward"), and rejects a walk-forward with nothing to re-fit - /// (no axis) or an unknown `--select` value — each a strict usage error. - #[test] - fn parse_walkforward_blueprint_args_grammar() { - let a = parse_walkforward_blueprint_args(&[ - "--axis", "stage1_signal.fast.length=2,3", "--select", "plateau:mean", "--name", "wf", - ]).expect("valid tail parses"); - assert_eq!(a.axes.len(), 1); - assert_eq!(a.name, "wf"); - assert!(matches!(a.select, Selection::Plateau(_))); - // a walk-forward with nothing to re-fit is rejected: - assert!(parse_walkforward_blueprint_args(&[]).is_err()); - assert!(parse_walkforward_blueprint_args(&["--name", "wf"]).is_err()); - // an unknown --select value is rejected: - assert!(parse_walkforward_blueprint_args(&["--axis", "a=1,2", "--select", "bogus"]).is_err()); - // a repeated --name is rejected (mirrors the sweep/mc blueprint parsers): - assert!(parse_walkforward_blueprint_args(&["--axis", "a=1,2", "--name", "a", "--name", "b"]).is_err()); - // default name + default argmax: - let b = parse_walkforward_blueprint_args(&["--axis", "a=1,2"]).expect("minimal tail parses"); - assert_eq!(b.name, "walkforward"); - assert!(matches!(b.select, Selection::Argmax)); - } - - /// Property: `--list-axes` is a standalone query sub-mode — it parses alone - /// (setting `list_axes` and binding no axes), is mutually exclusive with a real - /// sweep in either flag order, and its absence leaves `list_axes` false while a - /// bare sweep still requires >= 1 axis. - #[test] - fn parse_sweep_blueprint_args_list_axes_is_standalone() { - let a = parse_sweep_blueprint_args(&["--list-axes"]).expect("--list-axes alone parses"); - assert!(a.list_axes); - assert!(a.axes.is_empty()); - // mutually exclusive with a real sweep, in either order: - assert!(parse_sweep_blueprint_args(&["--list-axes", "--axis", "stage1_signal.fast.length=2,3"]).is_err()); - assert!(parse_sweep_blueprint_args(&["--axis", "stage1_signal.fast.length=2,3", "--list-axes"]).is_err()); - // a bare sweep (no --list-axes) still requires >= 1 axis and reports list_axes=false: - let b = parse_sweep_blueprint_args(&["--axis", "stage1_signal.fast.length=2,3"]).expect("bare sweep parses"); - assert!(!b.list_axes); - } - - /// Property: the `aura mc ` grammar requires `--seeds ` with - /// `n >= 1`, defaults the family name to "mc", and — synthetic-only this cycle — - /// rejects `--real` (and any other unknown flag) as a strict usage error, never a - /// silent default or a real-data fallback (real-data MC rides #172). - #[test] - fn parse_mc_blueprint_args_grammar() { - // --seeds required and parsed; default name "mc"; seeds >= 1; unknown/real flags rejected. - let ok = parse_mc_blueprint_args(&["--seeds", "8", "--name", "mc1"]).expect("valid"); - assert_eq!(ok.n_seeds, 8); - assert_eq!(ok.name, "mc1"); - let def = parse_mc_blueprint_args(&["--seeds", "3"]).expect("default name"); - assert_eq!(def.name, "mc"); - assert!(parse_mc_blueprint_args(&[]).is_err(), "--seeds is required"); - assert!(parse_mc_blueprint_args(&["--seeds", "0"]).is_err(), "seeds must be >= 1"); - assert!(parse_mc_blueprint_args(&["--seeds", "x"]).is_err(), "seeds must be a number"); - assert!( - parse_mc_blueprint_args(&["--seeds", "4", "--real", "EURUSD"]).is_err(), - "synthetic-only this cycle: --real is rejected (real-data MC is #172)" - ); - } - - /// Property: `parse_sweep_blueprint_args` delegates `--real/--from/--to` to the - /// shared `RealWindowGrammar`, yielding `DataChoice::Real { symbol, window }` when - /// `--real` is present and `DataChoice::Synthetic` when it is absent; a window flag - /// without `--real` is the strict usage error (not a silent synthetic fallback). - #[test] - fn parse_sweep_blueprint_args_accepts_real_window() { - let r = parse_sweep_blueprint_args(&["--axis", "x=1", "--real", "EURUSD", "--from", "100", "--to", "200"]).expect("real ok"); - assert_eq!(r.data, DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: Some(100), to_ms: Some(200) }); - let s = parse_sweep_blueprint_args(&["--axis", "x=1"]).expect("synthetic default"); - assert_eq!(s.data, DataChoice::Synthetic); // no --real -> synthetic - assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--from", "100"]).is_err()); // window flag without --real - } - /// Property: a `blueprint_sweep_family` member built from a serialized signal is /// the SAME trading result as the cycle-1 single run of that signal at the same /// params — the loaded-blueprint sweep reuses the identical `wrap_stage1r` run path @@ -5822,54 +5580,6 @@ mod tests { assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically"); } - /// `parse_walkforward_args` defaults to synthetic / name "walkforward" / no - /// persist, admits `--real ` (window-less here) yielding `DataChoice::Real`, - /// and rejects two name flags or a `--real` missing its symbol. - #[test] - fn parse_walkforward_args_defaults_and_accepts_real() { - // Selection is not PartialEq/Debug (it carries the opaque PlateauMode), so - // the comparable fields are asserted via the 5-tuple prefix and `select` - // separately via `matches!`. - let (strategy, name, persist, choice, grid, select) = - parse_walkforward_args(&[]).expect("defaults parse"); - assert_eq!( - (strategy, name, persist, choice, grid), - (Strategy::SmaCross, "walkforward".to_string(), false, DataChoice::Synthetic, Stage1RGrid::default()) - ); - assert!(matches!(select, Selection::Argmax), "default selection is argmax"); - let (strategy, name, persist, choice, grid, _) = - parse_walkforward_args(&["--real", "EURUSD", "--trace", "w"]).expect("real/trace parse"); - assert_eq!( - (strategy, name, persist, choice, grid), - (Strategy::SmaCross, "w".to_string(), true, - DataChoice::Real { symbol: "EURUSD".to_string(), from_ms: None, to_ms: None }, - Stage1RGrid::default()) - ); - assert!(parse_walkforward_args(&["--name", "a", "--trace", "b"]).is_err()); - assert!(parse_walkforward_args(&["--real"]).is_err()); - } - - #[test] - fn parse_walkforward_args_accepts_strategy_and_grid_flags() { - let r = parse_walkforward_args(&["--strategy", "stage1-r", "--fast", "5,10", "--stop-k", "2.0,3.0"]); - let (strategy, _, _, _, grid, _) = r.expect("valid stage1-r walkforward args"); - assert_eq!(strategy, Strategy::Stage1R); - assert_eq!(grid.fast, vec![5, 10]); - assert_eq!(grid.stop_k, vec![2.0, 3.0]); - assert!(parse_walkforward_args(&["--strategy", "bogus"]).is_err()); - } - - /// `parse_walkforward_args` mirrors the shared `RealWindowGrammar` real/window strictness: - /// an empty `--real` symbol and a repeated `--real`/`--from`/`--to` are usage - /// errors — the same real-grammar rejection its sibling `parse_sweep_args` gives. - #[test] - fn parse_walkforward_args_rejects_empty_symbol_and_repeated_real_window() { - assert!(parse_walkforward_args(&["--real", ""]).is_err()); // empty symbol - assert!(parse_walkforward_args(&["--real", "EURUSD", "--real", "GBPUSD"]).is_err()); - assert!(parse_walkforward_args(&["--real", "EURUSD", "--from", "1", "--from", "2"]).is_err()); - assert!(parse_walkforward_args(&["--real", "EURUSD", "--to", "1", "--to", "2"]).is_err()); - } - #[test] fn run_stage1_r_synthetic_folds_an_r_block() { // the stage1-r harness scores the SMA-cross signal in R: one shell-callable run @@ -5888,116 +5598,6 @@ mod tests { ); } - /// Property: a bare `aura run` parses to the default harness/data — `--harness sma` - /// over the synthetic stream, no trace. The grammar's identity element, so the - /// historic plain-`run` form keeps its meaning through the new tokenizer. - #[test] - fn parse_run_args_defaults_to_sma_synthetic() { - let a = parse_run_args(&[]).expect("bare run"); - assert!(matches!(a.harness, HarnessKind::Sma)); - assert!(matches!(a.data, RunData::Synthetic)); - assert!(a.trace.is_none()); - } - - /// Property: `--harness`, `--real [--from]` and `--trace` are orthogonal - /// modifiers — they compose in ANY order into one `RunArgs`, each captured into its - /// own field, so the tokenizer is order-independent (the historic literal arms were - /// not). - #[test] - fn parse_run_args_composes_harness_real_and_trace_in_any_order() { - let a = parse_run_args(&["--trace", "q1", "--harness", "stage1-r", "--real", "GER40", "--from", "100"]) - .expect("composed flags"); - assert!(matches!(a.harness, HarnessKind::Stage1R)); - match a.data { - RunData::Real { symbol, from, to } => { - assert_eq!(symbol, "GER40"); - assert_eq!(from, Some(100)); - assert_eq!(to, None); - } - _ => panic!("expected real data"), - } - assert_eq!(a.trace.as_deref(), Some("q1")); - } - - /// Property: `--harness macd` selects the MACD harness — the sole selector now that the - /// `--macd` back-compat flag is removed, so `--macd` is rejected as an unknown token; an - /// unknown harness name is rejected, and a window flag (`--from`) without `--real` is - /// rejected — the grammar's refusal edges. - #[test] - fn parse_run_args_harness_macd_selects_macd_and_rejects_removed_flag() { - assert!(matches!(parse_run_args(&["--harness", "macd"]).unwrap().harness, HarnessKind::Macd)); - assert!(parse_run_args(&["--macd"]).is_err()); // the removed flag is now an unknown token - assert!(parse_run_args(&["--harness", "nope"]).is_err()); - assert!(parse_run_args(&["--from", "1"]).is_err()); // --from without --real - } - - /// Property: the run-arg parser preserves every strictness branch the removed - /// `parse_real_args` unit test pinned — empty symbol, non-i64 window ms, a repeated - /// flag, and a flag missing its value all reject, so the `run` path stays exit-2 strict. - #[test] - fn parse_run_args_rejects_malformed_real_and_repeated_flags() { - assert!(parse_run_args(&["--real", ""]).is_err()); // empty symbol - assert!(parse_run_args(&["--real", "GER40", "--from", "x"]).is_err()); // non-i64 from - assert!(parse_run_args(&["--real", "GER40", "--to", "1.5"]).is_err()); // non-i64 to - assert!(parse_run_args(&["--harness", "sma", "--harness", "macd"]).is_err()); // repeated --harness - assert!(parse_run_args(&["--real", "GER40", "--trace", "a", "--trace", "b"]).is_err()); // repeated --trace - assert!(parse_run_args(&["--harness"]).is_err()); // flag missing its value - assert!(parse_run_args(&["--real", "GER40", "--from"]).is_err()); // flag missing its value - assert!(parse_run_args(&["bogus"]).is_err()); // unknown trailing token - } - - #[test] - fn parse_run_args_negative_cost_rates_are_named_not_usage() { - for flag in ["--cost-per-trade", "--slip-vol-mult", "--carry-per-cycle"] { - let err = parse_run_args(&["--harness", "stage1-r", flag, "-0.5"]) - .expect_err("a negative cost rate must be refused"); - assert!(err.contains("must be non-negative"), "{flag}: {err}"); - assert!(err.contains(flag), "message must name the offending flag: {err}"); - assert!(err.contains("-0.5"), "message must carry the value: {err}"); - } - } - - #[test] - fn parse_run_args_malformed_cost_rate_stays_grammar_usage() { - let err = parse_run_args(&["--harness", "stage1-r", "--cost-per-trade", "x"]) - .expect_err("a malformed rate is a grammar error"); - assert!(err.contains("usage"), "a malformed value stays the usage path: {err}"); - // The grammar-error usage surface — the more frequently hit one — carries the - // cost-flag note too (#153), so pin that dropping {COST_FLAGS_NOTE} from the - // `usage` closure regresses it, not only the `--help` print. - assert!(err.contains("per ENGINE cycle"), "the grammar usage must carry the cost-flag note: {err}"); - } - - #[test] - fn parse_run_args_cost_flags_require_r_harness() { - let cases: [(&[&str], &str); 3] = [ - (&["--harness", "sma"], "--cost-per-trade"), - (&["--harness", "macd"], "--slip-vol-mult"), - (&["--harness", "sma"], "--carry-per-cycle"), - ]; - for (harness, flag) in cases { - let mut argv: Vec<&str> = harness.to_vec(); - argv.extend_from_slice(&[flag, "0.001"]); - let err = parse_run_args(&argv).expect_err("cost flag on a non-R harness must be refused"); - assert!(err.contains("R-evaluator harness"), "{harness:?}+{flag}: {err}"); - } - } - - #[test] - fn parse_run_args_cost_flag_without_harness_defaults_sma_and_is_refused() { - let err = parse_run_args(&["--carry-per-cycle", "0.5"]) - .expect_err("the default harness is sma → cost flag refused"); - assert!(err.contains("R-evaluator harness"), "{err}"); - } - - #[test] - fn parse_run_args_cost_flags_accepted_on_stage1_r() { - let a = parse_run_args(&["--harness", "stage1-r", "--cost-per-trade", "0.001"]) - .expect("cost flags are accepted on the R harness"); - assert!(matches!(a.harness, HarnessKind::Stage1R)); - assert_eq!(a.cost, Some(0.001)); - } - #[test] fn parse_param_cells_decodes_typed_cells_in_order_and_refuses_malformed() { // The property: `--params` round-trips the externally-tagged Scalar wire form in @@ -6010,26 +5610,6 @@ mod tests { assert!(err.contains("--params"), "a malformed --params value names the flag: {err}"); } - #[test] - fn parse_blueprint_run_args_defaults_composes_and_guards() { - // The property: the blueprint-run tail defaults to empty params / synthetic data / - // seed 0, composes the flags in any order, and guards --from/--to behind --real. - let d = parse_blueprint_run_args(&[]).expect("bare blueprint run"); - assert!(d.params.is_empty()); - assert!(matches!(d.data, RunData::Synthetic)); - assert_eq!(d.seed, 0); - let a = parse_blueprint_run_args(&[ - "--seed", "7", "--params", "[{\"I64\":3}]", "--real", "GER40", "--from", "100", - ]) - .expect("composed in any order"); - assert_eq!(a.seed, 7); - assert_eq!(a.params, vec![Scalar::I64(3)]); - assert!(matches!(a.data, RunData::Real { ref symbol, from: Some(100), to: None } if symbol == "GER40")); - assert!(parse_blueprint_run_args(&["--from", "1"]).is_err()); // --from without --real - assert!(parse_blueprint_run_args(&["--seed", "x"]).is_err()); // non-numeric seed - assert!(parse_blueprint_run_args(&["--params", "[{\"I64\":1}]", "--params", "[]"]).is_err()); // repeated - } - /// Regenerates the committed demo signal blueprint the `aura run ` /// E2E loads. Ignored by default; run with `--ignored` after a signal change. #[test] @@ -6118,40 +5698,6 @@ mod tests { assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes"))); } - #[test] - fn parse_mc_args_bare_and_name_route_to_synthetic() { - assert_eq!(parse_mc_args(&[]), Ok(McArgs::Synthetic { name: "mc".to_string(), persist: false })); - assert_eq!(parse_mc_args(&["--name", "m"]), Ok(McArgs::Synthetic { name: "m".to_string(), persist: false })); - assert_eq!(parse_mc_args(&["--trace", "m"]), Ok(McArgs::Synthetic { name: "m".to_string(), persist: true })); - } - - #[test] - fn parse_mc_args_stage1_r_routes_to_real_r_with_defaults_and_overrides() { - assert_eq!( - parse_mc_args(&["--strategy", "stage1-r"]), - Ok(McArgs::RealR { - choice: DataChoice::Synthetic, grid: Stage1RGrid::default(), - block_len: 1, n_resamples: 1000, seed: 1, - }) - ); - let r = parse_mc_args(&["--strategy", "stage1-r", "--real", "USDJPY", - "--block-len", "5", "--resamples", "200", "--seed", "9", "--fast", "5,10"]); - let McArgs::RealR { choice, grid, block_len, n_resamples, seed } = r.expect("valid real-R mc args") - else { panic!("expected RealR") }; - assert_eq!(choice, DataChoice::Real { symbol: "USDJPY".to_string(), from_ms: None, to_ms: None }); - assert_eq!((block_len, n_resamples, seed), (5, 200, 9)); - assert_eq!(grid.fast, vec![5, 10]); - } - - #[test] - fn parse_mc_args_real_without_stage1_r_is_usage_error() { - // a bare --real (no R candidate) is still rejected — the synthetic seed-resweep - // is undefined over real bars; the R path needs --strategy stage1-r. - assert!(parse_mc_args(&["--real", "EURUSD"]).is_err()); - assert!(parse_mc_args(&["--strategy", "sma"]).is_err()); - assert!(parse_mc_args(&["--strategy", "stage1-r", "--name", "x"]).is_err()); // name invalid on R path - } - #[test] fn mc_r_bootstrap_json_carries_every_bootstrap_field_under_the_mc_r_bootstrap_key() { // Property: the `mc_r_bootstrap` output line is the full RBootstrap shape — diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index f6dc88a..8ca3bda 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -113,7 +113,7 @@ fn run_with_trailing_token_is_strict_and_exits_two() { String::from_utf8_lossy(&out.stdout) ); let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); - assert!(stderr.contains("usage"), "stderr was: {stderr:?}"); + assert!(stderr.contains("Usage"), "stderr was: {stderr:?}"); // Positive-preservation: bare `aura run` still runs and prints the report. let ok = Command::new(BIN).arg("run").output().expect("spawn aura run"); @@ -349,6 +349,43 @@ fn aura_run_rejects_an_unknown_node_blueprint() { assert!(stderr.contains("UnknownNodeType"), "names the cause: {stderr}"); } +/// Property (#175, refuse-don't-guess): built-in-only flags on `aura run +/// ` are REFUSED, not silently dropped. The loaded-blueprint +/// grammar accepts only `--params`/`--seed`/`--real`/`--from`/`--to`; the removed +/// hand-parser rejected any other flag (exit 2). clap's optional `[blueprint]` +/// positional makes `--trace` and the cost flags structurally parseable, so the +/// dispatch guard must re-reject them — mirroring the sweep/mc blueprint branches, +/// which reject their non-branch flags exhaustively. A regression that dropped the +/// guard would silently no-op a user-provided flag. +#[test] +fn run_blueprint_rejects_builtin_only_flags_exit_two() { + for extra in [ + &["--trace", "foo"][..], + &["--cost-per-trade", "1"][..], + &["--slip-vol-mult", "1"][..], + &["--carry-per-cycle", "1"][..], + ] { + let mut args = vec!["run", "tests/fixtures/stage1_signal.json"]; + args.extend_from_slice(extra); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(&args) + .output() + .expect("spawn aura run blueprint + builtin-only flag"); + assert_eq!( + out.status.code(), + Some(2), + "{args:?} must be refused (exit 2), got {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stdout.is_empty(), + "{args:?} must not emit a report on stdout: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + } +} + #[test] fn run_trace_persists_taps_and_plain_run_writes_no_traces() { let cwd = temp_cwd("run-trace"); @@ -629,7 +666,7 @@ fn no_args_prints_usage_and_exits_two() { assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); assert!(out.stdout.is_empty(), "stdout should be empty on the usage path"); let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); - assert!(stderr.contains("usage"), "stderr was: {stderr:?}"); + assert!(stderr.contains("Usage"), "stderr was: {stderr:?}"); } /// Property: `--help`/`-h` is the success-path help affordance, not the error @@ -664,32 +701,21 @@ fn help_flag_prints_usage_to_stdout_and_exits_zero() { ); } -/// Property (#131): `--help`/`-h` is UNIFORM across subcommands — `aura --help` -/// prints usage to stdout and exits 0 for every subcommand, not only the bare `aura -/// --help`. Pre-fix, `aura chart --help` errored with "unexpected chart argument" -/// (exit 2) while `aura run --help` leaked usage to stderr; this pins the uniform -/// success-path help affordance for all subcommands. +/// Property (post-clap migration, #175): subcommand help is SCOPED — `aura +/// --help` (and `-h`) prints that subcommand's own options to stdout and exits 0, +/// with nothing on stderr, for every subcommand. This supersedes the retired #131 +/// "uniform (byte-identical) help" surface: clap gives each subcommand its own +/// scoped Options section rather than one shared global usage blob. #[test] -fn per_subcommand_help_is_uniform_stdout_exit_zero() { +fn per_subcommand_help_is_scoped_stdout_exit_zero() { for sub in ["run", "chart", "sweep", "walkforward", "mc", "graph", "runs"] { - for flag in ["--help", "-h"] { - let out = Command::new(BIN).args([sub, flag]).output().expect("spawn aura --help"); - assert_eq!( - out.status.code(), - Some(0), - "`aura {sub} {flag}` should exit 0, got: {:?}", - out.status - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!( - stdout.contains("usage"), - "`aura {sub} {flag}` should print usage to stdout, got: {stdout:?}" - ); - assert!( - out.stderr.is_empty(), - "`aura {sub} {flag}` should not write to stderr, got: {:?}", - String::from_utf8_lossy(&out.stderr) - ); + for help in ["--help", "-h"] { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([sub, help]).output().expect("spawn"); + assert_eq!(out.status.code(), Some(0), "{sub} {help} exit"); + assert!(!out.stdout.is_empty(), "{sub} {help} stdout empty"); + assert!(out.stderr.is_empty(), "{sub} {help} stderr not empty: {:?}", + String::from_utf8_lossy(&out.stderr)); } } } @@ -703,17 +729,20 @@ fn run_help_surfaces_cost_flag_units_note() { let out = Command::new(BIN).args(["run", "--help"]).output().expect("spawn aura run --help"); assert_eq!(out.status.code(), Some(0), "run --help exits 0: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert!(stdout.contains("per ENGINE cycle"), "help must explain carry units: {stdout}"); - assert!(stdout.contains("stage1-r only"), "help must state the R-harness requirement: {stdout}"); + // clap wraps `long_help` at the render width, which can split a multi-word phrase + // across lines; pin the single tokens clap never hyphenates instead of the + // multi-word "per ENGINE cycle". + assert!(stdout.contains("ENGINE"), "help must explain carry units: {stdout}"); + assert!(stdout.contains("cycle"), "help must explain the per-cycle carry scale: {stdout}"); + assert!(stdout.contains("stage1-r"), "help must state the R-harness requirement: {stdout}"); } -/// Property (#153): the cost-flag note reaches the user through the *grammar-error* -/// path too, not only `--help`. A malformed cost value (`--cost-per-trade x`) is a -/// usage error → exit 2 with the note on STDERR (nothing on stdout). The note is -/// appended at two independent source sites (the `USAGE` const for `--help` and the -/// `parse_run_args` usage closure for grammar errors); the help test pins the first, -/// this pins the second — the more frequently hit surface — at the binary boundary, -/// where the closure's String must actually route to stderr+exit-2. +/// Property (#153/#175): a malformed cost value (`--cost-per-trade x`, not a f64) is +/// a usage error — exit 2 with a Usage line on STDERR and nothing on stdout. Post-clap +/// migration the terse parse error names the offending flag and points to `--help` +/// (where the units note lives, pinned by `run_help_surfaces_cost_flag_units_note`); +/// clap does not inline `long_help` into a parse error, so the multi-word units note +/// no longer rides the grammar-error path — the pin is the exit code + Usage surface. #[test] fn run_malformed_cost_value_usage_error_carries_note_on_stderr() { let out = Command::new(BIN) @@ -724,8 +753,7 @@ fn run_malformed_cost_value_usage_error_carries_note_on_stderr() { String::from_utf8_lossy(&out.stderr)); assert!(out.stdout.is_empty(), "a usage error must not emit a report on stdout: {:?}", out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("usage"), "the grammar-error path stays a usage message: {stderr:?}"); - assert!(stderr.contains("per ENGINE cycle"), "the grammar-error usage must carry the cost-flag note: {stderr:?}"); + assert!(stderr.contains("--cost-per-trade"), "the parse error names the offending flag: {stderr:?}"); } /// Property: `aura graph` emits a single self-contained HTML page — the @@ -3854,3 +3882,140 @@ fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() { let _ = std::fs::remove_dir_all(&cwd); } + +/// `--version` / `-V` surface the crate version on stdout and exit 0 (GNU MUST): +/// a version query is a successful query, not a usage error. The expected string +/// is the crate version itself (`CARGO_PKG_VERSION`, i.e. the workspace version), +/// so this stays honest across version bumps and keeps one source of truth. +#[test] +fn version_flag_prints_version_to_stdout_and_exits_zero() { + for flag in ["--version", "-V"] { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .arg(flag) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(0), "{flag}: exit {:?}", out.status); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(env!("CARGO_PKG_VERSION")), "{flag} stdout: {stdout:?}"); + } +} + +/// Subcommand help is SCOPED: `aura sweep --help` names sweep's own flags and is +/// NOT byte-identical to `aura run --help` — each subcommand documents its own +/// options rather than reprinting one shared global blob (the #131 uniform-help +/// surface retired). +#[test] +fn subcommand_help_is_scoped_not_uniform() { + let sweep = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["sweep", "--help"]).output().expect("spawn"); + let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--help"]).output().expect("spawn"); + assert_eq!(sweep.status.code(), Some(0), "sweep --help exit"); + assert_eq!(run.status.code(), Some(0), "run --help exit"); + let sweep_out = String::from_utf8_lossy(&sweep.stdout); + let run_out = String::from_utf8_lossy(&run.stdout); + assert!(sweep_out.contains("--strategy"), "sweep help names its flags: {sweep_out:?}"); + assert_ne!(sweep_out, run_out, "per-subcommand help must be scoped, not uniform"); +} + +/// The GNU `--flag=value` equals form is accepted, equivalent to the +/// space-separated `--flag value`. +#[test] +fn gnu_equals_form_is_accepted() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--harness=sma"]).output().expect("spawn"); + assert_eq!(out.status.code(), Some(0), + "run --harness=sma should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); +} + +/// The `--` end-of-options terminator is recognized (a bare `--` after the flags +/// is a no-op that still runs the default harness). +#[test] +fn double_dash_terminator_is_recognized() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--harness", "sma", "--"]).output().expect("spawn"); + assert_eq!(out.status.code(), Some(0), + "run ... -- should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); +} + +/// GNU long-option abbreviation (spec acceptance criterion 4): an unambiguous prefix +/// of a long flag is accepted (`--harn` → `--harness`), via clap `infer_long_args` +/// on the root command (which propagates to subcommands). An LLM/automation caller +/// always writes the full flag, but GNU compliance (the cycle's ratified purpose) +/// includes abbreviation, and clap delivers it with one root attribute. +#[test] +fn long_option_abbreviation_is_accepted() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["run", "--harn", "sma"]).output().expect("spawn"); + assert_eq!(out.status.code(), Some(0), + "run --harn sma (abbrev of --harness) should run; stderr: {}", + String::from_utf8_lossy(&out.stderr)); +} + +/// Property (#175, dual-grammar built-in branch stays strict): a leading positional +/// that is NOT an existing `.json` file is a usage error (exit 2), never silently +/// swallowed — for every dual-grammar subcommand (run/sweep/walkforward/mc). The +/// clap migration models the loaded-blueprint `[BLUEPRINT]` as an OPTIONAL positional +/// on one shared args struct; that makes a typo'd leading token (`aura run bogus`, +/// meant as `--harness bogus`) structurally parseable where the old hand-parser +/// rejected any stray token. Each built-in handler therefore re-asserts the guard +/// (`if a.blueprint.is_some() { usage; exit 2 }`). Dropping any one guard would +/// silently run the default harness on a mistyped invocation — the exact regression +/// clap's optional positional invites and this pins shut. Exit 2 preserved (the +/// usage/runtime split is iteration 2); nothing on stdout on the refusal path. +#[test] +fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() { + // A trailing flag is added where the built-in grammar needs one to reach the + // guard rather than a bare-run default; `bogus` never ends in `.json`, so the + // is_file() discriminator sends every case down the built-in branch. + for argv in [ + &["run", "bogus"][..], + &["sweep", "bogus", "--fast", "3"][..], + &["walkforward", "bogus", "--strategy", "sma"][..], + &["mc", "bogus"][..], + ] { + let out = Command::new(BIN).args(argv).output().expect("spawn aura "); + assert_eq!( + out.status.code(), + Some(2), + "{argv:?} must be refused (exit 2), got {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + assert!( + out.stdout.is_empty(), + "{argv:?} must not emit a report on stdout: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + } +} + +/// Property (#175, dual-grammar discriminator keys on `is_file()`, not the suffix): +/// `aura run .json` where the file does NOT exist is treated as the built-in +/// grammar with a stray positional (refused, exit 2, built-in usage naming +/// `--harness`), NOT as a loaded blueprint. The discriminator is +/// `ends_with(".json") && Path::is_file()`; a regression that dropped the `is_file()` +/// conjunct (suffix-only) would send this down the blueprint-read branch and fail +/// with a file-read error instead — a different, misleading refusal. The +/// `--harness`-in-stderr assertion pins that the built-in branch was chosen; the +/// positive `.json`-file path stays covered by `aura_run_loads_and_runs_a_blueprint_file`. +#[test] +fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() { + let out = Command::new(BIN) + .args(["run", "definitely-not-a-real-file.json"]) + .output() + .expect("spawn aura run .json"); + assert_eq!( + out.status.code(), + Some(2), + "a .json name that is not a file must be refused (exit 2), got {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + assert!(out.stdout.is_empty(), "no report on stdout: {:?}", String::from_utf8_lossy(&out.stdout)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--harness"), + "the built-in run grammar (not the blueprint-read path) must handle it: {stderr:?}" + ); +}