yxc20098 commited on
Commit
918dae2
·
1 Parent(s): 73f3edd

feat(scenario): proc-instruction-following-edge-case — conditional SOP (IFBench / BFCL V4 anchor)

Browse files

Wave-6 IFBench / BFCL V4 negative-instruction adherence pack:
selective-action discipline. The brief carries an explicit
conditional rule — 'Move the JEEP units to (90,20). Do NOT move the
TANK units.' — and the bench measures whether the agent honours both
halves.

Win predicate uses the type-filtered region predicate
`units_of_type_in_region_gte` (Wave-2 / Group-N) to check BOTH:
1. ≥3 jeeps in goal region around (90,20).
2. ≥3 tanks STILL in start region around (8,20).

Fail mirrors clause 2 as a `not` clause so a single tank dragged
out of the start cluster trips IMMEDIATE fail — the IFBench
instruction-precision teeth.

Tiers (one new variable per step):
easy = bare positive instruction only (no negative teeth — calibration).
medium = +negative teeth (must-not-move tanks anchored at (8,20)).
hard = +2 agent spawn_point groups (jeeps NW vs SW), tanks
duplicated at (8..10, 20) across BOTH groups so the
must-not-move region is identical across seeds.

Scripted validation (4 levels × 4 seeds each):
stall → LOSS (clock; after_ticks 4501 within max_turns 55).
move-all → WIN on easy (no teeth); LOSS turn 1 on medium/hard
(negative-instruction teeth fire when tank leaves cluster).
jeeps-only → WIN (intended capability play).

Registered in tests/test_hard_tier.py::UPGRADED (≥2 jeep spawn
groups, tanks anchored at common region across seeds).

Real-world anchor: IFBench conditional/edge-case instructions; BFCL
V4 negative-instruction compliance; conditional SOP; selective-action
discipline (mixed fleet, act on subset, leave the rest).

openra_bench/scenarios/packs/proc-instruction-following-edge-case.yaml ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # proc-instruction-following-edge-case — IFBench conditional / negative-
2
+ # instruction adherence (selective-action discipline).
3
+ #
4
+ # The mission brief carries an EXPLICIT CONDITIONAL RULE — "do X, but
5
+ # NOT Y" — and the bench measures whether the agent honours the
6
+ # carve-out. The intended capability is *selective action*: identify
7
+ # the units the instruction targets, act on those, and LEAVE THE OTHER
8
+ # UNITS WHERE THEY STARTED. Touching the prohibited group at all
9
+ # (even moving them a single cell) is a procedural violation and
10
+ # fails the mission outright, regardless of what the jeeps achieve.
11
+ #
12
+ # Concretely: the agent owns a mixed column — JEEPS and MEDIUM TANKS
13
+ # (2tnk) — at the west edge (x=8, y∈[18..22]). The brief says:
14
+ # "Move the JEEP units to (90, 20). Do NOT move the TANK units."
15
+ # The win predicate uses the type-filtered region predicate
16
+ # `units_of_type_in_region_gte` to check BOTH halves of the
17
+ # instruction simultaneously:
18
+ #
19
+ # 1. ≥3 JEEPS in region around (90, 20) — the positive instruction.
20
+ # 2. ≥3 TANKS still in region around (8, 20) — the negative
21
+ # instruction (the tanks must remain at their start).
22
+ #
23
+ # Both clauses must hold at the same observation tick — a "move tanks
24
+ # then later return them" play is structurally hard within the budget
25
+ # and is the wrong skill anyway (the brief forbade the move). The
26
+ # failure side mirrors clause 2 as a `not units_of_type_in_region_gte`
27
+ # clause so a single tank dragged out of the start cluster trips fail
28
+ # IMMEDIATELY (no need to wait for the deadline). This is the
29
+ # IFBench-style "instruction-precision" teeth: violation is detected
30
+ # the moment the agent acts on the prohibited group.
31
+ #
32
+ # Failure modes (all real, reachable LOSSES; no draw degeneracy):
33
+ # • STALL (only `observe`): jeeps never reach (90,20); the
34
+ # within_ticks deadline fires → after_ticks fail.
35
+ # • MOVE-ALL (the model treats the order as "move everything east"
36
+ # and ignores the carve-out): the tanks leave the start cluster
37
+ # within a few turns → `not units_of_type_in_region_gte 2tnk` fail
38
+ # fires as soon as <3 tanks remain near (8,20). Even if the jeeps
39
+ # reach (90,20), the run is forfeit — the brief said "do NOT move
40
+ # the tanks" and the agent moved them.
41
+ # • TANKS-ONLY (the model fixates on the wrong half — only
42
+ # tanks moved): the tanks leave the start AND the jeeps never
43
+ # reach the goal → both fail clauses fire.
44
+ # • INTENDED (move JEEPS east to (90,20), issue NO order to the
45
+ # tanks): jeeps arrive on time, tanks remain at start → WIN.
46
+ #
47
+ # Real-world anchor: IFBench / instruction-precision benchmarks
48
+ # ("output only X, do not include Y"); BFCL V4 negative-instruction
49
+ # compliance ("call this tool but DO NOT call that one"); conditional
50
+ # SOP ("perform action A on units of class C, leave class D
51
+ # untouched"); robotic mixed-asset selective-task execution.
52
+ #
53
+ # Distinct from:
54
+ # • proc-no-attack-passive-only (B0): forbids a tool entirely
55
+ # (attack_unit / attack_move) — the carve-out is on TOOLS, not on
56
+ # a subset of UNITS. Here the agent IS allowed `move_units`; the
57
+ # constraint is the SET OF UNITS that may receive that command.
58
+ # • proc-strict-toolban-under-pressure: also a tool-level ban.
59
+ # • coord-squad-handoff: positive sequenced relay (both squads must
60
+ # move). Here exactly one squad must move and the other must NOT.
61
+ #
62
+ # Tick budget note: ~90 ticks/turn (tick ≈ 93 + 90·(turn-1)). With
63
+ # max_turns=55 the reachable max tick is 4953, so the fail
64
+ # `after_ticks: 4501` bites well within the budget (no draw). The
65
+ # intended jeep convoy travels ~82 cells; jeep speed is ~12 cells/turn
66
+ # so it arrives in ~7-10 turns (~tick 700-1000), well under the
67
+ # deadline.
68
+ #
69
+ # Hard-tier curation: hard defines 2 agent spawn_point groups for the
70
+ # JEEPS (NW vs SW staging — y=8 vs y=32), round-robined by seed; the
71
+ # TANKS are duplicated at the SAME start cell (8,20) across BOTH
72
+ # spawn groups (the must-not-move group is anchored to ONE region so
73
+ # the negative-instruction predicate is well-defined). This pack is
74
+ # in `tests/test_hard_tier.py::UPGRADED`.
75
+
76
+ meta:
77
+ id: proc-instruction-following-edge-case
78
+ title: 'Selective Action — Move the Jeeps, Do NOT Move the Tanks'
79
+ capability: action
80
+ real_world_meaning: >
81
+ Conditional standard-operating-procedure: the order has both a
82
+ positive and a negative clause ("do X to units of class C; do NOT
83
+ do anything to units of class D"). The operator's skill is to
84
+ identify the targeted subset, act on it, and leave the rest
85
+ untouched. Spraying the same action over the whole fleet is the
86
+ classic failure mode — and is what the IFBench / BFCL V4
87
+ negative-instruction families measure.
88
+ robotics_analogue: >
89
+ Mixed-asset fleet with a per-class action allowlist for this
90
+ mission: forklifts may move pallets; the autonomous floor
91
+ scrubber must stay docked. The fleet controller must NOT issue
92
+ a move command to the scrubber even if the scene is messy and
93
+ the scrubber would naively be the "obvious next agent" to act.
94
+ benchmark_anchor:
95
+ - "IFBench conditional / edge-case instruction"
96
+ - "BFCL V4 negative instruction compliance"
97
+ - "conditional SOP compliance"
98
+ - "selective-action discipline"
99
+ author: "openra-bench"
100
+
101
+ base_map: rush-hour-arena
102
+
103
+ # Shared engine fields. The agent's tools are the bare action set —
104
+ # move/attack/observe/stop — and the instruction's teeth are in the
105
+ # WIN PREDICATE (must keep the tanks at start), not a forbidden-tools
106
+ # list. This is deliberate: the IFBench / BFCL V4 anchor is
107
+ # "everything is technically *allowed*, but the brief forbids it" —
108
+ # the bench measures whether the agent self-restrains.
109
+ base:
110
+ agent: {faction: allies, cash: 0}
111
+ enemy: {faction: soviet, cash: 0}
112
+ tools: [move_units, attack_unit, observe, stop]
113
+ planning: true
114
+ termination: {max_ticks: 6000}
115
+ actors: [] # every level supplies its own actor list via overrides.
116
+
117
+ levels:
118
+ # ── EASY ─────────────────────────────────────────────────────────
119
+ # The bare selective-action skill: ONLY the positive clause is
120
+ # tested. 3 jeeps + 3 tanks at the west edge; jeeps must reach
121
+ # (90,20); no negative-clause teeth. A naive "move everything"
122
+ # policy CAN win on easy — easy is the calibration tier (the
123
+ # capability is the difficulty step on medium).
124
+ easy:
125
+ description: >
126
+ Move ALL THREE JEEP units to the region around (90, 20) within
127
+ radius 6, before tick 4500. The TANK units may be ignored
128
+ (easy tier: no negative-instruction teeth). Stalling and
129
+ timeout LOSE.
130
+ overrides:
131
+ actors:
132
+ # 3 jeeps + 3 medium tanks at the west edge.
133
+ - {type: jeep, owner: agent, position: [8, 18]}
134
+ - {type: jeep, owner: agent, position: [8, 19]}
135
+ - {type: jeep, owner: agent, position: [8, 21]}
136
+ - {type: 2tnk, owner: agent, position: [8, 20]}
137
+ - {type: 2tnk, owner: agent, position: [9, 20]}
138
+ - {type: 2tnk, owner: agent, position: [10, 20]}
139
+ win_condition:
140
+ all_of:
141
+ - {units_of_type_in_region_gte:
142
+ {type: jeep, x: 90, y: 20, radius: 6, n: 3}}
143
+ - {within_ticks: 4500}
144
+ fail_condition:
145
+ any_of:
146
+ - {after_ticks: 4501}
147
+ - {not: {own_units_gte: 1}}
148
+ max_turns: 55
149
+
150
+ # ── MEDIUM ───────────────────────────────────────────────────────
151
+ # +1 controlled variable: the NEGATIVE INSTRUCTION teeth come in.
152
+ # The brief now explicitly forbids moving the tanks. The win
153
+ # condition adds a second clause ("≥3 tanks still at start") and
154
+ # the fail condition adds the symmetric `not` clause so a single
155
+ # tank dragged out of the start cluster trips an IMMEDIATE fail
156
+ # (no need to wait for the deadline). This is the IFBench /
157
+ # BFCL V4 anchor cell — measuring whether the model honours the
158
+ # carve-out under no other pressure than the brief itself.
159
+ medium:
160
+ description: >
161
+ Move ALL THREE JEEP units to the region around (90, 20) within
162
+ radius 6, before tick 4500. Do NOT move the TANK units — all
163
+ three TANK (2tnk) units must remain in the start region around
164
+ (8, 20) within radius 6. Issuing ANY move order to a tank
165
+ breaks the tank-at-start clause as soon as a tank leaves the
166
+ cluster and FAILS the mission immediately. Stalling, moving a
167
+ tank, force-loss, and timeout all LOSE.
168
+ overrides:
169
+ actors:
170
+ - {type: jeep, owner: agent, position: [8, 18]}
171
+ - {type: jeep, owner: agent, position: [8, 19]}
172
+ - {type: jeep, owner: agent, position: [8, 21]}
173
+ - {type: 2tnk, owner: agent, position: [8, 20]}
174
+ - {type: 2tnk, owner: agent, position: [9, 20]}
175
+ - {type: 2tnk, owner: agent, position: [10, 20]}
176
+ win_condition:
177
+ all_of:
178
+ - {units_of_type_in_region_gte:
179
+ {type: jeep, x: 90, y: 20, radius: 6, n: 3}}
180
+ - {units_of_type_in_region_gte:
181
+ {type: 2tnk, x: 8, y: 20, radius: 6, n: 3}}
182
+ - {within_ticks: 4500}
183
+ fail_condition:
184
+ any_of:
185
+ - {after_ticks: 4501}
186
+ - {not: {own_units_gte: 1}}
187
+ - {not: {units_of_type_in_region_gte:
188
+ {type: 2tnk, x: 8, y: 20, radius: 6, n: 3}}}
189
+ max_turns: 55
190
+
191
+ # ── HARD ─────────────────────────────────────────────────────────
192
+ # +1 controlled variable vs medium: the JEEP starting corner is
193
+ # seed-randomised (NW staging y=8 vs SW staging y=32 — 2 agent
194
+ # spawn_point groups, round-robined by seed). The TANKS always
195
+ # start clustered at (8..10, 20) — the must-not-move group is
196
+ # ANCHORED to one region so the negative-instruction predicate is
197
+ # well-defined and identical across seeds. The selective-action
198
+ # skill is unchanged; the spawn variation prevents memorised
199
+ # opening generalisation (test_hard_tier.py::UPGRADED contract).
200
+ hard:
201
+ description: >
202
+ Move ALL THREE JEEP units to the region around (90, 20) within
203
+ radius 6, before tick 4500 — your jeeps may start at the
204
+ NORTHWEST (y≈8) OR SOUTHWEST (y≈32) corner depending on the
205
+ seed; route accordingly. Do NOT move the TANK units — all
206
+ three TANK (2tnk) units must remain in the start region around
207
+ (8, 20) within radius 6. Issuing any move order to a tank
208
+ breaks the tank-at-start clause as soon as a tank leaves the
209
+ cluster and FAILS the mission immediately. Stalling, moving a
210
+ tank, force-loss, and timeout all LOSE.
211
+ overrides:
212
+ actors:
213
+ # spawn_point 0 — NW jeep staging (y≈8).
214
+ - {type: jeep, owner: agent, position: [8, 7], spawn_point: 0}
215
+ - {type: jeep, owner: agent, position: [8, 8], spawn_point: 0}
216
+ - {type: jeep, owner: agent, position: [8, 9], spawn_point: 0}
217
+ # spawn_point 1 — SW jeep staging (y≈32).
218
+ - {type: jeep, owner: agent, position: [8, 31], spawn_point: 1}
219
+ - {type: jeep, owner: agent, position: [8, 32], spawn_point: 1}
220
+ - {type: jeep, owner: agent, position: [8, 33], spawn_point: 1}
221
+ # TANKS are anchored at (8..10, 20) in BOTH spawn groups
222
+ # (the must-not-move region is identical across seeds).
223
+ # CLAUDE.md: if ANY agent actor declares spawn_point, every
224
+ # agent actor WITHOUT spawn_point is filtered OUT — so the
225
+ # tanks are duplicated across both groups at identical coords.
226
+ - {type: 2tnk, owner: agent, position: [8, 20], spawn_point: 0}
227
+ - {type: 2tnk, owner: agent, position: [9, 20], spawn_point: 0}
228
+ - {type: 2tnk, owner: agent, position: [10, 20], spawn_point: 0}
229
+ - {type: 2tnk, owner: agent, position: [8, 20], spawn_point: 1}
230
+ - {type: 2tnk, owner: agent, position: [9, 20], spawn_point: 1}
231
+ - {type: 2tnk, owner: agent, position: [10, 20], spawn_point: 1}
232
+ win_condition:
233
+ all_of:
234
+ - {units_of_type_in_region_gte:
235
+ {type: jeep, x: 90, y: 20, radius: 6, n: 3}}
236
+ - {units_of_type_in_region_gte:
237
+ {type: 2tnk, x: 8, y: 20, radius: 6, n: 3}}
238
+ - {within_ticks: 4500}
239
+ fail_condition:
240
+ any_of:
241
+ - {after_ticks: 4501}
242
+ - {not: {own_units_gte: 1}}
243
+ - {not: {units_of_type_in_region_gte:
244
+ {type: 2tnk, x: 8, y: 20, radius: 6, n: 3}}}
245
+ max_turns: 55
tests/test_hard_tier.py CHANGED
@@ -359,6 +359,18 @@ UPGRADED = [
359
  # sit on the spawn-matched row so throughput is symmetric per
360
  # spawn, but a memorised opening cannot generalise across seeds.
361
  "econ-cash-reserve-management",
 
 
 
 
 
 
 
 
 
 
 
 
362
  ]
363
 
364
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
359
  # sit on the spawn-matched row so throughput is symmetric per
360
  # spawn, but a memorised opening cannot generalise across seeds.
361
  "econ-cash-reserve-management",
362
+ # Wave-6 IFBench / BFCL V4 negative-instruction seed — conditional
363
+ # SOP / selective-action discipline anchor. The brief carries an
364
+ # explicit carve-out ("move the JEEPS to (90,20); do NOT move the
365
+ # TANKS"); win checks BOTH halves via type-filtered region
366
+ # (`units_of_type_in_region_gte`). Hard tier defines 2 agent
367
+ # spawn_point groups (NW jeep staging y≈8 / SW jeep staging y≈32)
368
+ # round-robined by seed; the TANKS are duplicated at (8..10, 20)
369
+ # across BOTH groups so the must-not-move region is identical and
370
+ # well-defined across seeds. The spawn variation prevents memorised
371
+ # opening generalisation without diluting the selective-action
372
+ # signal.
373
+ "proc-instruction-following-edge-case",
374
  ]
375
 
376
  # Consciously NOT spawn-varied, with the reason (keeps the curation
tests/test_proc_instruction_following_edge_case.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """proc-instruction-following-edge-case: IFBench / BFCL V4 negative
2
+ instruction adherence (selective-action discipline).
3
+
4
+ The brief carries an EXPLICIT CONDITIONAL RULE — "move the JEEP units
5
+ to (90,20); do NOT move the TANK units." The bench measures whether
6
+ the agent honours BOTH halves of the instruction. The win predicate
7
+ checks:
8
+
9
+ 1. ≥3 jeeps in the goal region around (90,20).
10
+ 2. ≥3 tanks STILL in the start region around (8,20).
11
+
12
+ The fail predicate mirrors clause 2 as a `not` clause so a single tank
13
+ dragged out of the start cluster trips an IMMEDIATE loss (no need to
14
+ wait for the deadline) — that is the IFBench instruction-precision
15
+ teeth.
16
+
17
+ The bar (per CLAUDE.md) must hold on every level × every hard seed:
18
+
19
+ - STALL -> LOSS (clock)
20
+ - MOVE-ALL -> WIN on easy (no negative teeth), LOSS on
21
+ medium/hard (touching the tanks fails fast)
22
+ - JEEPS-ONLY -> WIN (the intended capability play)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
31
+
32
+ from openra_bench.scenarios import load_pack
33
+ from openra_bench.scenarios.loader import compile_level
34
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
35
+
36
+ PACK = (
37
+ Path(__file__).parent.parent
38
+ / "openra_bench"
39
+ / "scenarios"
40
+ / "packs"
41
+ / "proc-instruction-following-edge-case.yaml"
42
+ )
43
+
44
+
45
+ def _win_clauses(c):
46
+ return dict(c.win_condition.__pydantic_extra__ or {})["all_of"]
47
+
48
+
49
+ def _fail_clauses(c):
50
+ return dict(c.fail_condition.__pydantic_extra__ or {})["any_of"]
51
+
52
+
53
+ # ── A. STRUCTURAL: predicate / clauses / deadlines wired correctly ───
54
+
55
+ def test_easy_has_positive_clause_only():
56
+ """Easy is the calibration tier — only the positive instruction
57
+ (jeeps reach (90,20)) is in the win predicate. Moving the tanks
58
+ is harmless on easy (the negative teeth come in on medium)."""
59
+ c = compile_level(load_pack(PACK), "easy")
60
+ win = _win_clauses(c)
61
+ type_clauses = [cl for cl in win if "units_of_type_in_region_gte" in cl]
62
+ types = [cl["units_of_type_in_region_gte"]["type"] for cl in type_clauses]
63
+ assert types == ["jeep"], f"easy: expected only the jeep clause, got {types}"
64
+
65
+
66
+ @pytest.mark.parametrize("level", ["medium", "hard"])
67
+ def test_medium_and_hard_enforce_both_halves_of_the_instruction(level):
68
+ """Medium and hard MUST encode BOTH:
69
+ - positive: ≥3 jeeps at goal (90,20) and
70
+ - negative: ≥3 tanks still at start (8,20).
71
+ Anything less and the bench fails to enforce the carve-out."""
72
+ c = compile_level(load_pack(PACK), level)
73
+ win = _win_clauses(c)
74
+ type_clauses = [cl for cl in win if "units_of_type_in_region_gte" in cl]
75
+ by_type = {cl["units_of_type_in_region_gte"]["type"]: cl["units_of_type_in_region_gte"]
76
+ for cl in type_clauses}
77
+ assert "jeep" in by_type, f"{level}: missing positive jeep clause"
78
+ assert by_type["jeep"]["x"] == 90 and by_type["jeep"]["y"] == 20, (
79
+ f"{level}: jeep goal must be (90,20), got "
80
+ f"({by_type['jeep']['x']},{by_type['jeep']['y']})"
81
+ )
82
+ assert by_type["jeep"]["n"] >= 3, f"{level}: jeep clause needs n>=3"
83
+ assert "2tnk" in by_type, f"{level}: missing negative tanks-at-start clause"
84
+ assert by_type["2tnk"]["x"] == 8 and by_type["2tnk"]["y"] == 20, (
85
+ f"{level}: tanks-at-start must be (8,20), got "
86
+ f"({by_type['2tnk']['x']},{by_type['2tnk']['y']})"
87
+ )
88
+ assert by_type["2tnk"]["n"] >= 3, f"{level}: tanks clause needs n>=3"
89
+
90
+
91
+ @pytest.mark.parametrize("level", ["medium", "hard"])
92
+ def test_medium_and_hard_have_immediate_violation_fail_clause(level):
93
+ """The fail predicate must include a `not units_of_type_in_region
94
+ _gte 2tnk` clause so moving a tank trips fail IMMEDIATELY (no
95
+ wait for deadline)."""
96
+ c = compile_level(load_pack(PACK), level)
97
+ fails = _fail_clauses(c)
98
+ not_clauses = [cl["not"] for cl in fails if "not" in cl]
99
+ has_immediate = any(
100
+ "units_of_type_in_region_gte" in nc
101
+ and nc["units_of_type_in_region_gte"]["type"] == "2tnk"
102
+ for nc in not_clauses
103
+ )
104
+ assert has_immediate, (
105
+ f"{level}: fail_condition must include `not units_of_type_in"
106
+ f"_region_gte 2tnk` so moving a tank trips IMMEDIATE fail"
107
+ )
108
+
109
+
110
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
111
+ def test_level_has_binding_deadline_and_reachable_loss(level):
112
+ c = compile_level(load_pack(PACK), level)
113
+ win = _win_clauses(c)
114
+ wt = [cl["within_ticks"] for cl in win if "within_ticks" in cl]
115
+ assert wt, f"{level}: missing within_ticks deadline"
116
+ # The deadline must bite within max_turns (engine ~90 ticks/turn).
117
+ assert wt[0] < 93 + 90 * (c.max_turns - 1), (
118
+ f"{level}: within_ticks {wt[0]} unreachable inside max_turns "
119
+ f"{c.max_turns} (would draw on timeout)"
120
+ )
121
+ fail = _fail_clauses(c)
122
+ at = [cl["after_ticks"] for cl in fail if "after_ticks" in cl]
123
+ assert at, f"{level}: missing after_ticks in fail_condition"
124
+ assert at[0] <= 93 + 90 * (c.max_turns - 1), (
125
+ f"{level}: after_ticks {at[0]} unreachable inside max_turns "
126
+ f"{c.max_turns} (would draw on timeout)"
127
+ )
128
+
129
+
130
+ def test_hard_has_two_spawn_groups_for_jeeps():
131
+ """Hard tier must have ≥2 jeep spawn_point groups (the
132
+ test_hard_tier.py::UPGRADED contract). Tanks are anchored at
133
+ (8..10, 20) across BOTH groups so the negative-instruction
134
+ predicate is identical and well-defined across seeds."""
135
+ c = compile_level(load_pack(PACK), "hard")
136
+ agent_actors = [a for a in c.scenario.actors if a.owner == "agent"]
137
+ sp = {(a.spawn_point if a.spawn_point is not None else 0) for a in agent_actors}
138
+ assert len(sp) >= 2, (
139
+ f"hard: must define ≥2 agent spawn_point groups; got {sorted(sp)}"
140
+ )
141
+ # Tanks must be duplicated in EVERY spawn group at the same coords
142
+ # so the negative-instruction predicate is identical across seeds.
143
+ tank_cells_per_group = {}
144
+ for a in agent_actors:
145
+ if a.type == "2tnk":
146
+ g = a.spawn_point if a.spawn_point is not None else 0
147
+ tank_cells_per_group.setdefault(g, set()).add(tuple(a.position))
148
+ assert len(tank_cells_per_group) >= 2, (
149
+ "hard: tanks must be present in BOTH spawn groups"
150
+ )
151
+ cell_sets = list(tank_cells_per_group.values())
152
+ assert all(s == cell_sets[0] for s in cell_sets), (
153
+ f"hard: tanks must occupy the SAME cells across spawn groups; "
154
+ f"got {tank_cells_per_group}"
155
+ )
156
+
157
+
158
+ # ── B. PREDICATE UNIT TEST: tank-at-start negative clause semantics ──
159
+
160
+ class _Sig:
161
+ def __init__(self):
162
+ self.game_tick = 100
163
+ self.then_progress: dict = {}
164
+
165
+
166
+ def _ctx(units):
167
+ return WinContext(signals=_Sig(), render_state={"units_summary": units})
168
+
169
+
170
+ def test_tanks_still_at_start_is_satisfied_by_unmoved_tanks():
171
+ spec = {"units_of_type_in_region_gte":
172
+ {"type": "2tnk", "x": 8, "y": 20, "radius": 6, "n": 3}}
173
+ tanks = [
174
+ {"type": "2tnk", "cell_x": 8, "cell_y": 20, "id": "1"},
175
+ {"type": "2tnk", "cell_x": 9, "cell_y": 20, "id": "2"},
176
+ {"type": "2tnk", "cell_x": 10, "cell_y": 20, "id": "3"},
177
+ ]
178
+ assert evaluate(spec, _ctx(tanks)) is True
179
+
180
+
181
+ def test_tanks_still_at_start_breaks_the_moment_a_tank_leaves_cluster():
182
+ """The negative-instruction teeth: as soon as <3 tanks remain in
183
+ the start region, the clause becomes False — so the fail-side
184
+ `not` clause fires immediately."""
185
+ spec = {"units_of_type_in_region_gte":
186
+ {"type": "2tnk", "x": 8, "y": 20, "radius": 6, "n": 3}}
187
+ # Two tanks still at start, one dragged east — predicate is False.
188
+ moved = [
189
+ {"type": "2tnk", "cell_x": 8, "cell_y": 20, "id": "1"},
190
+ {"type": "2tnk", "cell_x": 9, "cell_y": 20, "id": "2"},
191
+ {"type": "2tnk", "cell_x": 40, "cell_y": 20, "id": "3"},
192
+ ]
193
+ assert evaluate(spec, _ctx(moved)) is False
194
+ # And the corresponding `not` (which IS the fail clause) is True.
195
+ not_spec = {"not": spec}
196
+ assert evaluate(not_spec, _ctx(moved)) is True
197
+
198
+
199
+ def test_jeeps_do_not_satisfy_the_tanks_at_start_clause():
200
+ """Type-filter teeth: a jeep parked at the tanks' start does NOT
201
+ count toward the 2tnk-at-start clause (the carve-out is on UNIT
202
+ TYPE, not on cell occupancy)."""
203
+ spec = {"units_of_type_in_region_gte":
204
+ {"type": "2tnk", "x": 8, "y": 20, "radius": 6, "n": 3}}
205
+ jeeps = [
206
+ {"type": "jeep", "cell_x": 8, "cell_y": 20, "id": "j1"},
207
+ {"type": "jeep", "cell_x": 9, "cell_y": 20, "id": "j2"},
208
+ {"type": "jeep", "cell_x": 10, "cell_y": 20, "id": "j3"},
209
+ ]
210
+ assert evaluate(spec, _ctx(jeeps)) is False
211
+
212
+
213
+ # ── C. SOLVENCY / NO-CHEAT: scripted policies on the live engine ─────
214
+
215
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
216
+ from openra_bench.eval_core import run_level # noqa: E402
217
+
218
+
219
+ def _split(rs):
220
+ us = rs.get("units_summary", []) or []
221
+ jeeps = [str(u["id"]) for u in us if u.get("type") == "jeep"]
222
+ tanks = [str(u["id"]) for u in us if u.get("type") == "2tnk"]
223
+ return jeeps, tanks
224
+
225
+
226
+ def _stall(rs, C):
227
+ return [C.observe()]
228
+
229
+
230
+ def _move_all(rs, C):
231
+ """The classic IFBench failure mode: model treats the brief as
232
+ 'move everything east' and ignores the carve-out. On medium and
233
+ hard the negative-instruction teeth fire the moment a tank
234
+ leaves the start cluster."""
235
+ jeeps, tanks = _split(rs)
236
+ cmds = []
237
+ if jeeps:
238
+ cmds.append(C.move_units(jeeps, 90, 20))
239
+ if tanks:
240
+ cmds.append(C.move_units(tanks, 90, 20))
241
+ return cmds or [C.observe()]
242
+
243
+
244
+ def _jeeps_only(rs, C):
245
+ """The intended capability play: move ONLY the jeeps to the goal,
246
+ issue NO order to the tanks. The tanks stay at their start and
247
+ both halves of the instruction are honoured."""
248
+ jeeps, _ = _split(rs)
249
+ if not jeeps:
250
+ return [C.observe()]
251
+ return [C.move_units(jeeps, 90, 20)]
252
+
253
+
254
+ # Seeds 1..4 = held-out seed contract from CLAUDE.md.
255
+
256
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
257
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
258
+ def test_stall_loses(level, seed):
259
+ c = compile_level(load_pack(PACK), level)
260
+ res = run_level(c, _stall, seed=seed)
261
+ assert res.outcome == "loss", (
262
+ f"{level} seed{seed}: stall must LOSE on timeout, got {res.outcome}"
263
+ )
264
+
265
+
266
+ @pytest.mark.parametrize("level", ["medium", "hard"])
267
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
268
+ def test_move_all_loses_when_negative_teeth_present(level, seed):
269
+ """On medium/hard the brief forbids moving the tanks; spraying
270
+ move-east across both squads must LOSE the moment a tank leaves
271
+ the start cluster."""
272
+ c = compile_level(load_pack(PACK), level)
273
+ res = run_level(c, _move_all, seed=seed)
274
+ assert res.outcome == "loss", (
275
+ f"{level} seed{seed}: move-all (touched the tanks) must LOSE, "
276
+ f"got {res.outcome}"
277
+ )
278
+
279
+
280
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
281
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
282
+ def test_jeeps_only_wins(level, seed):
283
+ """The intended selective-action policy must WIN on every level
284
+ × every seed."""
285
+ c = compile_level(load_pack(PACK), level)
286
+ res = run_level(c, _jeeps_only, seed=seed)
287
+ assert res.outcome == "win", (
288
+ f"{level} seed{seed}: jeeps-only (intended play) must WIN, "
289
+ f"got {res.outcome}"
290
+ )