# `aura run` — end-to-end sample-harness CLI — Design Spec **Date:** 2026-06-04 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude > Cycle 0010. Tracker: Gitea issue #8 (Brummel/Aura), milestone *Walking > skeleton* — the skeleton's **closing seam**. Consumes the cycle-0009 report > surface (#6) and the cycle-0007 signal-quality nodes (#4/#5). ## Goal `aura run` bootstraps a built-in **sample harness** (source → SMA-cross signal → `Exposure` → `SimBroker` → recording sinks), runs it deterministically (C1), and prints the structured **metrics + manifest** (#6) as canonical JSON to stdout — the headline C14 "run a sim, emit structured metrics" move, end-to-end, from a real binary for the first time. The sample harness ships **with sinks** so a newcomer sees a populated trace immediately (C22). ## The crux this cycle resolves There is **no shipped recording sink node** today — recording (C22 / cycle 0006) exists only as `#[cfg(test)]` fixtures in `harness.rs` and `report.rs`. Since `Harness::run` returns `()` and a sink is the *only* data-out path, a runnable CLI is impossible without first shipping a reusable recording node. This cycle therefore ships one (`aura-std::Recorder`) as its first component, then wires the CLI on top. ## Non-goals (out of scope) - **The experiment-builder API / fluent `HarnessBuilder` DSL.** The ledger lists it under "Open architectural threads not yet resolved"; this cycle does **not** commit it. The sample harness is authored in Rust as a plain constructor function over the existing raw `Harness::bootstrap(nodes, sources, edges)` API — that *is* "harness wiring in Rust" (C17/C20). The elaborate builder is a later cycle. - **`aura new` scaffolder and the `Aura.toml` schema** — deferred architectural threads, untouched. - **The data-server source (#7).** The sample runs on a built-in synthetic stream; the source is swappable at the ingestion boundary (C3) when #7 lands. - **A git-commit build script.** The manifest `commit` is filled from `option_env!("AURA_COMMIT")` (defaults to `"unknown"`); capturing the real HEAD via `build.rs` is deferred. - **An argument-parsing dependency (clap/…).** The workspace is deliberately zero-dependency; `aura run` hand-parses `std::env::args` for one subcommand. - **Refactoring the existing `#[cfg(test)]` Recorder fixtures** to use the shipped node. They stay as historical snapshots; the shipped `Recorder` is the go-forward reusable one. De-duplication is a possible later tidy. ## Architecture Two deliverables, in dependency order: 1. **`aura-std::Recorder`** — a reusable recording node (the glossary *sink* role): a pure consumer (`output: vec![]`, C8) that holds an `mpsc::Sender<(Timestamp, Vec)>` as its out-of-graph destination and, on every fired cycle, sends `(ctx.now(), row)` where `row` is the newest value of each declared input column. `mpsc` (std) keeps aura-std zero-external-dep and avoids the `Rc`/`RefCell` interior mutability the purity invariant (C7) forbids — the same destination shape the test fixtures already use. Supports all four scalar kinds (a sink must be able to record any base column, C22), though the CLI uses it only for f64. 2. **`aura-cli`** gains a `run` subcommand: - `fn sample_harness() -> (Harness, Receiver<…>, Receiver<…>)` — composes the `aura-std` nodes (`Sma`/`Sub`/`Exposure`/`SimBroker` + two `Recorder`s) via `Harness::bootstrap`, returning the harness and the two sink receivers (equity, exposure). This is the Rust-authored harness (C17/C20); `aura-cli` legitimately depends on both `aura-engine` and `aura-std`, so the engine stays domain-free (it never names SMA/exposure — cycle-0007 note). - `fn run_sample() -> RunReport` — bootstrap → `run` on a built-in synthetic price stream → drain both sinks → `f64_field` → `summarize` → pair with a `RunManifest` → return the `RunReport`. Pure and deterministic (C1): same build → same report. - `fn main()` — hand-parse `args`: `run` ⇒ `println!("{}", run_sample().to_json())`, exit 0; anything else ⇒ a one-line usage message to **stderr**, exit 2. `aura-cli` adds `aura-std` to its dependencies (it currently depends only on `aura-engine`). ## Concrete code shapes ### User-facing: the invocation and its output (the Step-4 evidence) ```console $ aura run {"manifest":{"commit":"unknown","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":0.5},"window":[1,7],"seed":0,"broker":"sim-optimal(pip_size=0.0001)"},"metrics":{"total_pips":,"max_drawdown":,"exposure_sign_flips":}} $ echo $? 0 $ aura aura: usage: aura run # (to stderr) $ echo $? 2 ``` The exact metric values are pinned by the Testing strategy below (they follow from the chosen synthetic stream); the JSON **shape** is the cycle-0009 documented schema. The synthetic stream is chosen to rise then reverse, so the demo trace is non-trivial (a sign flip and a drawdown), per C22's "populated trace". ### Delivered: `aura-std::Recorder` (the sink node) ```rust use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp}; use std::sync::mpsc::Sender; /// A recording sink (the glossary *sink* role, C8/C22): a pure consumer that, /// each fired cycle, sends `(ctx.now(), row)` — the newest value of each input /// column — to an out-of-graph `mpsc` destination it holds. Returns `None` /// (records, forwards nothing). `mpsc` keeps the engine's purity invariant (C7): /// no `Rc`/`RefCell`. Returns `None` (filters) until every input column is warm. pub struct Recorder { kinds: Vec, firing: Firing, tx: Sender<(Timestamp, Vec)>, } impl Recorder { /// A recorder over `kinds.len()` input columns of the given kinds, each with /// the given firing policy, sending recorded rows to `tx`. pub fn new(kinds: &[ScalarKind], firing: Firing, tx: Sender<(Timestamp, Vec)>) -> Self { /* … */ } } impl Node for Recorder { fn schema(&self) -> NodeSchema { /* inputs: one InputSpec per kind (lookback 1, self.firing); output: vec![] */ } fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { // read newest of each input column by kind; None until all warm; // tx.send((ctx.now(), row)); return None. } } ``` ### Delivered: the CLI seam (`aura-cli/src/main.rs`) ```rust use aura_engine::{f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target}; use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use std::sync::mpsc::{self, Receiver}; /// The built-in synthetic price stream: rises then reverses so the demo trace /// carries a sign flip and a drawdown (C22 populated trace). fn synthetic_prices() -> Vec<(Timestamp, Scalar)> { /* deterministic ticks t=1.. */ } /// Bootstrap the sample signal-quality harness with two recording sinks /// (equity on the SimBroker, exposure on the Exposure node). Rust-authored /// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. fn sample_harness() -> (Harness, Receiver<(Timestamp, Vec)>, Receiver<(Timestamp, Vec)>) { /* … */ } /// Run the sample harness and fold it into a RunReport (drain → f64_field → /// summarize → RunManifest). Deterministic (C1): same build → same report. fn run_sample() -> RunReport { /* … */ } fn main() { let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("run") => println!("{}", run_sample().to_json()), _ => { eprintln!("aura: usage: aura run"); std::process::exit(2); } } } ``` ### Implementation-shape note (secondary) The only changed existing file is `crates/aura-cli/src/main.rs` (stub → the above) plus `crates/aura-cli/Cargo.toml` (+`aura-std`, +`aura-core` path deps) and `crates/aura-std/src/lib.rs` (+`mod recorder; pub use recorder::Recorder;`). No `aura-engine` / `Harness` / node-contract change. ## Components - **`Recorder`** (`aura-std/src/recorder.rs`) — the shipped sink node. - **`synthetic_prices`** — the built-in deterministic source stream. - **`sample_harness`** — the Rust-authored sample harness builder (aura-cli). - **`run_sample`** — bootstrap→run→drain→reduce→report (aura-cli). - **`main`** — arg dispatch + stdout/stderr/exit-code policy. ## Data flow `synthetic_prices` → source → SMA(2)/SMA(4) → `Sub` → `Exposure` → `SimBroker`; the price also taps directly into the broker's price slot (cycle-0007 wiring). Two `Recorder`s tap the `SimBroker` equity output and the `Exposure` output; each holds an `mpsc::Sender`. After `Harness::run` returns, `run_sample` drains both receivers (`rx.try_iter().collect()`), projects field 0 with `f64_field`, `summarize`s the two streams, pairs the metrics with a `RunManifest` (`commit` from `option_env!`, `params`/`window`/`seed`/`broker` from the sample's known configuration), and returns the `RunReport`. `main` prints its `to_json`. ## Error handling - Unknown / missing subcommand → one-line usage to **stderr**, `exit(2)`. (`run` is the only verb this cycle.) - `run_sample` is total: the sample harness is fixed and valid, so `Harness::bootstrap` cannot fail (the test pins this); `f64_field` / `summarize` are the cycle-0009 contracts (f64-only sinks, so no kind-mismatch panic on the wired columns). No `Result` plumbing in the happy path. - `Recorder::eval` returns `None` until every input column is warm, then sends; a dropped receiver makes `tx.send` error, which is ignored (the World owns the receiver lifetime, exactly as the fixtures do). ## Testing strategy - **`aura-std` `Recorder` unit test:** drive a tiny harness (or the node directly) so the recorder captures a known f64 stream; assert the drained `(Timestamp, Vec)` rows match, and that it returns `None` during warm-up (pure consumer). Mirrors the fixture's proven behaviour, now on the shipped node. - **`aura-cli` `run_sample` unit test** (`#[cfg(test)] mod tests` in `main.rs`): call `run_sample()` twice; assert the two `RunReport`s are equal (determinism, C1) and that the metrics equal the hand-computed values for the chosen synthetic stream (pins `total_pips` / `max_drawdown` / `exposure_sign_flips`, and a non-zero drawdown + ≥1 sign flip so the demo trace is non-trivial). - **`aura-cli` CLI integration test** (`tests/cli_run.rs`): spawn the built binary via `std::process::Command::new(env!("CARGO_BIN_EXE_aura"))`: - `run` → exit 0, stdout is exactly `run_sample().to_json()` + newline (assert the manifest+metrics keys + a parseable single-line object); - no args → exit 2, stderr contains `usage`. Gates (profile `commands`): `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`, `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps`. ## Load-bearing decisions flagged for review 1. **The recording sink ships as `aura-std::Recorder`** (not `aura-engine`, not `aura-cli`-local). Rationale: it is a *universal block* (C16 — aura-std's remit), domain-free, and reusable by any World/harness; `aura-engine` stays the bare engine (Harness + report, no nodes). It holds an `mpsc::Sender` — the purity-preserving destination the fixtures already validated. 2. **Minimal harness authoring: a plain Rust constructor function, no builder DSL.** This satisfies C17/C20 ("wiring is Rust") and closes the skeleton without prematurely committing the open experiment-builder-API thread. If you want this cycle to instead introduce a first slice of a real `HarnessBuilder` API, say so at review — that is a materially bigger cycle and I'd recommend splitting it out. 3. **Zero-dependency CLI**: hand-parsed args, no clap; `commit` via `option_env!("AURA_COMMIT")` defaulting to `"unknown"` (no `build.rs` git capture yet). Both keep the cycle minimal and the workspace dep-free; both are trivially upgraded later. ## Acceptance criteria - [ ] `aura run` bootstraps and runs the sample harness end-to-end and exits 0. - [ ] The sample harness includes (two) sinks; a run records displayable traces (equity + exposure), drained by the World. - [ ] The run emits the structured metrics + manifest from #6 as canonical JSON on stdout. - [ ] Harness wiring is Rust (a constructor function over the bootstrap API); the CLI's `main` holds no strategy logic (it only selects `run` and prints). - [ ] A reusable `aura-std::Recorder` sink node ships and is unit-tested. - [ ] An integration test drives the real binary (`run` → exit 0 + JSON stdout; bad args → exit 2 + usage stderr). - [ ] `cargo test --workspace`, clippy `-D warnings`, and `cargo doc -D warnings` are clean; the workspace stays zero-(external-)dependency.