feat(0095): monte-carlo over a loaded blueprint — aura mc <bp.json> --seeds N

The MC half of #170 (World/C21): `aura mc <closed blueprint.json> --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
This commit is contained in:
2026-07-01 13:18:00 +02:00
parent 860b89ad93
commit 14e27a978b
2 changed files with 330 additions and 11 deletions
+268 -11
View File
@@ -2558,13 +2558,26 @@ fn reproduce_family_in(reg: &Registry, id: &str, data: &DataSource) -> Reproduce
.map(|(n, v)| format!("{n}={}", render_value(v)))
.collect::<Vec<_>>()
.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 <blueprint.json>` 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<Box<dyn aura_engine::Source>> {
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<McFamily, String> {
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 <blueprint.json>`); 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<u64> = (1..=n_seeds).collect();
let base_point: Vec<Scalar> = 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 <blueprint.json> --axis <name>=<csv> …`: 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<Scalar>)], name: &str, pe
}
}
/// `aura mc <blueprint.json> --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<SweepBlueprintArgs, Strin
Ok(SweepBlueprintArgs { axes, name, persist, data: data.finish(&usage)? })
}
/// Parsed tail of `aura mc <blueprint.json> …`: 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<McBlueprintArgs, String> {
let usage = || "usage: mc <blueprint.json> --seeds <n> [--name <n>]".to_string();
let mut n_seeds: Option<u64> = None;
let mut name: Option<String> = 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 <blueprint.json>` grammar requires `--seeds <n>` 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<_>>(), 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(&reg, &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