yxc20098 commited on
Commit
39fba02
ยท
1 Parent(s): 247ff7a

Training-parity minimap (real terrain + legend) + viewer (system/thinking/debrief)

Browse files

Model-facing fix โ€” 'is the minimap correctly sent?': it now is, with
an interpretable legend.
- minimap.terrain_png_for(): extract map.png bytes from the resolved
.oramap (zip), lru-cached
- minimap.render_b64(): prefer the training render_minimap (real
terrain + embedded legend the model can read) when terrain is
available; bench-native PNG only as fallback
- ModelAgent(base_map=โ€ฆ) resolves terrain once; _user_message +
eval_core playback both render via the training path so the saved
PNGs match exactly what the model saw; run_eval factories pass
base_map

Viewer parity with training view_v7_pipeline.py โ€” show everything:
- battle_viewer.episode_view exposes system_prompt, debrief (the
briefing/scoped re-prompt handed to the model), tool_result
- app.py Battle Viewer: collapsible system prompt (char count),
open DEBRIEF/briefing (full, interrupt-tagged), reasoning(thinking),
tool calls, tool result, signals, goal
- Streamlit scripts/view_playback.py: system-prompt expander + model/
run caption (same format)

tests: test_minimap.py +2 (terrain extract + training renderer used,
agent terrain wiring). 15 touched-module tests green.

app.py CHANGED
@@ -247,22 +247,38 @@ def _bv_turn_md(v: dict, heading: str) -> str:
247
  "reward vector: "
248
  + " ".join(f"`{k}={float(x):.2f}`" for k, x in rv.items()),
249
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  if v.get("reasoning"):
251
- lines += ["", "**reasoning**", "> " + str(v["reasoning"]).replace(
252
- "\n", "\n> ")]
253
  if v.get("assistant_text"):
254
  lines += ["", "**model said**", str(v["assistant_text"])]
255
  cmds = v.get("commands", [])
256
- lines += ["", "**commands**", "```\n" + (
257
  "\n".join(cmds) if cmds else "(none)") + "\n```"]
 
 
258
  sig = v.get("signals", {})
259
  if sig:
260
  lines += ["signals: " + " ".join(
261
  f"`{k}={sig[k]}`" for k in sig)]
262
- if v.get("briefing"):
263
- b = str(v["briefing"])
264
- lines += ["", "<details><summary>briefing</summary>\n\n```\n"
265
- + b[:2000] + "\n```\n</details>"]
266
  return "\n\n".join(s for s in lines if s != "")
267
 
268
 
 
247
  "reward vector: "
248
  + " ".join(f"`{k}={float(x):.2f}`" for k, x in rv.items()),
249
  ]
250
+ # System prompt (the deterministic scenario knowledge the model
251
+ # was given) โ€” collapsible, shown with char count like the
252
+ # training pipeline viewer.
253
+ sp = str(v.get("system_prompt") or "")
254
+ if sp:
255
+ lines += [
256
+ "", f"<details><summary>๐Ÿง  system prompt ({len(sp)} chars)"
257
+ "</summary>\n\n```\n" + sp[:6000] + "\n```\n</details>"
258
+ ]
259
+ # DEBRIEF / briefing actually handed to the model this turn
260
+ # (post-interrupt this is the scoped re-prompt).
261
+ deb = str(v.get("debrief") or v.get("briefing") or "")
262
+ if deb:
263
+ tag = "โšก DEBRIEF (interrupt)" if v.get("interrupt") else "briefing"
264
+ lines += [
265
+ "", f"<details open><summary>{tag}</summary>\n\n```\n"
266
+ + deb[:8000] + "\n```\n</details>"
267
+ ]
268
  if v.get("reasoning"):
269
+ lines += ["", "**๐Ÿค” reasoning (thinking)**",
270
+ "> " + str(v["reasoning"]).replace("\n", "\n> ")]
271
  if v.get("assistant_text"):
272
  lines += ["", "**model said**", str(v["assistant_text"])]
273
  cmds = v.get("commands", [])
274
+ lines += ["", "**tool calls**", "```\n" + (
275
  "\n".join(cmds) if cmds else "(none)") + "\n```"]
276
+ if v.get("tool_result"):
277
+ lines += [f"tool result: `{v['tool_result']}`"]
278
  sig = v.get("signals", {})
279
  if sig:
280
  lines += ["signals: " + " ".join(
281
  f"`{k}={sig[k]}`" for k in sig)]
 
 
 
 
282
  return "\n\n".join(s for s in lines if s != "")
283
 
284
 
openra_bench/agent.py CHANGED
@@ -374,13 +374,16 @@ def build_briefing(render_state: dict, objective: str = "") -> str:
374
  return "\n".join(lines)
375
 
376
 
377
- def _render_minimap_b64(render_state: dict) -> str | None:
378
- """Best-effort minimap PNG. Returns None (text-only fallback) when
379
- terrain isn't resolvable โ€” vision degrades gracefully in Phase 0."""
 
 
 
380
  try:
381
- from .minimap import render_png_b64
382
 
383
- return render_png_b64(render_state)
384
  except Exception as e: # noqa: BLE001 โ€” vision is optional
385
  logger.debug("minimap render skipped: %s", e)
386
  return None
@@ -466,11 +469,22 @@ class ModelAgent:
466
  objective: str = "",
467
  provider: ChatProvider | None = None,
468
  system_extra: str = "",
 
469
  ):
470
  self.cfg = cfg
471
  self.objective = objective
472
  self.tools = _tool_schemas(allowed_tools)
473
  self.provider = provider or make_provider(cfg)
 
 
 
 
 
 
 
 
 
 
474
  sys_content = SYSTEM_PROMPT
475
  if system_extra:
476
  # Deterministic scenario-scoped game knowledge (glossary,
@@ -483,7 +497,7 @@ class ModelAgent:
483
  def _user_message(self, render_state: dict) -> dict:
484
  text = build_briefing(render_state, self.objective)
485
  if self.cfg.vision:
486
- b64 = _render_minimap_b64(render_state)
487
  if b64:
488
  return {
489
  "role": "user",
 
374
  return "\n".join(lines)
375
 
376
 
377
+ def _render_minimap_b64(
378
+ render_state: dict, terrain_png: bytes | None = None
379
+ ) -> str | None:
380
+ """Best-effort minimap PNG. With `terrain_png` uses the training
381
+ renderer (real terrain + an embedded legend the model can read);
382
+ else the bench fallback. None โ‡’ graceful text-only."""
383
  try:
384
+ from .minimap import render_b64
385
 
386
+ return render_b64(render_state, terrain_png)
387
  except Exception as e: # noqa: BLE001 โ€” vision is optional
388
  logger.debug("minimap render skipped: %s", e)
389
  return None
 
469
  objective: str = "",
470
  provider: ChatProvider | None = None,
471
  system_extra: str = "",
472
+ base_map: str = "",
473
  ):
474
  self.cfg = cfg
475
  self.objective = objective
476
  self.tools = _tool_schemas(allowed_tools)
477
  self.provider = provider or make_provider(cfg)
478
+ # Real terrain (map.png from the .oramap) so the model gets the
479
+ # training renderer's terrain+legend minimap, not colour dots.
480
+ self._terrain: bytes | None = None
481
+ if base_map:
482
+ try:
483
+ from .minimap import terrain_png_for
484
+
485
+ self._terrain = terrain_png_for(base_map)
486
+ except Exception: # noqa: BLE001
487
+ self._terrain = None
488
  sys_content = SYSTEM_PROMPT
489
  if system_extra:
490
  # Deterministic scenario-scoped game knowledge (glossary,
 
497
  def _user_message(self, render_state: dict) -> dict:
498
  text = build_briefing(render_state, self.objective)
499
  if self.cfg.vision:
500
+ b64 = _render_minimap_b64(render_state, self._terrain)
501
  if b64:
502
  return {
503
  "role": "user",
openra_bench/battle_viewer.py CHANGED
@@ -145,9 +145,20 @@ def episode_view(ep_dir: str | Path, turn_idx: int) -> dict:
145
  return {"n_turns": 0, "manifest": ep["manifest"]}
146
  i = max(0, min(turn_idx, n - 1))
147
  t = turns[i]
148
- users = _role_turns(ep["messages"], "user")
149
- assts = _role_turns(ep["messages"], "assistant")
 
 
 
 
 
 
 
150
  g = t.get("goal") or {}
 
 
 
 
151
  return {
152
  "n_turns": n,
153
  "turn_idx": i,
@@ -156,11 +167,15 @@ def episode_view(ep_dir: str | Path, turn_idx: int) -> dict:
156
  "interrupt": t.get("interrupt"),
157
  "minimap_png": t.get("minimap_png"),
158
  "ascii_minimap": t.get("ascii_minimap", ""),
159
- "briefing": _text(users[i]["content"]) if i < len(users) else "",
 
 
160
  "reasoning": (assts[i].get("reasoning", "")
161
  if i < len(assts) else ""),
162
  "assistant_text": (_text(assts[i].get("content"))
163
  if i < len(assts) else ""),
 
 
164
  "commands": t.get("commands", []),
165
  "signals": t.get("signals", {}),
166
  "goal": g,
 
145
  return {"n_turns": 0, "manifest": ep["manifest"]}
146
  i = max(0, min(turn_idx, n - 1))
147
  t = turns[i]
148
+ msgs = ep["messages"]
149
+ sys_prompt = next(
150
+ (m.get("content", "") for m in msgs if m.get("role") == "system"), ""
151
+ )
152
+ if isinstance(sys_prompt, list):
153
+ sys_prompt = _text(sys_prompt)
154
+ users = _role_turns(msgs, "user")
155
+ assts = _role_turns(msgs, "assistant")
156
+ tools = _role_turns(msgs, "tool")
157
  g = t.get("goal") or {}
158
+ # The model's "debrief" for a turn = the briefing it was handed
159
+ # (post-interrupt this is the scoped re-prompt). Surface it
160
+ # explicitly + the interrupt reason, matching the training viewer.
161
+ briefing = _text(users[i]["content"]) if i < len(users) else ""
162
  return {
163
  "n_turns": n,
164
  "turn_idx": i,
 
167
  "interrupt": t.get("interrupt"),
168
  "minimap_png": t.get("minimap_png"),
169
  "ascii_minimap": t.get("ascii_minimap", ""),
170
+ "system_prompt": sys_prompt,
171
+ "briefing": briefing,
172
+ "debrief": briefing, # alias: what the model was (re-)briefed with
173
  "reasoning": (assts[i].get("reasoning", "")
174
  if i < len(assts) else ""),
175
  "assistant_text": (_text(assts[i].get("content"))
176
  if i < len(assts) else ""),
177
+ "tool_result": (_text(tools[i].get("content"))
178
+ if i < len(tools) else ""),
179
  "commands": t.get("commands", []),
180
  "signals": t.get("signals", {}),
181
  "goal": g,
openra_bench/eval_core.py CHANGED
@@ -219,8 +219,11 @@ def run_level(
219
  _png = None
220
  try:
221
  from .agent import _render_minimap_b64
 
222
 
223
- _png = _render_minimap_b64(rs)
 
 
224
  except Exception: # noqa: BLE001 โ€” playback never breaks a run
225
  pass
226
  from .goal_tracker import turn_goal
 
219
  _png = None
220
  try:
221
  from .agent import _render_minimap_b64
222
+ from .minimap import terrain_png_for
223
 
224
+ _png = _render_minimap_b64(
225
+ rs, terrain_png_for(compiled.scenario.base_map)
226
+ )
227
  except Exception: # noqa: BLE001 โ€” playback never breaks a run
228
  pass
229
  from .goal_tracker import turn_goal
openra_bench/minimap.py CHANGED
@@ -12,9 +12,59 @@ from __future__ import annotations
12
  import base64
13
  import io
14
  import logging
 
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  CELL = 6 # px per map cell (โ‰ˆ768ร—240 for a 128ร—40 map โ€” legible)
19
 
20
  # (R,G,B)
 
12
  import base64
13
  import io
14
  import logging
15
+ import zipfile
16
+ from functools import lru_cache
17
 
18
  logger = logging.getLogger(__name__)
19
 
20
+
21
+ @lru_cache(maxsize=32)
22
+ def terrain_png_for(base_map: str) -> bytes | None:
23
+ """Raw `map.png` bytes from the resolved `.oramap` (a zip), cached.
24
+ None when the map can't be resolved โ€” caller falls back."""
25
+ if not base_map:
26
+ return None
27
+ try:
28
+ from .scenarios.loader import resolve_map_path
29
+
30
+ p = resolve_map_path(base_map)
31
+ if not p:
32
+ return None
33
+ with zipfile.ZipFile(p, "r") as zf:
34
+ if "map.png" in zf.namelist():
35
+ return zf.read("map.png")
36
+ except Exception as e: # noqa: BLE001
37
+ logger.debug("terrain extract failed for %s: %s", base_map, e)
38
+ return None
39
+
40
+
41
+ def render_b64(render_state: dict, terrain_png: bytes | None = None) -> str | None:
42
+ """Preferred minimap: the training renderer (real terrain + an
43
+ embedded legend the model can read) when terrain is available;
44
+ otherwise the self-contained bench fallback. Either way a *valid*
45
+ base64 PNG, or None for graceful text-only."""
46
+ if terrain_png:
47
+ try:
48
+ from openra_rl_training.training.minimap_renderer import (
49
+ render_minimap,
50
+ )
51
+
52
+ b64 = render_minimap(
53
+ terrain_png=terrain_png,
54
+ map_width=int(render_state.get("map_width", 64) or 64),
55
+ map_height=int(render_state.get("map_height", 64) or 64),
56
+ bounds_x=int(render_state.get("bounds_x", 0) or 0),
57
+ bounds_y=int(render_state.get("bounds_y", 0) or 0),
58
+ own_units=render_state.get("units_summary", []) or [],
59
+ enemy_units=render_state.get("enemy_summary", []) or [],
60
+ ascii_minimap=render_state.get("minimap", "") or "",
61
+ )
62
+ if b64:
63
+ return b64
64
+ except Exception as e: # noqa: BLE001 โ€” fall back below
65
+ logger.debug("training minimap failed, using fallback: %s", e)
66
+ return render_png_b64(render_state)
67
+
68
  CELL = 6 # px per map cell (โ‰ˆ768ร—240 for a 128ร—40 map โ€” legible)
69
 
70
  # (R,G,B)
openra_bench/playback_view.py CHANGED
@@ -72,10 +72,27 @@ def render_streamlit(root: str) -> None: # pragma: no cover - UI glue
72
  m = ep["manifest"]
73
  st.title(f"{m.get('scenario', pick)} โ€” {m.get('outcome', '?')}")
74
  st.caption(
 
75
  f"turns {m.get('turns')}/{m.get('max_turns')} ยท "
76
  f"capability {m.get('capability')} ยท seed {m.get('seed')}"
77
  )
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  asst = _assistant_turns(ep["messages"])
80
  for i, t in enumerate(ep["turns"]):
81
  with st.expander(
 
72
  m = ep["manifest"]
73
  st.title(f"{m.get('scenario', pick)} โ€” {m.get('outcome', '?')}")
74
  st.caption(
75
+ f"{m.get('model','?')} ยท run {m.get('run_id','?')} ยท "
76
  f"turns {m.get('turns')}/{m.get('max_turns')} ยท "
77
  f"capability {m.get('capability')} ยท seed {m.get('seed')}"
78
  )
79
 
80
+ # System prompt once at top (the deterministic scenario knowledge
81
+ # the model was given) โ€” same as the training pipeline viewer.
82
+ sysp = next(
83
+ (x.get("content", "") for x in ep["messages"]
84
+ if x.get("role") == "system"), ""
85
+ )
86
+ if isinstance(sysp, list):
87
+ sysp = " ".join(
88
+ p.get("text", "") for p in sysp if isinstance(p, dict)
89
+ )
90
+ if sysp:
91
+ with st.expander(f"๐Ÿง  System prompt ({len(sysp)} chars)",
92
+ expanded=False):
93
+ st.code(sysp, language="text")
94
+
95
+ users = [x for x in ep["messages"] if x.get("role") == "user"]
96
  asst = _assistant_turns(ep["messages"])
97
  for i, t in enumerate(ep["turns"]):
98
  with st.expander(
openra_bench/run_eval.py CHANGED
@@ -53,6 +53,7 @@ def _default_agent_factory(provider_cfg) -> AgentFactory:
53
  compiled.max_turns,
54
  ),
55
  system_extra=scenario_primer(compiled),
 
56
  )
57
  return agent.agent_fn
58
 
@@ -145,6 +146,7 @@ def evaluate(
145
  ),
146
  provider=shared,
147
  system_extra=scenario_primer(compiled),
 
148
  ).agent_fn
149
 
150
  # Run/model identity so a single playback root can hold many runs
 
53
  compiled.max_turns,
54
  ),
55
  system_extra=scenario_primer(compiled),
56
+ base_map=compiled.scenario.base_map,
57
  )
58
  return agent.agent_fn
59
 
 
146
  ),
147
  provider=shared,
148
  system_extra=scenario_primer(compiled),
149
+ base_map=compiled.scenario.base_map,
150
  ).agent_fn
151
 
152
  # Run/model identity so a single playback root can hold many runs
tests/test_minimap.py CHANGED
@@ -77,3 +77,37 @@ def test_agent_attaches_image_when_vision_on():
77
  assert url.startswith("data:image/png;base64,")
78
  # the attached payload is itself a valid PNG
79
  Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).verify()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  assert url.startswith("data:image/png;base64,")
78
  # the attached payload is itself a valid PNG
79
  Image.open(io.BytesIO(base64.b64decode(url.split(",", 1)[1]))).verify()
80
+
81
+
82
+ # โ”€โ”€ training renderer (real terrain + embedded legend) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
83
+
84
+
85
+ def test_terrain_png_extracted_and_training_renderer_used():
86
+ pytest.importorskip("matplotlib")
87
+ from openra_bench.minimap import render_b64, terrain_png_for
88
+
89
+ t = terrain_png_for("rush-hour-arena")
90
+ assert t and t[:4] == b"\x89PNG" # real map.png from .oramap
91
+ rs = {
92
+ "minimap": "\n".join("#" * 128 for _ in range(40)),
93
+ "map_width": 128, "map_height": 40, "bounds_x": 0, "bounds_y": 0,
94
+ "units_summary": [{"id": "1", "cell_x": 6, "cell_y": 8}],
95
+ "enemy_summary": [],
96
+ }
97
+ with_terrain = render_b64(rs, t)
98
+ fallback = render_b64(rs, None)
99
+ im = Image.open(io.BytesIO(base64.b64decode(with_terrain)))
100
+ im.verify()
101
+ assert im.format == "PNG"
102
+ assert with_terrain != fallback # training path actually used
103
+ assert terrain_png_for("no-such-map") is None # graceful
104
+
105
+
106
+ def test_agent_uses_terrain_when_base_map_given():
107
+ from openra_bench.agent import ModelAgent
108
+ from openra_bench.providers import ProviderConfig
109
+
110
+ a = ModelAgent(ProviderConfig(vision=True), allowed_tools=["observe"],
111
+ provider=type("P", (), {"complete": lambda *x, **k: None})(),
112
+ base_map="rush-hour-arena")
113
+ assert a._terrain and a._terrain[:4] == b"\x89PNG"