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)