# Stage-1 R CLI surface (iteration 3) — Implementation Plan > **Parent spec:** `docs/specs/0065-stage1-r-signal-quality.md` (the > "## Iteration 3 — the CLI surface (#129)" section). > > **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run > this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** make the Stage-1 R layer operable from the command line — `aura run --harness stage1-r [--real ] [--trace ]` runs a backtest and prints its signal-quality metrics (the R block) in the `RunReport` JSON, in one shell call. **Architecture:** promote `vol_stop` + `risk_executor` (+ a `StopRule` axis) from test fixtures to public `aura-engine` composite-builders; add a `stage1_r_blueprint` that fans one bias into both `SimBroker` (pip) and the `risk_executor` (R); add a `parse_run_args` tokenizer + `--harness ` selector routing to the built-in harnesses; fold `summarize_r → RunMetrics.r` in the run path; extend `persist_traces` with a third `r_equity` tap for `aura chart --tap r_equity`. **Tech Stack:** `aura-engine` (GraphBuilder/Composite, report), `aura-std` nodes (Sma/Sub/Bias/SimBroker/Recorder/LinComb/FixedStop/Sizer/PositionManagement + vol_stop primitives), `aura-cli` (hand-rolled argv dispatch, trace store). **Files this plan creates or modifies:** - Modify: `crates/aura-engine/Cargo.toml` — move `aura-std` from `[dev-dependencies]` to `[dependencies]`. - Create: `crates/aura-engine/src/composites.rs` — `vol_stop`, `StopRule`, `risk_executor` public builders. - Modify: `crates/aura-engine/src/lib.rs:45-67` — `mod composites;` + `pub use composites::{...}`. - Modify: `crates/aura-engine/tests/vol_stop_composite.rs` — delete local `fn vol_stop`, import the public one. - Modify: `crates/aura-engine/tests/risk_executor.rs` — delete local `fn risk_executor`, call `risk_executor(StopRule::Fixed(d), b)`; add a Vol-backed RED test. - Modify: `crates/aura-cli/src/main.rs` — `stage1_r_blueprint`, `run_stage1_r`, a new additive `persist_traces_r` 3-tap helper (the existing 2-tap `persist_traces` + its 7 pip callers stay byte-unchanged), `parse_run_args`/`RunArgs`/`HarnessKind`/`RunData`/`run_dispatch`, the `run` argv arm, `USAGE`. - Test: `crates/aura-cli/tests/cli_run.rs` — stage1-r smoke, selector, flag-composition, r_equity round-trip. - Test: `crates/aura-cli/src/main.rs` (inline `#[cfg(test)]`) — `parse_run_args` grammar unit tests. **Architecture decisions pinned (one sentence each, decided by the orchestrator):** - **Composites live in `aura-engine`** (not aura-std, not a new crate): they are the engine's convenience/execution layer over the standard nodes — the sibling tier of `report::summarize_r`, already in aura-engine — and this yields a fully acyclic normal-dependency graph (`aura-engine → aura-std → aura-core`) where homing them in aura-std would force a Cargo dev-dependency cycle. - **The `r_equity` summing `LinComb` lives in the `stage1_r_blueprint`** (not inside `risk_executor`): the R-equity series is a charting-specific derivation of the CLI harness, so the reusable executor keeps exposing exactly the PM record. - **`risk_executor`'s body is the fixture verbatim except the one stop `g.add` line**, which becomes a `match StopRule { Fixed => g.add(FixedStop…), Vol => g.add(vol_stop…) }` — every downstream edge is arm-independent because both stops expose `price → stop_distance`. - **The synthetic stage1-r harness uses `StopRule::Vol { length: 3, k: 2.0 }`** over a ~18-tick rise-fall-rise stream so the short vol stop warms up and the SMA-cross bias flips at least once (≥1 closed trade for the smoke test). --- ## Task 1: Promote `vol_stop` + `risk_executor` + `StopRule` to public `aura-engine` API **Files:** - Modify: `crates/aura-engine/Cargo.toml` - Create: `crates/aura-engine/src/composites.rs` - Modify: `crates/aura-engine/src/lib.rs:45-67` - Modify: `crates/aura-engine/tests/vol_stop_composite.rs` - Modify: `crates/aura-engine/tests/risk_executor.rs` - [ ] **Step 1: Move `aura-std` to a normal dependency** In `crates/aura-engine/Cargo.toml`, add to the `[dependencies]` section (after the `serde_json` line): ```toml # aura-std's standard nodes back the public composite-builders in src/composites.rs # (vol_stop, risk_executor) — the engine's convenience layer over the standard nodes. # aura-std depends only on aura-core, so this stays acyclic (aura-engine -> aura-std # -> aura-core). aura-std = { path = "../aura-std" } ``` and DELETE the now-redundant line under `[dev-dependencies]`: ```toml aura-std = { path = "../aura-std" } ``` (The `[dev-dependencies]` section keeps `chrono` / `chrono-tz`.) - [ ] **Step 2: Create `crates/aura-engine/src/composites.rs`** ```rust //! Reusable Stage-1 composite-builders: the volatility stop and the per-symbol //! RiskExecutor, promoted from the iter-2 integration-test fixtures so the CLI //! harness (and any consumer) can wire them. Both are `GraphBuilder` compositions //! of `aura-std` primitives — the engine's convenience layer over the standard //! nodes (the sibling of `report::summarize_r`, which likewise lives in this crate //! and reads the `aura-std` PositionManagement record). The Veto is a documented //! seam, not a node (a pass-through identity is exactly what C19/C23 DCE deletes), //! so it appears nowhere here. use aura_core::Scalar; use aura_std::{ Delay, Ema, FixedStop, LinComb, Mul, PositionManagement, Sizer, Sqrt, Sub, PM_FIELD_NAMES, }; use crate::{Composite, GraphBuilder}; /// The volatility stop as a composition of primitives: /// `stop_distance = k · Sqrt(Ema(Mul(Δ,Δ), length))`, `Δ = price − Delay(price, 1)` /// — a rolling EWMA standard deviation. Role: input `price` → output `stop_distance`. pub fn vol_stop(length: i64, k: f64) -> Composite { let mut g = GraphBuilder::new("vol_stop"); let price = g.input_role("price"); let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); // z^-1: prev price let sub = g.add(Sub::builder()); // Δ = price − prev let sq = g.add(Mul::builder()); // Δ·Δ = Δ² let ema = g.add(Ema::builder().bind("length", Scalar::i64(length))); // EWMA variance let sqrt = g.add(Sqrt::builder()); // → σ (price units) let scale = g.add(LinComb::builder(1).bind("weights[0]", Scalar::f64(k))); // k·σ g.feed(price, [delay.input("series"), sub.input("lhs")]); g.connect(delay.output("value"), sub.input("rhs")); g.connect(sub.output("value"), sq.input("lhs")); g.connect(sub.output("value"), sq.input("rhs")); // square: feed Δ to both legs g.connect(sq.output("value"), ema.input("series")); g.connect(ema.output("value"), sqrt.input("value")); g.connect(sqrt.output("value"), scale.input("term[0]")); g.expose(scale.output("value"), "stop_distance"); g.build().expect("vol_stop composite wires") } /// The protective stop-rule axis (C11 structural): a fixed distance, or the /// volatility-scaled `vol_stop`. Both expose `price → stop_distance`, so the /// RiskExecutor embeds either by identical downstream wiring. pub enum StopRule { Fixed(f64), Vol { length: i64, k: f64 }, } /// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal /// `stop-rule → Sizer → PositionManagement`, exposing every field of PM's dense /// R-record. Price fans to BOTH the stop-rule and PM; bias fans to the Sizer and PM; /// the Sizer's `size` feeds PM's size slot. The embedded stop is the chosen `StopRule`. pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite { let mut g = GraphBuilder::new("risk_executor"); let bias = g.input_role("bias"); let price = g.input_role("price"); let stop = match stop { StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))), StopRule::Vol { length, k } => g.add(vol_stop(length, k)), }; let sizer = g.add(Sizer::builder().bind("risk_budget", Scalar::f64(risk_budget))); let pm = g.add(PositionManagement::builder()); g.feed(price, [stop.input("price"), pm.input("price")]); // price fans to stop + PM g.feed(bias, [sizer.input("bias"), pm.input("bias")]); // bias fans to sizer + PM g.connect(stop.output("stop_distance"), sizer.input("stop_distance")); g.connect(stop.output("stop_distance"), pm.input("stop_distance")); g.connect(sizer.output("size"), pm.input("size")); // flat-1R size into PM for field in PM_FIELD_NAMES { g.expose(pm.output(field), field); } g.build().expect("risk_executor wires") } ``` - [ ] **Step 3: Re-export from `crates/aura-engine/src/lib.rs`** Add `mod composites;` to the module block (alphabetical, after `mod builder;` at line 46): ```rust mod builder; mod composites; mod graph_model; ``` Add the public re-export after the `builder` re-export (line 58): ```rust pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle}; pub use composites::{risk_executor, vol_stop, StopRule}; ``` - [ ] **Step 4: Verify the engine builds with the promoted API** Run: `cargo build -p aura-engine` Expected: builds clean (composites.rs compiles against the new normal `aura-std` dep). - [ ] **Step 5: Rewire `crates/aura-engine/tests/vol_stop_composite.rs`** Delete the local `fn vol_stop(length: i64, k: f64) -> Composite { … }` (lines 13-31). Replace the imports at lines 6-9 with (pruning the now-unused composite-builder deps; the compiler under `-D warnings` enumerates any leftover): ```rust use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{vol_stop, GraphBuilder, VecSource}; use aura_std::Recorder; use std::sync::mpsc::channel; ``` The test body (`vol_stop_emits_k_times_rolling_stddev`, which calls `g.add(vol_stop(3, 2.0))` at line 40) is unchanged — it now calls the promoted public `vol_stop`. - [ ] **Step 6: Rewire `crates/aura-engine/tests/risk_executor.rs` + add a Vol-backed RED test** Delete the local `fn risk_executor(stop_distance: f64, risk_budget: f64) -> Composite { … }` (lines 26-42). Update the imports at line 11-12 to import the promoted fn + `StopRule` and drop the now-unused composite-builder deps (compiler-pruned under `-D warnings`): ```rust use aura_engine::{risk_executor, summarize_r, GraphBuilder, RunMetrics, StopRule, VecSource}; use aura_std::{PositionManagement, Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS}; ``` In `run_executor` (line 82), change the call at line 87 from `g.add(risk_executor(stop_distance, risk_budget))` to: ```rust let exec = g.add(risk_executor(StopRule::Fixed(stop_distance), risk_budget)); ``` Then ADD this RED test (new coverage: the Vol-backed executor, the match arm that did not exist before the promotion): ```rust /// Property: the RiskExecutor embeds the `vol_stop` composite (the new `StopRule::Vol` /// arm) and folds to a finite RMetric — the volatility-defined default the `stage1-r` /// harness uses. A short k·σ stop over a rising-then-falling path opens a trade and /// (stop or window-end) closes at least one, so the fold is non-empty and sane. #[test] fn risk_executor_vol_stop_arm_bootstraps_and_folds() { let (tx, rx) = channel(); let mut g = GraphBuilder::new("vol_harness"); let price = g.source_role("price", ScalarKind::F64); let strat = g.add(ConstLongBias::builder()); let exec = g.add(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0)); let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx)); g.feed(price, [strat.input("price"), exec.input("price")]); g.connect(strat.output("bias"), exec.input("bias")); for (i, field) in PM_FIELD_NAMES.iter().enumerate() { let col: &'static str = format!("col[{i}]").leak(); g.connect(exec.output(field), rec.input(col)); } let mut h = g .build() .expect("vol_harness wires") .bootstrap_with_params(vec![]) .expect("bootstraps"); let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0]; let stream: Vec<(Timestamp, Scalar)> = path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64), Scalar::f64(p))).collect(); h.run(vec![Box::new(VecSource::new(stream))]); let ledger: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); let m = summarize_r(&ledger, 0.0); assert!(m.n_trades >= 1, "the vol-stop executor must fold at least one trade"); assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn); } ``` (`ConstLongBias` stays the local producer defined at lines 48-78; `Scalar`, `ScalarKind`, `Firing`, `Timestamp`, `channel` are already imported by the file.) - [ ] **Step 7: Run the engine tests — fixtures green + the new Vol arm** Run: `cargo test -p aura-engine risk_executor` Expected: PASS — `risk_executor_bootstraps_and_folds_to_expected_rmetric`, `risk_executor_r_invariant_under_risk_budget`, `folded_rmetrics_survives_runmetrics_serde_round_trip`, and the new `risk_executor_vol_stop_arm_bootstraps_and_folds` all pass. Run: `cargo test -p aura-engine vol_stop` Expected: PASS — `vol_stop_emits_k_times_rolling_stddev` (now calling the promoted fn). --- ## Task 2: The `stage1_r_blueprint` harness + the `run_stage1_r` R fold (`aura-cli`) **Files:** - Modify: `crates/aura-cli/src/main.rs` (add `stage1_r_blueprint`, `stage1_r_prices`, `run_stage1_r`; extend the `use` of `aura_engine`/`aura_std`) - [ ] **Step 1: Write the failing smoke test** (inline `#[cfg(test)] mod tests` in `main.rs`) ```rust #[test] fn run_stage1_r_synthetic_folds_an_r_block() { // the stage1-r harness scores the SMA-cross signal in R: one shell-callable run // yields a RunReport whose metrics.r is Some with a finite SQN and >= 1 trade. let report = run_stage1_r(RunData::Synthetic, None); let r = report.metrics.r.as_ref().expect("stage1-r run must populate metrics.r"); assert!(r.n_trades >= 1, "expected >= 1 trade, got {}", r.n_trades); assert!(r.sqn.is_finite(), "SQN must be finite, got {}", r.sqn); assert!(r.expectancy_r.is_finite(), "E[R] must be finite, got {}", r.expectancy_r); } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` Expected: FAIL — `run_stage1_r` / `RunData` do not exist yet (compile error). - [ ] **Step 3: Add the harness, the synthetic stream, and the run handler** Ensure `main.rs`'s imports bring in the promoted composites + the nodes the harness needs (extend the existing `use aura_engine::{…}` and `use aura_std::{…}` lines): `aura_engine::{risk_executor, vol_stop, StopRule, GraphBuilder, Composite, summarize_r}` (several already imported) and `aura_std::{Sma, Sub, Bias, SimBroker, Recorder, LinComb, PM_FIELD_NAMES, PM_RECORD_KINDS}` (most already imported — add the missing ones; the compiler enumerates). Add the data selector enum near the other CLI types: ```rust /// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1 /// close bars for a vetted symbol over an optional window. enum RunData { Synthetic, Real { symbol: String, from: Option, to: Option }, } ``` Add the synthetic stream (a rise-fall-rise path so the SMA-cross bias flips and the short vol stop warms up — reuses the `macd_prices` shape, kept local so a future `macd_prices` edit cannot silently change this harness's trades): ```rust /// A rise-fall-rise synthetic stream for the stage1-r smoke run: long enough to warm /// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the /// RiskExecutor opens and closes at least one trade. Deterministic (C1). fn stage1_r_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() } ``` Add the dual-tap harness blueprint: ```rust /// The Stage-1 R harness: the SMA-cross → `Bias` signal fanned into BOTH a `SimBroker` /// (pip equity, the existing pip yardstick) AND the `risk_executor` (the dense R-record, /// the new R yardstick), so one run scores the same signal in pips and in R. Four taps: /// equity (SimBroker), exposure (Bias), the 14-column R-record (drained into /// `summarize_r`), and `r_equity = cum_realized_r + unrealized_r` (a single series for /// `aura chart --tap r_equity`). All params bound at build → compiles with an empty point. #[allow(clippy::type_complexity)] fn stage1_r_blueprint( tx_eq: mpsc::Sender<(Timestamp, Vec)>, tx_ex: mpsc::Sender<(Timestamp, Vec)>, tx_r: mpsc::Sender<(Timestamp, Vec)>, tx_req: mpsc::Sender<(Timestamp, Vec)>, ) -> Composite { let mut g = GraphBuilder::new("stage1_r"); // SMA-cross signal → Bias (the same signal the pip sample uses). let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2))); let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4))); let spread = g.add(Sub::builder()); let exposure = g.add(Bias::builder().named("exposure").bind("scale", Scalar::f64(0.5))); // pip branch (verbatim from sample_blueprint_with_sinks). 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)); // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. let exec = g.add(risk_executor(StopRule::Vol { length: 3, k: 2.0 }, 1.0)); let rrec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)); // r_equity = cum_realized_r + unrealized_r — one tapped series for charting. let r_equity = g.add( LinComb::builder(2) .bind("weights[0]", Scalar::f64(1.0)) .bind("weights[1]", Scalar::f64(1.0)), ); let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); let price = g.source_role("price", ScalarKind::F64); g.feed( price, [fast.input("series"), slow.input("series"), broker.input("price"), exec.input("price")], ); g.connect(fast.output("value"), spread.input("lhs")); g.connect(slow.output("value"), spread.input("rhs")); g.connect(spread.output("value"), exposure.input("signal")); // bias fans to: broker (pip), exposure sink, and the RiskExecutor (R). g.connect(exposure.output("bias"), broker.input("exposure")); g.connect(exposure.output("bias"), ex.input("col[0]")); g.connect(exposure.output("bias"), exec.input("bias")); g.connect(broker.output("equity"), eq.input("col[0]")); // tap the dense R-record (all PM fields) for summarize_r. for (i, field) in PM_FIELD_NAMES.iter().enumerate() { let col: &'static str = format!("col[{i}]").leak(); g.connect(exec.output(field), rrec.input(col)); } // r_equity sum + tap. g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); g.connect(r_equity.output("value"), req.input("col[0]")); g.build().expect("stage1_r wiring resolves") } ``` Add the run handler (synthetic + real in one fn, mirroring `run_sample` / `run_sample_real` for the source and folding both yardsticks): ```rust /// `aura run --harness stage1-r [--real [--from][--to]] [--trace ]`: build the /// dual-tap stage1-r harness, run it on synthetic or real M1 data, fold the pip taps via /// `summarize` and the dense R-record via `summarize_r`, and attach the R block as /// `RunMetrics.r = Some(..)`. Pure/deterministic (C1). round_trip_cost is 0.0 (Stage-1 is /// frictionless; a --cost knob is a follow-on). fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport { if let Some(n) = trace && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) { eprintln!("aura: {e}"); std::process::exit(2); } let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, rx_req) = mpsc::channel(); let flat = stage1_r_blueprint(tx_eq, tx_ex, tx_r, tx_req) .compile_with_params(&[]) .expect("valid stage1-r blueprint"); let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness"); let (sources, window, pip_size): (Vec>, _, f64) = match &data { RunData::Synthetic => { let sources: Vec> = vec![Box::new(VecSource::new(stage1_r_prices()))]; let window = window_of(&sources).expect("non-empty synthetic stream"); (sources, window, SYNTHETIC_PIP_SIZE) } RunData::Real { symbol, from, to } => { let spec = instrument_spec_or_refuse(symbol); let server = std::sync::Arc::new(data_server::DataServer::new(data_server::DEFAULT_DATA_PATH)); if !server.has_symbol(symbol) { no_real_data(symbol); } let window = probe_window(&server, symbol, *from, *to); let source: Box = match aura_ingest::M1FieldSource::open( &server, symbol, *from, *to, aura_ingest::M1Field::Close, ) { Some(s) => Box::new(s), None => no_real_data(symbol), }; (vec![source], window, spec.pip_size) } }; h.run(sources); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); let manifest = sim_optimal_manifest( vec![ ("sma_fast".to_string(), Scalar::i64(2)), ("sma_slow".to_string(), Scalar::i64(4)), ("exposure_scale".to_string(), Scalar::f64(0.5)), ("stop".to_string(), Scalar::i64(3)), // vol_stop length ], window, 0, pip_size, ); if let Some(name) = trace { persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows); } let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); metrics.r = Some(summarize_r(&r_rows, 0.0)); RunReport { manifest, metrics } } ``` Also add the three-tap persister `run_stage1_r` calls — a SEPARATE helper, NOT a fourth arg on the two-tap `persist_traces`: that keeps the seven pip callers (`:561, :613, :1024, :1101, :1370, :1459, :1695`) byte-unchanged, so the `cli_run.rs` `"taps":["equity","exposure"]` pin stays green. `reduce_for_tap` already decimates `r_equity` by its MinMax default (an equity-like curve), so the chart viewer is unchanged. ```rust /// Persist a stage1-r run's three taps: equity (off the SimBroker), exposure (off the /// Bias), and r_equity = cum_realized_r + unrealized_r (off the RiskExecutor). Separate /// from the two-tap `persist_traces` so the pip handlers stay byte-unchanged on disk. fn persist_traces_r( name: &str, manifest: &RunManifest, eq_rows: &[(Timestamp, Vec)], ex_rows: &[(Timestamp, Vec)], req_rows: &[(Timestamp, Vec)], ) { let taps = vec![ ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows), ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows), ColumnarTrace::from_rows("r_equity", &[ScalarKind::F64], req_rows), ]; if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) { eprintln!("aura: trace persist failed: {e}"); std::process::exit(2); } } ``` (The smoke test in Step 1 does not pass `--trace`, so `persist_traces_r` is compiled-but-not-run here; the binary-driven r_equity round-trip in Task 3 exercises it once `--harness stage1-r` is routable.) - [ ] **Step 4: Run the test to verify it passes** Run: `cargo test -p aura-cli run_stage1_r_synthetic_folds_an_r_block` Expected: PASS — `metrics.r` is `Some` with `n_trades >= 1` and a finite SQN. (If the synthetic stream yields 0 closed trades, lengthen/steepen `stage1_r_prices` until ≥1 — the test pins `n_trades >= 1`.) --- ## Task 3: `parse_run_args` tokenizer + `--harness` selector + `run_dispatch` **Files:** - Modify: `crates/aura-cli/src/main.rs` (add `RunArgs`/`HarnessKind`/`parse_run_args`/`run_dispatch`; replace the `run` argv arms; update `USAGE`) - Test: `crates/aura-cli/tests/cli_run.rs` (selector + flag-composition integration tests) - Test: `crates/aura-cli/src/main.rs` inline (`parse_run_args` grammar unit tests) - [ ] **Step 1: Write the failing tests** Inline parser unit tests (in `main.rs` `#[cfg(test)] mod tests`): ```rust #[test] fn parse_run_args_defaults_to_sma_synthetic() { let a = parse_run_args(&[]).expect("bare run"); assert!(matches!(a.harness, HarnessKind::Sma)); assert!(matches!(a.data, RunData::Synthetic)); assert!(a.trace.is_none()); } #[test] fn parse_run_args_composes_harness_real_and_trace_in_any_order() { let a = parse_run_args(&["--trace", "q1", "--harness", "stage1-r", "--real", "GER40", "--from", "100"]) .expect("composed flags"); assert!(matches!(a.harness, HarnessKind::Stage1R)); match a.data { RunData::Real { symbol, from, to } => { assert_eq!(symbol, "GER40"); assert_eq!(from, Some(100)); assert_eq!(to, None); } _ => panic!("expected real data"), } assert_eq!(a.trace.as_deref(), Some("q1")); } #[test] fn parse_run_args_macd_is_an_alias_and_rejects_harness_conflict() { assert!(matches!(parse_run_args(&["--macd"]).unwrap().harness, HarnessKind::Macd)); assert!(parse_run_args(&["--macd", "--harness", "sma"]).is_err()); assert!(parse_run_args(&["--harness", "nope"]).is_err()); assert!(parse_run_args(&["--from", "1"]).is_err()); // --from without --real } ``` cli_run integration tests (in `crates/aura-cli/tests/cli_run.rs`, driving the binary via `Command::new(BIN)` exactly like the existing tests): ```rust #[test] fn run_harness_stage1_r_prints_an_r_block() { let out = std::process::Command::new(BIN).args(["run", "--harness", "stage1-r"]).output().unwrap(); assert!(out.status.success()); let s = String::from_utf8(out.stdout).unwrap(); assert!(s.contains("\"r\":{"), "stage1-r run must carry an r block: {s}"); assert!(s.contains("\"sqn\""), "the r block must carry sqn: {s}"); } #[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}"); } #[test] fn run_unknown_harness_exits_two() { let out = std::process::Command::new(BIN).args(["run", "--harness", "nope"]).output().unwrap(); assert_eq!(out.status.code(), Some(2)); } #[test] fn stage1_r_trace_persists_r_equity_and_charts_it() { // `temp_cwd` (cli_run.rs:13) gives a unique CWD with no external tempfile dep — the // pattern the existing trace tests use; it returns a `PathBuf`, so `.join` directly. let dir = temp_cwd("stage1-r-trace"); let run = Command::new(BIN) .current_dir(&dir) .args(["run", "--harness", "stage1-r", "--trace", "q1"]) .output() .unwrap(); assert!(run.status.success()); // the third tap is persisted beside equity/exposure assert!(dir.join("runs/traces/q1/r_equity.json").exists(), "r_equity tap must persist"); let chart = Command::new(BIN) .current_dir(&dir) .args(["chart", "q1", "--tap", "r_equity"]) .output() .unwrap(); assert!(chart.status.success()); let html = String::from_utf8(chart.stdout).unwrap(); assert!(!html.is_empty() && html.contains("r_equity"), "chart must render the r_equity series"); } ``` > Uses `temp_cwd("stage1-r-trace")` (cli_run.rs:13) — the existing trace tests' pattern, no > external `tempfile` dep — and `Command` (imported at cli_run.rs:4). The plain-`run` tap-order > pin `run_trace_index_carries_manifest_and_ordered_tap_list` (cli_run.rs:329) must stay green: > only the stage1-r harness writes a third tap. If the `html.contains("r_equity")` label differs, > mirror the assertion shape of the existing `chart_emits_self_contained_html_for_a_persisted_run` > (cli_run.rs:416). - [ ] **Step 2: Run the tests to verify they fail** Run: `cargo test -p aura-cli parse_run_args` Expected: FAIL — `parse_run_args` / `RunArgs` / `HarnessKind` do not exist (compile error). - [ ] **Step 3: Add the grammar types, the parser, and the dispatcher** ```rust /// 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. enum HarnessKind { Sma, Macd, Stage1R, } /// The parsed `aura run` invocation: a harness, a data source, and an optional trace name. struct RunArgs { harness: HarnessKind, data: RunData, trace: Option, } /// Parse the `run` tail: `[--harness ] [--macd] [--real [--from ] [--to ]] /// [--trace ]` in any order, each flag at most once. `--harness sma` is the default; /// `--macd` is a back-compat alias for `--harness macd` and conflicts with `--harness`; /// `--from`/`--to` are legal only with `--real`. Pure (no I/O, no exit) so the grammar is /// unit-testable; `main` does the side effects. fn parse_run_args(rest: &[&str]) -> Result { let usage = || { "run [--harness ] [--macd] [--real [--from ] [--to ]] [--trace ]" .to_string() }; let mut harness: Option = None; let mut macd_flag = false; let mut symbol: Option = None; let mut from: Option = None; let mut to: Option = None; let mut trace: Option = None; let mut tail = rest; while let Some((flag, t)) = tail.split_first() { match *flag { "--macd" if !macd_flag => { macd_flag = true; tail = t; } "--harness" if harness.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; harness = Some(match *value { "sma" => HarnessKind::Sma, "macd" => HarnessKind::Macd, "stage1-r" => HarnessKind::Stage1R, _ => return Err(usage()), }); tail = t; } "--real" if symbol.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; if value.is_empty() { return Err(usage()); } symbol = Some((*value).to_string()); tail = t; } "--from" if from.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; from = Some(value.parse().map_err(|_| usage())?); tail = t; } "--to" if to.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; to = Some(value.parse().map_err(|_| usage())?); tail = t; } "--trace" if trace.is_none() => { let (value, t) = t.split_first().ok_or_else(usage)?; trace = Some((*value).to_string()); tail = t; } _ => return Err(usage()), } } // --macd is the alias; it conflicts with an explicit --harness. let harness = match (harness, macd_flag) { (Some(_), true) => return Err(usage()), (Some(h), false) => h, (None, true) => HarnessKind::Macd, (None, false) => HarnessKind::Sma, }; // --from/--to require --real. if symbol.is_none() && (from.is_some() || to.is_some()) { return Err(usage()); } let data = match symbol { Some(s) => RunData::Real { symbol: s, from, to }, None => RunData::Synthetic, }; Ok(RunArgs { harness, data, trace }) } /// 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) -> Result { let trace = args.trace.as_deref(); Ok(match (args.harness, args.data) { (HarnessKind::Sma, RunData::Synthetic) => run_sample(trace), (HarnessKind::Macd, RunData::Synthetic) => run_macd(trace), (HarnessKind::Stage1R, data) => run_stage1_r(data, trace), (HarnessKind::Sma, RunData::Real { symbol, from, to }) => { run_sample_real(&symbol, from, to, trace) } (HarnessKind::Macd, RunData::Real { .. }) => { return Err("the macd harness has no --real form".to_string()) } }) } ``` - [ ] **Step 4: Replace the `run` argv arms in `main()`** Replace the five `run` arms (lines 1710-1722) with a single tail-parser arm: ```rust ["run", rest @ ..] => match parse_run_args(rest) { Ok(args) => match run_dispatch(args) { Ok(report) => println!("{}", report.to_json()), Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } }, ``` (The `["chart", rest @ ..]` arm and all others below stay unchanged.) - [ ] **Step 5: Update the `USAGE` string** (line 1701-1702) Change the leading `run` clause from `aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ]` to `aura run [--harness ] [--macd] [--real [--from ] [--to ]] [--trace ]`. - [ ] **Step 6: Run the parser + selector tests, then the full cli_run suite** Run: `cargo test -p aura-cli parse_run_args` Expected: PASS — the three grammar tests. Run: `cargo test -p aura-cli run_harness` Expected: PASS — `run_harness_stage1_r_prints_an_r_block`, `run_harness_sma_is_pip_only_no_r_block`, `run_unknown_harness_exits_two`. Run: `cargo test -p aura-cli --test cli_run` Expected: PASS — every existing run/--macd/--real/--trace/chart/usage test stays green (the tokenizer accepts every form the old literal arms accepted, including the strict trailing-token and `run --real` paths), AND the new `stage1_r_trace_persists_r_equity_and_charts_it` round-trip passes, AND `run_trace_index_carries_manifest_and_ordered_tap_list` still pins the plain-`run` tap list to exactly `["equity","exposure"]` (pip runs write no third tap). --- ## Final gates (run after all tasks) - [ ] **Whole-workspace build** Run: `cargo build --workspace` Expected: clean. - [ ] **Whole-workspace test** Run: `cargo test --workspace` Expected: all green (the prior 500+ tests plus the new engine + cli tests). - [ ] **Lint** Run: `cargo clippy --workspace --all-targets -- -D warnings` Expected: clean (no unused imports left from the fixture rewires).