irregular6612 commited on
Commit
8cccda6
·
1 Parent(s): 2f15d6b

feat(cp3): SessionRunner orchestrator (cut replay -> play -> trace)

Browse files
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