Skip to content

Give sensors JSON-safe seeds so submissions can be packed again - #67

Merged
thc1006 merged 1 commit into
developfrom
fix/json-safe-sensor-seeds
Jul 27, 2026
Merged

Give sensors JSON-safe seeds so submissions can be packed again#67
thc1006 merged 1 commit into
developfrom
fix/json-safe-sensor-seeds

Conversation

@thc1006

@thc1006 thc1006 commented Jul 27, 2026

Copy link
Copy Markdown
Member

A competitor cannot pack a submission on develop right now. Worth looking at before the next release.

What happens

TypeError: Object of type SeedSequence is not JSON serializable

pack_for_submission serializes the flight with RocketPyEncoder. The flight carries the rocket, Rocket.to_dict() includes its sensors, and a sensor hands back whatever seed it was constructed with. Since #54 that seed is a SeedSequence, and the encoder has no handler for it, so it falls through to the stdlib encoder and raises.

Both scenarios are affected. Their sensors are noise free, but the object is stored either way, so packing fails after any launch.

I reproduced it by running the real pack_for_submission on scenario 0 against current develop.

Why nothing caught it

The sensor tests check observation streams, and the golden masters check trajectories. Neither goes near RocketPyEncoder or pack_for_submission, and there was no submission-serialization test on either side of the submodule. ActiveRocketPy's side is symmetrical: Sensor.to_dict() stores the seed unchanged, so any non-JSON seed leaks into the public serialization path.

The change

Each spawned child is converted to a plain 128-bit int before it reaches a sensor. The domain tag and the per-sensor decorrelation are unchanged, and the streams stay reproducible.

Correction to what I wrote here originally: this is not representation-only. default_rng(child) uses the child's spawn_key, while default_rng(int) builds a fresh root SeedSequence, so the exact bit streams differ. I checked all three children and none match. Preserving them exactly would mean serializing child.state and rebuilding with SeedSequence(**state), which needs encoder support upstream. With both shipped scenarios at noise_density: 0.0 the generator is never drawn from, so nothing changes today, and both golden masters confirm that. It does mean the sensor streams from #54 are not the streams a noisy scenario would get, which is worth settling before scenario 2 ships.

With noise-free sensors the RNG is never drawn from, so trajectories and scores are unaffected. Both golden masters pass unchanged, which is the evidence for that.

While in the same lines: Gymnasium reports np_random_seed == -1 when np_random was assigned directly rather than seeded, and SeedSequence rejects negative entropy, so that path raised ValueError at launch. The entropy now comes from the generator when the seed is unknown.

Tests

tests/test_submission_serialization.py covers the gap: sensor seeds are plain ints, each sensor's to_dict() survives the encoder, the same seed replays while a different one diverges and no two sensors share a seed, and a directly assigned generator no longer crashes the launch.

Full suite with BPC_RUN_SLOW_TESTS=1: 49 passed plus 10 subtests, both golden masters included.

Related: this is the interaction between #54 and the ActiveRocketPy update in #60. Follow-ups from the same review are in #64.

A launched rocket currently cannot be packed for submission. pack_for_submission runs the flight through RocketPyEncoder, the flight carries the rocket, the rocket carries its sensors, and a sensor hands back the seed it was built with. Since #54 that seed is a SeedSequence, which the encoder cannot serialize, so packing raises TypeError: Object of type SeedSequence is not JSON serializable. Both scenarios are affected even though their sensors are noise free, because the object is stored regardless.

Derive a plain 128-bit int from each spawned child instead. The domain separation and the per-sensor decorrelation are unchanged, and with noise-free sensors the trajectories and scores are identical, which both golden masters confirm.

Also stop Gymnasium's unknown-seed marker from crashing the launch: np_random_seed is -1 when np_random was assigned directly, and SeedSequence rejects negative entropy. Draw the entropy from the generator in that case.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 27, 2026 05:46
@thc1006 thc1006 added the bug Something isn't working label Jul 27, 2026

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.

@thc1006
thc1006 merged commit d1bac84 into develop Jul 27, 2026
2 checks passed
@thc1006
thc1006 deleted the fix/json-safe-sensor-seeds branch July 27, 2026 05:50
@zuorenchen

Copy link
Copy Markdown
Member

thanks for the fix

@thc1006

thc1006 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Post-merge review of this one. The crash it fixed is fixed; two things it left open are now closed in #77, and one suggestion I looked at and did not take.

The stream really did change. _seed_sequence_to_int does not preserve what the spawned child would produce: default_rng(child) mixes in the child's spawn_key, default_rng(int) builds a fresh root. I checked all three children and none match, while SeedSequence(**child.state) reproduces them exactly. I have corrected the claim in this PR's description, which said the spawn tree was unchanged.

Accepting the change rather than reverting it, because both shipped scenarios run at noise_density: 0.0, so no noisy baseline exists and there is no stability contract to break. The real exposure was that nothing would notice the derivation moving again, so #77 pins the three values. Preserving #54's exact streams needs SeedSequence serialization upstream, filed as RocketPy-Team/RocketPy#1087.

The test stopped one layer short of the failure. This PR's test encodes sensor.to_dict(); the packer encodes the whole flight. I put a number on the gap: reverting this PR with only that coverage leaves the suite green, and with #77's tests it fails 8 of 11.

While writing that I hit AttributeError: 'Flight' object has no attribute 't_final' from packing a flight that had only been launched. That is my test being unrealistic rather than a bug here, since evaluate.py always runs to termination first, but it is worth knowing the packer only works on a finished flight.

One suggestion I did not take, in case it comes up again: narrowing seed < 0 to seed == -1. SeedSequence rejects every negative value, not only -1:

SeedSequence(-1) -> ValueError: expected non-negative integer
SeedSequence(-5) -> ValueError: expected non-negative integer

So that change converts an unexpected negative into a crash at launch, where < 0 falls back to drawing entropy. #77 has a test for it and making the change fails it.

The seed is None arm is gone in #77 as well. Gymnasium's property initializes rather than returning None, so it could not run, and had it run it would have left the sensors unseeded, which is a fairness problem rather than a coverage one.

@thc1006 thc1006 mentioned this pull request Jul 28, 2026
thc1006 added a commit that referenced this pull request Jul 28, 2026
…tion

#67 stopped submissions crashing, but its test stops at sensor.to_dict() while
the packer encodes the whole flight. Reverting #67 with only that coverage in
place leaves the suite green on nine of eleven checks; with the tests here it
fails eight, including the two that encode the real thing.

Three gaps closed.

The production boundary is now exercised: one test encodes env._rocket_flight
through RocketPyEncoder, another drives the real pack_for_submission and reads
the file back. Both run a scenario 0 episode to termination, because that is the
state the packer is called in. An unfinished flight has no t_final and its
to_dict() raises AttributeError, which says nothing about whether a submission
can be packed. They sit behind BPC_RUN_SLOW_TESTS, the gate the golden masters
already use.

The seed derivation is pinned to fixed values. Converting a spawned child to an
int does not preserve the stream the child itself would produce, since
default_rng(child) mixes in its spawn_key while default_rng(int) builds a fresh
root. That was the deliberate trade for a seed with a JSON form, and both shipped
scenarios run at noise_density 0.0 so nothing draws from these generators yet,
which is exactly why nothing would notice the derivation moving again. Narrowing
to 32 bits or dropping the domain tag both stay reproducible and distinct, and
both now fail.

The unreachable branch is gone. Gymnasium's np_random_seed property initializes
rather than ever returning None, so the None arm could not run; if it ever had,
it handed the sensors seed=None and left them unseeded, which is a fairness
problem rather than a coverage one. None and negative values now share the
fallback that draws entropy from the generator.

On the review suggestion to narrow this to seed == -1: SeedSequence rejects every
negative value, not just -1, so that change turns an unexpected negative into a
crash at launch instead of a usable seed. There is a test for it now, and making
that change fails it.

Two RNG contracts are pinned separately: a known seed leaves np_random untouched,
and the unknown-seed path consumes exactly four uint32.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
thc1006 added a commit that referenced this pull request Jul 28, 2026
…-gaps

Cover the submission path #67 actually fixed, and pin the seed derivation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants