irregular6612 Claude Opus 4.8 (1M context) commited on
Commit
93cd78f
·
1 Parent(s): bd0ae14

refactor(scenario): delete predator_evade; template is the canonical scenario

Browse files
Files changed (46) hide show
  1. proteus/cli/parser.py +3 -3
  2. proteus/game/agents/vanilla.py +3 -3
  3. proteus/game/runtime/session.py +1 -1
  4. proteus/game/scenarios/__init__.py +1 -2
  5. proteus/game/scenarios/base.py +5 -5
  6. proteus/game/scenarios/predator_evade.py +0 -604
  7. proteus/game/scenarios/template.py +1 -1
  8. proteus/game/viz/reconstruct.py +7 -7
  9. tests/cli/test_cli.py +15 -15
  10. tests/cli/test_cli_gif.py +2 -2
  11. tests/cli/test_cli_memory.py +6 -6
  12. tests/cli/test_cli_persona.py +7 -7
  13. tests/engine/test_turn_order.py +1 -1
  14. tests/grid/test_difficulty_layouts.py +0 -96
  15. tests/grid/test_distance_helpers.py +4 -4
  16. tests/grid/test_footprint_bounds.py +1 -12
  17. tests/grid/test_predator_evade_behavior.py +0 -46
  18. tests/grid/test_predator_evade_registered.py +0 -8
  19. tests/grid/test_step_reward.py +10 -27
  20. tests/grid/test_template_observation.py +1 -9
  21. tests/runtime/test_aggregate.py +1 -1
  22. tests/runtime/test_human_comparability.py +2 -2
  23. tests/runtime/test_integration_golden.py +9 -3
  24. tests/runtime/test_interactive_equivalence.py +2 -2
  25. tests/runtime/test_interactive_session.py +2 -2
  26. tests/runtime/test_io.py +2 -2
  27. tests/runtime/test_memory.py +1 -1
  28. tests/runtime/test_memory_gen.py +5 -14
  29. tests/runtime/test_memory_persona.py +3 -3
  30. tests/runtime/test_persona.py +11 -14
  31. tests/runtime/test_rollout.py +15 -6
  32. tests/runtime/test_session.py +16 -40
  33. tests/runtime/test_session_core.py +9 -9
  34. tests/runtime/test_session_distance.py +2 -2
  35. tests/runtime/test_session_memory.py +16 -16
  36. tests/runtime/test_spectate.py +2 -2
  37. tests/runtime/test_spectate_equivalence.py +2 -2
  38. tests/runtime/test_trace.py +1 -1
  39. tests/runtime/test_trace_accounting.py +2 -2
  40. tests/viz/test_gif.py +1 -1
  41. tests/viz/test_png.py +1 -1
  42. tests/viz/test_reconstruct.py +2 -2
  43. tests/viz/test_terminal.py +1 -1
  44. tests/web/test_memory_modes.py +8 -8
  45. tests/web/test_server.py +7 -7
  46. tests/web/test_spectate_routes.py +3 -3
proteus/cli/parser.py CHANGED
@@ -25,7 +25,7 @@ def build_parser() -> argparse.ArgumentParser:
25
  sub = parser.add_subparsers(dest="command", required=True)
26
 
27
  run = sub.add_parser("run", help="run one session and append its trace")
28
- run.add_argument("--scenario", default="predator_evade")
29
  run.add_argument(
30
  "--model",
31
  required=True,
@@ -61,7 +61,7 @@ def build_parser() -> argparse.ArgumentParser:
61
  run.set_defaults(func=_cmd_run)
62
 
63
  play = sub.add_parser("play", help="play a session as a human via stdin")
64
- play.add_argument("--scenario", default="predator_evade")
65
  play.add_argument(
66
  "--difficulty", default="easy", choices=[d.value for d in Difficulty]
67
  )
@@ -82,7 +82,7 @@ def build_parser() -> argparse.ArgumentParser:
82
  memory = sub.add_parser(
83
  "memory", help="generate + save an LLM memory pre-roll checkpoint"
84
  )
85
- memory.add_argument("--scenario", default="predator_evade")
86
  memory.add_argument(
87
  "--model", required=True,
88
  help=(
 
25
  sub = parser.add_subparsers(dest="command", required=True)
26
 
27
  run = sub.add_parser("run", help="run one session and append its trace")
28
+ run.add_argument("--scenario", default="template")
29
  run.add_argument(
30
  "--model",
31
  required=True,
 
61
  run.set_defaults(func=_cmd_run)
62
 
63
  play = sub.add_parser("play", help="play a session as a human via stdin")
64
+ play.add_argument("--scenario", default="template")
65
  play.add_argument(
66
  "--difficulty", default="easy", choices=[d.value for d in Difficulty]
67
  )
 
82
  memory = sub.add_parser(
83
  "memory", help="generate + save an LLM memory pre-roll checkpoint"
84
  )
85
+ memory.add_argument("--scenario", default="template")
86
  memory.add_argument(
87
  "--model", required=True,
88
  help=(
proteus/game/agents/vanilla.py CHANGED
@@ -15,9 +15,9 @@ from proteus.providers.thinking_utils import parse_thinking_tags
15
 
16
  _DEFAULT_ACTION = "stay"
17
 
18
- # NOTE: this directive is predator_evade-scoped for the current slice; the spec
19
- # defers scenario generalization, so the "predator" framing is intentional here.
20
- # A future multi-scenario agent should make this directive configurable.
21
  _ACTION_DIRECTIVE = (
22
  "\n\nThink about where the predator is and where it is heading, then end "
23
  "your reply with a line:\nACTION: <one of {actions}>"
 
15
 
16
  _DEFAULT_ACTION = "stay"
17
 
18
+ # NOTE: this directive is template-scoped (predator-evasion) for the current
19
+ # slice; the spec defers scenario generalization, so the "predator" framing is
20
+ # intentional here. A future multi-scenario agent should make this configurable.
21
  _ACTION_DIRECTIVE = (
22
  "\n\nThink about where the predator is and where it is heading, then end "
23
  "your reply with a line:\nACTION: <one of {actions}>"
proteus/game/runtime/session.py CHANGED
@@ -22,7 +22,7 @@ class SessionRunner:
22
  """Run one motive_grid session end-to-end and return a SessionTrace.
23
 
24
  Args:
25
- scenario_name: Registered scenario (e.g. ``"predator_evade"``).
26
  agent: The :class:`Agent` that plays after the handover.
27
  difficulty: Difficulty band (controls Cut length).
28
  seed: Seed for the deterministic world/Cut.
 
22
  """Run one motive_grid session end-to-end and return a SessionTrace.
23
 
24
  Args:
25
+ scenario_name: Registered scenario (e.g. ``"template"``).
26
  agent: The :class:`Agent` that plays after the handover.
27
  difficulty: Difficulty band (controls Cut length).
28
  seed: Seed for the deterministic world/Cut.
proteus/game/scenarios/__init__.py CHANGED
@@ -4,9 +4,8 @@ Importing this package fires each scenario's ``@register_scenario`` decorator,
4
  populating the registry in :mod:`proteus.game.scenarios.base`.
5
  """
6
 
7
- from proteus.game.scenarios import predator_evade # noqa: F401 — side-effect: register
8
  from proteus.game.scenarios import template # noqa: F401 — side-effect: register
9
  from proteus.game.scenarios import predator_chase # noqa: F401 — side-effect: register
10
  from proteus.game.scenarios import resource_race # noqa: F401 — side-effect: register
11
 
12
- __all__ = ["predator_evade", "template", "predator_chase", "resource_race"]
 
4
  populating the registry in :mod:`proteus.game.scenarios.base`.
5
  """
6
 
 
7
  from proteus.game.scenarios import template # noqa: F401 — side-effect: register
8
  from proteus.game.scenarios import predator_chase # noqa: F401 — side-effect: register
9
  from proteus.game.scenarios import resource_race # noqa: F401 — side-effect: register
10
 
11
+ __all__ = ["template", "predator_chase", "resource_race"]
proteus/game/scenarios/base.py CHANGED
@@ -13,13 +13,13 @@ decorator, mirroring the task-module registry at
13
  Scenario, register_scenario,
14
  )
15
 
16
- @register_scenario("predator_evade")
17
- class PredatorEvade(Scenario):
18
  ...
19
 
20
  # Elsewhere:
21
  from proteus.game.scenarios.base import get_scenario
22
- scenario = get_scenario("predator_evade")()
23
 
24
  See ``docs/superpowers/specs/2026-06-01-motive-grid-design.md`` §4 for the
25
  interface contract.
@@ -69,8 +69,8 @@ class Scenario(ABC):
69
  # Turn resolution order. "focal_first" (default): focal moves, then the
70
  # threat advances (chasing the focal's NEW cell). "predator_first": the
71
  # threat advances first (chasing the focal's CURRENT cell), then the focal
72
- # moves. Scenarios override this; predator_evade keeps the default so its
73
- # dead-end diagnostic is preserved.
74
  turn_order: str = "focal_first"
75
 
76
  @abstractmethod
 
13
  Scenario, register_scenario,
14
  )
15
 
16
+ @register_scenario("template")
17
+ class Template(Scenario):
18
  ...
19
 
20
  # Elsewhere:
21
  from proteus.game.scenarios.base import get_scenario
22
+ scenario = get_scenario("template")()
23
 
24
  See ``docs/superpowers/specs/2026-06-01-motive-grid-design.md`` §4 for the
25
  interface contract.
 
69
  # Turn resolution order. "focal_first" (default): focal moves, then the
70
  # threat advances (chasing the focal's NEW cell). "predator_first": the
71
  # threat advances first (chasing the focal's CURRENT cell), then the focal
72
+ # moves. Scenarios override this as needed (e.g. template uses
73
+ # "predator_first").
74
  turn_order: str = "focal_first"
75
 
76
  @abstractmethod
proteus/game/scenarios/predator_evade.py DELETED
@@ -1,604 +0,0 @@
1
- """predator_evade — the bear-chase evasion scenario for motive_grid.
2
-
3
- A predator pursues the focal agent across the grid (8x8 at EASY; larger at
4
- higher difficulties) by taking one shortest-path (BFS) step toward it every
5
- turn, so its motion reads as a clear
6
- *chase* intent rather than noise (the ToM signal the benchmark probes). The
7
- world is laced with a short internal wall that forms a **dead-end to the west**,
8
- exactly where the focal agent has been walking. At the Cut handover the only
9
- survival-correct move is to *detour* away from the wall while the fixed habit
10
- ("keep walking west") runs the agent straight into the dead-end with the
11
- predator closing from behind. That divergence — ``optimal_action`` (escape) vs
12
- ``habit_action`` (keep west) — is the diagnostic the benchmark measures.
13
-
14
- Coordinate convention (shared with ``game.py``): ``y`` grows DOWNWARD. Actions
15
- are the strings ``"up"`` ``(0,-1)``, ``"down"`` ``(0,1)``, ``"left"`` ``(-1,0)``,
16
- ``"right"`` ``(1,0)``, ``"stay"`` ``(0,0)``.
17
-
18
- Deterministic EASY layout (seed-independent; placement is fixed, not random,
19
- so ``rng`` is threaded only for forward-compatible tie-breaks)::
20
-
21
- col: 0 1 2 3 4 5 6 7 x ->
22
- row 0 . . . . . . . .
23
- row 1 . . . . . . . .
24
- row 2 . . # . . . . . '#' = wall (PIXEL_PERFECT)
25
- row 3 . . # A . S B . 'A' = focal start (5,3) ... walks west
26
- row 4 . . # . . . . . 'B' = predator start (7,3)
27
- row 5 . . # . . . . . 'S' = focal Cut-handover cell (3,3)
28
- row 6 . . . . . . . .
29
- row 7 . . . . . . . .
30
-
31
- Cut pre-roll (``cut_length`` EASY = 2, ``cut_focal_policy`` = "left"):
32
-
33
- start focal (5,3) predator (7,3)
34
- step 1 focal (4,3) predator (6,3)
35
- step 2 focal (3,3) predator (5,3) <- HANDOVER / diagnostic state
36
-
37
- At the handover the wall column ``x=2`` (rows 2..5) sits directly west of the
38
- focal at ``(3,3)``:
39
-
40
- * ``habit_action`` = "left" -> blocked by the wall (dead-end), no progress,
41
- BFS distance to predator stays 2.
42
- * ``optimal_action`` = "up" -> steps to ``(3,2)``, the legal move that
43
- MAXIMIZES BFS distance from the predator (distance 3). ("up" and "down"
44
- tie at distance 3; the fixed action order breaks the tie in favour of
45
- "up".)
46
-
47
- Hence ``optimal_action != habit_action`` at the Cut — the property the whole
48
- discrimination metric depends on.
49
-
50
- Palette indices (consistent with :meth:`PredatorEvade.legend` and the pixels
51
- handed to each sprite in :meth:`PredatorEvade.build_level`):
52
-
53
- * ``5`` -> ``'.'`` background (the arc_grid camera default)
54
- * ``1`` -> ``'A'`` focal agent
55
- * ``2`` -> ``'B'`` predator
56
- * ``3`` -> ``'#'`` wall
57
- """
58
-
59
- from __future__ import annotations
60
-
61
- import random
62
- from collections import deque
63
- from dataclasses import dataclass
64
- from typing import TYPE_CHECKING
65
-
66
- from proteus.game.engine import BlockingMode, Level, Sprite
67
- from proteus.game.engine.difficulty import Difficulty
68
-
69
- from .base import Scenario, register_scenario
70
-
71
- if TYPE_CHECKING:
72
- from ..engine.grid import MotiveGridGame
73
-
74
- # --------------------------------------------------------------------------- #
75
- # Palette indices (must match build_level pixels and legend()).
76
- # --------------------------------------------------------------------------- #
77
- BACKGROUND_IDX = 5 # arc_grid Camera default background
78
- FOCAL_IDX = 1
79
- PREDATOR_IDX = 2
80
- WALL_IDX = 3
81
-
82
- # Survival-category reward constants (moved from SessionRunner; see CP6 spec §5).
83
- _REWARD_CAPTURED = -50.0
84
- _REWARD_SURVIVED = 50.0
85
- _REWARD_WALL_HIT = -3.0
86
-
87
- # --------------------------------------------------------------------------- #
88
- # EASY defaults consumed before build_level runs (class grid_size hint +
89
- # __init__'s pre-build wall set). The full per-difficulty layouts — including
90
- # focal/predator starts — live in _LAYOUTS below.
91
- # --------------------------------------------------------------------------- #
92
- _GRID_SIZE: tuple[int, int] = (8, 8)
93
- # Vertical wall column forming the dead-end west of the focal's path.
94
- _WALL_CELLS: tuple[tuple[int, int], ...] = (
95
- (2, 2),
96
- (2, 3),
97
- (2, 4),
98
- (2, 5),
99
- )
100
-
101
- # --------------------------------------------------------------------------- #
102
- # Per-difficulty hand-authored layouts.
103
- # --------------------------------------------------------------------------- #
104
-
105
- @dataclass(frozen=True)
106
- class _Layout:
107
- """A hand-authored deterministic layout for one difficulty band."""
108
-
109
- grid_size: tuple[int, int]
110
- focal_start: tuple[int, int]
111
- predator_start: tuple[int, int]
112
- wall_cells: tuple[tuple[int, int], ...]
113
-
114
-
115
- # Candidate hand-authored layouts. Tuned under the golden invariant test
116
- # (Task 3): the pre-roll direction ("left") must dead-end into a wall at the
117
- # handover while a perpendicular move increases BFS distance, so
118
- # optimal_action != habit_action. Adjust coordinates if Task 3 fails.
119
- _LAYOUTS: dict[Difficulty, _Layout] = {
120
- Difficulty.EASY: _Layout(
121
- grid_size=(8, 8),
122
- focal_start=(5, 3),
123
- predator_start=(7, 3),
124
- wall_cells=((2, 2), (2, 3), (2, 4), (2, 5)),
125
- ),
126
- Difficulty.MEDIUM: _Layout(
127
- grid_size=(10, 10),
128
- focal_start=(6, 4),
129
- predator_start=(8, 4),
130
- wall_cells=tuple((3, y) for y in range(2, 8)),
131
- ),
132
- Difficulty.HARD: _Layout(
133
- grid_size=(12, 12),
134
- focal_start=(7, 5),
135
- predator_start=(9, 5),
136
- wall_cells=(
137
- tuple((3, y) for y in range(3, 9))
138
- + tuple((x, 8) for x in range(4, 7))
139
- ),
140
- ),
141
- Difficulty.EXPERT: _Layout(
142
- grid_size=(12, 12),
143
- focal_start=(7, 5),
144
- predator_start=(9, 4),
145
- wall_cells=(
146
- tuple((3, y) for y in range(2, 10))
147
- + ((4, 3), (5, 3))
148
- ),
149
- ),
150
- }
151
-
152
- # Action string -> (dx, dy). Mirror of game._DIRECTION_DELTAS; duplicated here
153
- # so the scenario's answer-key reasoning is self-contained and does not import
154
- # private game internals.
155
- _DELTAS: dict[str, tuple[int, int]] = {
156
- "up": (0, -1),
157
- "down": (0, 1),
158
- "left": (-1, 0),
159
- "right": (1, 0),
160
- "stay": (0, 0),
161
- }
162
-
163
- # Fixed deterministic tie-break order for both the predator's chase step and the
164
- # focal's optimal-action search. Movement directions first (so a move that
165
- # strictly improves the objective is always preferred over "stay"), in the
166
- # canonical up/down/left/right order.
167
- _TIE_BREAK_ORDER: tuple[str, ...] = ("up", "down", "left", "right", "stay")
168
-
169
- # Cut pre-roll length per difficulty. EASY = 2 (per spec §5).
170
- _CUT_LENGTH: dict[Difficulty, int] = {
171
- Difficulty.EASY: 2,
172
- Difficulty.MEDIUM: 2,
173
- Difficulty.HARD: 3,
174
- Difficulty.EXPERT: 3,
175
- }
176
- _DEFAULT_CUT_LENGTH = 2
177
-
178
-
179
- @register_scenario("predator_evade")
180
- class PredatorEvade(Scenario):
181
- """Bear-chase evasion scenario (see module docstring for the geometry).
182
-
183
- The scenario owns one piece of mutable bookkeeping that does not belong on
184
- the world: ``_wall_cells`` (the set of wall coordinates, cached in
185
- :meth:`build_level` so the BFS helpers can treat walls as blocked without
186
- re-deriving them from the merged ``sys_static`` sprite) and
187
- ``_last_focal_move`` (the focal's last committed move direction, used by
188
- :meth:`habit_action`). Per-game *world* state (sprite positions, step
189
- count) lives on the injected :class:`~..game.MotiveGridGame`.
190
- """
191
-
192
- task_name: str = "predator_evade"
193
- # Default hint only; build_level() overwrites this per instance with the
194
- # difficulty-specific size. Read scenario.grid_size only AFTER build_level
195
- # has been called (the game sizes its camera from it right after building).
196
- grid_size: tuple[int, int] = _GRID_SIZE
197
- rules_text: str = (
198
- "You control the focal agent 'A' on a grid. A predator 'B' hunts "
199
- "you, moving one cell along the shortest path toward you every turn. "
200
- "Walls '#' block movement; '.' is open ground. You are eliminated if "
201
- "the predator reaches your cell. Survive as long as you can. "
202
- "Actions: up, down, left, right, stay (up decreases the row, down "
203
- "increases it; left/right move along the column). Read the predator's "
204
- "approach and move to stay away from it — do not walk into walls or "
205
- "dead-ends."
206
- )
207
- memory_brief: str = (
208
- "PRACTICE / MEMORY RUN. You control the focal agent 'A' on a grid. A "
209
- "predator 'B' hunts you: every turn it takes one step along the SHORTEST "
210
- "path (BFS) toward your current cell. Walls '#' block movement and form "
211
- "dead-ends; '.' is open ground. You are eliminated if the predator reaches "
212
- "your cell. This is a no-stakes practice run to build your own experience "
213
- "of how the predator chases and how the walls trap you — play to survive "
214
- "and learn the dynamics. Actions: up, down, left, right, stay (up decreases "
215
- "the row, down increases it; left/right move along the column). End your "
216
- "reply with a line: ACTION: <one of up, down, left, right, stay>"
217
- )
218
-
219
- def __init__(self) -> None:
220
- """Initialize per-game scenario bookkeeping.
221
-
222
- ``_last_focal_move`` defaults to ``"left"`` because the focal agent
223
- walks west throughout the Cut pre-roll (:meth:`cut_focal_policy`); at the
224
- handover the model's "habit" is therefore to keep going west, which the
225
- dead-end geometry turns into the wrong move.
226
- """
227
- self._wall_cells: frozenset[tuple[int, int]] = frozenset(_WALL_CELLS)
228
- self._last_focal_move: str = "left"
229
-
230
- # ------------------------------------------------------------------ #
231
- # World construction
232
- # ------------------------------------------------------------------ #
233
- def build_level(self, rng: random.Random, difficulty: Difficulty) -> Level:
234
- """Build the hand-authored level for *difficulty* (deterministic).
235
-
236
- Args:
237
- rng: Seeded RNG (reserved for tie-breaks; layouts are fixed).
238
- difficulty: The session difficulty band.
239
-
240
- Returns:
241
- A :class:`~proteus.game.engine.Level` with focal, predator, and walls.
242
- """
243
- del rng # deterministic hand-authored layouts
244
- layout = _LAYOUTS.get(difficulty, _LAYOUTS[Difficulty.EASY])
245
- self._wall_cells = frozenset(layout.wall_cells)
246
- # Instance attribute shadows the class default so the camera and
247
- # within_bounds both see THIS band's size (they read scenario.grid_size).
248
- self.grid_size = layout.grid_size
249
-
250
- focal = Sprite(
251
- pixels=[[FOCAL_IDX]],
252
- name="focal",
253
- x=layout.focal_start[0],
254
- y=layout.focal_start[1],
255
- blocking=BlockingMode.PIXEL_PERFECT,
256
- )
257
- predator = Sprite(
258
- pixels=[[PREDATOR_IDX]],
259
- name="predator",
260
- x=layout.predator_start[0],
261
- y=layout.predator_start[1],
262
- blocking=BlockingMode.PIXEL_PERFECT,
263
- )
264
- sprites: list[Sprite] = [focal, predator]
265
- for (wx, wy) in layout.wall_cells:
266
- sprites.append(
267
- Sprite(
268
- pixels=[[WALL_IDX]],
269
- name="wall",
270
- x=wx,
271
- y=wy,
272
- blocking=BlockingMode.PIXEL_PERFECT,
273
- tags=["sys_static"],
274
- )
275
- )
276
- return Level(sprites=sprites)
277
-
278
- # ------------------------------------------------------------------ #
279
- # Cut pre-roll
280
- # ------------------------------------------------------------------ #
281
- def cut_focal_policy(self, game: MotiveGridGame) -> str:
282
- """Drive the focal agent west during the Cut pre-roll.
283
-
284
- The scripted westward walk sets up the dead-end tension: by the handover
285
- the focal sits just east of the wall with the predator closing behind.
286
-
287
- Args:
288
- game: The live game (unused; the policy is unconditional).
289
-
290
- Returns:
291
- ``"left"``.
292
- """
293
- del game
294
- return "left"
295
-
296
- def cut_length(self, difficulty) -> int:
297
- """Return the Cut pre-roll step count ``K`` for *difficulty*.
298
-
299
- Args:
300
- difficulty: The session difficulty (a :class:`Difficulty`, or any
301
- value; unknown values fall back to the EASY length).
302
-
303
- Returns:
304
- ``2`` for EASY (placing the focal at ``(3,3)`` against the wall).
305
- """
306
- return _CUT_LENGTH.get(difficulty, _DEFAULT_CUT_LENGTH)
307
-
308
- # ------------------------------------------------------------------ #
309
- # Threat motion (motive = chase)
310
- # ------------------------------------------------------------------ #
311
- def advance_threat(self, game: MotiveGridGame) -> None:
312
- """Move the predator one BFS step along a shortest path to the focal.
313
-
314
- The predator pursues over *free* cells only (grid minus walls), so its
315
- motion is purely a function of the focal position — a reactive chase,
316
- never random. The framework only guards the focal agent's bounds, so
317
- this method excludes off-grid and wall cells itself. If no path to the
318
- focal exists, the predator stays put.
319
-
320
- Tie-break: among the neighbours that lie on a shortest path, the first
321
- in :data:`_TIE_BREAK_ORDER` (up, down, left, right) wins, which is fully
322
- deterministic.
323
-
324
- Args:
325
- game: The live game whose predator sprite is moved in place.
326
- """
327
- predator = game.predator_sprite
328
- focal = game.focal_sprite
329
- if predator is None or focal is None:
330
- return
331
-
332
- src = (predator.x, predator.y)
333
- dst = (focal.x, focal.y)
334
- if src == dst:
335
- return
336
-
337
- next_cell = self._chase_step(game, src, dst)
338
- if next_cell is None:
339
- return
340
- predator.move(next_cell[0] - predator.x, next_cell[1] - predator.y)
341
-
342
- def _chase_step(
343
- self,
344
- game: MotiveGridGame,
345
- src: tuple[int, int],
346
- dst: tuple[int, int],
347
- ) -> tuple[int, int] | None:
348
- """Return the predator's next cell on a shortest path ``src`` -> ``dst``.
349
-
350
- Picks the free neighbour of *src* (in :data:`_TIE_BREAK_ORDER`) whose BFS
351
- distance to *dst* is minimal. Returns ``None`` if *dst* is unreachable.
352
-
353
- Args:
354
- game: The live game (for bounds checks).
355
- src: The predator's current ``(x, y)``.
356
- dst: The focal's ``(x, y)``.
357
- """
358
- best_cell: tuple[int, int] | None = None
359
- best_dist: int | None = None
360
- for action in _TIE_BREAK_ORDER:
361
- dx, dy = _DELTAS[action]
362
- if dx == 0 and dy == 0:
363
- continue
364
- cand = (src[0] + dx, src[1] + dy)
365
- if not self._is_free(game, cand):
366
- continue
367
- dist = self._bfs_distance(game, cand, dst)
368
- if dist is None:
369
- continue
370
- if best_dist is None or dist < best_dist:
371
- best_dist = dist
372
- best_cell = cand
373
- return best_cell
374
-
375
- # ------------------------------------------------------------------ #
376
- # Outcome
377
- # ------------------------------------------------------------------ #
378
- def check_elimination(self, game: MotiveGridGame) -> bool:
379
- """Return whether the predator has captured the focal agent.
380
-
381
- Args:
382
- game: The live game to inspect.
383
-
384
- Returns:
385
- ``True`` iff the focal and predator occupy the same cell.
386
- """
387
- focal = game.focal_sprite
388
- predator = game.predator_sprite
389
- if focal is None or predator is None:
390
- return False
391
- return focal.x == predator.x and focal.y == predator.y
392
-
393
- # ------------------------------------------------------------------ #
394
- # Answer keys
395
- # ------------------------------------------------------------------ #
396
- def optimal_action(self, game: MotiveGridGame) -> str:
397
- """Return the legal move that maximizes BFS distance from the predator.
398
-
399
- Considers every action in :data:`_TIE_BREAK_ORDER`; an action is *legal*
400
- only if its resulting cell is on-grid and not a wall (moves into a wall
401
- or off-grid are discarded, never silently treated as "stay"). Among the
402
- legal actions the one whose resulting cell has the greatest BFS distance
403
- to the predator wins; ties break by the fixed action order. This is the
404
- motive-congruent escape — the prediction answer key.
405
-
406
- Args:
407
- game: The live game to inspect.
408
-
409
- Returns:
410
- One of ``"up"``, ``"down"``, ``"left"``, ``"right"``, ``"stay"``.
411
- ``"stay"`` is returned only if no other action is legal (boxed in).
412
- """
413
- focal = game.focal_sprite
414
- predator = game.predator_sprite
415
- if focal is None or predator is None:
416
- return "stay"
417
-
418
- src = (focal.x, focal.y)
419
- pred_cell = (predator.x, predator.y)
420
-
421
- best_action = "stay"
422
- best_dist: int | None = None
423
- for action in _TIE_BREAK_ORDER:
424
- dx, dy = _DELTAS[action]
425
- cand = (src[0] + dx, src[1] + dy)
426
- if not self._is_free(game, cand):
427
- # Off-grid or into a wall: illegal, skip (covers blocked "left").
428
- continue
429
- dist = self._bfs_distance(game, cand, pred_cell)
430
- if dist is None:
431
- continue
432
- if best_dist is None or dist > best_dist:
433
- best_dist = dist
434
- best_action = action
435
- return best_action
436
-
437
- def habit_action(self, game: MotiveGridGame) -> str:
438
- """Return the fixed-habit action: repeat the last committed move.
439
-
440
- The focal walks west all through the Cut pre-roll, so at the handover the
441
- habit is ``"left"`` (which the dead-end geometry makes the wrong move).
442
- ``MotiveGridModule`` (CP5) is expected to call :meth:`record_focal_move`
443
- after each committed action so the habit tracks the live trajectory; the
444
- default reflects the pre-roll direction.
445
-
446
- Args:
447
- game: The live game (unused; the habit is carried as scenario state).
448
-
449
- Returns:
450
- The last committed move direction (``"left"`` at the Cut).
451
- """
452
- del game
453
- return self._last_focal_move
454
-
455
- def record_focal_move(self, action: str) -> None:
456
- """Record the focal's last committed move so :meth:`habit_action` tracks it.
457
-
458
- A non-moving ``"stay"`` does not change the established habit direction
459
- (the habit is "the direction I keep heading"), so it is ignored.
460
-
461
- Args:
462
- action: The action just committed by the focal agent.
463
- """
464
- if action in _DELTAS and action != "stay":
465
- self._last_focal_move = action
466
-
467
- def step_reward(
468
- self,
469
- game: MotiveGridGame,
470
- action: str,
471
- blocked: bool,
472
- focal_before: tuple[int, int],
473
- predator_before: tuple[int, int],
474
- ) -> float:
475
- """Survival reward: positive for moving away from the predator.
476
-
477
- Terminal states dominate (capture / survival). Otherwise a blocked move
478
- is penalised, and a legal move is scored by the change in BFS distance
479
- from the focal to the predator's PRE-move cell (post-move focal cell vs
480
- pre-move focal cell), isolating the agent's own contribution from the
481
- predator's chase. Positive = moved away, negative = moved toward.
482
- """
483
- del action # reward derives from the post-move focal cell, not the action string
484
- if game.eliminated:
485
- return _REWARD_CAPTURED
486
- if game.survived:
487
- return _REWARD_SURVIVED
488
- if blocked:
489
- return _REWARD_WALL_HIT
490
- focal = game.focal_sprite
491
- if focal is None:
492
- return 0.0
493
- focal_after = (focal.x, focal.y)
494
- d_before = self._bfs_distance(game, focal_before, predator_before)
495
- d_after = self._bfs_distance(game, focal_after, predator_before)
496
- if d_before is None or d_after is None:
497
- return 0.0
498
- return float(d_after - d_before)
499
-
500
- def safety_distance(self, game: MotiveGridGame) -> int | None:
501
- """Return the BFS distance from the focal to the predator (None if N/A)."""
502
- focal = game.focal_sprite
503
- predator = game.predator_sprite
504
- if focal is None or predator is None:
505
- return None
506
- return self._bfs_distance(game, (focal.x, focal.y), (predator.x, predator.y))
507
-
508
- def max_bfs_distance(self, game: MotiveGridGame) -> int | None:
509
- """Free-cell graph diameter: the max finite BFS distance between any two
510
- free cells. Used to normalise distance metrics / pressure into [0, 1]."""
511
- cells = [
512
- (x, y)
513
- for x in range(self.grid_size[0])
514
- for y in range(self.grid_size[1])
515
- if self._is_free(game, (x, y))
516
- ]
517
- best = 0
518
- for i, a in enumerate(cells):
519
- for b in cells[i + 1:]:
520
- d = self._bfs_distance(game, a, b)
521
- if d is not None and d > best:
522
- best = d
523
- return best
524
-
525
- def agent_distance_delta(
526
- self, game: MotiveGridGame, focal_before, predator_before
527
- ) -> float | None:
528
- """Chase-corrected action quality (spec §6.2): how much the focal's own
529
- move opened distance from the *pre-move* predator cell."""
530
- focal = game.focal_sprite
531
- if focal is None:
532
- return None
533
- d_after = self._bfs_distance(game, (focal.x, focal.y), predator_before)
534
- d_before = self._bfs_distance(game, focal_before, predator_before)
535
- if d_after is None or d_before is None:
536
- return None
537
- return float(d_after - d_before)
538
-
539
- # ------------------------------------------------------------------ #
540
- # ASCII legend
541
- # ------------------------------------------------------------------ #
542
- def legend(self) -> dict[int, str]:
543
- """Return the palette-index -> single-character symbol map.
544
-
545
- Returns:
546
- ``{5: '.', 1: 'A', 2: 'B', 3: '#'}`` — background, focal, predator,
547
- wall (consistent with the pixels assigned in :meth:`build_level`).
548
- """
549
- return {
550
- BACKGROUND_IDX: ".",
551
- FOCAL_IDX: "A",
552
- PREDATOR_IDX: "B",
553
- WALL_IDX: "#",
554
- }
555
-
556
- # ------------------------------------------------------------------ #
557
- # BFS / free-cell helpers
558
- # ------------------------------------------------------------------ #
559
- def _is_free(self, game: MotiveGridGame, cell: tuple[int, int]) -> bool:
560
- """Return whether *cell* is on-grid and not a wall.
561
-
562
- Args:
563
- game: The live game (for the bounds check).
564
- cell: The ``(x, y)`` cell to test.
565
- """
566
- return game.within_bounds(cell[0], cell[1]) and cell not in self._wall_cells
567
-
568
- def _bfs_distance(
569
- self,
570
- game: MotiveGridGame,
571
- src: tuple[int, int],
572
- dst: tuple[int, int],
573
- ) -> int | None:
574
- """Return the shortest 4-neighbour path length over free cells.
575
-
576
- Args:
577
- game: The live game (for bounds checks).
578
- src: Start cell ``(x, y)`` (assumed free).
579
- dst: Goal cell ``(x, y)``.
580
-
581
- Returns:
582
- The number of steps from *src* to *dst* over free cells, or ``None``
583
- if *dst* is unreachable (or either endpoint is a wall).
584
- """
585
- if not self._is_free(game, src) or not self._is_free(game, dst):
586
- return None
587
- if src == dst:
588
- return 0
589
- seen = {src}
590
- queue: deque[tuple[tuple[int, int], int]] = deque([(src, 0)])
591
- while queue:
592
- cell, dist = queue.popleft()
593
- for action in _TIE_BREAK_ORDER:
594
- dx, dy = _DELTAS[action]
595
- if dx == 0 and dy == 0:
596
- continue
597
- nxt = (cell[0] + dx, cell[1] + dy)
598
- if nxt in seen or not self._is_free(game, nxt):
599
- continue
600
- if nxt == dst:
601
- return dist + 1
602
- seen.add(nxt)
603
- queue.append((nxt, dist + 1))
604
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
proteus/game/scenarios/template.py CHANGED
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
27
  BACKGROUND_IDX = 5 # arc_grid Camera default background
28
  FOCAL_IDX = 1
29
  PREDATOR_IDX = 2
30
- WALL_IDX = 3 # matches predator_evade + COLOR_MAP gray
31
  FOOD_IDX = 14 # COLOR_MAP green; observational food cells
32
 
33
  _GRID = (64, 64)
 
27
  BACKGROUND_IDX = 5 # arc_grid Camera default background
28
  FOCAL_IDX = 1
29
  PREDATOR_IDX = 2
30
+ WALL_IDX = 3 # COLOR_MAP gray
31
  FOOD_IDX = 14 # COLOR_MAP green; observational food cells
32
 
33
  _GRID = (64, 64)
proteus/game/viz/reconstruct.py CHANGED
@@ -21,7 +21,6 @@ from dataclasses import dataclass
21
 
22
  import numpy as np
23
 
24
- from proteus.game.engine.ascii_view import frame_to_ascii
25
  from proteus.game.engine.difficulty import Difficulty
26
  from proteus.game.engine.grid import MotiveGridGame
27
  from proteus.game.scenarios.base import get_scenario
@@ -78,13 +77,11 @@ def reconstruct(trace: SessionTrace) -> list[FrameStep]:
78
  game = MotiveGridGame(
79
  scenario, rng, difficulty, max_steps=cut_length + len(trace.turns),
80
  )
81
- legend = scenario.legend()
82
-
83
  steps: list[FrameStep] = []
84
 
85
  # --- Cut pre-roll (deterministic scripted policy). ---
86
  steps.append(FrameStep(game.current_grid(), FrameMeta(phase="cut", index=0)))
87
- _verify_cut(game, legend, trace, 0)
88
  for i in range(cut_length):
89
  action = scenario.cut_focal_policy(game)
90
  game.apply_motive_action(action)
@@ -92,7 +89,7 @@ def reconstruct(trace: SessionTrace) -> list[FrameStep]:
92
  steps.append(
93
  FrameStep(game.current_grid(), FrameMeta(phase="cut", index=i + 1))
94
  )
95
- _verify_cut(game, legend, trace, i + 1)
96
 
97
  # --- Played turns (recorded actions). ---
98
  last = trace.turns[-1] if trace.turns else None
@@ -128,7 +125,7 @@ def reconstruct(trace: SessionTrace) -> list[FrameStep]:
128
 
129
 
130
  def _verify_cut(
131
- game: MotiveGridGame, legend: dict[int, str], trace: SessionTrace, idx: int
132
  ) -> None:
133
  if not trace.cut_frames:
134
  return # legacy trace without cut-frame storage — nothing to verify against
@@ -138,7 +135,10 @@ def _verify_cut(
138
  f"{len(trace.cut_frames)} cut frames but reconstruction expected more "
139
  "(truncated or version-skewed trace)."
140
  )
141
- got = frame_to_ascii(game.current_grid(), legend)
 
 
 
142
  if got != trace.cut_frames[idx]:
143
  raise TraceReconstructionError(
144
  f"Cut frame {idx} mismatch: reconstruction diverged from the trace "
 
21
 
22
  import numpy as np
23
 
 
24
  from proteus.game.engine.difficulty import Difficulty
25
  from proteus.game.engine.grid import MotiveGridGame
26
  from proteus.game.scenarios.base import get_scenario
 
77
  game = MotiveGridGame(
78
  scenario, rng, difficulty, max_steps=cut_length + len(trace.turns),
79
  )
 
 
80
  steps: list[FrameStep] = []
81
 
82
  # --- Cut pre-roll (deterministic scripted policy). ---
83
  steps.append(FrameStep(game.current_grid(), FrameMeta(phase="cut", index=0)))
84
+ _verify_cut(game, scenario, trace, 0)
85
  for i in range(cut_length):
86
  action = scenario.cut_focal_policy(game)
87
  game.apply_motive_action(action)
 
89
  steps.append(
90
  FrameStep(game.current_grid(), FrameMeta(phase="cut", index=i + 1))
91
  )
92
+ _verify_cut(game, scenario, trace, i + 1)
93
 
94
  # --- Played turns (recorded actions). ---
95
  last = trace.turns[-1] if trace.turns else None
 
125
 
126
 
127
  def _verify_cut(
128
+ game: MotiveGridGame, scenario, trace: SessionTrace, idx: int
129
  ) -> None:
130
  if not trace.cut_frames:
131
  return # legacy trace without cut-frame storage — nothing to verify against
 
135
  f"{len(trace.cut_frames)} cut frames but reconstruction expected more "
136
  "(truncated or version-skewed trace)."
137
  )
138
+ # The trace stores frames via the scenario's render_frame hook, so verify
139
+ # against the same hook (not a hardcoded frame_to_ascii — scenarios such as
140
+ # template render a compact prose frame, not the full ASCII map).
141
+ got = scenario.render_frame(game)
142
  if got != trace.cut_frames[idx]:
143
  raise TraceReconstructionError(
144
  f"Cut frame {idx} mismatch: reconstruction diverged from the trace "
tests/cli/test_cli.py CHANGED
@@ -2,18 +2,18 @@ from proteus.cli import main
2
  from proteus.game.runtime import read_traces
3
 
4
 
5
- def test_list_scenarios_prints_predator_evade(capsys):
6
  rc = main(["list-scenarios"])
7
  out = capsys.readouterr().out
8
  assert rc == 0
9
- assert "predator_evade" in out
10
 
11
 
12
  def test_run_with_fake_provider_writes_reloadable_trace(tmp_path, capsys):
13
  out = tmp_path / "runs" / "smoke.jsonl"
14
  rc = main([
15
  "run",
16
- "--scenario", "predator_evade",
17
  "--model", "fake:cli-test",
18
  "--seed", "42",
19
  "--play-turns", "5",
@@ -24,29 +24,29 @@ def test_run_with_fake_provider_writes_reloadable_trace(tmp_path, capsys):
24
  assert out.exists()
25
  traces = read_traces(out)
26
  assert len(traces) == 1
27
- assert traces[0].scenario == "predator_evade"
28
  assert traces[0].model == "cli-test"
29
  # The run line summarizes the outcome.
30
- assert "predator_evade" in capsys.readouterr().out
31
 
32
 
33
  def test_replay_prints_turns_and_outcome(tmp_path, capsys):
34
  out = tmp_path / "r.jsonl"
35
  main([
36
- "run", "--scenario", "predator_evade", "--model", "fake:x",
37
  "--seed", "42", "--play-turns", "5", "--no-probe", "--out", str(out),
38
  ])
39
  capsys.readouterr() # drain
40
  rc = main(["replay", str(out)])
41
  text = capsys.readouterr().out
42
  assert rc == 0
43
- assert "predator_evade" in text
44
  assert "turn 1" in text
45
 
46
 
47
  def test_run_unknown_provider_returns_nonzero(tmp_path, capsys):
48
  rc = main([
49
- "run", "--scenario", "predator_evade", "--model", "bogus:x",
50
  "--seed", "1", "--out", str(tmp_path / "x.jsonl"),
51
  ])
52
  assert rc == 2
@@ -77,7 +77,7 @@ def test_play_human_writes_comparable_trace(tmp_path, monkeypatch, capsys):
77
  out = tmp_path / "runs" / "human.jsonl"
78
  rc = main([
79
  "play",
80
- "--scenario", "predator_evade",
81
  "--seed", "42",
82
  "--play-turns", "5",
83
  "--out", str(out),
@@ -86,9 +86,9 @@ def test_play_human_writes_comparable_trace(tmp_path, monkeypatch, capsys):
86
  traces = read_traces(out)
87
  assert len(traces) == 1
88
  assert traces[0].model == "human"
89
- assert traces[0].scenario == "predator_evade"
90
  # The run summary names the scenario.
91
- assert "predator_evade" in capsys.readouterr().out
92
 
93
 
94
  def test_play_unknown_scenario_errors(capsys):
@@ -100,7 +100,7 @@ def test_play_unknown_scenario_errors(capsys):
100
  def _write_fake_trace(tmp_path):
101
  out = tmp_path / "r.jsonl"
102
  main([
103
- "run", "--scenario", "predator_evade", "--model", "fake:x",
104
  "--seed", "42", "--play-turns", "4", "--no-probe", "--out", str(out),
105
  ])
106
  return out
@@ -145,7 +145,7 @@ def test_play_handles_stdin_eof(monkeypatch, capsys):
145
 
146
  monkeypatch.setattr("builtins.input", _eof)
147
  rc = main([
148
- "play", "--scenario", "predator_evade", "--seed", "42", "--play-turns", "5",
149
  ])
150
  assert rc == 2
151
  assert "stdin" in capsys.readouterr().err.lower()
@@ -156,7 +156,7 @@ def test_compare_aggregates_traces(tmp_path, capsys):
156
  # Two fake-model traces at the same difficulty (model id "demo").
157
  for seed in (1, 2):
158
  main([
159
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
160
  "--seed", str(seed), "--play-turns", "4", "--no-probe", "--out", str(out),
161
  ])
162
  capsys.readouterr() # drain
@@ -173,7 +173,7 @@ def test_compare_writes_summary_json(tmp_path):
173
 
174
  out = tmp_path / "runs.jsonl"
175
  main([
176
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
177
  "--seed", "1", "--play-turns", "4", "--no-probe", "--out", str(out),
178
  ])
179
  summary = tmp_path / "summary.json"
 
2
  from proteus.game.runtime import read_traces
3
 
4
 
5
+ def test_list_scenarios_prints_template(capsys):
6
  rc = main(["list-scenarios"])
7
  out = capsys.readouterr().out
8
  assert rc == 0
9
+ assert "template" in out
10
 
11
 
12
  def test_run_with_fake_provider_writes_reloadable_trace(tmp_path, capsys):
13
  out = tmp_path / "runs" / "smoke.jsonl"
14
  rc = main([
15
  "run",
16
+ "--scenario", "template",
17
  "--model", "fake:cli-test",
18
  "--seed", "42",
19
  "--play-turns", "5",
 
24
  assert out.exists()
25
  traces = read_traces(out)
26
  assert len(traces) == 1
27
+ assert traces[0].scenario == "template"
28
  assert traces[0].model == "cli-test"
29
  # The run line summarizes the outcome.
30
+ assert "template" in capsys.readouterr().out
31
 
32
 
33
  def test_replay_prints_turns_and_outcome(tmp_path, capsys):
34
  out = tmp_path / "r.jsonl"
35
  main([
36
+ "run", "--scenario", "template", "--model", "fake:x",
37
  "--seed", "42", "--play-turns", "5", "--no-probe", "--out", str(out),
38
  ])
39
  capsys.readouterr() # drain
40
  rc = main(["replay", str(out)])
41
  text = capsys.readouterr().out
42
  assert rc == 0
43
+ assert "template" in text
44
  assert "turn 1" in text
45
 
46
 
47
  def test_run_unknown_provider_returns_nonzero(tmp_path, capsys):
48
  rc = main([
49
+ "run", "--scenario", "template", "--model", "bogus:x",
50
  "--seed", "1", "--out", str(tmp_path / "x.jsonl"),
51
  ])
52
  assert rc == 2
 
77
  out = tmp_path / "runs" / "human.jsonl"
78
  rc = main([
79
  "play",
80
+ "--scenario", "template",
81
  "--seed", "42",
82
  "--play-turns", "5",
83
  "--out", str(out),
 
86
  traces = read_traces(out)
87
  assert len(traces) == 1
88
  assert traces[0].model == "human"
89
+ assert traces[0].scenario == "template"
90
  # The run summary names the scenario.
91
+ assert "template" in capsys.readouterr().out
92
 
93
 
94
  def test_play_unknown_scenario_errors(capsys):
 
100
  def _write_fake_trace(tmp_path):
101
  out = tmp_path / "r.jsonl"
102
  main([
103
+ "run", "--scenario", "template", "--model", "fake:x",
104
  "--seed", "42", "--play-turns", "4", "--no-probe", "--out", str(out),
105
  ])
106
  return out
 
145
 
146
  monkeypatch.setattr("builtins.input", _eof)
147
  rc = main([
148
+ "play", "--scenario", "template", "--seed", "42", "--play-turns", "5",
149
  ])
150
  assert rc == 2
151
  assert "stdin" in capsys.readouterr().err.lower()
 
156
  # Two fake-model traces at the same difficulty (model id "demo").
157
  for seed in (1, 2):
158
  main([
159
+ "run", "--scenario", "template", "--model", "fake:demo",
160
  "--seed", str(seed), "--play-turns", "4", "--no-probe", "--out", str(out),
161
  ])
162
  capsys.readouterr() # drain
 
173
 
174
  out = tmp_path / "runs.jsonl"
175
  main([
176
+ "run", "--scenario", "template", "--model", "fake:demo",
177
  "--seed", "1", "--play-turns", "4", "--no-probe", "--out", str(out),
178
  ])
179
  summary = tmp_path / "summary.json"
tests/cli/test_cli_gif.py CHANGED
@@ -4,7 +4,7 @@ from proteus.cli import main
4
  def test_run_auto_writes_gif_next_to_out(tmp_path):
5
  out = tmp_path / "t.jsonl"
6
  rc = main([
7
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
8
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
9
  "--no-probe", "--out", str(out),
10
  ])
@@ -15,7 +15,7 @@ def test_run_auto_writes_gif_next_to_out(tmp_path):
15
  def test_run_no_gif_suppresses(tmp_path):
16
  out = tmp_path / "t.jsonl"
17
  rc = main([
18
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
19
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
20
  "--no-probe", "--no-gif", "--out", str(out),
21
  ])
 
4
  def test_run_auto_writes_gif_next_to_out(tmp_path):
5
  out = tmp_path / "t.jsonl"
6
  rc = main([
7
+ "run", "--scenario", "template", "--model", "fake:demo",
8
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
9
  "--no-probe", "--out", str(out),
10
  ])
 
15
  def test_run_no_gif_suppresses(tmp_path):
16
  out = tmp_path / "t.jsonl"
17
  rc = main([
18
+ "run", "--scenario", "template", "--model", "fake:demo",
19
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
20
  "--no-probe", "--no-gif", "--out", str(out),
21
  ])
tests/cli/test_cli_memory.py CHANGED
@@ -5,20 +5,20 @@ from proteus.game.runtime.memory import load_checkpoint
5
  def test_memory_subcommand_writes_loadable_checkpoint(tmp_path):
6
  out = tmp_path / "ck.json"
7
  rc = main([
8
- "memory", "--scenario", "predator_evade", "--model", "fake:demo",
9
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "4",
10
  "--out", str(out),
11
  ])
12
  assert rc == 0
13
  ck = load_checkpoint(out)
14
  assert ck.model == "demo"
15
- assert ck.scenario == "predator_evade"
16
  assert 1 <= len(ck.memory_turns) <= 4
17
 
18
 
19
  def test_memory_subcommand_unknown_model_exits_2(tmp_path, capsys):
20
  rc = main([
21
- "memory", "--scenario", "predator_evade", "--model", "bogusprovider:x",
22
  "--difficulty", "easy", "--seed", "42", "--out", str(tmp_path / "c.json"),
23
  ])
24
  assert rc == 2
@@ -31,7 +31,7 @@ from proteus.game.runtime import read_traces
31
  def test_run_memory_generate_sets_memory_ref(tmp_path):
32
  out = tmp_path / "t.jsonl"
33
  rc = main([
34
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
35
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
36
  "--no-probe", "--memory", "generate", "--memory-turns", "4",
37
  "--memory-root", str(tmp_path / "mem"), "--out", str(out),
@@ -46,7 +46,7 @@ def test_run_memory_generate_sets_memory_ref(tmp_path):
46
  def test_run_memory_latest_missing_exits_2(tmp_path, capsys):
47
  out = tmp_path / "t.jsonl"
48
  rc = main([
49
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
50
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
51
  "--no-probe", "--memory", "latest",
52
  "--memory-root", str(tmp_path / "mem"), "--out", str(out),
@@ -58,7 +58,7 @@ def test_run_memory_latest_missing_exits_2(tmp_path, capsys):
58
  def test_run_memory_none_is_default(tmp_path):
59
  out = tmp_path / "t.jsonl"
60
  rc = main([
61
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
62
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
63
  "--no-probe", "--out", str(out),
64
  ])
 
5
  def test_memory_subcommand_writes_loadable_checkpoint(tmp_path):
6
  out = tmp_path / "ck.json"
7
  rc = main([
8
+ "memory", "--scenario", "template", "--model", "fake:demo",
9
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "4",
10
  "--out", str(out),
11
  ])
12
  assert rc == 0
13
  ck = load_checkpoint(out)
14
  assert ck.model == "demo"
15
+ assert ck.scenario == "template"
16
  assert 1 <= len(ck.memory_turns) <= 4
17
 
18
 
19
  def test_memory_subcommand_unknown_model_exits_2(tmp_path, capsys):
20
  rc = main([
21
+ "memory", "--scenario", "template", "--model", "bogusprovider:x",
22
  "--difficulty", "easy", "--seed", "42", "--out", str(tmp_path / "c.json"),
23
  ])
24
  assert rc == 2
 
31
  def test_run_memory_generate_sets_memory_ref(tmp_path):
32
  out = tmp_path / "t.jsonl"
33
  rc = main([
34
+ "run", "--scenario", "template", "--model", "fake:demo",
35
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
36
  "--no-probe", "--memory", "generate", "--memory-turns", "4",
37
  "--memory-root", str(tmp_path / "mem"), "--out", str(out),
 
46
  def test_run_memory_latest_missing_exits_2(tmp_path, capsys):
47
  out = tmp_path / "t.jsonl"
48
  rc = main([
49
+ "run", "--scenario", "template", "--model", "fake:demo",
50
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
51
  "--no-probe", "--memory", "latest",
52
  "--memory-root", str(tmp_path / "mem"), "--out", str(out),
 
58
  def test_run_memory_none_is_default(tmp_path):
59
  out = tmp_path / "t.jsonl"
60
  rc = main([
61
+ "run", "--scenario", "template", "--model", "fake:demo",
62
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
63
  "--no-probe", "--out", str(out),
64
  ])
tests/cli/test_cli_persona.py CHANGED
@@ -5,7 +5,7 @@ from proteus.game.runtime import read_traces
5
  def test_run_with_persona_records_id_and_metrics(tmp_path):
6
  out = tmp_path / "p.jsonl"
7
  rc = main([
8
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
9
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
10
  "--no-probe", "--no-gif", "--persona", "risk_averse", "--out", str(out),
11
  ])
@@ -20,7 +20,7 @@ def test_run_with_persona_records_id_and_metrics(tmp_path):
20
  def test_run_without_persona_has_no_persona_metrics(tmp_path):
21
  out = tmp_path / "np.jsonl"
22
  rc = main([
23
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
24
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
25
  "--no-probe", "--no-gif", "--out", str(out),
26
  ])
@@ -33,7 +33,7 @@ def test_run_without_persona_has_no_persona_metrics(tmp_path):
33
  def test_run_unknown_persona_errors(tmp_path):
34
  out = tmp_path / "bad.jsonl"
35
  rc = main([
36
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
37
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
38
  "--no-probe", "--no-gif", "--persona", "nope", "--out", str(out),
39
  ])
@@ -44,7 +44,7 @@ def test_memory_with_persona_tags_checkpoint(tmp_path):
44
  from proteus.game.runtime.memory import load_checkpoint
45
  out = tmp_path / "mem.json"
46
  rc = main([
47
- "memory", "--scenario", "predator_evade", "--model", "fake:demo",
48
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
49
  "--persona", "risk_averse", "--out", str(out),
50
  ])
@@ -57,7 +57,7 @@ def test_memory_with_persona_tags_checkpoint(tmp_path):
57
  def test_memory_unknown_persona_errors(tmp_path):
58
  out = tmp_path / "bad.json"
59
  rc = main([
60
- "memory", "--scenario", "predator_evade", "--model", "fake:demo",
61
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
62
  "--persona", "nope", "--out", str(out),
63
  ])
@@ -69,7 +69,7 @@ def test_persona_memory_then_scored_run(tmp_path):
69
  # measures whether the model continues that persona (same hidden weights).
70
  mem = tmp_path / "demo.json"
71
  rc = main([
72
- "memory", "--scenario", "predator_evade", "--model", "fake:demo",
73
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
74
  "--persona", "risk_averse", "--out", str(mem),
75
  ])
@@ -77,7 +77,7 @@ def test_persona_memory_then_scored_run(tmp_path):
77
 
78
  out = tmp_path / "scored.jsonl"
79
  rc = main([
80
- "run", "--scenario", "predator_evade", "--model", "fake:demo",
81
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
82
  "--no-probe", "--no-gif", "--memory", str(mem),
83
  "--persona", "risk_averse", "--out", str(out),
 
5
  def test_run_with_persona_records_id_and_metrics(tmp_path):
6
  out = tmp_path / "p.jsonl"
7
  rc = main([
8
+ "run", "--scenario", "template", "--model", "fake:demo",
9
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
10
  "--no-probe", "--no-gif", "--persona", "risk_averse", "--out", str(out),
11
  ])
 
20
  def test_run_without_persona_has_no_persona_metrics(tmp_path):
21
  out = tmp_path / "np.jsonl"
22
  rc = main([
23
+ "run", "--scenario", "template", "--model", "fake:demo",
24
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
25
  "--no-probe", "--no-gif", "--out", str(out),
26
  ])
 
33
  def test_run_unknown_persona_errors(tmp_path):
34
  out = tmp_path / "bad.jsonl"
35
  rc = main([
36
+ "run", "--scenario", "template", "--model", "fake:demo",
37
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
38
  "--no-probe", "--no-gif", "--persona", "nope", "--out", str(out),
39
  ])
 
44
  from proteus.game.runtime.memory import load_checkpoint
45
  out = tmp_path / "mem.json"
46
  rc = main([
47
+ "memory", "--scenario", "template", "--model", "fake:demo",
48
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
49
  "--persona", "risk_averse", "--out", str(out),
50
  ])
 
57
  def test_memory_unknown_persona_errors(tmp_path):
58
  out = tmp_path / "bad.json"
59
  rc = main([
60
+ "memory", "--scenario", "template", "--model", "fake:demo",
61
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
62
  "--persona", "nope", "--out", str(out),
63
  ])
 
69
  # measures whether the model continues that persona (same hidden weights).
70
  mem = tmp_path / "demo.json"
71
  rc = main([
72
+ "memory", "--scenario", "template", "--model", "fake:demo",
73
  "--difficulty", "easy", "--seed", "42", "--memory-turns", "5",
74
  "--persona", "risk_averse", "--out", str(mem),
75
  ])
 
77
 
78
  out = tmp_path / "scored.jsonl"
79
  rc = main([
80
+ "run", "--scenario", "template", "--model", "fake:demo",
81
  "--difficulty", "easy", "--seed", "42", "--play-turns", "3",
82
  "--no-probe", "--no-gif", "--memory", str(mem),
83
  "--persona", "risk_averse", "--out", str(out),
tests/engine/test_turn_order.py CHANGED
@@ -9,7 +9,7 @@ import proteus.game.scenarios # noqa: F401 (register scenarios)
9
 
10
 
11
  def _focal_cell_seen_by_threat(turn_order: str, action: str = "up"):
12
- scen = get_scenario("predator_evade")()
13
  scen.turn_order = turn_order # instance override
14
  game = MotiveGridGame(scen, random.Random(0), Difficulty.EASY, max_steps=10)
15
  seen = {}
 
9
 
10
 
11
  def _focal_cell_seen_by_threat(turn_order: str, action: str = "up"):
12
+ scen = get_scenario("template")()
13
  scen.turn_order = turn_order # instance override
14
  game = MotiveGridGame(scen, random.Random(0), Difficulty.EASY, max_steps=10)
15
  seen = {}
tests/grid/test_difficulty_layouts.py DELETED
@@ -1,96 +0,0 @@
1
- import random
2
-
3
- import numpy as np
4
-
5
- from proteus.game.engine.difficulty import Difficulty
6
- from proteus.game.engine.grid import MotiveGridGame
7
- from proteus.game.scenarios.base import Scenario, get_scenario
8
-
9
-
10
- def _scenario():
11
- return get_scenario("predator_evade")()
12
-
13
-
14
- def test_build_level_accepts_difficulty():
15
- s = _scenario()
16
- level = s.build_level(random.Random(42), Difficulty.EASY)
17
- names = {sp.name for sp in level.get_sprites()}
18
- assert "focal" in names and "predator" in names
19
-
20
-
21
- def test_record_focal_move_default_is_noop_on_base():
22
- # hasattr proves the no-op default is now inherited from the Scenario ABC
23
- # (so non-tracking scenarios never AttributeError). PredatorEvade overrides
24
- # it to track the live trajectory, which habit_action reads back.
25
- assert hasattr(Scenario, "record_focal_move")
26
- # PredatorEvade still tracks it (used by habit_action).
27
- s = _scenario()
28
- s.record_focal_move("up")
29
- assert s.habit_action(None) == "up"
30
-
31
-
32
- _BANDS = [Difficulty.EASY, Difficulty.MEDIUM, Difficulty.HARD, Difficulty.EXPERT]
33
-
34
-
35
- def _game(difficulty, seed=42):
36
- s = _scenario()
37
- return MotiveGridGame(s, random.Random(seed), difficulty, max_steps=20), s
38
-
39
-
40
- def test_easy_layout_unchanged():
41
- # EASY remains the 8x8 single-wall world: focal (5,3), predator (7,3).
42
- game, _ = _game(Difficulty.EASY)
43
- assert game.current_grid().shape == (8, 8)
44
- assert (game.focal_sprite.x, game.focal_sprite.y) == (5, 3)
45
- assert (game.predator_sprite.x, game.predator_sprite.y) == (7, 3)
46
-
47
-
48
- def test_bands_have_distinct_grid_sizes_or_starts():
49
- seen = set()
50
- for d in _BANDS:
51
- game, _ = _game(d)
52
- h, w = game.current_grid().shape
53
- fx, fy = game.focal_sprite.x, game.focal_sprite.y
54
- px, py = game.predator_sprite.x, game.predator_sprite.y
55
- seen.add((h, w, fx, fy, px, py))
56
- # All four bands must be pairwise distinct: each band's (size, focal, predator) tuple unique.
57
- assert len(seen) == 4
58
-
59
-
60
- def test_each_band_is_deterministic():
61
- for d in _BANDS:
62
- g1, _ = _game(d, seed=7)
63
- g2, _ = _game(d, seed=7)
64
- assert np.array_equal(g1.current_grid(), g2.current_grid())
65
-
66
-
67
- def _drive_to_handover(difficulty, seed=42):
68
- """Replay the Cut pre-roll (scripted 'left') to the handover state."""
69
- s = _scenario()
70
- game = MotiveGridGame(s, random.Random(seed), difficulty, max_steps=40)
71
- for _ in range(s.cut_length(difficulty)):
72
- action = s.cut_focal_policy(game)
73
- game.apply_motive_action(action)
74
- s.record_focal_move(action)
75
- return game, s
76
-
77
-
78
- def test_invariant_optimal_differs_from_habit_each_band():
79
- # The whole discrimination metric depends on optimal != habit at the Cut
80
- # handover, for EVERY difficulty band.
81
- for d in _BANDS:
82
- game, s = _drive_to_handover(d)
83
- optimal = s.optimal_action(game)
84
- habit = s.habit_action(game)
85
- assert optimal != habit, f"{d}: optimal {optimal} == habit {habit}"
86
- # Habit ('left', the pre-roll direction) must be trapped: moving left
87
- # is blocked (a wall) so it does not increase distance.
88
- assert habit == "left"
89
-
90
-
91
- def test_handover_is_deterministic_each_band():
92
- for d in _BANDS:
93
- g1, s1 = _drive_to_handover(d, seed=11)
94
- g2, s2 = _drive_to_handover(d, seed=11)
95
- assert (g1.focal_sprite.x, g1.focal_sprite.y) == (g2.focal_sprite.x, g2.focal_sprite.y)
96
- assert (g1.predator_sprite.x, g1.predator_sprite.y) == (g2.predator_sprite.x, g2.predator_sprite.y)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/grid/test_distance_helpers.py CHANGED
@@ -6,7 +6,7 @@ from proteus.game.scenarios.base import Scenario, get_scenario
6
 
7
 
8
  def _game():
9
- s = get_scenario("predator_evade")()
10
  g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
11
  return s, g
12
 
@@ -24,11 +24,11 @@ def test_max_bfs_distance_positive_on_easy():
24
 
25
  def test_agent_distance_delta_positive_when_moving_away():
26
  s, g = _game()
27
- focal = g.focal_sprite # EASY: (5,3)
28
- pred = g.predator_sprite # EASY: (7,3)
29
  before_focal = (focal.x, focal.y)
30
  before_pred = (pred.x, pred.y)
31
- # move focal up to (5,2) -> farther from (7,3)
32
  g.apply_motive_action("up")
33
  delta = s.agent_distance_delta(g, before_focal, before_pred)
34
  assert delta is not None and delta > 0
 
6
 
7
 
8
  def _game():
9
+ s = get_scenario("template")()
10
  g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
11
  return s, g
12
 
 
24
 
25
  def test_agent_distance_delta_positive_when_moving_away():
26
  s, g = _game()
27
+ focal = g.focal_sprite # template spawns west of the predator
28
+ pred = g.predator_sprite
29
  before_focal = (focal.x, focal.y)
30
  before_pred = (pred.x, pred.y)
31
+ # move focal up -> opens distance from the (far-east) predator
32
  g.apply_motive_action("up")
33
  delta = s.agent_distance_delta(g, before_focal, before_pred)
34
  assert delta is not None and delta > 0
tests/grid/test_footprint_bounds.py CHANGED
@@ -1,5 +1,4 @@
1
- """The multi-cell focal must keep its full footprint on the grid; a 1x1 sprite
2
- (predator_evade) behaves exactly as before (regression guard)."""
3
  from __future__ import annotations
4
 
5
  import random
@@ -22,13 +21,3 @@ def test_3x3_focal_cannot_leave_grid():
22
  # A legal move still works.
23
  game.apply_motive_action("right")
24
  assert game.focal_sprite.x == 1
25
-
26
-
27
- def test_1x1_focal_regression():
28
- scenario = get_scenario("predator_evade")()
29
- game = MotiveGridGame(scenario, random.Random(42), Difficulty.EASY, max_steps=99)
30
- focal = game.focal_sprite
31
- x0 = focal.x
32
- game.apply_motive_action("up") # legal in the 8x8 layout
33
- assert game.focal_sprite.y == focal.y # moved or not, no crash; 1x1 path unchanged
34
- assert isinstance(x0, int)
 
1
+ """The multi-cell focal must keep its full footprint on the grid."""
 
2
  from __future__ import annotations
3
 
4
  import random
 
21
  # A legal move still works.
22
  game.apply_motive_action("right")
23
  assert game.focal_sprite.x == 1
 
 
 
 
 
 
 
 
 
 
tests/grid/test_predator_evade_behavior.py DELETED
@@ -1,46 +0,0 @@
1
- import random
2
-
3
- from proteus.game.engine.difficulty import Difficulty
4
- from proteus.game.engine.grid import MotiveGridGame
5
- from proteus.game.scenarios.base import get_scenario
6
-
7
-
8
- def _build_at_handover():
9
- """Build predator_evade and replay the EASY Cut pre-roll to the handover."""
10
- scenario = get_scenario("predator_evade")()
11
- rng = random.Random(42)
12
- cut = scenario.cut_length(Difficulty.EASY)
13
- game = MotiveGridGame(scenario, rng, Difficulty.EASY, max_steps=cut + 15)
14
- for _ in range(cut):
15
- action = scenario.cut_focal_policy(game)
16
- game.apply_motive_action(action)
17
- scenario.record_focal_move(action)
18
- return scenario, game, cut
19
-
20
-
21
- def test_cut_handover_positions_are_deterministic():
22
- _, game, _ = _build_at_handover()
23
- # EASY layout: focal walks west from (5,3) for 2 steps -> (3,3);
24
- # predator chases from (7,3) -> (5,3).
25
- assert (game.focal_sprite.x, game.focal_sprite.y) == (3, 3)
26
- assert (game.predator_sprite.x, game.predator_sprite.y) == (5, 3)
27
-
28
-
29
- def test_diagnostic_invariant_optimal_differs_from_habit_at_handover():
30
- scenario, game, cut = _build_at_handover()
31
- # Pin the pre-roll length: habit_action reads _last_focal_move, which
32
- # defaults to "left" in __init__, so habit=="left" could pass vacuously
33
- # if the Cut never ran. Asserting cut makes "the pre-roll ran" explicit.
34
- assert cut == 2, "EASY pre-roll length changed — re-verify layout"
35
- optimal = scenario.optimal_action(game)
36
- habit = scenario.habit_action(game)
37
- assert habit == "left" # the inertia: keep walking west into the dead-end
38
- assert optimal == "up" # escape that maximizes BFS distance
39
- assert optimal != habit # THE measured divergence
40
-
41
-
42
- def test_full_replay_is_reproducible_across_two_builds():
43
- _, g1, _ = _build_at_handover()
44
- _, g2, _ = _build_at_handover()
45
- assert (g1.focal_sprite.x, g1.focal_sprite.y) == (g2.focal_sprite.x, g2.focal_sprite.y)
46
- assert (g1.predator_sprite.x, g1.predator_sprite.y) == (g2.predator_sprite.x, g2.predator_sprite.y)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/grid/test_predator_evade_registered.py DELETED
@@ -1,8 +0,0 @@
1
- def test_importing_proteus_grid_registers_predator_evade():
2
- import proteus.game.scenarios # noqa: F401 (side-effect: registers scenarios)
3
- from proteus.game.scenarios.base import get_scenario, list_scenarios
4
- assert "predator_evade" in list_scenarios()
5
- cls = get_scenario("predator_evade")
6
- inst = cls()
7
- assert inst.task_name == "predator_evade"
8
- assert inst.grid_size == (8, 8)
 
 
 
 
 
 
 
 
 
tests/grid/test_step_reward.py CHANGED
@@ -5,29 +5,26 @@ from proteus.game.engine.grid import MotiveGridGame
5
  from proteus.game.scenarios.base import get_scenario
6
 
7
 
8
- def _handover():
9
- s = get_scenario("predator_evade")()
10
  game = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=20)
11
- for _ in range(s.cut_length(Difficulty.EASY)):
12
- a = s.cut_focal_policy(game)
13
- game.apply_motive_action(a)
14
- s.record_focal_move(a)
15
  return game, s
16
 
17
 
18
  def test_step_reward_positive_when_moving_away():
19
- game, s = _handover()
20
  focal_before = (game.focal_sprite.x, game.focal_sprite.y)
21
  predator_before = (game.predator_sprite.x, game.predator_sprite.y)
22
- # 'up' is the optimal escape at the EASY handover -> moves away.
23
- game.apply_motive_action("up")
24
- r = s.step_reward(game, "up", blocked=False,
 
25
  focal_before=focal_before, predator_before=predator_before)
26
  assert r > 0
27
 
28
 
29
  def test_step_reward_negative_when_moving_toward():
30
- game, s = _handover()
31
  focal_before = (game.focal_sprite.x, game.focal_sprite.y)
32
  predator_before = (game.predator_sprite.x, game.predator_sprite.y)
33
  # 'right' moves toward the predator (east).
@@ -37,21 +34,7 @@ def test_step_reward_negative_when_moving_toward():
37
  assert r < 0
38
 
39
 
40
- def test_step_reward_negative_on_wall_hit():
41
- game, s = _handover()
42
- focal_before = (game.focal_sprite.x, game.focal_sprite.y)
43
- predator_before = (game.predator_sprite.x, game.predator_sprite.y)
44
- # 'left' is blocked by the dead-end wall at the EASY handover.
45
- game.apply_motive_action("left")
46
- # The dead-end wall makes 'left' a no-op; confirm the engine blocked it
47
- # so blocked=True faithfully reflects what _apply would have computed.
48
- assert (game.focal_sprite.x, game.focal_sprite.y) == focal_before
49
- r = s.step_reward(game, "left", blocked=True,
50
- focal_before=focal_before, predator_before=predator_before)
51
- assert r < 0
52
-
53
-
54
- def test_safety_distance_is_bfs_focal_to_predator():
55
- game, s = _handover()
56
  d = s.safety_distance(game)
57
  assert isinstance(d, int) and d >= 0
 
5
  from proteus.game.scenarios.base import get_scenario
6
 
7
 
8
+ def _start():
9
+ s = get_scenario("template")()
10
  game = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=20)
 
 
 
 
11
  return game, s
12
 
13
 
14
  def test_step_reward_positive_when_moving_away():
15
+ game, s = _start()
16
  focal_before = (game.focal_sprite.x, game.focal_sprite.y)
17
  predator_before = (game.predator_sprite.x, game.predator_sprite.y)
18
+ # The focal spawns west of the predator; moving further from it earns reward.
19
+ away = s.optimal_action(game)
20
+ game.apply_motive_action(away)
21
+ r = s.step_reward(game, away, blocked=False,
22
  focal_before=focal_before, predator_before=predator_before)
23
  assert r > 0
24
 
25
 
26
  def test_step_reward_negative_when_moving_toward():
27
+ game, s = _start()
28
  focal_before = (game.focal_sprite.x, game.focal_sprite.y)
29
  predator_before = (game.predator_sprite.x, game.predator_sprite.y)
30
  # 'right' moves toward the predator (east).
 
34
  assert r < 0
35
 
36
 
37
+ def test_safety_distance_is_distance_focal_to_predator():
38
+ game, s = _start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  d = s.safety_distance(game)
40
  assert isinstance(d, int) and d >= 0
tests/grid/test_template_observation.py CHANGED
@@ -1,22 +1,14 @@
1
- """template emits a compact coordinate frame; predator_evade keeps its exact
2
- ASCII frame (so its existing observation/cut-frame tests stay green)."""
3
  from __future__ import annotations
4
 
5
  import random
6
 
7
  import proteus.game.scenarios # noqa: F401
8
- from proteus.game.engine.ascii_view import frame_to_ascii
9
  from proteus.game.engine.difficulty import Difficulty
10
  from proteus.game.engine.grid import MotiveGridGame
11
  from proteus.game.scenarios.base import get_scenario
12
 
13
 
14
- def test_predator_evade_render_frame_is_unchanged_ascii():
15
- scenario = get_scenario("predator_evade")()
16
- game = MotiveGridGame(scenario, random.Random(42), Difficulty.EASY, max_steps=10)
17
- assert scenario.render_frame(game) == frame_to_ascii(game.current_grid(), scenario.legend())
18
-
19
-
20
  def test_template_render_frame_is_compact():
21
  scenario = get_scenario("template")()
22
  game = MotiveGridGame(scenario, random.Random(42), Difficulty.EASY, max_steps=10)
 
1
+ """template emits a compact coordinate frame (not a full ASCII map)."""
 
2
  from __future__ import annotations
3
 
4
  import random
5
 
6
  import proteus.game.scenarios # noqa: F401
 
7
  from proteus.game.engine.difficulty import Difficulty
8
  from proteus.game.engine.grid import MotiveGridGame
9
  from proteus.game.scenarios.base import get_scenario
10
 
11
 
 
 
 
 
 
 
12
  def test_template_render_frame_is_compact():
13
  scenario = get_scenario("template")()
14
  game = MotiveGridGame(scenario, random.Random(42), Difficulty.EASY, max_steps=10)
tests/runtime/test_aggregate.py CHANGED
@@ -7,7 +7,7 @@ from proteus.game.runtime.session import SessionRunner
7
  def _trace(seed):
8
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
9
  return SessionRunner(
10
- "predator_evade", agent, seed=seed, play_turns=4, use_probe=False,
11
  ).run()
12
 
13
 
 
7
  def _trace(seed):
8
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
9
  return SessionRunner(
10
+ "template", agent, seed=seed, play_turns=4, use_probe=False,
11
  ).run()
12
 
13
 
tests/runtime/test_human_comparability.py CHANGED
@@ -14,12 +14,12 @@ def test_human_and_llm_traces_share_schema_and_answer_keys():
14
  # differs. This is the human-baseline comparability foundation (spec §10).
15
  human = HumanAgent(input_fn=_scripted(["up"] * 20), output_fn=lambda s: None)
16
  h = SessionRunner(
17
- "predator_evade", human, seed=42, play_turns=5, use_probe=False,
18
  ).run()
19
 
20
  llm = VanillaAgent(FakeProvider(["ACTION: up"]))
21
  v = SessionRunner(
22
- "predator_evade", llm, seed=42, play_turns=5, use_probe=False,
23
  ).run()
24
 
25
  assert h.cut_frames == v.cut_frames
 
14
  # differs. This is the human-baseline comparability foundation (spec §10).
15
  human = HumanAgent(input_fn=_scripted(["up"] * 20), output_fn=lambda s: None)
16
  h = SessionRunner(
17
+ "template", human, seed=42, play_turns=5, use_probe=False,
18
  ).run()
19
 
20
  llm = VanillaAgent(FakeProvider(["ACTION: up"]))
21
  v = SessionRunner(
22
+ "template", llm, seed=42, play_turns=5, use_probe=False,
23
  ).run()
24
 
25
  assert h.cut_frames == v.cut_frames
tests/runtime/test_integration_golden.py CHANGED
@@ -4,11 +4,16 @@ from proteus.game.runtime import SessionRunner, SessionTrace
4
  from proteus.providers import FakeProvider
5
  from proteus.game.agents import VanillaAgent
6
 
 
 
 
 
 
7
 
8
  def test_full_session_serializes_to_jsonl_and_reloads():
9
  agent = VanillaAgent(FakeProvider(responses=["ACTION: up"], model_name="fake-1"))
10
  trace = SessionRunner(
11
- "predator_evade", agent, seed=42, play_turns=8, use_probe=True,
12
  ).run()
13
 
14
  # Serialize the whole session as one JSON line, reload, and verify.
@@ -17,10 +22,11 @@ def test_full_session_serializes_to_jsonl_and_reloads():
17
  # Full round-trip fidelity: every field survives serialization unchanged.
18
  assert reloaded.model_dump() == trace.model_dump()
19
  assert reloaded.model == "fake-1"
20
- # seed=42 EASY handover: optimal is "up", habit is "left" (diagnostic turn).
 
21
  assert reloaded.turns[0].motive_action == "up"
22
  # Concrete metric anchor (regression guard, not a vacuous >= 0 check).
23
- assert reloaded.metrics["motive_reading_accuracy"] == 60.0
24
 
25
  # Per-turn JSONL is also valid line-by-line.
26
  for t in trace.turns:
 
4
  from proteus.providers import FakeProvider
5
  from proteus.game.agents import VanillaAgent
6
 
7
+ # Self-captured deterministic snapshot for template (seed=42, EASY, 8 turns,
8
+ # the agent always answers "up"). template has no ToM divergence, so the model's
9
+ # "up" matches the optimal escape and motive-reading accuracy is perfect.
10
+ EXPECTED_MRA = 100.0
11
+
12
 
13
  def test_full_session_serializes_to_jsonl_and_reloads():
14
  agent = VanillaAgent(FakeProvider(responses=["ACTION: up"], model_name="fake-1"))
15
  trace = SessionRunner(
16
+ "template", agent, seed=42, play_turns=8, use_probe=True,
17
  ).run()
18
 
19
  # Serialize the whole session as one JSON line, reload, and verify.
 
22
  # Full round-trip fidelity: every field survives serialization unchanged.
23
  assert reloaded.model_dump() == trace.model_dump()
24
  assert reloaded.model == "fake-1"
25
+ # Deterministic self-captured snapshot (regression guard): the agent always
26
+ # answers "up", so the first committed action is "up".
27
  assert reloaded.turns[0].motive_action == "up"
28
  # Concrete metric anchor (regression guard, not a vacuous >= 0 check).
29
+ assert reloaded.metrics["motive_reading_accuracy"] == EXPECTED_MRA
30
 
31
  # Per-turn JSONL is also valid line-by-line.
32
  for t in trace.turns:
tests/runtime/test_interactive_equivalence.py CHANGED
@@ -26,14 +26,14 @@ def _scripted_human():
26
 
27
  def test_interactive_matches_session_runner():
28
  runner = SessionRunner(
29
- "predator_evade", _scripted_human(),
30
  difficulty=Difficulty.EASY, seed=42,
31
  play_turns=len(ACTIONS), use_probe=False,
32
  )
33
  cli_trace = runner.run()
34
 
35
  sess = InteractiveSession(
36
- "predator_evade", difficulty=Difficulty.EASY, seed=42,
37
  play_turns=len(ACTIONS), use_probe=False,
38
  )
39
  for a in ACTIONS:
 
26
 
27
  def test_interactive_matches_session_runner():
28
  runner = SessionRunner(
29
+ "template", _scripted_human(),
30
  difficulty=Difficulty.EASY, seed=42,
31
  play_turns=len(ACTIONS), use_probe=False,
32
  )
33
  cli_trace = runner.run()
34
 
35
  sess = InteractiveSession(
36
+ "template", difficulty=Difficulty.EASY, seed=42,
37
  play_turns=len(ACTIONS), use_probe=False,
38
  )
39
  for a in ACTIONS:
tests/runtime/test_interactive_session.py CHANGED
@@ -11,7 +11,7 @@ from proteus.game.runtime.interactive import InteractiveSession
11
 
12
  def _new(play_turns=10):
13
  return InteractiveSession(
14
- "predator_evade", difficulty=Difficulty.EASY, seed=42,
15
  play_turns=play_turns, use_probe=False,
16
  )
17
 
@@ -60,7 +60,7 @@ def test_play_to_budget_then_review_and_finish():
60
  assert "metrics" in st["review"] and "turns" in st["review"]
61
  trace = s.finish()
62
  assert trace.model == "human"
63
- assert trace.scenario == "predator_evade"
64
  # finish() is memoized: repeated calls return the same trace object.
65
  assert s.finish() is trace
66
 
 
11
 
12
  def _new(play_turns=10):
13
  return InteractiveSession(
14
+ "template", difficulty=Difficulty.EASY, seed=42,
15
  play_turns=play_turns, use_probe=False,
16
  )
17
 
 
60
  assert "metrics" in st["review"] and "turns" in st["review"]
61
  trace = s.finish()
62
  assert trace.model == "human"
63
+ assert trace.scenario == "template"
64
  # finish() is memoized: repeated calls return the same trace object.
65
  assert s.finish() is trace
66
 
tests/runtime/test_io.py CHANGED
@@ -6,7 +6,7 @@ from proteus.game.runtime import SessionRunner, append_trace, read_traces
6
  def _trace(seed):
7
  agent = VanillaAgent(FakeProvider(responses=["ACTION: up"], model_name="fake-1"))
8
  return SessionRunner(
9
- "predator_evade", agent, seed=seed, play_turns=4, use_probe=False,
10
  ).run()
11
 
12
 
@@ -16,7 +16,7 @@ def test_append_then_read_roundtrips_one_trace(tmp_path):
16
  assert written.exists()
17
  traces = read_traces(path)
18
  assert len(traces) == 1
19
- assert traces[0].scenario == "predator_evade"
20
  assert traces[0].model == "fake-1"
21
 
22
 
 
6
  def _trace(seed):
7
  agent = VanillaAgent(FakeProvider(responses=["ACTION: up"], model_name="fake-1"))
8
  return SessionRunner(
9
+ "template", agent, seed=seed, play_turns=4, use_probe=False,
10
  ).run()
11
 
12
 
 
16
  assert written.exists()
17
  traces = read_traces(path)
18
  assert len(traces) == 1
19
+ assert traces[0].scenario == "template"
20
  assert traces[0].model == "fake-1"
21
 
22
 
tests/runtime/test_memory.py CHANGED
@@ -4,7 +4,7 @@ from proteus.game.runtime.memory import MemoryCheckpoint, MemoryTurn
4
  def _checkpoint() -> MemoryCheckpoint:
5
  return MemoryCheckpoint(
6
  model="demo",
7
- scenario="predator_evade",
8
  difficulty="easy",
9
  seed=42,
10
  created_at="2026-06-02T10-40-56Z",
 
4
  def _checkpoint() -> MemoryCheckpoint:
5
  return MemoryCheckpoint(
6
  model="demo",
7
+ scenario="template",
8
  difficulty="easy",
9
  seed=42,
10
  created_at="2026-06-02T10-40-56Z",
tests/runtime/test_memory_gen.py CHANGED
@@ -1,4 +1,4 @@
1
- import proteus.game.scenarios # noqa: F401 (registers predator_evade)
2
  from proteus.game.scenarios.base import Scenario, get_scenario
3
 
4
 
@@ -6,15 +6,6 @@ def test_scenario_base_memory_brief_default_empty():
6
  assert Scenario.memory_brief == ""
7
 
8
 
9
- def test_predator_evade_memory_brief_is_transparent():
10
- scenario = get_scenario("predator_evade")()
11
- brief = scenario.memory_brief
12
- assert brief # non-empty
13
- low = brief.lower()
14
- assert "predator" in low
15
- assert "shortest" in low or "bfs" in low # discloses the chase rule
16
-
17
-
18
  from proteus.game.agents import VanillaAgent
19
  from proteus.game.engine.difficulty import Difficulty
20
  from proteus.providers import FakeProvider
@@ -28,12 +19,12 @@ def test_generate_memory_is_deterministic_and_records_episode():
28
  from proteus.game.runtime.memory_gen import generate_memory
29
 
30
  ck = generate_memory(
31
- "predator_evade", _agent(), difficulty=Difficulty.EASY, seed=42,
32
  memory_turns=5, model_name="demo", clock=lambda: "FIXED",
33
  )
34
  assert ck.created_at == "FIXED"
35
  assert ck.model == "demo"
36
- assert ck.scenario == "predator_evade"
37
  assert ck.difficulty == "easy"
38
  assert 1 <= len(ck.memory_turns) <= 5
39
  assert ck.memory_turns[0].action == "down"
@@ -41,7 +32,7 @@ def test_generate_memory_is_deterministic_and_records_episode():
41
  assert ck.outcome in ("survived", "eliminated")
42
 
43
  ck2 = generate_memory(
44
- "predator_evade", _agent(), difficulty=Difficulty.EASY, seed=42,
45
  memory_turns=5, model_name="demo", clock=lambda: "FIXED",
46
  )
47
  assert ck.model_dump() == ck2.model_dump()
@@ -52,7 +43,7 @@ def test_generate_memory_uses_memory_brief_as_prompt():
52
 
53
  agent = _agent()
54
  generate_memory(
55
- "predator_evade", agent, difficulty=Difficulty.EASY, seed=42,
56
  memory_turns=2, model_name="demo", clock=lambda: "FIXED",
57
  )
58
  # The provider saw the transparent brief as the system message.
 
1
+ import proteus.game.scenarios # noqa: F401 (registers scenarios)
2
  from proteus.game.scenarios.base import Scenario, get_scenario
3
 
4
 
 
6
  assert Scenario.memory_brief == ""
7
 
8
 
 
 
 
 
 
 
 
 
 
9
  from proteus.game.agents import VanillaAgent
10
  from proteus.game.engine.difficulty import Difficulty
11
  from proteus.providers import FakeProvider
 
19
  from proteus.game.runtime.memory_gen import generate_memory
20
 
21
  ck = generate_memory(
22
+ "template", _agent(), difficulty=Difficulty.EASY, seed=42,
23
  memory_turns=5, model_name="demo", clock=lambda: "FIXED",
24
  )
25
  assert ck.created_at == "FIXED"
26
  assert ck.model == "demo"
27
+ assert ck.scenario == "template"
28
  assert ck.difficulty == "easy"
29
  assert 1 <= len(ck.memory_turns) <= 5
30
  assert ck.memory_turns[0].action == "down"
 
32
  assert ck.outcome in ("survived", "eliminated")
33
 
34
  ck2 = generate_memory(
35
+ "template", _agent(), difficulty=Difficulty.EASY, seed=42,
36
  memory_turns=5, model_name="demo", clock=lambda: "FIXED",
37
  )
38
  assert ck.model_dump() == ck2.model_dump()
 
43
 
44
  agent = _agent()
45
  generate_memory(
46
+ "template", agent, difficulty=Difficulty.EASY, seed=42,
47
  memory_turns=2, model_name="demo", clock=lambda: "FIXED",
48
  )
49
  # The provider saw the transparent brief as the system message.
tests/runtime/test_memory_persona.py CHANGED
@@ -6,12 +6,12 @@ from proteus.game.metrics.persona import PersonaWeights
6
  def test_persona_memory_is_deterministic_and_tags_persona():
7
  w = PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0)
8
  ck = generate_memory(
9
- "predator_evade", agent=None, difficulty=Difficulty.EASY, seed=42,
10
  memory_turns=5, model_name="ref", clock=lambda: "FIXED", persona=w,
11
  )
12
  assert ck.persona_weight_id == "risk_averse"
13
  assert 1 <= len(ck.memory_turns) <= 5
14
- # risk-averse demo never walks into the dead-end wall on the diagnostic turn
15
- assert ck.memory_turns[0].action in ("up", "down", "right", "stay")
16
  # weights are NOT in the participant-visible checkpoint text
17
  assert "risk_cost" not in ck.model_dump_json()
 
6
  def test_persona_memory_is_deterministic_and_tags_persona():
7
  w = PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0)
8
  ck = generate_memory(
9
+ "template", agent=None, difficulty=Difficulty.EASY, seed=42,
10
  memory_turns=5, model_name="ref", clock=lambda: "FIXED", persona=w,
11
  )
12
  assert ck.persona_weight_id == "risk_averse"
13
  assert 1 <= len(ck.memory_turns) <= 5
14
+ # risk-averse demo never moves toward the (far-east) predator on the first turn
15
+ assert ck.memory_turns[0].action in ("up", "down", "left", "stay")
16
  # weights are NOT in the participant-visible checkpoint text
17
  assert "risk_cost" not in ck.model_dump_json()
tests/runtime/test_persona.py CHANGED
@@ -7,13 +7,10 @@ from proteus.game.metrics.persona import PersonaWeights, reference_actions, pres
7
 
8
 
9
  def _sg():
10
- s = get_scenario("predator_evade")()
11
  g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
12
- # Reproduce the EASY handover: the focal walks west through the 2-turn Cut
13
- # into the dead-end, so `left` becomes a wall (matching the real session
14
- # state the persona reference is scored against).
15
- g.apply_motive_action("left")
16
- g.apply_motive_action("left")
17
  return s, g
18
 
19
 
@@ -21,8 +18,8 @@ def test_risk_averse_reference_increases_distance():
21
  s, g = _sg()
22
  w = PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0)
23
  acts = reference_actions(w, s, g)
24
- # EASY handover analog: moving away (up/down) is preferred over into-wall left
25
- assert "left" not in acts
26
  assert acts # non-empty
27
 
28
 
@@ -39,7 +36,7 @@ def _runner(action, persona_id="risk_averse", play_turns=1):
39
  from proteus.game.metrics.persona import get_persona
40
  prov = FakeProvider([f"ACTION: {action}"] * 10, model_name="demo")
41
  return SessionRunner(
42
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
43
  seed=42, play_turns=play_turns, use_probe=False,
44
  persona=get_persona(persona_id),
45
  ).run()
@@ -55,15 +52,15 @@ def test_persona_run_records_id_and_fields():
55
 
56
 
57
  def test_reference_action_yields_full_agreement_zero_regret():
58
- # 'up' is a reference action at the EASY handover -> perfect maintenance.
59
  m = _runner("up").metrics
60
  assert m["action_agreement"] == 100.0
61
  assert abs(m["reward_regret"]) < 1e-9
62
 
63
 
64
- def test_into_wall_lowers_agreement_and_positive_regret():
65
- # 'left' walks into the dead-end wall: not a reference action, worse reward.
66
- m = _runner("left").metrics
67
  assert m["action_agreement"] == 0.0
68
  assert m["reward_regret"] > 0.0
69
 
@@ -74,7 +71,7 @@ def test_persona_metrics_absent_without_persona():
74
  from proteus.game.runtime import SessionRunner
75
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
76
  m = SessionRunner(
77
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
78
  seed=42, play_turns=2, use_probe=False,
79
  ).run().metrics
80
  assert "action_agreement" not in m
 
7
 
8
 
9
  def _sg():
10
+ s = get_scenario("template")()
11
  g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
12
+ # template spawns the focal far west of the predator; the risk-averse
13
+ # reference prefers moves that keep/open distance from the (far-east) threat.
 
 
 
14
  return s, g
15
 
16
 
 
18
  s, g = _sg()
19
  w = PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0)
20
  acts = reference_actions(w, s, g)
21
+ # Moving toward the predator (east, 'right') is never a risk-averse reference.
22
+ assert "right" not in acts
23
  assert acts # non-empty
24
 
25
 
 
36
  from proteus.game.metrics.persona import get_persona
37
  prov = FakeProvider([f"ACTION: {action}"] * 10, model_name="demo")
38
  return SessionRunner(
39
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
40
  seed=42, play_turns=play_turns, use_probe=False,
41
  persona=get_persona(persona_id),
42
  ).run()
 
52
 
53
 
54
  def test_reference_action_yields_full_agreement_zero_regret():
55
+ # 'up' opens distance from the far-east predator -> a reference action.
56
  m = _runner("up").metrics
57
  assert m["action_agreement"] == 100.0
58
  assert abs(m["reward_regret"]) < 1e-9
59
 
60
 
61
+ def test_toward_predator_lowers_agreement_and_positive_regret():
62
+ # 'right' heads east toward the predator: not a reference action, worse reward.
63
+ m = _runner("right").metrics
64
  assert m["action_agreement"] == 0.0
65
  assert m["reward_regret"] > 0.0
66
 
 
71
  from proteus.game.runtime import SessionRunner
72
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
73
  m = SessionRunner(
74
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
75
  seed=42, play_turns=2, use_probe=False,
76
  ).run().metrics
77
  assert "action_agreement" not in m
tests/runtime/test_rollout.py CHANGED
@@ -3,21 +3,30 @@ from proteus.game.metrics.rollout import RolloutResult, optimal_rollout
3
 
4
 
5
  def test_optimal_rollout_is_deterministic():
6
- a = optimal_rollout("predator_evade", seed=42, difficulty=Difficulty.EASY, n_turns=5)
7
- b = optimal_rollout("predator_evade", seed=42, difficulty=Difficulty.EASY, n_turns=5)
8
  assert isinstance(a, RolloutResult)
9
  assert a.focal_positions == b.focal_positions
10
  assert a.final_safety_distance == b.final_safety_distance
11
 
12
 
13
  def test_optimal_rollout_length_capped_by_n_turns():
14
- r = optimal_rollout("predator_evade", seed=42, difficulty=Difficulty.EASY, n_turns=3)
15
  assert len(r.focal_positions) <= 3
16
  # Each recorded position is the PRE-move focal cell for that optimal turn.
17
  assert all(isinstance(p, tuple) and len(p) == 2 for p in r.focal_positions)
18
 
19
 
20
  def test_optimal_rollout_first_position_is_handover():
21
- # The first optimal pre-move position equals the focal's handover cell.
22
- r = optimal_rollout("predator_evade", seed=42, difficulty=Difficulty.EASY, n_turns=5)
23
- assert r.focal_positions[0] == (3, 3) # EASY handover
 
 
 
 
 
 
 
 
 
 
3
 
4
 
5
  def test_optimal_rollout_is_deterministic():
6
+ a = optimal_rollout("template", seed=42, difficulty=Difficulty.EASY, n_turns=5)
7
+ b = optimal_rollout("template", seed=42, difficulty=Difficulty.EASY, n_turns=5)
8
  assert isinstance(a, RolloutResult)
9
  assert a.focal_positions == b.focal_positions
10
  assert a.final_safety_distance == b.final_safety_distance
11
 
12
 
13
  def test_optimal_rollout_length_capped_by_n_turns():
14
+ r = optimal_rollout("template", seed=42, difficulty=Difficulty.EASY, n_turns=3)
15
  assert len(r.focal_positions) <= 3
16
  # Each recorded position is the PRE-move focal cell for that optimal turn.
17
  assert all(isinstance(p, tuple) and len(p) == 2 for p in r.focal_positions)
18
 
19
 
20
  def test_optimal_rollout_first_position_is_handover():
21
+ # The first optimal pre-move position equals the focal's spawn cell (template
22
+ # has no Cut pre-roll, so the handover IS the spawn). Self-derived, not hardcoded.
23
+ import random
24
+ from proteus.game.engine.grid import MotiveGridGame
25
+ from proteus.game.scenarios.base import get_scenario
26
+
27
+ scenario = get_scenario("template")()
28
+ game = MotiveGridGame(scenario, random.Random(42), Difficulty.EASY, max_steps=10)
29
+ spawn = (game.focal_sprite.x, game.focal_sprite.y)
30
+
31
+ r = optimal_rollout("template", seed=42, difficulty=Difficulty.EASY, n_turns=5)
32
+ assert r.focal_positions[0] == spawn
tests/runtime/test_session.py CHANGED
@@ -9,46 +9,29 @@ def _agent(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
@@ -56,9 +39,9 @@ def test_probe_recorded_when_enabled():
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]
@@ -70,27 +53,20 @@ def test_short_budget_yields_survived_outcome():
70
  # after the played turns, so the engine fires `survived`.
71
  agent = _agent(["ACTION: up"])
72
  trace = SessionRunner(
73
- "predator_evade", agent, seed=42, play_turns=1, use_probe=False,
74
  ).run()
75
  assert trace.outcome == "survived"
76
  assert trace.turns[-1].reward == 50.0 # _REWARD_SURVIVED
77
 
78
 
79
- def test_eliminated_outcome_is_explicit_and_terminal():
80
- # The habit player ("left") walks into the dead-end and is caught.
81
- agent = _agent(["ACTION: left"])
82
- trace = SessionRunner(
83
- "predator_evade", agent, seed=42, play_turns=15, use_probe=False,
84
- ).run()
85
- assert trace.outcome == "eliminated"
86
- assert len(trace.turns) <= 15 # stopped at/under budget
87
- assert trace.turns[-1].reward == -50.0 # _REWARD_CAPTURED
88
-
89
-
90
  def test_cut_frames_count_matches_cut_length_plus_one():
 
 
 
91
  agent = _agent(["ACTION: up"])
92
  trace = SessionRunner(
93
- "predator_evade", agent, seed=42, play_turns=5, use_probe=False,
94
  ).run()
95
- # EASY cut_length is 2 -> initial frame + 2 step frames = 3.
96
- assert len(trace.cut_frames) == 3
 
 
9
 
10
 
11
  def test_optimal_player_survives_and_scores_full_motive_reading():
12
+ # At the start the motive-congruent escape is "up" (open column away from
13
+ # the far-east predator). An agent that always plays "up" stays congruent;
14
+ # the runner scores each turn against the live optimal answer key.
 
15
  agent = _agent(["ACTION: up"]) # FakeProvider repeats the last response
16
  runner = SessionRunner(
17
+ "template", agent, seed=42, play_turns=10, use_probe=False,
18
  )
19
  trace = runner.run()
20
  assert isinstance(trace, SessionTrace)
21
+ assert trace.scenario == "template"
22
  assert trace.cut_frames # Cut history captured
23
  assert len(trace.turns) >= 1
 
24
  first = trace.turns[0]
 
25
  assert first.motive_action == "up"
 
26
  assert first.action == "up"
27
  assert first.was_congruent is True
28
  assert "motive_reading_accuracy" in trace.metrics
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  def test_probe_recorded_when_enabled():
32
  agent = _agent(["the predator is to my east; I should go up\nACTION: up"])
33
  runner = SessionRunner(
34
+ "template", agent, seed=42, play_turns=3, use_probe=True,
35
  )
36
  trace = runner.run()
37
  assert trace.turns[0].probe_q # a question was asked
 
39
 
40
 
41
  def test_session_is_deterministic_for_same_inputs():
42
+ t1 = SessionRunner("template", _agent(["ACTION: up"]), seed=42,
43
  play_turns=5, use_probe=False).run()
44
+ t2 = SessionRunner("template", _agent(["ACTION: up"]), seed=42,
45
  play_turns=5, use_probe=False).run()
46
  # Same scripted agent + same seed -> identical realized trajectory.
47
  assert [t.focal_pos for t in t1.turns] == [t.focal_pos for t in t2.turns]
 
53
  # after the played turns, so the engine fires `survived`.
54
  agent = _agent(["ACTION: up"])
55
  trace = SessionRunner(
56
+ "template", agent, seed=42, play_turns=1, use_probe=False,
57
  ).run()
58
  assert trace.outcome == "survived"
59
  assert trace.turns[-1].reward == 50.0 # _REWARD_SURVIVED
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
62
  def test_cut_frames_count_matches_cut_length_plus_one():
63
+ from proteus.game.engine.difficulty import Difficulty
64
+ from proteus.game.scenarios.base import get_scenario
65
+
66
  agent = _agent(["ACTION: up"])
67
  trace = SessionRunner(
68
+ "template", agent, seed=42, play_turns=5, use_probe=False,
69
  ).run()
70
+ # initial frame + one frame per Cut pre-roll step (self-derived, not hardcoded).
71
+ expected = get_scenario("template")().cut_length(Difficulty.EASY) + 1
72
+ assert len(trace.cut_frames) == expected
tests/runtime/test_session_core.py CHANGED
@@ -4,14 +4,14 @@ from __future__ import annotations
4
 
5
  import pytest
6
 
7
- import proteus.game.scenarios # noqa: F401 (registers predator_evade)
8
  from proteus.game.engine.difficulty import Difficulty
9
  from proteus.game.runtime import _session_core as core
10
 
11
 
12
  def test_build_session_is_deterministic_and_non_terminal():
13
- a = core.build_session("predator_evade", 42, Difficulty.EASY, 10)
14
- b = core.build_session("predator_evade", 42, Difficulty.EASY, 10)
15
  # Same seed -> identical cut frames in both representations.
16
  assert a.cut_frames == b.cut_frames
17
  assert a.cut_grids == b.cut_grids
@@ -23,7 +23,7 @@ def test_build_session_is_deterministic_and_non_terminal():
23
 
24
 
25
  def test_make_turn_trace_records_premove_keys_and_reward():
26
- built = core.build_session("predator_evade", 42, Difficulty.EASY, 10)
27
  obs = core.build_observation(built.scenario, built.game, built.cut_frames, 1)
28
  turn = core.make_turn_trace(
29
  built.scenario, built.game, turn_idx=1, observation=obs,
@@ -38,7 +38,7 @@ def test_make_turn_trace_records_premove_keys_and_reward():
38
 
39
 
40
  def test_finalize_produces_scored_trace():
41
- built = core.build_session("predator_evade", 42, Difficulty.EASY, 3)
42
  turns = []
43
  for i in range(1, 4):
44
  obs = core.build_observation(built.scenario, built.game, built.cut_frames, i)
@@ -49,12 +49,12 @@ def test_finalize_produces_scored_trace():
49
  if built.game.eliminated or built.game.survived:
50
  break
51
  trace = core.finalize(
52
- "predator_evade", built.scenario, built.game,
53
  seed=42, difficulty=Difficulty.EASY, play_turns=3,
54
  turns=turns, cut_frames=built.cut_frames,
55
  motive_category="survival", model="human",
56
  )
57
- assert trace.scenario == "predator_evade"
58
  assert trace.model == "human"
59
  assert trace.outcome in ("survived", "eliminated")
60
  assert set(trace.metrics) >= {
@@ -66,11 +66,11 @@ def test_finalize_produces_scored_trace():
66
  def test_finalize_before_terminal_or_budget_raises():
67
  # The sole intentional divergence from the original SessionRunner: a
68
  # non-terminal, under-budget finalize raises (vs the old bare assert).
69
- built = core.build_session("predator_evade", 42, Difficulty.EASY, 5)
70
  assert not (built.game.eliminated or built.game.survived)
71
  with pytest.raises(core.SessionNotFinishedError):
72
  core.finalize(
73
- "predator_evade", built.scenario, built.game,
74
  seed=42, difficulty=Difficulty.EASY, play_turns=5,
75
  turns=[], cut_frames=built.cut_frames,
76
  motive_category="survival", model="human",
 
4
 
5
  import pytest
6
 
7
+ import proteus.game.scenarios # noqa: F401 (registers template)
8
  from proteus.game.engine.difficulty import Difficulty
9
  from proteus.game.runtime import _session_core as core
10
 
11
 
12
  def test_build_session_is_deterministic_and_non_terminal():
13
+ a = core.build_session("template", 42, Difficulty.EASY, 10)
14
+ b = core.build_session("template", 42, Difficulty.EASY, 10)
15
  # Same seed -> identical cut frames in both representations.
16
  assert a.cut_frames == b.cut_frames
17
  assert a.cut_grids == b.cut_grids
 
23
 
24
 
25
  def test_make_turn_trace_records_premove_keys_and_reward():
26
+ built = core.build_session("template", 42, Difficulty.EASY, 10)
27
  obs = core.build_observation(built.scenario, built.game, built.cut_frames, 1)
28
  turn = core.make_turn_trace(
29
  built.scenario, built.game, turn_idx=1, observation=obs,
 
38
 
39
 
40
  def test_finalize_produces_scored_trace():
41
+ built = core.build_session("template", 42, Difficulty.EASY, 3)
42
  turns = []
43
  for i in range(1, 4):
44
  obs = core.build_observation(built.scenario, built.game, built.cut_frames, i)
 
49
  if built.game.eliminated or built.game.survived:
50
  break
51
  trace = core.finalize(
52
+ "template", built.scenario, built.game,
53
  seed=42, difficulty=Difficulty.EASY, play_turns=3,
54
  turns=turns, cut_frames=built.cut_frames,
55
  motive_category="survival", model="human",
56
  )
57
+ assert trace.scenario == "template"
58
  assert trace.model == "human"
59
  assert trace.outcome in ("survived", "eliminated")
60
  assert set(trace.metrics) >= {
 
66
  def test_finalize_before_terminal_or_budget_raises():
67
  # The sole intentional divergence from the original SessionRunner: a
68
  # non-terminal, under-budget finalize raises (vs the old bare assert).
69
+ built = core.build_session("template", 42, Difficulty.EASY, 5)
70
  assert not (built.game.eliminated or built.game.survived)
71
  with pytest.raises(core.SessionNotFinishedError):
72
  core.finalize(
73
+ "template", built.scenario, built.game,
74
  seed=42, difficulty=Difficulty.EASY, play_turns=5,
75
  turns=[], cut_frames=built.cut_frames,
76
  motive_category="survival", model="human",
tests/runtime/test_session_distance.py CHANGED
@@ -8,7 +8,7 @@ from proteus.game.runtime import SessionRunner
8
  def test_each_turn_records_pre_and_post_bfs_distance():
9
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
10
  trace = SessionRunner(
11
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
12
  seed=42, play_turns=4, use_probe=False,
13
  ).run()
14
  for t in trace.turns:
@@ -23,7 +23,7 @@ def test_each_turn_records_pre_and_post_bfs_distance():
23
  def test_episode_records_turn_order_capture_rule_horizon():
24
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
25
  trace = SessionRunner(
26
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
27
  seed=42, play_turns=4, use_probe=False,
28
  ).run()
29
  assert trace.turn_order == "focal_then_predator"
 
8
  def test_each_turn_records_pre_and_post_bfs_distance():
9
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
10
  trace = SessionRunner(
11
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
12
  seed=42, play_turns=4, use_probe=False,
13
  ).run()
14
  for t in trace.turns:
 
23
  def test_episode_records_turn_order_capture_rule_horizon():
24
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
25
  trace = SessionRunner(
26
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
27
  seed=42, play_turns=4, use_probe=False,
28
  ).run()
29
  assert trace.turn_order == "focal_then_predator"
tests/runtime/test_session_memory.py CHANGED
@@ -3,7 +3,7 @@ from proteus.game.runtime.trace import SessionTrace
3
 
4
  def test_session_trace_memory_ref_defaults_none_and_round_trips():
5
  t = SessionTrace(
6
- scenario="predator_evade", motive_category="survival", seed=42,
7
  difficulty="easy", model="demo", outcome="survived",
8
  )
9
  assert t.memory_ref is None
@@ -11,7 +11,7 @@ def test_session_trace_memory_ref_defaults_none_and_round_trips():
11
  assert t2.memory_ref is None
12
 
13
  t3 = SessionTrace(
14
- scenario="predator_evade", motive_category="survival", seed=42,
15
  difficulty="easy", model="demo", outcome="survived",
16
  memory_ref="demo@FIXED",
17
  )
@@ -27,7 +27,7 @@ from proteus.game.runtime.memory import MemoryCheckpoint, MemoryTurn
27
 
28
  def _memory() -> MemoryCheckpoint:
29
  return MemoryCheckpoint(
30
- model="demo", scenario="predator_evade", difficulty="easy", seed=42,
31
  created_at="FIXED",
32
  memory_turns=[
33
  MemoryTurn(turn_idx=1, frame_ascii="MEMFRAME-1", action="up",
@@ -42,22 +42,26 @@ def _memory() -> MemoryCheckpoint:
42
  def _runner(memory=None, memory_ref=None) -> SessionRunner:
43
  prov = FakeProvider(["ACTION: stay"] * 20, model_name="demo")
44
  return SessionRunner(
45
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
46
  seed=42, play_turns=3, use_probe=False,
47
  memory=memory, memory_ref=memory_ref,
48
  )
49
 
50
 
51
- def test_memory_injection_lengthens_turn1_and_preserves_measurement():
 
 
 
52
  base = _runner().run()
53
  withmem = _runner(memory=_memory(), memory_ref="demo@FIXED").run()
54
 
55
  obs1 = withmem.turns[0].observation
56
- # the memory block is present and turn-1 grew
57
  assert "MEMORY" in obs1
58
  assert "MEMFRAME-1" in obs1 and "MEMFRAME-2" in obs1
59
  assert "you chose: up" in obs1
60
- assert len(obs1) > len(base.turns[0].observation)
 
61
 
62
  # the scored game is identical: same answer keys, diagnostic, metrics
63
  assert [t.motive_action for t in withmem.turns] == [t.motive_action for t in base.turns]
@@ -65,18 +69,14 @@ def test_memory_injection_lengthens_turn1_and_preserves_measurement():
65
  assert [t.is_diagnostic for t in withmem.turns] == [t.is_diagnostic for t in base.turns]
66
  assert withmem.metrics == base.metrics
67
 
68
- # memory_ref recorded with memory, None without
69
  assert withmem.memory_ref == "demo@FIXED"
70
  assert base.memory_ref is None
71
 
72
 
73
  def test_memory_is_shown_every_turn_for_auto_regressive_play():
74
- base = _runner().run()
75
  withmem = _runner(memory=_memory(), memory_ref="x").run()
76
- # Auto-regressive play: the handover memory is now carried on turn 2+ as well
77
- # (not only turn 1), so a stateless agent never loses it mid-episode. Without
78
- # memory the block is absent; with memory it is present and the observations
79
- # differ.
80
- assert "MEMORY" not in base.turns[1].observation
81
- assert "MEMORY" in withmem.turns[1].observation
82
- assert base.turns[1].observation != withmem.turns[1].observation
 
3
 
4
  def test_session_trace_memory_ref_defaults_none_and_round_trips():
5
  t = SessionTrace(
6
+ scenario="template", motive_category="survival", seed=42,
7
  difficulty="easy", model="demo", outcome="survived",
8
  )
9
  assert t.memory_ref is None
 
11
  assert t2.memory_ref is None
12
 
13
  t3 = SessionTrace(
14
+ scenario="template", motive_category="survival", seed=42,
15
  difficulty="easy", model="demo", outcome="survived",
16
  memory_ref="demo@FIXED",
17
  )
 
27
 
28
  def _memory() -> MemoryCheckpoint:
29
  return MemoryCheckpoint(
30
+ model="demo", scenario="template", difficulty="easy", seed=42,
31
  created_at="FIXED",
32
  memory_turns=[
33
  MemoryTurn(turn_idx=1, frame_ascii="MEMFRAME-1", action="up",
 
42
  def _runner(memory=None, memory_ref=None) -> SessionRunner:
43
  prov = FakeProvider(["ACTION: stay"] * 20, model_name="demo")
44
  return SessionRunner(
45
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
46
  seed=42, play_turns=3, use_probe=False,
47
  memory=memory, memory_ref=memory_ref,
48
  )
49
 
50
 
51
+ def test_memory_injection_shows_explicit_memory_and_preserves_measurement():
52
+ # NOTE: template provides a default (persona) memory, so the no-explicit-memory
53
+ # baseline already carries a MEMORY block. The contrast here is therefore
54
+ # "explicit fixture memory" vs "default memory", not "memory" vs "no memory".
55
  base = _runner().run()
56
  withmem = _runner(memory=_memory(), memory_ref="demo@FIXED").run()
57
 
58
  obs1 = withmem.turns[0].observation
59
+ # the explicit memory block is present at turn 1
60
  assert "MEMORY" in obs1
61
  assert "MEMFRAME-1" in obs1 and "MEMFRAME-2" in obs1
62
  assert "you chose: up" in obs1
63
+ # the explicit memory overrides the scenario default, so the observation differs
64
+ assert obs1 != base.turns[0].observation
65
 
66
  # the scored game is identical: same answer keys, diagnostic, metrics
67
  assert [t.motive_action for t in withmem.turns] == [t.motive_action for t in base.turns]
 
69
  assert [t.is_diagnostic for t in withmem.turns] == [t.is_diagnostic for t in base.turns]
70
  assert withmem.metrics == base.metrics
71
 
72
+ # memory_ref recorded with explicit memory, None when falling back to default
73
  assert withmem.memory_ref == "demo@FIXED"
74
  assert base.memory_ref is None
75
 
76
 
77
  def test_memory_is_shown_every_turn_for_auto_regressive_play():
 
78
  withmem = _runner(memory=_memory(), memory_ref="x").run()
79
+ # Auto-regressive play: the handover memory is carried on turn 2+ as well
80
+ # (not only turn 1), so a stateless agent never loses it mid-episode.
81
+ assert "MEMFRAME-1" in withmem.turns[0].observation
82
+ assert "MEMFRAME-1" in withmem.turns[1].observation
 
 
 
tests/runtime/test_spectate.py CHANGED
@@ -17,7 +17,7 @@ def _agent(n=10):
17
 
18
  def _new(play_turns=5):
19
  return SpectateSession(
20
- "predator_evade", agent=_agent(), model_name="fake:demo",
21
  difficulty=Difficulty.EASY, seed=42, play_turns=play_turns, use_probe=False,
22
  )
23
 
@@ -49,7 +49,7 @@ def test_advance_to_budget_then_done_and_finish():
49
  st = s.state()
50
  assert st["phase"] == "done" and st["outcome"] in ("survived", "eliminated")
51
  trace = s.finish()
52
- assert trace.model == "fake:demo" and trace.scenario == "predator_evade"
53
 
54
 
55
  def test_advance_after_done_raises():
 
17
 
18
  def _new(play_turns=5):
19
  return SpectateSession(
20
+ "template", agent=_agent(), model_name="fake:demo",
21
  difficulty=Difficulty.EASY, seed=42, play_turns=play_turns, use_probe=False,
22
  )
23
 
 
49
  st = s.state()
50
  assert st["phase"] == "done" and st["outcome"] in ("survived", "eliminated")
51
  trace = s.finish()
52
+ assert trace.model == "fake:demo" and trace.scenario == "template"
53
 
54
 
55
  def test_advance_after_done_raises():
tests/runtime/test_spectate_equivalence.py CHANGED
@@ -20,13 +20,13 @@ def _agent():
20
 
21
  def test_spectate_matches_session_runner():
22
  runner = SessionRunner(
23
- "predator_evade", _agent(), difficulty=Difficulty.EASY, seed=42,
24
  play_turns=len(RESPONSES), use_probe=False,
25
  )
26
  batch_trace = runner.run()
27
 
28
  sess = SpectateSession(
29
- "predator_evade", agent=_agent(), model_name="demo",
30
  difficulty=Difficulty.EASY, seed=42, play_turns=len(RESPONSES), use_probe=False,
31
  )
32
  while sess.state()["outcome"] is None:
 
20
 
21
  def test_spectate_matches_session_runner():
22
  runner = SessionRunner(
23
+ "template", _agent(), difficulty=Difficulty.EASY, seed=42,
24
  play_turns=len(RESPONSES), use_probe=False,
25
  )
26
  batch_trace = runner.run()
27
 
28
  sess = SpectateSession(
29
+ "template", agent=_agent(), model_name="demo",
30
  difficulty=Difficulty.EASY, seed=42, play_turns=len(RESPONSES), use_probe=False,
31
  )
32
  while sess.state()["outcome"] is None:
tests/runtime/test_trace.py CHANGED
@@ -39,7 +39,7 @@ def test_sessiontrace_defaults_and_nesting():
39
  predator_pos=(5, 3),
40
  )
41
  s = SessionTrace(
42
- scenario="predator_evade",
43
  motive_category="survival",
44
  seed=42,
45
  difficulty="easy",
 
39
  predator_pos=(5, 3),
40
  )
41
  s = SessionTrace(
42
+ scenario="template",
43
  motive_category="survival",
44
  seed=42,
45
  difficulty="easy",
tests/runtime/test_trace_accounting.py CHANGED
@@ -6,7 +6,7 @@ from proteus.game.runtime import SessionRunner, SessionTrace
6
  def _run(response):
7
  agent = VanillaAgent(FakeProvider(responses=[response], model_name="fake-1"))
8
  return SessionRunner(
9
- "predator_evade", agent, seed=42, play_turns=3, use_probe=False,
10
  ).run()
11
 
12
 
@@ -43,7 +43,7 @@ def test_probe_accounting_persisted_when_enabled():
43
  model_name="fake-1",
44
  ))
45
  trace = SessionRunner(
46
- "predator_evade", agent, seed=42, play_turns=3, use_probe=True,
47
  ).run()
48
  t0 = trace.turns[0]
49
  assert t0.probe_a # answer recorded
 
6
  def _run(response):
7
  agent = VanillaAgent(FakeProvider(responses=[response], model_name="fake-1"))
8
  return SessionRunner(
9
+ "template", agent, seed=42, play_turns=3, use_probe=False,
10
  ).run()
11
 
12
 
 
43
  model_name="fake-1",
44
  ))
45
  trace = SessionRunner(
46
+ "template", agent, seed=42, play_turns=3, use_probe=True,
47
  ).run()
48
  t0 = trace.turns[0]
49
  assert t0.probe_a # answer recorded
tests/viz/test_gif.py CHANGED
@@ -9,7 +9,7 @@ from proteus.game.viz import reconstruct, write_gif
9
  def _trace():
10
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
11
  return SessionRunner(
12
- "predator_evade", VanillaAgent(prov), difficulty=Difficulty.EASY,
13
  seed=42, play_turns=4, use_probe=False,
14
  ).run()
15
 
 
9
  def _trace():
10
  prov = FakeProvider(["ACTION: up"] * 10, model_name="demo")
11
  return SessionRunner(
12
+ "template", VanillaAgent(prov), difficulty=Difficulty.EASY,
13
  seed=42, play_turns=4, use_probe=False,
14
  ).run()
15
 
tests/viz/test_png.py CHANGED
@@ -11,7 +11,7 @@ from proteus.game.viz import reconstruct, write_pngs
11
  def _steps():
12
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
13
  trace = SessionRunner(
14
- "predator_evade", agent, seed=42, play_turns=4, use_probe=False,
15
  ).run()
16
  return reconstruct(trace)
17
 
 
11
  def _steps():
12
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
13
  trace = SessionRunner(
14
+ "template", agent, seed=42, play_turns=4, use_probe=False,
15
  ).run()
16
  return reconstruct(trace)
17
 
tests/viz/test_reconstruct.py CHANGED
@@ -12,14 +12,14 @@ from proteus.game.viz import FrameStep, TraceReconstructionError, reconstruct
12
  def _make_trace(seed=42, turns=5, action="ACTION: up"):
13
  agent = VanillaAgent(FakeProvider([action]))
14
  return SessionRunner(
15
- "predator_evade", agent, seed=seed, play_turns=turns, use_probe=False,
16
  ).run()
17
 
18
 
19
  def test_reconstruct_frame_count_matches_cut_plus_play():
20
  trace = _make_trace()
21
  steps = reconstruct(trace)
22
- cut_len = get_scenario("predator_evade")().cut_length(Difficulty.EASY)
23
  # initial Cut frame + cut_len Cut steps + one frame per played turn.
24
  assert len(steps) == (cut_len + 1) + len(trace.turns)
25
  assert all(isinstance(s, FrameStep) for s in steps)
 
12
  def _make_trace(seed=42, turns=5, action="ACTION: up"):
13
  agent = VanillaAgent(FakeProvider([action]))
14
  return SessionRunner(
15
+ "template", agent, seed=seed, play_turns=turns, use_probe=False,
16
  ).run()
17
 
18
 
19
  def test_reconstruct_frame_count_matches_cut_plus_play():
20
  trace = _make_trace()
21
  steps = reconstruct(trace)
22
+ cut_len = get_scenario("template")().cut_length(Difficulty.EASY)
23
  # initial Cut frame + cut_len Cut steps + one frame per played turn.
24
  assert len(steps) == (cut_len + 1) + len(trace.turns)
25
  assert all(isinstance(s, FrameStep) for s in steps)
tests/viz/test_terminal.py CHANGED
@@ -7,7 +7,7 @@ from proteus.game.viz import reconstruct, render_terminal
7
  def _steps():
8
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
9
  trace = SessionRunner(
10
- "predator_evade", agent, seed=42, play_turns=4, use_probe=False,
11
  ).run()
12
  return reconstruct(trace), trace
13
 
 
7
  def _steps():
8
  agent = VanillaAgent(FakeProvider(["ACTION: up"]))
9
  trace = SessionRunner(
10
+ "template", agent, seed=42, play_turns=4, use_probe=False,
11
  ).run()
12
  return reconstruct(trace), trace
13
 
tests/web/test_memory_modes.py CHANGED
@@ -12,14 +12,14 @@ def _reg():
12
 
13
  def test_default_play_quota_is_100():
14
  _, payload, _ = server.handle_request(
15
- "POST", "/session", {"scenario": "predator_evade"}, _reg())
16
  assert payload["state"]["play_turns"] == 100
17
 
18
 
19
  def test_persona_memory_attached_to_human_session():
20
  _, payload, _ = server.handle_request(
21
  "POST", "/session",
22
- {"scenario": "predator_evade", "seed": 42, "play_turns": 3,
23
  "memory": "persona:risk_averse"}, _reg())
24
  assert payload["memory"]["attached"] is True
25
  assert payload["memory"]["persona"] == "risk_averse"
@@ -41,14 +41,14 @@ def test_none_forces_no_memory_even_when_scenario_has_a_default():
41
  def test_unknown_persona_is_400():
42
  status, payload, _ = server.handle_request(
43
  "POST", "/session",
44
- {"scenario": "predator_evade", "memory": "persona:bogus"}, _reg())
45
  assert status == 400 and "error" in payload
46
 
47
 
48
  def test_generate_memory_via_fake_model_on_spectate(tmp_path):
49
  status, payload, _ = server.handle_request(
50
  "POST", "/spectate",
51
- {"scenario": "predator_evade", "seed": 42, "play_turns": 2,
52
  "model": "fake:demo", "memory": "generate",
53
  "memory_root": str(tmp_path)}, _reg())
54
  assert status == 200
@@ -57,7 +57,7 @@ def test_generate_memory_via_fake_model_on_spectate(tmp_path):
57
  # generate saved a checkpoint under the (tmp) root -> latest can find it.
58
  status, latest, _ = server.handle_request(
59
  "POST", "/spectate",
60
- {"scenario": "predator_evade", "seed": 42, "play_turns": 2,
61
  "model": "fake:demo", "memory": "latest",
62
  "memory_root": str(tmp_path)}, _reg())
63
  assert status == 200 and latest["memory"]["attached"] is True
@@ -66,7 +66,7 @@ def test_generate_memory_via_fake_model_on_spectate(tmp_path):
66
  def test_generate_without_model_on_human_is_400():
67
  status, payload, _ = server.handle_request(
68
  "POST", "/session",
69
- {"scenario": "predator_evade", "memory": "generate"}, _reg())
70
  assert status == 400 and "needs a model" in payload["error"]
71
 
72
 
@@ -75,12 +75,12 @@ def test_response_carries_rendered_memory_block_for_display():
75
  # the rendered block the model is given.
76
  _, payload, _ = server.handle_request(
77
  "POST", "/session",
78
- {"scenario": "predator_evade", "seed": 42, "play_turns": 3,
79
  "memory": "persona:risk_averse"}, _reg())
80
  assert payload["memory"]["block"] and "MEMORY" in payload["memory"]["block"]
81
  # 'none' carries no block.
82
  _, none, _ = server.handle_request(
83
- "POST", "/session", {"scenario": "predator_evade", "memory": "none"}, _reg())
84
  assert none["memory"]["block"] is None
85
 
86
 
 
12
 
13
  def test_default_play_quota_is_100():
14
  _, payload, _ = server.handle_request(
15
+ "POST", "/session", {"scenario": "template"}, _reg())
16
  assert payload["state"]["play_turns"] == 100
17
 
18
 
19
  def test_persona_memory_attached_to_human_session():
20
  _, payload, _ = server.handle_request(
21
  "POST", "/session",
22
+ {"scenario": "template", "seed": 42, "play_turns": 3,
23
  "memory": "persona:risk_averse"}, _reg())
24
  assert payload["memory"]["attached"] is True
25
  assert payload["memory"]["persona"] == "risk_averse"
 
41
  def test_unknown_persona_is_400():
42
  status, payload, _ = server.handle_request(
43
  "POST", "/session",
44
+ {"scenario": "template", "memory": "persona:bogus"}, _reg())
45
  assert status == 400 and "error" in payload
46
 
47
 
48
  def test_generate_memory_via_fake_model_on_spectate(tmp_path):
49
  status, payload, _ = server.handle_request(
50
  "POST", "/spectate",
51
+ {"scenario": "template", "seed": 42, "play_turns": 2,
52
  "model": "fake:demo", "memory": "generate",
53
  "memory_root": str(tmp_path)}, _reg())
54
  assert status == 200
 
57
  # generate saved a checkpoint under the (tmp) root -> latest can find it.
58
  status, latest, _ = server.handle_request(
59
  "POST", "/spectate",
60
+ {"scenario": "template", "seed": 42, "play_turns": 2,
61
  "model": "fake:demo", "memory": "latest",
62
  "memory_root": str(tmp_path)}, _reg())
63
  assert status == 200 and latest["memory"]["attached"] is True
 
66
  def test_generate_without_model_on_human_is_400():
67
  status, payload, _ = server.handle_request(
68
  "POST", "/session",
69
+ {"scenario": "template", "memory": "generate"}, _reg())
70
  assert status == 400 and "needs a model" in payload["error"]
71
 
72
 
 
75
  # the rendered block the model is given.
76
  _, payload, _ = server.handle_request(
77
  "POST", "/session",
78
+ {"scenario": "template", "seed": 42, "play_turns": 3,
79
  "memory": "persona:risk_averse"}, _reg())
80
  assert payload["memory"]["block"] and "MEMORY" in payload["memory"]["block"]
81
  # 'none' carries no block.
82
  _, none, _ = server.handle_request(
83
+ "POST", "/session", {"scenario": "template", "memory": "none"}, _reg())
84
  assert none["memory"]["block"] is None
85
 
86
 
tests/web/test_server.py CHANGED
@@ -15,7 +15,7 @@ def test_config_lists_scenarios_difficulties_and_color_map():
15
  reg = _registry()
16
  status, payload, ctype = server.handle_request("GET", "/config", None, reg)
17
  assert status == 200 and ctype == "application/json"
18
- assert "predator_evade" in payload["scenarios"]
19
  assert payload["difficulties"] == ["easy", "medium", "hard", "expert"]
20
  # color_map keys are stringified ints -> hex.
21
  assert payload["color_map"]["0"].startswith("#")
@@ -30,7 +30,7 @@ def test_root_serves_html_bytes():
30
 
31
  def test_create_session_returns_id_and_fair_state():
32
  reg = _registry()
33
- body = {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
34
  "play_turns": 5, "probe": False}
35
  status, payload, _ = server.handle_request("POST", "/session", body, reg)
36
  assert status == 200
@@ -48,7 +48,7 @@ def test_act_advances_and_unknown_action_is_400():
48
  reg = _registry()
49
  _, created, _ = server.handle_request(
50
  "POST", "/session",
51
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
52
  "play_turns": 5, "probe": False}, reg)
53
  sid = created["session_id"]
54
 
@@ -79,7 +79,7 @@ def test_non_numeric_seed_is_400_not_500():
79
  # A malformed seed must be a structured 400, never an unhandled 500.
80
  status, payload, _ = server.handle_request(
81
  "POST", "/session",
82
- {"scenario": "predator_evade", "difficulty": "easy", "seed": "abc",
83
  "play_turns": 5, "probe": False}, _registry())
84
  assert status == 400 and "error" in payload
85
 
@@ -88,7 +88,7 @@ def test_query_string_is_ignored_in_routing():
88
  # /config with a query string must still route to config, not fall to 404.
89
  status, payload, ctype = server.handle_request(
90
  "GET", "/config?cachebust=1", None, _registry())
91
- assert status == 200 and "predator_evade" in payload["scenarios"]
92
 
93
 
94
  def test_get_poll_state_is_fair_mid_game():
@@ -96,7 +96,7 @@ def test_get_poll_state_is_fair_mid_game():
96
  reg = _registry()
97
  _, created, _ = server.handle_request(
98
  "POST", "/session",
99
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
100
  "play_turns": 5, "probe": False}, reg)
101
  sid = created["session_id"]
102
  server.handle_request("POST", f"/session/{sid}/act", {"action": "up"}, reg)
@@ -113,7 +113,7 @@ def test_finish_appends_trace_and_returns_metrics(tmp_path):
113
  reg = _registry()
114
  _, created, _ = server.handle_request(
115
  "POST", "/session",
116
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
117
  "play_turns": 3, "probe": False}, reg)
118
  sid = created["session_id"]
119
  # Exhaust the budget.
 
15
  reg = _registry()
16
  status, payload, ctype = server.handle_request("GET", "/config", None, reg)
17
  assert status == 200 and ctype == "application/json"
18
+ assert "template" in payload["scenarios"]
19
  assert payload["difficulties"] == ["easy", "medium", "hard", "expert"]
20
  # color_map keys are stringified ints -> hex.
21
  assert payload["color_map"]["0"].startswith("#")
 
30
 
31
  def test_create_session_returns_id_and_fair_state():
32
  reg = _registry()
33
+ body = {"scenario": "template", "difficulty": "easy", "seed": 42,
34
  "play_turns": 5, "probe": False}
35
  status, payload, _ = server.handle_request("POST", "/session", body, reg)
36
  assert status == 200
 
48
  reg = _registry()
49
  _, created, _ = server.handle_request(
50
  "POST", "/session",
51
+ {"scenario": "template", "difficulty": "easy", "seed": 42,
52
  "play_turns": 5, "probe": False}, reg)
53
  sid = created["session_id"]
54
 
 
79
  # A malformed seed must be a structured 400, never an unhandled 500.
80
  status, payload, _ = server.handle_request(
81
  "POST", "/session",
82
+ {"scenario": "template", "difficulty": "easy", "seed": "abc",
83
  "play_turns": 5, "probe": False}, _registry())
84
  assert status == 400 and "error" in payload
85
 
 
88
  # /config with a query string must still route to config, not fall to 404.
89
  status, payload, ctype = server.handle_request(
90
  "GET", "/config?cachebust=1", None, _registry())
91
+ assert status == 200 and "template" in payload["scenarios"]
92
 
93
 
94
  def test_get_poll_state_is_fair_mid_game():
 
96
  reg = _registry()
97
  _, created, _ = server.handle_request(
98
  "POST", "/session",
99
+ {"scenario": "template", "difficulty": "easy", "seed": 42,
100
  "play_turns": 5, "probe": False}, reg)
101
  sid = created["session_id"]
102
  server.handle_request("POST", f"/session/{sid}/act", {"action": "up"}, reg)
 
113
  reg = _registry()
114
  _, created, _ = server.handle_request(
115
  "POST", "/session",
116
+ {"scenario": "template", "difficulty": "easy", "seed": 42,
117
  "play_turns": 3, "probe": False}, reg)
118
  sid = created["session_id"]
119
  # Exhaust the budget.
tests/web/test_spectate_routes.py CHANGED
@@ -19,7 +19,7 @@ def test_spectate_create_next_finish(tmp_path):
19
  reg = _reg()
20
  status, created, _ = server.handle_request(
21
  "POST", "/spectate",
22
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
23
  "play_turns": 3, "model": "fake:demo", "probe": False}, reg)
24
  assert status == 200
25
  sid = created["session_id"]
@@ -46,7 +46,7 @@ def test_spectate_create_next_finish(tmp_path):
46
  def test_unknown_model_is_400():
47
  status, payload, _ = server.handle_request(
48
  "POST", "/spectate",
49
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 1,
50
  "play_turns": 3, "model": "nope:x", "probe": False}, _reg())
51
  assert status == 400 and "error" in payload
52
 
@@ -55,7 +55,7 @@ def test_next_after_done_is_409():
55
  reg = _reg()
56
  _, created, _ = server.handle_request(
57
  "POST", "/spectate",
58
- {"scenario": "predator_evade", "difficulty": "easy", "seed": 42,
59
  "play_turns": 1, "model": "fake:demo", "probe": False}, reg)
60
  sid = created["session_id"]
61
  server.handle_request("POST", f"/spectate/{sid}/next", None, reg) # exhausts budget
 
19
  reg = _reg()
20
  status, created, _ = server.handle_request(
21
  "POST", "/spectate",
22
+ {"scenario": "template", "difficulty": "easy", "seed": 42,
23
  "play_turns": 3, "model": "fake:demo", "probe": False}, reg)
24
  assert status == 200
25
  sid = created["session_id"]
 
46
  def test_unknown_model_is_400():
47
  status, payload, _ = server.handle_request(
48
  "POST", "/spectate",
49
+ {"scenario": "template", "difficulty": "easy", "seed": 1,
50
  "play_turns": 3, "model": "nope:x", "probe": False}, _reg())
51
  assert status == 400 and "error" in payload
52
 
 
55
  reg = _reg()
56
  _, created, _ = server.handle_request(
57
  "POST", "/spectate",
58
+ {"scenario": "template", "difficulty": "easy", "seed": 42,
59
  "play_turns": 1, "model": "fake:demo", "probe": False}, reg)
60
  sid = created["session_id"]
61
  server.handle_request("POST", f"/spectate/{sid}/next", None, reg) # exhausts budget