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
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 (ChartMeta → window.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