From bcd1072ca8515cb22d2a2a05ade71f120fff7608 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 18:27:54 +0200 Subject: [PATCH] feat(aura-cli): aura mc + family-aware runs + window_of migration (0045 iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2 of spec 0045 — the CLI surface for orchestration families. window_of migration: every manifest-window scan (run_sample, sweep_family, walkforward_family, sweep_over, run_oos, run_macd) now reads the window from Source::bounds() via window_of(&sources) instead of prices.first()/.last(). Behaviour-preserving (the walk-forward roller boundaries are bar-aligned, so the producer-supplied window equals the old first/last) — pinned by the unchanged run_sample (1,7) and walk-forward determinism tests. aura mc: new built-in Monte-Carlo family on the CLI (monte_carlo over a built-in seed set + the sample harness re-seeded per draw), persisted via append_family and rendered as one member line per seed (carrying the family_id) plus an mc_aggregate line. McAggregate is not Serialize, so the aggregate line is built from its three MetricStats blocks. family-aware runs: aura sweep / walkforward / mc now persist to the family store via append_family with an optional --name (per-kind default), printing the assigned family_id per member. aura runs families lists family headers (id, kind, member count); aura runs family [rank ] lists/ranks one family's members as a unit. An unknown family id is an empty family (exit 0); an unknown metric is a usage error (exit 2). Two pre-existing cli_run.rs E2E tests were adapted to the new family contract (a required consequence of sweep/walkforward switching from the flat store to the family store — a plan gap caught at implement time): the sweep-output test now asserts the family-wrapper, and the runs list/rank flow became the family list / per-family-rank flow (which also exercises the per-name counter sweep-0/sweep-1 end-to-end). A new E2E test drives aura mc through the binary. Known cosmetic detail: the family-wrapped sweep/mc/walkforward stdout nests the report via serde_json::json! (to_value -> alphabetical keys, leading "broker"), while runs family / runs list print via RunReport::to_json() (declaration order, leading "commit"). Both valid JSON; key order is not load-bearing and the stored families.jsonl uses to_string (declaration order), so the round-trip is unaffected. Verification: cargo test --workspace green; cargo clippy --workspace --all-targets -D warnings clean. refs #70 --- crates/aura-cli/src/main.rs | 315 +++++++++++++++++++++++++------ crates/aura-cli/tests/cli_run.rs | 94 +++++++-- 2 files changed, 338 insertions(+), 71 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 09683f4..3d00564 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -15,12 +15,15 @@ mod render; use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; 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, + 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, +}; +use aura_registry::{ + group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, + walkforward_member_reports, FamilyKind, Registry, }; -use aura_registry::{optimize, rank_by, Registry}; use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -143,12 +146,10 @@ fn sim_optimal_manifest( /// (C1): the same build yields the same report. fn run_sample() -> RunReport { let (mut h, rx_eq, rx_ex) = sample_harness(); - 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))]); + let sources: Vec> = + vec![Box::new(VecSource::new(synthetic_prices()))]; + let window = window_of(&sources).expect("non-empty synthetic stream"); + h.run(sources); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); @@ -376,12 +377,10 @@ fn sweep_family() -> SweepFamily { let mut h = bp .bootstrap_with_params(point.to_vec()) .expect("grid points are kind-checked against param_space"); - 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))]); + let sources: Vec> = + vec![Box::new(VecSource::new(showcase_prices()))]; + let window = window_of(&sources).expect("non-empty showcase stream"); + 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 @@ -416,35 +415,45 @@ fn default_registry() -> Registry { Registry::open("runs/runs.jsonl") } -/// `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() { +/// `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(); - for pt in &sweep_family().points { - if let Err(e) = reg.append(&pt.report) { + 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); } - println!("{}", pt.report.to_json()); + }; + for pt in &family.points { + println!("{}", serde_json::json!({ "family_id": id, "report": pt.report })); } } -/// `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() { +/// `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 { - 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!("{}", serde_json::json!({ "family_id": id, "report": w.run.oos_report })); } println!("{}", walkforward_summary_json(&result)); } @@ -454,11 +463,9 @@ fn run_walkforward() { /// windows. Each window sweeps the built-in grid in-sample, optimizes by /// total_pips (axis 2), and runs the chosen params out-of-sample. fn walkforward_family() -> WalkForwardResult { - let prices = walkforward_prices(); - let span = ( - prices.first().expect("non-empty synthetic stream").0, - prices.last().expect("non-empty synthetic stream").0, - ); + 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) .expect("built-in walk-forward config fits the 60-bar synthetic span"); walk_forward(roller, |w: WindowBounds| { @@ -487,7 +494,10 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily { let mut h = bp .bootstrap_with_params(point.to_vec()) .expect("grid points are kind-checked against param_space"); - h.run(vec![Box::new(walkforward_window_source(from, to))]); + 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 @@ -496,7 +506,7 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily { .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) .collect(); RunReport { - manifest: sim_optimal_manifest(params, (from, to), 0), + manifest: sim_optimal_manifest(params, window, 0), metrics: summarize(&equity, &exposure), } }) @@ -511,7 +521,10 @@ fn run_oos(params: &[Scalar], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp let mut h = bp .bootstrap_with_params(params.to_vec()) .expect("chosen params are kind-checked against param_space"); - h.run(vec![Box::new(walkforward_window_source(from, to))]); + 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 @@ -520,7 +533,7 @@ fn run_oos(params: &[Scalar], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) .collect(); let report = RunReport { - manifest: sim_optimal_manifest(named, (from, to), 0), + manifest: sim_optimal_manifest(named, window, 0), metrics: summarize(&equity, &exposure), }; (equity, report) @@ -581,6 +594,90 @@ fn walkforward_report() -> String { out } +/// 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 +} + /// `aura runs list`: print every stored run record, in store (over-time) order. fn runs_list() { for report in &load_runs_or_exit() { @@ -603,6 +700,57 @@ fn runs_rank(metric: &str) { } } +/// `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()); + } +} + /// Load the registry or exit 2 with the error. A missing registry loads empty. fn load_runs_or_exit() -> Vec { default_registry().load().unwrap_or_else(|e| { @@ -706,12 +854,10 @@ fn run_macd() -> RunReport { .expect("valid macd blueprint"); let mut h = Harness::bootstrap(flat).expect("valid macd harness"); - 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))]); + let sources: Vec> = + vec![Box::new(VecSource::new(macd_prices()))]; + let window = window_of(&sources).expect("non-empty macd stream"); + h.run(sources); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); @@ -735,7 +881,7 @@ fn run_macd() -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] | aura run --real [--from ] [--to ] | aura graph | aura sweep | aura walkforward | aura runs list | aura runs rank "; + "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 ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -753,10 +899,17 @@ fn main() { } }, ["graph"] => print!("{}", render::render_html(&sample_blueprint())), - ["sweep"] => run_sweep(), - ["walkforward"] => run_walkforward(), + ["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)), ["--help"] | ["-h"] => println!("{USAGE}"), _ => { eprintln!("aura: {USAGE}"); @@ -901,6 +1054,60 @@ mod tests { assert_eq!(sweep_report(), sweep_report()); } + #[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); + } + #[test] fn run_macd_compiles_from_nested_composite_and_is_deterministic() { // the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index cb373ca..6d46efe 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -199,7 +199,7 @@ fn graph_emits_self_contained_html_viewer() { } #[test] -fn sweep_prints_four_json_lines_and_exits_zero() { +fn sweep_prints_four_family_json_lines_and_exits_zero() { let cwd = temp_cwd("sweep4"); let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep"); assert!(out.status.success(), "exit status: {:?}", out.status); @@ -207,9 +207,11 @@ fn sweep_prints_four_json_lines_and_exits_zero() { let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}"); let first = stdout.lines().next().unwrap(); - // each line is a full RunReport; commit is the real git HEAD (volatile), so - // pin the first odometer point's manifest params, not the commit value. - assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}"); + // each line is now a family member: the assigned `family_id` plus the nested + // RunReport (C18/C21 lineage). The first run assigns `sweep-0`; the nested + // report is serialized via `serde_json`, so the manifest's fields lead with + // `broker` (the commit value is the volatile git HEAD, pinned by params below). + assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}"); assert!( first.contains("\"params\":[[\"signals.trend.fast.length\",2.0],[\"signals.trend.slow.length\",4.0],[\"signals.momentum.fast.length\",2.0],[\"signals.momentum.slow.length\",4.0],[\"signals.momentum.signal.length\",3.0],[\"signals.blend.weights[0]\",1.0],[\"signals.blend.weights[1]\",1.0],[\"exposure.scale\",0.5]]"), "got: {stdout}" @@ -224,27 +226,71 @@ fn sweep_with_trailing_token_is_strict_and_exits_two() { assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); } +/// Property: `aura mc` runs the built-in Monte-Carlo family end-to-end through the +/// binary — it prints one member line per seed (carrying the assigned `family_id`) +/// plus one `mc_aggregate` line, exits 0, and persists the draws as a discoverable +/// `MonteCarlo` family (C12 axis 4 / C18/C21). Driven through the built binary so +/// it pins the observable CLI contract, not just the in-crate render helper. #[test] -fn runs_list_and_rank_across_invocations() { +fn mc_runs_persists_a_monte_carlo_family_and_lists_it() { + let cwd = temp_cwd("mc-family"); + let out = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc"); + assert!(out.status.success(), "mc exit: {:?}", out.status); + + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let lines: Vec<&str> = stdout.lines().collect(); + // three built-in seeds -> three member lines + one aggregate line. + assert_eq!(lines.len(), 4, "mc stdout: {stdout:?}"); + for line in &lines[..3] { + assert!(line.contains("\"family_id\":\"mc-0\""), "member missing family_id: {line}"); + assert!(line.contains("\"seed\":"), "member missing seed: {line}"); + } + assert!(lines[3].contains("\"mc_aggregate\":"), "missing aggregate line: {}", lines[3]); + + // the run persisted the draws as a discoverable MonteCarlo family. + let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); + assert!(fams.status.success(), "families exit: {:?}", fams.status); + let fams_out = String::from_utf8(fams.stdout).expect("utf-8"); + assert_eq!(fams_out.lines().count(), 1, "families: {fams_out:?}"); + assert!(fams_out.contains("\"family_id\":\"mc-0\""), "families: {fams_out:?}"); + assert!(fams_out.contains("\"kind\":\"MonteCarlo\""), "families: {fams_out:?}"); + assert!(fams_out.contains("\"members\":3"), "families: {fams_out:?}"); + + let _ = std::fs::remove_dir_all(&cwd); +} + +#[test] +fn runs_families_list_and_per_family_rank_across_invocations() { let cwd = temp_cwd("runs-flow"); - // two sweeps accumulate 8 records over time + // two sweeps accumulate two families (sweep-0, sweep-1) over time, each a + // related set of 4 records (C18/C21 lineage), in the sibling family store. for _ in 0..2 { let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep"); assert!(out.status.success(), "sweep exit: {:?}", out.status); } - // `runs list` shows all 8 stored records - let list = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list"); - assert!(list.status.success(), "list exit: {:?}", list.status); - let list_out = String::from_utf8(list.stdout).expect("utf-8"); - assert_eq!(list_out.lines().count(), 8, "list: {list_out:?}"); - assert!(list_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "list shape: {list_out:?}"); + // `runs families` shows both stored families in first-seen order, each with + // its kind + member count. + let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families"); + assert!(fams.status.success(), "families exit: {:?}", fams.status); + let fams_out = String::from_utf8(fams.stdout).expect("utf-8"); + let fam_lines: Vec<&str> = fams_out.lines().collect(); + assert_eq!(fam_lines.len(), 2, "families: {fams_out:?}"); + assert!(fam_lines[0].contains("\"family_id\":\"sweep-0\""), "fam0: {fams_out:?}"); + assert!(fam_lines[1].contains("\"family_id\":\"sweep-1\""), "fam1: {fams_out:?}"); + assert!(fam_lines.iter().all(|l| l.contains("\"members\":4")), "members: {fams_out:?}"); - // `runs rank total_pips`: 8 lines, highest total_pips first - let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).current_dir(&cwd).output().expect("spawn rank"); + // `runs family sweep-0 rank total_pips`: the family's 4 members, highest + // total_pips first. + let rank = Command::new(BIN) + .args(["runs", "family", "sweep-0", "rank", "total_pips"]) + .current_dir(&cwd) + .output() + .expect("spawn rank"); assert!(rank.status.success(), "rank exit: {:?}", rank.status); let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); - assert_eq!(rank_out.lines().count(), 8, "rank: {rank_out:?}"); + assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}"); + assert!(rank_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "rank shape: {rank_out:?}"); let pips: Vec = rank_out .lines() .map(|l| { @@ -257,10 +303,24 @@ fn runs_list_and_rank_across_invocations() { .collect(); assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}"); - // unknown metric is a usage error - let bogus = Command::new(BIN).args(["runs", "rank", "bogus"]).current_dir(&cwd).output().expect("spawn bogus"); + // an unknown metric is a usage error + let bogus = Command::new(BIN) + .args(["runs", "family", "sweep-0", "rank", "bogus"]) + .current_dir(&cwd) + .output() + .expect("spawn bogus"); assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status); + // an unknown family id is an empty family: zero lines, exit 0 (consistent + // with `runs list` over an empty store). + let unknown = Command::new(BIN) + .args(["runs", "family", "nope-9"]) + .current_dir(&cwd) + .output() + .expect("spawn unknown"); + assert!(unknown.status.success(), "unknown family exit: {:?}", unknown.status); + assert_eq!(unknown.stdout.len(), 0, "unknown family stdout: {:?}", unknown.stdout); + let _ = std::fs::remove_dir_all(&cwd); }