From 5f31eccc7c77898922ca888bfc2f9782af4ab0bc Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 21 Jun 2026 17:39:20 +0200 Subject: [PATCH] spec: 0061 families-comparison view (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-side cycle: `aura chart ` overlays one tap (default equity) of all members of a sweep / MC / walk-forward family in one self-contained uPlot page, consistent across kinds via the existing union-ts spine. The view consumes a "set of runs to compare" (a family is its first producer) — the first step toward the programmable analysis meta-level (C21), without rebuilding the orchestration axes. aura-registry gains name_kind / read_family / ensure_name_free (a write-guard making name resolution a total function); aura-cli gains the comparison ChartData builder + emit_chart branch + --tap. aura-engine untouched (C9/C14). Brainstorm-ratified, grounding-check PASS (11/11 current-behaviour assumptions ratified by green tests). Signed under /boss on the grounding PASS. refs #107 --- docs/specs/0061-families-comparison-view.md | 250 ++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 docs/specs/0061-families-comparison-view.md diff --git a/docs/specs/0061-families-comparison-view.md b/docs/specs/0061-families-comparison-view.md new file mode 100644 index 0000000..6b44a36 --- /dev/null +++ b/docs/specs/0061-families-comparison-view.md @@ -0,0 +1,250 @@ +# Families-comparison view — Design Spec + +**Date:** 2026-06-21 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude +**Reference issue:** #107 (Brummel/Aura) + +## Goal + +Give `aura chart ` a **cross-member comparison view**: overlay one tap +(default `equity`) of **all members of a family** in a single self-contained +uPlot page, consistent across sweep / Monte-Carlo / walk-forward families. Today +`aura chart ` charts a single run, and `aura chart /` a single +member; the family-as-a-whole comparison that the single-run trace charts (#101) +deferred is what this cycle adds. + +The view is **decoupled from family production**: it consumes a *set of runs to +compare*; a family is the first producer of such a set. This is the first concrete +step toward the programmable analysis meta-level (C21) — the orchestration axes +becoming composable tools that feed a general comparable-run-set — **without** +rebuilding those axes this cycle. + +## Architecture + +A pure read-side addition, the same three-layer split as the single-run chart +(#101 / cycle 0056): + +- **`aura-engine`** — **untouched** (headless serializer, C14). `join_on_ts` and + `ColumnarTrace` already perform the timestamp alignment the comparison needs. +- **`aura-registry`** — owner of the trace store + families lineage (C18). Gains + the *set-of-runs resolver* (`read_family`), the *name classifier* (`name_kind`), + and the *write-guard* (`ensure_name_free`). This is the C21-pointing seam: a + comparison set is `Vec`; a family is its first producer, and future + meta-level producers dock at the same shape. +- **`aura-cli`** — gains the comparison `ChartData` builder, the `emit_chart` + branch on name kind, and `--tap` parsing; **reuses `render_chart_html` + unchanged** (overlay default, `--panels` small-multiples). Calls the write-guard + once per tracing command. + +The whole change lives in `aura-registry` + `aura-cli` (C9 — exactly like #101). + +## Concrete code shapes + +### User-facing program (the acceptance-criterion evidence) + +```text +# A researcher sweeps a strategy, tracing every member (existing): +$ aura sweep --strategy sma --trace grid +{"family_id":"grid-1","report":{"manifest":{"commit":...}}} # one line per member +... + +# NEW — chart the whole family in one comparison view: +$ aura chart grid > grid.html +# grid.html: a self-contained uPlot page; one equity curve PER MEMBER, all on +# ONE shared y-scale (pips), each labelled by its member_key (cycle 0059's +# portable key, e.g. "longonly.enabled-false"), aligned on the union-ts spine. + +$ aura chart grid --tap exposure # overlay a different tap across members +$ aura chart grid --panels # small-multiples: one mini-chart per member +$ aura chart grid/ # UNCHANGED: a single member (subpath → single-run path) +$ aura chart somerun # UNCHANGED: a single run, all its taps overlaid + +# Refuse-don't-guess (all exit 2, never panic): +$ aura chart nope # no recorded run/family 'nope' +$ aura chart grid --tap nosuch # family members have no tap 'nosuch' +$ aura run --trace grid # 'grid' is already a family — refuse to reuse the name +``` + +The default — no `--tap` — differs by path and is deliberately so: a single run +shows **all** its taps overlaid (unchanged); a family overlays **one** tap +(`equity`), because all-taps × all-members would be unreadable. `--tap ` +restricts to one tap on **both** paths. + +### Implementation shape (secondary) + +**`aura-registry/src/trace_store.rs`** — new public surface beside `read`/`write`: + +```rust +// NEW: the on-disk shape of a name. +pub enum NameKind { Run, Family, NotFound } + +// NEW: one member of a family, read back. +pub struct FamilyMember { pub key: String, pub traces: RunTraces } + +// NEW: which kind a write intends, for the guard. +pub enum WriteKind { Run, Family } + +impl TraceStore { + // top-level index.json present -> Run + // else ≥1 immediate subdir has index.json -> Family + // else -> NotFound + pub fn name_kind(&self, name: &str) -> NameKind { ... } + + // Enumerate immediate subdirs of traces// that contain index.json, + // read each via the existing read("/"), collect FamilyMember + // { key = subdir name, traces }, SORTED BY key (deterministic, C1). + // A member subdir whose index.json exists but a tap file is malformed + // propagates TraceStoreError::Parse{file,..}. + pub fn read_family(&self, name: &str) -> Result, TraceStoreError> { ... } + + // The write-guard. Refuse cross-kind name reuse: + // WriteKind::Run && name_kind == Family -> Err(NameTaken{..}) + // WriteKind::Family && name_kind == Run -> Err(NameTaken{..}) + // Same-kind (or NotFound) -> Ok(()). Called ONCE per command, before any write. + pub fn ensure_name_free(&self, name: &str, intent: WriteKind) -> Result<(), TraceStoreError> { ... } +} + +// NEW error variant; Display: "'' already used as a run" / "... as a family". +// TraceStoreError::NameTaken { name: String, existing: NameKind } +``` + +**`aura-cli/src/main.rs`** — branch the chart verb; guard the tracing commands: + +```rust +// before: +["chart", name] => emit_chart(name, ChartMode::Overlay), +["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels), +// after: a parse_chart_args helper (idiom of parse_sweep_args/parse_real_args), +// accepting ` [--tap ] [--panels]` in any order. +["chart", rest @ ..] => match parse_chart_args(rest) { + Ok((name, tap, mode)) => emit_chart(&name, tap.as_deref(), mode), + Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } +}, + +// emit_chart gains the tap selector and branches on name kind: +fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) { + let store = TraceStore::open("runs"); + match store.name_kind(name) { + NameKind::Run => { + let data = build_chart_data(store.read(name)?); // existing path + let data = match tap { Some(t) => filter_to_tap(data, t)?, None => data }; + print!("{}", render::render_chart_html(&data, mode)); + } + NameKind::Family => { + let members = store.read_family(name)?; // propagate Parse → exit 2 + let data = build_comparison_chart_data(&members, tap.unwrap_or("equity"))?; + print!("{}", render::render_chart_html(&data, mode)); + } + NameKind::NotFound => { eprintln!("aura: no recorded run/family '{name}' ..."); exit(2); } + } +} + +// NEW: one Series per member (the chosen tap), labelled by member.key, all sharing +// ONE y-scale (same quantity), aligned on the union-ts spine via the existing +// join_on_ts — the mirror of build_chart_data with members-as-sides instead of +// taps-as-sides. Err if NO member carries `tap` (refuse-don't-guess). +fn build_comparison_chart_data(members: &[FamilyMember], tap: &str) -> Result { ... } +``` + +The write-guard call sites (once per command, when `--trace` is set): the single-run +trace path (`run`/`run --macd`/`run --real`) calls `ensure_name_free(name, +WriteKind::Run)`; the family paths (`run_sweep`/`run_mc`/`run_walkforward`) call +`ensure_name_free(name, WriteKind::Family)` **before** the member loop. On `Err`, +print and `exit(2)`. + +## Components + +1. **`TraceStore::name_kind`** — classifier; the read-side half of the total + name-resolution function. +2. **`TraceStore::read_family`** — the set-of-runs resolver; members sorted by key. +3. **`TraceStore::ensure_name_free`** — the write-guard; makes the only ambiguous + on-disk state (a name used by both a run and a family) unreachable. +4. **`build_comparison_chart_data`** (cli) — one shared-y-scale series per member. +5. **`emit_chart` branch + `parse_chart_args` + `--tap`** (cli) — the CLI surface. + +## Data flow + +```text +aura chart grid [--tap equity] [--panels] + → name_kind("grid") = Family + → read_family("grid") = [FamilyMember{key:"…-2_…-4", traces}, …] (sorted) + → build_comparison_chart_data(members, "equity"): + xs = sorted union of every member's equity-tap timestamps + per member: ONE Series over xs (null where absent, via join_on_ts), + name = member.key, y_scale_id = one shared id + → render_chart_html(&ChartData, mode) → self-contained uPlot page → stdout +``` + +**Cross-kind consistency from one mechanism.** Sweep/MC members share the data +window → the union spine is their shared axis → a true overlay. Walk-forward +members are *disjoint* OOS windows → each null outside its segment → the same union +spine renders them as the *stitched* curve. The ordinal-x default (cycle 0057) +carries both (index over the union spine). No per-family-kind special-casing — one +builder, three correct readings. + +## Error handling + +Refuse-don't-guess throughout (C10/C18); every failure is `exit 2`, never a panic: + +- `aura chart ` → `name_kind` = `NotFound` → exit 2. +- A `traces//` directory with no top-level `index.json` and no member subdir + carrying `index.json` → `NotFound` → exit 2 (empty/ill-formed family treated as + absent — the same treat-as-absent discipline as `read`'s `NotFound`). +- A family member whose `index.json` is present but a tap file is malformed → + `TraceStoreError::Parse{file,..}` propagated → exit 2, naming the file. +- `--tap ` where **no** member carries tap `t` → exit 2, naming the tap (members + of one family share a harness, so a tap is present in all or none). +- Write-guard cross-kind collision → `TraceStoreError::NameTaken` → exit 2, + `"'' already used as a run/family; pick another --trace name"`. + +## Testing strategy + +**`aura-registry` unit (`trace_store.rs`):** +- `read_family` over a constructed family of 2–3 member subdirs → members **sorted + by key**; a stray non-`index.json` file/dir is ignored; a dir with no member + `index.json` → empty; round-trips with `write`. +- `name_kind` → `Run` for a single-run dir, `Family` for a member-subdir dir, + `NotFound` for an absent / empty name. +- `ensure_name_free` → `Err(NameTaken)` for both collision directions (Run-over- + Family and Family-over-Run); `Ok` for same-kind and for a fresh name. + +**`aura-cli` unit (`main.rs`):** +- `build_comparison_chart_data` over 2 members with **shared** timestamps → 2 + series, labelled by key, all sharing one `y_scale_id`, aligned (no nulls). +- over 2 members with **disjoint** timestamps (walk-forward-like) → 2 series, + null-complementary on the union spine (the stitched shape). +- `--tap` naming an absent tap → `Err`. + +**`aura-cli` integration (`tests/cli_run.rs`):** +- `aura sweep --trace t` then `aura chart t` → a page whose injected + `window.AURA_TRACES` carries **N member series** with the member-key labels. +- `aura chart unknown` → exit 2. +- collision: `aura run --trace t` then `aura sweep --trace t` → exit 2 (and the + reverse order). +- regression: `aura chart ` (no `--tap`) → unchanged all-taps page. + +**Post-cycle:** a `tester` E2E that a real `aura chart ` page renders. + +## Acceptance criteria + +- `aura chart ` emits a self-contained uPlot page overlaying one tap per + member across **all three** family kinds, members sharing one y-scale, labelled + by member key — driven by one builder, no per-kind branching. +- Name resolution is a **total function**: every name → exactly one of + {single-run, family, not-found}; the cross-kind collision state is unreachable + (write-guard). +- Every error path is `exit 2`, never a panic. +- The single-run chart path is **byte-unchanged** when no `--tap` is given + (existing `cli_run.rs` chart tests stay green); `aura-engine` is untouched. +- Determinism (C1): `read_family` order is by key; a given family renders + identically across runs. + +## Out of scope (unblocked follow-ons, not this cycle) + +- The run-context header on the chart page (#102). +- The multi-output viewer case (#47). +- The **orchestration-composability rebuild** — sweep/MC/WFO as composable tools in + a programmable analysis meta-level (the larger C21 cycle). +- Stale-member cleanup on family re-run; level-of-detail / sampling for very large + families (the #101 scale-path non-goals carry over).