Skip to content

BUG: reject live RNG objects as Sensor seeds - #1174

Open
myungjunlee wants to merge 3 commits into
RocketPy-Team:developfrom
myungjunlee:fix/sensor-seed-json-serializable
Open

BUG: reject live RNG objects as Sensor seeds#1174
myungjunlee wants to merge 3 commits into
RocketPy-Team:developfrom
myungjunlee:fix/sensor-seed-json-serializable

Conversation

@myungjunlee

@myungjunlee myungjunlee commented Aug 16, 2026

Copy link
Copy Markdown

Summary

Sensor.__init__ hands the seed to numpy.random.default_rng, which also accepts Generator and BitGenerator objects. The sensor constructs, to_dict() emits the object verbatim, and the failure surfaces later at json.dumps():

import json
import numpy as np
from rocketpy.sensors import Barometer
from rocketpy._encoders import RocketPyEncoder

sensor = Barometer(sampling_rate=10, seed=np.random.default_rng(5))  # accepted
json.dumps(sensor.to_dict(), cls=RocketPyEncoder)
# TypeError: Object of type Generator is not JSON serializable

This is the case #1124 leaves behind. #1124 closed the SeedSequence half of #1087 by teaching RocketPyEncoder to write one out, which works because a SeedSequence is defined by its entropy and spawn key and still describes the stream after a round trip.

A Generator has no such description. Its state advances on every draw, so what to_dict() wrote would depend on when it ran, and a restored copy would not reproduce the stream the sensor used. There is nothing to serialize it to, so it is rejected at the constructor instead, where the caller can still see which argument was wrong.

What changes

  • Generator and BitGenerator seeds raise TypeError at construction.
  • seed is annotated as int | Sequence[int] | np.random.SeedSequence | None on every constructor that takes one — the type this issue names in its own wording — so the contract is stated where the argument is declared and not only in the docstring. The docstrings now match.
  • Nothing else. Ints, numpy ints, SeedSequence, None, and the sequences of ints default_rng accepts all behave exactly as they do on develop today.

Test plan

  • pytest tests/unit/sensors/ -q → 63 passed, including BUG: serialize numpy SeedSequence for sensor seeds (#1087) #1124's test_seedsequence_sensor_seed_is_json_serializable
  • pytest tests/unit -q → no new failures (the 4 test_sensitivity.py errors are a missing statsmodels in my environment and reproduce on a clean develop)
  • Added: Generator/BitGenerator rejected · SeedSequence and a sequence of ints explicitly still accepted · None/0/int/np.int64/2**128-1 still accepted and round trip

`Sensor.__init__` passes the seed straight to `numpy.random.default_rng`,
which also accepts `Generator` and `BitGenerator` objects. The sensor then
constructs successfully and stores the object on `self._seed`, where
`to_dict()` emits it verbatim, so the failure only surfaces later at
`json.dumps()`, far from the call that caused it.

RocketPy-Team#1124 closed the `SeedSequence` case in RocketPy-Team#1087 by teaching `RocketPyEncoder`
to write one out. That works because a `SeedSequence` is defined by its
entropy and spawn key, so it still describes the stream after a round trip.
A `Generator` has no such description: its state advances on every draw, so
whatever `to_dict()` wrote would depend on when it ran, and restoring it
would not reproduce the stream the sensor actually used.

Reject those two in the constructor instead, so the failure stays at the
call that caused it. Ints, numpy ints, `SeedSequence` and `None` are
untouched, as are the sequences of ints `default_rng` accepts and the
encoder already serializes, so no seed that works today is rejected.

Annotate `seed` on every constructor that takes one, with the type the
issue itself names, so the contract is stated where the argument is
declared rather than only in the docstring.
@myungjunlee
myungjunlee force-pushed the fix/sensor-seed-json-serializable branch from e8c43e8 to 6ac4ad9 Compare August 16, 2026 08:24

@thc1006 thc1006 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.

Requesting changes for two correctness gaps:

  1. RandomState is accepted by default_rng on NumPy >= 2.2, so the original “constructs successfully, fails later at JSON serialization” bug is still reachable on a supported RocketPy dependency version.
  2. Accepted mutable seed descriptors are stored by reference. A later mutation can make to_dict() serialize a seed that no longer describes the stream used to initialize the sensor. This affects list/ndarray seeds and SeedSequence instances backed by mutable entropy.

I would also align the annotation and error text with the actual array-like integer contract (np.integer and ndarray are accepted today), and make the round-trip tests compare the generated noise stream rather than only the stored seed/state.

The constructor-level rejection is the right direction; these changes would make the boundary complete rather than covering only two currently known live RNG classes.

Comment thread rocketpy/sensors/sensor.py Outdated
# entropy and spawn key; a live generator has no such description.
# Without this check the sensor builds fine and only fails at
# json.dumps(), far from the call that caused it.
if isinstance(seed, (np.random.Generator, np.random.BitGenerator)):

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.

np.random.default_rng also accepts np.random.RandomState starting with NumPy 2.2. RocketPy supports numpy>=1.23 with no upper bound, so this still leaves the same late-failure path on current NumPy: the sensor constructs, the RandomState remains live state, and RocketPyEncoder cannot encode it.

Could we reject RandomState here as well and add it to the parametrized rejection test? More generally, validating against the stable seed-descriptor contract (SeedSequence entropy inputs) would be less brittle than blacklisting whichever live RNG types default_rng happens to accept today.

default_rng also accepts RandomState from NumPy 2.2 on, and RocketPy pins
no upper bound on numpy, so the previous isinstance list let it through to
the same late TypeError at json.dumps() that RocketPy-Team#1087 reported.

Check the stable half of the contract instead of enumerating the live types:
accept ints, array_like of ints and SeedSequence, and refuse the rest. A
seed kind numpy starts accepting later is now refused at construction rather
than reaching serialization.

Widen the annotation to the array_like integer contract the check actually
takes. It goes through a SeedLike union so the seven signatures stay inside
the line limit while help() and inspect.signature() still expand the members.
The existing round-trip tests assert on the stored seed value, which would
still pass for a seed that survives JSON without naming the stream the
original sensor used. Draw from the restored sensor instead and compare it
against a fresh one built from the same seed, across the four descriptor
kinds the constructor accepts.
@myungjunlee

Copy link
Copy Markdown
Author

Thanks — the RandomState gap is real, and it took the PR's own claim with it. Both correctness points reproduce here on numpy 2.4.6. Pushed two commits.

RandomState, and the shape of the check. You're right that blacklisting live types is the brittle half of the contract, and this is a good demonstration of why: RandomState is not a subclass of either Generator or BitGenerator, so the isinstance list walked straight past it.

default_rng(RandomState)         -> Generator
Accelerometer(seed=RandomState)  -> constructs
json.dumps(...)                  -> TypeError: Object of type RandomState is not JSON serializable

So the check now accepts descriptors instead of naming live types: None, an int (including np.integer), an array_like of ints (nested, ndarray of integer dtype, empty included), or a SeedSequence. Everything else is refused at the constructor. The live-RNG branch is kept ahead of it purely for the error message, since "your seed's state advances as noise is drawn" is more useful than "invalid seed type".

Worth noting for anyone tightening this later: default_rng accepts an empty sequence and bool, and both serialize, so the descriptor check has to let them through.

Annotation. Widened to what the check actually takes, via a SeedLike union in sensors/sensor.py. It is a real types.UnionType, so help() and inspect.signature() still expand it to int | numpy.integer | Sequence[int] | numpy.ndarray | SeedSequence | None — the alias only keeps the seven signatures inside the 88-column limit. Docstrings and the Raises section follow.

Round-trip tests. Agreed, comparing the stored seed proves too little. Added a test that draws from the restored sensor and compares the sequence against a fresh sensor built from the same seed, parametrized over int, np.int64, int sequence and SeedSequence. It goes through RocketPyDecoder, since a SeedSequence comes back as a plain dict without it.

Mutable seed descriptors. Confirmed, and it is worse than only the seed field:

seed = [1, 2, 3]
sensor = Accelerometer(sampling_rate=10, seed=seed)
seed[0] = 999
sensor.to_dict()["seed"]   # [999, 2, 3]

SeedSequence backed by mutable entropy does the same, and its serialized signature.hash stays at the old value while entropy changes, so the record is not merely stale but internally inconsistent.

I've left it out of this PR deliberately. self._seed = seed predates it and is untouched here, and the failure is a different one — this PR is about a seed that cannot be written down at all and fails loudly at json.dumps(), whereas aliasing writes a value that succeeds and is quietly wrong. A copy at assignment would fix it, but that changes what to_dict()["seed"] is seed means for every caller, which seems worth its own issue and its own tests rather than riding along here.

Local run: tests/unit/sensors 81 passed, ruff and pylint clean. CI on this fork is still waiting on workflow approval.

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.

2 participants