From a0d8aec3f734eba091b595d6135df6939f0a8369 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 6 Jul 2026 14:22:59 +0200 Subject: [PATCH] feat(campaign): expand the campaign matrix over risk regimes (#210 T3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regime becomes the fourth structural coordinate of the campaign matrix, a kept-separate axis (a peer of window, not aggregated-over like instrument): - aura-campaign: CellSpec gains `regime` + `regime_ordinal`; the executor resolves the regime list (absent/empty risk -> one default cell, ordinal 0), runs a cell per (strategy, instrument, window, regime), and keys the nominee map by the 3-tuple including the regime ordinal — so generalize aggregates across instruments WITHIN a regime, never across regimes. Family names gain a `-r{regime_ordinal}` discriminator so two regimes' families cannot collide. - aura-registry: `CampaignGeneralization` gains `regime_ordinal` (with `#[serde(default)]`, so pre-feature stored records still parse — pinned by a new backward-compat test). The existing single-regime execute tests shift their family ids by `-r0`; a new test pins that two regimes yield distinct `-r0`/`-r1` families. `CellSpec.regime` is write-only in this slice — its sole reader is the deferred member runner (the next slice) — a held quality nit, seam-prep as the plan prescribes. Full workspace suite green. refs #210 --- crates/aura-campaign/src/exec.rs | 71 +++++++++++-------- crates/aura-campaign/src/lib.rs | 8 ++- crates/aura-campaign/tests/execute.rs | 65 ++++++++++++++++- crates/aura-campaign/tests/member_seam_e2e.rs | 2 + crates/aura-registry/src/lib.rs | 27 +++++++ crates/aura-registry/src/lineage.rs | 5 ++ 6 files changed, 145 insertions(+), 33 deletions(-) diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index c2938d8..5379ec6 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -121,35 +121,47 @@ pub fn execute( // Per-(strategy_ordinal, window_ordinal) nominees across instruments, in // cell-loop (instrument) order — the campaign-scope generalize input. type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>; - let mut nominees: BTreeMap<(usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); + let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new(); + // Absent/empty risk = a single default cell (regime None, ordinal 0); a + // non-empty list maps each regime to Some. The default's value is the member + // runner's — the doc is never mutated (its content id hashes with risk absent). + let regimes: Vec<(usize, Option)> = if campaign.risk.is_empty() { + vec![(0, None)] + } else { + campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect() + }; for (strategy_ordinal, (entry, (strategy_id, blueprint_json))) in campaign.strategies.iter().zip(strategies).enumerate() { for instrument in &campaign.data.instruments { for (window_ordinal, window) in campaign.data.windows.iter().enumerate() { - let cell = CellSpec { - strategy_ordinal, - strategy_id: strategy_id.clone(), - blueprint_json: blueprint_json.clone(), - axes: entry.axes.clone(), - instrument: instrument.clone(), - window_ms: (window.from_ms, window.to_ms), - }; - let (outcome, realization) = run_cell( - &cell, - process, - campaign_prefix, - window_ordinal, - campaign.seed, - runner, - registry, - )?; - nominees - .entry((strategy_ordinal, window_ordinal)) - .or_default() - .push((instrument.clone(), outcome.nominee.clone())); - cells_out.push(outcome); - cells_rec.push(realization); + for (regime_ordinal, regime) in ®imes { + let cell = CellSpec { + strategy_ordinal, + strategy_id: strategy_id.clone(), + blueprint_json: blueprint_json.clone(), + axes: entry.axes.clone(), + instrument: instrument.clone(), + window_ms: (window.from_ms, window.to_ms), + regime: *regime, + regime_ordinal: *regime_ordinal, + }; + let (outcome, realization) = run_cell( + &cell, + process, + campaign_prefix, + window_ordinal, + campaign.seed, + runner, + registry, + )?; + nominees + .entry((strategy_ordinal, window_ordinal, *regime_ordinal)) + .or_default() + .push((instrument.clone(), outcome.nominee.clone())); + cells_out.push(outcome); + cells_rec.push(realization); + } } } } @@ -163,7 +175,7 @@ pub fn execute( }); let mut generalizations: Vec = Vec::new(); if let Some(metric) = generalize_metric { - for ((strategy_ordinal, window_ordinal), cells) in &nominees { + for ((strategy_ordinal, window_ordinal, regime_ordinal), cells) in &nominees { let winners: Vec<(String, Vec<(String, Scalar)>)> = cells .iter() .filter_map(|(inst, nom)| { @@ -187,6 +199,7 @@ pub fn execute( generalizations.push(CampaignGeneralization { strategy_ordinal: *strategy_ordinal, window_ordinal: *window_ordinal, + regime_ordinal: *regime_ordinal, generalization: grade, winners, missing, @@ -247,8 +260,8 @@ fn run_cell( // Deterministic, self-describing family name (the "-{run}" // suffix is appended by the registry). let family_name = format!( - "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}", - cell.strategy_ordinal, cell.instrument, + "{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}", + cell.strategy_ordinal, cell.instrument, cell.regime_ordinal, ); let family = run_members(cell, &grid, runner)?; let family_id = registry @@ -349,8 +362,8 @@ fn run_cell( // Deterministic, self-describing family name (the "-{run}" // suffix is appended by the registry). let family_name = format!( - "{campaign_prefix}-{}-{}-w{window_ordinal}-s{stage_ordinal}", - cell.strategy_ordinal, cell.instrument, + "{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}", + cell.strategy_ordinal, cell.instrument, cell.regime_ordinal, ); // The seam crossing is plain points: the walk-forward stage // re-sweeps the surviving param points and never needs the diff --git a/crates/aura-campaign/src/lib.rs b/crates/aura-campaign/src/lib.rs index 5e743b6..9f26653 100644 --- a/crates/aura-campaign/src/lib.rs +++ b/crates/aura-campaign/src/lib.rs @@ -19,7 +19,7 @@ use std::collections::BTreeMap; use aura_core::Scalar; use aura_engine::RunReport; use aura_registry::{check_r_metric, RegistryError}; -use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, SelectRule, StageBlock}; +use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, RiskRegime, SelectRule, StageBlock}; /// One structural cell of the campaign matrix: (strategy, instrument, /// window) — #198 decision 7. @@ -34,6 +34,12 @@ pub struct CellSpec { pub instrument: String, /// Inclusive epoch-ms bounds. pub window_ms: (i64, i64), + /// The cell's risk regime. `None` = the member runner's baked default (the + /// absent/empty-`risk` case); `Some` = an explicit document regime. + pub regime: Option, + /// The regime's ordinal in the resolved regime list (0 for the default) — + /// keys the generalize unit and discriminates family names. + pub regime_ordinal: usize, } /// The harness/data binding seam — the ONLY thing a consumer implements. diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index db9a539..da3800d 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -236,7 +236,7 @@ fn execute_sweep_only_records_family_and_selection() { assert_eq!(out.cells.len(), 1); assert_eq!(out.cells[0].families.len(), 1); assert_eq!(out.cells[0].families[0].reports.len(), 4); - let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]); + let expected_family_id = format!("{}-0-EURUSD-w0-r0-s0-0", &CAMPAIGN_ID[..8]); assert_eq!(out.cells[0].families[0].family_id, expected_family_id); // planted argmax: (3,9) is odometer point 3 (last axis fastest) @@ -299,7 +299,7 @@ fn execute_selection_free_sweep_records_family_without_selection() { assert_eq!(out.cells[0].families.len(), 1); assert_eq!(out.cells[0].families[0].reports.len(), 4); assert_eq!(out.cells[0].families[0].block, "std::sweep"); - let expected_family_id = format!("{}-0-EURUSD-w0-s0-0", &CAMPAIGN_ID[..8]); + let expected_family_id = format!("{}-0-EURUSD-w0-r0-s0-0", &CAMPAIGN_ID[..8]); assert_eq!(out.cells[0].families[0].family_id, expected_family_id); assert!(out.cells[0].selections.is_empty(), "no winner is named without a selection group"); @@ -379,7 +379,7 @@ fn execute_is_deterministic_twice() { assert_eq!((first.record.run, second.record.run), (0, 1)); // family ids differ ONLY by the "-{run}" suffix - let base = format!("{}-0-EURUSD-w0-s0", &CAMPAIGN_ID[..8]); + let base = format!("{}-0-EURUSD-w0-r0-s0", &CAMPAIGN_ID[..8]); assert_eq!(first.cells[0].families[0].family_id, format!("{base}-0")); assert_eq!(second.cells[0].families[0].family_id, format!("{base}-1")); @@ -774,3 +774,62 @@ fn execute_persist_taps_stamps_trace_name() { let expected_1 = format!("{}-1", &CAMPAIGN_ID[..8]); assert_eq!(second.record.trace_name.as_deref(), Some(expected_1.as_str())); } + +/// Property (#210 T3): a campaign document's `risk` list is a structural +/// matrix axis, expanded like instruments/windows — each regime runs its own +/// full per-cell pipeline (its own cell, its own sweep family), distinguished +/// by a `-r{regime_ordinal}` family-name segment. +#[test] +fn execute_runs_one_family_per_regime_with_distinct_ids() { + let reg = temp_registry("two_regimes"); + let mut doc = campaign(&["EURUSD"]); + doc.risk = vec![ + aura_research::RiskRegime::Vol { length: 3, k: 1.5 }, + aura_research::RiskRegime::Vol { length: 3, k: 2.0 }, + ]; + let proc_doc = process(vec![sweep_stage(false)]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("two-regime campaign executes"); + + // one cell per regime, each with its own sweep family. + assert_eq!(out.cells.len(), 2, "one cell per regime"); + let p = &CAMPAIGN_ID[..8]; + assert_eq!(out.cells[0].families[0].family_id, format!("{p}-0-EURUSD-w0-r0-s0-0")); + assert_eq!(out.cells[1].families[0].family_id, format!("{p}-0-EURUSD-w0-r1-s0-0")); +} + +/// Property (#210 T3, C10 enforcement): generalize is graded PER REGIME, never +/// collapsed or cross-regime argmax'd — a regression that dropped +/// `regime_ordinal` from the campaign-scope nominee key (the `nominees` +/// `BTreeMap` in `execute`) would merge both regimes' per-instrument nominees +/// into ONE generalization group instead of two, doubling `winners` (with a +/// duplicate instrument name per group) rather than keeping each regime's +/// grade separate. Two instruments x two regimes, no wf stage (sweep winner +/// nominates directly) isolates the keying from any other stage's behaviour. +#[test] +fn execute_generalize_keeps_regimes_separate() { + let reg = temp_registry("generalize_regimes"); + let mut doc = campaign(&["AAA", "BBB"]); + doc.risk = vec![ + aura_research::RiskRegime::Vol { length: 3, k: 1.5 }, + aura_research::RiskRegime::Vol { length: 3, k: 2.0 }, + ]; + let proc_doc = process(vec![sweep_stage(false), generalize_stage()]); + let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®) + .expect("two-regime generalize campaign executes"); + + // 2 instruments x 2 regimes -> 4 cells, but exactly 2 generalize groups + // (one per regime), each grading exactly the 2 instruments in ITS regime. + assert_eq!(out.cells.len(), 4); + assert_eq!(out.record.generalizations.len(), 2, "one generalization per regime"); + let regime_ordinals: Vec = + out.record.generalizations.iter().map(|g| g.regime_ordinal).collect(); + assert_eq!(regime_ordinals, vec![0, 1], "each regime keeps its own generalize group"); + for g in &out.record.generalizations { + assert_eq!(g.winners.len(), 2, "each regime's group grades both instruments, not both regimes' nominees combined"); + let instruments: Vec<&str> = g.winners.iter().map(|(inst, _)| inst.as_str()).collect(); + assert_eq!(instruments, vec!["AAA", "BBB"], "no cross-regime duplicate instrument entries"); + assert!(g.missing.is_empty()); + assert!(g.generalization.is_some(), "two instruments -> graded"); + } +} diff --git a/crates/aura-campaign/tests/member_seam_e2e.rs b/crates/aura-campaign/tests/member_seam_e2e.rs index a086bb2..82e7308 100644 --- a/crates/aura-campaign/tests/member_seam_e2e.rs +++ b/crates/aura-campaign/tests/member_seam_e2e.rs @@ -22,6 +22,8 @@ fn cell() -> CellSpec { axes: BTreeMap::new(), instrument: "EURUSD".to_string(), window_ms: (0, 20), + regime: None, + regime_ordinal: 0, } } diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 84f6a9f..6752174 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -1852,6 +1852,7 @@ mod tests { generalizations: vec![CampaignGeneralization { strategy_ordinal: 0, window_ordinal: 0, + regime_ordinal: 0, generalization: Some(Generalization { selection_metric: "net_expectancy_r".to_string(), n_instruments: 2, @@ -1906,6 +1907,32 @@ mod tests { ); } + /// Property (#210 T3, C14/C23 widening convention): a stored + /// `campaign_runs.jsonl` line whose `generalizations` entries predate the + /// risk-regime axis — carrying `strategy_ordinal`/`window_ordinal`/ + /// `winners`/`missing` but no `regime_ordinal` key at all — still parses, + /// with `regime_ordinal` defaulting to 0 (`#[serde(default)]` on + /// `CampaignGeneralization::regime_ordinal`). Without that default, every + /// campaign-run store written before this iteration would fail to load + /// the instant it contained a recorded generalization. + #[test] + fn campaign_run_generalization_without_regime_ordinal_still_parses() { + let path = temp_family_dir("campaign_run_pre_regime_ordinal"); + let store = path.with_file_name("campaign_runs.jsonl"); + let line = r#"{"campaign":"cafe","process":"beef","run":0,"seed":7,"cells":[{"strategy":"3f9c","instrument":"EURUSD","window_ms":[0,1000],"stages":[{"block":"std::gate","survivor_ordinals":[0,2]}]}],"generalizations":[{"strategy_ordinal":0,"window_ordinal":0,"winners":[["EURUSD",[["fast.length",{"I64":3}]]]],"missing":["GER40"]}]}"#; + fs::write(&store, format!("{line}\n")).expect("write pre-regime-ordinal line"); + let reg = Registry::open(&path); + let loaded = reg.load_campaign_runs().expect("pre-regime-ordinal generalization parses"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].generalizations.len(), 1); + assert_eq!( + loaded[0].generalizations[0].regime_ordinal, 0, + "a missing regime_ordinal key defaults to 0, not a parse failure" + ); + assert_eq!(loaded[0].generalizations[0].strategy_ordinal, 0); + assert_eq!(loaded[0].generalizations[0].missing, vec!["GER40".to_string()]); + } + /// The 0109 name composition (#201 d5 / the spec's seam note): a `Some` /// trace_name on the input record — the executor's claim sentinel, /// content ignored — is replaced on the STORED line with the derived diff --git a/crates/aura-registry/src/lineage.rs b/crates/aura-registry/src/lineage.rs index 3f0c1e5..f337e8e 100644 --- a/crates/aura-registry/src/lineage.rs +++ b/crates/aura-registry/src/lineage.rs @@ -158,6 +158,11 @@ pub enum StageBootstrap { pub struct CampaignGeneralization { pub strategy_ordinal: usize, pub window_ordinal: usize, + /// The regime ordinal (0 for the default regime) — the third structural + /// coordinate, so a generalize grade is unambiguous per (strategy, window, + /// regime). `#[serde(default)]` keeps pre-feature stored records parsing. + #[serde(default)] + pub regime_ordinal: usize, /// None when fewer than 2 instrument cells produced a winner (gate /// truncation) — the shortfall is recorded, never computed around. #[serde(default, skip_serializing_if = "Option::is_none")]