diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 68c8f0f..bbaa70c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1,15 +1,7 @@ -//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and -//! automation drive: author a node, run a sim/sweep, emit structured metrics). -//! -//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample -//! signal-quality harness (synthetic source → SMA-cross → Bias → SimBroker → -//! recording sinks), runs it deterministically (C1), and prints the run's -//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move). -//! -//! `aura run --real [--from ] [--to ]` feeds that same harness -//! real M1 close bars, streamed lazily from the local data-server archive through -//! the #71 Source seam (`M1FieldSource`) instead of the synthetic stream — the -//! first real-data backtest from the CLI. +//! `aura` — the programmatic / CLI face of the engine. Topology lives only as +//! data (#159): `aura run`/`sweep`/`walkforward`/`mc` load a serialized blueprint +//! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests; +//! this binary authors no built-in harness. mod render; mod graph_construct; @@ -24,21 +16,21 @@ use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timesta 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_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, - SweepFamily, SweepPoint, SyntheticSpec, Target, VecSource, WalkForwardResult, + r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, BindError, BlueprintNode, + Composite, FamilySelection, GraphBuilder, Harness, JoinedRow, McAggregate, + McFamily, RBootstrap, RollMode, RunManifest, RunMetrics, RunReport, SelectionMode, + SweepFamily, SweepPoint, SyntheticSpec, VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun, }; use aura_registry::{ check_r_metric, group_families, mc_member_reports, optimize_deflated, optimize_plateau, rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind, - FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, WriteKind, + FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, }; use aura_std::{ - Bias, CarryCost, ConstantCost, Ema, GatedRecorder, LinComb, LongOnly, Recorder, RollingMax, - RollingMin, SeriesReducer, SimBroker, Sma, Sub, VolSlippageCost, PM_FIELD_NAMES, + CarryCost, ConstantCost, GatedRecorder, LinComb, Recorder, RollingMax, + RollingMin, SeriesReducer, SimBroker, Sub, VolSlippageCost, PM_FIELD_NAMES, PM_RECORD_KINDS, }; // `std_vocabulary` is now only reached through `project::Env::resolve` in production @@ -61,17 +53,24 @@ use aura_std::Scale; // `Delay`/`Scale` above. #[cfg(test)] use aura_std::{Add, Gt, Latch, Mul, Sqrt}; -use std::sync::mpsc::{self, Receiver}; +// `Bias`/`Ema`/`Sma`/`ColumnarTrace` are only reached from the test module; the +// imports are test-only, mirroring `Delay`/`Scale` above. +#[cfg(test)] +use aura_engine::ColumnarTrace; +#[cfg(test)] +use aura_std::{Bias, Ema, Sma}; +use std::sync::mpsc; use std::sync::LazyLock; use std::collections::HashSet; use clap::{Args, Parser, Subcommand}; -/// The pip size the built-in *synthetic* harnesses run at: a 5-decimal FX major -/// (`EURUSD`-shaped). The synthetic streams carry no instrument, so there is no -/// recorded geometry sidecar to thread; a single named source keeps the broker's -/// divisor (`sample_harness` / `SimBroker::builder`) and the recorded broker label -/// (`sim_optimal_manifest`) in lockstep, so they cannot silently drift apart. -/// The real path threads the sidecar's looked-up `pip_size` instead. +/// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a +/// blueprint) run at: a 5-decimal FX major (`EURUSD`-shaped). The synthetic streams +/// carry no instrument, so there is no recorded geometry sidecar to thread; a +/// single named source keeps the broker's divisor (`SimBroker::builder`) and the +/// recorded broker label (`sim_optimal_manifest`) in lockstep, so they cannot +/// silently drift apart. The real path threads the sidecar's looked-up `pip_size` +/// instead. const SYNTHETIC_PIP_SIZE: f64 = 0.0001; /// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the @@ -94,30 +93,10 @@ const WINNER_SELECTION_METRIC: &str = "sqn_normalized"; /// resample count and block length are the shared `aura_registry::DEFLATION_*`. const DEFLATION_SEED: u64 = 0xDEF1_A7ED; -/// The built-in synthetic price stream: rises through t=4 then reverses, so the -/// demo trace carries one exposure sign flip and a real drawdown (C22 populated -/// trace). Deterministic and fixed (C1). -fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { - [ - (1_i64, 1.0000_f64), - (2, 1.0010), - (3, 1.0030), - (4, 1.0060), - (5, 1.0040), - (6, 1.0010), - (7, 0.9990), - ] - .iter() - .map(|&(t, p)| (Timestamp(t), Scalar::f64(p))) - .collect() -} - -/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks -/// rising, falling, then rising again so the trend SMA spread and the MACD -/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up -/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite, -/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter -/// `synthetic_prices`. Deterministic and fixed (C1). +/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising +/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in +/// blueprint/campaign sweep, walk-forward, and MC families. Deterministic and +/// fixed (C1). fn showcase_prices() -> Vec<(Timestamp, Scalar)> { [ 1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, @@ -129,66 +108,6 @@ fn showcase_prices() -> Vec<(Timestamp, Scalar)> { .collect() } -/// Bootstrap the sample signal-quality harness with two recording sinks (equity -/// tapped on the SimBroker, exposure tapped on the Bias node). Rust-authored -/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The -/// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the -/// broker's slot 0 (slot order is load-bearing — both are f64). -// The harness-plus-two-drained-sink-receivers tuple has exactly one call site -// (`run_sample`); a named type would be speculative abstraction this cycle. -#[allow(clippy::type_complexity)] -fn sample_harness(pip_size: f64) -> ( - Harness, - Receiver<(Timestamp, Vec)>, - Receiver<(Timestamp, Vec)>, -) { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let f64_recorder_sig = || aura_engine::NodeSchema { - inputs: vec![aura_engine::PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }], - output: vec![], - params: vec![], - }; - let h = Harness::bootstrap(FlatGraph { - nodes: vec![ - Box::new(Sma::new(2)), // 0 fast SMA - Box::new(Sma::new(4)), // 1 slow SMA - Box::new(Sub::new()), // 2 spread - Box::new(Bias::new(0.5)), // 3 bias - Box::new(SimBroker::new(pip_size)), // 4 sim-optimal broker - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink - Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink - ], - signatures: vec![ - Sma::builder().schema().clone(), - Sma::builder().schema().clone(), - Sub::builder().schema().clone(), - Bias::builder().schema().clone(), - SimBroker::builder(pip_size).schema().clone(), - f64_recorder_sig(), - f64_recorder_sig(), - ], - sources: vec![SourceSpec { - kind: ScalarKind::F64, - targets: vec![ - Target { node: 0, slot: 0 }, - Target { node: 1, slot: 0 }, - Target { node: 4, slot: 1 }, // price into the broker's price slot - ], - }], - edges: vec![ - Edge { from: 0, to: 2, slot: 0, from_field: 0 }, - Edge { from: 1, to: 2, slot: 1, from_field: 0 }, - Edge { from: 2, to: 3, slot: 0, from_field: 0 }, - Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // exposure into broker slot 0 - Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5 - Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6 - ], - }) - .expect("valid sample signal-quality DAG"); - (h, rx_eq, rx_ex) -} - /// Build the sim-optimal `RunManifest`: the engine-external descriptor fields /// (commit, seed-free synthetic run, broker label) are constant across the CLI's /// built-in harnesses — only `params` and `window` vary. Centralizing the broker @@ -215,36 +134,10 @@ fn sim_optimal_manifest( } } -/// Persist a run's drained taps to the on-disk trace store under `runs/traces//` -/// (beside the run registry's `runs/`). Shared by every run form — all drain the same -/// two f64 taps (equity off the broker, exposure off the strategy) and carry a -/// `RunManifest`. Pure wiring over `ColumnarTrace` + `TraceStore`; the engine is -/// untouched. An I/O failure is a runtime error (stderr + exit 1), per the spec. -fn persist_traces( - name: &str, - manifest: &RunManifest, - eq_rows: &[(Timestamp, Vec)], - ex_rows: &[(Timestamp, Vec)], - env: &project::Env, -) { - let taps = vec![ - ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows), - ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows), - ]; - if let Err(e) = env.trace_store().write(name, manifest, &taps) { - eprintln!("aura: trace persist failed: {e}"); - std::process::exit(1); - } -} - -/// The maximum length (bytes) of an on-disk member-key path component. Comfortably -/// under the 255-byte POSIX `NAME_MAX` and inside Windows' 260-char `MAX_PATH` -/// once the `runs/traces//` prefix and `/.json` suffix are added. -const MAX_KEY: usize = 200; - /// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` to /// `_`. The single source of filesystem-portability for an on-disk path component -/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe). +/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe). Used by +/// `campaign_run`'s per-window member naming. fn sanitize_component(s: &str) -> String { s.chars() .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' }) @@ -264,35 +157,6 @@ fn render_value(v: &Scalar) -> String { } } -/// FNV-1a-64 over `bytes` — a fixed, version-stable, non-cryptographic hash used -/// only as the over-cap member-key disambiguator (`std`'s `DefaultHasher` is -/// explicitly unstable across releases, so it cannot name an on-disk artefact). -fn fnv1a64(bytes: &[u8]) -> u64 { - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for &b in bytes { - h ^= b as u64; - h = h.wrapping_mul(0x0000_0100_0000_01b3); - } - h -} - -/// The portable, collision-free member key for a swept grid point: one `name-value` -/// token per *varying* axis (in `named`'s param-space slot order), joined by `_`, -/// every token sanitised to the portable charset. Pinned (singleton) axes carry no -/// information and are omitted. A 1-point grid (no varying axis) keys as `"m"`. A -/// key over `MAX_KEY` degrades to a conformant `h-<16-hex>` FNV fallback so the -/// key is one valid path component for ANY grid (the #105 generalisation). -fn member_key(named: &[(String, Scalar)], varying: &HashSet) -> String { - let key: String = named - .iter() - .filter(|(n, _)| varying.contains(n)) - .map(|(n, v)| format!("{}-{}", sanitize_component(n), sanitize_component(&render_value(v)))) - .collect::>() - .join("_"); - let key = if key.is_empty() { "m".to_string() } else { key }; - if key.len() <= MAX_KEY { key } else { format!("h-{:016x}", fnv1a64(key.as_bytes())) } -} - /// Default decimation budget: target horizontal buckets. ~2000 buckets ⇒ ≤ ~4000 /// spine slots (min+max per bucket) — a few-thousand-point page regardless of the /// underlying multi-year M1 point count. @@ -584,80 +448,6 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env } } -/// Run the sample harness and fold it into a `RunReport` (drain both sinks → -/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic -/// (C1): the same build yields the same report. -fn run_sample(trace: Option<&str>, 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 (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); - let sources: Vec> = - vec![Box::new(VecSource::new(synthetic_prices()))]; - let window = window_of(&sources).expect("non-empty synthetic stream"); - 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 manifest = sim_optimal_manifest( - vec![ - ("sma_fast".to_string(), Scalar::i64(2)), - ("sma_slow".to_string(), Scalar::i64(4)), - ("bias_scale".to_string(), Scalar::f64(0.5)), - ], - window, - 0, - SYNTHETIC_PIP_SIZE, - ); - if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows, env); - } - let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - RunReport { manifest, metrics } -} - -/// `aura run --real `: run the built-in sample harness over real M1 close -/// bars streamed lazily from the local data-server archive through the #71 Source -/// seam (`M1FieldSource`, a `Box`), not the synthetic `VecSource`. Same -/// fold as `run_sample`. The manifest window is read from a *separate* probe source -/// (a Source is single-pass), so the run source streams the window untouched. -/// A no-local-data condition (unknown symbol, or a window overlapping no file / no -/// bars) is a runtime error: stderr + exit(1), not a panic. -fn run_sample_real( - symbol: &str, from_ms: Option, to_ms: Option, trace: Option<&str>, 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 (source, window, pip_size) = open_real_source(symbol, from_ms, to_ms, env); - let (mut h, rx_eq, rx_ex) = sample_harness(pip_size); - h.run(vec![source]); - - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let manifest = sim_optimal_manifest( - vec![ - ("sma_fast".to_string(), Scalar::i64(2)), - ("sma_slow".to_string(), Scalar::i64(4)), - ("bias_scale".to_string(), Scalar::f64(0.5)), - ], - window, - 0, - pip_size, - ); - if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows, env); - } - let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - RunReport { manifest, metrics } -} - /// What `--real` parsing yields: the synthetic default, or a real symbol + an /// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable. #[derive(Debug, Clone, PartialEq)] @@ -690,7 +480,7 @@ enum DataSource { }, } -/// No-local-data refusal — stderr + exit(1), mirroring `run_sample_real`. +/// No-local-data refusal — stderr + exit(1). fn no_real_data(symbol: &str, env: &project::Env) -> ! { eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path()); std::process::exit(1) @@ -721,7 +511,7 @@ fn pip_or_refuse( /// Probe the full data window: open a single-pass probe `M1FieldSource`, drain it /// for the first/last timestamp, and return `(first, last)`. Refuses (via /// `no_real_data`) when the symbol/window yields no source or no bars. Shared by -/// `run_sample_real` (which needs the manifest window from a probe separate from +/// `open_real_source` (which needs the manifest window from a probe separate from /// the run source) and `DataSource::full_window`. fn probe_window( server: &std::sync::Arc, @@ -745,7 +535,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. Used by `run_sample_real`. +/// pip honest by construction. Used by `resolve_run_data`. fn open_real_source( symbol: &str, from_ms: Option, @@ -772,8 +562,7 @@ fn open_real_source( impl DataSource { /// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol /// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), - /// via the same `pip_or_refuse` / `no_real_data` helpers as - /// `run_sample_real`. + /// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses. fn from_choice(choice: DataChoice, env: &project::Env) -> DataSource { match choice { DataChoice::Synthetic => DataSource::Synthetic, @@ -797,7 +586,7 @@ impl DataSource { /// The full run window, probed once. Synthetic: the showcase span. Real: /// `probe_window` drains a separate single-pass probe source for first/last ts - /// (the same helper `run_sample_real` uses for its manifest window). + /// (the same helper `open_real_source` uses for its manifest window). fn full_window(&self, env: &project::Env) -> (Timestamp, Timestamp) { match self { DataSource::Synthetic => { @@ -862,226 +651,6 @@ impl DataSource { } } - /// The built-in strategy's length grid, **per data kind**. Synthetic keeps the - /// short lengths that fit the 18/60-bar demo streams (a 200-bar MA would never - /// warm on a 60-bar stream); real uses realistic M1 lengths so the SMA-cross + - /// MACD signal is a real trend cross over tens of thousands of bars, not noise. - /// Returns `(trend_fast grid, trend_slow grid, (macd_fast, macd_slow, macd_signal))`; - /// only the two trend axes vary (a 2×2 sweep), the MACD lengths are pinned. - fn strategy_lengths(&self) -> ([i64; 2], [i64; 2], (i64, i64, i64)) { - match self { - DataSource::Synthetic => ([2, 3], [4, 5], (2, 4, 3)), - // 50/200-style intraday crosses on M1 (minutes), standard 12/26/9 MACD. - DataSource::Real { .. } => ([50, 100], [200, 400], (12, 26, 9)), - } - } -} - -/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread). -/// CLI-local sample builder; the engine ships no sample (the duplication with -/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA -/// lengths are injected at compile, not baked here. -fn sma_cross(name: &str) -> Composite { - let mut g = GraphBuilder::new(name); - let fast = g.add(Sma::builder().named("fast")); // fast SMA leg - let slow = g.add(Sma::builder().named("slow")); // slow SMA leg - let sub = g.add(Sub::builder()); - let price = g.input_role("price"); - g.feed(price, [fast.input("series"), slow.input("series")]); - g.connect(fast.output("value"), sub.input("lhs")); - g.connect(slow.output("value"), sub.input("rhs")); - g.expose(sub.output("value"), "cross"); - g.build().expect("sample sma_cross wiring resolves") -} - -/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD), combined -/// by a weighted sum. A multiply-nested composite (root → signals → {trend, -/// momentum}); the blend is a multi-param node living inside it, with one weight -/// bound as a structural constant (so it drops out of the sweepable surface). -fn signals(name: &str) -> Composite { - let mut g = GraphBuilder::new(name); - let trend = g.add(sma_cross("trend")); // trend leg (one f64 "cross") - let momentum = g.add(macd("momentum")); // momentum leg (3 outputs) - // blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal]. - // weights[2] is bound (a fixed signal-line weight) → removed from param_space; - // weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.* - // (and renders the `blend:` prefix). - let blend = g.add( - LinComb::builder(3) - .named("blend") - .bind("weights[2]", Scalar::f64(0.5)), - ); - let price = g.input_role("price"); - g.feed(price, [trend.input("price"), momentum.input("price")]); - g.connect(trend.output("cross"), blend.input("term[0]")); // trend.cross → blend.term[0] - g.connect(momentum.output("histogram"), blend.input("term[1]")); // momentum.histogram → blend.term[1] - g.connect(momentum.output("signal"), blend.input("term[2]")); // momentum.signal → blend.term[2] - g.expose(blend.output("value"), "signal"); - g.build().expect("sample signals wiring resolves") -} - -/// The sample signal-quality blueprint (value-empty) **with its two recording -/// sinks reachable**: returns the equity + exposure receivers a per-point sweep -/// run drains (the eight free params — the trend SMA lengths, the momentum EMA -/// lengths, the two open blend weights, and the exposure scale — are injected at -/// compile via the point vector). The root harness of the sample topology, whose -/// signal is built by the nested `signals` composite; `build_sample` (the `aura -/// graph` entry) is expressed on top of it. -#[allow(clippy::type_complexity)] -fn sample_blueprint_with_sinks(pip_size: f64) -> ( - Composite, - Receiver<(Timestamp, Vec)>, - Receiver<(Timestamp, Vec)>, -) { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let mut g = GraphBuilder::new("sample"); - let sig = g.add(signals("signals")); - let exposure = g.add(Bias::builder().named("bias")); - let broker = g.add(SimBroker::builder(pip_size)); - let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); - let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, [sig.input("price"), broker.input("price")]); - g.connect(sig.output("signal"), exposure.input("signal")); // blended signal -> Bias - g.connect(exposure.output("bias"), broker.input("exposure")); // bias -> broker slot 0 - g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink - g.connect(exposure.output("bias"), ex.input("col[0]")); // bias -> sink - let bp = g.build().expect("sample blueprint wiring resolves"); - (bp, rx_eq, rx_ex) -} - -/// The sample blueprint without its sink receivers — the `aura graph` render -/// entry, which never runs the graph (so the receivers are dropped). -fn build_sample() -> Composite { - sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE).0 -} - -/// The built-in sample rendered by `aura graph`. -fn sample_blueprint() -> Composite { - build_sample() -} - -/// Run the built-in sample over a small built-in grid (fast ∈ {2,3}, -/// slow ∈ {4,5}, scale ∈ {0.5} — 4 points) and render one JSON line per point in -/// enumeration (odometer) order. Pure + deterministic (C1): the same build yields -/// the same report. Each point builds a fresh blueprint (fresh sink channels), -/// bootstraps it under the point vector, runs it, and folds the drained sinks to -/// metrics — the per-point closure the engine `sweep` drives disjointly. -fn sweep_family(trace: Option<&str>, data: &DataSource, env: &project::Env) -> SweepFamily { - let pip = data.pip_size(); - let window = data.full_window(env); - let bp = sample_blueprint_with_sinks(pip).0; - let space = bp.param_space(); - let (tf, ts, (mf, ms, msig)) = data.strategy_lengths(); - let binder = bp - .axis("signals.trend.fast.length", tf) - .axis("signals.trend.slow.length", ts) - .axis("signals.momentum.fast.length", [mf]) - .axis("signals.momentum.slow.length", [ms]) - .axis("signals.momentum.signal.length", [msig]) - .axis("signals.blend.weights[0]", [1.0]) - .axis("signals.blend.weights[1]", [1.0]) - .axis("bias.scale", [0.5]); - let varying: HashSet = binder.varying_axes().into_iter().collect(); - binder - .sweep(|point| { - let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip); - let mut h = bp - .bootstrap_with_cells(point) - .expect("grid points are kind-checked against param_space"); - let sources = data.run_sources(env); - h.run(sources); - let eq_rows = rx_eq.try_iter().collect::>(); - let ex_rows = rx_ex.try_iter().collect::>(); - let named = zip_params(&space, point); - let key = member_key(&named, &varying); - let manifest = sim_optimal_manifest(named, window, 0, pip); - if let Some(name) = trace { - persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, env); - } - let equity = f64_field(&eq_rows, 0); - let exposure = f64_field(&ex_rows, 0); - RunReport { manifest, metrics: summarize(&equity, &exposure) } - }) - .expect("the built-in named grid matches the sample param-space") -} - -/// The EMA-distance momentum demo strategy with its two recording sinks reachable. -/// The signal is how far price sits above/below its own EMA (`price - ema`), bounded -/// into a directional (unsized) bias by `Bias`, then passed through the long-only -/// `LongOnly` gate. Three swept -/// knobs of three kinds — `ema.length` (i64), `bias.scale` (f64), -/// `longonly.enabled` (bool) — with nothing in common with the SMA-cross demo: the -/// generic-sweep proof. The exposure sink taps the FINAL (gated) exposure so the -/// bool's effect is visible in the trace. -#[allow(clippy::type_complexity)] -fn momentum_blueprint_with_sinks(pip_size: f64) -> ( - Composite, - Receiver<(Timestamp, Vec)>, - Receiver<(Timestamp, Vec)>, -) { - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let mut g = GraphBuilder::new("momentum"); - // Name the param-bearing nodes explicitly so the swept paths are guaranteed - // ema.length / bias.scale / longonly.enabled regardless of default-name - // derivation (the member-key examples + the param-space test depend on these). - let ema = g.add(Ema::builder().named("ema")); // ema.length - let dist = g.add(Sub::builder()); // momentum = price - ema - let expo = g.add(Bias::builder().named("bias")); // bias.scale - let gate = g.add(LongOnly::builder().named("longonly")); // longonly.enabled (the bool param) - let broker = g.add(SimBroker::builder(pip_size)); - let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); - let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, [ema.input("series"), dist.input("lhs"), broker.input("price")]); - g.connect(ema.output("value"), dist.input("rhs")); // momentum = price - ema - g.connect(dist.output("value"), expo.input("signal")); - g.connect(expo.output("bias"), gate.input("exposure")); - g.connect(gate.output("exposure"), broker.input("exposure")); // gated exposure -> broker - g.connect(broker.output("equity"), eq.input("col[0]")); // equity -> sink - g.connect(gate.output("exposure"), ex.input("col[0]")); // gated exposure -> sink - let bp = g.build().expect("momentum blueprint wiring resolves"); - (bp, rx_eq, rx_ex) -} - -/// Run the momentum strategy over its built-in grid — `ema.length ∈ {5,10}` × -/// `bias.scale ∈ {0.5,1.0}` × `longonly.enabled ∈ {true,false}` = 8 points, -/// all three axes varying. Mirrors `sweep_family`: capture the binder's varying -/// axes, key each member via the generic portable `member_key`. With `--trace`, -/// persist each member under `runs/traces///`. -fn momentum_sweep_family(trace: Option<&str>, data: &DataSource, env: &project::Env) -> SweepFamily { - let pip = data.pip_size(); - let window = data.full_window(env); - let bp = momentum_blueprint_with_sinks(pip).0; - let space = bp.param_space(); - let binder = bp - .axis("ema.length", [5, 10]) - .axis("bias.scale", [0.5, 1.0]) - .axis("longonly.enabled", [true, false]); - let varying: HashSet = binder.varying_axes().into_iter().collect(); - binder - .sweep(|point| { - let (bp, rx_eq, rx_ex) = momentum_blueprint_with_sinks(pip); - let mut h = bp - .bootstrap_with_cells(point) - .expect("grid points are kind-checked against param_space"); - let sources = data.run_sources(env); - h.run(sources); - let eq_rows = rx_eq.try_iter().collect::>(); - let ex_rows = rx_ex.try_iter().collect::>(); - let named = zip_params(&space, point); - let key = member_key(&named, &varying); - let manifest = sim_optimal_manifest(named, window, 0, pip); - if let Some(name) = trace { - persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, env); - } - let equity = f64_field(&eq_rows, 0); - let exposure = f64_field(&ex_rows, 0); - RunReport { manifest, metrics: summarize(&equity, &exposure) } - }) - .expect("the momentum named grid matches the momentum param-space") } /// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor @@ -1116,26 +685,6 @@ impl Default for RGrid { } } -/// Render a sweep family as one `RunReport` JSON line per point. Test helper: -/// production (`run_sweep`) renders *and* persists per point. -#[cfg(test)] -fn sweep_report() -> String { - let mut out = String::new(); - for pt in &sweep_family(None, &DataSource::Synthetic, &project::Env::std()).points { - out.push_str(&pt.report.to_json()); - out.push('\n'); - } - out -} - -/// Which built-in strategy `aura sweep` runs. Default (today's behaviour) is the -/// SMA-cross sample; `momentum` is the bool-param demo. -#[derive(Clone, Copy, PartialEq, Debug)] -enum Strategy { - SmaCross, - Momentum, -} - /// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is /// the bare-best pick deflated for trials (#144, the default); `Plateau` argmaxes /// the neighbourhood-smoothed surface instead (opt-in via `--select`). @@ -1156,18 +705,6 @@ fn parse_select(s: &str) -> Result { } } -impl Strategy { - /// The CLI `--strategy` token this variant parses from — the inverse of the - /// `parse_*_args` match arms. Used to echo the offending strategy in error - /// messages so they name the actual input. - fn cli_token(self) -> &'static str { - match self { - Strategy::SmaCross => "sma", - Strategy::Momentum => "momentum", - } - } -} - /// Parse a comma-separated list of `T` (each item parsed via `FromStr`), rejecting /// any item that fails to parse — the shared validator for the r-sma grid flags /// (#137). The caller folds the `Err` into the subcommand `usage()` so a malformed @@ -1209,71 +746,6 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { ) } -/// `aura sweep [--strategy ] [--name |--trace ]`: run the -/// selected built-in sweep, persist it as a *family* (related records sharing one -/// `family_id`, C18/C21) via `append_family`, and print each point's record line -/// carrying the assigned id. With `--trace`, every strategy -/// (`sma`/`momentum`) persists each member's streams under -/// `runs/traces///` (opt-in). -fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource, env: &project::Env) { - if persist - && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) - { - eprintln!("aura: {e}"); - std::process::exit(1); - } - let reg = env.registry(); - 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), - }; - let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { - Ok(id) => id, - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }; - for pt in &family.points { - println!("{}", family_member_line(&id, &pt.report)); - } -} - -/// `aura walkforward [--name |--trace ]`: run a built-in rolling walk-forward -/// over the sample blueprint + a synthetic windowed source. Per window: sweep the -/// built-in grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3, -/// where aura-cli bridges engine + registry), run the chosen params out-of-sample. -/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`, -/// print each carrying the assigned id, then the stitched summary line. With -/// `--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, select: Selection, - env: &project::Env, -) { - if persist - && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) - { - eprintln!("aura: {e}"); - std::process::exit(1); - } - let reg = env.registry(); - 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)) - { - Ok(id) => id, - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }; - for w in &result.windows { - println!("{}", family_member_line(&id, &w.run.oos_report)); - } - println!("{}", walkforward_summary_json(&result)); -} - /// Resolve the in-sample winner under the chosen selection objective. `Argmax` /// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid /// surface — it needs the grid lattice, so a sweep with no lattice (a future random @@ -1299,122 +771,6 @@ 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 — 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, - select: Selection, env: &project::Env, -) -> WalkForwardResult { - let span = data.wf_full_span(env); - let (is_len, oos_len, step) = data.wf_window_sizes(); - let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) { - Ok(r) => r, - Err(e) => { - eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}"); - std::process::exit(2); - } - }; - match strategy { - Strategy::SmaCross => { - let space = sample_blueprint_with_sinks(data.pip_size()).0.param_space(); - walk_forward(roller, space, |w: WindowBounds| { - let (is_family, lattice) = sweep_over(w.is.0, w.is.1, data, env); - let (best, selection) = match select_winner(&is_family, "total_pips", select, lattice.as_deref()) { - Ok(v) => v, - Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } - }; - let (oos_equity, mut oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace, data, env); - oos_report.manifest.selection = Some(selection); - WindowRun { - // The tag-free sweep winner is the chosen point; its kinds live on - // WalkForwardResult.space (computed once above from the same blueprint). - chosen_params: best.params, - oos_equity, - oos_report, - } - }) - } - other => { - eprintln!( - "aura: walkforward has no form for strategy '{}'", - other.cli_token() - ); - std::process::exit(2); - } - } -} - -/// Sweep the built-in named grid over an in-sample window, sourcing the in-memory -/// windowed stream. Mirrors `sweep_family`, but windowed by `[from, to]`. -fn sweep_over( - from: Timestamp, to: Timestamp, data: &DataSource, env: &project::Env, -) -> (SweepFamily, Option>) { - let pip = data.pip_size(); - let bp = sample_blueprint_with_sinks(pip).0; - let space = bp.param_space(); - let (tf, ts, (mf, ms, msig)) = data.strategy_lengths(); - bp.axis("signals.trend.fast.length", tf) - .axis("signals.trend.slow.length", ts) - .axis("signals.momentum.fast.length", [mf]) - .axis("signals.momentum.slow.length", [ms]) - .axis("signals.momentum.signal.length", [msig]) - .axis("signals.blend.weights[0]", [1.0]) - .axis("signals.blend.weights[1]", [1.0]) - .axis("bias.scale", [0.5]) - .sweep_with_lattice(|point| { - let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip); - let mut h = bp - .bootstrap_with_cells(point) - .expect("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 equity = f64_field(&rx_eq.try_iter().collect::>(), 0); - let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - RunReport { - manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, pip), - metrics: summarize(&equity, &exposure), - } - }) - .map(|(fam, lat)| (fam, Some(lat))) - .expect("the built-in named grid matches the sample param-space") -} - -/// Run the chosen params over an out-of-sample window; return the recorded -/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record). -fn run_oos( - params: &[Cell], - from: Timestamp, - to: Timestamp, - trace: Option<&str>, - data: &DataSource, - env: &project::Env, -) -> (Vec<(Timestamp, f64)>, RunReport) { - let pip = data.pip_size(); - let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(pip); - 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 manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, pip); - if let Some(name) = trace { - persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows, env); - } - let equity = f64_field(&eq_rows, 0); - let exposure = f64_field(&ex_rows, 0); - let report = RunReport { manifest, metrics: summarize(&equity, &exposure) }; - (equity, report) -} - /// Pool every OOS window's per-trade R series into one flat vector, in roll order /// (window order, then within-window trade order). Windows with no `r` block /// contribute nothing. The single home of the pooling-in-roll-order semantics — @@ -1557,59 +913,6 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { ) } -/// Render the built-in walk-forward as the per-window OOS RunReport lines plus the -/// summary line — the `run_walkforward` shape minus registry persistence. Test -/// helper (mirrors `sweep_report`). -#[cfg(test)] -fn walkforward_report() -> String { - let result = walkforward_family( - Strategy::SmaCross, None, &DataSource::Synthetic, Selection::Argmax, - &project::Env::std(), - ); - let mut out = String::new(); - for w in &result.windows { - out.push_str(&w.run.oos_report.to_json()); - out.push('\n'); - } - out.push_str(&walkforward_summary_json(&result)); - out.push('\n'); - out -} - -/// The built-in Monte-Carlo family: the sample harness over a fixed (empty) base -/// point, re-seeded across a built-in seed set — each seed a disjoint C1 -/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`, -/// varying the *seed* rather than a tuning param. The seed -> `Source` -/// construction lives inside the per-draw closure (eager-agnostic, #71). -fn mc_family(trace: Option<&str>, env: &project::Env) -> McFamily { - let base_point: Vec = Vec::new(); - monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { - let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); - let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; - let sources: Vec> = vec![Box::new(spec.source(seed))]; - let window = window_of(&sources).expect("non-empty synthetic stream"); - h.run(sources); - let eq_rows = rx_eq.try_iter().collect::>(); - let ex_rows = rx_ex.try_iter().collect::>(); - let manifest = sim_optimal_manifest( - vec![ - ("sma_fast".to_string(), Scalar::i64(2)), - ("sma_slow".to_string(), Scalar::i64(4)), - ("bias_scale".to_string(), Scalar::f64(0.5)), - ], - window, - seed, - SYNTHETIC_PIP_SIZE, - ); - if let Some(name) = trace { - persist_traces(&format!("{name}/seed{seed}"), &manifest, &eq_rows, &ex_rows, env); - } - let equity = f64_field(&eq_rows, 0); - let exposure = f64_field(&ex_rows, 0); - RunReport { manifest, metrics: summarize(&equity, &exposure) } - }) -} - /// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not /// `Serialize` (only its `MetricStats` fields are), so the line is built from the /// three per-metric stat blocks. @@ -1624,32 +927,6 @@ fn mc_aggregate_json(agg: &McAggregate) -> String { .to_string() } -/// `aura mc [--name |--trace ]`: run the built-in Monte-Carlo family, persist -/// it to the family store via `append_family` (C18/C21), print each draw's record -/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`, -/// also persist each draw's streams under `runs/traces//seed/` (opt-in). -fn run_mc(name: &str, persist: bool, env: &project::Env) { - if persist - && let Err(e) = env.trace_store().ensure_name_free(name, WriteKind::Family) - { - eprintln!("aura: {e}"); - std::process::exit(1); - } - let reg = env.registry(); - let family = mc_family(persist.then_some(name), env); - let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { - Ok(id) => id, - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(1); - } - }; - for draw in &family.draws { - println!("{}", mc_member_line(&id, draw.seed, &draw.report)); - } - println!("{}", mc_aggregate_json(&family.aggregate)); -} - /// 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 { @@ -1665,24 +942,6 @@ fn mc_r_bootstrap_json(b: &RBootstrap) -> String { .to_string() } -/// Render the built-in Monte-Carlo family as the per-draw `RunReport` lines plus -/// the aggregate line — the `run_mc` shape minus registry persistence (no -/// `family_id`, which is store-assigned). Test helper, mirroring `sweep_report` / -/// `walkforward_report`: it carries the C1-determinism test of the family -/// computation, separate from the store-dependent id. -#[cfg(test)] -fn mc_report() -> String { - let family = mc_family(None, &project::Env::std()); - let mut out = String::new(); - for draw in &family.draws { - out.push_str(&draw.report.to_json()); - out.push('\n'); - } - out.push_str(&mc_aggregate_json(&family.aggregate)); - out.push('\n'); - out -} - /// `aura runs families`: one header line per stored family (id, kind, member /// count), in first-seen store order. fn runs_families(env: &project::Env) { @@ -1912,132 +1171,6 @@ fn reproduce_family(id: &str, env: &project::Env) { } } -// --- MACD proof-of-concept (a richer, nested indicator + strategy) ----------- - -/// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line -/// (their spread) → a signal `Ema` of that line → the histogram (line − signal). -/// The composite exposes all **three MACD lines** as a named output record -/// (`macd`, `signal`, `histogram`); the strategy trades the histogram by reading -/// `from_field: 2`. A richer fixture than `sma_cross`: a nested EMA-of-EMA chain -/// with interior fan-out (the MACD line feeds *both* the signal EMA and the -/// histogram). Three `length` knobs (fast, slow, signal) are injected at compile -/// in node order; value-empty here. -fn macd(name: &str) -> Composite { - let mut g = GraphBuilder::new(name); - let fast = g.add(Ema::builder().named("fast")); // fast EMA - let slow = g.add(Ema::builder().named("slow")); // slow EMA - let line = g.add(Sub::builder()); // MACD line = fast − slow - let signal = g.add(Ema::builder().named("signal")); // signal EMA of the MACD line - let hist = g.add(Sub::builder()); // histogram = MACD line − signal - let price = g.input_role("price"); - g.feed(price, [fast.input("series"), slow.input("series")]); - g.connect(fast.output("value"), line.input("lhs")); // fast → line - g.connect(slow.output("value"), line.input("rhs")); // slow → line - g.connect(line.output("value"), signal.input("series")); // line → signal EMA - g.connect(line.output("value"), hist.input("lhs")); // line → histogram - g.connect(signal.output("value"), hist.input("rhs")); // signal → histogram - g.expose(line.output("value"), "macd"); // the MACD line - g.expose(signal.output("value"), "signal"); // the signal line - g.expose(hist.output("value"), "histogram"); // the histogram - g.build().expect("sample macd wiring resolves") -} - -/// The MACD strategy blueprint (value-empty): the `macd` histogram → `Bias` → -/// `SimBroker` → recording sinks. Channels are threaded so a run can drain the -/// sinks; `macd_blueprint` drops the receivers for the structural render. -fn macd_strategy_blueprint( - tx_eq: mpsc::Sender<(Timestamp, Vec)>, - tx_ex: mpsc::Sender<(Timestamp, Vec)>, -) -> Composite { - let mut g = GraphBuilder::new("macd_strategy"); - let macd_node = g.add(macd("macd")); - let exposure = g.add(Bias::builder().named("bias")); - let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE)); - let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)); - let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)); - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, [macd_node.input("price"), broker.input("price")]); - g.connect(macd_node.output("histogram"), exposure.input("signal")); // histogram → Bias - g.connect(exposure.output("bias"), broker.input("exposure")); // bias → broker slot 0 - g.connect(broker.output("equity"), eq.input("col[0]")); // equity → sink - g.connect(exposure.output("bias"), ex.input("col[0]")); // bias → sink - g.build().expect("macd_strategy wiring resolves") -} - -/// The MACD strategy blueprint as a param-space fixture (receivers dropped, since -/// `param_space()` reads structure only, never runs the graph). -#[cfg(test)] -fn macd_blueprint() -> Composite { - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - macd_strategy_blueprint(tx_eq, tx_ex) -} - -/// The point vector for the MACD strategy, in `param_space()` slot order: -/// `[fast EMA length, slow EMA length, signal EMA length, exposure scale]`. Short -/// windows so the 7-tick synthetic stream still produces a non-trivial trace -/// (conventional MACD is 12/26/9, meaningless on 7 points). -fn macd_point() -> Vec { - vec![Scalar::i64(2), Scalar::i64(4), Scalar::i64(3), Scalar::f64(0.5)] -} - -/// A longer synthetic stream than the SMA sample's 7 ticks: MACD's EMAs each warm -/// up over their `length`, so the stream rises, falls, then rises again to give the -/// histogram room to flip sign more than once *after* warm-up. Deterministic (C1). -fn macd_prices() -> Vec<(Timestamp, Scalar)> { - [ - 1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, - 1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092, - ] - .iter() - .enumerate() - .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p))) - .collect() -} - -/// Run the MACD strategy: compile the nested composite blueprint to a flat harness -/// (the same bootstrap path the SMA sample's compiled view uses), drive it on the -/// synthetic stream, and fold both sinks into a `RunReport`. Pure and -/// deterministic (C1). -fn run_macd(trace: Option<&str>, 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 flat = macd_strategy_blueprint(tx_eq, tx_ex) - .compile_with_params(&macd_point()) - .expect("valid macd blueprint"); - let mut h = Harness::bootstrap(flat).expect("valid macd harness"); - - let sources: Vec> = - vec![Box::new(VecSource::new(macd_prices()))]; - let window = window_of(&sources).expect("non-empty macd stream"); - 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 manifest = sim_optimal_manifest( - vec![ - ("ema_fast".to_string(), Scalar::i64(2)), - ("ema_slow".to_string(), Scalar::i64(4)), - ("ema_signal".to_string(), Scalar::i64(3)), - ("bias_scale".to_string(), Scalar::f64(0.5)), - ], - window, - 0, - SYNTHETIC_PIP_SIZE, - ); - if let Some(name) = trace { - persist_traces(name, &manifest, &eq_rows, &ex_rows, env); - } - let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - RunReport { manifest, metrics } -} - // --- r-sma harness (the SMA-cross signal scored in R) -------------------- /// The r-sma vol-stop EWMA length (cycles). Single source for the `StopRule::Vol` @@ -2901,23 +2034,6 @@ fn r_meanrev_signal(window: Option, band_k: Option) -> Composite { g.build().expect("r_meanrev signal wiring resolves") } -/// Which built-in harness `aura run` drives. A fixed compile-time enumeration over -/// Rust-authored harnesses — NOT a runtime node registry and NOT a DSL (C9/C17): the -/// CLI *runs* an authored harness, it does not wire one. -#[derive(Debug)] -enum HarnessKind { - Sma, - Macd, -} - -/// 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, -} - /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells /// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells /// bind positionally against the loaded signal's `param_space`. A malformed array is @@ -2941,22 +2057,6 @@ fn parse_scalar_csv(csv: &str) -> Option> { }).collect() } -/// Route a parsed `run` invocation to its harness handler. The compile-time `match` IS -/// the selector — the harnesses are Rust-authored built-ins, picked by name (C9/C17). -fn run_dispatch(args: RunArgs, env: &project::Env) -> Result { - let trace = args.trace.as_deref(); - Ok(match (args.harness, args.data) { - (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace, env), - (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace, env), - (HarnessKind::Sma, RunData::Real { symbol, from, to }) => { - run_sample_real(&symbol, from, to, trace, env) - } - (HarnessKind::Macd, RunData::Real { .. }) => { - return Err("the macd harness has no --real form".to_string()) - } - }) -} - // ============================== clap parser surface ============================== // The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` @@ -3142,9 +2242,6 @@ 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 (default sma). - #[arg(long)] - harness: Option, /// Blueprint params (JSON scalar-cell array; .json mode). #[arg(long)] params: Option, @@ -3169,7 +2266,8 @@ struct RunCmd { struct SweepCmd { /// A loaded blueprint (.json); omit for the built-in --strategy grammar. blueprint: Option, - /// Built-in strategy: sma | momentum | r-sma (default sma). + /// Legacy `--strategy` selector: no built-in value remains (use a blueprint). + /// Retired tokens fall to the generic usage error. #[arg(long)] strategy: Option, /// Real instrument symbol to sweep over (recorded data); omit for the synthetic stream. @@ -3199,7 +2297,7 @@ struct SweepCmd { struct WalkforwardCmd { /// A loaded blueprint (.json); omit for the built-in --strategy grammar. blueprint: Option, - /// Built-in strategy: sma | momentum | r-sma (default sma). + /// Built-in strategy: r-sma (the R-scored SMA-cross sugar over the shipped example). #[arg(long)] strategy: Option, /// Real instrument symbol to validate over (recorded data); omit for the synthetic stream. @@ -3241,7 +2339,7 @@ struct WalkforwardCmd { struct McCmd { /// A loaded blueprint (.json); omit for the built-in grammar. blueprint: Option, - /// Built-in strategy: r-sma selects the R-bootstrap path (else the synthetic seed-resweep). + /// Built-in strategy: r-sma (the R-scored SMA-cross sugar over the shipped example). #[arg(long)] strategy: Option, /// Real instrument symbol for the R-bootstrap (recorded data); requires --strategy r-sma. @@ -3312,34 +2410,6 @@ fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunD } } -/// Build the existing `RunArgs` (the type `run_dispatch` consumes) from the -/// built-in-branch `RunCmd` fields — the old `parse_run_args` body minus the argv -/// tokenizing clap now owns. A stray positional (a non-`.json`-file `[blueprint]`) -/// is an unexpected token; the harness-enum map, the cost-flags-require-R-harness -/// guard, and the non-negative-rate checks reuse the existing message strings. -fn run_args_from(a: &RunCmd) -> Result { - let usage = || "Usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ]".to_string(); - // A positional that is not an existing `.json` blueprint is an unexpected token - // (the built-in run grammar takes only flags) — the #16 strict reading. - if a.blueprint.is_some() { - return Err(usage()); - } - let harness = match a.harness.as_deref() { - None | Some("sma") => HarnessKind::Sma, - Some("macd") => HarnessKind::Macd, - Some(_) => return Err(usage()), - }; - if a.real.is_none() && (a.from.is_some() || a.to.is_some()) { - return Err(usage()); - } - let data = match a.real.as_deref() { - Some(s) if !s.is_empty() => RunData::Real { symbol: s.to_string(), from: a.from, to: a.to }, - Some(_) => return Err(usage()), - None => RunData::Synthetic, - }; - Ok(RunArgs { harness, data, trace: a.trace.clone() }) -} - /// The real walk-forward roller sizes in ms (the campaign wf stage works in ms: /// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are /// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same @@ -3495,16 +2565,6 @@ fn data_choice_from( } } -/// Map a built-in `--strategy` token to a `Strategy` (default sma), reusing the old -/// 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(_) => return Err(usage()), - }) -} - /// Resolve `--name`/`--trace` (mutually exclusive) into `(family_name, persist)`, /// defaulting the name when neither is given. fn name_persist( @@ -3545,10 +2605,6 @@ fn parse_axes( fn dispatch_run(a: RunCmd, env: &project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { - if a.harness.is_some() { - eprintln!("aura: --harness is not valid with a blueprint file"); - std::process::exit(2); - } // The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to; // the built-in-only flags are rejected here (exit 2), never silently dropped — // mirroring the sweep/mc blueprint branches, which reject their non-branch flags @@ -3595,21 +2651,11 @@ fn dispatch_run(a: RunCmd, env: &project::Env) { println!("{}", report.to_json()); } None => { - if a.params.is_some() || a.seed.is_some() { - eprintln!("aura: --params/--seed require a blueprint file"); - std::process::exit(2); - } - let run_args = run_args_from(&a).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - match run_dispatch(run_args, env) { - Ok(report) => println!("{}", report.to_json()), - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); - } - } + eprintln!( + "aura: Usage: aura run [--params ] \ + [--seed ] [--real [--from ] [--to ]]" + ); + std::process::exit(2); } } } @@ -3625,7 +2671,14 @@ fn dispatch_chart(a: ChartCmd, env: &project::Env) { fn dispatch_graph(a: GraphCmd, env: &project::Env) { match a.sub { - None => print!("{}", render::render_html(&sample_blueprint())), + None => { + let bp = blueprint_from_json( + include_str!("../examples/r_sma_open.json"), + &|t| env.resolve(t), + ) + .expect("the shipped r-sma example reloads into a renderable blueprint"); + print!("{}", render::render_html(&bp)); + } Some(GraphSub::Build) => graph_construct::build_cmd(env), Some(GraphSub::Introspect(i)) => graph_construct::introspect_cmd(i, env), Some(GraphSub::Register { file }) => graph_construct::register_cmd(&file, env), @@ -3738,9 +2791,11 @@ fn dispatch_new(a: NewCmd, _env: &project::Env) { /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. fn dispatch_sweep(a: SweepCmd, env: &project::Env) { + // Single-sourced: the blueprint grammar and the no-blueprint usage error must + // stay in lockstep, so both arms below read this one closure. + let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); match is_blueprint_file(&a.blueprint) { Some(path) => { - let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); std::process::exit(2); @@ -3880,25 +2935,8 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { } } None => { - let usage = || "Usage: aura sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ]".to_string(); - if a.blueprint.is_some() || !a.axis.is_empty() || a.list_axes { - eprintln!("aura: {}", usage()); - std::process::exit(2); - } - let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "sweep", &usage) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - run_sweep(strategy, &name, persist, DataSource::from_choice(data, env), env); + eprintln!("aura: {}", usage()); + std::process::exit(2); } } } @@ -3946,7 +2984,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { run_blueprint_walkforward(&doc, &axes, &name, DataSource::Synthetic, select, env); } None => { - let usage = || "Usage: aura walkforward [--strategy ] [--real [--from ] [--to ]] [--name | --trace ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--select ]".to_string(); + let usage = || "Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --strategy r-sma --real [--from ] [--to ] [--fast ] [--slow ] [--stop-length ] [--stop-k ]".to_string(); if a.blueprint.is_some() || !a.axis.is_empty() { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -4006,28 +3044,8 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { }); return; } - let strategy = strategy_from(a.strategy.as_deref(), &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let bad = |m: String| -> ! { - eprintln!("aura: {m}"); - std::process::exit(2) - }; - let select = match a.select.as_deref() { - Some(s) => parse_select(s).unwrap_or_else(|()| bad(usage())), - None => Selection::Argmax, - }; - let (name, persist) = name_persist(a.name.as_deref(), a.trace.as_deref(), "walkforward", &usage) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - let data = data_choice_from(a.real.as_deref(), a.from, a.to, &usage).unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(2); - }); - run_walkforward(strategy, &name, persist, DataSource::from_choice(data, env), select, env); + eprintln!("aura: {}", usage()); + std::process::exit(2); } } } @@ -4075,7 +3093,7 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic, env); } None => { - let usage = || "Usage: aura mc [--name |--trace ] | aura mc --strategy r-sma [--real [--from ] [--to ]] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ]".to_string(); + let usage = || "Usage: aura mc --seeds [--name ] | aura mc --strategy r-sma --real [--from ] [--to ] [--fast ] [--slow ] [--stop-length ] [--stop-k ] [--block-len ] [--resamples ] [--seed ]".to_string(); if a.blueprint.is_some() || a.seeds.is_some() { eprintln!("aura: {}", usage()); std::process::exit(2); @@ -4142,12 +3160,8 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { 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); + eprintln!("aura: {}", usage()); + std::process::exit(2); } } } @@ -4656,15 +3670,6 @@ mod tests { ); } - // The vetted GER40 real-data window: the whole of September 2024 (UTC, - // inclusive), the same calendar month the gated ingest `ger40_breakout_real` - // test drives. Expressed in Unix-ms (`run_sample_real`'s window currency): - // `[2024-09-01T00:00:00Z, 2024-10-01T00:00:00Z - 1ms]`. Both gated GER40 - // tests bound their runs to this window so the C1-determinism check stays - // fast — an unbounded `None, None` run drains the full archive every call. - const GER40_SEP2024_FROM_MS: i64 = 1_725_148_800_000; - const GER40_SEP2024_TO_MS: i64 = 1_727_740_799_999; - #[test] fn data_source_synthetic_pip_and_window_match_the_built_ins() { let env = project::Env::std(); @@ -4688,380 +3693,6 @@ mod tests { assert_eq!(WF_REAL_STEP_NS, 30 * day_ns); } - #[test] - fn strategy_lengths_are_short_for_synthetic_realistic_for_real() { - // Synthetic keeps the demo-stream lengths (byte-unchanged: the 18/60-bar - // built-in streams cannot warm a long MA). - assert_eq!(DataSource::Synthetic.strategy_lengths(), ([2, 3], [4, 5], (2, 4, 3))); - // Real uses realistic M1 lengths — no 2-5-bar noise over tens of thousands - // of bars. Constructing Real needs a server, but strategy_lengths matches on - // the variant only (no data access). - let real = DataSource::Real { - server: std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)), - symbol: "EURUSD".into(), - from_ms: None, - to_ms: None, - pip: 0.0001, - }; - let (tf, ts, macd) = real.strategy_lengths(); - assert_eq!((tf, ts, macd), ([50, 100], [200, 400], (12, 26, 9))); - // every trend-fast < every trend-slow (a valid SMA cross, both variants). - assert!(tf.iter().max().unwrap() < ts.iter().min().unwrap()); - let (stf, sts, _) = DataSource::Synthetic.strategy_lengths(); - assert!(stf.iter().max().unwrap() < sts.iter().min().unwrap()); - } - - #[test] - fn walkforward_report_is_deterministic() { - // The built-in WFO render is byte-identical across two - // calls (C1). - assert_eq!(walkforward_report(), walkforward_report()); - } - - #[test] - fn walkforward_report_has_one_oos_line_per_window_plus_summary() { - // N per-window OOS RunReport lines + one summary line. - let out = walkforward_report(); - let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines.len(), 4); // built-in roll = 3 windows + 1 summary - assert!(lines[3].contains(r#""walkforward""#), "summary line: {}", lines[3]); - for line in &lines[..3] { - assert!( - line.contains(r#""manifest""#) && line.contains(r#""metrics""#), - "expected an OOS RunReport line, got: {line}", - ); - } - } - - /// `walkforward_summary_json_from_reports` reduces a hand-built two-window - /// report set to the same `{"windows", "stitched_total_pips", - /// "param_stability", "oos_r"}` shape as the live-`WalkForwardResult` path: - /// pips sum in roll order, each r-sma axis reduces across windows, and the - /// pooled `trade_rs` yields the R-metric block. - #[test] - fn walkforward_summary_from_reports_reconstructs_the_summary() { - // Two windows, chosen params (3,12,14,2.0) then (5,20,14,2.0). - let mk = |fast: i64, slow: i64, pips: f64, rs: Vec| -> RunReport { - let mut rep = RunReport { - manifest: RunManifest { - commit: "t".into(), - params: vec![ - ("fast.length".into(), Scalar::I64(fast)), - ("slow.length".into(), Scalar::I64(slow)), - ("stop_length".into(), Scalar::I64(14)), - ("stop_k".into(), Scalar::F64(2.0)), - ("bias_scale".into(), Scalar::F64(0.5)), - ], - window: (Timestamp(0), Timestamp(0)), - seed: 0, - broker: "t".into(), - selection: None, - instrument: None, - topology_hash: None, - project: None, - }, - metrics: summarize(&[], &[]), - }; - rep.metrics.total_pips = pips; - // `r_metrics_from_rs` deliberately clears `trade_rs` (it is the terminal - // pooled reducer, not a per-window record); a real OOS window's report - // gets `trade_rs` from `summarize_r` over the raw trade record instead, - // so restore it here to model that shape. - rep.metrics.r = - Some(aura_engine::RMetrics { trade_rs: rs.clone(), ..r_metrics_from_rs(&rs) }); - rep - }; - let reports = vec![mk(3, 12, 10.0, vec![1.0, -0.5]), mk(5, 20, -4.0, vec![0.25])]; - let line = walkforward_summary_json_from_reports(&reports); - let v: serde_json::Value = serde_json::from_str(&line).unwrap(); - let wf = &v["walkforward"]; - assert_eq!(wf["windows"].as_u64(), Some(2)); - assert_eq!(wf["stitched_total_pips"].as_f64(), Some(6.0)); // 10.0 + -4.0 - let ps = wf["param_stability"].as_array().unwrap(); - assert_eq!(ps.len(), 4, "four r-sma slots"); - assert_eq!(ps[0]["mean"].as_f64(), Some(4.0)); // fast: (3+5)/2 - assert_eq!(ps[1]["mean"].as_f64(), Some(16.0)); // slow: (12+20)/2 - assert!(wf["oos_r"].is_object(), "pooled oos_r present when any window has r"); - assert_eq!(wf["oos_r"]["n_trades"].as_u64(), Some(3)); // 2 + 1 - } - - /// The drained sink trace of a seeded run — the recorded rows of the equity - /// and exposure sinks. Compared row-for-row so the C1 seed-determinism - /// property is tested at the trace level (strictly stronger than the folded - /// 3-field metrics). `PartialEq` not `Eq`: `Scalar` carries `f64`. - #[derive(Debug, PartialEq)] - struct SeededTrace { - equity: Vec<(Timestamp, Vec)>, - exposure: Vec<(Timestamp, Vec)>, - } - - /// A seeded run of the sample harness: the synthetic stream is generated - /// from `seed`, that same seed is recorded into the manifest, and the - /// drained sink trace is returned alongside the report. Every byte of both - /// is a function of `seed`. - fn run_sample_seeded(seed: u64) -> (RunReport, SeededTrace) { - let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); - let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 }; - let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1)); - h.run(vec![Box::new(spec.source(seed))]); - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - let report = RunReport { - manifest: sim_optimal_manifest( - vec![ - ("sma_fast".to_string(), Scalar::i64(2)), - ("sma_slow".to_string(), Scalar::i64(4)), - ("bias_scale".to_string(), Scalar::f64(0.5)), - ], - window, - seed, - SYNTHETIC_PIP_SIZE, - ), - metrics, - }; - (report, SeededTrace { equity: eq_rows, exposure: ex_rows }) - } - - #[test] - fn same_seed_bit_identical_trace() { - // Bit-identical sink trace for a fixed seed (acceptance bullet 1, C1). - let (_, trace_a) = run_sample_seeded(42); - let (_, trace_b) = run_sample_seeded(42); - assert_eq!(trace_a, trace_b); - } - - #[test] - fn different_seed_different_trace() { - // Different seeds perturb the trace (acceptance bullet 2). - let (a, _) = run_sample_seeded(1); - let (b, _) = run_sample_seeded(2); - assert_ne!(a.metrics, b.metrics); - } - - #[test] - fn seed_recorded_in_manifest() { - // The seed that drove the run is recorded (acceptance bullet 3). - let (report, _) = run_sample_seeded(7); - assert_eq!(report.manifest.seed, 7); - } - - #[test] - fn sample_blueprint_with_sinks_bootstraps_runs_and_drains() { - // the factory returns the two Recorder receivers (build_sample drops them), - // so a caller can bootstrap one point, run it, and drain both sinks. - let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(SYNTHETIC_PIP_SIZE); - let mut h = bp - .with("signals.trend.fast.length", 2) - .with("signals.trend.slow.length", 4) - .with("signals.momentum.fast.length", 2) - .with("signals.momentum.slow.length", 4) - .with("signals.momentum.signal.length", 3) - .with("signals.blend.weights[0]", 1.0) - .with("signals.blend.weights[1]", 1.0) - .with("bias.scale", 0.5) - .bootstrap() - .expect("sample blueprint compiles under a valid point"); - h.run(vec![Box::new(VecSource::new(showcase_prices()))]); - assert!(!rx_eq.try_iter().collect::>().is_empty(), "equity sink drained empty"); - assert!(!rx_ex.try_iter().collect::>().is_empty(), "exposure sink drained empty"); - } - - #[test] - fn sweep_report_renders_four_points_in_odometer_order() { - let out = sweep_report(); - let lines: Vec<&str> = out.lines().collect(); - assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}"); - // each line is a full RunReport; the commit is the real git HEAD - // (volatile), so pin the per-point manifest params (odometer order, last - // axis fastest) + the metric keys, not the commit value. - for line in &lines { - assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}"); - } - assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line0: {}", lines[0]); - assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line1: {}", lines[1]); - assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line2: {}", lines[2]); - assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["bias.scale",{"F64":0.5}]]"#), "line3: {}", lines[3]); - for line in &lines { - assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}"); - assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}"); - assert!(line.contains(r#""bias_sign_flips":"#), "missing flips: {line}"); - assert!(line.ends_with('}'), "line not closed: {line}"); - } - } - - #[test] - fn sweep_report_is_deterministic() { - // C1 at the CLI edge: the same build yields a bit-identical report. - assert_eq!(sweep_report(), sweep_report()); - } - - #[test] - fn mc_report_is_deterministic_and_one_line_per_seed() { - // C1 at the CLI edge: the family computation renders bit-identically. - assert_eq!(mc_report(), mc_report()); - let out = mc_report(); - let lines: Vec<&str> = out.lines().collect(); - // three seeds -> three member lines + one aggregate line - assert_eq!(lines.len(), 4, "expected 3 members + 1 aggregate: {out}"); - for line in &lines[..3] { - assert!(line.contains(r#""total_pips":"#), "member line missing metrics: {line}"); - } - assert!(lines[3].contains(r#""mc_aggregate":"#), "missing aggregate line: {}", lines[3]); - } - - #[test] - fn cli_families_persist_and_round_trip_per_kind() { - use aura_registry::{ - group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, - FamilyKind, Registry, - }; - let env = project::Env::std(); - let dir = std::env::temp_dir().join(format!("aura-cli-fam-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("temp dir"); - let reg = Registry::open(dir.join("runs.jsonl")); - - // the exact persist chain `run_sweep`/`run_mc`/`run_walkforward` use, against - // a fresh temp store (the run_* fns themselves bind `env.registry()`): - // engine family -> per-kind extractor -> append_family. - let sid = reg - .append_family( - "sweep", - FamilyKind::Sweep, - &sweep_member_reports(&sweep_family(None, &DataSource::Synthetic, &env)), - ) - .expect("sweep family"); - let mid = reg - .append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None, &env))) - .expect("mc family"); - let wid = reg - .append_family( - "walkforward", - FamilyKind::WalkForward, - &walkforward_member_reports(&walkforward_family( - Strategy::SmaCross, None, &DataSource::Synthetic, Selection::Argmax, - &env, - )), - ) - .expect("walkforward family"); - assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0")); - - let families = group_families(reg.load_family_members().expect("load")); - assert_eq!(families.len(), 3); - let by_id = |id: &str| families.iter().find(|f| f.id == id).expect("family present"); - assert_eq!(by_id("sweep-0").kind, FamilyKind::Sweep); - assert_eq!(by_id("mc-0").kind, FamilyKind::MonteCarlo); - assert_eq!(by_id("mc-0").members.len(), 3); // 3 seeds - assert_eq!(by_id("walkforward-0").kind, FamilyKind::WalkForward); - assert_eq!(by_id("walkforward-0").members.len(), 3); // 3 windows - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn run_macd_compiles_from_nested_composite_and_is_deterministic() { - // the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a - // flat runnable harness (the call not panicking proves the compile+bootstrap - // path), and runs it. C1 determinism: two runs are bit-identical. - let env = project::Env::std(); - let r1 = run_macd(None, &env); - let r2 = run_macd(None, &env); - assert_eq!(r1.metrics, r2.metrics); - assert_eq!(r1.to_json(), r2.to_json()); - - // the synthetic stream is carried end-to-end and the trace is well-formed. - let (from, to) = r1.manifest.window; - assert_eq!((from.0, to.0), (1, 18)); - assert!(r1.metrics.total_pips.is_finite(), "macd pips must be finite: {:?}", r1.metrics); - assert!(r1.metrics.max_drawdown >= 0.0, "drawdown is non-negative: {:?}", r1.metrics); - // after warm-up the EMA-of-EMA histogram crosses zero, so the strategy - // reverses exposure at least once — a genuinely non-trivial trace. - assert!( - r1.metrics.bias_sign_flips >= 1, - "macd trace should flip exposure: {:?}", - r1.metrics - ); - } - - /// E2E acceptance (#41, the worked example): the real MACD strategy - /// blueprint's swept param surface qualifies the three otherwise-indistinguishable - /// EMA `length` slots by node name to `macd.fast.length` / `macd.slow.length` / - /// `macd.signal.length` — the named composite boundary visible end-to-end through - /// `param_space()`, with the slot count and order unchanged (C23 — node names are - /// non-load-bearing: every interior slot stays sweepable, the `bias.scale` - /// knob is unaffected). - #[test] - fn macd_param_space_surfaces_the_three_named_legs() { - let names: Vec = - macd_blueprint().param_space().into_iter().map(|p| p.name).collect(); - // three named composite-interior slots, in declared (fast, slow, signal) - // order, then the strategy-level Bias `scale` (a root-level leaf). - assert_eq!( - names, - vec![ - "macd.fast.length".to_string(), - "macd.slow.length".to_string(), - "macd.signal.length".to_string(), - "bias.scale".to_string(), - ], - "MACD param surface must expose the three named EMA lengths + scale", - ); - } - - /// `aura run --real ` dogfoods the #71 streaming Source seam: the same - /// built-in sample signal-quality harness, but fed real M1 **close** bars - /// streamed lazily through `aura_ingest::M1FieldSource` (a `Box`), - /// not synthetic `VecSource` prices. The property: over the verified bounded - /// Sept-2024 GER40 window, `run_sample_real` yields a `RunReport` whose - /// `total_pips` is finite and is C1-deterministic — two runs of the same - /// window are bit-identical JSON. Bounding the window (vs the full unbounded - /// archive) keeps the determinism check fast. - /// - /// Gated like the ingest `streaming_seam` test: skip (early return) when the - /// local Pepperstone archive is absent, so the test never fails on a machine - /// without the data. Uses `GER40` — a *vetted* symbol (pip 1.0): the - /// per-instrument-pip refusal makes `run_sample_real` reject an un-specced - /// symbol before any data access (`std::process::exit(1)`), so this CLI-level - /// test must drive a symbol in the instrument table. The bounded-window AAPL.US - /// streaming property still lives in the ingest `streaming_seam` test, which - /// builds its source literally without the spec lookup. - #[test] - fn run_sample_real_streams_real_close_bars_deterministically() { - // GER40 is in the vetted instrument table (index pip 1.0); the un-specced - // AAPL.US would now refuse at the spec lookup before any data access. - const SYMBOL: &str = "GER40"; - - // Mirror skip_if_no_data: never fail where the local archive is absent. - let server = std::sync::Arc::new(data_server::DataServer::new( - data_server::DEFAULT_DATA_PATH, - )); - if !server.has_symbol(SYMBOL) { - eprintln!( - "skip: no local data at {} (symbol {SYMBOL} absent)", - data_server::DEFAULT_DATA_PATH - ); - return; - } - - // The headline: a real-data run over the bounded Sept-2024 window yields - // a finite, C1-deterministic RunReport. - let env = project::Env::std(); - let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); - let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); - - assert!( - r1.metrics.total_pips.is_finite(), - "real-data run must yield finite pips: {:?}", - r1.metrics - ); - // C1 at the CLI edge: the same real window streamed twice is bit-identical. - assert_eq!( - r1.to_json(), - r2.to_json(), - "two real-data runs of the same window must be bit-identical (C1)" - ); - } - #[test] fn sim_optimal_manifest_renders_per_instrument_pip() { let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0); @@ -5070,184 +3701,23 @@ mod tests { assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)"); } - #[test] - fn run_real_ger40_uses_index_pip() { - // Gated: needs local GER40 data. Mirrors the existing real-path test's skip. - let server = data_server::DataServer::new(data_server::DEFAULT_DATA_PATH); - if !server.has_symbol("GER40") { - eprintln!("skip: no local GER40 data at {}", data_server::DEFAULT_DATA_PATH); - return; - } - // Bounded to the vetted Sept-2024 window so the pip-label + determinism - // checks run fast (not the full unbounded archive). - let env = project::Env::std(); - let report = - run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); - // The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001. - assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)"); - // Deterministic (C1): a second run yields the same report. - let again = - run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None, &env); - assert_eq!(report.manifest.broker, again.manifest.broker); - assert_eq!(report.metrics.total_pips, again.metrics.total_pips); - } - - #[test] - fn run_sample_is_deterministic_and_non_trivial() { - let env = project::Env::std(); - let r1 = run_sample(None, &env); - let r2 = run_sample(None, &env); - // C1 determinism: two runs are bit-identical (metrics + rendered JSON). - assert_eq!(r1.metrics, r2.metrics); - assert_eq!(r1.to_json(), r2.to_json()); - - let m = &r1.metrics; - // exactly one bias sign flip in the demo trace (rises then reverses). - assert_eq!(m.bias_sign_flips, 1); - // a non-trivial, populated trace: a real drawdown. - assert!(m.max_drawdown > 0.0); - // hand-computed magnitudes for the chosen stream (float tolerance; the - // computation's dust is ~1e-15). - assert!( - (m.max_drawdown - 0.17).abs() < 1e-9, - "max_drawdown = {}", - m.max_drawdown - ); - assert!( - (m.total_pips - (-0.13)).abs() < 1e-9, - "total_pips = {}", - m.total_pips - ); - - // manifest carries the sample's known configuration. - let (from, to) = r1.manifest.window; - assert_eq!((from.0, to.0), (1, 7)); - // commit is the build's git identity (or the no-git "unknown" fallback); - // either way it is non-empty and fixed at compile time, so it is stable - // across runs of the same build (C1 determinism, already asserted above - // via `to_json()`). - assert!(!r1.manifest.commit.is_empty()); - assert_eq!(r1.manifest.commit, r2.manifest.commit); - } - - fn pair(name: &str, v: Scalar) -> (String, Scalar) { - (name.to_string(), v) - } - - #[test] - fn member_key_renders_varying_axes_portably() { - let named = vec![ - pair("ema.length", Scalar::i64(5)), - pair("bias.scale", Scalar::f64(0.5)), - pair("longonly.enabled", Scalar::bool(true)), - ]; - let varying: std::collections::HashSet = - named.iter().map(|(n, _)| n.clone()).collect(); - let key = member_key(&named, &varying); - assert_eq!(key, "ema.length-5_bias.scale-0.5_longonly.enabled-true"); - assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))); - } - - #[test] - fn member_key_omits_pinned_axes() { - let named = vec![ - pair("ema.length", Scalar::i64(5)), - pair("bias.scale", Scalar::f64(0.5)), - pair("longonly.enabled", Scalar::bool(false)), - ]; - let mut varying = std::collections::HashSet::new(); - varying.insert("longonly.enabled".to_string()); - assert_eq!(member_key(&named, &varying), "longonly.enabled-false"); - } - - #[test] - fn member_key_handles_negative_float_and_sanitises_names() { - let named = vec![pair("bias.scale", Scalar::f64(-0.5))]; - let varying: std::collections::HashSet = - ["bias.scale".to_string()].into_iter().collect(); - assert_eq!(member_key(&named, &varying), "bias.scale--0.5"); - - let named2 = vec![pair("weird key!", Scalar::f64(1.0))]; - let varying2: std::collections::HashSet = - ["weird key!".to_string()].into_iter().collect(); - assert_eq!(member_key(&named2, &varying2), "weird_key_-1"); - } - - #[test] - fn member_key_is_m_when_no_axis_varies() { - let named = vec![pair("bias.scale", Scalar::f64(0.5))]; - let varying = std::collections::HashSet::new(); - assert_eq!(member_key(&named, &varying), "m"); - } - - #[test] - fn member_key_caps_length_with_conformant_hash_fallback() { - let named: Vec<(String, Scalar)> = (0..40) - .map(|i| pair(&format!("some.long.axis.path.number.{i}"), Scalar::i64(i))) - .collect(); - let varying: std::collections::HashSet = - named.iter().map(|(n, _)| n.clone()).collect(); - let key = member_key(&named, &varying); - assert!(key.len() <= MAX_KEY, "over-cap key not bounded: {} bytes", key.len()); - assert!(key.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))); - let mut named2 = named.clone(); - named2[0].1 = Scalar::i64(999); - assert_ne!(key, member_key(&named2, &varying), "distinct over-cap inputs must differ"); - } - - #[test] - fn member_key_collision_free_over_a_non_trend_axis_set() { - // #105 regression: vary bias.scale + a bool (NOT the old hardcoded - // trend.fast/slow). Distinct points -> distinct keys; the old f…s… key - // would have collapsed them all to one dir. - let varying: std::collections::HashSet = - ["bias.scale".to_string(), "longonly.enabled".to_string()].into_iter().collect(); - let p = |s: f64, b: bool| { - vec![ - pair("ema.length", Scalar::i64(5)), // pinned -> omitted from key - pair("bias.scale", Scalar::f64(s)), - pair("longonly.enabled", Scalar::bool(b)), - ] - }; - let keys: Vec = [(0.5, true), (0.5, false), (1.0, true), (1.0, false)] - .iter() - .map(|&(s, b)| member_key(&p(s, b), &varying)) - .collect(); - let unique: std::collections::HashSet<&String> = keys.iter().collect(); - assert_eq!(unique.len(), 4, "distinct points must yield distinct keys: {keys:?}"); - } - - #[test] - fn momentum_param_space_is_ema_exposure_longonly() { - // pins the default node-name path segments (ema / exposure / longonly) and - // the param order/kinds the member key + sweep depend on. - let names: Vec = - momentum_blueprint_with_sinks(SYNTHETIC_PIP_SIZE).0.param_space().into_iter().map(|p| p.name).collect(); - assert_eq!( - names, - vec![ - "ema.length".to_string(), - "bias.scale".to_string(), - "longonly.enabled".to_string(), - ], - ); - } - - #[test] - fn momentum_sweep_is_deterministic_and_has_eight_points() { - let env = project::Env::std(); - let a = momentum_sweep_family(None, &DataSource::Synthetic, &env); - let b = momentum_sweep_family(None, &DataSource::Synthetic, &env); - assert_eq!(a.points.len(), 8, "2x2x2 grid = 8 points"); - assert_eq!(a, b, "C1: the momentum family is a pure function of the build"); - } + // Note (#159 cut 4 collateral): this also removes the `member_key_*` unit + // tests, the `pair()` fixture helper they shared, and + // `momentum_param_space_is_ema_exposure_longonly` / + // `momentum_sweep_is_deterministic_and_has_eight_points` — every one of them + // exercised `member_key`/`momentum_blueprint_with_sinks`/`momentum_sweep_family`, + // whose only production caller was the retired PIP built-in `run_sweep` + // machinery (Task 1 of this iter deleted `member_key`/`MAX_KEY`/`fnv1a64` + // outright: their sole callers were `sweep_family`/`momentum_sweep_family`, + // both gone). The member-key-distinctness assertion in the survivor test + // below is dropped for the same reason (`member_key` no longer exists); the + // doc comment is trimmed to match. /// Property: a `blueprint_sweep_family` member built from a serialized signal is /// the SAME trading result as the cycle-1 single run of that signal at the same /// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path /// (the keystone). Every member of one family carries the SAME `topology_hash` (the - /// loaded signal's, the deviation from the Rust-built mirror), and distinct grid - /// points key to distinct `member_key`s (members are distinguishable). + /// loaded signal's, the deviation from the Rust-built mirror). #[test] fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() { // An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the @@ -5284,13 +3754,6 @@ mod tests { let member4 = &family.points[0].report; // slow=4 is the first odometer point assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run"); assert_eq!(member4.manifest.topology_hash, single.manifest.topology_hash); - - // (c) the two members' keys differ (member_key over the varying slow.length axis). - let varying: HashSet = - ["sma_signal.slow.length".to_string()].into_iter().collect(); - let k4 = member_key(&family.points[0].report.manifest.params, &varying); - let k6 = member_key(&family.points[1].report.manifest.params, &varying); - assert_ne!(k4, k6, "distinct grid points key distinctly"); } #[test] diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index 14e4f3c..dce2ac6 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -225,7 +225,6 @@ pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String { #[cfg(test)] mod tests { use super::*; - use crate::sample_blueprint; /// `aura graph`'s page is self-contained (no remote fetch), embeds the /// deterministic model as the viewer's data source, carries the Graphviz-WASM @@ -234,7 +233,13 @@ mod tests { /// against the prototype. #[test] fn render_html_is_self_contained_and_embeds_the_model() { - let html = render_html(&sample_blueprint()); + let env = crate::project::Env::std(); + let bp = aura_engine::blueprint_from_json( + include_str!("../examples/r_sma_open.json"), + &|t| env.resolve(t), + ) + .expect("the shipped r-sma example reloads into a renderable blueprint"); + let html = render_html(&bp); // a complete HTML document assert!(html.starts_with(""), "not an HTML doc"); assert!(html.trim_end().ends_with(""), "HTML doc not closed"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index ba07fe8..3259796 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -19,7 +19,9 @@ fn temp_cwd(name: &str) -> std::path::PathBuf { #[test] fn run_prints_json_and_exits_zero() { - let out = Command::new(BIN).arg("run").output().expect("spawn aura run"); + // `run` is blueprint-only now (#159 cut 4 retired the bare built-in default); + // the shipped r-sma example is the deterministic reference blueprint. + let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); @@ -32,11 +34,11 @@ fn run_prints_json_and_exits_zero() { // identity (asserted by `run_manifest_commit_carries_real_git_head`), so // this shape check pins only the surrounding structure, not the value. assert!(line.starts_with("{\"manifest\":{\"commit\":\""), "got: {line}"); - assert!(line.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "got: {line}"); - assert!(line.contains("\"window\":[1,7]"), "got: {line}"); + assert!(line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=0.0001)\""), "got: {line}"); + assert!(line.contains("\"window\":[1,18]"), "got: {line}"); assert!(line.contains("\"metrics\":{\"total_pips\":"), "got: {line}"); - // the integer sign-flip count is stable across float renderings. - assert!(line.ends_with("\"bias_sign_flips\":1}}"), "got: {line}"); + // the r-sma harness carries an R block (unlike the retired pip-only default). + assert!(line.contains("\"r\":{\"expectancy_r\":"), "got: {line}"); } /// Property: the RunManifest is self-identifying — its `commit` carries the @@ -63,7 +65,8 @@ fn run_manifest_commit_carries_real_git_head() { assert!(!head_sha.is_empty(), "empty HEAD sha"); // Drive the binary and pull `manifest.commit` out of the single JSON line. - let out = Command::new(BIN).arg("run").output().expect("spawn aura run"); + // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example stands in. + let out = Command::new(BIN).args(["run", "examples/r_sma.json"]).output().expect("spawn aura run"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); let line = stdout.trim_end(); @@ -91,9 +94,11 @@ fn run_manifest_commit_carries_real_git_head() { /// error path (usage on stderr, exit 2), never a silent full run. This is the /// strict reading ratified in #16: keying only on the first token being `run` /// and ignoring the rest would let a typo masquerade as a successful run. -/// The same test pins positive-preservation — bare `aura run` still emits the -/// JSON report on stdout and exits 0 — so the strict guard cannot regress the -/// happy path. +/// The same test pins positive-preservation — a valid `aura run ` +/// still emits the JSON report on stdout and exits 0 — so the strict guard cannot +/// regress the happy path. (`run` is blueprint-only now, #159 cut 4: there is no +/// more bare/built-in default to preserve, so the positive case uses the shipped +/// r-sma example.) #[test] fn run_with_trailing_token_is_strict_and_exits_two() { // Negative: an unexpected trailing token is rejected like a bad-args call. @@ -115,18 +120,21 @@ fn run_with_trailing_token_is_strict_and_exits_two() { let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); assert!(stderr.contains("Usage"), "stderr was: {stderr:?}"); - // Positive-preservation: bare `aura run` still runs and prints the report. - let ok = Command::new(BIN).arg("run").output().expect("spawn aura run"); + // Positive-preservation: a valid blueprint invocation still runs and prints the report. + let ok = Command::new(BIN) + .args(["run", "examples/r_sma.json"]) + .output() + .expect("spawn aura run examples/r_sma.json"); assert_eq!( ok.status.code(), Some(0), - "bare `aura run` must still succeed: {:?}", + "a valid `aura run ` must still succeed: {:?}", ok.status ); let ok_stdout = String::from_utf8(ok.stdout).expect("utf-8 stdout"); assert!( ok_stdout.trim_start().starts_with("{\"manifest\":"), - "bare `aura run` should print the JSON report, got: {ok_stdout:?}" + "a valid run should print the JSON report, got: {ok_stdout:?}" ); } @@ -134,9 +142,10 @@ fn run_with_trailing_token_is_strict_and_exits_two() { fn run_real_no_geometry_symbol_refuses_with_exit_1() { // The geometry-sidecar lookup precedes any bar-data access, so a symbol with no // recorded geometry refuses with NO local data required (CI-safe). "NONEXISTENT" - // has no recorded geometry on any host. + // has no recorded geometry on any host. `run` is blueprint-only now (#159 cut 4); + // the shipped r-sma example reaches the same pip-refusal gate in `run_data_from`. let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "NONEXISTENT"]) + .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1"); @@ -160,7 +169,7 @@ fn run_real_no_geometry_symbol_refuses_with_exit_1() { #[test] fn run_real_refusal_names_no_authored_floor() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "NONEXISTENT"]) + .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1"); @@ -189,7 +198,7 @@ fn run_real_refusal_names_no_authored_floor() { #[test] fn run_real_no_geometry_symbol_emits_no_stdout_report() { let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "NONEXISTENT"]) + .args(["run", "examples/r_sma.json", "--real", "NONEXISTENT"]) .output() .expect("spawn aura"); assert_eq!(out.status.code(), Some(1), "expected exit 1: {:?}", out.status); @@ -211,8 +220,15 @@ fn run_real_no_geometry_symbol_emits_no_stdout_report() { /// EURUSD data is on the machine). #[test] fn run_real_sidecar_symbol_never_hits_the_pip_refusal() { + // A bounded window (the same EURUSD 2024-06 calendar month the gated sweep tests + // use) keeps this fast; the full archive is unbounded and not needed for a + // pip-lookup property. `run` is blueprint-only now (#159 cut 4). let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "EURUSD"]) + .args([ + "run", "examples/r_sma.json", "--real", "EURUSD", + "--from", &EURUSD_JUN2024_FROM_MS.to_string(), + "--to", &EURUSD_JUN2024_TO_MS.to_string(), + ]) .output() .expect("spawn aura"); let stderr = String::from_utf8_lossy(&out.stderr); @@ -253,8 +269,10 @@ fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() { // month the gated ingest path drives; bounding it keeps the run fast. const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; + // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still + // routes through the same real-data pip lookup + manifest stamping. let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) + .args(["run", "examples/r_sma.json", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS]) .output() .expect("spawn aura"); @@ -275,7 +293,7 @@ fn run_real_sidecar_index_pip_reaches_the_emitted_manifest() { let line = stdout.trim_end(); // the looked-up index pip (1.0 -> "1") reaches the manifest, not the FX 0.0001. assert!( - line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), + line.contains("\"broker\":\"sim-optimal+risk-executor(pip_size=1)\""), "GER40 must render the index pip in the manifest, got: {line}" ); } @@ -292,8 +310,10 @@ fn run_real_nonvetted_symbol_resolves_pip_from_sidecar() { // FX trades continuously; the GER40 Sept-2024 window also has USDJPY bars. const FROM_MS: &str = "1725148800000"; const TO_MS: &str = "1727740799999"; + // `run` is blueprint-only now (#159 cut 4); the shipped r-sma example still + // routes through the same real-data pip lookup. let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "USDJPY", "--from", FROM_MS, "--to", TO_MS]) + .args(["run", "examples/r_sma.json", "--real", "USDJPY", "--from", FROM_MS, "--to", TO_MS]) .output() .expect("spawn aura"); @@ -391,256 +411,13 @@ fn run_blueprint_rejects_builtin_only_flags_exit_two() { ); } -#[test] -fn run_trace_persists_taps_and_plain_run_writes_no_traces() { - let cwd = temp_cwd("run-trace"); - - // plain `aura run` (no --trace) persists nothing to disk. - let plain = Command::new(BIN).arg("run").current_dir(&cwd).output().expect("spawn aura run"); - assert!(plain.status.success(), "plain run exit: {:?}", plain.status); - assert!(!cwd.join("runs/traces").exists(), "plain run must write no trace files"); - - // `aura run --trace demo` persists the two taps + index, and still prints the report. - let traced = Command::new(BIN) - .args(["run", "--trace", "demo"]) - .current_dir(&cwd) - .output() - .expect("spawn aura run --trace"); - assert!(traced.status.success(), "traced run exit: {:?}", traced.status); - let stdout = String::from_utf8(traced.stdout).expect("utf-8 stdout"); - assert!(stdout.trim_end().starts_with("{\"manifest\":{"), "report still printed: {stdout:?}"); - assert!(cwd.join("runs/traces/demo/index.json").exists(), "index.json written"); - assert!(cwd.join("runs/traces/demo/equity.json").exists(), "equity tap written"); - assert!(cwd.join("runs/traces/demo/exposure.json").exists(), "exposure tap written"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: a persisted `.json` is the **columnar SoA form** a downstream -/// chart can consume, not an opaque blob — it carries the four -/// `tap`/`kinds`/`ts`/`columns` keys, the equity tap is kind-tagged `["F64"]` -/// (C7: the base type survives on disk), and the SoA invariant holds: `ts` has one -/// entry per recorded cycle and EVERY column has exactly that many entries (parallel -/// arrays, one timestamp per row). This is acceptance criterion 2 at the built-binary -/// file boundary — the unit round-trip pins the in-memory transpose, but only an -/// end-to-end run pins that the bytes actually written to disk hold that shape. -/// Asserts on structure + array-length parity, never on the exact float values -/// (pip equity's low bits vary build-to-build), so it stays deterministic. -#[test] -fn run_trace_persists_columnar_soa_shape_on_disk() { - let cwd = temp_cwd("run-trace-shape"); - let out = Command::new(BIN) - .args(["run", "--trace", "demo"]) - .current_dir(&cwd) - .output() - .expect("spawn aura run --trace"); - assert!(out.status.success(), "traced run exit: {:?}", out.status); - - let equity = std::fs::read_to_string(cwd.join("runs/traces/demo/equity.json")) - .expect("read equity.json"); - // The four SoA keys are present, equity is f64-kind-tagged, and the parallel - // arrays open as expected (the synthetic sample harness has a single column). - assert!(equity.contains("\"tap\":\"equity\""), "tap name missing: {equity}"); - assert!(equity.contains("\"kinds\":[\"F64\"]"), "F64 kind tag missing: {equity}"); - assert!(equity.contains("\"ts\":["), "ts axis missing: {equity}"); - assert!(equity.contains("\"columns\":[["), "columns array missing: {equity}"); - - // SoA invariant: one timestamp per row, and every column has exactly that many - // entries. Count the ts entries (the synthetic window is 7 cycles) and the - // single column's entries by their comma-separated cardinality. - let ts_body = json_array_body(&equity, "\"ts\":["); - let col_body = json_array_body(&equity, "\"columns\":[["); - let ts_len = ts_body.split(',').count(); - let col_len = col_body.split(',').count(); - assert_eq!(ts_len, 7, "synthetic run records 7 cycles; ts was: {ts_body:?}"); - assert_eq!( - col_len, ts_len, - "SoA parity broken: ts has {ts_len} entries but the column has {col_len}; file: {equity}" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: `index.json` is the run's **table of contents** — it carries the run's -/// `RunManifest` (so a chart page can show run context: commit, broker, window) and -/// the tap names in a **deterministic order** (`["equity","exposure"]`). The serve -/// half (`aura chart`, iteration 2) reads taps in this index order; a regression that -/// dropped the manifest, or made the tap list order-unstable, would silently corrupt -/// the chart header / the per-series mapping. Asserts on the manifest's structural -/// presence (the commit value is the volatile git HEAD, pinned elsewhere) and the -/// exact deterministic tap order. -#[test] -fn run_trace_index_carries_manifest_and_ordered_tap_list() { - let cwd = temp_cwd("run-trace-index"); - let out = Command::new(BIN) - .args(["run", "--trace", "demo"]) - .current_dir(&cwd) - .output() - .expect("spawn aura run --trace"); - assert!(out.status.success(), "traced run exit: {:?}", out.status); - - let index = std::fs::read_to_string(cwd.join("runs/traces/demo/index.json")) - .expect("read index.json"); - // The manifest is embedded with its identifying context for the chart header. - assert!(index.contains("\"manifest\":{\"commit\":\""), "manifest missing: {index}"); - assert!(index.contains("\"window\":[1,7]"), "window missing from manifest: {index}"); - assert!( - index.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), - "broker label missing: {index}" - ); - // The tap list is the deterministic read order the serve path depends on. - assert!( - index.contains("\"taps\":[\"equity\",\"exposure\"]"), - "tap order must be deterministic [equity, exposure]: {index}" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: `--trace` is an **orthogonal modifier** — it composes with `aura run -/// --real `, not only plain `run`, and is strictly **additive**: the looked-up -/// instrument pip still reaches stdout byte-for-byte while the same two taps land on -/// disk. The spec's headline is "one shared persist helper, every run form"; the -/// existing E2E only exercised the synthetic `run` path. Gated on local GER40 data -/// (skip cleanly when absent, the project convention) so it never fails on a -/// data-less machine; the no-data path is the distinct "no local data" refusal. -#[test] -fn run_real_with_trace_persists_taps_and_keeps_stdout_unchanged() { - const FROM_MS: &str = "1725148800000"; - const TO_MS: &str = "1727740799999"; - let cwd = temp_cwd("run-real-trace"); - let out = Command::new(BIN) - .args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS, "--trace", "ger"]) - .current_dir(&cwd) - .output() - .expect("spawn aura run --real --trace"); - - // Skip on a data-less machine: GER40 with no local data takes the - // distinct no-data path (exit 1, runtime), never the pip-refusal, and writes no traces. - if out.status.code() == Some(1) { - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("no local data for symbol 'GER40'"), - "exit 1 for GER40 --trace must be the no-data path, got: {stderr}" - ); - eprintln!("skip: no local GER40 data"); - let _ = std::fs::remove_dir_all(&cwd); - return; - } - - assert_eq!(out.status.code(), Some(0), "real --trace exit: {:?}", out.status); - // Additive: the index-pip report still reaches stdout unchanged. - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let line = stdout.trim_end(); - assert!(line.starts_with("{\"manifest\":{"), "report still printed: {line}"); - assert!( - line.contains("\"broker\":\"sim-optimal(pip_size=1)\""), - "real --trace must keep the GER40 index pip on stdout: {line}" - ); - // And the same two taps landed under the named run dir. - assert!(cwd.join("runs/traces/ger/index.json").exists(), "index.json written"); - assert!(cwd.join("runs/traces/ger/equity.json").exists(), "equity tap written"); - assert!(cwd.join("runs/traces/ger/exposure.json").exists(), "exposure tap written"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Extract the body of a JSON array opened by `marker` (e.g. `"ts":[`) up to its -/// closing `]`, by substring — no serde dependency, mirroring the sibling tests' -/// substring parsing. Used only to count comma-separated entries for the SoA -/// length-parity check. -fn json_array_body<'a>(json: &'a str, marker: &str) -> &'a str { - let start = json.find(marker).expect("array marker present") + marker.len(); - let rest = &json[start..]; - let end = rest.find(']').expect("array is closed"); - &rest[..end] -} - -#[test] -fn chart_emits_self_contained_html_for_a_persisted_run() { - let cwd = temp_cwd("chart-serve"); - - // persist a run first (iteration 1), then chart it. - let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); - - let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart"); - assert!(chart.status.success(), "chart exit: {:?}", chart.status); - let html = String::from_utf8(chart.stdout).expect("utf-8 html"); - assert!(html.starts_with(""), "not an HTML doc: {:?}", &html[..html.len().min(40)]); - assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); - assert!(html.contains("\"equity\""), "equity series missing from the chart"); - assert!(html.contains("uPlot=function"), "vendored uPlot not inlined"); - - // a missing run is a runtime failure (stderr + exit 1), not a panic. - let missing = Command::new(BIN).args(["chart", "nope"]).current_dir(&cwd).output().expect("spawn chart nope"); - assert_eq!(missing.status.code(), Some(1), "missing run must exit 1"); - assert!(!missing.status.success()); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the `--panels` flag is an honest CLI dispatch axis — `chart ` -/// serves the overlay page and `chart --panels` serves the panels page over -/// the SAME persisted run. The mode the viewer reads (`AURA_TRACES.mode`) reflects -/// which arm dispatched: a `--panels`→Overlay miswiring would leave every shape and -/// render test green while silently ignoring the flag, so this pins it at the -/// binary's observable stdout. -#[test] -fn chart_panels_flag_selects_panels_mode() { - let cwd = temp_cwd("chart-panels"); - - let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); - - // default (no flag) -> overlay. - let overlay = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart"); - assert!(overlay.status.success(), "chart exit: {:?}", overlay.status); - let overlay_html = String::from_utf8(overlay.stdout).expect("utf-8 html"); - assert!(overlay_html.contains("\"mode\":\"overlay\""), "default chart must serve overlay mode"); - assert!(!overlay_html.contains("\"mode\":\"panels\""), "default chart must not serve panels mode"); - - // --panels -> panels, over the very same persisted run. - let panels = Command::new(BIN).args(["chart", "demo", "--panels"]).current_dir(&cwd).output().expect("spawn chart --panels"); - assert!(panels.status.success(), "chart --panels exit: {:?}", panels.status); - let panels_html = String::from_utf8(panels.stdout).expect("utf-8 html"); - assert!(panels_html.contains("\"mode\":\"panels\""), "--panels must serve panels mode"); - assert!(!panels_html.contains("\"mode\":\"overlay\""), "--panels must not fall back to overlay"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: `--tap ` on a single-run chart is a CLEAN refusal at the -/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested, -/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach -/// `eprintln!` + `exit(1)`, never a panic and never a chart of zero series. A -/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would -/// exit 0 with an empty page; this fails it. Archive-local (persists its own run). -#[test] -fn chart_tap_nonexistent_on_run_refuses_with_exit_1() { - let cwd = temp_cwd("chart-tap-missing"); - - // persist a single run; its taps are `equity` / `exposure`, never `nope`. - let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); - - let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap"); - assert_eq!(out.status.code(), Some(1), "nonexistent tap must exit 1: {:?}", out.status); - assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout)); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("run has no tap named 'nope'"), - "expected the no-such-tap refusal message, got: {stderr}" - ); - // #131: the refusal lists the valid taps so the user can correct the typo - // (the demo run's taps are `equity` and `exposure`). - assert!( - stderr.contains("available:") && stderr.contains("equity") && stderr.contains("exposure"), - "the refusal should enumerate the available taps, got: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} +// Note (#159 cut 4 collateral): `--trace` on a single `aura run`/`aura sweep` +// is no longer reachable from any CLI surface — the built-in default that +// accepted it retired with the last two PIP strategies, and the blueprint +// branches (`dispatch_run`, `run_blueprint_sweep`) never wire a trace-persist +// path. Repointing would require new production code (out of scope for a +// test-fixing task), so the single-run trace-persist + shape tests are +// deleted here rather than adjusted. /// Property: an unknown `chart ` is a runtime failure pinned at BOTH the exit code /// and the user-facing message — exit 1 (never a panic) AND the NotFound branch's @@ -752,27 +529,6 @@ fn graph_emits_self_contained_html_viewer() { assert!(!stdout.contains("Sub(#Sf,#Ss)"), "retired ascii-dag notation must be gone: {stdout}"); } -#[test] -fn sweep_prints_four_family_json_lines_and_exits_zero() { - let cwd = temp_cwd("sweep4"); - let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep"); - assert!(out.status.success(), "exit status: {:?}", out.status); - - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}"); - let first = stdout.lines().next().unwrap(); - // each line is now a family member: the assigned `family_id` plus the nested - // RunReport (C18/C21 lineage). The first run assigns `sweep-0`; the nested - // report is serialized via `serde_json`, so the manifest's fields lead with - // `broker` (the commit value is the volatile git HEAD, pinned by params below). - assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}"); - assert!( - first.contains("\"params\":[[\"signals.trend.fast.length\",{\"I64\":2}],[\"signals.trend.slow.length\",{\"I64\":4}],[\"signals.momentum.fast.length\",{\"I64\":2}],[\"signals.momentum.slow.length\",{\"I64\":4}],[\"signals.momentum.signal.length\",{\"I64\":3}],[\"signals.blend.weights[0]\",{\"F64\":1.0}],[\"signals.blend.weights[1]\",{\"F64\":1.0}],[\"bias.scale\",{\"F64\":0.5}]]"), - "got: {stdout}" - ); - let _ = std::fs::remove_dir_all(&cwd); -} - #[test] fn sweep_with_trailing_token_is_strict_and_exits_two() { // strict-arg reading (#16): an unexpected trailing token is a usage error. @@ -780,47 +536,25 @@ fn sweep_with_trailing_token_is_strict_and_exits_two() { assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); } -/// Property: `aura mc` runs the built-in Monte-Carlo family end-to-end through the -/// binary — it prints one member line per seed (carrying the assigned `family_id`) -/// plus one `mc_aggregate` line, exits 0, and persists the draws as a discoverable -/// `MonteCarlo` family (C12 axis 4 / C18/C21). Driven through the built binary so -/// it pins the observable CLI contract, not just the in-crate render helper. -#[test] -fn mc_runs_persists_a_monte_carlo_family_and_lists_it() { - let cwd = temp_cwd("mc-family"); - let out = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc"); - assert!(out.status.success(), "mc exit: {:?}", out.status); - - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let lines: Vec<&str> = stdout.lines().collect(); - // three built-in seeds -> three member lines + one aggregate line. - assert_eq!(lines.len(), 4, "mc stdout: {stdout:?}"); - for line in &lines[..3] { - assert!(line.contains("\"family_id\":\"mc-0\""), "member missing family_id: {line}"); - assert!(line.contains("\"seed\":"), "member missing seed: {line}"); - } - assert!(lines[3].contains("\"mc_aggregate\":"), "missing aggregate line: {}", lines[3]); - - // the run persisted the draws as a discoverable MonteCarlo family. - let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); - assert!(fams.status.success(), "families exit: {:?}", fams.status); - let fams_out = String::from_utf8(fams.stdout).expect("utf-8"); - assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}"); - assert!(fams_out.contains("\"family_id\":\"mc-0\""), "families: {fams_out:?}"); - assert!(fams_out.contains("\"kind\":\"MonteCarlo\""), "families: {fams_out:?}"); - assert!(fams_out.contains("\"members\":3"), "families: {fams_out:?}"); - - let _ = std::fs::remove_dir_all(&cwd); -} - #[test] fn runs_families_list_and_per_family_rank_across_invocations() { let cwd = temp_cwd("runs-flow"); - // two sweeps accumulate two families (sweep-0, sweep-1) over time, each a - // related set of 4 records (C18/C21 lineage), in the sibling family store. + // two blueprint sweeps accumulate two families (sweep-0, sweep-1) over time, + // each a related set of 4 records (C18/C21 lineage), in the sibling family + // store. `sweep` is blueprint-only now (#159 cut 4); the shipped open r-sma + // example over a 2x2 axis grid stands in for the retired built-in default. + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); for _ in 0..2 { - let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep"); - assert!(out.status.success(), "sweep exit: {:?}", out.status); + let out = Command::new(BIN) + .args([ + "sweep", &fixture, + "--axis", "sma_signal.fast.length=2,4", + "--axis", "sma_signal.slow.length=8,16", + ]) + .current_dir(&cwd) + .output() + .expect("spawn sweep"); + assert!(out.status.success(), "sweep exit: {:?}; stderr: {}", out.status, String::from_utf8_lossy(&out.stderr)); } // `runs families` shows both stored families in first-seen order, each with @@ -896,334 +630,11 @@ fn runs_bare_subcommand_is_strict_and_exits_two() { assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); } -/// Property: `aura mc --trace ` persists one standalone, round-tripping -/// run-dir per draw seed (the built-in MC family draws seeds 1,2,3), and a plain -/// `aura mc` (no --trace) writes no trace tree (opt-in; byte-unchanged path). -#[test] -fn mc_trace_persists_a_member_dir_per_seed() { - let cwd = temp_cwd("mc-trace"); - - // opt-in OFF: plain `aura mc` persists no per-member trace dirs. - let plain = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc"); - assert!(plain.status.success(), "plain mc exit: {:?}", plain.status); - assert!(!cwd.join("runs/traces").exists(), "plain mc must write no trace files"); - - // opt-in ON: one standalone run-dir per draw seed. - let traced = Command::new(BIN) - .args(["mc", "--trace", "mc1"]) - .current_dir(&cwd) - .output() - .expect("spawn aura mc --trace"); - assert!(traced.status.success(), "traced mc exit: {:?}", traced.status); - for seed in [1, 2, 3] { - let dir = cwd.join(format!("runs/traces/mc1/seed{seed}")); - assert!(dir.join("index.json").exists(), "seed{seed} index.json missing"); - assert!(dir.join("equity.json").exists(), "seed{seed} equity tap missing"); - assert!(dir.join("exposure.json").exists(), "seed{seed} exposure tap missing"); - // the persisted equity tap is columnar SoA (C7): kind tag + ts/column parity. - let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json"); - assert!(equity.contains("\"kinds\":[\"F64\"]"), "seed{seed} F64 tag: {equity}"); - let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count(); - let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count(); - assert_eq!(col_len, ts_len, "seed{seed} SoA parity: ts={ts_len} col={col_len}; {equity}"); - } - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: `aura sweep --trace ` persists one standalone, round-tripping -/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4 -/// points, content-keyed by the varying axes (signals.trend.fast.length / -/// signals.trend.slow.length); a plain `aura sweep` writes nothing. -#[test] -fn sweep_trace_persists_a_member_dir_per_grid_point() { - let cwd = temp_cwd("sweep-trace"); - - let plain = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep"); - assert!(plain.status.success(), "plain sweep exit: {:?}", plain.status); - assert!(!cwd.join("runs/traces").exists(), "plain sweep must write no trace files"); - - let traced = Command::new(BIN) - .args(["sweep", "--trace", "swp"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --trace"); - assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status); - for key in [ - "signals.trend.fast.length-2_signals.trend.slow.length-4", - "signals.trend.fast.length-2_signals.trend.slow.length-5", - "signals.trend.fast.length-3_signals.trend.slow.length-4", - "signals.trend.fast.length-3_signals.trend.slow.length-5", - ] { - let dir = cwd.join(format!("runs/traces/swp/{key}")); - assert!(dir.join("index.json").exists(), "{key} index.json missing"); - assert!(dir.join("equity.json").exists(), "{key} equity tap missing"); - assert!(dir.join("exposure.json").exists(), "{key} exposure tap missing"); - let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json"); - assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}"); - let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count(); - let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count(); - assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}"); - } - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: `aura walkforward --trace ` persists one standalone, -/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each -/// keyed `oos`; a plain `aura walkforward` writes nothing. -#[test] -fn walkforward_trace_persists_a_member_dir_per_oos_window() { - let cwd = temp_cwd("wf-trace"); - - let plain = Command::new(BIN).arg("walkforward").current_dir(&cwd).output().expect("spawn aura walkforward"); - assert!(plain.status.success(), "plain walkforward exit: {:?}", plain.status); - assert!(!cwd.join("runs/traces").exists(), "plain walkforward must write no trace files"); - - let traced = Command::new(BIN) - .args(["walkforward", "--trace", "wf1"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward --trace"); - assert!(traced.status.success(), "traced walkforward exit: {:?}", traced.status); - - let base = cwd.join("runs/traces/wf1"); - let mut members: Vec = std::fs::read_dir(&base) - .expect("read wf1 trace dir") - .map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned()) - .collect(); - members.sort(); - assert_eq!(members.len(), 3, "built-in roll = 3 OOS windows -> 3 member dirs; got {members:?}"); - for key in &members { - assert!(key.starts_with("oos"), "member key must be oos: {key}"); - let dir = base.join(key); - assert!(dir.join("index.json").exists(), "{key} index.json missing"); - let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json"); - assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}"); - let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count(); - let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count(); - assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}"); - } - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (C1 / Fork-C content-derived keys): `aura sweep --trace` is -/// reproducible — re-running it produces the SAME member-dir set and -/// BYTE-IDENTICAL tap files. The whole reason member keys are derived from the -/// grid point's content (not a runtime ordinal counter) is that members run in -/// parallel across threads (C1: parallelism *across* sims); a thread-race in the -/// keying or the persisted bytes would silently regress determinism. A runtime -/// counter would shuffle which member lands in which content-keyed dir run-to-run; this -/// test fails loudly if that drift is ever reintroduced. -#[test] -fn sweep_trace_is_byte_deterministic_across_runs() { - let collect = |tag: &str| -> std::collections::BTreeMap { - let cwd = temp_cwd(tag); - let out = Command::new(BIN) - .args(["sweep", "--trace", "det"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --trace"); - assert!(out.status.success(), "{tag} sweep exit: {:?}", out.status); - - // Map each member dir's tap files to their bytes (member_key/file -> content). - let base = cwd.join("runs/traces/det"); - let mut taps = std::collections::BTreeMap::new(); - for member in std::fs::read_dir(&base).expect("read det trace dir") { - let member = member.expect("dir entry").file_name().to_string_lossy().into_owned(); - for file in ["index.json", "equity.json", "exposure.json"] { - let body = std::fs::read_to_string(base.join(&member).join(file)) - .unwrap_or_else(|e| panic!("{tag} read {member}/{file}: {e}")); - taps.insert(format!("{member}/{file}"), body); - } - } - let _ = std::fs::remove_dir_all(&cwd); - taps - }; - - let first = collect("sweep-det-1"); - let second = collect("sweep-det-2"); - - // Same member-dir set, run-to-run (content-keyed, not order-keyed). - let first_keys: Vec<&String> = first.keys().collect(); - let second_keys: Vec<&String> = second.keys().collect(); - assert_eq!(first_keys, second_keys, "member-dir set drifted across runs"); - - // Every tap byte-identical across the two runs (the index.json git-commit - // field is build-identity, constant within one build, so it compares equal - // here too — this pins run-to-run determinism, C1). - assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)"); -} - -/// Property (#105 generic key, the bool-param proof): `aura sweep --strategy -/// momentum --trace ` sweeps the momentum strategy — a totally different -/// param surface incl. a bool — and persists one portable member dir per grid -/// point. Every member-dir name is filesystem-conformant (`[A-Za-z0-9._-]`), the -/// bool axis shows up (both `longonly.enabled-true` and `-false`), and one member -/// is chartable. -#[test] -fn momentum_sweep_trace_persists_portable_member_dirs_with_the_bool() { - let cwd = temp_cwd("momentum-trace"); - - let traced = Command::new(BIN) - .args(["sweep", "--strategy", "momentum", "--trace", "mom"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy momentum --trace"); - assert!( - traced.status.success(), - "momentum sweep exit: {:?}; stderr: {}", - traced.status, - String::from_utf8_lossy(&traced.stderr) - ); - - let base = cwd.join("runs/traces/mom"); - let members: Vec = std::fs::read_dir(&base) - .expect("read mom trace dir") - .map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned()) - .collect(); - assert_eq!(members.len(), 8, "2x2x2 momentum grid = 8 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!(members.iter().any(|m| m.contains("longonly.enabled-true")), "missing bool=true: {members:?}"); - assert!(members.iter().any(|m| m.contains("longonly.enabled-false")), "missing bool=false: {members:?}"); - - // one member is chartable (the bool sits inside the dir name passed through). - let one = &members[0]; - let chart = Command::new(BIN) - .args(["chart", &format!("mom/{one}")]) - .current_dir(&cwd) - .output() - .expect("spawn aura chart mom/"); - assert!(chart.status.success(), "chart exit: {:?}", chart.status); - assert!( - String::from_utf8_lossy(&chart.stdout).contains("uPlot"), - "expected a self-contained uPlot page" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#105, the `--strategy` selector at the stdout boundary): `aura sweep -/// --strategy momentum` SWITCHES the swept surface — it prints exactly the -/// 8-point momentum family (vs the default SMA arm's 4 points), and every line is -/// a `RunReport` over the momentum param surface, carrying the bool axis as a -/// TYPED `{"Bool":...}` param (both `true` and `false` appear across the grid). -/// The disk-trace test pins the member-dir names; this pins the binary's actual -/// stdout. A selector miswiring (`momentum`→`SmaCross`) would leave that test -/// green — the dirs would still differ — while silently printing SMA reports -/// here; nothing else catches it. Asserts on structure + the typed bool token, -/// never the volatile commit value or the metric floats, so it stays -/// deterministic. Run in a fresh temp cwd so the family-store ordinal is stable. -#[test] -fn sweep_strategy_momentum_prints_eight_typed_bool_member_lines() { - let cwd = temp_cwd("sweep-strategy-momentum"); - let out = Command::new(BIN) - .args(["sweep", "--strategy", "momentum"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy momentum"); - assert!( - out.status.success(), - "momentum sweep exit: {:?}; stderr: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - ); - - let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - let lines: Vec<&str> = stdout.lines().collect(); - // 2x2x2 momentum grid = 8 member lines (vs the default SMA arm's 4): the - // selector switched the swept surface, observable at stdout. - assert_eq!(lines.len(), 8, "momentum sweep must print 8 family lines: {stdout:?}"); - - // Every line is a family member: assigned id + a nested RunReport over the - // MOMENTUM param surface (the SMA arm carries `signals.trend.*`, never - // `ema.length`/`longonly.enabled`). - for line in &lines { - assert!(line.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {line}"); - assert!(line.contains("[\"ema.length\","), "momentum surface missing ema.length: {line}"); - assert!(!line.contains("signals.trend.fast.length"), "must not be the SMA surface: {line}"); - } - - // The bool axis is carried as a TYPED `{"Bool":...}` param, both values - // present across the 8-point grid — the bool-param proof at the stdout edge. - assert!( - stdout.contains("[\"longonly.enabled\",{\"Bool\":true}]"), - "missing typed Bool=true param: {stdout}" - ); - assert!( - stdout.contains("[\"longonly.enabled\",{\"Bool\":false}]"), - "missing typed Bool=false param: {stdout}" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#105, the bool actually gates — observable on disk): the -/// `longonly.enabled` param is genuinely WIRED into the momentum harness, not a -/// no-op knob. For one grid point's two bool members (same `ema.length` + -/// `bias.scale`, differing only in the bool), the `enabled=true` member -/// records a long-only exposure stream — every recorded value `>= 0` — while the -/// otherwise-identical `enabled=false` member records at least one NEGATIVE -/// exposure over the same deterministic showcase stream. This pins the gate's -/// effect on the persisted C7 exposure tap end-to-end: the LongOnly unit test -/// proves `max(x,0)` in isolation, the dir-name test proves the bool reaches the -/// key, but only this proves the bool changes the OBSERVED run output. A -/// regression that dropped the gate from the wiring (passing exposure straight to -/// the broker) would make the two members' exposure taps identical — this fails -/// loudly. Asserts on the sign (>=0 / <0), never exact floats, so it is -/// deterministic across builds. -#[test] -fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() { - let cwd = temp_cwd("momentum-longonly-gate"); - let traced = Command::new(BIN) - .args(["sweep", "--strategy", "momentum", "--trace", "gate"]) - .current_dir(&cwd) - .output() - .expect("spawn aura sweep --strategy momentum --trace"); - assert!( - traced.status.success(), - "momentum --trace exit: {:?}; stderr: {}", - traced.status, - String::from_utf8_lossy(&traced.stderr) - ); - - // One grid point, its two bool members (identical but for the bool). - let base = cwd.join("runs/traces/gate"); - let enabled = base.join("ema.length-5_bias.scale-1_longonly.enabled-true"); - let disabled = base.join("ema.length-5_bias.scale-1_longonly.enabled-false"); - - let exposures = |dir: &std::path::Path| -> Vec { - let body = std::fs::read_to_string(dir.join("exposure.json")).expect("read exposure.json"); - // the single f64 column's comma-separated values (substring parse, no serde). - json_array_body(&body, "\"columns\":[[") - .split(',') - .map(|t| t.trim().parse::().expect("exposure value is an f64")) - .collect() - }; - - let enabled_vals = exposures(&enabled); - let disabled_vals = exposures(&disabled); - - // enabled=true is a long-only gate: NO recorded exposure is negative. - assert!( - enabled_vals.iter().all(|&x| x >= 0.0), - "long-only (enabled=true) must clamp short exposure to >= 0; got: {enabled_vals:?}" - ); - // enabled=false passes through: at least one negative survives over the same - // showcase stream — proof the bool genuinely changes the run, not a no-op. - assert!( - disabled_vals.iter().any(|&x| x < 0.0), - "pass-through (enabled=false) must keep a short exposure over this stream; got: {disabled_vals:?}" - ); - - let _ = std::fs::remove_dir_all(&cwd); -} +// Note: the family-trace-persistence tests deleted above (mc/sweep/walkforward +// per-member trace dirs, the momentum bool-param dir/gate proofs, the sweep +// determinism pin) are the Step 3 PIP-surface deletions — same underlying +// cause as the collateral note above (no blueprint-mode arm persists +// per-member traces). // --- Real-data family path (#106, plan 0060 Task 5) ------------------------- // @@ -1237,126 +648,31 @@ fn momentum_longonly_true_clamps_exposure_nonnegative_false_keeps_negatives() { const EURUSD_JUN2024_FROM_MS: i64 = 1_717_200_000_000; const EURUSD_JUN2024_TO_MS: i64 = 1_719_791_999_999; -// EURUSD 2024 full year (UTC inclusive ms): 2024-01-01 00:00:00.000 .. -// 2024-12-31 23:59:59.999 — a span wide enough for several 90/30/30-day -// walk-forward windows. -const EURUSD_2024_FROM_MS: i64 = 1_704_067_200_000; -const EURUSD_2024_TO_MS: i64 = 1_735_689_599_999; - fn local_data_present() -> bool { std::path::Path::new(data_server::DEFAULT_DATA_PATH).is_dir() } -/// Property: `aura sweep --real EURUSD` runs the whole built-in grid over REAL M1 -/// close bars (not the synthetic VecSource), persisting one filesystem-portable -/// member dir per grid point — the four-point SMA grid lands as four -/// `[A-Za-z0-9._-]` dirs under `runs/traces//`, and one of them charts as a -/// self-contained uPlot page. This pins the real source actually flowing through -/// the family builders (Tasks 2-4) at the built-binary boundary, gated on local -/// data so it never fails on a data-less machine. -#[test] -fn sweep_real_persists_portable_member_dirs_over_real_bars() { - if !local_data_present() { - eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); - return; - } - let dir = temp_cwd("sweep_real"); - let out = std::process::Command::new(BIN) - .args(["sweep", "--real", "EURUSD", - "--from", &EURUSD_JUN2024_FROM_MS.to_string(), - "--to", &EURUSD_JUN2024_TO_MS.to_string(), - "--trace", "swpr"]) - .current_dir(&dir).output().unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/swpr")).unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); - assert_eq!(members.len(), 4, "four sweep members: {members:?}"); - assert!(members.iter().all(|k| k.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))), - "all keys filesystem-portable: {members:?}"); - // one member charts as a uPlot page - let key = &members[0]; - let chart = std::process::Command::new(BIN) - .args(["chart", &format!("swpr/{key}")]).current_dir(&dir).output().unwrap(); - assert!(chart.status.success(), "stderr: {}", String::from_utf8_lossy(&chart.stderr)); - assert!(String::from_utf8_lossy(&chart.stdout).contains("uPlot")); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Property: `aura walkforward --real EURUSD` over a full year of real bars rolls -/// the real-data window roller (90/30/30-day, ns-stamped) into SEVERAL OOS -/// windows, persisting one `oos`-keyed member dir per window. Pins that the -/// windowed real path draws its span from the probed `--from..--to` (not the -/// synthetic showcase span) and that the roller produces a multi-window family -/// over real data, gated on the local archive. -#[test] -fn walkforward_real_persists_one_oos_member_per_window() { - if !local_data_present() { - eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); - return; - } - let dir = temp_cwd("wf_real"); - // a full year so 90/30/30-day rolling fits several windows - let out = std::process::Command::new(BIN) - .args(["walkforward", "--real", "EURUSD", - "--from", &EURUSD_2024_FROM_MS.to_string(), - "--to", &EURUSD_2024_TO_MS.to_string(), - "--trace", "wfr"]) - .current_dir(&dir).output().unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - let members: Vec<_> = std::fs::read_dir(dir.join("runs/traces/wfr")).unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()).collect(); - assert!(members.iter().all(|k| k.starts_with("oos")), "oos member dirs: {members:?}"); - assert!(members.len() >= 2, "several OOS windows over a year: {members:?}"); - - let _ = std::fs::remove_dir_all(&dir); -} - /// Property: the per-instrument pip refusal fires for `aura sweep --real /// ` exactly as it does for `aura run --real` — a symbol with no /// recorded geometry (`NONEXISTENT`, no sidecar on any host) is rejected with exit 1 /// and the "no recorded geometry" message BEFORE any bar-data access. NOT gated: the /// refusal precedes the archive entirely, so it is CI-safe and runs on every machine. +/// (#159 cut 4: `sweep` is blueprint-only now, so the fixture supplies a valid +/// blueprint + axis to clear the usage grammar before the geometry refusal fires.) #[test] fn sweep_real_no_geometry_symbol_refuses_with_exit_1() { // not gated: the pip refusal happens before any data access. let dir = temp_cwd("sweep_real_refuse"); + let fixture = format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(BIN) - .args(["sweep", "--real", "NONEXISTENT"]).current_dir(&dir).output().unwrap(); + .args(["sweep", &fixture, "--axis", "sma_signal.fast.length=2,4", "--real", "NONEXISTENT"]) + .current_dir(&dir).output().unwrap(); assert_eq!(out.status.code(), Some(1)); assert!(String::from_utf8_lossy(&out.stderr).contains("no recorded geometry")); let _ = std::fs::remove_dir_all(&dir); } -/// Property (C1): the same real sweep run twice is byte-identical — re-running -/// `aura sweep --real EURUSD` over the same bounded window produces an identical -/// report body. The whole determinism contract rests on a real backtest being -/// reproducible; a thread-race in the real source ingestion or member fold would -/// surface as a drift here. Gated on the local archive. -#[test] -fn sweep_real_is_byte_deterministic_across_runs() { - if !local_data_present() { - eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH); - return; - } - let run = || { - // a fresh wiped cwd per call -> an empty registry each run, so the - // `family_id` ordinal is `sweep-0` for both; the raw stdout bodies are - // directly comparable with no ordinal normalisation needed. - let dir = temp_cwd("sweep_real_det"); - let o = std::process::Command::new(BIN) - .args(["sweep", "--real", "EURUSD", - "--from", &EURUSD_JUN2024_FROM_MS.to_string(), - "--to", &EURUSD_JUN2024_TO_MS.to_string()]) - .current_dir(&dir).output().unwrap(); - let body = String::from_utf8_lossy(&o.stdout).into_owned(); - let _ = std::fs::remove_dir_all(&dir); - body - }; - assert_eq!(run(), run(), "same real sweep twice must be byte-identical (C1)"); -} - // GER40 Sept-2024 window (inclusive Unix-ms) — the same gated calendar month // used by `run_real_sidecar_index_pip_reaches_the_emitted_manifest` and the // campaign-path e2e in `research_docs.rs`. @@ -1809,7 +1125,9 @@ fn builtin_branch_usage_lines_lead_with_the_house_style_prefix() { /// for a single `"Usage"` occurrence — a regression that fixed only the first /// alternative (leaving `… | mc --strategy r-sma …` unprefixed) would still /// pass it. `--seeds` with no blueprint file is the built-in branch's own -/// grammar violation, reaching this exact literal. +/// grammar violation, reaching this exact literal. (#159 cut 4: the first +/// alternative's literal is now the blueprint-mode grammar, not a bare +/// `[--name` — `mc` is blueprint-first.) #[test] fn mc_builtin_usage_names_the_program_in_both_alternatives() { let dir = temp_cwd("mc_usage_both_alts"); @@ -1821,7 +1139,7 @@ fn mc_builtin_usage_names_the_program_in_both_alternatives() { assert_eq!(out.status.code(), Some(2), "mc --seeds without a blueprint must exit 2; status: {:?}", out.status); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("Usage: aura mc [--name"), + stderr.contains("Usage: aura mc "), "first alternative must name the program: {stderr:?}" ); assert!( @@ -1855,223 +1173,21 @@ fn mc_over_a_blueprint_usage_names_the_program() { let _ = std::fs::remove_dir_all(&dir); } -/// Property: `aura chart ` overlays one series per family member, labelled -/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members; -/// each member key appears as a series name in the injected AURA_TRACES. -#[test] -fn chart_of_a_family_overlays_one_series_per_member() { - let cwd = temp_cwd("chart-family"); - let swept = Command::new(BIN) - .args(["sweep", "--trace", "fam"]) - .current_dir(&cwd) - .output() - .expect("spawn sweep --trace"); - assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status); - - let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam"); - assert!(chart.status.success(), "chart family exit: {:?}", chart.status); - let html = String::from_utf8(chart.stdout).expect("utf-8 html"); - assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); - for key in [ - "signals.trend.fast.length-2_signals.trend.slow.length-4", - "signals.trend.fast.length-2_signals.trend.slow.length-5", - "signals.trend.fast.length-3_signals.trend.slow.length-4", - "signals.trend.fast.length-3_signals.trend.slow.length-5", - ] { - assert!(html.contains(key), "member series '{key}' missing from the family chart"); - } - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps -/// (equity + exposure); `--tap equity` filters to one. A regression net for the -/// "single-run byte-unchanged when no --tap" acceptance bullet. -#[test] -fn chart_single_run_tap_filter_keeps_only_the_named_tap() { - let cwd = temp_cwd("chart-tap"); - let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); - - // no --tap: both taps present (unchanged behaviour). - let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart"); - assert!(all.status.success()); - let all_html = String::from_utf8(all.stdout).expect("utf-8"); - assert!(all_html.contains("\"equity\""), "equity series missing"); - assert!(all_html.contains("\"exposure\""), "exposure series missing"); - - // --tap equity: only equity remains. - let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap"); - assert!(one.status.success(), "chart --tap exit: {:?}", one.status); - let one_html = String::from_utf8(one.stdout).expect("utf-8"); - assert!(one_html.contains("\"equity\""), "equity series missing under --tap"); - assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: a trace name cannot be reused across kinds — the write-guard refuses -/// it (exit 2), keeping `aura chart ` resolution a total function. -#[test] -fn trace_name_collision_across_kinds_is_refused() { - let cwd = temp_cwd("collision"); - - // run then sweep on the same name -> the sweep is refused. - let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(run.status.success(), "run --trace exit: {:?}", run.status); - let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace"); - assert_eq!(sweep.status.code(), Some(1), "sweep onto a run name must exit 1"); - - // reverse: sweep then run on a fresh name -> the run is refused. - let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u"); - assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status); - let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u"); - assert_eq!(run2.status.code(), Some(1), "run onto a family name must exit 1"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the write-guard refuses ONLY cross-kind name reuse — a SAME-kind -/// overwrite stays Ok. Re-running `aura sweep --trace ` onto a name already -/// held by a family succeeds (exit 0), so the guard does not over-broadly forbid -/// all reuse. Without this the collision test alone would pass even if the guard -/// degenerated into "refuse every existing name", silently breaking re-tracing. -#[test] -fn same_kind_overwrite_is_allowed_by_the_write_guard() { - let cwd = temp_cwd("same-kind-overwrite"); - - let first = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam"); - assert!(first.status.success(), "first sweep --trace exit: {:?}", first.status); - - // Same name, same kind (family -> family): the guard must NOT refuse it. - let again = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam again"); - assert_eq!(again.status.code(), Some(0), "same-kind family overwrite must stay Ok, got: {:?}; stderr: {}", again.status, String::from_utf8_lossy(&again.stderr)); - - // And the re-traced family is still chartable (name resolution unbroken). - let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam"); - assert!(chart.status.success(), "chart after overwrite exit: {:?}", chart.status); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: the family overlay honours a non-default `--tap` — `chart -/// --tap exposure` overlays the exposure tap of every member, not the default -/// equity. Pins the family branch's `tap.unwrap_or("equity")` selection: a -/// regression that ignored `--tap` on families (always equity) would still pass -/// the default-equity family test, so the explicit non-default tap is its own net. -#[test] -fn chart_family_with_tap_overlays_the_named_tap_per_member() { - let cwd = temp_cwd("chart-family-tap"); - - let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace"); - assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status); - - let chart = Command::new(BIN).args(["chart", "fam", "--tap", "exposure"]).current_dir(&cwd).output().expect("spawn chart fam --tap exposure"); - assert!(chart.status.success(), "chart family --tap exposure exit: {:?}", chart.status); - let html = String::from_utf8(chart.stdout).expect("utf-8 html"); - assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); - // One series per member, keyed by member key — the exposure overlay still - // spans all four grid points (the tap selection changes the column, not the - // member set). - for key in [ - "signals.trend.fast.length-2_signals.trend.slow.length-4", - "signals.trend.fast.length-2_signals.trend.slow.length-5", - "signals.trend.fast.length-3_signals.trend.slow.length-4", - "signals.trend.fast.length-3_signals.trend.slow.length-5", - ] { - assert!(html.contains(key), "member series '{key}' missing from the --tap exposure family chart"); - } - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#102, single-run run-context reaches the served page): `aura chart -/// ` injects the run's `RunManifest` context into `window.AURA_TRACES.meta` -/// at the binary boundary — `kind:"run"`, the chart name, the manifest broker and -/// data window, the charted taps, and `members:null` (a single run carries no -/// member count). The render-layer unit (`render_chart_html_injects_run_context`) -/// proves the struct serializes, but only an end-to-end run pins that the -/// *built-and-read-back* manifest threads through `build_chart_data` into the -/// served HTML; a regression dropping the `meta` build (charting bare series with a -/// `ChartMeta::default()`) would leave every render/unit test green while serving a -/// header-less page. Asserts on the stable manifest fields (the synthetic window is -/// `[1,7]`, the broker is the FX sim-optimal label, kind/taps/members are fixed), -/// NEVER on the volatile `commit` (the dirty git HEAD), so it stays deterministic. -#[test] -fn chart_run_serves_manifest_run_context_in_meta() { - let cwd = temp_cwd("chart-run-meta"); - - let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); - assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); - - let chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart"); - assert!(chart.status.success(), "chart exit: {:?}", chart.status); - let html = String::from_utf8(chart.stdout).expect("utf-8 html"); - - // The run-context meta is injected, tagged as a single run named `demo`. - assert!(html.contains("\"meta\":{"), "run-context meta not injected into the served page: {html:?}"); - assert!(html.contains("\"kind\":\"run\""), "served meta must be kind run"); - assert!(html.contains("\"name\":\"demo\""), "served meta must carry the run name"); - // The manifest's broker + data window thread through to the served page. - assert!(html.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""), "manifest broker missing from served meta"); - assert!(html.contains("\"window\":[1,7]"), "manifest window missing from served meta"); - // The charted taps + the single-run sentinel. - assert!(html.contains("\"taps\":[\"equity\",\"exposure\"]"), "charted taps missing from served meta"); - assert!(html.contains("\"members\":null"), "a single run must carry no member count"); - - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property (#102, family run-context + SPANNING window at the served page): `aura -/// chart ` injects family-shaped `meta` — `kind:"family"`, a `members` -/// count, the one compared `taps`, and crucially a `window` that is the SPAN across -/// all members, NOT a single member's window. The built-in sweep grid has 4 -/// members, each over the synthetic `[1,7]` window, but the family's recorded -/// timestamps union to a wider `[1,18]` span; the served `window` must report that -/// span (the ledger's families-comparison invariant: only the span labels a -/// walk-forward family's true OOS coverage, and for sweep/MC it is still the union -/// of member windows). A regression that took the first member's window, or dropped -/// the family `members` count, would still pass the family-overlay series test -/// (which only checks series labels); this pins the header context. Deterministic: -/// asserts the fixed kind/members/taps/span, never the volatile commit. -#[test] -fn chart_family_serves_member_count_and_spanning_window_in_meta() { - let cwd = temp_cwd("chart-family-meta"); - - let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace"); - assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status); - - let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam"); - assert!(chart.status.success(), "chart family exit: {:?}", chart.status); - let html = String::from_utf8(chart.stdout).expect("utf-8 html"); - - assert!(html.contains("\"meta\":{"), "family run-context meta not injected: {html:?}"); - assert!(html.contains("\"kind\":\"family\""), "served meta must be kind family"); - assert!(html.contains("\"name\":\"fam\""), "served meta must carry the family name"); - // The 4-point built-in grid -> a member count of 4 (the single-run sentinel is gone). - assert!(html.contains("\"members\":4"), "family meta must carry the member count, got: {html}"); - assert!(!html.contains("\"members\":null"), "a family must not carry the single-run null sentinel"); - // The default compared tap (equity), one entry — not the per-member labels. - assert!(html.contains("\"taps\":[\"equity\"]"), "family meta must carry the single compared tap"); - // The SPANNING window across members ([1,18]), wider than any single member's [1,7]. - assert!(html.contains("\"window\":[1,18]"), "family meta must carry the SPAN across members, not one member's window; got: {html}"); - - let _ = std::fs::remove_dir_all(&cwd); -} +// Note (#159 cut 4 collateral): this removes the `aura chart ` +// CLI-integration tests (single-run and family overlay/tap-filter/meta, the +// cross-kind write-guard collision + same-kind-overwrite pair) — every one of +// them populated its `TraceStore` fixture via a bare `run --trace`/`sweep +// --trace` invocation, both retired. The only surviving `TraceStore::write` +// call site left in the whole crate is `campaign_run::persist_campaign_traces` +// (behind a campaign's `--persist-taps`) — an entirely different, much +// heavier fixture than any of these tests set up. Removed as PIP-retirement +// collateral, not +// repointed; the render/build logic they exercised stays covered by the +// hand-built-fixture unit tests (`build_chart_data_threads_run_manifest_into_meta` +// here, `render_chart_html_*` in render.rs), which never touch the CLI or disk. // --- `aura run --harness ` selector (iter-3 Task 3) -------------------- -/// 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, -/// not that every run sprouts an `r` block. -#[test] -fn run_harness_sma_is_pip_only_no_r_block() { - let out = std::process::Command::new(BIN).args(["run", "--harness", "sma"]).output().unwrap(); - assert!(out.status.success()); - let s = String::from_utf8(out.stdout).unwrap(); - assert!(!s.contains("\"r\":"), "a pip run must omit the r key: {s}"); -} - /// 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] @@ -2080,64 +1196,6 @@ fn run_unknown_harness_exits_two() { assert_eq!(out.status.code(), Some(2)); } -/// 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 -/// `oos_r` block. The spec promised the bare path's golden is unchanged; the -/// signature widening defaults to SmaCross and the summary gates `oos_r` on -/// `metrics.r.is_some()`, so a regression that leaked R-reporting (or any other -/// key) into the SMA summary would be a silent contract break. This pins the -/// negative: the SMA arm produces no `metrics.r`, hence no `oos_r`. -#[test] -fn walkforward_bare_sma_summary_has_no_oos_r() { - let cwd = temp_cwd("wf-bare-sma"); - let out = Command::new(BIN) - .arg("walkforward") - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward"); - assert!(out.status.success(), "bare walkforward exit: {:?}", out.status); - let stdout = String::from_utf8(out.stdout).expect("utf-8"); - let lines: Vec<&str> = stdout.trim().lines().collect(); - let summary: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); - let wf = &summary["walkforward"]; - assert!(wf["oos_r"].is_null(), "bare SMA summary must NOT carry oos_r: {wf}"); - assert!(wf["windows"].is_number(), "bare SMA summary keeps windows: {wf}"); - assert!(wf["stitched_total_pips"].is_number(), "bare SMA summary keeps stitched_total_pips: {wf}"); - assert!(wf["param_stability"].is_array(), "bare SMA summary keeps param_stability: {wf}"); - // no per-window member line carries a metrics.r block (R-reporting is r-sma-only) - for l in &lines[..lines.len() - 1] { - let v: serde_json::Value = serde_json::from_str(l).unwrap(); - assert!(v["report"]["metrics"]["r"].is_null(), "bare SMA per-window line must NOT carry metrics.r: {l}"); - } - let _ = std::fs::remove_dir_all(&cwd); -} - -/// Property: a strategy that parses but has no walk-forward form (e.g. -/// `momentum`) is rejected with exit 2 and a stderr message that names the -/// OFFENDING strategy by its CLI token — not silently run as SMA, not crashed. -/// The dispatch's catch-all arm routes through `Strategy::cli_token()`, so the -/// diagnostic must echo the token the user typed (`momentum`), proving the -/// inverse map is wired. Distinct from a bad `--strategy bogus` (a usage parse -/// error): here the token is a valid strategy with no walk-forward arm. -#[test] -fn walkforward_unsupported_strategy_exits_2_naming_the_token() { - let cwd = temp_cwd("wf-unsupported"); - let out = Command::new(BIN) - .args(["walkforward", "--strategy", "momentum"]) - .current_dir(&cwd) - .output() - .expect("spawn aura walkforward --strategy momentum"); - assert_eq!(out.status.code(), Some(2), "unsupported walkforward strategy must exit 2"); - let stderr = String::from_utf8(out.stderr).expect("utf-8 stderr"); - assert!( - stderr.contains("momentum"), - "stderr must name the offending strategy token: {stderr:?}" - ); - assert!(out.stdout.is_empty(), "no family summary on the rejected path: {:?}", out.stdout); - let _ = std::fs::remove_dir_all(&cwd); -} - /// Property (#159 cut 2): `--strategy r-breakout` is a fully retired CLI token, on /// BOTH `sweep` and `walkforward` — indistinguishable from an unrecognized token /// (`bogus`), i.e. it falls through `strategy_from`'s catch-all into the plain @@ -2192,6 +1250,39 @@ fn sweep_and_walkforward_reject_retired_r_meanrev_token_as_unrecognized() { } } +/// Property (#159 cut 4): `--strategy sma` and `--strategy momentum` are fully +/// retired CLI tokens on both sweep and walkforward — the generic usage error, +/// no special-casing (the PIP built-in strategies retired with their runners). +#[test] +fn sweep_and_walkforward_reject_retired_pip_tokens_as_unrecognized() { + for tok in ["sma", "momentum"] { + for verb in ["sweep", "walkforward"] { + let args = [verb, "--strategy", tok]; + let cwd = temp_cwd(&format!("retired-pip-{tok}-{verb}")); + let out = Command::new(BIN) + .args(args) + .current_dir(&cwd) + .output() + .unwrap_or_else(|e| panic!("spawn aura {args:?}: {e}")); + assert_eq!( + out.status.code(), + Some(2), + "`aura {args:?}` must exit 2; status: {:?}", + out.status + ); + let stderr = String::from_utf8_lossy(&out.stderr); + let want = format!("aura: Usage: aura {verb} "); + assert!( + stderr.starts_with(&want), + "`aura {args:?}` must fall into the generic usage error, not a \ + special-cased message: {stderr:?}" + ); + assert!(out.stdout.is_empty(), "no summary on the rejected path: {:?}", out.stdout); + let _ = std::fs::remove_dir_all(&cwd); + } + } +} + /// Property (#159 cut 2): `--channel` is genuinely removed from the sweep grammar, /// not merely unused — clap rejects it as a structurally unknown argument (exit 2) /// rather than silently accepting and dropping it. A regression that left the flag @@ -3506,31 +2597,14 @@ fn aura_reproduce_rejects_an_unknown_family() { let _ = std::fs::remove_dir_all(&cwd); } -/// E2E (refuse-don't-guess): `aura reproduce` on a HARD-WIRED sweep family — its members -/// carry no `topology_hash` (not built from a blueprint) — exits non-zero, naming that the -/// member is not a generated run. Content-addressed reproduction covers only generated runs. -#[test] -fn aura_reproduce_rejects_a_non_generated_family() { - let cwd = temp_cwd("reproduce_non_generated"); - let sweep = std::process::Command::new(BIN) - .args(["sweep", "--name", "hw"]) - .current_dir(&cwd) - .output() - .expect("run aura sweep"); - assert!(sweep.status.success(), "hard-wired sweep ok: {}", String::from_utf8_lossy(&sweep.stderr)); - let out = std::process::Command::new(BIN) - .args(["reproduce", "hw-0"]) - .current_dir(&cwd) - .output() - .expect("run aura reproduce"); - assert!(!out.status.success(), "a non-generated family exits non-zero"); - assert!( - String::from_utf8_lossy(&out.stderr).contains("not a generated run"), - "names the cause: {}", - String::from_utf8_lossy(&out.stderr) - ); - let _ = std::fs::remove_dir_all(&cwd); -} +// Note (#159 cut 4 collateral): this removes +// `aura_reproduce_rejects_a_non_generated_family` — its fixture was a +// HARD-WIRED (bare `sweep --name`) family with no `topology_hash`; sweep is +// blueprint-only now and every blueprint-sweep member stamps a +// `topology_hash`, so no surviving CLI surface can construct the fixture. +// The defensive "not a generated run" refusal in `main.rs` stays as dead-path +// safety for a malformed/legacy `families.jsonl`. Removed as collateral, not +// repointed. /// E2E (refuse-don't-guess): `aura reproduce` when the content-addressed blueprint was /// removed from the store exits non-zero, naming the missing blueprint — never re-runs @@ -3774,36 +2848,44 @@ fn subcommand_help_is_scoped_not_uniform() { } /// The GNU `--flag=value` equals form is accepted, equivalent to the -/// space-separated `--flag value`. +/// space-separated `--flag value`. (#159 cut 4: `--harness` retired along with +/// the built-in default harness; `--seed` on a shipped closed blueprint is the +/// surviving vehicle flag for this clap-grammar property.) #[test] fn gnu_equals_form_is_accepted() { + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--harness=sma"]).output().expect("spawn"); + .args(["run", &fixture, "--seed=0"]).output().expect("spawn"); assert_eq!(out.status.code(), Some(0), - "run --harness=sma should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); + "run --seed=0 should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); } /// The `--` end-of-options terminator is recognized (a bare `--` after the flags -/// is a no-op that still runs the default harness). +/// is a no-op that still runs). (#159 cut 4: repointed off the retired +/// `--harness` onto `--seed` over a shipped closed blueprint.) #[test] fn double_dash_terminator_is_recognized() { + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--harness", "sma", "--"]).output().expect("spawn"); + .args(["run", &fixture, "--seed", "0", "--"]).output().expect("spawn"); assert_eq!(out.status.code(), Some(0), "run ... -- should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); } /// GNU long-option abbreviation (spec acceptance criterion 4): an unambiguous prefix -/// of a long flag is accepted (`--harn` → `--harness`), via clap `infer_long_args` +/// of a long flag is accepted (`--se` → `--seed`), via clap `infer_long_args` /// on the root command (which propagates to subcommands). An LLM/automation caller /// always writes the full flag, but GNU compliance (the cycle's ratified purpose) -/// includes abbreviation, and clap delivers it with one root attribute. +/// includes abbreviation, and clap delivers it with one root attribute. (#159 cut 4: +/// repointed off the retired `--harness` onto `--seed` over a shipped closed +/// blueprint.) #[test] fn long_option_abbreviation_is_accepted() { + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--harn", "sma"]).output().expect("spawn"); + .args(["run", &fixture, "--se", "0"]).output().expect("spawn"); assert_eq!(out.status.code(), Some(0), - "run --harn sma (abbrev of --harness) should run; stderr: {}", + "run --se 0 (abbrev of --seed) should run; stderr: {}", String::from_utf8_lossy(&out.stderr)); } @@ -3846,14 +2928,16 @@ fn dual_grammar_stray_positional_is_a_usage_error_not_swallowed() { } /// Property (#175, dual-grammar discriminator keys on `is_file()`, not the suffix): -/// `aura run .json` where the file does NOT exist is treated as the built-in -/// grammar with a stray positional (refused, exit 2, built-in usage naming -/// `--harness`), NOT as a loaded blueprint. The discriminator is -/// `ends_with(".json") && Path::is_file()`; a regression that dropped the `is_file()` -/// conjunct (suffix-only) would send this down the blueprint-read branch and fail -/// with a file-read error instead — a different, misleading refusal. The -/// `--harness`-in-stderr assertion pins that the built-in branch was chosen; the -/// positive `.json`-file path stays covered by `aura_run_loads_and_runs_a_blueprint_file`. +/// `aura run .json` where the file does NOT exist is refused with the same +/// usage error as a bare, blueprint-less `run` (exit 2), NOT read as a blueprint. +/// The discriminator is `ends_with(".json") && Path::is_file()`; a regression that +/// dropped the `is_file()` conjunct (suffix-only) would send this down the +/// blueprint-read branch and fail with a file-read error instead — a different, +/// misleading refusal. (#159 cut 4: the built-in `--harness` branch retired, so the +/// no-blueprint arm now emits the loaded-blueprint usage line unconditionally; the +/// assertion is repointed onto that line, still distinct from a file-read error.) +/// The positive `.json`-file path stays covered by +/// `aura_run_loads_and_runs_a_blueprint_file`. #[test] fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() { let out = Command::new(BIN) @@ -3870,14 +2954,17 @@ fn dual_grammar_discriminator_requires_an_existing_file_not_just_json_suffix() { assert!(out.stdout.is_empty(), "no report on stdout: {:?}", String::from_utf8_lossy(&out.stdout)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( - stderr.contains("--harness"), - "the built-in run grammar (not the blueprint-read path) must handle it: {stderr:?}" + stderr.contains("Usage: aura run "), + "the no-blueprint usage arm (not the blueprint-read path) must handle it: {stderr:?}" ); } /// The exit-code partition (#175 iteration 2, deviation #8): a USAGE error (a /// malformed command line) exits 2; a RUNTIME failure (a well-formed command /// whose needed data/state is missing) exits 1. Pins the partition as a property. +/// (#159 cut 4: `run` is blueprint-only now, so the runtime-failure arm needs a +/// shipped closed blueprint to clear the usage grammar before the no-geometry +/// refusal fires.) #[test] fn exit_codes_partition_usage_two_from_runtime_one() { // usage: an unknown flag is a command-line error → exit 2 @@ -3886,8 +2973,9 @@ fn exit_codes_partition_usage_two_from_runtime_one() { assert_eq!(usage.status.code(), Some(2), "unknown flag is a usage error → 2; stderr: {}", String::from_utf8_lossy(&usage.stderr)); // runtime: a well-formed command whose symbol has no recorded data → exit 1 + let fixture = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); let runtime = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) - .args(["run", "--real", "NONEXISTENT"]).output().expect("spawn"); + .args(["run", &fixture, "--real", "NONEXISTENT"]).output().expect("spawn"); assert_eq!(runtime.status.code(), Some(1), "no data for a valid command is a runtime failure → 1; stderr: {}", String::from_utf8_lossy(&runtime.stderr));