audit(families-view): cycle close 0061 — drift-clean, ledger records the comparison view

Architect drift review over 99fd32b..HEAD (the housekeeping commits #99/#98 plus
the 0061 cycle): code drift-clean. Engine untouched (aura-engine + aura-core diff
empty — C9/C14); C21 realized faithfully (the comparison view consumes a set of
runs, a family is the first producer, no orchestration-axis rebuild); C18/C10
refuse-don't-guess honoured (every new error path exits 2, never panics; all six
tracing entry points guarded before persist, so name resolution is total). The only
finding was a ledger-realization gap, lifted in this commit.

Ledger (docs/design/INDEX.md): added a C22 amendment recording the
families-comparison view as realized (#107) — aura chart <family> overlays one tap
across a family's members on a shared y-scale, union-ts spine giving one mechanism /
three correct readings, the write-guard totalising name resolution; reconciled the
two stale "families-comparison ... remain open" lines (the #104 note and the
open-threads list) and named the deferred follow-ons (#102 run-context header, #47
multi-column tap selection, the orchestration-composability rebuild).

Regression: cargo test --workspace green (no failures), clippy --workspace
--all-targets -D warnings exit 0, cargo build --workspace clean — verified by the
orchestrator before close. No baseline to update (aura's regression gate is the
gated determinism suite, which is part of the workspace tests).

Retire the cycle's ephemeral spec + plan (0061). refs #107
This commit is contained in:
2026-06-21 18:58:32 +02:00
parent 4c64feb9ed
commit 53eeba5e16
3 changed files with 36 additions and 919 deletions
+36 -4
View File
@@ -1040,7 +1040,8 @@ targets disjoint member dirs, so it is lock-free. Opt-in: without `--trace`,
stdout/registry are byte-unchanged. Because `TraceStore` resolves `<name>/<member_key>`
as a subpath, `aura chart <name>/<member_key>` charts any single member with no
view-side change — the write-side precondition for the family-comparison **view**
(overlay / small-multiples across members), which itself remains open.
(overlay / small-multiples across members) — now realized (#107; see the
families-comparison amendment below).
**Amendment (real-data family source, #106, 8e5d14b).** The family runs gain an
**opt-in real-data source axis**: `aura sweep|walkforward --real <SYMBOL> [--from
@@ -1067,6 +1068,34 @@ sizes — is a *demo-strategy calibration* patch; the real answer is project-aut
strategies (C9: a project crate owns its own grid), deferred to the project-env
work.
**Amendment (families-comparison view, #107, 4c64feb).** The family-comparison
**view** the #104 amendment left open is now realized. `aura chart <name>`
classifies the name on disk (`TraceStore::name_kind`: a top-level `index.json` is a
single run; else member subdirs with `index.json` are a family; else not-found) and,
for a family, overlays **one tap** (default `equity`, `--tap` to pick) of **every
member** in one self-contained uPlot page: `build_comparison_chart_data` emits one
labelled series per member, all on a **single shared y-scale** — members measure one
identical quantity, so a shared scale is what makes them comparable, unlike the
single-run overlay of *different* taps (per-series scales). The series align on the
same union-ts spine via `join_on_ts`, so **one mechanism yields three correct
readings**: sweep/MC members share the data window (a true overlay), walk-forward
members are disjoint OOS windows (null-complementary → the stitched curve). The view
consumes a **set of runs** (`&[FamilyMember]` from `TraceStore::read_family`, sorted
by key for determinism, C1); a family is its *first producer* — the deliberate first
step toward the programmable analysis meta-level (C21) **without** rebuilding the
orchestration axes (that is its own later cycle). Name resolution is made a **total
function** by the write-guard `TraceStore::ensure_name_free`, called once per tracing
command (`run`/`run --macd`/`run --real` → Run; `sweep`/`mc`/`walkforward` → Family)
to refuse cross-kind name reuse — so the one ambiguous on-disk state (a name used by
both a run and a family) is unreachable. Every error path exits 2, never panics
(C18/C10 refuse-don't-guess). Engine untouched (C9/C14): the whole change is
`aura-registry` (the resolver + classifier + guard) + `aura-cli` (the builder +
`emit_chart` name-kind branch + `--tap`); the single-run page is byte-unchanged when
no `--tap` is given. **Still deferred:** the run-context header on the served page
(#102); multi-column tap selection (`build_comparison_chart_data` projects column 0 —
the comparison taps in scope are single-column f64; #47); and the
orchestration-composability rebuild (sweep/MC/WFO as composable meta-level tools).
### C23 — Graph compilation and behaviour-preserving optimisation
**Guarantee.** The bootstrap (C19) is a **compilation**: it lowers a param-generic,
named **blueprint** (the authoring source — nodes, composites, strategy, harness;
@@ -1133,9 +1162,12 @@ load-bearing in the flat graph.
#101; not egui). Settled for the first cut: raw per-tap trace persistence to
disk (columnar/SoA form, C7) + serve-time `join_on_ts` alignment + a static
self-contained HTML chart page (uPlot, vendored like the `render_html`
Graphviz-WASM blob), feeds overlaid or timestamp-aligned in panels. Still open:
families-comparison meta-views (walk-forward, sweep surfaces, multi-strategy /
instrument comparison), a local server, and the run/replay clock controls.
Graphviz-WASM blob), feeds overlaid or timestamp-aligned in panels. The
families-comparison **view**`aura chart <family>` overlaying one tap across a
family's members on a shared y-scale — shipped (#107, see the C22 amendment).
Still open: richer comparison meta-views (sweep-surface heatmaps, cross-family /
multi-strategy comparison, the run-context header #102, multi-column tap selection
#47), a local server, and the run/replay clock controls.
- **Parameter-space search strategies** (Bayesian/genetic) — pluggable policies
atop the atomic sim unit (C12), not yet designed.
- **`aura new` scaffolder, the experiment-builder API, and `Aura.toml`'s
-665
View File
@@ -1,665 +0,0 @@
# Families-comparison view — Implementation Plan
> **Parent spec:** `docs/specs/0061-families-comparison-view.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** `aura chart <family>` overlays one tap (default `equity`) of all members
of a sweep / MC / walk-forward family in one self-contained uPlot page, with a
write-guard making name resolution a total function.
**Architecture:** Pure read-side, three layers. `aura-registry` gains name
classification (`name_kind`), the set-of-runs resolver (`read_family`), and the
write-guard (`ensure_name_free` + a `NameTaken` error). `aura-cli` gains the
comparison `ChartData` builder (one shared-y-scale series per member, union-ts
spine via the existing `join_on_ts`), a `parse_chart_args` helper, the `--tap`
flag, the `emit_chart` name-kind branch, and the write-guard calls. `aura-engine`
is untouched.
**Tech Stack:** Rust; `aura-registry/src/trace_store.rs`, `aura-registry/src/lib.rs`,
`aura-cli/src/main.rs`, `aura-cli/tests/cli_run.rs`. Build `cargo build --workspace`;
test `cargo test -p <crate>` / `cargo test --workspace`; lint
`cargo clippy --workspace --all-targets -- -D warnings`.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-registry/src/trace_store.rs` — add `NameKind`,
`FamilyMember`, `WriteKind`, `name_kind`, `read_family`, `ensure_name_free`,
`TraceStoreError::NameTaken` + its Display arm; new unit tests in `mod tests`.
- Modify: `crates/aura-registry/src/lib.rs:30-31` — re-export the three new types.
- Modify: `crates/aura-cli/src/main.rs` — import the new types; add
`build_comparison_chart_data`, `filter_to_tap`, `parse_chart_args`; rewrite the
`emit_chart` body + the chart dispatch arm; update `USAGE`; add `ensure_name_free`
calls in the six tracing fns; new unit tests in `mod tests`.
- Test: `crates/aura-cli/tests/cli_run.rs` — family-chart, unknown-exit-2,
collision, and single-run-regression integration tests.
---
## Task 1: Registry — name classification, family resolver, write-guard
**Files:**
- Modify: `crates/aura-registry/src/trace_store.rs:14-131`
- Modify: `crates/aura-registry/src/lib.rs:30-31`
- [ ] **Step 1: Write the failing unit tests**
Append these three tests inside the existing `#[cfg(test)] mod tests` block in
`crates/aura-registry/src/trace_store.rs` (after `corrupt_tap_file_is_a_parse_error_naming_the_file`, before the closing `}` at :210). They use the existing `temp_traces_root` / `sample_manifest` / `sample_taps` helpers and `super::*`.
```rust
#[test]
fn name_kind_classifies_run_family_and_absent() {
let root = temp_traces_root("namekind");
let store = TraceStore::open(&root);
store.write("solo", &sample_manifest(), &sample_taps()).expect("write solo");
store.write("fam/m1", &sample_manifest(), &sample_taps()).expect("write m1");
assert_eq!(store.name_kind("solo"), NameKind::Run);
assert_eq!(store.name_kind("fam"), NameKind::Family);
assert_eq!(store.name_kind("ghost"), NameKind::NotFound);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn read_family_returns_members_sorted_ignoring_strays() {
let root = temp_traces_root("readfamily");
let store = TraceStore::open(&root);
// members written out of order; read_family must sort by key.
store.write("fam/b", &sample_manifest(), &sample_taps()).expect("write b");
store.write("fam/a", &sample_manifest(), &sample_taps()).expect("write a");
// a stray file directly under the family dir is ignored (no index.json there).
fs::write(root.join("traces/fam/stray.txt"), "x").expect("stray");
let members = store.read_family("fam").expect("read_family");
let keys: Vec<&str> = members.iter().map(|m| m.key.as_str()).collect();
assert_eq!(keys, vec!["a", "b"]);
assert_eq!(members[0].traces.taps.len(), 2);
// an absent family reads as empty (treat-as-absent).
assert!(store.read_family("ghost").expect("ghost").is_empty());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn ensure_name_free_refuses_cross_kind_reuse() {
let root = temp_traces_root("guard");
let store = TraceStore::open(&root);
store.write("asrun", &sample_manifest(), &sample_taps()).expect("write run");
store.write("asfam/m1", &sample_manifest(), &sample_taps()).expect("write fam");
assert!(matches!(
store.ensure_name_free("asrun", WriteKind::Family),
Err(TraceStoreError::NameTaken { .. })
));
assert!(matches!(
store.ensure_name_free("asfam", WriteKind::Run),
Err(TraceStoreError::NameTaken { .. })
));
// same-kind re-run and a fresh name are fine.
assert!(store.ensure_name_free("asrun", WriteKind::Run).is_ok());
assert!(store.ensure_name_free("asfam", WriteKind::Family).is_ok());
assert!(store.ensure_name_free("fresh", WriteKind::Run).is_ok());
let _ = fs::remove_dir_all(&root);
}
```
- [ ] **Step 2: Run the tests to verify they fail (do not compile)**
Run: `cargo test -p aura-registry`
Expected: FAIL — compile errors `cannot find type NameKind`, `cannot find type WriteKind`, `no method named name_kind/read_family/ensure_name_free`, `no variant NameTaken`.
- [ ] **Step 3: Add the new public types**
In `crates/aura-registry/src/trace_store.rs`, immediately after the `RunTraces`
struct (after :25), insert:
```rust
/// The on-disk shape of a trace name: a single run, a family of members, or absent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameKind {
/// `traces/<name>/index.json` is present — a single recorded run.
Run,
/// No top-level `index.json`, but ≥1 immediate subdir has one — a family.
Family,
/// Neither — no recorded run or family of this name.
NotFound,
}
/// One member of a family, read back: its key (the member-dir name) + its traces.
#[derive(Debug)]
pub struct FamilyMember {
pub key: String,
pub traces: RunTraces,
}
/// Which kind of write a name is about to receive — the write-guard's intent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteKind {
Run,
Family,
}
```
- [ ] **Step 4: Add the three methods to `impl TraceStore`**
In the same file, inside `impl TraceStore`, after `read` (after :97), insert:
```rust
/// Classify a name by its on-disk shape (the read-side of total name
/// resolution). Top-level `index.json` -> `Run`; else ≥1 immediate subdir with
/// `index.json` -> `Family`; else `NotFound`.
pub fn name_kind(&self, name: &str) -> NameKind {
let run_dir = self.dir.join(name);
if run_dir.join("index.json").is_file() {
return NameKind::Run;
}
if let Ok(entries) = fs::read_dir(&run_dir) {
for entry in entries.flatten() {
if entry.path().join("index.json").is_file() {
return NameKind::Family;
}
}
}
NameKind::NotFound
}
/// Read every member of a family: each immediate subdir of `traces/<name>/`
/// that carries an `index.json`, read via `read("<name>/<key>")`, collected and
/// **sorted by key** (deterministic, C1). An absent/empty family reads as an
/// empty Vec (treat-as-absent); a malformed member propagates its `Parse` error.
pub fn read_family(&self, name: &str) -> Result<Vec<FamilyMember>, TraceStoreError> {
let run_dir = self.dir.join(name);
let entries = match fs::read_dir(&run_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(TraceStoreError::Io(e)),
};
let mut members = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.path().join("index.json").is_file() {
continue; // stray file or non-member subdir
}
let key = match entry.file_name().into_string() {
Ok(k) => k,
Err(_) => continue, // non-UTF8 dir name: skip
};
let traces = self.read(&format!("{name}/{key}"))?;
members.push(FamilyMember { key, traces });
}
members.sort_by(|a, b| a.key.cmp(&b.key));
Ok(members)
}
/// The write-guard: refuse cross-kind reuse of a trace name, so the only
/// ambiguous on-disk state (a name used by both a run and a family) is
/// unreachable. Same-kind (overwrite) and a fresh name are `Ok`. Called once
/// per command before any write.
pub fn ensure_name_free(&self, name: &str, intent: WriteKind) -> Result<(), TraceStoreError> {
match (intent, self.name_kind(name)) {
(WriteKind::Run, NameKind::Family) => {
Err(TraceStoreError::NameTaken { name: name.to_string(), existing: NameKind::Family })
}
(WriteKind::Family, NameKind::Run) => {
Err(TraceStoreError::NameTaken { name: name.to_string(), existing: NameKind::Run })
}
_ => Ok(()),
}
}
```
- [ ] **Step 5: Add the `NameTaken` variant + its Display arm**
In the `TraceStoreError` enum (:101-109), add a variant before the closing `}`:
```rust
/// A trace name already used by the other kind (run vs family) — refuse reuse.
NameTaken { name: String, existing: NameKind },
```
In `impl fmt::Display for TraceStoreError` (:111-123), add an arm in the `match`:
```rust
TraceStoreError::NameTaken { name, existing } => {
let kind = match existing {
NameKind::Run => "run",
NameKind::Family => "family",
NameKind::NotFound => "name",
};
write!(f, "'{name}' already used as a {kind}; pick another --trace name")
}
```
- [ ] **Step 6: Re-export the new types**
In `crates/aura-registry/src/lib.rs:30-31`, extend the `pub use trace_store::{...}`
line to include the three new types. Add `FamilyMember, NameKind, WriteKind` to the
brace list (alongside the existing `RunTraces, TraceStore, TraceStoreError`).
- [ ] **Step 7: Run the tests to verify they pass**
Run: `cargo test -p aura-registry`
Expected: PASS — `test result: ok.` with the three new tests plus the existing
`trace_store` tests all passing.
---
## Task 2: CLI — comparison view (builder, parse, dispatch, emit_chart)
**Files:**
- Modify: `crates/aura-cli/src/main.rs:24-27` (import), `:250-272` (builder),
`:277-290` (emit_chart), `:1396-1397` (USAGE), `:1418-1419` (dispatch),
`:1453-2120` (unit tests)
- Test: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Write the failing builder unit tests**
Append inside `crates/aura-cli/src/main.rs`'s `#[cfg(test)] mod tests` (after the
`use super::*;` region, beside the other unit tests). They construct
`FamilyMember`s directly (its fields are `pub`):
```rust
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> aura_registry::FamilyMember {
let rows: Vec<(Timestamp, Vec<Scalar>)> =
ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect();
let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
aura_registry::FamilyMember {
key: key.to_string(),
traces: aura_registry::RunTraces {
manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0),
taps: vec![tap],
},
}
}
#[test]
fn comparison_overlays_one_shared_scale_series_per_member() {
let members = vec![
cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]),
cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]),
];
let data = build_comparison_chart_data(&members, "equity").expect("builds");
assert_eq!(data.xs, vec![1, 2, 3]);
assert_eq!(data.series.len(), 2);
assert_eq!(data.series[0].name, "a");
assert_eq!(data.series[1].name, "b");
// ONE shared y-scale across members (same quantity).
assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id);
// shared ts -> dense, no nulls.
assert!(data.series[0].points.iter().all(Option::is_some));
}
#[test]
fn comparison_disjoint_members_are_null_complementary() {
let members = vec![
cmp_member("oos1", &[1, 2], &[10.0, 11.0]),
cmp_member("oos2", &[3, 4], &[20.0, 21.0]),
];
let data = build_comparison_chart_data(&members, "equity").expect("builds");
assert_eq!(data.xs, vec![1, 2, 3, 4]);
assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]);
assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]);
}
#[test]
fn comparison_errors_when_no_member_has_the_tap() {
let members = vec![cmp_member("a", &[1], &[1.0])];
assert!(build_comparison_chart_data(&members, "nosuch").is_err());
}
```
- [ ] **Step 2: Run the unit tests to verify they fail (do not compile)**
Run: `cargo test -p aura-cli`
Expected: FAIL — compile error `cannot find function build_comparison_chart_data`
and `FamilyMember`/`RunTraces` unresolved in `aura_registry`.
- [ ] **Step 3: Import the new registry types**
In `crates/aura-cli/src/main.rs`, the `use aura_registry::{...}` block (:24-27),
add `FamilyMember` and `NameKind` to the brace list. (`WriteKind` is added in
Task 3.)
- [ ] **Step 4: Add `build_comparison_chart_data` and `filter_to_tap`**
In `crates/aura-cli/src/main.rs`, immediately after `build_chart_data` (after :272),
insert:
```rust
/// Build the comparison `ChartData` for a family: one `Series` per member (the
/// chosen `tap`'s column), labelled by `member.key`, ALL sharing ONE `y_scale_id`
/// (the members measure one identical quantity, so a shared scale is what makes
/// them comparable — unlike the single-run overlay, whose series are different
/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data
/// uses. `Err` if NO member carries `tap` (refuse-don't-guess).
fn build_comparison_chart_data(
members: &[FamilyMember],
tap: &str,
) -> Result<ChartData, String> {
let mut member_rows: Vec<(String, Vec<(Timestamp, Vec<Scalar>)>)> = Vec::new();
for m in members {
if let Some(t) = m.traces.taps.iter().find(|t| t.tap == tap) {
member_rows.push((m.key.clone(), t.to_rows()));
}
}
if member_rows.is_empty() {
return Err(format!("no family member has a tap named '{tap}'"));
}
let mut xs: Vec<i64> =
member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect();
xs.sort_unstable();
xs.dedup();
let spine: Vec<(Timestamp, Vec<Scalar>)> =
xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> =
member_rows.iter().map(|(_, r)| r.as_slice()).collect();
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
// One shared y-scale across all member series (same quantity).
let y_scale_id = format!("y_cmp_{tap}");
let mut series: Vec<Series> = Vec::new();
for (i, (key, _)) in member_rows.iter().enumerate() {
let points: Vec<Option<f64>> =
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect();
series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points });
}
Ok(ChartData { xs, series })
}
/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the
/// run has no such tap (refuse-don't-guess). Used by the `--tap` flag on the
/// single-run chart path; without `--tap` the single-run page is unchanged.
fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
let series: Vec<Series> = data.series.into_iter().filter(|s| s.name == tap).collect();
if series.is_empty() {
return Err(format!("run has no tap named '{tap}'"));
}
Ok(ChartData { xs: data.xs, series })
}
```
- [ ] **Step 5: Add `parse_chart_args` and rewrite `emit_chart`**
In `crates/aura-cli/src/main.rs`, add the parser beside the other arg parsers
(e.g. after `parse_real_args`, ~:395):
```rust
/// Parse `aura chart` args: `<name> [--tap <t>] [--panels]` in any order.
fn parse_chart_args(rest: &[&str]) -> Result<(String, Option<String>, ChartMode), String> {
let mut name: Option<String> = None;
let mut tap: Option<String> = None;
let mut mode = ChartMode::Overlay;
let mut i = 0;
while i < rest.len() {
match rest[i] {
"--panels" => {
mode = ChartMode::Panels;
i += 1;
}
"--tap" => {
let t = rest.get(i + 1).ok_or("--tap needs a value")?;
tap = Some((*t).to_string());
i += 2;
}
other if !other.starts_with("--") && name.is_none() => {
name = Some(other.to_string());
i += 1;
}
other => return Err(format!("unexpected chart argument '{other}'")),
}
}
let name = name.ok_or("chart needs a <name>")?;
Ok((name, tap, mode))
}
```
Replace the entire body of `emit_chart` (:277-290) with this signature + body:
```rust
/// `aura chart <name> [--tap <t>] [--panels]`: classify the name and render. A
/// single run charts all its taps (or the one `--tap` selects); a family overlays
/// one tap (default `equity`) across its members; an unknown name is a usage error
/// (stderr + exit 2), never a panic.
fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) {
let store = TraceStore::open("runs");
match store.name_kind(name) {
NameKind::Run => {
let traces = match store.read(name) {
Ok(t) => t,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let mut data = build_chart_data(traces);
if let Some(t) = tap {
data = match filter_to_tap(data, t) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
}
print!("{}", render::render_chart_html(&data, mode));
}
NameKind::Family => {
let members = match store.read_family(name) {
Ok(m) => m,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
let data = match build_comparison_chart_data(&members, tap.unwrap_or("equity")) {
Ok(d) => d,
Err(e) => {
eprintln!("aura: {e}");
std::process::exit(2);
}
};
print!("{}", render::render_chart_html(&data, mode));
}
NameKind::NotFound => {
eprintln!(
"aura: no recorded run or family '{name}' under runs/traces \
(run `aura run --trace {name}` or `aura sweep --trace {name}` first)"
);
std::process::exit(2);
}
}
}
```
- [ ] **Step 6: Rewire the chart dispatch + USAGE**
In `main()`, replace the two chart arms (:1418-1419):
```rust
["chart", name] => emit_chart(name, ChartMode::Overlay),
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
```
with one rest-match arm:
```rust
["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);
}
},
```
In the `USAGE` const (:1397), change the token `aura chart <name> [--panels]` to
`aura chart <name> [--tap <t>] [--panels]`.
- [ ] **Step 7: Run unit tests + lint to verify green**
Run: `cargo test -p aura-cli`
Expected: PASS — the three new builder tests plus all existing `aura-cli` tests
pass (the existing `chart_*` integration tests stay green: single-run path
unchanged when no `--tap`).
Run: `cargo clippy -p aura-cli --all-targets -- -D warnings`
Expected: exit 0 (no dead-code: every new fn is wired through `emit_chart`).
- [ ] **Step 8: Write the family-chart + regression integration tests**
Append to `crates/aura-cli/tests/cli_run.rs` (using the existing `BIN` / `temp_cwd`):
```rust
/// Property: `aura chart <family>` overlays one series per family member, labelled
/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members;
/// each member key appears as a series name in the injected AURA_TRACES.
#[test]
fn chart_of_a_family_overlays_one_series_per_member() {
let cwd = temp_cwd("chart-family");
let swept = Command::new(BIN)
.args(["sweep", "--trace", "fam"])
.current_dir(&cwd)
.output()
.expect("spawn sweep --trace");
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
for key in [
"signals.trend.fast.length-2_signals.trend.slow.length-4",
"signals.trend.fast.length-2_signals.trend.slow.length-5",
"signals.trend.fast.length-3_signals.trend.slow.length-4",
"signals.trend.fast.length-3_signals.trend.slow.length-5",
] {
assert!(html.contains(key), "member series '{key}' missing from the family chart");
}
let _ = std::fs::remove_dir_all(&cwd);
}
/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps
/// (equity + exposure); `--tap equity` filters to one. A regression net for the
/// "single-run byte-unchanged when no --tap" acceptance bullet.
#[test]
fn chart_single_run_tap_filter_keeps_only_the_named_tap() {
let cwd = temp_cwd("chart-tap");
let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace");
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
// no --tap: both taps present (unchanged behaviour).
let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart");
assert!(all.status.success());
let all_html = String::from_utf8(all.stdout).expect("utf-8");
assert!(all_html.contains("\"equity\""), "equity series missing");
assert!(all_html.contains("\"exposure\""), "exposure series missing");
// --tap equity: only equity remains.
let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap");
assert!(one.status.success(), "chart --tap exit: {:?}", one.status);
let one_html = String::from_utf8(one.stdout).expect("utf-8");
assert!(one_html.contains("\"equity\""), "equity series missing under --tap");
assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure");
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 9: Run the integration tests to verify they pass**
Run: `cargo test -p aura-cli --test cli_run`
Expected: PASS — the two new tests plus all existing `cli_run` tests pass.
---
## Task 3: CLI — write-guard calls + collision tests
**Files:**
- Modify: `crates/aura-cli/src/main.rs:24-27` (import `WriteKind`); the six tracing
fns (`run_sample`, `run_sample_real`, `run_macd`, `run_sweep`, `run_walkforward`,
`run_mc`)
- Test: `crates/aura-cli/tests/cli_run.rs`
- [ ] **Step 1: Write the failing collision integration tests**
Append to `crates/aura-cli/tests/cli_run.rs`:
```rust
/// Property: a trace name cannot be reused across kinds — the write-guard refuses
/// it (exit 2), keeping `aura chart <name>` resolution a total function.
#[test]
fn trace_name_collision_across_kinds_is_refused() {
let cwd = temp_cwd("collision");
// run then sweep on the same name -> the sweep is refused.
let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace");
assert!(run.status.success(), "run --trace exit: {:?}", run.status);
let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace");
assert_eq!(sweep.status.code(), Some(2), "sweep onto a run name must exit 2");
// reverse: sweep then run on a fresh name -> the run is refused.
let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u");
assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status);
let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u");
assert_eq!(run2.status.code(), Some(2), "run onto a family name must exit 2");
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `cargo test -p aura-cli --test cli_run trace_name_collision_across_kinds_is_refused`
Expected: FAIL — without the guard, the second write succeeds, so the status code
is `Some(0)`, not `Some(2)`.
- [ ] **Step 3: Import `WriteKind`**
In `crates/aura-cli/src/main.rs`, add `WriteKind` to the `use aura_registry::{...}`
brace list (:24-27).
- [ ] **Step 4: Guard the single-run tracing paths**
In each of `run_sample`, `run_sample_real`, `run_macd` (all take `trace:
Option<&str>`), add at the very top of the function body, before any run work:
```rust
if let Some(n) = trace {
if let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) {
eprintln!("aura: {e}");
std::process::exit(2);
}
}
```
- [ ] **Step 5: Guard the family tracing paths**
In each of `run_sweep`, `run_walkforward`, `run_mc`, add at the very top of the
function body (they take `name: &str` + `persist: bool`), before the family build:
```rust
if persist {
if let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) {
eprintln!("aura: {e}");
std::process::exit(2);
}
}
```
- [ ] **Step 6: Run the collision test to verify it passes**
Run: `cargo test -p aura-cli --test cli_run trace_name_collision_across_kinds_is_refused`
Expected: PASS.
- [ ] **Step 7: Full workspace gate**
Run: `cargo test --workspace`
Expected: PASS — no failures across all crates.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: exit 0.
Run: `cargo build --workspace`
Expected: clean build.
-250
View File
@@ -1,250 +0,0 @@
# 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).