plan: 0068 stage1-r CLI surface (iter-3)

Executable plan for iteration 3 of cycle 0065 - make the Stage-1 R layer
operable from the command line. Three tasks:
- Task 1: promote vol_stop + risk_executor + a StopRule axis from test
  fixtures to public aura-engine composite-builders (aura-std moves to a
  normal dep; the two fixtures rewired to call them; a Vol-backed RED test
  added).
- Task 2: the stage1_r_blueprint dual-tap harness (one bias fanned into both
  SimBroker and the RiskExecutor) + run_stage1_r folding summarize_r into
  RunMetrics.r + persist_traces_r (a separate 3-tap helper that leaves the
  seven pip callers byte-unchanged).
- Task 3: a parse_run_args tokenizer + --harness selector + run_dispatch,
  replacing the run literal-slice arms; binary-driven selector / flag-
  composition / r_equity-round-trip tests.

Recon: two plan-recon agents (aura-cli surface, aura-engine promotion).
Verified before handoff by a 3-critic adversarial pass (API-fidelity,
plan-discipline, behavioral-correctness): discipline and behavior SOUND, one
API fix applied (tempdir -> the existing temp_cwd helper, no tempfile dep).

refs #129 #117
This commit is contained in:
2026-06-24 11:20:44 +02:00
parent 08d09df97d
commit 13086b8590
+784
View File
@@ -0,0 +1,784 @@
# 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 <SYM>] [--trace <n>]` 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 <name>` 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<Scalar>)> = 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<i64>, to: Option<i64> },
}
```
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<Scalar>)>,
tx_ex: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_r: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
tx_req: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> 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 <SYM> [--from][--to]] [--trace <n>]`: 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<Box<dyn aura_engine::Source>>, _, f64) = match &data {
RunData::Synthetic => {
let sources: Vec<Box<dyn aura_engine::Source>> =
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<dyn aura_engine::Source> = 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<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = 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<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)],
req_rows: &[(Timestamp, Vec<Scalar>)],
) {
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<String>,
}
/// Parse the `run` tail: `[--harness <name>] [--macd] [--real <SYM> [--from <ms>] [--to <ms>]]
/// [--trace <name>]` 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<RunArgs, String> {
let usage = || {
"run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>]"
.to_string()
};
let mut harness: Option<HarnessKind> = None;
let mut macd_flag = false;
let mut symbol: Option<String> = None;
let mut from: Option<i64> = None;
let mut to: Option<i64> = None;
let mut trace: Option<String> = 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<RunReport, String> {
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 <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>]`
to
`aura run [--harness <sma|macd|stage1-r>] [--macd] [--real <SYMBOL> [--from <ms>] [--to <ms>]] [--trace <name>]`.
- [ ] **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).