Files
Aura/docs/design/contracts/c22-playground-traces.md
T
claude 9221bcd167 feat(aura-runner, aura-cli): a run reports the trace handle it recorded under
A single run persisted its taps and then said nothing about where: the only
way to learn the directory name was `ls runs/traces/`, and the chart intake's
not-found refusal pointed at "the handle a sweep/walk-forward/campaign run
printed" — two verbs retired with #319, and a single run printed no handle at
all. A caller holding a family id from a families listing was stuck: that id
is not a trace handle, and nothing said so.

The handle now rides BESIDE the report, not inside it. Both declared-tap entry
points return `RunOutcome { report, skipped, trace_name }` in place of the
`(report, skipped)` pair, and the CLI composes report + handle into one stdout
object through a `serde(flatten)` wrapper. `RunReport`, `MeasurementReport`,
`RunManifest` and the registry's compat mirror are untouched, so no stored
record shape moves and a tap-free run's line stays byte-identical.

Placement is the load-bearing decision, and it follows a precedent rather than
inventing one: the report is the durable C18 run record — its manifest states
what the run *was* — while a trace directory is where its output went. The
project settled this exact question one cycle earlier for the sibling value,
where the unbound-tap names ride beside the report so the CLI, not the
library, prints the note (C27/#297). An embedding host gets the handle as a
value, never as text to parse back.

The chart refusal gains a second arm. A family id
(`{campaign8}-{strategy_ordinal}-{instrument}-w..-r..-s..-{run}`) is
recognised syntactically — the trailing segment shape plus an 8-hex head, the
form `derive_trace_name` mints — and answered with the campaign's REAL
recorded handles, filtered to those `chart` would accept, suggesting one only
when exactly one matches. It never derives a handle by truncating the id: the
id's second segment counts strategies while the handle's counts runs, and one
campaign run mints family ids at several strategy ordinals whose traces all
live under that single run's handle. `TraceStore::names()` supplies the
enumeration, keeping trace file I/O in the store where C22 puts it.

Verification caught three errors worth recording. The first spec draft claimed
the handle was the id's leading pair — refuted by a green test where a run-0
campaign mints ordinal-1 ids. The second draft's replacement refusal said a
campaign run "prints one handle per family"; it prints one per run. Review
then found the `NotFound` filter untested — deleting it left the suite green —
so the case now has a test that fails without it.

closes #309
2026-07-26 23:10:24 +02:00

10 KiB

C22 — The playground is a trace explorer; sinks are the recording mechanism

Guarantee. The World is a program (C21): nothing is displayable until it runs, and its harnesses are transient machinery (built, run, discarded — C19). The only durable, displayable substance is the recorded trace, captured by sinks — pure consumer nodes (C8) that persist a stream (equity, a bias / exposure level, a node's output) into the run registry (C18). Displayable = exactly what a sink recorded; with no sink, only the input params + summary metrics remain. The playground is therefore an execution viewer / trace explorer, and it plays any harness (the harness player, not a harness, never bound to a default one): before a run it shows the program structure (graph-as-data, C9) + param knobs; during a run, live sink streams; after a run, recorded traces + metrics from the registry — including meta-views (stitched walk-forward, sweep surfaces, multi-strategy / instrument comparison). Observability is explicit and selective — you instrument what you want to see; the choice of sinks is part of the experiment. The engine ships sample blueprints and a scaffolded starter so a newcomer has something to run at once; a fresh project is empty until built, run, and instrumented. The shipped visual face is web-from-disk — disk-persisted traces rendered as self-contained HTML chart pages (not egui).

Forbids. A scene-editor model that assumes a persistent, populated world; constructing or wiring topology in the UI (topology is Rust + hot-reload, C9/C17 — the UI reflects the live graph-as-data and tunes runtime params via sliders, C12); retaining un-sinked data; binding the playground to a single / default harness. The "never a scene editor" forbid is precisely no persistent scene-at-rest (a World is a program) — it does not forbid a future blueprint editor over the World-owned topology data (C24); that data format would be the substrate such an editor round-trips, not a new mechanism.

Why. A program has no scene-at-rest to inspect; what persists is what it records. Making sinks the one recording-and-observability mechanism keeps "what can I see?" answerable by "what did I instrument?", and keeps the engine UI-agnostic (C14). Live param tuning (runtime values, no topology change — C12 / C19) gives the interactive feel without a wiring DSL. The visual face is orthogonal and optional: the blueprint data format (C24) is required by C21 (generation / structural search) and C18 (reproduction) at the CLI level, autonomously, independent of any visual surface — so the playground's form (web or egui, viewer or editor, or not built at all) is downstream, and topology authoring does not live in it. Generalized by the role model (C25): every artifact's canonical, complete form is text, every operation is executable headless, and any visual surface is a stateless projection that reads/writes that canonical form and adds no semantics; read-only projections (this trace explorer) rank far above write editors, because the Blockly-litmus discipline (C25) already keeps each vocabulary palette-generatable without one.

Current state

Sinks at the substrate. A recorded trace is exactly what a recording (tap) node pushed out of the graph — there is no engine recording registry; the constructing World holds each recording node's destination. Harness::run returns () and recording is a node-side concern, so one run records many streams (one per tap) rather than exactly one row. Recorded streams are sparse and timestamped (a record per fired cycle, tagged ctx.now()), matching a trace of timestamped events (C18).

Encoding / storage / rendering split (C14). A drained tap is encoded as a columnar (SoA, C7) ColumnarTrace — struct→JSON only — in aura-engine (report.rs); file I/O is aura-registry::TraceStore, persisting each run under runs/traces/<name>/ beside the run registry's runs.jsonl; rendering is aura-cli (render_chart_html + the vendored chart-viewer.js). The engine stays headless.

Single run. aura exec <blueprint.json> (#319) persists every tap the blueprint declares to the trace store under the run's own name, on both shapes: a bias-output strategy (aura-runner::member::run_signal_r, R-wrapped) and a bare measurement blueprint with ≥1 declared tap but no bias (aura-runner::measure::run_measurement). A tap-free run writes nothing to runs/ and its stdout stays byte-identical. exec carries no free-standing --trace <NAME> flag — naming a trace is the family / campaign path's job, not a per-run flag (the repeatable --tap TAP=FOLD selector, #310, only chooses which of the blueprint's own declared taps to subscribe, never names the trace).

A run that persists anything reports the handle it wrote under as trace_name on its stdout line (#309) — appended to the record's own bytes, absent entirely when the run recorded nothing — so the handle is read, never guessed. The library hands it to an embedding caller as a value beside the report, like the unbound-tap names (C27/#297); it is not part of the stored record.

Families. A campaign document's presentation.persist_taps (the closed tap vocabulary, C18/C27) requests traces for each cell's nominee, run through aura exec <campaign.json|id> (#319 — a document's axes replace the retired sweep|walkforward --real <SYM> --trace <NAME> grammar entirely; there is no per-invocation --trace/--name flag left on any surface): aura-runner::runner::persist_campaign_traces writes each member under a depth-2 fan-out runs/traces/{campaign8}-{run}/<cell>/<member>/, the name derived deterministically (derive_trace_name), never user-chosen. Every written member is independently re-run once in non-reduce trace mode over its own recorded window, and its re-derived metrics are asserted equal to the recorded member's — a C1 drift alarm that refuses (exit 1) rather than persist a silently-wrong trace. TraceStore::ensure_name_free makes name resolution a total function, refusing cross-kind reuse of one name by both a run and a family.

Viewer. aura chart <name> [--tap <t>] [--panels] classifies the name on disk (TraceStore::name_kind: top-level index.json → a single Run; member subdirs → a Family; else NotFound, which then tries campaign-name resolution), reads the traces back, and aligns all taps on a synthetic union-timestamp spine via the post-run join_on_ts (C3 — a post-run join, not an in-graph merge; C1 — pure, no live external call), emitting a self-contained uPlot page (vendored, like the graph render). A single run overlays all its taps, each on its own y-scale (or the one --tap selects); a family overlays one tap (default equity, --tap to pick) of every member on a single shared y-scale — members measure one identical quantity, so a shared scale is what makes them comparable. One mechanism yields three correct readings: sweep / MC members share the data window (a true overlay), walk-forward members are disjoint OOS windows (null-complementary → the stitched curve). A run-context header (ChartMetawindow.AURA_TRACES.meta → the pure buildHeader) renders chips for kind / name / members / tap / broker / window / seed / commit / bound-params; a family's window is the span (min from, max to) across members. Serve-time min-max decimation (decimate, CHART_DECIMATE_BUCKETS = 2000) thins the page to a few-thousand spine slots (full recorded data stays on disk, view-only), and the reduction is tap-aware (reduce_for_tap / ReduceKind): an unbounded cumulative curve (equity) keeps the per-bucket min/max envelope so drawdowns survive, while a bounded level series (exposure / the C10 bias level, ∈ [-1,+1]) reduces by per-bucket mean so its net / duty-cycle level shows instead of collapsing to a solid ±1 band. --panels renders stacked panels instead of the overlay.

Newcomer. aura new <name> scaffolds a data-only project — a paths-only Aura.toml, a runnable starter blueprint blueprints/signal.json over the std vocabulary (an SMA-cross → Bias strategy), .gitignore, and a project CLAUDE.md — no crate, no build step (aura nodes new attaches a native node crate, C16/C17). The engine also ships example blueprints under crates/aura-cli/examples/r_{sma,breakout,channel,meanrev}.json. Honest gap against the Guarantee's "populated trace immediately": these shipped blueprints are strategies (a bias output, no declared taps), so aura exec prints summary R-metrics to stdout but writes no on-disk trace — a chartable trace today comes from a blueprint that declares taps (single run) or from a campaign document requesting presentation.persist_taps.

Deferred. Live sink streams during a run are not built — taps are buffer-then-drain (collected, then written after the run), and there is no local replay server or replay clock; the shipped face is post-run static HTML. Reworking tap draining into live subscribers (stream-to-disk / fold / live consumer) is #283. Embeddable chart / graph fragments for hosting in external pages are unbuilt (#150). The programmable analysis meta-level — families as composable orchestration operators — is C21's open direction, not this contract's.

History: c22-playground-traces.history.md

See also

  • C27 — declared taps: the named sinks a run persists
  • C8 — sinks are pure consumer nodes
  • C21 — the World is the program the playground plays
  • C24 — the topology data a future editor would round-trip
  • C25 — visual surfaces as stateless projections
  • C14 — headless core, two faces (the encode / store / render split)
  • C18 — the run registry the trace store sits beside
  • C9 — topology grown in Rust; the structure the viewer reflects
  • C10 — the bounded bias / exposure level the viewer reduces by mean