Contact-friction study: what μ = 0.5 between pusher and T does to the brain

All runs in this study are on pymunk. Nothing here uses SuperDex. The two engines compared are:

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.

Stage A - 25 episodes on pymunk-wall with the current brain

Store tag study_A_wall05; videos docs/videos/study_A_wall05/.

Stage B - paired comparison against the 25 pymunk-reference episodes

Outcomes

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):

What friction changed at the stroke level

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:

  1. The pusher sticks. With a friction cone of 26.6° the contact point stops migrating along the edge (drift down 35–42 %). A frictionless push lets the block swing under the pusher until the pushed edge faces the push direction; the pusher slides along the edge as it does so. Friction resists that relative slide, and the resisting tangential force has a moment about the CoG that opposes the swing.
  2. Pushes become straighter. Rotation per px of travel drops 11–14 %; translation is delivered in full or slightly more (1.05×). Rotate plans under-deliver (0.85×). Translate plans also rotate the block less as a side effect (angle change during translate bouts −4.3° vs −7.2°), which is exactly why the translate-first "mixed, far" seeds got better: their translations stopped disturbing the angle.

Why the collapsed seeds collapsed: the alignment stall loop

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.

What B says the next brain must do

  1. Expect 0.85× on rotation and ~1.0× on translation at μ = 0.5, i.e. ask for ~17 % more lever on rotate plans. The gain is small; it is not the main effect.
  2. Stop relying on self-alignment. A translating bout that is released on 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.
  3. Use the straighter push. Since translation no longer rotates the block as a side effect, translate-then-rotate sequences are cleaner under friction than they were - the three new solves show it.

Per-bout data: figures/contact_study/bout_delivery.png. Raw JSON of every number above: scripts/contact_study.py --json.

Stage C - Brain-contact1

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.

Stage D - Brain-contact2, two closed-loop passes

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).

What the closed loop actually measured

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.

Standing of the brains at μ = 0.5, same 25 seeds

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.

Stage E - contact3, and the decision

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-seed eval: each engine with its own default brain

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.