yxc20098 commited on
Commit
c044b07
ยท
1 Parent(s): c68e036

Phase 2: human-labeling machine core

Browse files

Lets a human play the exact scenarios the LLM plays, for a direct
human-vs-LLM comparison on one leaderboard. New
openra_bench/human_labeling.py โ€” all headless-testable; a browser/
terminal UI is a thin shell over these pieces:

- minimap_click_to_cell / cell_to_minimap_rect: pure pixel<->cell
transforms, renderer-agnostic (parameterised by rendered image size),
out-of-bounds clicks clamp to the playable grid.
- own_units_at_cell / own_buildings_at_cell / enemy_at_cell:
click-to-select and click-to-target resolution against render_state.
- HumanAction + human_actions_to_commands: a human's point-and-click
gestures translated into engine Commands by delegating to the SAME
agent._to_commands the LLM path uses -> human and model emit
byte-identical commands.
- HumanController: satisfies the Phase 1 Controller contract, gets the
same observation the LLM gets (prompt_v2 briefing), runs turn-based,
and records a playback-compatible transcript so a human run renders
in the existing battle viewer beside model runs.
- ScriptedInputSource: deterministic input source for tests / replay.
- run_human_session: session entry point โ€” compiles a pack by id and
scores a human run through the identical run_level path as a model.

tests/test_human_labeling.py: 22 tests โ€” transforms (round-trip, edge
clamping, bad-input guards, renderer-agnosticism), click resolution,
every gesture's tool-call normalisation, malformed-gesture dropping,
Controller-contract conformance, and two end-to-end run_level runs.

openra_bench/human_labeling.py ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 โ€” the human-labeling machine.
2
+
3
+ Lets a human play the exact scenarios the LLM plays, for a direct
4
+ human-vs-LLM comparison on one leaderboard. Design constraints
5
+ (user-confirmed):
6
+
7
+ * the human gets the **same observation the LLM gets** (the rendered
8
+ minimap + the text briefing) โ€” the UI just displays what
9
+ `render_state` already carries;
10
+ * the cadence is **turn-based, matching the LLM** โ€” one `act()` call is
11
+ one human decision turn;
12
+ * the human interacts by **clicking the minimap**, not typing world
13
+ coordinates โ€” the UI runs `minimap_click_to_cell` to recover the
14
+ world cell, then assembles `HumanAction`s.
15
+
16
+ Everything here is headless-testable; a browser/terminal UI is a thin
17
+ shell over these pieces:
18
+
19
+ 1. `minimap_click_to_cell` / `cell_to_minimap_rect` โ€” the pure
20
+ pixelโ‡„cell transforms (renderer-agnostic: parameterised by the
21
+ rendered image size).
22
+ 2. `own_units_at_cell` / `enemy_at_cell` โ€” click-to-selection and
23
+ click-to-target resolution against `render_state`.
24
+ 3. `HumanAction` + `human_actions_to_commands` โ€” a human's per-turn
25
+ point-and-click gestures, translated into engine `Command`s by
26
+ delegating to the **same** `agent._to_commands` the LLM path uses,
27
+ so human and model emit byte-identical commands.
28
+ 4. `HumanController` โ€” a Controller (Phase 1 contract) whose `act()`
29
+ pulls the turn's `HumanAction`s from an injected input source and
30
+ records a playback-compatible transcript. The episode then runs
31
+ through `run_level` identically to a model run, so the trace, the
32
+ playback, and the leaderboard entry are directly comparable.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from dataclasses import dataclass, field
38
+ from typing import Any, Callable, Sequence
39
+
40
+ from .controller import BaseController, EpisodeContext
41
+
42
+ # An input source yields the human's gestures for one decision turn.
43
+ # Either a callable `(render_state) -> [HumanAction]` (the real UI: a
44
+ # blocking browser/terminal round-trip) or a pre-scripted list of turns
45
+ # (tests, or replay of a recorded session).
46
+ TurnActions = "list[HumanAction]"
47
+ InputSource = "Callable[[dict], list[HumanAction]] | Sequence[list[HumanAction]]"
48
+
49
+
50
+ # โ”€โ”€ Pixel โ‡„ cell transforms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
51
+
52
+
53
+ def minimap_click_to_cell(
54
+ px: float,
55
+ py: float,
56
+ img_w: int,
57
+ img_h: int,
58
+ map_cols: int,
59
+ map_rows: int,
60
+ ) -> tuple[int, int]:
61
+ """Map a click at pixel ``(px, py)`` on a rendered minimap of size
62
+ ``img_w ร— img_h`` back to a world cell on a ``map_cols ร— map_rows``
63
+ grid.
64
+
65
+ Renderer-agnostic: pass the *actual* rendered image dimensions, so
66
+ this works for either minimap renderer (`minimap.render_png_b64`
67
+ at CELL=6, or the vendored `prompt_v2.minimap_b64`). The result is
68
+ clamped to the playable grid โ€” an out-of-bounds click resolves to
69
+ the nearest edge cell rather than raising."""
70
+ if img_w <= 0 or img_h <= 0 or map_cols <= 0 or map_rows <= 0:
71
+ raise ValueError("image and map dimensions must be positive")
72
+ cx = int(px * map_cols / img_w)
73
+ cy = int(py * map_rows / img_h)
74
+ cx = min(map_cols - 1, max(0, cx))
75
+ cy = min(map_rows - 1, max(0, cy))
76
+ return cx, cy
77
+
78
+
79
+ def cell_to_minimap_rect(
80
+ cx: int,
81
+ cy: int,
82
+ img_w: int,
83
+ img_h: int,
84
+ map_cols: int,
85
+ map_rows: int,
86
+ ) -> tuple[int, int, int, int]:
87
+ """Inverse of `minimap_click_to_cell`: the pixel rectangle
88
+ ``(left, top, width, height)`` a world cell occupies on the
89
+ rendered minimap. The UI uses it to draw selection highlights."""
90
+ if img_w <= 0 or img_h <= 0 or map_cols <= 0 or map_rows <= 0:
91
+ raise ValueError("image and map dimensions must be positive")
92
+ left = int(cx * img_w / map_cols)
93
+ top = int(cy * img_h / map_rows)
94
+ right = int((cx + 1) * img_w / map_cols)
95
+ bottom = int((cy + 1) * img_h / map_rows)
96
+ return left, top, max(1, right - left), max(1, bottom - top)
97
+
98
+
99
+ # โ”€โ”€ Click โ†’ selection / target resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
100
+
101
+
102
+ def _cells_near(cx: int, cy: int, radius: int):
103
+ for yy in range(cy - radius, cy + radius + 1):
104
+ for xx in range(cx - radius, cx + radius + 1):
105
+ yield xx, yy
106
+
107
+
108
+ def own_units_at_cell(
109
+ render_state: dict, cx: int, cy: int, radius: int = 1
110
+ ) -> list[str]:
111
+ """Ids of the agent's own units within `radius` cells of (cx, cy) โ€”
112
+ click-to-select. Buildings are excluded (a click on a building is a
113
+ building selection; see `own_buildings_at_cell`)."""
114
+ near = set(_cells_near(cx, cy, radius))
115
+ out: list[str] = []
116
+ for u in render_state.get("units_summary", []) or []:
117
+ if not isinstance(u, dict):
118
+ continue
119
+ if (int(u.get("cell_x", -99)), int(u.get("cell_y", -99))) in near:
120
+ uid = u.get("id")
121
+ if uid is not None:
122
+ out.append(str(uid))
123
+ return out
124
+
125
+
126
+ def own_buildings_at_cell(
127
+ render_state: dict, cx: int, cy: int, radius: int = 1
128
+ ) -> list[str]:
129
+ """Ids of the agent's own buildings within `radius` cells of
130
+ (cx, cy) โ€” click-to-select for repair / sell / power_down."""
131
+ near = set(_cells_near(cx, cy, radius))
132
+ out: list[str] = []
133
+ for b in render_state.get("own_buildings", []) or []:
134
+ if not isinstance(b, dict):
135
+ continue
136
+ if (int(b.get("cell_x", -99)), int(b.get("cell_y", -99))) in near:
137
+ bid = b.get("id")
138
+ if bid is not None:
139
+ out.append(str(bid))
140
+ return out
141
+
142
+
143
+ def enemy_at_cell(
144
+ render_state: dict, cx: int, cy: int, radius: int = 1
145
+ ) -> str | None:
146
+ """Id of the nearest visible enemy actor within `radius` cells of
147
+ (cx, cy), or None โ€” click-to-target for `attack`. Picks the closest
148
+ so a click between two enemies is unambiguous."""
149
+ best: tuple[int, str] | None = None
150
+ for e in render_state.get("enemy_summary", []) or []:
151
+ if not isinstance(e, dict):
152
+ continue
153
+ ex, ey = int(e.get("cell_x", -99)), int(e.get("cell_y", -99))
154
+ d2 = (ex - cx) ** 2 + (ey - cy) ** 2
155
+ if d2 <= radius * radius * 2:
156
+ eid = e.get("id")
157
+ if eid is not None and (best is None or d2 < best[0]):
158
+ best = (d2, str(eid))
159
+ return best[1] if best else None
160
+
161
+
162
+ # โ”€โ”€ Human action โ†’ engine Command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
163
+
164
+ # The gesture vocabulary. Each gesture maps onto exactly one LLM tool
165
+ # name so the human path reuses `agent._to_commands` verbatim. The
166
+ # `move` gesture is the only rename โ€” the engine tool is `move_units`.
167
+ _TARGETED_MOVE = {
168
+ "move": "move_units",
169
+ "attack_move": "attack_move",
170
+ "harvest": "harvest",
171
+ "set_rally_point": "set_rally_point",
172
+ }
173
+ _UNIT_ONLY = {
174
+ "stop", "deploy", "sell", "repair", "power_down",
175
+ "set_primary", "unload", "patrol",
176
+ }
177
+
178
+
179
+ @dataclass
180
+ class HumanAction:
181
+ """One point-and-click gesture from the human, already resolved to
182
+ world-cell coordinates (the UI ran `minimap_click_to_cell` first).
183
+
184
+ `mode` is the gesture; the other fields are filled per gesture:
185
+
186
+ * ``move`` / ``attack_move`` / ``harvest`` / ``set_rally_point`` โ€”
187
+ `units` + `target` cell.
188
+ * ``attack`` โ€” `units` + either `target_id` (an enemy actor) or a
189
+ `target` cell (falls back to `attack_move`).
190
+ * ``guard`` โ€” `units` + `target_id` (ally to escort).
191
+ * ``stop`` / ``deploy`` / ``sell`` / ``repair`` / ``power_down`` /
192
+ ``set_primary`` / ``unload`` / ``patrol`` โ€” `units` only.
193
+ * ``set_stance`` โ€” `units` + `stance` (0โ€“3).
194
+ * ``build`` / ``cancel_production`` โ€” `unit_type`.
195
+ * ``place_building`` โ€” `unit_type` + `target` cell.
196
+ * ``observe`` / ``surrender`` โ€” no payload (pass-turn / concede).
197
+ """
198
+
199
+ mode: str
200
+ units: list[str] = field(default_factory=list)
201
+ target: tuple[int, int] | None = None
202
+ target_id: str | None = None
203
+ unit_type: str = ""
204
+ stance: int = 0
205
+ note: str = "" # optional free-text rationale (playback only)
206
+ raw: dict = field(default_factory=dict) # original click payload
207
+
208
+ def to_tool_call(self) -> dict | None:
209
+ """Normalise into the ``{name, arguments}`` tool-call dict that
210
+ `agent._to_commands` consumes. Returns None for a gesture that
211
+ cannot form a valid command (dropped silently, like a malformed
212
+ LLM tool call)."""
213
+ m = self.mode
214
+ if m == "observe":
215
+ return {"name": "observe", "arguments": {}}
216
+ if m == "surrender":
217
+ return {"name": "surrender", "arguments": {}}
218
+ if m == "attack":
219
+ if self.target_id is not None:
220
+ return {
221
+ "name": "attack_unit",
222
+ "arguments": {
223
+ "unit_ids": list(self.units),
224
+ "target_id": str(self.target_id),
225
+ },
226
+ }
227
+ # No enemy under the click โ†’ close on the cell instead.
228
+ if self.target is not None and self.units:
229
+ return {
230
+ "name": "attack_move",
231
+ "arguments": {
232
+ "unit_ids": list(self.units),
233
+ "target_x": int(self.target[0]),
234
+ "target_y": int(self.target[1]),
235
+ },
236
+ }
237
+ return None
238
+ if m == "guard":
239
+ if not self.units or self.target_id is None:
240
+ return None
241
+ return {
242
+ "name": "guard",
243
+ "arguments": {
244
+ "unit_ids": list(self.units),
245
+ "target_id": str(self.target_id),
246
+ },
247
+ }
248
+ if m in _TARGETED_MOVE:
249
+ if not self.units or self.target is None:
250
+ return None
251
+ return {
252
+ "name": _TARGETED_MOVE[m],
253
+ "arguments": {
254
+ "unit_ids": list(self.units),
255
+ "target_x": int(self.target[0]),
256
+ "target_y": int(self.target[1]),
257
+ },
258
+ }
259
+ if m == "set_stance":
260
+ if not self.units:
261
+ return None
262
+ return {
263
+ "name": "set_stance",
264
+ "arguments": {
265
+ "unit_ids": list(self.units),
266
+ "stance": int(self.stance),
267
+ },
268
+ }
269
+ if m in _UNIT_ONLY:
270
+ if not self.units:
271
+ return None
272
+ return {"name": m, "arguments": {"unit_ids": list(self.units)}}
273
+ if m in ("build", "cancel_production"):
274
+ if not self.unit_type:
275
+ return None
276
+ return {"name": m, "arguments": {"item": str(self.unit_type)}}
277
+ if m == "place_building":
278
+ if not self.unit_type or self.target is None:
279
+ return None
280
+ return {
281
+ "name": "place_building",
282
+ "arguments": {
283
+ "item": str(self.unit_type),
284
+ "target_x": int(self.target[0]),
285
+ "target_y": int(self.target[1]),
286
+ },
287
+ }
288
+ return None # unknown gesture โ€” dropped
289
+
290
+ def describe(self) -> str:
291
+ """One-line human-readable summary for the playback transcript."""
292
+ if self.mode in ("observe", "surrender"):
293
+ return self.mode
294
+ bits = [self.mode]
295
+ if self.units:
296
+ bits.append(f"units={','.join(self.units)}")
297
+ if self.target_id is not None:
298
+ bits.append(f"target={self.target_id}")
299
+ if self.target is not None:
300
+ bits.append(f"@{self.target[0]},{self.target[1]}")
301
+ if self.unit_type:
302
+ bits.append(f"item={self.unit_type}")
303
+ if self.mode == "set_stance":
304
+ bits.append(f"stance={self.stance}")
305
+ return " ".join(bits)
306
+
307
+
308
+ def human_actions_to_commands(
309
+ actions: Sequence[HumanAction], Command: Any
310
+ ) -> list:
311
+ """Translate a turn's `HumanAction`s into engine `Command`s by
312
+ delegating to `agent._to_commands` โ€” the exact translator the LLM
313
+ path uses โ€” so a human and a model emit byte-identical commands."""
314
+ from .agent import _to_commands
315
+
316
+ calls = [tc for a in actions if (tc := a.to_tool_call()) is not None]
317
+ return _to_commands(calls, Command)
318
+
319
+
320
+ # โ”€โ”€ Input sources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
321
+
322
+
323
+ class ScriptedInputSource:
324
+ """A deterministic, pre-recorded input source โ€” one entry per turn.
325
+
326
+ Drives `HumanController` headlessly in tests, and replays a saved
327
+ human session. When the script is exhausted every further turn
328
+ yields a single `observe` (pass-turn)."""
329
+
330
+ def __init__(self, turns: Sequence[Sequence[HumanAction]]):
331
+ self._turns = [list(t) for t in turns]
332
+ self._i = 0
333
+
334
+ def __call__(self, render_state: dict) -> list[HumanAction]:
335
+ if self._i >= len(self._turns):
336
+ return [HumanAction(mode="observe")]
337
+ turn = self._turns[self._i]
338
+ self._i += 1
339
+ return list(turn)
340
+
341
+
342
+ # โ”€โ”€ The Controller โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
343
+
344
+
345
+ class HumanController(BaseController):
346
+ """A Controller (Phase 1 contract) driven by a human.
347
+
348
+ `act()` pulls the turn's `HumanAction`s from `input_source` and
349
+ translates them into engine `Command`s. It also records a
350
+ playback-compatible transcript into `self.history` (the same
351
+ `{role, content, tool_calls}` shape `ModelAgent` writes), so a human
352
+ run renders in the existing battle viewer beside model runs and the
353
+ leaderboard entry is directly comparable.
354
+
355
+ `input_source` is either a callable ``(render_state) -> [HumanAction]``
356
+ โ€” the real UI's blocking browser/terminal round-trip โ€” or a
357
+ sequence of per-turn action lists (a `ScriptedInputSource`, a plain
358
+ list, or a saved replay)."""
359
+
360
+ def __init__(
361
+ self,
362
+ input_source: "InputSource",
363
+ name: str = "human",
364
+ ):
365
+ super().__init__(name=name)
366
+ if callable(input_source):
367
+ self._source = input_source
368
+ else:
369
+ self._source = ScriptedInputSource(input_source)
370
+ self.stats = {"turns": 0, "tool_calls": 0, "empty_replies": 0}
371
+ self._ctx: EpisodeContext | None = None
372
+
373
+ def reset(self, ctx: EpisodeContext) -> None:
374
+ """Per-episode hook: stamp context and seed the transcript with
375
+ a system message naming the scenario and objective."""
376
+ self._ctx = ctx
377
+ self.history = [
378
+ {
379
+ "role": "system",
380
+ "content": (
381
+ f"Human-labeling session โ€” scenario "
382
+ f"{ctx.pack_id}:{ctx.level} (seed {ctx.seed}). "
383
+ f"Objective: {ctx.objective or '(see briefing)'}"
384
+ ),
385
+ }
386
+ ]
387
+ self.stats = {"turns": 0, "tool_calls": 0, "empty_replies": 0}
388
+
389
+ @staticmethod
390
+ def _briefing(render_state: dict) -> str:
391
+ """The SAME text briefing the LLM is shown (vendored
392
+ `prompt_v2.briefing`, with a defensive fallback)."""
393
+ try:
394
+ from .prompt_v2 import briefing as _v2_brief
395
+
396
+ return _v2_brief(render_state)
397
+ except Exception: # noqa: BLE001 โ€” never break a turn
398
+ return (
399
+ f"tick={render_state.get('game_tick', 0)} "
400
+ f"explored={render_state.get('explored_percent', 0.0):.1f}%"
401
+ )
402
+
403
+ def act(self, observation: dict, Command: Any) -> list:
404
+ self.stats["turns"] += 1
405
+ # The human sees exactly the LLM's observation.
406
+ self.history.append(
407
+ {"role": "user", "content": self._briefing(observation)}
408
+ )
409
+ actions = list(self._source(observation) or [])
410
+ calls = [
411
+ tc for a in actions if (tc := a.to_tool_call()) is not None
412
+ ]
413
+ cmds = human_actions_to_commands(actions, Command)
414
+ self.stats["tool_calls"] += len(cmds)
415
+ if not cmds:
416
+ self.stats["empty_replies"] += 1
417
+ cmds = [Command.observe()]
418
+ # Playback-compatible assistant turn: a human-readable summary
419
+ # plus the structured tool_calls, mirroring ModelAgent.agent_fn.
420
+ notes = "; ".join(a.note for a in actions if a.note)
421
+ self.history.append(
422
+ {
423
+ "role": "assistant",
424
+ "content": "; ".join(a.describe() for a in actions)
425
+ or "observe",
426
+ "reasoning": notes,
427
+ "tool_calls": [
428
+ {
429
+ "id": f"h{i}",
430
+ "type": "function",
431
+ "function": {
432
+ "name": c["name"],
433
+ "arguments": c["arguments"],
434
+ },
435
+ }
436
+ for i, c in enumerate(calls)
437
+ ],
438
+ }
439
+ )
440
+ for i in range(len(calls)):
441
+ self.history.append(
442
+ {"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}
443
+ )
444
+ return cmds
445
+
446
+
447
+ # โ”€โ”€ Session harness โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
448
+
449
+
450
+ def run_human_session(
451
+ pack_id: str,
452
+ level: str = "easy",
453
+ seed: int = 1,
454
+ input_source: "InputSource" = (),
455
+ playback: Any = None,
456
+ name: str = "human",
457
+ ):
458
+ """Run a full human-labeling session and return the scored
459
+ `EpisodeResult`.
460
+
461
+ Compiles the named pack/level and drives a `HumanController` through
462
+ `run_level` โ€” the IDENTICAL scoring path a model run takes โ€” so the
463
+ result, the per-turn playback, and the leaderboard entry are
464
+ directly comparable to an LLM's run on the same scenario.
465
+
466
+ `input_source` is a callable ``(render_state) -> [HumanAction]`` (the
467
+ real UI's blocking browser/terminal round-trip) or a pre-recorded
468
+ sequence of per-turn `HumanAction` lists (a replay)."""
469
+ from .eval_core import run_level
470
+ from .scenarios import load_pack
471
+ from .scenarios.loader import PACKS_DIR, compile_level
472
+
473
+ compiled = compile_level(
474
+ load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
475
+ )
476
+ controller = HumanController(input_source, name=name)
477
+ return run_level(compiled, controller, seed=seed, playback=playback)
tests/test_human_labeling.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 2 โ€” the human-labeling machine.
2
+
3
+ `openra_bench/human_labeling.py` lets a human play the exact scenarios
4
+ the LLM plays. This file pins:
5
+
6
+ * the pixelโ‡„cell transforms (`minimap_click_to_cell` /
7
+ `cell_to_minimap_rect`) โ€” round-trip, edge clamping, bad-input guards;
8
+ * click-to-selection / click-to-target resolution against render_state;
9
+ * `HumanAction.to_tool_call` โ€” every gesture normalises to the SAME
10
+ tool-call shape the LLM emits (and malformed gestures are dropped);
11
+ * `HumanController` โ€” satisfies the Phase 1 Controller contract, records
12
+ a playback-compatible transcript, and runs end-to-end through
13
+ `run_level` exactly like a model agent.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import pytest
19
+
20
+ from openra_bench.controller import EpisodeContext, is_controller
21
+ from openra_bench.human_labeling import (
22
+ HumanAction,
23
+ HumanController,
24
+ ScriptedInputSource,
25
+ cell_to_minimap_rect,
26
+ enemy_at_cell,
27
+ minimap_click_to_cell,
28
+ own_buildings_at_cell,
29
+ own_units_at_cell,
30
+ )
31
+
32
+
33
+ # โ”€โ”€ Pixel โ‡„ cell transforms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
34
+
35
+
36
+ def test_click_to_cell_basic():
37
+ # 128ร—40 map rendered at CELL=6 โ†’ 768ร—240 px. A click at the centre
38
+ # of cell (10, 5) is pixel (63, 33).
39
+ assert minimap_click_to_cell(63, 33, 768, 240, 128, 40) == (10, 5)
40
+ # Top-left corner.
41
+ assert minimap_click_to_cell(0, 0, 768, 240, 128, 40) == (0, 0)
42
+
43
+
44
+ def test_click_to_cell_clamps_out_of_bounds():
45
+ # Past the right/bottom edge โ†’ clamped to the last cell.
46
+ assert minimap_click_to_cell(9999, 9999, 768, 240, 128, 40) == (127, 39)
47
+ # Negative โ†’ clamped to 0.
48
+ assert minimap_click_to_cell(-50, -50, 768, 240, 128, 40) == (0, 0)
49
+
50
+
51
+ def test_click_to_cell_rejects_bad_dimensions():
52
+ for bad in [
53
+ (10, 10, 0, 240, 128, 40),
54
+ (10, 10, 768, 240, 0, 40),
55
+ (10, 10, 768, -1, 128, 40),
56
+ ]:
57
+ with pytest.raises(ValueError):
58
+ minimap_click_to_cell(*bad)
59
+
60
+
61
+ def test_cell_to_rect_round_trips():
62
+ # Clicking anywhere inside a cell's rect recovers that cell.
63
+ for cx, cy in [(0, 0), (10, 5), (127, 39), (64, 20)]:
64
+ left, top, w, h = cell_to_minimap_rect(cx, cy, 768, 240, 128, 40)
65
+ assert w >= 1 and h >= 1
66
+ mid_x, mid_y = left + w // 2, top + h // 2
67
+ assert minimap_click_to_cell(mid_x, mid_y, 768, 240, 128, 40) == (
68
+ cx,
69
+ cy,
70
+ )
71
+
72
+
73
+ def test_transforms_are_renderer_agnostic():
74
+ # Same cell, two different rendered sizes โ€” both resolve correctly.
75
+ assert minimap_click_to_cell(400, 150, 800, 300, 128, 40) == (64, 20)
76
+ assert minimap_click_to_cell(200, 75, 400, 150, 128, 40) == (64, 20)
77
+
78
+
79
+ # โ”€โ”€ Click โ†’ selection / target resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
80
+
81
+
82
+ _RS = {
83
+ "units_summary": [
84
+ {"id": 7, "cell_x": 20, "cell_y": 10},
85
+ {"id": 8, "cell_x": 21, "cell_y": 10},
86
+ {"id": 9, "cell_x": 80, "cell_y": 30},
87
+ ],
88
+ "own_buildings": [
89
+ {"id": 100, "cell_x": 18, "cell_y": 12},
90
+ ],
91
+ "enemy_summary": [
92
+ {"id": 50, "cell_x": 60, "cell_y": 20},
93
+ {"id": 51, "cell_x": 62, "cell_y": 21},
94
+ ],
95
+ }
96
+
97
+
98
+ def test_own_units_at_cell_selects_by_proximity():
99
+ sel = own_units_at_cell(_RS, 20, 10, radius=1)
100
+ assert set(sel) == {"7", "8"} # both within 1 cell
101
+ assert own_units_at_cell(_RS, 80, 30, radius=0) == ["9"]
102
+ assert own_units_at_cell(_RS, 5, 5, radius=1) == [] # empty region
103
+
104
+
105
+ def test_own_buildings_at_cell():
106
+ assert own_buildings_at_cell(_RS, 18, 12, radius=1) == ["100"]
107
+ assert own_buildings_at_cell(_RS, 20, 10, radius=1) == []
108
+
109
+
110
+ def test_enemy_at_cell_picks_nearest():
111
+ # Click on (60,20) โ†’ enemy 50 sits there exactly.
112
+ assert enemy_at_cell(_RS, 60, 20, radius=1) == "50"
113
+ # Click nearer 51.
114
+ assert enemy_at_cell(_RS, 62, 21, radius=1) == "51"
115
+ # No enemy near the agent base.
116
+ assert enemy_at_cell(_RS, 20, 10, radius=1) is None
117
+
118
+
119
+ # โ”€โ”€ HumanAction โ†’ tool call โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
120
+
121
+
122
+ def test_action_move_normalises_to_move_units():
123
+ a = HumanAction(mode="move", units=["7", "8"], target=(40, 20))
124
+ assert a.to_tool_call() == {
125
+ "name": "move_units",
126
+ "arguments": {
127
+ "unit_ids": ["7", "8"],
128
+ "target_x": 40,
129
+ "target_y": 20,
130
+ },
131
+ }
132
+
133
+
134
+ def test_action_attack_with_target_id_is_attack_unit():
135
+ a = HumanAction(mode="attack", units=["7"], target_id="50")
136
+ tc = a.to_tool_call()
137
+ assert tc["name"] == "attack_unit"
138
+ assert tc["arguments"] == {"unit_ids": ["7"], "target_id": "50"}
139
+
140
+
141
+ def test_action_attack_without_enemy_falls_back_to_attack_move():
142
+ # Click on empty ground in attack mode โ†’ close on the cell.
143
+ a = HumanAction(mode="attack", units=["7"], target=(55, 25))
144
+ tc = a.to_tool_call()
145
+ assert tc["name"] == "attack_move"
146
+ assert tc["arguments"]["target_x"] == 55
147
+
148
+
149
+ def test_action_build_and_place():
150
+ assert HumanAction(mode="build", unit_type="pbox").to_tool_call() == {
151
+ "name": "build",
152
+ "arguments": {"item": "pbox"},
153
+ }
154
+ place = HumanAction(
155
+ mode="place_building", unit_type="pbox", target=(30, 15)
156
+ ).to_tool_call()
157
+ assert place["name"] == "place_building"
158
+ assert place["arguments"] == {
159
+ "item": "pbox",
160
+ "target_x": 30,
161
+ "target_y": 15,
162
+ }
163
+
164
+
165
+ def test_action_unit_only_and_stance():
166
+ assert HumanAction(mode="stop", units=["7"]).to_tool_call() == {
167
+ "name": "stop",
168
+ "arguments": {"unit_ids": ["7"]},
169
+ }
170
+ st = HumanAction(
171
+ mode="set_stance", units=["7", "8"], stance=3
172
+ ).to_tool_call()
173
+ assert st == {
174
+ "name": "set_stance",
175
+ "arguments": {"unit_ids": ["7", "8"], "stance": 3},
176
+ }
177
+
178
+
179
+ def test_action_observe_and_surrender():
180
+ assert HumanAction(mode="observe").to_tool_call() == {
181
+ "name": "observe",
182
+ "arguments": {},
183
+ }
184
+ assert HumanAction(mode="surrender").to_tool_call() == {
185
+ "name": "surrender",
186
+ "arguments": {},
187
+ }
188
+
189
+
190
+ def test_malformed_actions_drop_to_none():
191
+ # move without units / without target โ†’ not a valid command.
192
+ assert HumanAction(mode="move", target=(1, 2)).to_tool_call() is None
193
+ assert HumanAction(mode="move", units=["7"]).to_tool_call() is None
194
+ # attack with neither target_id nor cell.
195
+ assert HumanAction(mode="attack", units=["7"]).to_tool_call() is None
196
+ # build with no item; unknown gesture.
197
+ assert HumanAction(mode="build").to_tool_call() is None
198
+ assert HumanAction(mode="nonsense", units=["7"]).to_tool_call() is None
199
+
200
+
201
+ def test_action_describe_is_human_readable():
202
+ d = HumanAction(mode="move", units=["7", "8"], target=(40, 20)).describe()
203
+ assert "move" in d and "40,20" in d
204
+
205
+
206
+ # โ”€โ”€ ScriptedInputSource โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
207
+
208
+
209
+ def test_scripted_input_source_yields_then_observes():
210
+ src = ScriptedInputSource(
211
+ [
212
+ [HumanAction(mode="move", units=["7"], target=(5, 5))],
213
+ [HumanAction(mode="stop", units=["7"])],
214
+ ]
215
+ )
216
+ assert src({})[0].mode == "move"
217
+ assert src({})[0].mode == "stop"
218
+ # Exhausted โ†’ pass-turn observe forever.
219
+ assert src({})[0].mode == "observe"
220
+ assert src({})[0].mode == "observe"
221
+
222
+
223
+ # โ”€โ”€ HumanController contract + transcript โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
224
+
225
+
226
+ def test_human_controller_satisfies_controller_contract():
227
+ c = HumanController([], name="tester")
228
+ assert is_controller(c)
229
+ assert c.name == "tester"
230
+
231
+
232
+ def test_human_controller_reset_seeds_transcript():
233
+ c = HumanController([])
234
+ c.reset(
235
+ EpisodeContext(
236
+ pack_id="demo", level="easy", seed=2, objective="hold the line"
237
+ )
238
+ )
239
+ assert c.history[0]["role"] == "system"
240
+ assert "demo:easy" in c.history[0]["content"]
241
+ assert "hold the line" in c.history[0]["content"]
242
+ assert c.stats == {"turns": 0, "tool_calls": 0, "empty_replies": 0}
243
+
244
+
245
+ # โ”€โ”€ Engine-backed: command translation + end-to-end run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
246
+
247
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
248
+ pytest.importorskip(
249
+ "openra_rl_training", reason="Rust env wheel not installed"
250
+ )
251
+
252
+
253
+ def test_human_actions_translate_to_real_commands():
254
+ """A human's gestures become real engine Commands via the SAME
255
+ `agent._to_commands` the LLM path uses."""
256
+ from openra_train import Command
257
+
258
+ from openra_bench.human_labeling import human_actions_to_commands
259
+
260
+ actions = [
261
+ HumanAction(mode="move", units=["1"], target=(10, 10)),
262
+ HumanAction(mode="observe"),
263
+ ]
264
+ cmds = human_actions_to_commands(actions, Command)
265
+ assert len(cmds) == 2
266
+ assert "MoveUnits" in repr(cmds[0])
267
+ assert "Observe" in repr(cmds[1])
268
+
269
+
270
+ def _smallest_easy_pack():
271
+ from openra_bench.scenarios import load_pack
272
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
273
+
274
+ best = None
275
+ for f in sorted(PACKS_DIR.glob("*.yaml")):
276
+ if f.name.startswith(("_", "TEMPLATE")):
277
+ continue
278
+ try:
279
+ pack = load_pack(f)
280
+ if pack.meta.status != "active" or "easy" not in pack.levels:
281
+ continue
282
+ c = compile_level(pack, "easy")
283
+ except Exception: # noqa: BLE001
284
+ continue
285
+ if c.map_supported and (best is None or c.max_turns < best.max_turns):
286
+ best = c
287
+ return best
288
+
289
+
290
+ def test_human_controller_runs_end_to_end_through_run_level():
291
+ """A HumanController driven by a scripted session runs through
292
+ `run_level` exactly like a model agent โ€” producing a scored
293
+ EpisodeResult and a playback-compatible transcript."""
294
+ from openra_bench.eval_core import run_level
295
+
296
+ compiled = _smallest_easy_pack()
297
+ assert compiled is not None
298
+
299
+ # A trivial scripted human: observe every turn (a pass-turn run).
300
+ ctrl = HumanController(ScriptedInputSource([]), name="human-test")
301
+ res = run_level(compiled, ctrl, seed=1)
302
+
303
+ assert res.outcome in ("win", "loss", "draw")
304
+ assert ctrl.stats["turns"] >= 1
305
+ # Transcript is playback-shaped: system seed + per-turn user/assistant.
306
+ assert ctrl.history[0]["role"] == "system"
307
+ roles = {m["role"] for m in ctrl.history}
308
+ assert {"user", "assistant"} <= roles
309
+ # Every assistant turn carries structured tool_calls (observe here).
310
+ asst = [m for m in ctrl.history if m["role"] == "assistant"]
311
+ assert asst and all("tool_calls" in m for m in asst)
312
+
313
+
314
+ def test_run_human_session_scores_a_named_pack():
315
+ """`run_human_session` compiles a real pack by id and scores a
316
+ human run through the same path as a model run."""
317
+ from openra_bench.human_labeling import run_human_session
318
+
319
+ compiled = _smallest_easy_pack()
320
+ assert compiled is not None
321
+
322
+ res = run_human_session(
323
+ compiled.pack_id, level="easy", seed=1, input_source=[]
324
+ )
325
+ assert res.outcome in ("win", "loss", "draw")
326
+ assert res.scenario == f"{compiled.pack_id}:easy"
327
+ assert res.seed == 1