Spaces:
Runtime error
Runtime error
Commit ·
aa9d950
1
Parent(s): 28cd053
feat(cp8): hidden persona weights + R_w reference policy + pressure
Browse files- proteus/runtime/persona.py +151 -0
- tests/runtime/test_persona.py +32 -0
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
|