Skip to content

BUG: give each CustomSampler its own stream instead of the model's seed - #1102

Open
thc1006 wants to merge 7 commits into
RocketPy-Team:developfrom
thc1006:bug/custom-sampler-seed-groups
Open

BUG: give each CustomSampler its own stream instead of the model's seed#1102
thc1006 wants to merge 7 commits into
RocketPy-Team:developfrom
thc1006:bug/custom-sampler-seed-groups

Conversation

@thc1006

@thc1006 thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Addresses #1093. Not Closes, because the keyword only fires when a pull request targets the default branch and this targets develop.

Pull request type

  • Code changes (bugfix, features)

Checklist

  • Tests for the changes have been added
  • Docs have been reviewed and added / updated
  • Lint (ruff check / ruff format --check / pylint rocketpy/ tests/ docs/) has passed locally
  • All tests have passed locally

pylint exits 0, pytest tests/unit tests/integration is 2049 passed, 44 skipped.

Current behavior

_validate_custom_sampler ended in sampler.reset_seed(seed), and seed was the model's own, handed unchanged to every sampler on it. A sampler written the way the documentation teaches builds np.random.default_rng(seed), so two of them started from identical state and drew identical values. Not nearly identical, the same to every digit:

mass    14.659110385288670  ->  0.466220770577340
radius   0.063966220770577  ->  0.466220770577341

Those are the standard normal deviates behind each draw; the last digit is the scaling. A study varying both parameters was varying one, and any correlation it reported between them was an artefact of the seeding.

New behavior

Each sampler, or each group of samplers that share a generator, gets a child derived from the model's seed and the input names:

def _sampler_seed(seed, input_names):
    root = np.random.SeedSequence(
        entropy=seed, spawn_key=_names_as_spawn_key(tuple(sorted(input_names)))
    )
    words = root.generate_state(4, dtype=np.uint32)
    return sum(int(word) << (32 * position) for position, word in enumerate(words))

Keyed by name rather than position, so declaring another parameter does not move the streams already there. The name is length-prefixed into spawn-key words, which no two names share, rather than hashed: a collision would put two samplers back on one stream, which is the bug this exists to prevent. wd4s4xka50 and p56cjcee10 are both valid identifiers whose CRC32 is 1560575156, and an earlier version of this gave them the same seed.

The child keeps its full 128 bits, matching the width the Monte Carlo seeding uses rather than being cut to 64.

Samplers that share a generator

docs/user/custom_sampler.rst documents two wrappers over one bivariate generator, so that wind X and wind Y stay correlated. CustomSampler gains a seed_group property, self by default, and those wrappers return the generator they share. A group is seeded once between its members, from a seed derived from all their names.

Resetting each member in turn discarded every seed but the last and left the group's stream decided by whichever member went last, so adding a third wrapper moved the first two. The group is reset directly when it knows how, rather than through one member, since the member cannot be assumed to reset identically or to hold nothing of its own.

The correlation is untouched by any of this. It comes from sharing one samples_list, not from sharing a seed:

theoretical, 0.171/sqrt(0.2*0.3)   0.6981
measured                           0.6986

That is the documented pattern executed out of the .rst rather than retyped.

The example also filled a 1000-pair cache inside reset_seed. With per-index seeding that reset happens once per simulation, so it built a thousand pairs to use one, every time. The cache starts empty now and top_up fills the shortfall on first use.

Tests

Sixteen in tests/unit/stochastic/test_custom_sampler.py. Each fix has a mutation that fails a named test and takes nothing else:

mutation fails
one seed for every sampler two_samplers_do_not_draw_the_same_deviate
fresh entropy per reseed the reproducibility and ordering tests
name key back to CRC32 two_names_that_a_hash_would_collide
seed cut to 64 bits the_sampler_seed_keeps_the_full_width
seeding in declaration order a_shared_generator_lands_on_the_same_seed
group keyed by sampler a_shared_group_is_seeded_once
reset through a member a_group_that_can_reset_itself_is_reset_directly
helper stops sorting a_group_key_does_not_depend_on_the_order
RuntimeError only the legacy RandomState test

The fresh-entropy one is the one worth having. Independence is easy to get by throwing the seed away, and that passes the first test while losing the property the class exists for.

Breaking change

  • Yes

Sampled values change for any study using a CustomSampler, since each now receives a different seed. Anything leaning on the accidental correlation will see the parameters move apart, which is the fix rather than a regression, but a baselined study will notice.

A sampler built on the legacy numpy.random.RandomState will now fail: it refuses seeds above 2**32-1 and these are 128 bits. The error names the input it belongs to and keeps the original as its cause, rather than surfacing as a bare ValueError.

Additional information

Found while reviewing #1054. The two overlap in rocketpy/stochastic/stochastic_model.py: that PR changes the base model's list sampling and nominal snapshot, this one changes the sampler lifecycle. They merge cleanly today and I ran the merged tree rather than assuming, 291 passed and 4 skipped, but whichever lands second wants a rebase rather than a trust in that.

Related: #1096, whose eager cache this takes a bite out of.

Every sampler on a model was reset with the model's seed, so two backed by
default_rng started from identical state and drew identical underlying values.
Not nearly identical, the same to every digit: two Gaussians with different
means and spreads both produced the deviate 0.466220770577340.

A study varying two parameters that way is varying one, and the correlation it
reports between them is an artefact of the seeding.

Each sampler now gets a child derived from the model's seed and the input's
name. Keyed by name rather than position so declaring another parameter does
not move the streams of the ones already there, and crc32 rather than hash
because hash is not stable across processes.

The documented wind X/Y wrappers are unaffected. Their correlation comes from
sharing one samples_list, not from sharing a seed, so handing them separate
children leaves it intact: measured 0.7010 against the covariance's 0.6981.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 requested a review from a team as a code owner August 8, 2026 02:59
@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.22222% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.59%. Comparing base (e0ff281) to head (10a0eee).
⚠️ Report is 24 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/stochastic/stochastic_model.py 96.96% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1102      +/-   ##
===========================================
+ Coverage    82.18%   82.59%   +0.41%     
===========================================
  Files          122      128       +6     
  Lines        16355    16583     +228     
===========================================
+ Hits         13441    13697     +256     
+ Misses        2914     2886      -28     

☔ 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.

Two problems with the first version of this, both found in review.

CRC32 is 32 bits, and a collision puts two samplers back on one stream, which
is the bug the keying exists to prevent. `wd4s4xka50` and `p56cjcee10` are both
valid identifiers with CRC32 1560575156, and both derived the same seed. The
name is length-prefixed into spawn-key words now, which no two names share, and
the child is kept at its full 128 bits to match the Monte Carlo seeding rather
than being cut to 64.

Samplers can also share one generator on purpose, as the documented wind pair
does, and each reset overwrites the last. With one seed per name, whichever was
reset last decided the stream, so the same seed meant different runs depending
on the order the model was declared in. Seeding is its own pass over sorted
names now.

The pass is separate from the validation loop deliberately. That loop's order
sets __dict__, and so the order every other input is drawn in, so sorting it
would have moved the samples of every model with a tuple in it.

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

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Both of these were right and both are fixed in 4a1b6ac.

CRC32 collides. 32 bits is not enough for a stream domain, and a collision puts two samplers back on one stream, which is exactly what this PR exists to prevent. The reproducer holds:

crc32("wd4s4xka50") == crc32("p56cjcee10") == 1560575156
_sampler_seed(4242, ...) == 9598975395077455406   for both

My "107 built-in names, no collisions" was the wrong test. It says nothing about a subclass, a future field, or a name a user picks. The name is length-prefixed into spawn-key words now, which no two names can share, and the child keeps its full 128 bits instead of being cut to 64. That also lines it up with the width #1054 uses rather than having the two disagree.

The shared generator was order-coupled, and that one I introduced. Before this branch both wrappers got the same seed, so order did not matter. One seed per name made whichever wrapper reset last decide the stream:

declared x then y   (0.63119306, 0.61655487)
declared y then x   (0.61655487, 0.63119306)

Seeding is its own pass over sorted names now, so the shared generator lands on the same seed whichever way the model was written.

Where I did not follow the suggestion

You proposed a seed_group property on CustomSampler, with grouping and a per-group reset. I did the sorted pass instead, which is two lines against about forty and needs no change to the ABC or the documented wrappers.

It fixes what I broke. What it leaves is that two wrappers reading one generator take successive values, so swapping the declaration swaps which wrapper gets which number. That is inherent to sharing a generator rather than a seeding property, it is true on develop today, and the seed contract holds either way. If you would rather have the group abstraction anyway, say so and I will send it, but I would rather not add the API on my own initiative for a case that already behaves.

One thing worth flagging

My first attempt at the order fix was to sort the main validation loop, which looked like a one-word change. It is not: that loop's order sets __dict__, and dict_generator walks __dict__, so sorting it moved the draw order of every tuple in every model. A three-input model went from zulu, alpha, mike to alphabetical, with different values. The seeding pass is separate for that reason, and there is a test pinning it.

Local: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 2041 passed and 44 skipped. Three mutations, one per fix, each taking only its own test.

Two wrappers can share one generator on purpose, as the documented wind pair
do. One seed per name reset that generator once per wrapper, so every seed but
the last was discarded and the group's stream was decided by whichever member
sorted last. Adding a third wrapper to the same generator therefore moved the
first two, which name keying is meant to prevent.

CustomSampler gains a `seed_group` property, `self` by default, so a wrapper
can say which generator it shares. Members of a group are seeded once between
them, with the seed derived from all their names rather than from whichever
went last. The documented wind wrappers declare it.

Before, resetting six times for three wrappers and moving the pair when a third
arrived. After, once, and adding an independent sampler leaves the group where
it was.

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

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

You were right and I was wrong to argue it away. 1defe34 adds the group semantics.

My case for the sorted pass was that it fixed what I had broken and the rest was inherent. It did fix the order coupling, but it left something I had also introduced, and I did not go looking for it:

shared group of wind_x, wind_y      x=0.63119306  y=0.61655487   generator reset 4x
add wind_z to the same generator    x=-0.6761184  y=0.49262482   generator reset 6x

Before this branch every sampler took the model's seed, so adding a member to a shared group changed nothing for the others. Keying by name protects independent samplers from exactly that, and left the shared group unprotected, because its stream was whatever the last-sorted member's seed made it. So the inconsistency was mine, not inherent.

CustomSampler now has a seed_group property, self by default, and a group is seeded once between its members with a seed derived from all their names. The documented wind wrappers return their shared generator. The generator is reset once rather than once per wrapper, which also stops the 1000-sample cache in that example being rebuilt and thrown away, so it takes a bite out of #1096 as well.

shared group                     reset 1x
independent sampler added        group unmoved
X.seed_group is Y.seed_group     True
correlation                      0.6986 against 0.6981

Adding a member to a group still moves that group's stream. That one does look inherent to me: the group is keyed by its members, and a different set of members is a different group. Say if you disagree.

One thing I did and then undid

Grouping keys on id, which is unique only among live objects, so I went looking for whether a seed_group that builds its answer could be freed and have its address reused. In a tight loop it certainly can: five successive temporaries all came back with the same id. I held a reference and wrote a test.

The test passed with the fix removed. In this loop's actual shape the temporaries do not collide, so the test proved nothing and I have taken it out. The reference is still held, because keying on an id you have not retained is fragile whether or not I could make it bite today, but the comment now says it is defensive rather than pretending it fixes something I observed.

Local: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 2043 passed and 44 skipped, and the documented example still executes out of the .rst.

Only RuntimeError was caught, and the seed handed over is now 128 bits, which
the legacy numpy.random.RandomState refuses:

    ValueError: Seed must be between 0 and 2**32 - 1

Before this branch a sampler received the model's seed, usually a small int,
so RandomState took it. A sampler built on RandomState therefore breaks here,
and used to break with a bare ValueError that named nothing.

The seed stays 128 bits, since that is what keeps the streams apart and what
default_rng, the documented choice, takes. The error now says which input the
sampler belongs to and keeps the original as its cause.

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

Three things from review, all small.

The documented bivariate generator filled a 1000-pair cache inside reset_seed.
With per-index seeding that reset happens once per simulation, so a study built
on this example generated a thousand pairs and used one, every time. 0.099 ms
each, about 10 s over 100k simulations. `top_up` already fills the shortfall on
first use, so the eager fill is gone and the cache starts empty.

The group reset went through the first member rather than the group, which
assumes every member resets identically and keeps nothing of its own. The group
holds the shared state, so it is reset directly when it knows how, and the
member is the fallback.

`_sampler_seed` now sorts the names itself. The caller does today, and a future
one that forgets would hand a single group two different seeds.

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

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Three more in 7fb0133, and the description is rewritten, since it still described the first version of this.

The documented example filled a cache it threw away. reset_seed generated 1000 pairs, and with per-index seeding that reset happens once per simulation, so a study built on this example produced a thousand pairs and used one, every time:

after construction       1000 pairs generated, none asked for
5 reseeds                5000 more generated, 0 used
                         0.099 ms each, about 10 s over 100k simulations

The cache starts empty now and top_up fills the shortfall on first use. Executed out of the .rst: 0 pairs after construction, 4000 after 4000 draws, correlation 0.6986 against the covariance's 0.6981.

The group reset went through a member. It dispatched to the first sampler in the group rather than to the group, which assumes every member resets identically and holds nothing of its own. The group owns the shared generator, so it is reset directly when it knows how, with the member as the fallback.

_sampler_seed sorts the names itself. The caller sorts today; a future one that forgets would hand a single group two different seeds.

I have also removed an earlier comment of mine here that said the conflict with #1054 was one line of imports. That was true of bffa1fac and stopped being true once this grew a seed_group contract and two signature changes, and it named a zlib import that no longer exists. The accurate version is in the description: the two overlap in the sampler lifecycle, they merge cleanly today, and I ran the merged tree rather than trusting that.

Local: ruff clean, pylint rocketpy/ tests/ docs/ exit 0, 2049 passed and 44 skipped, sixteen tests with a mutation each.

Two rules the property invites breaking, both silent.

Identity has to be stable. Building the answer on each call, which returning
from a property makes easy, gives every member a different identity and puts
each back in a group of its own: a two-member group goes from one reset to two.

A group belongs to one model. Declaring the same generator on two models has
them both seed it, and the later one wins, which is the overwrite the grouping
exists to prevent.

The documented wind pair already returns a stored attribute, so the example
teaches the stable form.

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

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 74a6ba02 with the ownership rules on seed_group, which you asked for and which I had left implicit.

Two ways to break it, both silent, both measured:

Identity has to be stable across calls. Returning from a property makes it easy to build the answer each time, and then every member has a different identity and goes back into a group of its own. A two-member group drops from one reset to two:

stable identity, 2 members   -> group reset 1 time
unstable identity, 2 members -> group reset 2 times

A group belongs to one model. Declaring the same generator on two models has them both seed it and the later one wins, which is the overwrite the grouping is here to prevent.

I did not add tests for either. The first is a consequence of test_a_shared_group_is_seeded_once_between_its_members, which already fails if grouping stops working. The second is a caveat rather than a behaviour worth freezing, and a test asserting the last model wins would fail on anyone who later makes cross-model groups work properly.

I also considered detecting an unstable group and warning, and decided against it. It degrades to per-member seeding, which is the default anyway, so nothing goes wrong that would not have gone wrong without the property.

The wind pair in the docs returns a stored attribute, so the example already teaches the stable form.

Local: ruff clean, pylint 10.00/10 exit 0, stochastic unit suite 39 passed.

The automation that normally writes it cannot run on a pull request from a
fork, which is RocketPy-Team#1101, so this one is by hand.

It is a breaking change and the entry says so: fixed-seed CustomSampler
baselines move, and a sampler built on the legacy RandomState has to move to
default_rng because the seed it now receives is 128 bits wide.

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

thc1006 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Added the changelog entry directly here, as you suggested, since the automation cannot run on a fork pull request until #1101 is fixed. Head is 10a0eeec.

It records both migrations: fixed-seed CustomSampler baselines move, and a sampler built on the legacy RandomState has to move to default_rng because the seed it now receives is 128 bits wide.

On the merge order, that matches what I found independently: #1104 first, then this branch updated so the Sphinx warnings-as-errors job actually runs against the custom_sampler.rst change. I will rebase once #1104 lands rather than now, so the run is against the real merge candidate.

On StochasticModel(seed=SeedSequence(...)): agreed it is not a blocker, and agreed the half-accepting behaviour is the part worth avoiding. The docstring says int, and _sampler_seed builds SeedSequence(entropy=seed, spawn_key=...), so a SeedSequence passed in would be rejected there while default_rng would have taken it. I would rather reject it early with a clear message than extend the state, since accepting it means deciding what a caller-supplied spawn_key composes with. Happy to add that guard here if you want it in scope, otherwise it belongs with the same question in #1054's root seed handling.

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