a8b1ba45c5
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.
3117 lines
150 KiB
Rust
3117 lines
150 KiB
Rust
//! 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 = ®
|
||
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 = ®
|
||
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 = ®
|
||
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(®, &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(®, &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(®, &bias_fixture());
|
||
let (_json_b, content_b, identity_b) = seed_blueprint(®, &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(®, &bias_fixture());
|
||
let (_json_b, content_b, _identity_b) = seed_blueprint(®, &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(®, &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(®, &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(®, &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(®, &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 = ®
|
||
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",
|
||
);
|
||
}
|
||
}
|