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
12 KiB
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_tsandColumnarTracealready 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 isVec<FamilyMember>; a family is its first producer, and future meta-level producers dock at the same shape.aura-cli— gains the comparisonChartDatabuilder, theemit_chartbranch on name kind, and--tapparsing; reusesrender_chart_htmlunchanged (overlay default,--panelssmall-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)
# 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:
// 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:
// 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
TraceStore::name_kind— classifier; the read-side half of the total name-resolution function.TraceStore::read_family— the set-of-runs resolver; members sorted by key.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.build_comparison_chart_data(cli) — one shared-y-scale series per member.emit_chartbranch +parse_chart_args+--tap(cli) — the CLI surface.
Data flow
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-levelindex.jsonand no member subdir carryingindex.json→NotFound→ exit 2 (empty/ill-formed family treated as absent — the same treat-as-absent discipline asread'sNotFound). - A family member whose
index.jsonis present but a tap file is malformed →TraceStoreError::Parse{file,..}propagated → exit 2, naming the file. --tap <t>where no member carries tapt→ 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_familyover a constructed family of 2–3 member subdirs → members sorted by key; a stray non-index.jsonfile/dir is ignored; a dir with no memberindex.json→ empty; round-trips withwrite.name_kind→Runfor a single-run dir,Familyfor a member-subdir dir,NotFoundfor an absent / empty name.ensure_name_free→Err(NameTaken)for both collision directions (Run-over- Family and Family-over-Run);Okfor same-kind and for a fresh name.
aura-cli unit (main.rs):
build_comparison_chart_dataover 2 members with shared timestamps → 2 series, labelled by key, all sharing oney_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).
--tapnaming an absent tap →Err.
aura-cli integration (tests/cli_run.rs):
aura sweep --trace tthenaura chart t→ a page whose injectedwindow.AURA_TRACEScarries N member series with the member-key labels.aura chart unknown→ exit 2.- collision:
aura run --trace tthenaura 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
--tapis given (existingcli_run.rschart tests stay green);aura-engineis untouched. - Determinism (C1):
read_familyorder 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).