feat(campaign): monte_carlo stage arm + campaign-scope generalize phase (0108 tasks 3-4)

The mc arm bootstraps the stage's incoming R-evidence with one
semantics, input-shaped by position (#200 decision 1): after a
walk_forward it pools the wf family's per-window OOS trade_rs in roll
order into ONE r_bootstrap (PooledOos); after sweep/gates it
bootstraps each surviving member's fresh in-memory series
(PerSurvivor, ordinals into the population family; a zero-trade
member records the engine's defined all-zero degenerate). Seeded from
the campaign doc's seed (C1). The wf family is read by reference from
the cell's families vec — no clone. run_cell's Generalize arm is an
explicit no-op naming the campaign scope.

The generalize phase runs after the cell loop (#200 decision 2):
nominees (last wf window's OOS report, else the sweep winner; None on
gate truncation) grouped by (strategy, window) across instruments via
a BTreeMap (deterministic order); >= 2 winners feed the shipped
generalization(), fewer record the shortfall (generalization: None,
missing named) — divergent per-instrument winners are exposed via
their params, never averaged away.

Gates: campaign 40/0 (4 execute_mc + 3 execute_generalize new),
workspace build clean; the known-RED cli integration test flips in
task 5.

refs #200
This commit is contained in:
2026-07-04 00:57:35 +02:00
parent ab778f2361
commit a1cd7bc6f5
2 changed files with 472 additions and 13 deletions
+141 -9
View File
@@ -12,13 +12,14 @@ use std::sync::Mutex;
use aura_analysis::{FamilySelection, SelectionMode};
use aura_core::{zip_params, Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics, RunReport,
Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller, WindowRun,
r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics,
RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller,
WindowRun,
};
use aura_registry::{
optimize, optimize_deflated, optimize_plateau, sweep_member_reports,
walkforward_member_reports, CampaignRunRecord, CellRealization, FamilyKind, PlateauMode,
Registry, StageRealization, StageSelection,
generalization, optimize, optimize_deflated, optimize_plateau, sweep_member_reports,
walkforward_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization,
FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
};
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
@@ -54,6 +55,11 @@ pub struct StageSelectionOut {
pub struct CellOutcome {
pub families: Vec<StageFamily>,
pub selections: Vec<StageSelectionOut>,
/// The cell's nominated generalize candidate (spec-fixed): the LAST
/// walk-forward window's OOS report + chosen params when a wf stage ran
/// (the freshest out-of-sample evidence for the finally-selected params),
/// else the sweep stage's winner; `None` for gate-truncated cells.
pub nominee: Option<(Vec<(String, Scalar)>, RunReport)>,
}
/// Everything one campaign run produced: the stored realization record, the
@@ -111,6 +117,10 @@ pub fn execute(
let mut cells_out: Vec<CellOutcome> = Vec::new();
let mut cells_rec: Vec<CellRealization> = Vec::new();
// 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();
for (strategy_ordinal, (entry, (strategy_id, blueprint_json))) in
campaign.strategies.iter().zip(strategies).enumerate()
{
@@ -133,19 +143,63 @@ pub fn execute(
runner,
registry,
)?;
nominees
.entry((strategy_ordinal, window_ordinal))
.or_default()
.push((instrument.clone(), outcome.nominee.clone()));
cells_out.push(outcome);
cells_rec.push(realization);
}
}
}
// Campaign-scope generalize (#200 d2): for each (strategy, window), grade
// the per-instrument nominees via the shipped `generalization`; fewer than
// 2 winners is a recorded shortfall, never computed around.
let generalize_metric = process.pipeline.iter().find_map(|s| match s {
StageBlock::Generalize { metric } => Some(metric.clone()),
_ => None,
});
let mut generalizations: Vec<CampaignGeneralization> = Vec::new();
if let Some(metric) = generalize_metric {
for ((strategy_ordinal, window_ordinal), cells) in &nominees {
let winners: Vec<(String, Vec<(String, Scalar)>)> = cells
.iter()
.filter_map(|(inst, nom)| {
nom.as_ref().map(|(params, _)| (inst.clone(), params.clone()))
})
.collect();
let missing: Vec<String> = cells
.iter()
.filter(|(_, nom)| nom.is_none())
.map(|(inst, _)| inst.clone())
.collect();
let pairs: Vec<(String, &RunReport)> = cells
.iter()
.filter_map(|(inst, nom)| nom.as_ref().map(|(_, report)| (inst.clone(), report)))
.collect();
let grade = if pairs.len() >= 2 {
Some(generalization(&pairs, &metric).map_err(ExecFault::Registry)?)
} else {
None
};
generalizations.push(CampaignGeneralization {
strategy_ordinal: *strategy_ordinal,
window_ordinal: *window_ordinal,
generalization: grade,
winners,
missing,
});
}
}
let mut record = CampaignRunRecord {
campaign: campaign_id.to_string(),
process: process_id,
run: 0,
seed: campaign.seed,
cells: cells_rec,
generalizations: Vec::new(),
generalizations,
};
let run = registry.append_campaign_run(&record).map_err(ExecFault::Registry)?;
record.run = run;
@@ -170,6 +224,9 @@ fn run_cell(
// The surviving population: (ordinal into the nearest preceding
// family_id-bearing stage's family, param point, member report).
let mut survivors: Vec<(usize, Vec<Scalar>, RunReport)> = Vec::new();
// The cell's nominated generalize candidate: last wf window's OOS report
// when a walk_forward ran, else the sweep winner; None when truncated.
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
match stage {
@@ -200,6 +257,9 @@ fn run_cell(
params: params.clone(),
selection: selection.clone(),
});
// Nominate the sweep winner (superseded by a later
// walk_forward stage; cleared by an empty gate).
nominee = Some((params.clone(), winner.report.clone()));
stages.push(StageRealization {
block: "std::sweep".to_string(),
family_id: Some(family_id.clone()),
@@ -245,6 +305,7 @@ fn run_cell(
bootstrap: None,
});
if empty {
nominee = None; // a truncated cell nominates no candidate
break; // decision 8: truncate this cell, keep running cells
}
}
@@ -278,16 +339,73 @@ fn run_cell(
selection: None,
bootstrap: None,
});
// Nominate the LAST window's OOS report (roll order per
// walkforward_member_reports) with its chosen params — the wf
// stage stamps them on the fresh OOS report's manifest.
nominee = fam
.reports
.last()
.map(|last| (last.manifest.params.clone(), last.clone()));
families.push(fam);
}
StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => {
unreachable!("preflight refuses non-v1 stages before any member runs")
StageBlock::MonteCarlo { resamples, block_len } => {
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
// input: after a walk_forward, ONE bootstrap over the pooled
// per-window OOS trade-R series (roll order); after
// sweep/gates, one bootstrap per surviving member. Seeded from
// the campaign seed (the deflation convention, C1).
let bootstrap =
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
&pooled_trade_rs(&fam.reports),
*resamples as usize,
*block_len as usize,
seed,
)),
// Zero-trade members (r: None) feed an empty slice —
// the engine's defined all-zero degenerate, recorded
// explicitly (a null result is a valid result).
None => StageBootstrap::PerSurvivor(
survivors
.iter()
.map(|(ordinal, _, report)| {
let rs = report
.metrics
.r
.as_ref()
.map(|r| r.trade_rs.as_slice())
.unwrap_or(&[]);
(
*ordinal,
r_bootstrap(
rs,
*resamples as usize,
*block_len as usize,
seed,
),
)
})
.collect(),
),
};
stages.push(StageRealization {
block: "std::monte_carlo".to_string(),
family_id: None,
survivor_ordinals: None,
selection: None,
bootstrap: Some(bootstrap),
});
}
StageBlock::Generalize { .. } => {
// Campaign-scope stage (#200 d2): `execute` runs it AFTER the
// cell loop, over the per-cell nominees across instruments —
// nothing realizes per cell.
}
}
}
Ok((
CellOutcome { families, selections },
CellOutcome { families, selections, nominee },
CellRealization {
strategy: cell.strategy_id.clone(),
instrument: cell.instrument.clone(),
@@ -452,6 +570,20 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
}
/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs`
/// concatenated in report order — which IS roll order (the wf stage builds its
/// family via `walkforward_member_reports`, per-window OOS reports in roll
/// order). Reports without an R block contribute nothing.
fn pooled_trade_rs(reports: &[RunReport]) -> Vec<f64> {
let mut pooled = Vec::new();
for report in reports {
if let Some(r) = &report.metrics.r {
pooled.extend_from_slice(&r.trade_rs);
}
}
pooled
}
/// A placeholder for a faulted window slot: arity-correct chosen params (the
/// engine asserts every window's chosen-point arity against the param-space),
/// empty OOS equity, inert report. Never surfaces — a recorded window fault
+331 -4
View File
@@ -6,11 +6,11 @@ use std::collections::BTreeMap;
use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner};
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_engine::{RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_registry::{FamilyKind, Registry};
use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap};
use aura_research::{
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
ProcessRef, SelectRule, StageBlock, StrategyEntry, Window,
ProcessRef, SelectRule, StageBlock, StrategyEntry, WfMode, Window,
};
const CAMPAIGN_ID: &str = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999";
@@ -39,6 +39,14 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
.expect("planted axis present")
}
/// The deterministic 4-trade R series a planted report carries (matches
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory
/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every
/// existing equality and round-trip assertion stays green.
fn planted_trade_rs(net: f64) -> Vec<f64> {
vec![net, -net, 2.0 * net, net]
}
fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64, i64)) -> RunReport {
let fast = param_i64(params, "fast");
let slow = param_i64(params, "slow");
@@ -75,7 +83,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
sqn_normalized: net,
net_expectancy_r: net,
conviction_terciles_r: [0.0, 0.0, 0.0],
trade_rs: Vec::new(),
trade_rs: planted_trade_rs(net),
}),
},
}
@@ -95,6 +103,28 @@ impl MemberRunner for FakeRunner {
}
}
/// Wraps the clean fake and strips the R block from ONE param point's report —
/// the zero-trade survivor for the mc degenerate pin.
struct ZeroTradeRunner {
inner: FakeRunner,
strip: Vec<(String, Scalar)>,
}
impl MemberRunner for ZeroTradeRunner {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
let mut report = self.inner.run_member(cell, params, window_ms)?;
if params == self.strip.as_slice() {
report.metrics.r = None;
}
Ok(report)
}
}
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest):
/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
fn axes_2x2() -> BTreeMap<String, Axis> {
@@ -144,6 +174,24 @@ fn gate_stage(value: f64) -> StageBlock {
}
}
/// 2 rolling windows over the campaign window [1_000, 9_000]: IS 4_000 ms,
/// OOS 2_000 ms, step 2_000 ms -> OOS [5_000, 6_999] and [7_000, 8_999]
/// (window 2 would need OOS end 10_999 > 9_000, so the roller stops at 2).
fn wf_stage() -> StageBlock {
StageBlock::WalkForward {
in_sample_ms: 4_000,
out_of_sample_ms: 2_000,
step_ms: 2_000,
mode: WfMode::Rolling,
metric: "net_expectancy_r".to_string(),
select: SelectRule::Argmax,
}
}
fn generalize_stage() -> StageBlock {
StageBlock::Generalize { metric: "net_expectancy_r".to_string() }
}
fn process(pipeline: Vec<StageBlock>) -> ProcessDoc {
ProcessDoc {
format_version: 1,
@@ -358,3 +406,282 @@ fn execute_refuses_malformed_campaign_id() {
}
assert!(reg.load_family_members().expect("load members").is_empty());
}
#[test]
fn execute_mc_after_gate_bootstraps_each_survivor() {
let reg = temp_registry("mc_per_survivor");
let doc = campaign(&["EURUSD"]); // seed: 7
let proc_doc = process(vec![
sweep_stage(false),
gate_stage(0.3),
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("mc-after-gate campaign executes");
// planted nets .26/.29/.36/.39 -> gate gt 0.3 keeps ordinals 2 and 3
let cell = &out.record.cells[0];
assert_eq!(cell.stages.len(), 3);
let mc = &cell.stages[2];
assert_eq!(mc.block, "std::monte_carlo");
assert_eq!(mc.family_id, None);
assert_eq!(mc.survivor_ordinals, None);
assert_eq!(mc.selection, None);
// each survivor's bootstrap == a hand-called r_bootstrap on its planted
// trade_rs, seeded from the campaign doc (the deflation convention)
let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
let expected = StageBootstrap::PerSurvivor(vec![
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)),
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)),
]);
assert_eq!(mc.bootstrap, Some(expected));
// the record round-trips through the store with the bootstrap intact
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs[0], out.record);
}
#[test]
fn execute_mc_after_wf_pools_the_oos_series() {
let reg = temp_registry("mc_pooled");
let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000]
let proc_doc = process(vec![
sweep_stage(false),
wf_stage(),
StageBlock::MonteCarlo { resamples: 200, block_len: 3 },
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("mc-after-wf campaign executes");
// the roller yields 2 windows; both pick the planted argmax (3,9)
let wf = &out.cells[0].families[1];
assert_eq!(wf.block, "std::walk_forward");
assert_eq!(wf.reports.len(), 2);
// pooled input = the wf family reports' trade_rs concatenated in report
// order (roll order per walkforward_member_reports)
let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports {
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
}
let net = (3 * 10 + 9) as f64 / 100.0;
let mut expected_pool = planted_trade_rs(net);
expected_pool.extend(planted_trade_rs(net));
assert_eq!(pooled, expected_pool);
let mc = &out.record.cells[0].stages[2];
assert_eq!(mc.block, "std::monte_carlo");
assert_eq!(mc.family_id, None);
assert_eq!(mc.bootstrap, Some(StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 3, 7))));
}
#[test]
fn execute_mc_zero_trade_member_records_degenerate() {
let reg = temp_registry("mc_zero_trade");
let doc = campaign(&["EURUSD"]); // seed: 7
let proc_doc =
process(vec![sweep_stage(false), StageBlock::MonteCarlo { resamples: 200, block_len: 2 }]);
// ordinal 0 == odometer point (2,6): its report carries no R block at all
let runner = ZeroTradeRunner {
inner: FakeRunner::clean(),
strip: vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))],
};
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
.expect("a zero-trade member is a valid result, not a fault");
let mc = &out.record.cells[0].stages[1];
assert_eq!(mc.block, "std::monte_carlo");
let Some(StageBootstrap::PerSurvivor(entries)) = &mc.bootstrap else {
panic!("expected a per-survivor bootstrap, got {:?}", mc.bootstrap);
};
// no gate ran: all 4 members survive, each annotated
assert_eq!(entries.iter().map(|(o, _)| *o).collect::<Vec<_>>(), vec![0, 1, 2, 3]);
// the r: None member records the engine's defined all-zero degenerate
assert_eq!(entries[0].1, r_bootstrap(&[], 200, 2, 7));
assert_eq!(entries[0].1.n_trades, 0);
assert_eq!(entries[0].1.e_r.mean, 0.0);
assert_eq!(entries[0].1.prob_le_zero, 0.0);
}
#[test]
fn execute_generalize_across_two_instruments() {
let reg = temp_registry("generalize_two");
// the fake negates "AAA" nets -> divergent winners across instruments
let doc = campaign(&["AAA", "BBB"]); // seed: 7
let proc_doc = process(vec![sweep_stage(false), wf_stage(), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("generalize campaign executes");
assert_eq!(out.record.generalizations.len(), 1);
let g = &out.record.generalizations[0];
assert_eq!((g.strategy_ordinal, g.window_ordinal), (0, 0));
assert!(g.missing.is_empty());
// winners carry each instrument's nominated params (the last wf window's
// chosen point): AAA argmaxes its NEGATED nets -> (2,6); BBB -> (3,9)
assert_eq!(
g.winners,
vec![
(
"AAA".to_string(),
vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))],
),
(
"BBB".to_string(),
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))],
),
],
);
// the stored grade == the shipped generalization() hand-called over the
// two nominated reports (cells are in instrument order: AAA then BBB)
let pairs: Vec<(String, &RunReport)> = out
.cells
.iter()
.zip(["AAA", "BBB"])
.map(|(cell, inst)| {
let (_, report) = cell.nominee.as_ref().expect("nominee present");
(inst.to_string(), report)
})
.collect();
let expected = generalization(&pairs, "net_expectancy_r").expect("hand-called generalization");
assert_eq!(g.generalization.as_ref(), Some(&expected));
// divergence is exposed, never averaged: worst case is AAA's negated net
let aaa_net = -((2 * 10 + 6) as f64 / 100.0);
let bbb_net = (3 * 10 + 9) as f64 / 100.0;
assert_eq!(
expected.per_instrument,
vec![("AAA".to_string(), aaa_net), ("BBB".to_string(), bbb_net)],
);
assert_eq!(expected.worst_case, aaa_net);
assert_eq!(expected.sign_agreement, 1);
// the record round-trips through the store with the generalization intact
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs[0], out.record);
}
#[test]
fn execute_generalize_shortfall_records_missing() {
let reg = temp_registry("generalize_shortfall");
// AAA's planted nets are all negative -> the gt-0 gate empties its cell
let doc = campaign(&["AAA", "BBB"]);
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("a truncated cell is a recorded shortfall, not a fault");
assert!(out.cells[0].nominee.is_none(), "gate-truncated cell nominates nothing");
assert_eq!(out.record.generalizations.len(), 1);
let g = &out.record.generalizations[0];
assert_eq!(g.generalization, None, "fewer than 2 winners -> no grade computed");
assert_eq!(g.missing, vec!["AAA".to_string()]);
assert_eq!(g.winners.len(), 1);
assert_eq!(g.winners[0].0, "BBB");
assert_eq!(
g.winners[0].1,
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))],
);
}
#[test]
fn execute_generalize_only_after_sweep() {
let reg = temp_registry("generalize_sweep_only");
let doc = campaign(&["EURUSD", "GER40"]);
let proc_doc = process(vec![sweep_stage(false), generalize_stage()]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("sweep-only generalize campaign executes");
// no wf ran: each cell nominates its sweep winner (planted argmax (3,9))
let winner_params =
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))];
for cell in &out.cells {
let (params, report) = cell.nominee.as_ref().expect("sweep winner nominated");
assert_eq!(params, &winner_params);
assert_eq!(report.manifest.params, winner_params);
}
let g = &out.record.generalizations[0];
assert_eq!(
g.winners,
vec![
("EURUSD".to_string(), winner_params.clone()),
("GER40".to_string(), winner_params.clone()),
],
);
assert!(g.missing.is_empty());
let grade = g.generalization.as_ref().expect("two winners -> graded");
let net = (3 * 10 + 9) as f64 / 100.0;
assert_eq!(grade.n_instruments, 2);
assert_eq!(grade.sign_agreement, 2);
assert_eq!(grade.worst_case, net);
}
/// Property (0108 acceptance criterion 1, the spec's worked example shape
/// `sweep, gate, walk_forward, monte_carlo, generalize`): the two terminal
/// annotators compose in ONE pipeline without disturbing each other or the
/// population stages' generalize-nominee. `std::monte_carlo` sits between
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its
/// per-cell bootstrap still pools exactly the wf family's OOS `trade_rs`
/// (unaffected by generalize running after it), and `std::generalize`'s
/// campaign-scope nominee is still the wf stage's last-window winner
/// (unaffected by mc having annotated the cell first). A regression that
/// let one annotator's bookkeeping leak into or clobber the other's input
/// (e.g. mc consuming the nominee, or generalize reading mc's output)
/// would flip an assertion here even though each annotator's own isolated
/// test (above) stays green.
#[test]
fn execute_mc_and_generalize_compose_in_one_pipeline() {
let reg = temp_registry("mc_and_generalize_compose");
let doc = campaign(&["AAA", "BBB"]); // seed: 7
let proc_doc = process(vec![
sweep_stage(false),
gate_stage(-1.0), // gt -1.0: every planted net (AAA negated too) survives
wf_stage(),
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
generalize_stage(),
]);
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("full v2 pipeline executes");
// both cells realize all four per-cell stages; generalize contributes no
// per-cell stage (it is the campaign-scope phase after the cell loop)
for cell in &out.record.cells {
assert_eq!(cell.stages.len(), 4);
assert_eq!(cell.stages[3].block, "std::monte_carlo");
}
// mc's bootstrap pools the wf family's OOS trade_rs exactly as it does
// with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
let wf = &out.cells[0].families[1];
assert_eq!(wf.block, "std::walk_forward");
let mut pooled: Vec<f64> = Vec::new();
for report in &wf.reports {
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
}
let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
// generalize still nominates the wf stage's last-window winner per
// instrument, sourced independently of the mc stage between them
assert_eq!(out.record.generalizations.len(), 1);
let g = &out.record.generalizations[0];
assert!(g.missing.is_empty(), "the -1.0 gate keeps every member on both instruments");
// AAA's nets are negated by the fake -> argmax picks the LEAST-negative
// point (2,6); BBB argmaxes its unmodified nets at (3,9) (same as
// execute_generalize_across_two_instruments).
assert_eq!(
g.winners[0],
("AAA".to_string(), vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))]),
);
assert_eq!(
g.winners[1],
("BBB".to_string(), vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))]),
);
assert!(g.generalization.is_some(), "two instruments -> graded");
// the record round-trips through the store with BOTH annotations intact
let runs = reg.load_campaign_runs().expect("load campaign runs");
assert_eq!(runs[0], out.record);
}