diff --git a/docs/plans/0061-families-comparison-view.md b/docs/plans/0061-families-comparison-view.md new file mode 100644 index 0000000..c7c8d94 --- /dev/null +++ b/docs/plans/0061-families-comparison-view.md @@ -0,0 +1,665 @@ +# 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 ` 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 ` / `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//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//` + /// that carries an `index.json`, read via `read("/")`, 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, 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)> = + 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 { + let mut member_rows: Vec<(String, Vec<(Timestamp, Vec)>)> = 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 = + member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect(); + xs.sort_unstable(); + xs.dedup(); + + let spine: Vec<(Timestamp, Vec)> = + xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect(); + let sides: Vec<&[(Timestamp, Vec)]> = + member_rows.iter().map(|(_, r)| r.as_slice()).collect(); + let joined: Vec = 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 = Vec::new(); + for (i, (key, _)) in member_rows.iter().enumerate() { + let points: Vec> = + 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 { + let series: Vec = 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: ` [--tap ] [--panels]` in any order. +fn parse_chart_args(rest: &[&str]) -> Result<(String, Option, ChartMode), String> { + let mut name: Option = None; + let mut tap: Option = 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 ")?; + Ok((name, tap, mode)) +} +``` + +Replace the entire body of `emit_chart` (:277-290) with this signature + body: + +```rust +/// `aura chart [--tap ] [--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 [--panels]` to +`aura chart [--tap ] [--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 ` 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 ` 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.