Files
Aura/crates/aura-registry/src/lib.rs
T
Brummel 89d967a940 feat(0067): SQN100 metric + stage1-r sweep --trace
Two additive follow-ups to the cycle-0065/0066 R-sweep surface.

Part A (#130) — n-normalized SQN (SQN100): RMetrics gains sqn_normalized =
(mean_R/stdev_R)·√(min(n,100)), a turnover-robust ranking objective comparable
across sweep members with different trade counts. The raw sqn is kept
byte-identical (computed sqrt(n)*mean/sd, NOT via a factored shared ratio, so
the existing metric does not drift even by 1 ULP). The field carries
#[serde(default)] for C18 back-compat (a pre-0067 r: block deserialises with
sqn_normalized = 0.0). The registry learns Metric::SqnNormalized (opt-in; the
default ranker and raw sqn are unchanged), so `runs family rank sqn_normalized`
works. The 0066 single-run golden is re-baselined (the r: block gains the
field; for n=3 ≤ 100 the cap is inactive so sqn_normalized == sqn).

Part B (#135) — wire --trace for the stage1-r sweep: stage1_r_sweep_family now
honours --trace, persisting each member's equity/exposure/r_equity under
runs/traces/<n>/<member_key>/ via the existing persist_traces_r (mirroring
momentum_sweep_family); the interim run_sweep refusal guard is dropped. --trace
is now symmetric across all three sweep strategies and a swept member is
chartable (chart <n>/<member> --tap r_equity).

Tests: SQN100 cap / below-cap / empty + serde back-compat (engine); rank-by
sqn_normalized + unknown-metric message (registry); an E2E guard that both
SQN values fold from recorded records and agree below the cap (the cross-crate
column seam); inverted stage1-r --trace persistence + a CLI rank-by-
sqn_normalized integration. Full workspace suite green (527 passed, 0 failed);
clippy --all-targets -D warnings clean.

Derived fork decisions logged on #130 and #135 (cap = fixed const 100; opt-in
metric, default ranker unchanged; serde(default); Part B mirrors
momentum_sweep_family + reuses persist_traces_r).

closes #130
closes #135
2026-06-24 18:10:54 +02:00

550 lines
25 KiB
Rust

//! The run registry (C18): an append-only store of run records — one
//! `(manifest, metrics)` `RunReport` per line in a JSONL file — with a typed
//! read-path (serde) for listing and ranking runs across invocations ("compare
//! experiments over time", which has no home in git or Gitea). Storage is
//! serde_json; display is the caller's concern (the CLI prints via
//! `RunReport::to_json`).
//!
//! 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 its `family` name + `run` index (the
//! `family_id` handle is derived from the pair), re-derived as a unit by
//! [`group_families`]. The flat runs store and its API are untouched.
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use aura_engine::{RunReport, SweepFamily, SweepPoint};
mod compat;
mod lineage;
pub use lineage::{
group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family,
FamilyKind, FamilyRunRecord,
};
mod trace_store;
pub use trace_store::{FamilyMember, NameKind, RunTraces, TraceStore, TraceStoreError, WriteKind};
/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
pub struct Registry {
path: PathBuf,
}
impl Registry {
/// Bind to a JSONL runs path. No I/O — the file is created lazily on first
/// append.
///
/// A `Registry` owns **two** directory-co-located stores: the bound runs file
/// (this `path`) and a fixed-name `families.jsonl` **sibling** in the same
/// directory — written by [`Registry::append_family`] and read by
/// [`Registry::load_family_members`], both via
/// `self.path.with_file_name("families.jsonl")`. Isolation between registries
/// is therefore **per-directory, not per-filename**: two `open` calls with
/// different runs *filenames* in the same directory share one family store. To
/// isolate runs (e.g. per-process or per-test), bind a distinct *directory*,
/// not merely a distinct runs filename.
pub fn open(path: impl AsRef<Path>) -> Registry {
Registry { path: path.as_ref().to_path_buf() }
}
/// Append one record as a single JSON line, creating the file (and its parent
/// directory) if absent.
pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
// a RunReport is finite by construction (its f64 fields are finite), so
// serialization is infallible here.
let line = serde_json::to_string(report).expect("a finite RunReport serializes");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Parse every non-empty line back into a typed `RunReport`, in file order. A
/// missing file is an empty registry (`Ok(vec![])`), not an error.
pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> {
let text = match fs::read_to_string(&self.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 reports = Vec::new();
for (i, raw) in text.lines().enumerate() {
if raw.trim().is_empty() {
continue;
}
// Read via the back-compat mirror: tolerant of both the current
// tagged-`Scalar` param shape (`{"F64":2.0}`) and the legacy pre-0047
// bare-float shape (`2.0`). The forward write path (`append`,
// `RunReport::to_json`) is unchanged — it still emits the tagged form.
let report: RunReport = serde_json::from_str::<compat::RunReportRead>(raw)
.map_err(|source| RegistryError::Parse { line: i + 1, source })?
.into();
reports.push(report);
}
Ok(reports)
}
}
/// Which metric a best-first comparison keys on. Resolves a metric *name* (a
/// string at the API boundary) to a closed kind once, so the per-comparison hot
/// path branches on this rather than re-parsing the name.
#[derive(Clone, Copy)]
enum Metric {
TotalPips,
MaxDrawdown,
BiasSignFlips,
Sqn,
SqnNormalized,
ExpectancyR,
NetExpectancyR,
}
/// The per-metric **best-first ordering** of two reports: `Less` means `a` is
/// better than `b`. "Best" is fixed by each metric's meaning: `total_pips` and
/// the four R keys (`sqn`, `sqn_normalized`, `expectancy_r`, `net_expectancy_r`) higher-is-better;
/// `max_drawdown` and `bias_sign_flips` lower-is-better. The top-level `f64` keys
/// (`total_pips`, `max_drawdown`) use `partial_cmp` — finite by construction, so
/// the `Ordering` always exists. The R keys live in an optional `metrics.r` block
/// and use `total_cmp`: a member with `r: None` injects `f64::NEG_INFINITY` (the
/// worst rank for higher-is-better), and `total_cmp` orders that — and any stray
/// `NaN` — deterministically without panicking. An unknown metric name is a
/// `RegistryError::UnknownMetric`.
///
/// The single source of truth for "best" — both [`rank_by`] (sort by it) and
/// [`optimize`] (argmax by it) call this, so the per-metric direction lives in
/// exactly one place. The metric name is matched once here; the returned closure
/// carries the resolved `Metric`, so per-comparison work is just the key compare.
fn metric_cmp(
metric: &str,
) -> Result<impl Fn(&RunReport, &RunReport) -> Ordering, RegistryError> {
let metric = match metric {
"total_pips" => Metric::TotalPips,
"max_drawdown" => Metric::MaxDrawdown,
// accept the new name AND the pre-rename one (CLI back-compat).
"bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips,
"sqn" => Metric::Sqn,
"sqn_normalized" => Metric::SqnNormalized,
"expectancy_r" => Metric::ExpectancyR,
"net_expectancy_r" => Metric::NetExpectancyR,
other => return Err(RegistryError::UnknownMetric(other.to_string())),
};
// The R metrics live in an optional block; a missing `r` sorts to the bottom
// for these higher-is-better keys (NEG_INFINITY < any finite R).
fn r_get(rep: &RunReport, f: impl Fn(&aura_engine::RMetrics) -> f64) -> f64 {
rep.metrics.r.as_ref().map(f).unwrap_or(f64::NEG_INFINITY)
}
Ok(move |a: &RunReport, b: &RunReport| match metric {
// higher-is-better -> better when greater
Metric::TotalPips => b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap(),
// lower-is-better -> better when smaller
Metric::MaxDrawdown => a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap(),
Metric::BiasSignFlips => {
a.metrics.bias_sign_flips.cmp(&b.metrics.bias_sign_flips)
}
// higher-is-better; total_cmp so NEG_INFINITY orders deterministically and
// no NaN can panic.
Metric::Sqn => r_get(b, |r| r.sqn).total_cmp(&r_get(a, |r| r.sqn)),
Metric::SqnNormalized => {
r_get(b, |r| r.sqn_normalized).total_cmp(&r_get(a, |r| r.sqn_normalized))
}
Metric::ExpectancyR => {
r_get(b, |r| r.expectancy_r).total_cmp(&r_get(a, |r| r.expectancy_r))
}
Metric::NetExpectancyR => {
r_get(b, |r| r.net_expectancy_r).total_cmp(&r_get(a, |r| r.net_expectancy_r))
}
})
}
/// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed
/// by each metric's meaning (see `metric_cmp`). A stable sort keeps file
/// (insertion) order among ties. An unknown metric name is a
/// `RegistryError::UnknownMetric`.
pub fn rank_by(mut reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> {
let cmp = metric_cmp(metric)?;
reports.sort_by(|a, b| cmp(a, b));
Ok(reports)
}
/// `rank_by`'s argmax: return the single best `SweepPoint` of a `SweepFamily`
/// under a named metric, by the same fixed per-metric sense of best (see
/// `metric_cmp`). The whole point is carried — its `params` coordinate AND its
/// `RunReport` — because the caller needs the params that won. Ties on the metric
/// resolve to the **earliest** enumeration-order point (the family is already in
/// odometer order; only a strictly-better later point displaces the incumbent).
/// An unknown metric is a `RegistryError::UnknownMetric`. A `SweepFamily` is
/// non-empty by construction (a sweep over a zero-point grid is rejected upstream
/// by `EmptyAxis`), so `reduce` always yields a winner.
pub fn optimize(family: &SweepFamily, metric: &str) -> Result<SweepPoint, RegistryError> {
let cmp = metric_cmp(metric)?;
let winner = family
.points
.iter()
.reduce(|best, p| if cmp(&p.report, &best.report) == Ordering::Less { p } else { best })
.expect("a SweepFamily is non-empty by construction (EmptyAxis is rejected upstream)");
Ok(winner.clone())
}
/// What can go wrong reading or ranking the registry.
#[derive(Debug)]
pub enum RegistryError {
/// An I/O error reading or writing the JSONL file.
Io(std::io::Error),
/// A stored line did not parse as a `RunReport` (1-based line number).
Parse { line: usize, source: serde_json::Error },
/// `rank_by` was given a metric name it does not know.
UnknownMetric(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::Io(e) => write!(f, "registry i/o: {e}"),
RegistryError::Parse { line, source } => {
write!(f, "registry parse error at line {line}: {source}")
}
RegistryError::UnknownMetric(m) => write!(
f,
"unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips, sqn, sqn_normalized, expectancy_r, net_expectancy_r)"
),
}
}
}
impl std::error::Error for RegistryError {}
impl From<std::io::Error> for RegistryError {
fn from(e: std::io::Error) -> Self {
RegistryError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::RMetrics;
use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), Scalar::f64(1.0))],
window: (Timestamp(1), Timestamp(2)),
seed: 0,
broker: "b".to_string(),
},
metrics: RunMetrics { total_pips, max_drawdown, bias_sign_flips: flips, r: None },
}
}
/// A family member carrying an R block — mirrors `report_with` but sets
/// `metrics.r = Some(..)`. Only sqn / expectancy_r / net_expectancy_r vary per
/// call (the rank keys); `sqn_normalized` mirrors `sqn`; the rest are fixed.
fn report_with_r(sqn: f64, expectancy_r: f64, net_expectancy_r: f64) -> RunReport {
let mut rep = report_with(0.0, 0.0, 0);
rep.metrics.r = Some(RMetrics {
expectancy_r,
n_trades: 4,
win_rate: 0.5,
avg_win_r: 1.5,
avg_loss_r: -0.5,
profit_factor: 3.0,
max_r_drawdown: 0.5,
n_open_at_end: 0,
sqn,
sqn_normalized: sqn, // mirror sqn: only the rank-key wiring is under test here
net_expectancy_r,
conviction_terciles_r: [0.0, 0.0, 0.0],
});
rep
}
#[test]
fn rank_by_sqn_orders_members_descending() {
let reports = vec![
report_with_r(0.5, 0.1, 0.1),
report_with_r(2.0, 0.4, 0.4),
report_with_r(1.0, 0.2, 0.2),
];
let ranked = rank_by(reports, "sqn").expect("rank sqn");
let sqns: Vec<f64> = ranked.iter().map(|r| r.metrics.r.as_ref().unwrap().sqn).collect();
assert_eq!(sqns, vec![2.0, 1.0, 0.5], "sqn must rank highest-first");
}
#[test]
fn rank_by_sqn_normalized_orders_members_descending() {
// report_with_r sets sqn_normalized == sqn, so ranking by the new key
// orders these members highest-first by their sqn value.
let reports = vec![
report_with_r(0.5, 0.1, 0.1),
report_with_r(2.0, 0.4, 0.4),
report_with_r(1.0, 0.2, 0.2),
];
let ranked = rank_by(reports, "sqn_normalized").expect("rank sqn_normalized");
let vals: Vec<f64> = ranked
.iter()
.map(|r| r.metrics.r.as_ref().unwrap().sqn_normalized)
.collect();
assert_eq!(vals, vec![2.0, 1.0, 0.5], "sqn_normalized must rank highest-first");
}
#[test]
fn rank_by_expectancy_and_net_expectancy_r() {
let reports = vec![report_with_r(1.0, 0.1, 0.05), report_with_r(1.0, 0.3, 0.25)];
let by_exp = rank_by(reports.clone(), "expectancy_r").expect("rank expectancy_r");
assert_eq!(by_exp[0].metrics.r.as_ref().unwrap().expectancy_r, 0.3);
let by_net = rank_by(reports, "net_expectancy_r").expect("rank net_expectancy_r");
assert_eq!(by_net[0].metrics.r.as_ref().unwrap().net_expectancy_r, 0.25);
}
#[test]
fn rank_r_metric_sorts_none_member_last() {
// a pip-only member (r: None) ranks below every member with an R block.
let reports = vec![report_with(1.0, 0.5, 1), report_with_r(0.5, 0.1, 0.1)];
let ranked = rank_by(reports, "sqn").expect("rank sqn with a None member");
assert!(ranked[0].metrics.r.is_some(), "the R member ranks first");
assert!(ranked[1].metrics.r.is_none(), "the None member sorts last");
}
#[test]
fn unknown_metric_message_lists_r_metrics() {
let msg = RegistryError::UnknownMetric("nope".to_string()).to_string();
assert!(msg.contains("sqn"), "msg: {msg}");
assert!(msg.contains("sqn_normalized"), "msg: {msg}");
assert!(msg.contains("expectancy_r"), "msg: {msg}");
assert!(msg.contains("net_expectancy_r"), "msg: {msg}");
}
fn temp_path(name: &str) -> PathBuf {
// unique per test + per process; no external tempfile dependency
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
}
#[test]
fn append_then_load_round_trips_in_order() {
let path = temp_path("roundtrip");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
let a = report_with(1.0, 0.5, 1);
let b = report_with(2.0, 0.3, 2);
reg.append(&a).expect("append a");
reg.append(&b).expect("append b");
assert_eq!(reg.load().expect("load"), vec![a, b]);
let _ = fs::remove_file(&path);
}
#[test]
fn load_missing_file_is_empty() {
let path = temp_path("missing");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
assert_eq!(reg.load().expect("load missing"), Vec::<RunReport>::new());
}
#[test]
fn corrupt_line_is_a_parse_error_with_line_number() {
let path = temp_path("corrupt");
let _ = fs::remove_file(&path);
let reg = Registry::open(&path);
reg.append(&report_with(1.0, 0.5, 1)).expect("append");
let mut f = fs::OpenOptions::new().append(true).open(&path).expect("open");
writeln!(f, "not json").expect("write corrupt line");
drop(f);
match reg.load() {
Err(RegistryError::Parse { line, .. }) => assert_eq!(line, 2),
other => panic!("expected Parse at line 2, got {other:?}"),
}
let _ = fs::remove_file(&path);
}
/// A `runs.jsonl` line written before the cycle-0047 typed-`Scalar` params
/// migration carries each param value as a **bare JSON number** (`2.0`),
/// where the current format tags it (`{"F64":2.0}`). `load()` is the durable
/// C18 read-path and must stay back-compatible across that wire-shape change:
/// a legacy bare-float line must still load, the bare float read back as the
/// documented `f64` coercion — not erroring at the first param. (The current
/// typed shape is already covered by the round-trip test.)
#[test]
fn load_reads_a_legacy_bare_float_params_line() {
let path = temp_path("legacy_bare_float");
let _ = fs::remove_file(&path);
// An autonomous, minimal legacy line: a single bare-float param is the
// whole trigger (the rest of the RunReport is in the current shape). This
// mirrors the real pre-0047 store, where the first param `2.0` is where
// the typed-Scalar deserializer first chokes.
let legacy = r#"{"manifest":{"commit":"c","params":[["sma_cross.fast",2.0]],"window":[1,2],"seed":0,"broker":"b"},"metrics":{"total_pips":0.0,"max_drawdown":0.0,"exposure_sign_flips":0}}"#;
fs::write(&path, format!("{legacy}\n")).expect("write legacy line");
let reg = Registry::open(&path);
let reports = reg.load().expect("a legacy bare-float params line must still load");
assert_eq!(reports.len(), 1);
// the bare float reads back as the documented f64 coercion
assert_eq!(reports[0].manifest.params, vec![("sma_cross.fast".to_string(), Scalar::f64(2.0))]);
let _ = fs::remove_file(&path);
}
#[test]
fn rank_by_orders_best_first_per_metric() {
let reports = vec![
report_with(1.0, 0.9, 5),
report_with(3.0, 0.1, 1),
report_with(2.0, 0.5, 3),
];
let by_pips = rank_by(reports.clone(), "total_pips").expect("rank pips");
assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc
let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd");
assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc
// the legacy rank-string still resolves (the dual-accept alias).
let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips");
assert_eq!(by_flips[0].metrics.bias_sign_flips, 1); // lower-is-better -> asc
// the new rank-string ranks identically.
let by_flips_new = rank_by(reports.clone(), "bias_sign_flips").expect("rank flips (new name)");
assert_eq!(by_flips_new[0].metrics.bias_sign_flips, 1);
}
#[test]
fn rank_by_unknown_metric_is_an_error() {
let reports = vec![report_with(1.0, 0.5, 1)];
match rank_by(reports, "sharpe") {
Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "sharpe"),
other => panic!("expected UnknownMetric, got {other:?}"),
}
}
/// `optimize` is `rank_by`'s argmax: over a `SweepFamily` it returns the
/// single winning `SweepPoint` — its `params` coordinate AND its `RunReport`
/// — chosen by the metric's *fixed* sense of best (`total_pips`
/// higher-is-better), and a tie on that metric resolves to the **earliest
/// enumeration-order** point, never a later one. The point carried, not just
/// its metric, is the property: the caller needs the params that won.
#[test]
fn optimize_picks_the_max_metric_point_ties_to_earliest() {
// A hand-built family in odometer order. Two points tie at the maximal
// total_pips (3.0); the EARLIER of the two (index 1, params p=10) must
// win, not the later (index 3, params p=30) — that pins the tie rule.
let point = |p: f64, pips: f64| SweepPoint {
params: vec![Cell::from_f64(p)],
report: report_with(pips, 0.5, 0),
};
let family = SweepFamily {
space: vec![],
points: vec![
point(0.0, 1.0), // 0: also-ran
point(10.0, 3.0), // 1: tied max, earliest -> the winner
point(20.0, 2.0), // 2: also-ran
point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
],
};
let winner = super::optimize(&family, "total_pips").expect("optimize total_pips");
// the maximal-metric point wins...
assert_eq!(winner.report.metrics.total_pips, 3.0);
// ...and ties resolve to the earliest enumeration-order point: its params
// identify point 1, not point 3.
assert_eq!(winner.params, vec![Cell::from_f64(10.0)]);
// the whole winning point is returned, params AND report together.
assert_eq!(winner, family.points[1]);
}
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);
// the split (family, run) fields are stamped on each member, and the
// user-facing handle is derived from them (id is "{family}-{run}").
assert_eq!((fam.members[0].family.as_str(), fam.members[0].run), ("f", 0));
assert_eq!(fam.members[0].family_id(), "f-0");
// members re-derived as a unit, ordinal-ordered, equal to the stamped reports
let member_reports: Vec<_> = fam.members.iter().map(|m| m.report.clone()).collect();
assert_eq!(member_reports, reports);
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
}
}