4 tasks: parse_mc_blueprint_args, blueprint_mc_family (closed-blueprint guard + seed-driven synthetic walks), the reproduce MonteCarlo realization branch, and run_blueprint_mc + the .json dispatch arm + E2E. All in aura-cli/main.rs (engine/registry untouched).
24 KiB
Monte-Carlo over a Loaded Blueprint — Implementation Plan
Parent spec:
docs/specs/0095-mc-over-a-loaded-blueprint.mdFor agentic workers: REQUIRED SUB-SKILL: use the
implementskill to run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Add aura mc <blueprint.json> --seeds N — a Monte-Carlo family from a
loaded CLOSED blueprint, each seed drawing a distinct synthetic walk — persisted as
FamilyKind::MonteCarlo with the content-addressed store hook, aura reproduce
bit-identical.
Architecture: All code lands in crates/aura-cli/src/main.rs (engine + registry
untouched — invariants 8/9). A new .json-discriminator on the ["mc", ..] dispatch
arm (mirroring the 0093 sweep arm) routes to blueprint_mc_family (builds an
McFamily by running the fixed closed blueprint over N seed-driven synthetic walks
through the shared run_blueprint_member path) → run_blueprint_mc (the persist seam:
put_blueprint + append_family(MonteCarlo) + print). reproduce_family_in gains a
MonteCarlo realization branch that reconstructs each member's seed-driven walk.
Tech Stack: Rust; SyntheticSpec::source(seed) (aura-engine), McFamily/McDraw/
McAggregate (aura-engine), run_blueprint_member (unchanged), Registry::put_blueprint
append_family(aura-registry),topology_hash/content_id(local).
Files this plan creates or modifies:
- Modify:
crates/aura-cli/src/main.rs— newparse_mc_blueprint_args+McBlueprintArgs(besideparse_sweep_blueprint_args~3797-3835); newsynthetic_walk_sources+blueprint_mc_family+run_blueprint_mc(besideblueprint_sweep_family/run_blueprint_sweep~3154-3246); theMonteCarlobranch inreproduce_family_in(~2503-2574, the run at ~2561); the["mc", rest @ ..]dispatch arm (~4006-4015). - Test:
crates/aura-cli/src/main.rs— unit tests beside the sweep units (~5144-5230):parse_mc_blueprint_args_grammar,blueprint_mc_family_seeds_differ,reproduce_family_re_derives_every_mc_member_bit_identically. - Test:
crates/aura-cli/tests/cli_run.rs— E2E beside the sweep/reproduce E2Es (~3296-3489):aura_mc_over_a_blueprint_reproduces_bit_identically,aura_mc_rejects_an_open_blueprint.
Facts pinned by plan-recon (line refs are current; re-anchor by symbol if drifted):
run_blueprint_member(signal: Composite, point: &[Cell], space: &[ParamSpec], sources: Vec<Box<dyn aura_engine::Source>>, window: (Timestamp,Timestamp), seed: u64, pip: f64, topo: &str) -> RunReport— unchanged; stampsmanifest.seed/manifest.topology_hash.McDraw { pub seed: u64, pub report: RunReport },McFamily { pub draws, pub aggregate },McAggregate::from_draws(&[McDraw]) -> McAggregate(all pub;from_drawsneeds draws non-empty).SyntheticSpec { start: f64, len: usize, step: i64 };.source(seed) -> impl Source;window_of(&[Box<dyn Source>]) -> Option<(Timestamp,Timestamp)>.mc_member_line(id, seed, report) -> String;mc_aggregate_json(&McAggregate) -> String;mc_member_reports(&McFamily) -> Vec<RunReport>.Family { pub id, pub kind: FamilyKind, pub members: Vec<FamilyRunRecord> };FamilyKind::MonteCarlois a variant.stage1_signal(fast: Option<i64>, slow: Option<i64>) -> Composite(Some binds → closed).STAGE1_R_STOP_LENGTH = 3, fixture SMA slow=4 → the graph warms in ~4 bars, so a len-60 walk yields many trades that differ across seeds.- All needed imports (
Cell,ParamSpec,Composite,McFamily,McDraw,McAggregate,SyntheticSpec,window_of,blueprint_from_json,blueprint_to_json,FamilyKind,mc_member_reports,std_vocabulary) are already inmain.rs— nouseedits.
Task 1: parse_mc_blueprint_args + McBlueprintArgs
Files:
-
Modify:
crates/aura-cli/src/main.rs(besideparse_sweep_blueprint_args~3797-3835) -
Test:
crates/aura-cli/src/main.rs(beside the sweep-arg unitparse_sweep_blueprint_args_grammar~5107) -
Step 1: Write the failing test
Add to the #[cfg(test)] mod tests block, beside parse_sweep_blueprint_args_grammar:
#[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)"
);
}
- Step 2: Run test to verify it fails
Run: cargo test -p aura-cli --bin aura parse_mc_blueprint_args_grammar
Expected: FAIL — compile error cannot find function parse_mc_blueprint_args (the fn/struct do not exist yet).
- Step 3: Write minimal implementation
Add beside parse_sweep_blueprint_args (~3797):
/// 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()) })
}
- Step 4: Run test to verify it passes
Run: cargo test -p aura-cli --bin aura parse_mc_blueprint_args_grammar
Expected: PASS.
Task 2: synthetic_walk_sources + blueprint_mc_family (closed-blueprint guard)
Files:
-
Modify:
crates/aura-cli/src/main.rs(besideblueprint_sweep_family~3154;mc_familypattern ~2210) -
Test:
crates/aura-cli/src/main.rs(besideblueprint_sweep_member_equals_single_run_and_shares_topology_hash~5151) -
Step 1: Write the failing test
#[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);
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");
}
- Step 2: Run test to verify it fails
Run: cargo test -p aura-cli --bin aura blueprint_mc_family_seeds_differ
Expected: FAIL — compile error cannot find function blueprint_mc_family (fn does not exist yet).
- Step 3: Write minimal implementation
Add beside blueprint_sweep_family (~3154):
/// 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 is rejected with a named error (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) -> McFamily {
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() {
eprintln!(
"aura: mc requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
space.len()
);
std::process::exit(2);
}
let point: Vec<Cell> = Vec::new(); // closed -> empty point (as `aura run <blueprint.json>`)
let draws: Vec<McDraw> = (1..=n_seeds)
.map(|seed| {
let sources = synthetic_walk_sources(seed);
let window = window_of(&sources).expect("non-empty synthetic walk");
let report =
run_blueprint_member(reload(doc), &point, &space, sources, window, seed, pip, &topo);
McDraw { seed, report }
})
.collect();
let aggregate = McAggregate::from_draws(&draws);
McFamily { draws, aggregate }
}
- Step 4: Run test to verify it passes
Run: cargo test -p aura-cli --bin aura blueprint_mc_family_seeds_differ
Expected: PASS. (If the three draws come back identical, the walk did not warm the
stop — increase SyntheticSpec.len; 60 should suffice for slow=4 + stop-len 3.)
Task 3: reproduce_family_in MonteCarlo realization branch
Files:
-
Modify:
crates/aura-cli/src/main.rs(reproduce_family_in~2503-2574; the run at ~2561-2570) -
Test:
crates/aura-cli/src/main.rs(besidereproduce_family_re_derives_every_member_bit_identically~5194) -
Step 1: Write the failing test
#[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);
// 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);
}
- Step 2: Run test to verify it fails
Run: cargo test -p aura-cli --bin aura reproduce_family_re_derives_every_mc_member_bit_identically
Expected: FAIL — the assertion every MC member re-derives bit-identically fails: without
the branch, reproduce re-runs MC members over data.run_sources() (the showcase series),
not the seed-driven walk, so rerun.metrics != stored.metrics for every member.
- Step 3: Write minimal implementation
In reproduce_family_in, replace the single member run (currently ~2561-2570):
let rerun = run_blueprint_member(
reload(),
&point,
&space,
data.run_sources(),
window,
stored.manifest.seed,
pip,
&hash,
);
with a realization-aware version that reconstructs the member's data from the facet its family kind recorded:
// 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).
let (sources, member_window, seed) = match family.kind {
FamilyKind::MonteCarlo => {
let s = synthetic_walk_sources(stored.manifest.seed);
let w = window_of(&s).expect("non-empty synthetic walk");
(s, w, stored.manifest.seed)
}
_ => (data.run_sources(), window, stored.manifest.seed),
};
let rerun = run_blueprint_member(
reload(),
&point,
&space,
sources,
member_window,
seed,
pip,
&hash,
);
(window is the pre-loop data.full_window() at ~2515, used unchanged for the _ arm;
family.kind is on the grouped Family the loop iterates.)
- Step 4: Run test to verify it passes
Run: cargo test -p aura-cli --bin aura reproduce_family_re_derives_every_mc_member_bit_identically
Expected: PASS.
- Step 5: Verify the Sweep reproduce path stays green
Run: cargo test -p aura-cli --bin aura reproduce_family_re_derives_every_member_bit_identically
Expected: PASS (the _ arm is byte-unchanged for Sweep members).
Task 4: run_blueprint_mc + the ["mc", ..] dispatch arm + E2E
Files:
-
Modify:
crates/aura-cli/src/main.rs(run_blueprint_mcbesiderun_blueprint_sweep~3210; the["mc", rest @ ..]dispatch arm ~4006-4015) -
Test:
crates/aura-cli/tests/cli_run.rs(besideaura_reproduce_re_derives_a_persisted_sweep_bit_identically~3456) -
Step 1: Write the failing E2E tests
Add to crates/aura-cli/tests/cli_run.rs:
/// E2E (acc 1+3+4): `aura mc <closed blueprint.json> --seeds N` builds a MonteCarlo
/// family (one member per seed), writes exactly one content-addressed blueprint, and
/// `aura reproduce <id>` 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 <open blueprint.json>` 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);
}
- Step 2: Run tests to verify they fail
Run: cargo test -p aura-cli --test cli_run aura_mc_over_a_blueprint_reproduces_bit_identically
Expected: FAIL — aura mc <fixture>.json currently falls into parse_mc_args, which does
not accept a .json first positional, so the command errors (no .json discriminator yet).
- Step 3: Write
run_blueprint_mc
Add beside run_blueprint_sweep (~3210):
/// `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);
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));
}
- Step 4: Grow the
["mc", ..]dispatch arm
Replace the current arm (~4006-4015):
["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)
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
with the .json-discriminator arm (mirroring the ["sweep", ..] arm ~3952):
["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;
}
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);
}
}
}
- Step 5: Run the E2E tests to verify they pass
Run: cargo test -p aura-cli --test cli_run aura_mc_over_a_blueprint_reproduces_bit_identically
Expected: PASS.
Run: cargo test -p aura-cli --test cli_run aura_mc_rejects_an_open_blueprint
Expected: PASS.
- Step 6: Verify the hard-wired
aura mcpath is undisturbed
Run: cargo test -p aura-cli --test cli_run mc
Expected: PASS (the hard-wired-mc E2Es still green — the .json discriminator keeps the
non-.json path falling through to parse_mc_args).
Task 5: Full-workspace gate
Files: none (verification only)
- Step 1: Build the workspace
Run: cargo build --workspace
Expected: builds clean, 0 errors.
- Step 2: Run the full test suite
Run: cargo test --workspace
Expected: all suites pass (the 3 new units + 2 new E2Es green; no regressions).
- Step 3: Lint
Run: cargo clippy --workspace --all-targets -- -D warnings
Expected: 0 warnings.