Spaces:
Runtime error
Runtime error
Commit ·
93cd78f
1
Parent(s): bd0ae14
refactor(scenario): delete predator_evade; template is the canonical scenario
Browse files- proteus/cli/parser.py +3 -3
- proteus/game/agents/vanilla.py +3 -3
- proteus/game/runtime/session.py +1 -1
- proteus/game/scenarios/__init__.py +1 -2
- proteus/game/scenarios/base.py +5 -5
- proteus/game/scenarios/predator_evade.py +0 -604
- proteus/game/scenarios/template.py +1 -1
- proteus/game/viz/reconstruct.py +7 -7
- tests/cli/test_cli.py +15 -15
- tests/cli/test_cli_gif.py +2 -2
- tests/cli/test_cli_memory.py +6 -6
- tests/cli/test_cli_persona.py +7 -7
- tests/engine/test_turn_order.py +1 -1
- tests/grid/test_difficulty_layouts.py +0 -96
- tests/grid/test_distance_helpers.py +4 -4
- tests/grid/test_footprint_bounds.py +1 -12
- tests/grid/test_predator_evade_behavior.py +0 -46
- tests/grid/test_predator_evade_registered.py +0 -8
- tests/grid/test_step_reward.py +10 -27
- tests/grid/test_template_observation.py +1 -9
- tests/runtime/test_aggregate.py +1 -1
- tests/runtime/test_human_comparability.py +2 -2
- tests/runtime/test_integration_golden.py +9 -3
- tests/runtime/test_interactive_equivalence.py +2 -2
- tests/runtime/test_interactive_session.py +2 -2
- tests/runtime/test_io.py +2 -2
- tests/runtime/test_memory.py +1 -1
- tests/runtime/test_memory_gen.py +5 -14
- tests/runtime/test_memory_persona.py +3 -3
- tests/runtime/test_persona.py +11 -14
- tests/runtime/test_rollout.py +15 -6
- tests/runtime/test_session.py +16 -40
- tests/runtime/test_session_core.py +9 -9
- tests/runtime/test_session_distance.py +2 -2
- tests/runtime/test_session_memory.py +16 -16
- tests/runtime/test_spectate.py +2 -2
- tests/runtime/test_spectate_equivalence.py +2 -2
- tests/runtime/test_trace.py +1 -1
- tests/runtime/test_trace_accounting.py +2 -2
- tests/viz/test_gif.py +1 -1
- tests/viz/test_png.py +1 -1
- tests/viz/test_reconstruct.py +2 -2
- tests/viz/test_terminal.py +1 -1
- tests/web/test_memory_modes.py +8 -8
- tests/web/test_server.py +7 -7
- 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="
|
| 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="
|
| 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="
|
| 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
|
| 19 |
-
# defers scenario generalization, so the "predator" framing is
|
| 20 |
-
# A future multi-scenario agent should make this
|
| 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. ``"
|
| 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__ = ["
|
|
|
|
| 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("
|
| 17 |
-
class
|
| 18 |
...
|
| 19 |
|
| 20 |
# Elsewhere:
|
| 21 |
from proteus.game.scenarios.base import get_scenario
|
| 22 |
-
scenario = get_scenario("
|
| 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
|
| 73 |
-
#
|
| 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 #
|
| 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,
|
| 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,
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 6 |
rc = main(["list-scenarios"])
|
| 7 |
out = capsys.readouterr().out
|
| 8 |
assert rc == 0
|
| 9 |
-
assert "
|
| 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", "
|
| 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 == "
|
| 28 |
assert traces[0].model == "cli-test"
|
| 29 |
# The run line summarizes the outcome.
|
| 30 |
-
assert "
|
| 31 |
|
| 32 |
|
| 33 |
def test_replay_prints_turns_and_outcome(tmp_path, capsys):
|
| 34 |
out = tmp_path / "r.jsonl"
|
| 35 |
main([
|
| 36 |
-
"run", "--scenario", "
|
| 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 "
|
| 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", "
|
| 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", "
|
| 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 == "
|
| 90 |
# The run summary names the scenario.
|
| 91 |
-
assert "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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 == "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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", "
|
| 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("
|
| 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("
|
| 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 #
|
| 28 |
-
pred = g.predator_sprite
|
| 29 |
before_focal = (focal.x, focal.y)
|
| 30 |
before_pred = (pred.x, pred.y)
|
| 31 |
-
# move focal up
|
| 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
|
| 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
|
| 9 |
-
s = get_scenario("
|
| 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 =
|
| 20 |
focal_before = (game.focal_sprite.x, game.focal_sprite.y)
|
| 21 |
predator_before = (game.predator_sprite.x, game.predator_sprite.y)
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
|
|
|
| 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 =
|
| 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
|
| 41 |
-
game, s =
|
| 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
|
| 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 |
-
"
|
| 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 |
-
"
|
| 18 |
).run()
|
| 19 |
|
| 20 |
llm = VanillaAgent(FakeProvider(["ACTION: up"]))
|
| 21 |
v = SessionRunner(
|
| 22 |
-
"
|
| 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 |
-
"
|
| 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 |
-
#
|
|
|
|
| 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"] ==
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 == "
|
| 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 |
-
"
|
| 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 == "
|
| 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="
|
| 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
|
| 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 |
-
"
|
| 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 == "
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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
|
| 15 |
-
assert ck.memory_turns[0].action in ("up", "down", "
|
| 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("
|
| 11 |
g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
|
| 12 |
-
#
|
| 13 |
-
#
|
| 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 |
-
#
|
| 25 |
-
assert "
|
| 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 |
-
"
|
| 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'
|
| 59 |
m = _runner("up").metrics
|
| 60 |
assert m["action_agreement"] == 100.0
|
| 61 |
assert abs(m["reward_regret"]) < 1e-9
|
| 62 |
|
| 63 |
|
| 64 |
-
def
|
| 65 |
-
# '
|
| 66 |
-
m = _runner("
|
| 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 |
-
"
|
| 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("
|
| 7 |
-
b = optimal_rollout("
|
| 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("
|
| 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
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 13 |
-
#
|
| 14 |
-
#
|
| 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 |
-
"
|
| 19 |
)
|
| 20 |
trace = runner.run()
|
| 21 |
assert isinstance(trace, SessionTrace)
|
| 22 |
-
assert trace.scenario == "
|
| 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 |
-
"
|
| 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("
|
| 60 |
play_turns=5, use_probe=False).run()
|
| 61 |
-
t2 = SessionRunner("
|
| 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 |
-
"
|
| 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 |
-
"
|
| 94 |
).run()
|
| 95 |
-
#
|
| 96 |
-
|
|
|
|
|
|
| 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
|
| 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("
|
| 14 |
-
b = core.build_session("
|
| 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("
|
| 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("
|
| 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 |
-
"
|
| 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 == "
|
| 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("
|
| 70 |
assert not (built.game.eliminated or built.game.survived)
|
| 71 |
with pytest.raises(core.SessionNotFinishedError):
|
| 72 |
core.finalize(
|
| 73 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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="
|
| 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="
|
| 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="
|
| 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 |
-
"
|
| 46 |
seed=42, play_turns=3, use_probe=False,
|
| 47 |
memory=memory, memory_ref=memory_ref,
|
| 48 |
)
|
| 49 |
|
| 50 |
|
| 51 |
-
def
|
|
|
|
|
|
|
|
|
|
| 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
|
| 57 |
assert "MEMORY" in obs1
|
| 58 |
assert "MEMFRAME-1" in obs1 and "MEMFRAME-2" in obs1
|
| 59 |
assert "you chose: up" in obs1
|
| 60 |
-
|
|
|
|
| 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
|
| 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
|
| 77 |
-
# (not only turn 1), so a stateless agent never loses it mid-episode.
|
| 78 |
-
|
| 79 |
-
|
| 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 |
-
"
|
| 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 == "
|
| 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 |
-
"
|
| 24 |
play_turns=len(RESPONSES), use_probe=False,
|
| 25 |
)
|
| 26 |
batch_trace = runner.run()
|
| 27 |
|
| 28 |
sess = SpectateSession(
|
| 29 |
-
"
|
| 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="
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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("
|
| 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 |
-
"
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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 "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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 "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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
|