From fe11cfea8a4542869e17b043527a5d3bc110556c Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 10 Jun 2026 20:28:31 +0200 Subject: [PATCH] feat(engine,registry): SweepPoint carries RunReport; add aura-registry (cycle D / iter 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iteration 2 of the run-registry cycle (#33): make the sweep family self-describing and add the registry store. Engine. SweepPoint.metrics (RunMetrics) becomes SweepPoint.report (RunReport), and the sweep closure bound is now Fn(&[Scalar]) -> RunReport. This closes the manifest-per-point gap cycle C explicitly deferred to #33 — each swept point now carries its full (manifest, metrics), the unit the registry indexes (C18). All engine-internal callers, the engine test fixture run_point, and the three sweep tests were rethreaded in the same iteration so the crate stays green; the CLI caller followed in the same change so `cargo test --workspace` is green again. CLI. The sweep_report closure builds a per-point RunReport (manifest params = param_space names zipped onto the point via scalar_as_param_f64; commit/window/ seed/broker as run_sample builds them) and prints via RunReport::to_json. The hand-rolled sweep_point_to_json/json_string/scalar_token are retired (the report renders itself), and the orphaned ParamSpec/SweepPoint imports dropped. Net: aura run, aura sweep, and (next iteration) aura runs all print the same RunReport shape. The two aura sweep goldens now pin the per-point manifest params, NOT the commit (it is the real git HEAD, volatile). Registry. New crate aura-registry: Registry::{open, append, load} over an append-only runs/runs.jsonl (one serde_json line per RunReport), free rank_by (best-first per metric — total_pips desc, max_drawdown/exposure_sign_flips asc), and RegistryError (Io / Parse{line} / UnknownMetric). Five unit tests cover append->load round-trip, missing-file = empty, corrupt-line = Parse{line}, per-metric ranking, and unknown-metric error. Built and unit-tested; NOT yet CLI-wired (aura sweep persistence + aura runs list/rank are iteration 3). Verified: cargo test --workspace green (aura-registry 5, aura-engine 93, aura-cli 7+8, others unchanged); clippy --workspace --all-targets -D warnings clean; aura run stdout shape unchanged; aura sweep now emits full RunReports. refs #33 --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/aura-cli/src/main.rs | 132 +++++++------------- crates/aura-cli/tests/cli_run.rs | 9 +- crates/aura-engine/src/sweep.rs | 53 +++++--- crates/aura-registry/Cargo.toml | 17 +++ crates/aura-registry/src/lib.rs | 202 +++++++++++++++++++++++++++++++ 7 files changed, 308 insertions(+), 115 deletions(-) create mode 100644 crates/aura-registry/Cargo.toml create mode 100644 crates/aura-registry/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 02f901f..9b57048 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -83,6 +83,15 @@ dependencies = [ "data-server", ] +[[package]] +name = "aura-registry" +version = "0.1.0" +dependencies = [ + "aura-core", + "aura-engine", + "serde_json", +] + [[package]] name = "aura-std" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8d6934e..3d0db60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/aura-engine", "crates/aura-cli", "crates/aura-ingest", + "crates/aura-registry", ] [workspace.package] diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index be6ebb4..fc1f79f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -8,10 +8,10 @@ mod render; -use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness, - OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target, + OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target, }; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -206,57 +206,13 @@ fn sample_blueprint() -> Composite { build_sample() } -/// Render one swept point as one canonical JSON object (C14, hand-rolled — the -/// engine's `json_str` is private, so the rules of `RunReport::to_json` are -/// mirrored here, not called): `{"params":{name:value,…},"metrics":{…}}`. Param -/// names come from `param_space()` zipped onto the point vector; `f64` fields use -/// the round-trippable shortest form, so a whole-valued float renders without a -/// fractional part (`12.0` -> `12`). -fn sweep_point_to_json(pt: &SweepPoint, space: &[ParamSpec]) -> String { - let mut params = String::from("{"); - for (i, (ps, v)) in space.iter().zip(&pt.params).enumerate() { - if i > 0 { - params.push(','); - } - params.push_str(&json_string(&ps.name)); - params.push(':'); - params.push_str(&scalar_token(*v)); - } - params.push('}'); - format!( - "{{\"params\":{params},\"metrics\":{{\"total_pips\":{tp},\"max_drawdown\":{dd},\"exposure_sign_flips\":{fl}}}}}", - tp = pt.metrics.total_pips, - dd = pt.metrics.max_drawdown, - fl = pt.metrics.exposure_sign_flips, - ) -} - -/// Minimal JSON string rendering: wrap in quotes, escape `"` and `\` (mirrors the -/// engine's private `json_str`; our param names contain neither, but the escape -/// keeps the helper honest). -fn json_string(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -/// One scalar as its JSON value token: an integer for `I64`, the shortest -/// round-trippable form for `F64`, `true`/`false` for `Bool`, the epoch-ns -/// integer for a `Timestamp`. -fn scalar_token(v: Scalar) -> String { - match v { - Scalar::I64(n) => n.to_string(), - Scalar::F64(f) => f.to_string(), - Scalar::Bool(b) => b.to_string(), - Scalar::Ts(t) => t.0.to_string(), +/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type. +/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales). +fn scalar_as_param_f64(s: &Scalar) -> f64 { + match s { + Scalar::I64(n) => *n as f64, + Scalar::F64(f) => *f, + other => unreachable!("non-numeric sweep param: {other:?}"), } } @@ -282,14 +238,33 @@ fn sweep_report() -> String { let mut h = bp .bootstrap_with_params(point.to_vec()) .expect("grid points are kind-checked against param_space"); - h.run(vec![synthetic_prices()]); + let prices = synthetic_prices(); + let window = ( + prices.first().expect("non-empty stream").0, + prices.last().expect("non-empty stream").0, + ); + h.run(vec![prices]); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - summarize(&equity, &exposure) + let params = space + .iter() + .zip(point) + .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) + .collect(); + RunReport { + manifest: RunManifest { + commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), + params, + window, + seed: 0, + broker: "sim-optimal(pip_size=0.0001)".to_string(), + }, + metrics: summarize(&equity, &exposure), + } }); let mut out = String::new(); for pt in &family.points { - out.push_str(&sweep_point_to_json(pt, &space)); + out.push_str(&pt.report.to_json()); out.push('\n'); } out @@ -488,46 +463,21 @@ mod tests { assert!(!rx_ex.try_iter().collect::>().is_empty(), "exposure sink drained empty"); } - #[test] - fn sweep_point_to_json_renders_canonical_form() { - let space = vec![ - ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 }, - ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 }, - ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }, - ]; - let pt = SweepPoint { - params: vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)], - // fully-qualified: RunMetrics is needed only by this test, so it is not - // imported into production (where it would be an unused import — the - // production code reads `pt.metrics` by field, never naming the type). - metrics: aura_engine::RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 }, - }; - assert_eq!( - sweep_point_to_json(&pt, &space), - r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#, - ); - } - #[test] fn sweep_report_renders_four_points_in_odometer_order() { let out = sweep_report(); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}"); - // params byte-pinned per line: odometer order (last axis fastest) + the - // param-name and value-token rules. Metric *values* are left to the - // determinism check below (they are computed f64s, not predictable here). - assert!(lines[0].starts_with( - r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"# - )); - assert!(lines[1].starts_with( - r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5},"metrics":{"# - )); - assert!(lines[2].starts_with( - r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5},"metrics":{"# - )); - assert!(lines[3].starts_with( - r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5},"metrics":{"# - )); + // each line is a full RunReport; the commit is the real git HEAD + // (volatile), so pin the per-point manifest params (odometer order, last + // axis fastest) + the metric keys, not the commit value. + for line in &lines { + assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}"); + } + assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]); + assert!(lines[1].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]); + assert!(lines[2].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]); + assert!(lines[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]); for line in &lines { assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}"); assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 25a19e0..7febbc8 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -197,11 +197,12 @@ fn sweep_prints_four_json_lines_and_exits_zero() { // one JSON line per grid point (4-point built-in grid), each terminated by a // newline from `sweep_report`. assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}"); - // the first line is the first odometer point (fast=2, slow=4), params pinned. + let first = stdout.lines().next().unwrap(); + // each line is a full RunReport; commit is the real git HEAD (volatile), so + // pin the first odometer point's manifest params, not the commit value. + assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}"); assert!( - stdout.lines().next().unwrap().starts_with( - "{\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5},\"metrics\":{" - ), + first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"), "got: {stdout}" ); } diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index 6091abd..4c69bdc 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -5,7 +5,7 @@ //! the engine cannot generically own — C8/C18). use aura_core::{ParamSpec, Scalar, ScalarKind}; -use crate::RunMetrics; +use crate::RunReport; use std::sync::atomic::{AtomicUsize, Ordering}; /// A validated cartesian grid over a blueprint's param-space: one discrete @@ -91,12 +91,14 @@ pub enum SweepError { EmptyAxis { slot: usize }, } -/// One enumerated point and the metrics its run produced. Self-describing: the -/// `params` vector is the point's coordinate in `param_space()` order. +/// One enumerated point and the full `RunReport` its run produced. +/// Self-describing: `params` is the point's coordinate in `param_space()` order, +/// and `report` carries the run's `(manifest, metrics)` — the unit the run +/// registry indexes (C18). #[derive(Clone, Debug, PartialEq)] pub struct SweepPoint { pub params: Vec, - pub metrics: RunMetrics, + pub report: RunReport, } /// The ordered result family of a sweep — one `SweepPoint` per grid point, in @@ -110,10 +112,10 @@ pub struct SweepFamily { /// the family in enumeration order. `run_one` builds + bootstraps + runs + /// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs /// are lock-free. Parallelism is `available_parallelism()` workers — `std` only, -/// no external dependency (C16). +/// via `std::thread::scope`. pub fn sweep(space: &GridSpace, run_one: F) -> SweepFamily where - F: Fn(&[Scalar]) -> RunMetrics + Sync, + F: Fn(&[Scalar]) -> RunReport + Sync, { let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); sweep_with_threads(space, nthreads, run_one) @@ -128,7 +130,7 @@ where /// the completion order. Only the cursor is shared; the results side is lock-free. fn sweep_with_threads(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily where - F: Fn(&[Scalar]) -> RunMetrics + Sync, + F: Fn(&[Scalar]) -> RunReport + Sync, { let points = space.points(); // `points.len() >= 1` always (GridSpace rejects empty axes), so the clamp @@ -136,11 +138,11 @@ where let nthreads = nthreads.clamp(1, points.len()); let cursor = AtomicUsize::new(0); - let mut results: Vec<(usize, RunMetrics)> = std::thread::scope(|scope| { + let mut results: Vec<(usize, RunReport)> = std::thread::scope(|scope| { let handles: Vec<_> = (0..nthreads) .map(|_| { scope.spawn(|| { - let mut local: Vec<(usize, RunMetrics)> = Vec::new(); + let mut local: Vec<(usize, RunReport)> = Vec::new(); loop { let i = cursor.fetch_add(1, Ordering::Relaxed); if i >= points.len() { @@ -159,7 +161,7 @@ where SweepFamily { points: results .into_iter() - .map(|(i, metrics)| SweepPoint { params: points[i].clone(), metrics }) + .map(|(i, report)| SweepPoint { params: points[i].clone(), report }) .collect(), } } @@ -169,7 +171,7 @@ mod tests { use super::*; use crate::{ f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role, - RunMetrics, Target, + RunManifest, Target, }; use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; @@ -327,10 +329,12 @@ mod tests { (bp, rx_eq, rx_ex) } - /// Build + bootstrap + run + drain + summarize one grid point. A free `fn` - /// (Copy + Sync) so it serves as the `sweep` closure AND a direct reference - /// for the "sweep == N independent runs" comparison. - fn run_point(point: &[Scalar]) -> RunMetrics { + /// Build + bootstrap + run + drain + summarize one grid point into a + /// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `sweep` closure + /// AND a direct reference for the "sweep == N independent runs" comparison. + /// The manifest is a minimal fixed fixture — the metrics are the run's, and + /// determinism makes `run_point` reproduce a point's report exactly. + fn run_point(point: &[Scalar]) -> RunReport { let (bp, rx_eq, rx_ex) = composite_sma_cross_harness(); let mut h = bp .bootstrap_with_params(point.to_vec()) @@ -338,7 +342,16 @@ mod tests { h.run(vec![synthetic_prices()]); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - summarize(&equity, &exposure) + RunReport { + manifest: RunManifest { + commit: "test".to_string(), + params: Vec::new(), + window: (Timestamp(0), Timestamp(0)), + seed: 0, + broker: "test".to_string(), + }, + metrics: summarize(&equity, &exposure), + } } fn sma_cross_grid() -> GridSpace { @@ -371,8 +384,8 @@ mod tests { // each point's metrics equal a direct, independent run of the same point: // the sweep adds enumeration + execution, never a metrics change (C1). for pt in &family.points { - assert_eq!(pt.metrics, run_point(&pt.params)); - assert!(pt.metrics.total_pips.is_finite()); + assert_eq!(pt.report, run_point(&pt.params)); + assert!(pt.report.metrics.total_pips.is_finite()); } } @@ -391,9 +404,9 @@ mod tests { fn distinct_points_produce_distinct_metrics() { let family = sweep(&sma_cross_grid(), run_point); // not a constant family: differing SMA lengths produce differing equity - let first = family.points[0].metrics.total_pips; + let first = family.points[0].report.metrics.total_pips; assert!( - family.points.iter().any(|p| p.metrics.total_pips != first), + family.points.iter().any(|p| p.report.metrics.total_pips != first), "a 4-point SMA-length grid must not collapse to one metric", ); } diff --git a/crates/aura-registry/Cargo.toml b/crates/aura-registry/Cargo.toml new file mode 100644 index 0000000..530f75c --- /dev/null +++ b/crates/aura-registry/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "aura-registry" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +# the run registry's typed read-path: serde_json parses stored RunReport records +# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport +# derives serde in aura-engine. +aura-engine = { path = "../aura-engine" } +serde_json = { workspace = true } + +[dev-dependencies] +# fixtures construct RunReport/RunManifest literals, whose window needs Timestamp +aura-core = { path = "../aura-core" } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs new file mode 100644 index 0000000..5d812b6 --- /dev/null +++ b/crates/aura-registry/src/lib.rs @@ -0,0 +1,202 @@ +//! 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`). + +use std::fmt; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use aura_engine::RunReport; + +/// 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 path. No I/O — the file is created lazily on first append. + pub fn open(path: impl AsRef) -> 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, 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; + } + let report = + serde_json::from_str(raw).map_err(|source| RegistryError::Parse { line: i + 1, source })?; + reports.push(report); + } + Ok(reports) + } +} + +/// 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 +/// `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())), + } + Ok(reports) +} + +/// 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, exposure_sign_flips)" + ), + } + } +} + +impl std::error::Error for RegistryError {} + +impl From for RegistryError { + fn from(e: std::io::Error) -> Self { + RegistryError::Io(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::Timestamp; + use aura_engine::{RunManifest, RunMetrics, RunReport}; + + fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport { + RunReport { + manifest: RunManifest { + commit: "c".to_string(), + params: vec![("p".to_string(), 1.0)], + window: (Timestamp(1), Timestamp(2)), + seed: 0, + broker: "b".to_string(), + }, + metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips }, + } + } + + 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::::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); + } + + #[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 + let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips"); + assert_eq!(by_flips[0].metrics.exposure_sign_flips, 1); // lower-is-better -> asc + } + + #[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:?}"), + } + } +}