Cycle 0009 (Walking skeleton milestone, issue #6): a post-run pure-function reduction over the recorded pip-equity + exposure streams into summary metrics (total pips, peak-to-trough drawdown, exposure sign-flip count) plus a caller-supplied RunManifest (commit, params, data-window, seed, broker), paired as RunReport with canonical hand-rolled zero-dep JSON (C14). Lives in a new aura-engine report module; no engine/Harness/node-contract change. The reduction is post-run, not a node (C8 caps a node at one record per eval with no end-of-run eval); matches C18's manifest+metrics-per-run-from-day-one. The `aura run` subcommand that prints the report is deferred to #8. Grounding-check PASS (all load-bearing assumptions ratified by green tests / authoritative sources). Spec-validation parse pass is a documented no-op (no parser configured for the profile). refs #6
14 KiB
Run metrics + run manifest — Design Spec
Date: 2026-06-04 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Cycle 0009. Tracker: Gitea issue #6 (Brummel/Aura), milestone Walking skeleton. Unblocks issue #8 (
aura runCLI), which wires the surface this cycle delivers into an actual subcommand.
Goal
A run must produce the two artefacts C18 mandates "per run from day one": a
small manifest (the reproducible descriptor — node identity/commit,
param-set, data-window, seed, broker profile) and summary metrics reduced
from the run's recorded streams (at least total pips, peak-to-trough drawdown,
and an exposure sign-flip count as a turnover proxy). Determinism (C1/C12) makes
the run reproducible from the manifest, so the pair (manifest, metrics) is the
durable run record; the full result is re-derivable on demand.
This cycle delivers the library surface for that pair — typed RunManifest
/ RunMetrics / RunReport values, a pure summarize reduction over recorded
streams, and canonical-JSON rendering for the structured (C14) face — plus an
end-to-end test that runs a harness and produces the pair. Printing it from an
actual aura run subcommand is the next cycle (#8); this cycle stops at the
library surface and its demonstrating test.
Non-goals (out of scope)
- The
aura runsubcommand / any CLI wiring — that is #8. This cycle's end-to-end demonstration is a harness test, not a binary. - The queryable run registry, lineage, run-diff, promotion/status — the later C18 milestone over manifests that already exist. This cycle emits the manifest+metrics; it does not store or index them.
- A typed/ranged param-space surface.
node.rsdeliberately defers tunable params to the blueprint/optimizer layer (settled in the cycle-0008 audit). The manifest records the params the harness author bound as plainname → valuepairs; it does not introduce the optimizer-facing param-space type. - Any new engine mechanism.
Harness/ the run loop / the node contract are unchanged; this is a pure-additive consumer surface over the recorded streams the cycle-0006 sink mechanism already produces.
Architecture
The reduction is a post-run pure function over recorded streams, not a node.
A node emits at most one record per eval and there is no end-of-run eval
(C8), so an end-of-run reduction has no home as a node. The pipeline is exactly
the one C18 describes:
recording sink (cycle 0006) captures a stream out of the graph
│ Harness::run(...) -> () [unchanged]
▼
World/runner drains the sink's channel -> Vec<(Timestamp, Vec<Scalar>)>
│ f64_field(rows, 0)
▼
(Timestamp, f64) samples ──► summarize(equity, exposure) -> RunMetrics
│
RunManifest (caller-supplied) ─┴─► RunReport { manifest, metrics }
│ to_json()
▼
canonical JSON (C14)
summarize is total and pure: identical recorded streams → identical metrics,
which is the C1/C12 reproducibility acceptance (the surrounding run is already
bit-identical under C1, and a pure reduction preserves that).
Home: a new report module in aura-engine, beside Harness. Not
aura-core: a hot-reloadable node cdylib never constructs a manifest or reduces
a run — only the World/runner does, and the World links the engine, not just the
core contract. aura-cli (which depends on aura-engine) renders the JSON to
stdout in #8.
Concrete code shapes
Delivered library surface (aura-engine/src/report.rs)
use aura_core::{Scalar, Timestamp};
/// Summary metrics reduced from a run's recorded streams — the "-> metrics"
/// half of C12's atomic sim unit. Pure function of the recorded streams.
#[derive(Clone, Debug, PartialEq)]
pub struct RunMetrics {
/// Final cumulative pip equity — the last value of the (cumulative)
/// pip-equity curve. 0.0 if the curve is empty.
pub total_pips: f64,
/// Largest peak-to-trough drop on the cumulative pip curve:
/// `max_t (running_peak(t) - equity(t))`, always >= 0.0 (0.0 if the curve
/// is monotonic non-decreasing or empty).
pub max_drawdown: f64,
/// Count of adjacent recorded exposure samples whose `signum` differs
/// (`signum(0.0) == 0.0`) — a turnover proxy. Counts long<->short
/// reversals AND transitions into/out of flat; it is the plain
/// sign-change count over the recorded exposure series, not a
/// reversal-only count.
pub exposure_sign_flips: u64,
}
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
/// that bootstraps and runs the harness holds those and fills them in.
#[derive(Clone, Debug, PartialEq)]
pub struct RunManifest {
/// Node/engine identity: the git commit of the frozen artifact
/// (C18 — commit = identity; the frozen bot *is* a commit).
pub commit: String,
/// The bound tuning params as ordered `name -> value` pairs. Precursor to
/// the eventual typed param-space (deferred — see Non-goals); honest about
/// what exists today.
pub params: Vec<(String, f64)>,
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
pub window: (Timestamp, Timestamp),
/// The RNG seed (C12 seed-as-input). 0 for a seed-free synthetic run.
pub seed: u64,
/// The broker profile label, e.g. `"sim-optimal(pip_size=1.0)"`.
pub broker: String,
}
/// A run's full structured result: the descriptor plus the metrics it
/// reproduces. The durable run record of C18 ("stores manifests + metrics,
/// re-derives full results on demand").
#[derive(Clone, Debug, PartialEq)]
pub struct RunReport {
pub manifest: RunManifest,
pub metrics: RunMetrics,
}
impl RunReport {
/// Render canonical, machine-readable JSON (C14). Hand-rolled: the schema
/// is tiny, closed, and flat, so the deliberately zero-dependency workspace
/// stays zero-dependency. Field order is fixed; `f64` is rendered with the
/// round-trippable `{}` shortest form (finite values only — pip equity and
/// exposure are always finite). `params` renders as a JSON object in
/// insertion order.
pub fn to_json(&self) -> String { /* shape; planner owns bytes */ }
}
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics { /* shape; planner owns bytes */ }
/// Bridge a sink's recorded `(ts, row)` stream to `summarize`: extract one
/// `f64` field of each row into `(ts, f64)` samples. Panics if a row is too
/// short or the field is not an `f64` scalar — a wiring bug (the sink's
/// declared kinds are fixed at bootstrap), reported like the engine's other
/// "kind checked at wiring" contract violations.
pub fn f64_field(
rows: &[(Timestamp, Vec<Scalar>)],
field: usize,
) -> Vec<(Timestamp, f64)> { /* shape; planner owns bytes */ }
North-star slice — what the World/runner author writes (this is #8's seam)
Shown minimal and honest: the harness wiring (SMA fast/slow → Sub → Exposure
→ SimBroker, with a price tap into the broker) is the cycle-0007 pattern and
is not re-specified here; the new bytes are the drain → reduce → report tail.
// ... bootstrap the harness with two recording sinks: one on the SimBroker
// equity output, one on the Exposure output; run it ...
h.run(streams);
let equity = f64_field(&equity_rows, 0); // drained from the equity sink
let exposure = f64_field(&exposure_rows, 0); // drained from the exposure sink
let metrics = summarize(&equity, &exposure);
let report = RunReport {
manifest: RunManifest {
commit: env!("AURA_COMMIT").to_string(),
params: vec![
("sma_fast".into(), 2.0),
("sma_slow".into(), 4.0),
("exposure_scale".into(), 1.0),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
broker: "sim-optimal(pip_size=1.0)".into(),
},
metrics,
};
println!("{}", report.to_json()); // the structured C14 face (lands in #8)
Canonical JSON shape (one line; whitespace fixed)
{"manifest":{"commit":"abc123","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":1},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}
Components
RunMetrics— the three summary numbers. Plain data;PartialEqso tests assert exact equality and determinism.RunManifest— the reproducible descriptor; all fields caller-supplied.RunReport— the(manifest, metrics)pair +to_json.summarize(equity, exposure)— the pure reduction.total_pips= last equity value;max_drawdown= running-peak minus value, maximized;exposure_sign_flips= adjacent-signum-change count.f64_field(rows, field)— the recorded-Vec<Scalar>-row →(ts, f64)adapter; the one place that knows a recorded row isScalar-typed.
Data flow
The cycle-0006 sink mechanism is unchanged: a recording node pushes
(ctx.now(), row) out of the graph each fired cycle. After Harness::run
returns, the World holds each sink's drained Vec<(Timestamp, Vec<Scalar>)>.
f64_field projects the single f64 column (field 0) of the equity stream and
of the exposure stream; summarize folds the two projections into RunMetrics;
the World pairs it with a RunManifest it constructed and renders to_json. No
data flows back into the graph; the reduction is strictly downstream of a
completed run.
Error handling
summarizeis total: empty streams →total_pips = 0.0,max_drawdown = 0.0,exposure_sign_flips = 0. No panics, noResult.to_jsonis total: it writes to aString; finite-float assumption is documented (pip equity / exposure are finite by construction —SimBrokerintegrates finite returns,Exposureclamps to[-1, +1]).f64_fieldpanics on a row shorter thanfieldor a non-f64scalar atfield: that is a wiring bug (a sink's declared kinds are fixed at bootstrap, so a correctly-wired equity/exposure sink always yieldsf64at field 0), surfaced exactly like the engine's existingexpect("… checked at wiring")contract violations rather than silently dropped.
Testing strategy
Unit tests in report.rs (aura-engine):
summarizetotal_pips = last cumulative value; 0.0 on empty.summarizemax_drawdown on a non-monotonic curve (up → down → up): asserts the worst peak-to-trough drop, not the final drop; 0.0 on a monotonic curve.summarizeexposure_sign_flips on a crafted series (+ + - 0 -→ flips at+→-,-→0,0→-= 3) pinning the documented signum-change definition; 0 on a constant-sign series.to_jsonsnapshot: a knownRunReportrenders the exact canonical string above (field order, JSON number forms,params-as-object).f64_fieldprojects field 0 of multi-field rows; panics on a kind mismatch (a#[should_panic]test).
End-to-end determinism test (the C18 / acceptance demonstrator), reusing the
cycle-0007 harness shape (Sma fast/slow → Sub → Exposure → SimBroker,
price tapped into the broker) with two recording sinks:
- build + run the harness twice on identical synthetic input;
- drain,
f64_field,summarizeeach run; - assert the two
RunMetricsare bit-identical and the twoto_jsonstrings are identical — "same manifest → same metrics" (C1/C12).
Gates (profile commands): cargo test --workspace,
cargo clippy --workspace --all-targets -- -D warnings,
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps.
Load-bearing decision flagged for review
Structured output is hand-rolled canonical JSON, zero-dependency — not serde.
The workspace today has zero external dependencies by deliberate policy
("Add crates only when a boundary proves real"). The manifest+metrics schema is
tiny, closed, and flat, so a hand-written to_json satisfies C14
(machine-readable, stable, parseable) without pulling serde/serde_json and
breaking that stance. serde is the natural choice later, when the run-registry
milestone (C18) grows a larger, evolving, possibly-deserialized schema — that is
an effort/scope judgement deferred to when the boundary is real, not a
correctness need now. If you would rather adopt serde now (e.g. you expect the
schema to churn immediately, or want #[derive(Serialize)] ergonomics from day
one), say so at review and the spec flips to a serde-based to_json.
Acceptance criteria
- A run produces
RunMetricswith total pips, max drawdown, and exposure sign-flips, reduced from its recorded streams (summarize). - A run produces a
RunManifestcarrying commit, params, data-window, seed, and broker profile. RunReport::to_jsonemits structured, machine-readable JSON (C14) in a fixed canonical form.- An end-to-end test runs a harness twice and asserts identical metrics + identical JSON — same manifest → same metrics (C1/C12).
- No engine/
Harness/node-contract change; the surface is pure-additive in a newaura-enginereportmodule; the workspace stays zero-dependency. cargo test --workspace, clippy-D warnings, andcargo doc -D warningsare clean.