Reference parity: pymunk-reference against the published Push-T

pusht_sim.envs.pymunk_reference_env.ReferencePushTEnv claims to be the environment the Push-T literature was measured on: LeRobot's gym_pusht, inherited unchanged from Diffusion Policy. This file records what that claim was checked against, what was found to differ, and what was done about each difference.

Reproduce with:

.venv/bin/python scripts/reference_parity.py

Sources read line by line on 2026-09-24:

Why the harness needs two interpreters

The published environment cannot run on pymunk 7: _setup calls Space.add_collision_handler, removed in that major. This repo runs pymunk 7.3.0. So scripts/reference_parity.py runs our backend in this venv, and drives the real gym_pusht in a separate virtualenv on pymunk 6.11.1 (/tmp/refenv, --refenv to move it) through a JSON pipe.

That makes every zero below say two things at once: our reproduction matches the published environment, and the Chipmunk changes between pymunk 6 and 7 do not move this scene.

ours    python 3.12.3  pymunk 7.3.0  numpy 2.5.3
theirs  python 3.12.3  pymunk 6.11.1  numpy 2.5.3  gym-pusht 0.1.6

Measurements

Per seed the harness compares two things: the published reset(seed=n) (the published draw put through the published legacy setter), and then an open-loop rollout from one shared start with an identical action sequence generated by the harness itself. All figures are the worst deviation over every step of every episode.

Default run: 5 seeds x 40 control steps

 seed  steps        start        agent        block        angle       keypts        cover        obs32   dn  touch
-------------------------------------------------------------------------------------------------------------------
    0     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.747e-16    1.493e-05    0      8
    1     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    9.714e-17    1.525e-05    0      4
    2     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    6.939e-18    1.469e-05    0      9
    3     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.608e-16    1.510e-05    0      9
    4     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.665e-16    1.480e-05    0      6
-------------------------------------------------------------------------------------------------------------------
worst           0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.747e-16    1.525e-05    0

PASS: worst float64 deviation 3.747e-16 against tolerance 1.000e-09; worst contact-count gap 0; worst packed-observation gap 1.525e-05 against 6.200e-05 (float32)

Long horizon: 10 seeds x 120 control steps

--seeds 0,1,2,3,4,5,6,7,8,9 --steps 120. Seed 7 is in contact for 118 of its 120 steps, so this is not a measurement of two blocks sitting still.

 seed  steps        start        agent        block        angle       keypts        cover        obs32   dn  touch
-------------------------------------------------------------------------------------------------------------------
    0    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.943e-16    1.526e-05    0     14
    1    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    4.857e-16    1.526e-05    0     12
    2    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    2.602e-17    1.430e-05    0      7
    3    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.608e-16    1.523e-05    0     74
    4    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    5.551e-17    1.491e-05    0     11
    5    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.665e-16    1.509e-05    0     10
    6    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    8.674e-19    1.522e-05    0     10
    7    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.516e-05    0    118
    8    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.388e-17    1.516e-05    0     52
    9    120    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.331e-16    1.512e-05    0     18
-------------------------------------------------------------------------------------------------------------------
worst           0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    4.857e-16    1.526e-05    0

PASS: worst float64 deviation 4.857e-16 against tolerance 1.000e-09; worst contact-count gap 0; worst packed-observation gap 1.526e-05 against 6.200e-05 (float32)

The two published extra options: --block-cog 0,60 --damping 0.9

 seed  steps        start        agent        block        angle       keypts        cover        obs32   dn  touch
-------------------------------------------------------------------------------------------------------------------
    0     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.525e-05    0      9
    1     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.388e-16    1.525e-05    0      8
    2     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    1.469e-05    0     12
    3     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    6.072e-17    1.505e-05    0     25
    4     40    0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.608e-16    1.480e-05    0     22
-------------------------------------------------------------------------------------------------------------------
worst           0.000e+00    0.000e+00    0.000e+00    0.000e+00    0.000e+00    3.608e-16    1.525e-05    0

PASS: worst float64 deviation 3.608e-16 against tolerance 1.000e-09; worst contact-count gap 0; worst packed-observation gap 1.525e-05 against 6.200e-05 (float32)

Reading the two non-zero columns

Everything else - pusher position, block position, block angle, all sixteen keypoint coordinates, and the contact count - is bit-identical, at the seeded start and at every step.

Every gap found, and what was done

1. Observation angle convention - was wrong, now faithful

get_obs reports self.block.angle % (2 * np.pi) (L383); this backend reported the raw pymunk angle, so a block at -0.314 rad here read 5.969 rad there. Every LeRobot pusht state was written by get_obs, so anything comparing our state vector against the dataset saw a 2pi discontinuity.

Fixed: _poses now reduces modulo a turn, so get_state(), the packed observation and info["state"] all use the published convention. reset(state=...) still round-trips as a pose - feed -0.314 in, get 5.969 out, feed that back in and nothing moves - and the unwrapped, continuously winding angle is still on the body as env._block.angle for anything that needs it. Nothing in this repo did: every angle comparison here already goes through a wrap (experiments._wrap_angle, policies._wrap) or through cos/sin.

2. Initial-state sampling - was wrong, now faithful

Upstream draws integers(50, 450), integers(50, 450), integers(100, 400), integers(100, 400), uniform(-pi, pi) (L276-280) from the generator gymnasium builds as np.random.default_rng(seed). BasePushTEnv.sample_state drew uniform floats over 50..462 and 100..412 - a different support, so a seeded start here was not a start the published environment could produce.

Fixed: ReferencePushTEnv.sample_state overrides the base draw with the published one. Same constructor, same order, so seed n here selects the state seed n selects there - the start column above is that equality, measured end to end at 0.0.

Consequence, deliberate: this is the one place the backend breaks the base class's "one seed, one scenario on every backend" rule, so make_env("pymunk", seed=n) and make_env("pymunk-reference", seed=n) no longer agree on where the episode starts. Anything comparing engines has to sample once and pass the state to the others with reset(state=...); the published draw is integral and strictly inside the SI backends' support, so it is always a legal start for them. The web session does exactly this.

3. Legacy _set_state - was a deviation, now a deviation with an exact entry point

Upstream assigns the block position and then the angle (L457-462) and says why: "for compatibility with legacy data". pymunk rotates about the centre of gravity, which for this block is (0, 45), so the pose asked for is not the pose obtained - upstream's own docstring shows [0, 10, 20, 30, 1] coming back as (57.87, 50.69), a shift of 42 units. Recorded LeRobot/Diffusion Policy episode starts were all written through that setter, so replaying one through a setter that honours the pose puts the block in the wrong place.

Both behaviours, explicitly:

4. Missing accessors - added

5. Found while reading the two sources side by side

Tests

tests/test_env_reference.py covers all of the above without needing the reference virtualenv - gym-pusht's semantics are re-stated there as literals from its source rather than imported. The single test that runs the real package shells out to scripts/reference_parity.py and skips when /tmp/refenv is absent.

.venv/bin/python -m pytest tests/test_env_reference.py -q