Files
Aura/crates/aura-registry/src/lineage.rs
T
claude 883401309a feat(registry): serialize the append write path for concurrent writers
Registry::append_family and append_campaign_run did unsynchronized
OpenOptions::append + writeln! to their shared JSONL stores; concurrent
writers through one shared &Registry interleaved one append's content
and newline writes with another's, merging records onto one physical
line and corrupting the store (the #276 RED test failed 3/3 with a
"trailing characters" parse error). The planned parallel campaign cell
loop (#277) makes exactly that caller shape reachable, so this is its
prerequisite.

Mechanism: one internal append_write_lock: Mutex<()> on Registry,
acquired by both append functions and held across the whole
read-the-run-counter-then-write critical section — serializing the
write also serializes the read-before-write counter derivation, so
concurrent appends under one name cannot double-assign a run index.
Mutual exclusion around a per-cell-per-stage microsecond write, not a
barrier: cells never rendezvous, and the sim event loop stays lock-free
(C1's "without locking" governs the sims; this lock sits at the
results-persistence edge outside the hot path). A poisoned lock is
entered anyway (PoisonError::into_inner): every store line is
independent, so one panicked writer must not wedge all later appends.

Scope, per the decision log on #276: in-process synchronization owned
by the Registry type itself; cross-process locking stays out of
contract (the corrected doc comments now state the actual guarantee).
The serial per-cell-buffer alternative stays open to #277 as an
ordering decision on top — this lock does not preclude it.

Adds the sibling test pinning append_campaign_run under the same
concurrent load. Verified: the #276 headline test green 3/3 (was red
3/3), sibling green, cargo test --workspace no failures, clippy
--workspace --all-targets -D warnings clean.

closes #276
2026-07-16 12:36:31 +02:00

449 lines
20 KiB
Rust

//! Registry lineage for orchestration families (C18/C21): a family — a sweep,
//! Monte-Carlo, or walk-forward run — is a set of run records sharing one
//! parent identity — the `(family, run)` pair, from which the user-facing
//! `family_id` handle (`"{family}-{run}"`) is derived. This module adds the
//! persisted lineage
//! unit ([`FamilyRunRecord`]), the family-store accessors on [`Registry`]
//! ([`Registry::append_family`] / [`Registry::load_family_members`]), and the
//! round-trip that re-derives a [`Family`] from the stored links
//! ([`group_families`]). The family store is a sibling JSONL of the flat runs
//! store (`families.jsonl`), so the flat `runs.jsonl` path and API are untouched.
//!
//! Campaign realizations (cycle 0107) follow the same growth pattern: a thin
//! [`CampaignRunRecord`] linking untouched family records, one JSONL line per
//! campaign run in a second sibling store (`campaign_runs.jsonl`), written by
//! [`Registry::append_campaign_run`] and read by [`Registry::load_campaign_runs`].
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use aura_core::Scalar;
use aura_engine::{
FamilySelection, McFamily, RBootstrap, RunReport, SweepFamily, WalkForwardResult,
};
use serde::{Deserialize, Serialize};
use crate::{Generalization, Registry, RegistryError};
/// Which C12 orchestration axis produced a family.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum FamilyKind {
Sweep,
MonteCarlo,
WalkForward,
CrossInstrument,
}
/// One persisted family member: a run record stamped with its lineage link.
/// The parent reference is split into its two factors — `family` (the parent
/// name) and `run` (the per-name run index) — from which the user-facing
/// `family_id` handle (`"{family}-{run}"`) is derived rather than stored.
/// `ordinal` is the member's position in the family's canonical order
/// (odometer / seed-input / roll).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FamilyRunRecord {
pub family: String,
pub run: usize,
pub kind: FamilyKind,
pub ordinal: usize,
pub report: RunReport,
}
impl FamilyRunRecord {
/// The user-facing family handle — `"{family}-{run}"`, derived, never stored or parsed.
pub fn family_id(&self) -> String {
format!("{}-{}", self.family, self.run)
}
}
/// A re-derived family: the members sharing one `(family, run)` key (and thus one
/// derived `family_id`), ordinal-sorted — the round-trip result of
/// [`group_families`].
#[derive(Clone, Debug, PartialEq)]
pub struct Family {
pub id: String,
pub kind: FamilyKind,
pub members: Vec<FamilyRunRecord>,
}
/// Campaign realization: a THIN linking record over untouched family records
/// (#198). One JSONL line per campaign run in `campaign_runs.jsonl`, a sibling
/// of `runs.jsonl`/`families.jsonl` — the registry's growth pattern. `run` is
/// the per-campaign counter assigned by [`Registry::append_campaign_run`] (the
/// family store's per-name `run` pattern).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CampaignRunRecord {
/// Campaign document content id.
pub campaign: String,
/// Process document content id.
pub process: String,
/// Per-campaign run counter — assigned on append, never caller-supplied.
pub run: usize,
pub seed: u64,
pub cells: Vec<CellRealization>,
/// Campaign-scope generalize annotations (cycle 0108), one per
/// (strategy, window) across instruments. Absent pre-0108 — the
/// serde-default widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub generalizations: Vec<CampaignGeneralization>,
/// The TraceStore family name this realization claims when the document
/// requests persist_taps — a pure derivation ("{campaign8}-{run}"): the
/// executor hands a claim sentinel (`Some`, any content) and
/// [`Registry::append_campaign_run`] composes the derived name onto the
/// stored line; the consumer persists the bytes (#201 d5). `None` when
/// the document requests no taps. Absent pre-0109 — the serde-default
/// widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace_name: Option<String>,
}
/// The recorded fault of a failed cell (#272): the pipeline stage it fired in,
/// a closed kind tag aggregations count on, and the prose detail. `no_data` is
/// one kind among others, not a special path.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellFault {
pub stage: usize,
pub kind: CellFaultKind,
pub detail: String,
}
/// Closed fault-kind vocabulary (#272).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CellFaultKind {
NoData,
Bind,
Run,
Panic,
Window,
}
/// One failed walk-forward fold (#272): the roll ordinal that produced no OOS
/// report, with the fault that killed it. The surviving folds still pool; an
/// aggregate over this stage must name the ratio.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct WindowFault {
pub window_ordinal: usize,
pub kind: CellFaultKind,
pub detail: String,
}
/// A cell whose requested window is only partially covered by the archive
/// (#272): the effective evaluated bounds and any whole months missing inside
/// them. Recorded only when it deviates from the requested window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellCoverage {
pub effective_from_ms: i64,
pub effective_to_ms: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gap_months: Vec<String>,
}
/// One realized (strategy, instrument, window) cell: the pipeline prefix that
/// actually ran (`stages` stops after an empty gate).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CellRealization {
/// Blueprint content id.
pub strategy: String,
pub instrument: String,
/// Inclusive epoch-ms window.
pub window_ms: (i64, i64),
pub stages: Vec<StageRealization>,
/// The cell's risk regime (#219/#212) — mirrors `CellSpec::regime`: `None`
/// is the member runner's baked default stop, `Some` an explicit document
/// regime. Threaded through so a downstream re-run (the trace-persist C1
/// drift alarm) binds the SAME stop the cell recorded under, instead of
/// hardcoding the default. `#[serde(default, skip_serializing_if =
/// "Option::is_none")]` keeps pre-#219 stored records byte-identical on
/// round-trip (they predate the risk axis, so their implicit regime was
/// always the default / absent).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub regime: Option<aura_research::RiskRegime>,
/// The regime's ordinal in the resolved regime list (0 for the default) —
/// mirrors `CellSpec::regime_ordinal`; discriminates trace dirs for two
/// regimes over the same (strategy, instrument, window) cell.
/// `#[serde(default, skip_serializing_if = "is_zero_ordinal")]` for the
/// same pre-#219 byte-identical round-trip reason.
#[serde(default, skip_serializing_if = "is_zero_ordinal")]
pub regime_ordinal: usize,
/// Set when the cell failed (#272): the pipeline prefix in `stages` ran,
/// the faulting stage did not complete, no nominee was produced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fault: Option<CellFault>,
/// Set when the archive only partially covers the requested window (#272).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage: Option<CellCoverage>,
}
/// `CellRealization::regime_ordinal`'s serialize-skip predicate: the default
/// regime (ordinal 0) is omitted, keeping pre-#219 stored lines byte-stable.
fn is_zero_ordinal(n: &usize) -> bool {
*n == 0
}
/// One realized pipeline stage. `family_id` is set for family-producing stages
/// (sweep / walk_forward); `survivor_ordinals` for gate stages (ordinals index
/// the nearest preceding `family_id`-bearing stage's family); `selection` for
/// sweep stages (walk-forward selections live in the wf family members'
/// manifests).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StageRealization {
pub block: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub survivor_ordinals: Option<Vec<usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<StageSelection>,
/// std::monte_carlo stages only (cycle 0108). Absent pre-0108 — the
/// serde-default widening keeps existing stored lines parseable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap: Option<StageBootstrap>,
/// std::walk_forward stages only (#272): folds that produced no OOS report;
/// the surviving folds pooled, and aggregates must name the ratio.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub window_faults: Vec<WindowFault>,
}
/// The recorded winner of a selection-bearing stage: its ordinal in the stage's
/// family, its winning param coordinate, and the selection provenance.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StageSelection {
pub winner_ordinal: usize,
pub params: Vec<(String, Scalar)>,
pub selection: FamilySelection,
}
/// The recorded result of a `std::monte_carlo` stage (cycle 0108, #200 d1/d4):
/// the payload mirrors the stage's input shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StageBootstrap {
/// After sweep/gates: one bootstrap per surviving member,
/// `(ordinal into the population family, result)`.
PerSurvivor(Vec<(usize, RBootstrap)>),
/// After a walk_forward: one bootstrap over the pooled per-window
/// OOS trade-R series (roll order).
PooledOos(RBootstrap),
}
/// The campaign-scope generalize annotation (#200 d2): recorded at the scope
/// it is computed, keyed (strategy, window) across instruments.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CampaignGeneralization {
pub strategy_ordinal: usize,
pub window_ordinal: usize,
/// The regime ordinal (0 for the default regime) — the third structural
/// coordinate, so a generalize grade is unambiguous per (strategy, window,
/// regime). `#[serde(default)]` keeps pre-feature stored records parsing.
#[serde(default)]
pub regime_ordinal: usize,
/// None when fewer than 2 instrument cells produced a winner (gate
/// truncation) — the shortfall is recorded, never computed around.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generalization: Option<Generalization>,
/// Per-instrument winning params — divergent winners are exposed,
/// never averaged away. Present whenever >= 1 winner exists.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub winners: Vec<(String, Vec<(String, Scalar)>)>,
/// Instrument cells that contributed no winner (gate-truncated), by name.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing: Vec<String>,
}
impl Registry {
/// Assign a fresh per-name `run` index (one past the highest `run` already
/// stored for `name`, or `0` if unseen), stamp `reports` as ordinal-ordered
/// members carrying `name` + that `run`, and append them as N JSONL lines to
/// the family store (a sibling of the flat runs store, `families.jsonl`,
/// leaving the flat path untouched). Returns the derived id `"{name}-{run}"`.
/// Reads the store once to pick the run index, then writes, the whole
/// read-then-write critical section serialized by `Registry`'s internal
/// `append_write_lock` (#276) — safe for concurrent writers sharing one
/// `&Registry`.
pub fn append_family(
&self,
name: &str,
kind: FamilyKind,
reports: &[RunReport],
) -> Result<String, RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let run = next_run(name, &self.load_family_members()?);
let path = self.path.with_file_name("families.jsonl");
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
for (ordinal, report) in reports.iter().enumerate() {
let record = FamilyRunRecord {
family: name.to_string(),
run,
kind,
ordinal,
report: report.clone(),
};
let line = serde_json::to_string(&record).expect("a finite FamilyRunRecord serializes");
writeln!(file, "{line}")?;
}
Ok(format!("{name}-{run}"))
}
/// Parse every stored family member, in file order. A missing file is an empty
/// family store (`Ok(vec![])`), exactly as [`Registry::load`] treats a missing
/// flat store.
pub fn load_family_members(&self) -> Result<Vec<FamilyRunRecord>, RegistryError> {
let path = self.path.with_file_name("families.jsonl");
let text = match fs::read_to_string(&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 records = Vec::new();
for (i, raw) in text.lines().enumerate() {
if raw.trim().is_empty() {
continue;
}
let record = serde_json::from_str(raw)
.map_err(|source| RegistryError::Parse { line: i + 1, source })?;
records.push(record);
}
Ok(records)
}
/// Assign a fresh per-campaign `run` index (one past the highest `run`
/// already stored for `record.campaign`, or `0` if unseen — the
/// [`Registry::append_family`] counter pattern), write the record carrying
/// that assigned run as ONE JSONL line to the campaign-run store (a sibling
/// of the flat runs store, `campaign_runs.jsonl`), and return the assigned
/// run. The input record's own `run` field is ignored; a `Some`
/// `trace_name` (the executor's claim sentinel, cycle 0109) is replaced
/// with the derived `"{campaign8}-{run}"` on the stored line, `None`
/// stays `None`. Reads the store once to pick the run index, then writes,
/// the whole read-then-write critical section serialized by the same
/// `Registry` `append_write_lock` that `append_family` uses (#276) — safe
/// for concurrent writers sharing one `&Registry`.
pub fn append_campaign_run(
&self,
record: &CampaignRunRecord,
) -> Result<usize, RegistryError> {
let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let run = next_campaign_run(&record.campaign, &self.load_campaign_runs()?);
let path = self.path.with_file_name("campaign_runs.jsonl");
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?;
// A `Some` trace_name (any content — the executor's claim sentinel,
// #201 d5) is replaced with the derived name; `None` stays `None`.
let trace_name = record.trace_name.as_ref().map(|_| derive_trace_name(&record.campaign, run));
let stored = CampaignRunRecord { run, trace_name, ..record.clone() };
let line = serde_json::to_string(&stored).expect("a finite CampaignRunRecord serializes");
writeln!(file, "{line}")?;
Ok(run)
}
/// Parse every stored campaign-run record, in file order. A missing file is
/// an empty campaign-run store (`Ok(vec![])`), exactly as
/// [`Registry::load_family_members`] treats a missing family store.
pub fn load_campaign_runs(&self) -> Result<Vec<CampaignRunRecord>, RegistryError> {
let path = self.path.with_file_name("campaign_runs.jsonl");
let text = match fs::read_to_string(&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 records = Vec::new();
for (i, raw) in text.lines().enumerate() {
if raw.trim().is_empty() {
continue;
}
let record = serde_json::from_str(raw)
.map_err(|source| RegistryError::Parse { line: i + 1, source })?;
records.push(record);
}
Ok(records)
}
}
/// The next per-name run index: one past the highest `run` already stored for
/// `name`, or `0` if the name is unseen. A clean numeric max over the `run`
/// field — no string parsing (the old fused-id form had to generate-and-check to
/// stay robust to `-` inside a name; the split removes that concern).
fn next_run(name: &str, records: &[FamilyRunRecord]) -> usize {
records
.iter()
.filter(|r| r.family == name)
.map(|r| r.run + 1)
.max()
.unwrap_or(0)
}
/// The next per-campaign run index: one past the highest `run` already stored
/// for `campaign`, or `0` if the campaign is unseen — [`next_run`]'s parallel
/// for the campaign-run store. Deliberately a separate fn, not a
/// generalization of `next_run`: the two stores' key fields stay independently
/// named and typed.
fn next_campaign_run(campaign: &str, records: &[CampaignRunRecord]) -> usize {
records
.iter()
.filter(|r| r.campaign == campaign)
.map(|r| r.run + 1)
.max()
.unwrap_or(0)
}
/// The TraceStore family name a campaign realization claims:
/// `"{campaign8}-{run}"` — the safe 8-char prefix of `campaign` (or the whole
/// id if shorter; the registry seam must not panic on a shorter id even
/// though the executor guarantees a 64-hex one) joined with `run` (#201 d5).
/// The single source of truth for the composition: [`Registry::
/// append_campaign_run`] calls it for the stored line, and
/// `aura-campaign::execute` calls it to mirror the same name onto the
/// returned copy.
pub fn derive_trace_name(campaign: &str, run: usize) -> String {
let prefix = campaign.get(..8).unwrap_or(campaign);
format!("{prefix}-{run}")
}
/// Group member records into families by their `(family, run)` key (first-seen
/// family order; each family's members ordinal-sorted), deriving the family's
/// `id` once at construction. The round-trip: `append_family(...)` then
/// `group_families(load_family_members())` recovers the families.
pub fn group_families(records: Vec<FamilyRunRecord>) -> Vec<Family> {
let mut order: Vec<(String, usize)> = Vec::new();
let mut by_key: HashMap<(String, usize), Family> = HashMap::new();
for rec in records {
let key = (rec.family.clone(), rec.run);
let fam = by_key.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
Family { id: rec.family_id(), kind: rec.kind, members: Vec::new() }
});
fam.members.push(rec);
}
let mut families: Vec<Family> =
order.into_iter().map(|k| by_key.remove(&k).expect("key was inserted")).collect();
for fam in &mut families {
fam.members.sort_by_key(|m| m.ordinal);
}
families
}
/// The member reports of a sweep family, in enumeration order — the trivial
/// extractor the CLI hands to [`Registry::append_family`].
pub fn sweep_member_reports(family: &SweepFamily) -> Vec<RunReport> {
family.points.iter().map(|p| p.report.clone()).collect()
}
/// The member reports of a Monte-Carlo family, in seed-input order.
pub fn mc_member_reports(family: &McFamily) -> Vec<RunReport> {
family.draws.iter().map(|d| d.report.clone()).collect()
}
/// The per-window OOS reports of a walk-forward result, in roll order.
pub fn walkforward_member_reports(result: &WalkForwardResult) -> Vec<RunReport> {
result.windows.iter().map(|w| w.run.oos_report.clone()).collect()
}