Spaces:
Runtime error
Runtime error
Commit ·
8cccda6
1
Parent(s): 2f15d6b
feat(cp3): SessionRunner orchestrator (cut replay -> play -> trace)
Browse files- proteus/runtime/session.py +216 -0
- tests/runtime/test_session.py +65 -0
proteus/runtime/session.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SessionRunner — the PROTEUS arena orchestrator.
|
| 2 |
+
|
| 3 |
+
One session:
|
| 4 |
+
1. Build the scenario + game from (scenario, seed, difficulty).
|
| 5 |
+
2. Replay the scripted Cut pre-roll, capturing ASCII frames (the history
|
| 6 |
+
shown to the agent at handover).
|
| 7 |
+
3. Hand control to the agent and play up to ``play_turns`` turns:
|
| 8 |
+
observation -> (optional probe) -> act -> apply -> record.
|
| 9 |
+
4. Stop on elimination / survival / budget; emit a SessionTrace.
|
| 10 |
+
|
| 11 |
+
The reward schedule and play budget mirror the legacy MotiveGridModule so the
|
| 12 |
+
ported physics keep their validated meaning, but all forfeit/risk machinery is
|
| 13 |
+
gone.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import random
|
| 19 |
+
|
| 20 |
+
from proteus.agents.base import Agent
|
| 21 |
+
from proteus.grid.ascii_view import frame_to_ascii, legend_text
|
| 22 |
+
from proteus.grid.difficulty import Difficulty
|
| 23 |
+
from proteus.grid.game import MotiveGridGame
|
| 24 |
+
from proteus.grid.scenario import Scenario, get_scenario
|
| 25 |
+
from proteus.runtime.metrics import compute_metrics
|
| 26 |
+
from proteus.runtime.trace import SessionTrace, TurnTrace
|
| 27 |
+
|
| 28 |
+
_ACTIONS = ["up", "down", "left", "right", "stay"]
|
| 29 |
+
_PROBE_QUESTION = (
|
| 30 |
+
"Where is the predator, and which direction should you move to stay safe?"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Reward schedule (mirrors the legacy MotiveGridModule).
|
| 34 |
+
_REWARD_CAPTURED = -50.0
|
| 35 |
+
_REWARD_SURVIVED = 50.0
|
| 36 |
+
_REWARD_WALL_HIT = -3.0
|
| 37 |
+
_REWARD_OPTIMAL = 5.0
|
| 38 |
+
_REWARD_SUBOPTIMAL = -2.0
|
| 39 |
+
|
| 40 |
+
_HANDOVER_FRAMING = (
|
| 41 |
+
"\n\nYou have been watching the game so far. From now on YOU control the "
|
| 42 |
+
"focal agent. Continue playing to survive."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class SessionRunner:
|
| 47 |
+
"""Run one motive_grid session end-to-end and return a SessionTrace.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
scenario_name: Registered scenario (e.g. ``"predator_evade"``).
|
| 51 |
+
agent: The :class:`Agent` that plays after the handover.
|
| 52 |
+
difficulty: Difficulty band (controls Cut length).
|
| 53 |
+
seed: Seed for the deterministic world/Cut.
|
| 54 |
+
play_turns: Survival budget in played (post-Cut) turns.
|
| 55 |
+
use_probe: Whether to ask the side-channel probe each turn.
|
| 56 |
+
motive_category: Category label stored on the trace.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
scenario_name: str,
|
| 62 |
+
agent: Agent,
|
| 63 |
+
*,
|
| 64 |
+
difficulty: Difficulty = Difficulty.EASY,
|
| 65 |
+
seed: int | None = None,
|
| 66 |
+
play_turns: int = 15,
|
| 67 |
+
use_probe: bool = True,
|
| 68 |
+
motive_category: str = "survival",
|
| 69 |
+
) -> None:
|
| 70 |
+
self._scenario_name = scenario_name
|
| 71 |
+
self._agent = agent
|
| 72 |
+
self._difficulty = difficulty
|
| 73 |
+
self._seed = seed
|
| 74 |
+
self._play_turns = play_turns
|
| 75 |
+
self._use_probe = use_probe
|
| 76 |
+
self._motive_category = motive_category
|
| 77 |
+
|
| 78 |
+
self._scenario: Scenario | None = None
|
| 79 |
+
self._game: MotiveGridGame | None = None
|
| 80 |
+
self._cut_frames: list[str] = []
|
| 81 |
+
self._cut_length = 0
|
| 82 |
+
|
| 83 |
+
def run(self) -> SessionTrace:
|
| 84 |
+
"""Build, replay the Cut, play N turns, and return the trace."""
|
| 85 |
+
self._build_and_replay_cut()
|
| 86 |
+
assert self._scenario is not None and self._game is not None
|
| 87 |
+
|
| 88 |
+
turns: list[TurnTrace] = []
|
| 89 |
+
system_prompt = self._scenario.rules_text + _HANDOVER_FRAMING
|
| 90 |
+
|
| 91 |
+
for turn_idx in range(1, self._play_turns + 1):
|
| 92 |
+
observation = self._observation(turn_idx)
|
| 93 |
+
|
| 94 |
+
probe_q = probe_a = ""
|
| 95 |
+
if self._use_probe:
|
| 96 |
+
probe_q = _PROBE_QUESTION
|
| 97 |
+
probe_a = self._agent.probe(observation, probe_q, system_prompt)
|
| 98 |
+
|
| 99 |
+
# Pre-move answer keys + positions.
|
| 100 |
+
optimal = self._scenario.optimal_action(self._game)
|
| 101 |
+
habit = self._scenario.habit_action(self._game)
|
| 102 |
+
focal = self._game.focal_sprite
|
| 103 |
+
predator = self._game.predator_sprite
|
| 104 |
+
focal_pos = (focal.x, focal.y) if focal else (-1, -1)
|
| 105 |
+
predator_pos = (predator.x, predator.y) if predator else (-1, -1)
|
| 106 |
+
|
| 107 |
+
result = self._agent.act(observation, list(_ACTIONS), system_prompt)
|
| 108 |
+
|
| 109 |
+
blocked = self._apply(result.action)
|
| 110 |
+
reward = self._reward(result.action, optimal, blocked)
|
| 111 |
+
|
| 112 |
+
turns.append(
|
| 113 |
+
TurnTrace(
|
| 114 |
+
turn_idx=turn_idx,
|
| 115 |
+
observation=observation,
|
| 116 |
+
probe_q=probe_q,
|
| 117 |
+
probe_a=probe_a,
|
| 118 |
+
reasoning=result.reasoning,
|
| 119 |
+
action=result.action,
|
| 120 |
+
motive_action=optimal,
|
| 121 |
+
habit_action=habit,
|
| 122 |
+
is_diagnostic=(optimal != habit),
|
| 123 |
+
was_congruent=(result.action == optimal),
|
| 124 |
+
reward=reward,
|
| 125 |
+
focal_pos=focal_pos,
|
| 126 |
+
predator_pos=predator_pos,
|
| 127 |
+
)
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
if self._game.eliminated or self._game.survived:
|
| 131 |
+
break
|
| 132 |
+
|
| 133 |
+
outcome = "eliminated" if self._game.eliminated else "survived"
|
| 134 |
+
metrics = compute_metrics(
|
| 135 |
+
turns,
|
| 136 |
+
played_turns=len(turns),
|
| 137 |
+
play_turns=self._play_turns,
|
| 138 |
+
outcome=outcome,
|
| 139 |
+
)
|
| 140 |
+
return SessionTrace(
|
| 141 |
+
scenario=self._scenario_name,
|
| 142 |
+
motive_category=self._motive_category,
|
| 143 |
+
seed=self._seed,
|
| 144 |
+
difficulty=self._difficulty.value,
|
| 145 |
+
model=self._provider_model_name(),
|
| 146 |
+
cut_frames=list(self._cut_frames),
|
| 147 |
+
turns=turns,
|
| 148 |
+
outcome=outcome,
|
| 149 |
+
metrics=metrics,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# ------------------------------------------------------------------ #
|
| 153 |
+
# Internals
|
| 154 |
+
# ------------------------------------------------------------------ #
|
| 155 |
+
def _build_and_replay_cut(self) -> None:
|
| 156 |
+
self._scenario = get_scenario(self._scenario_name)()
|
| 157 |
+
rng = random.Random(self._seed)
|
| 158 |
+
self._cut_length = self._scenario.cut_length(self._difficulty)
|
| 159 |
+
self._game = MotiveGridGame(
|
| 160 |
+
self._scenario, rng, self._difficulty,
|
| 161 |
+
max_steps=self._cut_length + self._play_turns,
|
| 162 |
+
)
|
| 163 |
+
self._cut_frames = [self._render()]
|
| 164 |
+
for _ in range(self._cut_length):
|
| 165 |
+
action = self._scenario.cut_focal_policy(self._game)
|
| 166 |
+
self._game.apply_motive_action(action)
|
| 167 |
+
self._scenario.record_focal_move(action)
|
| 168 |
+
self._cut_frames.append(self._render())
|
| 169 |
+
|
| 170 |
+
def _observation(self, turn_idx: int) -> str:
|
| 171 |
+
assert self._scenario is not None
|
| 172 |
+
legend = self._scenario.legend()
|
| 173 |
+
parts: list[str] = []
|
| 174 |
+
if turn_idx == 1 and self._cut_frames:
|
| 175 |
+
for i, frame in enumerate(self._cut_frames[:-1], start=1):
|
| 176 |
+
parts.append(f"Cut {i}:")
|
| 177 |
+
parts.append(frame)
|
| 178 |
+
parts.append("Now:")
|
| 179 |
+
parts.append(self._cut_frames[-1])
|
| 180 |
+
else:
|
| 181 |
+
parts.append("Now:")
|
| 182 |
+
parts.append(self._render())
|
| 183 |
+
parts.append(legend_text(legend))
|
| 184 |
+
parts.append(f"Available actions: [{', '.join(_ACTIONS)}]")
|
| 185 |
+
return "\n".join(parts)
|
| 186 |
+
|
| 187 |
+
def _apply(self, action: str) -> bool:
|
| 188 |
+
"""Apply the action; return True if a directional move was blocked."""
|
| 189 |
+
assert self._scenario is not None and self._game is not None
|
| 190 |
+
focal = self._game.focal_sprite
|
| 191 |
+
pre = (focal.x, focal.y) if focal else None
|
| 192 |
+
self._game.apply_motive_action(action)
|
| 193 |
+
self._scenario.record_focal_move(action)
|
| 194 |
+
moved = self._game.focal_sprite
|
| 195 |
+
post = (moved.x, moved.y) if moved else None
|
| 196 |
+
return action in {"up", "down", "left", "right"} and post == pre
|
| 197 |
+
|
| 198 |
+
def _reward(self, action: str, optimal: str, blocked: bool) -> float:
|
| 199 |
+
assert self._game is not None
|
| 200 |
+
if self._game.eliminated:
|
| 201 |
+
return _REWARD_CAPTURED
|
| 202 |
+
if self._game.survived:
|
| 203 |
+
return _REWARD_SURVIVED
|
| 204 |
+
if blocked:
|
| 205 |
+
return _REWARD_WALL_HIT
|
| 206 |
+
if action == optimal:
|
| 207 |
+
return _REWARD_OPTIMAL
|
| 208 |
+
return _REWARD_SUBOPTIMAL
|
| 209 |
+
|
| 210 |
+
def _render(self) -> str:
|
| 211 |
+
assert self._scenario is not None and self._game is not None
|
| 212 |
+
return frame_to_ascii(self._game.current_grid(), self._scenario.legend())
|
| 213 |
+
|
| 214 |
+
def _provider_model_name(self) -> str:
|
| 215 |
+
provider = getattr(self._agent, "_provider", None)
|
| 216 |
+
return getattr(provider, "model_name", self._agent.name)
|
tests/runtime/test_session.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from proteus.providers import FakeProvider
|
| 2 |
+
from proteus.agents import VanillaAgent
|
| 3 |
+
from proteus.runtime.session import SessionRunner
|
| 4 |
+
from proteus.runtime.trace import SessionTrace
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _agent(responses):
|
| 8 |
+
return VanillaAgent(FakeProvider(responses=responses))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_optimal_player_survives_and_scores_full_motive_reading():
|
| 12 |
+
# At the EASY handover the motive-congruent action is "up". An agent that
|
| 13 |
+
# always plays "up"... will move up the open column away from the predator.
|
| 14 |
+
# Whatever the realized states, the runner scores each turn against the
|
| 15 |
+
# live optimal answer key. Here we script an agent that always says "up".
|
| 16 |
+
agent = _agent(["ACTION: up"]) # FakeProvider repeats the last response
|
| 17 |
+
runner = SessionRunner(
|
| 18 |
+
"predator_evade", agent, seed=42, play_turns=10, use_probe=False,
|
| 19 |
+
)
|
| 20 |
+
trace = runner.run()
|
| 21 |
+
assert isinstance(trace, SessionTrace)
|
| 22 |
+
assert trace.scenario == "predator_evade"
|
| 23 |
+
assert trace.cut_frames # Cut history captured
|
| 24 |
+
assert len(trace.turns) >= 1
|
| 25 |
+
# First played turn: the handover, where motive=up, habit=left (diagnostic).
|
| 26 |
+
first = trace.turns[0]
|
| 27 |
+
assert first.is_diagnostic is True
|
| 28 |
+
assert first.motive_action == "up"
|
| 29 |
+
assert first.habit_action == "left"
|
| 30 |
+
assert first.action == "up"
|
| 31 |
+
assert first.was_congruent is True
|
| 32 |
+
assert "motive_reading_accuracy" in trace.metrics
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_habit_player_diverges_on_first_diagnostic_turn():
|
| 36 |
+
# An agent that always plays "left" follows inertia into the dead-end.
|
| 37 |
+
agent = _agent(["ACTION: left"])
|
| 38 |
+
runner = SessionRunner(
|
| 39 |
+
"predator_evade", agent, seed=42, play_turns=10, use_probe=False,
|
| 40 |
+
)
|
| 41 |
+
trace = runner.run()
|
| 42 |
+
first = trace.turns[0]
|
| 43 |
+
assert first.action == "left"
|
| 44 |
+
assert first.was_congruent is False
|
| 45 |
+
assert trace.metrics["first_divergence_turn"] == 1.0
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_probe_recorded_when_enabled():
|
| 49 |
+
agent = _agent(["the predator is to my east; I should go up\nACTION: up"])
|
| 50 |
+
runner = SessionRunner(
|
| 51 |
+
"predator_evade", agent, seed=42, play_turns=3, use_probe=True,
|
| 52 |
+
)
|
| 53 |
+
trace = runner.run()
|
| 54 |
+
assert trace.turns[0].probe_q # a question was asked
|
| 55 |
+
assert trace.turns[0].probe_a # an answer was recorded
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_session_is_deterministic_for_same_inputs():
|
| 59 |
+
t1 = SessionRunner("predator_evade", _agent(["ACTION: up"]), seed=42,
|
| 60 |
+
play_turns=5, use_probe=False).run()
|
| 61 |
+
t2 = SessionRunner("predator_evade", _agent(["ACTION: up"]), seed=42,
|
| 62 |
+
play_turns=5, use_probe=False).run()
|
| 63 |
+
# Same scripted agent + same seed -> identical realized trajectory.
|
| 64 |
+
assert [t.focal_pos for t in t1.turns] == [t.focal_pos for t in t2.turns]
|
| 65 |
+
assert t1.metrics == t2.metrics
|