← Latent physics world modelsDocumentation indexGlossaryCode

v4.1 design sweep — the side-wind. Does the sign matter now?

Headline: no cell qualifies, and the reason is not the wind’s magnitude, the axis, or the paddle’s width. It is the paddle’s speed, exactly as sweep.md predicted. Nothing was collected.

Run BEFORE any training, same as v4. 60 episodes × 200 steps per cell, seeds 5000+, interceptions per floor visit. Script: sweep_v41.py, run with python runs/v4_design/sweep_v41.py (431 s, one core). Raw numbers in sweep_v41.json, console output in sweep_v41.log.

What was changed and why

The v4 sweep found the gap between the full and the sign-blind oracle was 0.00 at every gravity in the range, and diagnosed it: a vertical acceleration perturbs the landing height, and height converts into x only through the ball’s slow horizontal speed, so a wrong sign was worth at most 0.035 in x — a quarter of a paddle — and even that shrank to zero as the ball arrived.

v4.1 turns the acceleration ninety degrees. BoxConfig.gravity_axis="x" makes the flipping term act on ball_v[0]: a side-wind, +1 blowing toward +x, still flipped by every paddle contact, still reported in the gravity_sign state column and EVENT_FLIP. The hypothesis was that the sign’s effect on the landing point becomes first-order (a t² directly in x, ~0.36 over a 60-frame fall at 1e-4) rather than second-order, and as a bonus that the vertical dynamics become sign-independent — which removes the energy-budget cue the v4 sweep found by accident (a 1.20× difference in traverse duration between the signs, available from one traverse with no memory of the flip at all).

The second half of that worked perfectly. The first half did not.

The table

pinned = fraction of episodes spending >20 % of their frames within one ball radius of a side wall; pin_fr = mean fraction of frames so spent. Read both against the baseline at the bottom of the section — they are much less informative than they look.

axis wind paddle_w launch oracle ballistic sign-blind gap stay random visits/ep flips/ep stalled pinned pin_fr
x 0.00005 0.26 14.5° 0.98 0.98 0.98 0.00 0.52 0.39 1.72 1.68 13% 33% 19.3%
x 0.00005 0.16 14.5° 0.98 0.98 0.98 0.00 0.38 0.30 1.73 1.70 13% 32% 18.7%
x 0.0001 0.26 14.5° 0.98 0.98 0.98 0.00 0.50 0.41 1.72 1.68 13% 42% 21.7%
x 0.0001 0.16 14.5° 0.98 0.98 0.98 0.00 0.38 0.35 1.72 1.68 13% 37% 20.5%
x 0.0002 0.26 14.5° 0.98 0.98 0.98 0.00 0.46 0.42 1.72 1.68 13% 33% 18.6%
x 0.0002 0.16 14.5° 0.99 0.98 0.98 0.00 0.35 0.31 1.72 1.72 13% 38% 19.0%
y (v4 ref) 0.0001 0.26 40° 0.98 0.98 0.98 0.00 0.41 0.41 2.12 2.08 5% 28% 19.0%

Criterion: smallest wind with gap ≥ 0.15 and ≤ 5 % stalled-or-pinned episodes, preferring paddle_w 0.26. No cell meets it. The gap is 0.00 in all six new cells, as it was in all eight of v4’s.

The two hazard columns are not the story, and both are false alarms

They were in the brief as guards, so they were measured; neither is what blocks a cell. Measured over 60 episodes × 200 steps under a ball-chasing policy:

world stalled episodes frames stuck wall-pinned frames min |vy|
plain v1 (no gravity, 14.5°) 17% 1.9% 19.1% 0.0050
v4.1 axis x, 1e-4, 14.5° 13% 0.7% 22.2% 0.0056
v4.1 axis x, 1e-4, 40° 0% 0.0% 21.4% 0.0142
v4 axis y, 1e-4, 40° 5% 1.8% 20.8% 0.0000

So the environment v4.1 asks for is cleaner than v4’s: no stalls, no pinning, and — the design goal — no per-traverse energy cue. It simply does not make the sign matter to the controller.

Why not. The recoverability table, and a closed form

slack = frames_until_landing − |Δx| / paddle_speed: how many frames of paddle travel the blind oracle has spare to undo its own error. In v4 it was never negative on any of 4,171 approach frames. Under the side-wind:

cell frames to landing n mean |Δx| frac > half a paddle mean slack frac slack < 0
x, 1e-4, w=0.26 20–40 1322 0.020 1.1% 28.6 0.00%
  40–80 1007 0.069 26.3% 51.4 0.00%
  80–120 444 0.062 16.2% 95.3 0.00%
x, 2e-4, w=0.16 20–40 1431 0.056 31.4% 27.4 0.00%
  40–80 1069 0.094 35.1% 50.5 0.00%
  80–120 330 0.105 38.5% 93.2 0.00%
y, 1e-4, w=0.26 (v4) 40–80 494 0.031 6.9% 48.8 0.00%

The axis change did do its job on |Δx|: 0.105 against v4’s 0.035, a 3× improvement, and 38.5 % of far-out frames now differ by more than half a paddle where v4 managed 8.4 %. Slack is still negative zero times out of 4,338.

Two things cap it, and the second is fatal

1. The box folds the parabola. The predicted 0.3 assumed a free parabola in x. There is no free parabola in x — there are side walls every 0.84 units, and the ball hits one every ~40 frames. The wind does not translate the ball, it shifts the phase of a bounded oscillation, and the resulting separation saturates. Measured directly (two identical episodes, opposite fixed wind, STAY policy, divergence of the true landing x):

wind mean true |Δx| at landing max
0.0001 0.132 0.459
0.0002 0.180 0.759
0.0005 0.154 0.663
0.001 0.189 0.748

It stops growing after 2e-4. A 10× wind buys 5 % more separation. There is no magnitude at which this knob keeps paying.

2. The error still vanishes at the deadline — necessarily. This is the part the axis change could never fix, and it has a closed form. Slack is negative only when

|Δx| > paddle_speed · t_land

Against that, |Δx| is bounded twice: it grows as a · t_land² (the two hypotheses separating), and it is capped by the box at 1 − 2r. So a frame where the blind oracle is forced to lose needs both

a · t² > v_p · t      ⟹  t > v_p / a          (the error must have grown)
v_p · t < 1 − 2r      ⟹  t < (1 − 2r) / v_p   (the paddle must not have time to cross anyway)

which is a non-empty window only if v_p² < a · (1 − 2r). With v4’s paddle (v_p = 0.030) and the box (1 − 2r = 0.84) that demands

a > 0.030² / 0.84 = 1.07e-3

But the same physics that makes the ball’s x-motion bounded caps the usable wind from the other side: the ball can only cross the box against the wind if vx² > 2a(1 − 2r), and the fastest a v4 ball is ever launched horizontally is 0.0213, giving

a < 0.0213² / (2 · 0.84) = 2.7e-4

1.07e-3 > 2.7e-4. The two constraints do not overlap, by a factor of four. No side-wind exists that both makes the sign binding and leaves a playable box, at this paddle speed. That is not a tuning failure; it is an arithmetic one.

The appendix: what the lever actually is

sweep.md said the fix was to remove the slack — slow the paddle, widen the box, or hide the ball — and not to touch gravity. The closed form above says the same thing (v_p² is the term to attack). So the sweep tests it: hold the side-wind fixed and vary only paddle_speed. 40 episodes/cell.

axis wind paddle_w paddle_speed ballistic sign-blind gap stay frac slack < 0
x 0.0002 0.16 0.030 (v1) 0.97 0.97 0.00 0.35 0.0%
x 0.0002 0.16 0.012 0.96 0.96 0.00 0.35 0.0%
x 0.0002 0.16 0.006 0.91 0.88 +0.03 0.35 5.9%
y 0.0001 0.16 0.006 0.86 0.87 −0.01 0.35 0.3%

Two readings, and the second is the one worth keeping:

+0.03 is still far short of 0.15, so this is a direction, not a cell. A v4.2 would have to combine the side-wind with a paddle slow enough (or a box wide enough, or an occluder late enough) that the criterion holds with margin, and then re-run this sweep. The occluder composes with gravity_axis, and v3.1 already demonstrated that hiding the ball on the final approach is the one lever that has beaten a memoryless oracle in this codebase.

What was chosen

Nothing. The brief’s rule — stop before collecting if no cell qualifies — applies. data/v4 is untouched (still the vertical-gravity datasets, 1.4 GB), no datasets were re-collected, no figures regenerated, and no VAE trained.

What is in the tree is the mechanism, tested and inert by default: BoxConfig.gravity_axis (default "y", byte-identical to v4 — tests/test_env_v41.py asserts it on frames, states and events together), the axis-x ballistic solver, --gravity-axis on the collector, and worldsim.collect.wall_pinned_fraction. If a v4.2 gets a paddle-speed or occluder lever, the side-wind is ready for it.

For the record: the cue that the side-wind does remove

The one v4 finding that a v4.1 would have fixed, had a cell qualified. Under vertical gravity the sign also set the ball’s energy budget, so a single traverse betrayed it — 48.0 vs 40.0 median frames, a 1.20× cue needing no memory of the flip at all. Under a side-wind vy is never touched, so the two signs have identical vertical dynamics: same traverse durations, same |vy|, same height distribution, by construction rather than by tuning. tests/test_env_v41.py::test_vertical_motion_is_independent_of_the_sign asserts it exactly (==, not allclose) over 2,000+ frames. The cue is not reduced; it is zero. That property survives into any v4.2 built on this axis.


Back to: sweep.md (the v4 sweep this revises), ../../docs/v4/00_v4_design.md §2b.