From a8fc019e3258b94b36446a44883a6b8d7dc5864d Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Thu, 9 Jul 2026 10:08:04 +0200 Subject: [PATCH] Derive a unique per-job seed for minicalosim shard generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent job launches in create_root_files.py can start within the same wall-clock second, and minicalosim's default seed falls back to time(NULL) in that case — so two "independent" shards could silently get identical RNG state and produce byte-identical physics. Requires the companion MINICALOSIM_SEED env-var support in the minicalosim repo. Co-Authored-By: Claude Sonnet 5 --- scripts/create_root_files.py | 21 +++++++++++++-- tests/test_create_root_files.py | 45 ++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/scripts/create_root_files.py b/scripts/create_root_files.py index 495ae8a..1b1c8f2 100644 --- a/scripts/create_root_files.py +++ b/scripts/create_root_files.py @@ -25,6 +25,7 @@ import shutil import subprocess import sys import uuid +import zlib from dataclasses import dataclass from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -104,6 +105,20 @@ def plan_jobs( return jobs +def job_seed(kind: str, gen: str, job: SimJob) -> int: + """Deterministic RNG seed for one sim job, unique per (kind, gen, detector, config, shard). + + Jobs run concurrently (ThreadPoolExecutor below) and can start within the + same wall-clock second; minicalosim's default seed falls back to + time(NULL) in that case, so two concurrently-launched jobs can silently + get identical RNG state and produce byte-identical physics despite + landing in separate shard files. Deriving the seed from the full job + identity instead keeps it both unique and reproducible. + """ + key = f"{kind}|{gen}|{job.detector}|{job.config or ''}|{job.shard_index}" + return zlib.crc32(key.encode()) & 0x7FFFFFFF + + def run_job( job: SimJob, executable: Path, @@ -124,7 +139,8 @@ def run_job( cmd.append(job.config) cmd.append(str(events_per_file)) - result = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True) + env = dict(os.environ, MINICALOSIM_SEED=str(job_seed(kind, gen, job))) + result = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True, env=env) if result.returncode != 0: return JobResult( @@ -254,7 +270,8 @@ def run_make_root( / job.detector / f"shard-{job.shard_index:03d}.root" ) - print(f" {' '.join(cmd)} -> {dest}") + seed = job_seed(kind, gen, job) + print(f" MINICALOSIM_SEED={seed} {' '.join(cmd)} -> {dest}") if not execute: print("\nDry run only — pass --execute to apply.") diff --git a/tests/test_create_root_files.py b/tests/test_create_root_files.py index 72d8955..4d21f5e 100644 --- a/tests/test_create_root_files.py +++ b/tests/test_create_root_files.py @@ -9,6 +9,7 @@ from scripts import create_root_files parse_detector_spec = create_root_files.parse_detector_spec next_shard_index = create_root_files.next_shard_index plan_jobs = create_root_files.plan_jobs +job_seed = create_root_files.job_seed run_job = create_root_files.run_job run_all = create_root_files.run_all SimJob = create_root_files.SimJob @@ -35,7 +36,13 @@ start = time.time() time.sleep({sleep}) end = time.time() payload = json.dumps( - {{"argv": sys.argv[1:], "cwd": os.getcwd(), "start": start, "end": end}} + {{ + "argv": sys.argv[1:], + "cwd": os.getcwd(), + "start": start, + "end": end, + "seed": os.environ.get("MINICALOSIM_SEED"), + }} ) for i in range({output_count}): with open(f"out_{{i}}.root", "w") as f: @@ -130,6 +137,42 @@ def test_plan_jobs_multiple_detectors_each_start_independently(tmp_path): assert by_detector["sampling_fe_scint"] == [0, 1] +def test_job_seed_deterministic(): + job = SimJob(detector="pbwo4", config=None, shard_index=3) + assert job_seed("steps", "gen1", job) == job_seed("steps", "gen1", job) + + +def test_job_seed_varies_by_shard_index(): + a = SimJob(detector="pbwo4", config=None, shard_index=0) + b = SimJob(detector="pbwo4", config=None, shard_index=1) + assert job_seed("steps", "gen1", a) != job_seed("steps", "gen1", b) + + +def test_job_seed_varies_by_detector(): + a = SimJob(detector="pbwo4", config=None, shard_index=0) + b = SimJob(detector="sampling_pb_scint", config="pb_scint", shard_index=0) + assert job_seed("steps", "gen1", a) != job_seed("steps", "gen1", b) + + +def test_job_seed_varies_by_gen(): + job = SimJob(detector="pbwo4", config=None, shard_index=0) + assert job_seed("steps", "gen1", job) != job_seed("steps", "gen2", job) + + +def test_run_job_passes_deterministic_seed_env_var(tmp_path): + fake = _write_fake_executable(tmp_path / "fake_exe.py") + (tmp_path / "raw" / "steps" / "gen1").mkdir(parents=True) + tmp_root = tmp_path / ".sim-tmp" + tmp_root.mkdir() + + job = SimJob(detector="pbwo4", config=None, shard_index=5) + result = run_job(job, fake, 10000, tmp_path, "steps", "gen1", tmp_root) + + assert result.dest is not None + payload = json.loads(result.dest.read_text()) + assert payload["seed"] == str(job_seed("steps", "gen1", job)) + + def test_run_job_moves_output_to_correct_shard_path(tmp_path): fake = _write_fake_executable(tmp_path / "fake_exe.py") gen_dir = tmp_path / "raw" / "steps" / "gen1"