OpenRA-Bench / openra_bench /full_playback.py
yxc20098's picture
Phase 4 data plumbing + 3rd defect partial fix
9000fe3
Raw
History Blame Contribute Delete
14.1 kB
"""Audit-ready per-cell data capture (Phase 4 paper-collection format).
This is the **audit format** used by `scripts/collect_eval_data.py`. It
is additive over `openra_bench.playback.Playback`: that one stays the
inspect-by-human format (legible per-turn record, terse signals); this
one captures EVERYTHING needed to forensically replay or re-score a
run after the fact — full obs (including `_raw` and `spatial`), the
exact briefing the model saw, the system prompt, the literal HTTP
request body sent to the provider, the literal response (content +
tool_calls + finish_reason + usage), engine warnings, and a `terminal`
block on the final turn (outcome, final_obs, wall-clock, tokens).
Layout: one JSONL per (model, pack, level, seed, fog_mode) cell at
<root>/<pack>__<level>__seed<N>__<fog_mode>.jsonl
PNG minimaps go alongside in a sibling dir of the same stem:
<root>/<pack>__<level>__seed<N>__<fog_mode>/turn_<N>.png
The JSONL line for a turn refs the PNG by relative path (relative to
the JSONL file's parent). A `terminal:` field on the final line marks
the episode complete — `scripts/collect_eval_data.py --resume` uses
that marker to skip cells that already finished cleanly.
Why a separate writer (vs extending Playback): the legacy Playback
format is what `scripts/view_playback.py` reads and what `run_eval.py`
emits today; rewriting it would invalidate every existing playback
dir and break the viewer for ~1000 historical episodes. FullPlayback
runs ALONGSIDE Playback when both are configured; either can be
disabled independently.
"""
from __future__ import annotations
import base64
import json
import time
from dataclasses import asdict, is_dataclass
from pathlib import Path
from typing import Any
def _jsonable(o: Any) -> Any:
"""Recursive serializer matching playback._jsonable but tolerant of
bytes (base64'd) and tuples (preserved as lists). Bytes are wrapped
as `{"__b64__": <base64-string>}` so a round-trip recovers them."""
if isinstance(o, bytes):
return {"__b64__": base64.b64encode(o).decode("ascii")}
if is_dataclass(o) and not isinstance(o, type):
return _jsonable(asdict(o))
if isinstance(o, dict):
return {str(k): _jsonable(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [_jsonable(v) for v in o]
if isinstance(o, set):
return sorted(_jsonable(v) for v in o)
if isinstance(o, (str, int, float, bool)) or o is None:
return o
return repr(o)
def _safe(s: str) -> str:
"""Slug a model id / pack id for path use: keep alnum, `.`, `_`, `-`;
everything else (especially `/`) becomes `_`."""
out = []
for ch in s:
out.append(ch if (ch.isalnum() or ch in "._-") else "_")
return "".join(out)
def cell_stem(pack_id: str, level: str, seed: int, fog_mode: str) -> str:
return f"{_safe(pack_id)}__{_safe(level)}__seed{int(seed)}__{_safe(fog_mode)}"
class FullPlayback:
"""Per-cell audit-ready JSONL + PNG writer.
Construct one per (pack, level, seed, fog_mode) cell. Call
`record_turn(...)` once per model turn (mirroring how the legacy
Playback is driven from `run_level`); call `finalize(...)` on
episode end (it emits the `terminal:` field merged into the last
turn line, NOT a new line — so the file is one-line-per-turn and
the terminal marker is unambiguous).
Concurrency: one cell == one subprocess in the collector, so no
cross-cell locking is needed. Within a process, this class is NOT
thread-safe (the eval loop is single-threaded per episode).
"""
def __init__(
self,
root: str | Path,
pack_id: str,
level: str,
seed: int,
fog_mode: str,
):
self.root = Path(root)
self.root.mkdir(parents=True, exist_ok=True)
self.stem = cell_stem(pack_id, level, seed, fog_mode)
self.jsonl_path = self.root / f"{self.stem}.jsonl"
self.png_dir = self.root / self.stem
self.png_dir.mkdir(parents=True, exist_ok=True)
# Use a sidecar tmp until finalize, then move atomically over the
# final path so a `--resume` scan only sees complete files.
self._tmp_path = self.root / f"{self.stem}.jsonl.partial"
self._fh = open(self._tmp_path, "w")
self.pack_id = pack_id
self.level = level
self.seed = seed
self.fog_mode = fog_mode
self._t0 = time.time()
# Buffer the last turn line so finalize() can merge `terminal:`
# into it instead of writing a fresh trailing line (one line ==
# one turn; the terminal block is a field on the last line).
self._last_rec: dict | None = None
# Token accounting across the whole episode (provider-reported
# usage from each model call); the totals land in `terminal:`.
self._tokens_in = 0
self._tokens_out = 0
# First turn carries the system_prompt; subsequent turns repeat
# it as `null` to keep the per-turn record uniform but small.
self._sysp_written = False
# ── per-turn ──────────────────────────────────────────────────────
def record_turn(
self,
*,
turn: int,
tick: int | None,
obs: dict,
briefing: str,
system_prompt: str,
model_request: dict | None,
model_response: dict | None,
commands_issued: list,
engine_warnings: list[str],
signals: Any,
minimap_png_b64: str | None = None,
done: bool = False,
interrupt: str | None = None,
extra: dict | None = None,
) -> None:
# Flush the previously-buffered line now that we know we're past
# it (a new turn started, so the prior turn was NOT terminal).
if self._last_rec is not None:
self._fh.write(json.dumps(_jsonable(self._last_rec)) + "\n")
self._fh.flush()
self._last_rec = None
# Track per-call tokens (provider returns them in response.usage)
u = (model_response or {}).get("usage") or {}
self._tokens_in += int(u.get("prompt_tokens", 0) or 0)
self._tokens_out += int(u.get("completion_tokens", 0) or 0)
png_rel: str | None = None
if minimap_png_b64:
try:
png_path = self.png_dir / f"turn_{int(turn):03d}.png"
png_path.write_bytes(base64.b64decode(minimap_png_b64))
# Relative to the JSONL's parent dir so the file is
# portable (you can move the run dir without breaking
# refs).
png_rel = f"{self.stem}/turn_{int(turn):03d}.png"
except Exception: # noqa: BLE001 — never break a run on I/O
png_rel = None
rec: dict = {
"turn": int(turn),
"tick": tick,
"interrupt": interrupt,
"obs": _jsonable(obs),
"briefing": briefing,
"system_prompt": system_prompt if not self._sysp_written else None,
"model_request": _jsonable(model_request) if model_request else None,
"model_response": _jsonable(model_response) if model_response else None,
"commands_issued": [repr(c) for c in commands_issued],
"engine_warnings": list(engine_warnings or []),
"signals": _jsonable(_signal_snapshot(signals)),
"minimap_png": png_rel,
"done": bool(done),
}
if extra:
rec["extra"] = _jsonable(extra)
self._sysp_written = True
# Buffer; finalize() merges `terminal:` into this line.
self._last_rec = rec
def finalize(
self,
*,
outcome: str,
final_obs: dict | None,
manifest_extra: dict | None = None,
) -> None:
"""Stamp the buffered last line with the `terminal:` block,
flush, and atomically move the partial file over the final
path so `--resume` sees a fully-complete cell."""
wall = round(time.time() - self._t0, 3)
terminal = {
"outcome": outcome,
"final_obs": _jsonable(final_obs) if final_obs is not None else None,
"wall_clock_seconds": wall,
"total_tokens_in": int(self._tokens_in),
"total_tokens_out": int(self._tokens_out),
}
if manifest_extra:
terminal["manifest"] = _jsonable(manifest_extra)
if self._last_rec is None:
# Episode produced zero turns (engine crashed on reset, say).
# Emit a synthetic terminal-only record so `--resume` can
# still detect "this cell was attempted and completed".
self._last_rec = {
"turn": 0,
"tick": None,
"interrupt": None,
"obs": None,
"briefing": "",
"system_prompt": None,
"model_request": None,
"model_response": None,
"commands_issued": [],
"engine_warnings": [],
"signals": {},
"minimap_png": None,
"done": True,
}
self._last_rec["terminal"] = terminal
self._fh.write(json.dumps(_jsonable(self._last_rec)) + "\n")
self._fh.flush()
try:
self._fh.close()
except Exception: # noqa: BLE001
pass
try:
self._tmp_path.replace(self.jsonl_path)
except Exception: # noqa: BLE001 — last-ditch fallback
try:
self.jsonl_path.write_text(self._tmp_path.read_text())
except Exception: # noqa: BLE001
pass
def abort(self) -> None:
"""Close without finalizing. The .partial file stays on disk so
a post-hoc diagnostic can inspect what was captured before the
crash; the final .jsonl is NOT created, so `--resume` will
correctly retry this cell on the next invocation."""
try:
if self._last_rec is not None:
self._fh.write(json.dumps(_jsonable(self._last_rec)) + "\n")
self._fh.flush()
self._fh.close()
except Exception: # noqa: BLE001
pass
def _signal_snapshot(signals: Any) -> dict:
"""Pull every primitive scalar / list off an EpisodeSignals (or
duck-typed shim) into a JSON-safe dict. Defensive: a missing attr
is just absent. Mirrors the existing playback shape so downstream
tools recognise the same field names, but adds a few signals
(resources, harvesters, tool_violations) that the existing playback
omits."""
fields = (
"game_tick",
"cash",
"resources",
"resource_capacity",
"power_provided",
"power_drained",
"harvesters",
"explored_percent",
"units_killed",
"units_lost",
"enemies_seen_ids",
"enemy_buildings_seen_ids",
"production_items",
"tool_violations",
"outcome",
)
out: dict[str, Any] = {}
for f in fields:
if not hasattr(signals, f):
continue
v = getattr(signals, f)
if isinstance(v, (set, frozenset)):
v = sorted(_jsonable(x) for x in v)
out[f] = _jsonable(v)
# Convenience: counts + computed signals downstream uses heavily.
if "enemies_seen_ids" in out and isinstance(out["enemies_seen_ids"], list):
out["enemies_seen_count"] = len(out["enemies_seen_ids"])
if "enemy_buildings_seen_ids" in out and isinstance(
out["enemy_buildings_seen_ids"], list
):
out["enemy_buildings_seen_count"] = len(out["enemy_buildings_seen_ids"])
if "cash" in out and "resources" in out:
try:
out["economy_value"] = int(out["cash"]) + int(out["resources"])
except (TypeError, ValueError):
pass
return out
def is_complete_cell(jsonl_path: str | Path) -> bool:
"""True iff `jsonl_path` exists, is non-empty, and the LAST line
carries a `terminal:` field. The resume scanner uses this so a
crash mid-cell (partial .jsonl with no terminal) is correctly
retried, while a cleanly finished cell is correctly skipped.
Reads only the file tail (~64KB) — safe to call on thousands of
cells in a scan."""
p = Path(jsonl_path)
if not p.exists() or p.stat().st_size == 0:
return False
# Tail read: locate the last newline boundary so we can parse just
# the trailing line without loading the whole file. The terminal
# line carries the FULL final_obs (which on a long episode with
# spatial tensors can be many MB), so we walk back from EOF until
# we cross a newline rather than guessing a fixed window.
try:
with open(p, "rb") as fh:
fh.seek(0, 2)
size = fh.tell()
# Walk back in 256KB chunks until we hit a newline OR start.
chunk = 256 * 1024
buf = b""
pos = size
while pos > 0:
step = min(chunk, pos)
pos -= step
fh.seek(pos)
buf = fh.read(step) + buf
# The last line begins right after the LAST newline in
# buf (excluding a trailing newline). We need TWO
# newlines (or start-of-file + one) to be sure we have
# the complete last line.
stripped = buf.rstrip(b"\n")
nl = stripped.rfind(b"\n")
if nl != -1:
last_bytes = stripped[nl + 1:]
break
if pos == 0:
last_bytes = stripped
break
last = last_bytes.decode("utf-8", errors="replace").strip()
if not last:
return False
rec = json.loads(last)
except (OSError, ValueError):
return False
return isinstance(rec, dict) and "terminal" in rec