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
+42 -29
View File
@@ -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<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() {
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 &regimes {
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<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,
}
}