Files
Aura/docs/specs/0010-aura-run-cli.md
T
Brummel 4a78170dfb spec: 0010 aura run CLI
Cycle 0010, the Walking-skeleton milestone's closing seam: an `aura run`
subcommand that bootstraps a built-in sample harness (synthetic source →
SMA-cross → Exposure → SimBroker → recording sinks), runs it
deterministically, and prints the cycle-0009 metrics+manifest report as
canonical JSON to stdout.

The crux the cycle resolves: no recording sink node ships today (recording
lives only as #[cfg(test)] fixtures), and Harness::run returns () with a sink
as the only data-out path. So this cycle first ships a reusable
aura-std::Recorder (a pure consumer holding an mpsc::Sender, purity-preserving
per C7), then wires the CLI on top.

Load-bearing decisions, user-approved at the brainstorm gate:
- Recorder ships in aura-std (universal block, C16), not engine or CLI-local.
- Sample harness is authored as a plain Rust constructor over the raw
  Harness::bootstrap API (C17/C20) — the open experiment-builder DSL thread is
  deliberately NOT committed this cycle.
- Zero-dependency CLI: hand-parsed args (no clap); manifest commit from
  option_env!("AURA_COMMIT") defaulting to "unknown" (build.rs git capture
  deferred).

Grounding-check PASS (all load-bearing codebase assumptions ratified by green
tests). Non-goals: experiment-builder DSL, aura new, Aura.toml schema, the
data-server source (#7), build.rs git capture.

refs #8
2026-06-04 20:29:29 +02:00

259 lines
13 KiB
Markdown

# `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<Scalar>)>` 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":<e.g. 3>,"max_drawdown":<e.g. 2>,"exposure_sign_flips":<e.g. 1>}}
$ 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<ScalarKind>,
firing: Firing,
tx: Sender<(Timestamp, Vec<Scalar>)>,
}
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<Scalar>)>) -> 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<Scalar>)>, Receiver<(Timestamp, Vec<Scalar>)>) { /* … */ }
/// 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<Scalar>)` 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.