Files
Aura/docs/specs/0061-families-comparison-view.md
T
Brummel 5f31eccc7c 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
2026-06-21 17:39:20 +02:00

12 KiB
Raw Blame History

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-engineuntouched (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)

# 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

  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

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.jsonNotFound → 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_kindRun for a single-run dir, Family for a member-subdir dir, NotFound for an absent / empty name.
  • ensure_name_freeErr(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).