OpenRA-Bench / openra_bench /handoff.py
yxc20098's picture
Add handoff ablation (recover-from-deficit / capitalize-on-advantage)
cb15568
Raw
History Blame Contribute Delete
6.15 kB
"""Handoff ablation — hand a model a partially-played game.
A handoff episode is split: a `prefix` controller plays the first `k`
turns, then the model inherits the live game state and finishes it.
It is a PURE STATE handoff — the model gets no transcript of the
prefix, only the board it produced ("take over from here").
Sweeping the prefix QUALITY decomposes two capabilities:
* a **good** prefix (a winning trajectory) → can the model *capitalize
on an advantage*? A flat-low outcome curve means it derails even a
won position.
* a **bad** prefix (a losing trajectory, or `stall`) → can the model
*recover from a deficit*? This is the controlled measurement of the
freeze-and-panic failure mode: handed a losing board, does the model
fight (retreat / redirect) or sit on `observe`/`stop`? The
`passivity` stat on the result quantifies exactly that.
The prefix is a recorded run replayed turn-for-turn. Because engine
actor ids are seed-deterministic, a replayed trajectory MUST come from
the same `pack:level:seed` as the handoff episode.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .controller import BaseController, as_controller, introspection_source
# A turn is "passive" when the model issued nothing but these — the
# freeze-and-panic tell (low-commitment default instead of an active
# retreat / redirect).
_PASSIVE_TOOLS = {"observe", "stop"}
def stall_policy(render_state: dict, Command: Any) -> list:
"""The canonical losing prefix: do nothing, every turn. Synthesises
a guaranteed-deficit handoff with no recorded trajectory needed."""
return [Command.observe()]
def _load_trajectory(source: Any) -> list[list[dict]]:
"""Per-turn tool-call lists from a recorded run. `source` may be a
ready list, a Playback directory, or a path to its messages.json."""
if isinstance(source, list):
return source
p = Path(source)
if p.is_dir():
p = p / "messages.json"
msgs = json.loads(p.read_text())
turns: list[list[dict]] = []
for m in msgs:
if m.get("role") != "assistant":
continue
calls: list[dict] = []
for tc in m.get("tool_calls") or []:
fn = tc.get("function") or {}
args = fn.get("arguments")
if isinstance(args, str):
try:
args = json.loads(args)
except (ValueError, TypeError):
args = {}
calls.append({"name": fn.get("name"), "arguments": args or {}})
turns.append(calls)
return turns
class TrajectoryController(BaseController):
"""Replays a recorded run: turn N re-issues the commands the
recorded agent issued on its turn N. Past the recording's end it
falls back to `observe()`. Used as a deterministic handoff prefix —
a `win`-outcome run is a good prefix, a `loss` is a bad one."""
def __init__(self, source: Any, name: str | None = None) -> None:
super().__init__(name or "trajectory")
self._turns = _load_trajectory(source)
self._i = 0
def reset(self, ctx: Any) -> None:
self._i = 0
def act(self, observation: dict, Command: Any) -> list:
from .agent import _to_commands
if self._i < len(self._turns):
calls = self._turns[self._i]
self._i += 1
return _to_commands(calls, Command) or [Command.observe()]
return [Command.observe()]
def _is_passive(cmds: list, _cmd_name) -> bool:
"""A turn with no command other than observe/stop (or no command)."""
if not cmds:
return True
return all((_cmd_name(c) or "") in _PASSIVE_TOOLS for c in cmds)
class HandoffController(BaseController):
"""`prefix` plays turns 0..k-1, then `main` inherits the live state
and finishes the episode. Pure state handoff — `main` carries no
transcript of the prefix.
`handoff_stats` accumulates, over the MAIN agent's turns only:
`main_turns`, `passive_turns` (observe/stop-only), and `passivity`
(their ratio) — the freeze-and-panic signal. When the prefix handed
`main` a losing position, `passivity` IS passivity-under-pressure."""
def __init__(
self, prefix: Any, main: Any, k: int, name: str | None = None
) -> None:
super().__init__(name or f"handoff-k{int(k)}")
self._prefix = as_controller(prefix)
self._main = as_controller(main)
self._k = max(0, int(k))
self._turn = 0
# Playback should record the MAIN agent's transcript, not this
# wrapper's — expose it as the introspection source.
self.source = introspection_source(self._main)
self.handoff_stats = self._fresh_stats()
def _fresh_stats(self) -> dict:
return {
"k": self._k, "main_turns": 0,
"passive_turns": 0, "passivity": 0.0,
}
def reset(self, ctx: Any) -> None:
self._turn = 0
self._prefix.reset(ctx)
self._main.reset(ctx)
self.handoff_stats = self._fresh_stats()
def act(self, observation: dict, Command: Any) -> list:
if self._turn < self._k:
self._turn += 1
return self._prefix.act(observation, Command)
cmds = self._main.act(observation, Command)
self._turn += 1
from .eval_core import _cmd_tool_name
st = self.handoff_stats
st["main_turns"] += 1
if _is_passive(cmds, _cmd_tool_name):
st["passive_turns"] += 1
st["passivity"] = st["passive_turns"] / st["main_turns"]
return cmds
def run_handoff(
compiled: Any, main: Any, prefix: Any, k: int,
seed: int = 0, playback: Any = None,
):
"""Run a handoff episode: `prefix` plays the first `k` turns, `main`
finishes. Returns the `EpisodeResult` with `.handoff_stats` attached
(k, main_turns, passive_turns, passivity)."""
from .eval_core import run_level
ctrl = HandoffController(prefix, main, k)
res = run_level(compiled, ctrl, seed, playback)
res.handoff_stats = dict(ctrl.handoff_stats)
return res