spec: 0009 run metrics + manifest
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
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
# 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 run` CLI), 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 run` subcommand / 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.rs` deliberately defers tunable
|
||||
params to the blueprint/optimizer layer (settled in the cycle-0008 audit). The
|
||||
manifest records the params the harness author bound as plain `name → value`
|
||||
pairs; 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`)
|
||||
|
||||
```rust
|
||||
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.
|
||||
|
||||
```rust
|
||||
// ... 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)
|
||||
|
||||
```json
|
||||
{"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; `PartialEq` so 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 is `Scalar`-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
|
||||
|
||||
- `summarize` is **total**: empty streams → `total_pips = 0.0`,
|
||||
`max_drawdown = 0.0`, `exposure_sign_flips = 0`. No panics, no `Result`.
|
||||
- `to_json` is **total**: it writes to a `String`; finite-float assumption is
|
||||
documented (pip equity / exposure are finite by construction — `SimBroker`
|
||||
integrates finite returns, `Exposure` clamps to `[-1, +1]`).
|
||||
- `f64_field` **panics** on a row shorter than `field` or a non-`f64` scalar at
|
||||
`field`: that is a wiring bug (a sink's declared kinds are fixed at bootstrap,
|
||||
so a correctly-wired equity/exposure sink always yields `f64` at field 0),
|
||||
surfaced exactly like the engine's existing `expect("… checked at wiring")`
|
||||
contract violations rather than silently dropped.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Unit tests in `report.rs` (`aura-engine`):
|
||||
|
||||
- `summarize` total_pips = last cumulative value; 0.0 on empty.
|
||||
- `summarize` max_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.
|
||||
- `summarize` exposure_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_json` snapshot: a known `RunReport` renders the exact canonical string
|
||||
above (field order, JSON number forms, `params`-as-object).
|
||||
- `f64_field` projects 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`, `summarize` each run;
|
||||
- assert the two `RunMetrics` are bit-identical and the two `to_json` strings 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 `RunMetrics` with total pips, max drawdown, and exposure
|
||||
sign-flips, reduced from its recorded streams (`summarize`).
|
||||
- [ ] A run produces a `RunManifest` carrying commit, params, data-window, seed,
|
||||
and broker profile.
|
||||
- [ ] `RunReport::to_json` emits 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 new `aura-engine` `report` module; the workspace stays zero-dependency.
|
||||
- [ ] `cargo test --workspace`, clippy `-D warnings`, and `cargo doc -D warnings`
|
||||
are clean.
|
||||
Reference in New Issue
Block a user