# 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<dyn Source> = Box::new(VecSource::new(f64_stream(&[(10, 1.0), (40, 2.0)])));
        let b: Box<dyn Source> = 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<dyn Source> = Box::new(VecSource::new(vec![]));
        let e2: Box<dyn Source> = 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<dyn Source>]) -> 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<M1Parsed>,
    chunk: Option<Arc<[M1Parsed]>>,
    pos: usize,
    field: M1Field,
    head: Option<(Timestamp, Scalar)>,
}
```

AFTER:
```rust
pub struct M1FieldSource {
    iter: SymbolChunkIter<M1Parsed>,
    chunk: Option<Arc<[M1Parsed]>>,
    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<i64>,
    to_ms: Option<i64>,
}
```

- [ ] **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<_>>(), 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<_>>(), 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<_>>(), 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::<RunReport>::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<RunReport> = 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<FamilyRunRecord>,
}

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<String, RegistryError> {
        let existing: HashSet<String> =
            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<Vec<FamilyRunRecord>, 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>) -> 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<FamilyRunRecord>) -> Vec<Family> {
    let mut order: Vec<String> = Vec::new();
    let mut by_id: HashMap<String, 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() }
        });
        fam.members.push(rec);
    }
    let mut families: Vec<Family> =
        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<RunReport> {
    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<RunReport> {
    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<RunReport> {
    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<Box<dyn aura_engine::Source>> =
        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<Box<dyn aura_engine::Source>> =
                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<Box<dyn aura_engine::Source>> =
        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::<Vec<_>>(), 0);
            let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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<Box<dyn aura_engine::Source>> =
                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::<Vec<_>>(), 0);
            let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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::<Vec<_>>(), 0);
    let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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<Box<dyn aura_engine::Source>> =
        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::<Vec<_>>(), 0);
    let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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<Box<dyn aura_engine::Source>> =
        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<Scalar> = 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<Box<dyn aura_engine::Source>> = 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::<Vec<_>>(), 0);
        let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 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 <n>]`: 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 <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura walkforward | aura runs list | aura runs rank <metric>";
```

AFTER:
```rust
const USAGE: &str =
    "usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura mc | aura walkforward | aura runs list | aura runs rank <metric>";
```

- [ ] **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 <n>]`: 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 <n>]`: 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 <id> [rank <metric>]`: 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<RunReport> = 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 <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep | aura mc | aura walkforward | aura runs list | aura runs rank <metric>";
```

AFTER:
```rust
const USAGE: &str =
    "usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs list | aura runs rank <metric> | aura runs families | aura runs family <id> [rank <metric>]";
```

- [ ] **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.
