C12 axis 4: Monte-Carlo as a sweep over seeds. `monte_carlo(base_point, seeds, closure) -> McFamily`, the analog of SweepFamily, reusing the disjoint-parallel executor (extracted into a shared `run_indexed` core) rather than forking a new run loop. Each realization is a disjoint C1 unit; parallelism is across draws. The aggregate (V1) covers all three run metrics with mean + p5/p25/p50/p75/p95, a pure post-run reduction over the retained raw draws. Eager-agnostic firewall (#71): the API takes seeds + a per-draw closure, never a materialized stream Vec; seed->Source construction stays a closure-body concern. Signed under the /boss spec auto-sign gate: objective gates green (precondition, self-review, grounding-check PASS) and a unanimous five-lens spec-skeptic panel (criterion, grounding, scope-fork, ambiguity, plan-readiness) returned SOUND. The aggregate fork was settled to V1 by the user (issue #68 reconciliation comment, verbatim "ja, wir machen v1", 2026-06-15). refs #68
18 KiB
Monte-Carlo Family — Design Spec
Date: 2026-06-15 Status: Approved (boss-signed, auto-sign gate) Authors: orchestrator + Claude
Goal
Add monte_carlo, the Monte-Carlo orchestration family — C12 axis 4:
Monte-Carlo is a sweep over seeds. Where sweep (cycle 0028/0029) varies a
tuning param across a grid, monte_carlo varies the seed across a seed set,
producing an McFamily — the analog of SweepFamily. Each realization is a
disjoint C1 unit; parallelism is across realizations, never within one. The
family carries the per-seed RunReports plus a stored aggregate (mean +
quantiles of all three run metrics across realizations) for the common
robustness question.
This is the fourth orchestration-family axis of the milestone The World, part
II. Its precondition — a seed that actually perturbs a run — shipped in cycle
0042 (seed-as-input, SyntheticSpec::source(seed), live RunManifest.seed).
Closes #68.
Architecture
monte_carlo reuses the existing disjoint-parallel executor; it does not
fork a new run loop (issue #68 design constraint). The reuse is realized by
extracting the index-parallel core that sweep_with_threads already contains
into a neutral helper both families drive:
run_indexed(n, nthreads, |i| -> T) <- the shared disjoint-parallel core
├── sweep_with_threads (i -> point i -> RunReport) [behaviour-preserving refactor]
└── monte_carlo_with_threads (i -> seed i -> RunReport) [new]
run_indexed is exactly today's sweep_with_threads body, generalized from
RunReport to a generic T: Send and from "point index" to "job index": a
shared atomic cursor work-steals job indices across available_parallelism()
std::thread::scope workers, each tags its result with the job index, and the
results are sorted by that index after the scope joins — so output order is the
input order, independent of completion order (C1). Only the cursor is
shared; the results side is lock-free.
The refactor of sweep_with_threads onto run_indexed is behaviour-
preserving (C11/C23: the flat graph and its execution are unchanged; this is a
pure code-organisation change above the engine loop). C1 is the correctness
invariant: the existing sweep tests (family_is_deterministic_across_thread_counts,
sweep_equals_n_independent_runs) stay green, which is the proof the refactor
preserved behaviour.
Eager-agnostic firewall (#71). monte_carlo never takes or stores a
materialized per-draw Vec<Stream>. It takes seeds: &[u64] plus a per-draw
closure Fn(u64, &[Scalar]) -> RunReport; the seed→Source construction lives
inside the closure body (it builds a Source the way #66 wires it). So a
later lazy Harness::run needs no McFamily API break.
Module home. A new crates/aura-engine/src/mc.rs holds the MC family types
and monte_carlo. The shared run_indexed core lives where both modules can
reach it; the recommendation is a small neutral helper visible to both (e.g.
pub(crate) in sweep.rs, or a tiny exec submodule). The load-bearing
constraint is one executor, not two; the exact file placement is the
planner's, as long as sweep and monte_carlo share the same core.
Concrete code shapes
Worked author example (the acceptance evidence — user-facing surface)
A trader-researcher asks: how robust is this strategy's tail drawdown across stochastic market realizations? They run a fixed base point over a seed set and read the distribution of the metrics:
use aura_engine::{
monte_carlo, McFamily, RunReport, RunManifest, Scalar, SyntheticSpec, Timestamp,
f64_field, summarize,
};
// The strategy is fixed; only the market realization (the seed) varies.
let base_point = vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)];
let seeds: Vec<u64> = (0..256).collect();
let family: McFamily = monte_carlo(&base_point, &seeds, |seed, point| {
// seed -> Source is a closure-body concern (eager-agnostic firewall, #71):
// build a seeded *Source*, never a materialized Vec; the engine sees a Source.
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("base point is kind-checked against param_space");
let spec = SyntheticSpec { start: 1.0, len: 1024, step: 60_000 };
let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1));
h.run(vec![Box::new(spec.source(seed))]); // <- the seed perturbs THIS run
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "…".into(),
params: vec![("fast".into(), 2.0), ("slow".into(), 4.0), ("scale".into(), 0.5)],
window,
seed, // <- the seed that drove this draw is recorded (C12)
broker: "sim-optimal(pip_size=0.0001)".into(),
},
metrics: summarize(&equity, &exposure),
}
});
// The common-case aggregate is stored on the family — pure convenience:
println!("median total pips: {}", family.aggregate.total_pips.p50);
println!("p95 drawdown (tail risk): {}", family.aggregate.max_drawdown.p95);
// Custom statistics ALWAYS read the retained raw draws directly (the V1 rationale:
// the draws are the source of truth; the stored aggregate unlocks no capability
// the draws don't already give):
let worst = family
.draws
.iter()
.map(|d| d.report.metrics.max_drawdown)
.fold(0.0_f64, f64::max);
This is the criterion's empirical evidence: the World's differentiator is families of harnesses (C12), and "run over N seeds → robustness distribution" is the named MC axis a researcher reaches for. Each draw is a disjoint, deterministic C1 unit; the family is reproducible.
Implementation shapes (secondary)
New: the result types (mc.rs). Analog to SweepPoint / SweepFamily:
/// One Monte-Carlo realization: the seed that drove it and the full RunReport.
/// Self-describing, analog to `SweepPoint { params, report }`.
#[derive(Clone, Debug, PartialEq)]
pub struct McDraw {
pub seed: u64,
pub report: RunReport,
}
/// The result family of a Monte-Carlo run — one McDraw per seed, in seed-INPUT
/// order (independent of thread completion), plus the stored aggregate (V1).
#[derive(Clone, Debug, PartialEq)]
pub struct McFamily {
pub draws: Vec<McDraw>,
pub aggregate: McAggregate,
}
/// Distribution summary of all three run metrics across the realizations (V1:
/// covers every metric, not a single "chosen" one). A pure post-run reduction
/// over `draws` — stored for the common robustness case; custom stats read the
/// raw draws.
#[derive(Clone, Debug, PartialEq)]
pub struct McAggregate {
pub total_pips: MetricStats,
pub max_drawdown: MetricStats,
pub exposure_sign_flips: MetricStats,
}
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
/// the median (the two coincide by definition), so the V1 "mean/median/quantiles"
/// is `mean` + `p50` + the surrounding quantiles, with no redundant field.
#[derive(Clone, Debug, PartialEq)]
pub struct MetricStats {
pub mean: f64,
pub p5: f64,
pub p25: f64,
pub p50: f64, // == median
pub p75: f64,
pub p95: f64,
}
New: the entry point + thread-explicit core (mc.rs). Mirrors sweep /
sweep_with_threads:
/// Run `run_one(seed, base_point)` over every seed, disjointly in parallel (C1),
/// and collect the family in seed-input order plus the aggregate. The varying
/// dimension is the *seed* (C12 axis 4: MC = sweep over seeds), not a tuning
/// param. `base_point` is constant across draws.
///
/// Precondition: `seeds` is non-empty (a Monte-Carlo over zero realizations has
/// no defined aggregate). A debug-assert guards it; the stated `-> McFamily`
/// signature (issue #68) is preserved — no Result, matching `sweep`.
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
monte_carlo_with_threads(base_point, seeds, nthreads, run_one)
}
// module-private; the public `monte_carlo` derives the worker count, the tests
// drive it at 1 and N to pin determinism under concurrency (C1).
fn monte_carlo_with_threads<F>(
base_point: &[Scalar],
seeds: &[u64],
nthreads: usize,
run_one: F,
) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
{
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
let reports = run_indexed(seeds.len(), nthreads, |i| run_one(seeds[i], base_point));
let draws: Vec<McDraw> = seeds
.iter()
.zip(reports)
.map(|(&seed, report)| McDraw { seed, report })
.collect();
let aggregate = McAggregate::from_draws(&draws);
McFamily { draws, aggregate }
}
Refactor: extract the shared core (behaviour-preserving). Before — the
disjoint-parallel loop is inlined in sweep_with_threads and hard-typed to
RunReport. After — it is run_indexed<T>, and sweep_with_threads calls it:
// AFTER (shared core): generic over the per-job result T.
pub(crate) fn run_indexed<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
{
let nthreads = nthreads.clamp(1, n.max(1));
let cursor = AtomicUsize::new(0);
let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..nthreads)
.map(|_| scope.spawn(|| {
let mut local = Vec::new();
loop {
let i = cursor.fetch_add(1, Ordering::Relaxed);
if i >= n { break; }
local.push((i, run_one(i)));
}
local
}))
.collect();
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
results.sort_by_key(|&(i, _)| i);
results.into_iter().map(|(_, t)| t).collect()
}
// AFTER: sweep_with_threads is a thin adapter over the shared core.
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
where
F: Fn(&[Scalar]) -> RunReport + Sync,
{
let points = space.points();
let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i]));
SweepFamily {
points: points
.into_iter()
.zip(reports)
.map(|(params, report)| SweepPoint { params, report })
.collect(),
}
}
New: the aggregate reduction (mc.rs). A pure function of the draws:
impl McAggregate {
/// Pure reduction over the realizations — recomputable from `draws` alone
/// (it is exactly what `monte_carlo` stored). Empty `draws` is a caller
/// precondition violation (debug-asserted upstream); on a non-empty slice
/// every field is finite.
pub fn from_draws(draws: &[McDraw]) -> McAggregate {
let pick = |f: fn(&RunMetrics) -> f64| -> MetricStats {
let mut xs: Vec<f64> = draws.iter().map(|d| f(&d.report.metrics)).collect();
xs.sort_by(|a, b| a.partial_cmp(b).expect("run metrics are finite"));
MetricStats {
mean: xs.iter().sum::<f64>() / xs.len() as f64,
p5: quantile(&xs, 0.05),
p25: quantile(&xs, 0.25),
p50: quantile(&xs, 0.50),
p75: quantile(&xs, 0.75),
p95: quantile(&xs, 0.95),
}
};
McAggregate {
total_pips: pick(|m| m.total_pips),
max_drawdown: pick(|m| m.max_drawdown),
exposure_sign_flips: pick(|m| m.exposure_sign_flips as f64),
}
}
}
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
/// pre-sorted, non-empty slice. `p` in `[0,1]`. rank = p*(n-1); interpolate
/// between the two bracketing order statistics. n == 1 returns the sole value.
fn quantile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = p * (n - 1) as f64;
let lo = rank.floor() as usize;
let frac = rank - lo as f64;
if lo + 1 < n {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
} else {
sorted[n - 1]
}
}
Components
| Component | Home | Role |
|---|---|---|
run_indexed<T> |
sweep.rs (shared core) |
the one disjoint-parallel executor; index-order output, lock-free results |
sweep_with_threads |
sweep.rs |
refactored onto run_indexed (behaviour-preserving) |
McDraw |
mc.rs |
one realization: { seed, report } |
McFamily |
mc.rs |
{ draws, aggregate } — the result family |
MetricStats |
mc.rs |
mean + p5/p25/p50/p75/p95 of one metric |
McAggregate |
mc.rs |
MetricStats for each of the three run metrics; from_draws |
monte_carlo / monte_carlo_with_threads |
mc.rs |
entry point + thread-explicit core |
quantile |
mc.rs (private) |
type-7 linear-interpolation quantile |
New public exports from aura-engine (lib.rs): monte_carlo, McDraw,
McFamily, McAggregate, MetricStats.
Data flow
base_point: &[Scalar] seeds: &[u64] run_one: Fn(u64, &[Scalar]) -> RunReport
│ │ │
└──────────┬─────────┘ │
▼ │
monte_carlo_with_threads │
│ run_indexed(seeds.len(), …, i => run_one(seeds[i], base_point))
▼ │ (disjoint, C1, across draws)
Vec<RunReport> ──zip(seeds)──▶ Vec<McDraw> (seed-input order)
│ │
▼ │
McAggregate::from_draws ──▶ McAggregate (pure post-run reduction)
│
▼
McFamily { draws, aggregate }
Error handling
- Empty seed set. A documented precondition:
seedsmust be non-empty (a Monte-Carlo over zero realizations has no defined mean/quantile). Guarded bydebug_assert!, consistent with the stated-> McFamilysignature (noResult, matchingsweep). Spec-level articulation, not a user-settled point: the issue's signature returnsMcFamily, and adding aResultwould break it the same way the rejected V2 broke the signature; the raw-draws safety valve makes the edge low-stakes. Flagged for review. - Duplicate seeds. Allowed, not deduplicated: N seeds → N draws even if a seed repeats (a repeated seed yields an identical, deterministic draw). Seed uniqueness is the caller's concern.
- NaN ordering in the aggregate. Run metrics are finite by construction
(
summarizeproduces finitef64s over recorded streams), so the sort usespartial_cmp(...).expect("finite")— a non-finite metric is a wiring bug, surfaced like the engine's other checked-at-construction violations, not silently mis-sorted.
Testing strategy
RED-first executable spec. The tests mirror the sweep test set, adapted to the
seed axis:
monte_carlo_runs_one_draw_per_seed_in_input_order—monte_carlo(point, &[1, 2, 3], run_one).drawscarries seeds[1, 2, 3]in that order, independent of completion order.family_is_deterministic_across_thread_counts—monte_carlo_with_threads( …, 1, …) == monte_carlo_with_threads(…, 8, …)and both equal the publicmonte_carlo(C1: order = seed input, not completion).draw_equals_independent_seeded_run— each draw's report equals a direct, independentrun_one(seed, point)(MC adds enumeration + execution, never a metrics change — C1), the analog ofsweep_equals_n_independent_runs.distinct_seeds_produce_distinct_draws— a multi-seed family does not collapse to one metric (the seed actually perturbs each run), the analog ofdistinct_points_produce_distinct_metrics.aggregate_is_a_pure_reduction—McAggregate::from_draws(&family.draws) == family.aggregate; the stored aggregate is recomputable from the draws.aggregate_stats_on_known_fixture— over draws with known metric values[0, 1, 2, 3, 4],mean == 2.0,p50 == 2.0,p5 ≈ 0.2,p95 ≈ 3.8(type-7), pinning the quantile + mean math.quantile_endpoints_and_singleton—quantile(&[x], p) == xfor anyp;quantile(sorted, 0.0) == sorted[0];quantile(sorted, 1.0) == sorted.last().- Refactor guard (already green, must stay green): the existing
sweep.rstests (sweep_equals_n_independent_runs,family_is_deterministic_across_thread_counts,distinct_points_produce_distinct_metrics) are the proof therun_indexedextraction preserved behaviour (C1/C11/C23).
Acceptance criteria
monte_carlo(base_point, seeds, run_one) -> McFamilyexists and is exported fromaura-engine, with the eager-agnostic signature (seeds + per-draw closure, never a materialized stream Vec).- N seeds → N disjoint, deterministic realizations; the family is reproducible
(same seed set → same
McFamily) and order is seed-input order across any thread count (C1). - The disjoint-parallel executor is reused, not forked:
sweepandmonte_carlodrive the samerun_indexedcore, and the pre-existingsweeptests stay green (behaviour-preserving refactor). - The aggregate covers all three metrics (V1), each with mean + p5/p25/p50/p75/p95, and is a pure post-run reduction recomputable from the retained raw draws.
cargo test --workspacegreen;cargo clippy --workspace --all-targets -D warningsclean;cargo doc -p aura-engineclean (new intra-doc links resolve).
Non-goals
- No CLI surface (
aura mc …) this cycle —monte_carlois a library entry, likesweep. A CLI/registry surface for MC families is #70's territory (registry lineage for orchestration families). - No random param-sweep (#52) — that is the grid's stochastic other half; this cycle is the seed axis only.
- No walk-forward (#69).
- No typed param-space;
base_pointstays&[Scalar]inparam_space()order, assweepalready uses.