File size: 12,752 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
"""Weight-space programming with Beam: Beam-1.0-501B writes the weights of a small neural network
that plays SUPERBEAM, with no training (no data, no loss, no gradients), in the spirit of
https://rikiyat.github.io/blog/weight-space-programming/ .

The network: 8 features -> 16 ReLU -> 6 action logits, action = argmax. Features are read from the
game's registers/RAM each turn (see `features`). Beam gets the game rules, the feature definitions and
the network shape, and replies with the weights as JSON. Each candidate is scored on TRAINING levels
(seeds 0..TRAIN_LEVELS-1); Beam gets per-level results as feedback and may revise. The candidate with
the best training score is then evaluated once on the 20 held-out TEST levels used by the leaderboard
(seeds 1000000..1000019, difficulty 0.5, one attempt per level).

  modal run scripts/beam_weights.py [--rounds 4]
  -> results/beam_weights.json (all rounds: Beam's reply, weights, scores) and the test result.

The Hugging Face token stays in the Modal secret `huggingface-secret` and is never printed.
"""
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request

import modal
import numpy as np

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
OUT = os.path.join(ROOT, "results", "beam_weights.json")
MODEL = "reflection/Beam-1.0-501B-A23B:baseten"
TRAIN_LEVELS = 10
TEST_SEEDS = list(range(1000000, 1000020))
N_IN, N_HID, N_OUT = 8, 16, 6
ACTIONS = ["noop", "left", "right", "jump", "right+jump", "left+jump"]
FEATURES = [
    ("standing", "1 if you stand on ground or on a pipe this frame, else 0 (in the air)"),
    ("height", "how high your feet are: 0 on ground level, 8 on a pipe top, 16 on a tall pipe top (px)"),
    ("gap_dist", "px from your front (right) edge to the start of the next gap in the ground ahead; 0 if the gap is already under your front edge; 32 if no gap within 32 px"),
    ("gap_width", "width of that gap in px (8 or 16); 0 if none"),
    ("wall_dist", "px from your front edge to the next block whose top is higher than your feet (a pipe seen from the ground, or a tall pipe seen from a pipe); 32 if none within 32 px"),
    ("wall_height", "how many px higher that block's top is than your feet (8 or 16); 0 if none"),
    ("walker_dist", "px from your front edge to the nearest walker ahead of you (0 if touching); 32 if none within 32 px"),
    ("vy", "vertical speed in px per frame: negative while rising, positive while falling, 0 when standing"),
]

app = modal.App("beamboot-beam-weights")


@app.function(image=modal.Image.debian_slim(python_version="3.12").pip_install("numpy"),
              secrets=[modal.Secret.from_name("huggingface-secret")], timeout=600)
def beam_chat(messages, model=MODEL, max_tokens=32000):
    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 not found in the Modal secret")
    payload = {"model": model, "messages": messages, "max_tokens": max_tokens}
    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=580) as response:
            out = json.load(response)
    except urllib.error.HTTPError as error:
        raise RuntimeError("HF router HTTP %d: %s" % (error.code, error.read(800).decode(errors="replace"))) from None
    out["_latency_s"] = round(time.time() - t0, 1)
    return out


# ------------------------------------------------------------------ the game side (local, CPU)
def features(env):
    """The 8 features, read from the emulator's registers and RAM."""
    regs = env.backend.regs()
    x, y, cam = regs[0xA], regs[0xB], regs[0xD]
    wx = cam * 8 + x
    front = wx + 8
    standing = 1.0 if regs[0xE] == y and y in (18, 10, 2) else 0.0
    feet = y + 6                                         # first pixel row below the player
    height = float(max(0, 24 - feet))
    lv = env.live_level()
    ncol = len(lv)

    def top(c):                                          # top pixel row of column c (None = gap)
        if c >= ncol:
            return 24
        t = lv[c] & 3
        return None if t == 0 else {1: 24, 2: 16, 3: 8}[t]

    gap_dist, gap_width = 32.0, 0.0
    wall_dist, wall_height = 32.0, 0.0
    c0 = max(0, wx // 8)
    for c in range(c0, min(ncol, c0 + 7)):
        left = c * 8
        if left + 8 <= front - 8:                        # column entirely behind the player
            continue
        d = max(0, left - front)
        if d > 32:
            break
        t = top(c)
        if t is None and gap_dist == 32.0:
            w = 0
            while c + w < ncol and top(c + w) is None:
                w += 1
            gap_dist, gap_width = float(d), float(8 * w)
        if t is not None and t < feet and wall_dist == 32.0 and left >= front - 1:
            wall_dist, wall_height = float(d), float(feet - t)
    walker_dist = 32.0
    et = env.sym["ETAB"]
    for e in range(2):
        alive, col, off = (env.backend.ram(et + 4 * e + i) for i in range(3))
        if not alive:
            continue
        d = col * 8 + off - front
        if -8 <= d <= 32:
            walker_dist = min(walker_dist, float(max(0, d)))
    vy = 0.0 if standing else float(regs[0xC] - 8)
    return np.array([standing, height, gap_dist, gap_width, wall_dist, wall_height, walker_dist, vy])


def act(net, f):
    h = np.maximum(0.0, net["W1"] @ f + net["b1"])
    return int(np.argmax(net["W2"] @ h + net["b2"]))


def play(net, seed, max_frames=600):
    from beamboot.superbeam_gym import SuperBeamEnv
    env = SuperBeamEnv(level=seed, difficulty=0.5, frame_skip=4, max_frames=max_frames)
    env.reset()
    term = trunc = False
    info, last_f = {}, None
    while not (term or trunc):
        last_f = features(env)
        _, _, term, trunc, info = env.step(act(net, last_f))
    cause = None
    if info.get("dead"):
        cause = ("fell into a gap" if last_f[2] <= 8 else "hit a walker" if last_f[6] <= 8 else "died")
    elif not info.get("won"):
        cause = "ran out of time (stuck: e.g. walking into a pipe wall, or standing still)"
    return {"seed": seed, "won": bool(info.get("won")), "progress": round(float(info.get("best_progress", 0)), 3),
            "coins": int(info.get("coins", 0)), "cause": cause,
            "where": None if last_f is None else dict(zip([n for n, _ in FEATURES], [round(float(v), 1) for v in last_f]))}


def score(net, seeds):
    res = [play(net, s) for s in seeds]
    return {"won": sum(r["won"] for r in res), "levels": len(res),
            "mean_progress": round(float(np.mean([r["progress"] for r in res])), 3), "detail": res}


def parse_net(text):
    blocks = re.findall(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S) or re.findall(r"(\{\s*\"W1\".*\})", text, re.S)
    if not blocks:
        raise ValueError("no JSON object with W1/b1/W2/b2 found")
    d = json.loads(blocks[-1])
    net = {k: np.array(d[k], dtype=float) for k in ("W1", "b1", "W2", "b2")}
    shapes = {"W1": (N_HID, N_IN), "b1": (N_HID,), "W2": (N_OUT, N_HID), "b2": (N_OUT,)}
    for k, s in shapes.items():
        if net[k].shape != s:
            raise ValueError("%s has shape %s, expected %s" % (k, net[k].shape, s))
    return net


SYSTEM = """You are a weight-space programmer. You write the weights of a small neural network directly, by reasoning about what each unit should compute. There is no training, no data and no gradient descent: the numbers you write are the final network."""


def task_prompt():
    sys.path.insert(0, os.path.join(ROOT, "scripts"))
    from beam_play import SYSTEM as RULES                 # the same game rules Beam is given when it plays
    feats = "\n".join("  x[%d] %s: %s" % (i, n, d) for i, (n, d) in enumerate(FEATURES))
    acts = "\n".join("  %d %s" % (i, a) for i, a in enumerate(ACTIONS))
    rules = RULES.split("Actions:")[0].strip()
    return f"""Write the weights of a neural network that plays SUPERBEAM well.

THE GAME (these are the rules a player is given):
{rules}

THE NETWORK. Every 4 frames it reads 8 numbers x[0..7] and picks one action:
  h = relu(W1 @ x + b1)        # W1: 16 x 8, b1: 16
  logits = W2 @ h + b2         # W2: 6 x 16, b2: 6
  action = argmax(logits)
Inputs (read from the game's memory; distances in pixels):
{feats}
Actions (index: name):
{acts}

Design it like a program: give each hidden unit a job (for example "a gap is close", "a wall is close
and I stand on the ground", "a walker is close", "I am in the air"), set its weights and bias so it
switches on only in that situation (ReLU), then wire the units to the action logits so that the right
action wins. Keep "right" as the default so the player keeps moving. Unused units can be all zeros.

Reply with your reasoning, then ONE JSON object in a ```json block with keys "W1" (16 lists of 8 numbers),
"b1" (16 numbers), "W2" (6 lists of 16 numbers), "b2" (6 numbers). Numbers only, no expressions."""


def feedback(sc):
    lines = ["Your network on %d training levels: won %d, mean progress %.0f%%." % (sc["levels"], sc["won"], 100 * sc["mean_progress"])]
    for r in sc["detail"]:
        if r["won"]:
            lines.append("  level %d: won" % r["seed"])
        else:
            lines.append("  level %d: %s at %.0f%% of the level; inputs at that moment: %s"
                         % (r["seed"], r["cause"], 100 * r["progress"], json.dumps(r["where"])))
    lines.append("Improve the weights to win more levels. Reply with reasoning, then the full JSON (all four arrays).")
    return "\n".join(lines)


@app.local_entrypoint()
def main(rounds: int = 4):
    train = list(range(TRAIN_LEVELS))
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task_prompt()}]
    log, best = [], None
    for r in range(rounds):
        out = beam_chat.remote(messages)
        msg = out["choices"][0]["message"]
        text = msg.get("content") or ""
        reasoning = msg.get("reasoning_content") or msg.get("reasoning") or ""
        finish = out["choices"][0].get("finish_reason")
        entry = {"round": r + 1, "latency_s": out.get("_latency_s"), "finish_reason": finish,
                 "usage": out.get("usage"), "reply": text, "reasoning_chars": len(reasoning)}
        print("round %d: finish=%s content=%d chars reasoning=%d chars" % (r + 1, finish, len(text), len(reasoning)), flush=True)
        try:
            try:
                net = parse_net(text)
            except ValueError:                           # JSON sometimes lands only in the reasoning
                net = parse_net(reasoning)
                entry["parsed_from"] = "reasoning_content"
            sc = score(net, train)
            entry.update(weights={k: v.tolist() for k, v in net.items()}, train=sc)
            print("round %d: train won %d/%d, mean progress %.0f%% (%.0fs)" % (r + 1, sc["won"], sc["levels"], 100 * sc["mean_progress"], out.get("_latency_s", 0)), flush=True)
            if best is None or (sc["won"], sc["mean_progress"]) > (best[1]["won"], best[1]["mean_progress"]):
                best = (net, sc, r + 1)
            follow = feedback(sc)
        except Exception as e:                           # unparseable reply: ask again
            entry["error"] = str(e)
            print("round %d: %s" % (r + 1, e), flush=True)
            follow = "I could not use that: %s. Reply with the full JSON object in a ```json block." % e
        log.append(entry)
        json.dump({"model": MODEL, "rounds": log}, open(OUT, "w"), indent=1)
        if best and best[1]["won"] == best[1]["levels"]:
            break
        turn = {"role": "assistant", "content": text}
        if reasoning:
            turn["reasoning_content"] = reasoning
        messages += [turn, {"role": "user", "content": follow}]
    result = {"model": MODEL, "network": "8 -> 16 ReLU -> 6, weights written by Beam, no training",
              "rounds": log}
    if best:
        test = score(best[0], TEST_SEEDS)
        result.update(best_round=best[2], best_train=best[1], test=test,
                      best_weights={k: v.tolist() for k, v in best[0].items()})
        print("TEST (20 held-out levels): won %d/20, mean progress %.0f%% (round %d weights)"
              % (test["won"], 100 * test["mean_progress"], best[2]), flush=True)
    json.dump(result, open(OUT, "w"), indent=1)
    print("wrote", OUT)