diff --git a/crates/aura-runner/src/reproduce.rs b/crates/aura-runner/src/reproduce.rs index 85320a6..b4b7fed 100644 --- a/crates/aura-runner/src/reproduce.rs +++ b/crates/aura-runner/src/reproduce.rs @@ -30,17 +30,39 @@ pub struct ReproduceReport { /// load failure) — the single place `reproduce_family` and `reproduce_family_in` /// resolve a family, so the two exit-1 error phrasings can't drift out of sync /// between the call sites. +/// +/// `id` is tried first against the derived `family_id` handle (`"{family}-{run}"`, +/// exact match, first precedence). If that misses, it falls back to the bare +/// enumeration `family` name (`FamilyRunRecord::family` / `Registry:: +/// load_family_members()`'s natural list-then-reproduce workflow, #298): a name +/// that names exactly one stored run resolves to it, but a name with more than +/// one stored run is ambiguous and refused rather than guessed, listing the +/// candidate handles. pub fn load_family(reg: &Registry, id: &str) -> Result { let members = reg.load_family_members().map_err(|e| RunnerError { exit_code: 1, message: format!("{e}"), })?; - group_families(members) - .into_iter() - .find(|f| f.id == id) + let families = group_families(members); + if let Some(family) = families.iter().find(|f| f.id == id) { + return Ok(family.clone()); + } + let by_name: Vec<&Family> = + families.iter().filter(|f| f.members.iter().any(|m| m.family == id)).collect(); + match by_name.as_slice() { + [only] => Ok((*only).clone()), // reproduce is an action, not a lookup: an unknown id is a hard error (distinct // from `runs family `'s treat-as-empty exit 0). - .ok_or_else(|| RunnerError { exit_code: 1, message: format!("no such family '{id}'") }) + [] => Err(RunnerError { exit_code: 1, message: format!("no such family '{id}'") }), + many => { + let candidates = + many.iter().map(|f| f.id.clone()).collect::>().join(", "); + Err(RunnerError { + exit_code: 1, + message: format!("ambiguous family '{id}': candidates {candidates}"), + }) + } + } } /// Re-derive every member of a persisted sweep family from the content-addressed store @@ -297,4 +319,42 @@ mod tests { }); assert_eq!(family.id, canonical); } + + /// A name shared by more than one stored run must refuse rather than guess + /// (#298 fix): `load_family` lists every candidate handle in the refusal + /// message so the caller can pick the intended one, instead of silently + /// resolving to whichever run happens first. + #[test] + fn load_family_refuses_a_name_with_more_than_one_stored_run() { + let reg = temp_registry("ambiguous-name"); + reg.append_family("f", FamilyKind::Sweep, &[minimal_report()]) + .expect("append family f, run 0"); + reg.append_family("f", FamilyKind::Sweep, &[minimal_report()]) + .expect("append family f, run 1"); + + let err = load_family(®, "f") + .expect_err("a name naming two stored runs must not resolve silently"); + assert!(err.message.contains("ambiguous family 'f'"), "message: {}", err.message); + assert!(err.message.contains("f-0"), "message: {}", err.message); + assert!(err.message.contains("f-1"), "message: {}", err.message); + } + + /// The derived handle keeps first precedence over the name fallback (#298 + /// fix): when a family named `"f-0"` collides with the canonical handle of + /// another family `"f"`'s run 0, looking up `"f-0"` resolves to the family + /// whose HANDLE is `"f-0"`, not the family whose NAME happens to match. + #[test] + fn load_family_exact_handle_precedes_a_name_collision() { + let reg = temp_registry("handle-name-collision"); + reg.append_family("f", FamilyKind::Sweep, &[minimal_report()]) + .expect("append family f, run 0 -> handle f-0"); + reg.append_family("f-0", FamilyKind::Sweep, &[minimal_report()]) + .expect("append family f-0, run 0 -> handle f-0-0, name f-0"); + + let family = load_family(®, "f-0").unwrap_or_else(|e| { + panic!("the exact handle 'f-0' must resolve: {}", e.message) + }); + assert_eq!(family.id, "f-0"); + assert_eq!(family.members[0].family, "f"); + } } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 07eeb2a..f6d4d79 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1512,7 +1512,12 @@ the integrity check). `aura reproduce ` re-derives every persisted me the member's blueprint by its `topology_hash`, reconstruct the sweep point from the recorded params, re-run through the **same** `run_blueprint_member` the live sweep uses (so bit-identity is by construction, C1), and compare metrics — refuse-don't-guess on an -unknown id / missing stored blueprint (exit 2), DIVERGED → exit 1. The manifest + the +unknown id / missing stored blueprint (exit 1 — recorded state is missing, C14's +runtime-failure class; this paragraph over-claimed exit 2 until #298 recorded the code's +actual, C14-consistent behaviour), DIVERGED → exit 1. The id resolves first as the +derived `{family}-{run}` handle; a bare enumeration name naming exactly one stored run +resolves as fallback, an ambiguous name refuses listing the candidate handles (#298 — +the list-then-reproduce seam). The manifest + the content-addressed store + the commit are the complete re-derivation recipe (C18). One blueprint is stored per family (all members share the signal `topology_hash` — C11/C12 dedup). `aura graph introspect --content-id` exposes the same id for an op-script, via the