LeoMaxwell commited on
Commit
4764138
·
verified ·
1 Parent(s): 01bed92

Add inference code and MineExplorer patches

Browse files
README.md CHANGED
@@ -102,3 +102,68 @@ Perception prompts (thinking off, temperature 0, 320x180 frame):
102
  ## Prompt (B)
103
 
104
  `Task: find {target}. Reply with the behavior code for the next second.` The curiosity variant appends ` Seen directions (0, 45, ..., 315 deg right of view): {8 bits}. Visited cells 4 blocks along them: {8 bits}.`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  ## Prompt (B)
103
 
104
  `Task: find {target}. Reply with the behavior code for the next second.` The curiosity variant appends ` Seen directions (0, 45, ..., 315 deg right of view): {8 bits}. Visited cells 4 blocks along them: {8 bits}.`
105
+
106
+ ## 推理代码 / Inference code
107
+
108
+ `code/` 是评测时实际用的脚本,原样上传,保留原来的相对目录(脚本之间按相对路径 import)。`patches/` 是对 MineExplorer 的两个补丁。
109
+
110
+ - `code/train/ctrl_policy.py`:A 和 B 的策略。`Policy` 是 A(好奇心版按 `w0` 的行数自动加记忆输入;`box_joint_lora` 也用它),`BTPolicy` 是 B,`make_policy` 按 `kind` 选择。
111
+ - `code/train/hid_policy.py`、`code/train/hid_server.py`:`hidden_frozen`、`hidden_joint_lora` 的策略和隐状态特征服务(HTTP,`--adapter` 加载 LoRA)。`hid_server.py` 的模型加载函数来自 `code/train/bt_train.py`。
112
+ - `code/bench/diag_perception*.py`:感知问题原文、框的解析和视场常量,`ctrl_policy.py` 从这里 import;`code/train/grpo_locations.json` 在 import 时读取。
113
+ - `code/bench/splits_find25.json`:find25 的 25 个任务编号。
114
+ - `patches/MineExplorer.patch`:MineExplorer(commit `cca4c5e`)的 `eval_benchmark.py`,加了 `MC_POLICY` 入口(用上面的策略代替 LLM agent)、`--tasks`、多个 sandbox 轮流分配(`MC_SANDBOX_URLS`);`env/minerl_sandbox.py` 的 reset 超时可配(`MC_RESET_TIMEOUT`)。
115
+ - `patches/sandbox_mc_server.patch`:sandbox 镜像里的 `minestudio/mc_server.py`,step 返回 info(策略要读 `player_pos`),每次 reset 重新设 world seed。
116
+
117
+ `code/` holds the scripts the evaluations ran, uploaded unchanged in their original relative layout (they import each other by relative path); `patches/` holds two patches to MineExplorer.
118
+
119
+ - `code/train/ctrl_policy.py`: the policies of A and B. `Policy` is A (the curiosity variant turns on the memory inputs from the row count of `w0`; `box_joint_lora` uses it too), `BTPolicy` is B, and `make_policy` picks one by `kind`.
120
+ - `code/train/hid_policy.py`, `code/train/hid_server.py`: the policy of `hidden_frozen` / `hidden_joint_lora` and the HTTP hidden-state server (`--adapter` loads a LoRA). `hid_server.py` takes its model loader from `code/train/bt_train.py`.
121
+ - `code/bench/diag_perception*.py`: the perception prompts, the box parser and the field-of-view constants, imported by `ctrl_policy.py`; `code/train/grpo_locations.json` is read at import time.
122
+ - `code/bench/splits_find25.json`: the 25 task ids of find25.
123
+ - `patches/MineExplorer.patch`: MineExplorer (commit `cca4c5e`) `eval_benchmark.py` gains the `MC_POLICY` hook (runs the policies above instead of the LLM agent), `--tasks` and round-robin over several sandboxes (`MC_SANDBOX_URLS`); `env/minerl_sandbox.py` gets a configurable reset timeout (`MC_RESET_TIMEOUT`).
124
+ - `patches/sandbox_mc_server.patch`: `minestudio/mc_server.py` in the sandbox image returns the step info (the policies read `player_pos`) and re-applies the world seed on every reset.
125
+
126
+ Setup:
127
+
128
+ ```bash
129
+ cd code
130
+ git clone https://github.com/meituan-longcat/MineExplorer.git
131
+ git -C MineExplorer checkout cca4c5e && git -C MineExplorer apply ../../patches/MineExplorer.patch
132
+ mkdir -p models && ln -s ../../Qwen3.5-9B models/Qwen3.5-9B
133
+ ```
134
+
135
+ Start the sandbox from MineExplorer's Docker image (`davidzhth/mineexplorer:0.0.1`, see its README) with `patches/sandbox_mc_server.patch` applied to `/home/sandbox/mc/lumine-client/minestudio/mc_server.py`. Versions used: vLLM 0.27.1; torch 2.13, transformers 5.15, peft 0.21 for `hid_server.py`; Python 3.11 with MineExplorer's requirements plus numpy, openai, requests for the eval.
136
+
137
+ Perception server:
138
+
139
+ ```bash
140
+ # A: base Qwen3.5-9B
141
+ vllm serve models/Qwen3.5-9B --served-model-name perception --dtype bfloat16 --max-model-len 16384 \
142
+ --limit-mm-per-prompt '{"image": 24}' --port 9060
143
+ # B or box_joint_lora: the LoRA under the name the policy asks for (box: ../ablations/box_joint_lora/grpo_r25)
144
+ vllm serve models/Qwen3.5-9B --served-model-name perception-base --dtype bfloat16 --max-model-len 16384 \
145
+ --limit-mm-per-prompt '{"image": 24}' --port 9060 --enable-lora --max-lora-rank 16 \
146
+ --lora-modules perception=../B_behavior_token/grpo_r25
147
+ # hidden_frozen (no adapter) and hidden_joint_lora
148
+ CUDA_VISIBLE_DEVICES=0 python train/hid_server.py --port 9210 --adapter ../ablations/hidden_joint_lora/grpo_r25
149
+ ```
150
+
151
+ Eval (find25, 1000 steps; our tables pool seeds 0, 10000, 20000; keep `--num-workers` at 2 or more, since 1 takes a serial path that ignores `MC_SANDBOX_URLS`):
152
+
153
+ ```bash
154
+ cd MineExplorer
155
+ MC_SANDBOX_URLS=http://localhost:8031,http://localhost:8032 MC_RESET_TIMEOUT=1200 \
156
+ AGENT_API_KEY=unused AGENT_API_BASE=unused MC_POLICY='<json below>' \
157
+ python eval_benchmark.py --model ctrl --benchmark-dir benchmark --output-dir out_s0 \
158
+ --tasks $(python -c "import json; print(','.join(json.load(open('../bench/splits_find25.json'))['eval']))") \
159
+ --num-workers 13 --max-steps 1000
160
+ ```
161
+
162
+ | Model | Server | `MC_POLICY` |
163
+ |---|---|---|
164
+ | A (any `A_cerebellum/*/ctrl.npz`) | vLLM, base | `{"npz": "/abs/ctrl.npz", "perception": "vlm", "url": "http://localhost:9060/v1", "model": "perception", "seed": 0}` |
165
+ | `box_joint_lora` | vLLM + its LoRA | as A, with its `ctrl.npz` |
166
+ | B | vLLM + B's LoRA | `{"kind": "bt", "table": "/abs/table.npz", "url": "http://localhost:9060/v1", "model": "perception", "seed": 0}`, plus `"memory": true` for `grpo_r25_curiosity` |
167
+ | `hidden_frozen`, `hidden_joint_lora` | `hid_server.py` (adapter only for `hidden_joint_lora`) | `{"kind": "hid", "npz": "/abs/ctrl.npz", "url": "http://localhost:9210", "seed": 0}` |
168
+
169
+ Each episode writes `steps.jsonl` with the policy's per-step log (inputs, sampled bins, perception replies).
code/bench/diag_perception.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnosis B (2026-10-13): can the base Qwen3.5-9B (no LoRA) recognize the target in our 320x180 frames?
2
+
3
+ Frames: GRPO v2 rollouts, rounds 1-5 (round 6 was cut off mid-rollout when training was paused), frames/NNNN.png =
4
+ the exact PNG bytes the policy saw. frames/i.png shows the pose after step i (steps.jsonl `pos`); frame 0 the
5
+ teleport location (train/grpo_locations.json) with yaw 0, pitch 0, which is also the origin of the spawn-relative
6
+ milestone targets. Angles follow benchmark_gen/utils.py (target yaw = atan2(-dx, dz)); a camera yaw delta adds to
7
+ pos.yaw (checked on round 1: 6346 of 6346 turns), so a target at a larger yaw is to the right. Pitch > 0 looks down.
8
+ - positives: target 3-12 blocks away, <= 20 deg left/right of the view center, <= 30 deg above/below it (the
9
+ vertical half field of view is 35: Minecraft fov 70, options.txt fov:0.0);
10
+ - behind negatives: same distance band, >= 120 deg off center;
11
+ - wrong-name negatives: the positive frames, asked about a target name of another scene that is neither a target of
12
+ this scene nor a block its build commands place.
13
+ At most 2 frames per episode and target, >= 20 frames apart, then round-robin over scenes.
14
+ Question "Is there a <name> in this image? Answer yes or no.", thinking off, temperature 0.
15
+ Writes items.jsonl, summary.md and contact_sheet.png (48 positives, red circle = projected target point).
16
+ Usage: diag_perception.py --url http://localhost:9041/v1 --model NAME [--n 200] [--out DIR]
17
+ """
18
+ import argparse
19
+ import base64
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ import json
22
+ import math
23
+ from pathlib import Path
24
+ import random
25
+
26
+ from openai import OpenAI
27
+ from PIL import Image, ImageDraw
28
+
29
+ ROOT = Path(__file__).resolve().parents[1]
30
+ GRPO = ROOT / "runs/grpo_v2_20261013"
31
+ ROUNDS = [f"round{r:03d}" for r in range(1, 6)]
32
+ BENCH = ROOT / "MineExplorer/benchmark"
33
+ LOCATIONS = json.loads((ROOT / "train/grpo_locations.json").read_text())
34
+ EYE = 1.62 # eye height above player_pos (the feet)
35
+ W, H, HALF_VFOV = 320, 180, 35.0
36
+
37
+
38
+ def episodes():
39
+ """Finished rollout episodes: (episode dir, scene, steps, poses, targets); poses[i] is the pose of frames/i.png,
40
+ targets are (milestone id, task, absolute target)."""
41
+ for result in sorted(p for r in ROUNDS for p in (GRPO / r).glob("*/result.json")):
42
+ ep = result.parent
43
+ scene = ep.name.split("_")[0]
44
+ x, y, z = LOCATIONS[scene]
45
+ steps = [json.loads(line) for line in (ep / "steps.jsonl").read_text().splitlines()]
46
+ poses = [{"x": x, "y": y, "z": z, "yaw": 0.0, "pitch": 0.0}] + [s["pos"] for s in steps]
47
+ meta = json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())
48
+ targets = [(m["milestone_id"], m["task"], [t + o for t, o in zip(m["rules"][0]["params"]["target"], (x, y, z))])
49
+ for m in meta["milestones"]["milestones"]]
50
+ yield ep, scene, steps, poses, targets
51
+
52
+
53
+ def view(pose, target):
54
+ """(distance, horizontal, vertical): distance from the feet as the milestone check; horizontal = target yaw minus
55
+ view yaw in [-180, 180), > 0 = right; vertical = target elevation from the eye above the view center."""
56
+ dx, dy, dz = target[0] - pose["x"], target[1] - pose["y"] - EYE, target[2] - pose["z"]
57
+ h = (math.degrees(math.atan2(-dx, dz)) - pose["yaw"] + 180) % 360 - 180
58
+ v = math.degrees(math.atan2(dy, math.hypot(dx, dz))) + pose["pitch"]
59
+ return math.dist((pose["x"], pose["y"], pose["z"]), target), h, v
60
+
61
+
62
+ def spread(candidates, n, rng):
63
+ """At most 2 per (episode, target), >= 20 frames apart, then round-robin over scenes up to n."""
64
+ rng.shuffle(candidates)
65
+ groups = {}
66
+ for c in candidates:
67
+ g = groups.setdefault((c["ep"], c["mid"]), [])
68
+ if len(g) < 2 and all(abs(c["frame"] - o["frame"]) >= 20 for o in g):
69
+ g.append(c)
70
+ by_scene = {}
71
+ for c in (c for g in groups.values() for c in g):
72
+ by_scene.setdefault(c["scene"], []).append(c)
73
+ queues = [by_scene[s] for s in sorted(by_scene)]
74
+ out = []
75
+ while len(out) < n and any(queues):
76
+ out += [q.pop() for q in queues if q][:n - len(out)]
77
+ return out
78
+
79
+
80
+ def select(n):
81
+ """(positives, behind negatives, wrong-name negatives), n of each where available."""
82
+ rng = random.Random(0)
83
+ pos, behind, names, placed = [], [], {}, {}
84
+ for ep, scene, _, poses, targets in episodes():
85
+ placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
86
+ for mid, task, target in targets:
87
+ if not task.startswith("find "): # a handful of "interact with X" / "open door" goals
88
+ continue
89
+ name = task[5:].removeprefix("a ").replace("_", " ")
90
+ names.setdefault(scene, set()).add(name)
91
+ for i, pose in enumerate(poses):
92
+ dist, h, v = view(pose, target)
93
+ item = {"ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": i, "name": name,
94
+ "asked": name, "dist": round(dist, 1), "h": round(h, 1), "v": round(v, 1)}
95
+ if 3 <= dist <= 12 and abs(h) <= 20 and abs(v) <= 30:
96
+ pos.append(item)
97
+ elif 3 <= dist <= 12 and abs(h) >= 120:
98
+ behind.append(item)
99
+ pos, behind = spread(pos, n, rng), spread(behind, n, rng)
100
+ every = sorted(set().union(*names.values()))
101
+ wrong = [{**p, "asked": rng.choice([x for x in every if x not in names[p["scene"]]
102
+ and x.replace(" ", "_") not in placed[p["scene"]]])} for p in pos]
103
+ return pos, behind, wrong
104
+
105
+
106
+ def main():
107
+ ap = argparse.ArgumentParser()
108
+ ap.add_argument("--url", required=True)
109
+ ap.add_argument("--model", required=True)
110
+ ap.add_argument("--n", type=int, default=200)
111
+ ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception")
112
+ args = ap.parse_args()
113
+ args.out.mkdir(parents=True, exist_ok=True)
114
+ pos, behind, wrong = select(args.n)
115
+ print(f"positives {len(pos)} from {len({p['scene'] for p in pos})} scenes, behind {len(behind)} from "
116
+ f"{len({p['scene'] for p in behind})} scenes, wrong-name {len(wrong)}", flush=True)
117
+
118
+ client = OpenAI(api_key="EMPTY", base_url=args.url)
119
+
120
+ def ask(kind_item):
121
+ kind, item = kind_item
122
+ png = (ROOT / item["ep"] / "frames" / f"{item['frame']:04d}.png").read_bytes()
123
+ r = client.chat.completions.create(
124
+ model=args.model, temperature=0, max_tokens=5,
125
+ messages=[{"role": "user", "content": [
126
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}},
127
+ {"type": "text", "text": f"Is there a {item['asked']} in this image? Answer yes or no."}]}],
128
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}})
129
+ answer = r.choices[0].message.content
130
+ return {"kind": kind, **item, "answer": answer, "yes": answer.strip().lower().startswith("yes")}
131
+
132
+ with ThreadPoolExecutor(16) as pool:
133
+ items = list(pool.map(ask, [("pos", p) for p in pos] + [("behind", b) for b in behind]
134
+ + [("wrong", w) for w in wrong]))
135
+ with (args.out / "items.jsonl").open("w") as f:
136
+ f.writelines(json.dumps(item, ensure_ascii=False) + "\n" for item in items)
137
+
138
+ rate = lambda xs: f"{100 * sum(x['yes'] for x in xs) / len(xs):.1f}%" if xs else "-"
139
+ by_kind = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
140
+ lines = ["| 类型 | 数量 | 答 yes 的比例 |", "|---|---:|---:|",
141
+ f"| 正例(目标在视野中央附近) | {len(by_kind['pos'])} | {rate(by_kind['pos'])} |",
142
+ f"| 背后负例(目标在身后) | {len(by_kind['behind'])} | {rate(by_kind['behind'])} |",
143
+ f"| 错名负例(同一批正例帧,问别的场景的目标名) | {len(by_kind['wrong'])} | {rate(by_kind['wrong'])} |"]
144
+ per = sorted({x["asked"] for x in items}, key=lambda n: -sum(x["asked"] == n for x in by_kind["pos"]))[:20]
145
+ lines += ["", "逐目标(正例最多的 20 个;错名一列是把这个名字问在不含它的帧上):", "",
146
+ "| 目标 | 正例 n | 正例 yes | 背后 n | 背后 yes | 错名 n | 错名 yes |", "|---|---:|---:|---:|---:|---:|---:|"]
147
+ for n in per:
148
+ cells = [[x for x in by_kind[k] if x["asked"] == n] for k in ("pos", "behind", "wrong")]
149
+ lines.append(f"| {n} | " + " | ".join(f"{len(c)} | {rate(c)}" for c in cells) + " |")
150
+ (args.out / "summary.md").write_text(
151
+ "帧来自 GRPO v2 第 1 到 5 轮 rollout(策略实际看到的 320×180 PNG)。正例:目标距离 3 到 12 格,偏离视野中心左右 ≤20°、"
152
+ "上下 ≤30°(竖直半视场 35°);背后负例:同距离段,偏离 ≥120°;错名负例:同一批正例帧,问另一个场景的目标名"
153
+ "(本场景的目标和建造命令里都没有这种方块,但个别名字如南瓜、甘蔗可能在自然地形里出现)。每个回合每个目标最多 2 帧、"
154
+ f"相隔 ≥20 帧,按场景轮流抽:正例来自 {len({p['scene'] for p in pos})} 个场景,背后负例来自 "
155
+ f"{len({p['scene'] for p in behind})} 个场景。只按几何选帧,不判断遮挡;"
156
+ "接触表 contact_sheet.png 是前 48 个正例(红圈为目标点按视角投影的位置,标注为目标名、回答、距离、回合/帧号),"
157
+ "全部逐条结果见 items.jsonl。\n\n" + "\n".join(lines) + "\n")
158
+
159
+ sheet = Image.new("RGB", (6 * W, 8 * (H + 14)), "white")
160
+ for k, x in enumerate(by_kind["pos"][:48]):
161
+ img = Image.open(ROOT / x["ep"] / "frames" / f"{x['frame']:04d}.png").convert("RGB")
162
+ draw = ImageDraw.Draw(img)
163
+ # approximate pinhole projection from the two angles (exact at pitch 0); 16:9, vertical fov 70
164
+ u = W / 2 + W / 2 * math.tan(math.radians(x["h"])) / (math.tan(math.radians(HALF_VFOV)) * W / H)
165
+ v = H / 2 - H / 2 * math.tan(math.radians(x["v"])) / math.tan(math.radians(HALF_VFOV))
166
+ draw.ellipse((u - 6, v - 6, u + 6, v + 6), outline="red", width=2)
167
+ cell = (k % 6 * W, k // 6 * (H + 14))
168
+ sheet.paste(img, (cell[0], cell[1] + 14))
169
+ ImageDraw.Draw(sheet).text((cell[0] + 2, cell[1] + 1), f"{x['name']} | {x['answer'].strip()[:5]} | {x['dist']:.0f}m | "
170
+ f"{Path(x['ep']).name}/{x['frame']}", fill="black")
171
+ sheet.save(args.out / "contact_sheet.png")
172
+ print((args.out / "summary.md").read_text(), flush=True)
173
+
174
+
175
+ if __name__ == "__main__":
176
+ main()
code/bench/diag_perception_direction.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnosis B2 (2026-10-13): can the base Qwen3.5-9B (no LoRA) tell a controller WHERE the named target is in our
2
+ 320x180 frames, not only whether it is there?
3
+
4
+ Frames, poses, angles and spreading come from bench/diag_perception.py (B), with wider bands:
5
+ - positives: target 3-16 blocks away, |h| <= 45, |v| <= 30, 100 from each h bin [-45,-15), [-15,15], (15,45];
6
+ - behind negatives: same distance band, |h| >= 120, 100;
7
+ - wrong-name negatives: every third positive frame, asked about a target name of another scene that is neither a target
8
+ of this scene nor a block its build commands place (B's rule).
9
+ Two questions per frame, thinking off, temperature 0:
10
+ (a) grounding in Qwen's native JSON format. Qwen3.5 answers [{"bbox_2d": [x1, y1, x2, y2], "label": ...}] in relative
11
+ 0-1000 coordinates (checked on six clearly visible frames of B: values up to 959 and 825 on a 320x180 frame that vLLM
12
+ resizes to 352x192; scaled by W/1000 and H/1000 the boxes sit on the objects). The first box counts; its center gives
13
+ a horizontal angle with B's pinhole model (exact at pitch 0);
14
+ (b) one word: left, center, right or none (truth for positives: center = |h| <= 15).
15
+ Writes items.jsonl, summary.md and contact_sheet.png (48 positives, 16 per h bin: green = boxes, the first one thick,
16
+ red = projected target point as in B).
17
+ Usage: diag_perception_direction.py --url http://localhost:9045/v1 --model NAME [--out DIR]
18
+ """
19
+ import argparse
20
+ import base64
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ import json
23
+ import math
24
+ from pathlib import Path
25
+ import random
26
+ import re
27
+ import statistics
28
+
29
+ from openai import OpenAI
30
+ from PIL import Image, ImageDraw
31
+
32
+ from diag_perception import BENCH, H, HALF_VFOV, ROOT, W, episodes, spread, view
33
+
34
+ TAN_HALF_HFOV = math.tan(math.radians(HALF_VFOV)) * W / H # horizontal half fov ~51.2 deg
35
+ SIDES = ("left", "center", "right")
36
+ GROUND = ("Locate the {0} in this image and report its bbox coordinates in JSON format. "
37
+ "If there is no {0} in the image, output an empty list [].")
38
+ WORD = ("Where is the {0} in this image? Answer with one word: left, center, or right. "
39
+ "Answer none if there is no {0} in the image.")
40
+ BOX = re.compile(r'"bbox_2d"\s*:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
41
+
42
+
43
+ def side(h):
44
+ return "left" if h < -15 else "center" if h <= 15 else "right"
45
+
46
+
47
+ def select():
48
+ """(positives, behind negatives, wrong-name negatives)."""
49
+ rng = random.Random(0)
50
+ by_side, behind, names, placed = {s: [] for s in SIDES}, [], {}, {}
51
+ for ep, scene, _, poses, targets in episodes():
52
+ placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
53
+ for mid, task, target in targets:
54
+ if not task.startswith("find "):
55
+ continue
56
+ name = task[5:].removeprefix("a ").replace("_", " ")
57
+ names.setdefault(scene, set()).add(name)
58
+ for i, pose in enumerate(poses):
59
+ dist, h, v = (round(a, 1) for a in view(pose, target))
60
+ item = {"ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": i, "name": name,
61
+ "asked": name, "dist": dist, "h": h, "v": v, "pitch": round(pose["pitch"], 1)}
62
+ if 3 <= dist <= 16 and abs(h) <= 45 and abs(v) <= 30:
63
+ by_side[side(h)].append(item)
64
+ elif 3 <= dist <= 16 and abs(h) >= 120:
65
+ behind.append(item)
66
+ pos = [x for s in SIDES for x in spread(by_side[s], 100, rng)]
67
+ behind = spread(behind, 100, rng)
68
+ every = sorted(set().union(*names.values()))
69
+ wrong = [{**p, "asked": rng.choice([x for x in every if x not in names[p["scene"]]
70
+ and x.replace(" ", "_") not in placed[p["scene"]]])} for p in pos[::3]]
71
+ return pos, behind, wrong
72
+
73
+
74
+ def spearman(a, b):
75
+ rank = lambda xs: [sorted(xs).index(x) + (sorted(xs).count(x) - 1) / 2 for x in xs]
76
+ return statistics.correlation(rank(a), rank(b))
77
+
78
+
79
+ def main():
80
+ ap = argparse.ArgumentParser()
81
+ ap.add_argument("--url", required=True)
82
+ ap.add_argument("--model", required=True)
83
+ ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception_direction")
84
+ args = ap.parse_args()
85
+ args.out.mkdir(parents=True, exist_ok=True)
86
+ pos, behind, wrong = select()
87
+ counts = ", ".join(f"{s} {sum(side(p['h']) == s for p in pos)}" for s in SIDES)
88
+ print(f"positives {len(pos)} ({counts}) from "
89
+ f"{len({p['scene'] for p in pos})} scenes, behind {len(behind)} from {len({b['scene'] for b in behind})} "
90
+ f"scenes, wrong-name {len(wrong)}", flush=True)
91
+
92
+ client = OpenAI(api_key="EMPTY", base_url=args.url)
93
+
94
+ def ask(kind_item):
95
+ kind, item = kind_item
96
+ png = (ROOT / item["ep"] / "frames" / f"{item['frame']:04d}.png").read_bytes()
97
+ image = {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}}
98
+
99
+ def chat(prompt, max_tokens):
100
+ r = client.chat.completions.create(
101
+ model=args.model, temperature=0, max_tokens=max_tokens,
102
+ messages=[{"role": "user", "content": [image, {"type": "text", "text": prompt.format(item["asked"])}]}],
103
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}})
104
+ return r.choices[0].message.content
105
+
106
+ ground, word = chat(GROUND, 256), chat(WORD, 5)
107
+ boxes = [[float(c) for c in b] for b in BOX.findall(ground)]
108
+ out = {"kind": kind, **item, "ground": ground, "boxes": boxes, "word_raw": word,
109
+ "word": next((w for w in SIDES + ("none",) if word.strip().lower().startswith(w)), "other")}
110
+ if boxes:
111
+ x1, _, x2, _ = boxes[0]
112
+ out["h_box"] = round(math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)), 1)
113
+ return out
114
+
115
+ with ThreadPoolExecutor(32) as pool:
116
+ items = list(pool.map(ask, [("pos", p) for p in pos] + [("behind", b) for b in behind]
117
+ + [("wrong", w) for w in wrong]))
118
+ with (args.out / "items.jsonl").open("w") as f:
119
+ f.writelines(json.dumps(item, ensure_ascii=False) + "\n" for item in items)
120
+
121
+ by_kind = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
122
+ pct = lambda k, n: f"{100 * k / n:.1f}%" if n else "-"
123
+ med = lambda xs: f"{statistics.median(xs):+.1f}" if xs else "-"
124
+
125
+ def row(label, xs):
126
+ det = [x for x in xs if x["boxes"]]
127
+ err = [x["h_box"] - x["h"] for x in det]
128
+ said = [x for x in xs if x["word"] in SIDES]
129
+ return (f"| {label} | {len(xs)} | {pct(len(det), len(xs))} | {med([abs(e) for e in err]).lstrip('+')} | "
130
+ f"{med(err)} | {pct(sum(abs(e) <= 10 for e in err), len(det))} | "
131
+ f"{pct(sum(abs(e) <= 15 for e in err), len(det))} | "
132
+ f"{pct(sum(side(x['h_box']) == side(x['h']) for x in det), len(xs))} | {pct(len(said), len(xs))} | "
133
+ f"{pct(sum(x['word'] == side(x['h']) for x in said), len(xs))} | "
134
+ f"{pct(sum(x['word'] == side(x['h']) for x in said), len(said))} |")
135
+
136
+ p = by_kind["pos"]
137
+ lines = ["| 正例分组 | n | (a) 给框率 | (a) 角度误差中位数(度) | (a) 有符号误差中位数 | (a) 误差 ≤10° | (a) 误差 ≤15° | "
138
+ "(a) 框推出的方向对(占全部) | (b) 非 none 率 | (b) 方向对(占全部) | (b) 方向对(占作答) |",
139
+ "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", row("全部", p)]
140
+ lines += [row(f"距离 {lo}-{hi} 格", [x for x in p if lo <= x["dist"] < hi or (hi == 16 and x["dist"] == 16)])
141
+ for lo, hi in ((3, 6), (6, 10), (10, 16))]
142
+ lines += [row(f"h {label}", [x for x in p if side(x["h"]) == s])
143
+ for s, label in zip(SIDES, ("左 [-45,-15)", "中 [-15,15]", "右 (15,45]"))]
144
+ lines += ["", "(a) 的误差只算给了框的正例;“占全部”把没给框或答 none 算作错。", "",
145
+ "(b) 的混淆表(行为真值,列为回答):", "", "| 真值 | left | center | right | none | 其他 |",
146
+ "|---|---:|---:|---:|---:|---:|"]
147
+ lines += [f"| {s} | " + " | ".join(str(sum(x["word"] == w for x in p if side(x["h"]) == s))
148
+ for w in SIDES + ("none", "other")) + " |" for s in SIDES]
149
+ lines += ["", "负例(误报率):", "", "| 负例类型 | n | (a) 给框率 | (b) 非 none 率 |", "|---|---:|---:|---:|"]
150
+ lines += [f"| {label} | {len(by_kind[k])} | {pct(sum(bool(x['boxes']) for x in by_kind[k]), len(by_kind[k]))} | "
151
+ f"{pct(sum(x['word'] in SIDES for x in by_kind[k]), len(by_kind[k]))} |"
152
+ for k, label in (("behind", "背后(目标在身后)"), ("wrong", "错名(正例帧,问别的场景的目标名)"))]
153
+ det = [x for x in p if x["boxes"]]
154
+ height = [x["boxes"][0][3] - x["boxes"][0][1] for x in det]
155
+ area = [(x["boxes"][0][2] - x["boxes"][0][0]) * (x["boxes"][0][3] - x["boxes"][0][1]) for x in det]
156
+ dist = [x["dist"] for x in det]
157
+ lines += ["", f"距离线索(给了框的 {len(det)} 个正例,第一个框):框高与几何距离的 Spearman ρ = "
158
+ f"{spearman(height, dist):+.2f},框面积与几何距离 ρ = {spearman(area, dist):+.2f}。"]
159
+ (args.out / "summary.md").write_text(
160
+ "帧和几何同 B(bench/diag_perception.py):GRPO v2 第 1 到 5 轮 rollout 中策略实际看到的 320×180 PNG。"
161
+ "正例:目标距离 3 到 16 格,水平角 |h| ≤ 45°、竖直角 |v| ≤ 30°,按 h 分三段 [-45,-15)、[-15,15]、(15,45] 各抽 100;"
162
+ "背后负例:同距离段,|h| ≥ 120°,100 个;错名负例:每三个正例取一帧,问另一个场景的目标名(B 的规则),"
163
+ f"{len(by_kind['wrong'])} 个。每个回合每个目标在每段最多 2 帧、相隔 ≥20 帧,按场景轮流抽:正例来自 "
164
+ f"{len({x['scene'] for x in p})} 个场景、{len({x['ep'] for x in p})} 个回合,背后负例来自 "
165
+ f"{len({x['scene'] for x in by_kind['behind']})} 个场景。只按几何选帧,不判断遮挡。\n\n"
166
+ "坐标系核对:Qwen3.5 的原生定位输出是 JSON 列表 [{\"bbox_2d\": [x1, y1, x2, y2], \"label\": ...}],坐标是相对 0 到 1000,"
167
+ "不是像素:在 B 的 6 张目标清楚可见的帧上,y 最大到 825、x 最大到 959(原图 320×180,vLLM 送进模型时缩放为 352×192,"
168
+ "两种像素坐标都不可能出现这些值),按 x·W/1000、y·H/1000 换算后框落在物体上(品红/紫色玻璃、箱子、淡蓝混凝土准确,"
169
+ "矢车菊和盆栽竹子这种很小的物体偏约 15 像素)。下面取第一个框的中心,用针孔模型(水平半视场 "
170
+ f"{math.degrees(math.atan(TAN_HALF_HFOV)):.1f}°,俯仰为 0 时精确)换算成水平角,和几何 h 比较。\n\n"
171
+ f"问题(思考关闭,温度 0):(a) \"{GROUND.format('<name>')}\";(b) \"{WORD.format('<name>')}\","
172
+ "真值 center 为 |h| ≤ 15°。\n\n" + "\n".join(lines) + "\n\n"
173
+ "接触表 contact_sheet.png:每段前 16 个正例(左、中、右依次排列),绿框为 (a) 的框(第一个加粗),红圈为目标点按视角投影的位置"
174
+ "(同 B),标注为序号、目标名、距离、h、(b) 的回答;全部逐条结果见 items.jsonl。\n")
175
+
176
+ sheet = Image.new("RGB", (6 * W, 8 * (H + 14)), "white")
177
+ shown = [x for s in SIDES for x in [x for x in p if side(x["h"]) == s][:16]]
178
+ for k, x in enumerate(shown):
179
+ img = Image.open(ROOT / x["ep"] / "frames" / f"{x['frame']:04d}.png").convert("RGB")
180
+ draw = ImageDraw.Draw(img)
181
+ u = W / 2 + W / 2 * math.tan(math.radians(x["h"])) / TAN_HALF_HFOV
182
+ v = H / 2 - H / 2 * math.tan(math.radians(x["v"])) / math.tan(math.radians(HALF_VFOV))
183
+ draw.ellipse((u - 6, v - 6, u + 6, v + 6), outline="red", width=2)
184
+ for j, (x1, y1, x2, y2) in enumerate(x["boxes"]):
185
+ draw.rectangle((x1 * W / 1000, y1 * H / 1000, x2 * W / 1000, y2 * H / 1000), outline=(0, 255, 0),
186
+ width=2 if j == 0 else 1)
187
+ cell = (k % 6 * W, k // 6 * (H + 14))
188
+ sheet.paste(img, (cell[0], cell[1] + 14))
189
+ ImageDraw.Draw(sheet).text((cell[0] + 2, cell[1] + 1), f"{k} {x['name']} | {x['dist']:.0f}m | h{x['h']:+.0f} | "
190
+ f"{x['word_raw'].strip()[:7]}", fill="black")
191
+ sheet.save(args.out / "contact_sheet.png")
192
+ print((args.out / "summary.md").read_text(), flush=True)
193
+
194
+
195
+ if __name__ == "__main__":
196
+ main()
code/bench/diag_perception_presence.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Verification step 1b (2026-10-13): presence scores of the base Qwen3.5-9B (no LoRA) from token logprobs, plus the
2
+ box temporal check of PLAN_DECOUPLED_20261013.md 2.4 point 3. Thinking off, temperature 0, top 20 logprobs.
3
+
4
+ 1b, on step 1's 500 items (runs/diag_20261013/perception_direction/items.jsonl: 300 geometric positives, 100 behind,
5
+ 100 wrong-name), two scores:
6
+ - s_yes: B's question, max_tokens 1; P(yes) / (P(yes) + P(no)), case and leading-space variants summed;
7
+ - s_box: step 1's grounding question with max_tokens 48 (the controller's call). Answers either open a code fence
8
+ ("```", "json", "\\n", "[", "\\n", "\\t", '{"', ...) or refuse ("There are no objects belonging to ..."); after the fence
9
+ the list token is "[" with P("[]") ~ 0 (stored per item as p_list_empty), so empty vs non-empty is decided at the
10
+ first token: s_box = P(first token "```").
11
+ AUC: 300 positives vs 200 negatives, and step 1's hand-labelled contact-sheet positives (37 visible vs 11 not).
12
+ Temporal check on GRPO v2 rounds 1-5: disjoint windows of 5 consecutive frames turning >= 5 deg at each step.
13
+ real = a target in the geometric field of view (|h| <= 51.2, |v| <= 35) 3-16 blocks away in all 5 frames;
14
+ wrong = the same windows asked about a wrong name (step 1's rule); behind = a target 3-16 blocks away with |h| >= 120
15
+ in all 5 frames (spread as step 1, up to 100). Per step, the first box centre's angle change is compared with
16
+ -delta yaw, the change a fixed world point shows.
17
+ Writes items.jsonl, windows.jsonl, summary.md.
18
+ Usage: diag_perception_presence.py --url http://localhost:9041/v1 --model NAME [--out DIR]
19
+ """
20
+ import argparse
21
+ import base64
22
+ from concurrent.futures import ThreadPoolExecutor
23
+ import json
24
+ import math
25
+ from pathlib import Path
26
+ import random
27
+ import statistics
28
+
29
+ from openai import OpenAI
30
+
31
+ from diag_perception import BENCH, HALF_VFOV, ROOT, episodes, spread, view
32
+ from diag_perception_direction import BOX, GROUND, SIDES, TAN_HALF_HFOV, side
33
+
34
+ STEP1 = ROOT / "runs/diag_20261013/perception_direction/items.jsonl"
35
+ HIDDEN = {11, 17, 19, 20, 27, 29, 33, 36, 43, 45, 47} # contact-sheet positives judged not visible (step 1 summary.md)
36
+ HALF_HFOV = math.degrees(math.atan(TAN_HALF_HFOV))
37
+ YESNO = "Is there a {0} in this image? Answer yes or no."
38
+
39
+
40
+ def windows(rng):
41
+ """(real, wrong, behind) windows: start frame, the 4 yaw changes, geometric (dist, h, v) per frame."""
42
+ real, behind, names, placed = [], [], {}, {}
43
+ for ep, scene, _, poses, targets in episodes():
44
+ placed[scene] = " ".join(json.loads((BENCH / scene / "multi-agent/metadata.json").read_text())["commands"])
45
+ turn = [(b["yaw"] - a["yaw"] + 180) % 360 - 180 for a, b in zip(poses, poses[1:])]
46
+ for mid, task, target in targets:
47
+ if not task.startswith("find "):
48
+ continue
49
+ name = task[5:].removeprefix("a ").replace("_", " ")
50
+ names.setdefault(scene, set()).add(name)
51
+ free = {"real": 0, "behind": 0}
52
+ for s in range(len(poses) - 4):
53
+ if not all(abs(d) >= 5 for d in turn[s:s + 4]):
54
+ continue
55
+ geo = [[round(a, 1) for a in view(poses[s + j], target)] for j in range(5)]
56
+ kind = ("real" if all(3 <= d <= 16 and abs(h) <= HALF_HFOV and abs(v) <= HALF_VFOV for d, h, v in geo)
57
+ else "behind" if all(3 <= d <= 16 and abs(h) >= 120 for d, h, v in geo) else None)
58
+ if kind and s >= free[kind]:
59
+ free[kind] = s + 5
60
+ (real if kind == "real" else behind).append(
61
+ {"kind": kind, "ep": str(ep.relative_to(ROOT)), "scene": scene, "mid": mid, "frame": s,
62
+ "name": name, "asked": name, "turn": [round(d, 2) for d in turn[s:s + 4]], "geo": geo})
63
+ every = sorted(set().union(*names.values()))
64
+ wrong = [{**w, "kind": "wrong", "asked": rng.choice([x for x in every if x not in names[w["scene"]]
65
+ and x.replace(" ", "_") not in placed[w["scene"]]])}
66
+ for w in real]
67
+ return real, wrong, spread(behind, 100, rng)
68
+
69
+
70
+ def auc(pos, neg):
71
+ return sum((a > b) + 0.5 * (a == b) for a in pos for b in neg) / (len(pos) * len(neg))
72
+
73
+
74
+ def main():
75
+ ap = argparse.ArgumentParser()
76
+ ap.add_argument("--url", required=True)
77
+ ap.add_argument("--model", required=True)
78
+ ap.add_argument("--out", type=Path, default=ROOT / "runs/diag_20261013/perception_presence")
79
+ args = ap.parse_args()
80
+ args.out.mkdir(parents=True, exist_ok=True)
81
+ step1 = [json.loads(line) for line in STEP1.read_text().splitlines()]
82
+ real, wrong, behind = windows(random.Random(0))
83
+ print(f"windows: real {len(real)} ({len({w['scene'] for w in real})} scenes, {len({w['ep'] for w in real})} "
84
+ f"episodes), wrong {len(wrong)}, behind {len(behind)} ({len({w['scene'] for w in behind})} scenes)", flush=True)
85
+
86
+ client = OpenAI(api_key="EMPTY", base_url=args.url)
87
+
88
+ def chat(key, prompt, max_tokens):
89
+ ep, frame, asked = key
90
+ png = (ROOT / ep / "frames" / f"{frame:04d}.png").read_bytes()
91
+ r = client.chat.completions.create(
92
+ model=args.model, temperature=0, max_tokens=max_tokens, logprobs=True, top_logprobs=20,
93
+ messages=[{"role": "user", "content": [
94
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode()}},
95
+ {"type": "text", "text": prompt.format(asked)}]}],
96
+ extra_body={"chat_template_kwargs": {"enable_thinking": False}})
97
+ return r.choices[0].message.content, r.choices[0].logprobs.content
98
+
99
+ def ground(key):
100
+ text, lp = chat(key, GROUND, 48)
101
+ boxes = [[float(c) for c in b] for b in BOX.findall(text)]
102
+ out = {"ground": text, "boxes": boxes,
103
+ "s_box": sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip() == "```"),
104
+ "p_list_empty": sum(math.exp(t.logprob) for t in lp[3].top_logprobs if t.token == "[]")
105
+ if text.startswith("```json\n[") else None}
106
+ if boxes:
107
+ x1, _, x2, _ = boxes[0]
108
+ out["h_box"] = round(math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)), 1)
109
+ return out
110
+
111
+ def yes(key):
112
+ text, lp = chat(key, YESNO, 1)
113
+ p = {w: sum(math.exp(t.logprob) for t in lp[0].top_logprobs if t.token.strip().lower() == w)
114
+ for w in ("yes", "no")}
115
+ return {"answer": text, "s_yes": p["yes"] / (p["yes"] + p["no"])}
116
+
117
+ keys1 = [(x["ep"], x["frame"], x["asked"]) for x in step1]
118
+ keys = list(dict.fromkeys(keys1 + [(w["ep"], w["frame"] + j, w["asked"]) for w in real + wrong + behind
119
+ for j in range(5)]))
120
+ with ThreadPoolExecutor(32) as pool:
121
+ g = dict(zip(keys, pool.map(ground, keys)))
122
+ y = dict(zip(keys1, pool.map(yes, keys1)))
123
+ items = [{**{k: x[k] for k in ("kind", "ep", "scene", "mid", "frame", "name", "asked", "dist", "h", "v", "pitch")},
124
+ **y[key], **g[key]} for x, key in zip(step1, keys1)]
125
+ wins = [{**w, "ground": [g[(w["ep"], w["frame"] + j, w["asked"])] for j in range(5)]}
126
+ for w in real + wrong + behind]
127
+ real, wrong, behind = ([w for w in wins if w["kind"] == k] for k in ("real", "wrong", "behind"))
128
+ with (args.out / "items.jsonl").open("w") as f:
129
+ f.writelines(json.dumps(x, ensure_ascii=False) + "\n" for x in items)
130
+ with (args.out / "windows.jsonl").open("w") as f:
131
+ f.writelines(json.dumps(w, ensure_ascii=False) + "\n" for w in wins)
132
+
133
+ by = {k: [x for x in items if x["kind"] == k] for k in ("pos", "behind", "wrong")}
134
+ shown = [x for s in SIDES for x in [x for x in by["pos"] if side(x["h"]) == s][:16]]
135
+ vis = [x for k, x in enumerate(shown) if k not in HIDDEN]
136
+ hid = [x for k, x in enumerate(shown) if k in HIDDEN]
137
+ neg = by["behind"] + by["wrong"]
138
+ aucs = ["| 比较 | n | s_yes AUC | s_box AUC |", "|---|---|---:|---:|"]
139
+ aucs += [f"| {label} | {len(a)} 对 {len(b)} | {auc([x['s_yes'] for x in a], [x['s_yes'] for x in b]):.3f} | "
140
+ f"{auc([x['s_box'] for x in a], [x['s_box'] for x in b]):.3f} |"
141
+ for label, a, b in (("正例对负例(背后 + 错名)", by["pos"], neg), ("正例对背后", by["pos"], by["behind"]),
142
+ ("正例对错名", by["pos"], by["wrong"]), ("人工:可见对看不见", vis, hid),
143
+ ("人工可见对负例", vis, neg))]
144
+ med = lambda xs: f"{statistics.median(xs):.3f}"
145
+ scores = ["| 组 | n | s_yes 中位数 | 答 yes | s_box 中位数 | 给框(48 token) |", "|---|---:|---:|---:|---:|---:|"]
146
+ scores += [f"| {label} | {len(xs)} | {med([x['s_yes'] for x in xs])} | "
147
+ f"{sum(x['answer'].strip().lower().startswith('yes') for x in xs)} | {med([x['s_box'] for x in xs])} | "
148
+ f"{sum(bool(x['boxes']) for x in xs)} |"
149
+ for label, xs in (("正例", by["pos"]), ("背后", by["behind"]), ("错名", by["wrong"]),
150
+ ("人工可见", vis), ("人工看不见", hid))]
151
+ fence = [x["p_list_empty"] for x in items if x["p_list_empty"] is not None]
152
+ starts = sorted({x["ground"][:12] for x in items if x["p_list_empty"] is None})
153
+ scores += ["", f"代码块开头的回答 {len(fence)} 条,列表 token 上 P(\"[]\") 最大 {max(fence):.1e};"
154
+ f"其余 {len(items) - len(fence)} 条开头为 {starts}。"]
155
+
156
+ def row(label, seqs):
157
+ """seqs: (4 yaw changes, 5 box centre angles, None = no box)."""
158
+ had = [(-d, a, b) for turns, hs in seqs for d, a, b in zip(turns, hs, hs[1:]) if a is not None]
159
+ both = [(x, b - a) for x, a, b in had if b is not None]
160
+ slope, icpt = statistics.linear_regression(*zip(*both))
161
+ rms = math.sqrt(statistics.fmean((y - slope * x - icpt) ** 2 for x, y in both))
162
+ frames = [h for _, hs in seqs for h in hs]
163
+ big = [y for x, y in both if abs(x) > 10]
164
+ pct = lambda k, n: f"{100 * k / n:.0f}%"
165
+ return (f"| {label} | {len(seqs)} | {pct(sum(h is not None for h in frames), len(frames))} | "
166
+ f"{pct(sum(b is None for _, _, b in had), len(had))} | {len(both)} | "
167
+ f"{statistics.median(abs(x) for x, _ in both):.1f} | {slope:.2f} | {icpt:+.1f} | {rms:.1f} | "
168
+ f"{pct(sum(abs(y - x) <= 5 for x, y in both), len(both))} | {pct(sum(abs(y) <= 5 for y in big), len(big))} | "
169
+ f"{pct(sum(min(abs(y - x), abs(y)) > 10 for x, y in both), len(both))} | "
170
+ f"{statistics.median(abs(y) for _, y in both):.1f} |")
171
+
172
+ boxes = lambda ws: [(w["turn"], [f.get("h_box") for f in w["ground"]]) for w in ws]
173
+ rng = random.Random(0)
174
+ pool = [f["h_box"] for w in wrong + behind for f in w["ground"] if "h_box" in f]
175
+ errs = [x["h_box"] - x["h"] for x in by["pos"] if "h_box" in x]
176
+
177
+ def fake_sim(redraw, world):
178
+ """Fake box angles over the fake windows' turns (20 repeats): first from the observed fake boxes; each step
179
+ redrawn with probability redraw, else kept (world = 1: shifted by -delta yaw like a fixed world point)."""
180
+ seqs = []
181
+ for w in (wrong + behind) * 20:
182
+ hs = [rng.choice(pool)]
183
+ for d in w["turn"]:
184
+ hs.append(rng.choice(pool) if rng.random() < redraw else hs[-1] - world * d)
185
+ seqs.append((w["turn"], hs))
186
+ return seqs
187
+
188
+ def real_sim(redraw):
189
+ """True target angle + an error from the 300 positives (plan 2.4 point 1), redrawn with probability redraw."""
190
+ seqs = []
191
+ for w in real * 20:
192
+ e, hs = rng.choice(errs), []
193
+ for _, h, _ in w["geo"]:
194
+ e = rng.choice(errs) if hs and rng.random() < redraw else e
195
+ hs.append(h + e)
196
+ seqs.append((w["turn"], hs))
197
+ return seqs
198
+
199
+ temporal = ["| 窗口 | 窗口数 | 帧给框 | 消失(上一帧有框,这一帧没有) | 相邻两帧都有框的步 | \\|Δyaw\\| 中位数 | 斜率 | 截距 | "
200
+ "残差 RMS | 随世界点(\\|Δh_box + Δyaw\\| ≤ 5°) | 画面不动(\\|Δh_box\\| ≤ 5°,\\|Δyaw\\| > 10° 的步) | "
201
+ "跳(两者都偏 >10°) | \\|Δh_box\\| 中位数 |",
202
+ "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
203
+ row("真目标:几何真值 h(参照)", [(w["turn"], [g[1] for g in w["geo"]]) for w in real]),
204
+ row("真目标:框", boxes(real)), row("假框:错名(同一批窗口)", boxes(wrong)),
205
+ row("假框:背后", boxes(behind)), row("假框合计", boxes(wrong + behind)),
206
+ row("模拟假框 (1) 每步独立", fake_sim(1, 0)), row("模拟假框 (2) 固定世界点", fake_sim(0, 1)),
207
+ row("模拟假框 (3) 画面里不动", fake_sim(0, 0)), row("模拟假框 (3),每步以 0.5 概率重抽", fake_sim(0.5, 0)),
208
+ row("模拟真框:每步独立误差(2.4 第 1 点原样)", real_sim(1)), row("模拟真框:误差保持,每步以 0.5 概率重抽", real_sim(0.5))]
209
+ (args.out / "summary.md").write_text(
210
+ "基座 Qwen3.5-9B(无 LoRA),思考关闭,温度 0,取前 20 个 token 的 logprob。\n\n"
211
+ "## 1b 存在性分数\n\n"
212
+ "同第 1 步的 500 条(runs/diag_20261013/perception_direction/items.jsonl:几何正例 300,背后负例 100,错名负例 100)。"
213
+ f"s_yes:B 的问题 \"{YESNO.format('<name>')}\",max_tokens 1,P(yes)/(P(yes)+P(no)),大小写和前导空格变体合计。"
214
+ "s_box:第 1 步的定位问题,max_tokens 48(2.2 里控制器的调用)。回答只有两种开头:代码块(```、json、换行、[、换行、制表符、{\"…)"
215
+ "或拒答(There are no objects belonging to …);进了代码块后列表 token 是 [,P(\"[]\") 约为 0,"
216
+ "所以空和非空在第一个 token 上决定:s_box = P(第一个 token 是 ```)。人工标注取第 1 步 summary.md 接触表核对:"
217
+ "48 个正例里序号 11、17、19、20、27、29、33、36、43、45、47 看不见,其余 37 个可见。\n\n"
218
+ + "\n".join(aucs) + "\n\n" + "\n".join(scores) + "\n\n"
219
+ "## 框的时间行为\n\n"
220
+ "GRPO v2 第 1 到 5 轮 rollout,5 帧连续窗口,4 步每步 |Δyaw| ≥ 5°,同一回合同一目标的窗口不重叠。"
221
+ f"真目标:目标在全部 5 帧都在几何视场内(|h| ≤ {HALF_HFOV:.1f}°,|v| ≤ 35°)、距离 3 到 16 格,全部取用 {len(real)} 个"
222
+ f"({len({w['scene'] for w in real})} 个场景、{len({w['ep'] for w in real})} 个回合,这批 rollout 里满足条件的只有这么多);"
223
+ "错名:同一批窗口问别的场景的目标名(第 1 步的规则);背后:目标在全部 5 帧都在 3 到 16 格、|h| ≥ 120°,按第 1 步的 spread 取"
224
+ f" {len(behind)} 个({len({w['scene'] for w in behind})} 个场景)。定位问题同 1b(max_tokens 48),取第一个框中心换算水平角 h_box。"
225
+ "每步把 Δh_box 对 −Δyaw(固定世界点在画面里的水平角变化)做最小二乘:固定世界点斜率 1,画面里不动斜率 0。"
226
+ "参照行用目标点的几何 h 代替框,差别来自走路的视差。跳:框中心变化离固定世界点的预测和画面不动都超过 10°。"
227
+ "模拟行在观测窗口的转头序列上各重复 20 次(seed 0):假框的位置从观测到的假框 h_box 里抽,真框 = 目标几何 h + 从 1b 的 300 个正例里抽的"
228
+ "框误差;“重抽”指这一步换一个新抽样,否则沿用上一步(假框 (2) 再按 −Δyaw 平移)。\n\n"
229
+ + "\n".join(temporal) + "\n\n"
230
+ "逐条结果:items.jsonl(1b 的 500 条),windows.jsonl(每个窗口 5 帧的回答、框、h_box、s_box)。\n")
231
+ print((args.out / "summary.md").read_text(), flush=True)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
code/bench/splits_find25.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "eval": [
3
+ "0046",
4
+ "0048",
5
+ "0050",
6
+ "0062",
7
+ "0066",
8
+ "0084",
9
+ "0116",
10
+ "0147",
11
+ "0148",
12
+ "0158",
13
+ "0165",
14
+ "0188",
15
+ "0198",
16
+ "0208",
17
+ "0233",
18
+ "0237",
19
+ "0243",
20
+ "0261",
21
+ "0310",
22
+ "0313",
23
+ "0329",
24
+ "0348",
25
+ "0356",
26
+ "0377",
27
+ "0386"
28
+ ],
29
+ "note": "First 25 tasks of splits_find.json eval in its fixed sorted order (hop mix 10/7/5/3 vs 20/15/10/5 in the 50). Fixed before any 25-task result; chosen 2026-10-11 so the running 20-frame eval (which dispatches in this order) can stop at 25."
30
+ }
code/train/bt_train.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LoRA training of the BT policy (EXPERIMENT_LOG.md 2026-10-17): Qwen3.5-9B reads one frame and BT_PROMPT with the
2
+ target name and writes the behavior code (one digit token per FSQ dimension). Runs with torchrun in .train-venv.
3
+
4
+ Rows (jsonl): image (PNG path), name, code (digits); ppo rows also adv and vllm_logp, and memory (the two 0/1 strings
5
+ of the decision's prompt) when the rollouts ran with memory. A row is the chat template of [image, bt_prompt(name,
6
+ memory)] with the generation prompt (thinking off), as vLLM renders BTPolicy's request, followed by the code and
7
+ <|im_end|>; batches are right-padded and the scored tokens gathered per row.
8
+ - sft: cross entropy on the code tokens and <|im_end|>, new LoRA (r 16, alpha 32, train_iql.py's modules), AdamW,
9
+ 10-update warmup then cosine; per-digit and whole-code (all digits argmax) accuracy on --valid after each epoch.
10
+ Saves the adapter to --out (vLLM-loadable).
11
+ - ppo: continues --adapter (and its optimizer.pt if it has one, i.e. a PPO round's), loads --ref (the SFT adapter) as
12
+ a second adapter; old and ref log-probs of the code tokens computed once with no_grad, then --epochs of PPO:
13
+ loss = -min(ratio A, clip(ratio, 0.8, 1.2) A) + beta * sum over the code tokens of k3 = exp(ref - lp) - (ref - lp) - 1,
14
+ ratio = exp(sum lp - sum old). Saves the adapter, optimizer.pt and stats.json (pg_loss, kl, clip_frac of the last
15
+ epoch; logp_gap = mean of old - vllm_logp, logp_gap_abs) to --out.
16
+ Usage: CUDA_VISIBLE_DEVICES=6,7 .train-venv/bin/python -m torch.distributed.run --nproc_per_node=2 train/bt_train.py
17
+ sft --data TRAIN.jsonl --valid VALID.jsonl --out DIR [--epochs 1 --lr 1e-4 --max-updates N]
18
+ ... train/bt_train.py ppo --data ROUND/train.jsonl --adapter ADAPTER --ref SFT --out DIR
19
+ """
20
+ import argparse
21
+ import json
22
+ import math
23
+ import os
24
+ import random
25
+ import time
26
+ from pathlib import Path
27
+
28
+ import torch
29
+ import torch.distributed as dist
30
+ from peft import LoraConfig, PeftModel, get_peft_model
31
+ from PIL import Image
32
+ from torch.nn.parallel import DistributedDataParallel
33
+ from torch.utils.data import DataLoader, DistributedSampler
34
+ from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
35
+
36
+ from ctrl_policy import bt_prompt
37
+
38
+ ROOT = Path(__file__).resolve().parents[1]
39
+ MODULES = (r"model.language_model.*\.(q_proj|k_proj|v_proj|o_proj|in_proj_qkv|in_proj_z|in_proj_b|in_proj_a|out_proj"
40
+ r"|gate_proj|up_proj|down_proj)$") # train_iql.py
41
+ END = "<|im_end|>"
42
+ CLIP, BETA = 0.2, 0.02
43
+
44
+
45
+ def log(message):
46
+ print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True)
47
+
48
+
49
+ class Collate:
50
+ def __init__(self, processor):
51
+ self.processor = processor
52
+
53
+ def __call__(self, rows):
54
+ p = self.processor
55
+ texts = [p.apply_chat_template([{"role": "user", "content": [{"type": "image"}, {"type": "text",
56
+ "text": bt_prompt(r["name"], r.get("memory"))}]}], tokenize=False,
57
+ add_generation_prompt=True, enable_thinking=False) + r["code"] + END
58
+ for r in rows]
59
+ inputs = p(text=texts, images=[Image.open(r["image"]).convert("RGB") for r in rows], padding=True,
60
+ padding_side="right", return_tensors="pt")
61
+ n = len(rows[0]["code"])
62
+ ends = inputs["attention_mask"].sum(1) # sequence lengths: the last token, <|im_end|>, is at end - 1
63
+ targets = torch.stack([inputs["input_ids"][i, e - n - 1:e] for i, e in enumerate(ends)]) # code + im_end
64
+ extra = {k: torch.tensor([r[k] for r in rows], dtype=torch.float32) for k in ("adv", "vllm_logp", "index")
65
+ if k in rows[0]}
66
+ return {"inputs": dict(inputs), "ends": ends, "targets": targets, **extra}
67
+
68
+
69
+ def token_logprobs(model, batch, device):
70
+ """(B, n + 1) log-probs of the code tokens and <|im_end|>, and the logits' argmax at those positions."""
71
+ inputs = {k: v.to(device) for k, v in batch["inputs"].items()}
72
+ ends, k = batch["ends"], batch["targets"].shape[1]
73
+ lo, hi = int(ends.min()) - k - 1, int(ends.max()) - 1 # logits at position j predict token j + 1
74
+ with torch.autocast("cuda", dtype=torch.bfloat16):
75
+ logits = model(**inputs, logits_to_keep=torch.arange(lo, hi, device=device), use_cache=False).logits
76
+ rows = torch.stack([logits[i, e - k - 1 - lo:e - 1 - lo] for i, e in enumerate(ends.tolist())]).float()
77
+ lp = rows.log_softmax(-1)
78
+ targets = batch["targets"].to(device)
79
+ return lp.gather(-1, targets[..., None])[..., 0], rows.argmax(-1) == targets
80
+
81
+
82
+ def load_model(local):
83
+ model = Qwen3_5ForConditionalGeneration.from_pretrained(
84
+ ROOT / "models/Qwen3.5-9B", dtype=torch.bfloat16, device_map={"": local}, attn_implementation="sdpa",
85
+ local_files_only=True, use_kernels=True)
86
+ model.requires_grad_(False)
87
+ return model
88
+
89
+
90
+ def train_setup(model):
91
+ model.enable_input_require_grads()
92
+ model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
93
+ model.config.use_cache = False
94
+ model.config.text_config.use_cache = False
95
+
96
+
97
+ def loader(rows, collate, batch, rank, world, seed, shuffle=True):
98
+ sampler = DistributedSampler(rows, num_replicas=world, rank=rank, seed=seed, shuffle=shuffle)
99
+ return sampler, DataLoader(rows, batch_size=batch, sampler=sampler, collate_fn=collate, num_workers=4,
100
+ persistent_workers=True, prefetch_factor=2)
101
+
102
+
103
+ @torch.no_grad()
104
+ def accuracy(model, rows, collate, batch, rank, world, device):
105
+ """Per-digit and whole-code accuracy (teacher-forced argmax) of the code tokens on rows."""
106
+ model.eval()
107
+ totals = torch.zeros(3, device=device)
108
+ for b in DataLoader(rows[rank::world], batch_size=batch, collate_fn=collate, num_workers=4):
109
+ _, right = token_logprobs(model, b, device)
110
+ right = right[:, :-1]
111
+ totals += torch.tensor([right.sum(), right.all(1).sum(), len(right)], device=device, dtype=torch.float32)
112
+ dist.all_reduce(totals)
113
+ model.train()
114
+ return {"digit_acc": round((totals[0] / totals[2] / right.shape[1]).item(), 4),
115
+ "code_acc": round((totals[1] / totals[2]).item(), 4), "rows": int(totals[2])}
116
+
117
+
118
+ def sft(args, rank, world, local, device):
119
+ rows = [json.loads(l) for l in args.data.read_text().splitlines()]
120
+ valid = [json.loads(l) for l in args.valid.read_text().splitlines()]
121
+ processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
122
+ collate = Collate(processor)
123
+ torch.manual_seed(args.seed)
124
+ model = get_peft_model(load_model(local), LoraConfig(r=16, lora_alpha=32, lora_dropout=0.0,
125
+ task_type="CAUSAL_LM", target_modules=MODULES))
126
+ train_setup(model)
127
+ ddp = DistributedDataParallel(model, device_ids=[local])
128
+ sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
129
+ opt = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=args.lr)
130
+ total = min(args.max_updates or 10**9, len(data) * args.epochs)
131
+ if rank == 0:
132
+ log(f"SFT rows {len(rows)} valid {len(valid)}, {world} GPUs x batch {args.batch}, updates {total}")
133
+ log("VALID before " + json.dumps(accuracy(model, valid, collate, args.batch, rank, world, device)))
134
+ else:
135
+ accuracy(model, valid, collate, args.batch, rank, world, device)
136
+ update, start = 0, time.monotonic()
137
+ for epoch in range(args.epochs):
138
+ sampler.set_epoch(epoch)
139
+ for b in data:
140
+ lp, _ = token_logprobs(ddp, b, device)
141
+ loss = -lp.mean()
142
+ opt.zero_grad(set_to_none=True)
143
+ loss.backward()
144
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
145
+ for g in opt.param_groups:
146
+ g["lr"] = args.lr * min(1, (update + 1) / 10) * 0.5 * (1 + math.cos(math.pi * update / total))
147
+ opt.step()
148
+ update += 1
149
+ if rank == 0 and (update <= 5 or update % 50 == 0):
150
+ s = time.monotonic() - start
151
+ log(f"SFT update {update}/{total} loss {loss.item():.4f} {s / update:.2f} s/update "
152
+ f"eta {(total - update) * s / update / 60:.0f} min peak {torch.cuda.max_memory_allocated() / 2**30:.1f} GiB")
153
+ if update >= total:
154
+ break
155
+ acc = accuracy(model, valid, collate, args.batch, rank, world, device)
156
+ if rank == 0:
157
+ log(f"VALID epoch {epoch + 1} " + json.dumps(acc))
158
+ model.save_pretrained(args.out)
159
+ (args.out / "valid.json").write_text(json.dumps({"epoch": epoch + 1, "updates": update, **acc}))
160
+ if update >= total:
161
+ break
162
+
163
+
164
+ def ppo(args, rank, world, local, device):
165
+ rows = [{**json.loads(l), "index": i} for i, l in enumerate(args.data.read_text().splitlines())]
166
+ processor = AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
167
+ collate = Collate(processor)
168
+ torch.manual_seed(args.seed)
169
+ model = PeftModel.from_pretrained(load_model(local), args.adapter, is_trainable=True)
170
+ model.load_adapter(args.ref, adapter_name="ref")
171
+ n = len(rows[0]["code"])
172
+ lp_of = {}
173
+ for name in ("ref", "default"): # once per round, no_grad: the reference and the old policy
174
+ model.set_adapter(name)
175
+ model.eval()
176
+ mine = {}
177
+ with torch.no_grad():
178
+ for b in DataLoader(rows[rank::world], batch_size=2 * args.batch, collate_fn=collate, num_workers=4):
179
+ lp, _ = token_logprobs(model, b, device)
180
+ mine.update(zip(b["index"].long().tolist(), lp[:, :n].cpu()))
181
+ gathered = [None] * world
182
+ dist.all_gather_object(gathered, mine)
183
+ lp_of[name] = torch.stack([{k: v for g in gathered for k, v in g.items()}[i] for i in range(len(rows))])
184
+ model.set_adapter("default")
185
+ for name, p in model.named_parameters():
186
+ p.requires_grad = "lora_" in name and ".default." in name
187
+ model.train()
188
+ train_setup(model)
189
+ ddp = DistributedDataParallel(model, device_ids=[local])
190
+ params = [p for p in model.parameters() if p.requires_grad]
191
+ opt = torch.optim.AdamW(params, lr=args.lr)
192
+ if (args.adapter / "optimizer.pt").exists():
193
+ opt.load_state_dict(torch.load(args.adapter / "optimizer.pt", map_location=device))
194
+ for g in opt.param_groups:
195
+ g["lr"] = args.lr
196
+ sampler, data = loader(rows, collate, args.batch, rank, world, args.seed)
197
+ old_all, ref_all = lp_of["default"].to(device), lp_of["ref"].to(device)
198
+ vllm = torch.tensor([r["vllm_logp"] for r in rows], device=device)
199
+ gap = old_all.sum(1) - vllm
200
+ start = time.monotonic()
201
+ for epoch in range(args.epochs):
202
+ sampler.set_epoch(epoch)
203
+ stats = []
204
+ for step, b in enumerate(data):
205
+ idx = b["index"].long().to(device)
206
+ lp, _ = token_logprobs(ddp, b, device)
207
+ lp = lp[:, :n]
208
+ adv = b["adv"].to(device)
209
+ ratio = (lp.sum(1) - old_all[idx].sum(1)).exp()
210
+ pg = -torch.min(ratio * adv, ratio.clamp(1 - CLIP, 1 + CLIP) * adv)
211
+ d = ref_all[idx] - lp
212
+ kl = (d.exp() - d - 1).sum(1)
213
+ loss = (pg + BETA * kl).mean() / args.accumulation
214
+ sync = (step + 1) % args.accumulation == 0 or step + 1 == len(data)
215
+ if sync:
216
+ loss.backward()
217
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
218
+ opt.step()
219
+ opt.zero_grad(set_to_none=True)
220
+ else:
221
+ with ddp.no_sync():
222
+ loss.backward()
223
+ stats.append([pg.mean().item(), kl.mean().item(), ((ratio - 1).abs() > CLIP).float().mean().item()])
224
+ if rank == 0:
225
+ log(f"PPO epoch {epoch + 1} pg {sum(s[0] for s in stats) / len(stats):+.4f} "
226
+ f"kl {sum(s[1] for s in stats) / len(stats):.4f} {time.monotonic() - start:.0f} s")
227
+ totals = torch.tensor(stats, device=device).mean(0)
228
+ dist.all_reduce(totals)
229
+ totals /= world
230
+ if rank == 0:
231
+ args.out.mkdir(parents=True, exist_ok=True)
232
+ model.save_pretrained(args.out, selected_adapters=["default"])
233
+ torch.save(opt.state_dict(), args.out / "optimizer.pt")
234
+ result = {"pg_loss": round(totals[0].item(), 4), "kl": round(totals[1].item(), 4),
235
+ "clip_frac": round(totals[2].item(), 4), "logp_gap": round(gap.mean().item(), 4),
236
+ "logp_gap_abs": round(gap.abs().mean().item(), 4), "rows": len(rows),
237
+ "train_s": round(time.monotonic() - start)}
238
+ (args.out / "stats.json").write_text(json.dumps(result))
239
+ log("PPO_DONE " + json.dumps(result))
240
+
241
+
242
+ def main():
243
+ ap = argparse.ArgumentParser()
244
+ ap.add_argument("mode", choices=["sft", "ppo"])
245
+ ap.add_argument("--data", type=Path, required=True)
246
+ ap.add_argument("--valid", type=Path)
247
+ ap.add_argument("--adapter", type=Path)
248
+ ap.add_argument("--ref", type=Path)
249
+ ap.add_argument("--out", type=Path, required=True)
250
+ ap.add_argument("--epochs", type=int)
251
+ ap.add_argument("--lr", type=float)
252
+ ap.add_argument("--batch", type=int, default=16, help="rows per GPU per forward")
253
+ ap.add_argument("--accumulation", type=int, default=2, help="ppo: forwards per update (64 rows on 2 GPUs)")
254
+ ap.add_argument("--max-updates", type=int)
255
+ ap.add_argument("--seed", type=int, default=0)
256
+ args = ap.parse_args()
257
+ rank, world, local = int(os.environ["RANK"]), int(os.environ["WORLD_SIZE"]), int(os.environ["LOCAL_RANK"])
258
+ torch.set_num_threads(4)
259
+ random.seed(args.seed)
260
+ torch.cuda.set_device(local)
261
+ device = torch.device("cuda", local)
262
+ dist.init_process_group("nccl", device_id=device)
263
+ if args.mode == "sft":
264
+ args.epochs, args.lr = args.epochs or 1, args.lr or 1e-4
265
+ sft(args, rank, world, local, device)
266
+ else:
267
+ args.epochs, args.lr = args.epochs or 2, args.lr or 3e-6
268
+ ppo(args, rank, world, local, device)
269
+ dist.barrier()
270
+ dist.destroy_process_group()
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
code/train/ctrl_policy.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Controller policy of the decoupled plan (PLAN_DECOUPLED_20261013.md sections 3 and 5): a frozen perception gives
2
+ (p, h, v) of the current target, a small MLP on the last 20 steps picks the MineRL action. Runs in the eval venv
3
+ (numpy, openai; no torch). MineExplorer/eval_benchmark.py (MC_POLICY) and the GRPO rollouts call Policy.act once per
4
+ step; train/ctrl.py (torch) trains the MLP and exports it as npz: w0, b0, w1, b1, w2, b2 with weights as (in, out).
5
+
6
+ Actions, three heads sampled independently:
7
+ - movement, 15 combinations (MOVES); sprint only with forward, else MineExplorer's validation swaps in a no-op. A VPT
8
+ key bitmask maps to its class by cancelling opposite keys (as the game does), then dropping sneak, sprint and jump
9
+ in turn until the combination is one of MOVES;
10
+ - camera yaw, 17 bins (YAWS: 0, +-0.5 ... +-64 deg, > 0 turns right); camera pitch, 13 bins (PITCHES: 0, +-0.5 ...
11
+ +-16 deg, > 0 looks down). A recorded delta maps to the nearest bin on a log scale, 0 only for an exact 0.
12
+ attack, use, inventory, hotbar, ESC and the remaining keys are always 0.
13
+ Input: 5 numbers per step, the last 20 steps oldest first (100): p, h/HALF_HFOV, v/HALF_VFOV of the current target
14
+ (bench/diag_perception.py view: h > 0 right of the view center, v > 0 above it) and the policy's own previous camera
15
+ action yaw/64, pitch/16. Steps before the episode start are 0; when the target changes, the target part of the
16
+ earlier steps is set to 0. A controller whose w0 has 116 rows also gets 16 memory inputs after the window (memory():
17
+ for the 8 directions yaw + 45 i, i = 0..7, whether the direction's world-yaw sector was a view sector and whether the
18
+ 4x4 cell 4 blocks along it was stood in, over the episode's poses up to the current one).
19
+ Perception:
20
+ - gt: p = 1 and the exact h, v when the target is in the geometric field of view (|h| <= 51.2, |v| <= 35), else 0;
21
+ - vlm: two questions, sent at the same time, on the frame the eval client sends (320x180 PNG), thinking off,
22
+ temperature 0, worded as measured: step 1's grounding question (bench/diag_perception_direction.py GROUND,
23
+ max_tokens 48) with the answer started as START, whose first box's center through the pinhole model gives h, v
24
+ (0 without a box), and step 1b's presence score s_yes (bench/diag_perception_presence.py YESNO, max_tokens 1:
25
+ P(yes) / (P(yes) + P(no)) from the top 20 logprobs) as p. Asked on the episode's first step, when the target name
26
+ changes and PERCEIVE_EVERY steps after the last call; the steps in between carry the last answer over (carry()).
27
+ Current target: the first milestone in order that has rules, was not satisfied at spawn and is not completed (the
28
+ checker marks it); its name is the milestone task "find X" -> X with underscores as spaces.
29
+ Per-step log (Policy.act): x (the input, rounded to 4 decimals; the forward pass uses exactly these values), target
30
+ (milestone index), bins [move, yaw, pitch], logp (sum over the three heads), perception (vlm: reply, box, p on the
31
+ steps that asked, None on the carried ones; gt: None), held (steps since the last VLM call: 0 on the steps that asked,
32
+ and always for gt), truth of the current target (seen = in the geometric field of view, dist from the feet as the
33
+ milestone check, h, v; the checker's yaw error is -h).
34
+ """
35
+ from collections import deque
36
+ from concurrent.futures import ThreadPoolExecutor
37
+ import math
38
+ import time
39
+ from pathlib import Path
40
+ import sys
41
+
42
+ import numpy as np
43
+ from openai import OpenAI
44
+
45
+ ROOT = Path(__file__).resolve().parents[1]
46
+ sys.path.append(str(ROOT / "bench"))
47
+ from diag_perception import EYE, HALF_VFOV, view # noqa: E402
48
+ from diag_perception_direction import BOX, GROUND, TAN_HALF_HFOV # noqa: E402
49
+ from diag_perception_presence import YESNO # noqa: E402
50
+
51
+ WINDOW = 20
52
+ START = '```json\n[\n\t{"bbox_2d": [' # the start of the grounding answer (ground())
53
+ PERCEIVE_EVERY = 3 # vlm: steps from one perception call to the next (find25 evals and GRPO rollouts)
54
+ HALF_HFOV = math.degrees(math.atan(TAN_HALF_HFOV)) # 51.2
55
+ MOVES = [(), ("forward",), ("forward", "jump"), ("forward", "right"), ("forward", "left"), ("forward", "sprint"),
56
+ ("forward", "jump", "sprint"), ("back",), ("right",), ("left",), ("back", "left"), ("back", "right"),
57
+ ("forward", "right", "jump"), ("jump",), ("forward", "left", "jump")]
58
+ YAWS = np.array([-64, -32, -16, -8, -4, -2, -1, -0.5, 0, 0.5, 1, 2, 4, 8, 16, 32, 64])
59
+ PITCHES = np.array([-16, -8, -4, -2, -1, -0.5, 0, 0.5, 1, 2, 4, 8, 16])
60
+ CAM_SCALE = np.array([16.0, 64.0]) # camera [pitch, yaw] -> input
61
+ KEYS = {"forward": 1, "back": 2, "left": 4, "right": 8, "jump": 16, "sprint": 32, "sneak": 64} # VPT key bitmask
62
+ NOOP = {"ESC": 0, "attack": 0, "back": 0, "forward": 0, "left": 0, "right": 0, "jump": 0, "camera": [0.0, 0.0],
63
+ "drop": 0, **{f"hotbar.{i}": 0 for i in range(1, 10)}, "inventory": 0, "pickItem": 0, "sneak": 0,
64
+ "sprint": 0, "swapHands": 0, "use": 0}
65
+
66
+
67
+ def move_class(keys):
68
+ """MOVES index of a VPT key bitmask."""
69
+ masks = [sum(KEYS[k] for k in m) for m in MOVES]
70
+ for pair in (3, 12): # forward+back, left+right
71
+ if keys & pair == pair:
72
+ keys &= ~pair
73
+ for drop in (0, 64, 32, 16):
74
+ keys &= ~drop
75
+ if keys in masks:
76
+ return masks.index(keys)
77
+
78
+
79
+ MOVE_OF_KEYS = np.array([move_class(k) for k in range(128)])
80
+
81
+
82
+ def cam_bin(deg, bins):
83
+ """Bin index (into YAWS or PITCHES) of camera deltas in degrees."""
84
+ j = np.clip(np.round(np.log2(np.maximum(np.abs(deg), 1e-9) / 0.5)), 0, len(bins) // 2 - 1)
85
+ return (len(bins) // 2 + np.sign(deg) * (j + 1)).astype(int)
86
+
87
+
88
+ def gt_features(h, v):
89
+ """GT perception (..., 3): p, h/HALF_HFOV, v/HALF_VFOV in the geometric field of view, else 0."""
90
+ seen = (np.abs(h) <= HALF_HFOV) & (np.abs(v) <= HALF_VFOV)
91
+ return np.stack([seen, h / HALF_HFOV * seen, v / HALF_VFOV * seen], -1).astype(float)
92
+
93
+
94
+ def rows(target, prev_cam):
95
+ """Window steps (..., 5) from target features (..., 3) and the previous camera action [pitch, yaw] (..., 2)."""
96
+ return np.concatenate([target, (prev_cam / CAM_SCALE)[..., ::-1]], -1)
97
+
98
+
99
+ def sector(yaw):
100
+ """World-yaw sector of a yaw in degrees as logged (unwrapped too): 8 sectors of 45 deg, sector 0 = -22.5..22.5."""
101
+ return int(((yaw + 22.5) % 360) // 45)
102
+
103
+
104
+ def cell(x, z):
105
+ """4x4 XZ cell of a position, as bench/find_metrics.py."""
106
+ return math.floor(x / 4), math.floor(z / 4)
107
+
108
+
109
+ def memory(sectors, cells, pose):
110
+ """The 16 memory inputs at pose: for the directions t = yaw + 45 i, i = 0..7 (> 0 turns right), 1 if sector(t) is
111
+ in sectors, then 1 if the cell 4 blocks along t (horizontal direction dx = -sin t, dz = cos t) is in cells."""
112
+ yaws = [pose["yaw"] + 45 * i for i in range(8)]
113
+ return np.array([sector(t) in sectors for t in yaws]
114
+ + [cell(pose["x"] - 4 * math.sin(math.radians(t)), pose["z"] + 4 * math.cos(math.radians(t)))
115
+ in cells for t in yaws], dtype=float)
116
+
117
+
118
+ def logits(w, x):
119
+ """(move, yaw, pitch) logits for inputs x (..., 100, or 116 with memory); w = the exported npz."""
120
+ z = np.maximum(x @ w["w0"] + w["b0"], 0)
121
+ z = np.maximum(z @ w["w1"] + w["b1"], 0)
122
+ return np.split(z @ w["w2"] + w["b2"], [len(MOVES), len(MOVES) + len(YAWS)], -1)
123
+
124
+
125
+ def log_softmax(z):
126
+ z = z - z.max(-1, keepdims=True)
127
+ return z - np.log(np.exp(z).sum(-1, keepdims=True))
128
+
129
+
130
+ def action(move, yaw, pitch):
131
+ """MineRL action dict of the three bins."""
132
+ a = dict(NOOP, camera=[float(PITCHES[pitch]), float(YAWS[yaw])])
133
+ a.update({key: 1 for key in MOVES[move]})
134
+ return a
135
+
136
+
137
+ def target_name(task):
138
+ """Name asked about a milestone task: "find X" -> X with underscores as spaces (diagnoses B, C and step 1)."""
139
+ return task[5:].removeprefix("a ").replace("_", " ")
140
+
141
+
142
+ def current_target(checker, presatisfied):
143
+ """(milestone index, absolute target [x, y, z], name) of the current target."""
144
+ for i, m in enumerate(checker._milestones):
145
+ if m.get("rules") and m["milestone_id"] not in presatisfied and not checker._completed[i]:
146
+ params = m["rules"][0]["params"]
147
+ target = params["target"]
148
+ if params.get("coordinate_frame") == "spawn_relative":
149
+ o = checker._spawn_pos
150
+ target = [target[0] + o["x"], target[1] + o["y"], target[2] + o["z"]]
151
+ return i, target, target_name(m["task"])
152
+
153
+
154
+ def box_angles(box):
155
+ """(h, v) of a box [x1, y1, x2, y2] in Qwen's relative 0-1000 coordinates: its center through the pinhole model
156
+ (exact at pitch 0), as step 1."""
157
+ x1, y1, x2, y2 = box
158
+ return (math.degrees(math.atan(((x1 + x2) / 1000 - 1) * TAN_HALF_HFOV)),
159
+ math.degrees(math.atan((1 - (y1 + y2) / 1000) * math.tan(math.radians(HALF_VFOV)))))
160
+
161
+
162
+ def chat(client, model, frame, prompt, max_tokens, start=None, **kwargs):
163
+ """One question about one frame (base64 PNG), thinking off, temperature 0, as steps 1 and 1b asked it. start: the
164
+ beginning of the answer, which the model continues (the reply is the continuation, its stop string included)."""
165
+ messages = [{"role": "user", "content": [
166
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + frame}},
167
+ {"type": "text", "text": prompt}]}]
168
+ extra = {"chat_template_kwargs": {"enable_thinking": False}}
169
+ if start:
170
+ messages.append({"role": "assistant", "content": start})
171
+ extra.update(continue_final_message=True, add_generation_prompt=False, include_stop_str_in_output=True)
172
+ return client.chat.completions.create(model=model, temperature=0, max_tokens=max_tokens, messages=messages,
173
+ extra_body=extra, **kwargs).choices[0]
174
+
175
+
176
+ def ground(client, model, frame, name):
177
+ """Step 1's grounding question, max_tokens 48 -> (reply, first box or None, h, v); h = v = 0 without a box.
178
+ The answer is started as START, how 15,505 of the first 15,533 replies of the BC find25 began (the other 28, 20
179
+ of them about melon, said there is none and now get a box), so the model writes the first box's four numbers and
180
+ stops at the first "]"; the prompt is then the question's followed by the tokens the model wrote for START
181
+ (vLLM /tokenize). reply: START and the model's numbers."""
182
+ reply = START + chat(client, model, frame, GROUND.format(name), 48, start=START, stop=["]"]).message.content
183
+ box = BOX.search(reply)
184
+ if not box:
185
+ return reply, None, 0.0, 0.0
186
+ box = [float(c) for c in box.groups()]
187
+ return reply, box, *box_angles(box)
188
+
189
+
190
+ def presence(client, model, frame, name):
191
+ """Step 1b's s_yes: diagnosis B's yes/no question, max_tokens 1, top 20 logprobs; P(yes) / (P(yes) + P(no)) with
192
+ case and leading-space variants summed."""
193
+ top = chat(client, model, frame, YESNO.format(name), 1, logprobs=True, top_logprobs=20).logprobs.content[0]
194
+ p = {w: sum(math.exp(t.logprob) for t in top.top_logprobs if t.token.strip().lower() == w) for w in ("yes", "no")}
195
+ return p["yes"] / (p["yes"] + p["no"])
196
+
197
+
198
+ def vlm_features(client, model, frame, name):
199
+ """VLM perception -> (p, h/HALF_HFOV, v/HALF_VFOV), log {reply, box, p}; the two questions are sent at once."""
200
+ with ThreadPoolExecutor(1) as pool:
201
+ grounding = pool.submit(ground, client, model, frame, name)
202
+ p = presence(client, model, frame, name)
203
+ reply, box, h, v = grounding.result()
204
+ return np.array([p, h / HALF_HFOV, v / HALF_VFOV]), {"reply": reply, "box": box, "p": round(p, 4)}
205
+
206
+
207
+ def carry(p, box, yaw, pitch, pose):
208
+ """Features at pose of a VLM answer (p, first box or None) given at view yaw, pitch, as the C_vlm simulation keeps
209
+ a box's world point (train/ctrl.py simulate()): h moves by -delta yaw, v by +delta pitch, the box center stays in
210
+ the image; p is held, and without a box h = v = 0."""
211
+ if box is None:
212
+ return np.array([p, 0.0, 0.0])
213
+ h, v = box_angles(box)
214
+ h = np.clip(h - ((pose["yaw"] - yaw + 180) % 360 - 180), -HALF_HFOV, HALF_HFOV)
215
+ v = np.clip(v + pose["pitch"] - pitch, -HALF_VFOV, HALF_VFOV)
216
+ return np.array([p, h / HALF_HFOV, v / HALF_VFOV])
217
+
218
+
219
+ class Policy:
220
+ """The controller for one episode. seed: anything np.random.default_rng takes (eval: [seed, task id]);
221
+ perception "gt" or "vlm" (url and model of the perception server)."""
222
+
223
+ def __init__(self, npz, seed, perception="gt", url=None, model=None):
224
+ self.w = dict(np.load(npz))
225
+ self.rng = np.random.default_rng(seed)
226
+ self.perception, self.model = perception, model
227
+ self.client = OpenAI(api_key="EMPTY", base_url=url) if perception == "vlm" else None
228
+ self.window = deque((np.zeros(5) for _ in range(WINDOW)), maxlen=WINDOW)
229
+ self.target, self.cam = None, [0.0, 0.0]
230
+ self.asked, self.held = None, 0 # vlm: (name, p, box, yaw, pitch) of the last call; steps since it
231
+ self.memory = len(self.w["w0"]) == 5 * WINDOW + 16
232
+ self.sectors, self.cells = set(), set() # memory: view sectors and cells of the episode's poses so far
233
+
234
+ def vlm_features(self, frame, name):
235
+ """The VLM perception call (train/optimus_policy.py ExpertPerceptionPolicy overrides it)."""
236
+ return vlm_features(self.client, self.model, frame, name)
237
+
238
+ def act(self, frame, info, checker, presatisfied):
239
+ """frame: this step's frame as the eval client sends it (base64 PNG, 320x180; used by vlm); info: the env
240
+ info before the action (player_pos); checker: the episode's MilestoneChecker; presatisfied: ids of milestones
241
+ satisfied at spawn. Returns (MineRL action dict, step log)."""
242
+ index, target, name = current_target(checker, presatisfied)
243
+ if index != self.target:
244
+ for r in self.window:
245
+ r[:3] = 0
246
+ self.target = index
247
+ pose = info["player_pos"]
248
+ dist, h, v = view(pose, target)
249
+ if self.perception == "gt":
250
+ feat, raw = gt_features(h, v), None
251
+ elif self.asked and self.asked[0] == name and self.held < PERCEIVE_EVERY - 1:
252
+ self.held += 1
253
+ feat, raw = carry(*self.asked[1:], pose), None
254
+ else:
255
+ feat, raw = self.vlm_features(frame, name)
256
+ self.asked, self.held = (name, feat[0], raw["box"], pose["yaw"], pose["pitch"]), 0
257
+ self.window.append(rows(feat, np.array(self.cam)))
258
+ x = np.concatenate(self.window)
259
+ if self.memory:
260
+ self.sectors.add(sector(pose["yaw"]))
261
+ self.cells.add(cell(pose["x"], pose["z"]))
262
+ x = np.concatenate([x, memory(self.sectors, self.cells, pose)])
263
+ x = np.round(x, 4)
264
+ heads = [log_softmax(z) for z in logits(self.w, x)]
265
+ bins = [int(self.rng.choice(len(lp), p=np.exp(lp))) for lp in heads]
266
+ a = action(*bins)
267
+ self.cam = a["camera"]
268
+ return a, {"x": x.tolist(), "target": index, "bins": bins,
269
+ "logp": float(sum(lp[b] for lp, b in zip(heads, bins))), "perception": raw, "held": self.held,
270
+ "truth": {"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2), "h": round(h, 2),
271
+ "v": round(v, 2)}}
272
+
273
+
274
+ # BT arm (EXPERIMENT_LOG.md 2026-10-17): the VLM (Qwen3.5-9B + LoRA) reads the current frame and the target name and
275
+ # writes a behavior code, one digit per FSQ dimension of train/bt_tokenizer.py; the next CHUNK ticks run open loop,
276
+ # each tick's three bins sampled from the decoder's distributions for the code (table.npz probs, mixed radix index).
277
+ CHUNK = 20
278
+ BT_PROMPT = "Task: find {}. Reply with the behavior code for the next second."
279
+ BT_MEMORY = " Seen directions (0, 45, ..., 315 deg right of view): {}. Visited cells 4 blocks along them: {}."
280
+ BT_NOOP = [0, len(YAWS) // 2, len(PITCHES) // 2]
281
+
282
+
283
+ def bt_prompt(name, mem=None):
284
+ """The BT text prompt for target name; with mem (the two 8-character 0/1 strings of memory(), directions then
285
+ cells) BT_MEMORY follows."""
286
+ return BT_PROMPT.format(name) + (BT_MEMORY.format(*mem) if mem else "")
287
+
288
+
289
+ def bt_code(reply, levels):
290
+ """Table index of a reply: its first len(levels) characters as digits, each clipped to its level; None if they
291
+ are not all digits."""
292
+ digits = reply[:len(levels)]
293
+ if len(digits) < len(levels) or not digits.isdigit():
294
+ return None
295
+ index = 0
296
+ for d, level in zip(digits, levels):
297
+ index = index * level + min(int(d), level - 1)
298
+ return index
299
+
300
+
301
+ class BTPolicy:
302
+ """The BT policy for one episode, with Policy's act(). table: the tokenizer's decode-table npz; url, model: the
303
+ vLLM serving the LoRA. seed: None (GRPO rollouts) or [eval seed, task id]; decision k of the episode then samples
304
+ with seed eval_seed * 10**7 + task_id * 1000 + k, and the tick bins with a numpy Generator seeded by
305
+ eval_seed * 10**7 + task_id * 1000. Asks on the episode's first step, every CHUNK ticks and when the current target
306
+ changes; an unparseable reply runs CHUNK no-op ticks (valid False). memory True (the curiosity variant,
307
+ EXPERIMENT_LOG.md 2026-10-18): every step adds the pose's sector and cell to the episode's sets as Policy does, and
308
+ each decision's prompt carries memory() at that pose as two 0/1 strings (bt_prompt()).
309
+ Per-step log: target, bins, tick (index in the chunk), truth as Policy; on decision steps also reply, code (the
310
+ clipped digits), valid, logprobs (vLLM, per generated token), act_s (seconds of the call) and, with memory, memory
311
+ (the two strings in the prompt)."""
312
+
313
+ def __init__(self, table, seed, url, model, memory=False):
314
+ t = np.load(table)
315
+ self.levels, self.probs = [int(x) for x in t["levels"]], t["probs"]
316
+ self.seed = None if seed is None else seed[0] * 10**7 + seed[1] * 1000
317
+ self.rng = np.random.default_rng(self.seed)
318
+ self.client, self.model = OpenAI(api_key="EMPTY", base_url=url), model
319
+ self.target, self.tick, self.decisions, self.chunk = None, CHUNK, 0, None
320
+ self.memory, self.sectors, self.cells = memory, set(), set()
321
+
322
+ def ask(self, frame, name, mem=None):
323
+ extra = {"chat_template_kwargs": {"enable_thinking": False}}
324
+ if self.seed is not None:
325
+ extra["seed"] = self.seed + self.decisions
326
+ choice = self.client.chat.completions.create(
327
+ model=self.model, temperature=1.0, max_tokens=len(self.levels), logprobs=True, extra_body=extra,
328
+ messages=[{"role": "user", "content": [
329
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64," + frame}},
330
+ {"type": "text", "text": bt_prompt(name, mem)}]}]).choices[0]
331
+ return choice.message.content, [t.logprob for t in choice.logprobs.content]
332
+
333
+ def act(self, frame, info, checker, presatisfied):
334
+ index, target, name = current_target(checker, presatisfied)
335
+ pose = info["player_pos"]
336
+ dist, h, v = view(pose, target)
337
+ log = {"target": index}
338
+ if self.memory:
339
+ self.sectors.add(sector(pose["yaw"]))
340
+ self.cells.add(cell(pose["x"], pose["z"]))
341
+ if index != self.target or self.tick == CHUNK:
342
+ t = time.monotonic()
343
+ mem = None
344
+ if self.memory:
345
+ bits = "".join(str(int(b)) for b in memory(self.sectors, self.cells, pose))
346
+ mem = log["memory"] = [bits[:8], bits[8:]]
347
+ reply, logprobs = self.ask(frame, name, mem)
348
+ code = bt_code(reply, self.levels)
349
+ self.chunk = self.probs[code] if code is not None else None
350
+ self.target, self.tick = index, 0
351
+ self.decisions += 1
352
+ log.update(reply=reply, valid=code is not None, logprobs=logprobs, act_s=round(time.monotonic() - t, 3),
353
+ code=code is not None and "".join(str(min(int(d), l - 1)) for d, l in zip(reply, self.levels))
354
+ or None)
355
+ bins = BT_NOOP if self.chunk is None else [
356
+ int(self.rng.choice(len(p), p=p / p.sum()))
357
+ for p in np.split(self.chunk[self.tick].astype(float), [len(MOVES), len(MOVES) + len(YAWS)])]
358
+ log.update(bins=bins, tick=self.tick, truth={"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2),
359
+ "h": round(h, 2), "v": round(v, 2)})
360
+ self.tick += 1
361
+ return action(*bins), log
362
+
363
+
364
+ class NoopPolicy:
365
+ """Every step the no-op action (kind "noop")."""
366
+
367
+ def act(self, frame, info, checker, presatisfied):
368
+ return dict(NOOP), {}
369
+
370
+
371
+ def make_policy(kind="ctrl", **kwargs):
372
+ """Policy (kind "ctrl") or BTPolicy (kind "bt") from an MC_POLICY / rollout job policy dict; kinds "hid" and
373
+ "hid_teacher": the hidden-state arms' policies (train/hid_policy.py)."""
374
+ if kind.startswith("hid"):
375
+ from hid_policy import make
376
+ return make(kind, **kwargs)
377
+ if kind == "box": # task 4b (EXPERIMENT_LOG.md 2026-10-19): train/box_policy.py
378
+ from box_policy import BoxPolicy
379
+ return BoxPolicy(**kwargs)
380
+ if kind == "noop": # what a 1B expert that never writes parseable JSON amounts to (EXPERIMENT_LOG.md 2026-10-19)
381
+ return NoopPolicy()
382
+ if kind == "openha": # OpenHA baseline (EXPERIMENT_LOG.md 2026-10-19): train/openha_policy.py
383
+ from openha_policy import OpenHAPolicy
384
+ return OpenHAPolicy(**kwargs)
385
+ if kind.startswith("optimus"): # task 1A (EXPERIMENT_LOG.md 2026-10-19): train/optimus_policy.py
386
+ import optimus_policy
387
+ return (optimus_policy.OptimusActionPolicy if kind == "optimus_action"
388
+ else optimus_policy.ExpertPerceptionPolicy)(**kwargs)
389
+ return BTPolicy(**kwargs) if kind == "bt" else Policy(**kwargs)
code/train/grpo_locations.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"0004": [3630.5, 70.0, 1967.5], "0005": [4163.5, 63.0, 1937.5], "0007": [4990.5, 68.0, 1967.5], "0008": [5390.5, 67.0, 1967.5], "0012": [6990.5, 64.0, 1967.5], "0017": [9150.5, 68.0, 1967.5], "0022": [11150.5, 63.0, 1967.5], "0024": [11630.5, 79.0, 1967.5], "0025": [12190.5, 70.0, 1967.5], "0039": [4830.5, 64.0, 2367.5], "0045": [7390.5, 88.0, 2367.5], "0051": [9630.5, 68.0, 2367.5], "0053": [10430.5, 88.0, 2367.5], "0064": [2030.5, 63.0, 2767.5], "0071": [4830.5, 74.0, 2767.5], "0072": [5390.5, 69.0, 2767.5], "0075": [6430.5, 64.0, 2767.5], "0077": [7230.5, 95.0, 2767.5], "0083": [9618.5, 70.0, 2806.5], "0099": [3390.5, 80.0, 3167.5], "0100": [3950.5, 87.0, 3167.5], "0101": [4190.5, 79.0, 3167.5], "0114": [9230.5, 64.0, 3167.5], "0126": [14350.5, 70.0, 3167.5], "0131": [3390.5, 81.0, 3567.5], "0132": [3950.5, 67.0, 3567.5], "0135": [5150.5, 79.0, 3567.5], "0144": [8430.5, 64.0, 3567.5], "0153": [11985.5, 68.0, 3662.5], "0157": [13630.5, 66.0, 3567.5], "0167": [5150.5, 64.0, 3967.5], "0174": [7630.5, 63.0, 3967.5], "0187": [12785.5, 78.0, 4062.5], "0190": [14030.5, 67.0, 3967.5], "0204": [6830.5, 64.0, 4367.5], "0205": [7550.5, 70.0, 4367.5], "0214": [10830.5, 69.0, 4367.5], "0215": [11390.5, 63.0, 4367.5], "0218": [12590.5, 77.0, 4367.5], "0222": [13985.5, 68.0, 4462.5], "0226": [2785.5, 94.0, 4862.5], "0227": [3230.5, 70.0, 4767.5], "0228": [3630.5, 67.0, 4767.5], "0229": [3985.5, 67.0, 4862.5], "0245": [10545.5, 73.0, 4862.5], "0248": [11790.5, 87.0, 4767.5], "0250": [12430.5, 111.0, 4767.5], "0256": [2030.5, 68.0, 5167.5], "0257": [2430.5, 71.0, 5167.5], "0258": [2785.5, 63.0, 5262.5], "0264": [5230.5, 64.0, 5167.5], "0268": [6830.5, 76.0, 5167.5], "0269": [7390.5, 83.0, 5167.5], "0275": [9950.5, 65.0, 5167.5], "0277": [10430.5, 68.0, 5167.5], "0278": [10990.5, 73.0, 5167.5], "0283": [12830.5, 70.0, 5167.5], "0288": [2350.5, 77.0, 5567.5], "0290": [2830.5, 71.0, 5567.5], "0293": [4350.5, 79.0, 5567.5], "0301": [7230.5, 89.0, 5567.5], "0302": [7790.5, 74.0, 5567.5], "0306": [9550.5, 69.0, 5567.5], "0307": [9790.5, 68.0, 5567.5], "0325": [4030.5, 74.0, 5967.5], "0335": [8063.5, 68.0, 5998.5], "0340": [10350.5, 70.0, 5967.5], "0344": [11790.5, 66.0, 5967.5], "0350": [13985.5, 65.0, 6062.5], "0351": [14463.5, 76.0, 5998.5], "0354": [2830.5, 71.0, 6367.5], "0355": [3230.5, 82.0, 6367.5], "0361": [5709.5, 68.0, 6416.5], "0364": [7023.5, 64.0, 6398.5], "0369": [9150.5, 69.0, 6367.5], "0374": [10830.5, 70.0, 6367.5], "0387": [3230.5, 69.0, 6767.5], "0402": [9230.5, 65.0, 6767.5], "0415": [14371.5, 63.0, 6752.5], "0424": [5218.5, 70.0, 7206.5], "0429": [7465.5, 66.0, 7181.5], "0433": [8990.5, 72.0, 7167.5], "0435": [9630.5, 70.0, 7167.5], "0436": [10190.5, 77.0, 7167.5], "0438": [10830.5, 69.0, 7167.5], "0440": [11790.5, 75.0, 7167.5], "0443": [12830.5, 67.0, 7167.5], "0445": [13745.5, 63.0, 7262.5], "0452": [3630.5, 92.0, 7567.5], "0454": [4418.180300335881, 67.0, 7606.180300335881], "0467": [9790.5, 75.0, 7567.5], "0469": [10430.5, 73.0, 7567.5], "0474": [12590.5, 64.0, 7567.5], "0478": [14030.5, 86.0, 7567.5], "0479": [14750.5, 67.0, 7567.5], "0483": [3390.5, 67.0, 7967.5], "0484": [3790.5, 69.0, 7967.5], "0486": [4578.5, 66.0, 8006.5], "0494": [7630.5, 86.0, 7967.5], "0497": [9150.5, 67.0, 7967.5], "0503": [11230.5, 80.0, 7967.5], "0509": [13709.5, 63.0, 8016.5], "0515": [3550.5, 70.0, 8367.5], "0516": [3630.5, 64.0, 8367.5], "0521": [5950.5, 74.0, 8367.5], "0523": [6430.5, 81.0, 8367.5], "0524": [6830.5, 75.0, 8367.5], "0528": [8430.5, 66.0, 8367.5], "0544": [2350.5, 72.0, 8767.5], "0550": [4430.5, 72.0, 8767.5], "0551": [4830.5, 74.0, 8767.5], "0553": [5630.5, 63.0, 8767.5], "0554": [6030.5, 83.0, 8767.5], "0558": [7630.5, 76.0, 8767.5], "0569": [12030.5, 73.0, 8767.5], "0572": [13345.5, 75.0, 8862.5], "0576": [2350.5, 68.0, 9167.5], "0577": [2430.5, 63.0, 9167.5], "0578": [2990.5, 64.0, 9167.5], "0585": [5790.5, 64.0, 9167.5], "0593": [9065.5, 67.0, 9181.5], "0598": [10990.5, 68.0, 9167.5], "0608": [2030.5, 69.0, 9567.5], "0618": [6030.5, 76.0, 9567.5], "0620": [6978.5, 70.0, 9606.5], "0625": [8830.5, 70.0, 9567.5], "0628": [10350.5, 65.0, 9567.5], "0631": [11230.5, 70.0, 9567.5], "0632": [11745.5, 66.0, 9662.5], "0635": [12990.5, 64.0, 9567.5], "0637": [13630.5, 63.0, 9567.5], "0639": [14430.5, 72.0, 9567.5], "0640": [2190.5, 72.0, 9967.5], "0645": [4030.5, 63.0, 9967.5], "0648": [5230.5, 80.0, 9967.5], "0652": [6830.5, 73.0, 9967.5], "0667": [12830.5, 68.0, 9967.5], "0671": [14430.5, 73.0, 9967.5], "0672": [2030.5, 89.0, 10367.5], "0676": [3630.5, 70.0, 10367.5], "0677": [4190.5, 71.0, 10367.5], "0681": [5630.5, 80.0, 10367.5], "0684": [6990.5, 74.0, 10367.5], "0694": [10990.5, 70.0, 10367.5], "0697": [12350.5, 85.0, 10367.5], "0700": [13390.5, 85.0, 10367.5], "0703": [14738.5, 63.0, 10406.5], "0704": [2030.5, 78.0, 10767.5], "0705": [2430.5, 73.0, 10767.5], "0710": [4590.5, 79.0, 10767.5], "0712": [5390.5, 63.0, 10767.5], "0714": [6190.5, 64.0, 10767.5], "0722": [9390.5, 70.0, 10767.5], "0731": [13150.5, 69.0, 10767.5], "0741": [4030.5, 68.0, 11167.5], "0747": [6418.5, 63.0, 11206.5], "0756": [10145.5, 69.0, 11262.5], "0765": [13630.5, 71.0, 11167.5], "0767": [14750.5, 64.0, 11167.5], "0770": [2990.5, 91.0, 11567.5], "0771": [3390.5, 66.0, 11567.5], "0775": [4830.5, 71.0, 11567.5], "0788": [10190.5, 74.0, 11567.5], "0790": [10818.5, 77.0, 11606.5], "0792": [11950.5, 64.0, 11567.5], "0797": [13790.5, 69.0, 11567.5], "0800": [2030.5, 67.0, 11967.5], "0801": [2430.5, 67.0, 11967.5], "0802": [2830.5, 72.0, 11967.5], "0807": [5150.5, 67.0, 11967.5], "0808": [5230.5, 72.0, 11967.5]}
code/train/hid_policy.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policies of the hidden-state arms (EXPERIMENT_LOG.md 2026-10-19, tasks 3 and 4a); eval venv (numpy, requests).
2
+
3
+ HidPolicy (MC_POLICY / rollout kind "hid"): the controller's input is [feature (4096), camera history (40)] -> MLP
4
+ (train/hid_bc.py Net: 256 -> 256 -> A's three heads, exported as ctrl_policy's npz layout, read by ctrl_policy.logits).
5
+ - feature: train/hid_server.py's Qwen3.5-9B hidden state for (the current frame, the current target name), asked on
6
+ A's perception schedule (the episode's first step, when the target name changes, PERCEIVE_EVERY steps after the
7
+ last call) and held unchanged in between;
8
+ - camera history: A's window camera part, the previous camera action [yaw/64, pitch/16] of the last 20 steps oldest
9
+ first (zeros before the episode start; not reset on a target change), rounded to 4 decimals as A's x.
10
+ Sampling as ctrl_policy.Policy (three heads, numpy Generator seeded by seed). url: the feature server (a list: one
11
+ picked per process). Options: log_feat logs the feature (base64 bf16 bits) on asking steps; frames marks asking
12
+ steps with "frame" (train/grpo_rollout.py then saves the PNG); ref (an npz) asks the server for the base feature too
13
+ and logs ref, the 45 log-probs of that controller on [base feature, camera history] (the 4a KL reference).
14
+ Per-step log: target, bins, logp, cam (the 40 history values), held (0 on asking steps), feat_s on asking steps
15
+ (seconds of the call), truth as Policy; plus feat / frame / ref as above.
16
+
17
+ Teacher (kind "hid_teacher"): A's controller (ctrl_policy.Policy, gt perception) whose log marks with "frame" the
18
+ steps where A's vlm schedule would ask (the BC demonstrations, train/hid_bc.py collect).
19
+ """
20
+ import base64
21
+ from collections import deque
22
+ import os
23
+ import time
24
+
25
+ import numpy as np
26
+ import requests
27
+
28
+ from ctrl_policy import (CAM_SCALE, PERCEIVE_EVERY, WINDOW, Policy, action, current_target, gt_features, log_softmax,
29
+ logits, view)
30
+
31
+
32
+ def decode(s):
33
+ """base64 bf16 bits -> float32 vector (exact)."""
34
+ return (np.frombuffer(base64.b64decode(s), np.uint16).astype(np.uint32) << 16).view(np.float32)
35
+
36
+
37
+ class HidPolicy:
38
+ def __init__(self, npz, seed, url, log_feat=False, frames=False, ref=None):
39
+ self.w = dict(np.load(npz))
40
+ self.ref = dict(np.load(ref)) if ref else None
41
+ self.rng = np.random.default_rng(seed)
42
+ urls = url if isinstance(url, list) else [url]
43
+ self.url = urls[os.getpid() % len(urls)]
44
+ self.log_feat, self.frames = log_feat, frames
45
+ self.cams = deque((np.zeros(2) for _ in range(WINDOW)), maxlen=WINDOW)
46
+ self.cam = [0.0, 0.0]
47
+ self.asked, self.held, self.feat, self.base = None, 0, None, None
48
+
49
+ def act(self, frame, info, checker, presatisfied):
50
+ index, target, name = current_target(checker, presatisfied)
51
+ pose = info["player_pos"]
52
+ dist, h, v = view(pose, target)
53
+ log = {}
54
+ if self.asked == name and self.held < PERCEIVE_EVERY - 1:
55
+ self.held += 1
56
+ else:
57
+ t = time.monotonic()
58
+ r = requests.post(self.url + "/feat", json={"frame": frame, "name": name}, timeout=600)
59
+ r.raise_for_status()
60
+ r = r.json()
61
+ self.feat = decode(r["feat"])
62
+ self.base = decode(r["base"]) if self.ref else None
63
+ self.asked, self.held = name, 0
64
+ log["feat_s"] = round(time.monotonic() - t, 3)
65
+ if self.log_feat:
66
+ log["feat"] = r["feat"]
67
+ if self.frames:
68
+ log["frame"] = True
69
+ self.cams.append(np.array(self.cam)[::-1] / CAM_SCALE[::-1])
70
+ cam = np.round(np.concatenate(self.cams), 4)
71
+ heads = [log_softmax(z) for z in logits(self.w, np.concatenate([self.feat, cam]))]
72
+ bins = [int(self.rng.choice(len(lp), p=np.exp(lp))) for lp in heads]
73
+ a = action(*bins)
74
+ self.cam = a["camera"]
75
+ if self.ref:
76
+ log["ref"] = np.round(np.concatenate([log_softmax(z) for z in logits(self.ref, np.concatenate(
77
+ [self.base, cam]))]), 4).tolist()
78
+ log.update(target=index, bins=bins, logp=float(sum(lp[b] for lp, b in zip(heads, bins))), cam=cam.tolist(),
79
+ held=self.held, truth={"seen": bool(gt_features(h, v)[0]), "dist": round(dist, 2),
80
+ "h": round(h, 2), "v": round(v, 2)})
81
+ return a, log
82
+
83
+
84
+ class Teacher(Policy):
85
+ def __init__(self, npz, seed):
86
+ super().__init__(npz, seed, "gt")
87
+ self.asked_name, self.since = None, 0
88
+
89
+ def act(self, frame, info, checker, presatisfied):
90
+ name = current_target(checker, presatisfied)[2]
91
+ a, log = super().act(frame, info, checker, presatisfied)
92
+ if self.asked_name == name and self.since < PERCEIVE_EVERY - 1:
93
+ self.since += 1
94
+ else:
95
+ self.asked_name, self.since = name, 0
96
+ log["frame"] = True
97
+ return a, log
98
+
99
+
100
+ def make(kind, **kwargs):
101
+ return Teacher(**kwargs) if kind == "hid_teacher" else HidPolicy(**kwargs)
code/train/hid_server.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hidden-state feature of the hidden-state arms (EXPERIMENT_LOG.md 2026-10-19, tasks 3 and 4a) and its HTTP server.
2
+ Runs in .train-venv.
3
+
4
+ Feature: Qwen3.5-9B's last-layer hidden state (after the final norm, i.e. the lm_head input; 4096 dims) at the last
5
+ prompt token of A's grounding question (ctrl_policy.ground(): the frame, GROUND.format(name), thinking off, the answer
6
+ started as START, rendered by the chat template with continue_final_message as vLLM renders it), prefill only. Batches
7
+ are right-padded and each row's last real token is gathered. Values travel as base64 of the bf16 bits (encode/decode;
8
+ hid_policy.decode is the numpy twin), so a logged feature is exactly the one the controller used.
9
+
10
+ Server: one model replica per GPU (threads), a shared queue, each replica takes up to MAX_BATCH waiting requests per
11
+ forward. POST /feat {"frame": base64 PNG, "name": target name} -> {"feat": ...} and, with --base, also "base": the
12
+ feature with the adapter disabled (the 4a KL reference input). The port opens once every replica is loaded.
13
+ Usage: CUDA_VISIBLE_DEVICES=2 .train-venv/bin/python train/hid_server.py --port 9210 [--gpus 0] [--adapter DIR --base]
14
+ """
15
+ import argparse
16
+ import base64
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ import io
19
+ import json
20
+ from pathlib import Path
21
+ import queue
22
+ import sys
23
+ import threading
24
+ import time
25
+
26
+ import torch
27
+ from PIL import Image
28
+ from transformers import AutoProcessor
29
+
30
+ from bt_train import MODULES, load_model # noqa: F401 (MODULES: the LoRA targets, re-exported for hid_grpo)
31
+ from ctrl_policy import START
32
+
33
+ ROOT = Path(__file__).resolve().parents[1]
34
+ sys.path.append(str(ROOT / "bench"))
35
+ from diag_perception_direction import GROUND # noqa: E402
36
+
37
+ MAX_BATCH = 32
38
+
39
+
40
+ def processor():
41
+ return AutoProcessor.from_pretrained(ROOT / "models/Qwen3.5-9B", local_files_only=True)
42
+
43
+
44
+ def prompt(proc, name):
45
+ return proc.apply_chat_template(
46
+ [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": GROUND.format(name)}]},
47
+ {"role": "assistant", "content": START}],
48
+ tokenize=False, add_generation_prompt=False, continue_final_message=True, enable_thinking=False)
49
+
50
+
51
+ def batch_inputs(proc, images, names):
52
+ return dict(proc(text=[prompt(proc, n) for n in names], images=images, padding=True, padding_side="right",
53
+ return_tensors="pt"))
54
+
55
+
56
+ def features(model, inputs, device):
57
+ """(B, 4096) bf16 last-layer hidden states at each row's last prompt token; model: the Qwen3.5 model or its
58
+ PeftModel (grad follows the caller's mode)."""
59
+ body = (model.get_base_model() if hasattr(model, "get_base_model") else model).model
60
+ x = {k: v.to(device) for k, v in inputs.items()}
61
+ with torch.autocast("cuda", dtype=torch.bfloat16):
62
+ h = body(**x, use_cache=False).last_hidden_state
63
+ return h[torch.arange(len(h), device=device), x["attention_mask"].sum(1) - 1]
64
+
65
+
66
+ def encode(f):
67
+ """bf16 vector -> base64 of its bits."""
68
+ return base64.b64encode(f.to(torch.bfloat16).view(torch.int16).cpu().numpy().tobytes()).decode()
69
+
70
+
71
+ def decode(s):
72
+ return torch.frombuffer(bytearray(base64.b64decode(s)), dtype=torch.int16).view(torch.bfloat16)
73
+
74
+
75
+ def png(b64):
76
+ return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
77
+
78
+
79
+ def main():
80
+ ap = argparse.ArgumentParser()
81
+ ap.add_argument("--port", type=int, required=True)
82
+ ap.add_argument("--gpus", default="0", help="local cuda indices, one replica each")
83
+ ap.add_argument("--adapter", type=Path)
84
+ ap.add_argument("--base", action="store_true", help="also return the feature with the adapter disabled")
85
+ args = ap.parse_args()
86
+ proc = processor()
87
+ jobs = queue.Queue()
88
+
89
+ def replica(model, device):
90
+ while True:
91
+ items = [jobs.get()]
92
+ while len(items) < MAX_BATCH:
93
+ try:
94
+ items.append(jobs.get_nowait())
95
+ except queue.Empty:
96
+ break
97
+ try:
98
+ inputs = batch_inputs(proc, [png(i["frame"]) for i in items], [i["name"] for i in items])
99
+ with torch.no_grad():
100
+ out = {"feat": features(model, inputs, device)}
101
+ if args.base:
102
+ with model.disable_adapter():
103
+ out["base"] = features(model, inputs, device)
104
+ for k, item in enumerate(items):
105
+ item["out"] = {key: encode(f[k]) for key, f in out.items()}
106
+ except Exception as e: # answer every waiting request rather than leave it hanging
107
+ for item in items:
108
+ item["out"] = {"error": f"{type(e).__name__}: {e}"}
109
+ for item in items:
110
+ item["done"].set()
111
+
112
+ gpus = [int(g) for g in args.gpus.split(",")]
113
+ for g in gpus:
114
+ model = load_model(g)
115
+ if args.adapter:
116
+ from peft import PeftModel
117
+ model = PeftModel.from_pretrained(model, args.adapter)
118
+ model.eval()
119
+ threading.Thread(target=replica, args=(model, torch.device("cuda", g)), daemon=True).start()
120
+
121
+ class Handler(BaseHTTPRequestHandler):
122
+ def log_message(self, *a):
123
+ pass
124
+
125
+ def reply(self, code, body):
126
+ data = json.dumps(body).encode()
127
+ self.send_response(code)
128
+ self.send_header("Content-Type", "application/json")
129
+ self.send_header("Content-Length", str(len(data)))
130
+ self.end_headers()
131
+ self.wfile.write(data)
132
+
133
+ def do_GET(self):
134
+ self.reply(200, {"gpus": gpus})
135
+
136
+ def do_POST(self):
137
+ item = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
138
+ item["done"] = threading.Event()
139
+ jobs.put(item)
140
+ item["done"].wait()
141
+ self.reply(500 if "error" in item["out"] else 200, item["out"])
142
+
143
+ server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
144
+ server.daemon_threads = True
145
+ print(f"{time.strftime('%Y-%m-%d %H:%M:%S')} feature server on port {args.port}, gpus {gpus}, adapter "
146
+ f"{args.adapter}, base {args.base}", flush=True)
147
+ server.serve_forever()
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
patches/MineExplorer.patch ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ diff --git a/env/minerl_sandbox.py b/env/minerl_sandbox.py
2
+ index b74f0e8..b3f3860 100644
3
+ --- a/env/minerl_sandbox.py
4
+ +++ b/env/minerl_sandbox.py
5
+ @@ -194,7 +194,8 @@ class MineRLSandboxEnv(gym.Env):
6
+ for attempt in range(max_retries):
7
+ try:
8
+ logger.info(f"Resetting environment (attempt {attempt + 1}/{max_retries}) session_id={self.session_id}...")
9
+ - response = self._post("reset_env", timeout=180)
10
+ + # a client timeout must not trigger a retry while the server is still resetting (two resets at once)
11
+ + response = self._post("reset_env", timeout=int(os.getenv("MC_RESET_TIMEOUT", "180")))
12
+ if response.get("status") != 0:
13
+ raise RuntimeError(f"Server error during reset: {response.get('msg')}")
14
+ return self._get_obs_from_response(response), {}
15
+ diff --git a/eval_benchmark.py b/eval_benchmark.py
16
+ index 54487bd..a579b48 100644
17
+ --- a/eval_benchmark.py
18
+ +++ b/eval_benchmark.py
19
+ @@ -22,6 +22,10 @@ if str(_ROOT_DIR) not in sys.path:
20
+ from env.minerl_sandbox import MineRLSandboxEnv
21
+ from env.render import RenderWrapper
22
+ from mc_agent import DefaultAgent, MinerRLActionSpace, OpenAIProvider, VLLMProvider, DefaultContextBuilder
23
+ +from mc_agent.utils import convert_buffer_to_base64_images
24
+ +
25
+ +sys.path.append(str(_ROOT_DIR.parent / "train"))
26
+ +from ctrl_policy import make_policy # noqa: E402
27
+
28
+ FRAME_BUFFER_SIZE = 20
29
+ # MAX_STEPS = 50
30
+ @@ -45,13 +49,22 @@ class MineRLBenchmarkEnv(MineRLSandboxEnv):
31
+ raise FileNotFoundError(f"Metadata not found: {meta_path}")
32
+ with open(meta_path, "r", encoding="utf-8") as f:
33
+ self._metadata = json.load(f)
34
+ + self._scenario_id = meta_path.parent.parent.name
35
+ self._parse_metadata()
36
+ super().__init__(env_id=self._scene_name, use_friday=use_friday)
37
+
38
+ def _parse_metadata(self) -> None:
39
+ - self._commands_list = self._metadata.get("commands", [])
40
+ + # command feedback ("Changed the block at ...") covered the lower-left of the first ~150 frames
41
+ + self._commands_list = ["/gamerule sendCommandFeedback false"] + self._metadata.get("commands", [])
42
+ self._task_text = self._metadata.get("task_text", "")
43
+ self._scene_name = self._metadata.get("scene_name", "benchmark_scene")
44
+ + self._world_seed = 0
45
+ + if os.getenv("MC_EXPLORE_TASK"): # goal-free exploration: bare world, one fixed seed per scenario id
46
+ + self._commands_list = ["/gamerule sendCommandFeedback false", "/time set day"]
47
+ + self._task_text = os.environ["MC_EXPLORE_TASK"]
48
+ + self._world_seed = int(self._scenario_id) + 1
49
+ + if os.getenv("MC_START_YAW"): # start facing this yaw (the reversed-start find25 variant: 180)
50
+ + self._commands_list.append(f"/tp @p ~ ~ ~ {os.environ['MC_START_YAW']} 0")
51
+ logger.info(f"[BenchmarkEnv] scene={self._scene_name} commands={len(self._commands_list)}")
52
+ logger.info(f"[BenchmarkEnv] task: {self._task_text[:100]}")
53
+
54
+ @@ -61,7 +74,7 @@ class MineRLBenchmarkEnv(MineRLSandboxEnv):
55
+ env='MinecraftSim',
56
+ obs_size=[128, 128],
57
+ render_size=[640, 360],
58
+ - seed=0,
59
+ + seed=self._world_seed,
60
+ record=False,
61
+ record_path='./output/',
62
+ yaml_config=None,
63
+ @@ -118,6 +131,7 @@ def _run_benchmark(
64
+ vllm_url: str = "http://localhost:8000/v1",
65
+ frame_size: int = FRAME_BUFFER_SIZE,
66
+ use_friday: bool = False,
67
+ + end_on_esc: bool = False,
68
+ ) -> Dict[str, Any]:
69
+ """Run one benchmark scenario and save results."""
70
+ global _shutdown_requested
71
+ @@ -129,8 +143,15 @@ def _run_benchmark(
72
+ scene_id = meta_path.parent.parent.name
73
+ task_desc = metadata_dict.get("task_text", f"Complete the Minecraft task for {scene_id}.")
74
+ safe_model = model.replace("/", "_")
75
+ -
76
+ - if _HAS_MILESTONE_CHECKER:
77
+ + explore_task = os.getenv("MC_EXPLORE_TASK") # goal-free: no milestones, always runs max_steps
78
+ + # MC_POLICY (JSON: npz, perception gt|vlm, url and model of the perception server for vlm, seed) runs the
79
+ + # controller of train/ctrl_policy.py instead of agent.get_action; it samples with the seed and the scene id
80
+ + policy_cfg = json.loads(os.getenv("MC_POLICY", "null"))
81
+ + policy = policy_cfg and make_policy(**{**policy_cfg, "seed": [policy_cfg["seed"], int(scene_id)]})
82
+ + if explore_task:
83
+ + task_desc = explore_task
84
+ + checker = MilestoneChecker([])
85
+ + elif _HAS_MILESTONE_CHECKER:
86
+ try:
87
+ checker = MilestoneChecker.from_metadata(metadata_path)
88
+ logger.info(f"Loaded {len(checker._milestones)} milestones")
89
+ @@ -159,13 +180,16 @@ def _run_benchmark(
90
+
91
+ env = RenderWrapper(_base_env, save_messages=True, save_path=str(output_dir))
92
+
93
+ - frame_buffer = deque(maxlen=frame_size)
94
+ + # MC_FRAME_STRIDE=k feeds frame_size frames spaced k steps apart (default 1: consecutive).
95
+ + frame_stride = int(os.getenv("MC_FRAME_STRIDE", "1"))
96
+ + agent.frame_stride = frame_stride
97
+ + frame_buffer = deque(maxlen=(frame_size - 1) * frame_stride + 1)
98
+ thought_history: List[str] = []
99
+ action_history: List[Dict] = []
100
+ _frame_completed: Dict[str, Optional[int]] = {}
101
+ _presatisfied_ids: set = set()
102
+ _frame_offset: int = 0
103
+ - _all_done_logged: bool = False
104
+ + initial_player_pos: Dict[str, Any] = {}
105
+ long_term_memory: str = ""
106
+
107
+ original_sigint_handler = signal.signal(signal.SIGINT, _signal_handler)
108
+ @@ -173,7 +197,6 @@ def _run_benchmark(
109
+ try:
110
+ has_loading = loading_command_steps > 0
111
+ obs, info = env.reset(save_frame=not has_loading)
112
+ - frame_buffer.append(obs["pov"])
113
+
114
+ for step in range(loading_command_steps):
115
+ if _shutdown_requested:
116
+ @@ -190,8 +213,9 @@ def _run_benchmark(
117
+ if not info.get("player_pos"):
118
+ _, noop_action = agent.get_default_action(is_call_failed=False)
119
+ obs, reward, terminated, truncated, info = env.step(noop_action, save_frame=True)
120
+ - frame_buffer.append(obs["pov"])
121
+
122
+ + frame_buffer.append(obs["pov"])
123
+ + initial_player_pos = dict(info.get("player_pos", {}))
124
+ checker.reset(info)
125
+ agent.load_system_prompt(task_desc)
126
+
127
+ @@ -205,7 +229,8 @@ def _run_benchmark(
128
+ # agent takes any action). These are treated as "pre-satisfied" and
129
+ # will NOT be counted as completed — they are excluded via a blocklist
130
+ # so that _frame_completed is only set when the agent genuinely
131
+ - # triggers the condition during gameplay.
132
+ + # triggers the condition during gameplay. They must also be excluded
133
+ + # from the required targets, otherwise the episode can never succeed.
134
+ _baseline_check = checker.check(info)
135
+ _presatisfied_ids: set = set()
136
+ for _bms in _baseline_check:
137
+ @@ -217,11 +242,17 @@ def _run_benchmark(
138
+ )
139
+ # Reset the checker state so those pre-satisfied flags don't persist
140
+ checker.reset(info)
141
+ + _trackable = [
142
+ + m["milestone_id"] for m in checker._milestones
143
+ + if m.get("rules") and m["milestone_id"] not in _presatisfied_ids
144
+ + ]
145
+ + if not _trackable:
146
+ + logger.info(f"[{run_id}] No unsatisfied trackable targets at spawn; excluding episode from scores.")
147
+
148
+ step_error: Optional[str] = None
149
+ step = -1
150
+
151
+ - for step in range(max_steps):
152
+ + for step in range(max_steps if _trackable or explore_task else 0):
153
+ if _shutdown_requested:
154
+ logger.info(f"[{run_id}] Shutdown requested, exiting after step {step}...")
155
+ step_error = "interrupted_by_user"
156
+ @@ -230,10 +261,17 @@ def _run_benchmark(
157
+ logger.info(f"[{run_id}] --- Step {step + 1}/{max_steps} ---")
158
+
159
+ try:
160
+ - thought, action, memory_update = agent.get_action(
161
+ - list(frame_buffer), list(thought_history), list(action_history), step + 1,
162
+ - long_term_memory=long_term_memory
163
+ - )
164
+ + if policy:
165
+ + # a policy with raw_frame (train/optimus_policy.py) gets the env's RGB frame, not the 320x180 PNG
166
+ + frame = obs["pov"] if getattr(policy, "raw_frame", False) else \
167
+ + convert_buffer_to_base64_images([obs["pov"]])[0]
168
+ + action, policy_log = policy.act(frame, info, checker, _presatisfied_ids)
169
+ + thought = memory_update = ""
170
+ + else:
171
+ + thought, action, memory_update = agent.get_action(
172
+ + list(frame_buffer)[::-1][::frame_stride][::-1], list(thought_history), list(action_history),
173
+ + step + 1, long_term_memory=long_term_memory
174
+ + )
175
+ except Exception as agent_err:
176
+ logger.error(f"[{run_id}] Agent call failed: {agent_err}. Retrying in 10s...")
177
+ time.sleep(10)
178
+ @@ -249,6 +287,7 @@ def _run_benchmark(
179
+
180
+ thought_history.append(thought)
181
+ action_history.append(action)
182
+ + player_pos_before = dict(info.get("player_pos", {}))
183
+
184
+ try:
185
+ augmented_action = checker.augment_action_with_queries(action, info)
186
+ @@ -258,6 +297,13 @@ def _run_benchmark(
187
+ time.sleep(10)
188
+ continue
189
+
190
+ + with open(output_dir / "steps.jsonl", "a", encoding="utf-8") as _f:
191
+ + _f.write(json.dumps({"step": step + 1, "thought": thought, "memory": long_term_memory,
192
+ + "action": {k: v for k, v in action.items() if v not in (0, [0.0, 0.0], [0, 0])},
193
+ + "player_pos_before": player_pos_before,
194
+ + "player_pos_after": info.get("player_pos", {}),
195
+ + **({"policy": policy_log} if policy else {})},
196
+ + ensure_ascii=False) + "\n")
197
+ done = terminated or truncated
198
+ frame_buffer.append(obs["pov"])
199
+
200
+ @@ -272,11 +318,17 @@ def _run_benchmark(
201
+ _frame_completed[mid] = env.frame_count - _frame_offset
202
+ logger.success(f"[{run_id}] Milestone '{mid}' completed at step {step + 1} (frame {_frame_completed[mid]})")
203
+
204
+ - if not _all_done_logged and checker.num_completed() > 0 and checker.all_done():
205
+ - _all_done_logged = True
206
+ - logger.success(f"[{run_id}] All milestones completed at step {step + 1}!")
207
+ + if _trackable and all(m in _frame_completed for m in _trackable):
208
+ + logger.success(f"[{run_id}] Every trackable milestone done at step {step + 1}, stopping early.")
209
+ + break
210
+ +
211
+ + # the prompt says ESC ends the episode, but the released sandbox never reports done for it and
212
+ + # agents then press ESC for the rest of the budget; --end-on-esc ends it as the prompt promises
213
+ + if end_on_esc and action.get("ESC"):
214
+ + logger.info(f"[{run_id}] Agent pressed ESC at step {step + 1}, ending the episode.")
215
+ + break
216
+
217
+ - if (step + 1) % 10 == 0:
218
+ + if (step + 1) % 300 == 0:
219
+ env.save_video_checkpoint()
220
+
221
+ if done:
222
+ @@ -314,11 +366,12 @@ def _run_benchmark(
223
+ })
224
+
225
+ # Compute milestones_completed / all_milestones_done using only the
226
+ - # corrected _frame_completed dict (excludes pre-satisfied milestones).
227
+ + # corrected _frame_completed dict. Pre-satisfied milestones are excluded
228
+ + # from both the completed count and the number of required targets.
229
+ trackable_mids = {
230
+ ms.get("milestone_id", "")
231
+ for ms in checker._milestones
232
+ - if len(ms.get("rules", [])) > 0
233
+ + if len(ms.get("rules", [])) > 0 and ms.get("milestone_id", "") not in _presatisfied_ids
234
+ }
235
+ corrected_completed = sum(
236
+ 1 for mid in trackable_mids
237
+ @@ -338,6 +391,9 @@ def _run_benchmark(
238
+ "mode": "multi-agent",
239
+ "metadata_path": str(meta_path),
240
+ "total_steps": total_steps,
241
+ + "frame_size": frame_size,
242
+ + "frame_stride": frame_stride,
243
+ + "initial_player_pos": initial_player_pos,
244
+ "milestones_completed": corrected_completed,
245
+ "milestones_trackable": corrected_trackable,
246
+ "milestones_total": len(checker._milestones),
247
+ @@ -358,6 +414,8 @@ def _run_benchmark(
248
+ def _worker_eval(worker_args: dict) -> dict:
249
+ import signal as _signal
250
+ _signal.signal(_signal.SIGINT, _signal.SIG_IGN)
251
+ + if worker_args.get("sandbox_url"):
252
+ + os.environ["MC_SANDBOX_URL"] = worker_args["sandbox_url"]
253
+
254
+ _root = Path(__file__).resolve().parent
255
+ sys.path.append(str(_root))
256
+ @@ -372,6 +430,7 @@ def _worker_eval(worker_args: dict) -> dict:
257
+ vllm_url=worker_args.get("vllm_url", "http://localhost:8000/v1"),
258
+ frame_size=worker_args.get("frame_size", FRAME_BUFFER_SIZE),
259
+ use_friday=worker_args.get("use_friday", False),
260
+ + end_on_esc=worker_args.get("end_on_esc", False),
261
+ )
262
+
263
+
264
+ @@ -382,10 +441,12 @@ def eval_benchmark(
265
+ help="Path to benchmark directory"),
266
+ output_dir: str = typer.Option("outputs", "--output-dir", "-o",
267
+ help="Output directory"),
268
+ - loading_command_steps: int = typer.Option(20, "--loading-command-steps",
269
+ + loading_command_steps: int = typer.Option(200, "--loading-command-steps", # failed-command chat lines fade after 200 ticks
270
+ help="No-op steps after reset"),
271
+ max_steps: int = typer.Option(MAX_STEPS, "--max-steps",
272
+ help="Maximum agent steps per episode"),
273
+ + frame_size: int = typer.Option(FRAME_BUFFER_SIZE, "--frame-size", min=1,
274
+ + help="Number of recent observation frames"),
275
+ resume: bool = typer.Option(False, "--resume",
276
+ help="Resume evaluation (skip completed)"),
277
+ use_vllm: bool = typer.Option(False, "--use-vllm",
278
+ @@ -398,6 +459,10 @@ def eval_benchmark(
279
+ help="Limit number of scenarios to evaluate"),
280
+ use_friday: bool = typer.Option(False, "--use-friday",
281
+ help="Use Friday platform sandbox instead of local Docker"),
282
+ + tasks: Optional[str] = typer.Option(None, "--tasks",
283
+ + help="Comma-separated scenario ids to evaluate (default: all)"),
284
+ + end_on_esc: bool = typer.Option(False, "--end-on-esc",
285
+ + help="End the episode when the agent presses ESC"),
286
+ ):
287
+ """Evaluate all benchmark scenarios in benchmark_dir."""
288
+ logger.info(f"--- Starting evaluation (model={model}) ---")
289
+ @@ -418,6 +483,10 @@ def eval_benchmark(
290
+ logger.warning(f"No metadata.json found under {bench_path}")
291
+ return
292
+
293
+ + if tasks:
294
+ + wanted = set(tasks.split(","))
295
+ + metadata_entries = [e for e in metadata_entries if e[0] in wanted]
296
+ +
297
+ if limit and limit > 0:
298
+ metadata_entries = metadata_entries[:limit]
299
+
300
+ @@ -433,7 +502,18 @@ def eval_benchmark(
301
+ if resume and result_path.exists():
302
+ logger.info(f"[SKIP] {scene_num} - result.json exists")
303
+ with open(result_path, "r", encoding="utf-8") as f:
304
+ - all_results.append(json.load(f))
305
+ + summary = json.load(f)
306
+ + if not summary.get("error"):
307
+ + milestones = MilestoneChecker.from_metadata(str(meta_path))._milestones
308
+ + trackable = {m["milestone_id"] for m in milestones if m.get("rules")}
309
+ + statuses = summary["milestone_status"]
310
+ + trackable -= {m["milestone_id"] for m in statuses if m.get("presatisfied_at_spawn")}
311
+ + summary["milestones_trackable"] = len(trackable)
312
+ + summary["milestones_completed"] = sum(
313
+ + m["completed"] for m in statuses if m["milestone_id"] in trackable
314
+ + )
315
+ + summary["all_milestones_done"] = bool(trackable) and summary["milestones_completed"] == len(trackable)
316
+ + all_results.append(summary)
317
+ else:
318
+ pending.append((scene_num, meta_path))
319
+
320
+ @@ -466,9 +546,11 @@ def eval_benchmark(
321
+ output_dir=scene_out_dir,
322
+ loading_command_steps=loading_command_steps,
323
+ max_steps=max_steps,
324
+ + frame_size=frame_size,
325
+ use_vllm=use_vllm,
326
+ vllm_url=vllm_url,
327
+ use_friday=use_friday,
328
+ + end_on_esc=end_on_esc,
329
+ )
330
+ except Exception as e:
331
+ logger.error(f"[ERROR] {scene_num}: {e}")
332
+ @@ -485,51 +567,44 @@ def eval_benchmark(
333
+ else:
334
+ import multiprocessing as _mp
335
+
336
+ - effective_workers = min(num_workers, len(pending))
337
+ mp_ctx = _mp.get_context("spawn")
338
+ -
339
+ - remaining = list(pending)
340
+ - batch_num = 0
341
+ -
342
+ - while remaining:
343
+ - batch = remaining[:effective_workers]
344
+ - remaining = remaining[effective_workers:]
345
+ - batch_num += 1
346
+ -
347
+ - logger.info(f"\n[Batch {batch_num}] Launching {len(batch)} worker(s)")
348
+ -
349
+ - batch_jobs: List[Dict[str, Any]] = []
350
+ - for scene_num, meta_path in batch:
351
+ - scene_out_dir = out_root / scene_num
352
+ - scene_out_dir.mkdir(parents=True, exist_ok=True)
353
+ - batch_jobs.append({
354
+ - "metadata_path": str(meta_path),
355
+ - "model": model,
356
+ - "output_dir": str(scene_out_dir),
357
+ - "loading_command_steps": loading_command_steps,
358
+ - "max_steps": max_steps,
359
+ - "use_vllm": use_vllm,
360
+ - "vllm_url": vllm_url,
361
+ - "use_friday": use_friday,
362
+ - "_scene_num": scene_num,
363
+ - })
364
+ -
365
+ - with ProcessPoolExecutor(max_workers=len(batch_jobs), mp_context=mp_ctx) as pool:
366
+ - futures = {pool.submit(_worker_eval, job): job for job in batch_jobs}
367
+ - for future in as_completed(futures):
368
+ - job = futures[future]
369
+ - scene_num = job["_scene_num"]
370
+ - try:
371
+ - summary = future.result()
372
+ - all_results.append(summary)
373
+ - task_icon = "✅" if summary.get("all_milestones_done") else "❌"
374
+ - logger.info(f"[DONE] {scene_num} {task_icon} | milestones {summary.get('milestones_completed', 0)}/{summary.get('milestones_trackable', 0)}")
375
+ - except Exception as exc:
376
+ - logger.error(f"[ERROR] {scene_num}: {exc}")
377
+ - summary = _make_error_summary(scene_num, exc)
378
+ - all_results.append(summary)
379
+ -
380
+ - total = len(all_results)
381
+ + urls = [u for u in os.getenv("MC_SANDBOX_URLS", "").split(",") if u]
382
+ + jobs: List[Dict[str, Any]] = []
383
+ + for i, (scene_num, meta_path) in enumerate(pending):
384
+ + scene_out_dir = out_root / scene_num
385
+ + scene_out_dir.mkdir(parents=True, exist_ok=True)
386
+ + jobs.append({
387
+ + "metadata_path": str(meta_path),
388
+ + "model": model,
389
+ + "output_dir": str(scene_out_dir),
390
+ + "loading_command_steps": loading_command_steps,
391
+ + "max_steps": max_steps,
392
+ + "frame_size": frame_size,
393
+ + "use_vllm": use_vllm,
394
+ + "vllm_url": vllm_url,
395
+ + "use_friday": use_friday,
396
+ + "end_on_esc": end_on_esc,
397
+ + "sandbox_url": urls[i % len(urls)] if urls else None,
398
+ + "_scene_num": scene_num,
399
+ + })
400
+ +
401
+ + with ProcessPoolExecutor(max_workers=min(num_workers, len(jobs)), mp_context=mp_ctx) as pool:
402
+ + futures = {pool.submit(_worker_eval, job): job for job in jobs}
403
+ + for future in as_completed(futures):
404
+ + job = futures[future]
405
+ + scene_num = job["_scene_num"]
406
+ + try:
407
+ + summary = future.result()
408
+ + all_results.append(summary)
409
+ + task_icon = "✅" if summary.get("all_milestones_done") else "❌"
410
+ + logger.info(f"[DONE] {scene_num} {task_icon} | milestones {summary.get('milestones_completed', 0)}/{summary.get('milestones_trackable', 0)}")
411
+ + except Exception as exc:
412
+ + logger.error(f"[ERROR] {scene_num}: {exc}")
413
+ + summary = _make_error_summary(scene_num, exc)
414
+ + all_results.append(summary)
415
+ +
416
+ + scored_results = [r for r in all_results if r.get("milestones_trackable", 0) > 0 or r.get("error")]
417
+ + total = len(scored_results)
418
+ done = sum(1 for r in all_results if r.get("all_milestones_done"))
419
+ ms_done = sum(r.get("milestones_completed", 0) for r in all_results)
420
+ ms_total = sum(r.get("milestones_trackable", 0) for r in all_results)
421
+ @@ -539,7 +614,7 @@ def eval_benchmark(
422
+ logger.info("-" * 60)
423
+ task_rate = done / total * 100 if total > 0 else 0.0
424
+ ms_rate = ms_done / ms_total * 100 if ms_total > 0 else 0.0
425
+ - logger.info(f"Scenes: {total} | Tasks: {done}/{total} ({task_rate:.1f}%) | Milestones: {ms_done}/{ms_total} ({ms_rate:.1f}%)")
426
+ + logger.info(f"Scenes: {len(all_results)} | Eligible tasks: {done}/{total} ({task_rate:.1f}%) | Milestones: {ms_done}/{ms_total} ({ms_rate:.1f}%)")
427
+ logger.info(f"{'='*60}")
428
+
429
+ agg_path = out_root / "eval_summary.json"
430
+ @@ -548,6 +623,7 @@ def eval_benchmark(
431
+ "model": model,
432
+ "benchmark": str(bench_path),
433
+ "total": total,
434
+ + "excluded": len(all_results) - total,
435
+ "done": done,
436
+ "ms_done": ms_done,
437
+ "ms_total": ms_total,
patches/sandbox_mc_server.patch ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --- a/mc_server.py
2
+ +++ b/mc_server.py
3
+ @@ -118,6 +118,30 @@
4
+ raise RuntimeError("obs/info contains neither 'image' nor 'pov'")
5
+
6
+
7
+ +_INFO_SKIP = {"pov", "image", "location_stats", "equipped_items", "custom"}
8
+ +
9
+ +
10
+ +def info_for_client(info: dict) -> dict:
11
+ + """Judge-relevant fields of the sim info (the released server returned none)."""
12
+ + out = {k: convert_numpy_types(dict(v) if isinstance(v, dict) else v)
13
+ + for k, v in (info or {}).items() if k not in _INFO_SKIP}
14
+ + inv = {}
15
+ + for slot in (info or {}).get("inventory", {}).values():
16
+ + if slot.get("type") not in (None, "none", "air"):
17
+ + inv[slot["type"]] = inv.get(slot["type"], 0) + int(slot.get("quantity", 0))
18
+ + out["inventory"] = inv
19
+ + raw = (info or {}).get("inventory", {})
20
+ + hotbar = []
21
+ + for i in range(9):
22
+ + slot = raw.get(i) or raw.get(str(i)) or {}
23
+ + t = slot.get("type")
24
+ + hotbar.append(None if t in (None, "none", "air") else f"{t} x{int(slot.get('quantity', 0))}")
25
+ + out["hotbar"] = hotbar
26
+ + hand = (info or {}).get("equipped_items", {}).get("mainhand", {})
27
+ + out["mainhand"] = hand.get("type") if isinstance(hand, dict) else None
28
+ + return out
29
+ +
30
+ +
31
+ # =========================
32
+ # API 实现
33
+ # =========================
34
+ @@ -224,6 +248,10 @@
35
+ return err(f"session '{session_id}' not found")
36
+
37
+ try:
38
+ + if session.env_type == "minestudio":
39
+ + # MineRL clears its seed at the end of every reset (even a failed one), so a retried reset
40
+ + # would build a random world; re-apply the create_env seed each time
41
+ + session.env.env.seed(session.env.seed)
42
+ result = session.env.reset()
43
+
44
+ if isinstance(result, tuple):
45
+ @@ -276,6 +304,7 @@
46
+ "screenshot": screenshot,
47
+ "reward": float(reward),
48
+ "done": done,
49
+ + "info": info_for_client(info),
50
+ "timing": {
51
+ "env_step_ms": round((t2 - t1) * 1000, 1),
52
+ "encode_ms": round((t4 - t3) * 1000, 1),