plan: 0061 families-comparison view

Three tasks: (1) aura-registry — NameKind/FamilyMember/WriteKind, name_kind,
read_family, ensure_name_free + a NameTaken error variant; (2) aura-cli — the
comparison ChartData builder (shared y-scale, union-ts spine), filter_to_tap,
parse_chart_args, the emit_chart name-kind branch, the --tap flag, USAGE; (3)
aura-cli — the write-guard calls in the six tracing fns + collision tests. Each
task RED-first with per-crate gates; workspace test + clippy at the close.

refs #107
This commit is contained in:
2026-06-21 17:49:42 +02:00
parent 5f31eccc7c
commit da5068e10a
+665
View File
@@ -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 <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.