From f7c809e5ce681241d9a8c8e081b26b7e3d30fde0 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 7 Jul 2026 17:41:59 +0200 Subject: [PATCH] =?UTF-8?q?refactor(cli):=20retire=20the=20r-sma=20demo=20?= =?UTF-8?q?builder=20=E2=80=94=20topology=20now=20lives=20only=20as=20data?= =?UTF-8?q?=20(#159=20cut=201b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut 1b of the hard-wired demo retirement — the removal half. With the proven example shipped in cut 1, delete the r-sma builder and its whole transitive closure so no production function constructs the r-sma topology any more; it exists only as data (crates/aura-cli/examples/r_sma{,_open}.json). Deleted: sma_signal (the builder), r_sma_graph, run_r_sma (aura run --harness r-sma), r_sma_sweep_family (aura sweep --strategy r-sma), the synthetic inline r-sma walkforward/mc machinery (r_sma_space/r_sma_sweep_over/run_oos_r/ run_mc_r_bootstrap/mc_r_bootstrap_report + the walkforward_family RSma arm), both fixture emitters, the round-trip + cut-1 byte-fidelity tests, the R_SMA_FAST/ SLOW/BIAS_SCALE consts, and the Strategy::RSma / HarnessKind::RSma / McArgs::RealR variants. The three dissolved-verb synth sites (generalize/walkforward/mc --strategy r-sma --real) now embed the shipped open example via include_str! instead of building it — byte-safe, guarded by the unchanged dissolved-verb real-archive goldens. The two now-redundant fixtures move to the examples (deleted, ~40 readers repointed). The --cost-* flags on `aura run` retire with the harness (their sole consumer was run_r_sma; cost-model coverage survives engine/std/composites-level). The retired demo surfaces (--harness r-sma, sweep/walkforward/mc --strategy r-sma inline) have working data successors: aura run examples/r_sma.json, aura sweep examples/r_sma_open.json --axis …, and the --strategy r-sma --real campaign path. Deviation from the plan, noted: StopArm collapsed to StopRule in wrap_r's signature (its only other variant, Open, died with r_sma_graph) — cleaner than the planned vestigial single-variant enum; behaviour-preserving, touching campaign_run.rs and the r-breakout/r-meanrev call sites. Follow-up (not this cut): wrap_r's cost-node-building branch is now unreachable (nothing passes Some(CostConfig) after run_r_sma's deletion) yet clippy-clean — a latent cleanup for when the inline cost surface fully retires. The stage1-r param-suffix remapping (r_sma_friendly_name + the FAST/SLOW/ STOP_LENGTH/STOP_K_SUFFIX consts) went dead and was removed, closing #167 by deletion. Verification (own, not the workflow's report): full `cargo test --workspace` green; `cargo clippy --workspace --all-targets -- -D warnings` clean, no dead-code residue; the cut-1 grade anchor (shipped_r_sma_example_reproduces_the_ builtin_grade) and the dissolved-verb real goldens (generalize/walkforward/mc _dissolves_through_the_campaign_path) stay byte-identical. A first workflow attempt was discarded after a subagent's `git checkout -- main.rs` recovery silently reverted earlier tasks' edits (filed on the plugin tracker as Brummel/Skills#23); this landing is the hardened re-run, verified by hand. closes #167 refs #159 --- crates/aura-cli/src/campaign_run.rs | 4 +- crates/aura-cli/src/main.rs | 948 ++---------- crates/aura-cli/tests/cli_run.rs | 1291 +---------------- .../aura-cli/tests/fixtures/sma_signal.json | 1 - .../tests/fixtures/sma_signal_open.json | 1 - crates/aura-cli/tests/graph_construct.rs | 12 +- crates/aura-cli/tests/project_load.rs | 2 +- crates/aura-cli/tests/research_docs.rs | 6 +- 8 files changed, 204 insertions(+), 2061 deletions(-) delete mode 100644 crates/aura-cli/tests/fixtures/sma_signal.json delete mode 100644 crates/aura-cli/tests/fixtures/sma_signal_open.json diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index e95f8f0..f1cd3b0 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -738,10 +738,10 @@ fn persist_campaign_traces( tx_ex, tx_r, tx_req, - crate::StopArm::Bound(aura_composites::StopRule::Vol { + aura_composites::StopRule::Vol { length: crate::R_SMA_STOP_LENGTH, k: crate::R_SMA_STOP_K, - }), + }, false, None, ) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index c291a89..0fa9c2f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -21,9 +21,9 @@ mod verb_sugar; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; -use aura_composites::{cost_graph, risk_executor, risk_executor_vol_open, StopRule}; +use aura_composites::{cost_graph, risk_executor, StopRule}; use aura_engine::{ - blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, monte_carlo, param_stability, r_bootstrap, + blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, monte_carlo, param_stability, r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, BindError, BlueprintNode, ColumnarTrace, Composite, Edge, FamilySelection, FlatGraph, GraphBuilder, Harness, JoinedRow, McAggregate, McFamily, RBootstrap, RollMode, RunManifest, RunMetrics, RunReport, SelectionMode, SourceSpec, @@ -730,7 +730,7 @@ fn probe_window( /// Single home of the real-source construction the single-run handlers share — the /// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and /// the run-source `open` (each refusal an stderr + exit 1). Pre-data refusals keep the -/// pip honest by construction. Shared by `run_sample_real` and `run_r_sma`. +/// pip honest by construction. Used by `run_sample_real`. fn open_real_source( symbol: &str, from_ms: Option, @@ -1116,272 +1116,10 @@ impl Default for RGrid { } } -/// The path-qualified `param_space()` suffix of the open vol-stop's EWMA length knob -/// (the `Ema` named `stop_length` in `risk_executor_vol_open`). The full slot name is -/// resolved at runtime from the live param-space by this suffix, so the composite path -/// prefix is never hand-synced. -const STOP_LENGTH_SUFFIX: &str = ".vol_stop.stop_length.length"; - -/// The path-qualified `param_space()` suffix of the open vol-stop's `k`-multiplier knob -/// (the `LinComb` named `stop_k`, whose single weight is `weights[0]`). -const STOP_K_SUFFIX: &str = ".vol_stop.stop_k.weights[0]"; - -/// The path-qualified `param_space()` suffixes of the two open SMA-cross signal knobs. -/// Since cycle 0092 the signal leg is a nested `sma_signal` composite, so its knobs -/// land in `param_space` under the composite prefix (`sma_signal.fast.length` / -/// `sma_signal.slow.length`); like the stop knobs they are resolved by suffix so the -/// prefix is never hand-synced, and rendered back to the bare `fast.length` / `slow.length` -/// manifest names by `r_sma_friendly_name` (the pre-0092 family-record names — C18). -const FAST_LENGTH_SUFFIX: &str = ".fast.length"; -const SLOW_LENGTH_SUFFIX: &str = ".slow.length"; - -/// The friendly manifest name for an open vol-stop slot, given its path-qualified -/// `param_space()` name: the two stop knobs render as `stop_length` / `stop_k` (their -/// pre-#137 manual manifest names), every other slot unchanged. Decoupling the manifest -/// name from the deep param-space path keeps the family record auditable (C18) while the -/// values flow through the real `.axis(..)` grid. -fn r_sma_friendly_name(space_name: &str) -> String { - if space_name.ends_with(STOP_LENGTH_SUFFIX) { - "stop_length".to_string() - } else if space_name.ends_with(STOP_K_SUFFIX) { - "stop_k".to_string() - } else if space_name.ends_with(FAST_LENGTH_SUFFIX) { - "fast.length".to_string() - } else if space_name.ends_with(SLOW_LENGTH_SUFFIX) { - "slow.length".to_string() - } else { - space_name.to_string() - } -} - -fn r_sma_sweep_family(trace: Option<&str>, data: &DataSource, grid: &RGrid, env: &project::Env) -> SweepFamily { - let pip = data.pip_size(); - let window = data.full_window(env); - // a single throwaway floated build, only to resolve param_space (borrow) then - // seed the named axes (move) — its taps are never drained, the per-point run_one - // rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self, - // .axis() consumes self, so one build suffices. The vol-stop knobs are left OPEN - // (`stop_open = true`) so all four knobs — signal AND stop — grid through the one - // `.axis(..)` mechanism (#137). - let (tx_eq, _) = mpsc::channel(); - let (tx_ex, _) = mpsc::channel(); - let (tx_r, _) = mpsc::channel(); - let (tx_req, _) = mpsc::channel(); - let bp = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None); - let space = bp.param_space(); - // resolve the open stop slots' exact path-qualified names from the live param-space - // (the composite prefix is never hand-synced — match by the stable suffix). - let stop_length_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(STOP_LENGTH_SUFFIX)) - .expect("open r-sma vol-stop exposes a stop_length axis"); - let stop_k_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(STOP_K_SUFFIX)) - .expect("open r-sma vol-stop exposes a stop_k axis"); - let fast_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(FAST_LENGTH_SUFFIX)) - .expect("open r-sma signal exposes a fast.length axis"); - let slow_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(SLOW_LENGTH_SUFFIX)) - .expect("open r-sma signal exposes a slow.length axis"); - let binder = bp - .axis(&fast_axis, grid.fast.clone()) - .axis(&slow_axis, grid.slow.clone()) - .axis(&stop_length_axis, grid.stop_length.clone()) - .axis(&stop_k_axis, grid.stop_k.clone()); - // the varying axes drive the member key; translate the deep stop-axis names to the - // friendly `stop_length` / `stop_k` the manifest uses, so the key reads the same - // names whether a signal or a stop axis is what varies. - let varying: HashSet = binder - .varying_axes() - .into_iter() - .map(|n| r_sma_friendly_name(&n)) - .collect(); - binder - .sweep(|point| { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, rx_req) = mpsc::channel(); - let reduce = trace.is_none(); - let mut h = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, reduce, None) - .bootstrap_with_cells(point) - .expect("r-sma grid points are kind-checked against param_space"); - h.run(data.run_sources(env)); - // record the swept knobs PLUS the fixed R-defining bias scale, matching the - // single run: a family member must be reproducible from its own manifest - // (C18). The stop knobs are now real swept axes (they appear in `point`), so - // they reach the manifest via `zip_params` — under their friendly - // `stop_length` / `stop_k` names so cross-member SQN comparability (C10) is - // auditable from the family record by the same names as before. - let mut named: Vec<(String, Scalar)> = zip_params(&space, point) - .into_iter() - .map(|(n, v)| (r_sma_friendly_name(&n), v)) - .collect(); - named.push(("bias_scale".to_string(), Scalar::f64(0.5))); - let key = member_key(&named, &varying); - let mut manifest = sim_optimal_manifest(named, window, 0, pip); - manifest.broker = r_sma_broker_label(pip); - let metrics = if reduce { - // folded: GatedRecorder emits O(trades) R rows; each SeriesReducer - // emits one [last, max_drawdown, sign_flips] summary row. - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let (total_pips, max_drawdown) = rx_eq - .try_iter() - .next() - .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) - .unwrap_or((0.0, 0.0)); - let bias_sign_flips = - rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); - let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; - m.r = Some(summarize_r(&r_rows, &[])); - m - } else { - // trace path (--trace set): raw recorders, persist, summarize — today's code. - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); - if let Some(name) = trace { - persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); - } - let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - m.r = Some(summarize_r(&r_rows, &[])); - m - }; - RunReport { manifest, metrics } - }) - .expect("the r-sma named grid matches the r-sma param-space") -} - -/// The param-space of the OPEN r-sma blueprint (all four knobs free) — the kinds -/// the per-window `WindowRun::chosen_params` are read against (C7). Mirrors the -/// throwaway-build param_space resolution inside `r_sma_sweep_family`. -fn r_sma_space() -> Vec { - let (tx_eq, _) = mpsc::channel(); - let (tx_ex, _) = mpsc::channel(); - let (tx_r, _) = mpsc::channel(); - let (tx_req, _) = mpsc::channel(); - r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None).param_space() -} - -/// Windowed reduce-mode r-sma sweep over `[from, to]` — the in-sample leg of the -/// r-sma walk-forward. Identical grid/axis/fold logic to `r_sma_sweep_family`, -/// but windowed (`windowed_sources`) and always folded (O(trades)/member): each -/// member's RunReport carries `metrics.r = Some(summarize_r(..))`, so the family is -/// rankable by an R metric. -fn r_sma_sweep_over( - from: Timestamp, to: Timestamp, data: &DataSource, grid: &RGrid, env: &project::Env, -) -> (SweepFamily, Option>) { - let pip = data.pip_size(); - let (tx_eq, _) = mpsc::channel(); - let (tx_ex, _) = mpsc::channel(); - let (tx_r, _) = mpsc::channel(); - let (tx_req, _) = mpsc::channel(); - let bp = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None); - let space = bp.param_space(); - let stop_length_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(STOP_LENGTH_SUFFIX)) - .expect("open r-sma vol-stop exposes a stop_length axis"); - let stop_k_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(STOP_K_SUFFIX)) - .expect("open r-sma vol-stop exposes a stop_k axis"); - let fast_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(FAST_LENGTH_SUFFIX)) - .expect("open r-sma signal exposes a fast.length axis"); - let slow_axis = space - .iter() - .map(|p| p.name.clone()) - .find(|n| n.ends_with(SLOW_LENGTH_SUFFIX)) - .expect("open r-sma signal exposes a slow.length axis"); - bp.axis(&fast_axis, grid.fast.clone()) - .axis(&slow_axis, grid.slow.clone()) - .axis(&stop_length_axis, grid.stop_length.clone()) - .axis(&stop_k_axis, grid.stop_k.clone()) - .sweep_with_lattice(|point| { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let mut h = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true, None) - .bootstrap_with_cells(point) - .expect("r-sma grid points are kind-checked against param_space"); - let sources = data.windowed_sources(from, to, env); - let window = window_of(&sources).expect("non-empty in-sample window"); - h.run(sources); - let mut named: Vec<(String, Scalar)> = zip_params(&space, point).into_iter() - .map(|(n, v)| (r_sma_friendly_name(&n), v)).collect(); - named.push(("bias_scale".to_string(), Scalar::f64(0.5))); - let mut manifest = sim_optimal_manifest(named, window, 0, pip); - manifest.broker = r_sma_broker_label(pip); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let (total_pips, max_drawdown) = rx_eq.try_iter().next() - .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())).unwrap_or((0.0, 0.0)); - let bias_sign_flips = rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); - let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; - m.r = Some(summarize_r(&r_rows, &[])); - RunReport { manifest, metrics: m } - }) - .map(|(fam, lat)| (fam, Some(lat))) - .expect("the r-sma named grid matches the r-sma param-space") -} - -/// Run the chosen r-sma params over an OOS window; return the recorded pip-equity -/// segment (for stitching) and the OOS RunReport whose `metrics.r` carries both the -/// R metrics and the per-trade `trade_rs`. Non-reduce (raw recorders): one window's -/// curve is bounded, and `stitch` needs the full pip-equity series. -fn run_oos_r( - params: &[Cell], from: Timestamp, to: Timestamp, trace: Option<&str>, data: &DataSource, - env: &project::Env, -) -> (Vec<(Timestamp, f64)>, RunReport) { - let pip = data.pip_size(); - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, rx_req) = mpsc::channel(); - let bp = r_sma_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, false, None); - let space = bp.param_space(); - let mut h = bp.bootstrap_with_cells(params) - .expect("chosen params pre-validated by the in-sample GridSpace::new"); - let sources = data.windowed_sources(from, to, env); - let window = window_of(&sources).expect("non-empty out-of-sample window"); - h.run(sources); - let eq_rows = rx_eq.try_iter().collect::>(); - let ex_rows = rx_ex.try_iter().collect::>(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); - let mut named: Vec<(String, Scalar)> = zip_params(&space, params).into_iter() - .map(|(n, v)| (r_sma_friendly_name(&n), v)).collect(); - named.push(("bias_scale".to_string(), Scalar::f64(0.5))); - let mut manifest = sim_optimal_manifest(named, window, 0, pip); - manifest.broker = r_sma_broker_label(pip); - if let Some(name) = trace { - persist_traces_r(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, &req_rows, &[], env); - } - let equity = f64_field(&eq_rows, 0); - let exposure = f64_field(&ex_rows, 0); - let mut metrics = summarize(&equity, &exposure); - metrics.r = Some(summarize_r(&r_rows, &[])); - (equity, RunReport { manifest, metrics }) -} - /// `aura sweep --strategy r-breakout`: sweep the breakout harness over a channel × /// stop grid. One `channel` length drives BOTH rolling nodes (parameter-ganging, #61), /// so the family iterates the cartesian product MANUALLY with a fully-bound graph per -/// point (compile_with_params(&[]) + Harness::bootstrap, like run_r_sma) rather than +/// point (compile_with_params(&[]) + Harness::bootstrap) rather than /// the open .axis/bootstrap_with_cells path. Each member folds the dense R-record via /// summarize_r, so the family is rankable by sqn / expectancy_r / ... (parity with /// r-sma). With --trace, raw recorders persist the per-cycle streams. @@ -1542,7 +1280,6 @@ fn sweep_report() -> String { enum Strategy { SmaCross, Momentum, - RSma, RBreakout, RMeanRev, } @@ -1575,7 +1312,6 @@ impl Strategy { match self { Strategy::SmaCross => "sma", Strategy::Momentum => "momentum", - Strategy::RSma => "r-sma", Strategy::RBreakout => "r-breakout", Strategy::RMeanRev => "r-meanrev", } @@ -1644,7 +1380,6 @@ fn run_sweep( let family = match strategy { Strategy::SmaCross => sweep_family(persist.then_some(name), &data, env), Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data, env), - Strategy::RSma => r_sma_sweep_family(persist.then_some(name), &data, grid, env), Strategy::RBreakout => r_breakout_sweep_family(persist.then_some(name), &data, grid, env), Strategy::RMeanRev => r_meanrev_sweep_family(persist.then_some(name), &data, grid, env), }; @@ -1669,7 +1404,7 @@ fn run_sweep( /// `--trace`, also persist each OOS member's streams under /// `runs/traces//oos/` (opt-in). Deterministic (C1). fn run_walkforward( - strategy: Strategy, name: &str, persist: bool, data: DataSource, grid: &RGrid, select: Selection, + strategy: Strategy, name: &str, persist: bool, data: DataSource, select: Selection, env: &project::Env, ) { if persist @@ -1679,7 +1414,7 @@ fn run_walkforward( std::process::exit(1); } let reg = env.registry(); - let result = walkforward_family(strategy, persist.then_some(name), &data, grid, select, env); + let result = walkforward_family(strategy, persist.then_some(name), &data, select, env); let id = match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) { @@ -1723,12 +1458,11 @@ fn select_winner( /// The built-in rolling walk-forward: 24-bar in-sample, 12-bar out-of-sample, /// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3 /// windows. Each window sweeps a grid in-sample, optimizes by a metric, and runs -/// the chosen params out-of-sample — both strategy-dispatched: the `SmaCross` arm -/// sweeps the SMA sample grid and optimizes by `total_pips` (axis 2); the -/// `RSma` arm sweeps the r-sma grid and optimizes by `sqn_normalized`. +/// the chosen params out-of-sample — strategy-dispatched: the `SmaCross` arm +/// sweeps the SMA sample grid and optimizes by `total_pips` (axis 2). /// Other strategies have no walk-forward form yet (exit 2). fn walkforward_family( - strategy: Strategy, trace: Option<&str>, data: &DataSource, grid: &RGrid, + strategy: Strategy, trace: Option<&str>, data: &DataSource, select: Selection, env: &project::Env, ) -> WalkForwardResult { let span = data.wf_full_span(env); @@ -1760,19 +1494,6 @@ fn walkforward_family( } }) } - Strategy::RSma => { - let space = r_sma_space(); - walk_forward(roller, space, |w: WindowBounds| { - let (is_family, lattice) = r_sma_sweep_over(w.is.0, w.is.1, data, grid, env); - let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, lattice.as_deref()) { - Ok(v) => v, - Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } - }; - let (oos_equity, mut oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data, env); - oos_report.manifest.selection = Some(selection); - WindowRun { chosen_params: best.params, oos_equity, oos_report } - }) - } other => { eprintln!( "aura: walkforward has no form for strategy '{}'", @@ -1998,7 +1719,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { #[cfg(test)] fn walkforward_report() -> String { let result = walkforward_family( - Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, + Strategy::SmaCross, None, &DataSource::Synthetic, Selection::Argmax, &project::Env::std(), ); let mut out = String::new(); @@ -2085,47 +1806,6 @@ fn run_mc(name: &str, persist: bool, env: &project::Env) { println!("{}", mc_aggregate_json(&family.aggregate)); } -/// Parsed form of the `mc` tail: either the synthetic seed-resweep (today's path, -/// preserved byte-for-byte) or the real-candidate R-bootstrap. The R path accepts -/// ONLY `--strategy r-sma` (the sole R-reporting walk-forward strategy); a bare -/// `--real` without it is a usage error (the synthetic seed-resweep is undefined -/// over real bars). -// Parsed once at the dispatch boundary and immediately destructured into the run -// call; never stored or collected, so the inter-variant size gap is free here. -#[allow(clippy::large_enum_variant)] -#[derive(Clone, Debug, PartialEq)] -enum McArgs { - Synthetic { name: String, persist: bool }, - RealR { choice: DataChoice, grid: RGrid, block_len: usize, n_resamples: usize, seed: u64 }, -} - -/// `aura mc --strategy r-sma [--real ]`: run the r-sma 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 -/// gross R (no costs); deterministic given `seed` (C1). -fn run_mc_r_bootstrap( - data: DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64, - env: &project::Env, -) { - println!("{}", mc_r_bootstrap_report(&data, grid, block_len, n_resamples, seed, env)); -} - -/// Assemble the `mc` R-bootstrap line: run the r-sma walk-forward, pool the OOS -/// per-trade R series in roll order, bootstrap `E[R]`, and render the `mc_r_bootstrap` -/// line. The body of `run_mc_r_bootstrap` minus the `println!`, so the full real-R -/// wiring (walk-forward -> non-empty pooling -> bootstrap -> render) is reachable -/// over synthetic data in a `#[cfg(test)]` unit, mirroring `mc_report` / -/// `walkforward_report` / `sweep_report`. -fn mc_r_bootstrap_report( - data: &DataSource, grid: &RGrid, block_len: usize, n_resamples: usize, seed: u64, - env: &project::Env, -) -> String { - let result = walkforward_family(Strategy::RSma, None, data, grid, Selection::Argmax, env); - let pooled = pooled_oos_trade_rs(&result); - let boot = r_bootstrap(&pooled, n_resamples, block_len, seed); - mc_r_bootstrap_json(&boot) -} - /// Render an `RBootstrap` as one canonical JSON line (`MetricStats` serializes; the /// scalar fields are spliced in), mirroring `mc_aggregate_json`. fn mc_r_bootstrap_json(b: &RBootstrap) -> String { @@ -2315,7 +1995,7 @@ fn reproduce_family_in( let (tx_r, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel(); let space = - wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), true, None).param_space(); + wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, None).param_space(); let point = point_from_params(&space, &stored.manifest.params); // A MonteCarlo member carries no tuning params (the params-join is empty), so its // reproduce line would print a BLANK member label; the seed IS its realization @@ -2525,19 +2205,6 @@ pub(crate) const R_SMA_STOP_LENGTH: i64 = 3; /// `StopRule::Vol` and its manifest record, like [`R_SMA_STOP_LENGTH`]. pub(crate) const R_SMA_STOP_K: f64 = 2.0; -/// The r-sma demo signal SMA lengths (fast/slow), bound at single-run build time. -/// Single source for the graph that runs (`r_sma_graph`), the signal that is hashed -/// (`sma_signal` → `topology_hash`), and the recorded manifest params — so the -/// hashed topology cannot drift from the executed one across the function boundary. -const R_SMA_FAST: i64 = 2; -const R_SMA_SLOW: i64 = 4; - -/// The r-sma demo `Bias` scale (conviction magnitude). Single source for the -/// `Bias` node bound in `sma_signal` (the hashed + executed signal) and the -/// recorded manifest param, so the hashed topology cannot drift from the recorded -/// `bias_scale` across the function boundary — the same guard as the SMA lengths. -const R_SMA_BIAS_SCALE: f64 = 0.5; - /// Short-horizon realized-range window for vol-scaled slippage. Deliberately /// distinct from `R_SMA_STOP_LENGTH` (3): scaling slippage by the stop's own /// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm @@ -2581,38 +2248,6 @@ fn r_sma_prices() -> Vec<(Timestamp, Scalar)> { static COL_PORTS: LazyLock> = LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect()); -/// The r-sma signal leg as a standalone, serializable Composite: SMA-cross → -/// Bias. Exposes a `price` input-role and a single `bias` output — the boundary -/// shape a serialized blueprint round-trips (the `blueprint_serde_e2e.rs` -/// template). The two SMA knobs are bound when `Some` (byte-identical to the old -/// build-time bind) and left open as `fast.length` / `slow.length` when `None`. -fn sma_signal(fast_len: Option, slow_len: Option) -> Composite { - let mut g = GraphBuilder::new("sma_signal"); - let mut fast_b = Sma::builder().named("fast"); - if let Some(l) = fast_len { - fast_b = fast_b.bind("length", Scalar::i64(l)); - } - let fast = g.add(fast_b); - let mut slow_b = Sma::builder().named("slow"); - if let Some(l) = slow_len { - slow_b = slow_b.bind("length", Scalar::i64(l)); - } - let slow = g.add(slow_b); - let spread = g.add(Sub::builder()); - let exposure = g.add( - Bias::builder() - .named("bias") - .bind("scale", Scalar::f64(R_SMA_BIAS_SCALE)), - ); - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, vec![fast.input("series"), slow.input("series")]); - g.connect(fast.output("value"), spread.input("lhs")); - g.connect(slow.output("value"), spread.input("rhs")); - g.connect(spread.output("value"), exposure.input("signal")); - g.expose(exposure.output("bias"), "bias"); - g.build().expect("sma_signal wiring resolves") -} - /// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158). /// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`) /// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the @@ -2628,69 +2263,11 @@ fn topology_hash(signal: &Composite) -> String { content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) } -/// The r-sma harness topology, shared by the single run and the sweep. The two -/// signal knobs are bound when `Some` (single run — identical to the old -/// build-time bind, output byte-unchanged) and left free when `None` (sweep — they -/// now nest in the `sma_signal` composite, so they land in `param_space` as -/// `sma_signal.fast.length` / `sma_signal.slow.length`, rendered back to the -/// bare `fast.length` / `slow.length` manifest names by `r_sma_friendly_name`). -/// The vol-stop knobs are -/// bound to the pinned constants when `stop_open` is `false` (single run + the -/// default sweep, output byte-unchanged) and left free when `true` (a gridded sweep -/// — they land in `param_space` under the `.vol_stop.stop_length.length` / -/// `.vol_stop.stop_k.weights[0]` suffixes). Four taps: equity (SimBroker), exposure -/// (Bias), the 14-column R-record (→ summarize_r), and r_equity = cum_realized_r + -/// unrealized_r. -/// -/// The seven-arg signature is a conscious keep, not an oversight: the four `tx_*` -/// are the per-tap recorder channels (one Recorder edge each — equity, exposure, -/// the R-record, r_equity), `fast_len`/`slow_len` are the two floatable signal -/// knobs, and `stop_open` selects the bound-vs-open vol-stop arm. A sender bundle -/// would only rename the same four channels into one struct field without removing -/// an edge, trading the lint for indirection; the `too_many_arguments` allow is -/// preferred over that churn. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -fn r_sma_graph( - tx_eq: mpsc::Sender<(Timestamp, Vec)>, - tx_ex: mpsc::Sender<(Timestamp, Vec)>, - tx_r: mpsc::Sender<(Timestamp, Vec)>, - tx_req: mpsc::Sender<(Timestamp, Vec)>, - fast_len: Option, - slow_len: Option, - stop_open: bool, - reduce: bool, - cost: Option<(CostConfig, mpsc::Sender<(Timestamp, Vec)>, mpsc::Sender<(Timestamp, Vec)>)>, -) -> Composite { - wrap_r( - sma_signal(fast_len, slow_len), - tx_eq, - tx_ex, - tx_r, - tx_req, - if stop_open { - StopArm::Open - } else { - StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }) - }, - reduce, - cost, - ) -} - -/// The vol-stop arm `wrap_r` embeds: an OPEN stop whose two knobs land in -/// `param_space` as sweep axes (the r-sma verb's gridded-stop path), or a BOUND -/// stop with fixed params (a single run, a blueprint/campaign member). -enum StopArm { - Open, - Bound(StopRule), -} - /// Wrap a `signal` composite (a `price`→`bias` leg) in the R run /// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the /// r_equity / cost legs. The signal is nested and its `price`/`bias` boundary is -/// wired across; everything else is verbatim from the old `r_sma_graph` body, so -/// a serialized signal loaded via `blueprint_from_json` runs through exactly the -/// scaffolding the Rust-built signal does. +/// wired across; a serialized signal loaded via `blueprint_from_json` runs through +/// exactly the scaffolding the Rust-built signal does. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn wrap_r( signal: Composite, @@ -2698,7 +2275,7 @@ fn wrap_r( tx_ex: mpsc::Sender<(Timestamp, Vec)>, tx_r: mpsc::Sender<(Timestamp, Vec)>, tx_req: mpsc::Sender<(Timestamp, Vec)>, - stop: StopArm, + stop: StopRule, reduce: bool, cost: Option<( CostConfig, @@ -2711,9 +2288,9 @@ fn wrap_r( let sig = g.add(BlueprintNode::Composite(signal)); // pip branch (verbatim from sample_blueprint_with_sinks). let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); - // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop is either - // left OPEN as sweep axes (`StopArm::Open`) or BOUND to a fixed `StopRule` — the pinned - // default constants, or an arbitrary per-regime rule the caller resolved (`StopArm::Bound`). + // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop is a + // fixed `StopRule` — the pinned default constants, or an arbitrary per-regime rule + // the caller resolved. // In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex // f64 series to one summary row, GatedRecorder retains only the gated R rows — the // O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the @@ -2732,10 +2309,7 @@ fn wrap_r( } else { g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) }; - let exec = g.add(match stop { - StopArm::Open => risk_executor_vol_open(1.0), - StopArm::Bound(rule) => risk_executor(rule, 1.0), - }); + let exec = g.add(risk_executor(stop, 1.0)); let rrec = if reduce { g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) } else { @@ -2844,8 +2418,7 @@ fn wrap_r( /// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the /// r-sma run paths feed to the harness: the built-in synthetic R stream, /// or a lazily-streamed real M1 close source (with its sidecar pip + probed window). -/// One definition shared by `run_r_sma` and `run_signal_r` so the -/// source/window/pip wiring cannot drift between the two paths. +/// Single definition used by `run_signal_r`. #[allow(clippy::type_complexity)] fn resolve_run_data( data: &RunData, @@ -2889,7 +2462,7 @@ fn run_signal_r( // The req tap (r_equity recorder) is wired but not persisted on this path; keep the // receiver alive so the sink's sends do not fail, but do not drain it. let (tx_req, _rx_req) = mpsc::channel(); - let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), false, None); + let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, None); let flat = wrapped .compile_with_params(params) .expect("signal binds + wraps to a valid harness"); @@ -2933,7 +2506,7 @@ fn run_blueprint_member( let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, _rx_req) = mpsc::channel(); - let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(stop), true, None) + let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, None) .bootstrap_with_cells(point) .expect("member bootstraps (point kind-checked against param_space)"); h.run(sources); @@ -2980,7 +2553,7 @@ fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { let (tx_ex, _) = mpsc::channel(); let (tx_r, _) = mpsc::channel(); let (tx_req, _) = mpsc::channel(); - wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopArm::Bound(StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }), true, None) + wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, None) } /// `aura sweep --list-axes`: one `:` line per open @@ -3110,10 +2683,9 @@ fn run_oos_blueprint( /// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the /// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the -/// winner OOS. Structural twin of `walkforward_family`'s `RSma` arm — the -/// generic `walk_forward` driver + `select_winner` reused; only the per-window -/// sweep/OOS source the loaded blueprint. In-closure errors (a bad `--axis`) -/// `exit(2)` with the sweep terminal's message, as the hard-wired arm does. +/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`; +/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors +/// (a bad `--axis`) `exit(2)` with the sweep terminal's message. fn blueprint_walkforward_family( doc: &str, axes: &[(String, Vec)], data: &DataSource, select: Selection, env: &project::Env, @@ -3375,7 +2947,7 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: /// -> Sub = bias in {-1,0,+1}`. The one Delay(1) on close feeds both rolling nodes, so /// each channel covers `close[t-N..t-1]` (the C2 guard: the current bar is excluded). /// `channel = None` leaves the lengths open (unused today; the family binds them); the -/// stop is bound (defines R), identical downstream to r_sma_graph. +/// stop is bound (defines R), identical downstream to `wrap_r`'s scaffolding. #[allow(clippy::type_complexity, clippy::too_many_arguments)] fn r_breakout_graph( tx_eq: mpsc::Sender<(Timestamp, Vec)>, @@ -3403,7 +2975,7 @@ fn r_breakout_graph( let up_latch = g.add(Latch::builder()); let down_latch = g.add(Latch::builder()); let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} - // pip branch (verbatim from r_sma_graph). + // pip branch (verbatim from wrap_r's scaffolding). let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); let gate_col = PM_FIELD_NAMES .iter() @@ -3582,104 +3154,6 @@ fn r_meanrev_graph( g.build().expect("r_meanrev wiring resolves") } -/// `aura run --harness r-sma [--real [--from][--to]] [--trace ]`: build the -/// dual-tap r-sma harness, run it on synthetic or real M1 data, fold the pip taps via -/// `summarize` and the dense R-record via `summarize_r`, and attach the R block as -/// `RunMetrics.r = Some(..)`. Pure/deterministic (C1). `cost` is the optional flat -/// round-trip cost per trade (price units): `Some(c)` wires the `ConstantCost` node and -/// the `net_r_equity` tap and folds the cost stream into `net_expectancy_r`; `None` is the -/// frictionless gross-R baseline (net == gross, no extra tap, byte-unchanged). -fn run_r_sma( - data: RunData, - trace: Option<&str>, - const_cost: Option, - slip_vol_mult: Option, - carry_per_cycle: Option, - env: &project::Env, -) -> RunReport { - if let Some(n) = trace - && let Err(e) = env.trace_store().ensure_name_free(n, WriteKind::Run) - { - eprintln!("aura: {e}"); - std::process::exit(1); - } - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, rx_req) = mpsc::channel(); - let (tx_net, rx_net) = mpsc::channel(); - let (tx_cost, rx_cost) = mpsc::channel(); - let cost_bundle = - if const_cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some() { - Some(( - CostConfig { - const_cost, - slip_vol_mult, - carry_per_cycle, - }, - tx_net, - tx_cost, - )) - } else { - None - }; - let flat = r_sma_graph( - tx_eq, - tx_ex, - tx_r, - tx_req, - Some(R_SMA_FAST), - Some(R_SMA_SLOW), - false, - false, - cost_bundle, - ) - .compile_with_params(&[]) - .expect("valid r-sma blueprint"); - let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); - - let (sources, window, pip_size) = resolve_run_data(&data, env); - h.run(sources); - - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); - // The cost taps drain empty when `cost` is `None` (no cost node wired): an empty - // `net_rows` suppresses the `net_r_equity` trace and an empty `cost_rows` folds to - // net == gross — the byte-unchanged frictionless baseline. - let net_rows: Vec<(Timestamp, Vec)> = rx_net.try_iter().collect(); - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - - let mut manifest = sim_optimal_manifest( - vec![ - ("sma_fast".to_string(), Scalar::i64(R_SMA_FAST)), - ("sma_slow".to_string(), Scalar::i64(R_SMA_SLOW)), - ("bias_scale".to_string(), Scalar::f64(R_SMA_BIAS_SCALE)), - ("stop_length".to_string(), Scalar::i64(R_SMA_STOP_LENGTH)), // vol_stop EWMA length - ("stop_k".to_string(), Scalar::f64(R_SMA_STOP_K)), // vol_stop multiplier (1R = k·σ) - ], - window, - 0, - pip_size, - ); - manifest.broker = r_sma_broker_label(pip_size); - manifest.topology_hash = Some(topology_hash(&sma_signal( - Some(R_SMA_FAST), - Some(R_SMA_SLOW), - ))); - // Project provenance is paired with `topology_hash` — the same #158 - // reproducibility-anchor group, exactly the three spots that already stamp - // `topology_hash` today (this fn, `run_signal_r`, `run_blueprint_member`). - manifest.project = env.provenance(); - if let Some(name) = trace { - persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows, &net_rows, env); - } - let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - metrics.r = Some(summarize_r(&r_rows, &cost_rows)); - RunReport { manifest, metrics } -} - /// Persist a r-sma run's taps: equity (off the SimBroker), exposure (off the Bias), /// r_equity = cum_realized_r + unrealized_r (off the RiskExecutor), and — only on a cost /// run — net_r_equity (gross r_equity minus the cost-in-R taps). Separate from the two-tap @@ -3716,19 +3190,14 @@ fn persist_traces_r( enum HarnessKind { Sma, Macd, - RSma, } -/// The parsed `aura run` invocation: a harness, a data source, an optional trace name, and -/// an optional flat round-trip cost per trade (`--cost-per-trade`, r-sma only this cycle). +/// The parsed `aura run` invocation: a harness, a data source, and an optional trace name. #[derive(Debug)] struct RunArgs { harness: HarnessKind, data: RunData, trace: Option, - cost: Option, - slip_vol_mult: Option, - carry_per_cycle: Option, } /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells @@ -3761,9 +3230,6 @@ fn run_dispatch(args: RunArgs, env: &project::Env) -> Result Ok(match (args.harness, args.data) { (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace, env), (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace, env), - (HarnessKind::RSma, data) => { - run_r_sma(data, trace, args.cost, args.slip_vol_mult, args.carry_per_cycle, env) - } (HarnessKind::Sma, RunData::Real { symbol, from, to }) => { run_sample_real(&symbol, from, to, trace, env) } @@ -3958,7 +3424,7 @@ 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 | r-sma (default sma). + /// Built-in harness: sma | macd (default sma). #[arg(long)] harness: Option, /// Blueprint params (JSON scalar-cell array; .json mode). @@ -3979,16 +3445,6 @@ struct RunCmd { /// 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. r-sma 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. r-sma 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). r-sma only.")] - carry_per_cycle: Option, } #[derive(Args)] @@ -4165,7 +3621,7 @@ fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunD /// 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(); + let usage = || "Usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ]".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() { @@ -4174,30 +3630,8 @@ fn run_args_from(a: &RunCmd) -> Result { let harness = match a.harness.as_deref() { None | Some("sma") => HarnessKind::Sma, Some("macd") => HarnessKind::Macd, - Some("r-sma") => HarnessKind::RSma, 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::RSma) - && (cost.is_some() || slip_vol_mult.is_some() || carry_per_cycle.is_some()) - { - return Err( - "cost flags require an R-evaluator harness (r-sma); \ - --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()); } @@ -4206,7 +3640,7 @@ fn run_args_from(a: &RunCmd) -> Result { Some(_) => return Err(usage()), None => RunData::Synthetic, }; - Ok(RunArgs { harness, data, trace: a.trace.clone(), cost, slip_vol_mult, carry_per_cycle }) + Ok(RunArgs { harness, data, trace: a.trace.clone() }) } /// The real walk-forward roller sizes in ms (the campaign wf stage works in ms: @@ -4268,9 +3702,8 @@ fn walkforward_args_from( /// dissolved `--strategy r-sma --real` branch). Single instrument, multi-value /// fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is a risk /// regime, not a swept axis); all four grid knobs required. `--block-len`/ -/// `--resamples`/`--seed` default to `1`/`1000`/`1` — byte-identical to the inline -/// `McArgs::RealR` defaults (`main.rs:5064-5066`), so an omitted flag produces the -/// same document either way. The usize->u32 conversion for `block_len`/`resamples` +/// `--resamples`/`--seed` default to `1`/`1000`/`1` — the same defaults the retired +/// real-R dispatch used, so an omitted flag produces the same document either way. The usize->u32 conversion for `block_len`/`resamples` /// lands HERE, at the argv boundary where the CLI's `usize` meets the document's /// `u32` vocabulary (`StageBlock::MonteCarlo`), keeping every downstream signature /// in the document's native type. `--name`/`--trace` are rejected: the inline mc @@ -4367,12 +3800,11 @@ fn data_choice_from( } /// 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. +/// four-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("r-sma") => Strategy::RSma, Some("r-breakout") => Strategy::RBreakout, Some("r-meanrev") => Strategy::RMeanRev, Some(_) => return Err(usage()), @@ -4428,11 +3860,7 @@ fn dispatch_run(a: RunCmd, env: &project::Env) { // 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() - { + if a.trace.is_some() { eprintln!("aura: Usage: aura run [--params ] [--seed ] [--real [--from ] [--to ]]"); std::process::exit(2); } @@ -4521,17 +3949,16 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { eprintln!("aura: {e}"); std::process::exit(2); } - // Synthesize the bare open SMA signal as the stored strategy blueprint (the + // Embed the shipped open SMA example as the stored strategy blueprint (the // same shape the sweep-dissolution fixture `sma_signal_open.json` stores); // fast/slow become single-value campaign axes and the stop rides the risk // regime (mapped back to StopRule::Vol by the member runner). topology_hash // == content_id_of(canonical), so the strategy ref resolves against this one // blueprint-store write. - let signal = sma_signal(None, None); - let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes"); + let canonical = include_str!("../examples/r_sma_open.json"); let reg = env.registry(); - let topo = topology_hash(&signal); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + let topo = content_id(canonical); + reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); @@ -4562,7 +3989,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { &symbols, from_ms, to_ms, - &canonical, + canonical, env, ) .unwrap_or_else(|m| { @@ -4870,11 +4297,10 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - let signal = sma_signal(None, None); - let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes"); + let canonical = include_str!("../examples/r_sma_open.json"); let reg = env.registry(); - let topo = topology_hash(&signal); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + let topo = content_id(canonical); + reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); @@ -4898,7 +4324,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); verb_sugar::run_walkforward_sugar( &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, - is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, &canonical, env, + is_ms, oos_ms, step_ms, &name, &symbol, from_ms, to_ms, canonical, env, ) .unwrap_or_else(|m| { eprintln!("aura: {m}"); @@ -4910,15 +4336,10 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - let mut grid = RGrid::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, @@ -4932,7 +4353,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - run_walkforward(strategy, &name, persist, DataSource::from_choice(data, env), &grid, select, env); + run_walkforward(strategy, &name, persist, DataSource::from_choice(data, env), select, env); } } } @@ -4995,11 +4416,10 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { eprintln!("aura: {m}"); std::process::exit(2); }); - let signal = sma_signal(None, None); - let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes"); + let canonical = include_str!("../examples/r_sma_open.json"); let reg = env.registry(); - let topo = topology_hash(&signal); - reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + let topo = content_id(canonical); + reg.put_blueprint(&topo, canonical).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); }); @@ -5022,7 +4442,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { verb_sugar::run_mc_sugar( &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, is_ms, oos_ms, step_ms, resamples, block_len, seed, &name, &symbol, from_ms, to_ms, - &canonical, env, + canonical, env, ) .unwrap_or_else(|m| { eprintln!("aura: {m}"); @@ -5030,8 +4450,9 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { }); return; } - // The R-bootstrap path is selected by any r-sma knob; otherwise the - // synthetic seed-resweep family. Name flags are invalid on the R path. + // Any r-sma-only knob reaching here (past the dissolved sugar path above) + // is a usage error now — the inline R-bootstrap execution retired with + // `run_r_sma` (#159); the sole real-R execution is the sugar path above. let r_path = a.strategy.is_some() || a.real.is_some() || a.from.is_some() @@ -5043,42 +4464,16 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { || 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("r-sma") || a.name.is_some() || a.trace.is_some() { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - let mut grid = RGrid::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, env), - McArgs::RealR { choice, grid, block_len, n_resamples, seed } => run_mc_r_bootstrap( - DataSource::from_choice(choice, env), &grid, block_len, n_resamples, seed, env, - ), + if r_path { + eprintln!("aura: {}", usage()); + std::process::exit(2); } + 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); + }); + run_mc(&name, persist, env); } } } @@ -5133,6 +4528,21 @@ fn main() { mod tests { use super::*; + /// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through the + /// public `blueprint_from_json` path — the single call site so a fixture + /// rename or vocabulary change is one edit, not fourteen. + fn load_closed_r_sma() -> Composite { + blueprint_from_json(include_str!("../examples/r_sma.json"), &|t| std_vocabulary(t)) + .expect("loads") + } + + /// Loads the shipped open r-sma example (both SMA lengths free) through the + /// public `blueprint_from_json` path. + fn load_open_r_sma() -> Composite { + blueprint_from_json(include_str!("../examples/r_sma_open.json"), &|t| std_vocabulary(t)) + .expect("loads") + } + #[test] fn select_winner_refuses_plateau_without_a_lattice() { // A plateau request with no lattice (a random sweep would yield None) is @@ -5698,7 +5108,7 @@ mod tests { "walkforward", FamilyKind::WalkForward, &walkforward_member_reports(&walkforward_family( - Strategy::SmaCross, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, + Strategy::SmaCross, None, &DataSource::Synthetic, Selection::Argmax, &env, )), ) @@ -6051,7 +5461,7 @@ mod tests { // An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the // serialized doc round-trips to the topology the single run hashes. let env = project::Env::std(); - let open = sma_signal(None, None); + let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; // fast pinned at 2, slow varied over {4, 6}: a 2x1 grid, slow the varying axis. @@ -6073,7 +5483,7 @@ mod tests { // same equity/exposure stream (total_pips/max_drawdown/bias_sign_flips) and the // same topology_hash, proving the loaded blueprint runs through the identical path. let single = run_signal_r( - sma_signal(None, None), + load_open_r_sma(), &[Scalar::i64(2), Scalar::i64(4)], RunData::Synthetic, 0, @@ -6097,14 +5507,14 @@ mod tests { // wraps the signal (name "sma_signal") so the names are prefixed — // exactly what `--axis` binds. let env = project::Env::std(); - let open = include_str!("../tests/fixtures/sma_signal_open.json"); + let open = include_str!("../examples/r_sma_open.json"); let space = blueprint_axis_probe(open, &env).param_space(); let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["sma_signal.fast.length", "sma_signal.slow.length"]); assert!(space.iter().all(|p| matches!(p.kind, ScalarKind::I64))); // A closed blueprint (both lengths bound) has no open axes. - let closed = include_str!("../tests/fixtures/sma_signal.json"); + let closed = include_str!("../examples/r_sma.json"); assert!(blueprint_axis_probe(closed, &env).param_space().is_empty()); } @@ -6112,7 +5522,7 @@ mod tests { fn blueprint_walkforward_family_refits_each_window() { // The open fixture's two SMA lengths are re-fit per IS window over a 2x2 grid. let env = project::Env::std(); - let doc = include_str!("../tests/fixtures/sma_signal_open.json"); + let doc = include_str!("../examples/r_sma_open.json"); let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]), ("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]), @@ -6135,7 +5545,7 @@ mod tests { // synthetic walk per draw), not just the manifest label. The anti-degenerate guard: // a regression to seed-as-label-only would make the three draws identical. let env = project::Env::std(); - let closed = sma_signal(Some(2), Some(4)); + let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint"); @@ -6160,7 +5570,19 @@ mod tests { // fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus // identical metrics; that is a wrong result with no error (C10 refuse-don't-guess). let env = project::Env::std(); - let deep = sma_signal(Some(2), Some(60)); // slow len == walk len -> never warms + // slow len (60) == walk len -> never warms + let mut g = GraphBuilder::new("deep_probe"); + let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2))); + let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(60))); + let spread = g.add(Sub::builder()); + let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, vec![fast.input("series"), slow.input("series")]); + g.connect(fast.output("value"), spread.input("lhs")); + g.connect(slow.output("value"), spread.input("rhs")); + g.connect(spread.output("value"), exposure.input("signal")); + g.expose(exposure.output("bias"), "bias"); + let deep = g.build().expect("deep probe wiring resolves"); let doc = blueprint_to_json(&deep).expect("serializes"); let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env) .expect_err("a vacuous (all-identical) Monte-Carlo is rejected, not returned"); @@ -6178,7 +5600,8 @@ mod tests { // blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the // rejection pre-empts the downstream compile_with_params arity panic. let env = project::Env::std(); - let open = sma_signal(None, None); // both SMA knobs free -> non-empty param_space + // both SMA knobs free -> non-empty param_space + let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic, &env) .expect_err("an open blueprint is rejected, not run"); @@ -6194,7 +5617,7 @@ mod tests { let reg = Registry::open(dir.join("runs.jsonl")); let env = project::Env::std(); - let open = sma_signal(None, None); + let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; // 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member. @@ -6234,7 +5657,7 @@ mod tests { // a CLOSED signal (both SMA knobs bound) — MC binds no axis. let env = project::Env::std(); - let closed = sma_signal(Some(2), Some(4)); + let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); @@ -6270,7 +5693,8 @@ mod tests { #[test] fn blueprint_sweep_family_rejects_a_fully_bound_blueprint() { let env = project::Env::std(); - let closed = sma_signal(Some(2), Some(4)); // both SMA knobs bound -> empty param_space + // both SMA knobs bound -> empty param_space + let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); // an axis naming a bound-out knob: the pre-fix path returns UnknownKnob for it. let axes = vec![( @@ -6303,7 +5727,8 @@ mod tests { let reg = Registry::open(dir.join("runs.jsonl")); let env = project::Env::std(); - let closed = sma_signal(Some(2), Some(4)); // MC binds no axis -> closed blueprint + // MC binds no axis -> closed blueprint + let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint"); @@ -6367,24 +5792,6 @@ mod tests { assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically"); } - #[test] - fn run_r_sma_synthetic_folds_an_r_block() { - // the r-sma harness scores the SMA-cross signal in R: one shell-callable run - // yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade. - let report = run_r_sma(RunData::Synthetic, None, None, None, None, &project::Env::std()); - let r = report.metrics.r.as_ref().expect("r-sma run must populate metrics.r"); - assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades); - assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn); - assert!(r.expectancy_r.is_finite(), "E[R] must be finite, got {}", r.expectancy_r); - // #132: the dual-tap harness runs a RiskExecutor branch alongside the - // SimBroker, so its manifest carries a dedicated broker label. - assert!( - report.manifest.broker.contains("risk-executor"), - "r-sma manifest should carry a dedicated broker label, got: {}", - report.manifest.broker - ); - } - #[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 @@ -6397,77 +5804,37 @@ mod tests { assert!(err.contains("--params"), "a malformed --params value names the flag: {err}"); } - /// Regenerates the committed demo signal blueprint the `aura run ` - /// E2E loads. Ignored by default; run with `--ignored` after a signal change. - #[test] - #[ignore = "regenerates the committed demo signal blueprint fixture"] - fn emit_demo_signal_fixture() { - let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); - std::fs::write("tests/fixtures/sma_signal.json", json).expect("write fixture"); - } - - /// Regenerates the committed OPEN demo signal blueprint the `aura sweep - /// ` E2E sweeps — fast/slow left unbound, so its param_space exposes - /// `sma_signal.fast.length` / `.slow.length` axes to grid. Ignored by default. - #[test] - #[ignore = "regenerates the committed open demo signal blueprint fixture"] - fn emit_demo_signal_open_fixture() { - let json = blueprint_to_json(&sma_signal(None, None)).expect("serializes"); - std::fs::write("tests/fixtures/sma_signal_open.json", json).expect("write fixture"); - } - - /// Regenerates the shipped r-sma demo examples (crates/aura-cli/examples/) from - /// the current builder. Ignored by default; run with `--ignored` after a signal - /// change so the byte-fidelity proof stays green. (#159 copy+prove.) - #[test] - #[ignore = "regenerates the committed r-sma example blueprints"] - fn emit_r_sma_examples() { - std::fs::create_dir_all("examples").expect("create examples dir"); - let closed = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); - std::fs::write("examples/r_sma.json", closed).expect("write closed example"); - let open = blueprint_to_json(&sma_signal(None, None)).expect("serializes"); - std::fs::write("examples/r_sma_open.json", open).expect("write open example"); - } - - /// The shipped examples under crates/aura-cli/examples/ ARE the builder's - /// serialization — the load-bearing proof the demo copy is identical to the - /// original, captured while the builder is alive (#159 copy+prove). Cut 1b's - /// builder deletion + synth-site swap rest on this. - #[test] - fn shipped_r_sma_examples_are_byte_identical_to_the_builder() { - assert_eq!( - include_str!("../examples/r_sma.json"), - blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"), - "closed example drifted from sma_signal(2,4) — re-run emit_r_sma_examples --ignored", - ); - assert_eq!( - include_str!("../examples/r_sma_open.json"), - blueprint_to_json(&sma_signal(None, None)).expect("serializes"), - "open example drifted from sma_signal(None,None) — re-run emit_r_sma_examples --ignored", - ); - } - - /// The cycle-1 keystone (C1): a signal serialized → loaded back through the - /// public `blueprint_from_json` path runs bit-identically to its Rust-built - /// twin — same metrics, same traces, same topology_hash. - #[test] - fn loaded_signal_runs_bit_identical_to_rust_built() { - let env = project::Env::std(); - let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); - let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("loads"); - let a = run_signal_r(sma_signal(Some(2), Some(4)), &[], RunData::Synthetic, 0, &env); - let b = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); - assert_eq!(a.to_json(), b.to_json(), "loaded run is bit-identical incl. topology_hash"); - assert_eq!(a.manifest.topology_hash.as_deref().map(str::len), Some(64), "64-hex sha256 present"); - } - /// `topology_hash` is deterministic per signal and distinguishes topologies — /// the #158 reproducibility-anchor property. #[test] fn topology_hash_is_stable_and_distinguishes() { - let h = topology_hash(&sma_signal(Some(2), Some(4))); - assert_eq!(h, topology_hash(&sma_signal(Some(2), Some(4))), "same signal -> same hash"); - assert_ne!(h, topology_hash(&sma_signal(Some(3), Some(4))), "different topology -> different hash"); + let closed = load_closed_r_sma(); + let open = load_open_r_sma(); + let h = topology_hash(&closed); + assert_eq!(h, topology_hash(&closed), "same signal -> same hash"); + assert_ne!(h, topology_hash(&open), "distinct blueprints -> distinct hash"); + + // Same shape as the closed example, differing only in the BOUND fast-SMA + // length (3 vs 2): the hash must still discriminate on bound param values, + // not merely on topology shape — distinct sweep/mc members must key to + // distinct store entries. + let mut g = GraphBuilder::new("sma_signal"); + let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(3))); + let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4))); + let spread = g.add(Sub::builder()); + let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, vec![fast.input("series"), slow.input("series")]); + g.connect(fast.output("value"), spread.input("lhs")); + g.connect(slow.output("value"), spread.input("rhs")); + g.connect(spread.output("value"), exposure.input("signal")); + g.expose(exposure.output("bias"), "bias"); + let bound_variant = g.build().expect("bound-value probe wiring resolves"); + assert_ne!( + h, + topology_hash(&bound_variant), + "same shape, different bound value -> different hash" + ); } /// #158 acc 1 (content-id stability across the store round-trip): a blueprint's content @@ -6476,7 +5843,8 @@ mod tests { /// topology_hash). `content_id` is the shared primitive `topology_hash` uses. #[test] fn content_id_is_stable_across_the_store_round_trip() { - let json = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); + let closed = load_closed_r_sma(); + let json = blueprint_to_json(&closed).expect("serializes"); let id = content_id(&json); assert_eq!(id.len(), 64, "a 64-hex sha256"); let reloaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("reloads"); @@ -6493,7 +5861,8 @@ mod tests { /// bytes and thus the same content id. #[test] fn content_id_is_stable_across_a_tolerated_tier1_field() { - let base = blueprint_to_json(&sma_signal(Some(2), Some(4))).expect("serializes"); + let closed = load_closed_r_sma(); + let base = blueprint_to_json(&closed).expect("serializes"); // a future Tier-1 optional field injected at the top level (the blueprint does not use it). let with_extra = base.replacen("{\"format_version\":1,", "{\"format_version\":1,\"future_optional\":123,", 1); @@ -6513,15 +5882,14 @@ mod tests { /// primitive. Pins that the two command paths cannot silently drift apart. #[test] fn topology_hash_is_the_content_id_of_the_canonical_form() { - let sig = sma_signal(Some(2), Some(4)); + let sig = load_closed_r_sma(); assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes"))); } - /// The op-script twin of `sma_signal(Some(2), Some(4))`: same topology — - /// SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` BOUND to - /// 0.5 (= `R_SMA_BIAS_SCALE`; boundness is identity-bearing) — differing - /// only in debug names (composite "graph" vs "sma_signal", unnamed Bias vs - /// `.named("bias")`). + /// The op-script twin of the closed r-sma example (`load_closed_r_sma`): same + /// topology — SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale` + /// BOUND to 0.5 (boundness is identity-bearing) — differing only in debug names + /// (composite "graph" vs "sma_signal", unnamed Bias vs `.named("bias")`). const IDENTITY_TWIN_DOC: &str = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}, @@ -6541,7 +5909,7 @@ mod tests { /// authoring paths. #[test] fn identity_id_bridges_the_rust_builder_and_op_script_paths() { - let rust_built = sma_signal(Some(2), Some(4)); + let rust_built = load_closed_r_sma(); let env = project::Env::std(); let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env) .expect("op-script twin builds"); @@ -6568,7 +5936,7 @@ mod tests { // breaks a test instead of silently shipping. Pins the user-visible wire // shape of the mc R-bootstrap render (the parser + engine primitive are // covered elsewhere; this is the output-shape layer). - let boot = r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7); + let boot = aura_engine::r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7); let line = mc_r_bootstrap_json(&boot); let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line"); let obj = &v["mc_r_bootstrap"]; @@ -6581,38 +5949,6 @@ mod tests { assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}"); } - #[test] - fn mc_r_bootstrap_report_pools_a_non_empty_oos_r_series_over_synthetic() { - // Property: the full real-R assembly path — walkforward_family(RSma) -> - // pooled_oos_trade_rs -> r_bootstrap -> mc_r_bootstrap_json — wires up and - // reduces a NON-EMPTY pooled OOS R series (the synthetic r-sma walk-forward - // closes >= 1 trade across its windows). Guards the wiring + the non-empty - // pooling branch the parser/primitive unit tests cannot reach; mirrors - // `mc_report` / `walkforward_report`. Deterministic (C1). - let env = project::Env::std(); - let result = walkforward_family( - Strategy::RSma, None, &DataSource::Synthetic, &RGrid::default(), Selection::Argmax, &env, - ); - let pooled = pooled_oos_trade_rs(&result); - assert!(!pooled.is_empty(), "synthetic r-sma walk-forward must pool >= 1 OOS trade R"); - - let line = mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env); - let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line"); - let obj = &v["mc_r_bootstrap"]; - // n_trades is the pooled-series length the bootstrap actually saw — non-zero - // proves the assembled pooling fed the primitive, not an empty fallback. - assert_eq!(obj["n_trades"], serde_json::json!(pooled.len())); - assert!(obj["n_trades"].as_u64().expect("n_trades is an integer") >= 1); - assert_eq!(obj["n_resamples"], serde_json::json!(256)); - assert!(obj["e_r"]["mean"].as_f64().expect("e_r.mean is a number").is_finite()); - - // C1 at the CLI edge: the assembled real-R line is byte-identical on re-run. - assert_eq!( - line, - mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env), - ); - } - /// A bare `McCmd` with every optional field defaulted to `None`/empty, so each /// `mc_args_from` refusal test below only sets the fields its scenario needs. fn bare_mc_cmd() -> McCmd { diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 002f87a..c583d0d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -322,7 +322,7 @@ fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() { #[test] fn aura_run_loads_and_runs_a_blueprint_file() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "tests/fixtures/sma_signal.json"]) + .args(["run", "examples/r_sma.json"]) .output() .expect("spawn aura run blueprint"); assert_eq!( @@ -362,37 +362,33 @@ fn aura_run_rejects_an_unknown_node_blueprint() { /// ` 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. +/// positional makes `--trace` structurally parseable (it stays on `RunCmd` for +/// the built-in-harness grammar), so the dispatch guard must re-reject it — +/// 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. (The cost flags this test used to also cover +/// were removed from `RunCmd` entirely in #159's builtin retirement; clap now +/// rejects them as unknown args before dispatch ever sees them, so they no +/// longer exercise this guard.) #[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/sma_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) - ); - } + let args = ["run", "examples/r_sma.json", "--trace", "foo"]; + 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] @@ -729,42 +725,6 @@ fn per_subcommand_help_is_scoped_stdout_exit_zero() { } } -/// Property (#153): the cost-flag units/constraints are discoverable from -/// `aura run --help` — the appended note states the in-R charge, the per-ENGINE- -/// cycle carry semantics, the `>= 0` constraint, and that cost flags are r-sma -/// only. Pre-#153 the usage listed the flags with no unit or scale guidance. -#[test] -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"); - // 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("r-sma"), "help must state the R-harness requirement: {stdout}"); -} - -/// 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) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "x"]) - .output() - .expect("spawn aura run --cost-per-trade x"); - assert_eq!(out.status.code(), Some(2), "a malformed cost value is a usage error (exit 2); 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("--cost-per-trade"), "the parse error names the offending flag: {stderr:?}"); -} - /// Property: `aura graph` emits a single self-contained HTML page — the /// interactive pin-graph viewer with the deterministic model inlined and the /// vendored Graphviz-WASM + pan-zoom inlined (no remote fetch). Driven through @@ -1418,7 +1378,7 @@ fn sweep_real_blueprint_member_manifest_stamps_the_default_stop() { return; } let dir = temp_cwd("sweep_real_blueprint_no_stop"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(BIN) .args([ "sweep", &fixture, @@ -1471,7 +1431,7 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { return; } let dir = temp_cwd("sweep_real_blueprint"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(BIN) .args([ "sweep", &fixture, @@ -1680,7 +1640,7 @@ fn sweep_real_blueprint_member_lines_pin_the_dissolved_contract() { #[test] fn aura_sweep_real_blueprint_refuses_a_raw_form_axis_name_before_data_access() { let dir = temp_cwd("sweep_real_rawname_refusal"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args([ "sweep", &fixture, @@ -1725,7 +1685,7 @@ fn aura_sweep_real_blueprint_subset_axes_refuses_without_store_litter() { return; } let dir = temp_cwd("sweep_real_subset_refusal"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args([ "sweep", &fixture, @@ -1880,7 +1840,7 @@ fn mc_builtin_usage_names_the_program_in_both_alternatives() { #[test] fn mc_over_a_blueprint_usage_names_the_program() { let dir = temp_cwd("mc_blueprint_usage_prefix"); - let fixture = format!("{}/tests/fixtures/sma_signal.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args(["mc", &fixture]) .current_dir(&dir) @@ -2100,19 +2060,6 @@ fn chart_family_serves_member_count_and_spanning_window_in_meta() { // --- `aura run --harness ` selector (iter-3 Task 3) -------------------- -/// Property: `aura run --harness r-sma` runs the R-scored harness — its stdout -/// `RunReport` carries the nested `r` block (the R yardstick), and that block carries -/// the `sqn` field. Pins the selector wiring r-sma → `run_r_sma` at the -/// built-binary boundary; a miswiring to the pip-only SMA arm would drop the `r` key. -#[test] -fn run_harness_r_sma_prints_an_r_block() { - let out = std::process::Command::new(BIN).args(["run", "--harness", "r-sma"]).output().unwrap(); - assert!(out.status.success()); - let s = String::from_utf8(out.stdout).unwrap(); - assert!(s.contains("\"r\":{"), "r-sma run must carry an r block: {s}"); - assert!(s.contains("\"sqn\""), "the r block must carry sqn: {s}"); -} - /// Property: `aura run --harness sma` is the pip-only default — its `RunReport` /// OMITS the `r` key entirely (the `skip_serializing_if` keeps a pip run byte-clean). /// The companion of the r-sma test: it proves the selector switches the surface, @@ -2125,49 +2072,6 @@ fn run_harness_sma_is_pip_only_no_r_block() { assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}"); } -/// Property (#153): a cost flag on a non-R harness (sma/macd, which produce no R) -/// is a usage error — exit 2 with a named diagnostic, no silent no-op, no `runs/` -/// write. The pre-#153 behaviour silently ignored the flag and exited 0. -#[test] -fn run_cost_flag_on_non_r_harness_is_refused_exit_2() { - let dir = temp_cwd("sma-cost-refused"); - let out = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "sma", "--cost-per-trade", "0.001"]) - .output() - .expect("spawn aura run --harness sma --cost-per-trade"); - assert_eq!(out.status.code(), Some(2), "cost on a non-R harness must exit 2; stderr: {}", - String::from_utf8_lossy(&out.stderr)); - assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}"); - assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (#153): the non-R-harness cost guard fires on the IMPLICIT default -/// harness too — `aura run --cost-per-trade ...` with no `--harness` defaults to -/// sma (non-R) and is refused (exit 2, named cause, no `runs/` write). Distinct -/// from the explicit `--harness sma` case: this pins that the guard reads the -/// *resolved* harness (after the `unwrap_or(Sma)` default), so a refactor that only -/// guarded an explicitly-named harness would leak a silent no-op through the default. -#[test] -fn run_cost_flag_on_default_harness_is_refused_exit_2() { - let dir = temp_cwd("default-harness-cost-refused"); - let out = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--cost-per-trade", "0.001"]) - .output() - .expect("spawn aura run --cost-per-trade (no --harness)"); - assert_eq!(out.status.code(), Some(2), "cost on the default (sma) harness must exit 2; stderr: {}", - String::from_utf8_lossy(&out.stderr)); - assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("R-evaluator harness"), "stderr must name the cause: {stderr:?}"); - assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); - let _ = std::fs::remove_dir_all(&dir); -} - /// Property: an unknown `--harness` name is a usage error at the binary boundary — /// exit 2 (never a silent default run), the `Err` arm wiring through to `exit(2)`. #[test] @@ -2176,726 +2080,6 @@ fn run_unknown_harness_exits_two() { assert_eq!(out.status.code(), Some(2)); } -/// Property: `--trace` on the r-sma harness persists the THIRD `r_equity` tap -/// beside equity/exposure, and that tap round-trips through `aura chart --tap -/// r_equity` into a rendered series. Pins the full author→persist→chart loop for the -/// R-equity series end to end (the pip harnesses write only the two taps). -#[test] -fn r_sma_trace_persists_r_equity_and_charts_it() { - // `temp_cwd` (cli_run.rs:13) gives a unique CWD with no external tempfile dep — the - // pattern the existing trace tests use; it returns a `PathBuf`, so `.join` directly. - let dir = temp_cwd("r-sma-trace"); - let run = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "r-sma", "--trace", "q1"]) - .output() - .unwrap(); - assert!(run.status.success()); - // the third tap is persisted beside equity/exposure - assert!(dir.join("runs/traces/q1/r_equity.json").exists(), "r_equity tap must persist"); - let chart = Command::new(BIN) - .current_dir(&dir) - .args(["chart", "q1", "--tap", "r_equity"]) - .output() - .unwrap(); - assert!(chart.status.success()); - let html = String::from_utf8(chart.stdout).unwrap(); - assert!(!html.is_empty() && html.contains("r_equity"), "chart must render the r_equity series"); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (Task 3, the run-path cost seam): `aura run --harness r-sma -/// --cost-per-trade ` charges a flat round-trip cost in R end to end — the run -/// persists a fourth `net_r_equity` tap beside r_equity and the report's -/// `net_expectancy_r` is STRICTLY below the gross `expectancy_r`. Pins the whole -/// flag→ConstantCost node→net_r_equity tap→summarize_r net fold loop (a no-cost run -/// leaves net == gross, the held golden); a regression that dropped the cost node or -/// left the net fold reading an empty stream would collapse net back onto gross here. -#[test] -fn r_sma_cost_run_persists_net_r_equity_and_charges_cost() { - let dir = temp_cwd("r-sma-cost"); - let run = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--trace", "c1"]) - .output() - .unwrap(); - assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status, - String::from_utf8_lossy(&run.stderr)); - assert!(dir.join("runs/traces/c1/net_r_equity.json").exists(), "net_r_equity trace persisted"); - let s = String::from_utf8(run.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let gross = v["metrics"]["r"]["expectancy_r"].as_f64().unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert!(net < gross, "cost charged: net {net} < gross {gross}"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (spec 0082, the cost-graph composition headline): `--cost-per-trade` -/// and `--slip-vol-mult` set together compose — both cost nodes sum into one -/// net-R curve. The combined net is strictly below the flat-cost-only net (the -/// vol-slippage node bites on top), and the `net_r_equity` trace persists. -#[test] -fn r_sma_both_costs_compose_net_below_each_alone() { - let dir = temp_cwd("r-sma-compose"); - let run_net = |args: &[&str], trace: &str| -> f64 { - let mut full = vec!["run", "--harness", "r-sma"]; - full.extend_from_slice(args); - full.extend_from_slice(&["--trace", trace]); - let run = Command::new(BIN).current_dir(&dir).args(&full).output().unwrap(); - assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status, - String::from_utf8_lossy(&run.stderr)); - let s = String::from_utf8(run.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap() - }; - let net_flat = run_net(&["--cost-per-trade", "2"], "flat"); - let net_both = run_net(&["--cost-per-trade", "2", "--slip-vol-mult", "0.5"], "both"); - assert!(dir.join("runs/traces/both/net_r_equity.json").exists(), "net_r_equity persisted"); - assert!(net_both < net_flat, "composed cost bites more: net_both {net_both} < net_flat {net_flat}"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Golden characterization (cycle 0083, the CostNode-trait migration): pins the -/// EXACT `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2`, so -/// the ConstantCost-through-CostRunner migration (Task 2) is VERIFIED byte-preserving -/// at the observable boundary, not assumed. The sibling -/// `r_sma_cost_run_persists_net_r_equity_and_charges_cost` only asserts the -/// RELATION `net < gross`; a regression that drifted the cost numerator or the -/// `cost_per_trade / |entry - stop|` R-normalization (the token form the cycle -/// promises to keep verbatim) would keep `net < gross` true and pass that test -/// silently while shifting this value. Pins only the cost-affected float — gross -/// `expectancy_r` is already pinned by `r_sma_single_run_output_golden` and is -/// unchanged by cost. Deterministic over the fixed synthetic stream (C1). -#[test] -fn r_sma_flat_cost_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "2"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, -614.3134020253314, - "flat-cost net_expectancy_r drifted from the golden — the ConstantCost \ - factor migration is not byte-preserving: {s}" - ); -} - -/// Golden characterization (cycle 0083, the composed cost path): pins the EXACT -/// `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2 -/// --slip-vol-mult 0.5`, so the VolSlippageCost-through-CostRunner migration (Task 3) -/// AND the CostSum per-field aggregation reading the shared `COST_FIELD_NAMES` -/// contract (Task 4) are VERIFIED byte-preserving end to end. The sibling -/// `r_sma_both_costs_compose_net_below_each_alone` only asserts the RELATION -/// `net_both < net_flat`; a drift in the vol-scaled numerator, the CostSum field -/// order, or the `cost_graph` composite's per-node `cost[k].` wiring would keep -/// that relation true and pass silently while shifting this value. Deterministic -/// over the fixed synthetic stream (C1). -#[test] -fn r_sma_composed_cost_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--slip-vol-mult", "0.5"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, -615.0304388396047, - "composed-cost net_expectancy_r drifted from the golden — the VolSlippageCost \ - factor + CostSum aggregation is not byte-preserving: {s}" - ); -} - -/// Golden characterization (cycle 0085, the per-held-cycle accrual path): pins the EXACT -/// `net_expectancy_r` of `aura run --harness r-sma --carry-per-cycle 0.5`, so the -/// CarryCost accrual (open_cost grows over the hold, dumps into cum at close) is VERIFIED -/// at the observable boundary. Deterministic over the fixed synthetic stream (C1). -#[test] -fn r_sma_carry_cost_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--carry-per-cycle", "0.5"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, -768.2095026600887, - "carry net_expectancy_r drifted from the golden: {s}" - ); -} - -/// Golden characterization (cycle 0085, composed at-close + accrual): pins the EXACT -/// `net_expectancy_r` of `aura run --harness r-sma --cost-per-trade 2 --carry-per-cycle -/// 0.5` — the per-trade ConstantCost and the per-held-cycle CarryCost sum through -/// cost_graph/CostSum into one net-R curve. Deterministic (C1). -#[test] -fn r_sma_cost_and_carry_composed_net_expectancy_r_golden() { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "2", "--carry-per-cycle", "0.5"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let net = v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap(); - assert_eq!( - net, -1383.7939051991182, - "composed cost+carry net_expectancy_r drifted from the golden: {s}" - ); -} - -/// Property (cycle 0085, the approach-B discriminator): with `--carry-per-cycle`, the -/// `net_r_equity` tap BLEEDS continuously during a multi-cycle hold — the gap to the -/// gross `r_equity` strictly grows each held cycle as the carry accrues, rather than -/// stepping only at close (approach A, which net_expectancy_r alone could not tell -/// apart). Read over the synthetic fixture's terminal open hold (the position held to the -/// end, never closing), where cum_cost_in_r is constant so the growth is the open-side -/// accrual alone. -#[test] -fn r_sma_carry_net_r_equity_bleeds_over_the_hold() { - let dir = temp_cwd("r-sma-carry-bleed"); - let run = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "r-sma", "--carry-per-cycle", "0.5", "--trace", "bleed"]) - .output() - .unwrap(); - assert!(run.status.success(), "exit: {:?}; stderr: {}", run.status, - String::from_utf8_lossy(&run.stderr)); - - let read_col = |tap: &str| -> Vec { - let s = std::fs::read_to_string(dir.join(format!("runs/traces/bleed/{tap}.json"))) - .unwrap_or_else(|e| panic!("read {tap}.json: {e}")); - json_array_body(&s, "\"columns\":[[") - .split(',') - .map(|x| x.trim().parse::().unwrap_or_else(|_| panic!("parse {tap} value {x:?}"))) - .collect() - }; - let r_eq = read_col("r_equity"); - let net_eq = read_col("net_r_equity"); - assert_eq!(r_eq.len(), net_eq.len(), "taps are co-temporal (parallel arrays)"); - let n = r_eq.len(); - assert!(n >= 3, "need a multi-cycle terminal hold to observe the bleed; len {n}"); - // gap = r_equity − net_r_equity = cum_cost_in_r + open_cost_in_r. Over the terminal - // open hold cum is constant, so a strictly growing gap proves the open-side accrual. - let gap = |i: usize| r_eq[i] - net_eq[i]; - assert!( - gap(n - 3) < gap(n - 2) && gap(n - 2) < gap(n - 1), - "net_r_equity must bleed during the terminal hold (gap strictly grows): \ - gaps {}, {}, {}", - gap(n - 3), gap(n - 2), gap(n - 1) - ); - assert!(gap(n - 1) > 0.0, "carry is charged: terminal gap {} > 0", gap(n - 1)); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (cycle 0085 + #153, cost-flag validation symmetry): a NEGATIVE -/// `--carry-per-cycle` is refused at the binary boundary — exit 2, a named -/// non-negativity diagnostic on stderr, nothing on stdout, no `runs/` write — the -/// same shape the sibling `--cost-per-trade`/`--slip-vol-mult` flags enforce. The -/// `v < 0.0` guard is load-bearing: without it a negative carry reaches -/// `CarryCost::new` and PANICs, and a negative price-unit carry would CREDIT R — -/// inverting the accrual sign. Deterministic; the refusal precedes any data access. -#[test] -fn r_sma_negative_carry_per_cycle_refused_non_negative_exit_2() { - let dir = temp_cwd("r-sma-carry-neg"); - let out = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "r-sma", "--carry-per-cycle", "-1"]) - .output() - .expect("spawn aura run --carry-per-cycle -1"); - assert_eq!(out.status.code(), Some(2), - "negative carry must exit 2, not panic; status: {:?}; stderr: {}", - out.status, String::from_utf8_lossy(&out.stderr)); - assert!(out.stdout.is_empty(), "a refused run must not emit a report on stdout: {:?}", out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("must be non-negative"), "negative carry stderr must name the cause: {stderr:?}"); - assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (#153): a negative `--cost-per-trade` is refused at the binary -/// boundary — exit 2, a named non-negativity diagnostic on stderr, no `runs/` -/// write — the sibling of the carry guard, exercising the per-trade arm. -#[test] -fn run_negative_cost_per_trade_refused_non_negative_exit_2() { - let dir = temp_cwd("cost-per-trade-neg"); - let out = Command::new(BIN) - .current_dir(&dir) - .args(["run", "--harness", "r-sma", "--cost-per-trade", "-0.5"]) - .output() - .expect("spawn aura run --cost-per-trade -0.5"); - assert_eq!(out.status.code(), Some(2), "negative cost must exit 2; stderr: {}", - String::from_utf8_lossy(&out.stderr)); - assert!(out.stdout.is_empty(), "a refused run must not emit a report: {:?}", out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("must be non-negative"), "stderr must name the cause: {stderr:?}"); - assert!(!dir.join("runs").exists(), "a refused run must not write the registry"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property (cycle 0085, carry is a monotone debit in the rate): a larger -/// `--carry-per-cycle` makes the run's `net_expectancy_r` STRICTLY more negative. The -/// carry is purely downstream — bias/stop/signal (hence trade count and gross R) are -/// identical across rates — so doubling the per-held-cycle carry can only deepen the -/// charge. The point-goldens pin two exact values but not the DIRECTION: a sign flip that -/// made carry a CREDIT, or a rate that were ignored/clamped, would re-baseline to some -/// other pair of numbers and slip past a golden; this relation survives any future -/// fixture re-baseline. Deterministic over the fixed synthetic stream (C1). -#[test] -fn r_sma_larger_carry_per_cycle_lowers_net_expectancy_r() { - let net = |rate: &str| -> f64 { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--carry-per-cycle", rate]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - v["metrics"]["r"]["net_expectancy_r"].as_f64().unwrap() - }; - let lo = net("0.5"); - let hi = net("1.0"); - assert!(hi < lo, "a larger carry must charge more (lower net): carry-1.0 {hi} < carry-0.5 {lo}"); -} - -/// Property (cycle 0085, the zero-rate boundary is exactly free): `--carry-per-cycle 0` -/// activates the cost path (a CarryCost node IS wired) yet charges NOTHING — the run's -/// `net_expectancy_r` is bit-identical to the gross `expectancy_r`. Guards against an -/// accrual branch that leaks a spurious charge even at rate 0 (e.g. an off-by-one in the -/// open-side mark): such a bug would shift net away from gross while the positive-rate -/// goldens stayed green. Pins the zero ACCRUAL is zero COST invariant relationally, so it -/// survives any future re-baseline of the gross value. Deterministic (C1). -#[test] -fn r_sma_zero_carry_per_cycle_charges_nothing() { - let out = Command::new(BIN) - .args(["run", "--harness", "r-sma", "--carry-per-cycle", "0"]) - .output() - .unwrap(); - assert!(out.status.success(), "exit: {:?}; stderr: {}", out.status, - String::from_utf8_lossy(&out.stderr)); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap(); - let r = &v["metrics"]["r"]; - let net = r["net_expectancy_r"].as_f64().unwrap(); - let gross = r["expectancy_r"].as_f64().unwrap(); - assert_eq!(net, gross, "zero carry must be exactly free: net {net} == gross {gross}; {s}"); -} - -/// Property (the spec's dual-yardstick headline): `aura run --harness r-sma` scores -/// ONE signal in BOTH yardsticks at once — the single emitted `RunReport` carries the pip -/// `metrics.total_pips` (off the SimBroker branch) AND the nested `r` block (off the -/// RiskExecutor branch), because one bias is fanned into both. The shipped selector test -/// pins only that the `r` block appears; this pins that the pip branch SURVIVES alongside -/// it. A regression that swapped the pip branch out for the R branch (dropping the dual -/// tap) would still print an `r` block — and pass that test — while silently losing the -/// pip yardstick; this fails it. Asserts on the structural co-presence of both keys, never -/// the volatile metric floats, so it stays deterministic. -#[test] -fn run_harness_r_sma_carries_both_pip_and_r_yardsticks() { - let out = std::process::Command::new(BIN) - .args(["run", "--harness", "r-sma"]) - .output() - .expect("spawn aura run --harness r-sma"); - assert!(out.status.success(), "exit: {:?}", out.status); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - // exactly one report line. - assert_eq!(s.lines().count(), 1, "stdout was: {s:?}"); - // the pip yardstick (SimBroker branch) is still present in the same report... - assert!(s.contains("\"total_pips\":"), "r-sma must keep the pip yardstick: {s}"); - // ...and the R yardstick (RiskExecutor branch) is folded in alongside it. - assert!(s.contains("\"r\":{"), "r-sma must carry the R yardstick too: {s}"); -} - -/// Property (C1, the foundational determinism invariant at the new harness boundary): -/// `aura run --harness r-sma` is byte-deterministic — running it twice over the -/// fixed synthetic stream yields BYTE-IDENTICAL stdout (modulo the build-constant git -/// commit, which is equal within one build). The whole engine rests on a backtest being -/// reproducible (C1); the sibling sweep/walkforward paths each have a determinism E2E, -/// but the brand-new dual-tap r-sma run-loop (the SMA→Bias fan into SimBroker + the -/// RiskExecutor + the summarize_r fold) had none. A non-determinism in the new R branch -/// (e.g. an unstable channel drain order or an allocator-ordered fold) would surface as a -/// drift here. Compares the full stdout body, so it pins the metric floats too. -#[test] -fn run_harness_r_sma_is_byte_deterministic_across_runs() { - let run = || { - let out = std::process::Command::new(BIN) - .args(["run", "--harness", "r-sma"]) - .output() - .expect("spawn aura run --harness r-sma"); - assert!(out.status.success(), "exit: {:?}", out.status); - String::from_utf8(out.stdout).expect("utf-8 stdout") - }; - assert_eq!(run(), run(), "the r-sma run must be byte-identical across runs (C1)"); -} - -/// Golden characterization (cycle 0066): pins the EXACT metric output of the -/// r-sma single run, so the `r_sma_graph` helper extraction (Task 2) is -/// VERIFIED byte-preserving, not assumed. Captured against HEAD before the -/// refactor; the refactor must keep it identical (a drift is a behaviour change → -/// debug). Commit-agnostic: pins only the `metrics` object, since the manifest -/// carries the volatile build commit. -#[test] -fn r_sma_single_run_output_golden() { - let out = Command::new(BIN).args(["run", "--harness", "r-sma"]).output().unwrap(); - assert!(out.status.success(), "exit: {:?}", out.status); - let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let key = "\"metrics\":"; - let start = s.find(key).expect("stdout has a metrics object"); - let metrics = s[start..].trim_end(); - assert_eq!( - metrics, - r#""metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"sqn_normalized":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}}}"#, - "r-sma single-run metric output drifted from the golden: {s}" - ); -} - -/// Property (#133): `aura sweep --strategy r-sma` runs the r-sma harness over -/// the fast×slow signal grid (4 members), each carrying an R block, persisted as a -/// rankable family; `runs family rank sqn` orders them highest-SQN first. -#[test] -fn sweep_strategy_r_sma_ranks_a_family_by_sqn() { - let cwd = temp_cwd("sweep-r-sma"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma"); - assert!( - out.status.success(), - "r-sma sweep exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 4, "r-sma sweep must print 4 member lines: {stdout:?}"); - for line in &lines { - assert!(line.contains("\"r\":{"), "member must carry an r block: {line}"); - assert!(line.contains("\"sqn\""), "member r block must carry sqn: {line}"); - // each member records the fixed R-defining params (the stop that defines 1R), - // so it is reproducible from its own manifest (C18) and the constant-stop - // basis of cross-member SQN comparability (C10) is auditable from the record. - assert!( - line.contains("\"stop_length\"") && line.contains("\"stop_k\""), - "member manifest must record the fixed R-defining stop params: {line}" - ); - } - // rank the family by sqn — highest first. - let rank = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "sqn"]) - .current_dir(&cwd) - .output() - .expect("spawn rank sqn"); - assert!(rank.status.success(), "rank sqn exit: {:?}", rank.status); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}"); - let sqns: Vec = rank_out - .lines() - .map(|l| { - let key = "\"sqn\":"; - let start = l.find(key).expect("line has sqn") + key.len(); - let tail = &l[start..]; - let end = tail.find([',', '}']).expect("token end"); - tail[..end].parse().expect("sqn is an f64") - }) - .collect(); - assert!(sqns.windows(2).all(|w| w[0] >= w[1]), "rank not descending by sqn: {sqns:?}"); - // an unknown metric is a usage error (exit 2). - let bogus = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "bogus"]) - .current_dir(&cwd) - .output() - .expect("spawn bogus"); - assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status); - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#130): `sqn_normalized` (SQN100) is a working rank key *through the -/// binary*, not just an in-memory `metric_cmp` arm. A persisted r-sma family -/// must carry the new `sqn_normalized` field in each member's on-disk `r` block, -/// and `aura runs family rank sqn_normalized` must parse the new key (it was -/// an `UnknownMetric` exit-2 error before #130), read the field back from -/// `families.jsonl`, and order the members highest-first. This exercises the CLI -/// parse path + the serde round-trip of the new field through the family store, -/// which the registry unit test (synthetic in-memory reports) never touches. The -/// signal-only grid means each member's n_trades is small (cap inactive), so -/// `sqn_normalized == sqn` per member and the SQN100 ranking matches the raw-SQN -/// ranking — asserted as a cross-check. -#[test] -fn sweep_strategy_r_sma_ranks_a_family_by_sqn_normalized() { - let cwd = temp_cwd("sweep-r-sma-sqn100"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma"); - assert!( - out.status.success(), - "r-sma sweep exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - // every persisted member carries the new field in its r block. - for line in stdout.lines() { - assert!( - line.contains("\"sqn_normalized\""), - "member r block must carry sqn_normalized: {line}" - ); - } - // rank by the NEW key — must succeed (was UnknownMetric/exit-2 before #130). - let rank = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "sqn_normalized"]) - .current_dir(&cwd) - .output() - .expect("spawn rank sqn_normalized"); - assert!( - rank.status.success(), - "rank sqn_normalized must succeed (exit 0): {:?}; stderr: {}", - rank.status, - String::from_utf8_lossy(&rank.stderr) - ); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}"); - let norms: Vec = rank_out - .lines() - .map(|l| { - let key = "\"sqn_normalized\":"; - let start = l.find(key).expect("line has sqn_normalized") + key.len(); - let tail = &l[start..]; - let end = tail.find([',', '}']).expect("token end"); - tail[..end].parse().expect("sqn_normalized is an f64") - }) - .collect(); - assert!( - norms.windows(2).all(|w| w[0] >= w[1]), - "rank not descending by sqn_normalized: {norms:?}" - ); - // cap inactive at these trade counts -> per member sqn_normalized == sqn, so the - // SQN100 ranking is the same as the raw-sqn ranking. - let by_sqn = Command::new(BIN) - .args(["runs", "family", "sweep-0", "rank", "sqn"]) - .current_dir(&cwd) - .output() - .expect("spawn rank sqn"); - let by_sqn_out = String::from_utf8(by_sqn.stdout).expect("utf-8"); - let sqns: Vec = by_sqn_out - .lines() - .map(|l| { - let key = "\"sqn\":"; - let start = l.find(key).expect("line has sqn") + key.len(); - let tail = &l[start..]; - let end = tail.find([',', '}']).expect("token end"); - tail[..end].parse().expect("sqn is an f64") - }) - .collect(); - assert_eq!(norms, sqns, "below the cap, SQN100 ranking must equal raw-sqn ranking"); - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#135): `aura sweep --strategy r-sma --trace ` now persists one -/// member dir per grid point (it was refused with exit 2 before). The 2×2 fast×slow -/// grid yields 4 members; each carries the THIRD `r_equity` tap (the r-sma member -/// has the R curve, unlike the two-tap sma/momentum members), and a member is -/// chartable via `chart / --tap r_equity`. `--name` (record the rankable -/// family, no per-member streams) still works. -#[test] -fn sweep_strategy_r_sma_trace_persists_member_dirs_with_r_equity() { - let cwd = temp_cwd("sweep-r-sma-trace"); - let traced = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma", "--trace", "t1"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma --trace"); - assert!( - traced.status.success(), - "r-sma --trace must persist members (exit 0): {:?}; stderr: {}", - traced.status, - String::from_utf8_lossy(&traced.stderr) - ); - let base = cwd.join("runs/traces/t1"); - let members: Vec = std::fs::read_dir(&base) - .expect("read t1 trace dir") - .map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned()) - .collect(); - assert_eq!(members.len(), 4, "2x2 fast×slow r-sma grid = 4 member dirs; got {members:?}"); - for m in &members { - assert!( - m.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')), - "non-portable member dir name: {m}" - ); - assert!( - base.join(m).join("r_equity.json").exists(), - "member {m} must carry the r_equity tap" - ); - } - // one member is chartable via chart / --tap r_equity. - let one = &members[0]; - let chart = Command::new(BIN) - .args(["chart", &format!("t1/{one}"), "--tap", "r_equity"]) - .current_dir(&cwd) - .output() - .expect("spawn aura chart t1/ --tap r_equity"); - assert!(chart.status.success(), "chart exit: {:?}", chart.status); - assert!( - String::from_utf8_lossy(&chart.stdout).contains("r_equity"), - "chart must render the r_equity series" - ); - // --name records the rankable family (no per-member streams) — still succeeds. - let named = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma", "--name", "n1"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma --name"); - assert!( - named.status.success(), - "r-sma --name must succeed: {:?}; stderr: {}", - named.status, - String::from_utf8_lossy(&named.stderr) - ); - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#137 — headline): `aura sweep --strategy r-sma` accepts the four -/// optional comma-separated grid flags `--fast`, `--slow`, `--stop-length`, -/// `--stop-k`, which become the sweep's grid axes over the four r_sma_graph -/// knobs (sma fast length, sma slow length, vol-stop length, vol-stop k). The -/// family is the cartesian product of the supplied lists; a single-value list per -/// axis is a 1-member family. This is the enabling change that lets the stop -/// timescale track a *slow* signal (the pinned 3-bar = 3-minute vol-stop is far too -/// fast for a 240/960-bar momentum cross) — so a slow time-series-momentum edge can -/// be screened across timeframes, stop included. -/// -/// Mirrors `sweep_strategy_r_sma_ranks_a_family_by_sqn`: drive the built binary, -/// read each stdout member line (the assigned `family_id` + the nested RunReport), -/// and inspect the manifest `params` array — whose on-disk shape is -/// `["fast.length",{"I64":N}]` / `["stop_k",{"F64":K}]` (verified against the live -/// CLI output). The single member must carry exactly fast=240, slow=960, -/// stop_length=240, stop_k=2.0. Today every one of these four flags is an unknown -/// token rejected with exit 2 (the grid only honours the hardcoded fast×slow), so -/// this is RED for the right reason: the flags + gridding are absent. -#[test] -fn sweep_strategy_r_sma_grids_all_four_knobs_from_csv_flags() { - let cwd = temp_cwd("sweep-r-sma-grid-flags"); - let out = Command::new(BIN) - .args([ - "sweep", - "--strategy", - "r-sma", - "--fast", - "240", - "--slow", - "960", - "--stop-length", - "240", - "--stop-k", - "2.0", - "--name", - "g1", - ]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma with grid flags"); - assert!( - out.status.success(), - "gridded r-sma sweep must succeed (exit 0): {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - let lines: Vec<&str> = stdout.lines().collect(); - // a one-value-per-axis grid is a 1-member family (cartesian product of singletons). - assert_eq!( - lines.len(), - 1, - "single-value lists on all four axes = a 1-member family: {stdout:?}" - ); - let member = lines[0]; - // the member is a real run (carries an R block), not a "0 ran" no-op. - assert!(member.contains("\"r\":{"), "member must carry an r block: {member}"); - // the single member's four griddable knobs take the supplied values — the - // on-disk manifest-params shape (verified against live CLI output). - for needle in [ - "[\"fast.length\",{\"I64\":240}]", - "[\"slow.length\",{\"I64\":960}]", - "[\"stop_length\",{\"I64\":240}]", - "[\"stop_k\",{\"F64\":2.0}]", - ] { - assert!( - member.contains(needle), - "member must grid {needle} from the CSV flag: {member}" - ); - } - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#137 — default preservation, the hard constraint): when the grid flags -/// are ABSENT, `aura sweep --strategy r-sma` must produce *exactly today's* -/// family — the 2×2 fast×slow grid {2,3}×{6,12} (4 members) with the stop pinned at -/// stop_length=3, stop_k=2.0. This guards the byte-greenness of every existing -/// golden/sweep test once the four flags land: a no-flags invocation falls back to -/// the historical defaults, not to an empty/changed grid. It pins the contract that -/// the flag wiring must not move the no-flags baseline. -#[test] -fn sweep_strategy_r_sma_no_flags_keeps_the_default_grid() { - let cwd = temp_cwd("sweep-r-sma-default-grid"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy r-sma"); - assert!( - out.status.success(), - "no-flags r-sma sweep exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - let lines: Vec<&str> = stdout.lines().collect(); - assert_eq!(lines.len(), 4, "no-flags grid = the historical 2×2 = 4 members: {stdout:?}"); - // the four members are exactly the {2,3}×{6,12} fast×slow cartesian product, - // each with the historical pinned stop (stop_length=3, stop_k=2.0). - let mut fast_slow: Vec<(&str, &str)> = Vec::new(); - for m in &lines { - assert!( - m.contains("[\"stop_length\",{\"I64\":3}]") && m.contains("[\"stop_k\",{\"F64\":2.0}]"), - "no-flags member must keep the historical pinned stop: {m}" - ); - let fast = if m.contains("[\"fast.length\",{\"I64\":2}]") { - "2" - } else { - assert!(m.contains("[\"fast.length\",{\"I64\":3}]"), "fast must be 2 or 3: {m}"); - "3" - }; - let slow = if m.contains("[\"slow.length\",{\"I64\":6}]") { - "6" - } else { - assert!(m.contains("[\"slow.length\",{\"I64\":12}]"), "slow must be 6 or 12: {m}"); - "12" - }; - fast_slow.push((fast, slow)); - } - fast_slow.sort_unstable(); - assert_eq!( - fast_slow, - vec![("2", "12"), ("2", "6"), ("3", "12"), ("3", "6")], - "no-flags grid must be exactly {{2,3}}×{{6,12}}: {fast_slow:?}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} - /// Extract the balanced `"metrics":{...}` object substring from one stdout member /// line. The `family_id` prefix legitimately differs between a no-trace sweep /// (`sweep-0`) and a `--trace n` sweep (`n-0`), so an equivalence check must @@ -2923,78 +2107,6 @@ fn metrics_object(line: &str) -> &str { panic!("unbalanced metrics object: {line}") } -/// Property (#138, task 8 — the streaming-reduction wiring invariant): a no-trace -/// `aura sweep --strategy r-sma` (the *folded* path — `SeriesReducer` folds the -/// eq/ex series to one summary row each, `GatedRecorder` retains only the gated R -/// rows, so memory is O(trades) not O(cycles)) reports **byte-for-byte the same -/// `metrics` object per member** as the `--trace` path (the *raw-recorder* -/// reference that buffers every per-cycle row). This pins the actual M3 fix at the -/// CLI seam: the engine-level Task-7 equivalence tests prove the sink nodes agree -/// in isolation, but only this end-to-end check proves the CLI *wiring* — -/// `reduce = trace.is_none()` selecting the folding topology, and the folded -/// consumer reconstructing `RunMetrics` (`total_pips`/`max_drawdown` from the eq -/// `SeriesReducer` row, `bias_sign_flips` from the ex row, `r` from `summarize_r` -/// over the gated rows) — produces the identical observable report. Members are -/// matched in stdout order (both paths iterate the same deterministic grid, C1), -/// and the `metrics` block excludes the family_id, which is allowed to differ. -#[test] -fn sweep_strategy_r_sma_folded_no_trace_metrics_equal_raw_trace_metrics() { - let cwd = temp_cwd("sweep-r-sma-fold-vs-raw"); - - // folded path: no --trace -> reduce = true (SeriesReducer + GatedRecorder). - let folded = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma"]) - .current_dir(&cwd) - .output() - .expect("spawn folded (no-trace) r-sma sweep"); - assert!( - folded.status.success(), - "folded no-trace sweep exit: {:?}; stderr: {}", - folded.status, - String::from_utf8_lossy(&folded.stderr) - ); - let folded_out = String::from_utf8(folded.stdout).expect("utf-8"); - - // raw path: --trace -> reduce = false (the verbatim Recorder reference). - let raw = Command::new(BIN) - .args(["sweep", "--strategy", "r-sma", "--trace", "t1"]) - .current_dir(&cwd) - .output() - .expect("spawn raw (--trace) r-sma sweep"); - assert!( - raw.status.success(), - "raw --trace sweep exit: {:?}; stderr: {}", - raw.status, - String::from_utf8_lossy(&raw.stderr) - ); - let raw_out = String::from_utf8(raw.stdout).expect("utf-8"); - - let folded_lines: Vec<&str> = folded_out.lines().collect(); - let raw_lines: Vec<&str> = raw_out.lines().collect(); - // both paths run the same 2×2 grid -> 4 members, in the same deterministic order. - assert_eq!(folded_lines.len(), 4, "folded sweep must print 4 members: {folded_out:?}"); - assert_eq!( - raw_lines.len(), - folded_lines.len(), - "the two paths must yield the same member count: folded {} vs raw {}", - folded_lines.len(), - raw_lines.len() - ); - - for (i, (f, r)) in folded_lines.iter().zip(raw_lines.iter()).enumerate() { - let fm = metrics_object(f); - let rm = metrics_object(r); - // the metrics block is non-trivial: it must carry the R yardstick, so this - // is comparing a real reduced output, not two empty objects. - assert!(fm.contains("\"r\":{"), "folded member {i} must carry an r block: {fm}"); - assert_eq!( - fm, rm, - "folded vs raw metrics diverge at member {i}\n folded: {fm}\n raw: {rm}" - ); - } - let _ = std::fs::remove_dir_all(&cwd); -} - /// The breakout strategy reaches the CLI seam: `aura sweep --strategy r-breakout` /// emits an R-bearing member, and the folded no-trace path equals the raw --trace path /// byte-for-byte (parity with the r-sma fold-vs-raw guard). channel 3 warms up on @@ -3183,207 +2295,6 @@ fn sweep_strategy_r_meanrev_grids_one_member_per_window() { let _ = std::fs::remove_dir_all(&cwd); } -/// Property: `aura walkforward --strategy r-sma` on synthetic data reports R -/// quality per window AND pooled across windows — every per-window member line -/// carries a `metrics.r` block (the windowed reduce-mode IS sweep folds the dense -/// R-record), and the summary line carries a pooled `oos_r` block (the across-window -/// reduction of the OOS per-trade R series). Deterministic: a second run is -/// byte-identical (C1). -#[test] -fn walkforward_strategy_r_sma_reports_oos_r() { - let run = || { - let cwd = temp_cwd("wf-r-sma"); - let out = Command::new(BIN) - .args(["walkforward", "--strategy", "r-sma"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward --strategy r-sma"); - assert!( - out.status.success(), - "walkforward --strategy r-sma exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - let _ = std::fs::remove_dir_all(&cwd); - stdout - }; - let out = run(); - let lines: Vec<&str> = out.trim().lines().collect(); - let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); - assert!(summary["walkforward"]["oos_r"].is_object(), "summary carries oos_r"); - assert!(summary["walkforward"]["oos_r"]["n_trades"].is_number()); - // each per-window member line carries metrics.r - for l in &lines[..lines.len() - 1] { - let v: serde_json::Value = serde_json::from_str(l).unwrap(); - assert!(v["report"]["metrics"]["r"].is_object(), "per-window r block present"); - } - let out2 = run(); - assert_eq!(out, out2, "r-sma walkforward is deterministic"); -} - -/// Property: a r-sma walk-forward stamps each OOS winner's manifest with the -/// trials-deflation provenance (#144), and `runs family rank` surfaces it as a -/// human-readable `deflated=… P(overfit)=…` line beside each member's JSON. This is -/// the end-to-end witness that the selection record reaches disk (C18) and the -/// display path renders it; sweep/mc families stay unstamped (verified elsewhere). -#[test] -fn runs_family_rank_shows_deflated_line() { - let cwd = temp_cwd("runs-deflated"); - let wf = Command::new(BIN) - .args(["walkforward", "--strategy", "r-sma", "--name", "wf-defl"]) - .current_dir(&cwd) - .output() - .expect("spawn walkforward --strategy r-sma --name wf-defl"); - assert!( - wf.status.success(), - "walkforward exit: {:?}; stderr: {}", - wf.status, - String::from_utf8_lossy(&wf.stderr) - ); - - let rank = Command::new(BIN) - .args(["runs", "family", "wf-defl-0", "rank", "sqn_normalized"]) - .current_dir(&cwd) - .output() - .expect("spawn runs family wf-defl-0 rank sqn_normalized"); - assert!(rank.status.success(), "rank exit: {:?}", rank.status); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - let _ = std::fs::remove_dir_all(&cwd); - - assert!(rank_out.contains("deflated="), "rank output missing deflated=: {rank_out:?}"); - assert!(rank_out.contains("P(overfit)="), "rank output missing P(overfit)=: {rank_out:?}"); - // each stamped member's JSON also carries the selection block (selection_metric). - assert!( - rank_out.contains("\"selection_metric\":\"sqn_normalized\""), - "selection block present: {rank_out:?}" - ); -} - -/// Property: a r-sma walk-forward run with `--select plateau:mean` selects the -/// neighbourhood-smoothed winner and stamps each OOS manifest with the plateau -/// provenance (`mode = PlateauMean`, `neighbourhood_score`, `n_neighbours`); `runs -/// family … rank` renders the `plateau(mean)=… over N cells` line, and the -/// deflation fields are omitted (orthogonal annotation). End-to-end witness that -/// the opt-in selection rule reaches disk (C18) and the display path renders it. -#[test] -fn walkforward_plateau_select_stamps_plateau_provenance() { - let cwd = temp_cwd("runs-plateau"); - let wf = Command::new(BIN) - .args([ - "walkforward", "--strategy", "r-sma", "--select", "plateau:mean", - "--fast", "50,100", "--slow", "200,400", - "--stop-length", "14,21", "--stop-k", "2.0,3.0", - "--name", "wf-plat", - ]) - .current_dir(&cwd) - .output() - .expect("spawn walkforward --select plateau:mean"); - assert!( - wf.status.success(), - "walkforward exit: {:?}; stderr: {}", - wf.status, - String::from_utf8_lossy(&wf.stderr) - ); - - let rank = Command::new(BIN) - .args(["runs", "family", "wf-plat-0", "rank", "sqn_normalized"]) - .current_dir(&cwd) - .output() - .expect("spawn runs family wf-plat-0 rank sqn_normalized"); - assert!(rank.status.success(), "rank exit: {:?}", rank.status); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - let _ = std::fs::remove_dir_all(&cwd); - - assert!(rank_out.contains("plateau(mean)="), "rank output missing plateau(mean)=: {rank_out:?}"); - assert!(rank_out.contains(" cells"), "rank output missing 'over N cells': {rank_out:?}"); - assert!(rank_out.contains("\"mode\":\"PlateauMean\""), "manifest carries PlateauMean: {rank_out:?}"); - assert!(!rank_out.contains("\"deflated_score\""), "plateau run omits deflated_score: {rank_out:?}"); -} - -/// Property (C23, the opt-in feature's headline guarantee): `--select argmax` is a -/// no-op against the default. A r-sma walk-forward run with an EXPLICIT -/// `--select argmax` produces stdout byte-for-byte identical to the same run with -/// no `--select` flag at all, AND keeps the trials-deflation provenance -/// (`mode == Argmax`, the deflated annotation) on each OOS manifest. Pins both -/// halves of "plateau is strictly opt-in": the flag's default IS argmax (no -/// divergence), and threading the new `Selection` did not silently demote argmax to -/// a bare pick (the #144 deflation path survives). A regression where `--select` -/// perturbed the default path, or where the default switched away from argmax, -/// passes every other test but fails this byte-equality. -#[test] -fn walkforward_select_argmax_is_byte_identical_to_default() { - let run = |args: &[&str]| { - let cwd = temp_cwd("wf-argmax-noop"); - let out = Command::new(BIN) - .arg("walkforward") - .args(args) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward"); - let _ = std::fs::remove_dir_all(&cwd); - assert!( - out.status.success(), - "walkforward exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - String::from_utf8(out.stdout).expect("utf-8") - }; - let default = run(&["--strategy", "r-sma"]); - let explicit = run(&["--strategy", "r-sma", "--select", "argmax"]); - assert_eq!(default, explicit, "explicit --select argmax must be a no-op vs the default"); - // argmax stayed the deflation path (not a bare argmax): the per-window member - // JSON carries the Argmax mode and its deflation annotation. - assert!(default.contains("\"mode\":\"Argmax\""), "argmax manifest mode: {default:?}"); - assert!(default.contains("\"deflated_score\""), "argmax keeps the deflation annotation: {default:?}"); - assert!(!default.contains("\"neighbourhood_score\""), "argmax omits the plateau annotation: {default:?}"); -} - -/// Property: `--select plateau:worst` reaches disk and the display path through the -/// DISTINCT worst-case branch — a separate `SelectionMode::PlateauWorst` enum -/// variant on the wire and a separate `plateau(worst)=` display label. The landed -/// happy-path E2E exercises only `plateau:mean`; the worst arm's serialization -/// variant and its display label are otherwise untouched end-to-end, so a -/// regression that mislabelled worst as mean (or failed to round-trip the variant) -/// would pass the mean test. Same fixture grid as the mean E2E, only the rule -/// differs. -#[test] -fn walkforward_plateau_worst_stamps_worst_variant_and_label() { - let cwd = temp_cwd("runs-plateau-worst"); - let wf = Command::new(BIN) - .args([ - "walkforward", "--strategy", "r-sma", "--select", "plateau:worst", - "--fast", "50,100", "--slow", "200,400", - "--stop-length", "14,21", "--stop-k", "2.0,3.0", - "--name", "wf-worst", - ]) - .current_dir(&cwd) - .output() - .expect("spawn walkforward --select plateau:worst"); - assert!( - wf.status.success(), - "walkforward exit: {:?}; stderr: {}", - wf.status, - String::from_utf8_lossy(&wf.stderr) - ); - - let rank = Command::new(BIN) - .args(["runs", "family", "wf-worst-0", "rank", "sqn_normalized"]) - .current_dir(&cwd) - .output() - .expect("spawn runs family wf-worst-0 rank sqn_normalized"); - assert!(rank.status.success(), "rank exit: {:?}", rank.status); - let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - let _ = std::fs::remove_dir_all(&cwd); - - assert!(rank_out.contains("plateau(worst)="), "rank output missing plateau(worst)=: {rank_out:?}"); - assert!(rank_out.contains("\"mode\":\"PlateauWorst\""), "manifest carries PlateauWorst: {rank_out:?}"); - // the worst label must not be rendered or stamped as mean - assert!(!rank_out.contains("plateau(mean)="), "worst run must not render the mean label: {rank_out:?}"); - assert!(!rank_out.contains("\"mode\":\"PlateauMean\""), "worst run must not stamp PlateauMean: {rank_out:?}"); -} - /// Property: the bare `aura walkforward` (default SMA-cross) summary is preserved /// byte-for-byte by the new `--strategy`/grid plumbing — it still carries exactly /// `windows`, `stitched_total_pips`, `param_stability` and NOT the r-sma-only @@ -3442,108 +2353,6 @@ fn walkforward_unsupported_strategy_exits_2_naming_the_token() { let _ = std::fs::remove_dir_all(&cwd); } -/// Property: `aura mc --strategy r-sma` runs the r-sma walk-forward on -/// synthetic data, bootstraps the pooled OOS per-trade R series, and prints exactly -/// one canonical `mc_r_bootstrap` line carrying an `e_r` distribution block and -/// `prob_le_zero`. Deterministic: a second run with the same (default) seed is -/// byte-identical (C1). Frictionless gross R — no costs, no `runs/` family write. -#[test] -fn mc_strategy_r_sma_prints_a_bootstrap_line_deterministically() { - let dir = temp_cwd("mc_r_sma_boot"); - let run = || { - Command::new(BIN) - .args(["mc", "--strategy", "r-sma", "--resamples", "200"]) - .current_dir(&dir) - .output() - .expect("spawn aura mc --strategy r-sma") - }; - let out = run(); - assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert_eq!(stdout.lines().count(), 1, "exactly one mc_r_bootstrap line: {stdout:?}"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON"); - assert!(v["mc_r_bootstrap"]["e_r"]["mean"].is_number(), "e_r block present: {stdout}"); - assert!(v["mc_r_bootstrap"]["prob_le_zero"].is_number(), "prob_le_zero present: {stdout}"); - assert_eq!(v["mc_r_bootstrap"]["n_resamples"], 200); - - let out2 = run(); - assert_eq!(stdout.as_bytes(), out2.stdout.as_slice(), "same seed -> byte-identical line (C1)"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property: `--block-len` is the moving-block knob, and it is observable AND -/// load-bearing at the binary boundary — it round-trips into the emitted -/// `block_len` field, and a different `block-len` (over the same seed + pooled R -/// series) yields a genuinely different E[R] distribution. This is what separates -/// the moving-block bootstrap from a plain i.i.d. trade shuffle: contiguous runs -/// preserve serial correlation a `block-len 1` shuffle erases. Without this test a -/// regression that dropped `--block-len` on the floor (or wired it to `block_len 1` -/// always) would still pass the happy-path E2E. Same-seed determinism (C1) pins -/// that the difference is the block length, not RNG noise. Not gated — synthetic. -#[test] -fn mc_r_sma_block_len_is_observable_and_changes_the_distribution() { - let dir = temp_cwd("mc_r_sma_block"); - let run = |block_len: &str| { - let out = Command::new(BIN) - .args(["mc", "--strategy", "r-sma", "--resamples", "256", "--seed", "1", "--block-len", block_len]) - .current_dir(&dir) - .output() - .expect("spawn aura mc --strategy r-sma --block-len"); - assert!(out.status.success(), "exit: {:?} stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); - String::from_utf8(out.stdout).expect("utf-8 stdout") - }; - - let one = run("1"); - let three = run("3"); - - let v1: serde_json::Value = serde_json::from_str(one.trim()).expect("canonical JSON (block-len 1)"); - let v3: serde_json::Value = serde_json::from_str(three.trim()).expect("canonical JSON (block-len 3)"); - // the flag round-trips verbatim into the emitted line. - assert_eq!(v1["mc_r_bootstrap"]["block_len"], 1, "block_len 1 must echo: {one}"); - assert_eq!(v3["mc_r_bootstrap"]["block_len"], 3, "block_len 3 must echo: {three}"); - // same seed, same pooled series, different block length -> different E[R] spread. - // (a no-op `--block-len` would make these byte-identical.) - assert_ne!(one, three, "block-len must change the resampled distribution, not be ignored"); - - // C1 at the CLI edge: the block-len-1 line is byte-identical on re-run. - assert_eq!(one, run("1"), "same seed+block-len -> byte-identical line (C1)"); - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property: a `--block-len` at or above the pooled-series length is CLAMPED to the -/// series length at the binary boundary (no panic, no out-of-bounds), and the -/// degenerate single-block case collapses the bootstrap — when every resample IS -/// the full series, the E[R] distribution becomes a single point (all quantiles -/// equal). The emitted `block_len` reports the clamped value, not the raw flag. -/// This pins the clamp the engine primitive guards in-crate, now at the observable -/// CLI edge where an off-by-one in the index arithmetic would otherwise surface as -/// a process crash. Not gated — synthetic r-sma pools a fixed, non-empty series. -#[test] -fn mc_r_sma_oversized_block_len_clamps_and_collapses() { - let dir = temp_cwd("mc_r_sma_clamp"); - let out = Command::new(BIN) - .args(["mc", "--strategy", "r-sma", "--resamples", "200", "--seed", "1", "--block-len", "9999"]) - .current_dir(&dir) - .output() - .expect("spawn aura mc --strategy r-sma --block-len 9999"); - assert!(out.status.success(), "must not crash on oversized block-len: {:?} stderr: {}", - out.status, String::from_utf8_lossy(&out.stderr)); - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("canonical JSON"); - let obj = &v["mc_r_bootstrap"]; - let n_trades = obj["n_trades"].as_u64().expect("n_trades is an integer"); - assert!(n_trades >= 1, "synthetic r-sma must pool a non-empty R series: {stdout}"); - // block_len is reported clamped to the pooled-series length, never the raw 9999. - assert_eq!(obj["block_len"], serde_json::json!(n_trades), - "oversized block-len must clamp to n_trades: {stdout}"); - // single-block degenerate: every resample is the full series, so the E[R] - // distribution is a single point — p5 == p95 (zero spread). - let p5 = obj["e_r"]["p5"].as_f64().expect("p5 is a number"); - let p95 = obj["e_r"]["p95"].as_f64().expect("p95 is a number"); - assert!((p5 - p95).abs() < 1e-12, "single-block bootstrap must have zero spread: p5={p5} p95={p95}"); - let _ = std::fs::remove_dir_all(&dir); -} - /// Property: `aura generalize` grades a single r-sma candidate across two /// instruments — its stdout carries the per-instrument breakdown + the worst-case /// floor + the sign-agreement, then the persisted CrossInstrument family handle. @@ -4360,7 +3169,7 @@ fn generalize_family_members_are_attributed_to_the_right_instrument() { #[test] fn aura_sweep_loads_a_blueprint_and_persists_a_sweep_family() { let cwd = temp_cwd("blueprint-sweep"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args([ "sweep", &fixture, @@ -4391,7 +3200,7 @@ fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() { use sha2::{Digest, Sha256}; let cwd = temp_cwd("blueprint-sweep-store"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args([ "sweep", &fixture, @@ -4447,7 +3256,7 @@ fn aura_sweep_persists_the_canonical_blueprint_keyed_by_topology_hash() { /// from a fully-bound blueprint, which is refused earlier as "nothing to sweep". #[test] fn aura_sweep_rejects_an_unknown_axis() { - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args(["sweep", &fixture, "--axis", "nope=1,2", "--name", "f"]) .output() @@ -4468,7 +3277,7 @@ fn aura_sweep_rejects_an_unknown_axis() { #[test] fn aura_reproduce_re_derives_a_persisted_sweep_family() { let cwd = temp_cwd("blueprint-reproduce"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let sweep = Command::new(BIN) .args([ "sweep", &fixture, @@ -4509,7 +3318,7 @@ fn aura_reproduce_re_derives_a_persisted_sweep_family() { #[test] fn aura_reproduce_reports_a_diverged_member_and_exits_one() { let cwd = temp_cwd("blueprint-reproduce-diverge"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let sweep = Command::new(BIN) .args([ "sweep", &fixture, @@ -4548,7 +3357,7 @@ fn aura_reproduce_reports_a_diverged_member_and_exits_one() { #[test] fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() { let cwd = temp_cwd("reproduce_roundtrip"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let sweep = std::process::Command::new(BIN) .args([ @@ -4587,7 +3396,7 @@ fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() { #[test] fn aura_mc_over_a_blueprint_reproduces_bit_identically() { let cwd = temp_cwd("mc-blueprint-reproduce"); - let fixture = format!("{}/tests/fixtures/sma_signal.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let mc = Command::new(BIN) .args(["mc", &fixture, "--seeds", "4", "--name", "mcx"]) @@ -4632,7 +3441,7 @@ fn aura_mc_over_a_blueprint_reproduces_bit_identically() { #[test] fn aura_walkforward_over_a_blueprint_reproduces_bit_identically() { let cwd = temp_cwd("blueprint-walkforward-reproduce"); - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let run = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=4,6", "--name", "wfr"]) @@ -4657,7 +3466,7 @@ fn aura_walkforward_over_a_blueprint_reproduces_bit_identically() { #[test] fn aura_walkforward_over_a_blueprint_persists_a_family() { let cwd = temp_cwd("blueprint-walkforward-persist"); - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["walkforward", &bp, "--axis", "sma_signal.fast.length=2,3", "--axis", "sma_signal.slow.length=4,6", "--name", "wfx"]) @@ -4682,7 +3491,7 @@ fn aura_walkforward_over_a_blueprint_persists_a_family() { /// error (exit 2), naming that >= 1 --axis is required. #[test] fn aura_walkforward_over_a_blueprint_rejects_no_axis() { - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["walkforward", &bp]) .output() @@ -4720,7 +3529,7 @@ fn aura_walkforward_over_a_blueprint_rejects_malformed() { /// while leaving the malformed test green. #[test] fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() { - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["walkforward", &bp, "--axis", "nope=1,2"]) .output() @@ -4752,7 +3561,7 @@ fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() { /// fan-out) makes every run deterministically single. #[test] fn aura_walkforward_emits_an_unknown_axis_rejection_once() { - let bp = format!("{}/tests/fixtures/sma_signal.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); for attempt in 1..=20 { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["walkforward", &bp, "--axis", "graph.fast.length=2,3"]) @@ -4774,7 +3583,7 @@ fn aura_walkforward_emits_an_unknown_axis_rejection_once() { #[test] fn aura_mc_rejects_an_open_blueprint() { let cwd = temp_cwd("mc-blueprint-open-reject"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args(["mc", &fixture, "--seeds", "4"]) .current_dir(&cwd) @@ -4794,7 +3603,7 @@ fn aura_mc_rejects_an_open_blueprint() { #[test] fn aura_run_rejects_an_open_blueprint_without_panicking() { let cwd = temp_cwd("run-blueprint-open-reject"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(BIN) .args(["run", &fixture]) .current_dir(&cwd) @@ -4868,7 +3677,7 @@ fn aura_reproduce_rejects_a_non_generated_family() { #[test] fn aura_reproduce_rejects_a_missing_stored_blueprint() { let cwd = temp_cwd("reproduce_missing_store"); - let fixture = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let sweep = std::process::Command::new(BIN) .args([ "sweep", @@ -4905,7 +3714,7 @@ fn aura_reproduce_rejects_a_missing_stored_blueprint() { /// the strings `--axis` binds — so a user can discover then sweep without guessing. #[test] fn aura_sweep_list_axes_prints_prefixed_names() { - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["sweep", &bp, "--list-axes"]) .output() @@ -4919,7 +3728,7 @@ fn aura_sweep_list_axes_prints_prefixed_names() { /// nothing and exits 0 — an empty axis set is the honest answer, not an error. #[test] fn aura_sweep_list_axes_closed_is_empty() { - let bp = format!("{}/tests/fixtures/sma_signal.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["sweep", &bp, "--list-axes"]) .output() @@ -4933,7 +3742,7 @@ fn aura_sweep_list_axes_closed_is_empty() { /// and takes no other flags. A query cannot also seed a sweep. #[test] fn aura_sweep_list_axes_rejects_other_flags() { - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) .args(["sweep", &bp, "--list-axes", "--axis", "sma_signal.fast.length=2,3"]) .output() @@ -5022,7 +3831,7 @@ fn aura_sweep_rejects_malformed_json_house_style() { #[test] fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() { let cwd = temp_cwd("blueprint-list-axes-roundtrip"); - let bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); // 1) discover the axis names (do NOT hardcode them) — this is what a user reads. let list = Command::new(BIN) diff --git a/crates/aura-cli/tests/fixtures/sma_signal.json b/crates/aura-cli/tests/fixtures/sma_signal.json deleted file mode 100644 index cbdc8c7..0000000 --- a/crates/aura-cli/tests/fixtures/sma_signal.json +++ /dev/null @@ -1 +0,0 @@ -{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/tests/fixtures/sma_signal_open.json b/crates/aura-cli/tests/fixtures/sma_signal_open.json deleted file mode 100644 index 52ec036..0000000 --- a/crates/aura-cli/tests/fixtures/sma_signal_open.json +++ /dev/null @@ -1 +0,0 @@ -{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}} \ No newline at end of file diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index d9aa9ca..df6789a 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -436,7 +436,7 @@ fn registered_id(stdout: &str) -> String { #[test] fn graph_register_stores_and_prints_content_id() { let dir = temp_cwd("register"); - let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &fixture("sma_signal.json")]); + let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]); assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); // #194: the register verb prints the bare id — `registered blueprint ()`. assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}"); @@ -454,7 +454,7 @@ fn graph_register_stores_and_prints_content_id() { #[test] fn graph_register_is_idempotent() { let dir = temp_cwd("register-idempotent"); - let bp = fixture("sma_signal.json"); + let bp = example("r_sma.json"); let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]); let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]); assert_eq!(code1, Some(0), "first register: {out1} {err1}"); @@ -471,7 +471,7 @@ fn graph_register_is_idempotent() { fn graph_params_lists_raw_axis_namespace() { let dir = temp_cwd("params"); let (stdout, stderr, code) = - run_in(&dir, &["graph", "introspect", "--params", &fixture("sma_signal_open.json")]); + run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]); assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}"); assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order"); } @@ -482,7 +482,7 @@ fn graph_params_lists_raw_axis_namespace() { #[test] fn graph_content_id_accepts_a_blueprint_file() { let dir = temp_cwd("content-id-file"); - let bp = fixture("sma_signal.json"); + let bp = example("r_sma.json"); let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]); assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}"); let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]); @@ -512,7 +512,7 @@ fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() { #[test] fn graph_params_by_content_id_matches_params_by_file() { let dir = temp_cwd("params-by-id"); - let bp = fixture("sma_signal_open.json"); + let bp = example("r_sma_open.json"); let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]); assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}"); let id = registered_id(®_out); @@ -533,7 +533,7 @@ fn graph_params_by_content_id_matches_params_by_file() { #[test] fn graph_params_tolerates_content_prefix_on_target() { let dir = temp_cwd("params-content-prefix"); - let bp = fixture("sma_signal_open.json"); + let bp = example("r_sma_open.json"); let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]); assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}"); let id = registered_id(®_out); diff --git a/crates/aura-cli/tests/project_load.rs b/crates/aura-cli/tests/project_load.rs index 645365c..abfe371 100644 --- a/crates/aura-cli/tests/project_load.rs +++ b/crates/aura-cli/tests/project_load.rs @@ -118,7 +118,7 @@ fn project_registry_anchors_at_discovered_root_not_invocation_cwd() { let runs_dir = dir.join("runs"); std::fs::remove_dir_all(&runs_dir).ok(); let open_bp = - format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = Command::new(env!("CARGO_BIN_EXE_aura")) .args([ "sweep", diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index c0d134c..039c293 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -372,7 +372,7 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() { // Seed one real, content-addressed, open-param blueprint into the // project's own store via a real sweep (mirrors `project_load.rs`'s // anchor test). - let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let (sweep_out, sweep_code) = run_code_in( dir, &[ @@ -798,7 +798,7 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() { /// Seed one open-param blueprint into the built demo project's store via a /// real sweep and return its content id (the referential test's recipe). fn seed_blueprint(dir: &Path, name: &str) -> String { - let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let (out, code) = run_code_in( dir, &[ @@ -2176,7 +2176,7 @@ fn campaign_run_accepts_a_graph_register_seeded_strategy() { ScratchPath::File(dir.join("onramp.process.json")), ScratchPath::File(dir.join("onramp.campaign.json")), ]); - let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let open_bp = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let (reg_out, reg_code) = run_code_in(dir, &["graph", "register", &open_bp]); assert_eq!(reg_code, Some(0), "graph register failed: {reg_out}"); let bp_id = reg_out