File size: 16,041 Bytes
324ecd2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 | """Beam plays SUPERBEAM: the hosted Beam model picks every action from an ASCII frame.
The game runs locally in the reference CHIP-8 emulator (beamboot/superbeam_gym.py).
A small Modal function proxies chat requests to the HF router; the HF token stays
in the Modal secret `huggingface-secret` and is never printed.
OMP_NUM_THREADS=1 .venv/bin/modal run scripts/beam_play.py # real run
OMP_NUM_THREADS=1 .venv/bin/modal run scripts/beam_play.py --dry-run # scripted stand-in, no HF calls
Budget: <= --max-requests chat requests per attempt (default 80) and
<= --total-cap overall (default 200, i.e. up to 3 attempts). Every HTTP attempt,
including retries, counts toward the cap.
Writes results/beam_play_run.json (every request/response, actions, rewards,
frames) and results/beam_key_schedule.json (best attempt's key schedule, for
replay on the in-weights backend).
"""
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
import modal
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RUN_OUT = os.path.join(ROOT, "results", "beam_play_run.json")
SCHED_OUT = os.path.join(ROOT, "results", "beam_key_schedule.json")
MODEL = "reflection/Beam-1.0-501B-A23B:baseten"
app = modal.App("beamboot-beam-play")
@app.function(image=modal.Image.debian_slim(python_version="3.12"),
secrets=[modal.Secret.from_name("huggingface-secret")], timeout=240)
def beam_chat(messages, tools, model=MODEL, max_tokens=6144):
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
or os.environ.get("HUGGINGFACE_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN"))
if not token:
raise RuntimeError("Hugging Face token was not found under a supported environment "
"variable name in the Modal secret")
payload = {"model": model, "messages": messages, "max_tokens": max_tokens}
if tools:
payload.update(tools=tools, tool_choice="auto")
req = urllib.request.Request("https://router.huggingface.co/v1/chat/completions",
data=json.dumps(payload).encode(),
headers={"Authorization": "Bearer " + token,
"Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=220) as response:
out = json.load(response)
except urllib.error.HTTPError as error:
raise RuntimeError(f"Hugging Face router returned HTTP {error.code}: "
f"{error.read(1000).decode(errors='replace')}") from None
out["_latency_s"] = round(time.time() - t0, 2)
return out
# ----------------------------------------------------------------- prompt
ACTION_NAMES = ["noop", "left", "right", "jump", "right+jump", "left+jump"]
TOOL = {"type": "function", "function": {
"name": "act",
"description": "Press the controller for the next 4 frames.",
"parameters": {"type": "object",
"properties": {"action": {"type": "string", "enum": ACTION_NAMES}},
"required": ["action"], "additionalProperties": False}}}
SYSTEM = """You are playing SUPERBEAM, an original side-scrolling platformer running on a CHIP-8 computer (64x32 pixel screen).
Each turn you get the screen as ASCII art plus a few facts, then you choose ONE action by calling the `act` tool.
Legend: P = you, E = walker enemy, # = ground, H = pipe (solid block), o = coin, F = flag pole, C = castle, . = empty.
Rules:
- Goal: reach the flag pole far to the right. The camera scrolls as you move right.
- Falling into a gap in the ground (columns where the ground rows are '.') kills you.
- Pipes are solid: you cannot walk through them, you can stand on top. A pipe is 8 px tall and can be
jumped onto from the ground. A tall pipe is 16 px tall: climb it from a pipe next to it. You cannot
jump while standing on a tall pipe; walk off it.
- Coins: touching one collects it (+points). Low coins: walk into them. Mid coins: jump from the ground
or walk on a pipe. High coins: only from a pipe top (jump) or by standing on a tall pipe.
- Walkers walk and turn around at gaps and pipes. Touching one from the side kills you; landing on top stomps it.
- One action = 4 frames. Running moves you 8 px per action. A jump rises 10 px, lasts about 9 frames
(~2 actions) and can only start while standing (on ground or a pipe).
- There is NO momentum: you only move sideways on turns where you choose right/left, in the air too.
While airborne, choosing "noop" makes you fall straight down (often into the gap). To clear a gap:
"right+jump" once, then keep choosing "right" every turn until you land (~18 px forward in total).
To get onto a pipe: walk up to it, "right+jump", then keep choosing "right".
- Good timing: jump over a gap on the turn when it is 1 px ahead of you or less (it may already be
"under you" while you still stand: you only fall once your back edge leaves the ground); jumping
earlier lands in a 16 px gap. Jump when a walker is about 4-20 px ahead (landing on it stomps it).
Otherwise keep running right.
Actions: noop, left, right, jump, right+jump, left+jump.
If you cannot call the tool, reply with exactly one action word and nothing else."""
def describe(env, obs):
"""Short factual legend derived from the game's registers/RAM (SuperBeamEnv.describe): your
position and footing, gaps, solid blocks (pipes) and coins on screen, walkers, coins, progress."""
return env.describe(obs)
def view(env):
"""Rows 0..25 of the labelled ASCII frame (rows 26-29 repeat the ground, 30-31 are the coin bar)."""
rows = env.ascii(legend=True).splitlines()
return "\n".join(rows[0:26])
def parse_action(message):
for call in message.get("tool_calls") or []:
try:
if call["function"]["name"] == "act":
a = json.loads(call["function"]["arguments"]).get("action")
if a in ACTION_NAMES:
return a, "tool"
except (KeyError, ValueError, TypeError, AttributeError):
pass
text = (message.get("content") or "").strip().lower()
words = re.findall(r"(?:left|right)\+jump|noop|left|right|jump", text)
if words:
return words[-1], "text"
return None, "invalid"
# ----------------------------------------------------------------- play loop
def play_attempt(env, chat, max_requests, history_turns=2, log=print):
obs, info = env.reset()
obs, _, _, _, info = env.step(0) # one noop so the first frame is drawn
history, history_turns_list, records, used = [], [], [], 0
while used < max_requests:
facts = describe(env, obs)
user = {"role": "user", "content": "Screen (rows 0-25, 64 columns):\n%s\n%s\nChoose your action."
% (view(env), facts)}
messages = [{"role": "system", "content": SYSTEM}] + history + [user]
response, error = None, None
for _try in range(3):
if used >= max_requests:
break
used += 1
try:
response = chat(messages, [TOOL])
break
except Exception as e: # provider/network error: record, maybe retry
error = str(e)[:2000]
log("request %d failed: %s" % (used, error[:300]))
if "HTTP 4" in error and "HTTP 429" not in error:
break
time.sleep(3)
if response is None:
records.append({"request": used, "messages": messages, "error": error})
if error and "HTTP 4" in error and "HTTP 429" not in error:
return records, used, "provider_error: " + error[:500]
continue
msg = response["choices"][0]["message"]
action, how = parse_action(msg)
a = ACTION_NAMES.index(action) if action else 0
frame_before = view(env)
obs, reward, term, trunc, info = env.step(a)
rec = {"request": used, "messages": messages, "response": response,
"action": action or "noop", "parsed_from": how, "reward": reward,
"frame": frame_before, "progress": info["best_progress"],
"frames": info["frames"], "terminated": term, "truncated": trunc}
records.append(rec)
log("req %2d %-10s (%s) progress %5.1f%% frames %3d reward %+6.2f%s"
% (used, action or "noop", how, 100 * info["best_progress"], info["frames"], reward,
" WIN" if info["won"] else (" DIED" if info["dead"] else "")))
# rolling history: assistant reply (with reasoning_content if returned) + result
assistant = {k: msg[k] for k in ("role", "content", "reasoning_content", "tool_calls") if k in msg}
assistant.setdefault("role", "assistant")
result = "done; progress now %.0f%%" % (100 * info["best_progress"])
# older turns keep only the facts line (no ASCII) to keep requests short
turn = [{"role": "user", "content": "(earlier turn) " + facts}, assistant]
calls = msg.get("tool_calls") or []
for call in calls:
turn.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": result})
if not calls:
turn[1] = {"role": "assistant", "content": action or "noop"}
if msg.get("reasoning_content"): # provider rejects assistant turns without their reasoning
turn[1]["reasoning_content"] = msg["reasoning_content"]
history_turns_list = (history_turns_list + [turn])[-history_turns:] if history_turns else []
history = [m for t in history_turns_list for m in t]
if term or trunc:
break
status = "won" if env.won else ("died" if env.dead else
("truncated" if env.frames >= env.max_frames else "request_cap"))
return records, used, status
def run(chat, max_requests=80, total_cap=200, frame_skip=4, max_attempts=3, model=MODEL, log=print,
save=True, backend="emulator", level="classic"):
global RUN_OUT, SCHED_OUT
level = int(level) if str(level).isdigit() else level
tag = "" if level == "classic" else "_level%s" % level
if backend != "emulator": # live run against the in-weights computer: separate files
RUN_OUT = os.path.join(ROOT, "results", "beam_live_weights%s_run.json" % tag)
SCHED_OUT = os.path.join(ROOT, "results", "beam_live_weights%s_schedule.json" % tag)
elif tag: # held-out / generated level evaluation
RUN_OUT = os.path.join(ROOT, "results", "levels", "beam%s_run.json" % tag)
SCHED_OUT = os.path.join(ROOT, "results", "levels", "beam%s_schedule.json" % tag)
sys.path.insert(0, ROOT)
from beamboot.superbeam_gym import SuperBeamEnv, WIN_X, START_X, IPF
est = -(-(WIN_X - START_X) // (2 * frame_skip))
log("estimate: level needs >= %d frames = >= %d actions at frame_skip=%d (cap %d requests/attempt, %d total)"
% (-(-(WIN_X - START_X) // 2), est, frame_skip, max_requests, total_cap))
if est > max_requests:
raise SystemExit("frame_skip too small for the request cap")
attempts, total = [], 0
for k in range(max_attempts):
budget = min(max_requests, total_cap - total)
if budget <= 0:
break
env = SuperBeamEnv(backend=backend, frame_skip=frame_skip, max_frames=600, level=level,
device="cuda" if backend != "emulator" else "cpu")
log("=== attempt %d (budget %d requests, backend %s) ===" % (k + 1, budget, backend))
t0 = time.time()
records, used, status = play_attempt(env, chat, budget, log=log)
total += used
attempts.append({"attempt": k + 1, "status": status, "requests_used": used,
"won": env.won, "dead": env.dead, "progress": env._info()["best_progress"],
"frames": env.frames, "actions": [ACTION_NAMES[a] for a in env.actions],
"key_schedule": env.key_schedule(), "requests": records,
"backend": backend, "wall_seconds": round(time.time() - t0, 1)})
if backend != "emulator":
st = env.backend.session.stats
attempts[-1]["weights"] = {
"frame_rows": env.backend.session.frame_rows,
"per_frame": [{"f": x.frame, "tokens": x.frame_tokens, "accepted_all": x.accepted_all,
"first_reject": x.first_reject, "s": round(x.seconds, 3)} for x in st],
"frames_all_draft_accepted": sum(x.accepted_all for x in st),
"compute_seconds": round(sum(x.seconds for x in st), 1)}
log("attempt %d: %s, progress %.1f%%, %d requests" % (k + 1, status,
100 * attempts[-1]["progress"], used))
if env.won or status.startswith("provider_error"):
break
best = max(attempts, key=lambda a: (a["won"], a["progress"]))
# verify the best schedule replays to the same outcome
venv = SuperBeamEnv(frame_skip=1, max_frames=600, level=level)
vinfo = venv.replay_schedule(best["key_schedule"], n_frames=best["frames"])
out = {"model": model, "level": level, "frame_skip": frame_skip, "ipf": IPF,
"max_requests_per_attempt": max_requests,
"total_cap": total_cap, "total_requests": total, "best_attempt": best["attempt"],
"best": {k: best[k] for k in ("status", "won", "progress", "frames", "requests_used")},
"replay_check": vinfo, "attempts": attempts}
log("best: attempt %d %s progress %.1f%% | total requests %d | replay %s"
% (best["attempt"], best["status"], 100 * best["progress"], total, vinfo))
if not save:
return out
os.makedirs(os.path.dirname(RUN_OUT), exist_ok=True)
with open(RUN_OUT, "w") as f:
json.dump(out, f, indent=1, default=str)
with open(SCHED_OUT, "w") as f:
json.dump({"rom": "roms/superbeam.ch8", "ipf": IPF, "frame_skip": frame_skip,
"frames": best["frames"], "won": best["won"], "progress": best["progress"],
"format": "[[frame, key, 'down'|'up'], ...] applied before that frame's instructions",
"events": best["key_schedule"]}, f, indent=1)
log("wrote %s and %s" % (os.path.relpath(RUN_OUT, ROOT), os.path.relpath(SCHED_OUT, ROOT)))
return out
def _dry_chat(messages, tools):
"""Offline stand-in: reads the facts line like the prompt suggests (no HF call)."""
text = messages[-1]["content"]
m = re.search(r"standing on (ground|a pipe)", text)
terrain, walkers = text.split("Walkers:")[0], text.split("Walkers:")[-1].split("Coins collected")[0]
near = [int(v) for v in re.findall(r"\((-?\d+) px ahead\)", terrain)]
foes = [int(v) for v in re.findall(r"\((-?\d+) px ahead\)", walkers)]
act = "right"
if m and (any(d <= 1 for d in near) or "under you" in terrain.split("Solid")[0]
or any(4 <= d <= 20 for d in foes)):
act = "right+jump"
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
{"id": "dry", "type": "function", "function": {"name": "act",
"arguments": json.dumps({"action": act})}}]}}]}
@app.local_entrypoint()
def main(dry_run: bool = False, max_requests: int = 80, total_cap: int = 200,
frame_skip: int = 4, model: str = MODEL, backend: str = "emulator", max_attempts: int = 3,
level: str = "classic"):
if dry_run:
run(_dry_chat, max_requests, total_cap, frame_skip, max_attempts, model="dry-run", save=False,
backend=backend, level=level)
return
run(lambda msgs, tools: beam_chat.remote(msgs, tools, model), max_requests, total_cap,
frame_skip, max_attempts, model=model, backend=backend, level=level)
|