Files
Aura/crates/aura-registry/src/lib.rs
T
claude a8b1ba45c5 feat(aura-engine, aura-vocabulary, aura-cli): the args channel end to end
The data plane reaches non-scalar structural config (closes #271, the
final Data-authorability-boundary item). Op::Add gains args
(string pairs, applied via try_args after resolve and before the bind
loop — no pending builder ever enters a graph; an arg-bearing type
without args refuses as MissingArg). OpError::BadArg carries the
precise ArgOpError; the CLI renders it as prose naming the arg, its
kind, and the kind's closed hint (exit 1, #175 content-fault class).

Serde: PrimitiveData.args (skip-if-empty — args-free documents stay
byte-identical, golden-pinned, so every existing content id is stable
per C18) with a data-driven format version: the writer emits 1 for
args-free documents and 2 when any primitive recursively carries args;
the loader accepts 1..=2. The old v2-refusal pin flips deliberately to
v3 (named contract change). Identity JSON keeps args — they are
structural, and the Rust configured() path and the args op-script
bridge to the same identity id (pinned).

Roster: Session / LinComb / CostSum enter (33 -> 36, both count pins;
the LinComb-rejection assertion flips to the resolved side). introspect
--node shows arg rows (name, kind, hint) plus the pending note;
OP_REFERENCE's add row teaches the args form. Registry comment
de-staled (LinComb is now roster-reachable).

Sequencing note: this commit lands op seam and roster together so no
intermediate tree state exposes a pending builder.
2026-07-24 21:43:02 +02:00

3117 lines
150 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_analysis::{one_sided_p_laplace, MetricStats, MetricVocabulary, SplitMix64};
use aura_backtest::{RunMetrics, RunReport};
use aura_engine::{
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, BlueprintDoc,
CompositeData, FamilySelection, NodeData, SelectionMode,
};
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, TapTraceWriter, TapWriterOpener, TraceStore,
TraceStoreError, TraceStreamer, WriteKind,
};
/// An append-only run registry over a JSONL file: one serde_json line per
/// `RunReport`.
pub struct Registry {
path: PathBuf,
/// Serializes the family/campaign-run append write path (#276): one
/// `&Registry` shared across threads must never interleave two appends'
/// content and newline writes, which unsynchronized `OpenOptions::append`
/// plus `writeln!` permits at the OS level. Held across each append's
/// read-the-counter-then-write critical section (`append_family`,
/// `append_campaign_run` in `lineage.rs`), so concurrent writers through
/// one handle also see a consistent per-name run counter. Store-agnostic:
/// it guards all four JSONL append paths — the flat runs store
/// ([`Registry::append`]), `families.jsonl`, `campaign_runs.jsonl`, and the
/// blueprint identity index (`blueprint_identity_index.jsonl`,
/// `append_identity_index`)
/// (they never contend on the same file, but share this one lock).
/// In-process only; cross-process locking stays out of contract.
append_write_lock: std::sync::Mutex<()>,
}
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_write_lock: std::sync::Mutex::new(()) }
}
/// Append one record as a single JSON line, creating the file (and its parent
/// directory) if absent. The write is serialized by `Registry`'s internal
/// `append_write_lock` (#276) — safe for concurrent writers sharing one
/// `&Registry`.
pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
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.
/// Raw write — C29-gated callers go through [`Registry::put_blueprint`]; this
/// stays for store-mechanics tests and pre-C29 store content.
fn put_blueprint_unchecked(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
fs::create_dir_all(self.blueprints_dir())?;
fs::write(self.blueprint_path(hash), canonical_json)?;
Ok(())
}
/// Write-once content-addressed put, C29-gated (#316): only a canonical
/// blueprint whose root composite and every named nested composite carry
/// a gate-passing doc enters the store. The gate guards the store
/// boundary itself — verb, sugar, and any future caller alike — and the
/// write path only: already-registered doc-less entries stay readable.
pub fn put_blueprint(&self, hash: &str, canonical_json: &str) -> Result<(), RegistryError> {
let doc: BlueprintDoc = serde_json::from_str(canonical_json)
.map_err(RegistryError::MalformedBlueprint)?;
gate_composite_docs(&doc.blueprint)?;
self.put_blueprint_unchecked(hash, canonical_json)
}
/// 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 label sidecar path (#317) — a sibling of `blueprint_identity_index.jsonl`,
/// the same "fixed-name sidecar, per-directory isolation" discipline (#191).
fn blueprint_names_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_names.jsonl")
}
/// Raw, ungated append: one label line, unconditionally, under the
/// registry's write mutex (#276). No shape or content-id check —
/// [`Registry::put_blueprint_label`] is the gated public entry; this
/// exists so tests can hand-append a stale line, mirroring
/// `append_identity_index`.
fn append_blueprint_label_line(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.blueprint_names_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&BlueprintNameLine {
name: name.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Label a stored blueprint (#317): appends `{"name":..,"content_id":..}`
/// to `blueprint_names.jsonl`, a sidecar beside the identity index. Gated
/// on a deterministic label shape (nonempty after trim, no whitespace, no
/// control characters — `RegistryError::BadLabel`, C29 discipline: shape
/// only, never content judgement) and on `content_id` resolving via
/// [`Registry::get_blueprint`] (`RegistryError::UnknownBlueprint`
/// otherwise) — both checked BEFORE the write lock is taken. Re-labelling
/// an existing name appends a new line (a repoint); resolution is
/// latest-wins (see [`Registry::resolve_blueprint_label`]).
pub fn put_blueprint_label(&self, name: &str, content_id: &str) -> Result<(), RegistryError> {
gate_label_shape(name)?;
if self.get_blueprint(content_id)?.is_none() {
return Err(RegistryError::UnknownBlueprint(content_id.to_string()));
}
self.append_blueprint_label_line(name, content_id)
}
/// Resolve a label to the content id of the LATEST line naming it, skipping
/// any line whose content id no longer resolves via `get_blueprint`
/// (self-repair spirit, #191) — so "latest" always means the latest VALID
/// entry, even when a later append points at a since-vanished id. Missing
/// sidecar file -> `None` (treat-as-empty, like `load_identity_index`).
pub fn resolve_blueprint_label(&self, name: &str) -> Option<String> {
self.latest_blueprint_labels().remove(name)
}
/// Every registered label, latest-wins-deduped and name-sorted — the
/// discovery surface's data source (`graph introspect --registered`).
/// Dangling entries (content id no longer resolves) are skipped.
pub fn blueprint_labels(&self) -> Vec<(String, String)> {
self.latest_blueprint_labels().into_iter().collect()
}
/// Every content id currently stored in the blueprint store — the
/// candidate list a `use` ref's content-id-PREFIX resolution (#302
/// semantics, #317) filters against, mirroring `list_process_ids`/
/// `list_campaign_ids`. `blueprints_dir()` and `doc_dir("blueprints")`
/// name the same directory, so `list_doc_ids`'s filename-stem walk
/// applies unchanged.
pub fn list_blueprint_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("blueprints")
}
/// Shared latest-wins-and-dangling-skipping read of `blueprint_names.jsonl`
/// into a name -> content_id map (`BTreeMap` gives the name-sorted order
/// `blueprint_labels` promises for free). A garbage line (torn write,
/// manual edit) is skipped, like every other JSONL sidecar read in this
/// module.
fn latest_blueprint_labels(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.blueprint_names_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
let Ok(line) = serde_json::from_str::<BlueprintNameLine>(raw) else { continue };
if self.get_blueprint(&line.content_id).ok().flatten().is_some() {
map.insert(line.name, line.content_id);
}
}
map
}
/// 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)),
}
}
/// Every content id currently stored for one document kind — the
/// filename stems under `doc_dir(kind)` (`doc_path`'s write-one/read-one
/// mapping, read back), for prefix resolution (#302). A missing store
/// dir (nothing registered yet) is treat-as-empty, not an error, the
/// same discipline `find_blueprint_by_identity`'s scan applies to a
/// missing blueprints dir.
fn list_doc_ids(&self, kind: &str) -> Result<Vec<String>, RegistryError> {
let entries = match fs::read_dir(self.doc_dir(kind)) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(RegistryError::Io(e)),
};
let mut ids = Vec::new();
for entry in entries {
let entry = entry?;
if let Some(id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) {
ids.push(id);
}
}
Ok(ids)
}
/// 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)
}
/// Every content id currently registered in the process store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_process_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("processes")
}
/// 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)
}
/// Every content id currently registered in the campaign store — the
/// candidate list `show`'s prefix resolution (#302) filters against.
pub fn list_campaign_ids(&self) -> Result<Vec<String>, RegistryError> {
self.list_doc_ids("campaigns")
}
/// 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)
}
}
/// One label->content mapping, one JSON object per line in
/// `blueprint_names.jsonl` (#317) — the `IdentityIndexLine` sidecar-line
/// shape, mirrored for the label store.
#[derive(serde::Serialize, serde::Deserialize)]
struct BlueprintNameLine {
name: String,
content_id: String,
}
/// Deterministic label-shape gate for `put_blueprint_label` (#317): nonempty
/// after trim, no whitespace, no control characters. String shape only —
/// never content judgement (the C29 discipline `doc_gate` also follows).
fn gate_label_shape(name: &str) -> Result<(), RegistryError> {
if name.trim().is_empty() {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must be nonempty" });
}
if name.chars().any(char::is_whitespace) {
return Err(RegistryError::BadLabel { name: name.to_string(), rule: "must not contain whitespace" });
}
if name.chars().any(|c| c.is_control()) {
return Err(RegistryError::BadLabel {
name: name.to_string(),
rule: "must not contain control characters",
});
}
Ok(())
}
/// C29 walk (#316): the root and every named nested composite must carry a
/// gate-passing doc. `doc: None` refuses exactly like `Some("")` — both are
/// the Empty fault ("carries no doc"). `pub` (#317): the `use` construction
/// seam (`aura-cli`) re-runs this SAME walk over a freshly-fetched composite
/// before it enters a new composition (fail fast on a doc-less fetched
/// entry) — the one walk, two call sites (register's write-path gate, use's
/// read-path gate), never a second implementation.
pub fn gate_composite_docs(c: &CompositeData) -> Result<(), RegistryError> {
let fault = match c.doc.as_deref() {
None => Some(aura_core::DocGateFault::Empty),
Some(d) => aura_core::doc_gate(&c.name, d).err(),
};
if let Some(fault) = fault {
return Err(RegistryError::UndescribedComposite { name: c.name.clone(), fault });
}
for n in &c.nodes {
if let NodeData::Composite(nested) = n {
gate_composite_docs(nested)?;
}
}
Ok(())
}
/// 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> },
}
/// One identity-index mapping: the identity id (debug-symbol-blind hash) of
/// a stored blueprint → the content id (byte-exact hash) it lives at. One
/// JSON line per mapping in the `blueprint_identity_index.jsonl` sidecar
/// (#191); duplicate identity ids resolve last-line-wins at read time, so a
/// later repair append heals an earlier stale line.
#[derive(serde::Serialize, serde::Deserialize)]
struct IdentityIndexLine {
identity_id: String,
content_id: 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)
}
/// The identity-index sidecar path — the `families.jsonl` discipline: a
/// fixed-name sibling of the runs store, so isolation stays
/// per-directory (#191).
fn identity_index_path(&self) -> PathBuf {
self.path.with_file_name("blueprint_identity_index.jsonl")
}
/// Append one identity→content mapping as a single JSON line, creating
/// the file (and its parent directory) if absent. Serialized by the
/// internal `append_write_lock` (#276) like every other JSONL append
/// path. Callers treat the result as best-effort: the index is a cache,
/// and a cache write must never degrade a correct read.
fn append_identity_index(&self, identity_id: &str, content_id: &str) -> Result<(), RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let path = self.identity_index_path();
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&IdentityIndexLine {
identity_id: identity_id.to_string(),
content_id: content_id.to_string(),
})
.expect("two plain strings serialize");
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Read the index into a LAST-line-wins identity→content map. The index
/// is a pure cache with the scan as its truth path, so reads never fail
/// the lookup: a missing or unreadable file is an empty map, and a line
/// that does not parse (torn write, manual edit) is skipped.
fn load_identity_index(&self) -> std::collections::BTreeMap<String, String> {
let Ok(text) = fs::read_to_string(self.identity_index_path()) else {
return std::collections::BTreeMap::new();
};
let mut map = std::collections::BTreeMap::new();
for raw in text.lines() {
if let Ok(line) = serde_json::from_str::<IdentityIndexLine>(raw) {
map.insert(line.identity_id, line.content_id);
}
}
map
}
/// Resolve an identity id to a stored blueprint's canonical JSON —
/// public so a campaign run resolves the same identity refs the
/// referential tier validates.
///
/// Index-first (#191): the persistent `blueprint_identity_index.jsonl`
/// sidecar is consulted and every hit is VERIFIED by loading that one
/// blueprint under the current `resolve` roster and recomputing its
/// identity id — the index is a cache, never an oracle, so a verified hit
/// is always a *valid* match (same identity id) under roster drift, store
/// surgery, or index corruption. Any miss or failed verification falls
/// back to the scan, which doubles as a full-store repair pass: it walks
/// every entry (no early return), recomputes each loadable entry's
/// identity id exactly as before, and best-effort appends every mapping
/// the index lacks or has wrong. A pre-index store therefore backfills
/// itself on the first miss; a read-only store simply keeps scanning as
/// it always has.
///
/// One corner is *not* byte-identical to the scan: two stored blueprints
/// that differ only in fields excluded from the identity hash (e.g. debug
/// names) share one `identity_id` but have distinct `content_id`s. The
/// scan always answers with the first `read_dir` match; a verified index
/// hit answers with whichever of those content ids the index currently
/// maps the identity to, which need not be the first match. Both are
/// valid blueprints for that identity, so the answer is correct either
/// way, just not guaranteed to pick the same one as an uncached scan.
pub fn find_blueprint_by_identity(
&self,
identity_id: &str,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Option<String>, RegistryError> {
let index = self.load_identity_index();
if let Some(content_id) = index.get(identity_id)
&& let Some(json) = self.get_blueprint(content_id)?
&& let Ok(composite) = blueprint_from_json(&json, resolve)
&& let Ok(identity_json) = blueprint_identity_json(&composite)
&& aura_research::content_id_of(&identity_json) == identity_id
{
return Ok(Some(json));
}
// Miss or failed verification: today's scan, as a repair pass. The
// answer comes from the walk (first `read_dir` match, as before),
// never from the repaired index.
let entries = match fs::read_dir(self.blueprints_dir()) {
Ok(e) => e,
Err(_) => return Ok(None), // no store yet -> nothing matches
};
let mut found = None;
// Collect-then-diff-append (#191): the walk's last-wins mapping is
// built locally first, and only entries whose FINAL value differs
// from the pre-walk `index` snapshot are appended, once, after the
// walk. This is what makes a repeated scan over an already-converged
// index a no-op even in the duplicate-identity/distinct-content twin
// corner (two stored blueprints sharing an identity id, e.g.
// differing only in debug names): comparing each entry against the
// stale pre-walk snapshot mid-walk would append one alternating line
// per twin every time, since the snapshot never equals both of them
// at once.
let mut walked = std::collections::BTreeMap::new();
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 };
let entry_identity = aura_research::content_id_of(&identity_json);
// The filename stem is the content id — `blueprint_path`'s
// write-one/read-one mapping, read back.
let Some(content_id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from))
else {
continue;
};
walked.insert(entry_identity.clone(), content_id);
if found.is_none() && entry_identity == identity_id {
found = Some(json);
}
}
for (walked_identity, content_id) in &walked {
if index.get(walked_identity) != Some(content_id) {
// Best-effort — a cache write must never degrade a correct
// read.
let _ = self.append_identity_index(walked_identity, content_id);
}
}
Ok(found)
}
}
/// The per-metric **best-first ordering** of two reports: `Less` means `a` is
/// better than `b`. "Best" is fixed by each metric's meaning as declared by
/// the payload's `MetricVocabulary` (direction via `higher_is_better`; a
/// missing optional block reads `f64::NEG_INFINITY`, the worst rank for
/// higher-is-better keys). Every metric compares via `total_cmp` (a total
/// order over `f64`), so strays order deterministically without panicking. An
/// unknown metric name is a `RegistryError::UnknownMetric` carrying the
/// vocabulary's own roster.
///
/// The single source of best-first *ordering* — both [`rank_by`] (sort by it)
/// and [`optimize`] (argmax by it) call this. The name is resolved once via
/// `M::resolve`; the returned closure carries the resolved key, so
/// per-comparison work is just reading each value and the key compare.
fn metric_cmp<M: MetricVocabulary>(
metric: &str,
) -> Result<impl Fn(&aura_engine::RunReport<M>, &aura_engine::RunReport<M>) -> Ordering, RegistryError>
{
let key = M::resolve(metric).ok_or_else(|| RegistryError::UnknownMetric {
name: metric.to_string(),
known: M::known(),
})?;
let hib = M::higher_is_better(key);
Ok(move |a: &aura_engine::RunReport<M>, b: &aura_engine::RunReport<M>| {
let (va, vb) = (a.metrics.value(key), b.metrics.value(key));
// 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<M: MetricVocabulary>(
mut reports: Vec<aura_engine::RunReport<M>>,
metric: &str,
) -> Result<Vec<aura_engine::RunReport<M>>, RegistryError> {
let cmp = metric_cmp::<M>(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<M: MetricVocabulary + Clone>(
family: &aura_engine::SweepFamily<M>,
metric: &str,
) -> Result<aura_engine::SweepPoint<M>, RegistryError> {
let cmp = metric_cmp::<M>(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())
}
/// 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
}
/// The best-of-K null-max distribution, vocabulary-generic: for each resample
/// every member draws ONE null statistic from its own vocabulary (in odometer
/// order, from a per-iteration seed) and the max across members is taken. A
/// member with no usable null input contributes nothing and consumes no rng
/// (bit-parity with the pre-#147 R fold, whose centring is now inside
/// `RunMetrics::null_draw`). Deterministic given `seed` (C1).
fn null_best_of_k<M: MetricVocabulary>(
family: &aura_engine::SweepFamily<M>,
key: M::Key,
n_resamples: usize,
block_len: usize,
seed: u64,
) -> Vec<f64> {
(0..n_resamples)
.map(|i| {
let mut rng = SplitMix64::new(seed ^ i as u64);
family.points.iter().fold(f64::NEG_INFINITY, |best, p| {
match p.report.metrics.null_draw(key, block_len, &mut rng) {
Some(v) => best.max(v),
None => best,
}
})
})
.collect()
}
/// Sample standard deviation of the K members' metric values (the `total_pips`
/// dispersion-floor arm). `< 2` members -> `0.0`.
fn member_sd<M: MetricVocabulary>(family: &aura_engine::SweepFamily<M>, key: M::Key) -> f64 {
let vals: Vec<f64> = family.points.iter().map(|p| p.report.metrics.value(key)).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<M: MetricVocabulary + Clone>(
family: &aura_engine::SweepFamily<M>, axis_lens: &[usize], metric: &str, mode: PlateauMode,
) -> Result<(aura_engine::SweepPoint<M>, FamilySelection), RegistryError> {
let key = M::resolve(metric).ok_or_else(|| RegistryError::UnknownMetric {
name: metric.to_string(),
known: M::known(),
})?;
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 = M::higher_is_better(key);
// 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| family.points[j].report.metrics.value(key)).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 = winner.report.metrics.value(key);
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).
/// Which arm runs is vocabulary-declared resampling null vs dispersion floor
/// (`M::has_resampling_null`): a resampling-null key gets a centred moving-block
/// reality-check (`overfit_probability` = `(count(null ≥ raw) + 1)/(n + 1)`,
/// `deflated_score` = `raw p95(null)`); otherwise a closed-form
/// expected-max-of-K dispersion floor, no probability. Deterministic given
/// `seed` (C1).
pub fn optimize_deflated<M: MetricVocabulary + Clone>(
family: &aura_engine::SweepFamily<M>, metric: &str, n_resamples: usize, block_len: usize, seed: u64,
) -> Result<(aura_engine::SweepPoint<M>, FamilySelection), RegistryError> {
let winner = optimize(family, metric)?;
let key = M::resolve(metric).ok_or_else(|| RegistryError::UnknownMetric {
name: metric.to_string(),
known: M::known(),
})?;
let k = family.points.len();
let raw = winner.report.metrics.value(key);
let (deflated_score, overfit_probability) = if M::has_resampling_null(key) {
let null_max = null_best_of_k(family, key, 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 yields a
// usable draw (`null_draw` returns `None` for every member, so each
// iteration's best-of-K folds to `NEG_INFINITY`). The latter is reachable —
// the R conduit `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 ge = usable.iter().filter(|&&x| x >= raw).count();
let over = one_sided_p_laplace(ge, usable.len());
(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
// 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!(
M::higher_is_better(key),
"dispersion-floor arm is for higher-is-better metrics only",
);
(raw - member_sd(family, key) * 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 R classification lives in the
/// backtest vocabulary (`RunMetricKey::r_based`, C10); this gate is the registry's
/// monomorphic wall around it, so the CLI never duplicates the R-set.
pub fn check_r_metric(metric: &str) -> Result<(), RegistryError> {
let key = <RunMetrics as MetricVocabulary>::resolve(metric).ok_or_else(|| {
RegistryError::UnknownMetric {
name: metric.to_string(),
known: <RunMetrics as MetricVocabulary>::known(),
}
})?;
if key.r_based() {
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 key = <RunMetrics as MetricVocabulary>::resolve(metric).ok_or_else(|| {
RegistryError::UnknownMetric {
name: metric.to_string(),
known: <RunMetrics as MetricVocabulary>::known(),
}
})?;
if !key.r_based() {
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(), rep.metrics.value(key)))
.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 outside the family's vocabulary.
UnknownMetric { name: String, known: &'static [&'static str] },
/// 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),
/// C29 register seam (#316): the canonical bytes offered to the blueprint
/// store do not parse as a canonical blueprint — the gate cannot certify
/// what it cannot read.
MalformedBlueprint(serde_json::Error),
/// C29 register seam (#316): a root or named nested composite entering
/// the store without a gate-passing doc. Registered artifacts are never
/// retroactively invalidated — the gate guards the write path only.
UndescribedComposite { name: String, fault: aura_core::DocGateFault },
/// Label sidecar (#317): a `put_blueprint_label` name failing the
/// deterministic label-shape gate (nonempty after trim, no whitespace, no
/// control characters). By-identifier, naming the violated rule.
BadLabel { name: String, rule: &'static str },
/// Label sidecar (#317): a `put_blueprint_label` content id that does not
/// resolve via [`Registry::get_blueprint`] — the label sidecar never
/// labels a blueprint the store does not have.
UnknownBlueprint(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::Io(e) => write!(f, "registry i/o: {e}"),
RegistryError::Parse { line, source } => {
write!(f, "registry parse error at line {line}: {source}")
}
RegistryError::UnknownMetric { name, known } => write!(
f,
"unknown metric '{name}' (known: {})",
known.join(", ")
),
RegistryError::NonRMetric(m) => write!(
f,
"metric '{m}' is not comparable across instruments; cross-instrument scoring is R-only ({})",
aura_backtest::R_BASED_METRICS.join(", ")
),
RegistryError::TooFewInstruments(n) => write!(
f,
"a generalization needs >= 2 instruments, got {n}"
),
RegistryError::MalformedBlueprint(e) => {
write!(f, "blueprint: not a canonical blueprint: {e}")
}
RegistryError::UndescribedComposite { name, fault } => match fault {
aura_core::DocGateFault::Empty => write!(
f,
"blueprint: composite `{name}` carries no doc — a registered \
composite describes itself (C29); add a doc line (builder \
.doc(\"...\"), blueprint \"doc\" member, or op-script \
{{\"op\":\"doc\"}}) before register"
),
aura_core::DocGateFault::RestatesName => write!(
f,
"blueprint: composite `{name}` has a doc that merely restates \
its name — a registered composite describes itself (C29)"
),
},
RegistryError::BadLabel { name, rule } => {
write!(f, "blueprint label {name:?}: {rule}")
}
RegistryError::UnknownBlueprint(id) => {
write!(f, "blueprint: no stored blueprint with content id \"{id}\"")
}
}
}
}
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_backtest::{RMetrics, RunMetrics, SweepFamily, SweepPoint};
use aura_engine::RunManifest;
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 { name: "nope".to_string(), known: aura_backtest::RANKABLE_METRICS }
.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_unchecked("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);
}
/// C29 (#316): the store refuses a doc-less root (None and "" alike),
/// a name-restating doc, and admits a described composite. The raw
/// unchecked path still round-trips pre-C29 doc-less bytes — registered
/// artifacts are never retroactively invalidated.
#[test]
fn blueprint_store_gates_docs_at_the_write_boundary() {
let reg = Registry::open(temp_family_dir("blueprint_store_doc_gate"));
let docless = r#"{"format_version":1,"blueprint":{"name":"s","nodes":[]}}"#;
let e = reg.put_blueprint("h1", docless).expect_err("doc-less root must refuse");
assert!(
e.to_string().contains("composite `s` carries no doc"),
"prose names the composite and the rule: {e}"
);
let restated = r#"{"format_version":1,"blueprint":{"name":"echo","doc":"Echo","nodes":[]}}"#;
let e = reg.put_blueprint("h2", restated).expect_err("restated doc must refuse");
assert!(e.to_string().contains("merely restates"), "prose names the rule: {e}");
let described = r#"{"format_version":1,"blueprint":{"name":"s","doc":"a described fixture blueprint","nodes":[]}}"#;
reg.put_blueprint("h3", described).expect("described root registers");
assert_eq!(reg.get_blueprint("h3").expect("get"), Some(described.to_string()));
// never-retroactive witness: a pre-C29 doc-less entry stays readable
reg.put_blueprint_unchecked("h4", docless).expect("raw write");
assert_eq!(reg.get_blueprint("h4").expect("get"), Some(docless.to_string()));
}
/// A doc-less named NESTED composite refuses even when the root is
/// described — the walk covers every named nested composite (C29).
#[test]
fn blueprint_store_gates_nested_composite_docs() {
let reg = Registry::open(temp_family_dir("blueprint_store_nested_doc_gate"));
let nested_docless = r#"{"format_version":1,"blueprint":{"name":"root","doc":"a described root","nodes":[{"composite":{"name":"inner","nodes":[]}}]}}"#;
let e = reg.put_blueprint("h1", nested_docless).expect_err("doc-less nested must refuse");
assert!(
e.to_string().contains("composite `inner` carries no doc"),
"prose names the nested composite: {e}"
);
}
/// A described fixture blueprint, ready for `put_blueprint`'s C29 gate —
/// `put_blueprint_label`'s content-id verification routes through
/// `get_blueprint`, so its target must first be a gate-passing entry.
fn described_fixture_blueprint(name: &str) -> String {
format!(
r#"{{"format_version":1,"blueprint":{{"name":"{name}","doc":"a described fixture blueprint","nodes":[]}}}}"#
)
}
/// Property (#317): a label round-trips to the blueprint it names, and
/// re-labelling the SAME name repoints it — resolution and the discovery
/// listing both answer with the latest target, never the first.
#[test]
fn blueprint_label_round_trips_latest_wins() {
let reg = Registry::open(temp_family_dir("label_round_trip"));
let a = described_fixture_blueprint("a");
let b = described_fixture_blueprint("b");
reg.put_blueprint("ha", &a).expect("put a");
reg.put_blueprint("hb", &b).expect("put b");
reg.put_blueprint_label("x", "ha").expect("label x -> a");
assert_eq!(reg.resolve_blueprint_label("x"), Some("ha".to_string()));
reg.put_blueprint_label("x", "hb").expect("re-label x -> b");
assert_eq!(reg.resolve_blueprint_label("x"), Some("hb".to_string()), "latest label wins");
assert_eq!(
reg.blueprint_labels(),
vec![("x".to_string(), "hb".to_string())],
"the discovery listing dedups to the latest target",
);
}
/// Property (#317): the label-shape gate is deterministic string shape
/// only (nonempty after trim, no whitespace, no control characters) —
/// each bad shape refuses with `BadLabel` naming a (non-empty) rule.
#[test]
fn blueprint_label_refuses_bad_shapes() {
let reg = Registry::open(temp_family_dir("label_bad_shapes"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
for bad in ["", " ", "a b", "a\tb"] {
match reg.put_blueprint_label(bad, "h1") {
Err(RegistryError::BadLabel { name, rule }) => {
assert_eq!(name, bad);
assert!(!rule.is_empty(), "BadLabel must name the violated rule for {bad:?}");
}
other => panic!("expected BadLabel for {bad:?}, got {other:?}"),
}
}
}
/// Property (#317): a label may only ever point at a blueprint the store
/// actually has — labelling an unregistered content id refuses
/// by-identifier, never silently writing a dangling line.
#[test]
fn blueprint_label_refuses_unknown_content_id() {
let reg = Registry::open(temp_family_dir("label_unknown_id"));
match reg.put_blueprint_label("x", "never-written") {
Err(RegistryError::UnknownBlueprint(id)) => assert_eq!(id, "never-written"),
other => panic!("expected UnknownBlueprint, got {other:?}"),
}
assert_eq!(reg.resolve_blueprint_label("x"), None, "the refused label must not have written");
}
/// Property (#317, #191 spirit): a dangling LATEST line (content id no
/// longer resolves) is skipped by resolution, which falls back to the
/// earlier still-valid line for the same name — latest-wins means
/// latest-VALID-wins.
#[test]
fn blueprint_label_resolution_skips_a_dangling_entry() {
let reg = Registry::open(temp_family_dir("label_dangling"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
reg.put_blueprint_label("x", "h1").expect("label x -> h1");
// hand-append a dangling line after the valid one, mirroring
// `append_identity_index`'s stale-line test shape: the content id it
// names was never registered.
reg.append_blueprint_label_line("x", "never-written").expect("append stale line");
assert_eq!(
reg.resolve_blueprint_label("x"),
Some("h1".to_string()),
"the dangling latest line is skipped; the earlier valid line for \"x\" wins",
);
assert_eq!(reg.blueprint_labels(), vec![("x".to_string(), "h1".to_string())]);
}
/// Property (#317, #276 discipline): N threads appending labels through
/// one shared `&Registry` must leave `blueprint_names.jsonl` well-formed —
/// every successful append surviving as its own intact, resolvable line,
/// none torn or merged (mirrors `concurrent_appends_keep_the_family_
/// store_well_formed`).
#[test]
fn concurrent_label_appends_keep_the_sidecar_well_formed() {
let reg = Registry::open(temp_family_dir("concurrent_label"));
let bp = described_fixture_blueprint("s");
reg.put_blueprint("h1", &bp).expect("put");
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-l{iter}");
if reg.put_blueprint_label(&name, "h1").is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
assert_eq!(
reg.blueprint_labels().len(),
total_ok,
"every successful label append must survive as its own intact, resolvable entry",
);
}
#[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 { name, .. }) => assert_eq!(name, "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
}
// Property: concurrent writers through the Registry append path leave the
// family store well-formed. When N threads share one registry handle (the
// #277 parallel cell loop's caller shape — exec.rs hands each cell the same
// `&Registry`) and each appends families to the shared `families.jsonl`,
// every member of every successful append must survive as its own complete,
// parseable JSON line — none torn, merged into a neighbour, or lost. Today's
// unsynchronized OpenOptions::append + writeln! interleaves the content and
// newline writes of concurrent appends, merging records onto one physical
// line so the store no longer parses.
#[test]
fn concurrent_appends_keep_the_family_store_well_formed() {
let path = temp_family_dir("concurrent");
let reg = Registry::open(&path);
// Inline input: four distinct members per family, appended under a name
// unique to each (thread, iteration) so a clean store holds exactly
// `successful_appends * members` lines.
let members: Vec<RunReport> = (0..4).map(|k| report_with(k as f64, 0.5, 1)).collect();
let per_append = members.len();
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
let members = &members;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let name = format!("t{tid}-f{iter}");
if reg.append_family(&name, FamilyKind::Sweep, members).is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
let loaded = reg.load_family_members();
assert!(
loaded.is_ok(),
"concurrent appends must leave a parseable store; the writes corrupted it: {:?}",
loaded.err(),
);
assert_eq!(
loaded.expect("store parses").len(),
total_ok * per_append,
"every member of every successful append must survive as its own intact line",
);
let _ = fs::remove_dir_all(path.parent().expect("temp family dir"));
}
/// Third sibling of the #276 concurrency property, for the flat runs store:
/// `Registry::append` (→ `runs.jsonl`) shares the same
/// `OpenOptions::append` + `writeln!` pattern as the family and
/// campaign-run appends, and it is public API with external consumers
/// (C18), so N threads appending through one shared `&Registry` must leave
/// `runs.jsonl` well-formed — every successful append's record intact and
/// parseable, none torn, merged, or lost.
#[test]
fn concurrent_flat_appends_keep_the_runs_store_well_formed() {
let path = temp_family_dir("concurrent_flat");
let reg = Registry::open(&path);
let report = report_with(1.0, 0.5, 1);
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|_| {
let reg = &reg;
let report = &report;
scope.spawn(move || {
let mut ok = 0usize;
for _ in 0..iters {
if reg.append(report).is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
let loaded = reg.load();
assert!(
loaded.is_ok(),
"concurrent flat appends must leave a parseable store; the writes corrupted it: {:?}",
loaded.err(),
);
assert_eq!(
loaded.expect("store parses").len(),
total_ok,
"every successful flat append must survive as its own intact line",
);
let _ = fs::remove_dir_all(path.parent().expect("temp family dir"));
}
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_backtest::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 = <RunMetrics as MetricVocabulary>::resolve("sqn_normalized").unwrap();
assert_eq!(sel.raw_winner_metric, winner.report.metrics.value(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_vocabulary::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a
// plain `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) route through `LinComb`, an arg-bearing type (#271,
// `aura-vocabulary/src/lib.rs`) — using `Bias` here keeps this fixture
// independent of that construction channel, so it stands in as the
// "real, open-param, vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test");
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 (#191): the identity-index sidecar is a last-line-wins cache
/// over identity id → content id — a missing file reads as empty (no
/// error, since the scan is the truth path), a later append for the same
/// identity id heals an earlier stale line, and a garbage line (torn
/// concurrent write, manual edit) is skipped rather than failing the read.
#[test]
fn identity_index_reads_last_line_wins_and_skips_garbage() {
let reg = Registry::open(temp_family_dir("identity_index_read"));
// missing file: empty map, no error — the index is a cache and the
// scan is the truth path.
assert!(reg.load_identity_index().is_empty());
reg.append_identity_index("id-a", "content-1").expect("append");
reg.append_identity_index("id-b", "content-2").expect("append");
// a stale line for id-a is healed by a later append: last line wins.
reg.append_identity_index("id-a", "content-3").expect("append");
// garbage lines (torn concurrent write, manual edit) are skipped.
let text = fs::read_to_string(reg.identity_index_path()).expect("index exists");
fs::write(reg.identity_index_path(), format!("{text}not json\n")).expect("garbage line");
let map = reg.load_identity_index();
assert_eq!(map.get("id-a").map(String::as_str), Some("content-3"));
assert_eq!(map.get("id-b").map(String::as_str), Some("content-2"));
assert_eq!(map.len(), 2);
}
/// Store a composite as a canonical blueprint and return its
/// (blueprint_json, content_id, identity_id) — the seeding pattern of
/// `referential_tier_resolves_refs_and_checks_axes`, factored for the
/// identity-index tests.
fn seed_blueprint(reg: &Registry, composite: &aura_engine::Composite) -> (String, String, String) {
let blueprint_json = aura_engine::blueprint_to_json(composite).expect("serializes");
let content_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&content_id, &blueprint_json).expect("seed blueprint");
let identity_json = blueprint_identity_json(composite).expect("identity form");
let identity_id = aura_research::content_id_of(&identity_json);
(blueprint_json, content_id, identity_id)
}
/// The referential-tier fixture: a one-node composite over the zero-arg
/// vocabulary node `Bias`, loadable via `std_vocabulary`.
fn bias_fixture() -> aura_engine::Composite {
aura_engine::Composite::new(
"fixture",
vec![aura_strategy::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
/// A second, identity-distinct fixture: same shape over the zero-arg
/// vocabulary node `Delay`. Distinct node types are identity-bearing
/// (only debug names are identity-blind), so the two fixtures give the
/// store two entries with different identity ids.
fn delay_fixture() -> aura_engine::Composite {
aura_engine::Composite::new(
"fixture2",
vec![aura_std::Delay::builder().named("d").into()],
vec![],
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test")
}
#[test]
fn identity_index_lookup_backfills_and_resolves() {
let reg = Registry::open(temp_family_dir("identity_backfill"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (blueprint_json, content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// no index yet: the lookup answers via the scan fallback...
assert!(!reg.identity_index_path().exists());
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
// ...and repairs the index in the same pass.
assert_eq!(reg.load_identity_index().get(&identity_id), Some(&content_id));
// the second lookup resolves identically.
let again = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(again.as_deref(), Some(blueprint_json.as_str()));
}
#[test]
fn identity_index_verified_hit_answers_without_a_store_walk() {
let reg = Registry::open(temp_family_dir("identity_hit_no_walk"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (blueprint_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// backfill the index, then poison the store: a DIRECTORY named like
// a blueprint makes any scan's read_to_string error, so only a
// walk-free hit can succeed past it.
reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill");
fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir");
// verified hit: no walk, the poison is never touched.
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("hit path");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
// an unknown identity must scan — and the poison proves the scan
// would have bitten: the lookup errors instead of answering.
assert!(reg.find_blueprint_by_identity("0000", &resolve).is_err());
}
#[test]
fn identity_index_legacy_store_backfills_in_one_pass() {
let reg = Registry::open(temp_family_dir("identity_legacy_backfill"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (_json_a, content_a, identity_a) = seed_blueprint(&reg, &bias_fixture());
let (_json_b, content_b, identity_b) = seed_blueprint(&reg, &delay_fixture());
assert_ne!(identity_a, identity_b, "fixtures must be identity-distinct");
assert!(!reg.identity_index_path().exists());
// one miss-lookup (an absent identity) walks the store once and
// returns None — only the scan can prove absence...
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
// ...and that single walk backfills a mapping for EVERY loadable
// entry, not just the queried one.
let map = reg.load_identity_index();
assert_eq!(map.get(&identity_a), Some(&content_a));
assert_eq!(map.get(&identity_b), Some(&content_b));
assert_eq!(map.len(), 2);
}
#[test]
fn identity_index_stale_lines_degrade_to_scan_and_heal() {
let reg = Registry::open(temp_family_dir("identity_stale_heals"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (json_a, content_a, identity_a) = seed_blueprint(&reg, &bias_fixture());
let (_json_b, content_b, _identity_b) = seed_blueprint(&reg, &delay_fixture());
// stale shape 1: identity → an absent content id. Verify-on-hit
// cannot load it, the scan answers, the repair appends the correct
// pair behind the stale line — last line wins.
reg.append_identity_index(&identity_a, "feedface").expect("stale line");
let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(json_a.as_str()));
assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a));
// stale shape 2: identity → an EXISTING content id whose recomputed
// identity differs. Verify-on-hit refuses, the scan answers, the
// repair heals again.
reg.append_identity_index(&identity_a, &content_b).expect("stale line");
let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(json_a.as_str()));
assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a));
// healed for good: the hit path now clears a scan-poisoned store.
fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir");
let again = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("hit path");
assert_eq!(again.as_deref(), Some(json_a.as_str()));
}
#[test]
fn identity_index_unloadable_under_roster_stays_unmatched() {
let reg = Registry::open(temp_family_dir("identity_unloadable"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill");
// under a roster that cannot load the blueprint the verified hit
// fails and the scan skips the entry — `IdentityUnmatched` semantics
// preserved exactly (a preservation pin: green before AND after the
// lookup rework).
let empty = |_t: &str| None::<PrimitiveBuilder>;
assert_eq!(reg.find_blueprint_by_identity(&identity_id, &empty).expect("io ok"), None);
}
#[test]
fn identity_index_garbage_line_does_not_break_resolution() {
let reg = Registry::open(temp_family_dir("identity_garbage_line"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
let (blueprint_json, _content_id, identity_id) = seed_blueprint(&reg, &bias_fixture());
// a wholly-garbage index file must not break resolution (a
// preservation pin — the scan is the truth path).
fs::write(reg.identity_index_path(), "not json at all\n").expect("garbage index");
let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok");
assert_eq!(found.as_deref(), Some(blueprint_json.as_str()));
}
/// Property (#191 audit): the identity-index sidecar CONVERGES for a
/// same-identity-twin store — two blueprints sharing one identity id but
/// with distinct content ids (they differ only in identity-blind debug
/// symbols). Once one full repair walk has run, a further scan-triggering
/// lookup appends nothing (the file does not grow) while resolution
/// answers stay unchanged. The pre-fix repair pass compares each walked
/// entry against a mid-walk-stale snapshot, so with twins the snapshot can
/// never equal both entries and every full-scan lookup appends one more
/// (alternating) line — unbounded growth.
#[test]
fn identity_index_twin_store_converges_and_stops_growing() {
let reg = Registry::open(temp_family_dir("identity_twin_converges"));
let resolve = |t: &str| aura_vocabulary::std_vocabulary(t);
// Two same-identity twins: the one-node Bias composite, built with
// different debug names. Debug symbols are identity-blind, so the two
// share one identity id but hash to distinct content ids.
let (_json_a, content_a, identity) = seed_blueprint(&reg, &bias_fixture());
let twin = aura_engine::Composite::new(
"fixture_twin",
vec![aura_strategy::Bias::builder().named("b_twin").into()],
vec![],
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test");
let (_json_b, content_b, identity_twin) = seed_blueprint(&reg, &twin);
assert_eq!(identity, identity_twin, "twins must share one identity id");
assert_ne!(content_a, content_b, "twins must have distinct content ids");
// One converging walk: an absent identity always scans the whole
// store (only a scan proves absence), repairing the index.
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
let converged_lines = fs::read_to_string(reg.identity_index_path())
.map(|t| t.lines().count())
.unwrap_or(0);
// A further scan-triggering lookup over the CONVERGED index must not
// append another line (pre-fix: it appends one alternating twin line).
assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None);
let after_lines = fs::read_to_string(reg.identity_index_path())
.map(|t| t.lines().count())
.unwrap_or(0);
assert_eq!(
after_lines, converged_lines,
"a converged twin index must not grow on a further scan-triggering lookup",
);
// Resolution answers stay unchanged: the twin identity still resolves
// to a valid stored blueprint for that identity (preservation pin).
let found = reg.find_blueprint_by_identity(&identity, &resolve).expect("io ok");
assert!(found.is_some(), "the twin identity still resolves to a stored blueprint");
}
/// 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_vocabulary::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![],
)
.with_doc("fixture composite for the registry seeder test");
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_vocabulary::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_strategy::Bias::builder().named("b1").into(),
aura_strategy::Bias::builder().named("b2").into(),
],
vec![],
vec![],
vec![],
)
.with_doc("fixture composite for the registry seeder test");
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_vocabulary::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_strategy::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![],
)
.with_doc("fixture composite for the registry seeder test");
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 { name, .. }) => assert_eq!(name, "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)],
);
}
/// Sibling of `concurrent_appends_keep_the_family_store_well_formed`
/// (#276): `append_campaign_run` shares `append_family`'s unsynchronized
/// `OpenOptions::append` + `writeln!` pattern at the same seam, so N
/// threads appending campaign runs through one shared `&Registry` must
/// leave `campaign_runs.jsonl` well-formed — every successful append's
/// record intact and parseable, none torn or lost.
#[test]
fn concurrent_campaign_run_appends_keep_the_store_well_formed() {
let path = temp_family_dir("concurrent_campaign_run");
let reg = Registry::open(&path);
let n_threads = 8usize;
let iters = 50usize;
let total_ok: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..n_threads)
.map(|tid| {
let reg = &reg;
scope.spawn(move || {
let mut ok = 0usize;
for iter in 0..iters {
let campaign = format!("t{tid}-c{iter}");
if reg.append_campaign_run(&campaign_run_record(&campaign)).is_ok() {
ok += 1;
}
}
ok
})
})
.collect();
handles.into_iter().map(|h| h.join().expect("worker thread joined")).sum()
});
let loaded = reg.load_campaign_runs();
assert!(
loaded.is_ok(),
"concurrent campaign-run appends must leave a parseable store; the writes corrupted it: {:?}",
loaded.err(),
);
assert_eq!(
loaded.expect("store parses").len(),
total_ok,
"every successful campaign-run append must survive as its own intact line",
);
let _ = fs::remove_dir_all(path.parent().expect("temp family dir"));
}
#[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_backtest::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",
);
}
}