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:
+268
-11
@@ -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(®, &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
|
||||
|
||||
Reference in New Issue
Block a user