//! Executor semantics over a fake `MemberRunner` (hermetic — no engine
//! harness, no data): cell loop, sweep stage, gate filtering, zero-survivor
//! truncation, determinism, fault attribution, deflation seeding.

use std::collections::BTreeMap;

use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner};
use aura_core::{Scalar, ScalarKind, Timestamp};
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, SweepSelection, WfMode, Window,
};

const CAMPAIGN_ID: &str = "aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999";
const PROCESS_ID: &str = "9999888877776666555544443333222211110000ffffeeeeddddccccbbbbaaaa";
const STRATEGY_ID: &str = "1111222233334444555566667777888899990000aaaabbbbccccddddeeeeffff";

/// Deterministic fake: every planted metric is a pure function of
/// (params, instrument). `fast`/`slow` are the two I64 axes;
/// net = (fast*10 + slow)/100, negated for the instrument named "AAA".
struct FakeRunner {
    /// (params that fault, the fault) — checked before planting a report.
    faults: Vec<(Vec<(String, Scalar)>, MemberFault)>,
}

impl FakeRunner {
    fn clean() -> Self {
        FakeRunner { faults: Vec::new() }
    }
}

fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
    params
        .iter()
        .find(|(n, _)| n == name)
        .map(|(_, v)| v.as_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");
    let mut net = (fast * 10 + slow) as f64 / 100.0;
    if cell.instrument == "AAA" {
        net = -net;
    }
    RunReport {
        manifest: RunManifest {
            commit: "fake".to_string(),
            params: params.to_vec(),
            window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
            seed: 0,
            broker: "fake".to_string(),
            selection: None,
            instrument: Some(cell.instrument.clone()),
            topology_hash: Some(cell.strategy_id.clone()),
            project: None,
        },
        metrics: RunMetrics {
            total_pips: net * 100.0,
            max_drawdown: 1.0,
            bias_sign_flips: 1,
            r: Some(RMetrics {
                expectancy_r: net,
                n_trades: 4,
                win_rate: 0.5,
                avg_win_r: 1.0,
                avg_loss_r: -0.5,
                profit_factor: 2.0,
                max_r_drawdown: 0.5,
                n_open_at_end: 0,
                sqn: net,
                sqn_normalized: net,
                net_expectancy_r: net,
                conviction_terciles_r: [0.0, 0.0, 0.0],
                trade_rs: planted_trade_rs(net),
            }),
        },
    }
}

impl MemberRunner for FakeRunner {
    fn run_member(
        &self,
        cell: &CellSpec,
        params: &[(String, Scalar)],
        window_ms: (i64, i64),
    ) -> Result<RunReport, MemberFault> {
        if let Some((_, fault)) = self.faults.iter().find(|(p, _)| p.as_slice() == params) {
            return Err(fault.clone());
        }
        Ok(planted_report(cell, params, window_ms))
    }
}

/// 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> {
    let mut axes = BTreeMap::new();
    axes.insert(
        "fast".to_string(),
        Axis { kind: ScalarKind::I64, values: vec![Scalar::i64(2), Scalar::i64(3)] },
    );
    axes.insert(
        "slow".to_string(),
        Axis { kind: ScalarKind::I64, values: vec![Scalar::i64(6), Scalar::i64(9)] },
    );
    axes
}

fn campaign(instruments: &[&str]) -> CampaignDoc {
    CampaignDoc {
        format_version: 1,
        kind: DocKind::Campaign,
        name: "exec-test".to_string(),
        description: None,
        data: DataSection {
            instruments: instruments.iter().map(|s| s.to_string()).collect(),
            windows: vec![Window { from_ms: 1_000, to_ms: 9_000 }],
        },
        risk: vec![],
        strategies: vec![StrategyEntry {
            r#ref: DocRef::ContentId(STRATEGY_ID.to_string()),
            axes: axes_2x2(),
        }],
        process: ProcessRef { r#ref: DocRef::ContentId(PROCESS_ID.to_string()) },
        seed: 7,
        presentation: Presentation { persist_taps: vec![], emit: vec![] },
    }
}

fn sweep_stage(deflate: bool) -> StageBlock {
    StageBlock::Sweep {
        selection: Some(SweepSelection {
            metric: "net_expectancy_r".to_string(),
            select: SelectRule::Argmax,
            deflate,
        }),
    }
}

fn selection_free_sweep_stage() -> StageBlock {
    StageBlock::Sweep { selection: None }
}

fn gate_stage(value: f64) -> StageBlock {
    StageBlock::Gate {
        all: vec![Predicate { metric: "net_expectancy_r".to_string(), cmp: Cmp::Gt, value }],
    }
}

/// 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,
        kind: DocKind::Process,
        name: "exec-test-process".to_string(),
        description: None,
        pipeline,
    }
}

fn strategies() -> Vec<(String, String)> {
    vec![(STRATEGY_ID.to_string(), r#"{"format_version":1}"#.to_string())]
}

/// Per-test registry DIRECTORY (family/campaign stores are per-directory
/// siblings of the runs path — the aura-registry temp-dir idiom).
fn temp_registry(name: &str) -> Registry {
    let dir = std::env::temp_dir()
        .join(format!("aura-campaign-exec-{}-{}", std::process::id(), name));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create temp registry dir");
    Registry::open(dir.join("runs.jsonl"))
}

#[test]
fn execute_sweep_only_records_family_and_selection() {
    let reg = temp_registry("sweep_only");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![sweep_stage(false)]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("sweep-only campaign executes");

    // outcome payloads: one cell, one family of 4 members, one selection
    assert_eq!(out.run, 0);
    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-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)
    assert_eq!(out.cells[0].selections.len(), 1);
    let sel = &out.cells[0].selections[0];
    assert_eq!(sel.winner_ordinal, 3);
    assert_eq!(
        sel.params,
        vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))],
    );

    // family persisted: 4 sweep members under the derived id, ordinal-ordered
    let members = reg.load_family_members().expect("load members");
    assert_eq!(members.len(), 4);
    assert!(members.iter().all(|m| m.kind == FamilyKind::Sweep));
    assert!(members.iter().all(|m| m.family_id() == expected_family_id));
    assert_eq!(members.iter().map(|m| m.ordinal).collect::<Vec<_>>(), vec![0, 1, 2, 3]);

    // realization persisted: one record linking the family + the selection
    let runs = reg.load_campaign_runs().expect("load campaign runs");
    assert_eq!(runs.len(), 1);
    assert_eq!(runs[0], out.record);
    assert_eq!(runs[0].campaign, CAMPAIGN_ID);
    assert_eq!(runs[0].process, PROCESS_ID);
    assert_eq!(runs[0].run, 0);
    assert_eq!(runs[0].seed, 7);
    assert_eq!(
        runs[0].trace_name, None,
        "empty persist_taps claims no trace name (0109 contract)"
    );
    assert_eq!(runs[0].cells.len(), 1);
    let cell = &runs[0].cells[0];
    assert_eq!(cell.strategy, STRATEGY_ID);
    assert_eq!(cell.instrument, "EURUSD");
    assert_eq!(cell.window_ms, (1_000, 9_000));
    assert_eq!(cell.stages.len(), 1);
    assert_eq!(cell.stages[0].block, "std::sweep");
    assert_eq!(cell.stages[0].family_id.as_deref(), Some(expected_family_id.as_str()));
    let stored = cell.stages[0].selection.as_ref().expect("sweep stage carries a selection");
    assert_eq!(stored.winner_ordinal, 3);
    assert_eq!(stored.selection.mode, SelectionMode::Argmax);
    assert_eq!(stored.selection.n_trials, 4);
    assert!((stored.selection.raw_winner_metric - 0.39).abs() < 1e-12);
    assert_eq!(stored.selection.seed, None, "no deflation annotation without deflate");
}

/// A selection-free sweep records the family (the whole grid, exactly as a
/// selected sweep does) but never a selection or a nominee: there is no
/// winner to name, and nothing downstream to nominate for.
#[test]
fn execute_selection_free_sweep_records_family_without_selection() {
    let reg = temp_registry("selection_free_sweep");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![selection_free_sweep_stage()]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("selection-free sweep campaign executes");

    // outcome payloads: one cell, one family of 4 members, NO 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);
    assert_eq!(out.cells[0].families[0].block, "std::sweep");
    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");

    // family persisted: 4 sweep members under the derived id, same as a
    // selected sweep — the selection-free path differs only in what it
    // records ABOUT the family, never in the family itself.
    let members = reg.load_family_members().expect("load members");
    assert_eq!(members.len(), 4);
    assert!(members.iter().all(|m| m.kind == FamilyKind::Sweep));

    // realization: the stage carries a family_id but no selection; no
    // nominee-dependent stage ran, so no generalization is recorded either.
    let runs = reg.load_campaign_runs().expect("load campaign runs");
    assert_eq!(runs.len(), 1);
    assert_eq!(runs[0], out.record);
    assert_eq!(runs[0].cells[0].stages.len(), 1);
    let stage = &runs[0].cells[0].stages[0];
    assert_eq!(stage.block, "std::sweep");
    assert_eq!(stage.family_id.as_deref(), Some(expected_family_id.as_str()));
    assert!(stage.selection.is_none(), "a selection-free sweep records no selection");
    assert!(runs[0].generalizations.is_empty());
}

#[test]
fn execute_gate_filters_per_member() {
    let reg = temp_registry("gate_filters");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![sweep_stage(false), gate_stage(0.3)]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("gated campaign executes");

    // planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
    let cell = &out.record.cells[0];
    assert_eq!(cell.stages.len(), 2);
    assert_eq!(cell.stages[1].block, "std::gate");
    assert_eq!(cell.stages[1].survivor_ordinals, Some(vec![2, 3]));
    assert_eq!(cell.stages[1].family_id, None);
    assert_eq!(cell.stages[1].selection, None);
}

#[test]
fn execute_zero_survivors_truncates_cell_and_continues() {
    let reg = temp_registry("zero_survivors");
    // two instruments: the fake plants NEGATIVE nets for "AAA", positive for "BBB"
    let doc = campaign(&["AAA", "BBB"]);
    // sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both
    let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("a zero-survivor cell is a valid result, not a fault");

    assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
    // cell 0 (AAA): realization truncated AT the empty gate — stage 2 never realized
    let aaa = &out.record.cells[0];
    assert_eq!(aaa.instrument, "AAA");
    assert_eq!(aaa.stages.len(), 2);
    assert_eq!(aaa.stages[1].survivor_ordinals, Some(vec![]));
    // cell 1 (BBB): full pipeline realized
    let bbb = &out.record.cells[1];
    assert_eq!(bbb.instrument, "BBB");
    assert_eq!(bbb.stages.len(), 3);
    assert_eq!(bbb.stages[1].survivor_ordinals, Some(vec![0, 1, 2, 3]));
    assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3]));
}

#[test]
fn execute_is_deterministic_twice() {
    let reg = temp_registry("deterministic_twice");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![sweep_stage(false)]);
    let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("first run");
    let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("second run");

    // run counter advances per campaign id
    assert_eq!((first.run, second.run), (0, 1));
    assert_eq!((first.record.run, second.record.run), (0, 1));

    // family ids differ ONLY by the "-{run}" suffix
    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"));

    // everything else in the record is identical (C1): normalize the two
    // divergent fields and compare whole records
    let mut normalized = second.record.clone();
    normalized.run = first.record.run;
    normalized.cells[0].stages[0].family_id = first.record.cells[0].stages[0].family_id.clone();
    assert_eq!(normalized, first.record);

    // both records stored, in order
    let runs = reg.load_campaign_runs().expect("load campaign runs");
    assert_eq!(runs.len(), 2);
    assert_eq!(runs[0], first.record);
    assert_eq!(runs[1], second.record);
}

#[test]
fn execute_member_fault_aborts_with_lowest_index() {
    let reg = temp_registry("member_fault");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![sweep_stage(false)]);
    // faults planted at enumeration indices 1 (2,9) and 2 (3,6)
    let point = |fast: i64, slow: i64| {
        vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
    };
    let runner = FakeRunner {
        faults: vec![
            (point(2, 9), MemberFault::Run("boom at index 1".to_string())),
            (point(3, 6), MemberFault::Run("boom at index 2".to_string())),
        ],
    };
    let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
        .expect_err("a faulted member aborts the run");
    match err {
        ExecFault::Member(fault) => assert_eq!(
            fault,
            MemberFault::Run("boom at index 1".to_string()),
            "the LOWEST enumeration index's fault is reported",
        ),
        other => panic!("expected ExecFault::Member, got {other:?}"),
    }
    // the fault aborted before any family or realization write
    assert!(reg.load_family_members().expect("load members").is_empty());
    assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
}

#[test]
fn execute_deflate_uses_campaign_seed() {
    let reg = temp_registry("deflate_seed");
    let doc = campaign(&["EURUSD"]); // seed: 7
    let proc_doc = process(vec![sweep_stage(true)]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("deflated sweep executes");
    let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded");
    assert_eq!(sel.selection.mode, SelectionMode::Argmax);
    assert_eq!(sel.selection.seed, Some(7), "deflation nulls seed from the campaign doc");
    assert_eq!(sel.selection.n_resamples, Some(1000));
    assert_eq!(sel.selection.block_len, Some(5));
}

#[test]
fn execute_refuses_malformed_campaign_id() {
    let reg = temp_registry("bad_id");
    let doc = campaign(&["EURUSD"]);
    let proc_doc = process(vec![sweep_stage(false)]);
    let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect_err("a non-64-hex campaign id is refused, not sliced");
    match err {
        ExecFault::PipelineShape { detail } => assert!(
            detail.contains("campaign_id must be a 64-hex content id"),
            "detail names the id contract: {detail}",
        ),
        other => panic!("expected ExecFault::PipelineShape, got {other:?}"),
    }
    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);
}

/// The 0109 name contract (spec seam note / #201 d5): `execute` claims a
/// TraceStore family name iff the document requests persist_taps — the
/// RETURNED record and the STORED line both carry the same derived
/// `Some("{campaign8}-{run}")` (`append_campaign_run` composes it onto the
/// stored line from the claim sentinel; `execute` mirrors the same
/// derivation onto the returned copy after the counter is assigned). The
/// empty-taps `None` side is pinned on
/// `execute_sweep_only_records_family_and_selection`.
#[test]
fn execute_persist_taps_stamps_trace_name() {
    let reg = temp_registry("persist_taps_trace_name");
    let mut doc = campaign(&["EURUSD"]);
    doc.presentation.persist_taps = vec!["equity".to_string()];
    let proc_doc = process(vec![sweep_stage(false)]);
    let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("persist_taps campaign executes");

    // returned record: the derived name over the first run counter
    let expected = format!("{}-0", &CAMPAIGN_ID[..8]);
    assert_eq!(
        out.record.trace_name.as_deref(),
        Some(expected.as_str()),
        "the returned record carries the derived trace name",
    );

    // the stored line agrees — in the field and as a whole record
    let runs = reg.load_campaign_runs().expect("load campaign runs");
    assert_eq!(runs.len(), 1);
    assert_eq!(runs[0].trace_name.as_deref(), Some(expected.as_str()));
    assert_eq!(runs[0], out.record, "stored line and returned record are the same record");

    // a second run of the same campaign derives the next counter's name
    let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
        .expect("second persist_taps run");
    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");
    }
}
