fix(registry,cli): chart resolves the depth-2 sweep --trace family layout

TraceStore::name_kind and read_family resolved taps exactly one
directory below the family handle, so the per-member fan-out layout
sweep/walkforward --trace writes since #224
(<name>/<cell>/<member>/index.json) classified as NotFound and
`aura chart <printed handle>` exited 1. Both sides now resolve depth-1
(campaign nominee layout) and depth-2, with depth-2 members keyed
<cell>/<member> (deterministic sort, C1); the on-disk layout is
unchanged. emit_chart's not-found message no longer suggests re-running
with the unknown handle as --trace (the data-creating command cannot
take an output handle as input); the message prefix stays pinned.
Milestone-fieldtest finding B1 (fixtures: 09da04f); verified against
the fieldtest's own on-disk family and the campaign depth-1 sibling.
This commit is contained in:
2026-07-11 15:05:31 +02:00
parent abb6fbaa87
commit 5616aa6c6a
2 changed files with 98 additions and 19 deletions
+2 -2
View File
@@ -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 <SYM> --trace {name}` or \
`aura walkforward --real <SYM> --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);
}
+93 -14
View File
@@ -29,12 +29,35 @@ pub struct RunTraces {
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.
/// 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/<key>/index.json`) or
/// depth-2 (`dir/<cell>/<member>/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() {
if has_family_member(&run_dir) {
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.
/// Read every member of a family. Two on-disk shapes are resolved: the
/// depth-1 campaign layout (`<name>/<key>/index.json`, `key` = the immediate
/// subdir name) and the depth-2 #224 sweep/walk-forward fan-out
/// (`<name>/<cell>/<member>/index.json`, `key` = `<cell>/<member>` so members
/// stay distinguishable across cells). 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) {
@@ -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;
}
// 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 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: <name>/<cell>/<member>/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 "<cell>/<member>" 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");