From a1cd7bc6f55c9042bff175957f81eb396190580d Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 4 Jul 2026 00:57:35 +0200 Subject: [PATCH] feat(campaign): monte_carlo stage arm + campaign-scope generalize phase (0108 tasks 3-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-campaign/src/exec.rs | 150 +++++++++++- crates/aura-campaign/tests/execute.rs | 335 +++++++++++++++++++++++++- 2 files changed, 472 insertions(+), 13 deletions(-) diff --git a/crates/aura-campaign/src/exec.rs b/crates/aura-campaign/src/exec.rs index 1a906a8..1a658a1 100644 --- a/crates/aura-campaign/src/exec.rs +++ b/crates/aura-campaign/src/exec.rs @@ -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, pub selections: Vec, + /// 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 = Vec::new(); let mut cells_rec: Vec = 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 = 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 = 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, 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 { 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 { + 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 diff --git a/crates/aura-campaign/tests/execute.rs b/crates/aura-campaign/tests/execute.rs index e795597..77f9ed8 100644 --- a/crates/aura-campaign/tests/execute.rs +++ b/crates/aura-campaign/tests/execute.rs @@ -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 { + 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 { + 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 { @@ -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) -> 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(), ®) + .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(), ®) + .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 = 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, ®) + .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![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(), ®) + .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(), ®) + .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(), ®) + .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(), ®) + .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 = 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); +}