irregular6612 commited on
Commit
7a0c167
·
1 Parent(s): efc481d

feat(web): InteractiveSession — threadless stepwise play driver

Browse files
proteus/runtime/interactive.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InteractiveSession — a threadless, stepwise driver for human web play.
2
+
3
+ Holds the live scenario+game and advances exactly one turn per HTTP request.
4
+ Built on the same ``_session_core`` helpers as SessionRunner, so the trace it
5
+ emits at the end is identical to a SessionRunner(HumanAgent) trace for the same
6
+ actions (pinned by tests/runtime/test_interactive_equivalence.py).
7
+
8
+ Fairness: ``state()`` exposes only the grid + available actions while playing;
9
+ the per-turn answer keys (optimal/habit) and rewards are disclosed in ``review``
10
+ only once the game is over.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from proteus.grid.difficulty import Difficulty
15
+ from proteus.runtime import _session_core as core
16
+ from proteus.runtime.trace import SessionTrace, TurnTrace
17
+
18
+
19
+ class InteractiveSession:
20
+ def __init__(
21
+ self,
22
+ scenario_name: str,
23
+ *,
24
+ difficulty: Difficulty = Difficulty.EASY,
25
+ seed: int | None = None,
26
+ play_turns: int = 15,
27
+ use_probe: bool = False,
28
+ motive_category: str = "survival",
29
+ ) -> None:
30
+ self._scenario_name = scenario_name
31
+ self._difficulty = difficulty
32
+ self._seed = seed
33
+ self._play_turns = play_turns
34
+ self._use_probe = use_probe
35
+ self._motive_category = motive_category
36
+
37
+ built = core.build_session(scenario_name, seed, difficulty, play_turns)
38
+ self._scenario = built.scenario
39
+ self._game = built.game
40
+ self._cut_frames = built.cut_frames
41
+ self._cut_grids = built.cut_grids
42
+ self._turns: list[TurnTrace] = []
43
+ self._trace: SessionTrace | None = None
44
+
45
+ # ------------------------------------------------------------------ #
46
+ def _is_done(self) -> bool:
47
+ return (
48
+ self._game.eliminated
49
+ or self._game.survived
50
+ or len(self._turns) >= self._play_turns
51
+ )
52
+
53
+ def state(self) -> dict:
54
+ """The JSON-ready view (live = fair, no answer keys; review only when done).
55
+
56
+ When the game is over this finalizes and memoizes the trace (idempotent)
57
+ so it can populate ``review``; while playing it exposes only grid +
58
+ actions, never reward/optimal/habit.
59
+ """
60
+ done = self._is_done()
61
+ played = len(self._turns)
62
+ phase = "done" if done else ("cut_intro" if played == 0 else "play")
63
+ st: dict = {
64
+ "phase": phase,
65
+ "turn_idx": played,
66
+ "play_turns": self._play_turns,
67
+ "grid": core.grid_to_list(self._game.current_grid()),
68
+ "legend": {str(k): v for k, v in self._scenario.legend().items()},
69
+ "actions": list(core._ACTIONS),
70
+ "outcome": None,
71
+ "cut_frames": self._cut_grids if played == 0 else None,
72
+ "review": None,
73
+ }
74
+ if done:
75
+ trace = self.finish()
76
+ st["outcome"] = trace.outcome
77
+ st["review"] = {
78
+ "outcome": trace.outcome,
79
+ "metrics": trace.metrics,
80
+ "turns": [
81
+ {
82
+ "turn_idx": t.turn_idx,
83
+ "action": t.action,
84
+ "motive_action": t.motive_action,
85
+ "habit_action": t.habit_action,
86
+ "reward": t.reward,
87
+ "is_diagnostic": t.is_diagnostic,
88
+ "was_congruent": t.was_congruent,
89
+ }
90
+ for t in trace.turns
91
+ ],
92
+ }
93
+ return st
94
+
95
+ def step(self, action: str, probe_answer: str = "") -> dict:
96
+ if self._is_done():
97
+ raise core.SessionFinishedError("session already finished")
98
+ if action not in core._ACTIONS:
99
+ raise ValueError(
100
+ f"invalid action {action!r}; choose one of {core._ACTIONS}"
101
+ )
102
+ turn_idx = len(self._turns) + 1
103
+ observation = core.build_observation(
104
+ self._scenario, self._game, self._cut_frames, turn_idx,
105
+ )
106
+ probe_fields: dict[str, object] = {}
107
+ if self._use_probe:
108
+ probe_fields = dict(
109
+ probe_q=core._PROBE_QUESTION,
110
+ probe_a=probe_answer,
111
+ probe_reasoning="",
112
+ probe_raw_text=probe_answer,
113
+ )
114
+ self._turns.append(core.make_turn_trace(
115
+ self._scenario, self._game,
116
+ turn_idx=turn_idx, observation=observation,
117
+ action=action, raw_text=action, **probe_fields,
118
+ ))
119
+ return self.state()
120
+
121
+ def finish(self) -> SessionTrace:
122
+ if self._trace is not None:
123
+ return self._trace
124
+ self._trace = core.finalize(
125
+ self._scenario_name, self._scenario, self._game,
126
+ seed=self._seed, difficulty=self._difficulty,
127
+ play_turns=self._play_turns, turns=self._turns,
128
+ cut_frames=self._cut_frames, motive_category=self._motive_category,
129
+ model="human",
130
+ )
131
+ return self._trace
tests/runtime/test_interactive_session.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for InteractiveSession (HTTP-driven, stepwise play)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ import proteus.grid # noqa: F401
7
+ from proteus.grid.difficulty import Difficulty
8
+ from proteus.runtime._session_core import SessionFinishedError
9
+ from proteus.runtime.interactive import InteractiveSession
10
+
11
+
12
+ def _new(play_turns=10):
13
+ return InteractiveSession(
14
+ "predator_evade", difficulty=Difficulty.EASY, seed=42,
15
+ play_turns=play_turns, use_probe=False,
16
+ )
17
+
18
+
19
+ def test_initial_state_is_cut_intro_with_int_grid_and_no_answer_keys():
20
+ s = _new()
21
+ st = s.state()
22
+ assert st["phase"] == "cut_intro"
23
+ assert st["turn_idx"] == 0
24
+ assert st["outcome"] is None
25
+ assert st["review"] is None
26
+ # grid is a JSON-ready int matrix.
27
+ assert isinstance(st["grid"], list) and isinstance(st["grid"][0][0], int)
28
+ # cut animation frames are present on the first state only.
29
+ assert st["cut_frames"] is not None and len(st["cut_frames"]) >= 1
30
+ # fairness: live state leaks no reward / optimal / habit.
31
+ flat = str(st)
32
+ assert "reward" not in st and "motive_action" not in st and "habit" not in flat
33
+
34
+
35
+ def test_step_advances_turn_and_drops_cut_frames():
36
+ s = _new()
37
+ st = s.step("up")
38
+ assert st["phase"] == "play"
39
+ assert st["turn_idx"] == 1
40
+ assert st["cut_frames"] is None
41
+
42
+
43
+ def test_invalid_action_rejected():
44
+ s = _new()
45
+ with pytest.raises(ValueError):
46
+ s.step("northwest")
47
+
48
+
49
+ def test_play_to_budget_then_review_and_finish():
50
+ s = _new(play_turns=3)
51
+ for _ in range(3):
52
+ if s.state()["outcome"] is not None:
53
+ break
54
+ s.step("up")
55
+ st = s.state()
56
+ assert st["phase"] == "done"
57
+ assert st["outcome"] in ("survived", "eliminated")
58
+ # review is disclosed only when done.
59
+ assert st["review"] is not None
60
+ assert "metrics" in st["review"] and "turns" in st["review"]
61
+ trace = s.finish()
62
+ assert trace.model == "human"
63
+ assert trace.scenario == "predator_evade"
64
+ # finish() is memoized: repeated calls return the same trace object.
65
+ assert s.finish() is trace
66
+
67
+
68
+ def test_step_after_done_raises():
69
+ s = _new(play_turns=1)
70
+ s.step("up")
71
+ # play_turns=1 exhausts the budget -> done.
72
+ with pytest.raises(SessionFinishedError):
73
+ s.step("up")