feat(campaign): expand the campaign matrix over risk regimes (#210 T3)

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
This commit is contained in:
2026-07-06 14:22:59 +02:00
parent 2f9f3571ae
commit a0d8aec3f7
6 changed files with 145 additions and 33 deletions
+20 -7
View File
@@ -121,12 +121,21 @@ 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<aura_research::RiskRegime>)> = 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() {
for (regime_ordinal, regime) in &regimes {
let cell = CellSpec {
strategy_ordinal,
strategy_id: strategy_id.clone(),
@@ -134,6 +143,8 @@ pub fn execute(
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,
@@ -145,7 +156,7 @@ pub fn execute(
registry,
)?;
nominees
.entry((strategy_ordinal, window_ordinal))
.entry((strategy_ordinal, window_ordinal, *regime_ordinal))
.or_default()
.push((instrument.clone(), outcome.nominee.clone()));
cells_out.push(outcome);
@@ -153,6 +164,7 @@ pub fn execute(
}
}
}
}
// Campaign-scope generalize (#200 d2): for each (strategy, window), grade
// the per-instrument nominees via the shipped `generalization`; fewer than
@@ -163,7 +175,7 @@ pub fn execute(
});
let mut generalizations: Vec<CampaignGeneralization> = 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
+7 -1
View File
@@ -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<RiskRegime>,
/// 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.
+62 -3
View File
@@ -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(), &reg)
.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(), &reg)
.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<usize> =
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");
}
}
@@ -22,6 +22,8 @@ fn cell() -> CellSpec {
axes: BTreeMap::new(),
instrument: "EURUSD".to_string(),
window_ms: (0, 20),
regime: None,
regime_ordinal: 0,
}
}
+27
View File
@@ -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
+5
View File
@@ -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")]