diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 20a72c5..bd7b1a2 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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, diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index 7e845f7..0a0c150 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -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, + points: Vec>, +} + +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>) -> Result { + 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> { + 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::>().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::>().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"); + } } diff --git a/crates/aura-engine/tests/list_sweep_e2e.rs b/crates/aura-engine/tests/list_sweep_e2e.rs new file mode 100644 index 0000000..e57810a --- /dev/null +++ b/crates/aura-engine/tests/list_sweep_e2e.rs @@ -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)>, + mpsc::Receiver<(Timestamp, Vec)>, +) { + 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::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 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 = 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; +} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 4e4bea0..dc7dc6e 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -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, @@ -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![("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::::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]); + } } diff --git a/crates/aura-registry/src/lineage.rs b/crates/aura-registry/src/lineage.rs index 00c0426..a9ed66d 100644 --- a/crates/aura-registry/src/lineage.rs +++ b/crates/aura-registry/src/lineage.rs @@ -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, } +/// 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, +} + +/// 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, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub survivor_ordinals: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selection: Option, +} + +/// 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 { + 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, 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 diff --git a/crates/aura-registry/tests/campaign_run_store_e2e.rs b/crates/aura-registry/tests/campaign_run_store_e2e.rs new file mode 100644 index 0000000..c09fb2e --- /dev/null +++ b/crates/aura-registry/tests/campaign_run_store_e2e.rs @@ -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"); +}