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 ADDED
@@ -0,0 +1,3 @@
1
+ from marble_v1.taskset import MarbleTaskset
2
+
3
+ __all__ = ["MarbleTaskset"]
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any