diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index b21390f..37a4a7e 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1097,6 +1097,59 @@ fn momentum_sweep_family(trace: Option<&str>, data: &DataSource) -> SweepFamily .expect("the momentum named grid matches the momentum param-space") } +/// The honest broker label for the dual-tap stage1-r harness: it runs a RiskExecutor +/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report +/// it (#132). Shared by the single run and the sweep so the two cannot drift. +fn stage1_r_broker_label(pip_size: f64) -> String { + format!("sim-optimal+risk-executor(pip_size={pip_size})") +} + +/// `aura sweep --strategy stage1-r`: sweep the stage1-r harness over a fast×slow +/// SIGNAL grid (the stop + sizing stay fixed — see the cycle-0066 / #133 decision +/// log: risk_budget is R-invariant and bias.scale is sign-only under flat-1R, both +/// degenerate axes; the stop defines the R unit, so varying it would break +/// cross-member SQN comparability). Each member folds the dense R-record via +/// summarize_r, so its RunReport carries `r: Some(..)` and the family is rankable +/// by sqn / expectancy_r / net_expectancy_r. Per-member tracing is a follow-up: +/// `--trace` is refused for this strategy in `run_sweep` (rather than silently +/// no-op'd), so this `_trace` slot — kept for dispatch uniformity — is always None. +fn stage1_r_sweep_family(_trace: Option<&str>, data: &DataSource) -> SweepFamily { + let pip = data.pip_size(); + let window = data.full_window(); + // a single throwaway floated build, only to resolve param_space (borrow) then + // seed the named axes (move) — its taps are never drained, the per-point run_one + // rebuilds with live ones. Mirrors momentum_sweep_family: param_space takes &self, + // .axis() consumes self, so one build suffices. + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None); + let space = bp.param_space(); + bp.axis("fast.length", [2, 3]) + .axis("slow.length", [6, 12]) + .sweep(|point| { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None) + .bootstrap_with_cells(point) + .expect("stage1-r grid points are kind-checked against param_space"); + h.run(data.run_sources()); + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let named = zip_params(&space, point); + let mut manifest = sim_optimal_manifest(named, window, 0, pip); + manifest.broker = stage1_r_broker_label(pip); + let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); + metrics.r = Some(summarize_r(&r_rows, 0.0)); + RunReport { manifest, metrics } + }) + .expect("the stage1-r named grid matches the stage1-r param-space") +} + /// Render a sweep family as one `RunReport` JSON line per point. Test helper: /// production (`run_sweep`) renders *and* persists per point. #[cfg(test)] @@ -1122,10 +1175,11 @@ fn default_registry() -> Registry { enum Strategy { SmaCross, Momentum, + Stage1R, } /// Parse the `sweep` tail: -/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ]`. +/// `[--strategy ] [--real [--from ] [--to ]] [--name | --trace ]`. /// Defaults: SMA-cross, synthetic, name "sweep", no persist (today's bare `aura /// sweep`). `--name` and `--trace` are mutually exclusive; `--from`/`--to` (ms, /// `i64`) require `--real` (there is no synthetic window knob). Pure (no I/O / @@ -1133,7 +1187,7 @@ enum Strategy { /// token, a flag without its value, an unknown strategy, both name flags, or a /// window flag without `--real` rejects. fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice), String> { - let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ]".to_string(); + let usage = || "sweep [--strategy ] [--real [--from ] [--to ]] [--name | --trace ]".to_string(); let mut strategy = Strategy::SmaCross; let mut name: Option<(String, bool)> = None; // (name, persist) let mut real = RealWindowGrammar::default(); @@ -1149,6 +1203,7 @@ fn parse_sweep_args(rest: &[&str]) -> Result<(Strategy, String, bool, DataChoice strategy = match *value { "sma" => Strategy::SmaCross, "momentum" => Strategy::Momentum, + "stage1-r" => Strategy::Stage1R, _ => return Err(usage()), }; } @@ -1212,12 +1267,25 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { ) } -/// `aura sweep [--strategy ] [--name |--trace ]`: run the +/// `aura sweep [--strategy ] [--name |--trace ]`: run the /// selected built-in sweep, persist it as a *family* (related records sharing one /// `family_id`, C18/C21) via `append_family`, and print each point's record line -/// carrying the assigned id. With `--trace`, also persist each member's streams -/// under `runs/traces///` (opt-in). +/// carrying the assigned id. With `--trace`, the `sma`/`momentum` strategies also +/// persist each member's streams under `runs/traces///` (opt-in); +/// `stage1-r` ignores the trace slot for now — the family is persisted but no +/// per-member streams are written (per-member stage1-r tracing is a follow-up, see +/// `stage1_r_sweep_family`). fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { + // --trace (per-member stream tracing) is not yet wired for the stage1-r sweep + // (4-tap members; a documented follow-up). Refuse it explicitly rather than + // accept the flag and silently write nothing — `--name` records the family. + if persist && strategy == Strategy::Stage1R { + eprintln!( + "aura: --trace is not yet supported for --strategy stage1-r \ + (per-member tracing is a follow-up); use --name to record the rankable family" + ); + std::process::exit(2); + } if persist && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) { @@ -1228,6 +1296,7 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { let family = match strategy { Strategy::SmaCross => sweep_family(persist.then_some(name), &data), Strategy::Momentum => momentum_sweep_family(persist.then_some(name), &data), + Strategy::Stage1R => stage1_r_sweep_family(persist.then_some(name), &data), }; let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) { Ok(id) => id, @@ -1727,23 +1796,40 @@ fn stage1_r_prices() -> Vec<(Timestamp, Scalar)> { static COL_PORTS: LazyLock> = LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect()); -/// The Stage-1 R harness: the SMA-cross → `Bias` signal fanned into BOTH a `SimBroker` -/// (pip equity, the existing pip yardstick) AND the `risk_executor` (the dense R-record, -/// the new R yardstick), so one run scores the same signal in pips and in R. Four taps: -/// equity (SimBroker), exposure (Bias), the 14-column R-record (drained into -/// `summarize_r`), and `r_equity = cum_realized_r + unrealized_r` (a single series for -/// `aura chart --tap r_equity`). All params bound at build → compiles with an empty point. -#[allow(clippy::type_complexity)] -fn stage1_r_blueprint( +/// The Stage-1 R harness topology, shared by the single run and the sweep. The two +/// signal knobs are bound when `Some` (single run — identical to the old +/// build-time bind, output byte-unchanged) and left free when `None` (sweep — they +/// land in `param_space` as `fast.length` / `slow.length`). Four taps: equity +/// (SimBroker), exposure (Bias), the 14-column R-record (→ summarize_r), and +/// r_equity = cum_realized_r + unrealized_r. +/// +/// The six-arg signature is a conscious keep, not an oversight: the four `tx_*` +/// are the per-tap recorder channels (one Recorder edge each — equity, exposure, +/// the R-record, r_equity), and `fast_len`/`slow_len` are the two floatable signal +/// knobs. A sender bundle would only rename the same four channels into one struct +/// field without removing an edge, trading the lint for indirection; the +/// `too_many_arguments` allow is preferred over that churn. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +fn stage1_r_graph( tx_eq: mpsc::Sender<(Timestamp, Vec)>, tx_ex: mpsc::Sender<(Timestamp, Vec)>, tx_r: mpsc::Sender<(Timestamp, Vec)>, tx_req: mpsc::Sender<(Timestamp, Vec)>, + fast_len: Option, + slow_len: Option, ) -> Composite { let mut g = GraphBuilder::new("stage1_r"); // SMA-cross signal → Bias (the same signal the pip sample uses). - let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2))); - let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4))); + let mut fast_b = Sma::builder().named("fast"); + if let Some(l) = fast_len { + fast_b = fast_b.bind("length", Scalar::i64(l)); + } + let fast = g.add(fast_b); + let mut slow_b = Sma::builder().named("slow"); + if let Some(l) = slow_len { + slow_b = slow_b.bind("length", Scalar::i64(l)); + } + let slow = g.add(slow_b); let spread = g.add(Sub::builder()); let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5))); // pip branch (verbatim from sample_blueprint_with_sinks). @@ -1803,7 +1889,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport { let (tx_ex, rx_ex) = mpsc::channel(); let (tx_r, rx_r) = mpsc::channel(); let (tx_req, rx_req) = mpsc::channel(); - let flat = stage1_r_blueprint(tx_eq, tx_ex, tx_r, tx_req) + let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4)) .compile_with_params(&[]) .expect("valid stage1-r blueprint"); let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness"); @@ -1839,9 +1925,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport { 0, pip_size, ); - // #132: the dual-tap harness runs a RiskExecutor branch alongside the SimBroker, so - // the plain "sim-optimal" label would under-report it — record both honestly. - manifest.broker = format!("sim-optimal+risk-executor(pip_size={pip_size})"); + manifest.broker = stage1_r_broker_label(pip_size); if let Some(name) = trace { persist_traces_r(name, &manifest, &eq_rows, &ex_rows, &req_rows); } @@ -1971,7 +2055,7 @@ fn run_dispatch(args: RunArgs) -> Result { } const USAGE: &str = - "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--harness ] [--real [--from ] [--to ]] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 4b0fdb7..78a6a8e 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -1598,3 +1598,111 @@ fn run_harness_stage1_r_is_byte_deterministic_across_runs() { }; assert_eq!(run(), run(), "the stage1-r run must be byte-identical across runs (C1)"); } + +/// Golden characterization (cycle 0066): pins the EXACT metric output of the +/// stage1-r single run, so the `stage1_r_graph` helper extraction (Task 2) is +/// VERIFIED byte-preserving, not assumed. Captured against HEAD before the +/// refactor; the refactor must keep it identical (a drift is a behaviour change → +/// debug). Commit-agnostic: pins only the `metrics` object, since the manifest +/// carries the volatile build commit. +#[test] +fn stage1_r_single_run_output_golden() { + let out = Command::new(BIN).args(["run", "--harness", "stage1-r"]).output().unwrap(); + assert!(out.status.success(), "exit: {:?}", out.status); + let s = String::from_utf8(out.stdout).expect("utf-8 stdout"); + let key = "\"metrics\":"; + let start = s.find(key).expect("stdout has a metrics object"); + let metrics = s[start..].trim_end(); + assert_eq!( + metrics, + r#""metrics":{"total_pips":0.34185000000002036,"max_drawdown":0.11139999999998655,"bias_sign_flips":2,"r":{"expectancy_r":1.2710005136982836,"n_trades":3,"win_rate":1.0,"avg_win_r":1.2710005136982836,"avg_loss_r":0.0,"profit_factor":0.0,"max_r_drawdown":0.0,"n_open_at_end":1,"sqn":3.141496526818299,"net_expectancy_r":1.2710005136982836,"conviction_terciles_r":[0.9285858482198718,2.0771328641652427,0.8072828287097363]}}}"#, + "stage1-r single-run metric output drifted from the golden: {s}" + ); +} + +/// Property (#133): `aura sweep --strategy stage1-r` runs the stage1-r harness over +/// the fast×slow signal grid (4 members), each carrying an R block, persisted as a +/// rankable family; `runs family rank sqn` orders them highest-SQN first. +#[test] +fn sweep_strategy_stage1_r_ranks_a_family_by_sqn() { + let cwd = temp_cwd("sweep-stage1-r"); + let out = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-r"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy stage1-r"); + assert!( + out.status.success(), + "stage1-r sweep exit: {:?}; stderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8(out.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 4, "stage1-r sweep must print 4 member lines: {stdout:?}"); + for line in &lines { + assert!(line.contains("\"r\":{"), "member must carry an r block: {line}"); + assert!(line.contains("\"sqn\""), "member r block must carry sqn: {line}"); + } + // rank the family by sqn — highest first. + let rank = Command::new(BIN) + .args(["runs", "family", "sweep-0", "rank", "sqn"]) + .current_dir(&cwd) + .output() + .expect("spawn rank sqn"); + assert!(rank.status.success(), "rank sqn exit: {:?}", rank.status); + let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); + assert_eq!(rank_out.lines().count(), 4, "rank: {rank_out:?}"); + let sqns: Vec = rank_out + .lines() + .map(|l| { + let key = "\"sqn\":"; + let start = l.find(key).expect("line has sqn") + key.len(); + let tail = &l[start..]; + let end = tail.find([',', '}']).expect("token end"); + tail[..end].parse().expect("sqn is an f64") + }) + .collect(); + assert!(sqns.windows(2).all(|w| w[0] >= w[1]), "rank not descending by sqn: {sqns:?}"); + // an unknown metric is a usage error (exit 2). + let bogus = Command::new(BIN) + .args(["runs", "family", "sweep-0", "rank", "bogus"]) + .current_dir(&cwd) + .output() + .expect("spawn bogus"); + assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property (#133): `--trace` (per-member stream tracing) is not yet wired for the +/// stage1-r sweep, so it is REFUSED explicitly (exit 2) rather than accepted and +/// silently writing nothing — unlike sma/momentum, whose `--trace` writes member +/// dirs. `--name` (record the rankable family, no per-member streams) still works. +#[test] +fn sweep_strategy_stage1_r_trace_is_refused_name_works() { + let cwd = temp_cwd("sweep-stage1-r-trace"); + let traced = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-r", "--trace", "t1"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy stage1-r --trace"); + assert_eq!( + traced.status.code(), + Some(2), + "stage1-r --trace must be refused (exit 2): {:?}", + traced.status + ); + // --name records the rankable family (no per-member streams) — succeeds. + let named = Command::new(BIN) + .args(["sweep", "--strategy", "stage1-r", "--name", "n1"]) + .current_dir(&cwd) + .output() + .expect("spawn aura sweep --strategy stage1-r --name"); + assert!( + named.status.success(), + "stage1-r --name must succeed: {:?}; stderr: {}", + named.status, + String::from_utf8_lossy(&named.stderr) + ); + let _ = std::fs::remove_dir_all(&cwd); +} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index eb4c26a..d2cb410 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -101,13 +101,21 @@ enum Metric { TotalPips, MaxDrawdown, BiasSignFlips, + Sqn, + 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` -/// higher-is-better; `max_drawdown` and `bias_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`. +/// better than `b`. "Best" is fixed by each metric's meaning: `total_pips` and +/// the three R keys (`sqn`, `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 @@ -121,8 +129,16 @@ fn metric_cmp( "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, + "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(), @@ -131,6 +147,15 @@ fn metric_cmp( 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::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)) + } }) } @@ -183,7 +208,7 @@ impl fmt::Display for RegistryError { } RegistryError::UnknownMetric(m) => write!( f, - "unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips)" + "unknown metric '{m}' (known: total_pips, max_drawdown, bias_sign_flips, sqn, expectancy_r, net_expectancy_r)" ), } } @@ -201,6 +226,7 @@ impl From for RegistryError { 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 { @@ -216,6 +242,65 @@ mod tests { } } + /// 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); the rest are fixed plausible values. + 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, + 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 = 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_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("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))