Skip to content

Commit 612827f

Browse files
m2kulkarniclaude
andcommitted
PR-A4: behavior_3 trial-mode test suite (+ cleanup)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a203dc1 commit 612827f

10 files changed

Lines changed: 1531 additions & 0 deletions

tests/test_cache_freeze.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Contract: KV cache slots written while an agent is off-map (removed=1) are
2+
excluded from attention via per-agent garbage_mask.
3+
4+
The attention math is verified directly: we drive the transformer's
5+
forward_eval with a synthetic state dict, mark some slots as garbage,
6+
and assert that the attention weights at those slots are zero (after
7+
softmax).
8+
9+
We use the _probe_attention path in models.py which captures attention
10+
weights per layer in state["_attn_weights"].
11+
"""
12+
13+
import os
14+
15+
# Without this, _USE_LEGACY_EVAL defaults True and the streaming KV path
16+
# (which owns garbage_mask) is bypassed — the test would silently no-op.
17+
os.environ.setdefault("PUFFER_TRANSFORMER_LEGACY_EVAL", "0")
18+
import sys
19+
20+
import numpy as np
21+
import torch
22+
import torch.nn as nn
23+
24+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
25+
26+
27+
class _MinimalPolicy(nn.Module):
28+
"""Stub that satisfies TransformerWrapper's policy contract:
29+
encode_observations(obs, state) -> (B, hidden) and
30+
decode_actions(hidden) -> (logits, values)."""
31+
32+
def __init__(self, obs_dim, hidden, n_actions):
33+
super().__init__()
34+
self.encoder = nn.Linear(obs_dim, hidden)
35+
self.decoder_a = nn.Linear(hidden, n_actions)
36+
self.decoder_v = nn.Linear(hidden, 1)
37+
self.is_continuous = False
38+
39+
def encode_observations(self, obs, state=None):
40+
return self.encoder(obs)
41+
42+
def decode_actions(self, hidden):
43+
return self.decoder_a(hidden), self.decoder_v(hidden).squeeze(-1)
44+
45+
46+
class _StubEnv:
47+
"""Just exposes single_observation_space.shape — the only thing
48+
TransformerWrapper.__init__ reads from env."""
49+
50+
def __init__(self, obs_dim):
51+
from gymnasium import spaces
52+
53+
self.single_observation_space = spaces.Box(low=-1, high=1, shape=(obs_dim,))
54+
55+
56+
def _make_wrapper(batch_size=4, horizon=8, hidden=16, n_heads=2):
57+
from pufferlib.models import TransformerWrapper
58+
59+
env = _StubEnv(obs_dim=hidden)
60+
policy = _MinimalPolicy(obs_dim=hidden, hidden=hidden, n_actions=3)
61+
return TransformerWrapper(
62+
env=env,
63+
policy=policy,
64+
horizon=horizon,
65+
num_layers=2,
66+
num_heads=n_heads,
67+
input_size=hidden,
68+
hidden_size=hidden,
69+
)
70+
71+
72+
def test_garbage_mask_excludes_slots_from_attention():
73+
"""If garbage_mask[a, k] = True, the softmax weight at slot k for agent a
74+
must be 0 after the next forward."""
75+
torch.manual_seed(0)
76+
B, T, H = 4, 8, 16
77+
wrapper = _make_wrapper(batch_size=B, horizon=T, hidden=H, n_heads=2)
78+
wrapper.eval()
79+
80+
state = wrapper.init_eval_state(batch_size=B, device="cpu", dtype=torch.float32)
81+
state["_probe_attention"] = True
82+
83+
# Step 5 times so cache fills slots 0..4 for all agents. Agent 0 has
84+
# `removed=True` at steps 2 and 3 — slots 2, 3 should be marked garbage.
85+
for step in range(5):
86+
obs = torch.randn(B, H)
87+
removed = torch.zeros(B, dtype=torch.bool)
88+
if step in (2, 3):
89+
removed[0] = True
90+
state["removed"] = removed
91+
state["_attn_weights"] = [] # reset per step
92+
with torch.no_grad():
93+
wrapper.forward_eval(obs, state)
94+
95+
# After step 4: garbage_mask[0, 2] and [0, 3] should be True
96+
gm = state["garbage_mask"]
97+
assert gm[0, 2].item() and gm[0, 3].item(), f"garbage slots not marked for agent 0: {gm[0]}"
98+
# Other agents: nothing marked
99+
assert not gm[1:].any().item(), f"non-removed agents should have empty garbage_mask: {gm[1:]}"
100+
101+
# Now step once more (no removed) and inspect attention weights for agent 0
102+
state["removed"] = torch.zeros(B, dtype=torch.bool)
103+
state["_attn_weights"] = []
104+
obs = torch.randn(B, H)
105+
with torch.no_grad():
106+
wrapper.forward_eval(obs, state)
107+
108+
# Per-layer attention weights are (B, H, 1, horizon).
109+
# Agent 0's slots 2 and 3 must have zero weight (masked out by garbage_mask).
110+
for layer_rec in state["_attn_weights"]:
111+
w = layer_rec["weights"] # (B, H, 1, horizon)
112+
assert w[0, :, 0, 2].abs().max().item() < 1e-6, (
113+
f"layer {layer_rec['layer']}: slot 2 weight nonzero for agent 0: {w[0, :, 0, 2]}"
114+
)
115+
assert w[0, :, 0, 3].abs().max().item() < 1e-6, f"layer {layer_rec['layer']}: slot 3 weight nonzero for agent 0"
116+
# Sanity: other agents' slots 2, 3 should still get nonzero weight
117+
assert w[1, :, 0, 2].abs().max().item() > 1e-6, "agent 1 slot 2 should NOT be masked"
118+
119+
120+
def test_garbage_mask_clears_on_full_reset():
121+
"""reset_eval_state(state, done_indices=None) must zero garbage_mask."""
122+
B, T, H = 4, 8, 16
123+
wrapper = _make_wrapper(batch_size=B, horizon=T, hidden=H, n_heads=2)
124+
state = wrapper.init_eval_state(batch_size=B, device="cpu", dtype=torch.float32)
125+
state["garbage_mask"][:] = True
126+
wrapper.reset_eval_state(state, done_indices=None)
127+
assert not state["garbage_mask"].any().item(), "garbage_mask should be zeroed after full reset"
128+
129+
130+
def test_garbage_mask_clears_per_agent_on_partial_reset():
131+
"""reset_eval_state(state, done_indices=[a]) must zero garbage_mask[a]
132+
but leave other agents untouched."""
133+
B, T, H = 4, 8, 16
134+
wrapper = _make_wrapper(batch_size=B, horizon=T, hidden=H, n_heads=2)
135+
state = wrapper.init_eval_state(batch_size=B, device="cpu", dtype=torch.float32)
136+
state["garbage_mask"][:] = True
137+
wrapper.reset_eval_state(state, done_indices=torch.tensor([1, 3]))
138+
assert state["garbage_mask"][0].all().item(), "agent 0 garbage_mask should be unchanged"
139+
assert not state["garbage_mask"][1].any().item(), "agent 1 garbage_mask should be cleared"
140+
assert state["garbage_mask"][2].all().item(), "agent 2 garbage_mask should be unchanged"
141+
assert not state["garbage_mask"][3].any().item(), "agent 3 garbage_mask should be cleared"
142+
143+
144+
def test_no_garbage_mask_no_regression():
145+
"""Without `removed` in state, forward_eval must still work — model
146+
creates a fresh garbage_mask (all False), so attention is unchanged
147+
from the pre-fix behavior."""
148+
torch.manual_seed(0)
149+
B, T, H = 4, 8, 16
150+
wrapper = _make_wrapper(batch_size=B, horizon=T, hidden=H, n_heads=2)
151+
wrapper.eval()
152+
state = wrapper.init_eval_state(batch_size=B, device="cpu", dtype=torch.float32)
153+
# No state["removed"] key
154+
for _ in range(3):
155+
obs = torch.randn(B, H)
156+
with torch.no_grad():
157+
wrapper.forward_eval(obs, state)
158+
# garbage_mask stays all-False
159+
assert not state["garbage_mask"].any().item(), "without removed signal, no slots should be marked garbage"
160+
161+
162+
if __name__ == "__main__":
163+
test_garbage_mask_excludes_slots_from_attention()
164+
test_garbage_mask_clears_on_full_reset()
165+
test_garbage_mask_clears_per_agent_on_partial_reset()
166+
test_no_garbage_mask_no_regression()
167+
print("test_cache_freeze: PASS")

tests/test_env_level_trial.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Contract tests for B'' env-level trial semantic.
2+
3+
Design (see docs/src/trial_mode.md, "Env-level trials"):
4+
- Each env has ONE trial clock (env->trial_count, env->trial_start_timestep),
5+
not per-agent.
6+
- On ego goal-reach mid-trial: ego goes off-map (removed=1, INVALID_POSITION,
7+
vx=vy=0). No truncations / terminals yet — wait for trial-end.
8+
- env trial-end fires when ALL active egos in env have removed=1 OR env's
9+
per_trial_timeout elapses since trial start. At env trial-end:
10+
* truncations[i] = 1 for every active ego in env
11+
* trial_ended_this_step[i] = 1 for every active ego in env
12+
* All entities (egos + co-players) reset to init position; removed=0
13+
* env->trial_count++, env->trial_start_timestep = env->timestep
14+
- At env episode-end (env->trial_count == max_trials):
15+
* terminals[i] = 1 for every active ego in env
16+
* Option D: all egos removed=1 + off-map until c_reset
17+
18+
These tests run on a tiny env (per_trial_timeout=5, k=2) for determinism.
19+
"""
20+
21+
import os
22+
import sys
23+
import numpy as np
24+
25+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
26+
27+
MAP_DIR = "resources/drive/binaries/nuplan_201"
28+
INI = "pufferlib/config/ocean/drive.ini"
29+
30+
31+
def _make_env(k=2, scenario_length=5, num_agents=4, goal_radius=2.0):
32+
from pufferlib.ocean.drive import Drive
33+
34+
return Drive(
35+
num_agents=num_agents,
36+
map_dir=MAP_DIR,
37+
num_maps=10,
38+
scenario_length=scenario_length,
39+
ini_file=INI,
40+
goal_behavior=3,
41+
k_scenarios=k,
42+
max_trials_per_episode=k,
43+
per_trial_timeout=scenario_length,
44+
goal_radius=goal_radius,
45+
report_interval=10000,
46+
)
47+
48+
49+
def _zero_actions(env):
50+
return np.zeros(env.action_space.shape, dtype=env.actions.dtype)
51+
52+
53+
def test_removed_buffer_exists_and_is_zero_at_reset():
54+
"""Python-side `removed` SHM buffer exists and starts all-zero."""
55+
env = _make_env()
56+
env.reset(seed=42)
57+
assert hasattr(env, "removed"), "env must expose a `removed` SHM buffer"
58+
assert np.asarray(env.removed, dtype=bool).shape == (env.num_agents,)
59+
assert not np.asarray(env.removed, dtype=bool).any(), "removed must be all-zero after reset"
60+
env.close()
61+
62+
63+
def test_env_trial_end_fires_on_timeout_only():
64+
"""Tight goal_radius so no ego reaches. Trial-end MUST fire at
65+
per_trial_timeout for the env, with truncations=1 on every ego."""
66+
env = _make_env(k=2, scenario_length=5, num_agents=4, goal_radius=2.0)
67+
env.reset(seed=42)
68+
actions = _zero_actions(env)
69+
truncations_at = None
70+
for t in range(1, 10):
71+
env.step(actions)
72+
if np.asarray(env.truncations, dtype=bool).any():
73+
truncations_at = t
74+
break
75+
assert truncations_at == 5, f"trial-end (timeout) should fire at tick=5, got {truncations_at}"
76+
# Trial-end fires for ALL active agents simultaneously
77+
tr = np.asarray(env.truncations, dtype=bool)
78+
te = np.asarray(env.trial_ended_this_step, dtype=bool)
79+
assert tr.all() or tr.sum() >= 1, f"truncations should fire env-wide: {tr}"
80+
assert (tr == te).all(), f"truncations and trial_ended_this_step must align: tr={tr}, te={te}"
81+
env.close()
82+
83+
84+
def test_ego_goes_off_map_on_reach():
85+
"""Wide goal_radius so all egos reach quickly. Each ego should become
86+
removed=1 the step after it reaches goal."""
87+
env = _make_env(k=2, scenario_length=20, num_agents=4, goal_radius=200.0)
88+
env.reset(seed=42)
89+
actions = _zero_actions(env)
90+
if env.action_space.shape[-1] == 2:
91+
actions[:, 0] = 0.1 # gentle accel — within speed limit
92+
saw_removed = False
93+
for _ in range(20):
94+
env.step(actions)
95+
if np.asarray(env.removed, dtype=bool).any():
96+
saw_removed = True
97+
break
98+
assert saw_removed, "At least one ego should have removed=1 mid-trial after reaching goal"
99+
env.close()
100+
101+
102+
def test_env_trial_end_resets_all_entities_to_init():
103+
"""After a NON-terminal env trial-end (trial < max_trials), all egos
104+
must be back on-map (removed=0). k must be >= 3 so trial 1 end isn't
105+
the same step as episode-end."""
106+
env = _make_env(k=3, scenario_length=5, num_agents=4, goal_radius=200.0)
107+
env.reset(seed=42)
108+
actions = _zero_actions(env)
109+
if env.action_space.shape[-1] == 2:
110+
actions[:, 0] = 0.1
111+
# Run until first env trial-end
112+
for t in range(1, 20):
113+
env.step(actions)
114+
if np.asarray(env.truncations, dtype=bool).any():
115+
# At trial-end step itself, the reset has already fired in C —
116+
# removed should already be 0 (entities back at init).
117+
removed_after = np.asarray(env.removed, dtype=bool)
118+
term = np.asarray(env.terminals, dtype=bool)
119+
assert not term.any(), f"trial 1 must not also be episode-end (k={3}); got terminals={term}"
120+
assert not removed_after.any(), (
121+
f"After env trial-end (mid-episode), all egos must be back on-map. Got: {removed_after}"
122+
)
123+
env.close()
124+
return
125+
raise AssertionError("env trial-end never fired in 20 steps")
126+
127+
128+
def test_episode_end_fires_after_max_trials():
129+
"""After max_trials env trial-ends, terminals must fire for all egos.
130+
Option D semantic: removed=1 stays until c_reset."""
131+
env = _make_env(k=2, scenario_length=3, num_agents=4, goal_radius=2.0)
132+
env.reset(seed=42)
133+
actions = _zero_actions(env)
134+
trial_ends = 0
135+
term_at = None
136+
for t in range(1, 20):
137+
env.step(actions)
138+
if np.asarray(env.truncations, dtype=bool).any():
139+
trial_ends += 1
140+
if np.asarray(env.terminals, dtype=bool).any():
141+
term_at = t
142+
break
143+
assert trial_ends >= 1, f"expected ≥1 trial-end before episode end, got {trial_ends}"
144+
assert term_at is not None, "terminals never fired within 20 steps"
145+
# At terminals, all egos should be removed (Option D)
146+
assert np.asarray(env.removed, dtype=bool).all(), (
147+
f"after episode-end terminals, all egos should be removed: {env.removed}"
148+
)
149+
env.close()
150+
151+
152+
def test_truncations_not_fired_on_individual_reach():
153+
"""Before env trial-end, individual reaches must NOT fire truncations.
154+
Only the env-level trial-end does."""
155+
env = _make_env(k=2, scenario_length=30, num_agents=4, goal_radius=200.0)
156+
env.reset(seed=42)
157+
actions = _zero_actions(env)
158+
if env.action_space.shape[-1] == 2:
159+
actions[:, 0] = 0.1
160+
for t in range(1, 6):
161+
env.step(actions)
162+
rem = np.asarray(env.removed, dtype=bool)
163+
tr = np.asarray(env.truncations, dtype=bool)
164+
# If any ego has removed=1 but not all of them, truncations must NOT
165+
# fire yet — we're mid-trial waiting for stragglers.
166+
if rem.any() and not rem.all():
167+
assert not tr.any(), f"step={t}: removed={rem} but truncations={tr} — env trial-end fired prematurely"
168+
env.close()
169+
170+
171+
if __name__ == "__main__":
172+
test_removed_buffer_exists_and_is_zero_at_reset()
173+
test_env_trial_end_fires_on_timeout_only()
174+
test_ego_goes_off_map_on_reach()
175+
test_env_trial_end_resets_all_entities_to_init()
176+
test_episode_end_fires_after_max_trials()
177+
test_truncations_not_fired_on_individual_reach()
178+
print("test_env_level_trial: PASS")

0 commit comments

Comments
 (0)