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:
@@ -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 ®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<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user