spec: trace charts — persist taps + serve web charts (auto-signed)

First cut of aura's visual face (issue #101; the web-from-disk pivot
ratified into C14/C22 this session, commit 3b56efb): tap arbitrary
recorder streams from a run, persist them to disk as columnar (SoA)
JSON, and serve them as a self-contained uPlot HTML chart — feeds
overlaid on a shared timestamp axis, or timestamp-aligned in stacked
panels. `aura run [--macd] [--trace <name>]` and `aura run --real <SYM>
... [--trace <name>]` persist; `aura chart <name> [--panels]` serves.
Both modes share one synthetic-union-spine join_on_ts alignment.

Signed via the specify auto-sign gate: the user is away under an
explicit "work fully autonomously, document decisions" mandate, so no
manual signature was available. All objective gates green (precondition
clean, self-review clean, grounding-check PASS), and a unanimous 5-lens
adversarial spec-skeptic panel (criterion / grounding / scope-fork /
ambiguity / plan-readiness all SOUND).

The panel earned its keep — two self-correction rounds to unanimity:
- Round 0 caught (a) panels shipped NOT timestamp-aligned, contradicting
  the user's verbatim "aligned nach Timestamps in Panels" — corrected to
  conform to issue #101 Fork A; (b) the USAGE advertised `--macd --trace`
  with no dispatch arm — corrected to an orthogonal `--trace` composing
  with every run form via a shared persist helper.
- Round 1 caught the union-spine -> join_on_ts mapping under-specified
  (which tap is the spine?) — corrected with an explicit 3-step
  synthetic-empty-spine mechanism (no privileged tap, no points dropped),
  plus a to_rows uniformly-f64 clarification.

The two delegated naive-viewer decisions (per-series y-scale, union
spine) are documented with provenance on issue #101.

refs #101
This commit is contained in:
2026-06-18 23:28:58 +02:00
parent 3b56efbdb4
commit bcf48f3f86
@@ -0,0 +1,432 @@
# Trace charts — persist recorder taps, serve as web charts — Design Spec
**Date:** 2026-06-18
**Status:** Draft — awaiting spec review (autonomous build; specify auto-sign gate)
**Authors:** orchestrator + Claude
## Goal
Make a backtest's recorded streams **visible**. Today a run's recorder taps
(`equity`, `exposure`, or any wired column) are drained in-memory, folded to three
summary numbers (`total_pips` / `max_drawdown` / `exposure_sign_flips`), and then
discarded — nothing persists the time series, and the only visual surface
(`aura graph`) shows graph *structure*, not run *data*.
This cycle closes the gap end-to-end: **tap data from a graph → persist it to disk
→ serve it as a chart in the browser.** It is the first cut of aura's visual face
under the web-from-disk seam ratified this session into C14/C22 (commit `3b56efb`,
issue #101): the engine writes recorded traces to disk; a static self-contained
HTML page (the `render_html` precedent) charts them; multiple feeds are overlaid on
a shared timestamp axis, or aligned in stacked panels.
In scope (first cut):
1. A **columnar trace encoding** (`ColumnarTrace`) — the SoA form of one drained
tap, on disk as JSON (C7: one column = one series).
2. A **disk trace store** under `runs/traces/<name>/`, beside the run registry
(C18, `runs/`): write a run's taps, read them back.
3. `aura run --trace <name>` — persist the sample harness's taps (additive; the
default `aura run` is byte-for-byte unchanged).
4. `aura chart <name> [--panels]` — read a persisted run's taps and emit a
self-contained HTML chart page (uPlot, vendored), feeds **overlaid** (default,
shared timestamp x via the existing `join_on_ts`) or in **stacked panels**.
Out of scope (explicit follow-on, named so the boundary is auditable): families /
comparison across multiple runs (C21), a local HTTP server, level-of-detail /
"mipmapping" downsampling, an Arrow/Parquet columnar store, and live streaming
during a run.
## Architecture
Four layers, each landing where its existing siblings already live — no new crate,
no engine UI knowledge (C14 preserved):
| Layer | Crate / file | Sibling precedent it joins |
|-------|--------------|----------------------------|
| Columnar encoding (pure) | `aura-engine` / `report.rs` | `summarize`, `f64_field`, `join_on_ts` — pure post-run reductions over recorded streams |
| Disk trace store (I/O) | `aura-registry` / new `trace_store.rs` | `Registry` owns `runs/` + directory-co-located sibling stores (`families.jsonl`) |
| Chart-page assembly (string) | `aura-cli` / `render.rs` | `render_html` — self-contained HTML, vendored JS assets |
| CLI verbs + serve-time align | `aura-cli` / `main.rs` | the exhaustive argv match; `run_sample` drain→fold |
The crate-homes are dictated by the existing structure, not chosen: a columnar
encoding is the same family as `join_on_ts` (a pure reduction over
`&[(Timestamp, Vec<Scalar>)]`), the on-disk store is the same family as the
registry's directory-co-located stores, and the HTML assembly is the same family as
`render_html`. Engine purity holds: `aura-engine` does the *encoding* (struct →
JSON), never file I/O; the registry crate does the I/O; the CLI does stdout +
browser hand-off. No pixel/UI knowledge enters the engine (C14).
Determinism (C1) and causality (C2/C3) are untouched: persistence is a **post-run**
reduction over already-recorded sink output, exactly like `summarize`. `join_on_ts`
stays a post-run reduction (C3: not an in-graph join), now invoked at *serve* time
rather than only in example code.
## Concrete code shapes
### 1. The user-facing flow (the criterion's empirical evidence)
A researcher who just ran a backtest wants to *see* the equity curve and the
exposure, not read three numbers. The reach is two commands:
```console
# persist the run's taps under runs/traces/demo/ (default run output unchanged)
$ aura run --trace demo
{"manifest":{...},"metrics":{"total_pips":...,"max_drawdown":...,"exposure_sign_flips":...}}
$ ls runs/traces/demo/
index.json equity.json exposure.json
# serve them as a self-contained chart page (overlay: both feeds, shared ts axis)
$ aura chart demo > demo.html && xdg-open demo.html
# or timestamp-aligned stacked panels (one per feed, shared time axis)
$ aura chart demo --panels > demo.html
```
`equity.json` — the columnar (SoA) form of one tap, chart-ready (one column = one
series), kind-tagged for honesty (C7):
```json
{
"tap": "equity",
"kinds": ["F64"],
"ts": [1, 2, 3, 4, 5, 6, 7],
"columns": [[0.0, 0.0, 0.0010, 0.0040, 0.0040, 0.0010, -0.0010]]
}
```
`index.json` — the tap list (deterministic read order) + the run manifest, so the
page header can show run context (commit / params / window / broker), mirroring
`aura graph`'s header:
```json
{ "manifest": { "commit": "...", "params": [...], "window": [1,7], "seed": 0,
"broker": "sim-optimal(pip_size=0.0001)" },
"taps": ["equity", "exposure"] }
```
The emitted page is self-contained (no remote fetch), injecting the aligned data as
`window.AURA_TRACES` and inlining vendored uPlot + a `chart-viewer.js` — exactly the
`render_html` pattern.
### 2. `ColumnarTrace` (aura-engine / report.rs) — NEW
```rust
/// One drained recorder tap in columnar (SoA) form: parallel arrays, one per
/// recorded column, plus the shared recorded-timestamp axis. Chart-ready (a column
/// is a series of numbers) and kind-tagged (C7: the true base type of each column
/// survives even though values are coerced to f64 for plotting). Pure: the same
/// rows always encode to the same struct (C1).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ColumnarTrace {
pub tap: String,
pub kinds: Vec<String>, // "F64" | "I64" | "Bool" | "Timestamp" (no serde dep on ScalarKind)
pub ts: Vec<i64>, // recorded timestamps (Timestamp.0)
pub columns: Vec<Vec<f64>>, // columns[c][r] — coerced: f64 as-is, i64 as f64, bool 1/0, ts as f64
}
impl ColumnarTrace {
/// Transpose a drained tap (`(ts, row)` pairs) into columns. Empty rows -> empty
/// ts + empty columns sized to `kinds`.
pub fn from_rows(tap: &str, kinds: &[ScalarKind], rows: &[(Timestamp, Vec<Scalar>)]) -> Self { /* ... */ }
/// Inverse for the serve/align path: rebuild `(ts, row)` pairs with each cell as
/// `Scalar::f64(columns[c][r])`. The on-disk store is already f64 columns, so the
/// rebuilt rows are **uniformly f64** (the original base type survives only in
/// `kinds` as display metadata) — which keeps the serve-side `as_f64` projection
/// (§6 step 3) total, never the F64-only panic path. A true value round-trip for
/// the shipped F64 taps; for other kinds the value round-trips through the f64
/// store with `kinds` carrying the base type.
pub fn to_rows(&self) -> Vec<(Timestamp, Vec<Scalar>)> { /* ... */ }
}
```
### 3. `TraceStore` (aura-registry / trace_store.rs) — NEW
```rust
/// A per-run directory of columnar tap files under `<runs_dir>/traces/<name>/`,
/// beside the run registry's `runs.jsonl`. Directory-keyed (like the family
/// sibling store): one subdir per run name, one `<tap>.json` per tap, plus
/// `index.json` (tap order + manifest).
pub struct TraceStore { dir: PathBuf } // dir = <runs_dir>/traces
impl TraceStore {
pub fn open(runs_dir: impl AsRef<Path>) -> TraceStore { /* runs_dir/traces */ }
/// Write one run's taps + index under traces/<name>/, creating dirs as needed.
pub fn write(&self, name: &str, manifest: &RunManifest, taps: &[ColumnarTrace]) -> Result<(), TraceStoreError> { /* ... */ }
/// Read a run's index + taps back, in index order. A missing run dir is
/// `NotFound` (treat-as-absent, C18-consistent), not a panic.
pub fn read(&self, name: &str) -> Result<RunTraces, TraceStoreError> { /* ... */ }
}
pub struct RunTraces { pub manifest: RunManifest, pub taps: Vec<ColumnarTrace> }
```
### 4. Chart-page assembly (aura-cli / render.rs) — NEW, mirrors `render_html`
```rust
const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js");
const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css");
const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js"); // NEW asset
pub enum ChartMode { Overlay, Panels }
/// Self-contained chart page for one run's taps. `data` (a `ChartData`, §6) carries
/// the taps aligned on the shared union-spine x — one `Series` per (tap, column),
/// each with its own y-scale id so disparate magnitudes stay legible; `mode` selects
/// overlay vs panels over the *same* data. The page fetches nothing at view time.
pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
// SHELL_HEAD-style dark shell + run-context header
// + <style>UPLOT_CSS</style> + <script>UPLOT_JS</script>
// + window.AURA_TRACES = {mode, manifest, taps, xs, series:[{name,y_scale_id,points}]}
// + <script>CHART_VIEWER_JS</script> // overlay: one uPlot, per-series y-scale;
// // panels: one uPlot per series, shared xs
}
```
### 5. CLI wiring (aura-cli / main.rs) — before → after
`--trace <name>` is an **orthogonal modifier** that composes with **every** run form
— plain `run`, `run --macd`, and `run --real <SYM>`. All three already drain the
identical two taps (`rx_eq` / `rx_ex`) and build a `RunManifest` (`run_sample`:159,
`run_macd`:836, `run_sample_real`:238), so one shared helper serves all three; the
persist step is inserted between drain and fold (additive; runs only when the trace
name is `Some`):
```rust
// before:
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
// ... RunReport
// shared helper (aura-cli), called by run_sample / run_macd / run_sample_real:
fn persist_traces(name: &str, manifest: &RunManifest,
eq_rows: &[(Timestamp, Vec<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)]) -> Result<(), TraceStoreError> {
let taps = vec![
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
];
TraceStore::open("runs").write(name, manifest, &taps)
}
// after, in each run path (manifest hoisted before the fold; trace: Option<&str>):
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
if let Some(name) = trace { persist_traces(name, &manifest, &eq_rows, &ex_rows)?; }
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
// ... RunReport unchanged; when trace is None, stdout + behaviour are byte-for-byte as before
```
`main()` argv match. `--trace` composes with **every** run form; `run_sample` /
`run_macd` / `run_sample_real` each gain a `trace: Option<&str>` parameter — the
existing no-trace arms pass `None`, so their stdout stays byte-for-byte unchanged.
Every combination the new `USAGE` advertises has an explicit arm (no advertised form
falls through to the usage error):
```rust
// before:
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["run", "--real", rest @ ..] => match parse_real_args(rest) { Ok((sym, from, to)) => ... },
// after — existing arms pass None (unchanged stdout); new arms thread the trace name:
["run"] => println!("{}", run_sample(None).to_json()),
["run", "--macd"] => println!("{}", run_macd(None).to_json()),
["run", "--trace", name] => println!("{}", run_sample(Some(name)).to_json()),
["run", "--macd", "--trace", name] => println!("{}", run_macd(Some(name)).to_json()),
// parse_real_args -> 4-tuple (symbol, from, to, trace: Option<String>); the existing
// ["run","--real", rest @ ..] arm threads it: run_sample_real(&sym, from, to, trace.as_deref())
["chart", name] => emit_chart(name, ChartMode::Overlay),
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
```
`parse_real_args` gains one tail flag — `--trace <name>` — alongside the existing
`--from`/`--to` pairs (same once-only, value-required grammar), returning a fourth
`Option<String>` element; `run_sample_real` takes `trace: Option<&str>` and persists
the same two taps when it is `Some`. `emit_chart` reads the store and aligns
**all** taps as symmetric sides of a synthetic empty-payload union spine via
`join_on_ts` (the exact three-step mechanism in §6; no tap privileged, no points
dropped), producing one `ChartData` that feeds **both** modes (overlay superimposes
the series, panels stack one plot per series on that shared x), then prints the page; a
missing run name is a usage error (stderr + `exit 2`), consistent with the rest of the
CLI. `USAGE` becomes
`aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | …`
— every advertised `[--macd] [--trace]` combination has a matching arm above, so the
usage string and the dispatch cannot disagree.
### 6. Overlay & panel chart semantics (shared timestamp x; the y-axis choice)
**Both modes are timestamp-aligned on one shared x-axis** — the **sorted union of all
taps' recorded timestamps**, aligned via `join_on_ts` with the union as the spine
(each series null-filled where its tap did not fire — uPlot renders the gap; a *union*
spine drops no tap's points). This is exactly the user's "mehrere Feeds in einem
Chart, **oder aligned nach Timestamps in Panels**": the shared timestamp x is what
makes a vertical time-cursor line up across feeds in **both** modes (issue #101,
Fork A — timestamp-aligned panels).
The two modes differ only in **render** and in the **y-axis**, which is load-bearing
whenever feeds differ in magnitude (pip-equity ~±0.004 vs exposure ±1.0 — a single
shared y-scale would flatten the equity curve to a line):
- **Overlay** (default) — **one** plot, all series superimposed, **each on its own
auto-ranged y-scale** (uPlot's multi-scale: a named scale per series, independently
min/max-ranged). The first two series carry a drawn y-axis (left, right); any
further series keep their own auto-scale, read via the legend/tooltip. Suits a few
feeds you want superimposed in one frame.
- **Panels** (`--panels`) — **one stacked uPlot per series**, each on its own y-scale,
**all sharing the common union-spine timestamp x-axis** so a vertical time-cursor
lines up across the stacked panels (the "aligned nach Timestamps in Panels" the user
asked for). The clean choice when feeds are numerous or wildly different in
magnitude.
**Serve-time alignment (both modes), the exact mechanism — no privileged tap.**
`join_on_ts(spine, sides)` keys `sides` off a caller-supplied `spine` of
`(Timestamp, Vec<Scalar>)` rows and drops any side row whose ts is absent from the
spine (report.rs:498); it does **not** itself compute a union. `emit_chart` therefore
builds the alignment in three concrete steps, putting **no tap in the privileged
`JoinedRow.spine` slot**:
1. **Union x.** Concatenate every tap's `ts` array, sort, dedup → `xs: Vec<i64>`
the shared x-axis.
2. **Symmetric join.** Synthesize a spine of *empty-payload* rows
`xs.iter().map(|&t| (Timestamp(t), Vec::new()))`, and pass **all** taps (each
rebuilt via `ColumnarTrace::to_rows`) as the `sides` of
`join_on_ts(&spine, &all_tap_rows)`. Because the spine covers every tap's
timestamps, no side row is dropped; the synthetic spine's own empty payload is
never read. Every tap is a symmetric side — none occupies the never-gap-filled
`JoinedRow.spine`.
3. **Flatten to series.** Each tap contributes **one chart series per recorded
column** (the shipped `equity`/`exposure` taps are single-column → one series
each). Tap *i*'s column *c* maps to
`joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[c].as_f64()))` — the
value where tap *i* fired at that x, `None` (a uPlot gap) otherwise. The result is
`ChartData { xs: Vec<i64>, series: Vec<Series>, manifest, taps }`, where a
`Series = { name: String, y_scale_id: String, points: Vec<Option<f64>> }` (one
per (tap, column); the `y_scale_id` is what gives overlay its per-series y-scale).
Both modes consume the *same* `ChartData`: overlay draws all `series` on one plot
over `xs`; panels draw one stacked uPlot per `series`, all over the same `xs`.
For the first-cut sample harness the two taps (`equity`, `exposure`) are
**co-cadenced** (both fire every cycle), so the union spine is exactly their shared
timestamps; the per-series y-scales are what keep the ±0.004 equity legible beside the
±1.0 exposure. The **timestamp-aligned shared x (both modes)** is the source-mandated
contract (issue #101, Fork A); the **per-series y-scale** and the **union (vs.
one-tap) spine** are documented naive-viewer defaults settled under the
autonomous-build mandate and recorded on issue #101 — not user-picked design forks,
but documented orchestrator decisions within that mandate.
## Components
- **`ColumnarTrace`** (aura-engine): transpose/round-trip of one tap. Pure.
- **`TraceStore` + `RunTraces` + `TraceStoreError`** (aura-registry): the on-disk
per-run trace directory; write + read; missing = `NotFound`.
- **`render_chart_html` + `ChartMode` + `ChartData`** (aura-cli): page assembly.
- **Vendored assets** (aura-cli/assets): `uPlot.iife.min.js`, `uPlot.min.css`
(third-party, pinned + recorded in `refresh-assets.sh` like the existing
Graphviz-WASM/pan-zoom blobs), and `chart-viewer.js` (first-party, the
`AURA_TRACES` → uPlot bootstrap).
- **CLI** (aura-cli/main.rs): the shared `persist_traces` helper threaded into
`run_sample` / `run_macd` / `run_sample_real` (each `trace: Option<&str>`),
`emit_chart`, the new argv arms, and the serve-time union-spine `join_on_ts`
alignment shared by both chart modes.
## Data flow
```
aura run --trace demo
harness.run() -> recorder taps drained -> Vec<(Timestamp, Vec<Scalar>)> per tap
| |
| ColumnarTrace::from_rows (transpose, coerce, kind-tag)
| v
| TraceStore::write -> runs/traces/demo/{index,equity,exposure}.json
v
summarize -> RunReport -> stdout (UNCHANGED)
aura chart demo [--panels]
TraceStore::read(demo) -> RunTraces { manifest, taps: [ColumnarTrace] }
| |
| synthetic empty union-spine + ALL taps as sides -> join_on_ts -> ChartData
| { xs, series:[{name,y_scale_id,points: Option<f64> per x}] } (null = gap, no drop)
| overlay: ONE plot, all series, per-series y-scale
| panels: ONE stacked plot per series, all on the shared xs (timestamp-aligned)
v
render_chart_html(ChartData, mode) -> self-contained HTML -> stdout -> browser (uPlot)
```
## Error handling
- **Unknown run name** (`aura chart <name>` with no `runs/traces/<name>/`):
`TraceStore::read` returns `NotFound`; the CLI prints `aura: no recorded run
'<name>' under runs/traces` to stderr and `exit 2` — usage-error parity with the
rest of the CLI, never a panic. (Consistent with C18's treat-missing-as-absent.)
- **Corrupt / unreadable trace file**: `TraceStoreError::{Io, Parse{file,source}}`
surfaced like `RegistryError` (file named, propagated), `exit 2`.
- **`--trace` write failure** (I/O): propagated from the run path as a usage-level
error (stderr + `exit 2`); the run still computed its metrics, so the message
names the persistence step, not the run.
- **Empty taps** (a run that recorded nothing): `from_rows` yields empty arrays;
`render_chart_html` emits a page with an empty chart and a "no recorded samples"
note — no panic, no division-by-zero.
- **Bool/ts coercion**: documented and total (bool→1.0/0.0, ts→f64); `kinds`
preserves the real base type so the store stays honest (C7).
## Testing strategy
- **`ColumnarTrace` round-trip** (aura-engine unit): `from_rows` then `to_rows`
reproduces the row set; columnar shape asserted (ts length, column count =
kinds, per-kind coercion); empty-rows case.
- **`TraceStore` write→read** (aura-registry unit, temp dir): a written run reads
back equal (manifest + taps in index order); missing run = `NotFound`; a
corrupt tap file = `Parse{file,..}` with the file named. Temp-dir isolation
mirrors the registry's `temp_family_dir` pattern.
- **`render_chart_html` self-contained** (aura-cli unit, mirrors
`render_html_is_self_contained_and_embeds_the_model`): starts `<!doctype html>`,
closes `</html>`, no remote `<script src`, injects `window.AURA_TRACES =`, the
vendored uPlot global is present, overlay vs panels reflected in the injected
`mode`.
- **CLI e2e** (sibling of `tests/cli_run.rs`): `aura run --trace <tmp>` creates
`index.json` + per-tap files; a plain `aura run` writes nothing (default
unchanged); `aura chart <tmp>` emits HTML containing the equity series; `aura
chart <missing>` exits 2 with the diagnostic.
- **JS DOM behaviour** (node `.mjs`, mirroring `viewer_*.mjs`): load the emitted
page, assert uPlot built N series (overlay) / N panels (`--panels`) from
`AURA_TRACES`. Protects the one property a Rust test cannot: the page actually
renders the feeds.
Suggested iteration split for the planner (each independently shippable):
**iteration 1 (persist)** = `ColumnarTrace` + `TraceStore` + `aura run --trace`,
ending with traces on disk + round-trip green; **iteration 2 (serve)** = vendor
uPlot + `chart-viewer.js` + `render_chart_html` + `aura chart` + serve-time
alignment, ending with a chart in the browser.
## Acceptance criteria
1. `aura run --trace demo` (and `aura run --real <SYM> --trace demo`) writes
`runs/traces/demo/{index,equity,exposure}.json` in the columnar shape above; a
plain `aura run` / `aura run --real …` *without* `--trace` is byte-for-byte
unchanged on stdout and writes no trace files.
2. Each `<tap>.json` is the columnar SoA form — `ts` + one array per recorded
column, kind-tagged — and `ColumnarTrace` round-trips (`from_rows``to_rows`)
the recorded row set.
3. `aura chart demo` emits a **self-contained** HTML page (no remote fetch) that
charts the persisted feeds **overlaid** on a shared timestamp x-axis (aligned via
the union-spine `join_on_ts`, no tap's points dropped) with **each series on its
own auto-ranged y-scale** (§6) so the ±0.004 equity stays legible beside the ±1.0
exposure; `aura chart demo --panels` emits one stacked panel per series, **all
timestamp-aligned on the shared union-spine x** (a time-cursor lines up across the
stacked panels — the user's "aligned nach Timestamps in Panels"), each on its own
y-scale.
4. `aura chart <unknown>` exits 2 with a stderr diagnostic naming the missing run;
no panic on missing / empty / corrupt traces.
5. The engine gains no UI/pixel knowledge (C14): `aura-engine` only encodes
(struct→JSON), the registry crate does the I/O, the CLI does the browser
hand-off. Determinism (C1) and the no-in-graph-join rule (C3) are preserved —
persistence and alignment are post-run reductions.
6. `cargo build --workspace`, `cargo test --workspace`, and
`cargo clippy --workspace --all-targets -- -D warnings` are green.