spec: 0061 families-comparison view (boss-signed)

A read-side cycle: `aura chart <family>` 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
This commit is contained in:
2026-06-21 17:39:20 +02:00
parent e1ad0979de
commit 5f31eccc7c
+250
View File
@@ -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 <family>` 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 <name>` charts a single run, and `aura chart <name>/<member>` 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<FamilyMember>`; 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/<member_key> # 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 <name>`
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/<name>/ that contain index.json,
// read each via the existing read("<name>/<subdir>"), 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<Vec<FamilyMember>, 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: "'<name>' 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 `<name> [--tap <t>] [--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<ChartData, String> { ... }
```
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 <unknown>``name_kind` = `NotFound` → exit 2.
- A `traces/<name>/` 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 <t>` 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,
`"'<name>' 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 23 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 <single-run>` (no `--tap`) → unchanged all-taps page.
**Post-cycle:** a `tester` E2E that a real `aura chart <family>` page renders.
## Acceptance criteria
- `aura chart <family>` 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).