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:
@@ -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 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 <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).
|
||||
Reference in New Issue
Block a user