marble-v1 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- marble_v1/__init__.py +3 -0
- marble_v1/levels.py +169 -0
- marble_v1/sim.py +458 -0
- marble_v1/taskset.py +250 -0
- marble_v1-0.1.0.dist-info/METADATA +141 -0
- marble_v1-0.1.0.dist-info/RECORD +7 -0
- marble_v1-0.1.0.dist-info/WHEEL +4 -0
marble_v1/__init__.py
ADDED
marble_v1/levels.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Level generation: solvable by construction.
|
|
2
|
+
|
|
3
|
+
## Why levels are built forwards, not designed
|
|
4
|
+
|
|
5
|
+
A level is only useful here if a solution is known to exist — an unsolvable task
|
|
6
|
+
teaches a model nothing and is indistinguishable, from the outside, from a task
|
|
7
|
+
the model simply failed. Designing a layout and then hunting for its solution
|
|
8
|
+
gets that backwards and is expensive.
|
|
9
|
+
|
|
10
|
+
So each piece is placed where the marble *actually is*. Simulate what has been
|
|
11
|
+
built so far, read the path, drop the next piece onto it, and check the marble
|
|
12
|
+
really strikes it. Repeat. The layout that comes out is a solution, and the ring
|
|
13
|
+
goes wherever that solution ends up. Difficulty is the piece count.
|
|
14
|
+
|
|
15
|
+
The one rule that keeps a level honest: **every piece must be struck.** A piece
|
|
16
|
+
the marble sails past is a piece the level does not need, which would make the
|
|
17
|
+
stated inventory a lie and quietly hand the model an easier task than the label
|
|
18
|
+
claims.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import random
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from marble_v1.sim import (
|
|
27
|
+
ANCHOR,
|
|
28
|
+
MIN_SEPARATION,
|
|
29
|
+
POSITION_STEP,
|
|
30
|
+
ROTATION_STEP,
|
|
31
|
+
Kind,
|
|
32
|
+
Level,
|
|
33
|
+
Placement,
|
|
34
|
+
Verdict,
|
|
35
|
+
simulate,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DEEP = -20_000.0 # a ring far below everything: lets a run play out fully
|
|
39
|
+
RING_DROP = 420.0 # how far under the last piece the ring sits
|
|
40
|
+
MAX_ROT_STEPS = 6 # +/- 45 degrees, in 7.5 degree units
|
|
41
|
+
FIRST_DEPTH = 520.0 # the first piece never sits right under the ramp
|
|
42
|
+
|
|
43
|
+
# How far below the previous piece the next one may sit. The ceiling is the
|
|
44
|
+
# important half: without it a piece can land wherever the marble happens to be
|
|
45
|
+
# after a long fall — one generated level had eleven thousand units of empty air
|
|
46
|
+
# between two pieces, which asks the model to guess where a free-falling marble
|
|
47
|
+
# lands rather than to plan a bounce. A band also keeps runs short, and run
|
|
48
|
+
# length is what generation cost is made of.
|
|
49
|
+
BAND_NEAR = MIN_SEPARATION
|
|
50
|
+
BAND_FAR = 900.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _snap(v: float) -> float:
|
|
54
|
+
return round(v / POSITION_STEP) * POSITION_STEP
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Generated:
|
|
59
|
+
level: Level
|
|
60
|
+
solution: list[Placement]
|
|
61
|
+
seed: int
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def pieces(self) -> int:
|
|
65
|
+
return len(self.solution)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _place_next(
|
|
69
|
+
rng: random.Random,
|
|
70
|
+
placements: list[Placement],
|
|
71
|
+
ceiling_y: float,
|
|
72
|
+
bounds: float,
|
|
73
|
+
attempts: int = 40,
|
|
74
|
+
) -> Placement | None:
|
|
75
|
+
"""Drop one more piece onto the current path, and keep it only if the marble
|
|
76
|
+
actually hits it."""
|
|
77
|
+
probe = Level(ring_x=0.0, ring_y=DEEP, bounds=bounds)
|
|
78
|
+
run = simulate(probe, placements, keep_trace=True)
|
|
79
|
+
# Fair game: below the last piece by at least a marble's worth of room, not
|
|
80
|
+
# so far below that the puzzle becomes a free-fall estimate, and inside the
|
|
81
|
+
# strip the player is allowed to build in.
|
|
82
|
+
lo, hi = ceiling_y - BAND_FAR, ceiling_y - BAND_NEAR
|
|
83
|
+
candidates = [p for p in run.trace if lo <= p[1] <= hi and abs(p[0]) <= bounds - 40]
|
|
84
|
+
if not candidates:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
for _ in range(attempts):
|
|
88
|
+
px, py = candidates[rng.randrange(len(candidates))]
|
|
89
|
+
kind = rng.choice(list(Kind))
|
|
90
|
+
rot = rng.randint(-MAX_ROT_STEPS, MAX_ROT_STEPS)
|
|
91
|
+
candidate = Placement(kind, _snap(px), _snap(py - ANCHOR[kind]), rot)
|
|
92
|
+
trial = placements + [candidate]
|
|
93
|
+
out = simulate(probe, trial)
|
|
94
|
+
if len(trial) - 1 in out.struck and out.verdict is Verdict.MISSED:
|
|
95
|
+
return candidate
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def generate(
|
|
100
|
+
seed: int,
|
|
101
|
+
pieces: int,
|
|
102
|
+
bounds: float = 470.0,
|
|
103
|
+
ring_half_width: float = 118.0,
|
|
104
|
+
) -> Generated | None:
|
|
105
|
+
"""One level with `pieces` pieces, or None if this seed did not work out.
|
|
106
|
+
|
|
107
|
+
Failure is normal and cheap — the caller just moves to the next seed. Trying
|
|
108
|
+
to force a stubborn seed produces contrived layouts.
|
|
109
|
+
|
|
110
|
+
`ring_half_width` is the fine difficulty knob. Piece count alone turned out
|
|
111
|
+
to be far too coarse: measured on gemini-2.5-pro, one piece solved half the
|
|
112
|
+
time and two pieces solved none of it, with nothing in between. A wider mouth
|
|
113
|
+
makes a given piece count easier without changing what the model has to
|
|
114
|
+
reason about, which is what turns a cliff into a slope.
|
|
115
|
+
"""
|
|
116
|
+
rng = random.Random(seed)
|
|
117
|
+
placements: list[Placement] = []
|
|
118
|
+
ceiling_y = -FIRST_DEPTH
|
|
119
|
+
|
|
120
|
+
for _ in range(pieces):
|
|
121
|
+
nxt = _place_next(rng, placements, ceiling_y, bounds)
|
|
122
|
+
if nxt is None:
|
|
123
|
+
return None
|
|
124
|
+
placements.append(nxt)
|
|
125
|
+
ceiling_y = nxt.y
|
|
126
|
+
|
|
127
|
+
# Where does the finished layout end up? That is where the ring goes.
|
|
128
|
+
probe = Level(ring_x=0.0, ring_y=DEEP, bounds=bounds)
|
|
129
|
+
run = simulate(probe, placements, keep_trace=True)
|
|
130
|
+
ring_y = _snap(min(p.y for p in placements) - RING_DROP)
|
|
131
|
+
crossing = next((p for p in run.trace if p[1] <= ring_y), None)
|
|
132
|
+
if crossing is None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
level = Level(
|
|
136
|
+
ring_x=_snap(crossing[0]),
|
|
137
|
+
ring_y=ring_y,
|
|
138
|
+
ring_half_width=ring_half_width,
|
|
139
|
+
bounds=bounds,
|
|
140
|
+
inventory=_inventory(placements),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Built forwards, verified backwards: if this does not score, the level is
|
|
144
|
+
# not what it claims and gets thrown away rather than shipped.
|
|
145
|
+
if not simulate(level, placements).scored:
|
|
146
|
+
return None
|
|
147
|
+
return Generated(level=level, solution=placements, seed=seed)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _inventory(placements: list[Placement]) -> dict[Kind, int]:
|
|
151
|
+
counts: dict[Kind, int] = {}
|
|
152
|
+
for p in placements:
|
|
153
|
+
counts[p.kind] = counts.get(p.kind, 0) + 1
|
|
154
|
+
return counts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def stream(
|
|
158
|
+
start_seed: int,
|
|
159
|
+
pieces: int,
|
|
160
|
+
bounds: float = 470.0,
|
|
161
|
+
ring_half_width: float = 118.0,
|
|
162
|
+
):
|
|
163
|
+
"""Endless levels of a given size. Seeds that fail are skipped."""
|
|
164
|
+
seed = start_seed
|
|
165
|
+
while True:
|
|
166
|
+
made = generate(seed, pieces, bounds, ring_half_width)
|
|
167
|
+
seed += 1
|
|
168
|
+
if made is not None:
|
|
169
|
+
yield made
|
marble_v1/sim.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""The marble simulator, ported from Swift.
|
|
2
|
+
|
|
3
|
+
## Why this is a port and not a physics engine
|
|
4
|
+
|
|
5
|
+
The original runs the whole attempt to completion before anything is drawn,
|
|
6
|
+
with a fixed step and analytic contacts, because a layout that can give two
|
|
7
|
+
different answers is a layout nobody can learn from. That property is why the
|
|
8
|
+
game works, and it is also the entire reason this makes a usable RL
|
|
9
|
+
environment: the reward is computed by arithmetic, not by a judge model, so
|
|
10
|
+
there is nothing to talk it out of.
|
|
11
|
+
|
|
12
|
+
## What was left behind
|
|
13
|
+
|
|
14
|
+
The Swift original also accumulates a `RunPath` of exact parabolas, logs every
|
|
15
|
+
strike with its strength, and tracks spin — all of it for drawing and for
|
|
16
|
+
sound. None of it can change the outcome, so none of it is here. What survives
|
|
17
|
+
is the physics loop and the verdict.
|
|
18
|
+
|
|
19
|
+
## Determinism
|
|
20
|
+
|
|
21
|
+
Bit-identity with the Swift version is deliberately NOT a goal: `sin`, `cos`
|
|
22
|
+
and `atan2` may differ in the last place between platforms, and chasing that
|
|
23
|
+
would cost weeks and buy nothing. What is required is that this module give the
|
|
24
|
+
same answer for the same input on the machine that grades a rollout — which
|
|
25
|
+
plain IEEE-754 doubles in a fixed operation order already provide. The
|
|
26
|
+
consequence, and it matters: reference solutions must be generated by THIS
|
|
27
|
+
simulator, never imported from the Swift one.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import math
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import Enum
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------- constants
|
|
37
|
+
|
|
38
|
+
STEP = 1.0 / 240 # fixed, and small: at 240 Hz a marble at full speed
|
|
39
|
+
# moves less than its own radius, so it cannot tunnel
|
|
40
|
+
TIME_LIMIT = 26.0
|
|
41
|
+
GRAVITY = 1470.0
|
|
42
|
+
MARBLE_RADIUS = 66.0
|
|
43
|
+
STAR_RADIUS = 66.0
|
|
44
|
+
|
|
45
|
+
BOUNCE_THRESHOLD = 90.0 # below this closing speed a contact is a roll
|
|
46
|
+
SPRING_IMPULSE = 520.0
|
|
47
|
+
ROLLING_DRAG = 0.0016
|
|
48
|
+
MAX_SPEED = 1750.0
|
|
49
|
+
NO_PROGRESS_LIMIT = 2.6
|
|
50
|
+
STILL_LIMIT = 0.9
|
|
51
|
+
STILL_SPEED = 40.0
|
|
52
|
+
|
|
53
|
+
RAMP_RESTITUTION = 0.22
|
|
54
|
+
RAMP_FRICTION = 0.90
|
|
55
|
+
RAMP_INDEX = -1
|
|
56
|
+
|
|
57
|
+
POSITION_STEP = 20.0 # the placement grid
|
|
58
|
+
ROTATION_STEP = math.pi / 24 # 7.5 degrees
|
|
59
|
+
|
|
60
|
+
LADDER_RADIUS = 470.0
|
|
61
|
+
LADDER_SWEEP = 1.15
|
|
62
|
+
LADDER_THICKNESS = 46.0 + 21.0 # tubeSpacing + tubeWidth
|
|
63
|
+
|
|
64
|
+
PLATFORM_Y = -300.0
|
|
65
|
+
SURFACE_THICKNESS = 26.0
|
|
66
|
+
PLATFORM_COLLIDER_RADIUS = 8.0 # measured, not chosen — see the Swift note
|
|
67
|
+
OPENING_SPEED = 230.0
|
|
68
|
+
|
|
69
|
+
# The ramp the marble always starts on. Fixed: a puzzle needs a fixed starting
|
|
70
|
+
# condition, or nothing learned from one attempt carries to the next.
|
|
71
|
+
PLATFORM_CONTROLS = [(-560.0, 46.0), (-260.0, 22.0), (-90.0, -4.0), (10.0, -20.0)]
|
|
72
|
+
PLATFORM_SURFACE = [(x, y + PLATFORM_Y) for x, y in PLATFORM_CONTROLS]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Kind(str, Enum):
|
|
76
|
+
LADDER = "ladder"
|
|
77
|
+
SPRING = "spring"
|
|
78
|
+
DRUM = "drum"
|
|
79
|
+
BLOCK = "block"
|
|
80
|
+
BELL = "bell"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# size (w, h), restitution, surface friction
|
|
84
|
+
SPEC: dict[Kind, tuple[tuple[float, float], float, float]] = {
|
|
85
|
+
Kind.LADDER: ((300.0, 120.0), 0.20, 0.985),
|
|
86
|
+
Kind.SPRING: ((96.0, 132.0), 0.62, 0.88),
|
|
87
|
+
Kind.DRUM: ((186.0, 76.0), 0.74, 0.93),
|
|
88
|
+
Kind.BLOCK: ((150.0, 108.0), 0.30, 0.86),
|
|
89
|
+
Kind.BELL: ((158.0, 82.0), 0.52, 0.91),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# How far below a point on the marble's path a piece's centre must sit for the
|
|
93
|
+
# marble to land on its top face rather than through its middle.
|
|
94
|
+
ANCHOR: dict[Kind, float] = {
|
|
95
|
+
Kind.LADDER: 0.0, # the arc positions itself around its own centre
|
|
96
|
+
Kind.SPRING: 132.0 / 2 + MARBLE_RADIUS - 14,
|
|
97
|
+
Kind.DRUM: 76.0 / 2 + MARBLE_RADIUS - 10,
|
|
98
|
+
Kind.BLOCK: 108.0 / 2 + MARBLE_RADIUS - 6,
|
|
99
|
+
Kind.BELL: 82.0 / 2 + MARBLE_RADIUS - 8,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
MIN_SEPARATION = 210.0 # between consecutive pieces, from the original solver
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------- colliders
|
|
105
|
+
#
|
|
106
|
+
# Three shapes cover every piece. Kept as plain tuples rather than classes: this
|
|
107
|
+
# runs a few hundred thousand times per attempt.
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _clamp(v: float, lo: float, hi: float) -> float:
|
|
111
|
+
return lo if v < lo else hi if v > hi else v
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _segment_contact(px, py, radius, ax, ay, bx, by, bar):
|
|
115
|
+
"""A capsule: a thick line with rounded ends."""
|
|
116
|
+
rx, ry = bx - ax, by - ay
|
|
117
|
+
length_sq = rx * rx + ry * ry
|
|
118
|
+
t = 0.0 if length_sq <= 0 else _clamp(((px - ax) * rx + (py - ay) * ry) / length_sq, 0.0, 1.0)
|
|
119
|
+
cx, cy = ax + rx * t, ay + ry * t
|
|
120
|
+
dx, dy = px - cx, py - cy
|
|
121
|
+
distance = math.hypot(dx, dy)
|
|
122
|
+
reach = radius + bar
|
|
123
|
+
if distance >= reach:
|
|
124
|
+
return None
|
|
125
|
+
# Dead centre: push straight up rather than dividing by zero. A normal of
|
|
126
|
+
# NaN would poison the whole run.
|
|
127
|
+
if distance <= 0.0001:
|
|
128
|
+
return (0.0, 1.0, reach)
|
|
129
|
+
return (dx / distance, dy / distance, reach - distance)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _box_contact(px, py, radius, cx, cy, w, h, corner, rotation):
|
|
133
|
+
"""Everything box-like. A drum is this with a corner radius of half its
|
|
134
|
+
height, which makes it a capsule — the right shape for a disc edge-on."""
|
|
135
|
+
# Work in the box's own frame: a rotated box is only hard if you insist on
|
|
136
|
+
# staying in world space.
|
|
137
|
+
cos_r, sin_r = math.cos(-rotation), math.sin(-rotation)
|
|
138
|
+
dx, dy = px - cx, py - cy
|
|
139
|
+
lx = dx * cos_r - dy * sin_r
|
|
140
|
+
ly = dx * sin_r + dy * cos_r
|
|
141
|
+
|
|
142
|
+
half_w = max(0.0, w / 2 - corner)
|
|
143
|
+
half_h = max(0.0, h / 2 - corner)
|
|
144
|
+
qx = _clamp(lx, -half_w, half_w)
|
|
145
|
+
qy = _clamp(ly, -half_h, half_h)
|
|
146
|
+
ox, oy = lx - qx, ly - qy
|
|
147
|
+
distance = math.hypot(ox, oy)
|
|
148
|
+
|
|
149
|
+
if distance < 0.0001:
|
|
150
|
+
# Dead centre: push out the short way, which for a flat object is
|
|
151
|
+
# almost always up or down, and is what a marble resting on one wants.
|
|
152
|
+
ox, oy = 0.0, (1.0 if ly >= 0 else -1.0)
|
|
153
|
+
distance = 0.0
|
|
154
|
+
|
|
155
|
+
depth = radius + corner - distance
|
|
156
|
+
if depth <= 0:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
n = math.hypot(ox, oy)
|
|
160
|
+
nx, ny = (ox / n, oy / n) if n > 0 else (0.0, 1.0)
|
|
161
|
+
cos_b, sin_b = math.cos(rotation), math.sin(rotation)
|
|
162
|
+
return (nx * cos_b - ny * sin_b, nx * sin_b + ny * cos_b, depth)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _arc_contact(px, py, radius, cx, cy, arc_radius, start, end, thickness):
|
|
166
|
+
"""A ladder: the concave inside of a circular arc."""
|
|
167
|
+
ox, oy = px - cx, py - cy
|
|
168
|
+
distance = math.hypot(ox, oy)
|
|
169
|
+
if distance <= 0.0001:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Only the concave side collides. A marble that has fallen past the outside
|
|
173
|
+
# of a ladder should carry on past it, not be caught by its back.
|
|
174
|
+
surface = arc_radius - thickness / 2
|
|
175
|
+
depth = radius - (surface - distance)
|
|
176
|
+
if depth <= 0 or distance >= surface:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
angle = math.atan2(oy, ox)
|
|
180
|
+
lo, hi = min(start, end), max(start, end)
|
|
181
|
+
while angle < lo - math.pi:
|
|
182
|
+
angle += 2 * math.pi
|
|
183
|
+
while angle > hi + math.pi:
|
|
184
|
+
angle -= 2 * math.pi
|
|
185
|
+
# A marble-radius of slack at each end, so the ball rolls off the tip
|
|
186
|
+
# rather than clipping through it.
|
|
187
|
+
slack = radius / max(1.0, arc_radius)
|
|
188
|
+
if not (lo - slack < angle < hi + slack):
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
# The normal points from the surface towards the centre: the marble rides
|
|
192
|
+
# on the inside.
|
|
193
|
+
return (-ox / distance, -oy / distance, depth)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------- the level
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class Placement:
|
|
201
|
+
kind: Kind
|
|
202
|
+
x: float
|
|
203
|
+
y: float
|
|
204
|
+
rot_steps: int = 0 # in units of ROTATION_STEP, so the action is integral
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def rotation(self) -> float:
|
|
208
|
+
return self.rot_steps * ROTATION_STEP
|
|
209
|
+
|
|
210
|
+
def collider(self):
|
|
211
|
+
r = self.rotation
|
|
212
|
+
if self.kind is Kind.LADDER:
|
|
213
|
+
# The arc is authored around a centre above the piece, so the player
|
|
214
|
+
# positions the *dip* of the ladder rather than an invisible circle.
|
|
215
|
+
centre_x = self.x - math.sin(r) * LADDER_RADIUS
|
|
216
|
+
centre_y = self.y + math.cos(r) * LADDER_RADIUS
|
|
217
|
+
mid = math.pi * 1.5 + r
|
|
218
|
+
half = LADDER_SWEEP / 2
|
|
219
|
+
return ("arc", centre_x, centre_y, LADDER_RADIUS,
|
|
220
|
+
mid - half, mid + half, LADDER_THICKNESS)
|
|
221
|
+
(w, h), _, _ = SPEC[self.kind]
|
|
222
|
+
corner = h / 2 if self.kind is Kind.DRUM else 18.0
|
|
223
|
+
return ("box", self.x, self.y, w, h, corner, r)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class Level:
|
|
228
|
+
ring_x: float
|
|
229
|
+
ring_y: float
|
|
230
|
+
ring_half_width: float = 118.0
|
|
231
|
+
stars: list[tuple[float, float]] = field(default_factory=list)
|
|
232
|
+
bounds: float = 470.0
|
|
233
|
+
inventory: dict[Kind, int] = field(default_factory=dict)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class Verdict(str, Enum):
|
|
237
|
+
SCORED = "scored" # through the ring with every star taken
|
|
238
|
+
SHORT_OF_STARS = "short_of_stars" # through the ring, stars left behind
|
|
239
|
+
MISSED = "missed" # fell past the ring, or off the side
|
|
240
|
+
STALLED = "stalled" # stopped moving, or stopped descending
|
|
241
|
+
TIMED_OUT = "timed_out"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class Outcome:
|
|
246
|
+
verdict: Verdict
|
|
247
|
+
time: float
|
|
248
|
+
stars_taken: int
|
|
249
|
+
stars_total: int
|
|
250
|
+
x_at_ring: float | None # where it crossed the ring's line, if it did
|
|
251
|
+
miss_by: float | None # how far outside the mouth that crossing was
|
|
252
|
+
final_x: float
|
|
253
|
+
final_y: float
|
|
254
|
+
struck: frozenset[int] = frozenset()
|
|
255
|
+
"""Which placements the marble actually bounced off. A generated level whose
|
|
256
|
+
pieces are not all struck is a level with a smaller solution hiding in it,
|
|
257
|
+
which makes its stated inventory a lie."""
|
|
258
|
+
trace: list[tuple[float, float]] = field(default_factory=list)
|
|
259
|
+
"""The path, when asked for. Only the generator needs it — it places the next
|
|
260
|
+
piece where the marble will actually be, which is the whole reason generated
|
|
261
|
+
levels are solvable by construction."""
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def scored(self) -> bool:
|
|
265
|
+
return self.verdict is Verdict.SCORED
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def opening_slide() -> tuple[float, float, float, float, float]:
|
|
269
|
+
"""Where the marble is and how fast it is going when the scripted slide
|
|
270
|
+
down the fixed ramp ends. Returns (x, y, vx, vy, elapsed)."""
|
|
271
|
+
lift = MARBLE_RADIUS + SURFACE_THICKNESS / 2
|
|
272
|
+
fx = PLATFORM_CONTROLS[0][0] + 120
|
|
273
|
+
fy = PLATFORM_CONTROLS[0][1] + PLATFORM_Y + lift
|
|
274
|
+
tx = PLATFORM_CONTROLS[-1][0]
|
|
275
|
+
ty = PLATFORM_CONTROLS[-1][1] + PLATFORM_Y + lift
|
|
276
|
+
|
|
277
|
+
rx, ry = tx - fx, ty - fy
|
|
278
|
+
length = math.hypot(rx, ry)
|
|
279
|
+
slope = (-ry / length) if length > 0 else 0.0
|
|
280
|
+
# 5/7: a rolling sphere, not a sliding block.
|
|
281
|
+
acceleration = GRAVITY * slope * (5.0 / 7.0)
|
|
282
|
+
v0 = OPENING_SPEED
|
|
283
|
+
if acceleration > 0:
|
|
284
|
+
duration = (-v0 + math.sqrt(v0 * v0 + 2 * acceleration * length)) / acceleration
|
|
285
|
+
else:
|
|
286
|
+
duration = length / max(1.0, v0)
|
|
287
|
+
exit_speed = v0 + acceleration * duration
|
|
288
|
+
dx, dy = (rx / length, ry / length) if length > 0 else (1.0, 0.0)
|
|
289
|
+
return tx, ty, dx * exit_speed, dy * exit_speed, duration
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _passes_within(ax, ay, bx, by, radius, px, py) -> bool:
|
|
293
|
+
"""Distance from a point to a segment, thresholded. Stars are tested against
|
|
294
|
+
the whole step rather than its endpoint: at full speed the marble covers
|
|
295
|
+
seven units a step and would sail straight through one checked point by
|
|
296
|
+
point."""
|
|
297
|
+
dx, dy = bx - ax, by - ay
|
|
298
|
+
length_sq = dx * dx + dy * dy
|
|
299
|
+
t = 0.0 if length_sq <= 0 else _clamp(((px - ax) * dx + (py - ay) * dy) / length_sq, 0.0, 1.0)
|
|
300
|
+
return math.hypot(px - (ax + dx * t), py - (ay + dy * t)) <= radius
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def simulate(level: Level, placements: list[Placement], keep_trace: bool = False) -> Outcome:
|
|
304
|
+
"""Run one attempt to completion and report what happened."""
|
|
305
|
+
colliders = [p.collider() for p in placements]
|
|
306
|
+
kinds = [p.kind for p in placements]
|
|
307
|
+
rotations = [p.rotation for p in placements]
|
|
308
|
+
ramp = [("seg", PLATFORM_SURFACE[i][0], PLATFORM_SURFACE[i][1],
|
|
309
|
+
PLATFORM_SURFACE[i + 1][0], PLATFORM_SURFACE[i + 1][1],
|
|
310
|
+
PLATFORM_COLLIDER_RADIUS)
|
|
311
|
+
for i in range(len(PLATFORM_SURFACE) - 1)]
|
|
312
|
+
|
|
313
|
+
x, y, vx, vy, time = opening_slide()
|
|
314
|
+
taken: set[int] = set()
|
|
315
|
+
struck: set[int] = set()
|
|
316
|
+
verdict = Verdict.TIMED_OUT
|
|
317
|
+
x_at_ring: float | None = None
|
|
318
|
+
miss_by: float | None = None
|
|
319
|
+
|
|
320
|
+
still_for = 0.0
|
|
321
|
+
deepest_y = y
|
|
322
|
+
deepest_at = time
|
|
323
|
+
trace: list[tuple[float, float]] = [(x, y)] if keep_trace else []
|
|
324
|
+
|
|
325
|
+
while time < TIME_LIMIT:
|
|
326
|
+
prev_x, prev_y = x, y
|
|
327
|
+
|
|
328
|
+
vy -= GRAVITY * STEP
|
|
329
|
+
# Terminal velocity. Not realism — a marble that keeps accelerating down
|
|
330
|
+
# a tall layout becomes impossible to aim, and the whole point is that
|
|
331
|
+
# the player can reason about where it will go.
|
|
332
|
+
speed = math.hypot(vx, vy)
|
|
333
|
+
if speed > MAX_SPEED:
|
|
334
|
+
k = MAX_SPEED / speed
|
|
335
|
+
vx, vy = vx * k, vy * k
|
|
336
|
+
x += vx * STEP
|
|
337
|
+
y += vy * STEP
|
|
338
|
+
time += STEP
|
|
339
|
+
if keep_trace:
|
|
340
|
+
trace.append((x, y))
|
|
341
|
+
|
|
342
|
+
for i, (sx, sy) in enumerate(level.stars):
|
|
343
|
+
if i not in taken and _passes_within(prev_x, prev_y, x, y, STAR_RADIUS, sx, sy):
|
|
344
|
+
taken.add(i)
|
|
345
|
+
|
|
346
|
+
# Through the ring? Crossing its line is the test, not proximity to it.
|
|
347
|
+
if (prev_y - level.ring_y) * (y - level.ring_y) <= 0 and prev_y != y:
|
|
348
|
+
t = (level.ring_y - prev_y) / (y - prev_y)
|
|
349
|
+
cross = prev_x + (x - prev_x) * t
|
|
350
|
+
if abs(cross - level.ring_x) <= level.ring_half_width:
|
|
351
|
+
x_at_ring = cross
|
|
352
|
+
miss_by = 0.0
|
|
353
|
+
verdict = (Verdict.SCORED if len(taken) == len(level.stars)
|
|
354
|
+
else Verdict.SHORT_OF_STARS)
|
|
355
|
+
break
|
|
356
|
+
if x_at_ring is None: # remember the first crossing, for diagnostics
|
|
357
|
+
x_at_ring = cross
|
|
358
|
+
miss_by = abs(cross - level.ring_x) - level.ring_half_width
|
|
359
|
+
|
|
360
|
+
if y < level.ring_y - 900 or abs(x) > level.bounds + 400:
|
|
361
|
+
verdict = Verdict.MISSED
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Contacts, deepest first: with two objects overlapping, resolving the
|
|
365
|
+
# shallow one first just pushes the marble further into the other.
|
|
366
|
+
best = None
|
|
367
|
+
for idx, c in enumerate(colliders):
|
|
368
|
+
hit = (_arc_contact(x, y, MARBLE_RADIUS, *c[1:]) if c[0] == "arc"
|
|
369
|
+
else _box_contact(x, y, MARBLE_RADIUS, *c[1:]))
|
|
370
|
+
if hit and (best is None or hit[2] > best[1][2]):
|
|
371
|
+
best = (idx, hit)
|
|
372
|
+
for c in ramp:
|
|
373
|
+
hit = _segment_contact(x, y, MARBLE_RADIUS, *c[1:])
|
|
374
|
+
if hit and (best is None or hit[2] > best[1][2]):
|
|
375
|
+
best = (RAMP_INDEX, hit)
|
|
376
|
+
|
|
377
|
+
if best is not None:
|
|
378
|
+
idx, (nx, ny, depth) = best
|
|
379
|
+
on_ramp = idx == RAMP_INDEX
|
|
380
|
+
if on_ramp:
|
|
381
|
+
restitution, friction = RAMP_RESTITUTION, RAMP_FRICTION
|
|
382
|
+
else:
|
|
383
|
+
_, restitution, friction = SPEC[kinds[idx]]
|
|
384
|
+
|
|
385
|
+
# Push clear before touching the velocity, or the next step starts
|
|
386
|
+
# inside the object and the marble sinks further on every contact
|
|
387
|
+
# until it falls through.
|
|
388
|
+
x += nx * depth
|
|
389
|
+
y += ny * depth
|
|
390
|
+
|
|
391
|
+
approach = vx * nx + vy * ny
|
|
392
|
+
if approach < 0:
|
|
393
|
+
n_dx, n_dy = nx * approach, ny * approach
|
|
394
|
+
t_dx, t_dy = vx - n_dx, vy - n_dy
|
|
395
|
+
closing = -approach
|
|
396
|
+
if closing > BOUNCE_THRESHOLD:
|
|
397
|
+
if not on_ramp:
|
|
398
|
+
struck.add(idx)
|
|
399
|
+
vx = t_dx * friction - n_dx * restitution
|
|
400
|
+
vy = t_dy * friction - n_dy * restitution
|
|
401
|
+
if not on_ramp and kinds[idx] is Kind.SPRING:
|
|
402
|
+
# A spring pushes along its OWN axis, not along the
|
|
403
|
+
# contact normal. Clip one on the side with a
|
|
404
|
+
# normal-aligned impulse and it fires the marble
|
|
405
|
+
# sideways, which is not what anyone who just pointed a
|
|
406
|
+
# spring upwards expects.
|
|
407
|
+
r = rotations[idx]
|
|
408
|
+
vx += -math.sin(r) * SPRING_IMPULSE
|
|
409
|
+
vy += math.cos(r) * SPRING_IMPULSE
|
|
410
|
+
else:
|
|
411
|
+
# Resting or rolling: drop the normal component entirely and
|
|
412
|
+
# let gravity work along the surface. Bouncing at this speed
|
|
413
|
+
# is what makes a marble buzz on a flat surface instead of
|
|
414
|
+
# rolling along it.
|
|
415
|
+
vx = t_dx * (1 - ROLLING_DRAG)
|
|
416
|
+
vy = t_dy * (1 - ROLLING_DRAG)
|
|
417
|
+
|
|
418
|
+
# Stuck? Two rules, because "stuck" has two shapes and only one of them
|
|
419
|
+
# is standing still. A marble rocking in the dip of a ladder is moving
|
|
420
|
+
# the whole time — friction here is metal on metal, so it can rock for
|
|
421
|
+
# fifteen seconds — and a run that shows nothing for that long is broken
|
|
422
|
+
# whatever the velocity says. Depth is the only thing a run makes
|
|
423
|
+
# progress on, so depth is what it is measured against.
|
|
424
|
+
still_for = still_for + STEP if math.hypot(vx, vy) < STILL_SPEED else 0.0
|
|
425
|
+
if y < deepest_y - 4:
|
|
426
|
+
deepest_y = y
|
|
427
|
+
deepest_at = time
|
|
428
|
+
if still_for > STILL_LIMIT or time - deepest_at > NO_PROGRESS_LIMIT:
|
|
429
|
+
verdict = Verdict.STALLED
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
return Outcome(
|
|
433
|
+
verdict=verdict,
|
|
434
|
+
time=time,
|
|
435
|
+
stars_taken=len(taken),
|
|
436
|
+
stars_total=len(level.stars),
|
|
437
|
+
x_at_ring=x_at_ring,
|
|
438
|
+
miss_by=miss_by,
|
|
439
|
+
final_x=x,
|
|
440
|
+
final_y=y,
|
|
441
|
+
struck=frozenset(struck),
|
|
442
|
+
trace=trace,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def within_inventory(placements: list[Placement], inventory: dict[Kind, int]) -> bool:
|
|
447
|
+
"""A layout that uses pieces the level never offered is not a solution."""
|
|
448
|
+
used: dict[Kind, int] = {}
|
|
449
|
+
for p in placements:
|
|
450
|
+
used[p.kind] = used.get(p.kind, 0) + 1
|
|
451
|
+
return all(n <= inventory.get(k, 0) for k, n in used.items())
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def on_grid(p: Placement) -> bool:
|
|
455
|
+
"""Placements snap to the grid. Off-grid ones are rejected rather than
|
|
456
|
+
rounded: rounding silently changes the answer the model gave."""
|
|
457
|
+
return (abs(p.x / POSITION_STEP - round(p.x / POSITION_STEP)) < 1e-9
|
|
458
|
+
and abs(p.y / POSITION_STEP - round(p.y / POSITION_STEP)) < 1e-9)
|
marble_v1/taskset.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""marble-v1 — place pieces so a falling marble lands in a ring.
|
|
2
|
+
|
|
3
|
+
A marble rolls off a fixed ramp and falls. The model is given a ring somewhere
|
|
4
|
+
below, an inventory of pieces, and the physical constants; it must say where to
|
|
5
|
+
put each piece so the marble bounces its way into the ring. One reply, no
|
|
6
|
+
retries, no simulator to poke at — the whole task is predicting a chain of
|
|
7
|
+
bounces before any of them happen.
|
|
8
|
+
|
|
9
|
+
## Why this environment exists
|
|
10
|
+
|
|
11
|
+
Almost every RL environment scores its rollouts with a judge model or a rubric,
|
|
12
|
+
and both can be argued with: a persuasive answer can score well without being
|
|
13
|
+
right. Here the reward is computed by a fixed-step physics integrator. The
|
|
14
|
+
marble goes through the ring or it does not, and no amount of confident prose
|
|
15
|
+
moves that number. Reward hacking is not defended against here; it is impossible.
|
|
16
|
+
|
|
17
|
+
Two more properties fall out of the same design:
|
|
18
|
+
|
|
19
|
+
- **Infinite and solvable by construction.** Levels are built forwards along the
|
|
20
|
+
marble's real path (see `levels.py`), so a solution is known before the task is
|
|
21
|
+
handed out — a model that fails failed at something possible.
|
|
22
|
+
- **Cheap.** No browser, no VM, no judge calls. One rollout is one model call
|
|
23
|
+
plus a few milliseconds of arithmetic, which matters when a training run wants
|
|
24
|
+
hundreds of thousands of them.
|
|
25
|
+
|
|
26
|
+
Difficulty is the piece count, and it is the one knob: `--env.taskset.pieces`.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Iterator
|
|
34
|
+
from itertools import count
|
|
35
|
+
|
|
36
|
+
import verifiers.v1 as vf
|
|
37
|
+
|
|
38
|
+
from marble_v1 import levels as gen
|
|
39
|
+
from marble_v1.sim import (
|
|
40
|
+
GRAVITY,
|
|
41
|
+
MARBLE_RADIUS,
|
|
42
|
+
POSITION_STEP,
|
|
43
|
+
SPRING_IMPULSE,
|
|
44
|
+
Kind,
|
|
45
|
+
Level,
|
|
46
|
+
Placement,
|
|
47
|
+
on_grid,
|
|
48
|
+
opening_slide,
|
|
49
|
+
simulate,
|
|
50
|
+
within_inventory,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
PIECES = """| piece | size (w x h) | bounciness | surface grip |
|
|
54
|
+
|--------|--------------|------------|--------------|
|
|
55
|
+
| ladder | 300 x 120 | 0.20 | 0.985 |
|
|
56
|
+
| spring | 96 x 132 | 0.62 | 0.88 |
|
|
57
|
+
| drum | 186 x 76 | 0.74 | 0.93 |
|
|
58
|
+
| block | 150 x 108 | 0.30 | 0.86 |
|
|
59
|
+
| bell | 158 x 82 | 0.52 | 0.91 |"""
|
|
60
|
+
|
|
61
|
+
PROMPT = """A marble rolls off a fixed ramp and falls under gravity. Place the pieces you
|
|
62
|
+
are given so that it ends up passing through the ring.
|
|
63
|
+
|
|
64
|
+
COORDINATES: x runs right, y runs up. Gravity is {gravity:.0f} units/s^2 downward.
|
|
65
|
+
The marble has radius {radius:.0f} and leaves the ramp at ({sx:.0f}, {sy:.0f}) with
|
|
66
|
+
velocity ({vx:.0f}, {vy:.0f}) units/s.
|
|
67
|
+
|
|
68
|
+
THE RING: a horizontal mouth centred at ({ring_x:.0f}, {ring_y:.0f}), reaching {half_width:.0f}
|
|
69
|
+
units either side of that centre. The marble scores by crossing y = {ring_y:.0f}
|
|
70
|
+
with its x inside that mouth. It is lost if it falls {below:.0f} below the ring, or
|
|
71
|
+
goes past x = +/-{kill:.0f}.
|
|
72
|
+
|
|
73
|
+
YOUR PIECES — use exactly these, no more and no fewer:
|
|
74
|
+
{inventory}
|
|
75
|
+
|
|
76
|
+
{pieces}
|
|
77
|
+
|
|
78
|
+
A bounce keeps `bounciness` of the speed into the surface and `grip` of the speed
|
|
79
|
+
along it. A spring additionally fires the marble {impulse:.0f} units/s along its own
|
|
80
|
+
axis, so rotating a spring aims it. Everything except the ladder is a rounded
|
|
81
|
+
box; the ladder is a concave arc that cradles the marble.
|
|
82
|
+
|
|
83
|
+
PLACEMENT RULES:
|
|
84
|
+
- x and y must be multiples of {step:.0f}.
|
|
85
|
+
- x must be within +/-{bounds:.0f} — you may only build inside that strip.
|
|
86
|
+
- Rotation is in whole steps of 7.5 degrees, from -12 to 12.
|
|
87
|
+
- A piece is positioned by its centre.
|
|
88
|
+
|
|
89
|
+
Reply with your reasoning if you like, then give the layout as a JSON array in a
|
|
90
|
+
fenced block. Only the last such block counts.
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
[{{"kind": "block", "x": 120, "y": -800, "rot": 2}}]
|
|
94
|
+
```"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_layout(reply: str) -> list[Placement] | None:
|
|
98
|
+
"""The last fenced JSON array in the reply, as placements, or None.
|
|
99
|
+
|
|
100
|
+
Strict about shape and forgiving about nothing: a layout that is silently
|
|
101
|
+
repaired is not the layout the model proposed, and scoring a repaired one
|
|
102
|
+
measures the repair.
|
|
103
|
+
"""
|
|
104
|
+
blocks = re.findall(r"```(?:json)?\s*(\[.*?\])\s*```", reply, re.DOTALL)
|
|
105
|
+
if not blocks:
|
|
106
|
+
blocks = re.findall(r"(\[\s*\{.*?\}\s*\])", reply, re.DOTALL)
|
|
107
|
+
if not blocks:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
rows = json.loads(blocks[-1])
|
|
111
|
+
except (json.JSONDecodeError, ValueError):
|
|
112
|
+
return None
|
|
113
|
+
if not isinstance(rows, list):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
out: list[Placement] = []
|
|
117
|
+
for row in rows:
|
|
118
|
+
if not isinstance(row, dict):
|
|
119
|
+
return None
|
|
120
|
+
try:
|
|
121
|
+
kind = Kind(str(row["kind"]).strip().lower())
|
|
122
|
+
out.append(Placement(kind, float(row["x"]), float(row["y"]), int(row["rot"])))
|
|
123
|
+
except (KeyError, ValueError, TypeError):
|
|
124
|
+
return None
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MarbleData(vf.TaskData):
|
|
129
|
+
ring: list[float]
|
|
130
|
+
"""Centre x, centre y, half width."""
|
|
131
|
+
bounds: float
|
|
132
|
+
inventory: dict[str, int]
|
|
133
|
+
solution: list[dict]
|
|
134
|
+
"""A layout known to score. Never shown to the model — it is here so a run can
|
|
135
|
+
say what a working answer looked like next to the one that was given."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MarbleTask(vf.Task[MarbleData]):
|
|
139
|
+
def _level(self) -> Level:
|
|
140
|
+
rx, ry, hw = self.data.ring
|
|
141
|
+
return Level(
|
|
142
|
+
ring_x=rx,
|
|
143
|
+
ring_y=ry,
|
|
144
|
+
ring_half_width=hw,
|
|
145
|
+
bounds=self.data.bounds,
|
|
146
|
+
inventory={Kind(k): v for k, v in self.data.inventory.items()},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _run(self, trace: vf.Trace):
|
|
150
|
+
"""Parse, check the layout is legal, simulate. Returns (layout, outcome);
|
|
151
|
+
either can be None when the answer did not get that far."""
|
|
152
|
+
layout = parse_layout(trace.last_reply or "")
|
|
153
|
+
if layout is None:
|
|
154
|
+
return None, None
|
|
155
|
+
level = self._level()
|
|
156
|
+
legal = (
|
|
157
|
+
all(on_grid(p) for p in layout)
|
|
158
|
+
and all(abs(p.x) <= level.bounds and abs(p.rot_steps) <= 12 for p in layout)
|
|
159
|
+
and within_inventory(layout, level.inventory)
|
|
160
|
+
and len(layout) == sum(level.inventory.values())
|
|
161
|
+
)
|
|
162
|
+
if not legal:
|
|
163
|
+
return layout, None
|
|
164
|
+
return layout, simulate(level, layout)
|
|
165
|
+
|
|
166
|
+
@vf.reward(weight=1.0)
|
|
167
|
+
async def scored(self, trace: vf.Trace) -> float:
|
|
168
|
+
"""Did the marble go through the ring. Computed by the integrator, so
|
|
169
|
+
there is nothing here to talk out of its answer."""
|
|
170
|
+
_, outcome = self._run(trace)
|
|
171
|
+
return float(outcome is not None and outcome.scored)
|
|
172
|
+
|
|
173
|
+
@vf.reward(weight=0.0)
|
|
174
|
+
async def answered_legally(self, trace: vf.Trace) -> float:
|
|
175
|
+
"""Diagnostic. Separating this from `scored` is what tells a bad layout
|
|
176
|
+
apart from a bad answer — very different problems with the same zero."""
|
|
177
|
+
_, outcome = self._run(trace)
|
|
178
|
+
return float(outcome is not None)
|
|
179
|
+
|
|
180
|
+
@vf.reward(weight=0.0)
|
|
181
|
+
async def closeness(self, trace: vf.Trace) -> float:
|
|
182
|
+
"""Diagnostic: 1 through the mouth, falling to 0 a mouth-width outside it.
|
|
183
|
+
Kept out of the training signal on purpose — a model paid for near misses
|
|
184
|
+
learns to aim near the ring rather than through it."""
|
|
185
|
+
_, outcome = self._run(trace)
|
|
186
|
+
if outcome is None or outcome.miss_by is None:
|
|
187
|
+
return 0.0
|
|
188
|
+
if outcome.scored:
|
|
189
|
+
return 1.0
|
|
190
|
+
return max(0.0, 1.0 - outcome.miss_by / self.data.ring[2])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class MarbleConfig(vf.TasksetConfig):
|
|
194
|
+
pieces: int = 1
|
|
195
|
+
"""How many pieces every level needs. The coarse difficulty knob."""
|
|
196
|
+
ring_half_width: float = 118.0
|
|
197
|
+
"""How far the ring's mouth reaches either side of centre — the fine knob.
|
|
198
|
+
|
|
199
|
+
Piece count alone is far too coarse to calibrate with. Measured on
|
|
200
|
+
gemini-2.5-pro at the stock mouth: one piece 50%, two pieces 0%, three
|
|
201
|
+
pieces 0%. A cliff like that gives a training run nothing to climb, so the
|
|
202
|
+
mouth is what you widen to put a given piece count in a useful band.
|
|
203
|
+
"""
|
|
204
|
+
seed: int = 0
|
|
205
|
+
"""Where the level stream starts."""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class MarbleTaskset(vf.Taskset[MarbleTask, MarbleConfig]):
|
|
209
|
+
INFINITE = True
|
|
210
|
+
|
|
211
|
+
def load(self) -> Iterator[MarbleTask]:
|
|
212
|
+
sx, sy, vx, vy, _ = opening_slide()
|
|
213
|
+
stream = gen.stream(
|
|
214
|
+
self.config.seed,
|
|
215
|
+
self.config.pieces,
|
|
216
|
+
ring_half_width=self.config.ring_half_width,
|
|
217
|
+
)
|
|
218
|
+
for idx in count():
|
|
219
|
+
made = next(stream)
|
|
220
|
+
level = made.level
|
|
221
|
+
inventory = {k.value: v for k, v in level.inventory.items()}
|
|
222
|
+
listing = "\n".join(f"- {n} x {k}" for k, n in sorted(inventory.items()))
|
|
223
|
+
yield MarbleTask(
|
|
224
|
+
MarbleData(
|
|
225
|
+
idx=idx,
|
|
226
|
+
prompt=PROMPT.format(
|
|
227
|
+
gravity=GRAVITY,
|
|
228
|
+
radius=MARBLE_RADIUS,
|
|
229
|
+
sx=sx, sy=sy, vx=vx, vy=vy,
|
|
230
|
+
ring_x=level.ring_x,
|
|
231
|
+
ring_y=level.ring_y,
|
|
232
|
+
half_width=level.ring_half_width,
|
|
233
|
+
below=900,
|
|
234
|
+
kill=level.bounds + 400,
|
|
235
|
+
inventory=listing,
|
|
236
|
+
pieces=PIECES,
|
|
237
|
+
impulse=SPRING_IMPULSE,
|
|
238
|
+
step=POSITION_STEP,
|
|
239
|
+
bounds=level.bounds,
|
|
240
|
+
),
|
|
241
|
+
ring=[level.ring_x, level.ring_y, level.ring_half_width],
|
|
242
|
+
bounds=level.bounds,
|
|
243
|
+
inventory=inventory,
|
|
244
|
+
solution=[
|
|
245
|
+
{"kind": p.kind.value, "x": p.x, "y": p.y, "rot": p.rot_steps}
|
|
246
|
+
for p in made.solution
|
|
247
|
+
],
|
|
248
|
+
),
|
|
249
|
+
self.config.task,
|
|
250
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marble-v1
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Place pieces so a falling marble lands in a ring — an RL environment whose reward is computed by a deterministic physics integrator, not a judge model.
|
|
5
|
+
Project-URL: Homepage, https://github.com/p1at0/marble-v1
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: environment,evals,physics,rl,spatial-reasoning,verifiers
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: verifiers
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# marble-v1
|
|
13
|
+
|
|
14
|
+
Place pieces so a falling marble lands in a ring. One reply, no tools, no
|
|
15
|
+
simulator to poke at — the whole task is predicting a chain of bounces before
|
|
16
|
+
any of them happen.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv run eval @ eval.toml -m <model> \
|
|
20
|
+
--client.base-url <provider> --client.api-key-var <VAR>
|
|
21
|
+
|
|
22
|
+
uv run eval @ eval.toml -m <model> --env.taskset.pieces 3 # harder
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What makes it different
|
|
26
|
+
|
|
27
|
+
**The reward cannot be argued with.** There is no judge model and no rubric. A
|
|
28
|
+
fixed-step physics integrator runs the layout and reports whether the marble
|
|
29
|
+
went through the ring. Reward hacking is not defended against here; it is
|
|
30
|
+
impossible.
|
|
31
|
+
|
|
32
|
+
**Solvable by construction.** Levels are built forwards along the marble's real
|
|
33
|
+
path, so a working layout is known before the task is handed out. A model that
|
|
34
|
+
fails failed at something possible — which is not true of levels that are
|
|
35
|
+
designed and then hoped over.
|
|
36
|
+
|
|
37
|
+
**Infinite, and difficulty is one number.** `pieces` is the knob. Levels stream
|
|
38
|
+
from a seed; there is no dataset to exhaust.
|
|
39
|
+
|
|
40
|
+
**Cheap to run.** No browser, no VM, no judge calls. A rollout is one model call
|
|
41
|
+
plus a few milliseconds of arithmetic — the difference between a training run
|
|
42
|
+
being affordable and not.
|
|
43
|
+
|
|
44
|
+
## The task
|
|
45
|
+
|
|
46
|
+
A marble rolls off a fixed ramp with a known position and velocity and falls
|
|
47
|
+
under gravity. The model is given the ring's position and mouth width, an
|
|
48
|
+
inventory of pieces it must use exactly, each piece's size, bounciness and
|
|
49
|
+
surface grip, and the placement grid. It replies with a JSON layout.
|
|
50
|
+
|
|
51
|
+
Five pieces behave differently: a **spring** fires the marble along its own
|
|
52
|
+
axis, so rotating one aims it; a **ladder** is a concave arc that cradles;
|
|
53
|
+
**drum**, **block** and **bell** are rounded boxes with different bounce. All
|
|
54
|
+
placements snap to a 20-unit grid and 7.5-degree rotation steps, so the action
|
|
55
|
+
space is integral and an answer is either legal or it is not.
|
|
56
|
+
|
|
57
|
+
## Rewards
|
|
58
|
+
|
|
59
|
+
| name | weight | what it says |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `scored` | 1.0 | the marble went through the ring |
|
|
62
|
+
| `answered_legally` | 0.0 | a parseable layout obeying grid, strip and inventory |
|
|
63
|
+
| `closeness` | 0.0 | 1 through the mouth, 0 a mouth-width outside it |
|
|
64
|
+
|
|
65
|
+
The two diagnostics carry no weight on purpose. `closeness` is kept out of the
|
|
66
|
+
training signal because a model paid for near misses learns to aim near the ring
|
|
67
|
+
rather than through it. `answered_legally` exists because "could not format an
|
|
68
|
+
answer" and "got the physics wrong" are very different failures that otherwise
|
|
69
|
+
produce the same zero.
|
|
70
|
+
|
|
71
|
+
## Difficulty
|
|
72
|
+
|
|
73
|
+
Two knobs, and you need both. `pieces` alone is far too coarse — measured, it
|
|
74
|
+
goes 50% → 0% between one piece and two, and a cliff gives a training run
|
|
75
|
+
nothing to climb. `ring_half_width` is the fine one: a wider mouth converts near
|
|
76
|
+
misses into hits without changing what the model has to reason about.
|
|
77
|
+
|
|
78
|
+
Measured on `google/gemini-2.5-pro`, 10 tasks each, `null` harness:
|
|
79
|
+
|
|
80
|
+
| pieces | mouth | solved | legal answers |
|
|
81
|
+
|---|---|---|---|
|
|
82
|
+
| 1 | 118 | **50%** | 100% |
|
|
83
|
+
| 2 | 118 | 0% | 100% |
|
|
84
|
+
| 2 | 300 | 10% | 100% |
|
|
85
|
+
| 2 | 420 | **30%** | 100% |
|
|
86
|
+
| 3 | 118 | 0% | 100% |
|
|
87
|
+
|
|
88
|
+
The mouth knob moves the solve rate 0 → 10 → 30% at a fixed piece count, so any
|
|
89
|
+
model can be put in a band where it solves neither everything nor nothing.
|
|
90
|
+
|
|
91
|
+
`answered_legally` is 100% everywhere: no model in these runs failed to produce
|
|
92
|
+
a legal layout. Every zero above is a physics failure, not a formatting one.
|
|
93
|
+
|
|
94
|
+
## Baselines
|
|
95
|
+
|
|
96
|
+
Measured on generated levels, no model involved (`calib.py`):
|
|
97
|
+
|
|
98
|
+
| pieces | random legal layout scores | gold survives a one-step nudge |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| 1 | 3.7% | 58% |
|
|
101
|
+
| 2 | 1.8% | 51% |
|
|
102
|
+
| 3 | 1.4% | 31% |
|
|
103
|
+
|
|
104
|
+
The first column is the floor a model has to beat to be doing anything — 50% at
|
|
105
|
+
one piece is a 13x lift over guessing. The second says the solution is a basin
|
|
106
|
+
rather than a needle: the task rewards approximately right physics, not exact
|
|
107
|
+
recall.
|
|
108
|
+
|
|
109
|
+
Widening the mouth does not hand the win to chance. At two pieces, taking the
|
|
110
|
+
mouth from 118 to 420 moves the random floor only 1.8% → 6.4%, while it moves
|
|
111
|
+
the model 0% → 30%.
|
|
112
|
+
|
|
113
|
+
## What a rollout costs
|
|
114
|
+
|
|
115
|
+
The environment's own overhead is a few milliseconds of arithmetic — no browser,
|
|
116
|
+
no VM, no judge model. The model is what costs: a reasoning model produces long
|
|
117
|
+
chains on these problems, and `gemini-2.5-pro` averaged about $0.16 per rollout
|
|
118
|
+
in the runs above. Calibrate with a cheaper model first.
|
|
119
|
+
|
|
120
|
+
## Provenance
|
|
121
|
+
|
|
122
|
+
The simulator is a port of the one in a shipped iOS game, where determinism was
|
|
123
|
+
the feature rather than a detail: a player who nudges one piece and re-runs has
|
|
124
|
+
learned nothing if the same layout can give two answers. It is a fixed-step
|
|
125
|
+
integrator with analytic contacts, not a physics engine.
|
|
126
|
+
|
|
127
|
+
The port is checked against the original: on a five-piece reference layout the
|
|
128
|
+
marble's finishing position matches exactly, and intermediate crossings agree to
|
|
129
|
+
within one unit. Bit-identity across languages is explicitly not a goal — `sin`
|
|
130
|
+
and `cos` may differ in the last place — so reference solutions are generated by
|
|
131
|
+
this simulator rather than imported from it.
|
|
132
|
+
|
|
133
|
+
## Files
|
|
134
|
+
|
|
135
|
+
| file | what it is |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `marble_v1/sim.py` | the simulator: colliders, pieces, the physics loop |
|
|
138
|
+
| `marble_v1/levels.py` | level generation, solvable by construction |
|
|
139
|
+
| `marble_v1/taskset.py` | prompt, answer parsing, rewards |
|
|
140
|
+
| `calib.py` | difficulty measured without a model |
|
|
141
|
+
| `summarise.py` | aggregate an eval run into the three numbers |
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
marble_v1/__init__.py,sha256=lpIQk7OBsNij0aGxewxQpjfqALElEU4sEX_Q4jJD8Sk,73
|
|
2
|
+
marble_v1/levels.py,sha256=R3QMKJTlT9coSdnBx9_L3kTChMsOyt_3moho10D2WX0,5880
|
|
3
|
+
marble_v1/sim.py,sha256=Uj9_ukS8kaSFxe_1aanI3kjrAlUA9-MkZVWNnbm6uSE,18003
|
|
4
|
+
marble_v1/taskset.py,sha256=HGoVbevYiTQ7Qtx348NmHgzKEePLAv3W3ElTRzCyJl8,9630
|
|
5
|
+
marble_v1-0.1.0.dist-info/METADATA,sha256=CUAtSFJzjwG7vUa8yXUv1do_rtXvF6VagUIr-xI4_oc,5894
|
|
6
|
+
marble_v1-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
marble_v1-0.1.0.dist-info/RECORD,,
|