@@ -191,15 +191,22 @@ def simulate(
191191 number of workers will be equal to the number of CPUs available.
192192 A minimum of 2 workers is required for parallel mode.
193193 Default is None.
194- random_seed : int or numpy.random. SeedSequence, optional
194+ random_seed : int, numpy integer, sequence of ints, or SeedSequence, optional
195195 Root seed for the run. When provided, the sampled inputs are
196- reproducible and identical across serial and parallel execution
197- and across any number of workers, because each simulation index
198- draws from its own child stream spawned from this root. It accepts
199- an int or a ``SeedSequence``; a supplied ``SeedSequence`` is copied
200- rather than consumed, so repeated calls with the same seed produce
201- the same inputs. Default is None, which draws fresh entropy (not
202- reproducible), preserving the previous behavior.
196+ reproducible and identical across serial and parallel execution and
197+ across any number of workers: each simulation index derives its own
198+ decorrelated child stream from this root, so index ``i`` receives the
199+ same inputs no matter which worker runs it. A supplied ``SeedSequence``
200+ is copied from its full state rather than consumed, so repeated calls
201+ with the same seed reproduce the same inputs. Each model is reseeded
202+ with a 128-bit integer -- the seed type a custom sampler's
203+ ``reset_seed`` accepts. A stateful ``numpy.random.Generator`` or
204+ ``BitGenerator`` is rejected (it is an RNG to draw from, not a fixed
205+ seed); pass ``rng.bit_generator.seed_seq`` to seed from one. Default is
206+ None, which draws fresh entropy on each run -- the previous,
207+ non-reproducible default. This seeding is informed by Scientific Python
208+ SPEC 7 but keeps immutable seed-snapshot semantics rather than sharing a
209+ ``Generator``.
203210 kwargs : dict
204211 Custom arguments for simulation export of the ``inputs`` file. Options
205212 are:
@@ -233,6 +240,9 @@ def simulate(
233240 self ._export_config = kwargs
234241 self .number_of_simulations = number_of_simulations
235242 self ._initial_sim_idx = self .num_of_loaded_sims if append else 0
243+ # Small, picklable root seed state captured once per run; every
244+ # simulation index derives its child seed from it (see __child_seed).
245+ self .__root_state = None
236246
237247 print ("Starting Monte Carlo analysis" )
238248
@@ -302,19 +312,52 @@ def __root_seed_sequence(random_seed):
302312 )
303313 return np .random .SeedSequence (random_seed )
304314
315+ def __capture_root_state (self , random_seed ):
316+ """Capture the small, picklable root seed state for this run.
317+
318+ Stored once so serial mode and every parallel worker derive the same
319+ per-index child seeds from it (see ``__child_seed``), instead of
320+ materializing and pickling the full ``spawn(number_of_simulations)``
321+ list to each process.
322+ """
323+ root = self .__root_seed_sequence (random_seed )
324+ self .__root_state = (
325+ root .entropy ,
326+ root .spawn_key ,
327+ root .pool_size ,
328+ root .n_children_spawned ,
329+ )
330+
331+ def __child_seed (self , sim_idx ):
332+ """Return the seed sequence for a single simulation index.
333+
334+ This equals ``root.spawn(number_of_simulations)[sim_idx]`` but is O(1)
335+ in time and memory: ``SeedSequence.spawn`` derives child ``i`` by
336+ appending ``n_children_spawned + i`` to the parent ``spawn_key``, so
337+ rebuilding that one child directly reproduces it bit-for-bit while
338+ letting a worker reconstruct any index from the small root state alone.
339+ """
340+ entropy , spawn_key , pool_size , base = self .__root_state
341+ return np .random .SeedSequence (
342+ entropy = entropy ,
343+ spawn_key = (* spawn_key , base + sim_idx ),
344+ pool_size = pool_size ,
345+ )
346+
305347 def __seed_simulation (self , child_seed ):
306348 """Reseed the stochastic models for a single simulation index.
307349
308350 The per-index child seed is split three ways so the environment,
309351 rocket and flight draw from independent streams instead of sharing
310352 one. Seeding per simulation index (not per worker) is what makes the
311353 sampled inputs invariant to the execution mode and to the number of
312- workers.
354+ workers. Each sub-stream is handed over as a 128-bit ``int`` (see
355+ ``_seed_sequence_to_int``) so custom samplers keep working.
313356 """
314357 env_seed , rocket_seed , flight_seed = child_seed .spawn (3 )
315- self .environment ._set_stochastic (env_seed )
316- self .rocket ._set_stochastic (rocket_seed )
317- self .flight ._set_stochastic (flight_seed )
358+ self .environment ._set_stochastic (_seed_sequence_to_int ( env_seed ) )
359+ self .rocket ._set_stochastic (_seed_sequence_to_int ( rocket_seed ) )
360+ self .flight ._set_stochastic (_seed_sequence_to_int ( flight_seed ) )
318361
319362 def __run_in_serial (self , random_seed = None ): # pylint: disable=too-many-statements
320363 """
@@ -334,15 +377,13 @@ def __run_in_serial(self, random_seed=None): # pylint: disable=too-many-stateme
334377 n_simulations = self .number_of_simulations ,
335378 start_time = time (),
336379 )
337- child_seeds = self .__root_seed_sequence (random_seed ).spawn (
338- self .number_of_simulations
339- )
380+ self .__capture_root_state (random_seed )
340381 try :
341382 while sim_monitor .keep_simulating ():
342383 sim_idx = sim_monitor .increment () - 1
343384 inputs_json , outputs_json = "" , ""
344385
345- self .__seed_simulation (child_seeds [ sim_idx ] )
386+ self .__seed_simulation (self . __child_seed ( sim_idx ) )
346387 flight = self .__run_single_simulation ()
347388 inputs_json = self .__evaluate_flight_inputs (sim_idx )
348389 outputs_json = self .__evaluate_flight_outputs (flight , sim_idx )
@@ -399,19 +440,18 @@ def __run_in_parallel(self, n_workers=None, random_seed=None):
399440 )
400441
401442 processes = []
402- # One independent child seed per simulation index (not per
403- # worker), shared with every worker. The shared counter assigns
404- # indices, and index i always seeds from child_seeds[i] , so the
405- # sampled inputs do not depend on the number of workers.
406- child_seeds = self . __root_seed_sequence ( random_seed ). spawn (
407- self . number_of_simulations
408- )
443+ # Each worker derives one independent child seed per simulation
444+ # index (not per worker) from the shared root state: the counter
445+ # assigns indices and index i always seeds from __child_seed(i) , so
446+ # the sampled inputs do not depend on the number of workers. The
447+ # root state is small and travels with the pickled instance, so no
448+ # per-index seed list is materialized or sent to each process.
449+ self . __capture_root_state ( random_seed )
409450
410451 for _ in range (n_workers ):
411452 sim_producer = multiprocess .Process (
412453 target = self .__sim_producer ,
413454 args = (
414- child_seeds ,
415455 sim_monitor ,
416456 mutex ,
417457 simulation_error_event ,
@@ -453,16 +493,11 @@ def __validate_number_of_workers(self, n_workers):
453493 raise ValueError ("Number of workers must be at least 2 for parallel mode." )
454494 return n_workers
455495
456- def __sim_producer (self , child_seeds , sim_monitor , mutex , error_event ): # pylint: disable=too-many-statements
496+ def __sim_producer (self , sim_monitor , mutex , error_event ): # pylint: disable=too-many-statements
457497 """Simulation producer to be used in parallel by multiprocessing.
458498
459499 Parameters
460500 ----------
461- child_seeds : list[numpy.random.SeedSequence]
462- One seed sequence per simulation index. Before each simulation
463- the worker seeds the stochastic models from
464- ``child_seeds[sim_idx]``, where ``sim_idx`` comes from the shared
465- counter, so the inputs are invariant to the number of workers.
466501 sim_monitor : _SimMonitor
467502 The simulation monitor object to keep track of the simulations.
468503 mutex : multiprocess.Lock
@@ -478,7 +513,7 @@ def __sim_producer(self, child_seeds, sim_monitor, mutex, error_event): # pylin
478513
479514 inputs_json , outputs_json = "" , ""
480515
481- self .__seed_simulation (child_seeds [ sim_idx ] )
516+ self .__seed_simulation (self . __child_seed ( sim_idx ) )
482517 flight = self .__run_single_simulation ()
483518 inputs_json = self .__evaluate_flight_inputs (sim_idx )
484519 outputs_json = self .__evaluate_flight_outputs (flight , sim_idx )
@@ -1675,14 +1710,34 @@ def export_errors_to_json(self, filename):
16751710 self ._write_log_to_json (self .errors_log , filename )
16761711
16771712
1713+ def _seed_sequence_to_int (seed_sequence ):
1714+ """Collapse a ``SeedSequence`` into a 128-bit Python ``int``.
1715+
1716+ A plain ``int`` is the one seed type accepted alike by
1717+ ``numpy.random.default_rng``, ``numpy.random.RandomState`` and the stdlib
1718+ ``random.Random`` (which rejects a ``SeedSequence`` with a ``TypeError``
1719+ since Python 3.11), so a custom sampler whose ``reset_seed`` documents an
1720+ ``int`` keeps working. All four ``uint32`` words are combined to keep the
1721+ full 128-bit pool, so the environment/rocket/flight sub-streams stay
1722+ decorrelated instead of collapsing to a single 32-bit word.
1723+
1724+ The words are combined by value (little-endian word order), not via
1725+ ``tobytes()``, so the seed is the same on big- and little-endian machines
1726+ -- a byte-order-dependent seed would break the cross-platform
1727+ reproducibility this whole scheme exists to provide.
1728+ """
1729+ words = seed_sequence .generate_state (4 , dtype = np .uint32 )
1730+ return sum (int (word ) << (32 * position ) for position , word in enumerate (words ))
1731+
1732+
16781733def _claim_next_index (sim_monitor , mutex ):
16791734 """Atomically claim the next 0-based simulation index, or ``None`` if done.
16801735
16811736 ``keep_simulating()`` and ``increment()`` are two separate manager calls, so
16821737 the shared ``mutex`` has to be held across both. Without it, two workers can
16831738 each pass the ``count < number_of_simulations`` check at the tail before
1684- either increments, and both then claim an index, overrunning the requested
1685- number of simulations and indexing past the per- index seed list .
1739+ either increments, and both then claim an index, running more simulations
1740+ than were requested ( and duplicating a simulation index) .
16861741 """
16871742 mutex .acquire ()
16881743 try :
0 commit comments