//! 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_core::PrimitiveBuilder; use aura_engine::{ blueprint_from_json, blueprint_identity_json, expected_max_of_normals, r_metrics_from_rs, resample_block, FamilySelection, MetricStats, RunReport, SelectionMode, SplitMix64, SweepFamily, SweepPoint, }; use aura_research::{CampaignDoc, DocRef}; mod compat; mod lineage; pub use lineage::{ derive_trace_name, group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellCoverage, CellFault, CellFaultKind, CellRealization, Family, FamilyKind, FamilyRunRecord, StageBootstrap, StageRealization, StageSelection, WindowFault, }; 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) -> 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; } // 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::(raw) .map_err(|source| RegistryError::Parse { line: i + 1, source })? .into(); reports.push(report); } Ok(reports) } /// The content-addressed blueprint store dir — a sibling of the runs store, /// `.with_file_name("blueprints")`. A topology is stored once, /// keyed by its content id (`topology_hash`), so a whole sweep family's /// members share one stored blueprint (C18 tiny manifest; C11/C12 dedup). fn blueprints_dir(&self) -> PathBuf { self.path.with_file_name("blueprints") } /// The store path a blueprint with this content id lives at — the single /// content-id→path mapping `put_blueprint` and `get_blueprint` both route /// through (so the store can never write one path and read another; a /// drifted key would silently break round-trip), exposed so consumers /// never re-derive the layout. pub fn blueprint_path(&self, hash: &str) -> PathBuf { self.blueprints_dir().join(format!("{hash}.json")) } /// Write-once content-addressed put: `blueprints/.json` = `canonical_json`. /// Idempotent — the same content id always addresses identical canonical bytes, /// so a repeated write re-writes identical content. The registry does NOT /// verify `sha256(bytes) == hash` (no `sha2` dep here): the caller owns the /// hash, and reproduction's bit-identical metric compare is the integrity check. pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> { fs::create_dir_all(self.blueprints_dir())?; fs::write(self.blueprint_path(hash), canonical_json)?; Ok(()) } /// Read a stored blueprint by content id; `Ok(None)` if absent — the same /// treat-as-empty discipline `load` applies to a missing runs store. pub fn get_blueprint(&self, hash: &str) -> Result, RegistryError> { match fs::read_to_string(self.blueprint_path(hash)) { Ok(s) => Ok(Some(s)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(RegistryError::Io(e)), } } /// The content-addressed document-store dir for one document kind — /// a sibling of the runs store, like `blueprints/`. fn doc_dir(&self, kind: &str) -> PathBuf { self.path.with_file_name(kind) } /// The single id→path mapping every document put/get routes through /// (the `blueprint_path` write-one/read-one lockstep discipline). fn doc_path(&self, kind: &str, content_id: &str) -> PathBuf { self.doc_dir(kind).join(format!("{content_id}.json")) } /// Content-addressed put: computes the content id from the canonical /// bytes (unlike `put_blueprint`, whose caller owns the hash — document /// callers always hold canonical bytes, so self-keying is safe here) /// and writes `/.json`. Idempotent. fn put_doc(&self, kind: &str, canonical_json: &str) -> Result { let id = aura_research::content_id_of(canonical_json); fs::create_dir_all(self.doc_dir(kind))?; fs::write(self.doc_path(kind, &id), canonical_json)?; Ok(id) } /// Read a stored document by content id; `Ok(None)` if absent (the /// `get_blueprint` treat-as-empty discipline). fn get_doc(&self, kind: &str, content_id: &str) -> Result, RegistryError> { match fs::read_to_string(self.doc_path(kind, content_id)) { Ok(s) => Ok(Some(s)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(RegistryError::Io(e)), } } /// Store a canonical process document; returns its content id. pub fn put_process(&self, canonical_json: &str) -> Result { self.put_doc("processes", canonical_json) } /// Load a stored process document by content id (`Ok(None)` if absent). pub fn get_process(&self, content_id: &str) -> Result, RegistryError> { self.get_doc("processes", content_id) } /// Store a canonical campaign document; returns its content id. pub fn put_campaign(&self, canonical_json: &str) -> Result { self.put_doc("campaigns", canonical_json) } /// Load a stored campaign document by content id (`Ok(None)` if absent). pub fn get_campaign(&self, content_id: &str) -> Result, RegistryError> { self.get_doc("campaigns", content_id) } /// The store path a process document with this content id lives at — /// the same single mapping the put/get pair routes through, exposed so /// consumers never re-derive the layout. pub fn process_path(&self, content_id: &str) -> PathBuf { self.doc_path("processes", content_id) } /// The store path a campaign document with this content id lives at. pub fn campaign_path(&self, content_id: &str) -> PathBuf { self.doc_path("campaigns", content_id) } } /// Referential-validation findings for a campaign document. By-identifier /// and Display-free (the CLI phrases them). #[derive(Clone, Debug, PartialEq)] pub enum RefFault { ProcessNotFound(String), StrategyNotFound(String), IdentityUnmatched(String), StrategyUnloadable { id: String, error: String }, AxisNotInParamSpace { strategy: String, axis: String }, AxisKindMismatch { strategy: String, axis: String }, /// An open param of the resolved strategy is bound by no campaign axis — /// the executor's every-open-knob-required rule, mirrored at validate /// time so "valid" means "runnable". Carries the RAW `param_space()` /// path (never the wrapped bind-time path). ParamNotCovered { strategy: String, param: String }, /// A campaign `data.bindings` key that names no input role of any /// strategy in the campaign — the override would silently bind nothing /// (#231). Carries the strategies' actual role names for the prose. BindingRoleUnknown { role: String, roles: Vec }, } impl Registry { /// Referential tier: resolve the campaign's references against the /// project's stores and check each axis (name AND declared kind) against /// the referenced strategy's param space. IO faults are Errors; semantic /// findings are RefFaults. pub fn validate_campaign_refs( &self, doc: &CampaignDoc, resolve: &dyn Fn(&str) -> Option, ) -> Result, RegistryError> { let mut faults = Vec::new(); let mut known_roles: std::collections::BTreeSet = std::collections::BTreeSet::new(); if let DocRef::ContentId(id) = &doc.process.r#ref && self.get_process(id)?.is_none() { faults.push(RefFault::ProcessNotFound(id.clone())); } for entry in &doc.strategies { let (label, blueprint_json) = match &entry.r#ref { DocRef::ContentId(id) => match self.get_blueprint(id)? { Some(json) => (id.clone(), Some(json)), None => { faults.push(RefFault::StrategyNotFound(id.clone())); (id.clone(), None) } }, DocRef::IdentityId(id) => match self.find_blueprint_by_identity(id, resolve)? { Some(json) => (id.clone(), Some(json)), None => { faults.push(RefFault::IdentityUnmatched(id.clone())); (id.clone(), None) } }, }; let Some(json) = blueprint_json else { continue }; let composite = match blueprint_from_json(&json, resolve) { Ok(c) => c, Err(e) => { faults.push(RefFault::StrategyUnloadable { id: label.clone(), error: format!("{e:?}"), }); continue; } }; for role in composite.input_roles() { known_roles.insert(role.name.clone()); } let space = composite.param_space(); // #246: an axis naming a BOUND param re-opens it (bound value = // default) — an equally valid axis alongside the open surface, // not a param-space miss. `composite` here is the RAW strategy // (no CLI-side wrap), so a campaign axis and `bound_param_space()` // names live in the same (strategy-coordinate) namespace already // — no wrap-prefix stripping needed, unlike the CLI's // `wrapped_bound_overrides_of`/`override_paths`/`raw_bound_overrides_of`. let bound = composite.bound_param_space(); for (axis, ax) in &entry.axes { // Open space wins over bound on a (defensively impossible) // name collision, mirroring the original arm order; only the // kind SOURCE differs between the two found-cases, so one // shared mismatch push replaces the former duplicate arms. let kind = space .iter() .find(|p| &p.name == axis) .map(|p| p.kind) .or_else(|| bound.iter().find(|b| &b.name == axis).map(|b| b.kind)); match kind { Some(k) => { if ax.kind != k { faults.push(RefFault::AxisKindMismatch { strategy: label.clone(), axis: axis.clone(), }); } } None => faults.push(RefFault::AxisNotInParamSpace { strategy: label.clone(), axis: axis.clone(), }), } } // The reverse direction: every open param must be bound by some // axis (the executor refuses at member-bind otherwise — #203). for spec in &space { if !entry.axes.contains_key(&spec.name) { faults.push(RefFault::ParamNotCovered { strategy: label.clone(), param: spec.name.clone(), }); } } } // Binding KEYS (the 6b overrides): each must name an input role of at // least one campaign strategy — checked in this tier (not the // intrinsic one) because only the resolver sees the loaded blueprints' // roles. Binding VALUES are the intrinsic tier's concern. for role in doc.data.bindings.keys() { if !known_roles.contains(role) { faults.push(RefFault::BindingRoleUnknown { role: role.clone(), roles: known_roles.iter().cloned().collect(), }); } } Ok(faults) } /// Scan the blueprint store for a blueprint whose identity id matches — /// public so a campaign run resolves the same identity refs the /// referential tier validates. pub fn find_blueprint_by_identity( &self, identity_id: &str, resolve: &dyn Fn(&str) -> Option, ) -> Result, RegistryError> { let entries = match fs::read_dir(self.blueprints_dir()) { Ok(e) => e, Err(_) => return Ok(None), // no store yet -> nothing matches }; for entry in entries { let entry = entry?; let json = fs::read_to_string(entry.path())?; let Ok(composite) = blueprint_from_json(&json, resolve) else { continue }; let Ok(identity_json) = blueprint_identity_json(&composite) else { continue }; if aura_research::content_id_of(&identity_json) == identity_id { return Ok(Some(json)); } } Ok(None) } } /// 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 { 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 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, metric: &str) -> Result, 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 { 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 { // 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_net_trade_rs(rep: &RunReport) -> &[f64] { rep.metrics.r.as_ref().map(|r| r.net_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 `net_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 { let centred: Vec> = family.points.iter().map(|p| { let rs = member_net_trade_rs(&p.report); if rs.is_empty() { return Vec::new(); } let mean = rs.iter().sum::() / 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 = 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::() / n as f64; (vals.iter().map(|v| (v - mean).powi(2)).sum::() / (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::(), 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 = nbrs.iter().map(|&j| metric_value(&family.points[j].report, m)).collect(); let score = match mode { PlateauMode::Mean => vals.iter().sum::() / 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), }, )) } /// Deflation-bootstrap default: the resample count of the null-distribution /// bootstrap consumed by [`optimize_deflated`]. pub const DEFLATION_N_RESAMPLES: usize = 1000; /// Deflation-bootstrap default: the moving-block length of the null-distribution /// bootstrap consumed by [`optimize_deflated`]. pub const DEFLATION_BLOCK_LEN: usize = 5; /// `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 // `net_trade_rs` (every member's centred series is empty, so each iteration's // best-of-K folds to `NEG_INFINITY`). The latter is reachable — `net_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 = 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, })) } /// The cross-instrument generalization aggregate (#146): a brought candidate's /// per-instrument R-metric, reduced to a worst-case floor, a sign-agreement count, /// and the full breakdown. R-only (C10): `total_pips` is not comparable across /// instruments. Pure, deterministic (C1) — a fold over `per_instrument` in input /// order. `worst_case` is `min_i metric_i`; since every R-metric is /// higher-is-better, the min is unconditionally the conservative floor (no /// direction branch, unlike `PlateauMode::Worst`). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Generalization { pub selection_metric: String, pub n_instruments: usize, pub worst_case: f64, pub sign_agreement: usize, pub per_instrument: Vec<(String, f64)>, } /// Validate that `metric` is a known, R-based ranking key — the data-free pre-check /// the CLI runs before evaluating any instrument. The registry owns metric truth /// (C9), so the CLI never duplicates the R-set. pub fn check_r_metric(metric: &str) -> Result<(), RegistryError> { let m = resolve_metric(metric)?; if is_r_metric(m) { Ok(()) } else { Err(RegistryError::NonRMetric(metric.to_string())) } } /// Grade how consistently one candidate holds across instruments: read the chosen /// R-metric from each per-instrument report and reduce to the worst-case floor + /// the sign-agreement count + the per-instrument breakdown. R-only and `>= 2` /// instruments (the metric check precedes the arity check so a bad metric is /// reported even with an empty slice). Additive — reads, never re-ranks (C23). pub fn generalization( per_instrument: &[(String, &RunReport)], metric: &str, ) -> Result { let m = resolve_metric(metric)?; if !is_r_metric(m) { return Err(RegistryError::NonRMetric(metric.to_string())); } if per_instrument.len() < 2 { return Err(RegistryError::TooFewInstruments(per_instrument.len())); } let vals: Vec<(String, f64)> = per_instrument .iter() .map(|(label, rep)| (label.clone(), metric_value(rep, m))) .collect(); let worst_case = vals.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min); let sign_agreement = vals.iter().filter(|(_, v)| *v > 0.0).count(); Ok(Generalization { selection_metric: metric.to_string(), n_instruments: vals.len(), worst_case, sign_agreement, per_instrument: vals, }) } /// 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), /// A known metric that is not R-based — cross-instrument generalization is /// R-only (R is the only account/instrument-agnostic unit, C10). NonRMetric(String), /// A generalization was asked for with fewer than two instruments. TooFewInstruments(usize), } 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)" ), RegistryError::NonRMetric(m) => write!( f, "metric '{m}' is not comparable across instruments; cross-instrument scoring is R-only (sqn, sqn_normalized, expectancy_r, net_expectancy_r)" ), RegistryError::TooFewInstruments(n) => write!( f, "a generalization needs >= 2 instruments, got {n}" ), } } } 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::{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))], defaults: vec![], window: (Timestamp(1), Timestamp(2)), seed: 0, broker: "b".to_string(), selection: None, instrument: None, topology_hash: None, project: 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], net_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 = 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 = 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 { // fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): // no external tempfile dependency, and every call site already wipes // before use, so the fixed name genuinely reclaims the previous run. std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join(format!("aura-registry-{name}.jsonl")) } #[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 blueprint_store_round_trips_by_content_id() { let reg = Registry::open(temp_family_dir("blueprint_store_round_trip")); let canonical = r#"{"format_version":1,"input_roles":[],"nodes":[],"edges":[]}"#; reg.put_blueprint("deadbeef", canonical).expect("put"); // exact bytes come back, keyed by content id assert_eq!(reg.get_blueprint("deadbeef").expect("get"), Some(canonical.to_string())); // an absent id is Ok(None), not an error (treat-as-empty discipline) assert_eq!(reg.get_blueprint("never-written").expect("get"), None); } #[test] fn document_stores_round_trip_by_content_id() { let reg = Registry::open(temp_family_dir("document_store_round_trip")); let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let campaign = r#"{"format_version":1,"kind":"campaign","name":"c"}"#; let pid = reg.put_process(process).expect("put process"); assert_eq!(pid, aura_research::content_id_of(process)); assert_eq!(reg.get_process(&pid).expect("get process"), Some(process.to_string())); let cid = reg.put_campaign(campaign).expect("put campaign"); assert_eq!(reg.get_campaign(&cid).expect("get campaign"), Some(campaign.to_string())); assert_ne!(pid, cid); // absent id is Ok(None), not an error (treat-as-empty discipline) assert_eq!(reg.get_process("never-written").expect("get"), None); } #[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::(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. // Fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the // pre-create wipe below then genuinely reclaims the previous run. let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp")) .join(format!("aura-registry-fam-{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![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![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![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::::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 = 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, net_trade_rs: Vec) -> 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 // `net_trade_rs` conduit is restored: `r_metrics_from_rs` empties it // (`net_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(&net_trade_rs); r.net_trade_rs = net_trade_rs; SweepPoint { params: vec![], report: RunReport { manifest: RunManifest { commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)), seed: 0, broker: "b".into(), selection: None, instrument: None, topology_hash: None, project: 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 + net_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 `net_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_net_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.net_trade_rs.clear(); // mimic a serde-loaded member (net_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", ); } #[test] fn generalization_worst_case_is_the_floor_not_the_mean() { let a = report_with_r(0.0, 0.40, 0.0); // +0.40 let b = report_with_r(0.0, -0.20, 0.0); // -0.20 (the floor) let c = report_with_r(0.0, 0.10, 0.0); // +0.10 let pairs = vec![("A".to_string(), &a), ("B".to_string(), &b), ("C".to_string(), &c)]; let g = generalization(&pairs, "expectancy_r").expect("R metric"); assert_eq!(g.n_instruments, 3); assert!((g.worst_case - (-0.20)).abs() < 1e-12, "worst_case must be the min, got {}", g.worst_case); assert_eq!(g.sign_agreement, 2, "two instruments are net-positive"); assert_eq!(g.per_instrument, vec![ ("A".to_string(), 0.40), ("B".to_string(), -0.20), ("C".to_string(), 0.10), ]); } #[test] fn generalization_is_not_lifted_by_a_strong_instrument() { let a = report_with_r(0.0, 0.40, 0.0); let b = report_with_r(0.0, -0.20, 0.0); let strong = report_with_r(0.0, 99.0, 0.0); let pairs = vec![("A".to_string(), &a), ("B".to_string(), &b), ("S".to_string(), &strong)]; let g = generalization(&pairs, "expectancy_r").expect("R metric"); assert!((g.worst_case - (-0.20)).abs() < 1e-12, "a strong instrument must not raise the floor"); } #[test] fn generalization_refuses_a_non_r_metric() { let a = report_with_r(0.0, 0.4, 0.0); let b = report_with_r(0.0, 0.2, 0.0); let pairs = vec![("A".to_string(), &a), ("B".to_string(), &b)]; match generalization(&pairs, "total_pips") { Err(RegistryError::NonRMetric(m)) => assert_eq!(m, "total_pips"), other => panic!("expected NonRMetric, got {other:?}"), } } #[test] fn generalization_refuses_fewer_than_two_instruments() { let a = report_with_r(0.0, 0.4, 0.0); let pairs = vec![("A".to_string(), &a)]; match generalization(&pairs, "expectancy_r") { Err(RegistryError::TooFewInstruments(n)) => assert_eq!(n, 1), other => panic!("expected TooFewInstruments(1), got {other:?}"), } } #[test] fn generalization_reports_a_bad_metric_before_arity() { // The doc-comment pins this ordering: the metric check precedes the // arity check, so a bad metric is surfaced even on an empty slice // (which would otherwise yield TooFewInstruments(0)). This decides // which error the CLI shows when both are wrong; reversing the two // guards in `generalization` would flip this to TooFewInstruments. let pairs: Vec<(String, &RunReport)> = vec![]; match generalization(&pairs, "total_pips") { Err(RegistryError::NonRMetric(m)) => assert_eq!(m, "total_pips"), other => panic!("expected NonRMetric before arity, got {other:?}"), } } #[test] fn generalization_is_deterministic() { let a = report_with_r(0.0, 0.40, 0.0); let b = report_with_r(0.0, -0.20, 0.0); let pairs = vec![("A".to_string(), &a), ("B".to_string(), &b)]; let g1 = generalization(&pairs, "expectancy_r").expect("R metric"); let g2 = generalization(&pairs, "expectancy_r").expect("R metric"); assert_eq!(g1, g2, "a pure fold must be deterministic (C1)"); } /// Property: `validate_campaign_refs` resolves campaign document /// references (content-id and identity-id) against the registry's /// stores and cross-checks each declared tuning axis (name AND kind) /// against the resolved strategy's real, generic param space — not a /// hardcoded param name. Faults are reported per finding kind /// (ProcessNotFound / StrategyNotFound / AxisNotInParamSpace / /// AxisKindMismatch), and a fully-resolved, kind-correct document /// yields no faults at all. #[test] fn referential_tier_resolves_refs_and_checks_axes() { use aura_engine::{blueprint_to_json, Composite}; use aura_research::{Axis, DocRef}; let reg = Registry::open(temp_family_dir("referential_tier")); let resolve = |t: &str| aura_std::std_vocabulary(t); // A minimal fixture composite with one OPEN param, built from a // zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually // resolve it on load. `aura-composites`' shipped composites (vol_stop, // risk_executor*) all route through `LinComb`, whose builder needs a // structural arity argument and is therefore deliberately absent from // the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs) // — they cannot round-trip through `blueprint_from_json` with this // resolver, so this fixture stands in as the "real, open-param, // vocabulary-loadable composite" the test needs. let composite = Composite::new( "fixture", vec![aura_std::Bias::builder().named("b").into()], vec![], vec![], vec![], ); let space = composite.param_space(); let real = space.first().expect("fixture composite has an open param"); let real_name = real.name.clone(); let real_kind = real.kind; let blueprint_json = blueprint_to_json(&composite).expect("serializes"); let bp_id = aura_research::content_id_of(&blueprint_json); reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint"); let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let proc_id = reg.put_process(process).expect("seed process"); // one kind-correct bare value for the real param let bare = match real_kind { aura_core::ScalarKind::I64 => "4", aura_core::ScalarKind::F64 => "1.5", aura_core::ScalarKind::Bool => "true", aura_core::ScalarKind::Timestamp => "0", }; let kind_tag = format!("{real_kind:?}"); // unit-variant Debug == serde string let campaign_text = format!( concat!( r#"{{"format_version":1,"kind":"campaign","name":"c","#, r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, r#""axes":{{"{axis}":{{"kind":"{kind}","values":[{val}]}}}}}}],"#, r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# ), bp = bp_id, axis = real_name, kind = kind_tag, val = bare, proc = proc_id, ); let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses"); assert_eq!(reg.validate_campaign_refs(&doc, &resolve).expect("io ok"), Vec::new()); // unknown process + unknown strategy ids let mut missing = doc.clone(); missing.process.r#ref = DocRef::ContentId("00".into()); missing.strategies[0].r#ref = DocRef::ContentId("11".into()); let faults = reg.validate_campaign_refs(&missing, &resolve).expect("io ok"); assert!(faults.contains(&RefFault::ProcessNotFound("00".into()))); assert!(faults.contains(&RefFault::StrategyNotFound("11".into()))); // unknown axis name let mut bad_axis = doc.clone(); let ax = bad_axis.strategies[0].axes.remove(&real_name).unwrap(); bad_axis.strategies[0].axes.insert("nope".into(), ax); assert!(reg .validate_campaign_refs(&bad_axis, &resolve) .expect("io ok") .iter() .any(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == "nope"))); // declared kind != the param's kind let wrong_kind = if matches!(real_kind, aura_core::ScalarKind::I64) { aura_core::ScalarKind::F64 } else { aura_core::ScalarKind::I64 }; let mut mismatched = doc.clone(); // values were parsed under the right kind; re-kind the axis only — // the check compares DECLARED kind vs the param's kind let kept_values = mismatched.strategies[0].axes[&real_name].values.clone(); mismatched.strategies[0].axes.insert( real_name.clone(), Axis { kind: wrong_kind, values: kept_values }, ); assert!(reg .validate_campaign_refs(&mismatched, &resolve) .expect("io ok") .iter() .any(|f| matches!(f, RefFault::AxisKindMismatch { axis, .. } if axis == &real_name))); // identity ref resolves via the store scan let identity_json = blueprint_identity_json(&composite).expect("identity form"); let identity_id = aura_research::content_id_of(&identity_json); let mut by_identity = doc.clone(); by_identity.strategies[0].r#ref = DocRef::IdentityId(identity_id); assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new()); } /// Property (#246): a campaign axis naming a BOUND param (not an open /// one) is checked exactly like an open-param axis — a kind-correct axis /// over the bound path is fault-free, and a kind-mismatched one over that /// SAME bound path still yields `AxisKindMismatch` via the bound branch /// of `validate_campaign_refs`'s match, never silently accepted. Pure and /// archive/env-free, unlike the `sweep_dissolved_accepts_an_axis_over_a_ /// bound_param` e2e sibling, which only exercises this path when local /// data is present. #[test] fn referential_tier_accepts_a_kind_correct_axis_over_a_bound_param() { use aura_engine::{blueprint_to_json, Composite}; use aura_research::Axis; use aura_std::Sma; let reg = Registry::open(temp_family_dir("bound_axis_tier")); let resolve = |t: &str| aura_std::std_vocabulary(t); // A fixture composite with ONE bound param and NO open param, so the // reverse full-coverage check (every OPEN param must be bound by an // axis) is vacuously satisfied regardless of the axis under test. let composite = Composite::new( "fixture", vec![Sma::builder().named("s").bind("length", Scalar::i64(3)).into()], vec![], vec![], vec![], ); assert!(composite.param_space().is_empty(), "fixture is fully bound"); let bound = composite.bound_param_space(); let bound_param = bound.first().expect("fixture has one bound param"); let bound_name = bound_param.name.clone(); let bound_kind = bound_param.kind; let blueprint_json = blueprint_to_json(&composite).expect("serializes"); let bp_id = aura_research::content_id_of(&blueprint_json); reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint"); let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let proc_id = reg.put_process(process).expect("seed process"); let bare = match bound_kind { aura_core::ScalarKind::I64 => "4", aura_core::ScalarKind::F64 => "1.5", aura_core::ScalarKind::Bool => "true", aura_core::ScalarKind::Timestamp => "0", }; let kind_tag = format!("{bound_kind:?}"); let campaign_text = format!( concat!( r#"{{"format_version":1,"kind":"campaign","name":"c","#, r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, r#""axes":{{"{axis}":{{"kind":"{kind}","values":[{val}]}}}}}}],"#, r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# ), bp = bp_id, axis = bound_name, kind = kind_tag, val = bare, proc = proc_id, ); let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses"); assert_eq!( reg.validate_campaign_refs(&doc, &resolve).expect("io ok"), Vec::new(), "a kind-correct axis over a bound param must pass validation", ); // A kind-mismatched axis over the SAME bound path must still fault // via the bound branch — not silently accepted just because it // names no OPEN param. let wrong_kind = if matches!(bound_kind, aura_core::ScalarKind::I64) { aura_core::ScalarKind::F64 } else { aura_core::ScalarKind::I64 }; let mut mismatched = doc.clone(); let kept_values = mismatched.strategies[0].axes[&bound_name].values.clone(); mismatched.strategies[0].axes.insert( bound_name.clone(), Axis { kind: wrong_kind, values: kept_values }, ); assert!( reg.validate_campaign_refs(&mismatched, &resolve) .expect("io ok") .iter() .any(|f| matches!(f, RefFault::AxisKindMismatch { axis, .. } if axis == &bound_name)), "a kind-mismatched axis over a bound param must fault via the bound branch", ); } /// Property: the referential tier requires FULL open-param coverage — every /// open param of a referenced strategy must be bound by a campaign axis, so /// a document `validate` blesses is one `run` can actually bind (the /// glossary's "every open knob required, no default" rule, mirrored at /// validate time — C11). A campaign whose axes omit an open param must yield /// a `ParamNotCovered` fault carrying the RAW param path (the `param_space()` /// namespace the axes and `--params` share), while a fully-covering campaign /// stays fault-free (coverage must not over-reject a covered knob). #[test] fn referential_tier_requires_full_open_param_coverage() { use aura_engine::{blueprint_to_json, Composite}; let reg = Registry::open(temp_family_dir("coverage_tier")); let resolve = |t: &str| aura_std::std_vocabulary(t); // A fixture composite with TWO open params, from two zero-arg `Bias` // nodes so `std_vocabulary` can load it on round-trip (the sibling test // documents why LinComb composites cannot). Their raw param paths are // `b1.scale` and `b2.scale`. let composite = Composite::new( "fixture", vec![ aura_std::Bias::builder().named("b1").into(), aura_std::Bias::builder().named("b2").into(), ], vec![], vec![], vec![], ); let space = composite.param_space(); assert_eq!(space.len(), 2, "fixture exposes two open params"); let covered = space[0].name.clone(); // e.g. "b1.scale" let uncovered = space[1].name.clone(); // e.g. "b2.scale" let blueprint_json = blueprint_to_json(&composite).expect("serializes"); let bp_id = aura_research::content_id_of(&blueprint_json); reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint"); let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let proc_id = reg.put_process(process).expect("seed process"); // A campaign whose axes cover BOTH open params (kind-correct F64 values). let full_text = format!( concat!( r#"{{"format_version":1,"kind":"campaign","name":"c","#, r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, r#""axes":{{"{a}":{{"kind":"F64","values":[2.0]}},"{b}":{{"kind":"F64","values":[3.0]}}}}}}],"#, r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# ), bp = bp_id, a = covered, b = uncovered, proc = proc_id, ); let full = aura_research::parse_campaign(&full_text).expect("campaign parses"); assert_eq!( reg.validate_campaign_refs(&full, &resolve).expect("io ok"), Vec::new(), "a fully-covering campaign is fault-free (coverage must not over-reject)", ); // Drop the axis for `uncovered`: now one open param is bound by no axis. let mut partial = full.clone(); partial.strategies[0].axes.remove(&uncovered).expect("axis was present"); let faults = reg.validate_campaign_refs(&partial, &resolve).expect("io ok"); assert!( faults.iter().any(|f| matches!( f, RefFault::ParamNotCovered { param, .. } if param == &uncovered )), "an open param bound by no axis must fault at validate time, naming the \ RAW param path (not the wrapped bind-time path); got {faults:?}", ); } /// Property (#231): the referential tier checks campaign `data.bindings` /// KEYS against the resolved strategies' input roles (the 6b override /// seam) — a key naming no role of any strategy faults with the actual /// roles listed; a key naming a real role passes. The sibling of the /// unknown-axis check above; values are the intrinsic tier's concern. #[test] fn referential_tier_checks_binding_keys_against_strategy_roles() { use aura_engine::{blueprint_to_json, Composite, Role, Target}; let reg = Registry::open(temp_family_dir("binding_keys")); let resolve = |t: &str| aura_std::std_vocabulary(t); // The referential fixture composite (see // referential_tier_resolves_refs_and_checks_axes), plus ONE input // role so a binding key has something to name. let composite = Composite::new( "fixture", vec![aura_std::Bias::builder().named("b").into()], vec![], vec![Role { name: "price".to_string(), targets: vec![Target { node: 0, slot: 0 }], source: Some(aura_core::ScalarKind::F64), }], vec![], ); let space = composite.param_space(); let axis = space.first().expect("fixture has an open param").name.clone(); let blueprint_json = blueprint_to_json(&composite).expect("serializes"); let bp_id = aura_research::content_id_of(&blueprint_json); reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint"); let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#; let proc_id = reg.put_process(process).expect("seed process"); let campaign_text = format!( concat!( r#"{{"format_version":1,"kind":"campaign","name":"c","#, r#""data":{{"bindings":{{"price":"open"}},"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#, r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#, r#""axes":{{"{axis}":{{"kind":"F64","values":[1.5]}}}}}}],"#, r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#, r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"# ), bp = bp_id, axis = axis, proc = proc_id, ); let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses"); assert_eq!( reg.validate_campaign_refs(&doc, &resolve).expect("io ok"), Vec::new(), "a binding key naming a real strategy role is fault-free", ); let mut bad = doc.clone(); bad.data.bindings.insert("nope".to_string(), "close".to_string()); let faults = reg.validate_campaign_refs(&bad, &resolve).expect("io ok"); assert!( faults.iter().any(|f| matches!( f, RefFault::BindingRoleUnknown { role, roles } if role == "nope" && roles == &vec!["price".to_string()] )), "a key naming no strategy role must fault, listing the actual roles; got {faults:?}", ); } #[test] fn check_r_metric_accepts_r_and_refuses_pip() { assert!(check_r_metric("expectancy_r").is_ok()); assert!(check_r_metric("sqn_normalized").is_ok()); match check_r_metric("total_pips") { Err(RegistryError::NonRMetric(m)) => assert_eq!(m, "total_pips"), other => panic!("expected NonRMetric, got {other:?}"), } match check_r_metric("nope") { Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "nope"), other => panic!("expected UnknownMetric, got {other:?}"), } } /// A minimal campaign-run record for the store tests. `run` is deliberately /// wrong (99): `append_campaign_run` assigns the real counter and must /// override it in the stored line. fn campaign_run_record(campaign: &str) -> CampaignRunRecord { CampaignRunRecord { campaign: campaign.to_string(), process: "proc-id".to_string(), run: 99, seed: 7, cells: vec![], generalizations: vec![], trace_name: None, } } #[test] fn campaign_run_counter_assigns_sequential_runs() { let path = temp_family_dir("campaign_run_counter"); let reg = Registry::open(&path); let a0 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 0"); let a1 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 1"); let b0 = reg.append_campaign_run(&campaign_run_record("bbbb")).expect("bbbb run 0"); assert_eq!((a0, a1, b0), (0, 1, 0), "per-campaign counter, independent per id"); // the stored lines carry the ASSIGNED run, not the input record's 99 let stored = reg.load_campaign_runs().expect("load"); assert_eq!( stored.iter().map(|r| (r.campaign.as_str(), r.run)).collect::>(), vec![("aaaa", 0), ("aaaa", 1), ("bbbb", 0)], ); } #[test] fn load_campaign_runs_missing_file_is_empty() { let path = temp_family_dir("campaign_runs_missing"); let reg = Registry::open(&path); assert_eq!( reg.load_campaign_runs().expect("load missing"), Vec::::new() ); } #[test] fn campaign_run_record_roundtrips() { let path = temp_family_dir("campaign_run_roundtrip"); let reg = Registry::open(&path); let record = CampaignRunRecord { campaign: "cafe".to_string(), process: "beef".to_string(), run: 0, // matches the counter's first assignment, so whole-record PartialEq holds seed: 7, cells: vec![CellRealization { strategy: "3f9c".to_string(), instrument: "EURUSD".to_string(), window_ms: (1_136_073_600_000, 1_154_390_400_000), regime: None, regime_ordinal: 0, fault: None, coverage: None, stages: vec![ StageRealization { block: "std::sweep".to_string(), family_id: Some("cafe-0-EURUSD-w0-s0-0".to_string()), survivor_ordinals: None, selection: Some(StageSelection { winner_ordinal: 4, params: vec![ ("sma_cross.fast.length".to_string(), Scalar::i64(3)), ("sma_cross.slow.length".to_string(), Scalar::i64(9)), ], selection: FamilySelection { selection_metric: "sqn_normalized".to_string(), n_trials: 9, raw_winner_metric: 1.8, mode: SelectionMode::Argmax, deflated_score: Some(0.2), overfit_probability: Some(0.06), n_resamples: Some(1000), block_len: Some(5), seed: Some(7), neighbourhood_score: None, n_neighbours: None, }, }), bootstrap: None, window_faults: vec![], }, StageRealization { block: "std::gate".to_string(), family_id: None, survivor_ordinals: Some(vec![0, 3, 4, 7]), selection: None, bootstrap: None, window_faults: vec![], }, ], }], generalizations: vec![], trace_name: None, }; let run = reg.append_campaign_run(&record).expect("append"); assert_eq!(run, 0); assert_eq!(reg.load_campaign_runs().expect("load"), vec![record]); } #[test] fn campaign_run_record_roundtrips_with_annotator_fields() { use aura_engine::r_bootstrap; let path = temp_family_dir("campaign_run_annotator_roundtrip"); let reg = Registry::open(&path); // r_bootstrap is pure given its seed (C1), and serde_json round-trips // finite f64 exactly, so whole-record PartialEq holds across the store. let per_a = r_bootstrap(&[0.5, -0.2, 0.3, 1.1], 8, 2, 7); let per_b = r_bootstrap(&[-0.4, 0.9], 8, 1, 7); let pooled = r_bootstrap(&[0.2, 0.2, -0.1, 0.6, 0.4], 8, 2, 7); let record = CampaignRunRecord { campaign: "feed".to_string(), process: "beef".to_string(), run: 0, // matches the counter's first assignment, so whole-record PartialEq holds seed: 7, cells: vec![ CellRealization { strategy: "3f9c".to_string(), instrument: "EURUSD".to_string(), window_ms: (0, 1000), regime: None, regime_ordinal: 0, fault: None, coverage: None, stages: vec![StageRealization { block: "std::monte_carlo".to_string(), family_id: None, survivor_ordinals: None, selection: None, bootstrap: Some(StageBootstrap::PerSurvivor(vec![ (0, per_a), (2, per_b), ])), window_faults: vec![], }], }, CellRealization { strategy: "3f9c".to_string(), instrument: "GER40".to_string(), window_ms: (0, 1000), regime: None, regime_ordinal: 0, fault: None, coverage: None, stages: vec![StageRealization { block: "std::monte_carlo".to_string(), family_id: None, survivor_ordinals: None, selection: None, bootstrap: Some(StageBootstrap::PooledOos(pooled)), window_faults: vec![], }], }, ], generalizations: vec![CampaignGeneralization { strategy_ordinal: 0, window_ordinal: 0, regime_ordinal: 0, generalization: Some(Generalization { selection_metric: "net_expectancy_r".to_string(), n_instruments: 2, worst_case: 0.04, sign_agreement: 2, per_instrument: vec![ ("EURUSD".to_string(), 0.04), ("GER40".to_string(), 0.11), ], }), winners: vec![ ( "EURUSD".to_string(), vec![("sma_cross.fast.length".to_string(), Scalar::i64(3))], ), ( "GER40".to_string(), vec![("sma_cross.fast.length".to_string(), Scalar::i64(5))], ), ], missing: vec!["US500".to_string()], }], trace_name: None, }; let run = reg.append_campaign_run(&record).expect("append annotator record"); assert_eq!(run, 0); assert_eq!( reg.load_campaign_runs().expect("load annotator record"), vec![record], "bootstrap (both variants) and generalizations survive the round-trip", ); } /// A pre-0109 stored line — no `bootstrap`, no `generalizations`, no /// `trace_name` — still parses (the C14/C23 serde-default widening /// convention): existing `campaign_runs.jsonl` stores survive the record /// widening unchanged. #[test] fn campaign_run_line_without_new_fields_still_parses() { let path = temp_family_dir("campaign_run_pre_widening"); let store = path.with_file_name("campaign_runs.jsonl"); let line = r#"{"campaign":"cafe","process":"beef","run":0,"seed":7,"cells":[{"strategy":"3f9c","instrument":"EURUSD","window_ms":[0,1000],"stages":[{"block":"std::gate","survivor_ordinals":[0,2]}]}]}"#; fs::write(&store, format!("{line}\n")).expect("write pre-widening line"); let reg = Registry::open(&path); let loaded = reg.load_campaign_runs().expect("pre-widening line parses"); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].cells[0].stages[0].bootstrap, None); assert!(loaded[0].generalizations.is_empty()); assert_eq!( loaded[0].trace_name, None, "pre-0109 line: an absent trace_name parses to None" ); } /// #272: a pre-fault campaign_runs line (no fault/coverage/window_faults /// keys) parses and re-serializes byte-identical — the additive-widening /// guarantee for the new per-cell-fault fields. #[test] fn campaign_run_line_without_fault_fields_round_trips() { let line = r#"{"campaign":"c","process":"p","run":0,"seed":7,"cells":[{"strategy":"s","instrument":"EURUSD","window_ms":[0,10],"stages":[{"block":"std::sweep","family_id":"f-0"}]}]}"#; let rec: CampaignRunRecord = serde_json::from_str(line).expect("parse pre-fault line"); assert_eq!(rec.cells[0].fault, None); assert_eq!(rec.cells[0].coverage, None); assert!(rec.cells[0].stages[0].window_faults.is_empty()); assert_eq!(serde_json::to_string(&rec).expect("re-serialize"), line); } /// #272: a cell that failed serializes its fault, and a fold-fault list /// serializes on the stage — both absent-by-default, present when set. #[test] fn cell_fault_and_window_faults_serialize_when_present() { let fault = CellFault { stage: 0, kind: CellFaultKind::NoData, detail: "no data".into() }; assert_eq!( serde_json::to_string(&fault).expect("fault"), r#"{"stage":0,"kind":"no_data","detail":"no data"}"# ); let wf = WindowFault { window_ordinal: 2, kind: CellFaultKind::Run, detail: "boom".into() }; assert_eq!( serde_json::to_string(&wf).expect("window fault"), r#"{"window_ordinal":2,"kind":"run","detail":"boom"}"# ); } /// #272: a `CellCoverage` with gap months serializes them, and its /// effective-bounds round-trip (parse-then-reserialize) is byte-identical /// — pinning both the populated `gap_months` shape and the /// `skip_serializing_if = "Vec::is_empty"` omission when there are none. #[test] fn cell_coverage_serializes_gap_months_and_round_trips() { let with_gaps = CellCoverage { effective_from_ms: 0, effective_to_ms: 1000, gap_months: vec!["2024-02".into()], }; let json = serde_json::to_string(&with_gaps).expect("coverage with gaps"); assert_eq!( json, r#"{"effective_from_ms":0,"effective_to_ms":1000,"gap_months":["2024-02"]}"# ); let parsed: CellCoverage = serde_json::from_str(&json).expect("parse coverage"); assert_eq!(parsed, with_gaps); let no_gaps = CellCoverage { effective_from_ms: 0, effective_to_ms: 1000, gap_months: vec![] }; assert_eq!( serde_json::to_string(&no_gaps).expect("coverage without gaps"), r#"{"effective_from_ms":0,"effective_to_ms":1000}"# ); } /// Property (#210 T3, C14/C23 widening convention): a stored /// `campaign_runs.jsonl` line whose `generalizations` entries predate the /// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/ /// `winners`/`missing` but no `regime_ordinal` key at all — still parses, /// with `regime_ordinal` defaulting to 0 (`#[serde(default)]` on /// `CampaignGeneralization::regime_ordinal`). Without that default, every /// campaign-run store written before this iteration would fail to load /// the instant it contained a recorded generalization. #[test] fn campaign_run_generalization_without_regime_ordinal_still_parses() { let path = temp_family_dir("campaign_run_pre_regime_ordinal"); let store = path.with_file_name("campaign_runs.jsonl"); let line = r#"{"campaign":"cafe","process":"beef","run":0,"seed":7,"cells":[{"strategy":"3f9c","instrument":"EURUSD","window_ms":[0,1000],"stages":[{"block":"std::gate","survivor_ordinals":[0,2]}]}],"generalizations":[{"strategy_ordinal":0,"window_ordinal":0,"winners":[["EURUSD",[["fast.length",{"I64":3}]]]],"missing":["GER40"]}]}"#; fs::write(&store, format!("{line}\n")).expect("write pre-regime-ordinal line"); let reg = Registry::open(&path); let loaded = reg.load_campaign_runs().expect("pre-regime-ordinal generalization parses"); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].generalizations.len(), 1); assert_eq!( loaded[0].generalizations[0].regime_ordinal, 0, "a missing regime_ordinal key defaults to 0, not a parse failure" ); assert_eq!(loaded[0].generalizations[0].strategy_ordinal, 0); assert_eq!(loaded[0].generalizations[0].missing, vec!["GER40".to_string()]); } /// The 0109 name composition (#201 d5 / the spec's seam note): a `Some` /// trace_name on the input record — the executor's claim sentinel, /// content ignored — is replaced on the STORED line with the derived /// `"{campaign8}-{run}"` (prefix from the record's own `campaign`, run /// from the store's per-campaign counter); `None` stays `None`. The /// executor guarantees a 64-hex campaign id, but the registry seam must /// not panic on a shorter one — the safe-prefix case is pinned here. #[test] fn append_campaign_run_composes_trace_name_from_a_claim() { let path = temp_family_dir("campaign_run_trace_name"); let reg = Registry::open(&path); let long = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999"; // claim sentinel (empty string) -> derived name, per-campaign counter let mut claimed = campaign_run_record(long); claimed.trace_name = Some(String::new()); assert_eq!(reg.append_campaign_run(&claimed).expect("append claim 0"), 0); assert_eq!(reg.append_campaign_run(&claimed).expect("append claim 1"), 1); // no claim -> None stays None assert_eq!(reg.append_campaign_run(&campaign_run_record(long)).expect("append plain"), 2); // shorter-than-8-chars campaign id: composes from the whole id, no panic let mut short = campaign_run_record("abc"); short.trace_name = Some("sentinel content is ignored".to_string()); assert_eq!(reg.append_campaign_run(&short).expect("append short-id claim"), 0); let stored = reg.load_campaign_runs().expect("load"); assert_eq!( stored.iter().map(|r| r.trace_name.as_deref()).collect::>(), vec![Some("aaaabbbb-0"), Some("aaaabbbb-1"), None, Some("abc-0")], "a claim composes {{campaign8}}-{{run}} on the stored line; None stays None", ); } }