Skip to content

Streaming distributed optical Monte-Carlo with converging sketches and bounded output - #126

Open
Areustle wants to merge 4 commits into
mainfrom
streaming_compute
Open

Streaming distributed optical Monte-Carlo with converging sketches and bounded output#126
Areustle wants to merge 4 commits into
mainfrom
streaming_compute

Conversation

@Areustle

Copy link
Copy Markdown
Contributor

Summary

Adds a streaming, distributed optical Monte-Carlo path beside the existing one-shot compute(), exposed as a new nuspacesim stream command. Events are thrown in batches across a dask cluster; the MC integral accumulates as an exact additive sketch with live statistics, the run stops when it reaches a target precision, and only a bounded weighted sample of events is written to disk.

Motivated by three limits of the fixed-N path: memory grows with thrown_events (every valid event is retained), the integral's precision is only known after the run completes, and a long run gives no feedback while in flight.

nuspacesim stream --rel-unc 0.01 --reservoir-size 5000 sample_input_file.toml

compute() and the radio/ToO paths are untouched; this is purely additive.

Why the integral streams exactly

Every optical quantity RegionGeom.mcintegral reports is a linear functional of a per-event contribution scaled by config-only constants: np.sum, np.var, np.count_nonzero over the events, divided by total thrown. mcnorm is config-derived, and both spec_norm and sum_spec_weights are closed-form constants (the latter despite its name — it is not a sum over the drawn sample). There is therefore no hidden global normalization coupling batches, and the moment accumulators (n_thrown, n_valid, s1, s2, s1_geo, n_pass) merge additively with no loss of precision versus a one-shot run.

Validated: streaming vs compute() optical integral agree within 1.22σ at 200k thrown (ratio 1.017 — pure MC noise).

What's in it

Module Role
simulation/streaming/sketch.py MomentSketch — exact additive integral accumulator
simulation/streaming/reservoir.py WeightedReservoir — Efraimidis–Spirakis bottom-k sample
simulation/streaming/batch.py run_batch — the per-batch worker unit
simulation/streaming/driver.py compute_streaming — sliding-window dask loop, live panel, stop criteria
geometry/region_geometry.py mc_contribution() extracted (shared with the one-shot integral)

Stop criteria (first to trip): relative-uncertainty target with a min_thrown warmup, thrown-count backstop (count, default infinity), wall-clock limit, or a spacebar press — which ends the run early but gracefully, finalizing the partial result rather than aborting like Ctrl-C.

Batch sizing adapts to the throughput knee by default; --batch-size N fixes it for a reproducible schedule.

Notes for review

  • Log-domain sampling key. The Efraimidis–Spirakis key is computed as log(u)/w, not the textbook u**(1/w). Optical contributions are ~1e-5, so 1/w is ~1e5 and the direct form underflows to 0.0 for nearly every event, collapsing the sample. The log form is order-equivalent, underflow-free, and invariant to global weight rescaling. This was a real bug caught by the end-to-end test.
  • Parallelism moved up a level. EAS.__call__ gains serial=True so the optical kernel runs serially inside a worker (a nested cluster there would be pathological); the cluster uses threads_per_worker=1 so the per-batch seeding of the legacy global np.random is race-free.
  • Reproducibility. Per-batch RNG derives from SeedSequence(entropy, spawn_key=(batch_idx,)), so batch i yields the same events and keys regardless of scheduling. The materialized reservoir is reproducible; only the float-sum order in the sketch varies (harmlessly). Adaptive batch sizing is timing-dependent and therefore not bit-reproducible — use --batch-size when that matters.
  • mc_contribution extraction is regression-locked: its test oracle is a verbatim copy of the pre-refactor mcintegral body, asserting bit-exact equality.
  • Scope: optical only. Radio and Target-of-Opportunity are deferred (RegionGeomToO divides by a fixed time grid — a different streaming mode). Threading explicit RNG generators through the physics stages is also deferred.

Verification

  • 208 tests pass (35 new); black/isort/flake8/mypy/bandit/vulture/codespell clean on the new code.
  • Sketch equality vs a real RegionGeom.mcintegral is bit-exact on the linear terms; reservoir merge is order-independent, uniform-weight marginals pass a KS test.
  • End-to-end CLI runs verified, including the spacebar interrupt and multi-million-throw runs.

🤖 Generated with Claude Code

Areustle and others added 4 commits September 10, 2026 10:46
Pull the per-event half of RegionGeom.mcintegral into a module-level pure
function (geo factor after the separation cut + full weighted contribution
after the trigger cut) and have mcintegral call it, keeping only the
reduction. Byte-identical output, locked by a regression test whose oracle
is a verbatim copy of the pre-refactor math.

This gives the streaming sketch and the one-shot integral a single shared
definition of the integrand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New Simulation.Streaming model (batch_size, reservoir_size, rel_unc_target,
min_thrown, max_walltime_s with 0 = disabled, reservoir_weighting), same
pattern as cherenkov_quadrature: every field defaulted so existing config
files load unchanged. Example block added to sample_input_file.toml;
defaults, overrides, TOML backward-compat, and validation covered in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two mergeable, order-independent summaries for the distributed streaming MC:

- MomentSketch: additive accumulators (n_thrown, n_valid, s1, s2, s1_geo,
  n_pass) reproducing mcintegral's outputs exactly -- the integral is a mean
  of per-event contributions times config-only constants, so batching loses
  no precision. Variance keeps the legacy quirk: ddof=1 over valid events,
  divided by total thrown.

- WeightedReservoir: Efraimidis-Spirakis bottom-k sample with keys kept in
  LOG domain (log(u)/w). The direct u**(1/w) underflows to 0 at the ~1e-5
  contribution weights this simulation produces, collapsing the sample; the
  log form is order-equivalent, underflow-free, and scale-invariant. Merging
  is top-k of the union, so partial reservoirs tree-reduce in any order.

Tests: bit-exact single-batch equality vs a real RegionGeom.mcintegral,
merge exactness/associativity, KS-uniformity under equal weights,
importance bias under contribution weights, and validation error branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nuspacesim stream <config> [count]: throw events in batches across a
process-based dask cluster, merge each batch's sketch + reservoir into
running totals behind a live rich panel, and stop on the first of:
relative-uncertainty target (with min_thrown warmup), thrown-count backstop
(count, default infinity), wall-clock limit, or a spacebar press (graceful:
the partial result finalizes normally, STRMSTOP=user_interrupt). Only the
weighted reservoir is materialized; sketch results land in the legacy
OMCINT/OMCINTGO/ONEVPASS/OMCINTUN header keys plus STRM* provenance.

- batch.py run_batch: per-worker cached engines; per-batch RNG from
  SeedSequence(entropy, spawn_key=(batch_idx,)) so batch i is identical
  regardless of scheduling -- the reservoir is reproducible even though
  float-sum merge order isn't.
- Parallelism moves up to the batch level: EAS.__call__ gains serial=True
  (drives CphotAng.run directly, no nested cluster) and BackgroundCluster
  accepts LocalCluster kwargs (threads_per_worker=1 keeps the legacy global
  np.random seeding race-free; silence_logs quiets worker teardown noise).
- Batch size: --batch-size N for a fixed reproducible schedule, else
  _AdaptiveBatch grows toward the throughput knee (B/dt signal, EWMA,
  settle on diminishing gains) and holds.
- CLI overrides: --rel-unc, --reservoir-size, --batch-size; --monospectrum/
  --powerspectrum/cloud flags mirror `run`.

Validated against the one-shot compute(): optical MC integral agrees within
1.22 sigma at 200k thrown (pure MC noise); in-process worker tests lock the
run_batch determinism contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Areustle

Copy link
Copy Markdown
Contributor Author

Regression check against current main (post #124 + #127)

Rebased onto main @ a745b13 (clean, no conflicts). Since the branch's previous base, main changed only CI/packaging files (.github/workflows/*, .pre-commit-config.yaml, pyproject.toml, setup.cfg); this branch touches none of them — zero file overlap, so the merge cannot revert anything on main.

Behavioral A/B of the existing one-shot path. Ran a seeded compute() (np.random.seed(20260910), 100k thrown, main's own sample_input_file.toml — no [simulation.streaming] block, optical + radio enabled) on a clean worktree of main and on this branch, then compared the full result table:

  • All 16 columns bit-identical across 99,205 events — including the radio EFields (99205, 27) matrix, since radio shares RegionGeom.mcintegral whose per-event math this branch extracts into mc_contribution().
  • All 8 scalar integrals identical: OMCINT, OMCINTGO, OMCINTUN, ONEVPASS, RMCINT, RMCINTGO, RMCINTUN, RNEVPASS.
  • Loading main's config file unchanged also confirms the new optional [simulation.streaming] section is genuinely backward compatible.

Tests: 208 pass on the rebased branch; main's existing tests are modified only additively (0 deleted lines — test_config.py +52, test_cli.py +20); pre-commit all green.

Net: the streaming work is purely additive to the existing pipeline — compute() output is byte-for-byte unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant