cb32658fd1
Add an opt-in plateau selection objective for walk-forward, recorded on the same RunManifest.selection carrier #144 introduced. Default argmax is byte-identical (C23); plateau is reached only via a new --select flag. What landed: - FamilySelection / SelectionMode reshape (report.rs): the selection RULE (mode) and its ANNOTATION are orthogonal — deflation fields become Option (present iff Argmax), plus PlateauMean/PlateauWorst variants and neighbourhood_score/n_neighbours (present iff Plateau*). Legacy lines still load: the deflation scalars deserialize from bare values via serde default (C14/C18). compat.rs embeds FamilySelection by value — reshape flows through untouched. - GridSpace::axis_lens() + SweepBinder::sweep_with_lattice (engine): the grid radixes in param_space()/odometer order. sweep() delegates to sweep_with_lattice with the lattice dropped, so every existing .sweep() caller is byte-unchanged. - optimize_plateau + PlateauMode + closed_neighbourhood (aura-registry, C9): each member scores as the mean/worst of its closed mixed-radix grid neighbourhood's metric_value; the winner is the smoothed argmax by the metric's direction (earliest-odometer tie, as optimize). Pure, no RNG (C1); in-sample only (C2). A higher_is_better helper now sources the per-metric direction once, shared by metric_cmp and optimize_plateau. - CLI --select <argmax|plateau:mean|plateau:worst> (default argmax; unknown token exits 2). walkforward_family dispatches via a select_winner helper; sweep_over/stage1_r_sweep_over carry the lattice as Option<Vec<usize>>. runs-family display gains a plateau(<mode>)=<score> over <n> cells line. RandomSpace-refuse: walkforward has no --random producer, so the plateau-without-lattice guard is structural — select_winner returns the refuse on a None lattice (the caller prints the message and exits 2), unit-tested at the helper, not via a --random E2E (decided + recorded on #145 comment 1974, with the lattice-seam fork). Tests: plateau-picks-plateau-not-spike, mean-vs-worst, lower-is-better direction-flip, closed-neighbourhood golden, single-member degeneracy, C1 determinism; CLI select-parse, select_winner refuse; E2E argmax-byte-identical (C23), plateau:mean and plateau:worst provenance. Full workspace suite green; clippy --all-targets -D warnings clean. closes #145
1099 lines
52 KiB
Rust
1099 lines
52 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::{
|
||
expected_max_of_normals, r_metrics_from_rs, resample_block, FamilySelection, MetricStats,
|
||
RunReport, SelectionMode, SplitMix64, 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,
|
||
}
|
||
|
||
/// Resolve a metric NAME to the closed `Metric` kind (the single name→kind map).
|
||
/// An unknown name is a `RegistryError::UnknownMetric`.
|
||
fn resolve_metric(name: &str) -> Result<Metric, RegistryError> {
|
||
Ok(match name {
|
||
"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 metric's scalar value for one report. R metrics live in the optional `r`
|
||
/// block; a missing block reads `NEG_INFINITY` (the worst rank for the
|
||
/// higher-is-better R keys), preserving `metric_cmp`'s documented contract.
|
||
fn metric_value(rep: &RunReport, m: Metric) -> f64 {
|
||
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)
|
||
}
|
||
match m {
|
||
Metric::TotalPips => rep.metrics.total_pips,
|
||
Metric::MaxDrawdown => rep.metrics.max_drawdown,
|
||
Metric::BiasSignFlips => rep.metrics.bias_sign_flips as f64,
|
||
Metric::Sqn => r_get(rep, |r| r.sqn),
|
||
Metric::SqnNormalized => r_get(rep, |r| r.sqn_normalized),
|
||
Metric::ExpectancyR => r_get(rep, |r| r.expectancy_r),
|
||
Metric::NetExpectancyR => r_get(rep, |r| r.net_expectancy_r),
|
||
}
|
||
}
|
||
|
||
/// 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. Every
|
||
/// metric compares via `total_cmp` (a total order over `f64`): top-level keys are
|
||
/// finite by construction, while the R keys live in an optional `metrics.r` block
|
||
/// where a member with `r: None` reads `f64::NEG_INFINITY` (the worst rank for
|
||
/// higher-is-better) — `total_cmp` orders that, and any stray `NaN`,
|
||
/// deterministically without panicking. An unknown metric name is a
|
||
/// `RegistryError::UnknownMetric`.
|
||
///
|
||
/// The single source of best-first *ordering* — both [`rank_by`] (sort by it) and
|
||
/// [`optimize`] (argmax by it) call this. The per-metric direction itself lives in
|
||
/// `higher_is_better` (which this and `optimize_plateau` both read); `metric_cmp`
|
||
/// turns that direction into a `total_cmp` over the read values. The metric name is
|
||
/// resolved once via `resolve_metric`; the returned closure carries the resolved
|
||
/// `Metric`, so per-comparison work is just reading each value (`metric_value`) and
|
||
/// the key compare.
|
||
fn metric_cmp(metric: &str) -> Result<impl Fn(&RunReport, &RunReport) -> Ordering, RegistryError> {
|
||
let m = resolve_metric(metric)?;
|
||
let hib = higher_is_better(m);
|
||
Ok(move |a: &RunReport, b: &RunReport| {
|
||
let (va, vb) = (metric_value(a, m), metric_value(b, m));
|
||
// higher-is-better ranks the larger value first; lower-is-better the smaller.
|
||
if hib { vb.total_cmp(&va) } else { va.total_cmp(&vb) }
|
||
})
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
fn is_r_metric(m: Metric) -> bool {
|
||
matches!(m, Metric::Sqn | Metric::SqnNormalized | Metric::ExpectancyR | Metric::NetExpectancyR)
|
||
}
|
||
|
||
/// The metric's optimisation direction: `true` if a larger value is better
|
||
/// (`total_pips`, the four R keys), `false` for the lower-is-better keys
|
||
/// (`max_drawdown`, `bias_sign_flips`). The single direction source — `metric_cmp`
|
||
/// (best-first ordering) and `optimize_plateau` (smoothed argmax + worst-neighbour)
|
||
/// both read it, so the per-metric sense lives in exactly one place.
|
||
fn higher_is_better(m: Metric) -> bool {
|
||
!matches!(m, Metric::MaxDrawdown | Metric::BiasSignFlips)
|
||
}
|
||
|
||
/// The closed grid neighbourhood of flat index `i` over a mixed-radix lattice
|
||
/// (`axis_lens`, last axis fastest — the `GridSpace` odometer convention): `{i}`
|
||
/// plus each in-range ±1-per-axis cell. Pure index math, deterministic. The order
|
||
/// is `i` first then per-axis neighbours; callers use the result only as a set and
|
||
/// for its length, so the order is not load-bearing.
|
||
fn closed_neighbourhood(i: usize, axis_lens: &[usize]) -> Vec<usize> {
|
||
// decompose i to per-axis coords (last axis fastest)
|
||
let mut coords = vec![0usize; axis_lens.len()];
|
||
let mut rem = i;
|
||
for k in (0..axis_lens.len()).rev() {
|
||
coords[k] = rem % axis_lens[k];
|
||
rem /= axis_lens[k];
|
||
}
|
||
// recompose a coord vector to its flat index (Horner, last axis fastest)
|
||
let recompose = |c: &[usize]| c.iter().zip(axis_lens).fold(0usize, |flat, (&ck, &len)| flat * len + ck);
|
||
let mut out = vec![i];
|
||
for k in 0..axis_lens.len() {
|
||
for delta in [-1isize, 1] {
|
||
let ck = coords[k] as isize + delta;
|
||
if ck < 0 || ck as usize >= axis_lens[k] { continue; }
|
||
let mut nb = coords.clone();
|
||
nb[k] = ck as usize;
|
||
out.push(recompose(&nb));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn member_trade_rs(rep: &RunReport) -> &[f64] {
|
||
rep.metrics.r.as_ref().map(|r| r.trade_rs.as_slice()).unwrap_or(&[])
|
||
}
|
||
|
||
/// Recompute the selected R metric from a trade-R slice (reuses the engine's
|
||
/// `r_metrics_from_rs`, the same arithmetic `summarize_r` uses).
|
||
fn member_metric_from_rs(rs: &[f64], m: Metric) -> f64 {
|
||
let rm = r_metrics_from_rs(rs);
|
||
match m {
|
||
Metric::Sqn => rm.sqn,
|
||
Metric::SqnNormalized => rm.sqn_normalized,
|
||
Metric::ExpectancyR => rm.expectancy_r,
|
||
Metric::NetExpectancyR => rm.net_expectancy_r,
|
||
_ => unreachable!("member_metric_from_rs is R-only"),
|
||
}
|
||
}
|
||
|
||
/// The centred best-of-K null-max distribution: each member's `trade_rs` is
|
||
/// mean-subtracted (the no-edge null), then for each resample every member is
|
||
/// moving-block-resampled (in odometer order, from a per-iteration seed) and the
|
||
/// max recomputed metric across members is taken. Deterministic given `seed` (C1).
|
||
fn null_best_of_k(family: &SweepFamily, m: Metric, n_resamples: usize, block_len: usize, seed: u64) -> Vec<f64> {
|
||
let centred: Vec<Vec<f64>> = family.points.iter().map(|p| {
|
||
let rs = member_trade_rs(&p.report);
|
||
if rs.is_empty() { return Vec::new(); }
|
||
let mean = rs.iter().sum::<f64>() / rs.len() as f64;
|
||
rs.iter().map(|x| x - mean).collect()
|
||
}).collect();
|
||
(0..n_resamples).map(|i| {
|
||
let mut rng = SplitMix64::new(seed ^ i as u64);
|
||
centred.iter().fold(f64::NEG_INFINITY, |best, rs| {
|
||
if rs.is_empty() { return best; }
|
||
let bl = block_len.clamp(1, rs.len());
|
||
let v = member_metric_from_rs(&resample_block(rs, bl, &mut rng), m);
|
||
best.max(v)
|
||
})
|
||
}).collect()
|
||
}
|
||
|
||
/// Sample standard deviation of the K members' metric values (the `total_pips`
|
||
/// dispersion-floor arm). `< 2` members -> `0.0`.
|
||
fn member_sd(family: &SweepFamily, m: Metric) -> f64 {
|
||
let vals: Vec<f64> = family.points.iter().map(|p| metric_value(&p.report, m)).collect();
|
||
let n = vals.len();
|
||
if n < 2 { return 0.0; }
|
||
let mean = vals.iter().sum::<f64>() / n as f64;
|
||
(vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64).sqrt()
|
||
}
|
||
|
||
/// Plateau-selection aggregate: how a member's grid-neighbourhood metric is
|
||
/// reduced to one smoothed score before the argmax. `Mean` averages the closed
|
||
/// neighbourhood; `Worst` takes the most-pessimistic neighbour by the metric's
|
||
/// direction (biasing toward interior cells, which keep more neighbours).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub enum PlateauMode {
|
||
Mean,
|
||
Worst,
|
||
}
|
||
|
||
/// `optimize`'s argmax over the NEIGHBOURHOOD-SMOOTHED surface, plus its plateau
|
||
/// provenance. Each member scores as the mean (or worst-case) of its closed grid
|
||
/// neighbourhood's `metric_value`; the winner is the best smoothed score by the
|
||
/// metric's own direction (earliest-odometer tie, as `optimize`). `axis_lens` are
|
||
/// the grid radixes (`param_space()` order, last axis fastest). Pure,
|
||
/// deterministic (C1). The returned `SweepPoint` is the winning grid CELL;
|
||
/// `raw_winner_metric` is that cell's own metric, `neighbourhood_score` the
|
||
/// smoothed value the argmax maximised.
|
||
pub fn optimize_plateau(
|
||
family: &SweepFamily, axis_lens: &[usize], metric: &str, mode: PlateauMode,
|
||
) -> Result<(SweepPoint, FamilySelection), RegistryError> {
|
||
let m = resolve_metric(metric)?;
|
||
let n = family.points.len();
|
||
debug_assert_eq!(
|
||
axis_lens.iter().product::<usize>(), n,
|
||
"axis_lens product must equal the family size (a valid grid lattice)",
|
||
);
|
||
let hib = higher_is_better(m);
|
||
// smoothed score + closed-neighbourhood size per member, in odometer order
|
||
let scored: Vec<(usize, f64, usize)> = (0..n)
|
||
.map(|i| {
|
||
let nbrs = closed_neighbourhood(i, axis_lens);
|
||
let vals: Vec<f64> = nbrs.iter().map(|&j| metric_value(&family.points[j].report, m)).collect();
|
||
let score = match mode {
|
||
PlateauMode::Mean => vals.iter().sum::<f64>() / vals.len() as f64,
|
||
PlateauMode::Worst => {
|
||
if hib {
|
||
vals.iter().copied().fold(f64::INFINITY, f64::min)
|
||
} else {
|
||
vals.iter().copied().fold(f64::NEG_INFINITY, f64::max)
|
||
}
|
||
}
|
||
};
|
||
(i, score, nbrs.len())
|
||
})
|
||
.collect();
|
||
// argmax the smoothed surface by direction; earliest-odometer tie (only a
|
||
// strictly-better later cell displaces the incumbent, as `optimize`).
|
||
let &(wi, wscore, wn) = scored
|
||
.iter()
|
||
.reduce(|best, cur| {
|
||
let better = if hib { cur.1 > best.1 } else { cur.1 < best.1 };
|
||
if better { cur } else { best }
|
||
})
|
||
.expect("a SweepFamily is non-empty by construction (EmptyAxis is rejected upstream)");
|
||
let winner = family.points[wi].clone();
|
||
let raw = metric_value(&winner.report, m);
|
||
Ok((
|
||
winner,
|
||
FamilySelection {
|
||
selection_metric: metric.to_string(),
|
||
n_trials: n,
|
||
raw_winner_metric: raw,
|
||
mode: match mode {
|
||
PlateauMode::Mean => SelectionMode::PlateauMean,
|
||
PlateauMode::Worst => SelectionMode::PlateauWorst,
|
||
},
|
||
deflated_score: None,
|
||
overfit_probability: None,
|
||
n_resamples: None,
|
||
block_len: None,
|
||
seed: None,
|
||
neighbourhood_score: Some(wscore),
|
||
n_neighbours: Some(wn),
|
||
},
|
||
))
|
||
}
|
||
|
||
/// `optimize`'s argmax winner PLUS its trials-deflation provenance. The returned
|
||
/// `SweepPoint` is byte-identical to `optimize(family, metric)` (additive, C23).
|
||
/// R arm: a centred moving-block reality-check (`overfit_probability` =
|
||
/// `(count(null ≥ raw) + 1)/(n + 1)`, `deflated_score` = `raw − p95(null)`).
|
||
/// `total_pips` arm: a closed-form expected-max-of-K dispersion floor, no
|
||
/// probability. Deterministic given `seed` (C1).
|
||
pub fn optimize_deflated(
|
||
family: &SweepFamily, metric: &str, n_resamples: usize, block_len: usize, seed: u64,
|
||
) -> Result<(SweepPoint, FamilySelection), RegistryError> {
|
||
let winner = optimize(family, metric)?;
|
||
let m = resolve_metric(metric)?;
|
||
let k = family.points.len();
|
||
let raw = metric_value(&winner.report, m);
|
||
|
||
let (deflated_score, overfit_probability) = if is_r_metric(m) {
|
||
let null_max = null_best_of_k(family, m, n_resamples, block_len, seed);
|
||
// Keep only the finite best-of-K draws. The null is *not computable* in two
|
||
// degenerate cases: zero resamples (`null_max` empty), or no member carries
|
||
// `trade_rs` (every member's centred series is empty, so each iteration's
|
||
// best-of-K folds to `NEG_INFINITY`). The latter is reachable — `trade_rs`
|
||
// is `#[serde(skip)]`, so a family loaded back from the registry has empty
|
||
// conduits even when its `r` block (hence `raw`) is finite. Filtering to
|
||
// finite values collapses both into one "no usable null" branch, instead of
|
||
// letting `p95 = NEG_INFINITY` turn `deflated_score` into `+inf`.
|
||
let usable: Vec<f64> = null_max.into_iter().filter(|x| x.is_finite()).collect();
|
||
if usable.is_empty() {
|
||
// No reality-check is computable: floor to a degenerate-but-defined
|
||
// result — no deflation credit (`deflated_score == raw`) and a
|
||
// maximally-uncertain overfit probability (1.0), never a nonsensical inf.
|
||
(raw, Some(1.0))
|
||
} else {
|
||
let p95 = MetricStats::from_values(&usable).p95;
|
||
let over = (usable.iter().filter(|&&x| x >= raw).count() + 1) as f64
|
||
/ (usable.len() + 1) as f64;
|
||
(raw - p95, Some(over))
|
||
}
|
||
} else {
|
||
// The dispersion floor SUBTRACTS the expected-max inflation, so it is valid
|
||
// only for a higher-is-better metric (a larger value is the better one the
|
||
// search inflated). A lower-is-better metric would deflate wrong-signed; the
|
||
// two shipped call sites only pass `total_pips`, so this is unreached, but
|
||
// `optimize_deflated` is public — guard the assumption rather than leave it
|
||
// implicit.
|
||
debug_assert!(
|
||
!matches!(m, Metric::MaxDrawdown | Metric::BiasSignFlips),
|
||
"dispersion-floor arm is for higher-is-better metrics only",
|
||
);
|
||
(raw - member_sd(family, m) * expected_max_of_normals(k), None)
|
||
};
|
||
|
||
Ok((winner.clone(), FamilySelection {
|
||
selection_metric: metric.to_string(), n_trials: k, raw_winner_metric: raw,
|
||
mode: SelectionMode::Argmax,
|
||
deflated_score: Some(deflated_score), overfit_probability,
|
||
n_resamples: Some(n_resamples), block_len: Some(block_len), seed: Some(seed),
|
||
neighbourhood_score: None, n_neighbours: None,
|
||
}))
|
||
}
|
||
|
||
/// 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(),
|
||
selection: None,
|
||
},
|
||
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],
|
||
trade_rs: Vec::new(),
|
||
});
|
||
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);
|
||
}
|
||
|
||
/// Property: the compat read-path carries a manifest's `selection` block
|
||
/// through to the canonical `RunReport` — a line written *with* a
|
||
/// FamilySelection lifts it intact (not dropped) through `RunReportRead`'s
|
||
/// structural mirror and its `From` conversion.
|
||
#[test]
|
||
fn load_lifts_a_selection_block_through_the_compat_mirror() {
|
||
let line = r#"{"manifest":{"commit":"c","params":[],"window":[0,0],"seed":0,"broker":"b","selection":{"selection_metric":"sqn_normalized","n_trials":4,"raw_winner_metric":1.8,"deflated_score":0.2,"overfit_probability":0.06,"mode":"Argmax","n_resamples":1000,"block_len":5,"seed":42}},"metrics":{"total_pips":0.0,"max_drawdown":0.0,"bias_sign_flips":0}}"#;
|
||
let rep: RunReport = serde_json::from_str::<crate::compat::RunReportRead>(line).unwrap().into();
|
||
let sel = rep.manifest.selection.expect("selection lifted");
|
||
assert_eq!(sel.n_trials, 4);
|
||
assert_eq!(sel.mode, aura_engine::SelectionMode::Argmax);
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
fn member(total_pips: f64, trade_rs: Vec<f64>) -> SweepPoint {
|
||
// Every RMetrics field is derived from the R slice by `r_metrics_from_rs`
|
||
// (the same arithmetic production members carry), then the per-trade
|
||
// `trade_rs` conduit is restored: `r_metrics_from_rs` empties it
|
||
// (`trade_rs: Vec::new()`), whereas a production member is built by
|
||
// `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm
|
||
// resamples exactly that conduit, so a fixture without it cannot reach
|
||
// the R-arm path under test.
|
||
let mut r = aura_engine::r_metrics_from_rs(&trade_rs);
|
||
r.trade_rs = trade_rs;
|
||
SweepPoint {
|
||
params: vec![],
|
||
report: RunReport {
|
||
manifest: RunManifest {
|
||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||
seed: 0, broker: "b".into(), selection: None,
|
||
},
|
||
metrics: RunMetrics {
|
||
total_pips, max_drawdown: 0.0, bias_sign_flips: 0,
|
||
r: Some(r),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
fn fixture_family_with_r() -> SweepFamily {
|
||
SweepFamily { space: vec![], points: vec![
|
||
member(10.0, vec![1.0, -0.5, 0.8, -0.2, 0.6]),
|
||
member(30.0, vec![2.0, 1.5, -0.3, 1.0, 0.9]), // best by every metric here
|
||
member(5.0, vec![-0.5, 0.2, -0.8, 0.1, -0.3]),
|
||
]}
|
||
}
|
||
|
||
fn fixture_family_one_strong_edge() -> SweepFamily {
|
||
SweepFamily { space: vec![], points: vec![
|
||
member(1.0, vec![2.0, 2.5, 1.8, 2.2, 2.1, 1.9]), // strong, consistent +R edge
|
||
member(1.0, vec![0.05, -0.1, 0.0, 0.1, -0.05, 0.02]), // ~zero-edge noise
|
||
member(1.0, vec![-0.1, 0.1, 0.0, -0.05, 0.05, 0.0]),
|
||
]}
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_winner_is_byte_identical_to_optimize() {
|
||
let fam = fixture_family_with_r(); // helper: ≥3 members, varied sqn_normalized + trade_rs
|
||
for metric in ["total_pips", "sqn_normalized", "expectancy_r"] {
|
||
let plain = optimize(&fam, metric).unwrap();
|
||
let (defl, _) = optimize_deflated(&fam, metric, 200, 3, 7).unwrap();
|
||
assert_eq!(defl, plain, "deflation must not change the winner ({metric})");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_is_reproducible_from_its_seed() {
|
||
let fam = fixture_family_with_r();
|
||
let a = optimize_deflated(&fam, "sqn_normalized", 500, 4, 42).unwrap().1;
|
||
let b = optimize_deflated(&fam, "sqn_normalized", 500, 4, 42).unwrap().1;
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_total_pips_arm_floors_without_a_probability() {
|
||
let fam = fixture_family_with_r();
|
||
let sel = optimize_deflated(&fam, "total_pips", 100, 3, 1).unwrap().1;
|
||
assert!(sel.overfit_probability.is_none());
|
||
assert_eq!(sel.selection_metric, "total_pips");
|
||
assert!(sel.deflated_score.unwrap() <= sel.raw_winner_metric); // dispersion floor ≤ raw
|
||
}
|
||
|
||
/// Sibling-contract parity with `r_bootstrap`, which defines `n_resamples ==
|
||
/// 0` as a valid input that floors to an all-zero result. `optimize_deflated`'s
|
||
/// R arm must not panic on an empty null distribution: with zero resamples it
|
||
/// returns a degenerate-but-defined provenance — `deflated_score == raw` (the
|
||
/// p95 of an empty null is 0), `overfit_probability == 1` (the `(0 + 1)/(0 +
|
||
/// 1)` Laplace floor) — never indexing the empty `MetricStats::from_values`.
|
||
#[test]
|
||
fn optimize_deflated_zero_resamples_floors_like_r_bootstrap() {
|
||
let fam = fixture_family_with_r();
|
||
let sel = optimize_deflated(&fam, "expectancy_r", 0, 3, 1).unwrap().1;
|
||
assert_eq!(sel.n_resamples, Some(0));
|
||
assert_eq!(sel.deflated_score.unwrap(), sel.raw_winner_metric, "p95 of an empty null is 0");
|
||
assert_eq!(sel.overfit_probability, Some(1.0), "(0+1)/(0+1) Laplace floor");
|
||
}
|
||
|
||
/// Regression: a family whose members carry an `r` block but NO `trade_rs` —
|
||
/// the serde-skipped state of a family `load`ed back from the registry — must
|
||
/// NOT yield a `+inf` deflated score on the R arm with positive resamples. With
|
||
/// no member contributing a centred series, every best-of-K draw is
|
||
/// `NEG_INFINITY`; the finite-filter collapses this to the same degenerate floor
|
||
/// as zero-resamples (`deflated_score == raw`, `overfit_probability == 1.0`).
|
||
#[test]
|
||
fn optimize_deflated_no_trade_rs_floors_instead_of_infinity() {
|
||
let mut fam = fixture_family_with_r();
|
||
for p in &mut fam.points {
|
||
if let Some(r) = p.report.metrics.r.as_mut() {
|
||
r.trade_rs.clear(); // mimic a serde-loaded member (trade_rs is #[serde(skip)])
|
||
}
|
||
}
|
||
let sel = optimize_deflated(&fam, "expectancy_r", 500, 3, 1).unwrap().1;
|
||
assert!(sel.deflated_score.unwrap().is_finite(), "deflated_score must be finite, got {:?}", sel.deflated_score);
|
||
assert_eq!(sel.deflated_score.unwrap(), sel.raw_winner_metric);
|
||
assert_eq!(sel.overfit_probability, Some(1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_overfit_probability_is_low_for_a_clear_edge() {
|
||
// A family whose winner has a strong real R-edge over near-zero-edge peers:
|
||
// the centred best-of-K null rarely reaches the winner's raw metric.
|
||
let fam = fixture_family_one_strong_edge();
|
||
let sel = optimize_deflated(&fam, "expectancy_r", 1000, 1, 9).unwrap().1;
|
||
assert!(sel.overfit_probability.unwrap() < 0.2, "p={:?}", sel.overfit_probability);
|
||
assert!(sel.deflated_score.unwrap() > 0.0);
|
||
}
|
||
|
||
/// Every member shares the SAME strong +R edge. An *uncentred* null (each
|
||
/// member's own edged trades resampled) would put the best-of-K ≈ the winner ⇒
|
||
/// `overfit_probability ≈ 0.5`. The centred null removes each member's mean, so
|
||
/// the winner's real edge stands clear of a zero-edge null ⇒ a low probability.
|
||
/// This is the discriminator that the mean-subtraction (the statistic's whole
|
||
/// point) is load-bearing — removing the centring would flip this assertion.
|
||
fn fixture_family_uniform_edge() -> SweepFamily {
|
||
SweepFamily { space: vec![], points: vec![
|
||
member(1.0, vec![1.8, 2.2, 1.9, 2.1, 2.0, 1.7]),
|
||
member(1.0, vec![2.1, 1.9, 2.0, 2.2, 1.8, 2.0]),
|
||
member(1.0, vec![1.9, 2.0, 2.1, 1.8, 2.2, 1.9]),
|
||
]}
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_centres_the_null_so_a_uniform_edge_is_not_called_overfit() {
|
||
let fam = fixture_family_uniform_edge();
|
||
let sel = optimize_deflated(&fam, "expectancy_r", 1000, 1, 5).unwrap().1;
|
||
assert!(
|
||
sel.overfit_probability.unwrap() < 0.2,
|
||
"uniform real edge must read as NOT overfit under the centred null; an \
|
||
uncentred null would give ≈0.5. p={:?}",
|
||
sel.overfit_probability
|
||
);
|
||
}
|
||
|
||
/// No member has a real edge — all are small zero-mean noise. The winner is the
|
||
/// best-of-K by luck, and the centred best-of-K null reaches its tiny raw edge
|
||
/// routinely ⇒ a HIGH overfit probability (the companion to the clear-edge
|
||
/// low-p case: the statistic must distinguish luck from edge).
|
||
fn fixture_family_all_noise() -> SweepFamily {
|
||
SweepFamily { space: vec![], points: vec![
|
||
member(1.0, vec![0.3, -0.4, 0.2, -0.1, 0.1, -0.2, 0.15, -0.05]),
|
||
member(1.0, vec![-0.2, 0.3, -0.3, 0.1, 0.0, 0.2, -0.1, 0.05]),
|
||
member(1.0, vec![0.1, -0.1, 0.25, -0.2, -0.05, 0.15, 0.0, -0.1]),
|
||
]}
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_deflated_all_noise_family_has_high_overfit_probability() {
|
||
let fam = fixture_family_all_noise();
|
||
let sel = optimize_deflated(&fam, "expectancy_r", 1000, 1, 5).unwrap().1;
|
||
assert!(
|
||
sel.overfit_probability.unwrap() > 0.3,
|
||
"an all-noise family's lucky winner must read as overfit-prone, p={:?}",
|
||
sel.overfit_probability
|
||
);
|
||
}
|
||
|
||
/// C2 at the registry boundary: `optimize_deflated` has no access to any
|
||
/// out-of-sample report — its provenance is a pure function of the in-sample
|
||
/// family the caller passes. `n_trials` is exactly that family's size and
|
||
/// `raw_winner_metric` is that family's own argmax value, so the record cannot
|
||
/// encode out-of-sample information.
|
||
#[test]
|
||
fn optimize_deflated_provenance_reflects_only_the_passed_family() {
|
||
let fam = fixture_family_with_r();
|
||
let (winner, sel) = optimize_deflated(&fam, "sqn_normalized", 200, 3, 1).unwrap();
|
||
assert_eq!(sel.n_trials, fam.points.len());
|
||
let m = resolve_metric("sqn_normalized").unwrap();
|
||
assert_eq!(sel.raw_winner_metric, metric_value(&winner.report, m));
|
||
}
|
||
|
||
/// A 1-D grid (7 cells, odometer order) with two end spikes and a broad middle
|
||
/// plateau in `total_pips`. Bare argmax takes an end spike; the plateau-mean
|
||
/// argmax takes the interior plateau centre.
|
||
fn fixture_grid_spike_vs_plateau() -> SweepFamily {
|
||
let pips = [80.0, 5.0, 48.0, 50.0, 49.0, 5.0, 80.0];
|
||
SweepFamily { space: vec![], points: pips.iter().map(|&p| member(p, vec![1.0])).collect() }
|
||
}
|
||
|
||
#[test]
|
||
fn closed_neighbourhood_2x3_golden() {
|
||
// 2x3 lattice, odometer (last axis fastest):
|
||
// (0,0)=0 (0,1)=1 (0,2)=2
|
||
// (1,0)=3 (1,1)=4 (1,2)=5
|
||
let sorted = |i: usize| { let mut v = closed_neighbourhood(i, &[2, 3]); v.sort_unstable(); v };
|
||
assert_eq!(sorted(0), vec![0, 1, 3]); // corner (0,0): self + 2
|
||
assert_eq!(sorted(4), vec![1, 3, 4, 5]); // interior (1,1): self + 3
|
||
assert_eq!(sorted(2), vec![1, 2, 5]); // corner (0,2): self + 2
|
||
}
|
||
|
||
#[test]
|
||
fn plateau_picks_the_plateau_not_the_spike() {
|
||
let fam = fixture_grid_spike_vs_plateau();
|
||
let spike = optimize(&fam, "total_pips").unwrap();
|
||
let (centre, sel) = optimize_plateau(&fam, &[7], "total_pips", PlateauMode::Mean).unwrap();
|
||
assert_ne!(centre.report.metrics.total_pips, spike.report.metrics.total_pips);
|
||
assert_eq!(centre.report.metrics.total_pips, 50.0, "plateau centre is the middle cell");
|
||
assert_eq!(sel.mode, SelectionMode::PlateauMean);
|
||
assert_eq!(sel.raw_winner_metric, 50.0);
|
||
assert!(sel.neighbourhood_score.unwrap() < spike.report.metrics.total_pips,
|
||
"smoothed score is below the spike's raw peak");
|
||
assert_eq!(sel.n_neighbours, Some(3), "interior cell has self + 2 neighbours");
|
||
assert!(sel.deflated_score.is_none() && sel.n_resamples.is_none(),
|
||
"deflation fields omitted under plateau");
|
||
}
|
||
|
||
#[test]
|
||
fn plateau_worst_is_more_conservative_than_mean() {
|
||
let fam = fixture_grid_spike_vs_plateau();
|
||
let (_, mean_sel) = optimize_plateau(&fam, &[7], "total_pips", PlateauMode::Mean).unwrap();
|
||
let (_, worst_sel) = optimize_plateau(&fam, &[7], "total_pips", PlateauMode::Worst).unwrap();
|
||
// Worst scores each cell by its weakest neighbour, never above the mean.
|
||
assert!(worst_sel.neighbourhood_score.unwrap() <= mean_sel.neighbourhood_score.unwrap());
|
||
assert_eq!(worst_sel.mode, SelectionMode::PlateauWorst);
|
||
assert!(worst_sel.n_neighbours.unwrap() >= 2, "winner is an interior cell");
|
||
}
|
||
|
||
#[test]
|
||
fn plateau_single_member_equals_optimize() {
|
||
let fam = SweepFamily { space: vec![], points: vec![member(42.0, vec![1.0])] };
|
||
let plain = optimize(&fam, "total_pips").unwrap();
|
||
let (winner, sel) = optimize_plateau(&fam, &[1], "total_pips", PlateauMode::Mean).unwrap();
|
||
assert_eq!(winner.report.metrics.total_pips, plain.report.metrics.total_pips);
|
||
assert_eq!(sel.n_neighbours, Some(1), "a 1-cell grid's closed neighbourhood is itself");
|
||
assert_eq!(sel.neighbourhood_score, Some(42.0));
|
||
assert_eq!(sel.raw_winner_metric, 42.0);
|
||
}
|
||
|
||
#[test]
|
||
fn optimize_plateau_is_deterministic() {
|
||
let fam = fixture_grid_spike_vs_plateau();
|
||
let a = optimize_plateau(&fam, &[7], "total_pips", PlateauMode::Worst).unwrap().1;
|
||
let b = optimize_plateau(&fam, &[7], "total_pips", PlateauMode::Worst).unwrap().1;
|
||
assert_eq!(a, b);
|
||
}
|
||
|
||
/// A 1-D grid (7 cells, odometer order) in `max_drawdown` — the canonical
|
||
/// lower-is-better key. Two end *dips* (drawdown 1.0, the lure for a bare
|
||
/// lower-is-better argmax) flank a broad middle plateau of moderately-low
|
||
/// drawdown (≈30), with worse shoulders. A member carries only `max_drawdown`;
|
||
/// `total_pips`/`r` are inert here.
|
||
fn fixture_grid_dip_vs_plateau_lower_is_better() -> SweepFamily {
|
||
let dds = [1.0, 60.0, 30.0, 30.0, 31.0, 60.0, 1.0];
|
||
let point = |dd: f64| SweepPoint {
|
||
params: vec![],
|
||
report: report_with(0.0, dd, 0),
|
||
};
|
||
SweepFamily { space: vec![], points: dds.iter().map(|&dd| point(dd)).collect() }
|
||
}
|
||
|
||
/// Direction-flip coverage: on a lower-is-better metric (`max_drawdown`),
|
||
/// plateau selection exercises the `else` (smaller-is-better) argmax branch AND
|
||
/// the `Worst` arm's `NEG_INFINITY`/`max` fold. Bare argmax takes an end dip
|
||
/// (the global minimum drawdown); the plateau-mean argmax takes the interior
|
||
/// plateau centre, whose neighbourhood mean is the lowest. This is the
|
||
/// lower-is-better companion the higher-is-better plateau tests do not reach.
|
||
#[test]
|
||
fn plateau_lower_is_better_picks_the_plateau_not_the_dip() {
|
||
let fam = fixture_grid_dip_vs_plateau_lower_is_better();
|
||
let dip = optimize(&fam, "max_drawdown").unwrap();
|
||
assert_eq!(dip.report.metrics.max_drawdown, 1.0, "bare argmax takes the end dip");
|
||
|
||
let (centre, mean_sel) =
|
||
optimize_plateau(&fam, &[7], "max_drawdown", PlateauMode::Mean).unwrap();
|
||
// the plateau centre is an interior cell, not the end dip
|
||
assert_ne!(centre.report.metrics.max_drawdown, 1.0);
|
||
assert_eq!(mean_sel.mode, SelectionMode::PlateauMean);
|
||
assert_eq!(mean_sel.n_neighbours, Some(3), "interior cell has self + 2 neighbours");
|
||
// the winning cell's neighbourhood mean beats the end dip's (whose 60.0
|
||
// shoulder drags its mean up), so the smaller-is-better argmax did not pick
|
||
// the dip.
|
||
assert!(
|
||
mean_sel.neighbourhood_score.unwrap() < (1.0 + 60.0) / 2.0,
|
||
"plateau mean is below the end dip's neighbourhood mean; score={:?}",
|
||
mean_sel.neighbourhood_score,
|
||
);
|
||
|
||
// Worst arm: each cell scores by its highest (worst) drawdown neighbour
|
||
// (the NEG_INFINITY/max fold). It is never below the mean for a
|
||
// lower-is-better metric, and still avoids the dip whose worst neighbour is
|
||
// 60.0.
|
||
let (_, worst_sel) =
|
||
optimize_plateau(&fam, &[7], "max_drawdown", PlateauMode::Worst).unwrap();
|
||
assert_eq!(worst_sel.mode, SelectionMode::PlateauWorst);
|
||
assert!(
|
||
worst_sel.neighbourhood_score.unwrap() >= mean_sel.neighbourhood_score.unwrap(),
|
||
"worst (max drawdown) ≥ mean for a lower-is-better metric",
|
||
);
|
||
assert!(
|
||
worst_sel.neighbourhood_score.unwrap() < 60.0,
|
||
"the plateau interior's worst neighbour is below the dip's 60.0 shoulder",
|
||
);
|
||
}
|
||
}
|