yxc20098 commited on
Commit
741e23b
·
1 Parent(s): 58991b9

feat(scenario): proc-tool-use-multi-distractor — multi-tool clutter resistance (τ²-bench / BFCL V4 anchor)

Browse files

Wave-6 multi-distractor tool-relevance discipline pack. The agent is
handed a CLUTTERED 9-tool API surface (move_units, attack_unit,
attack_move, build, place_building, harvest, set_stance,
set_rally_point, observe) where only TWO tools are on the critical
path (move_units + observe). The other seven are MAXIMALLY SALIENT
distractors: a visible off-path weak-enemy garrison invites
attack_unit / attack_move, cash + pre-placed base invites build /
place_building, no-producer / no-harvester tools (set_rally_point,
harvest, set_stance) look generally useful but do nothing for THIS
goal.

Discriminator is OUTCOME-graded (reach + zero loss + within_ticks),
NOT tool-name graded — no forbidden_tools. The off-path enemies
are stance:0 (HoldFire) so they never pursue a clean column; the
cost of invoking a distractor tool is the TURNS BURNED ordering it
(each command also replaces the units' active move order so the
column never makes monotonic progress) while the within_ticks clock
keeps ticking. This is the τ²-bench multi-distractor / BFCL V4
cluttered-API setting: select the relevant subset of a large API
on your own, with no instruction telling you which to skip.

Distinct from sibling strict-toolban-fidelity-under-pressure (B3),
which uses a single forbidden_tools binary signal. This pack uses
NO forbidden_tools — pure outcome-graded relevance discipline.

Tiering:
easy : fewer distractors (1 off-path rifle), generous budget
(within_ticks 2000 / max_turns 25). Intended direct
policy wins in ~9 turns ≈ tick 813.
medium : full distractor palette (3-stack off-path garrison),
tighter budget (within_ticks 1500 / max_turns 22).
hard : + time-pressure central sentry stack at (60,18..22)
denying the direct y=20 lane, + TWO seed-driven
spawn_point groups (NORTH lane y=4..6 / SOUTH lane
y=34..36) round-robined by seed. Listed UPGRADED.

Validation (scripted, no network): intended movement-only policy
WINS every level/seed (1..4); stall (only observe) and use-
multiple-distractors (set_stance + attack_move + build + harvest
+ set_rally_point every turn) both LOSE every level/seed via the
within_ticks deadline.

meta.benchmark_anchor:
- τ²-bench multi-distractor handling
- BFCL V4 cluttered-API relevance
- IFBench distraction resistance
- junior-operator discipline test

Smoke (together / Qwen3.6-Plus, medium seed 1): loss, composite
0.2659 (model failed to ignore distractors — valid discrimination).

openra_bench/scenarios/packs/proc-tool-use-multi-distractor.yaml ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # proc-tool-use-multi-distractor — Wave-6 multi-distractor tool-relevance
2
+ # discipline (τ²-bench multi-distractor / BFCL V4 cluttered-API anchor).
3
+ #
4
+ # CAPABILITY (action): the agent is handed a CLUTTERED tool surface — 9
5
+ # tools, including several that are MAXIMALLY SALIENT (a visible weak
6
+ # enemy that invites attack_unit; cash on hand and a pre-placed base
7
+ # that invite build/place_building; harvesters absent but `harvest` is
8
+ # present; no producers but `set_rally_point` is present; `set_stance`
9
+ # / `attack_move` likewise tempt). Only TWO tools are actually needed
10
+ # to win: `move_units` (drive the column east) and `observe` (read
11
+ # state). The discipline test is whether the model can IGNORE the
12
+ # multiple irrelevant tools and use only the relevant ones.
13
+ #
14
+ # Distinct from sibling `strict-toolban-fidelity-under-pressure` (B3):
15
+ # B3: ONE forbidden tool (attack), procedural BINARY signal —
16
+ # tool_violations_gte:1 instantly fails.
17
+ # This: NO forbidden tools at all. Every tool is "allowed" in the
18
+ # allowlist sense — but invoking the irrelevant ones either
19
+ # (a) interrupts the units' move order so they stop progressing,
20
+ # (b) lures units off-course into a fight that costs casualties,
21
+ # or (c) burns the model's turn budget on no-ops while the
22
+ # within_ticks clock keeps ticking. The bench grades OUTCOME
23
+ # (reach + no losses + on time), not tool-name compliance —
24
+ # which IS the τ²-bench multi-distractor / BFCL V4 cluttered-
25
+ # API setting (the model must select the RELEVANT subset of a
26
+ # large API on its own; no instruction tells it which to skip).
27
+ #
28
+ # Why this cell exists in the Wave-6 taxonomy:
29
+ # - Real-world: τ²-bench's multi-distractor handling (a function-
30
+ # calling agent given a large toolset where most options are
31
+ # irrelevant to the current task); BFCL V4's cluttered-API
32
+ # relevance test (cherry-pick the right tool from a long list).
33
+ # - Robotics: a teleop operator panel exposing dozens of buttons;
34
+ # only two are on the critical path for THIS mission. A junior
35
+ # operator who reaches for every salient button wastes the
36
+ # window or causes collateral.
37
+ #
38
+ # Win : reach_region(110,20,r=6) AND units_lost_lte:0 AND within_ticks
39
+ # Fail : after_ticks (deadline) OR units_lost (any casualty)
40
+ #
41
+ # Failure modes (all real, reachable LOSSES; no draw degeneracy):
42
+ # • Stall (only observe): never moves; deadline LOSS via after_ticks.
43
+ # • Use-multiple-distractors (issue build / set_stance / attack_unit /
44
+ # attack_move / set_rally_point / harvest commands TO the agent's
45
+ # units every turn): each non-move command REPLACES the units'
46
+ # active move order so the column oscillates / stops, AND the
47
+ # attack_unit / attack_move calls lure the column into the off-
48
+ # path weak-enemy garrison where the garrison's return fire
49
+ # ticks a unit down → units_lost LOSS (or deadline LOSS).
50
+ # • Intended-direct (movement-only east to (110,20), ignore every
51
+ # other tool): the column arrives before the deadline with all
52
+ # units intact → WIN.
53
+ #
54
+ # Engine-footgun notes (re-asserted on every level):
55
+ # • ~90 ticks per decision turn ⇒ tick ≤ 93 + 90·(max_turns-1).
56
+ # Every `within_ticks` / `after_ticks` below is strictly under
57
+ # that threshold so the deadline genuinely BITES.
58
+ # • A persistent unarmed enemy `fact` at (118,20) keeps the
59
+ # episode alive past any enemy-elimination engine auto-`done`
60
+ # (CLAUDE.md engine fact #5) — so the win/fail evaluator reads
61
+ # reach_region + within_ticks instead of degenerating to DRAW.
62
+ # • Agent jeeps are stance:0 (HoldFire) so `move_units` en-route
63
+ # auto-fire is suppressed (CLAUDE.md engine fact). The off-path
64
+ # "weak enemy" garrison only fires when the agent CHOOSES to
65
+ # send units into its range (attack_unit / attack_move).
66
+ # • This pack does NOT use `forbidden_tools` — the test is
67
+ # outcome-graded, not tool-name graded. Every tool the model
68
+ # might pick is in `tools:`. The bench's tool_violations counter
69
+ # stays at 0 for every policy; the failure signal is reach /
70
+ # casualty / clock.
71
+ #
72
+ # Hard-tier curation: this pack IS in
73
+ # tests/test_hard_tier.py::UPGRADED — hard adds two agent spawn_point
74
+ # groups (NORTH lane y=10..12 / SOUTH lane y=28..30) so the route
75
+ # geometry differs per seed and a memorised "drive east on y=20"
76
+ # opening cannot generalise. Hard additionally adds time-pressure
77
+ # (a `patrol` enemy bot oscillating across the central corridor) —
78
+ # the patrol is itself a DISTRACTOR (the model is tempted to
79
+ # attack_unit it) AND a real threat to a y=20 column, reinforcing
80
+ # the spawn-vary controlled axis.
81
+
82
+ meta:
83
+ id: proc-tool-use-multi-distractor
84
+ title: 'Cluttered Tool Surface — Ignore the Distractor Tools and Just Move'
85
+ capability: action
86
+ real_world_meaning: >
87
+ The agent receives a CLUTTERED toolset (9 tools) where only TWO
88
+ are on the critical path for the current task (move + observe).
89
+ The other seven are MAXIMALLY SALIENT distractors: a visible
90
+ weak enemy invites `attack_unit`; cash on hand and a pre-placed
91
+ base invite `build` / `place_building`; `harvest` / `set_rally_
92
+ point` / `set_stance` / `attack_move` look generally useful but
93
+ do nothing for THIS goal. The discipline is to recognise which
94
+ subset of a large API is actually relevant and ignore the rest —
95
+ every off-path tool call either interrupts the unit's move
96
+ order, lures units into a costly engagement, or burns the
97
+ model's turn budget while the within_ticks clock keeps ticking.
98
+ robotics_analogue: >
99
+ A teleoperation panel with dozens of buttons (manipulator,
100
+ nav, gripper, vision, comms, logging…) but only two are on
101
+ the critical path for THIS mission. A disciplined operator
102
+ runs the two-button procedure; a junior operator who reaches
103
+ for every salient button wastes the window or causes
104
+ collateral damage. The skill is API/relevance discrimination
105
+ under no explicit "don't touch X" instruction.
106
+ benchmark_anchor:
107
+ - "τ²-bench multi-distractor handling"
108
+ - "BFCL V4 cluttered-API relevance"
109
+ - "IFBench distraction resistance"
110
+ - "junior-operator discipline test"
111
+ author: "openra-bench"
112
+
113
+ base_map: rush-hour-arena
114
+
115
+ # Shared engine fields. The tools allowlist is the FULL 9-tool
116
+ # cluttered API — no forbidden_tools. The agent must self-select
117
+ # the relevant subset (move_units + observe). Generous starting
118
+ # cash and a pre-placed agent base make `build` / `place_building`
119
+ # fully usable distractors (not gated out by tech-tree errors).
120
+ base:
121
+ agent: {faction: allies, cash: 0}
122
+ enemy: {faction: soviet, cash: 0}
123
+ # Full 9-tool API surface. Every tool the spec lists is present in
124
+ # `tools:`; none is in `forbidden_tools`. The discipline test is
125
+ # outcome-graded — irrelevant tool use shows up as missed deadline
126
+ # or unit loss, NOT as a tool_violations counter increment.
127
+ tools:
128
+ - move_units
129
+ - attack_unit
130
+ - attack_move
131
+ - build
132
+ - place_building
133
+ - harvest
134
+ - set_stance
135
+ - set_rally_point
136
+ - observe
137
+ planning: true
138
+ termination: {max_ticks: 6000}
139
+ actors:
140
+ # 4 jeep scouts at the west edge, tight column on y=20 (overridden
141
+ # per level / spawn group below). Jeeps are stance:0 (HoldFire)
142
+ # so the move_units en-route auto-fire engine quirk doesn't
143
+ # accidentally engage the off-path garrison. The garrison only
144
+ # fires if the agent chooses to send units into its range.
145
+ - {type: jeep, owner: agent, position: [8, 19], stance: 0}
146
+ - {type: jeep, owner: agent, position: [8, 20], stance: 0}
147
+ - {type: jeep, owner: agent, position: [8, 21], stance: 0}
148
+ - {type: jeep, owner: agent, position: [9, 20], stance: 0}
149
+ # Pre-placed agent base on the mid latitude (y=20) — set
150
+ # away from both hard-spawn lanes so it doesn't collide with
151
+ # the jeep column on any seed. Makes `build` / `place_building`
152
+ # fully usable distractor tools (no tech-tree failure to
153
+ # short-circuit the distraction). Cash is added per-level so
154
+ # `build` actually queues something.
155
+ - {type: fact, owner: agent, position: [14, 20]}
156
+ - {type: tent, owner: agent, position: [18, 20]}
157
+ - {type: powr, owner: agent, position: [14, 24]}
158
+ # Off-path "weak enemy" garrison (3 e1 rifles) on the NORTH
159
+ # shoulder at (45,8). VISIBLE early via the explored area around
160
+ # the agent base — they show up on the minimap as a salient
161
+ # attackable target. They sit on y=8, well off the y=20 direct
162
+ # corridor — going to attack them costs ~5 turns of route
163
+ # diversion AND the rifles fire back (jeeps that engage at
164
+ # close range eat hits from a 3-stack of e1). This is the
165
+ # `attack_unit` / `attack_move` distractor bait.
166
+ - {type: e1, owner: enemy, position: [45, 8], stance: 0}
167
+ - {type: e1, owner: enemy, position: [45, 9], stance: 0}
168
+ - {type: e1, owner: enemy, position: [46, 8], stance: 0}
169
+ # Persistent unarmed enemy marker at the far east — kept alive
170
+ # past any enemy auto-done so the win/fail evaluator reads
171
+ # reach_region + within_ticks instead of degenerating to DRAW
172
+ # (CLAUDE.md engine fact #5). Unarmed `fact` (cost 0) cannot
173
+ # damage the column.
174
+ - {type: fact, owner: enemy, position: [118, 20]}
175
+
176
+ levels:
177
+ # ── EASY ─────────────────────────────────────────────────────────
178
+ # Bare skill, FEWER distractors. The weak-enemy garrison is reduced
179
+ # to ONE rifleman (still visible, still a salient attack_unit
180
+ # target) and there is NO patrol on the corridor. Tick budget is
181
+ # GENEROUS so the discrimination is purely "did you use the
182
+ # relevant tools or not?" — a model that move_units east straight
183
+ # wins comfortably; a model that wastes turns on distractors runs
184
+ # out the clock.
185
+ # max_turns 25 ⇒ reachable tick 93+90·24 = 2253; within_ticks 2000
186
+ # fits (deadline reachable as LOSS). Direct policy: jeeps cover
187
+ # ~12 cells/turn ⇒ 102 cells in ~9 turns ≈ tick 813, well under
188
+ # 2000. Distractor-spam policy that re-orders units every turn
189
+ # never makes monotonic progress ⇒ deadline LOSS.
190
+ easy:
191
+ description: >
192
+ Get a jeep to the observation point at (110,20) within radius
193
+ 6 BEFORE tick 2000, losing none of your 4 jeeps. You have a
194
+ 9-tool API at your disposal — most of it is IRRELEVANT to
195
+ this task. The only tools you need are `move_units` and
196
+ `observe`. There is a single weak enemy rifleman dug in off-
197
+ path to the north at ~(45,8); attacking it is a distraction
198
+ that will pull your column off course and may cost units.
199
+ You also have a base and cash — building anything is a
200
+ distraction. Stalling (no orders) and force-loss both LOSE.
201
+ starting_cash: 2000
202
+ overrides:
203
+ actors:
204
+ - {type: jeep, owner: agent, position: [8, 19], stance: 0}
205
+ - {type: jeep, owner: agent, position: [8, 20], stance: 0}
206
+ - {type: jeep, owner: agent, position: [8, 21], stance: 0}
207
+ - {type: jeep, owner: agent, position: [9, 20], stance: 0}
208
+ - {type: fact, owner: agent, position: [14, 20]}
209
+ - {type: tent, owner: agent, position: [18, 20]}
210
+ - {type: powr, owner: agent, position: [14, 24]}
211
+ # EASY: one off-path rifle as the attack_unit distractor.
212
+ # stance:0 (HoldFire) so it sits put and never pursues even
213
+ # if the agent's `move_units` auto-fire (CLAUDE.md engine
214
+ # quirk: agents always auto-fire on the move regardless of
215
+ # the AGENT's stance) tags it; a clean "intended" column
216
+ # that walks past on an off-line lane is never engaged
217
+ # back. The distractor is the model's CHOICE to spend turns
218
+ # ordering attack_unit on it — not a forced kill-zone.
219
+ - {type: e1, owner: enemy, position: [45, 8], stance: 0}
220
+ - {type: fact, owner: enemy, position: [118, 20]}
221
+ win_condition:
222
+ all_of:
223
+ - {reach_region: {x: 110, y: 20, radius: 6}}
224
+ - {units_lost_lte: 0}
225
+ - {within_ticks: 2000}
226
+ fail_condition:
227
+ any_of:
228
+ - {after_ticks: 2001}
229
+ - {not: {units_lost_lte: 0}}
230
+ - {not: {own_units_gte: 1}}
231
+ max_turns: 25
232
+
233
+ # ── MEDIUM ───────────────────────────────────────────────────────
234
+ # +1 controlled variable vs easy: the FULL distractor palette. The
235
+ # off-path enemy garrison is back at its base (3 e1) — stronger
236
+ # attack_unit / attack_move bait. The tick budget tightens (1500)
237
+ # so a model that wastes a handful of turns on distractor commands
238
+ # genuinely misses the deadline. Spawn point stays on y=20.
239
+ # max_turns 22 ⇒ reachable tick 93+90·21 = 1983; within_ticks
240
+ # 1500 fits (deadline reachable as LOSS). Direct policy: ~9 turns
241
+ # ≈ tick 813 (well under 1500); a distractor-spam policy that
242
+ # re-orders units every turn replaces the units' active move with
243
+ # a no-op / off-course command and never makes monotonic east
244
+ # progress ⇒ the column runs out the clock.
245
+ medium:
246
+ description: >
247
+ Get a jeep to the observation point at (110,20) within radius
248
+ 6 BEFORE tick 1500, losing none of your 4 jeeps. Your 9-tool
249
+ API includes `attack_unit`, `attack_move`, `build`, `place_
250
+ building`, `harvest`, `set_stance`, `set_rally_point` — none
251
+ of these advance THIS mission. Use `move_units` and `observe`
252
+ only. A 3-rifleman garrison sits off-path at ~(45,8) — a
253
+ tempting target whose engagement costs route diversion and
254
+ likely casualties. The base in the west is a tempting `build`
255
+ site but building won't get a unit to the observation point.
256
+ Stalling and force-loss both LOSE.
257
+ starting_cash: 3000
258
+ overrides:
259
+ actors:
260
+ - {type: jeep, owner: agent, position: [8, 19], stance: 0}
261
+ - {type: jeep, owner: agent, position: [8, 20], stance: 0}
262
+ - {type: jeep, owner: agent, position: [8, 21], stance: 0}
263
+ - {type: jeep, owner: agent, position: [9, 20], stance: 0}
264
+ - {type: fact, owner: agent, position: [14, 20]}
265
+ - {type: tent, owner: agent, position: [18, 20]}
266
+ - {type: powr, owner: agent, position: [14, 24]}
267
+ # MEDIUM: full off-path 3-stack garrison.
268
+ - {type: e1, owner: enemy, position: [45, 8], stance: 0}
269
+ - {type: e1, owner: enemy, position: [45, 9], stance: 0}
270
+ - {type: e1, owner: enemy, position: [46, 8], stance: 0}
271
+ - {type: fact, owner: enemy, position: [118, 20]}
272
+ win_condition:
273
+ all_of:
274
+ - {reach_region: {x: 110, y: 20, radius: 6}}
275
+ - {units_lost_lte: 0}
276
+ - {within_ticks: 1500}
277
+ fail_condition:
278
+ any_of:
279
+ - {after_ticks: 1501}
280
+ - {not: {units_lost_lte: 0}}
281
+ - {not: {own_units_gte: 1}}
282
+ max_turns: 22
283
+
284
+ # ── HARD ─────────────────────────────────────────────────────────
285
+ # +2 controlled variables vs medium:
286
+ # 1. TIME-PRESSURE / DIRECT-CORRIDOR DENIAL: a stationary
287
+ # 3-rifleman sentry stack on the central corridor at
288
+ # (60,18..22) — bracketing y=18..22 with stance:3 fire arcs.
289
+ # Functions as the "time-pressure patrol" (real threat to a
290
+ # undisciplined direct-along-y=20 column AND yet another
291
+ # attack_unit / attack_move distractor target). The model
292
+ # has to navigate around it with move_units only (stay on
293
+ # its spawn lane until the final approach) while still
294
+ # ignoring the off-path north garrison.
295
+ # 2. TWO seed-driven spawn_point groups (NORTH y=4..6 /
296
+ # SOUTH y=34..36) so the start lane differs per seed; a
297
+ # memorised "drive east on y=20" opening cannot generalise.
298
+ # Lanes are sited well clear of the off-path north garrison
299
+ # (y=8..9) and the central corridor (y=18..22) so a "drive
300
+ # east on lane" intended policy never crosses either fire
301
+ # arc.
302
+ # max_turns 20 ⇒ reachable tick 93+90·19 = 1803; within_ticks
303
+ # 1500 fits (deadline reachable as LOSS).
304
+ hard:
305
+ description: >
306
+ Get a jeep to the observation point at (110,20) within radius
307
+ 6 BEFORE tick 1500, losing none of your 4 jeeps. Your 9-tool
308
+ API includes seven tools that are IRRELEVANT to this task —
309
+ use only `move_units` and `observe`. There is a 3-rifleman
310
+ off-path garrison at ~(45,8) AND a 3-rifleman sentry stack
311
+ bracketing the central corridor at ~(60,20). Both are
312
+ visible attackable targets that will pull an undisciplined
313
+ column off course or into fire. Your jeeps start either on
314
+ the NORTH lane (y≈5) or the SOUTH lane (y≈35) depending on
315
+ seed — read the minimap and route along your spawn lane,
316
+ dropping to (110,20) only at the final approach. Stalling,
317
+ force-loss, and timeout all LOSE.
318
+ starting_cash: 3000
319
+ overrides:
320
+ actors:
321
+ # NORTH lane spawn group: jeeps along y=4..6 (well clear
322
+ # of the y=8..9 off-path garrison row, so a "drive east on
323
+ # lane" intended policy steers around the garrison without
324
+ # casualties).
325
+ - {type: jeep, owner: agent, position: [8, 4], stance: 0, spawn_point: 0}
326
+ - {type: jeep, owner: agent, position: [8, 5], stance: 0, spawn_point: 0}
327
+ - {type: jeep, owner: agent, position: [8, 6], stance: 0, spawn_point: 0}
328
+ - {type: jeep, owner: agent, position: [9, 5], stance: 0, spawn_point: 0}
329
+ # SOUTH lane spawn group: jeeps along y=34..36 (well clear
330
+ # of the central y=18..22 sentry fire arc).
331
+ - {type: jeep, owner: agent, position: [8, 34], stance: 0, spawn_point: 1}
332
+ - {type: jeep, owner: agent, position: [8, 35], stance: 0, spawn_point: 1}
333
+ - {type: jeep, owner: agent, position: [8, 36], stance: 0, spawn_point: 1}
334
+ - {type: jeep, owner: agent, position: [9, 35], stance: 0, spawn_point: 1}
335
+ # Pre-placed agent base on y=20 — duplicated across BOTH
336
+ # spawn groups at identical coords (per CLAUDE.md engine
337
+ # fact: if ANY agent actor declares spawn_point, every
338
+ # actor without one is FILTERED OUT, so we must duplicate
339
+ # across groups). Sited away from both lanes so it doesn't
340
+ # collide with the jeep column on any seed.
341
+ - {type: fact, owner: agent, position: [14, 20], spawn_point: 0}
342
+ - {type: tent, owner: agent, position: [18, 20], spawn_point: 0}
343
+ - {type: powr, owner: agent, position: [14, 24], spawn_point: 0}
344
+ - {type: fact, owner: agent, position: [14, 20], spawn_point: 1}
345
+ - {type: tent, owner: agent, position: [18, 20], spawn_point: 1}
346
+ - {type: powr, owner: agent, position: [14, 24], spawn_point: 1}
347
+ # Off-path north garrison (3 e1 rifles at ~(45,8)) —
348
+ # visible attack_unit distractor. Enemy actors ALWAYS
349
+ # place regardless of spawn_point (CLAUDE.md engine fact).
350
+ - {type: e1, owner: enemy, position: [45, 8], stance: 0}
351
+ - {type: e1, owner: enemy, position: [45, 9], stance: 0}
352
+ - {type: e1, owner: enemy, position: [46, 8], stance: 0}
353
+ # Central corridor sentry stack (3 e1 stance:0 at x=60,
354
+ # y=18..22) — the lane-denial / attack_unit distractor on
355
+ # the central corridor. stance:0 keeps them stationary
356
+ # (no pursuit) but they're a salient attackable target
357
+ # bracketing y=18..22; a model that uses move_units only
358
+ # along its spawn lane (y=4..6 NORTH or y=34..36 SOUTH)
359
+ # never enters the sentry fire arc.
360
+ - {type: e1, owner: enemy, position: [60, 18], stance: 0}
361
+ - {type: e1, owner: enemy, position: [60, 20], stance: 0}
362
+ - {type: e1, owner: enemy, position: [60, 22], stance: 0}
363
+ # Persistent unarmed marker.
364
+ - {type: fact, owner: enemy, position: [118, 20]}
365
+ win_condition:
366
+ all_of:
367
+ - {reach_region: {x: 110, y: 20, radius: 6}}
368
+ - {units_lost_lte: 0}
369
+ - {within_ticks: 1500}
370
+ fail_condition:
371
+ any_of:
372
+ - {after_ticks: 1501}
373
+ - {not: {units_lost_lte: 0}}
374
+ - {not: {own_units_gte: 1}}
375
+ max_turns: 20
tests/test_hard_tier.py CHANGED
@@ -492,6 +492,14 @@ UPGRADED = [
492
  # seed; the centre-line raider tanks and centre-line hunt squad
493
  # face the seed-chosen latitude with equal pressure.
494
  "rob-multiple-simultaneous-pressures",
 
 
 
 
 
 
 
 
495
  ]
496
 
497
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
492
  # seed; the centre-line raider tanks and centre-line hunt squad
493
  # face the seed-chosen latitude with equal pressure.
494
  "rob-multiple-simultaneous-pressures",
495
+ # Wave-6 multi-distractor tool-relevance discipline pack (τ²-bench
496
+ # multi-distractor / BFCL V4 cluttered-API anchor). Hard tier
497
+ # defines two agent spawn_point groups (NORTH lane y=4..6 / SOUTH
498
+ # lane y=34..36) round-robined by seed; both lanes route around
499
+ # the same off-path north garrison (y=8..9) and central sentry
500
+ # stack (y=18..22), so a memorised "drive east on y=20" opening
501
+ # cannot generalise across the lane variants.
502
+ "proc-tool-use-multi-distractor",
503
  ]
504
 
505
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_proc_tool_use_multi_distractor.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """proc-tool-use-multi-distractor — Wave-6 cluttered-API tool relevance.
2
+
3
+ The bar (per CLAUDE.md): the intended (move-only, ignore-distractors)
4
+ policy WINS on every level and every hard seed (1–4); stall (only
5
+ observe) and use-multiple-distractors (re-orders units every turn
6
+ with non-move tools / wastes turns on build / harvest / set_rally_
7
+ point) policies LOSE on every level and every hard seed. Non-win is
8
+ a real reachable timeout LOSS — never a draw.
9
+
10
+ The pack's discriminator is OUTCOME-graded (reach + zero loss + on
11
+ time), NOT tool-name graded — there are no `forbidden_tools` and
12
+ the bench's `tool_violations` counter stays at 0 for every policy.
13
+ The model is given a 9-tool cluttered API (move_units, attack_unit,
14
+ attack_move, build, place_building, harvest, set_stance, set_rally_
15
+ point, observe) and must self-select the relevant subset
16
+ (move_units + observe). Off-path enemies are stance:0 (HoldFire)
17
+ so they never pursue a "clean" column — the cost of invoking an
18
+ attack tool is the TURNS BURNED ordering it, not a forced kill-
19
+ zone. Same for build/harvest/etc.: they don't fail engine-side,
20
+ they just waste the model's turn budget while the within_ticks
21
+ clock keeps ticking.
22
+
23
+ Validation is scripted (no model / network) — these four policies
24
+ are exhaustive proxies for the cluttered-API failure / success
25
+ modes and exercise the predicate teeth (reach_region, units_lost_
26
+ lte, within_ticks/after_ticks) directly through eval_core.run_level.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import types
32
+ from pathlib import Path
33
+
34
+ import pytest
35
+
36
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
37
+ from openra_bench.scenarios import load_pack
38
+ from openra_bench.scenarios.loader import compile_level
39
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
40
+
41
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
42
+ PACK_PATH = PACKS / "proc-tool-use-multi-distractor.yaml"
43
+
44
+
45
+ # ── unit-level predicate / schema checks (no engine) ──────────────────
46
+
47
+
48
+ def _ctx(units_xy=(), tick=1000, lost=0, violations=0):
49
+ """Synthesize a WinContext for predicate-level checks. Mirrors the
50
+ field set the live signals object exposes during eval_core."""
51
+ sig = types.SimpleNamespace(
52
+ game_tick=tick,
53
+ units_killed=0,
54
+ units_lost=lost,
55
+ tool_violations=violations,
56
+ own_buildings=[],
57
+ own_building_types=set(),
58
+ enemies_seen_ids=set(),
59
+ enemy_buildings_seen_ids=set(),
60
+ )
61
+ return WinContext(
62
+ signals=sig,
63
+ render_state={
64
+ "units_summary": [
65
+ {"cell_x": x, "cell_y": y} for x, y in units_xy
66
+ ]
67
+ },
68
+ )
69
+
70
+
71
+ def test_pack_compiles_and_meta_fields_populated():
72
+ pack = load_pack(PACK_PATH)
73
+ assert pack.meta.id == "proc-tool-use-multi-distractor"
74
+ assert pack.meta.capability == "action"
75
+ # benchmark_anchor must name the τ²-bench + BFCL V4 + IFBench +
76
+ # junior-operator anchors from the Wave-6 spec.
77
+ anchors = pack.meta.benchmark_anchor
78
+ assert isinstance(anchors, list) and anchors
79
+ joined = " ".join(anchors).lower()
80
+ assert "τ²-bench" in joined or "tau" in joined or "distractor" in joined
81
+ assert "bfcl" in joined
82
+ assert "ifbench" in joined or "distraction" in joined
83
+ assert "junior" in joined or "operator" in joined
84
+ # meta required-prose fields populated and reflect the cluttered-
85
+ # API framing.
86
+ rwm = pack.meta.real_world_meaning.lower()
87
+ assert "cluttered" in rwm or "distractor" in rwm or "irrelevant" in rwm
88
+ assert "robot" in pack.meta.robotics_analogue.lower() \
89
+ or "operator" in pack.meta.robotics_analogue.lower()
90
+ for lvl in ("easy", "medium", "hard"):
91
+ c = compile_level(pack, lvl)
92
+ assert c.map_supported
93
+ assert c.win_condition is not None
94
+ assert c.fail_condition is not None
95
+
96
+
97
+ def test_full_9_tool_api_surface_on_every_level():
98
+ """Cluttered-API contract: every level exposes the full 9-tool
99
+ spec'd API, NO `forbidden_tools` — the test is outcome-graded,
100
+ not tool-name graded. The model must self-select the relevant
101
+ subset (move_units + observe) from the 9 available tools."""
102
+ pack = load_pack(PACK_PATH)
103
+ expected = {
104
+ "move_units", "attack_unit", "attack_move", "build",
105
+ "place_building", "harvest", "set_stance", "set_rally_point",
106
+ "observe",
107
+ }
108
+ for lvl in ("easy", "medium", "hard"):
109
+ c = compile_level(pack, lvl)
110
+ tools = set(c.scenario.tools or [])
111
+ assert tools == expected, (
112
+ f"{lvl}: tools allowlist must be the full 9-tool API spec; "
113
+ f"got {sorted(tools)}, missing {sorted(expected - tools)}, "
114
+ f"extra {sorted(tools - expected)}"
115
+ )
116
+ # No forbidden_tools — the discriminator is OUTCOME, not name.
117
+ assert c.forbidden_tools == [], (
118
+ f"{lvl}: this pack must NOT use forbidden_tools (the "
119
+ f"discriminator is reach + zero-loss + within_ticks, NOT "
120
+ f"a tool_violations counter); got {c.forbidden_tools}"
121
+ )
122
+
123
+
124
+ def test_win_predicate_requires_reach_zero_loss_within_ticks():
125
+ """The win predicate must enforce the advertised capability: a
126
+ jeep reaches the observation point, zero units lost, before the
127
+ tick deadline. Each clause is load-bearing."""
128
+ pack = load_pack(PACK_PATH)
129
+ for lvl in ("easy", "medium", "hard"):
130
+ c = compile_level(pack, lvl)
131
+ in_region = [(110, 20), (109, 19), (110, 21)]
132
+ spawn_only = [(8, 5), (8, 20), (8, 35)]
133
+ # Intended win state — clean arrival in time.
134
+ assert evaluate(c.win_condition, _ctx(in_region, tick=900, lost=0)), \
135
+ f"{lvl}: clean arrival within deadline must WIN"
136
+ # Sitting at spawn — never wins regardless of tick / loss.
137
+ assert not evaluate(c.win_condition, _ctx(spawn_only, tick=900, lost=0)), \
138
+ f"{lvl}: sitting at spawn must NOT win"
139
+ # ONE loss — wins predicate has units_lost_lte:0, so any
140
+ # casualty invalidates the win clause.
141
+ assert not evaluate(c.win_condition, _ctx(in_region, tick=900, lost=1)), \
142
+ f"{lvl}: even a single casualty must invalidate the win"
143
+
144
+
145
+ def test_fail_predicate_reachable_loss_on_every_level():
146
+ """Fail must be a real reachable LOSS, not a draw. The after_ticks
147
+ fail clause must fit inside max_turns (~90 ticks/turn ⇒
148
+ 93 + 90·(max_turns-1)). Casualty (units_lost_lte:0 violated) and
149
+ full force-wipe (not own_units_gte:1) likewise fail."""
150
+ pack = load_pack(PACK_PATH)
151
+ in_region = [(110, 20)]
152
+ expected_deadline = {"easy": 2001, "medium": 1501, "hard": 1501}
153
+ for lvl in ("easy", "medium", "hard"):
154
+ c = compile_level(pack, lvl)
155
+ deadline = expected_deadline[lvl]
156
+ # after_ticks fits inside max_turns (no draw degeneracy)
157
+ assert deadline <= 93 + 90 * (c.max_turns - 1), (
158
+ f"{lvl}: after_ticks {deadline} must be reachable within "
159
+ f"max_turns {c.max_turns} (else timeout draws instead of "
160
+ f"losing). Max reachable tick "
161
+ f"{93 + 90 * (c.max_turns - 1)}."
162
+ )
163
+ # Past deadline → fail
164
+ assert evaluate(c.fail_condition, _ctx(in_region, tick=deadline + 1, lost=0)), \
165
+ f"{lvl}: past after_ticks must fail"
166
+ # Casualty → fail (units_lost_lte:0 is the cap on every tier)
167
+ assert evaluate(c.fail_condition, _ctx(in_region, tick=900, lost=1)), \
168
+ f"{lvl}: any unit loss must fail (cap is 0)"
169
+
170
+
171
+ def test_intended_predicate_wins_only_when_actually_at_observation_point():
172
+ """Sanity: the reach_region(110,20,r=6) clause must require an
173
+ actual arrival; the spawn area and the SW base must not satisfy
174
+ it. Generous radius admits any reasonable approach footprint."""
175
+ pack = load_pack(PACK_PATH)
176
+ for lvl in ("easy", "medium", "hard"):
177
+ c = compile_level(pack, lvl)
178
+ # SW base coords — not in the observation region.
179
+ sw_base = [(14, 20), (18, 20)]
180
+ assert not evaluate(c.win_condition, _ctx(sw_base, tick=900, lost=0)), \
181
+ f"{lvl}: SW base must NOT count as arrival at (110,20)"
182
+ # Adjacent (within radius 6) — wins.
183
+ near = [(108, 22)]
184
+ assert evaluate(c.win_condition, _ctx(near, tick=900, lost=0)), \
185
+ f"{lvl}: arrival within radius 6 must win"
186
+
187
+
188
+ def test_hard_has_two_spawn_groups_under_upgraded():
189
+ """The hard tier is in tests/test_hard_tier.py::UPGRADED, so it
190
+ MUST declare ≥2 distinct agent spawn_point groups (north lane /
191
+ south lane round-robined by seed)."""
192
+ pack = load_pack(PACK_PATH)
193
+ c = compile_level(pack, "hard")
194
+ groups = {
195
+ (a.spawn_point if a.spawn_point is not None else 0)
196
+ for a in c.scenario.actors
197
+ if a.owner == "agent"
198
+ }
199
+ assert groups == {0, 1}, (
200
+ f"hard must define exactly two spawn_point groups {{0, 1}} "
201
+ f"(north lane / south lane); got {sorted(groups)}"
202
+ )
203
+
204
+
205
+ def test_pack_is_in_upgraded_set():
206
+ """Round-trip check that the hard-tier curation matrix records
207
+ this pack as UPGRADED (spawn-varied), not NOT_APPLICABLE."""
208
+ from tests.test_hard_tier import NOT_APPLICABLE, UPGRADED
209
+
210
+ assert "proc-tool-use-multi-distractor" in UPGRADED, (
211
+ "pack must be registered in UPGRADED (≥2 spawn groups on hard)"
212
+ )
213
+ assert "proc-tool-use-multi-distractor" not in NOT_APPLICABLE, (
214
+ "pack is spawn-varied; should not appear in NOT_APPLICABLE"
215
+ )
216
+
217
+
218
+ # ── engine-driven scripted policies ──────────────────────────────────
219
+
220
+
221
+ def _stall_policy(rs, Command):
222
+ """Idle: only observe. Never moves; misses the within_ticks
223
+ deadline ⇒ after_ticks LOSS."""
224
+ return [Command.observe()]
225
+
226
+
227
+ def _intended_direct_policy(rs, Command):
228
+ """Movement-only beeline east to the observation point. Issues
229
+ `move_units` once per unit per turn — ignores every other tool
230
+ in the 9-tool API. On easy/medium the spawn is on y=20 so a
231
+ straight east drive arrives cleanly."""
232
+ units = [
233
+ u for u in (rs.get("units_summary") or [])
234
+ if not u.get("is_building", False)
235
+ ]
236
+ if not units:
237
+ return [Command.observe()]
238
+ return [
239
+ Command.move_units([str(u["id"])], target_x=110, target_y=20)
240
+ for u in units
241
+ ]
242
+
243
+
244
+ def _intended_lane_policy(rs, Command):
245
+ """Hard-tier version of the intended policy: stay on the spawn
246
+ lane (y≈5 north or y≈35 south, picked from current unit y) to
247
+ avoid the central sentry stack at (60,18..22) and the off-path
248
+ north garrison at (45,8). Drops south to (110,20) only at the
249
+ final approach (ux ≥ 100). Uses move_units only."""
250
+ units = [
251
+ u for u in (rs.get("units_summary") or [])
252
+ if not u.get("is_building", False)
253
+ ]
254
+ if not units:
255
+ return [Command.observe()]
256
+ cmds = []
257
+ for u in units:
258
+ ux, uy = u["cell_x"], u["cell_y"]
259
+ if ux < 100:
260
+ ly = 5 if uy < 20 else 35
261
+ cmds.append(
262
+ Command.move_units([str(u["id"])], target_x=105, target_y=ly)
263
+ )
264
+ else:
265
+ cmds.append(
266
+ Command.move_units([str(u["id"])], target_x=110, target_y=20)
267
+ )
268
+ return cmds
269
+
270
+
271
+ def _use_multiple_distractors_policy(rs, Command):
272
+ """The "wastes-turns-on-distractor-tools" failure mode: every
273
+ turn issue MULTIPLE non-move tool calls (set_stance, attack_move
274
+ on the units, build, harvest, set_rally_point) — each of which
275
+ REPLACES the units' active move order so the column never makes
276
+ monotonic east progress. The within_ticks deadline then bites.
277
+
278
+ This is the τ²-bench / BFCL V4 cluttered-API failure mode: a
279
+ model that "uses every tool the API exposes" because it sees a
280
+ salient distractor for each, instead of self-selecting the
281
+ relevant subset (move_units + observe)."""
282
+ units = [
283
+ u for u in (rs.get("units_summary") or [])
284
+ if not u.get("is_building", False)
285
+ ]
286
+ if not units:
287
+ return [Command.observe()]
288
+ cmds = []
289
+ # Set stance (re-issues an order to the units — replaces any
290
+ # prior move).
291
+ cmds.append(
292
+ Command.set_stance([str(u["id"]) for u in units], 3)
293
+ )
294
+ # attack_move to the off-path north garrison cell — pulls the
295
+ # column off-course.
296
+ for u in units:
297
+ cmds.append(
298
+ Command.attack_move([str(u["id"])], target_x=45, target_y=8)
299
+ )
300
+ # Build something irrelevant (cash is on hand, pbox is buildable).
301
+ cmds.append(Command.build("pbox"))
302
+ # No-op-tool calls: harvest with no harvester, set_rally_point
303
+ # with no producer.
304
+ cmds.append(
305
+ Command.harvest([str(units[0]["id"])], target_x=20, target_y=24)
306
+ )
307
+ cmds.append(
308
+ Command.set_rally_point([str(units[0]["id"])], target_x=15, target_y=15)
309
+ )
310
+ return cmds
311
+
312
+
313
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
314
+ def test_intended_policy_wins(level):
315
+ """The intended movement-only policy WINS on every level. On
316
+ easy/medium the column starts on y=20 and a direct beeline
317
+ suffices; on hard the column starts on y=5 or y=35 (round-robin
318
+ by seed) and the lane-respecting variant routes around the
319
+ central sentry stack and the off-path garrison."""
320
+ pytest.importorskip("openra_train")
321
+ from openra_bench.eval_core import run_level
322
+
323
+ c = compile_level(load_pack(PACK_PATH), level)
324
+ seeds = (1, 2, 3, 4)
325
+ pol = _intended_lane_policy if level == "hard" else _intended_direct_policy
326
+ for s in seeds:
327
+ res = run_level(c, pol, seed=s)
328
+ assert res.outcome == "win", (
329
+ f"{level} seed={s}: intended movement-only policy should "
330
+ f"WIN, got {res.outcome} after {res.turns} turns "
331
+ f"(lost={res.signals.units_lost}, "
332
+ f"tick={res.signals.game_tick}, "
333
+ f"tools_called={dict(res.signals.tools_called)})"
334
+ )
335
+ # Sanity: only move_units is invoked (the intended subset).
336
+ called = set(res.signals.tools_called.keys())
337
+ assert called <= {"move_units"}, (
338
+ f"{level} seed={s}: intended policy must call only "
339
+ f"move_units; got {dict(res.signals.tools_called)}"
340
+ )
341
+ # No tool_violations (the pack has no forbidden_tools, so
342
+ # this stays at 0 regardless — but check it as a contract).
343
+ assert res.signals.tool_violations == 0
344
+
345
+
346
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
347
+ def test_stall_policy_loses_on_deadline(level):
348
+ """Stall (only observe) LOSES on every level/seed because the
349
+ within_ticks deadline bites — the column never moves so the
350
+ fail_condition's after_ticks clause fires."""
351
+ pytest.importorskip("openra_train")
352
+ from openra_bench.eval_core import run_level
353
+
354
+ c = compile_level(load_pack(PACK_PATH), level)
355
+ seeds = (1, 2, 3, 4)
356
+ for s in seeds:
357
+ res = run_level(c, _stall_policy, seed=s)
358
+ assert res.outcome == "loss", (
359
+ f"{level} seed={s}: stall must LOSE on the deadline, got "
360
+ f"{res.outcome} after {res.turns} turns "
361
+ f"(tick={res.signals.game_tick})"
362
+ )
363
+ # Stall causes the LOSS via the deadline, not via casualty.
364
+ assert res.signals.units_lost == 0, (
365
+ f"{level} seed={s}: stall must not lose any units "
366
+ f"(no casualty trigger); got {res.signals.units_lost}"
367
+ )
368
+
369
+
370
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
371
+ def test_use_multiple_distractors_policy_loses(level):
372
+ """The "use-multiple-distractors-wastes-turns" failure mode LOSES
373
+ on every level/seed: the distractor tool calls each replace the
374
+ units' active move order so the column never makes monotonic
375
+ east progress, and the within_ticks deadline bites.
376
+
377
+ This IS the τ²-bench / BFCL V4 cluttered-API failure mode (a
378
+ model that fires every salient tool instead of picking only the
379
+ relevant subset)."""
380
+ pytest.importorskip("openra_train")
381
+ from openra_bench.eval_core import run_level
382
+
383
+ c = compile_level(load_pack(PACK_PATH), level)
384
+ seeds = (1, 2, 3, 4)
385
+ for s in seeds:
386
+ res = run_level(c, _use_multiple_distractors_policy, seed=s)
387
+ assert res.outcome == "loss", (
388
+ f"{level} seed={s}: use-multiple-distractors must LOSE, "
389
+ f"got {res.outcome} after {res.turns} turns "
390
+ f"(lost={res.signals.units_lost}, "
391
+ f"tick={res.signals.game_tick}, "
392
+ f"tools_called={dict(res.signals.tools_called)})"
393
+ )
394
+ # The distractor policy actively uses multiple non-move tools
395
+ # (the whole point of this failure mode). Sanity-check that
396
+ # it really did call ≥3 distinct distractor tools — otherwise
397
+ # the test isn't exercising the cluttered-API discriminator.
398
+ called = set(res.signals.tools_called.keys())
399
+ distractor_subset = called & {
400
+ "set_stance", "attack_move", "build",
401
+ "harvest", "set_rally_point",
402
+ }
403
+ assert len(distractor_subset) >= 3, (
404
+ f"{level} seed={s}: distractor policy must invoke ≥3 "
405
+ f"distinct distractor tools (the test of the cluttered-"
406
+ f"API failure mode); got distractors {sorted(distractor_subset)}"
407
+ )