feat(engine,registry): ListSpace + campaign-run realization store (0107 tasks 2-3)

aura-engine: ListSpace — explicit point set implementing Space beside
GridSpace/RandomSpace (a gate's survivor subset has no cartesian
structure); GridSpace-identical arity/kind validation reusing the
existing SweepError variants (KindMismatch.value_index carries the
point ordinal for a list); empty point list is valid and sweeps to an
empty family; re-exported at the crate root.

aura-registry: CampaignRunRecord/CellRealization/StageRealization/
StageSelection — the thin campaign-level linking record over untouched
family records (#198 decision 4) — with append_campaign_run (assigns
run via next_campaign_run, a parallel counter beside next_run) and
load_campaign_runs (missing file => empty) on the campaign_runs.jsonl
sibling store; blueprint_path and find_blueprint_by_identity promoted
to pub (the campaign executor resolves the same refs the referential
tier validates).

Gates: engine 290/0, registry 54/0, clippy -D warnings clean.

refs #198
This commit is contained in:
2026-07-03 19:34:08 +02:00
parent 67aff34923
commit b43def7d75
6 changed files with 692 additions and 25 deletions
+2 -1
View File
@@ -75,7 +75,8 @@ pub use report::{
RunReport, SelectionMode,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily,
SweepPoint,
};
pub use mc::{
monte_carlo, r_bootstrap, resample_block, McAggregate, McDraw, McFamily, MetricStats,
+163 -14
View File
@@ -1,11 +1,13 @@
//! Param-sweep (C12.1): enumerate a blueprint's param-space — either a cartesian
//! `GridSpace` (a discrete per-slot lattice) or a seeded `RandomSpace` (`N` draws
//! over typed continuous ranges) — and run each point disjointly (C1). Both
//! enumerations implement the `Space` trait that `sweep` is generic over, so
//! either runs through one execution path. This module owns enumeration
//! (`GridSpace` / `RandomSpace` / the `Space` trait), execution (`sweep`), and
//! collection (`SweepFamily`); the per-point run-to-metrics closure is the
//! author's (harness-specific sink glue the engine cannot generically own — C8/C18).
//! Param-sweep (C12.1): enumerate a blueprint's param-space — a cartesian
//! `GridSpace` (a discrete per-slot lattice), a seeded `RandomSpace` (`N` draws
//! over typed continuous ranges), or an explicit `ListSpace` (an arbitrary
//! validated point set, e.g. a gate's survivor subset) — and run each point
//! disjointly (C1). All three enumerations implement the `Space` trait that
//! `sweep` is generic over, so each runs through one execution path. This module
//! owns enumeration (`GridSpace` / `RandomSpace` / `ListSpace` / the `Space`
//! trait), execution (`sweep`), and collection (`SweepFamily`); the per-point
//! run-to-metrics closure is the author's (harness-specific sink glue the engine
//! cannot generically own — C8/C18).
use aura_core::{zip_params, Cell, ParamSpec, Scalar, ScalarKind};
use crate::RunReport;
@@ -125,15 +127,19 @@ impl Space for GridSpace {
}
}
/// A structural fault constructing a `GridSpace` or a `RandomSpace` — the shared
/// typed gate before any run (grid faults: `Arity` / `KindMismatch` / `EmptyAxis`;
/// random faults: `NonNumericRange` / `RangeKindMismatch` / `EmptyRange`).
/// A structural fault constructing a `GridSpace`, a `RandomSpace`, or a
/// `ListSpace` — the shared typed gate before any run (grid faults: `Arity` /
/// `KindMismatch` / `EmptyAxis`; random faults: `NonNumericRange` /
/// `RangeKindMismatch` / `EmptyRange`; list faults reuse `Arity` /
/// `KindMismatch`, with `value_index` carrying the point ordinal).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SweepError {
/// The number of axes does not equal the param-space slot count.
/// The number of axes (grid/random) — or of a single point's values (list)
/// — does not equal the param-space slot count.
Arity { expected: usize, got: usize },
/// A grid value's kind does not match its slot's declared kind. `slot` is the
/// flat param-space index; `value_index` is the position within that axis.
/// A grid or list value's kind does not match its slot's declared kind.
/// `slot` is the flat param-space index; `value_index` is the position
/// within that axis (grid) or the point ordinal (list).
KindMismatch { slot: usize, value_index: usize, expected: ScalarKind, got: ScalarKind },
/// A slot was given no values (would collapse the product to zero points).
EmptyAxis { slot: usize },
@@ -282,6 +288,58 @@ impl Space for RandomSpace {
}
}
/// An explicit, validated point set over a blueprint's param-space — the
/// enumeration for arbitrary member subsets (e.g. a gate's survivor set) that
/// no cartesian `GridSpace` can represent. Points are validated against
/// `space` at construction (the `GridSpace::new` contract: arity per point,
/// kind per slot); an empty point list is valid and yields an empty family.
#[derive(Debug)]
pub struct ListSpace {
space: Vec<ParamSpec>,
points: Vec<Vec<Scalar>>,
}
impl ListSpace {
/// Validate `points` against `space` (the blueprint's `param_space()`):
/// every point carries one value per slot (`Arity`), every value the
/// slot's declared kind (`KindMismatch`, whose `value_index` is the point
/// ordinal here). An empty point list is allowed — a legitimately-empty
/// survivor set is representable, it just enumerates zero points.
pub fn new(space: &[ParamSpec], points: Vec<Vec<Scalar>>) -> Result<Self, SweepError> {
for (point_index, point) in points.iter().enumerate() {
if point.len() != space.len() {
return Err(SweepError::Arity { expected: space.len(), got: point.len() });
}
for (slot, (v, ps)) in point.iter().zip(space).enumerate() {
if v.kind() != ps.kind {
return Err(SweepError::KindMismatch {
slot,
value_index: point_index,
expected: ps.kind,
got: v.kind(),
});
}
}
}
Ok(Self { space: space.to_vec(), points })
}
}
impl Space for ListSpace {
/// The points exactly as given, in input order (no enumeration structure —
/// determinism is the caller's declared order, C1), lowered to tag-free
/// cells in the declared kinds (the kind lives once, in `param_specs()`).
fn points(&self) -> Vec<Vec<Cell>> {
self.points
.iter()
.map(|p| p.iter().map(|s| s.cell()).collect())
.collect()
}
fn param_specs(&self) -> &[ParamSpec] {
&self.space
}
}
/// One enumerated point and the full `RunReport` its run produced.
/// Self-describing: `params` is the point's coordinate in `param_space()` order,
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
@@ -810,4 +868,95 @@ mod tests {
.collect();
assert_eq!(family.named_params(0), expected);
}
/// A fake report whose manifest `commit` encodes the input cells — enough to
/// prove the sweep closure ran on exactly the given point, without a harness
/// run. A free `fn` (Sync) so it serves directly as the `sweep` closure.
fn tagged_report(point: &[Cell]) -> RunReport {
RunReport {
manifest: RunManifest {
commit: point.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(","),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&[], &[]),
}
}
#[test]
fn list_space_runs_exactly_the_given_points_in_order() {
let space = i64_space(2);
// deliberately non-odometer order: the list enumerates the input order,
// not any canonical order
let ls = ListSpace::new(
&space,
vec![
vec![Scalar::i64(3), Scalar::i64(5)],
vec![Scalar::i64(2), Scalar::i64(4)],
vec![Scalar::i64(3), Scalar::i64(4)],
],
)
.expect("valid explicit point set");
let expected = vec![
vec![Cell::from_i64(3), Cell::from_i64(5)],
vec![Cell::from_i64(2), Cell::from_i64(4)],
vec![Cell::from_i64(3), Cell::from_i64(4)],
];
assert_eq!(ls.points(), expected);
let family = sweep(&ls, tagged_report);
assert_eq!(family.points.len(), 3);
assert_eq!(family.space, space, "family carries the validated param-space");
for (pt, cells) in family.points.iter().zip(&expected) {
assert_eq!(&pt.params, cells, "points carried tag-free, in input order");
let tag: String =
cells.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(",");
assert_eq!(pt.report.manifest.commit, tag, "the closure ran on exactly this point");
}
}
#[test]
fn list_space_refuses_arity_and_kind_mismatch() {
let space = i64_space(2);
// arity: the second point carries 1 value for a 2-slot space
let err = ListSpace::new(
&space,
vec![vec![Scalar::i64(1), Scalar::i64(2)], vec![Scalar::i64(3)]],
)
.unwrap_err();
assert_eq!(err, SweepError::Arity { expected: 2, got: 1 });
// kind: point 1, slot 1 carries an F64 in an I64 slot
let err = ListSpace::new(
&space,
vec![
vec![Scalar::i64(1), Scalar::i64(2)],
vec![Scalar::i64(3), Scalar::f64(0.5)],
],
)
.unwrap_err();
assert_eq!(
err,
SweepError::KindMismatch {
slot: 1,
value_index: 1,
expected: ScalarKind::I64,
got: ScalarKind::F64,
},
);
}
#[test]
fn list_space_allows_empty_point_list() {
let space = i64_space(2);
let ls = ListSpace::new(&space, vec![]).expect("an empty point list is valid");
assert!(ls.points().is_empty(), "zero points enumerated");
let family = sweep(&ls, tagged_report);
assert!(family.points.is_empty(), "an empty ListSpace sweeps to an empty family");
assert_eq!(family.space, space, "the empty family still carries the param-space");
}
}
+171
View File
@@ -0,0 +1,171 @@
//! End-to-end coverage for `ListSpace` (cycle 0107 task 2, C12.1): an explicit,
//! non-lattice point set — modelled on `random_sweep_e2e.rs`'s worked-author
//! pattern — driven through the PUBLIC `sweep` surface with a REAL bootstrapped
//! harness (not a fake closure), so the property under test is that `ListSpace`
//! integrates with the actual bootstrap -> run -> summarize pipeline the same
//! way `GridSpace`/`RandomSpace` do, exercising the exact use case `ListSpace`
//! exists for: re-running an arbitrary member subset (e.g. a gate's survivors)
//! that no cartesian `GridSpace` can represent.
use std::sync::mpsc;
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, ListSpace, OutField, ParamSpec,
Role, RunManifest, RunReport, Scalar, SweepError, Target, VecSource,
};
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
/// Seven synthetic F64 ticks — the same fixture shape as `random_sweep_e2e.rs`,
/// short enough that small SMA windows warm up deterministically.
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
[
(1_i64, 1.0000_f64),
(2, 1.0010),
(3, 1.0030),
(4, 1.0060),
(5, 1.0040),
(6, 1.0010),
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
.collect()
}
/// The same SMA-cross `[fast: I64, slow: I64, scale: F64]` harness as
/// `random_sweep_e2e.rs`, built through the public builder API.
#[allow(clippy::type_complexity)]
fn sma_cross_harness() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let sma_cross = Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross),
Bias::builder().into(),
SimBroker::builder(0.0001).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 1, to: 4, slot: 0, from_field: 0 },
],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + summarize one point into a real `RunReport` — the
/// full pipeline `ListSpace` must survive end-to-end, mirroring
/// `random_sweep_e2e.rs::run_point`.
fn run_point(point: &[Cell]) -> RunReport {
let (bp, rx_eq, rx_ex) = sma_cross_harness();
let mut h = bp.bootstrap_with_cells(point).expect("ListSpace validates points before sweep");
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "list-sweep-e2e".to_string(),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&equity, &exposure),
}
}
/// Property: `ListSpace` reproduces the exact use case it exists for — an
/// arbitrary, non-lattice member subset (e.g. what a gate stage's survivor
/// ordinals would pick back out of a prior sweep family) driven through a REAL
/// bootstrapped harness produces one finite-metric `RunReport` per given point,
/// in the given order — never the cartesian product a `GridSpace` over the same
/// values would enumerate.
#[test]
fn list_space_reruns_an_arbitrary_survivor_subset_through_a_real_harness() {
let space = sma_cross_harness().0.param_space();
// a hand-picked, non-lattice subset: point 1 and point 2 share no axis value
// pairwise with what a full [fast]x[slow]x[scale] grid over these values
// would also produce as *other* combinations — this is a strict subset, not
// a grid, and `ListSpace` must run exactly these three, nothing else.
let survivors = vec![
vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(1.0)],
vec![Scalar::i64(3), Scalar::i64(5), Scalar::f64(0.5)],
vec![Scalar::i64(2), Scalar::i64(5), Scalar::f64(0.75)],
];
let ls = ListSpace::new(&space, survivors.clone()).expect("valid survivor subset");
let family = sweep(&ls, run_point);
assert_eq!(family.points.len(), 3, "exactly the given survivor points ran, no more");
for (i, expected) in survivors.iter().enumerate() {
let named = family.named_params(i);
let got: Vec<Scalar> = named.into_iter().map(|(_, v)| v).collect();
assert_eq!(&got, expected, "point {i} carried through in input order, untouched");
assert!(
family.points[i].report.metrics.total_pips.is_finite(),
"point {i} ran through the real bootstrap -> run -> summarize pipeline"
);
}
}
/// Property: the typed validation gate on `ListSpace::new` rejects a
/// wrong-kind value BEFORE any run — a `F64` value in the harness's `I64`
/// `fast` slot returns the public `SweepError::KindMismatch`, never a panic
/// and never a run of the malformed point.
#[test]
fn list_space_rejects_wrong_kind_value_before_any_run() {
let space = sma_cross_harness().0.param_space();
let err = ListSpace::new(
&space,
vec![vec![Scalar::f64(2.0), Scalar::i64(4), Scalar::f64(1.0)]],
)
.expect_err("fast slot is I64, not F64");
assert_eq!(
err,
SweepError::KindMismatch {
slot: 0,
value_index: 0,
expected: ScalarKind::I64,
got: ScalarKind::F64,
}
);
// and the schema type stays reachable even on the rejection path (a real
// author inspects it to correct the point)
let _: &[ParamSpec] = &space;
}
+105 -9
View File
@@ -29,8 +29,9 @@ mod compat;
mod lineage;
pub use lineage::{
group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports, Family,
FamilyKind, FamilyRunRecord,
group_families, mc_member_reports, sweep_member_reports, walkforward_member_reports,
CampaignRunRecord, CellRealization, Family, FamilyKind, FamilyRunRecord, StageRealization,
StageSelection,
};
mod trace_store;
@@ -106,11 +107,12 @@ impl Registry {
self.path.with_file_name("blueprints")
}
/// 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 — `get` returns `None` and
/// reproduction cannot re-derive the member, rather than erroring).
fn blueprint_path(&self, hash: &str) -> PathBuf {
/// 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"))
}
@@ -280,8 +282,10 @@ impl Registry {
Ok(faults)
}
/// Scan the blueprint store for a blueprint whose identity id matches.
fn find_blueprint_by_identity(
/// Scan the blueprint store for a blueprint whose identity id matches
/// public so a campaign run resolves the same identity refs the
/// referential tier validates.
pub fn find_blueprint_by_identity(
&self,
identity_id: &str,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
@@ -1604,4 +1608,96 @@ mod tests {
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![],
}
}
#[test]
fn campaign_run_counter_assigns_sequential_runs() {
let path = temp_family_dir("campaign_run_counter");
let reg = Registry::open(&path);
let a0 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 0");
let a1 = reg.append_campaign_run(&campaign_run_record("aaaa")).expect("aaaa run 1");
let b0 = reg.append_campaign_run(&campaign_run_record("bbbb")).expect("bbbb run 0");
assert_eq!((a0, a1, b0), (0, 1, 0), "per-campaign counter, independent per id");
// the stored lines carry the ASSIGNED run, not the input record's 99
let stored = reg.load_campaign_runs().expect("load");
assert_eq!(
stored.iter().map(|r| (r.campaign.as_str(), r.run)).collect::<Vec<_>>(),
vec![("aaaa", 0), ("aaaa", 1), ("bbbb", 0)],
);
}
#[test]
fn load_campaign_runs_missing_file_is_empty() {
let path = temp_family_dir("campaign_runs_missing");
let reg = Registry::open(&path);
assert_eq!(
reg.load_campaign_runs().expect("load missing"),
Vec::<CampaignRunRecord>::new()
);
}
#[test]
fn campaign_run_record_roundtrips() {
let path = temp_family_dir("campaign_run_roundtrip");
let reg = Registry::open(&path);
let record = CampaignRunRecord {
campaign: "cafe".to_string(),
process: "beef".to_string(),
run: 0, // matches the counter's first assignment, so whole-record PartialEq holds
seed: 7,
cells: vec![CellRealization {
strategy: "3f9c".to_string(),
instrument: "EURUSD".to_string(),
window_ms: (1_136_073_600_000, 1_154_390_400_000),
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,
},
}),
},
StageRealization {
block: "std::gate".to_string(),
family_id: None,
survivor_ordinals: Some(vec![0, 3, 4, 7]),
selection: None,
},
],
}],
};
let run = reg.append_campaign_run(&record).expect("append");
assert_eq!(run, 0);
assert_eq!(reg.load_campaign_runs().expect("load"), vec![record]);
}
}
+122 -1
View File
@@ -8,12 +8,18 @@
//! 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_engine::{McFamily, RunReport, SweepFamily, WalkForwardResult};
use aura_core::Scalar;
use aura_engine::{FamilySelection, McFamily, RunReport, SweepFamily, WalkForwardResult};
use serde::{Deserialize, Serialize};
use crate::{Registry, RegistryError};
@@ -59,6 +65,60 @@ pub struct Family {
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>,
}
/// 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>,
}
/// 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>,
}
/// 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,
}
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
@@ -115,6 +175,53 @@ impl Registry {
}
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. Reads the store once
/// to pick the run index (a read-before-write; single-process CLI
/// invocations do not race).
pub fn append_campaign_run(
&self,
record: &CampaignRunRecord,
) -> Result<usize, RegistryError> {
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)?;
let stored = CampaignRunRecord { run, ..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
@@ -130,6 +237,20 @@ fn next_run(name: &str, records: &[FamilyRunRecord]) -> usize {
.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)
}
/// 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
@@ -0,0 +1,129 @@
//! End-to-end coverage for the campaign-run store (cycle 0107 task 3): the
//! `campaign_runs.jsonl` sibling store's persistence and isolation properties,
//! driven through the PUBLIC `Registry` surface only (`append_family` /
//! `append_campaign_run` / `load_family_members` / `load_campaign_runs`) — the
//! in-crate unit tests in `lib.rs`/`lineage.rs` pin the counter and round-trip
//! shape on one live `Registry` instance; these tests pin the two properties a
//! real multi-invocation CLI depends on: the store survives a fresh `Registry`
//! handle (it is a real on-disk file, not an in-memory cache), and writing to
//! it never perturbs the existing family store it sits beside.
use std::fs;
use std::path::PathBuf;
use aura_core::{Scalar, Timestamp};
use aura_engine::{FamilySelection, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_registry::{
CampaignRunRecord, CellRealization, FamilyKind, Registry, StageRealization, StageSelection,
};
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("aura-registry-campaign-e2e-{}-{}", std::process::id(), name));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir.join("runs.jsonl")
}
fn tiny_report() -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: 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: 1.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None },
}
}
fn campaign_run(campaign: &str) -> CampaignRunRecord {
CampaignRunRecord {
campaign: campaign.to_string(),
process: "proc".to_string(),
run: 0,
seed: 1,
cells: vec![CellRealization {
strategy: "strat-id".to_string(),
instrument: "EURUSD".to_string(),
window_ms: (0, 1000),
stages: vec![StageRealization {
block: "std::sweep".to_string(),
family_id: Some(format!("{campaign}-0-EURUSD-w0-s0-0")),
survivor_ordinals: None,
selection: Some(StageSelection {
winner_ordinal: 0,
params: vec![("x".to_string(), Scalar::i64(1))],
selection: FamilySelection {
selection_metric: "sqn_normalized".to_string(),
n_trials: 1,
raw_winner_metric: 1.0,
mode: SelectionMode::Argmax,
deflated_score: None,
overfit_probability: None,
n_resamples: None,
block_len: None,
seed: None,
neighbourhood_score: None,
n_neighbours: None,
},
}),
}],
}],
}
}
/// Property: the campaign-run store is a real on-disk file, not a cache tied to
/// one `Registry` instance — a record appended by one `Registry::open` handle
/// is visible to a SECOND, freshly-opened handle over the same path. This is
/// what makes the store usable across separate CLI invocations (author runs
/// `aura campaign run` twice, in two processes, against the same registry).
#[test]
fn campaign_run_persists_across_fresh_registry_handles() {
let path = temp_dir("persist_across_handles");
let writer = Registry::open(&path);
let run = writer.append_campaign_run(&campaign_run("camp-a")).expect("append");
assert_eq!(run, 0);
// a brand-new handle, as a second CLI invocation would construct
let reader = Registry::open(&path);
let loaded = reader.load_campaign_runs().expect("load from fresh handle");
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].campaign, "camp-a");
assert_eq!(loaded[0].run, 0);
}
/// Property: `campaign_runs.jsonl` is a sibling store, wholly independent of
/// the existing `families.jsonl` flat-run store it sits beside — appending a
/// campaign run does not add, remove, or alter a single family member record,
/// and appending a family does not touch the campaign-run store. Two
/// independently-growing stores at the same registry root, per the module's
/// documented growth pattern.
#[test]
fn campaign_run_store_does_not_perturb_the_sibling_family_store() {
let path = temp_dir("sibling_isolation");
let reg = Registry::open(&path);
let before_families = reg.append_family("fam", FamilyKind::Sweep, &[tiny_report()]).unwrap();
let members_before = reg.load_family_members().expect("load families before");
assert_eq!(members_before.len(), 1);
reg.append_campaign_run(&campaign_run("camp-b")).expect("append campaign run");
// the family store is byte-for-byte unaffected by the campaign-run write
let members_after = reg.load_family_members().expect("load families after");
assert_eq!(members_after, members_before, "family store untouched by a campaign-run append");
assert_eq!(before_families, "fam-0");
// and the reverse: appending another family leaves the campaign-run store's
// single record alone
reg.append_family("fam2", FamilyKind::Sweep, &[tiny_report()]).unwrap();
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs.len(), 1, "campaign-run store untouched by a family append");
assert_eq!(runs[0].campaign, "camp-b");
}