Files
Aura/crates/aura-registry/src/lib.rs
T
claude d3b1a1aead feat(campaign,registry,cli): per-cell fault isolation — a failed cell is recorded, never a global abort
closes #272

A member fault (no-data, bind, run, or a caught panic) is now a recorded
per-cell outcome instead of aborting the whole campaign and discarding every
already-computed cell. The incident that motivated this (a 22-instrument
campaign lost ~36 healthy cells ~6.7 min in because Copper had an archive gap)
now completes: the healthy cells persist, the gap cell is recorded as failed,
and the run exits 3.

Direction (owner decision 2026-07-14): run to completion and report
compromised results; no coverage preflight, no window synthesis.

Containment granularity:
- The CELL for a sweep-stage member fault (a grid hole structurally
  compromises winner selection, so the whole cell fails).
- The FOLD for a walk_forward member fault (independent time windows): the
  surviving folds pool into the family, failed folds are recorded as
  StageRealization.window_faults, and the summary names the ratio.

- aura-registry: additive CellFault / CellFaultKind (closed:
  no_data|bind|run|panic|window) / WindowFault / CellCoverage, plus
  fault/coverage fields on CellRealization and window_faults on
  StageRealization — all serde-default-skipped, so pre-#272 campaign_runs
  lines parse and round-trip byte-identical.
- aura-campaign: run_cell returns a fault-annotated CellRealization instead of
  Err (execute's accumulate-then-append-once tail is unchanged and now
  persists every healthy cell + the one run record); a `contain` split keeps
  ExecFault::Registry and doc-shape preflight faults global while Member/Window
  become per-cell/per-fold. Member panics are caught with
  catch_unwind(AssertUnwindSafe) at all three member-run sites (sweep IS/OOS)
  and recorded as MemberFault::Panic — a member panic no longer aborts the
  process. The wf stage partitions Registry faults (global) from Member/Window
  (per-fold) and filters faulted-fold placeholders (the
  "faulted-member-placeholder" broker sentinel) out of the persisted family.
- aura-cli: exec_fault_prose gains the Panic arm; CliMemberRunner::window_coverage
  derives effective bounds + interior gap months from the #264 archive
  primitives; present_campaign prints per-cell failure notes + a completion
  summary and threads the failed-cell count; a run with >=1 failed cell exits 3
  ("completed with failed cells") uniformly across `aura campaign run` and the
  dissolved sweep/walkforward/mc/generalize verbs (exit_on_campaign_result).
  Usage stays 2, refused-before-running stays 1, clean stays 0.

Tests: the global-abort pins flip to containment (execute + the two wf fault
tests → fold-containment + all-folds-fail-the-cell); new panic-containment
tests on both the sweep path (PanicRunner) and the wf path (this commit adds
the wf mirror the loop left uncovered); a new gapped-archive e2e (one covered
cell + one gap cell → exit 3); the ~14 CLI exit-1 pins move to the exit-3
register; a pre-#272-line byte-identical round-trip guard.

Suite: cargo test --workspace green (1309 tests, 0 failed); clippy clean.
Decision log: #272 comments (fork rationale, the fold Registry/Member split,
the placeholder sentinel, uniform exit-3).

Follow-up (minor, not blocking): the plan under-scoped Task 1 to aura-registry
though the additive fields also touch aura-campaign's exec.rs literals — the
loop absorbed it mechanically; a future plan for a cross-crate additive-field
change should scope every crate's construction sites in the first task.
2026-07-14 16:51:31 +02:00

2244 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Path>) -> Registry {
Registry { path: path.as_ref().to_path_buf() }
}
/// Append one record as a single JSON line, creating the file (and its parent
/// directory) if absent.
pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
// a RunReport is finite by construction (its f64 fields are finite), so
// serialization is infallible here.
let line = serde_json::to_string(report).expect("a finite RunReport serializes");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Parse every non-empty line back into a typed `RunReport`, in file order. A
/// missing file is an empty registry (`Ok(vec![])`), not an error.
pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> {
let text = match fs::read_to_string(&self.path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RegistryError::Io(e)),
};
let mut reports = Vec::new();
for (i, raw) in text.lines().enumerate() {
if raw.trim().is_empty() {
continue;
}
// Read via the back-compat mirror: tolerant of both the current
// tagged-`Scalar` param shape (`{"F64":2.0}`) and the legacy pre-0047
// bare-float shape (`2.0`). The forward write path (`append`,
// `RunReport::to_json`) is unchanged — it still emits the tagged form.
let report: RunReport = serde_json::from_str::<compat::RunReportRead>(raw)
.map_err(|source| RegistryError::Parse { line: i + 1, source })?
.into();
reports.push(report);
}
Ok(reports)
}
/// The content-addressed blueprint store dir — a sibling of the runs store,
/// `<runs.jsonl>.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/<hash>.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<Option<String>, 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 `<kind>/<id>.json`. Idempotent.
fn put_doc(&self, kind: &str, canonical_json: &str) -> Result<String, RegistryError> {
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<Option<String>, 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<String, RegistryError> {
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<Option<String>, 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<String, RegistryError> {
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<Option<String>, 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<String> },
}
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<PrimitiveBuilder>,
) -> Result<Vec<RefFault>, RegistryError> {
let mut faults = Vec::new();
let mut known_roles: std::collections::BTreeSet<String> = 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<PrimitiveBuilder>,
) -> Result<Option<String>, 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<Metric, RegistryError> {
Ok(match name {
"total_pips" => Metric::TotalPips,
"max_drawdown" => Metric::MaxDrawdown,
// accept the new name AND the pre-rename one (CLI back-compat).
"bias_sign_flips" | "exposure_sign_flips" => Metric::BiasSignFlips,
"sqn" => Metric::Sqn,
"sqn_normalized" => Metric::SqnNormalized,
"expectancy_r" => Metric::ExpectancyR,
"net_expectancy_r" => Metric::NetExpectancyR,
other => return Err(RegistryError::UnknownMetric(other.to_string())),
})
}
/// The metric's scalar value for one report. R metrics live in the optional `r`
/// block; a missing block reads `NEG_INFINITY` (the worst rank for the
/// higher-is-better R keys), preserving `metric_cmp`'s documented contract.
fn metric_value(rep: &RunReport, m: Metric) -> f64 {
fn r_get(rep: &RunReport, f: impl Fn(&aura_engine::RMetrics) -> f64) -> f64 {
rep.metrics.r.as_ref().map(f).unwrap_or(f64::NEG_INFINITY)
}
match m {
Metric::TotalPips => rep.metrics.total_pips,
Metric::MaxDrawdown => rep.metrics.max_drawdown,
Metric::BiasSignFlips => rep.metrics.bias_sign_flips as f64,
Metric::Sqn => r_get(rep, |r| r.sqn),
Metric::SqnNormalized => r_get(rep, |r| r.sqn_normalized),
Metric::ExpectancyR => r_get(rep, |r| r.expectancy_r),
Metric::NetExpectancyR => r_get(rep, |r| r.net_expectancy_r),
}
}
/// The per-metric **best-first ordering** of two reports: `Less` means `a` is
/// better than `b`. "Best" is fixed by each metric's meaning: `total_pips` and
/// the four R keys (`sqn`, `sqn_normalized`, `expectancy_r`, `net_expectancy_r`)
/// higher-is-better; `max_drawdown` and `bias_sign_flips` lower-is-better. Every
/// metric compares via `total_cmp` (a total order over `f64`): top-level keys are
/// finite by construction, while the R keys live in an optional `metrics.r` block
/// where a member with `r: None` reads `f64::NEG_INFINITY` (the worst rank for
/// higher-is-better) — `total_cmp` orders that, and any stray `NaN`,
/// deterministically without panicking. An unknown metric name is a
/// `RegistryError::UnknownMetric`.
///
/// The single source of best-first *ordering* — both [`rank_by`] (sort by it) and
/// [`optimize`] (argmax by it) call this. The per-metric direction itself lives in
/// `higher_is_better` (which this and `optimize_plateau` both read); `metric_cmp`
/// turns that direction into a `total_cmp` over the read values. The metric name is
/// resolved once via `resolve_metric`; the returned closure carries the resolved
/// `Metric`, so per-comparison work is just reading each value (`metric_value`) and
/// the key compare.
fn metric_cmp(metric: &str) -> Result<impl Fn(&RunReport, &RunReport) -> Ordering, RegistryError> {
let m = resolve_metric(metric)?;
let hib = higher_is_better(m);
Ok(move |a: &RunReport, b: &RunReport| {
let (va, vb) = (metric_value(a, m), metric_value(b, m));
// higher-is-better ranks the larger value first; lower-is-better the smaller.
if hib { vb.total_cmp(&va) } else { va.total_cmp(&vb) }
})
}
/// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed
/// by each metric's meaning (see `metric_cmp`). A stable sort keeps file
/// (insertion) order among ties. An unknown metric name is a
/// `RegistryError::UnknownMetric`.
pub fn rank_by(mut reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> {
let cmp = metric_cmp(metric)?;
reports.sort_by(|a, b| cmp(a, b));
Ok(reports)
}
/// `rank_by`'s argmax: return the single best `SweepPoint` of a `SweepFamily`
/// under a named metric, by the same fixed per-metric sense of best (see
/// `metric_cmp`). The whole point is carried — its `params` coordinate AND its
/// `RunReport` — because the caller needs the params that won. Ties on the metric
/// resolve to the **earliest** enumeration-order point (the family is already in
/// odometer order; only a strictly-better later point displaces the incumbent).
/// An unknown metric is a `RegistryError::UnknownMetric`. A `SweepFamily` is
/// non-empty by construction (a sweep over a zero-point grid is rejected upstream
/// by `EmptyAxis`), so `reduce` always yields a winner.
pub fn optimize(family: &SweepFamily, metric: &str) -> Result<SweepPoint, RegistryError> {
let cmp = metric_cmp(metric)?;
let winner = family
.points
.iter()
.reduce(|best, p| if cmp(&p.report, &best.report) == Ordering::Less { p } else { best })
.expect("a SweepFamily is non-empty by construction (EmptyAxis is rejected upstream)");
Ok(winner.clone())
}
fn is_r_metric(m: Metric) -> bool {
matches!(m, Metric::Sqn | Metric::SqnNormalized | Metric::ExpectancyR | Metric::NetExpectancyR)
}
/// The metric's optimisation direction: `true` if a larger value is better
/// (`total_pips`, the four R keys), `false` for the lower-is-better keys
/// (`max_drawdown`, `bias_sign_flips`). The single direction source — `metric_cmp`
/// (best-first ordering) and `optimize_plateau` (smoothed argmax + worst-neighbour)
/// both read it, so the per-metric sense lives in exactly one place.
fn higher_is_better(m: Metric) -> bool {
!matches!(m, Metric::MaxDrawdown | Metric::BiasSignFlips)
}
/// The closed grid neighbourhood of flat index `i` over a mixed-radix lattice
/// (`axis_lens`, last axis fastest — the `GridSpace` odometer convention): `{i}`
/// plus each in-range ±1-per-axis cell. Pure index math, deterministic. The order
/// is `i` first then per-axis neighbours; callers use the result only as a set and
/// for its length, so the order is not load-bearing.
fn closed_neighbourhood(i: usize, axis_lens: &[usize]) -> Vec<usize> {
// decompose i to per-axis coords (last axis fastest)
let mut coords = vec![0usize; axis_lens.len()];
let mut rem = i;
for k in (0..axis_lens.len()).rev() {
coords[k] = rem % axis_lens[k];
rem /= axis_lens[k];
}
// recompose a coord vector to its flat index (Horner, last axis fastest)
let recompose = |c: &[usize]| c.iter().zip(axis_lens).fold(0usize, |flat, (&ck, &len)| flat * len + ck);
let mut out = vec![i];
for k in 0..axis_lens.len() {
for delta in [-1isize, 1] {
let ck = coords[k] as isize + delta;
if ck < 0 || ck as usize >= axis_lens[k] { continue; }
let mut nb = coords.clone();
nb[k] = ck as usize;
out.push(recompose(&nb));
}
}
out
}
fn member_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<f64> {
let centred: Vec<Vec<f64>> = 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::<f64>() / rs.len() as f64;
rs.iter().map(|x| x - mean).collect()
}).collect();
(0..n_resamples).map(|i| {
let mut rng = SplitMix64::new(seed ^ i as u64);
centred.iter().fold(f64::NEG_INFINITY, |best, rs| {
if rs.is_empty() { return best; }
let bl = block_len.clamp(1, rs.len());
let v = member_metric_from_rs(&resample_block(rs, bl, &mut rng), m);
best.max(v)
})
}).collect()
}
/// Sample standard deviation of the K members' metric values (the `total_pips`
/// dispersion-floor arm). `< 2` members -> `0.0`.
fn member_sd(family: &SweepFamily, m: Metric) -> f64 {
let vals: Vec<f64> = family.points.iter().map(|p| metric_value(&p.report, m)).collect();
let n = vals.len();
if n < 2 { return 0.0; }
let mean = vals.iter().sum::<f64>() / n as f64;
(vals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1) as f64).sqrt()
}
/// Plateau-selection aggregate: how a member's grid-neighbourhood metric is
/// reduced to one smoothed score before the argmax. `Mean` averages the closed
/// neighbourhood; `Worst` takes the most-pessimistic neighbour by the metric's
/// direction (biasing toward interior cells, which keep more neighbours).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PlateauMode {
Mean,
Worst,
}
/// `optimize`'s argmax over the NEIGHBOURHOOD-SMOOTHED surface, plus its plateau
/// provenance. Each member scores as the mean (or worst-case) of its closed grid
/// neighbourhood's `metric_value`; the winner is the best smoothed score by the
/// metric's own direction (earliest-odometer tie, as `optimize`). `axis_lens` are
/// the grid radixes (`param_space()` order, last axis fastest). Pure,
/// deterministic (C1). The returned `SweepPoint` is the winning grid CELL;
/// `raw_winner_metric` is that cell's own metric, `neighbourhood_score` the
/// smoothed value the argmax maximised.
pub fn optimize_plateau(
family: &SweepFamily, axis_lens: &[usize], metric: &str, mode: PlateauMode,
) -> Result<(SweepPoint, FamilySelection), RegistryError> {
let m = resolve_metric(metric)?;
let n = family.points.len();
debug_assert_eq!(
axis_lens.iter().product::<usize>(), n,
"axis_lens product must equal the family size (a valid grid lattice)",
);
let hib = higher_is_better(m);
// smoothed score + closed-neighbourhood size per member, in odometer order
let scored: Vec<(usize, f64, usize)> = (0..n)
.map(|i| {
let nbrs = closed_neighbourhood(i, axis_lens);
let vals: Vec<f64> = nbrs.iter().map(|&j| metric_value(&family.points[j].report, m)).collect();
let score = match mode {
PlateauMode::Mean => vals.iter().sum::<f64>() / vals.len() as f64,
PlateauMode::Worst => {
if hib {
vals.iter().copied().fold(f64::INFINITY, f64::min)
} else {
vals.iter().copied().fold(f64::NEG_INFINITY, f64::max)
}
}
};
(i, score, nbrs.len())
})
.collect();
// argmax the smoothed surface by direction; earliest-odometer tie (only a
// strictly-better later cell displaces the incumbent, as `optimize`).
let &(wi, wscore, wn) = scored
.iter()
.reduce(|best, cur| {
let better = if hib { cur.1 > best.1 } else { cur.1 < best.1 };
if better { cur } else { best }
})
.expect("a SweepFamily is non-empty by construction (EmptyAxis is rejected upstream)");
let winner = family.points[wi].clone();
let raw = metric_value(&winner.report, m);
Ok((
winner,
FamilySelection {
selection_metric: metric.to_string(),
n_trials: n,
raw_winner_metric: raw,
mode: match mode {
PlateauMode::Mean => SelectionMode::PlateauMean,
PlateauMode::Worst => SelectionMode::PlateauWorst,
},
deflated_score: None,
overfit_probability: None,
n_resamples: None,
block_len: None,
seed: None,
neighbourhood_score: Some(wscore),
n_neighbours: Some(wn),
},
))
}
/// 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<f64> = null_max.into_iter().filter(|x| x.is_finite()).collect();
if usable.is_empty() {
// No reality-check is computable: floor to a degenerate-but-defined
// result — no deflation credit (`deflated_score == raw`) and a
// maximally-uncertain overfit probability (1.0), never a nonsensical inf.
(raw, Some(1.0))
} else {
let p95 = MetricStats::from_values(&usable).p95;
let over = (usable.iter().filter(|&&x| x >= raw).count() + 1) as f64
/ (usable.len() + 1) as f64;
(raw - p95, Some(over))
}
} else {
// The dispersion floor SUBTRACTS the expected-max inflation, so it is valid
// only for a higher-is-better metric (a larger value is the better one the
// search inflated). A lower-is-better metric would deflate wrong-signed; the
// two shipped call sites only pass `total_pips`, so this is unreached, but
// `optimize_deflated` is public — guard the assumption rather than leave it
// implicit.
debug_assert!(
!matches!(m, Metric::MaxDrawdown | Metric::BiasSignFlips),
"dispersion-floor arm is for higher-is-better metrics only",
);
(raw - member_sd(family, m) * expected_max_of_normals(k), None)
};
Ok((winner.clone(), FamilySelection {
selection_metric: metric.to_string(), n_trials: k, raw_winner_metric: raw,
mode: SelectionMode::Argmax,
deflated_score: Some(deflated_score), overfit_probability,
n_resamples: Some(n_resamples), block_len: Some(block_len), seed: Some(seed),
neighbourhood_score: None, n_neighbours: None,
}))
}
/// 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<Generalization, RegistryError> {
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<std::io::Error> for RegistryError {
fn from(e: std::io::Error) -> Self {
RegistryError::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::RMetrics;
use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), Scalar::f64(1.0))],
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<f64> = ranked.iter().map(|r| r.metrics.r.as_ref().unwrap().sqn).collect();
assert_eq!(sqns, vec![2.0, 1.0, 0.5], "sqn must rank highest-first");
}
#[test]
fn rank_by_sqn_normalized_orders_members_descending() {
// report_with_r sets sqn_normalized == sqn, so ranking by the new key
// orders these members highest-first by their sqn value.
let reports = vec![
report_with_r(0.5, 0.1, 0.1),
report_with_r(2.0, 0.4, 0.4),
report_with_r(1.0, 0.2, 0.2),
];
let ranked = rank_by(reports, "sqn_normalized").expect("rank sqn_normalized");
let vals: Vec<f64> = ranked
.iter()
.map(|r| r.metrics.r.as_ref().unwrap().sqn_normalized)
.collect();
assert_eq!(vals, vec![2.0, 1.0, 0.5], "sqn_normalized must rank highest-first");
}
#[test]
fn rank_by_expectancy_and_net_expectancy_r() {
let reports = vec![report_with_r(1.0, 0.1, 0.05), report_with_r(1.0, 0.3, 0.25)];
let by_exp = rank_by(reports.clone(), "expectancy_r").expect("rank expectancy_r");
assert_eq!(by_exp[0].metrics.r.as_ref().unwrap().expectancy_r, 0.3);
let by_net = rank_by(reports, "net_expectancy_r").expect("rank net_expectancy_r");
assert_eq!(by_net[0].metrics.r.as_ref().unwrap().net_expectancy_r, 0.25);
}
#[test]
fn rank_r_metric_sorts_none_member_last() {
// a pip-only member (r: None) ranks below every member with an R block.
let reports = vec![report_with(1.0, 0.5, 1), report_with_r(0.5, 0.1, 0.1)];
let ranked = rank_by(reports, "sqn").expect("rank sqn with a None member");
assert!(ranked[0].metrics.r.is_some(), "the R member ranks first");
assert!(ranked[1].metrics.r.is_none(), "the None member sorts last");
}
#[test]
fn unknown_metric_message_lists_r_metrics() {
let msg = RegistryError::UnknownMetric("nope".to_string()).to_string();
assert!(msg.contains("sqn"), "msg: {msg}");
assert!(msg.contains("sqn_normalized"), "msg: {msg}");
assert!(msg.contains("expectancy_r"), "msg: {msg}");
assert!(msg.contains("net_expectancy_r"), "msg: {msg}");
}
fn temp_path(name: &str) -> PathBuf {
// 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::<RunReport>::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::<crate::compat::RunReportRead>(line).unwrap().into();
let sel = rep.manifest.selection.expect("selection lifted");
assert_eq!(sel.n_trials, 4);
assert_eq!(sel.mode, aura_engine::SelectionMode::Argmax);
}
#[test]
fn rank_by_orders_best_first_per_metric() {
let reports = vec![
report_with(1.0, 0.9, 5),
report_with(3.0, 0.1, 1),
report_with(2.0, 0.5, 3),
];
let by_pips = rank_by(reports.clone(), "total_pips").expect("rank pips");
assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc
let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd");
assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc
// the legacy rank-string still resolves (the dual-accept alias).
let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips");
assert_eq!(by_flips[0].metrics.bias_sign_flips, 1); // lower-is-better -> asc
// the new rank-string ranks identically.
let by_flips_new = rank_by(reports.clone(), "bias_sign_flips").expect("rank flips (new name)");
assert_eq!(by_flips_new[0].metrics.bias_sign_flips, 1);
}
#[test]
fn rank_by_unknown_metric_is_an_error() {
let reports = vec![report_with(1.0, 0.5, 1)];
match rank_by(reports, "sharpe") {
Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "sharpe"),
other => panic!("expected UnknownMetric, got {other:?}"),
}
}
/// `optimize` is `rank_by`'s argmax: over a `SweepFamily` it returns the
/// single winning `SweepPoint` — its `params` coordinate AND its `RunReport`
/// — chosen by the metric's *fixed* sense of best (`total_pips`
/// higher-is-better), and a tie on that metric resolves to the **earliest
/// enumeration-order** point, never a later one. The point carried, not just
/// its metric, is the property: the caller needs the params that won.
#[test]
fn optimize_picks_the_max_metric_point_ties_to_earliest() {
// A hand-built family in odometer order. Two points tie at the maximal
// total_pips (3.0); the EARLIER of the two (index 1, params p=10) must
// win, not the later (index 3, params p=30) — that pins the tie rule.
let point = |p: f64, pips: f64| SweepPoint {
params: vec![Cell::from_f64(p)],
report: report_with(pips, 0.5, 0),
};
let family = SweepFamily {
space: vec![],
points: vec![
point(0.0, 1.0), // 0: also-ran
point(10.0, 3.0), // 1: tied max, earliest -> the winner
point(20.0, 2.0), // 2: also-ran
point(30.0, 3.0), // 3: tied max, but later -> must lose the tie
],
};
let winner = super::optimize(&family, "total_pips").expect("optimize total_pips");
// the maximal-metric point wins...
assert_eq!(winner.report.metrics.total_pips, 3.0);
// ...and ties resolve to the earliest enumeration-order point: its params
// identify point 1, not point 3.
assert_eq!(winner.params, vec![Cell::from_f64(10.0)]);
// the whole winning point is returned, params AND report together.
assert_eq!(winner, family.points[1]);
}
fn temp_family_dir(name: &str) -> PathBuf {
// a unique per-test directory so the families.jsonl sibling never collides
// across tests sharing the temp dir; the registry binds to runs.jsonl inside.
// 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<_>>(), vec![0, 1, 2]);
}
#[test]
fn per_name_counter_increments_independently() {
let path = temp_family_dir("counter");
let reg = Registry::open(&path);
let reports = vec![report_with(1.0, 0.5, 1)];
let x0 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-0");
let x1 = reg.append_family("x", FamilyKind::Sweep, &reports).expect("x-1");
let y0 = reg.append_family("y", FamilyKind::Sweep, &reports).expect("y-0");
assert_eq!((x0.as_str(), x1.as_str(), y0.as_str()), ("x-0", "x-1", "y-0"));
let families = group_families(reg.load_family_members().expect("load"));
assert_eq!(families.len(), 3);
}
#[test]
fn distinct_families_regroup_in_first_seen_order() {
let path = temp_family_dir("regroup");
let reg = Registry::open(&path);
let a = vec![report_with(1.0, 0.5, 1), report_with(2.0, 0.5, 1)];
let b = vec![report_with(3.0, 0.5, 1)];
let ida = reg.append_family("a", FamilyKind::Sweep, &a).expect("a");
let idb = reg.append_family("b", FamilyKind::WalkForward, &b).expect("b");
let families = group_families(reg.load_family_members().expect("load"));
// first-seen file order: a before b
assert_eq!(families.iter().map(|f| f.id.clone()).collect::<Vec<_>>(), vec![ida, idb]);
assert_eq!(families[0].members.len(), 2);
assert_eq!(families[1].kind, FamilyKind::WalkForward);
// members ordinal-sorted within each family
assert_eq!(families[0].members.iter().map(|m| m.ordinal).collect::<Vec<_>>(), vec![0, 1]);
}
#[test]
fn family_store_and_flat_store_are_disjoint() {
let path = temp_family_dir("disjoint");
let reg = Registry::open(&path);
// a family write leaves the flat runs store empty...
reg.append_family("f", FamilyKind::Sweep, &[report_with(1.0, 0.5, 1)]).expect("family");
assert_eq!(reg.load().expect("flat load"), Vec::<RunReport>::new());
// ...and a flat write leaves the family store untouched at one family.
reg.append(&report_with(9.0, 0.5, 1)).expect("flat append");
assert_eq!(reg.load().expect("flat load 2").len(), 1);
assert_eq!(group_families(reg.load_family_members().expect("members")).len(), 1);
}
#[test]
fn rank_a_family_as_a_unit() {
let path = temp_family_dir("rankfam");
let reg = Registry::open(&path);
let reports =
vec![report_with(1.0, 0.5, 1), report_with(3.0, 0.5, 1), report_with(2.0, 0.5, 1)];
reg.append_family("f", FamilyKind::Sweep, &reports).expect("family");
let fam = group_families(reg.load_family_members().expect("members"))
.pop()
.expect("one family");
let member_reports: Vec<RunReport> = fam.members.iter().map(|m| m.report.clone()).collect();
let ranked = rank_by(member_reports, "total_pips").expect("rank");
assert_eq!(ranked[0].metrics.total_pips, 3.0); // best-first within the family
}
fn member(total_pips: f64, net_trade_rs: Vec<f64>) -> SweepPoint {
// Every RMetrics field is derived from the R slice by `r_metrics_from_rs`
// (the same arithmetic production members carry), then the per-trade
// `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<_>>(),
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::<CampaignRunRecord>::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<_>>(),
vec![Some("aaaabbbb-0"), Some("aaaabbbb-1"), None, Some("abc-0")],
"a claim composes {{campaign8}}-{{run}} on the stored line; None stays None",
);
}
}