Files
Aura/docs/specs/0095-mc-over-a-loaded-blueprint.md
T
Brummel 7116660fe8 spec: 0095 monte-carlo over a loaded blueprint (boss-signed)
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 drawing a distinct synthetic price walk (the mc_family pattern), sharing
one topology_hash, written once to the content-addressed store (the 0094 hook)
so aura reproduce re-derives it bit-identically.

Scope narrowed from #170's two verbs by two read-only review gates:
- spec-skeptic: a loaded-blueprint 'walkforward' with no in-sample refit under
  the verb name that hard-wired walkforward uses for real IS-optimization is a
  guarantee collision -> walk-forward deferred to #173 (deps #169 axis-discovery).
- grounding-check: there is no per-knob declared-default mechanism and the raw
  bind path panics on arity mismatch -> MC runs a CLOSED blueprint over the empty
  point (as aura run does), rejecting an open blueprint with a named error + exit 2.

Grounding-check PASS on the revised spec; decisions logged on #170.
refs #170
2026-07-01 11:53:05 +02:00

15 KiB

Monte-Carlo over a Loaded Blueprint — Design Spec

Date: 2026-07-01 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Reference issue: #170 (World/C21 milestone). This cycle ships the Monte-Carlo half of #170; the walk-forward half is deferred (see Scope below). Design decisions, the grounding correction, and the two-gate narrowing are recorded on #170. Follow-on tracks: #172 (real-data block-bootstrap MC, deps #124) and #173 (walk-forward over a loaded blueprint, deps #169).

Goal

Add a .json-discriminator dispatch arm — aura mc <blueprint.json> --seeds N — that builds a Monte-Carlo family from a loaded (serialized) closed blueprint, mirroring cycle 0093's aura sweep <blueprint.json>. It runs the fixed blueprint across N seeds, each seed drawing a distinct synthetic price walk (SyntheticSpec:: source(seed), the mc_family pattern), persists the family under FamilyKind::MonteCarlo with the members' shared topology_hash, and writes the canonical blueprint once to the content-addressed store — so a persisted aura mc family reproduces bit-identically via aura reproduce (the concrete proof the store hook fired).

Scope — MC only this cycle; walk-forward deferred

#170 asked for both mc and walkforward over a loaded blueprint. Two independent review gates on the draft narrowed this cycle to MC only:

  • Verb-name collision (walk-forward). The hard-wired aura walkforward does a true per-window in-sample re-optimization (sweep_over(is) → select_winner → run_oos, main.rs:2010-2016). A loaded blueprint declares no sweepable axes yet (that is #169), so a loaded-blueprint walkforward could only be a fixed-blueprint windowed out-of-sample evaluation — no IS refit. Shipping that under the identical verb name (split only by a .json suffix) would let one trading term name two categorically different guarantees. The honest walk-forward over a loaded blueprint is the IS-refit form, which needs axis-discovery (#169) first; it is tracked by #173, not forced into this cycle under a colliding name.
  • The MC half carries no such collision: aura mc over a fixed closed blueprint, varying the data realization by seed, is a Monte-Carlo in the plain sense.

Architecture

The grounding correction (foundation)

run_blueprint_member(signal, point, space, sources, window, seed, pip, topo) (main.rs:3109) runs the member over the sources argument as given and uses seed / window only to stamp the manifest (sim_optimal_manifest). The stage-1-R graph is deterministic (C1). Therefore Monte-Carlo variation cannot come from the seed label alone — it must come from re-drawing the input, exactly as the hard-wired mc_family does (SyntheticSpec::source(seed) → a distinct price walk per seed, main.rs:2212). aura mc over a loaded blueprint therefore supplies seed-derived synthetic sources to run_blueprint_member per member:

verb facet per-member sources window seed
sweep (0093) param point showcase (full), fixed full 0
mc (new) seed SyntheticSpec::source(seed) walk that walk's window the seed

run_blueprint_member's signature is unchanged — MC only passes a different (sources, window, seed) triple than the sweep's (showcase, full, 0). The shared reduce-mode member path makes reproduction bit-identical by construction (C1) — the 0094 keystone.

Closed blueprint only (grounded constraint)

MC declares no parameter axis (only the seed varies), so it runs the blueprint at its fixed, fully-bound configuration — a closed blueprint (an empty wrapped param_space). This is exactly the case aura run <blueprint.json> already supports: it passes empty params and bootstraps only when the blueprint has zero free knobs (the closed stage1_signal.json fixture). Grounding confirmed there is no per-knob "declared default" mechanism — ParamSpec { name, kind } carries no default and compile_with_params requires params.len() == space.len(). So MC uses the empty point (&[]), never a synthesized default point.

An open blueprint (a non-empty wrapped param_space — free knobs) is rejected with a named error + exit 2 before any run: MC has no axis to bind those knobs. This also fixes the boundary honestly — the raw bootstrap/compile_with_params path panics on an arity mismatch (main.rs:3084), so MC guards against it up front rather than letting a panic escape. (To vary a knob, the user reaches for aura sweep <blueprint.json> --axis; to Monte-Carlo it, the blueprint must be closed.)

The persist seam

run_blueprint_mc replicates the two things the 0093 sweep persist seam does (run_blueprint_sweep, main.rs:3210):

  1. reg.put_blueprint(&topo, &canonical) — the content-addressed store write (the 0094 hook), keyed by the family's shared topology_hash, canonical bytes blueprint_to_json(blueprint_from_json(doc)). Load-bearing: without it, aura reproduce reports the family's members as "blueprint <hash> missing from store" (exit 2). (#170 reconciliation comment.)
  2. reg.append_family(name, FamilyKind::MonteCarlo, &member_reports).

Then print each member line (mc_member_line, carrying the seed) plus the aggregate line — matching the hard-wired run_mc output shape.

Reproduce becomes realization-aware (the one change to 0094 code)

reproduce_family_in (main.rs:2503) currently reconstructs every member over data.run_sources() (showcase) at data.full_window() with the stored seed — correct for Sweep, wrong for MC (whose data is seed-derived). It gains a per-member branch on the family kind that computes the member's (sources, window, seed):

  • MonteCarlosynthetic_walk_sources(manifest.seed), that walk's window, the seed.
  • everything else (Sweep, CrossInstrument) → data.run_sources(), data.full_window(), manifest.seed (unchanged; keeps 0094 green).

The grouped Family struct exposes .kind: FamilyKind (aura-registry lineage.rs:56); the manifest stores seed recoverably (compat.rs:30); synthetic_walk_sources is a fixed constant shared by the persist and reproduce paths, so the seed→walk reconstruction is bit-exact (C1).

Concrete code shapes

User-facing program (the acceptance evidence)

Monte-Carlo over a loaded closed blueprint, then prove it reproduces:

$ aura mc crates/aura-cli/tests/fixtures/stage1_signal.json --seeds 8 --name mc1
mc1  {"family_id":"mc1-0","seed":1,"report":{...topology_hash:"<H>"...}}
mc1  {"family_id":"mc1-0","seed":2,"report":{...topology_hash:"<H>"...}}
...  (8 members, one per seed; same topology_hash <H>, differing metrics)
{"mc_aggregate":{...}}

$ aura reproduce mc1-0
mc1-0  member <params>  reproduced: bit-identical
...  (8 members)
reproduced 8/8 members bit-identically      # exit 0

Rejecting an open blueprint (free knobs) — a named error, not a panic:

$ aura mc crates/aura-cli/tests/fixtures/stage1_signal_open.json --seeds 4
aura: mc requires a closed blueprint (no free parameters); stage1_signal_open.json
      has 2 free knobs — bind them or use `aura sweep --axis`      # exit 2

All members carry the shared topology_hash <H>; a single runs/blueprints/<H>.json is written per family (one blueprint per family, C11/C12). The synthetic-walk realization is the deliverable's machinery, not a trader-grade Monte-Carlo — a real-data block-bootstrap MC is #172.

Dispatch arm (before → after)

Before (main.rs:4006) — the hard-wired verb only:

["mc", rest @ ..] => match parse_mc_args(rest) { /* Synthetic | RealR */ },

After — a .json-file first positional selects the loaded-blueprint path (mirror of the 0093 ["sweep", rest @ ..] arm at 3952), else fall through unchanged:

["mc", rest @ ..] => {
    if let Some(path) = rest.first()
        .filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file())
    {
        let doc = read_to_string(path)/* exit 2 on io err */;
        blueprint_from_json(&doc, &|t| std_vocabulary(t))/* exit 2 on parse err, file-path ctx */;
        let McBlueprintArgs { n_seeds, name, data } =
            parse_mc_blueprint_args(&rest[1..])/* exit 2 on usage */;
        run_blueprint_mc(&doc, n_seeds, &name, DataSource::from_choice(data));
        return;
    }
    match parse_mc_args(rest) { /* unchanged */ }
}

Family builder + closed-blueprint guard

fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> SweepFamily {
    let reload = |d: &str| blueprint_from_json(d, &|t| std_vocabulary(t))
        .expect("doc parse-validated at the dispatch boundary");
    let topo = topology_hash(&reload(doc));
    let pip = data.pip_size();
    let space = /* wrapped param_space probe, as blueprint_sweep_family does (main.rs:3177) */;
    // closed-blueprint guard: MC binds no axis, so a free knob has no binder.
    if !space.is_empty() {
        eprintln!("aura: mc requires a closed blueprint (no free parameters); \
                   {n} free knob(s) — bind them or use `aura sweep --axis`", n = space.len());
        std::process::exit(2);
    }
    let point: Vec<Cell> = Vec::new();                       // closed → empty point (as `aura run`)
    let points = (1..=n_seeds).map(|seed| {
        let sources = synthetic_walk_sources(seed);          // fixed SyntheticSpec, seeded
        let window  = window_of(&sources).expect("non-empty synthetic walk");
        run_blueprint_member(reload(doc), &point, &space, sources, window, seed, pip, &topo)
    }).collect();
    SweepFamily { points /* SweepPoint { report } */ }
}

Reproduce realization branch (before → after)

Before (main.rs:2514-2570) — one path for all members:

let window = data.full_window();
for member in &family.members {
    // ... run_blueprint_member(reload(), &point, &space, data.run_sources(),
    //                          window, stored.manifest.seed, pip, &hash)
}

After — the (sources, window, seed) triple is chosen by the family kind:

for member in &family.members {
    let (sources, window, seed) = match family.kind {
        FamilyKind::MonteCarlo => {
            let s = synthetic_walk_sources(stored.manifest.seed);
            let w = window_of(&s).expect("non-empty walk");
            (s, w, stored.manifest.seed)
        }
        _ /* Sweep, CrossInstrument, (WalkForward → #173) */ =>
            (data.run_sources(), data.full_window(), stored.manifest.seed),
    };
    let rerun = run_blueprint_member(reload(), &point, &space, sources, window, seed, pip, &hash);
    outcomes.push((label, rerun.metrics == stored.metrics));
}

Components

All in aura-cli main.rs (invariant 8: the frozen engine is untouched; invariant 9: the registry stays a dumb bytes/records store):

  • parse_mc_blueprint_args(&[&str]) -> Result<McBlueprintArgs, String> — pure arg parser (mirror parse_sweep_blueprint_args): --seeds <n> (required, ≥1), --name <n> (default "mc"); synthetic-only (real data is #172).
  • blueprint_mc_family — the builder above, with the closed-blueprint guard.
  • run_blueprint_mc(doc, n_seeds, name, data) — the persist seam (put_blueprint + append_family(MonteCarlo) + print member/aggregate lines), mirroring run_blueprint_sweep / run_mc.
  • synthetic_walk_sources(seed) -> Vec<Box<dyn Source>> — the fixed seeded SyntheticSpec walk (sized to warm the loaded graph — showcase-length regime), shared by the persist and reproduce paths.
  • reproduce_family_in — gains the MonteCarlo realization branch.
  • one new dispatch arm.

Data flow

aura mc <bp> --seeds N: read + parse-validate doc → blueprint_mc_family (closed-guard; N members, each run_blueprint_member over its seed's synthetic walk) → put_blueprint(topo, canonical)append_family(name, MonteCarlo, reports) → print member lines + aggregate.

aura reproduce <id> (MC family): load families → find id → for each member, reconstruct synthetic_walk_sources(manifest.seed) + its window → run_blueprint_member → compare metrics → print bit-identical/DIVERGED → exit 0 (all identical) / 1 (any diverged).

Error handling

Mirrors the 0093/0094 boundary discipline (stderr aura: <msg> + exit 2, never a panic):

  • io error reading the .json file → aura: <path>: <e>, exit 2.
  • blueprint parse error → aura: <path>: <e>, exit 2 (validated once at the boundary, so per-member reloads are infallible).
  • open blueprint (non-empty wrapped param_space) → the closed-blueprint guard's named error, exit 2 (before any run — pre-empts the arity panic at main.rs:3084).
  • arg usage error (missing/0 --seeds, unknown flag) → the parser's usage string, exit 2.
  • aura reproduce on an MC family: unknown id / member without topology_hash / blueprint missing from store → exit 2 (unchanged 0094 behaviour); any member diverges → named DIVERGED + exit 1.

Testing strategy

  • Unit (cargo test -p aura-cli --bin aura):
    • blueprint_mc_family over the closed fixture with --seeds 3: 3 members, all sharing one topology_hash, with differing metrics — proves the seed reaches the data (the anti-degenerate guard; a regression to seed-as-label-only would make the members identical and fail this test).
    • parse_mc_blueprint_args: --seeds required/parsed/≥1, default name, unknown-flag rejection.
  • E2E (cargo test -p aura-cli --test cli_run) driving the built binary:
    • aura mc <closed fixture> --seeds 4 --name mcX then aura reproduce mcX-0 → asserts reproduced 4/4 members bit-identically + exit 0, and exactly one runs/blueprints/<hash>.json. This is acceptance criterion (d) — the store-hook proof.
    • aura mc <open fixture> --seeds 4 → asserts the closed-blueprint named error on stderr + exit 2 (NOT a panic / exit 101) — the boundary guard.
  • No new registry code (the append_family MonteCarlo round-trip is already green).

Acceptance criteria

  1. aura mc <closed blueprint.json> --seeds N builds an N-member FamilyKind::MonteCarlo family from the loaded blueprint, members sharing one topology_hash and differing in metrics (seed → synthetic data), and writes one stored blueprint.
  2. An open blueprint is rejected with a named aura: error + exit 2 (never a panic); the hard-wired aura mc path stays unchanged (the .json discriminator keeps them disjoint).
  3. Reproduce compatibility (the store-hook proof): a persisted aura mc family aura reproduces bit-identically (exit 0); the 0094 Sweep reproduce path stays green.
  4. Engine untouched (invariant 8); the registry gains no domain logic (invariant 9); cargo clippy --workspace --all-targets -- -D warnings clean; determinism (C1) holds — reproduction is bit-exact via the shared run_blueprint_member path.