|
14 | 14 | under the ``fork`` start method, so the worker-invariance test skips otherwise and |
15 | 15 | is marked ``slow`` to match the other Monte Carlo multiprocessing tests. |
16 | 16 |
|
17 | | -A dedicated numpy-only rocket is used so *all* randomness flows through the seeded |
18 | | -numpy generator. List-valued stochastic attributes are sampled with the standard |
19 | | -library ``random.choice`` (an unseeded global generator) which ``random_seed`` |
20 | | -does not govern; the fixture drops the only such attribute (a multi-element |
21 | | -``thrust_source``) so the inputs are byte-for-byte reproducible from the seed. |
| 17 | +A dedicated numpy-only rocket keeps the fork-based end-to-end test simple: it |
| 18 | +gives the motor a single ``thrust_source`` so the run has no list-valued attribute |
| 19 | +at all. List sampling is itself seeded now (it draws through the model generator, |
| 20 | +not the stdlib ``random.choice``) and is covered directly in |
| 21 | +``tests/unit/stochastic/test_stochastic_model``. |
| 22 | +
|
| 23 | +Seed derivation being independent of the multiprocessing start method (fork, |
| 24 | +spawn or forkserver) is verified separately by |
| 25 | +``test_seed_derivation_is_start_method_invariant``, which uses a top-level |
| 26 | +picklable target so it is safe under ``spawn``/``forkserver`` -- unlike the |
| 27 | +``Flight``-stub test above, which reaches workers only under ``fork``. |
22 | 28 | """ |
23 | 29 |
|
24 | 30 | import json |
| 31 | +import multiprocessing |
| 32 | +from types import SimpleNamespace |
25 | 33 |
|
| 34 | +import numpy as np |
26 | 35 | import pytest |
27 | 36 |
|
28 | 37 | import rocketpy.simulation.monte_carlo as mc_module |
29 | 38 | from rocketpy.simulation import MonteCarlo |
| 39 | +from rocketpy.simulation.monte_carlo import _seed_sequence_to_int |
30 | 40 | from rocketpy.stochastic import StochasticRocket, StochasticSolidMotor |
31 | 41 |
|
| 42 | +_child_seed = MonteCarlo._MonteCarlo__child_seed |
| 43 | + |
| 44 | + |
| 45 | +def _available_start_methods(): |
| 46 | + """The multiprocessing start methods this platform actually supports.""" |
| 47 | + supported = multiprocessing.get_all_start_methods() |
| 48 | + return [method for method in ("fork", "spawn", "forkserver") if method in supported] |
| 49 | + |
| 50 | + |
| 51 | +def _derive_index_seeds(root_state, indices): |
| 52 | + """Derive the per-index seed fingerprints from ``root_state``. |
| 53 | +
|
| 54 | + Top-level and picklable (only a small tuple and a list of ints cross the |
| 55 | + process boundary), so it runs unchanged under every start method -- including |
| 56 | + ``spawn``/``forkserver``, which re-import this module rather than inheriting |
| 57 | + the parent's memory. It calls the real production helpers (``__child_seed`` |
| 58 | + and ``_seed_sequence_to_int``) so the test tracks the shipped derivation. |
| 59 | + """ |
| 60 | + plan = SimpleNamespace(_MonteCarlo__root_state=root_state) |
| 61 | + return {index: _seed_sequence_to_int(_child_seed(plan, index)) for index in indices} |
| 62 | + |
32 | 63 |
|
33 | 64 | class _StubFlight: |
34 | 65 | """Minimal stand-in for ``Flight`` that skips trajectory integration.""" |
@@ -175,3 +206,39 @@ def test_inputs_are_worker_invariant( |
175 | 206 | for index in expected: |
176 | 207 | assert serial[index] == par2[index], f"serial vs parallel(2) differ at {index}" |
177 | 208 | assert serial[index] == par4[index], f"serial vs parallel(4) differ at {index}" |
| 209 | + |
| 210 | + |
| 211 | +@pytest.mark.parametrize("start_method", _available_start_methods()) |
| 212 | +def test_seed_derivation_is_start_method_invariant(start_method): |
| 213 | + """Per-index seeds derived in a worker match the main process under every |
| 214 | + available start method (fork, spawn, forkserver). |
| 215 | +
|
| 216 | + The full worker-invariance test above stubs the module-level ``Flight`` and so |
| 217 | + only reaches workers under ``fork``. This one instead checks the property that |
| 218 | + actually has to hold cross-platform -- that a simulation index maps to the same |
| 219 | + seed no matter which process derives it -- using a top-level picklable target |
| 220 | + and small picklable arguments, so it is valid under ``spawn``/``forkserver`` |
| 221 | + (Python 3.14's POSIX default) without relying on any inherited parent state. |
| 222 | + Two workers split the indices; their combined result must equal the |
| 223 | + single-process derivation. |
| 224 | + """ |
| 225 | + root = np.random.SeedSequence(2718281828) |
| 226 | + root_state = ( |
| 227 | + root.entropy, |
| 228 | + root.spawn_key, |
| 229 | + root.pool_size, |
| 230 | + root.n_children_spawned, |
| 231 | + ) |
| 232 | + indices = list(range(6)) |
| 233 | + expected = _derive_index_seeds(root_state, indices) |
| 234 | + |
| 235 | + context = multiprocessing.get_context(start_method) |
| 236 | + chunks = [(root_state, indices[0::2]), (root_state, indices[1::2])] |
| 237 | + with context.Pool(2) as pool: |
| 238 | + results = pool.starmap(_derive_index_seeds, chunks) |
| 239 | + |
| 240 | + combined = {} |
| 241 | + for result in results: |
| 242 | + combined.update(result) |
| 243 | + assert combined == expected |
| 244 | + assert sorted(combined) == indices |
0 commit comments