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

13 KiB

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 → ExposureSimBroker → 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 Recorders) 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_fieldsummarize → pair with a RunManifest → return the RunReport. Pure and deterministic (C1): same build → same report.
    • fn main() — hand-parse args: runprintln!("{}", 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)

$ 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)

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)

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) → SubExposureSimBroker; the price also taps directly into the broker's price slot (cycle-0007 wiring). Two Recorders 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, summarizes 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 RunReports 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.