diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 00949ef..210909c 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -4264,6 +4264,44 @@ fn walkforward_args_from( Ok((name, symbol, fast, slow, stop_length[0], stop_k[0], a.from, a.to)) } +/// Convert `McCmd` into the resolved argument shape the mc sugar consumes (the +/// dissolved `--strategy r-sma --real` branch). Single instrument, multi-value +/// fast/slow (the IS-refit grid), single-value stop (Fork A: the stop is a risk +/// regime, not a swept axis); all four grid knobs required. `--block-len`/ +/// `--resamples`/`--seed` default to `1`/`1000`/`1` — byte-identical to the inline +/// `McArgs::RealR` defaults (`main.rs:5064-5066`), so an omitted flag produces the +/// same document either way. The usize->u32 conversion for `block_len`/`resamples` +/// lands HERE, at the argv boundary where the CLI's `usize` meets the document's +/// `u32` vocabulary (`StageBlock::MonteCarlo`), keeping every downstream signature +/// in the document's native type. `--name`/`--trace` are rejected: the inline mc +/// R-path rejects them too (`main.rs:5047`), so the sugar preserves that (the +/// R-bootstrap records without a family name — the campaign name is a constant +/// "mc"). +#[allow(clippy::type_complexity)] +fn mc_args_from( + a: &McCmd, +) -> Result<(String, String, Vec, Vec, i64, f64, u32, u32, u64, Option, Option), String> { + let symbol = match a.real.as_deref() { + None | Some("") => return Err("mc --strategy r-sma dissolves only over --real ".to_string()), + Some(s) => s.to_string(), + }; + if a.name.is_some() || a.trace.is_some() { + return Err("mc --strategy r-sma: --name/--trace are not accepted (the R-bootstrap records without a family name)".to_string()); + } + let knobs = || "mc requires --fast --slow --stop-length --stop-k (fast/slow may be CSV grids; the stop is single-value)".to_string(); + let fast: Vec = parse_csv_list(a.fast.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let slow: Vec = parse_csv_list(a.slow.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let stop_length: Vec = parse_csv_list(a.stop_length.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + let stop_k: Vec = parse_csv_list(a.stop_k.as_deref().ok_or_else(knobs)?).map_err(|()| knobs())?; + if stop_length.len() != 1 || stop_k.len() != 1 { + return Err("mc: the stop is a single risk regime; --stop-length and --stop-k take one value each".to_string()); + } + let block_len = a.block_len.map(|v| v as u32).unwrap_or(1); + let resamples = a.resamples.map(|v| v as u32).unwrap_or(1000); + let seed = a.seed.unwrap_or(1); + Ok(("mc".to_string(), symbol, fast, slow, stop_length[0], stop_k[0], block_len, resamples, seed, a.from, a.to)) +} + /// Convert `GeneralizeCmd` into the resolved argument shape the generalize sugar /// consumes. The candidate is a single cell (clap already types `--fast`/etc. as /// one `i64`/`f64`), all four knobs required, `--real` a `>=2`-distinct comma @@ -4947,6 +4985,51 @@ fn dispatch_mc(a: McCmd, env: &project::Env) { eprintln!("aura: {}", usage()); std::process::exit(2); } + // Dissolution split (#210, Reading A): only the real-archive r-sma + // R-bootstrap execution routes through the campaign path. Everything else — + // synthetic-r-sma (no --real), the synthetic seed-resweep, the blueprint + // path — stays on the inline handler below, fenced until #159. + if a.strategy.as_deref() == Some("r-sma") && a.real.is_some() { + let (name, symbol, fast, slow, stop_length, stop_k, block_len, resamples, seed, from, to) = + mc_args_from(&a).unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(2); + }); + let signal = sma_signal(None, None); + let canonical = blueprint_to_json(&signal).expect("a bare sma_signal serializes"); + let reg = env.registry(); + let topo = topology_hash(&signal); + reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(1); + }); + // The inline `walkforward_family` sources its span from + // `DataSource::wf_full_span`, which for Real ALWAYS clips `--from`/`--to` + // to the archive's actual first/last bar in range: a holiday/weekend edge + // shifts every IS/OOS window's calendar placement, so the roller must clip + // identically here too, even when both flags are given, or the per-window + // winners and pooled OOS series diverge from the committed exact-grade anchor. + let source = DataSource::from_choice( + DataChoice::Real { symbol: symbol.clone(), from_ms: from, to_ms: to }, + env, + ); + let (from_ts, to_ts) = source.full_window(env); + let (from_ms, to_ms) = ( + aura_ingest::epoch_ns_to_unix_ms(from_ts), + aura_ingest::epoch_ns_to_unix_ms(to_ts), + ); + let (is_ms, oos_ms, step_ms) = wf_ms_sizes(); + verb_sugar::run_mc_sugar( + &fast, &slow, stop_length, stop_k, WINNER_SELECTION_METRIC, + is_ms, oos_ms, step_ms, resamples, block_len, seed, &name, &symbol, from_ms, to_ms, + &canonical, env, + ) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + return; + } // The R-bootstrap path is selected by any r-sma knob; otherwise the // synthetic seed-resweep family. Name flags are invalid on the R path. let r_path = a.strategy.is_some() @@ -6498,4 +6581,81 @@ mod tests { mc_r_bootstrap_report(&DataSource::Synthetic, &RGrid::default(), 1, 256, 1, &env), ); } + + /// A bare `McCmd` with every optional field defaulted to `None`/empty, so each + /// `mc_args_from` refusal test below only sets the fields its scenario needs. + fn bare_mc_cmd() -> McCmd { + McCmd { + blueprint: None, + strategy: None, + real: None, + from: None, + to: None, + name: None, + trace: None, + fast: None, + slow: None, + stop_length: None, + stop_k: None, + block_len: None, + resamples: None, + seed: None, + seeds: None, + } + } + + #[test] + fn mc_args_from_refuses_without_a_real_symbol() { + let a = bare_mc_cmd(); + let err = mc_args_from(&a).unwrap_err(); + assert!(err.contains("dissolves only over --real"), "refuse message: {err}"); + } + + #[test] + fn mc_args_from_refuses_an_empty_real_symbol() { + let a = McCmd { real: Some(String::new()), ..bare_mc_cmd() }; + let err = mc_args_from(&a).unwrap_err(); + assert!(err.contains("dissolves only over --real"), "refuse message: {err}"); + } + + #[test] + fn mc_args_from_refuses_name_and_trace() { + let a = McCmd { + real: Some("GER40".to_string()), + name: Some("a".to_string()), + trace: Some("b".to_string()), + ..bare_mc_cmd() + }; + let err = mc_args_from(&a).unwrap_err(); + assert!(err.contains("--name/--trace are not accepted"), "refuse message: {err}"); + } + + #[test] + fn mc_args_from_refuses_missing_knobs() { + let a = McCmd { + real: Some("GER40".to_string()), + fast: Some("3".to_string()), + slow: Some("12".to_string()), + ..bare_mc_cmd() + }; + let err = mc_args_from(&a).unwrap_err(); + assert!( + err.contains("requires --fast --slow --stop-length --stop-k"), + "missing-knob refusal: {err}" + ); + } + + #[test] + fn mc_args_from_refuses_a_multi_value_stop() { + let a = McCmd { + real: Some("GER40".to_string()), + fast: Some("3".to_string()), + slow: Some("12".to_string()), + stop_length: Some("14,20".to_string()), + stop_k: Some("2.0".to_string()), + ..bare_mc_cmd() + }; + let err = mc_args_from(&a).unwrap_err(); + assert!(err.contains("single risk regime"), "stop-regime refusal: {err}"); + } } diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index e67b6f2..0a7db0b 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -502,6 +502,170 @@ pub(crate) fn run_walkforward_sugar( Ok(()) } +/// The two generated documents of one dissolved `mc` invocation. +#[derive(Debug)] +pub(crate) struct GeneratedMc { + pub process: ProcessDoc, + pub campaign: CampaignDoc, +} + +/// Translate one `aura mc --strategy r-sma --real` invocation into its two generated +/// documents: a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process +/// (the sweep enumerates the IS-refit survivor grid for the wf stage; the terminal +/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps E[R]) +/// and a campaign running the fast/slow grid over one instrument under a single risk +/// regime that carries the stop. Unlike `translate_walkforward` (which hardcodes +/// `seed: 0`), `translate_mc` sets `campaign.seed = seed` (the mc `--seed`): the wf +/// winners are argmax hence deflation-seed-independent (the shipped walkforward +/// anchor proved this), so remapping the campaign seed leaves the pooled series +/// unchanged while making the terminal `r_bootstrap` at that seed reproduce the +/// inline bootstrap. The roller sizes (`in_sample_ms`/`out_of_sample_ms`/`step_ms`) +/// and `blueprint_canonical` come from the caller, exactly as for walkforward. +#[allow(clippy::too_many_arguments)] +pub(crate) fn translate_mc( + fast: &[i64], + slow: &[i64], + stop_length: i64, + stop_k: f64, + metric: &str, + in_sample_ms: u64, + out_of_sample_ms: u64, + step_ms: u64, + resamples: u32, + block_len: u32, + seed: u64, + name: &str, + symbol: &str, + from_ms: i64, + to_ms: i64, + blueprint_canonical: &str, +) -> Result { + let process = ProcessDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Process, + name: "mc".to_string(), + description: None, + pipeline: vec![ + StageBlock::Sweep { + selection: Some(SweepSelection { + metric: metric.to_string(), + select: SelectRule::Argmax, + deflate: false, + }), + }, + StageBlock::WalkForward { + in_sample_ms, + out_of_sample_ms, + step_ms, + mode: WfMode::Rolling, + metric: metric.to_string(), + select: SelectRule::Argmax, + }, + StageBlock::MonteCarlo { resamples, block_len }, + ], + }; + let mut doc_axes: BTreeMap = BTreeMap::new(); + doc_axes.insert( + "fast.length".to_string(), + axis_from_values("fast.length", &fast.iter().map(|&v| Scalar::i64(v)).collect::>())?, + ); + doc_axes.insert( + "slow.length".to_string(), + axis_from_values("slow.length", &slow.iter().map(|&v| Scalar::i64(v)).collect::>())?, + ); + let campaign = CampaignDoc { + format_version: FORMAT_VERSION, + kind: DocKind::Campaign, + name: name.to_string(), + description: None, + data: DataSection { + instruments: vec![symbol.to_string()], + windows: vec![Window { from_ms, to_ms }], + }, + strategies: vec![StrategyEntry { + r#ref: DocRef::ContentId(content_id_of(blueprint_canonical)), + axes: doc_axes, + }], + process: ProcessRef { r#ref: DocRef::ContentId(content_id_of(&process_to_json(&process))) }, + risk: vec![RiskRegime::Vol { length: stop_length, k: stop_k }], + seed, + presentation: Presentation { + persist_taps: vec![], + emit: vec!["family_table".to_string()], + }, + }; + Ok(GeneratedMc { process, campaign }) +} + +/// Auto-register both generated mc documents (content-addressed, idempotent). +/// Returns `(process_id, campaign_id)`. +pub(crate) fn register_generated_mc( + reg: &aura_registry::Registry, + generated: &GeneratedMc, +) -> Result<(String, String), String> { + let process_id = + reg.put_process(&process_to_json(&generated.process)).map_err(|e| e.to_string())?; + let campaign_id = + reg.put_campaign(&campaign_to_json(&generated.campaign)).map_err(|e| e.to_string())?; + Ok((process_id, campaign_id)) +} + +/// Run one dissolved `mc` invocation end-to-end: register the generated documents, +/// run through the one campaign path, then reprint the single `mc_r_bootstrap` grade +/// line from the recorded terminal `PooledOos` bootstrap. The stdout line is +/// byte-identical to the inline path (the committed exact-grade anchor is the gate). +/// mc prints ONLY the one summary line — no per-window member lines (unlike +/// walkforward): the monte_carlo stage is a terminal annotator, not a family. +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_mc_sugar( + fast: &[i64], + slow: &[i64], + stop_length: i64, + stop_k: f64, + metric: &str, + in_sample_ms: u64, + out_of_sample_ms: u64, + step_ms: u64, + resamples: u32, + block_len: u32, + seed: u64, + name: &str, + symbol: &str, + from_ms: i64, + to_ms: i64, + blueprint_canonical: &str, + env: &crate::project::Env, +) -> Result<(), String> { + let generated = translate_mc( + fast, slow, stop_length, stop_k, metric, in_sample_ms, out_of_sample_ms, step_ms, + resamples, block_len, seed, name, symbol, from_ms, to_ms, blueprint_canonical, + )?; + let probe_params: Vec<(String, Scalar)> = vec![ + ("fast.length".to_string(), Scalar::i64(fast[0])), + ("slow.length".to_string(), Scalar::i64(slow[0])), + ]; + validate_before_register(&generated.process, &generated.campaign, blueprint_canonical, &probe_params, env)?; + let reg = env.registry(); + let (_process_id, campaign_id) = register_generated_mc(®, &generated)?; + let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?; + + // The single cell's terminal monte_carlo StageRealization carries the pooled-OOS + // bootstrap (record path: outcome.record.cells[].stages[].bootstrap). + let boot = run + .outcome + .record + .cells + .iter() + .flat_map(|c| c.stages.iter()) + .find_map(|s| match &s.bootstrap { + Some(aura_registry::StageBootstrap::PooledOos(b)) => Some(b), + _ => None, + }) + .ok_or("mc produced no pooled-OOS bootstrap")?; + println!("{}", crate::mc_r_bootstrap_json(boot)); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -792,4 +956,123 @@ mod tests { assert_eq!(stored_process, process_to_json(&generated.process)); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn translate_mc_is_deterministic_and_carries_the_regime_and_seed() { + let a = translate_mc( + &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 42, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + let b = translate_mc( + &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 42, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + assert_eq!( + content_id_of(&campaign_to_json(&a.campaign)), + content_id_of(&campaign_to_json(&b.campaign)), + "identical invocations must generate identical campaign ids" + ); + // pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo + assert_eq!( + a.process.pipeline, + vec![ + StageBlock::Sweep { + selection: Some(SweepSelection { + metric: "sqn_normalized".to_string(), + select: SelectRule::Argmax, + deflate: false, + }) + }, + StageBlock::WalkForward { + in_sample_ms: 7_776_000_000, + out_of_sample_ms: 2_592_000_000, + step_ms: 2_592_000_000, + mode: WfMode::Rolling, + metric: "sqn_normalized".to_string(), + select: SelectRule::Argmax, + }, + StageBlock::MonteCarlo { resamples: 1000, block_len: 5 }, + ] + ); + assert_eq!(a.campaign.seed, 42, "the campaign seed carries the mc --seed"); + assert_eq!(a.campaign.risk, vec![RiskRegime::Vol { length: 14, k: 2.0 }]); + assert_eq!(a.campaign.data.instruments, vec!["GER40".to_string()]); + assert_eq!(a.campaign.strategies.len(), 1); + assert_eq!( + a.campaign.strategies[0].axes["fast.length"].values, + vec![Scalar::i64(3), Scalar::i64(5)] + ); + assert_eq!( + a.campaign.strategies[0].axes["slow.length"].values, + vec![Scalar::i64(12), Scalar::i64(20)] + ); + assert_eq!(a.process.name, "mc"); + assert!(aura_research::validate_campaign(&a.campaign).is_empty()); + assert!(aura_research::validate_process(&a.process).is_empty()); + assert!(aura_campaign::preflight(&a.process, &a.campaign).is_ok()); + } + + #[test] + fn translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids() { + let base = translate_mc( + &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 42, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + let different_seed = translate_mc( + &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 7, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + let different_k = translate_mc( + &[3, 5], &[12, 20], 14, 3.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 42, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + let base_id = content_id_of(&campaign_to_json(&base.campaign)); + let seed_id = content_id_of(&campaign_to_json(&different_seed.campaign)); + let k_id = content_id_of(&campaign_to_json(&different_k.campaign)); + assert_ne!(base_id, seed_id, "a different mc seed must not collide onto the same campaign id"); + assert_ne!(base_id, k_id, "a different stop_k must not collide onto the same campaign id"); + assert_ne!(seed_id, k_id, "the two diverging documents must not collide with each other"); + } + + #[test] + fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() { + let dir = std::env::temp_dir().join(format!( + "aura-verb-sugar-mc-registry-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let reg = aura_registry::Registry::open(dir.join("runs.jsonl")); + let generated = translate_mc( + &[3, 5], &[12, 20], 14, 2.0, "sqn_normalized", + 7_776_000_000, 2_592_000_000, 2_592_000_000, + 1000, 5, 42, + "mc", "GER40", 100, 200, "{\"bp\":1}", + ) + .unwrap(); + let (process_id, campaign_id) = register_generated_mc(®, &generated).unwrap(); + let stored_campaign = reg.get_campaign(&campaign_id).unwrap().expect("campaign stored"); + assert_eq!(stored_campaign, campaign_to_json(&generated.campaign)); + let DocRef::ContentId(ref_id) = &generated.campaign.process.r#ref else { + panic!("mc's process ref is a content id"); + }; + assert_eq!(ref_id, &process_id, "the stored process id must be the campaign's own ref"); + let stored_process = reg.get_process(ref_id).unwrap().expect("process stored"); + assert_eq!(stored_process, process_to_json(&generated.process)); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index ed92317..ed0904d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -5278,3 +5278,185 @@ fn mc_r_bootstrap_real_e2e_pins_the_exact_current_grade() { assert_eq!(mc["e_r"]["p50"].as_f64(), Some(-0.0023049902245975227), "E[R] median: {grade_line}"); assert_eq!(mc["prob_le_zero"].as_f64(), Some(0.558), "P(E[R] <= 0): {grade_line}"); } + +/// Property: `aura mc --strategy r-sma --real` is now thin sugar over the one campaign +/// path — a successful run durably auto-registers exactly one generated process +/// document, one generated campaign document (carrying the constant "mc" name and the +/// stop as a non-empty single risk regime), and one campaign-run record, and emits the +/// single `mc_r_bootstrap` grade line. The pipeline's leading argmax sweep and its +/// walk_forward stage each persist one family; the terminal monte_carlo stage is an +/// annotator and adds NO family. This is the observable proof the inline path is gone +/// and the dissolution runs through the executor. Gated on the GER40 2025 archive; +/// skips cleanly on a data refusal. +#[test] +fn mc_dissolves_through_the_campaign_path() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let cwd = temp_cwd("mc-dissolves"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "mc", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "--block-len", "5", "--resamples", "1000", "--seed", "42", + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + if out.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + assert_eq!(out.status.code(), Some(0), "exit: {:?}", out.status); + + // the single grade line is emitted (and nothing else on stdout). + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert_eq!(stdout.lines().count(), 1, "mc prints exactly one grade line: {stdout:?}"); + assert!( + stdout.lines().next().is_some_and(|l| l.starts_with("{\"mc_r_bootstrap\":")), + "the one line is the mc_r_bootstrap grade: {stdout}" + ); + + let count = |sub: &str| { + std::fs::read_dir(cwd.join("runs").join(sub)) + .map(|d| d.count()) + .unwrap_or(0) + }; + assert_eq!(count("processes"), 1, "one generated process document registered"); + assert_eq!(count("campaigns"), 1, "one generated campaign document registered"); + let runs_log = std::fs::read_to_string(cwd.join("runs").join("campaign_runs.jsonl")) + .expect("campaign_runs.jsonl exists after a sugar run"); + assert_eq!(runs_log.lines().count(), 1, "one campaign run recorded"); + + let campaigns_dir = cwd.join("runs").join("campaigns"); + let campaign_doc_path = std::fs::read_dir(&campaigns_dir) + .expect("campaigns dir exists") + .next() + .expect("exactly one campaign document") + .expect("readable dir entry") + .path(); + let campaign_doc_json = std::fs::read_to_string(&campaign_doc_path).expect("read campaign doc"); + assert!( + campaign_doc_json.contains("\"name\":\"mc\""), + "the generated campaign document carries the constant mc name: {campaign_doc_json}" + ); + assert!( + campaign_doc_json.contains("\"risk\":[") + && campaign_doc_json.contains("\"length\":14") + && campaign_doc_json.contains("\"k\":2.0"), + "the stop rides a non-empty risk regime: {campaign_doc_json}" + ); + + let fams = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["runs", "families"]) + .current_dir(&cwd) + .output() + .expect("spawn families"); + let fams_out = String::from_utf8_lossy(&fams.stdout).into_owned(); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"Sweep\"")).count(), + 1, + "one Sweep family from the pipeline's leading argmax stage: {fams_out}" + ); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"WalkForward\"")).count(), + 1, + "one WalkForward family holding the per-window OOS reports: {fams_out}" + ); + assert_eq!( + fams_out.lines().filter(|l| l.contains("\"kind\":\"MonteCarlo\"")).count(), + 0, + "the terminal monte_carlo stage is an annotator, not a family: {fams_out}" + ); +} + +/// Fork A: the stop is a single risk regime on the dissolved mc path — a multi-value +/// `--stop-length`/`--stop-k` is a usage refusal (exit 2), not a swept axis. Mirrors +/// `walkforward_dissolved_refuses_a_multi_value_stop`. NOT gated — the refusal is pure +/// argument parsing, before any data access. +#[test] +fn mc_dissolved_refuses_a_multi_value_stop() { + let cwd = temp_cwd("mc-multi-stop"); + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "mc", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3", "--slow", "12", "--stop-length", "14,20", "--stop-k", "2.0", + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura"); + assert_eq!(out.status.code(), Some(2), "multi-value stop is a usage refusal"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("single risk regime"), "stop-regime refusal: {stderr}"); +} + +/// Property: the one load-bearing decision new to the mc dissolution — `translate_mc` +/// maps `campaign.seed` to the mc `--seed` (unlike `translate_walkforward`, which +/// hardcodes `seed: 0`) — actually flows through the executor to the emitted grade, +/// not just into the generated document's content id (already unit-pinned by +/// `translate_mc_diverging_seeds_and_stops_do_not_collide_content_ids`). Two runs that +/// differ ONLY in `--seed` must emit a different `e_r` distribution (the bootstrap +/// resample draw is seeded) while pooling the IDENTICAL `n_trades` (the wf winners are +/// argmax-selected, hence deflation-seed-independent — the walkforward anchor already +/// proved this for the shared roller): the seed perturbs the resample, never the +/// underlying OOS trade-R series. Without this test, a dropped seed remap (e.g. mc +/// always bootstrapping at a fixed internal seed) would still pass every other mc +/// dissolution test, since none of them compare two distinct `--seed` runs. Gated on +/// the GER40 2025 archive; skips cleanly on a data refusal. +#[test] +fn mc_dissolved_seed_changes_the_bootstrap_draw_not_the_pooled_series() { + const FROM_MS: &str = "1735689600000"; + const TO_MS: &str = "1767225599000"; + let run = |seed: &str| { + let cwd = temp_cwd(&format!("mc-seed-{seed}")); + std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args([ + "mc", "--strategy", "r-sma", "--real", "GER40", + "--fast", "3,5", "--slow", "12,20", "--stop-length", "14", "--stop-k", "2.0", + "--block-len", "5", "--resamples", "1000", "--seed", seed, + "--from", FROM_MS, "--to", TO_MS, + ]) + .current_dir(&cwd) + .output() + .expect("spawn aura") + }; + let out42 = run("42"); + if out42.status.code() == Some(1) { + let stderr = String::from_utf8_lossy(&out42.stderr); + assert!( + stderr.contains("no local data") || stderr.contains("no recorded geometry"), + "exit 1 must be a data refusal, got: {stderr}" + ); + eprintln!("skip: no local GER40 data"); + return; + } + let out7 = run("7"); + assert_eq!(out42.status.code(), Some(0), "exit (seed 42): {:?}", out42.status); + assert_eq!(out7.status.code(), Some(0), "exit (seed 7): {:?}", out7.status); + + let parse = |out: &std::process::Output| -> serde_json::Value { + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + let line = stdout + .lines() + .find(|l| l.starts_with("{\"mc_r_bootstrap\":")) + .unwrap_or_else(|| panic!("the mc_r_bootstrap grade line: {stdout}")) + .to_string(); + serde_json::from_str(&line).expect("grade line parses as JSON") + }; + let v42 = parse(&out42); + let v7 = parse(&out7); + + assert_eq!( + v42["mc_r_bootstrap"]["n_trades"], v7["mc_r_bootstrap"]["n_trades"], + "the pooled OOS trade-R series is seed-independent (argmax winners): {v42} vs {v7}" + ); + assert_ne!( + v42["mc_r_bootstrap"]["e_r"]["mean"], v7["mc_r_bootstrap"]["e_r"]["mean"], + "a different --seed must move the resampled E[R] mean: {v42} vs {v7}" + ); +}