From d6daaef009702c448301495792703dcd3977c072 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 18:05:05 +0200 Subject: [PATCH] plan: 0045 registry lineage for orchestration families Decompose spec 0045 into two iterations. Iteration 1 (engine + registry core): Source::bounds() + window_of, M1FieldSource::bounds(), and the aura-registry lineage module (FamilyKind / FamilyRunRecord / Family, group_families, next_family_id, the three *_member_reports extractors, Registry::append_family / load_family_members) with round-trip and per-name-counter tests. Iteration 2 (CLI surface): aura mc, family-aware aura runs (families / family [rank]), sweep/walkforward family persistence via append_family with --name, and the window_of migration of the manifest-window scans. refs #70 --- docs/plans/0045-registry-lineage-families.md | 1356 ++++++++++++++++++ 1 file changed, 1356 insertions(+) create mode 100644 docs/plans/0045-registry-lineage-families.md diff --git a/docs/plans/0045-registry-lineage-families.md b/docs/plans/0045-registry-lineage-families.md new file mode 100644 index 0000000..416a343 --- /dev/null +++ b/docs/plans/0045-registry-lineage-families.md @@ -0,0 +1,1356 @@ +# Registry lineage for orchestration families — Implementation Plan + +> **Parent spec:** `docs/specs/0045-registry-lineage-families.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make an orchestration family (sweep / Monte-Carlo / walk-forward) a +persisted, linked unit in the run registry — a shared `family_id = "{name}-{counter}"` +stamped on each member record — and surface it through the CLI (`aura mc`, +family-aware `aura runs`), with every family-feeding run's manifest window taken +from a producer-supplied `Source::bounds()` rather than a materialized-`Vec` scan. + +**Architecture:** Three layers move, none of them the strategy graph. (1) `aura-engine` +gains a read-only `Source::bounds()` accessor + a free `window_of` fold (no registry +concept — C9 preserved). (2) `aura-ingest`'s `M1FieldSource` stores its requested +window and reports it via `bounds()`. (3) `aura-registry` gains a `lineage` module +(`FamilyRunRecord` + a sibling `families.jsonl` store, leaving the flat `runs.jsonl` +API byte-for-byte unchanged). (4) `aura-cli` adds `aura mc`, family-aware `aura runs`, +routes `sweep`/`walkforward`/`mc` through `append_family`, and migrates every +manifest-window scan to `window_of`. + +**Tech Stack:** Rust workspace — `aura-engine` (harness `Source` trait), +`aura-ingest` (`M1FieldSource`), `aura-registry` (new `lineage` module, `serde` +derive), `aura-cli` (`main.rs`), `serde`/`serde_json`. + +--- + +## Files this plan creates or modifies + +**Iteration 1 — engine + registry core (Tasks 1–3)** + +- Modify: `crates/aura-engine/src/harness.rs` — `Source::bounds()` (trait + `VecSource` impl), free `window_of`; tests. +- Modify: `crates/aura-engine/src/lib.rs:60-62` — re-export `window_of`. +- Modify: `crates/aura-ingest/src/lib.rs` — `M1FieldSource` stores `from_ms`/`to_ms`, impl `bounds()`. +- Test: `crates/aura-ingest/tests/streaming_seam.rs` — data-gated `bounds()` test. +- Create: `crates/aura-registry/src/lineage.rs` — `FamilyKind`, `FamilyRunRecord`, `Family`, `group_families`, `next_family_id`, three `*_member_reports`, `Registry::{append_family, load_family_members}`. +- Modify: `crates/aura-registry/src/lib.rs` — `mod lineage;` + re-exports + header doc; lineage tests in `mod tests`. +- Modify: `crates/aura-registry/Cargo.toml:8-13` — add `serde = { workspace = true, features = ["derive"] }`. + +**Iteration 2 — CLI surface (Tasks 4–6)** + +- Modify: `crates/aura-cli/src/main.rs` — `window_of` migration (`run_sample`, `sweep_family`, `walkforward_family`, `run_macd`, `sweep_over`, `run_oos`); `aura mc` (`mc_family`, `run_mc`, `mc_aggregate_json`, `mc_report` test helper); family-aware `runs` (`runs_families`, `runs_family`); `run_sweep`/`run_walkforward` → `append_family` with `name`; `USAGE` + `match` arms; tests. + +--- + +## Iteration 1 — engine + registry core + +### Task 1: `Source::bounds()` + `window_of` (`aura-engine`) + +**Files:** +- Modify: `crates/aura-engine/src/harness.rs` (trait `:57-66`, `impl Source for VecSource` `:82-91`, new free fn after `:91`) +- Modify: `crates/aura-engine/src/lib.rs:60-62` (re-export) +- Test: `crates/aura-engine/src/harness.rs` (`mod tests`, after `:518`) + +- [ ] **Step 1: Write the failing tests** + +In `crates/aura-engine/src/harness.rs`, inside `mod tests` (after the existing +`vec_source_empty_peeks_none` test at `:518`), add: + +```rust + #[test] + fn vec_source_bounds_are_buffer_ends_and_cursor_independent() { + let mut s = VecSource::new(f64_stream(&[(10, 1.0), (20, 2.0), (30, 3.0)])); + // bounds = (first ts, last ts) of the buffer + assert_eq!(s.bounds(), Some((Timestamp(10), Timestamp(30)))); + // cursor-independent: advancing does not move the reported extent + let _ = Source::next(&mut s); + assert_eq!(s.bounds(), Some((Timestamp(10), Timestamp(30)))); + // an empty buffer has no extent + let empty = VecSource::new(vec![]); + assert_eq!(empty.bounds(), None); + } + + #[test] + fn window_of_folds_union_extent_across_sources() { + let a: Box = Box::new(VecSource::new(f64_stream(&[(10, 1.0), (40, 2.0)]))); + let b: Box = Box::new(VecSource::new(f64_stream(&[(5, 1.0), (25, 2.0)]))); + // union extent: min first, max last + assert_eq!(window_of(&[a, b]), Some((Timestamp(5), Timestamp(40)))); + // all-empty -> None + let e1: Box = Box::new(VecSource::new(vec![])); + let e2: Box = Box::new(VecSource::new(vec![])); + assert_eq!(window_of(&[e1, e2]), None); + } + + #[test] + fn synthetic_source_bounds_match_analytic_span() { + let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; + let s = spec.source(7); + // first ts = 1 (i=0); last ts = (len-1)*step + 1 + assert_eq!(s.bounds(), Some((Timestamp(1), Timestamp((32 - 1) * 1 + 1)))); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-engine bounds` +Expected: FAIL — compile error `no method named \`bounds\` found for ... \`VecSource\`` / +`cannot find function \`window_of\`` (the trait method and free fn do not exist yet). + +- [ ] **Step 3: Add `bounds()` to the trait** + +In `crates/aura-engine/src/harness.rs`, in `pub trait Source` (after the `next` +method at `:65`, before the closing `}` at `:66`), add: + +```rust + + /// The inclusive `(from, to)` data extent this producer will stream, known + /// without materializing it (#71/C18): a lazy producer reports its window + /// bounds; an eager one reports its buffer's first/last. `None` = empty / + /// open-ended. Cursor-independent: a property of the data extent, not the + /// read position. + fn bounds(&self) -> Option<(Timestamp, Timestamp)>; +``` + +- [ ] **Step 4: Implement `bounds()` for `VecSource`** + +In `impl Source for VecSource` (after the `next` method at `:90`, before the +closing `}` at `:91`), add: + +```rust + fn bounds(&self) -> Option<(Timestamp, Timestamp)> { + // the eager source reads its own buffer ends (O(1) on the slice) — the + // consumer no longer scans a Vec. Cursor-independent (ignores self.cursor). + Some((self.stream.first()?.0, self.stream.last()?.0)) + } +``` + +- [ ] **Step 5: Add the `window_of` free fn** + +In `crates/aura-engine/src/harness.rs`, immediately after the `impl Source for +VecSource` block (after its closing `}` at `:91`), add: + +```rust + +/// The window of a run: the union extent across its sources (min `from`, max +/// `to`), each source contributing its [`Source::bounds`]. `None` when every +/// source is empty / open-ended. The producer-supplied replacement for scanning a +/// materialized `Vec` at the call site (#71/C18): byte-identical whether a source +/// is eager (`VecSource`) or lazily streamed. Uses `<`/`>` only, so it needs no +/// `Ord` on `Timestamp`. +pub fn window_of(sources: &[Box]) -> Option<(Timestamp, Timestamp)> { + let mut acc: Option<(Timestamp, Timestamp)> = None; + for s in sources { + if let Some((from, to)) = s.bounds() { + acc = Some(match acc { + None => (from, to), + Some((lo, hi)) => ( + if from < lo { from } else { lo }, + if to > hi { to } else { hi }, + ), + }); + } + } + acc +} +``` + +- [ ] **Step 6: Re-export `window_of`** + +In `crates/aura-engine/src/lib.rs`, the `pub use harness::{...}` block at `:60-62`. + +BEFORE: +```rust +pub use harness::{ + BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target, VecSource, +}; +``` + +AFTER: +```rust +pub use harness::{ + window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target, + VecSource, +}; +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cargo test -p aura-engine bounds` +Expected: PASS — `vec_source_bounds_are_buffer_ends_and_cursor_independent` and +`synthetic_source_bounds_match_analytic_span` both `ok`. + +Run: `cargo test -p aura-engine window_of` +Expected: PASS — `window_of_folds_union_extent_across_sources ... ok`. + +- [ ] **Step 8: Engine compile gate (workspace deferred to Task 2)** + +Run: `cargo build -p aura-engine` +Expected: finishes with 0 errors. (Do NOT build `--workspace` here: `aura-ingest`'s +`impl Source for M1FieldSource` now lacks `bounds()` and will not compile until +Task 2.) + +--- + +### Task 2: `M1FieldSource::bounds()` (`aura-ingest`) + +**Files:** +- Modify: `crates/aura-ingest/src/lib.rs` (struct `:164-170`, `open` `:186`, `impl Source` `:221-231`) +- Test: `crates/aura-ingest/tests/streaming_seam.rs` (after `:130`-region, end of file) + +- [ ] **Step 1: Write the failing data-gated test** + +In `crates/aura-ingest/tests/streaming_seam.rs`, append a new test (after the last +existing test in the file): + +```rust +#[test] +fn field_source_bounds_are_the_requested_window_normalized() { + let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH)); + if skip_if_no_data(&server) { + return; + } + let src = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close) + .expect("AAPL.US has data in the 2006-08 window"); + // bounds report the *requested* window normalized to epoch-ns, without + // streaming a single bar (producer-supplied window; #71 firewall). + assert_eq!( + Source::bounds(&src), + Some(( + aura_ingest::unix_ms_to_epoch_ns(FROM_MS), + aura_ingest::unix_ms_to_epoch_ns(TO_MS), + )) + ); + // an open-ended source (no window) has no known extent (Non-goals: archive + // extent query is deferred). + let open_ended = M1FieldSource::open(&server, SYMBOL, None, None, M1Field::Close) + .expect("AAPL.US archive exists"); + assert_eq!(Source::bounds(&open_ended), None); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p aura-ingest field_source_bounds` +Expected: FAIL — compile error `not all trait items implemented, missing: \`bounds\`` +in `impl aura_engine::Source for M1FieldSource` (the trait gained `bounds()` in +Task 1; the impl does not satisfy it yet). + +- [ ] **Step 3: Store the requested window on the struct** + +In `crates/aura-ingest/src/lib.rs`, the `pub struct M1FieldSource` at `:164-170`. + +BEFORE: +```rust +pub struct M1FieldSource { + iter: SymbolChunkIter, + chunk: Option>, + pos: usize, + field: M1Field, + head: Option<(Timestamp, Scalar)>, +} +``` + +AFTER: +```rust +pub struct M1FieldSource { + iter: SymbolChunkIter, + chunk: Option>, + pos: usize, + field: M1Field, + head: Option<(Timestamp, Scalar)>, + /// The requested window (inclusive Unix-ms), kept so `bounds()` can report a + /// producer-supplied extent without streaming (#71). `None` = open-ended. + from_ms: Option, + to_ms: Option, +} +``` + +- [ ] **Step 4: Store `from_ms`/`to_ms` in `open`** + +In `M1FieldSource::open` at `:186`. + +BEFORE: +```rust + let mut s = Self { iter, chunk: None, pos: 0, field, head: None }; +``` + +AFTER: +```rust + let mut s = Self { iter, chunk: None, pos: 0, field, head: None, from_ms, to_ms }; +``` + +- [ ] **Step 5: Implement `bounds()`** + +In `impl aura_engine::Source for M1FieldSource` (after the `next` method at `:230`, +before the closing `}` at `:231`), add: + +```rust + fn bounds(&self) -> Option<(Timestamp, Timestamp)> { + // the producer-supplied window: the requested [from_ms, to_ms] normalized + // to epoch-ns, known without streaming a bar (#71). Open-ended (either + // bound absent) -> no known extent (Non-goals: archive-extent query). + match (self.from_ms, self.to_ms) { + (Some(from), Some(to)) => Some((unix_ms_to_epoch_ns(from), unix_ms_to_epoch_ns(to))), + _ => None, + } + } +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cargo test -p aura-ingest field_source_bounds` +Expected: PASS — `field_source_bounds_are_the_requested_window_normalized ... ok` +(or a `skip: no local data` line + `ok` when the local archive is absent; the test +short-circuits via `skip_if_no_data`). + +- [ ] **Step 7: Workspace compile gate** + +Run: `cargo build --workspace` +Expected: finishes with 0 errors — every `Source` impl now provides `bounds()`. + +--- + +### Task 3: lineage module (`aura-registry`) + +**Files:** +- Modify: `crates/aura-registry/Cargo.toml:8-13` (serde derive dep) +- Create: `crates/aura-registry/src/lineage.rs` +- Modify: `crates/aura-registry/src/lib.rs` (`:1-6` header doc; after `:14` module + re-export; `mod tests` lineage tests) + +- [ ] **Step 1: Write the failing lineage tests** + +In `crates/aura-registry/src/lib.rs`, inside `mod tests` (after the last test +`optimize_picks_the_max_metric_point_ties_to_earliest`, before the mod's closing +`}` at `:285`), add the helper and five tests: + +```rust + + fn temp_family_dir(name: &str) -> PathBuf { + // a unique per-test directory so the families.jsonl sibling never collides + // across tests sharing the temp dir; the registry binds to runs.jsonl inside. + let dir = + std::env::temp_dir().join(format!("aura-registry-fam-{}-{}", std::process::id(), name)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create temp family dir"); + dir.join("runs.jsonl") + } + + #[test] + fn lineage_round_trips_one_family() { + let path = temp_family_dir("roundtrip"); + let reg = Registry::open(&path); + let reports = + vec![report_with(1.0, 0.5, 1), report_with(2.0, 0.3, 2), report_with(3.0, 0.1, 0)]; + let id = reg.append_family("f", FamilyKind::MonteCarlo, &reports).expect("append family"); + assert_eq!(id, "f-0"); + let families = group_families(reg.load_family_members().expect("load members")); + assert_eq!(families.len(), 1); + let fam = &families[0]; + assert_eq!(fam.id, "f-0"); + assert_eq!(fam.kind, FamilyKind::MonteCarlo); + // 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); + assert_eq!(fam.members.iter().map(|m| m.ordinal).collect::>(), vec![0, 1, 2]); + } + + #[test] + fn per_name_counter_increments_independently() { + let path = temp_family_dir("counter"); + let reg = Registry::open(&path); + let reports = vec![report_with(1.0, 0.5, 1)]; + let x0 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-0"); + let x1 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-1"); + let y0 = reg.append_family("y", FamilyKind::Sweep, &reports).expect("y-0"); + assert_eq!((x0.as_str(), x1.as_str(), y0.as_str()), ("x-0", "x-1", "y-0")); + let families = group_families(reg.load_family_members().expect("load")); + assert_eq!(families.len(), 3); + } + + #[test] + fn distinct_families_regroup_in_first_seen_order() { + let path = temp_family_dir("regroup"); + let reg = Registry::open(&path); + let a = vec![report_with(1.0, 0.5, 1), report_with(2.0, 0.5, 1)]; + let b = vec![report_with(3.0, 0.5, 1)]; + let ida = reg.append_family("a", FamilyKind::Sweep, &a).expect("a"); + let idb = reg.append_family("b", FamilyKind::WalkForward, &b).expect("b"); + let families = group_families(reg.load_family_members().expect("load")); + // first-seen file order: a before b + assert_eq!(families.iter().map(|f| f.id.clone()).collect::>(), vec![ida, idb]); + assert_eq!(families[0].members.len(), 2); + assert_eq!(families[1].kind, FamilyKind::WalkForward); + // members ordinal-sorted within each family + assert_eq!(families[0].members.iter().map(|m| m.ordinal).collect::>(), vec![0, 1]); + } + + #[test] + fn family_store_and_flat_store_are_disjoint() { + let path = temp_family_dir("disjoint"); + let reg = Registry::open(&path); + // a family write leaves the flat runs store empty... + reg.append_family("f", FamilyKind::Sweep, &[report_with(1.0, 0.5, 1)]).expect("family"); + assert_eq!(reg.load().expect("flat load"), Vec::::new()); + // ...and a flat write leaves the family store untouched at one family. + reg.append(&report_with(9.0, 0.5, 1)).expect("flat append"); + assert_eq!(reg.load().expect("flat load 2").len(), 1); + assert_eq!(group_families(reg.load_family_members().expect("members")).len(), 1); + } + + #[test] + fn rank_a_family_as_a_unit() { + let path = temp_family_dir("rankfam"); + let reg = Registry::open(&path); + let reports = + vec![report_with(1.0, 0.5, 1), report_with(3.0, 0.5, 1), report_with(2.0, 0.5, 1)]; + reg.append_family("f", FamilyKind::Sweep, &reports).expect("family"); + let fam = group_families(reg.load_family_members().expect("members")) + .pop() + .expect("one family"); + let member_reports: Vec = fam.members.iter().map(|m| m.report.clone()).collect(); + let ranked = rank_by(member_reports, "total_pips").expect("rank"); + assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-registry lineage_round_trips` +Expected: FAIL — compile error `cannot find type \`FamilyKind\`` / `cannot find +function \`group_families\`` / `no method named \`append_family\`` +(the lineage module does not exist yet). + +- [ ] **Step 3: Add the `serde` derive dependency** + +In `crates/aura-registry/Cargo.toml`, the `[dependencies]` table at `:8-13`. + +BEFORE: +```toml +[dependencies] +# the run registry's typed read-path: serde_json parses stored RunReport records +# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport +# derives serde in aura-engine. +aura-engine = { path = "../aura-engine" } +serde_json = { workspace = true } +``` + +AFTER: +```toml +[dependencies] +# the run registry's typed read-path: serde_json parses stored RunReport records +# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport +# derives serde in aura-engine. +aura-engine = { path = "../aura-engine" } +# the lineage record types (FamilyKind / FamilyRunRecord) derive serde; admitted +# under the same per-case policy as serde_json (INDEX.md). +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +``` + +- [ ] **Step 4: Create the lineage module** + +Create `crates/aura-registry/src/lineage.rs` with exactly: + +```rust +//! 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 +//! 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::fs; +use std::io::Write; + +use aura_engine::{McFamily, RunReport, SweepFamily, WalkForwardResult}; +use serde::{Deserialize, Serialize}; + +use crate::{Registry, RegistryError}; + +/// Which C12 orchestration axis produced a family. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum FamilyKind { + Sweep, + MonteCarlo, + WalkForward, +} + +/// 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). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FamilyRunRecord { + pub family_id: String, + 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`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Family { + pub id: String, + pub kind: FamilyKind, + pub members: Vec, +} + +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). + 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 path = self.path.with_file_name("families.jsonl"); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent)?; + } + 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(), + 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) + } + + /// Parse every stored family member, in file order. A missing file is an empty + /// family store (`Ok(vec![])`), exactly as [`Registry::load`] treats a missing + /// flat store. + pub fn load_family_members(&self) -> Result, RegistryError> { + let path = self.path.with_file_name("families.jsonl"); + let text = match fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(RegistryError::Io(e)), + }; + let mut records = Vec::new(); + for (i, raw) in text.lines().enumerate() { + if raw.trim().is_empty() { + continue; + } + let record = serde_json::from_str(raw) + .map_err(|source| RegistryError::Parse { line: i + 1, source })?; + records.push(record); + } + Ok(records) + } +} + +/// 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") +} + +/// 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. +pub fn group_families(records: Vec) -> Vec { + let mut order: Vec = Vec::new(); + let mut by_id: HashMap = 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() } + }); + fam.members.push(rec); + } + let mut families: Vec = + order.into_iter().map(|id| by_id.remove(&id).expect("id was inserted")).collect(); + for fam in &mut families { + fam.members.sort_by_key(|m| m.ordinal); + } + families +} + +/// The member reports of a sweep family, in enumeration order — the trivial +/// extractor the CLI hands to [`Registry::append_family`]. +pub fn sweep_member_reports(family: &SweepFamily) -> Vec { + family.points.iter().map(|p| p.report.clone()).collect() +} + +/// The member reports of a Monte-Carlo family, in seed-input order. +pub fn mc_member_reports(family: &McFamily) -> Vec { + family.draws.iter().map(|d| d.report.clone()).collect() +} + +/// The per-window OOS reports of a walk-forward result, in roll order. +pub fn walkforward_member_reports(result: &WalkForwardResult) -> Vec { + result.windows.iter().map(|w| w.run.oos_report.clone()).collect() +} +``` + +- [ ] **Step 5: Wire the module + re-exports + header doc in `lib.rs`** + +In `crates/aura-registry/src/lib.rs`, immediately after the +`use aura_engine::{RunReport, SweepFamily, SweepPoint};` line at `:14`, add: + +```rust + +mod lineage; +pub use lineage::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family, + FamilyKind, FamilyRunRecord, +}; +``` + +Then extend the module-level header doc. After the existing doc line at `:6` +(`//! \`RunReport::to_json\`).`), add: + +```rust +//! +//! 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. +``` + +- [ ] **Step 6: Run the lineage tests to verify they pass** + +Run: `cargo test -p aura-registry` +Expected: PASS — `test result: ok. 11 passed; 0 failed` (the 6 pre-existing tests ++ the 5 new lineage tests). In particular `family_store_and_flat_store_are_disjoint` +confirms the flat `runs.jsonl` path is untouched. + +- [ ] **Step 7: Iteration-1 lint gate** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: finishes with 0 warnings. + +--- + +## Iteration 2 — CLI surface + +### Task 4: `window_of` migration of the manifest-window scans (`aura-cli`) + +Behaviour-preserving refactor: every `prices.first()/.last()` manifest-window scan +(and the two already-windowed `(from, to)` manifest sites) is replaced by +`window_of(&sources)`. Byte-identical — the existing determinism/pin tests are the +gate (no new test). Architecture §1 of the spec governs ("the CLI's +`prices.first()/.last()` manifest-window scans are replaced by `window_of`"), so +`run_macd` — a scan the §Implementation-shapes enumeration omitted — is migrated too. + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (imports `:17-22`; `run_sample` `:144-151`; `sweep_family` `:379-384`; `walkforward_family` `:457-461`; `sweep_over` `:485-501`; `run_oos` `:514-525`; `run_macd` `:709-714`) + +- [ ] **Step 1: Import `window_of`** + +In `crates/aura-cli/src/main.rs`, the `use aura_engine::{...}` block at `:17-22`. + +BEFORE: +```rust +use aura_engine::{ + f64_field, param_stability, summarize, walk_forward, Composite, Edge, FlatGraph, + GraphBuilder, Harness, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, + SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller, + WindowRun, +}; +``` + +AFTER: +```rust +use aura_engine::{ + f64_field, param_stability, summarize, walk_forward, window_of, Composite, Edge, FlatGraph, + GraphBuilder, Harness, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, + SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller, + WindowRun, +}; +``` + +- [ ] **Step 2: Migrate `run_sample`** + +In `fn run_sample` at `:146-151`. + +BEFORE: +```rust + let prices = synthetic_prices(); + let window = ( + prices.first().expect("non-empty stream").0, + prices.last().expect("non-empty stream").0, + ); + h.run(vec![Box::new(VecSource::new(prices))]); +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(VecSource::new(synthetic_prices()))]; + let window = window_of(&sources).expect("non-empty synthetic stream"); + h.run(sources); +``` + +- [ ] **Step 3: Migrate `sweep_family`** + +In `fn sweep_family`, inside the `.sweep(|point| { ... })` closure at `:379-384`. + +BEFORE: +```rust + let prices = showcase_prices(); + let window = ( + prices.first().expect("non-empty stream").0, + prices.last().expect("non-empty stream").0, + ); + h.run(vec![Box::new(VecSource::new(prices))]); +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(VecSource::new(showcase_prices()))]; + let window = window_of(&sources).expect("non-empty showcase stream"); + h.run(sources); +``` + +- [ ] **Step 4: Migrate `walkforward_family` span** + +In `fn walkforward_family` at `:457-461`. + +BEFORE: +```rust + let prices = walkforward_prices(); + let span = ( + prices.first().expect("non-empty synthetic stream").0, + prices.last().expect("non-empty synthetic stream").0, + ); + let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling) +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(VecSource::new(walkforward_prices()))]; + let span = window_of(&sources).expect("non-empty synthetic stream"); + let roller = WindowRoller::new(span, 24, 12, 12, RollMode::Rolling) +``` + +- [ ] **Step 5: Migrate `sweep_over`** + +In `fn sweep_over`, inside the `.sweep(|point| { ... })` closure. Replace the +`h.run(...)` line at `:490` and the manifest `(from, to)` at `:499`. + +BEFORE: +```rust + h.run(vec![Box::new(walkforward_window_source(from, to))]); + let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); + let params = space + .iter() + .zip(point) + .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) + .collect(); + RunReport { + manifest: sim_optimal_manifest(params, (from, to), 0), + metrics: summarize(&equity, &exposure), + } +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(walkforward_window_source(from, to))]; + let window = window_of(&sources).expect("non-empty in-sample window"); + h.run(sources); + let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); + let params = space + .iter() + .zip(point) + .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) + .collect(); + RunReport { + manifest: sim_optimal_manifest(params, window, 0), + metrics: summarize(&equity, &exposure), + } +``` + +- [ ] **Step 6: Migrate `run_oos`** + +In `fn run_oos`. Replace the `h.run(...)` line at `:514` and the manifest +`(from, to)` at `:523`. + +BEFORE: +```rust + h.run(vec![Box::new(walkforward_window_source(from, to))]); + let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); + let named = space + .iter() + .zip(params) + .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) + .collect(); + let report = RunReport { + manifest: sim_optimal_manifest(named, (from, to), 0), + metrics: summarize(&equity, &exposure), + }; +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(walkforward_window_source(from, to))]; + let window = window_of(&sources).expect("non-empty out-of-sample window"); + h.run(sources); + let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); + let named = space + .iter() + .zip(params) + .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) + .collect(); + let report = RunReport { + manifest: sim_optimal_manifest(named, window, 0), + metrics: summarize(&equity, &exposure), + }; +``` + +- [ ] **Step 7: Migrate `run_macd`** + +In `fn run_macd` at `:709-714`. + +BEFORE: +```rust + let prices = macd_prices(); + let window = ( + prices.first().expect("non-empty stream").0, + prices.last().expect("non-empty stream").0, + ); + h.run(vec![Box::new(VecSource::new(prices))]); +``` + +AFTER: +```rust + let sources: Vec> = + vec![Box::new(VecSource::new(macd_prices()))]; + let window = window_of(&sources).expect("non-empty macd stream"); + h.run(sources); +``` + +- [ ] **Step 8: Run the full CLI suite (byte-identity regression gate)** + +Run: `cargo test -p aura-cli` +Expected: PASS — every test green, unchanged from before the migration. In +particular `run_sample_is_deterministic_and_non_trivial` (window still `(1, 7)`), +`sweep_report_is_deterministic`, the `walkforward_report` determinism/content tests, +and `run_macd_compiles_from_nested_composite_and_is_deterministic` all stay green, +proving `window_of` reproduces the old first/last windows byte-for-byte (the +walk-forward roller boundaries land on the bar grid, so the filtered-stream bounds +equal the requested `(from, to)`). + +--- + +### Task 5: `aura mc` — Monte-Carlo family on the CLI (`aura-cli`) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (imports `:17-23`; new `mc_family`, `run_mc`, `mc_aggregate_json`, `mc_report` test helper; `USAGE` `:737-738`; `match` arms `:756`-area; new test in `mod tests`) + +- [ ] **Step 1: Write the failing `aura mc` render test** + +In `crates/aura-cli/src/main.rs`, inside `mod tests` (alongside the other render +tests, e.g. after `sweep_report_is_deterministic` at `:902`), add: + +```rust + #[test] + fn mc_report_is_deterministic_and_one_line_per_seed() { + // C1 at the CLI edge: the family computation renders bit-identically. + assert_eq!(mc_report(), mc_report()); + let out = mc_report(); + let lines: Vec<&str> = out.lines().collect(); + // three seeds -> three member lines + one aggregate line + assert_eq!(lines.len(), 4, "expected 3 members + 1 aggregate: {out}"); + for line in &lines[..3] { + assert!(line.contains(r#""total_pips":"#), "member line missing metrics: {line}"); + } + assert!(lines[3].contains(r#""mc_aggregate":"#), "missing aggregate line: {}", lines[3]); + } + + #[test] + fn cli_families_persist_and_round_trip_per_kind() { + use aura_registry::{ + group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, + FamilyKind, Registry, + }; + let dir = std::env::temp_dir().join(format!("aura-cli-fam-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let reg = Registry::open(dir.join("runs.jsonl")); + + // the exact persist chain `run_sweep`/`run_mc`/`run_walkforward` use, against + // a fresh temp store (the run_* fns themselves bind `default_registry()`): + // engine family -> per-kind extractor -> append_family. + let sid = reg + .append_family("sweep", FamilyKind::Sweep, &sweep_member_reports(&sweep_family())) + .expect("sweep family"); + let mid = reg + .append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family())) + .expect("mc family"); + let wid = reg + .append_family( + "walkforward", + FamilyKind::WalkForward, + &walkforward_member_reports(&walkforward_family()), + ) + .expect("walkforward family"); + assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0")); + + let families = group_families(reg.load_family_members().expect("load")); + assert_eq!(families.len(), 3); + let by_id = |id: &str| families.iter().find(|f| f.id == id).expect("family present"); + assert_eq!(by_id("sweep-0").kind, FamilyKind::Sweep); + assert_eq!(by_id("mc-0").kind, FamilyKind::MonteCarlo); + assert_eq!(by_id("mc-0").members.len(), 3); // 3 seeds + assert_eq!(by_id("walkforward-0").kind, FamilyKind::WalkForward); + assert_eq!(by_id("walkforward-0").members.len(), 3); // 3 windows + let _ = std::fs::remove_dir_all(&dir); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-cli mc_report` +Expected: FAIL — compile error `cannot find function \`mc_report\`` / `cannot find +function \`mc_family\`` (the mc helpers do not exist yet; both new tests live in the +same crate so the whole test binary fails to compile). + +- [ ] **Step 3: Extend the engine + registry imports** + +In `crates/aura-cli/src/main.rs`: + +The `use aura_engine::{...}` block — add `monte_carlo`, `McAggregate`, `McFamily`. + +BEFORE: +```rust +use aura_engine::{ + f64_field, param_stability, summarize, walk_forward, window_of, Composite, Edge, FlatGraph, + GraphBuilder, Harness, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, + SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller, + WindowRun, +}; +``` + +AFTER: +```rust +use aura_engine::{ + f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, Composite, Edge, + FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode, RunManifest, RunReport, + SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, + WindowRoller, WindowRun, +}; +``` + +The `use aura_registry::{...}` line at `:23` — add `mc_member_reports` and `FamilyKind`. + +BEFORE: +```rust +use aura_registry::{optimize, rank_by, Registry}; +``` + +AFTER: +```rust +use aura_registry::{mc_member_reports, optimize, rank_by, FamilyKind, Registry}; +``` + +- [ ] **Step 4: Add `mc_family`, `mc_aggregate_json`, `run_mc`, and the `mc_report` test helper** + +In `crates/aura-cli/src/main.rs`, add these items (place them near the other +family builders/renderers, e.g. after `walkforward_report` at `:582`): + +```rust +/// The built-in Monte-Carlo family: the sample harness over a fixed (empty) base +/// point, re-seeded across a built-in seed set — each seed a disjoint C1 +/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`, +/// varying the *seed* rather than a tuning param. The seed -> `Source` +/// construction lives inside the per-draw closure (eager-agnostic, #71). +fn mc_family() -> McFamily { + let base_point: Vec = Vec::new(); + monte_carlo(&base_point, &[1, 2, 3], |seed, _base| { + let (mut h, rx_eq, rx_ex) = sample_harness(); + let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; + let sources: Vec> = vec![Box::new(spec.source(seed))]; + let window = window_of(&sources).expect("non-empty synthetic stream"); + h.run(sources); + let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); + RunReport { + manifest: sim_optimal_manifest( + vec![ + ("sma_fast".to_string(), 2.0), + ("sma_slow".to_string(), 4.0), + ("exposure_scale".to_string(), 0.5), + ], + window, + seed, + ), + metrics: summarize(&equity, &exposure), + } + }) +} + +/// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not +/// `Serialize` (only its `MetricStats` fields are), so the line is built from the +/// three per-metric stat blocks. +fn mc_aggregate_json(agg: &McAggregate) -> String { + serde_json::json!({ + "mc_aggregate": { + "total_pips": agg.total_pips, + "max_drawdown": agg.max_drawdown, + "exposure_sign_flips": agg.exposure_sign_flips, + } + }) + .to_string() +} + +/// `aura mc [--name ]`: run the built-in Monte-Carlo family, persist it to the +/// family store via `append_family` (C18/C21), print each draw's record line +/// (carrying the assigned `family_id`) plus the aggregate line. +fn run_mc(name: &str) { + let reg = default_registry(); + let family = mc_family(); + let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { + Ok(id) => id, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for draw in &family.draws { + println!( + "{}", + serde_json::json!({ "family_id": id, "seed": draw.seed, "report": draw.report }) + ); + } + println!("{}", mc_aggregate_json(&family.aggregate)); +} + +/// Render the built-in Monte-Carlo family as the per-draw `RunReport` lines plus +/// the aggregate line — the `run_mc` shape minus registry persistence (no +/// `family_id`, which is store-assigned). Test helper, mirroring `sweep_report` / +/// `walkforward_report`: it carries the C1-determinism test of the family +/// computation, separate from the store-dependent id. +#[cfg(test)] +fn mc_report() -> String { + let family = mc_family(); + let mut out = String::new(); + for draw in &family.draws { + out.push_str(&draw.report.to_json()); + out.push('\n'); + } + out.push_str(&mc_aggregate_json(&family.aggregate)); + out.push('\n'); + out +} +``` + +- [ ] **Step 5: Add `aura mc` to `USAGE`** + +In `crates/aura-cli/src/main.rs`, the `const USAGE` at `:737-738`. + +BEFORE: +```rust +const USAGE: &str = + "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep | aura walkforward | aura runs list | aura runs rank "; +``` + +AFTER: +```rust +const USAGE: &str = + "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep | aura mc | aura walkforward | aura runs list | aura runs rank "; +``` + +- [ ] **Step 6: Add the `mc` match arms** + +In `fn main`, the `match` block. After the `["walkforward"] => run_walkforward(),` +arm at `:757`, add: + +```rust + ["mc"] => run_mc("mc"), + ["mc", "--name", n] => run_mc(n), +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `cargo test -p aura-cli mc_report` +Expected: PASS — `mc_report_is_deterministic_and_one_line_per_seed ... ok`. + +Run: `cargo test -p aura-cli cli_families` +Expected: PASS — `cli_families_persist_and_round_trip_per_kind ... ok` (sweep / mc / +walk-forward each persist a family that `group_families` recovers as one unit of the +right kind and member count, ids `sweep-0` / `mc-0` / `walkforward-0` — spec test 11; +this is the exact persist chain the run_* functions drive, against a temp store). + +--- + +### Task 6: family-aware `aura runs` + `sweep`/`walkforward` → `append_family` (`aura-cli`) + +This task is wiring + a behaviour-changing refactor + new display commands. No new +RED test: the family-persist round-trip (spec test 11) is RED-tested in Task 5 and +covers the exact `engine family -> extractor -> append_family` chain `run_sweep` / +`run_walkforward` switch to here. The display commands' output is not spec-pinned; +their gate is "compiles + clippy-clean + the full suite stays green". The spec shows +`run_sweep("sweep")` / `run_mc("mc")` (name-only, internal `default_registry()`), so +the functions are not registry-injectable — hence the chain, not the entry point, is +what test 11 drives. + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (registry import `:23`; `run_sweep` `:419-431`; `run_walkforward` `:433-450`; new `runs_families`, `runs_family`; `USAGE` `:737-738`; `match` arms `:756-759`) + +- [ ] **Step 1: Extend the registry import** + +In `crates/aura-cli/src/main.rs`, the `use aura_registry::{...}` line at `:23` +(already carrying `mc_member_reports`, `FamilyKind` from Task 5) — add +`group_families`, `sweep_member_reports`, `walkforward_member_reports`. + +BEFORE: +```rust +use aura_registry::{mc_member_reports, optimize, rank_by, FamilyKind, Registry}; +``` + +AFTER: +```rust +use aura_registry::{ + group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, + walkforward_member_reports, FamilyKind, Registry, +}; +``` + +- [ ] **Step 2: Switch `run_sweep` to `append_family` with a name** + +In `crates/aura-cli/src/main.rs`, `fn run_sweep` at `:419-431`. + +BEFORE: +```rust +/// `aura sweep`: run the built-in sweep, persist each point's `RunReport` to the +/// registry (the run record, queryable over time — C18), and print each as one +/// JSON line. +fn run_sweep() { + let reg = default_registry(); + for pt in &sweep_family().points { + if let Err(e) = reg.append(&pt.report) { + eprintln!("aura: {e}"); + std::process::exit(2); + } + println!("{}", pt.report.to_json()); + } +} +``` + +AFTER: +```rust +/// `aura sweep [--name ]`: run the built-in sweep, persist it as a *family* +/// (related records sharing one `family_id`, C18/C21) via `append_family`, and +/// print each point's record line carrying the assigned id. +fn run_sweep(name: &str) { + let reg = default_registry(); + let family = sweep_family(); + let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { + Ok(id) => id, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for pt in &family.points { + println!("{}", serde_json::json!({ "family_id": id, "report": pt.report })); + } +} +``` + +- [ ] **Step 3: Switch `run_walkforward` to `append_family` with a name** + +In `crates/aura-cli/src/main.rs`, `fn run_walkforward` at `:433-450`. + +BEFORE: +```rust +/// `aura walkforward`: run a built-in rolling walk-forward over the sample +/// blueprint + a synthetic windowed source. Per window: sweep the built-in grid on +/// the in-sample slice, optimize by total_pips (axis 2 inside axis 3, where +/// aura-cli bridges engine + registry), run the chosen params out-of-sample, +/// persist the OOS RunReport (C18), and print it; finally print the stitched +/// summary line. Deterministic (C1). +fn run_walkforward() { + let reg = default_registry(); + let result = walkforward_family(); + for w in &result.windows { + if let Err(e) = reg.append(&w.run.oos_report) { + eprintln!("aura: {e}"); + std::process::exit(2); + } + println!("{}", w.run.oos_report.to_json()); + } + println!("{}", walkforward_summary_json(&result)); +} +``` + +AFTER: +```rust +/// `aura walkforward [--name ]`: run a built-in rolling walk-forward over the +/// sample blueprint + a synthetic windowed source. Per window: sweep the built-in +/// grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3, +/// where aura-cli bridges engine + registry), run the chosen params out-of-sample. +/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`, +/// print each carrying the assigned id, then the stitched summary line. +/// Deterministic (C1). +fn run_walkforward(name: &str) { + let reg = default_registry(); + let result = walkforward_family(); + let id = + match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result)) + { + Ok(id) => id, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for w in &result.windows { + println!("{}", serde_json::json!({ "family_id": id, "report": w.run.oos_report })); + } + println!("{}", walkforward_summary_json(&result)); +} +``` + +- [ ] **Step 4: Add `runs_families` and `runs_family`** + +In `crates/aura-cli/src/main.rs`, add after `fn runs_rank` (ends at `:604`): + +```rust +/// `aura runs families`: one header line per stored family (id, kind, member +/// count), in first-seen store order. +fn runs_families() { + let reg = default_registry(); + let members = match reg.load_family_members() { + Ok(m) => m, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for fam in group_families(members) { + println!( + "{}", + serde_json::json!({ "family_id": fam.id, "kind": fam.kind, "members": fam.members.len() }) + ); + } +} + +/// `aura runs family [rank ]`: list one family's member reports in +/// ordinal order, or best-first by `metric`. An unknown id is an empty family +/// (prints nothing, exit 0, consistent with `runs list` over an empty store); an +/// unknown metric is a usage error (stderr + exit 2). +fn runs_family(id: &str, rank: Option<&str>) { + let reg = default_registry(); + let members = match reg.load_family_members() { + Ok(m) => m, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + let Some(family) = group_families(members).into_iter().find(|f| f.id == id) else { + return; // unknown family id: empty, exit 0 + }; + let reports: Vec = family.members.iter().map(|m| m.report.clone()).collect(); + let ordered = match rank { + Some(metric) => match rank_by(reports, metric) { + Ok(r) => r, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }, + None => reports, + }; + for report in &ordered { + println!("{}", report.to_json()); + } +} +``` + +- [ ] **Step 5: Update `USAGE` to the final form** + +In `crates/aura-cli/src/main.rs`, the `const USAGE` (set to the mc-form in Task 5). + +BEFORE: +```rust +const USAGE: &str = + "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep | aura mc | aura walkforward | aura runs list | aura runs rank "; +``` + +AFTER: +```rust +const USAGE: &str = + "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep [--name ] | aura mc [--name ] | aura walkforward [--name ] | aura runs list | aura runs rank | aura runs families | aura runs family [rank ]"; +``` + +- [ ] **Step 6: Update the `match` arms** + +In `fn main`, the `match` block. Update the `sweep`/`walkforward` arms (now take a +name) and add the `--name` + `runs families`/`family` arms. + +BEFORE: +```rust + ["sweep"] => run_sweep(), + ["walkforward"] => run_walkforward(), + ["mc"] => run_mc("mc"), + ["mc", "--name", n] => run_mc(n), + ["runs", "list"] => runs_list(), + ["runs", "rank", metric] => runs_rank(metric), +``` + +AFTER: +```rust + ["sweep"] => run_sweep("sweep"), + ["sweep", "--name", n] => run_sweep(n), + ["walkforward"] => run_walkforward("walkforward"), + ["walkforward", "--name", n] => run_walkforward(n), + ["mc"] => run_mc("mc"), + ["mc", "--name", n] => run_mc(n), + ["runs", "list"] => runs_list(), + ["runs", "rank", metric] => runs_rank(metric), + ["runs", "families"] => runs_families(), + ["runs", "family", id] => runs_family(id, None), + ["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)), +``` + +(Note: the `["mc"]` / `["mc", "--name", n]` arms were added in Task 5; they appear +here only to locate the surrounding context for the edit.) + +- [ ] **Step 7: Full workspace test + lint gate** + +Run: `cargo test --workspace` +Expected: PASS — all crates green (engine bounds/window_of, ingest data-gated bounds, +registry lineage round-trips, CLI mc + family round-trip + the unchanged determinism +tests, which confirm `run_sweep`/`run_walkforward` still build cleanly after the +flat→family switch and no existing behaviour regressed). + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: finishes with 0 warnings.