AgentnessBench / tests /runtime /test_predator_chase_memory.py
irregular6612's picture
feat(director): predator_chase pack scatters (all survivors flee) after the first kill
58f91e9
Raw
History Blame Contribute Delete
7.18 kB
# tests/runtime/test_predator_chase_memory.py
"""Redesigned predator_chase memory: longer pre-kill preroll + wider, more
varied pre-kill roaming.
Baseline (pre-redesign, seed 7):
- first "eaten" event at turn_idx 71
- max distractor Manhattan displacement from start during the pre-kill phase
was only 12 cells (uniform random walk drifts back near the start).
These tests pin measurable improvements:
- the first kill must land meaningfully later (preroll floor),
- distractors must travel noticeably farther from their starts before the
first kill (wider action radius).
Hard invariants (a0 survivor, 3 genuine eaten, determinism) are asserted in
test_director_predator_chase.py and reinforced here as guardrails.
"""
from proteus.game.runtime.multiagent_director import author_predator_chase
_STARTS = [(10, 30), (14, 38), (18, 24), (12, 46)]
_PRED = (54, 31)
# Justified thresholds vs baseline (seed 7):
# first-eat baseline = 71 -> require >= 100 (the redesign lands it at 104,
# +33 turns / ~1.46x later; the floor sits a few turns below the achieved
# value because first-eat is non-monotonic in the grace/roam interaction).
# max pre-kill distractor displacement baseline = 12 -> require >= 20
# (the persistent-heading roam achieves 40, vs the random walk's 12).
_FIRST_EAT_FLOOR = 100
_SPREAD_FLOOR = 20
def _run():
return author_predator_chase(seed=7, agent_starts=_STARTS, predator_start=_PRED)
def _first_eat_turn(ck):
for t in ck.memory_turns:
for e in t.events:
if "eaten" in e:
return t.turn_idx
return None
def test_first_kill_happens_later():
"""First genuine 'eaten' event must occur at a clearly later turn."""
first = _first_eat_turn(_run())
assert first is not None, "expected at least one 'eaten' event"
assert first >= _FIRST_EAT_FLOOR, (
f"first eat at turn {first}, want >= {_FIRST_EAT_FLOOR}"
)
def test_distractors_roam_wider_before_first_kill():
"""Max distractor displacement from start during the pre-kill phase must
clearly exceed the uniform-random-walk baseline."""
ck = _run()
first = _first_eat_turn(ck)
assert first is not None
starts = {f"a{i}": p for i, p in enumerate(_STARTS) if i != 0}
max_disp = 0
for t in ck.memory_turns:
if t.turn_idx >= first:
break
for a in t.agents:
if a.id in starts:
s = starts[a.id]
d = abs(a.pos[0] - s[0]) + abs(a.pos[1] - s[1])
max_disp = max(max_disp, d)
assert max_disp >= _SPREAD_FLOOR, (
f"max pre-kill distractor displacement {max_disp}, want >= {_SPREAD_FLOOR}"
)
def _pred_center(turn):
for a in turn.agents:
if a.kind == "predator":
return (a.pos[0] + a.size // 2, a.pos[1] + a.size // 2)
return None
def _agent_center(a):
return (a.pos[0] + a.size // 2, a.pos[1] + a.size // 2)
# Window (in turns) right after the first kill over which the pack must visibly
# move AWAY from the predator. Kept short so edge-cornering of one agent cannot
# make the assertion vacuous or fragile.
_SCATTER_WINDOW = 6
# One-step slack: agent-agent collision (the no-overlap invariant) can block a
# survivor's optimal flee move for a single transition, costing at most ~2 cells
# of Manhattan distance before it recovers. We allow that much and no more.
# Measured separation between the two regimes for seed 7:
# roaming (pre-change): nearest survivor is steadily approached, min distance
# drops by 6 over the window -> exceeds the slack -> FAILS.
# fleeing (post-change): the pack holds the predator off, min distance drops
# by at most 2 (one blocked step) then holds -> within slack -> PASSES.
_BLOCK_SLACK = 2
def _min_survivor_dist(turn, ids):
"""Minimum Manhattan distance from any alive distractor in *ids* to the
predator on *turn*, or None if no predator / none of *ids* alive."""
pred = _pred_center(turn)
if pred is None:
return None
dists = [
abs(_agent_center(a)[0] - pred[0]) + abs(_agent_center(a)[1] - pred[1])
for a in turn.agents
if a.kind == "agent" and a.id in ids and a.alive
]
return min(dists) if dists else None
def test_pack_scatters_after_first_kill():
"""After the FIRST 'eaten' event the whole pack scatters and FLEES, so the
predator cannot keep closing the gap: the MINIMUM surviving-distractor
distance to the predator never drops more than one blocked step below its
value at the kill turn across the short window right after the kill.
Pre-change the survivors keep roaming (predator-unaware), so the predator
steadily corners the nearest one and the minimum distance collapses well
past the slack -> this assertion FAILS. With the scatter change every
survivor flees, the pack holds the predator off, and the minimum distance is
maintained -> it PASSES."""
ck = _run()
first = _first_eat_turn(ck)
assert first is not None
by_turn = {t.turn_idx: t for t in ck.memory_turns}
# The kill-turn frame is recorded PRE-move, so the agent being eaten is still
# alive in it. Restrict to the genuine SURVIVORS (alive non-chosen distractors
# that are NOT named in any 'eaten' event on the kill turn) so the baseline
# reflects the pack that must flee, not the agent about to die.
kill_turn = by_turn[first]
eaten_now = {e.split()[0] for e in kill_turn.events if "eaten" in e}
survivor_ids = {
a.id for a in kill_turn.agents
if a.kind == "agent" and a.id != "a0" and a.alive and a.id not in eaten_now
}
assert survivor_ids, "expected surviving distractors at the first-kill turn"
kill_min = _min_survivor_dist(kill_turn, survivor_ids)
assert kill_min is not None
floor = kill_min - _BLOCK_SLACK
checked = 0
for off in range(1, _SCATTER_WINDOW + 1):
t = by_turn.get(first + off)
if t is None:
break
m = _min_survivor_dist(t, survivor_ids)
if m is None: # final settled frame (predator removed) or no survivors
break
checked += 1
assert m >= floor, (
f"nearest survivor closed on the predator after the kill: "
f"min distance {m} at turn {t.turn_idx} < floor {floor} "
f"(kill-turn min {kill_min} - slack {_BLOCK_SLACK}); the pack did not flee"
)
assert checked >= 3, f"scatter window too short to be meaningful ({checked} frames)"
def test_guardrails_survivor_and_three_genuine_eaten():
"""a0 is the sole survivor, alive every turn, and exactly 3 distractors are
eaten by GENUINE predator contact (not the silent end safeguard)."""
ck = _run()
for t in ck.memory_turns:
chosen = next(a for a in t.agents if a.id == "a0")
assert chosen.alive
eaten = [e for t in ck.memory_turns for e in t.events if "eaten" in e]
assert len(eaten) == 3, f"expected 3 genuine eaten events, got {eaten}"
last = ck.memory_turns[-1]
alive = [a for a in last.agents if a.kind == "agent" and a.alive]
assert len(alive) == 1 and alive[0].id == "a0"