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
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
# 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)`:
|
||||
|
||||
- `MonteCarlo` → `synthetic_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:
|
||||
|
||||
```console
|
||||
$ 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:
|
||||
|
||||
```console
|
||||
$ 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:
|
||||
|
||||
```rust
|
||||
["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:
|
||||
|
||||
```rust
|
||||
["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
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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:
|
||||
|
||||
```rust
|
||||
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 reproduce`s **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.
|
||||
Reference in New Issue
Block a user