All runs in this study are on pymunk. Nothing here uses SuperDex. The two engines compared are:
pymunk-reference - the published LeWM/gym-pusht environment, bit-exact, frictionless (Shape.friction never assigned, so 0).pymunk-wall - the same environment with one change: contact_friction = 0.5 on the pusher circle and the two T polygons (src/pusht_sim/envs/pymunk_wall_env.py). Walls, damping = 0, mass, moment, seeds - unchanged; μ = 0 reproduces B bit for bit (tests/test_env_pymunk_wall.py).Same 25 seeds on both (the batch_pymunk_ref eval seeds, also published as
desertmouse/pusht-simulator-pymunk-reference): 44177 18033 67870 16370
57580 75204 98135 87949 81400 31775 75263 64785 52463 11980 68814 35669 2103
39679 2228 45997 32063 81358 79092 89650 91922. Same goal (256, 256, π/4),
same 200-step horizon, same brain (rule planner + the measured-behaviour
executor). The planner is deterministic on the start state, so the first
plan is identical on all 25 seed pairs; its delivery is the clean
per-engine measurement. Everything below is produced by
scripts/contact_study.py --ref B_ref_mu0=batch_pymunk_ref --tag A_wall_mu05=study_A_wall05.
Store tag study_A_wall05; videos docs/videos/study_A_wall05/.
| metric | B ref μ=0 | A wall μ=0.5 |
|---|---|---|
| solved (cov ≥ 0.95 and pusher clear) | 2 | 3 |
| final coverage mean | 0.613 | 0.582 |
| final coverage median | 0.713 | 0.469 |
| final coverage IQR | 0.375–0.918 | 0.358–0.918 |
| final angle error mean | 20.2° | 27.6° |
| final position error mean | 17.6 px | 23.7 px |
| contact bouts per episode | 3.6 | 4.0 |
Headline: one more solve, a worse median. Friction did not shift all
episodes one way; it split them. Per seed (coverage_per_seed.png):

Measured over every contact frame of every episode (step logs, wrapped angles):
| PUSH phase, in contact | B μ=0 | A μ=0.5 | change |
|---|---|---|---|
| contact point drift along the edge, px/frame (rotate plans) | 0.84 | 0.55 | −35 % |
| contact point drift along the edge, px/frame (translate plans) | 0.64 | 0.37 | −42 % |
| block rotation per px of block travel (rotate plans) | 0.714 °/px | 0.617 °/px | −14 % |
| block rotation per px of block travel (translate plans) | 0.396 °/px | 0.352 °/px | −11 % |
First bout, identical plan on identical state, 25 pairs:
| intent | pairs | B delivered | A delivered | A / B median (IQR) |
|---|---|---|---|---|
| rotate | 9 | 14.4° | 12.4° | 0.85 (0.75–1.02) |
| translate | 14 | 58.7 px | 64.0 px | 1.05 (1.01–1.20) |
So the earlier expectation - "friction makes every stroke over-deliver" - was wrong. The measured effect is the opposite for rotation:
The executor releases a translating bout when the push direction (the
pushed edge's inward normal) is more than release_alignment_deg off the
CoG→goal direction (executor.py, reason "alignment"). On the frictionless
engine this is the human criterion and it works because the block
self-aligns: a few frames of pushing swing the edge toward the goal
direction and the bout continues. With friction the swing is damped, the
edge does not come round, and the release fires at the earliest permitted
frame - every time, on the same plan, because the planner is deterministic
on a state that has barely moved.
Seed 98135, plans and outcomes:
B: tra bar_end_R -> alignment ang -134->-132 pos 147->146
tra bar_end_R -> alignment ang -132->-104 pos 146->107 <- the block swung, bout delivered
rot bar_end_R -> bout_length ang -104->-45 pos 107->7
A: tra bar_end_R -> alignment ang -134->-132 pos 147->146
tra bar_end_R -> alignment ang -132->-131 pos 146->144
... nine identical bouts, 1-2 degrees and 1-2 px each ...
tra bar_top -> horizon
Bout-outcome counts over the 25 episodes: alignment 56 → 63, placed 7 →
10; translate bouts shortened from 19 to 16 frames. The other collapsed
seeds (87949, 16370) show the same pattern with a rotate plan chosen where
the frictionless run's translate had self-aligned.
alignment having delivered nearly nothing must not be re-issued as the same plan. Either the executor keeps pushing (the block will translate straight - the thing friction is good at) and lets the planner fix the angle afterwards, or the planner treats such a release as "this edge is stalled" and changes edge or intent.Per-bout data: figures/contact_study/bout_delivery.png. Raw JSON of every
number above: scripts/contact_study.py --json.
contact1 planner (ContactRulePlanner, src/pusht_sim/brain/planner.py): the
rule planner with three changes, each traced to stage B: rotate levers x1/0.85;
an edge whose last bout stalled (< 5 px and < 3 deg) is skipped once and two
stalls in a row flip the intent; translating bouts release at 90 deg instead
of 60. Store tag study_C_contact1.
| metric | B ref μ=0 | A wall μ=0.5 | C contact1 |
|---|---|---|---|
| solved | 2 | 3 | 1 |
| coverage mean / median | 0.613 / 0.713 | 0.582 / 0.469 | 0.640 / 0.725 |
| final angle / position error | 20.2° / 17.6 px | 27.6° / 23.7 px | 16.8° / 12.8 px |
First-bout delivery against the reference: rotate 1.06x (was 0.85x), so the
lever gain is right. The stall loops are gone: 98135 0.047 → 0.810, 89650
0.000 → 0.725, 35669 0.092 → 0.390. But the 90 deg release runs translating
bouts past the goal when close - overshoot releases 1 → 18 - and three
seeds that A solved now end in finish loops at 0-3 px with 4-5 deg of angle
(32063, 44177, 67870): the finish pushes through the CoG and cannot turn the
block, and the lever rule is zero below 5 deg, so nothing in the brain can
close a 3-5 deg residual. Aggregate best of the three; solves worst.
contact2 (ContactLoopPlanner): contact1 plus (1) an online rotation gain -
delivered angle over the executor's own model (0.56 deg/s per px of lever x
bout seconds), averaged over the episode's rotate bouts, prior 0.85, scaling
the next lever within [0.5, 2]; (2) the 90 deg release only beyond 60 px from
the goal; (3) inside the finish box with > 2 deg left, a small-lever rotate
(4 px/deg, max 20 px) instead of a finish.
D1 (study_D_contact2): 3 solved, mean 0.571. The small-lever trim solved
81358 and 11980 cleanly (3.7 → 2.0 deg, 2.6 → 1.4 deg, no position lost) and
destroyed 32063, 44177, 52463, 57580 (−0.45 to −0.58 each): a rotating bout
near the goal had no angle-reached release and let go only on 25 px of drift.
D2 (study_D2_contact2_trim): one executor rule added - a rotate bout that
starts inside the alignment floor is a trim and releases at the finish
angle tolerance or 6 px of drift (TRIM_DRIFT_PX, executor.py).
| metric | B | A | C | D1 | D2 |
|---|---|---|---|---|---|
| solved | 2 | 3 | 1 | 3 | 4 |
| coverage mean | 0.613 | 0.582 | 0.640 | 0.571 | 0.653 |
| coverage median | 0.713 | 0.469 | 0.725 | 0.458 | 0.712 |
| final position error | 17.6 | 23.7 | 12.8 | 16.3 | 9.8 px |
| final angle error | 20.2 | 27.6 | 16.8 | 16.1 | 23.1° |
The four wrecked seeds recovered (+0.36 to +0.48 each); solved: 79092, 81358, 39679, 11980. Two rotation-heavy seeds fell against D1 (75204 −0.25, 18033 −0.22).
The online gain estimates came out at median 0.59 (IQR 0.54-0.62, 33 bouts), pinning the lever scale at its 2.0 clamp for most of every episode. That is not a friction measurement: rotate bouts release on alignment or drift long before the lever's predicted rotation is delivered, so "delivered / predicted" mostly measures bout truncation. A stroke-level loop needs a prediction that includes the release rule, or a signal taken while the bout is still in contact. Open.
Best available: Brain-contact2 + trim (D2) - most solves, highest mean
coverage, lowest final position error. Its remaining losses are the
inverted/flipped starts every brain fails on either engine (11 seeds below
0.5 in every column), and the gain saturation above. Pages:
docs/videos/contact-study/index.html (all stages, every seed);
scripts/contact_study.py, scripts/contact_study_page.py.
contact3 (ContactFrameLoopPlanner) replaces the per-bout gain with one
measured in contact, per frame: the executor accumulates block rotation
and CoG travel over the frames it touches the block (contact_turn_deg,
contact_travel_px in every bout outcome), and the ratio against the
reference engine's 0.714 deg/px is the friction effect alone. Clamp [0.7, 1.4].
Store tag study_E_contact3.
The measurement is now right: median gain 0.91 (IQR 0.77-0.99, 36 bouts), consistent with stage B's 0.85. The outcome is not: 3 solved (D2: 4), mean coverage 0.631 (D2: 0.653), 75204/18033 not recovered, 39679's solve lost. A 10 % lever change does not move outcomes because rotation delivery on this engine is decided by where a bout releases, not by lever magnitude.
Decision (acceptance set before the run: adopt only if >= D2 on solves and
mean coverage): pymunk-wall's default brain is Brain-contact2 + trim
(DEFAULT_PLANNER_BY_BACKEND in brain/planner.py; the tab, the CLI and
run_brain_episode follow it; --planner overrides). pymunk-reference
keeps the rule brain. contact3 stays registered as the correct friction
measurement for whoever builds the next release rule.
50 fresh seeds (disjoint from every earlier batch), same goal, 200 steps.
pymunk-reference with the rule brain against pymunk-wall (μ = 0.5)
with Brain-contact2 + trim. Tags eval50_pymunk_ref, eval50_pymunk_wall;
page docs/videos/eval50/. This compares engine+brain pairs; the
friction-only comparison is the 25-seed study above.
| metric | REF rule | WALL contact2 |
|---|---|---|
| solved | 5 | 6 |
| coverage mean / median | 0.591 / 0.719 | 0.584 / 0.623 |
| final position error | 24.6 px | 20.9 px |
| final angle error | 19.7° | 18.8° |
Paired per seed: wall − ref mean −0.008, median +0.004; better on 18 seeds, worse on 16, within ±0.05 on 16; Wilcoxon p = 0.80. No difference at the population level. The two batches solve different seeds (no overlap: ref 10560, 22402, 73939, 93128, 97593; wall 3075, 33340, 45081, 67306, 91328, 95399) and the eight largest swings are ±0.5-0.66 in both directions - the brains are deterministic and each has starts it handles and starts it does not. By start type: flipped n=19 0.530 vs 0.506, mixed n=20 0.711 vs 0.696, aligned n=6 0.608 vs 0.669, inverted n=5 0.33 vs 0.33 (0 solved on either).
Reading: at μ = 0.5 with its own brain, pymunk-wall performs as the published frictionless environment does with the published brain - the friction study's adaptations bought back exactly what friction cost, no more. The ceiling on both is the planner (inverted and most flipped starts), not the contact model.