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:
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user