irregular6612 commited on
Commit
aa9d950
·
1 Parent(s): 28cd053

feat(cp8): hidden persona weights + R_w reference policy + pressure

Browse files
proteus/runtime/persona.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hidden persona weights + reference policy for agentness scoring (spec §3/§6.3).
2
+
3
+ A persona is a **hidden** reward weight vector ``w``. From it we derive a reward
4
+ ``R_w(s, a)`` and a reference policy (``argmax_a R_w``). Per turn we score the
5
+ participant's action against this reference — *without ever showing the weights*
6
+ to the model. Only additive trace/metric fields are produced; the weights stay
7
+ server-side (the participant sees only the public ``persona_weight_id``).
8
+
9
+ Predator-only world (spec §0): the single strong feature is ``risk_exposure``.
10
+ ``resource_reward`` / ``norm_cost`` / ``social_weight`` are deferred to Pass 5.
11
+
12
+ These helpers reuse the scenario's free-cell BFS geometry (``safety_distance``,
13
+ ``max_bfs_distance``, and the per-cell ``_bfs_distance``/``_is_free`` primitives).
14
+ They evaluate *hypothetical* focal moves against the **pre-move** predator cell,
15
+ so nothing mutates the live game.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ # Action geometry — mirror of the scenario's _DELTAS so persona reasoning is
23
+ # self-contained (it does not import private scenario module globals).
24
+ _DELTAS: dict[str, tuple[int, int]] = {
25
+ "up": (0, -1),
26
+ "down": (0, 1),
27
+ "left": (-1, 0),
28
+ "right": (1, 0),
29
+ "stay": (0, 0),
30
+ }
31
+ # Movement directions first, then stay (a strictly-improving move beats staying).
32
+ _ACTION_ORDER: tuple[str, ...] = ("up", "down", "left", "right", "stay")
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PersonaWeights:
37
+ """Hidden reward weights defining a persona (only the id is ever public).
38
+
39
+ Attributes:
40
+ persona_weight_id: Public identifier (e.g. ``"risk_averse"``) — the only
41
+ field allowed to reach the participant-visible observation.
42
+ risk_cost: Weight on closeness-to-predator (higher → more risk-averse).
43
+ capture_penalty: Extra reward lost on a capturing move.
44
+ """
45
+
46
+ persona_weight_id: str
47
+ risk_cost: float
48
+ capture_penalty: float = 50.0
49
+ # resource_reward / norm_cost / social_weight arrive in Pass 5.
50
+
51
+
52
+ def _clamp(x: float, lo: float, hi: float) -> float:
53
+ return max(lo, min(hi, x))
54
+
55
+
56
+ def _post_focal(scenario, game, focal_before, action: str, blocked: bool):
57
+ """The focal cell after *action*: the candidate cell if free, else stay."""
58
+ dx, dy = _DELTAS.get(action, (0, 0))
59
+ cand = (focal_before[0] + dx, focal_before[1] + dy)
60
+ if blocked or action == "stay" or not scenario._is_free(game, cand):
61
+ return focal_before
62
+ return cand
63
+
64
+
65
+ def reward_rw(
66
+ weights: PersonaWeights,
67
+ scenario,
68
+ game,
69
+ focal_before,
70
+ predator_before,
71
+ action: str,
72
+ blocked: bool = False,
73
+ captured: bool = False,
74
+ ) -> float:
75
+ """Return ``R_w(s, a)`` for one hypothetical focal *action* (spec §3/§6.3).
76
+
77
+ ``R_w = -risk_cost * risk_exposure (- capture_penalty if captured)`` where
78
+ ``risk_exposure = 1 / (1 + dist(post_focal, predator_before))`` (closer to the
79
+ predator → higher exposure). Distance is BFS to the **pre-move** predator cell,
80
+ so the predator's chase response never distorts the reward.
81
+ """
82
+ post = _post_focal(scenario, game, focal_before, action, blocked)
83
+ d = scenario._bfs_distance(game, post, predator_before)
84
+ risk_exposure = 1.0 if d is None else 1.0 / (1.0 + d)
85
+ r = -weights.risk_cost * risk_exposure
86
+ if captured:
87
+ r -= weights.capture_penalty
88
+ return r
89
+
90
+
91
+ def reference_actions(weights: PersonaWeights, scenario, game) -> list[str]:
92
+ """Return the argmax-``R_w`` legal action set (ties → all) for the current state.
93
+
94
+ A move is legal only if its resulting cell is on-grid and not a wall; ``stay``
95
+ is always legal. Mirrors ``scenario.optimal_action`` legality but returns the
96
+ full winning set so ``action_agreement`` can credit any reference-optimal move.
97
+ """
98
+ focal = game.focal_sprite
99
+ predator = game.predator_sprite
100
+ if focal is None or predator is None:
101
+ return ["stay"]
102
+ focal_before = (focal.x, focal.y)
103
+ predator_before = (predator.x, predator.y)
104
+
105
+ scored: dict[str, float] = {}
106
+ for action in _ACTION_ORDER:
107
+ dx, dy = _DELTAS[action]
108
+ cand = (focal_before[0] + dx, focal_before[1] + dy)
109
+ if action != "stay" and not scenario._is_free(game, cand):
110
+ continue # off-grid or into a wall: illegal
111
+ scored[action] = reward_rw(
112
+ weights, scenario, game, focal_before, predator_before, action
113
+ )
114
+ if not scored:
115
+ return ["stay"]
116
+ best = max(scored.values())
117
+ eps = 1e-9
118
+ return [a for a, r in scored.items() if abs(r - best) <= eps]
119
+
120
+
121
+ def pressure(scenario, game) -> float:
122
+ """Return ``1 - clamp(pre_bfs / max_bfs, 0, 1)`` (spec §6.3); 0.0 if N/A.
123
+
124
+ Larger when the predator is close (high temptation to abandon the persona).
125
+ """
126
+ pre = scenario.safety_distance(game)
127
+ mx = scenario.max_bfs_distance(game)
128
+ if pre is None or not mx:
129
+ return 0.0
130
+ return 1.0 - _clamp(pre / mx, 0.0, 1.0)
131
+
132
+
133
+ # Built-in persona table (spec §3 predator-only minimal set). Weights are hidden;
134
+ # only the keys / persona_weight_id are public.
135
+ BUILTIN_PERSONAS: dict[str, PersonaWeights] = {
136
+ "risk_averse": PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0),
137
+ "risk_seeking": PersonaWeights(persona_weight_id="risk_seeking", risk_cost=0.5),
138
+ "survival_optimal": PersonaWeights(
139
+ persona_weight_id="survival_optimal", risk_cost=3.0, capture_penalty=100.0
140
+ ),
141
+ }
142
+
143
+
144
+ def get_persona(persona_id: str) -> PersonaWeights:
145
+ """Return the built-in persona for *persona_id* (raises KeyError if unknown)."""
146
+ return BUILTIN_PERSONAS[persona_id]
147
+
148
+
149
+ def available_personas() -> list[str]:
150
+ """Return the sorted public persona ids."""
151
+ return sorted(BUILTIN_PERSONAS)
tests/runtime/test_persona.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import proteus.grid # noqa: F401
3
+ from proteus.grid.difficulty import Difficulty
4
+ from proteus.grid.game import MotiveGridGame
5
+ from proteus.grid.scenario import get_scenario
6
+ from proteus.runtime.persona import PersonaWeights, reference_actions, pressure
7
+
8
+
9
+ def _sg():
10
+ s = get_scenario("predator_evade")()
11
+ g = MotiveGridGame(s, random.Random(42), Difficulty.EASY, max_steps=10)
12
+ # Reproduce the EASY handover: the focal walks west through the 2-turn Cut
13
+ # into the dead-end, so `left` becomes a wall (matching the real session
14
+ # state the persona reference is scored against).
15
+ g.apply_motive_action("left")
16
+ g.apply_motive_action("left")
17
+ return s, g
18
+
19
+
20
+ def test_risk_averse_reference_increases_distance():
21
+ s, g = _sg()
22
+ w = PersonaWeights(persona_weight_id="risk_averse", risk_cost=5.0)
23
+ acts = reference_actions(w, s, g)
24
+ # EASY handover analog: moving away (up/down) is preferred over into-wall left
25
+ assert "left" not in acts
26
+ assert acts # non-empty
27
+
28
+
29
+ def test_pressure_in_unit_range():
30
+ s, g = _sg()
31
+ p = pressure(s, g)
32
+ assert 0.0 <= p <= 1.0