89d967a940
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
550 lines
25 KiB
Rust
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
|
|
}
|
|
}
|