yxc20098 commited on
Commit
21e3501
·
1 Parent(s): 071fbf5

feat(scenario): proc-checklist-no-deviation — strict ordered visit-checklist (IFBench / PlanBench anchor)

Browse files

Wave-6 ACTION scenario isolating literal step-order compliance under
a restricted action API. The procedure is GIVEN as an ordered named
CHECKLIST of waypoints; the model must visit them in the EXACT
prescribed order with only {move_units, observe, stop}. Order is
enforced by the stateful, latched waypoint_sequence predicate (n:1):
out-of-order, skip-one, beeline-to-final, and idle policies can never
satisfy it — only the literal in-order execution wins.

Real-world / benchmark anchors:
- IFBench step-order compliance
- PlanBench strict ordering
- aviation pre-flight checklist
- SOP no-skip / no-reorder

Difficulty ladder (one controlled variable per tier):
- easy: 3 waypoints, generous clock (within_ticks 2900, max_turns 34)
- medium: 4 waypoints, tighter clock (within_ticks 3800, max_turns 44)
- hard: 5 waypoints + 2 spawn_point groups (FAR-WEST x=6 vs
NEAR-WEST x=20, both mid-y) (within_ticks 4500, max_turns 52)

No-defect / no-cheat bar (verified on every level × seed 1..4):
- intended in-order policy WINS on every cell
- stall / reorder (swap W1<->W2) / skip-one (drop middle) all LOSE
- within_ticks ≤ reachable tick = 93+90·(max_turns-1)
- after_ticks = within_ticks + 1 (real reachable timeout LOSS, not draw)
- hard tier has 2 distinct seed-driven spawn clusters
- tools allowlist is exactly {move_units, observe, stop}
- _NO_ENEMY pack: only fail path is the timeout (combat fails absent)

Geometry note: waypoints alternate NORTH (y=4) / SOUTH (y=36) /
EAST-CENTER (y=20). All start clusters are at mid-y (y=18..22) so the
canonical east-then-turn engine pathfinder cannot accidentally clip
an earlier waypoint's radius on a beeline to a later one — the latch
genuinely tests literal in-order discipline, not path coincidence.

Registered in tests/test_hard_tier.py::UPGRADED and tests/test_hard_
tier.py::_NO_ENEMY (no force-loss-by-combat clause is structurally
impossible on a no-enemy map; the timeout LOSS is the sole reachable
fail and was verified reachable inside max_turns).

Model smoke (Qwen3.6-Plus via together, medium seed=1): loss with
composite 0.2162 (P=0.0279 R=0.4248 A=1.0) — valid discrimination
(the model failed the strict ordered checklist; no tool violations).

openra_bench/scenarios/packs/proc-checklist-no-deviation.yaml ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate packs/proc-checklist-no-deviation.yaml
2
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
3
+ #
4
+ # ACTION focus — PROCEDURAL CHECKLIST, NO DEVIATION: the procedure is
5
+ # GIVEN as an ORDERED named checklist of waypoints; the model must
6
+ # visit them in the EXACT prescribed order with a restricted action API
7
+ # (`move_units, observe, stop`). Order is enforced by the stateful,
8
+ # latched `waypoint_sequence` predicate (n:1): Wk+1 only counts AFTER
9
+ # Wk was tagged, so a beeline-to-final, an out-of-order visit, a
10
+ # skipped step, or an idle can NEVER satisfy it. Only the exact
11
+ # in-order execution of EVERY step on the checklist wins. This is the
12
+ # bench's literal SOP / pre-flight-checklist analogue (no skip / no
13
+ # reorder / no improvisation) — the IFBench step-order axis isolated.
14
+ #
15
+ # No enemy on this map (rush-hour-arena, 128x40) — nothing can destroy
16
+ # the force, so the ONLY real failure is the timeout: a reachable
17
+ # `fail after_ticks` strictly past the within_ticks deadline. Stalling,
18
+ # skipping, or reordering all run out the clock ⇒ a real LOSS, never
19
+ # a draw. The `not own_units_gte:1` clause in fail is kept as belt-
20
+ # and-suspenders (the agent starts with 3 jeeps, so it never mis-fires
21
+ # on turn 1, and a degenerate engine event that wipes the force would
22
+ # still LOSE rather than draw). Keeps the _NO_ENEMY classification
23
+ # valid: no FORCE-LOSS-by-combat clause.
24
+ #
25
+ # Tick budget: the engine advances ~90 ticks per decision turn
26
+ # (tick ≈ 93 + 90·(turn-1)). Every within_ticks / after_ticks here is
27
+ # strictly ≤ 93 + 90·(max_turns-1) so the deadline (and therefore the
28
+ # timeout LOSS) is genuinely reachable inside max_turns — never inert.
29
+ # An idle jeep at the far edge does NOT trigger engine auto-done on
30
+ # this map (verified for the sibling strict-sequence pack: 50-turn
31
+ # idle stays alive), so the timeout LOSS is the sole reliable fail.
32
+ #
33
+ # Geometry note: the 3 jeeps start in a tight column at y≈20 (mid-y),
34
+ # and the checklist waypoints alternate NORTH / SOUTH / EAST-CENTER so
35
+ # a beeline to a later step cannot coincidentally cross an earlier
36
+ # step's radius — the latch genuinely tests literal in-order visit
37
+ # discipline rather than path coincidence.
38
+
39
+ meta:
40
+ id: proc-checklist-no-deviation
41
+ title: 'Procedural Checklist — No Deviation, No Skip, No Reorder'
42
+ capability: action
43
+ real_world_meaning: >
44
+ Follow an explicit ordered checklist with zero deviation: every
45
+ named step must be performed in the exact prescribed order. The
46
+ decision is literal ORDER compliance under a restricted action API
47
+ — not where to go or how, but WHEN each step is performed relative
48
+ to the others. Skipping a step, reordering steps, rushing straight
49
+ to the final step, or idling all fail the procedure. The bench
50
+ analogue of an aviation pre-flight checklist (where the order of
51
+ checks is a flight-safety property, not a stylistic preference)
52
+ and SOP compliance generally (no skip / no reorder).
53
+ robotics_analogue: >
54
+ Procedural compliance: a robot must satisfy a named ordered
55
+ checklist (visit checkpoint A, then B, then C, then D) within a
56
+ cycle-time budget where step ORDER is graded, not just the end
57
+ state — an out-of-order visit, a skipped step, or an idle all
58
+ fail the procedure inspection.
59
+ benchmark_anchor:
60
+ - IFBench step-order compliance
61
+ - PlanBench strict ordering
62
+ - aviation pre-flight checklist
63
+ - SOP no-skip / no-reorder
64
+ author: openra-bench
65
+
66
+ base_map: rush-hour-arena
67
+
68
+ base:
69
+ agent:
70
+ faction: allies
71
+ cash: 0
72
+ enemy:
73
+ faction: soviet
74
+ cash: 0
75
+ bot_type: ''
76
+ tools:
77
+ - move_units
78
+ - observe
79
+ - stop
80
+ # Three jeeps in a tight column at the WEST edge, mid-y. Waypoints
81
+ # are placed so that the canonical engine pathfinder (east-then-
82
+ # turn) on a beeline-to-later move cannot coincidentally cross an
83
+ # earlier waypoint's radius — the latch genuinely tests literal
84
+ # in-order visit discipline.
85
+ actors:
86
+ - type: jeep
87
+ owner: agent
88
+ position: [6, 18]
89
+ stance: 0
90
+ health: 100
91
+ facing: -1
92
+ count: 1
93
+ - type: jeep
94
+ owner: agent
95
+ position: [6, 20]
96
+ stance: 0
97
+ health: 100
98
+ facing: -1
99
+ count: 1
100
+ - type: jeep
101
+ owner: agent
102
+ position: [6, 22]
103
+ stance: 0
104
+ health: 100
105
+ facing: -1
106
+ count: 1
107
+
108
+ levels:
109
+ # ── EASY ── bare skill. THREE-step checklist alternating N / S /
110
+ # E-CENTER. The geometry deliberately makes a beeline-to-final
111
+ # impossible to satisfy the latch (W3 at mid-y, W1 north, W2 south
112
+ # — a straight east-bound move from the start column does NOT pass
113
+ # within radius 5 of W1 or W2). max_turns 34 → reachable tick
114
+ # 93+90·33 = 3063. Generous within_ticks (2900) so the in-order
115
+ # play has comfortable headroom while a staller / skipper LOSES.
116
+ easy:
117
+ description: >
118
+ Follow the GIVEN ordered checklist using ONLY move_units /
119
+ observe / stop. Visit the THREE named checkpoints STRICTLY IN
120
+ ORDER:
121
+ Step 1: S1 ≈ (40, 4) [NORTH edge]
122
+ Step 2: S2 ≈ (75, 36) [SOUTH edge]
123
+ Step 3: S3 ≈ (118, 20) [EAST-CENTER]
124
+ Reaching a later checkpoint before its predecessor does NOT
125
+ count and does NOT skip ahead — the checklist is latched.
126
+ Complete every step in order before the deadline or you lose.
127
+ win_condition:
128
+ all_of:
129
+ - waypoint_sequence:
130
+ id: CL-E
131
+ n: 1
132
+ radius: 5
133
+ points:
134
+ - {x: 40, y: 4}
135
+ - {x: 75, y: 36}
136
+ - {x: 118, y: 20}
137
+ - {within_ticks: 2900}
138
+ fail_condition:
139
+ any_of:
140
+ - {after_ticks: 2901}
141
+ - {not: {own_units_gte: 1}}
142
+ max_turns: 34
143
+
144
+ # ── MEDIUM ── +1 controlled variable vs easy: ONE MORE checklist
145
+ # step (4 named checkpoints, longer serpentine). Same strict-order
146
+ # latch, tighter clock. max_turns 44 → reachable tick 93+90·43 = 3963.
147
+ medium:
148
+ description: >
149
+ Follow the GIVEN ordered checklist using ONLY move_units /
150
+ observe / stop. Visit the FOUR named checkpoints STRICTLY IN
151
+ ORDER:
152
+ Step 1: S1 ≈ (40, 4) [NORTH edge]
153
+ Step 2: S2 ≈ (75, 36) [SOUTH edge]
154
+ Step 3: S3 ≈ (95, 4) [NORTH-east]
155
+ Step 4: S4 ≈ (118, 36) [SOUTH-east corner]
156
+ A later checkpoint reached early does NOT count and does NOT
157
+ advance the checklist; a skipped step can never be recovered.
158
+ Run the full checklist in order before the deadline or you lose.
159
+ win_condition:
160
+ all_of:
161
+ - waypoint_sequence:
162
+ id: CL-M
163
+ n: 1
164
+ radius: 5
165
+ points:
166
+ - {x: 40, y: 4}
167
+ - {x: 75, y: 36}
168
+ - {x: 95, y: 4}
169
+ - {x: 118, y: 36}
170
+ - {within_ticks: 3800}
171
+ fail_condition:
172
+ any_of:
173
+ - {after_ticks: 3801}
174
+ - {not: {own_units_gte: 1}}
175
+ max_turns: 44
176
+
177
+ # ── HARD ── +1 controlled variable vs medium: ONE MORE checklist
178
+ # step (5 named checkpoints, longer serpentine) AND a seed-chosen
179
+ # staging COLUMN (two spawn_point groups: FAR-WEST cluster at x=6
180
+ # or NEAR-WEST cluster at x=20, both at mid-y) so the exact timed
181
+ # ordered execution cannot be replayed from memory — the same
182
+ # ordered checklist must be re-run from whichever start the seed
183
+ # picks. Both staging columns are at mid-y so the canonical east-
184
+ # then-turn engine pathfinder doesn't accidentally clip the
185
+ # northern (y=4) or southern (y=36) waypoints en route. Tight
186
+ # clock; timeout is the only reachable LOSS (no enemy ⇒ no other
187
+ # combat fail). max_turns 52 → reachable tick 93+90·51 = 4683.
188
+ hard:
189
+ description: >
190
+ Follow the GIVEN ordered checklist (ONLY move_units / observe /
191
+ stop) from a seed-chosen staging column. Visit the FIVE named
192
+ checkpoints STRICTLY IN ORDER:
193
+ Step 1: S1 ≈ (40, 4) [NORTH edge]
194
+ Step 2: S2 ≈ (75, 36) [SOUTH edge]
195
+ Step 3: S3 ≈ (95, 4) [NORTH-east]
196
+ Step 4: S4 ≈ (118, 36) [SOUTH-east corner]
197
+ Step 5: S5 ≈ (118, 20) [EAST-CENTER]
198
+ The checklist is latched: a later checkpoint reached early does
199
+ not count or skip ahead, a skipped step is unrecoverable.
200
+ Complete every step in order within the tight deadline.
201
+ overrides:
202
+ actors:
203
+ # spawn_point 0 — FAR-WEST staging column (seed may pick).
204
+ - {type: jeep, owner: agent, position: [6, 18], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 0}
205
+ - {type: jeep, owner: agent, position: [6, 20], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 0}
206
+ - {type: jeep, owner: agent, position: [6, 22], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 0}
207
+ # spawn_point 1 — NEAR-WEST staging column (same checklist).
208
+ - {type: jeep, owner: agent, position: [20, 18], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 1}
209
+ - {type: jeep, owner: agent, position: [20, 20], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 1}
210
+ - {type: jeep, owner: agent, position: [20, 22], stance: 0, health: 100, facing: -1, count: 1, spawn_point: 1}
211
+ win_condition:
212
+ all_of:
213
+ - waypoint_sequence:
214
+ id: CL-H
215
+ n: 1
216
+ radius: 5
217
+ points:
218
+ - {x: 40, y: 4}
219
+ - {x: 75, y: 36}
220
+ - {x: 95, y: 4}
221
+ - {x: 118, y: 36}
222
+ - {x: 118, y: 20}
223
+ - {within_ticks: 4500}
224
+ fail_condition:
225
+ any_of:
226
+ - {after_ticks: 4501}
227
+ - {not: {own_units_gte: 1}}
228
+ max_turns: 52
tests/test_hard_tier.py CHANGED
@@ -400,6 +400,14 @@ UPGRADED = [
400
  # bands (y=12 / y=28) punishes the near-edge shortcut so a
401
  # memorised single-detour-band opening cannot generalise.
402
  "scout-and-report",
 
 
 
 
 
 
 
 
403
  ]
404
 
405
  # Consciously NOT spawn-varied, with the reason (keeps the curation
@@ -467,7 +475,7 @@ NOT_APPLICABLE = {
467
 
468
  # No-adversary maps: spawn variation applies but a force-loss
469
  # fail_condition is impossible (nothing can destroy the force).
470
- _NO_ENEMY = {"strict-sequence", "custom-map-no-enemy"}
471
 
472
 
473
  def _agent_spawn_points(pack_id: str, level: str) -> set:
 
400
  # bands (y=12 / y=28) punishes the near-edge shortcut so a
401
  # memorised single-detour-band opening cannot generalise.
402
  "scout-and-report",
403
+ # Wave-6 proc-checklist-no-deviation: strict ordered visit-
404
+ # checklist (IFBench step-order / PlanBench strict ordering /
405
+ # aviation pre-flight / SOP no-skip-no-reorder anchor). Hard
406
+ # tier defines 2 spawn_point groups (FAR-WEST x=6 vs NEAR-WEST
407
+ # x=20, both at mid-y so the canonical east-then-turn pathfinder
408
+ # cannot accidentally clip the northern (y=4) or southern (y=36)
409
+ # waypoints en route). _NO_ENEMY pack — only fail is the timeout.
410
+ "proc-checklist-no-deviation",
411
  ]
412
 
413
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
475
 
476
  # No-adversary maps: spawn variation applies but a force-loss
477
  # fail_condition is impossible (nothing can destroy the force).
478
+ _NO_ENEMY = {"strict-sequence", "custom-map-no-enemy", "proc-checklist-no-deviation"}
479
 
480
 
481
  def _agent_spawn_points(pack_id: str, level: str) -> set:
tests/test_proc_checklist_no_deviation.py ADDED
@@ -0,0 +1,368 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """proc-checklist-no-deviation — strict ordered visit-checklist.
2
+
3
+ ACTION focus: the procedure is GIVEN as an ordered named CHECKLIST of
4
+ waypoints; the model must visit them in the EXACT prescribed order
5
+ under a restricted action API (`move_units, observe, stop`). Order is
6
+ enforced by the latched, stateful `waypoint_sequence` predicate (n:1):
7
+ out-of-order, skip-one, beeline-to-final, and idle policies can never
8
+ satisfy it.
9
+
10
+ Anchors: IFBench step-order compliance, PlanBench strict ordering,
11
+ aviation pre-flight checklist, SOP no-skip / no-reorder. See
12
+ SCENARIO_REVIEW_CHECKLIST.md (A solvency / B stability / C capability).
13
+
14
+ The no-defect / no-cheat bar:
15
+
16
+ - Every level's win MUST use `waypoint_sequence` (n:1) — not parallel
17
+ region clauses — so order is genuinely enforced.
18
+ - Every level's `within_ticks` and `after_ticks` MUST be reachable
19
+ inside `max_turns` (engine ~90 ticks per decision turn) — otherwise
20
+ a staller draws instead of losing.
21
+ - `after_ticks` MUST be exactly `within_ticks + 1` so a non-completing
22
+ run is a real LOSS (never a draw).
23
+ - The intended in-order policy WINS on every seed 1..4 and every level.
24
+ - The three adversarial policies — stall, reorder, skip-one — LOSE
25
+ on every seed 1..4 and every level (no path-coincidence cheat).
26
+ - Pack `tools` are exactly the {move_units, observe, stop} allow-list.
27
+ - The agent starts with 3 jeeps and there is NO enemy on the map
28
+ (the only reachable fail is the timeout — `_NO_ENEMY` classification).
29
+ - Hard tier defines ≥2 distinct `spawn_point` groups (seed-driven
30
+ starts) so a single memorised opening can't generalise.
31
+
32
+ This file mirrors the test pattern used by `test_coordination_
33
+ ordered_rendezvous.py` and `test_strict_spec.py::test_strict_sequence_
34
+ enforces_strict_order_no_cheat`.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from pathlib import Path
40
+
41
+ import pytest
42
+
43
+ # openra_bench.scenarios eagerly imports the Rust adapter at module
44
+ # load (schema.py:15), so collection fails without the wheel. Skip the
45
+ # whole module if the env is missing — matches test_building_planning.
46
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
47
+
48
+ from openra_bench.scenarios import load_pack
49
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
50
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
51
+
52
+ PACK = PACKS_DIR / "proc-checklist-no-deviation.yaml"
53
+
54
+ # Per-level expected checklist (must mirror the YAML clauses).
55
+ _POINTS = {
56
+ "easy": [(40, 4), (75, 36), (118, 20)],
57
+ "medium": [(40, 4), (75, 36), (95, 4), (118, 36)],
58
+ "hard": [(40, 4), (75, 36), (95, 4), (118, 36), (118, 20)],
59
+ }
60
+
61
+ SEEDS = (1, 2, 3, 4)
62
+
63
+
64
+ # ---- structural / no-cheat invariants (pure-Python, no engine) ----
65
+
66
+
67
+ def _win_clauses(c):
68
+ return dict(c.win_condition.__pydantic_extra__ or {})["all_of"]
69
+
70
+
71
+ def _fail_clauses(c):
72
+ return dict(c.fail_condition.__pydantic_extra__ or {})["any_of"]
73
+
74
+
75
+ def _seq_value(c):
76
+ for cl in _win_clauses(c):
77
+ if "waypoint_sequence" in cl:
78
+ return cl["waypoint_sequence"]
79
+ return None
80
+
81
+
82
+ def test_meta_capability_and_anchors():
83
+ pack = load_pack(PACK)
84
+ assert pack.meta.capability == "action"
85
+ assert pack.meta.id == "proc-checklist-no-deviation"
86
+ # The four required real-world / benchmark anchors.
87
+ anchors = set(pack.meta.benchmark_anchor)
88
+ assert "IFBench step-order compliance" in anchors
89
+ assert "PlanBench strict ordering" in anchors
90
+ assert "aviation pre-flight checklist" in anchors
91
+ assert "SOP no-skip / no-reorder" in anchors
92
+
93
+
94
+ def test_tool_allowlist_is_strict():
95
+ pack = load_pack(PACK)
96
+ # The action API the model has access to — EXACTLY these three.
97
+ assert set(pack.base.get("tools", [])) == {"move_units", "observe", "stop"}
98
+
99
+
100
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
101
+ def test_level_uses_waypoint_sequence_n1_with_expected_count(level):
102
+ c = compile_level(load_pack(PACK), level)
103
+ win = _win_clauses(c)
104
+ seq = _seq_value(c)
105
+ assert seq is not None, f"{level}: must use waypoint_sequence for order"
106
+ # n=1: the spec says 'n:1, points list'.
107
+ assert int(seq.get("n", 1)) == 1, f"{level}: must be n:1"
108
+ expected_points = _POINTS[level]
109
+ assert len(seq["points"]) == len(expected_points), (
110
+ f"{level}: expected {len(expected_points)} waypoints, "
111
+ f"got {len(seq['points'])}"
112
+ )
113
+ for got, (ex, ey) in zip(seq["points"], expected_points):
114
+ assert (int(got["x"]), int(got["y"])) == (ex, ey)
115
+ # Critically NOT using simultaneous regions (which would let a
116
+ # beeline-to-final WIN — the action-multiunit-coordination shape
117
+ # is wrong for an ORDERED checklist).
118
+ assert not any("units_in_region_gte" in cl for cl in win), (
119
+ f"{level}: should use waypoint_sequence, not units_in_region_gte"
120
+ )
121
+
122
+
123
+ def test_waypoint_count_scales_with_difficulty():
124
+ pack = load_pack(PACK)
125
+ counts = [len(_seq_value(compile_level(pack, lvl))["points"])
126
+ for lvl in ("easy", "medium", "hard")]
127
+ assert counts == [3, 4, 5], (
128
+ f"waypoint count must ladder 3 -> 4 -> 5 (one per tier), got {counts}"
129
+ )
130
+
131
+
132
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
133
+ def test_deadline_and_timeout_are_reachable_and_bind(level):
134
+ c = compile_level(load_pack(PACK), level)
135
+ win = _win_clauses(c)
136
+ fail = _fail_clauses(c)
137
+ within = next(cl["within_ticks"] for cl in win if "within_ticks" in cl)
138
+ after = next(cl["after_ticks"] for cl in fail if "after_ticks" in cl)
139
+ max_tick = 93 + 90 * (c.max_turns - 1)
140
+ # Deadline and timeout both reachable inside the turn budget.
141
+ assert within <= max_tick, (level, within, max_tick)
142
+ assert after <= max_tick, (level, after, max_tick)
143
+ # Timeout strictly one tick past the deadline so a non-completing
144
+ # run is a real LOSS (never a draw).
145
+ assert after == within + 1, (level, after, within)
146
+
147
+
148
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
149
+ def test_fail_condition_has_no_force_loss_combat_clause(level):
150
+ # _NO_ENEMY pack: the only reachable fail is the timeout. The
151
+ # `not own_units_gte:1` belt-and-suspenders clause is allowed
152
+ # (the agent starts with 3 jeeps so it can't mis-fire on turn 1)
153
+ # but the combat-driven `units_lost_lte` / `units_killed_gte`
154
+ # clauses must NOT appear.
155
+ c = compile_level(load_pack(PACK), level)
156
+ fail = _fail_clauses(c)
157
+ keys = {k for cl in fail for k in cl}
158
+ assert "units_lost_lte" not in keys and "units_killed_gte" not in keys
159
+ # Combat-fail absent, but the timeout LOSS path is present.
160
+ assert any("after_ticks" in cl for cl in fail)
161
+
162
+
163
+ # ---- pure-Python predicate ordering invariants -----------------------
164
+
165
+
166
+ class _FakeSignals:
167
+ def __init__(self):
168
+ self.game_tick = 100
169
+ self.seq_progress: dict = {}
170
+
171
+
172
+ def _ctx(units):
173
+ return WinContext(
174
+ signals=_FakeSignals(),
175
+ render_state={"units_summary": [
176
+ {"cell_x": x, "cell_y": y} for x, y in units
177
+ ]},
178
+ )
179
+
180
+
181
+ def test_predicate_skip_one_never_satisfies():
182
+ """Predicate-level: skipping the middle waypoint NEVER satisfies."""
183
+ sig = _FakeSignals()
184
+ spec = {"waypoint_sequence": {
185
+ "id": "T-skip", "n": 1, "radius": 5,
186
+ "points": [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 90, "y": 10}],
187
+ }}
188
+ ctx_at = lambda xy: WinContext( # noqa: E731
189
+ signals=sig,
190
+ render_state={"units_summary": [{"cell_x": xy[0], "cell_y": xy[1]}]},
191
+ )
192
+ # W1 — advance.
193
+ evaluate(spec, ctx_at((10, 10)))
194
+ # Now jump to W3 (skip W2) repeatedly — must NEVER advance past 1.
195
+ for _ in range(8):
196
+ assert evaluate(spec, ctx_at((90, 10))) is False
197
+ assert sig.seq_progress["T-skip"] == 1
198
+
199
+
200
+ def test_predicate_reorder_never_satisfies():
201
+ """Predicate-level: visiting W2 first then never visiting W1 NEVER
202
+ satisfies (latch stays at idx 0)."""
203
+ sig = _FakeSignals()
204
+ spec = {"waypoint_sequence": {
205
+ "id": "T-reord", "n": 1, "radius": 5,
206
+ "points": [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 90, "y": 10}],
207
+ }}
208
+ ctx_at = lambda xy: WinContext( # noqa: E731
209
+ signals=sig,
210
+ render_state={"units_summary": [{"cell_x": xy[0], "cell_y": xy[1]}]},
211
+ )
212
+ # Visit W2 / W3 only (in any number) — latch is stuck at W1.
213
+ for _ in range(6):
214
+ assert evaluate(spec, ctx_at((50, 10))) is False
215
+ assert evaluate(spec, ctx_at((90, 10))) is False
216
+ assert sig.seq_progress["T-reord"] == 0
217
+
218
+
219
+ # ---- live-engine scripted-policy sweep (4 policies × 4 seeds × 3 levels) ----
220
+ # This is the no-defect / no-cheat bar: the intended in-order policy
221
+ # WINS on every cell; stall / reorder / skip-one all LOSE on every
222
+ # cell. No path-coincidence cheat.
223
+
224
+
225
+ def _in_radius(units, x, y, r=5):
226
+ return any((u["cell_x"] - x) ** 2 + (u["cell_y"] - y) ** 2 <= r * r
227
+ for u in units)
228
+
229
+
230
+ def _make_visit(points):
231
+ """Scripted policy: send every available unit to the next-needed
232
+ waypoint, advance state when arrived (within radius 5)."""
233
+ state = {"idx": 0}
234
+
235
+ def fn(rs, Command):
236
+ units = rs.get("units_summary", []) or []
237
+ if not units:
238
+ return [Command.observe()]
239
+ i = state["idx"]
240
+ if i >= len(points):
241
+ return [Command.observe()]
242
+ tx, ty = points[i]
243
+ if _in_radius(units, tx, ty):
244
+ state["idx"] = i + 1
245
+ i = state["idx"]
246
+ if i >= len(points):
247
+ return [Command.observe()]
248
+ tx, ty = points[i]
249
+ ids = [str(u["id"]) for u in units]
250
+ return [Command.move_units(ids, target_x=tx, target_y=ty)]
251
+
252
+ return fn
253
+
254
+
255
+ def _stall_policy(rs, Command):
256
+ return [Command.observe()]
257
+
258
+
259
+ def _make_reorder(points):
260
+ # Swap W1 and W2 — true out-of-order policy. The latch can never
261
+ # advance past idx 0 because W1 is visited AFTER W2.
262
+ pts = list(points)
263
+ pts[0], pts[1] = pts[1], pts[0]
264
+ return _make_visit(pts)
265
+
266
+
267
+ def _make_skip_middle(points):
268
+ # Drop the middle waypoint (latch never completes past it).
269
+ mid = len(points) // 2
270
+ return _make_visit(points[:mid] + points[mid + 1:])
271
+
272
+
273
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
274
+ @pytest.mark.parametrize("seed", SEEDS)
275
+ def test_intended_in_order_policy_wins(level, seed):
276
+ pytest.importorskip("openra_train")
277
+ from openra_bench.eval_core import run_level
278
+ c = compile_level(load_pack(PACK), level)
279
+ assert c.map_supported, f"{level}: rush-hour-arena must resolve"
280
+ res = run_level(c, _make_visit(_POINTS[level]), seed=seed)
281
+ assert res.outcome == "win", (
282
+ f"{level} seed={seed}: intended in-order play must WIN, "
283
+ f"got {res.outcome} (turns={res.turns})"
284
+ )
285
+
286
+
287
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
288
+ @pytest.mark.parametrize("seed", SEEDS)
289
+ def test_stall_policy_loses(level, seed):
290
+ pytest.importorskip("openra_train")
291
+ from openra_bench.eval_core import run_level
292
+ c = compile_level(load_pack(PACK), level)
293
+ res = run_level(c, _stall_policy, seed=seed)
294
+ assert res.outcome == "loss", (
295
+ f"{level} seed={seed}: stall must LOSE on the timeout, "
296
+ f"got {res.outcome} (turns={res.turns})"
297
+ )
298
+
299
+
300
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
301
+ @pytest.mark.parametrize("seed", SEEDS)
302
+ def test_reorder_policy_loses(level, seed):
303
+ pytest.importorskip("openra_train")
304
+ from openra_bench.eval_core import run_level
305
+ c = compile_level(load_pack(PACK), level)
306
+ res = run_level(c, _make_reorder(_POINTS[level]), seed=seed)
307
+ assert res.outcome == "loss", (
308
+ f"{level} seed={seed}: out-of-order play (swap W1<->W2) must "
309
+ f"LOSE — the latch is stuck — got {res.outcome} (turns={res.turns})"
310
+ )
311
+
312
+
313
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
314
+ @pytest.mark.parametrize("seed", SEEDS)
315
+ def test_skip_one_policy_loses(level, seed):
316
+ pytest.importorskip("openra_train")
317
+ from openra_bench.eval_core import run_level
318
+ c = compile_level(load_pack(PACK), level)
319
+ res = run_level(c, _make_skip_middle(_POINTS[level]), seed=seed)
320
+ assert res.outcome == "loss", (
321
+ f"{level} seed={seed}: skip-one play must LOSE — the latch "
322
+ f"never completes — got {res.outcome} (turns={res.turns})"
323
+ )
324
+
325
+
326
+ # ---- hard-tier spawn variation (UPGRADED contract) -------------------
327
+
328
+
329
+ def test_hard_has_two_spawn_point_groups():
330
+ c = compile_level(load_pack(PACK), "hard")
331
+ sp = {
332
+ (a.spawn_point if a.spawn_point is not None else 0)
333
+ for a in c.scenario.actors
334
+ if a.owner == "agent"
335
+ }
336
+ assert len(sp) >= 2, (
337
+ f"hard must define ≥2 agent spawn_point groups for seed-driven "
338
+ f"start variation; got {sorted(sp)}"
339
+ )
340
+
341
+
342
+ def test_hard_seeds_produce_distinct_starts():
343
+ """Different seeds must place the agent's start cluster differently
344
+ (the whole point of the two spawn_point groups)."""
345
+ pytest.importorskip("openra_train")
346
+ from openra_bench.eval_core import _scenario_to_tmp_yaml, RustEnvPool
347
+ from openra_bench.rust_adapter import RustObsAdapter
348
+
349
+ c = compile_level(load_pack(PACK), "hard")
350
+ tmp = _scenario_to_tmp_yaml(c)
351
+ pool = RustEnvPool(size=1, scenario_path=tmp)
352
+ env = pool.acquire()
353
+ starts = set()
354
+ try:
355
+ for seed in SEEDS:
356
+ ad = RustObsAdapter()
357
+ ad.observe(env.reset(seed=seed))
358
+ u = ad.render_state().get("units_summary", []) or []
359
+ if u:
360
+ starts.add(tuple(sorted((x["cell_x"], x["cell_y"]) for x in u)))
361
+ finally:
362
+ pool.release(env)
363
+ pool.shutdown()
364
+ Path(tmp).unlink(missing_ok=True)
365
+ assert len(starts) >= 2, (
366
+ f"hard seeds produced identical starts {starts}; "
367
+ "spawn_point round-robin not taking effect"
368
+ )