Skip to content

ENH: reproducible Monte Carlo via per-simulation-index seeding - #1054

Open
thc1006 wants to merge 24 commits into
RocketPy-Team:developfrom
thc1006:enh/reproducible-montecarlo-seeding
Open

ENH: reproducible Monte Carlo via per-simulation-index seeding#1054
thc1006 wants to merge 24 commits into
RocketPy-Team:developfrom
thc1006:enh/reproducible-montecarlo-seeding

Conversation

@thc1006

@thc1006 thc1006 commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Scope, up front, because three reviews have now asked me not to claim things this does not claim.

This makes the sampled inputs reproducible: for a given root seed, simulation index i draws the same stochastic parameters whether the run is serial or parallel and however many workers it uses. That is what .inputs.txt records and what the tests compare.

It does not make the flown inputs or the trajectory reproducible, and it does not close #1053. Two built-in random sources sit outside the seed tree this builds, and both are filed rather than folded in:

A third, #1109, is in the code this touches but predates it: dict_generator walks the whole instance, so a valid tuple initial_solution is read as a distribution and its last element called. This PR narrows the blast radius of that rather than closing it, and the reasoning is under "New behavior" below.


Pull request type

  • Code changes (bugfix, features)

Current behavior

MonteCarlo.simulate() seeds the stochastic models per worker in parallel mode (from a fresh, unseeded np.random.SeedSequence().spawn(n_workers)) and once at construction in serial mode. So the sampled inputs depend on the execution mode and the number of workers, and parallel runs are not reproducible run to run. This is #1053.

New behavior

Adds a keyword-only random_seed to simulate(). From that root, simulation index i is seeded from its own child of the root seed, derived before simulation i runs, so index i maps to the same seed no matter which worker runs it. The sampled inputs come out identical across serial, parallel(2) and parallel(N), and reproducible from the seed.

A few specifics:

  • O(1) per-index derivation. The child for index i is built by extending the root's spawn_key, which is exactly how SeedSequence.spawn derives it, so child(i) is bit-identical to root.spawn(number_of_simulations)[i]. Nothing pre-spawns a full list: a worker reconstructs any index from a small root state (entropy, spawn_key, pool_size, counter) that travels with the pickled instance, so nothing O(N) is sent to each process.
  • 128-bit int seeds. Each model is reseeded with a plain 128-bit int, not a SeedSequence. An int is the seed type numpy.random.default_rng and the stdlib random.Random both accept (a SeedSequence raises TypeError in random.Random since Python 3.11), so a custom sampler whose reset_seed documents an int keeps working. All four uint32 words are combined by value, so the seed is byte-order independent and keeps the full 128-bit pool rather than collapsing to 32 bits.
  • List-valued attributes are now seeded too. StochasticModel.dict_generator drew list attributes with the stdlib random.choice (an unseeded global instance), so random_seed did not govern them. It now draws the index from the model's own seeded generator, which also avoids numpy.random.choice coercing a heterogeneous list (Function, paths, arrays) to a single dtype.

random_seed is a seed, not a live RNG: it takes an int, a numpy integer, a sequence of ints, or a SeedSequence, with None = fresh entropy so existing behavior is unchanged unless you pass a seed. A supplied SeedSequence is copied from its full state before use, so it is never mutated and repeated calls with the same object reproduce the same run. This is informed by SPEC 7 and NumPy's parallel idiom, but keeps immutable seed-snapshot semantics rather than SPEC 7's stateful rng: a Generator/BitGenerator is not accepted, because reducing it to its underlying SeedSequence would ignore how far it has been consumed. Pass rng.bit_generator.seed_seq to seed from an existing generator.

Relation to #1071

#1071 targets the same issue. This PR takes the two ideas it got right, deriving each index's seed on demand instead of pre-spawning a list, and handing the samplers a plain int, and combines them with the parallel-claim lock below, the full 128-bit width (a single 32-bit word collides near 2**16 streams), the list-sampling fix, and cross-platform tests. Happy to reconcile the two however the maintainers prefer.

Notes from review

  • Parallel workers claimed the next index with an unlocked keep_simulating() + increment(). Near the end of a run two workers could both pass count < n and then both claim an index, running past the requested count. The claim now holds the shared mutex across the check and the increment, so each index is handed out once.
  • A supplied SeedSequence was returned as-is, and spawn() advances its child counter, so passing the same object twice was not reproducible. It is now copied from its full state, and Generator/BitGenerator are no longer accepted (see above).

Follow-up review round

A closer pass after the first reviews turned up four more fixes, all pushed here:

  • StochasticRocket._set_stochastic gave the same seed to the rocket body and to every surface, motor, rail button and parachute, so components that sample the same distribution (a main and a drogue parachute, for instance) drew identical cd_s and lag quantiles. Each component now gets its own child of the run's seed, in a fixed order, so they stay independent and reproducible.
  • dict_generator and StochasticRocket._randomize_position sampled list-valued attributes (component positions included) with the stdlib random.choice, which random_seed did not govern. Both now draw the index through the model's seeded generator, via a shared _random_choice helper.
  • simulate() set up (and, for append=False, truncated) the output files before the seed was validated, so passing a rejected seed destroyed a previous run's results. The seed is validated first now.
  • Corrected the seed helper's docstring about RandomState, and moved it to rocketpy.tools so the stochastic models can share it.

Known limitations and follow-ups

The 128-bit int does not fit the legacy numpy.random.RandomState, which caps seeds at 2**32 - 1. A custom sampler built on the modern default_rng (or the stdlib random.Random) takes it fine; one built on RandomState would need to reduce it. Since RandomState is the discouraged legacy path this felt like the right trade for keeping the full 128-bit decorrelation, but I am happy to revisit if you would rather cap the width.

Larger items from the review are better handled on their own, so they are filed separately rather than growing this PR:

Failure safety and log integrity

A later review round found several paths where a run that went wrong could still
be reported as a success. Those are fixed here too, with tests:

  • The parent waited for every worker with an unbounded join(), in start order.
    One worker stuck in a native call held it there while another had already set
    the error event, so neither the error nor the cleanup after it was reached,
    and Ctrl-C hung on the same join a second time. The wait is bounded now and
    returns as soon as the event is set. Shutdown signals the whole fleet before
    waiting on any of it, with a kill fallback.
  • The completeness check accepted a corrupt file: unreadable rows were skipped,
    rows with no index or an index outside the run were ignored, and JSON true
    or 1.0 passed for index 1 because both compare equal to it. Every row now
    has to be an object with a plain non-negative int index, the two files have
    to agree on the exact set, and an interrupted run may be short but not corrupt.
  • Both run paths cleared the current payload after the interruptible call rather
    than before it. Serial Ctrl-C on the first lap surfaced as an
    UnboundLocalError over the interrupt, and between laps the handler still
    held the row that had just been written. In the worker, a claim that failed on
    a later lap reported the simulation that had just succeeded.
  • The normal write path released the mutex in finally whether or not
    acquire() had returned, so a manager that died during acquire raised a
    second error over the first.
  • The error record kept either the inputs or the traceback, never both.
  • n_workers was validated after the logs were opened "w+", so asking for a
    worker count the run cannot use destroyed the previous results on the way to
    raising.

Tests

Seed handling is unit tested in tests/unit/simulation/test_monte_carlo_determinism.py:
accepted seed types, the SeedSequence copy preserving the full .state, the
O(1) child equal to spawn bit-for-bit including a root whose counter has
advanced and indices past 2**32, the 128-bit width, and the parallel index claim.

tests/integration/simulation/test_monte_carlo_determinism.py runs the real
parallel path, no stub, under fork, spawn and forkserver, comparing serial
against parallel(2) and parallel(4) per index. The fixtures are built so the
properties can fail: the shared stochastic environment has zero wind at every
altitude and zero times any factor is zero, so a compounding baseline cannot
show up in it, and a bare StochasticAirBrakes gives every parameter a standard
deviation of zero. The assertions check the eccentricities and the air brake are
among the compared fields, or stripping object identity could quietly empty the
comparison.

tests/unit/simulation/test_monte_carlo_log_integrity.py covers what the run is
allowed to call a success and how the fleet comes down when it is not.

The thorough version of that test is marked slow and pull-request CI skips
those, so it gated nothing. I said earlier that the weekly run would still
cover it; that was wrong. Scheduled Tests triggers on schedule and on
push to master, with no pull_request, so it only ever runs the default
branch and never sees a test that is still on a PR. There is a small version of it now that is not
marked, and because it takes the platform default each job ends up gating the
start method it actually runs: spawn on Windows and macOS, forkserver on Python
3.14's POSIX default, fork below that. It costs about six seconds.

That turned out to be worth more than the argument for it. While fixing the
shutdown timing below I reused a helper that sets the error event, and every
successful parallel run started reporting itself as failed. The new gate caught
it within seconds of being written, on a path the slow-marked version would not
have run in CI at all.

Later review round

Three more from a closer look at the head, all fixed here:

  • The completeness check judged the whole file, so a duplicate, a torn row or a
    pair that disagrees anywhere in it failed the run. The documented way to
    resume is to interrupt a run and carry on with append=True, and an
    interrupted run is exactly what leaves that damage, so a file could be damaged
    once and never resumed. It judges only the indices this run claimed now, and
    reports rather than raises on what an earlier run left.
  • The parent terminated the fleet the moment a worker reported an error, so a
    worker part way through a write was cut off. Measured at 0.0 ms against the
    5000 ms the interrupt path already gave. The shutdown was producing the torn
    rows the check then reported. Both paths give the same window now.
  • A torn row also makes the two files disagree, and the cross-file check ran
    first, so the error named the symptom rather than the cause. Reordered.

Checkpoint validation

The resume point came from a line count rather than from the indices on disk,
and the completeness check trusted it. A blank line makes the two disagree:

two rows                  -> num_of_loaded_sims = 2
two rows + one blank line -> 3
two rows + two blanks     -> 4      (the file holds only 0 and 1 either way)

So the next run starts at 2, index 1 is never written, and a check scoped to
the new range reports success. Appending now reads both logs first and refuses
unless what it finds is the run it is being asked to continue: every row
readable, no index twice, both files holding the same set, and the indices
forming exactly the range below the resume point. Nothing is opened for writing
until that passes. A run that was not interrupted is then held to the whole
range rather than to its own share of it.

A file numbered from 1 is named rather than reported as an off-by-one, since
serial runs used to be numbered that way and the answer is to re-baseline.

A file with a hole in it is refused rather than repaired. Filling holes needs
workers to claim from a plan instead of counting on from the end, which is
#1075. Until then, refusing loudly beats resuming in the wrong place quietly.

The test that used to empty both logs and then assert a four-simulation result
holding only indices 2 and 3 was a success is gone. That was the shape of the
bug rather than a guard against it.

Two other ways a previous run could be lost:

  • multiprocess is an optional extra and was imported inside the parallel
    path, which runs after both logs have been emptied. An install without
    rocketpy[monte-carlo] lost its results on the way to the ImportError.
  • The Generator rejection advised rng.bit_generator.seed_seq, which NumPy
    grew in 1.25 while this package declared numpy>=1.13, so the advice raised
    AttributeError on versions it claimed to support. The floor moves to 1.17,
    which default_rng has needed all along.

Also: the custom sampler fixture built a Generator in reset_seed and
dropped it while sample() drew from the process-global np.random, so
nothing in it answered to a seed and the 128-bit path went untested.

Breaking change

  • Yes

The exact numbers a run produces change (per-index seeding, the env/rocket/flight
split and the per-component split within a rocket, the 128-bit int seeds, the
serial index now counting from 0 to match parallel, and list-valued attributes
and positions now sampled through the seeded generator), so external code that
pinned exact Monte Carlo samples would need to re-baseline. The in-repo Monte
Carlo tests do not pin exact values (test_monte_carlo_simulate checks apogee
and impact velocity within a tolerance and still passes), and random_seed is
opt-in.

Partially addresses #1053. The per-index seeding for serial and parallel runs is
here; append=True continuing the same seeded stream is #1075, and the runtime
random sources are #1090 and #1091. I would rather leave #1053 open until those
land than close it on a guarantee that only covers the sampled inputs.

What this PR does and does not promise

The guarantee here is over the sampled inputs: for a given root seed,
simulation index i draws the same stochastic parameters whether the run is
serial or parallel and however many workers it uses. That is what .inputs.txt
records and what the tests compare.

It is deliberately not a guarantee about the whole trajectory yet, because two
built-in random sources sit outside the seed tree this PR builds. I found both
while going back over this change and filed them rather than growing the PR
further:

  • Parachute pressure noise is outside the Monte Carlo seed tree #1091: Parachute takes its pressure noise from the process-global
    np.random. Flight adds that noise to the pressure it hands the trigger, so
    it can move the deployment time and the descent with it. The shared fixtures
    already use non-zero noise, so this is on the ordinary path, not an opt-in.
  • MonteCarlo draws the flight dictionary three times, so the logged inputs are not the ones flown #1090: MonteCarlo calls _randomize_rail_length, _randomize_inclination
    and _randomize_heading, and each one draws the whole flight dictionary
    again. The Flight gets rail length from the first draw, inclination from the
    second and heading from the third, while the row written to .inputs.txt
    holds the third. Measured on the shared fixture, the logged inclination is
    85.60 while the flight used 84.46.

Until those land, two runs agreeing on .inputs.txt does not prove they flew
the same thing. Once they do, the promise can be restated in terms of results.

@thc1006
thc1006 marked this pull request as ready for review July 8, 2026 19:35
@thc1006
thc1006 requested a review from a team as a code owner July 8, 2026 19:35
Copilot AI review requested due to automatic review settings July 8, 2026 19:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.08541% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.91%. Comparing base (e0ff281) to head (ba029ca).
⚠️ Report is 24 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/simulation/monte_carlo.py 95.63% 10 Missing ⚠️
rocketpy/stochastic/stochastic_model.py 95.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1054      +/-   ##
===========================================
+ Coverage    82.18%   83.91%   +1.73%     
===========================================
  Files          122      128       +6     
  Lines        16355    16765     +410     
===========================================
+ Hits         13441    14068     +627     
+ Misses        2914     2697     -217     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@thc1006

thc1006 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

The seeding logic is unit-tested in tests/unit/simulation/test_monte_carlo_determinism.py: __root_seed_sequence accepts an int, a sequence of ints, or a SeedSequence (copied from its full state so the caller's object is not mutated and repeated calls reproduce) and rejects a stateful Generator/BitGenerator; __seed_simulation splits each child seed three ways; and _claim_next_index (the atomic index claim from the race fix) has a deterministic barrier-based test that over-claims and fails if the lock is removed. End-to-end reproducibility (serial, and serial == parallel) lives in tests/integration/, with only the fork-based worker-invariance test marked slow.

The lines codecov still shows uncovered are all in the parallel path: simulate's parallel=True dispatch, the worker setup in __run_in_parallel, and the __sim_producer loop. The coverage jobs cannot reach them because parallel=True is only exercised by the slow worker-invariance test (the jobs do not pass --runslow), and the producer body runs in forked worker processes that coverage.py does not instrument without concurrency = multiprocessing. The behavior is covered by the slow determinism and test_monte_carlo_simulate[parallel] tests, and the claim logic by the fast unit test above. Glad to set up multiprocessing coverage separately if you want the parallel path counted, but that felt out of scope for this PR.

@Gui-FernandesBR
Gui-FernandesBR force-pushed the enh/reproducible-montecarlo-seeding branch from 0d37ed6 to 761c092 Compare July 9, 2026 21:08

@phmbressan phmbressan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation is very clear and throughout, nice work.

The explanation on the concepts behind per index seeding (both in the issue and PR description) were rather helpful. I agree having reproducible results was an issue with the parallel per worker seeding.

Regarding the decisions on parameter naming, I agree with most of the decisions taken here. Moreover, the rng attribute is well docstringed, so it shouldn't be a matter of confusion to the user.

@MateusStano could you give your two cents on the changes here before we proceed with a merge?

Comment thread tests/integration/simulation/test_monte_carlo_determinism.py
Comment thread rocketpy/simulation/monte_carlo.py Outdated
Comment thread rocketpy/simulation/monte_carlo.py Outdated
thc1006 added a commit to thc1006/RocketPy that referenced this pull request Jul 11, 2026
Addresses review feedback on RocketPy-Team#1054.

Parallel workers claimed the next index with an unlocked
keep_simulating() + increment(), so near the end of a run two workers
could both pass the count < n check and then claim sim_idx == n; the
per-index child_seeds lookup turned that into an IndexError (before, it
only wrote one extra record). Move the claim into a _claim_next_index
helper that holds the shared mutex across the check and the increment,
so each index is handed out once and the counter never overshoots. A
deterministic unit test (a barrier plus a widened check-to-increment
window) over-claims and fails if the lock is dropped.

__root_seed_sequence returned the caller's SeedSequence, and spawn()
advances its child counter, so passing the same object to simulate()
twice produced different children. Copy it from its full state instead,
which leaves the caller untouched and keeps repeated calls reproducible.
Also drop Generator/BitGenerator from the accepted types: a stateful
generator is not a seed, and reducing it to its underlying SeedSequence
ignores how far it has been consumed. random_seed now takes an int, a
sequence of ints, or a SeedSequence.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 requested a review from MateusStano July 11, 2026 01:18
thc1006 added a commit to thc1006/RocketPy that referenced this pull request Jul 11, 2026
Addresses review feedback on RocketPy-Team#1054.

Parallel workers claimed the next index with an unlocked
keep_simulating() + increment(), so near the end of a run two workers
could both pass the count < n check and then claim sim_idx == n; the
per-index child_seeds lookup turned that into an IndexError (before, it
only wrote one extra record). Move the claim into a _claim_next_index
helper that holds the shared mutex across the check and the increment,
so each index is handed out once and the counter never overshoots. A
deterministic unit test (a barrier plus a widened check-to-increment
window) over-claims and fails if the lock is dropped.

__root_seed_sequence returned the caller's SeedSequence, and spawn()
advances its child counter, so passing the same object to simulate()
twice produced different children. Copy it from its full state instead,
which leaves the caller untouched and keeps repeated calls reproducible.
Also drop Generator/BitGenerator from the accepted types: a stateful
generator is not a seed, and reducing it to its underlying SeedSequence
ignores how far it has been consumed. random_seed now takes an int, a
sequence of ints, or a SeedSequence.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the enh/reproducible-montecarlo-seeding branch from 9c020b6 to 3e22729 Compare July 11, 2026 06:38
thc1006 added a commit to thc1006/RocketPy that referenced this pull request Jul 18, 2026
Addresses review feedback on RocketPy-Team#1054.

Parallel workers claimed the next index with an unlocked
keep_simulating() + increment(), so near the end of a run two workers
could both pass the count < n check and then claim sim_idx == n; the
per-index child_seeds lookup turned that into an IndexError (before, it
only wrote one extra record). Move the claim into a _claim_next_index
helper that holds the shared mutex across the check and the increment,
so each index is handed out once and the counter never overshoots. A
deterministic unit test (a barrier plus a widened check-to-increment
window) over-claims and fails if the lock is dropped.

__root_seed_sequence returned the caller's SeedSequence, and spawn()
advances its child counter, so passing the same object to simulate()
twice produced different children. Copy it from its full state instead,
which leaves the caller untouched and keeps repeated calls reproducible.
Also drop Generator/BitGenerator from the accepted types: a stateful
generator is not a seed, and reducing it to its underlying SeedSequence
ignores how far it has been consumed. random_seed now takes an int, a
sequence of ints, or a SeedSequence.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the enh/reproducible-montecarlo-seeding branch from 3e22729 to 6bf8bb6 Compare July 18, 2026 21:06
@thc1006

thc1006 commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

@MateusStano friendly ping when you have a moment. Both points from your last pass are addressed: the parallel index claim now holds the shared mutex across the check-and-increment (with a deterministic test that goes red if the lock is removed), and a supplied SeedSequence is copied from its full state before spawning, so repeated calls reproduce and the caller is left untouched. I replied inline on both threads. The test matrix and lint pass on the current head; the only red is the soft codecov patch check, which is the parallel-only lines I covered in the thread above. Whenever you get a chance to take another look, I would appreciate it.

@thc1006
thc1006 force-pushed the enh/reproducible-montecarlo-seeding branch from 6bf8bb6 to c529d0a Compare July 20, 2026 06:09
thc1006 added a commit to thc1006/RocketPy that referenced this pull request Jul 20, 2026
Addresses review feedback on RocketPy-Team#1054.

Parallel workers claimed the next index with an unlocked
keep_simulating() + increment(), so near the end of a run two workers
could both pass the count < n check and then claim sim_idx == n; the
per-index child_seeds lookup turned that into an IndexError (before, it
only wrote one extra record). Move the claim into a _claim_next_index
helper that holds the shared mutex across the check and the increment,
so each index is handed out once and the counter never overshoots. A
deterministic unit test (a barrier plus a widened check-to-increment
window) over-claims and fails if the lock is dropped.

__root_seed_sequence returned the caller's SeedSequence, and spawn()
advances its child counter, so passing the same object to simulate()
twice produced different children. Copy it from its full state instead,
which leaves the caller untouched and keeps repeated calls reproducible.
Also drop Generator/BitGenerator from the accepted types: a stateful
generator is not a seed, and reducing it to its underlying SeedSequence
ignores how far it has been consumed. random_seed now takes an int, a
sequence of ints, or a SeedSequence.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Heads up that this changed enough since the last look to be worth a fresh pass rather than merging on the earlier approval. @MateusStano @phmbressan when you have a moment.

What is new since the review:

  • Per-index child seeds are now derived in O(1) by extending the root spawn_key (bit-identical to spawn(n)[i]) instead of pre-spawning the whole list, so nothing O(N) is pickled to each worker.
  • The samplers get a plain 128-bit int rather than a SeedSequence, so a custom sampler's int-typed reset_seed keeps working (a SeedSequence raises TypeError in random.Random since 3.11). The int combines all four words by value, so it is byte-order independent.
  • List-valued stochastic attributes now draw from the model's seeded generator, so random_seed governs them too. That closes the gap the previous description called out as a known limitation.
  • Added a start-method-invariance test that runs under fork, spawn and forkserver in ordinary CI, since 3.14 moved the POSIX default to forkserver.

Both earlier concerns are still handled: the parallel claim holds the mutex across the check and the increment, and a supplied SeedSequence is copied from its full state. #1071 opened for the same issue in the meantime; the description notes how this relates and what it borrows. A re-review whenever you get the chance would be appreciated.

@thc1006

thc1006 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

@MateusStano @phmbressan a follow-up pass turned up a few more things worth fixing, so I have pushed them and would appreciate another look when you have time.

Since your reviews:

  • Component seeds: StochasticRocket._set_stochastic handed the same seed to the body and to every surface, motor, rail button and parachute, so a main and a drogue parachute drew the same cd_s and lag quantiles. Each component now gets its own child of the run's seed.
  • List sampling: dict_generator and _randomize_position sampled list-valued attributes (component positions among them) with the stdlib random.choice, which random_seed did not control. Both now draw through the model's seeded generator.
  • File safety: simulate() truncated the output files before the seed was validated, so a rejected seed destroyed a previous run's results. Validation runs first now.
  • Docs: corrected the seed helper's note about RandomState, since a 128-bit int does not fit its 32-bit seed.

I also marked the earlier threads resolved. The race and the SeedSequence copy are both fixed in the current code, and the dangling-files question checked out: the run writes only under tmp_path.

A few larger items from the same review are better as their own issues, so I opened #1075 (append continuation), #1076 (a full parallel test under spawn and forkserver) and #1077 (a seed for simulate_convergence), and linked them from the description. Thanks for the careful reviews.

@thc1006
thc1006 force-pushed the enh/reproducible-montecarlo-seeding branch from 5a9c119 to da2ba5c Compare July 20, 2026 08:45
@thc1006

thc1006 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

A quick note on the red CI here, so it isn't mistaken for a regression from this change: the failing jobs crash in test_flight_animation_export_gif (a VTK/PyVista off-screen GIF export) with Fatal Python error: Bus error. The same crash, at the same file and line, also hit develop's own Tests run a day ago (https://github.com/RocketPy-Team/RocketPy/actions/runs/29697106388), so it looks like a pre-existing flake in the off-screen rendering tests rather than anything this PR introduced. I checked the dependency set too: the green and red runs installed identical vtk, pyvista, matplotlib and pillow versions, and nothing in this branch touches the plotting or animation code.

Re-running usually clears it. Happy to help look at the flaky animation tests on their own if that would be useful.

Filed #1078 to track the flaky animation tests.

thc1006 added 9 commits August 7, 2026 23:25
Two things create_object samples were left out of the per-simulation reseed.

Air brakes were never in the reseed loop at all, so they drew from wherever
the generator had been left rather than from the simulation index. Every
seeding test passed because no fixture had an air brake, which is exactly
how it stayed hidden. The collections are now declared in one place and
walked from there, and a test scans create_object's source so a collection
added later cannot quietly miss the reseed.

CP and thrust eccentricity were validated once, at add time, against the
generator as it stood then. Reseeding replaced the generator but not those
values. Keep the specs as given and reapply them after each reseed.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
In the worker:

sim_idx and inputs_json are bound before the try. A failure in the index
claim used to raise UnboundLocalError inside the error handler, so nothing
was written and nothing was printed and the run ended with no record of
what went wrong. The handler now writes a JSON line either way, since
_read_log_file parses that file with json.loads.

Reporting a failure is best effort and must never replace the failure it
is reporting. Setting the shared event can raise on its own once the
manager has gone, and so can taking the mutex or writing the file. All of
it is guarded, the mutex is released if it was taken, and the original
exception is what leaves the worker. The worker re-raises so its exit code
says it died.

In the parent:

join() returns None however a child ended, so the shared event was the
only signal a run had. A worker can leave without setting it: SystemExit,
os._exit, a segfault in a native extension, a target that will not unpickle
under spawn, or its own error handler failing. Check the exit codes too.
Workers are started inside the try, so a start() that fails part way
through the fleet does not leave the running ones with nobody to reap them.

After the run, check that every index this run claimed left exactly one
input row and one output row. Neither file shows this on its own: the rows
look well formed, and reading them back keyed by index hides a duplicate
behind the row that overwrote it. A row cut off mid-write is reported as
the index that went missing rather than failing to parse, which is what
actually happened to it. A run stopped with Ctrl-C is exempt, since both
run paths catch it, keep what they have and return, and being short is the
point rather than a fault.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The existing tests cover the seed arithmetic everywhere and the real loop
under fork. Neither reaches multiprocess.Process, __sim_producer, the
manager proxies or pickling the stochastic object graph anywhere but fork,
and spawn is what Windows and macOS run, and forkserver is Python 3.14's
POSIX default.

Serial, two workers and four workers are compared per index under each
available start method. Object identity is stripped before comparing:
a Function's signature hash and its serialised source encode the object
rather than the value drawn for it, and a child that re-imported the module
cannot agree with the parent about those. Six fields differ across the
boundary on a real run and all six are these.

The fixtures are built so the properties can actually fail. The shared
stochastic environment has zero wind at every altitude, and zero times any
factor is zero, so a compounding baseline cannot show up in it; this one
sets a wind that is actually blowing. A bare StochasticAirBrakes gives
every parameter a standard deviation of zero, so it gets one that varies.
The assertions check the eccentricities and the air brake are among the
compared fields, or stripping identity could quietly empty the comparison.

Also covers the parent-side checks: a run missing an input row, a run
missing an output row, a row cut off mid-write, appending onto an earlier
run, and a run stopped with Ctrl-C.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Assigning to montecarlo._MonteCarlo__evaluate_flight_inputs and friends
trips pylint's invalid-name, which exits 16 and fails the Linters job even
though the score is 10.00. monkeypatch.setattr takes the name as a string,
so the check does not fire, and it puts the original back afterwards
instead of leaving the instance patched for whatever runs next.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A review of the seeding change turned up failure paths where a run that went
wrong could still be reported as a success. Six of them, all in the machinery
around the simulations rather than in the seeding itself.

The parent waited for every worker with an unbounded join, in the order they
were started. One worker stuck in a native call held it there while another had
already set the error event, so neither the error nor the cleanup after it was
ever reached, and Ctrl-C hung on the same join a second time. The wait is
bounded and gives up as soon as the event is set. Shutdown signals the whole
fleet before waiting on any of it, then falls back to kill, so a worker that
ignores the first signal does not keep the others, the manager and the open
files alive behind it.

The completeness check accepted a corrupt file. Rows it could not parse were
skipped, rows carrying no index or an index outside the run were ignored, and
JSON true or 1.0 passed for the index 1 because both compare equal to it. Every
row now has to be an object with a plain non-negative int index, the two files
have to agree on the exact set, and an interrupted run is allowed to be short
but not to be corrupt.

Both run paths cleared the current payload after the call that can be
interrupted rather than before it. In the serial path Ctrl-C on the first lap
reached the handler with it unbound, so the interrupt surfaced as an
UnboundLocalError, and between laps it still held the row that had just been
written. In the worker the same ordering meant a claim that failed on a later
lap reported the simulation that had just succeeded. The normal write path also
released the mutex in finally whether or not acquire had returned.

The error record kept either the inputs or the traceback, never both, so every
failure after sampling left no traceback in the file the run points the user at.

n_workers was validated after the logs were opened "w+", so asking for a worker
count the run cannot use destroyed the previous results on the way to raising.
All argument checking happens before any file is touched, and
number_of_simulations is checked too.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Three problems from review, and they compound.

The check judged the whole file, so a duplicate, a torn row or a pair that
disagrees anywhere in it failed the run. The documented way to resume a Monte
Carlo is to interrupt one and carry on with append=True, and an interrupted run
is exactly what leaves that damage behind, so a file could be damaged once and
never resumed again. It now judges only the indices this run claimed. Damage
below _initial_sim_idx is an earlier run's and is warned about rather than
raised on, while a run that wrote the whole file is still held to all of it.

The parent stopped waiting the moment a worker reported an error and terminated
the fleet immediately, giving a worker part way through a write no chance to
finish it. Measured: 0.0 ms on that path against the 5000 ms the interrupt path
already gave. So the shutdown produced the torn rows the check then reported.
Both paths give the same window now. Not by reusing _bring_the_fleet_down: that
sets the error event, which on a run that finished cleanly is what the crash
check reads next, and wiring it in made every successful parallel run report
itself as failed.

A torn row also makes the two files disagree, and the cross-file check ran
first, so the message named the symptom. The damage check runs first now.

The real parallel path was only exercised by a test marked slow, and
pull-request CI skips those, so the path this work exists to support gated
nothing. There is a small version of it now that is not marked slow and uses
the platform's default start method, so each CI job gates the one it actually
runs: spawn on Windows and macOS, forkserver on Python 3.14's POSIX default.
It caught the _bring_the_fleet_down mistake above within seconds of being
written.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The resume point came from a line count rather than from the indices on disk,
and the completeness check trusted it. A blank line makes the two disagree:
two rows plus one blank load as three simulations, so the next run starts at
index 2, index 1 is never written, and a check scoped to the new range reports
success. Measured at 3 for one blank line and 4 for two, with the file holding
only 0 and 1 either way.

Appending now reads both logs first and refuses unless what it finds is the
run it is being asked to continue: every row readable, no index twice, the
inputs and outputs holding the same set, and the indices forming exactly the
range below the resume point. Nothing is opened for writing until that passes,
so a checkpoint that cannot be resumed is left as it was found. A run that was
not interrupted is then held to the whole range rather than to its own share
of it.

A file numbered from 1 is named rather than reported as an off-by-one. Serial
runs used to be numbered that way, and appending onto one would rewrite its
last index instead of continuing, so the answer is to re-baseline.

This replaces the tolerance added in the previous commit for damage an earlier
run left behind. That belonged at the wrong end: a torn row holds an index
nobody can recover, so the resume point cannot be trusted either, and the
preflight refuses before any simulation is spent rather than after.

Two other things that could destroy a previous run:

multiprocess is an optional extra, and it was imported inside the parallel
path, which runs after both logs have been opened "w+" and emptied. An install
without rocketpy[monte-carlo] lost its previous results on the way to the
ImportError. It is imported with the other argument checks now.

The rejection message for a Generator advised rng.bit_generator.seed_seq,
which NumPy only grew in 1.25 while this package declared numpy>=1.13, so the
advice raised AttributeError on versions it claimed to support. It now says to
pass the seed the generator was built from, mentions seed_seq as a 1.25 option,
and lists integer sequences among the accepted inputs. The floor moves to 1.17,
which default_rng has needed all along.

Also fixes the custom sampler fixture, which built a Generator in reset_seed
and dropped it while sample() drew from the process-global np.random, so
nothing in it answered to a seed and the 128-bit path went untested. And states
in _nominal that construction-time snapshot semantics apply to every stochastic
model rather than only to the environment, with a test to hold it there.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
`dict_generator` walks the whole instance, so `parachutes` and `air_brakes`
were drawn from as ordinary lists. Since this branch started seeding list
choices from the model's own generator, that draw moved every later one, and
`StochasticRocket.dict_generator` discards it a few lines further down.

Attaching a main and a drogue changed the sampled mass under a fixed seed,
which is the property this branch exists to establish. One component does not
show it: `integers(1)` has a single outcome and NumPy returns it without
consuming any state, so the test covers 0, 1 and 2.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A worker killed outright sets no error event. If it died holding the shared
lock, its siblings never return either, so `any(is_alive())` stayed true and
the unbounded wait never reached the exit-code check below it. The wait now
also ends on a non-zero exit code. Only the unbounded one: the shutdown grace
period is bounded already and must not be cut short.

`type(value) in (int, np.integer)` is False for every NumPy integer, because
`type(np.int64(3))` is `np.int64`. It was written that way to keep `True` out,
which `isinstance` lets through, so both are now checked explicitly.

`number_of_simulations` is the total to reach when appending, not a batch to
add. Below the checkpoint it ran nothing and returned success, leaving a file
with more simulations than the caller asked for.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@Gui-FernandesBR
Gui-FernandesBR force-pushed the enh/reproducible-montecarlo-seeding branch from 4334438 to 069558d Compare August 8, 2026 02:25
thc1006 added 2 commits August 8, 2026 10:28
The unmarked test took the platform default and its docstring claimed that
gated spawn on macOS and forkserver on 3.14. Neither is true: multiprocess
hard-codes fork on every POSIX platform, macOS and 3.14 included, with a
`#FIXME: spawn` still beside the darwin branch. So the shipped parallel path
was gated on fork everywhere except Windows, and the failure message named the
stdlib start method rather than the one that made the workers.

The thorough test already covers all three through the real path and takes 26 s
against 89 s for the rest of the directory, so it is unmarked now and the
default-taking one is deleted rather than corrected. Its assertions were a
subset.

The start-method list is asked of multiprocess as well. That import is at
module level behind a try/except because it happens while tests are collected,
where importorskip would take the module down instead of skipping it.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
SeedSequence keeps a sequence entropy by reference, and the capture stored that
reference, so a caller who passed a list and later edited it changed the child
seeds of a run that had already read the seed. The docstring called it an
immutable snapshot, which it was only for an int.

    entropy = [1, 2, 3]
    mc.simulate(2, random_seed=entropy)
    entropy[0] = 999999          # moved every index of that run

Deep-copied on the way in now, with spawn_key made a tuple while it is there.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Two commits on top of your rebase, @Gui-FernandesBR. Both are defects in this branch's own code that I found while going back over it, so they belong here rather than in a follow-up.

27589f6 the fast suite was not gating what its docstring claimed. It said taking the platform default gated spawn on macOS and forkserver on 3.14's POSIX default. Neither is true. multiprocess hard-codes fork on every POSIX platform, macOS included, with a #FIXME: spawn still sitting beside the darwin branch:

if sys.platform == 'darwin':
    _default_context = DefaultContext(_concrete_contexts['fork'])  # FIXME: spawn
else:
    _default_context = DefaultContext(_concrete_contexts['fork'])

There is no version check in that file, so 3.14 does not change it either. The shipped parallel path was therefore gated on fork everywhere except Windows, and the failure message named the stdlib start method rather than the one that made the workers.

The thorough test already covers all three through the real path, and it costs 26 s against 89 s for the rest of that directory, so it is unmarked now and the default-taking one is deleted rather than corrected. Its assertions were a strict subset.

047517c the captured root seed was not a snapshot. SeedSequence keeps a sequence entropy by reference and the capture stored that reference, so a caller who passed a list and later reused it changed the child seeds of a run that had already read the seed:

entropy = [1, 2, 3]
mc.simulate(2, random_seed=entropy)
entropy[0] = 999999      # moved every index of that run

An int seed was never exposed to this, which is why it went unnoticed. Deep-copied on the way in now.

Each fix has a test that fails when the fix is removed, and the mutation only takes the new test with it. Local: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 1970 unit passed, 77 integration passed.

On the rebase itself: I had rebased locally onto the same develop before I saw yours, so I checked the trees matched before putting anything on top rather than force-pushing over you. They did, byte for byte, so these two are the only new content.

@MateusStano, your review is from 10 July and the branch has been through a rebase and a fair amount of rework since. Whenever you have time, a fresh look at the current head would be welcome.

Each worker got the full grace to itself, and twice over, once after terminate
and once after kill. Eight stubborn workers could therefore hold the parent for
sixteen grace periods rather than two, which is a 5 s promise turning into 80 s.

The deadline is shared now, so the wait costs the same whatever the fleet size.
Every worker is still joined, so exit codes are still reaped.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the enh/reproducible-montecarlo-seeding branch from 7d0c5da to f00c205 Compare August 8, 2026 02:46
@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Third and last of the ones I found going back over this branch, f00c205.

_stop_any_worker_still_running gave every worker the full grace to itself, and did it twice, once after terminate and once after kill. So the wait scaled with the fleet rather than being the bound the constant promises:

fleet   granted per phase      wall clock
 1      0.2s                   0.400s
 6      1.2s                   2.400s
32      6.4s                  12.800s

With _WORKER_SHUTDOWN_GRACE = 5.0 and eight stubborn workers that is 80 s of what reads like a 5 s promise. One shared deadline now, so all three rows are 0.400s and every worker is still joined, which is what reaps the exit codes.

The test asserts on the time each worker was granted rather than on how long the call took. My first version measured wall clock with 43% headroom, which would have been a flake waiting for a loaded runner; the granted times are exact and the same on any machine. Removing the fix fails it at fleet size 6 and leaves fleet size 1 passing, which is the point.

Verification on this head

Everything the workflows run, plus the slow suite:

ruff check                       clean
ruff format --check              clean
pylint rocketpy/ tests/ docs/    exit 0
pytest tests/unit                1972 passed, 12 skipped
pytest tests/integration         77 passed, 10 skipped
pytest rocketpy --doctest-modules  46 passed
pytest tests/acceptance          16 passed
pytest tests -m slow --runslow   37 passed, 1 failed

The slow failure is test_hrrr_atmosphere, and it is not this branch. It asks NOAA for a forecast twelve hours out and the live model did not reach that far when I ran it; the test's own comment says as much. The scheduled run on master passed yesterday.

Each of the three fixes has a mutation that fails a named test, and each mutation takes only its own test with it.

…meet

The assertion checked one fleet against the grace itself, so its slack had to
cover the machine's timer granularity. On Windows that is about 15 ms against a
200 ms grace, and the job failed at 0.213s under a 0.21s bound.

Comparing a fleet of six against a fleet of one carries the same granularity on
both sides, so it cancels. Six against twice one leaves roughly half the bound
spare on the Windows numbers, and the per-worker grace it replaced would grant
six times as much.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Windows 3.10 was red on a test I wrote two commits ago, and it failed for exactly the reason I claimed it could not. Fixed in 5edbc90.

I said the granted-time assertion was "exact and the same on any machine", which replaced a wall-clock one I had called a flake waiting to happen. It is not machine-independent. Checking a single fleet against the grace means the slack has to absorb the machine's timer granularity, and on Windows that is roughly 15 ms against a 200 ms grace:

E   AssertionError: terminate: 6 workers were granted 0.21s against a 0.2s deadline
E   assert 0.21299999999996544 <= (0.2 + 0.01)

Three milliseconds. The second worker was granted a sliver instead of zero, because monotonic() had not ticked past the deadline yet.

The property I actually want is that the wait does not grow with the fleet, so it now measures a fleet of one and a fleet of six and compares them. Both carry the same granularity, so it cancels:

one worker    0.400s
six workers   0.400s      bound 0.800s      50% spare

On the Windows numbers from that failed job the crowd would come in near 0.43s against the same 0.8s bound. Removing the fix grants six times as much and fails it.

Local on this head: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 1971 unit passed, and the test run five times over with no variation.

Worth saying plainly: this is the second timing assertion I have written for this one behaviour, and the first was wrong in the way I had just finished warning about. The 3 ms is a fair thing to have missed on Linux, the confident sentence in my last comment was not.

@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

A note on the one line this touches in requirements.txt, since it turns out to sit on top of something larger.

The change here is numpy>=1.13 to >=1.17, because SeedSequence and default_rng are 1.17 features and this branch would import-error below that. That part is right and I am leaving it.

What I had not checked is whether 1.17 is enough for the Python this package declares. It is not, quite. pyproject.toml says requires-python = ">=3.10", and NumPy's cp310 wheel coverage arrives later:

numpy 1.21.0   no cp310 wheels
numpy 1.21.2   manylinux x86_64 and aarch64 only
numpy 1.21.3   adds macOS universal2, macOS arm64, win_amd64

So a resolver on 3.10 that lands between 1.17 and 1.21.3 builds from source rather than failing outright, which is slow where it works and unpleasant where it does not.

I have not raised it here. Moving a floor past what this branch needs is a packaging decision rather than part of a seeding change, and there is a larger one next to it that has nothing to do with this PR: scipy>=1.0 is declared while monte_carlo.py imports scipy.stats.bootstrap at module level, and that arrived in SciPy 1.7. Anything from 1.0 to 1.6 satisfies the floor and then fails on import rocketpy.

Filed both together as #1107, since they are one edit to one file. Happy to fold the NumPy half into this PR instead if you would rather it travelled with the line that is already changing.

The refusal message suggested re-running "or renumber the file down by one",
while the comment two lines above it said the fix is to re-baseline rather than
retry. The comment was right.

Renumbering lines the indices up and leaves the seeds behind. Those rows came
from the old sequential scheme, so a renumbered file would carry rows 0..n-1
that this release's per-index derivation would never have produced for those
indices, and appending onto it would join two different seedings without
saying so.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Three things from this round, one of which was mine and wrong.

The renumber advice, 1b291dc. The refusal for a legacy one-based checkpoint said "Re-run the study, or renumber the file down by one", while the comment two lines above it said the fix is to re-baseline rather than retry. The comment was right and the message was contradicting it.

Renumbering lines the indices up and leaves the seeds behind. Those rows came from the old sequential scheme, so a renumbered file carries rows 0..n-1 that this release's per-index derivation would never have produced for those indices, and appending onto it joins two different seedings without saying so. The message now says that, and a test fails if the old advice comes back.

Hole recovery. I went looking for the same kind of over-promise there and did not find one. The docstring already says what it does:

A file with a hole in it is refused rather than repaired. Filling holes needs the workers to claim from a plan instead of counting on from the end, which is #1075; until then, refusing loudly beats resuming in the wrong place quietly.

Leaving that as it is.

The #1102 interaction. I said earlier that the two conflict in one line of imports. That was true when #1102 was bffa1fac and is not now: it has since grown a seed_group contract, a grouped reset pass and signature changes to _validate_custom_sampler and _validate_factors, so it is a lifecycle change rather than an import clash.

The current heads happen to merge with no textual conflict at all, which is the part worth not trusting, so I built the merge and ran it rather than reading it:

_validate_factors signature        (self, input_name, input_value)
_validate_custom_sampler signature (self, input_name, sampler)
_reset_custom_samplers  present
component_collections   present
_nominal                present
_random_choice          present

tests/unit/stochastic + tests/unit/simulation   291 passed, 4 skipped

Both sides survive and the call sites line up. Whichever lands second should still be rebased and rerun rather than merged on that evidence, but there is nothing to resolve by hand today.

Local on this head: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 1971 unit passed.

@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Moved the scope statement to the top of the description. It was already there, under "What this PR does and does not promise", but at line 48 of 218, and three rounds of review have now asked me not to claim things it explicitly declines to claim. Written but not read is the same as not written, and that is on me rather than on anyone reading it.

It now opens with what is and is not guaranteed, and names #1090 and #1091 with the measurement rather than leaving them to be found further down.

While tidying that I noticed something worse in the same area. I have been pointing at the dict_generator crash for three rounds as evidence that it predates this branch, and I never filed it. It is #1109 now, reproduced on develop at 235dc6e:

initial_solution as a 14-tuple   TypeError: 'float' object is not callable
initial_solution as a 14-list    sampled down to a single element, 8.0

Saying "that one is pre-existing" without leaving anything behind for someone to pick up is not much better than not saying it.

On why this branch narrows that rather than closing it, since the two look alike and the difference decides the scope. StochasticRocket builds air_brakes and parachutes before the base constructor, so they sit early in __dict__ and their draw moved every later one. This branch made list choices come from the model's own generator instead of the standard library's, which turned that from harmless into a shift of the whole model. So those five are mine to fix and they are fixed.

initial_solution is set after the base constructor, sorts last, and shifts nothing. I checked that rather than assuming it, by walking every Stochastic* subclass for attributes assigned before super().__init__:

StochasticModel      obj, last_rnd_dict, __stochastic_dict, __nominal_values   all dicts or objects
StochasticRocket     motors, aerodynamic_surfaces, rail_buttons                Components, not lists
                     air_brakes, parachutes                                    lists, both covered
                     __components_map, __eccentricity_specs                    dicts
StochasticParachute  trigger, noise, cd_s, radius                              genuine stochastic inputs

Nothing samplable is left uncovered, so the regression this branch introduced is closed. The wider rewrite, generating only from the declared names, is the right fix for #1109 and I would rather send it separately than add it to a change this size.

A failure after sampling wrote the inputs to .errors.txt with no traceback,
while a worker writes {index, ...inputs, error: traceback}. The file the run
tells the user to read named which inputs failed and not why.

Both paths now build the row through one helper. Three further points on that
path:

- inputs_json is cleared once the pair is on disk, so a failure inside
  print_update_status() reports itself rather than reporting an already
  committed row as one that never finished.
- sim_idx is bound before the loop, so a failure on the first iteration has an
  index to record.
- the re-raise is bare, so the handler's own line does not join the traceback.

KeyboardInterrupt keeps its own handler: an interrupt is not a failure with a
traceback worth recording, and it still logs the inputs that did not finish.

The append docstring said only that results are appended. It now says
number_of_simulations is the target total rather than a number to add, that a
lower value is refused, and that the root seed is not stored in the files.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed ba029ca5, which is the serial error path you flagged.

A failure after sampling wrote the inputs to .errors.txt with no traceback, while a worker writes {index, ...inputs, error: traceback}. So the file the run tells you to read named which inputs failed and not why. Both paths now build the row through one helper.

Three smaller things on the same path, since they only show up once the record is worth reading:

  • inputs_json is cleared once the pair is on disk. A failure inside print_update_status() was reporting an already committed row as one that never finished.
  • sim_idx is bound before the loop, so a failure on the first iteration has an index to record rather than none.
  • the re-raise is bare. raise error re-enters the handler frame and it appears twice in the traceback.

KeyboardInterrupt keeps its own handler. An interrupt is not a failure with a traceback worth writing to the error file, and it still logs the inputs that did not finish.

On the frame point, my first test for it asserted the failing frame survives, which is true of both spellings, so it passed with the fix removed. The assertion is now that the handler frame appears once:

bare raise    ['<module>', 'bare', 'raiser']
raise error   ['<module>', 'rethrow', 'rethrow', 'raiser']

Three tests, each pinned by reverting the line it covers and watching it fail.

Also rewrote the append docstring, which said only that results are appended. It now says number_of_simulations is the target total rather than a number to add, that a lower value is refused, and that the root seed is not stored in the files so the same random_seed has to be passed again. Both are covered by tests already in the branch.

One claim from the review I could not stand behind: that .csv and .json cannot be appended to. The resume index comes from num_of_loaded_sims, which is parsed records, not the raw line count I had been looking at, so I have no evidence for it and left it out.

Local: ruff clean, pylint 10.00/10 exit 0, 2159 passed and 45 skipped across unit and integration.

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

Labels

None yet

Projects

None yet

5 participants