diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index fc9ac9e..50bf38a 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -7,8 +7,9 @@ //! //! Orchestration **families** (sweep / Monte-Carlo / walk-forward, C12/C21) are //! stored as related records in a sibling family store (`families.jsonl`): each -//! member is a `RunReport` stamped with a shared `family_id`, re-derived as a unit -//! by [`group_families`]. The flat runs store and its API are untouched. +//! member is a `RunReport` stamped with its `family` name + `run` index (the +//! `family_id` handle is derived from the pair), re-derived as a unit by +//! [`group_families`]. The flat runs store and its API are untouched. use std::cmp::Ordering; use std::fmt; @@ -317,6 +318,10 @@ mod tests { let fam = &families[0]; assert_eq!(fam.id, "f-0"); assert_eq!(fam.kind, FamilyKind::MonteCarlo); + // the split (family, run) fields are stamped on each member, and the + // user-facing handle is derived from them (id is "{family}-{run}"). + assert_eq!((fam.members[0].family.as_str(), fam.members[0].run), ("f", 0)); + assert_eq!(fam.members[0].family_id(), "f-0"); // members re-derived as a unit, ordinal-ordered, equal to the stamped reports let member_reports: Vec<_> = fam.members.iter().map(|m| m.report.clone()).collect(); assert_eq!(member_reports, reports); diff --git a/crates/aura-registry/src/lineage.rs b/crates/aura-registry/src/lineage.rs index 491727b..4ada357 100644 --- a/crates/aura-registry/src/lineage.rs +++ b/crates/aura-registry/src/lineage.rs @@ -1,13 +1,15 @@ //! Registry lineage for orchestration families (C18/C21): a family — a sweep, //! Monte-Carlo, or walk-forward run — is a set of run records sharing one -//! `family_id` (the parent reference). This module adds the persisted lineage +//! parent identity — the `(family, run)` pair, from which the user-facing +//! `family_id` handle (`"{family}-{run}"`) is derived. This module adds the +//! persisted lineage //! unit ([`FamilyRunRecord`]), the family-store accessors on [`Registry`] //! ([`Registry::append_family`] / [`Registry::load_family_members`]), and the //! round-trip that re-derives a [`Family`] from the stored links //! ([`group_families`]). The family store is a sibling JSONL of the flat runs //! store (`families.jsonl`), so the flat `runs.jsonl` path and API are untouched. -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::fs; use std::io::Write; @@ -25,18 +27,30 @@ pub enum FamilyKind { } /// One persisted family member: a run record stamped with its lineage link. -/// `family_id` is the shared parent reference; `ordinal` is the member's position -/// in the family's canonical order (odometer / seed-input / roll). +/// The parent reference is split into its two factors — `family` (the parent +/// name) and `run` (the per-name run index) — from which the user-facing +/// `family_id` handle (`"{family}-{run}"`) is derived rather than stored. +/// `ordinal` is the member's position in the family's canonical order +/// (odometer / seed-input / roll). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct FamilyRunRecord { - pub family_id: String, + pub family: String, + pub run: usize, pub kind: FamilyKind, pub ordinal: usize, pub report: RunReport, } -/// A re-derived family: the members sharing one `family_id`, ordinal-sorted — the -/// round-trip result of [`group_families`]. +impl FamilyRunRecord { + /// The user-facing family handle — `"{family}-{run}"`, derived, never stored or parsed. + pub fn family_id(&self) -> String { + format!("{}-{}", self.family, self.run) + } +} + +/// A re-derived family: the members sharing one `(family, run)` key (and thus one +/// derived `family_id`), ordinal-sorted — the round-trip result of +/// [`group_families`]. #[derive(Clone, Debug, PartialEq)] pub struct Family { pub id: String, @@ -45,22 +59,20 @@ pub struct Family { } impl Registry { - /// Assign a fresh `family_id = "{name}-{counter}"` (counter = the smallest `k` - /// for which `"{name}-{k}"` is absent in the family store — a per-name run - /// index), stamp `reports` as ordinal-ordered members, and append them as N - /// JSONL lines to the family store (a sibling of the flat runs store, - /// `families.jsonl`, leaving the flat path untouched). Returns the assigned id. - /// Reads the store once to pick the counter (a read-before-write; single-process - /// CLI invocations do not race). + /// Assign a fresh per-name `run` index (one past the highest `run` already + /// stored for `name`, or `0` if unseen), stamp `reports` as ordinal-ordered + /// members carrying `name` + that `run`, and append them as N JSONL lines to + /// the family store (a sibling of the flat runs store, `families.jsonl`, + /// leaving the flat path untouched). Returns the derived id `"{name}-{run}"`. + /// Reads the store once to pick the run index (a read-before-write; + /// single-process CLI invocations do not race). pub fn append_family( &self, name: &str, kind: FamilyKind, reports: &[RunReport], ) -> Result { - let existing: HashSet = - self.load_family_members()?.into_iter().map(|r| r.family_id).collect(); - let family_id = next_family_id(name, &existing); + let run = next_run(name, &self.load_family_members()?); let path = self.path.with_file_name("families.jsonl"); if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { @@ -69,16 +81,16 @@ impl Registry { let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?; for (ordinal, report) in reports.iter().enumerate() { let record = FamilyRunRecord { - family_id: family_id.clone(), + family: name.to_string(), + run, kind, ordinal, report: report.clone(), }; - // a finite RunReport wrapped in a FamilyRunRecord serializes infallibly. let line = serde_json::to_string(&record).expect("a finite FamilyRunRecord serializes"); writeln!(file, "{line}")?; } - Ok(family_id) + Ok(format!("{name}-{run}")) } /// Parse every stored family member, in file order. A missing file is an empty @@ -104,32 +116,36 @@ impl Registry { } } -/// The next free `"{name}-{counter}"` id: the smallest `k >= 0` for which -/// `"{name}-{k}"` is not in `existing`. Robust to `-` inside `name` — it tests -/// membership of generated candidates, never parses the stored strings. Module- -/// private: the id form is an `append_family` implementation detail. -fn next_family_id(name: &str, existing: &HashSet) -> String { - (0usize..) - .map(|k| format!("{name}-{k}")) - .find(|id| !existing.contains(id)) - .expect("the usize counter space is effectively unbounded") +/// The next per-name run index: one past the highest `run` already stored for +/// `name`, or `0` if the name is unseen. A clean numeric max over the `run` +/// field — no string parsing (the old fused-id form had to generate-and-check to +/// stay robust to `-` inside a name; the split removes that concern). +fn next_run(name: &str, records: &[FamilyRunRecord]) -> usize { + records + .iter() + .filter(|r| r.family == name) + .map(|r| r.run + 1) + .max() + .unwrap_or(0) } -/// Group member records into families by `family_id` (first-seen family order; -/// each family's members ordinal-sorted). The round-trip: `append_family(...)` -/// then `group_families(load_family_members())` recovers the families. +/// Group member records into families by their `(family, run)` key (first-seen +/// family order; each family's members ordinal-sorted), deriving the family's +/// `id` once at construction. The round-trip: `append_family(...)` then +/// `group_families(load_family_members())` recovers the families. pub fn group_families(records: Vec) -> Vec { - let mut order: Vec = Vec::new(); - let mut by_id: HashMap = HashMap::new(); + let mut order: Vec<(String, usize)> = Vec::new(); + let mut by_key: HashMap<(String, usize), Family> = HashMap::new(); for rec in records { - let fam = by_id.entry(rec.family_id.clone()).or_insert_with(|| { - order.push(rec.family_id.clone()); - Family { id: rec.family_id.clone(), kind: rec.kind, members: Vec::new() } + let key = (rec.family.clone(), rec.run); + let fam = by_key.entry(key.clone()).or_insert_with(|| { + order.push(key.clone()); + Family { id: rec.family_id(), kind: rec.kind, members: Vec::new() } }); fam.members.push(rec); } let mut families: Vec = - order.into_iter().map(|id| by_id.remove(&id).expect("id was inserted")).collect(); + order.into_iter().map(|k| by_key.remove(&k).expect("key was inserted")).collect(); for fam in &mut families { fam.members.sort_by_key(|m| m.ordinal); } diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 8cb712f..a2b23c7 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -664,12 +664,12 @@ the reverse. **Realization (cycle 0045 — lineage as related records, #70).** The *lineage* depth is now realized as a **family store**: a sweep / Monte-Carlo / walk-forward run (the C12 axes) is persisted as a *set of related records* — each a -`FamilyRunRecord` (a `RunReport` stamped with a shared `family_id` + `kind` + +`FamilyRunRecord` (a `RunReport` stamped with its `family` + `run` + `kind` + `ordinal`) — in a sibling JSONL (`families.jsonl`), leaving the flat `runs.jsonl` path and its `append`/`load`/`rank_by`/`optimize` API byte-for-byte unchanged. -`family_id = "{name}-{counter}"` (an author-supplied name plus a per-name -generate-and-check run index — not a content hash; re-running the same family -mints a fresh id). `group_families` is the round-trip that re-derives a family +the user-facing `family_id = "{family}-{run}"` handle is **derived** from the +stored `family` name plus a per-name `run` index (assigned as a numeric max+1 — +not a content hash; re-running the same family mints a fresh id). `group_families` is the round-trip that re-derives a family from the stored links (re-listable / rankable as a unit — C21). The manifest *is* the re-derivation recipe (#71): no input-stream blob / path / payload enters a record, and a member's window is **producer-supplied** via