Spaces:
Running
Running
| <html><head><meta charset='utf-8'><title>Reference parity: `pymunk-reference` against the published Push-T</title><style> | |
| body{max-width:980px;margin:2rem auto;padding:0 1.25rem;font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif;color:#1c2330;background:#fafbfc} | |
| h1{font-size:1.7rem;border-bottom:2px solid #d8dee8;padding-bottom:.4rem}h2{margin-top:2.2rem;border-bottom:1px solid #e3e8ef;padding-bottom:.25rem} | |
| table{border-collapse:collapse;margin:1rem 0;font-size:13.5px;font-variant-numeric:tabular-nums}th,td{border:1px solid #d8dee8;padding:.35rem .6rem;text-align:left} | |
| th{background:#eef2f7}td:not(:first-child){text-align:right}code{background:#eef2f7;padding:.1rem .3rem;border-radius:3px;font-size:.92em} | |
| pre{background:#1c2330;color:#e6edf5;padding:.8rem 1rem;border-radius:6px;overflow:auto}pre code{background:none;color:inherit} | |
| img{max-width:100%;border:1px solid #d8dee8;border-radius:4px;margin:.5rem 0}blockquote{border-left:3px solid #6aa9ff;margin:0;padding:.2rem 1rem;color:#4b5563} | |
| nav{font-size:13px;margin-bottom:1.5rem}nav a{margin-right:1rem} | |
| </style></head><body><nav><a href="index.html">index</a> <a href="contact_friction_study.html">contact friction study</a> <a href="convergence_study.html">convergence study</a> <a href="episode_store.html">episode store</a> <a href="human_pushing_analysis.html">human pushing analysis</a> <a href="lewm_parity.html">lewm parity</a> <a href="reference_parity.html">reference parity</a> <a href="session_fidelity_and_behaviour.html">session fidelity and behaviour</a></nav><h1 id="reference-parity-pymunk-reference-against-the-published-push-t">Reference parity: <code>pymunk-reference</code> against the published Push-T</h1> | |
| <p><code>pusht_sim.envs.pymunk_reference_env.ReferencePushTEnv</code> claims to <em>be</em> the | |
| environment the Push-T literature was measured on: LeRobot's <code>gym_pusht</code>, | |
| 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.</p> | |
| <p>Reproduce with:</p> | |
| <pre><code>.venv/bin/python scripts/reference_parity.py | |
| </code></pre> | |
| <p>Sources read line by line on 2026-09-24:</p> | |
| <ul> | |
| <li><code>gym_pusht/envs/pusht.py</code> from the installed <code>gym-pusht</code> 0.1.6, and the same | |
| file on | |
| <a href="https://raw.githubusercontent.com/huggingface/gym-pusht/main/gym_pusht/envs/pusht.py"><code>main</code></a>. | |
| Line citations everywhere in this repo are the <code>main</code> numbering; the 0.1.6 | |
| file is the same code two lines further down.</li> | |
| <li><code>diffusion_policy/env/pusht/pusht_env.py</code> (the original the LeRobot <code>pusht</code> | |
| dataset was recorded in), to confirm the quirks below are not a gym-pusht | |
| regression but were there when the data was collected.</li> | |
| </ul> | |
| <h2 id="why-the-harness-needs-two-interpreters">Why the harness needs two interpreters</h2> | |
| <p>The published environment <strong>cannot run on pymunk 7</strong>: <code>_setup</code> calls | |
| <code>Space.add_collision_handler</code>, removed in that major. This repo runs pymunk | |
| 7.3.0. So <code>scripts/reference_parity.py</code> runs our backend in this venv, and | |
| drives the real <code>gym_pusht</code> in a separate virtualenv on pymunk 6.11.1 | |
| (<code>/tmp/refenv</code>, <code>--refenv</code> to move it) through a JSON pipe.</p> | |
| <p>That makes every zero below say two things at once: our reproduction matches | |
| the published environment, <em>and</em> the Chipmunk changes between pymunk 6 and 7 do | |
| not move this scene.</p> | |
| <pre><code>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 | |
| </code></pre> | |
| <h2 id="measurements">Measurements</h2> | |
| <p>Per seed the harness compares two things: the published <code>reset(seed=n)</code> (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 <strong>worst deviation over every step of | |
| every episode</strong>.</p> | |
| <h3 id="default-run-5-seeds-x-40-control-steps">Default run: 5 seeds x 40 control steps</h3> | |
| <pre><code> 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) | |
| </code></pre> | |
| <h3 id="long-horizon-10-seeds-x-120-control-steps">Long horizon: 10 seeds x 120 control steps</h3> | |
| <p><code>--seeds 0,1,2,3,4,5,6,7,8,9 --steps 120</code>. Seed 7 is in contact for 118 of its | |
| 120 steps, so this is not a measurement of two blocks sitting still.</p> | |
| <pre><code> 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) | |
| </code></pre> | |
| <h3 id="the-two-published-extra-options-block-cog-060-damping-09">The two published extra options: <code>--block-cog 0,60 --damping 0.9</code></h3> | |
| <pre><code> 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) | |
| </code></pre> | |
| <h3 id="reading-the-two-non-zero-columns">Reading the two non-zero columns</h3> | |
| <ul> | |
| <li><strong><code>cover</code>, up to 4.9e-16.</strong> Coverage is the only quantity neither side reads | |
| off a body. Upstream intersects two shapely <code>MultiPolygon</code>s built from the | |
| engine's shapes; this repo intersects the union polygon <code>geometry.tee_polygon</code> | |
| builds from the same vertices. Same area, different order of floating-point | |
| additions. Four ulps of a number in <code>[0, 1]</code>.</li> | |
| <li><strong><code>obs32</code>, up to 1.5e-05.</strong> <code>PushTState.to_vector</code> packs the observation as | |
| float32 because it crosses a JSON/HTTP boundary to the viewer; upstream's | |
| <code>get_obs</code> concatenates to float64. A float32 ulp at the top of the workspace | |
| is 3.05e-05, so this column is one ulp, and it is checked against its own | |
| float32 tolerance. <code>get_state()</code> and <code>info["state"]</code> keep full precision - | |
| which is what every other column is measured from - and the LeRobot <code>pusht</code> | |
| dataset stores float32 anyway.</li> | |
| </ul> | |
| <p>Everything else - pusher position, block position, block angle, all sixteen | |
| keypoint coordinates, and the contact count - is <strong>bit-identical</strong>, at the | |
| seeded start and at every step.</p> | |
| <h2 id="every-gap-found-and-what-was-done">Every gap found, and what was done</h2> | |
| <h3 id="1-observation-angle-convention-was-wrong-now-faithful">1. Observation angle convention - <em>was wrong, now faithful</em></h3> | |
| <p><code>get_obs</code> reports <code>self.block.angle % (2 * np.pi)</code> (L383); this backend | |
| reported the raw pymunk angle, so a block at -0.314 rad here read 5.969 rad | |
| there. Every LeRobot <code>pusht</code> state was written by <code>get_obs</code>, so anything | |
| comparing our state vector against the dataset saw a 2pi discontinuity.</p> | |
| <p><strong>Fixed:</strong> <code>_poses</code> now reduces modulo a turn, so <code>get_state()</code>, the packed | |
| observation and <code>info["state"]</code> all use the published convention. | |
| <code>reset(state=...)</code> still round-trips as a <em>pose</em> - 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 <code>env._block.angle</code> for anything that | |
| needs it. Nothing in this repo did: every angle comparison here already goes | |
| through a wrap (<code>experiments._wrap_angle</code>, <code>policies._wrap</code>) or through | |
| <code>cos</code>/<code>sin</code>.</p> | |
| <h3 id="2-initial-state-sampling-was-wrong-now-faithful">2. Initial-state sampling - <em>was wrong, now faithful</em></h3> | |
| <p>Upstream draws <code>integers(50, 450)</code>, <code>integers(50, 450)</code>, <code>integers(100, 400)</code>, | |
| <code>integers(100, 400)</code>, <code>uniform(-pi, pi)</code> (L276-280) from the generator | |
| gymnasium builds as <code>np.random.default_rng(seed)</code>. <code>BasePushTEnv.sample_state</code> | |
| drew uniform floats over <code>50..462</code> and <code>100..412</code> - a different support, so a | |
| seeded start here was not a start the published environment could produce.</p> | |
| <p><strong>Fixed:</strong> <code>ReferencePushTEnv.sample_state</code> overrides the base draw with the | |
| published one. Same constructor, same order, so seed <em>n</em> here selects the state | |
| seed <em>n</em> selects there - the <code>start</code> column above is that equality, measured | |
| end to end at 0.0.</p> | |
| <p><strong>Consequence, deliberate:</strong> this is the one place the backend breaks the base | |
| class's "one seed, one scenario on every backend" rule, so <code>make_env("pymunk", | |
| seed=n)</code> and <code>make_env("pymunk-reference", seed=n)</code> no longer agree on where | |
| the episode starts. Anything comparing engines has to sample once and pass the | |
| state to the others with <code>reset(state=...)</code>; 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.</p> | |
| <h3 id="3-legacy-_set_state-was-a-deviation-now-a-deviation-with-an-exact-entry-point">3. Legacy <code>_set_state</code> - <em>was a deviation, now a deviation with an exact entry point</em></h3> | |
| <p>Upstream assigns the block <em>position</em> 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 <code>(0, 45)</code>, so the pose asked for is not the | |
| pose obtained - upstream's own docstring shows <code>[0, 10, 20, 30, 1]</code> coming back | |
| as <code>(57.87, 50.69)</code>, a shift of 42 units. Recorded LeRobot/Diffusion Policy | |
| episode starts were <em>all</em> written through that setter, so replaying one through | |
| a setter that honours the pose puts the block in the wrong place.</p> | |
| <p><strong>Both behaviours, explicitly:</strong></p> | |
| <ul> | |
| <li><code>reset(state=...)</code> keeps <code>add_tee</code>'s angle-first order (L510-511), so the | |
| pose round-trips. This is what <code>PushTState</code>, <code>geometry.transform_points</code> and | |
| both other backends mean by a pose, and the whole three-way comparison rests | |
| on it.</li> | |
| <li><code>set_reference_state(vector_or_state)</code> is the legacy setter, verbatim, and | |
| reproduces the documented shift exactly. Use it for recorded data.</li> | |
| <li><code>reset_reference(seed)</code> is the two published steps together - published draw, | |
| legacy setter - i.e. gym-pusht's <code>reset(seed=n)</code> in one call.</li> | |
| </ul> | |
| <h3 id="4-missing-accessors-added">4. Missing accessors - <em>added</em></h3> | |
| <ul> | |
| <li><strong><code>get_keypoints()</code></strong> (L517-537): the eight block corners in world | |
| coordinates, <code>(8, 2)</code>; <code>.flatten()</code> is the 16-value <code>environment_state</code> | |
| LeRobot trains <code>environment_state_agent_pos</code> policies on. Taken from the | |
| engine's own shapes, because the index order in upstream's diagram is | |
| Chipmunk's re-winding of the input vertices and not <code>geometry.tee_vertices</code>' | |
| order. Both pymunk majors were checked to return the same winding.</li> | |
| <li><strong>Contact count</strong> (L425-426, L413-420): <code>info["n_contacts"]</code>, on pymunk 7's | |
| <code>Space.on_collision(0, 0, post_solve=...)</code> in place of the removed | |
| <code>add_collision_handler</code>. Only <code>post_solve</code> is registered, so the solver is | |
| untouched - and the parity runs above, which include an episode in contact | |
| for 118 of 120 steps, are the evidence that registering it changed no | |
| physics.</li> | |
| <li><strong><code>block_cog</code> and <code>damping</code></strong>: constructor arguments of <code>ReferencePushTEnv</code>, | |
| exactly as they are constructor arguments of <code>PushTEnv</code> (L140-141, L165-166), | |
| and measured to be exact above. They are deliberately <strong>not</strong> in <code>SimParams</code>: | |
| that dataclass is the shared, swept, serialised schema of the two SI engines, | |
| and neither of them has a "move the block's centre of gravity" or "multiply | |
| every velocity by this each step" knob to map them onto. Putting them there | |
| would add two permanently-inapplicable entries to every schema response, | |
| every sweep and every UI form. <code>make_env</code> therefore keeps offering the | |
| published defaults, and a caller who wants the options constructs the class.</li> | |
| </ul> | |
| <h3 id="5-found-while-reading-the-two-sources-side-by-side">5. Found while reading the two sources side by side</h3> | |
| <ul> | |
| <li><strong><code>info["n_contacts"]</code> is divided by the wrong number</strong> (L414). <code>_get_info</code> | |
| computes <code>n_steps = int(1 / self.dt * self.control_hz)</code>, which is 1000, where | |
| <code>step</code> (L240) computes the substep count as <code>int(1 / (self.dt * | |
| self.control_hz))</code>, which is 10. So the published "contact points per step" | |
| is the real count over a hundred times too large a divisor: it is 1 whenever | |
| anything touched during the step and 0 otherwise. <strong>Reproduced bug-for-bug</strong>, | |
| with the raw sum published alongside it as <code>info["n_contact_points"]</code>.</li> | |
| <li><strong><code>get_keypoints</code> is broken on NumPy 2.</strong> L537 calls <code>np.row_stack</code>, removed | |
| in NumPy 2.0, so <code>obs_type="environment_state_agent_pos"</code> raises | |
| <code>AttributeError</code> on the pinned gym-pusht 0.1.6 + NumPy 2.5.3 install. | |
| <strong>Deviation:</strong> ours uses <code>np.vstack</code>, which is what <code>row_stack</code> was an alias | |
| for - same values, and it runs. (The parity harness restores the alias in the | |
| reference subprocess so the published function can be called at all.)</li> | |
| <li><strong>No action clipping upstream.</strong> <code>step</code> declares <code>Box(0, 512)</code> but never | |
| enforces it; <code>BasePushTEnv.step</code> clips. Inside the declared range - every | |
| action a policy or a dataset produces - the two are identical. Left as is.</li> | |
| <li><strong>Observation dtype.</strong> Upstream concatenates to float64; <code>PushTState.to_vector</code> | |
| packs float32. See "Reading the two non-zero columns" above.</li> | |
| <li><strong>Termination comparison.</strong> Upstream terminates on <code>coverage > 0.95</code> (L256, | |
| strict); the base class uses <code>>=</code>. They differ only on an episode sitting | |
| exactly on the threshold.</li> | |
| <li><strong>Truncation.</strong> Upstream never truncates (L263 <code>truncated = False</code>); the base | |
| truncates at <code>max_episode_steps</code>. Base-owned, documented, left as is.</li> | |
| <li><strong>The two physics quirks were already reproduced and still are</strong>: shape | |
| friction is 0.0 everywhere because <code>body.friction = 1</code> (L476, L512) assigns | |
| to a property <code>pymunk.Body</code> does not have, and the block's moment is twice | |
| the <em>bar's</em> because L501 passes <code>vertices1</code> where it means <code>vertices2</code>. Both | |
| are present in the Diffusion Policy original too, so the recorded dataset has | |
| them, and <code>tests/test_env_reference.py</code> pins both.</li> | |
| </ul> | |
| <h2 id="tests">Tests</h2> | |
| <p><code>tests/test_env_reference.py</code> 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 <code>scripts/reference_parity.py</code> and skips when | |
| <code>/tmp/refenv</code> is absent.</p> | |
| <pre><code>.venv/bin/python -m pytest tests/test_env_reference.py -q | |
| </code></pre></body></html> |