4a78170dfb
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
259 lines
13 KiB
Markdown
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.
|