From 14e27a978bcfe88fbca7a69c4ffe89eaeafbd486 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 13:18:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(0095):=20monte-carlo=20over=20a=20loaded?= =?UTF-8?q?=20blueprint=20=E2=80=94=20aura=20mc=20=20--seeds=20N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MC half of #170 (World/C21): `aura mc --seeds N` builds a FamilyKind::MonteCarlo family from a loaded CLOSED blueprint. Each seed draws a distinct synthetic price walk (synthetic_walk_sources, the mc_family pattern), so members differ in the DATA (not just a manifest label) — the sim is deterministic (C1), so MC variation must come from re-drawing the input. Draws run disjointly in parallel via the shared monte_carlo helper (invariant 1). The canonical blueprint is stored once keyed by the shared topology_hash (the 0094 hook), and reproduce_family_in gains a MonteCarlo realization branch that reconstructs each member's seed-driven walk from manifest.seed — so a persisted mc family 'aura reproduce's bit-identically (the store-hook proof). Closed-blueprint only: MC binds no axis, so a free knob has no binder; an open blueprint yields a named Err (rendered to stderr + exit 2 at the run_blueprint_mc boundary, mirroring blueprint_sweep_family — no hidden process exit in the pure builder), pre-empting the compile_with_params arity panic. Two quality-gate-driven improvements over the ratified plan, both applied and verified: (1) delegate the disjoint draws to the engine monte_carlo seam instead of a hand-rolled loop (my plan's 'closure-shape mismatch' rationale was wrong — the Fn(u64,&[Scalar]) closure ignores the unused base_point, exactly as mc_family does); (2) the closed-blueprint guard returns Result rather than exiting inside the builder, matching the sweep sibling's contract and making the rejection unit-testable. Engine + registry untouched (invariants 8/9). Verified: cargo build --workspace clean; cargo clippy --workspace --all-targets -- -D warnings clean; cargo test --workspace green (5 new tests: the parser, anti-degenerate family, MC reproduce round-trip, and two E2Es incl. the open rejection; no regressions). refs #170 --- crates/aura-cli/src/main.rs | 279 +++++++++++++++++++++++++++++-- crates/aura-cli/tests/cli_run.rs | 62 +++++++ 2 files changed, 330 insertions(+), 11 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index f605119..88c82e4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -2558,13 +2558,26 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce .map(|(n, v)| format!("{n}={}", render_value(v))) .collect::>() .join(", "); + // Realization-aware: a MonteCarlo member ran over a seed-driven synthetic walk, + // not the showcase — reconstruct it from manifest.seed so the re-run matches (C1). + // The seed is manifest-carried and identical across realizations; only the sources + // and window vary by kind. + let seed = stored.manifest.seed; + let (sources, member_window) = match family.kind { + FamilyKind::MonteCarlo => { + let s = synthetic_walk_sources(seed); + let w = window_of(&s).expect("non-empty synthetic walk"); + (s, w) + } + _ => (data.run_sources(), window), + }; let rerun = run_blueprint_member( reload(), &point, &space, - data.run_sources(), - window, - stored.manifest.seed, + sources, + member_window, + seed, pip, &hash, ); @@ -3192,6 +3205,63 @@ fn blueprint_sweep_family( }) } +/// A fresh seeded synthetic price walk for one Monte-Carlo draw — the `mc_family` +/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the +/// `aura mc ` persist path AND the reproduce MonteCarlo branch, so the +/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded +/// stage1-r graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades. +fn synthetic_walk_sources(seed: u64) -> Vec> { + let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; + vec![Box::new(spec.source(seed))] +} + +/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed +/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The +/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free +/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling +/// [`blueprint_sweep_family`]: the IO wrapper [`run_blueprint_mc`] renders it to stderr + +/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw +/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce +/// re-runs), so reproduction is bit-identical (C1); every member carries the shared +/// `topology_hash`. +fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> Result { + let reload = |d: &str| { + blueprint_from_json(d, &|t| std_vocabulary(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible") + }; + let topo = topology_hash(&reload(doc)); + let pip = data.pip_size(); + // probe the wrapped param_space (as blueprint_sweep_family does); MC needs it empty. + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + let space = + wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None).param_space(); + if !space.is_empty() { + // Exit-free like blueprint_sweep_family: the builder's single error contract is this + // returned message (no hidden process exit), so the rejection is unit-testable; the IO + // wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary. + return Err(format!( + "mc requires a closed blueprint (no free parameters); {} free knob(s) — \ + bind them or use `aura sweep --axis`", + space.len() + )); + } + // Closed blueprint -> an empty base point (as `aura run `); the MC + // draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws + // to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1), + // deterministic in seed-input order — exactly as the sibling `mc_family` does. Each draw + // re-runs the shared reduce-mode member path over its own seeded synthetic walk. + let seeds: Vec = (1..=n_seeds).collect(); + let base_point: Vec = Vec::new(); + Ok(monte_carlo(&base_point, &seeds, |seed, _base| { + let sources = synthetic_walk_sources(seed); + let window = window_of(&sources).expect("non-empty synthetic walk"); + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo) + })) +} + /// `aura sweep --axis = …`: sweep a loaded signal over its /// named param-space axes (the cycle-2 World/C21 verb). Builds the family via /// [`blueprint_sweep_family`], surfaces an unknown / kind-mismatched axis as a named @@ -3245,6 +3315,46 @@ fn run_blueprint_sweep(doc: &str, axes: &[(String, Vec)], name: &str, pe } } +/// `aura mc --seeds N`: build a Monte-Carlo family from a loaded CLOSED +/// blueprint (the World/C21 verb), store the canonical blueprint ONCE keyed by the shared +/// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a +/// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line +/// (carrying the seed) plus the aggregate — mirroring `run_mc` / `run_blueprint_sweep`. +fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource) { + let family = blueprint_mc_family(doc, n_seeds, &data).unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }); + let reg = default_registry(); + // Store the canonical blueprint ONCE, keyed by the family's shared topology_hash. + let topo = family.draws[0] + .report + .manifest + .topology_hash + .clone() + .expect("a blueprint mc stamps every member's topology_hash"); + let canonical = blueprint_to_json( + &blueprint_from_json(doc, &|t| std_vocabulary(t)) + .expect("doc parse-validated at the dispatch boundary"), + ) + .expect("a loaded blueprint re-serializes"); + reg.put_blueprint(&topo, &canonical).unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }); + let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { + Ok(id) => id, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + for draw in &family.draws { + println!("{}", mc_member_line(&id, draw.seed, &draw.report)); + } + println!("{}", mc_aggregate_json(&family.aggregate)); +} + /// The stage1-r harness with its signal leg swapped for a Donchian channel breakout: /// `close -> Delay(1) -> {RollingMax,RollingMin}(channel) -> {Gt,Gt} -> {Latch,Latch} /// -> Sub = bias in {-1,0,+1}`. The one Delay(1) on close feeds both rolling nodes, so @@ -3834,6 +3944,38 @@ fn parse_sweep_blueprint_args(rest: &[&str]) -> Result …`: the seed count + the family name. +/// Synthetic-only this cycle (real-data MC rides #172), so `--real`/`--from`/`--to` +/// are rejected as unknown flags. Mirrors `parse_sweep_blueprint_args`' shape. +struct McBlueprintArgs { + n_seeds: u64, + name: String, +} + +fn parse_mc_blueprint_args(rest: &[&str]) -> Result { + let usage = || "usage: mc --seeds [--name ]".to_string(); + let mut n_seeds: Option = None; + let mut name: Option = None; + let mut tail = rest; + while let Some((flag, t)) = tail.split_first() { + let (value, t) = t.split_first().ok_or_else(usage)?; + match *flag { + "--seeds" => { + let n: u64 = value.parse().map_err(|_| usage())?; + if n == 0 { + return Err(usage()); + } + n_seeds = Some(n); + } + "--name" if name.is_none() => name = Some((*value).to_string()), + _ => return Err(usage()), + } + tail = t; + } + let n_seeds = n_seeds.ok_or_else(usage)?; // --seeds is required + Ok(McBlueprintArgs { n_seeds, name: name.unwrap_or_else(|| "mc".to_string()) }) +} + /// Lex a `--axis` CSV into typed Scalars by shape: an integer-shaped token is i64, /// otherwise f64. `resolve_axes` kind-checks each value against the param's declared /// kind afterwards (a mismatch is a named error, not a panic). @@ -4003,16 +4145,40 @@ fn main() { std::process::exit(2); } }, - ["mc", rest @ ..] => match parse_mc_args(rest) { - Ok(McArgs::Synthetic { name, persist }) => run_mc(&name, persist), - Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed }) => { - run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed) + ["mc", rest @ ..] => { + // A `.json`-file first argument selects the loaded-blueprint Monte-Carlo (the + // World/C21 verb), mirroring the `["sweep", ..]` discriminator; a bare + // `--strategy`/`--name` token never ends in `.json`, so the paths stay disjoint. + if let Some(path) = + rest.first().filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file()) + { + let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("aura: {path}: {e}"); + std::process::exit(2); + }); + if let Err(e) = blueprint_from_json(&doc, &|t| std_vocabulary(t)) { + eprintln!("aura: {path}: {e:?}"); + std::process::exit(2); + } + let McBlueprintArgs { n_seeds, name } = + parse_mc_blueprint_args(&rest[1..]).unwrap_or_else(|msg| { + eprintln!("aura: {msg}"); + std::process::exit(2); + }); + run_blueprint_mc(&doc, n_seeds, &name, DataSource::Synthetic); + return; } - Err(msg) => { - eprintln!("aura: {msg}"); - std::process::exit(2); + match parse_mc_args(rest) { + Ok(McArgs::Synthetic { name, persist }) => run_mc(&name, persist), + Ok(McArgs::RealR { choice, grid, block_len, n_resamples, seed }) => { + run_mc_r_bootstrap(DataSource::from_choice(choice), &grid, block_len, n_resamples, seed) + } + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } } - }, + } ["runs", "families"] => runs_families(), ["runs", "family", id] => runs_family(id, None), ["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)), @@ -5128,6 +5294,27 @@ mod tests { assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--name", "a", "--trace", "b"]).is_err()); // name + trace together } + /// Property: the `aura mc ` grammar requires `--seeds ` with + /// `n >= 1`, defaults the family name to "mc", and — synthetic-only this cycle — + /// rejects `--real` (and any other unknown flag) as a strict usage error, never a + /// silent default or a real-data fallback (real-data MC rides #172). + #[test] + fn parse_mc_blueprint_args_grammar() { + // --seeds required and parsed; default name "mc"; seeds >= 1; unknown/real flags rejected. + let ok = parse_mc_blueprint_args(&["--seeds", "8", "--name", "mc1"]).expect("valid"); + assert_eq!(ok.n_seeds, 8); + assert_eq!(ok.name, "mc1"); + let def = parse_mc_blueprint_args(&["--seeds", "3"]).expect("default name"); + assert_eq!(def.name, "mc"); + assert!(parse_mc_blueprint_args(&[]).is_err(), "--seeds is required"); + assert!(parse_mc_blueprint_args(&["--seeds", "0"]).is_err(), "seeds must be >= 1"); + assert!(parse_mc_blueprint_args(&["--seeds", "x"]).is_err(), "seeds must be a number"); + assert!( + parse_mc_blueprint_args(&["--seeds", "4", "--real", "EURUSD"]).is_err(), + "synthetic-only this cycle: --real is rejected (real-data MC is #172)" + ); + } + /// Property: `parse_sweep_blueprint_args` delegates `--real/--from/--to` to the /// shared `RealWindowGrammar`, yielding `DataChoice::Real { symbol, window }` when /// `--real` is present and `DataChoice::Synthetic` when it is absent; a window flag @@ -5190,6 +5377,42 @@ mod tests { assert_ne!(k4, k6, "distinct grid points key distinctly"); } + #[test] + fn blueprint_mc_family_seeds_differ() { + // MC over a CLOSED signal (both SMA knobs bound): 3 seeds -> 3 draws, one shared + // topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct + // synthetic walk per draw), not just the manifest label. The anti-degenerate guard: + // a regression to seed-as-label-only would make the three draws identical. + let closed = stage1_signal(Some(2), Some(4)); + let doc = blueprint_to_json(&closed).expect("serializes"); + let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic).expect("closed blueprint"); + + assert_eq!(family.draws.len(), 3, "one draw per seed"); + assert_eq!(family.draws.iter().map(|d| d.seed).collect::>(), vec![1, 2, 3]); + let topo = family.draws[0].report.manifest.topology_hash.clone(); + assert!(topo.is_some(), "members carry a topology_hash"); + assert!( + family.draws.iter().all(|d| d.report.manifest.topology_hash == topo), + "all members share one topology_hash" + ); + let m: Vec<_> = family.draws.iter().map(|d| &d.report.metrics).collect(); + assert!(m[0] != m[1] || m[1] != m[2], "seeds must yield differing realizations"); + } + + #[test] + fn blueprint_mc_family_rejects_an_open_blueprint() { + // Property: the mc family builder REFUSES an open blueprint (free knobs) by RETURNING + // a named error — never a hidden process exit — so the closed-blueprint precondition + // is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling + // blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the + // rejection pre-empts the downstream compile_with_params arity panic. + let open = stage1_signal(None, None); // both SMA knobs free -> non-empty param_space + let doc = blueprint_to_json(&open).expect("serializes"); + let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic) + .expect_err("an open blueprint is rejected, not run"); + assert!(err.contains("closed blueprint"), "names the closed-blueprint requirement: {err}"); + } + #[test] fn reproduce_family_re_derives_every_member_bit_identically() { // a unique temp runs store so the on-disk family + blueprint store do not collide. @@ -5229,6 +5452,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn reproduce_family_re_derives_every_mc_member_bit_identically() { + let dir = std::env::temp_dir().join(format!("aura-repro-mc-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let reg = Registry::open(dir.join("runs.jsonl")); + + // a CLOSED signal (both SMA knobs bound) — MC binds no axis. + let closed = stage1_signal(Some(2), Some(4)); + let doc = blueprint_to_json(&closed).expect("serializes"); + let data = DataSource::Synthetic; + let family = blueprint_mc_family(&doc, 3, &data).expect("closed blueprint"); + + // persist exactly as run_blueprint_mc does: store the blueprint, append the MC family. + let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo"); + let canonical = + blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap(); + reg.put_blueprint(&topo, &canonical).expect("store blueprint"); + let id = reg + .append_family("mcrepro", FamilyKind::MonteCarlo, &mc_member_reports(&family)) + .expect("append"); + + // reproduce: every MC member re-derives bit-identically (its seed-driven walk is + // reconstructed from manifest.seed — the realization branch). + let rep = reproduce_family_in(®, &id, &data); + assert_eq!(rep.outcomes.len(), 3, "three MC members reproduced"); + assert!( + rep.outcomes.iter().all(|(_, ok)| *ok), + "every MC member re-derives bit-identically: {:?}", + rep.outcomes + ); + let _ = std::fs::remove_dir_all(&dir); + } + /// Property: an f64 blueprint param survives the content-addressed store's /// serialize -> parse -> re-serialize round-trip **bit-identically**, so `aura /// reproduce` re-derives an f64-bearing member without DIVERGED. This is exactly diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index ca66f68..c07bb0e 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3488,6 +3488,68 @@ fn aura_reproduce_re_derives_a_persisted_sweep_bit_identically() { let _ = std::fs::remove_dir_all(&cwd); } +/// E2E (acc 1+3+4): `aura mc --seeds N` builds a MonteCarlo +/// family (one member per seed), writes exactly one content-addressed blueprint, and +/// `aura reproduce ` re-derives every member bit-identically (exit 0). +#[test] +fn aura_mc_over_a_blueprint_reproduces_bit_identically() { + let cwd = temp_cwd("mc-blueprint-reproduce"); + let fixture = format!("{}/tests/fixtures/stage1_signal.json", env!("CARGO_MANIFEST_DIR")); + + let mc = Command::new(BIN) + .args(["mc", &fixture, "--seeds", "4", "--name", "mcx"]) + .current_dir(&cwd) + .output() + .expect("spawn aura mc blueprint"); + assert!(mc.status.success(), "mc exit: {:?} stderr={}", mc.status, String::from_utf8_lossy(&mc.stderr)); + let mc_out = String::from_utf8(mc.stdout).expect("utf-8 stdout"); + // one member line per seed (each carries "seed":n) + one aggregate line. + assert_eq!( + mc_out.lines().filter(|l| l.contains("\"seed\":")).count(), + 4, + "one member line per seed: {mc_out}" + ); + assert!(mc_out.contains("mc_aggregate"), "prints the aggregate line: {mc_out}"); + + // exactly one blueprint stored for the whole family (one topology per family). + let store = cwd.join("runs/blueprints"); + let entries: Vec<_> = std::fs::read_dir(&store) + .unwrap_or_else(|e| panic!("mc must create the blueprint store {store:?}: {e}")) + .map(|e| e.expect("dir entry").path()) + .collect(); + assert_eq!(entries.len(), 1, "one stored topology per family: {entries:?}"); + + let repro = Command::new(BIN) + .args(["reproduce", "mcx-0"]) + .current_dir(&cwd) + .output() + .expect("spawn aura reproduce"); + assert!(repro.status.success(), "reproduce exit: {:?} stderr={}", repro.status, String::from_utf8_lossy(&repro.stderr)); + let repro_out = String::from_utf8(repro.stdout).expect("utf-8 stdout"); + assert!( + repro_out.contains("reproduced 4/4 members bit-identically"), + "every MC member re-derives: {repro_out}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + +/// E2E (acc 2): `aura mc ` is rejected with a named error + exit 2 +/// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder. +#[test] +fn aura_mc_rejects_an_open_blueprint() { + let cwd = temp_cwd("mc-blueprint-open-reject"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args(["mc", &fixture, "--seeds", "4"]) + .current_dir(&cwd) + .output() + .expect("spawn aura mc open-blueprint"); + assert_eq!(out.status.code(), Some(2), "an open blueprint fails clean (exit 2, not a panic): stderr={}", String::from_utf8_lossy(&out.stderr)); + let stderr = String::from_utf8(out.stderr).expect("utf-8"); + assert!(stderr.contains("closed blueprint"), "names the closed-blueprint requirement: {stderr}"); + let _ = std::fs::remove_dir_all(&cwd); +} + /// E2E (negative): `aura reproduce ` is a hard error (exit non-zero + named /// cause on stderr), distinct from `runs family `'s treat-as-empty exit 0. #[test]