diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index f067806..bbae347 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -5,12 +5,13 @@ //! serde_json; display is the caller's concern (the CLI prints via //! `RunReport::to_json`). +use std::cmp::Ordering; use std::fmt; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use aura_engine::RunReport; +use aura_engine::{RunReport, SweepFamily, SweepPoint}; /// An append-only run registry over a JSONL file: one serde_json line per /// `RunReport`. @@ -59,28 +60,75 @@ impl Registry { } } +/// 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, + ExposureSignFlips, +} + +/// 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` +/// higher-is-better; `max_drawdown` and `exposure_sign_flips` lower-is-better. +/// `f64` keys use `partial_cmp` (total, since metrics are finite by +/// construction). 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 Ordering, RegistryError> { + let metric = match metric { + "total_pips" => Metric::TotalPips, + "max_drawdown" => Metric::MaxDrawdown, + "exposure_sign_flips" => Metric::ExposureSignFlips, + other => return Err(RegistryError::UnknownMetric(other.to_string())), + }; + 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::ExposureSignFlips => { + a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips) + } + }) +} + /// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed -/// by each metric's meaning: `total_pips` higher-is-better (descending); -/// `max_drawdown` and `exposure_sign_flips` lower-is-better (ascending). A stable -/// sort keeps file (insertion) order among ties; `f64` keys use `partial_cmp` -/// (total, since metrics are finite by construction). An unknown metric name is a +/// 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, metric: &str) -> Result, RegistryError> { - match metric { - "total_pips" => { - reports.sort_by(|a, b| b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap()) - } - "max_drawdown" => { - reports.sort_by(|a, b| a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap()) - } - "exposure_sign_flips" => { - reports.sort_by(|a, b| a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips)) - } - other => return Err(RegistryError::UnknownMetric(other.to_string())), - } + 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 { + 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 {