diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ef2004b..6638f9f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -444,8 +444,8 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env NameKind::NotFound => { eprintln!( "aura: no recorded run or family '{name}' under runs/traces \ - (run `aura sweep --real --trace {name}` or \ - `aura walkforward --real --trace {name}` first)" + (check the handle a sweep/walk-forward/campaign run printed for a typo — \ + re-running with this handle as `--trace` will not create it)" ); std::process::exit(1); } diff --git a/crates/aura-registry/src/trace_store.rs b/crates/aura-registry/src/trace_store.rs index 37b0fa4..2c35a20 100644 --- a/crates/aura-registry/src/trace_store.rs +++ b/crates/aura-registry/src/trace_store.rs @@ -29,12 +29,35 @@ pub struct RunTraces { 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. + /// No top-level `index.json`, but ≥1 immediate subdir has one (depth-1), or + /// ≥1 immediate subdir's own immediate subdir has one (depth-2, the #224 + /// sweep/walk-forward fan-out) — a family. Family, /// Neither — no recorded run or family of this name. NotFound, } +/// Does `dir` hold ≥1 family member, at depth-1 (`dir//index.json`) or +/// depth-2 (`dir///index.json`, the #224 fan-out)? Shared by +/// `name_kind`'s classification. +fn has_family_member(dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(dir) else { return false }; + for entry in entries.flatten() { + let path = entry.path(); + if path.join("index.json").is_file() { + return true; + } + if let Ok(sub_entries) = fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + if sub_entry.path().join("index.json").is_file() { + return true; + } + } + } + } + false +} + /// One member of a family, read back: its key (the member-dir name) + its traces. #[derive(Debug)] pub struct FamilyMember { @@ -123,26 +146,28 @@ impl TraceStore { /// 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`. + /// `index.json` (depth-1, the campaign nominee layout) OR ≥1 immediate subdir + /// whose OWN immediate subdir has `index.json` (depth-2, the #224 + /// sweep/walk-forward per-cell member fan-out) -> `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; - } - } + if has_family_member(&run_dir) { + 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. + /// Read every member of a family. Two on-disk shapes are resolved: the + /// depth-1 campaign layout (`//index.json`, `key` = the immediate + /// subdir name) and the depth-2 #224 sweep/walk-forward fan-out + /// (`///index.json`, `key` = `/` so members + /// stay distinguishable across cells). 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) { @@ -153,15 +178,39 @@ impl TraceStore { 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 path = entry.path(); + if path.join("index.json").is_file() { + // depth-1: the immediate subdir IS a member. + 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 }); + continue; } - let key = match entry.file_name().into_string() { + // depth-2: the immediate subdir is a cell; its OWN immediate subdirs + // with `index.json` are the members. + let cell_name = 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 }); + let cell_entries = match fs::read_dir(&path) { + Ok(e) => e, + Err(_) => continue, // stray file or unreadable: skip + }; + for cell_entry in cell_entries.flatten() { + if !cell_entry.path().join("index.json").is_file() { + continue; // stray file or non-member subdir + } + let member_name = match cell_entry.file_name().into_string() { + Ok(k) => k, + Err(_) => continue, // non-UTF8 dir name: skip + }; + let key = format!("{cell_name}/{member_name}"); + let traces = self.read(&format!("{name}/{key}"))?; + members.push(FamilyMember { key, traces }); + } } members.sort_by(|a, b| a.key.cmp(&b.key)); Ok(members) @@ -345,6 +394,36 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn name_kind_classifies_depth_two_sweep_fanout_as_family() { + let root = temp_traces_root("namekind-depth2"); + let store = TraceStore::open(&root); + // the #224 sweep/walk-forward fan-out: ///index.json, + // two levels below the family name — no top-level or depth-1 index.json. + store.write("fam2/cellA/m1", &sample_manifest(), &sample_taps()).expect("write m1"); + assert_eq!(store.name_kind("fam2"), NameKind::Family); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn read_family_resolves_depth_two_sweep_fanout_with_cell_member_keys() { + let root = temp_traces_root("readfamily-depth2"); + let store = TraceStore::open(&root); + // members written out of order across two cells; read_family must sort by + // the combined "/" key, keeping members distinguishable + // across cells. + store.write("fam2/cellB/m1", &sample_manifest(), &sample_taps()).expect("write cellB/m1"); + store.write("fam2/cellA/m2", &sample_manifest(), &sample_taps()).expect("write cellA/m2"); + store.write("fam2/cellA/m1", &sample_manifest(), &sample_taps()).expect("write cellA/m1"); + // a stray file directly under a cell dir is ignored (no index.json there). + fs::write(root.join("traces/fam2/cellA/stray.txt"), "x").expect("stray"); + let members = store.read_family("fam2").expect("read_family"); + let keys: Vec<&str> = members.iter().map(|m| m.key.as_str()).collect(); + assert_eq!(keys, vec!["cellA/m1", "cellA/m2", "cellB/m1"]); + assert_eq!(members[0].traces.taps.len(), 2); + let _ = fs::remove_dir_all(&root); + } + #[test] fn ensure_name_free_refuses_cross_kind_reuse() { let root = temp_traces_root("guard");