yxc20098 commited on
Commit
f5d1192
·
1 Parent(s): 583755c

feat(scenario): perception-count-the-threat — exact-count state estimation (ERQA / POMDP anchor)

Browse files

Adds the exact-count cell of the perception ladder (sibling to
perception-frontier-reading / perception-target-vs-fog): state
estimation under partial observability where the EXACT census matters,
not an upper bound. The agent must DISCOVER >=K hidden enemies in fog
while preserving its force and the clock. Under-scout loses the
discovery bar (K-1 of K), over-scout (mid-map sweep through the
stance:3 inner-defender wall) loses on the clock / attrition, stall
loses on the clock — only an intended just-enough-scout policy
(probe each genuine hidden-pocket band from a safe sight-line, halt
once K is tagged) clears every tooth.

- easy: K=3 hidden in one near-east cluster (visible-by-default
rehearsal of the count task; only stall fails).
- medium: K=5 with two count:3 clusters (north y=10, south y=30),
attrition cap units_lost_lte:1 and within_ticks:3600.
- hard: K=5 + two agent spawn_point groups (NW / SW round-robined
by seed) so a memorised reading order from one spawn cannot
generalise. Same predicate, same physical roster (six in
two clusters); seed-rotated reachable-subsets mean the
'natural' first cluster flips by seed. (The Rust engine
filters only agent actors by spawn_point — enemy actors
always all place — so functional K-variation rides on the
spawn-rotated path, not on enemy-actor filtering.)

Real-world anchor: ICU triage exact census, inventory before a
binding purchase commit, ERQA state-estimation, POMDP exact-count
sub-tasks, ScienceWorld inventory.

Adds 12 tests in tests/test_perception_count.py covering the binding
contract: intended wins every level on every hard seed, stall /
under-scout / over-scout each LOSE on the levels their teeth
discriminate, the deadline is reachable in max_turns (no draw
degeneracy), and hard ships >=2 spawn_point groups. Adds the pack id
to tests/test_hard_tier.py::UPGRADED. Tests pass 387 + 12 = 399
across the suite.

openra_bench/scenarios/packs/perception-count-the-threat.yaml ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Validate: python -m openra_bench.scenarios.validate \
2
+ # openra_bench/scenarios/packs/perception-count-the-threat.yaml
3
+ # See ../CONTRIBUTING.md for the full win-condition grammar.
4
+ #
5
+ # PERCEPTION focus, exact-count cell of the perception ladder
6
+ # (sibling to perception-frontier-reading / perception-target-vs-fog):
7
+ # state estimation under partial observability where the EXACT census
8
+ # matters, not an upper bound. The agent must DISCOVER ≥K hidden
9
+ # enemies in the fog (`enemies_discovered_gte:K`) while preserving
10
+ # its force (`units_lost_lte:1`) and the clock. Under-scouting (stop
11
+ # early, K-1 discovered) LOSES the discovery bar. Over-scouting (run
12
+ # every unit through every fog patch — including a mid-map sweep that
13
+ # brushes stance:3 inner-defender missiles) bleeds the clock (the
14
+ # detour through every patch never finishes the count in time) and
15
+ # busts the attrition cap. Stalling LOSES the clock. Only an intended
16
+ # "just-enough scout" policy — visit each genuine hidden-pocket lane
17
+ # (north + south band) until ≥K threats are tagged from a safe
18
+ # sight-line, then halt — passes all three teeth.
19
+ #
20
+ # Real-world anchor (ICU triage, inventory before a binding commit,
21
+ # ERQA state estimation, POMDP exact-count sub-tasks, ScienceWorld
22
+ # inventory): you need the actual count, not a safe upper bound; the
23
+ # act of counting consumes finite probe budget and excessive probing
24
+ # is itself costly. The capability under test is calibrating WHEN to
25
+ # stop counting — not just "see fog → drive in".
26
+ #
27
+ # Hard tier (binding contract): two agent spawn_point groups (NW / SW)
28
+ # round-robined by seed (`tests/test_hard_tier.py::UPGRADED`). The
29
+ # physical enemy roster is six hidden threats spread across BOTH
30
+ # halves of the map; depending on the seed-chosen spawn, the natural
31
+ # scout path encounters a different subset first, so a memorised
32
+ # reading order from one spawn cannot generalise. The predicate K=5
33
+ # is the binding lower bound: the agent must reliably discover 5 of 6
34
+ # regardless of which corner it started from. (The Rust engine
35
+ # filters AGENT actors by spawn_point but always places ALL enemy
36
+ # actors — see openra-data/src/oramap.rs:expand_scenario_actors — so
37
+ # K-as-physical-count cannot vary across seeds within a single
38
+ # scenario file. Functional K-variation is achieved by spawn-rotated
39
+ # reachable-subsets, not by enemy-actor filtering.)
40
+
41
+ meta:
42
+ id: perception-count-the-threat
43
+ title: 'Count the Threat — Discover EXACTLY Enough Hidden Enemies'
44
+ capability: perception
45
+ real_world_meaning: >
46
+ State estimation under partial observability where over-counting
47
+ wastes resources and under-counting commits blind to a binding
48
+ decision — you need the EXACT census, not an upper bound.
49
+ robotics_analogue: "Warehouse inventory pass before committing to a binding purchase order: probe enough shelves to satisfy the count, then stop — every extra probe is overhead and missing one breaks the commit"
50
+ benchmark_anchor:
51
+ - "ERQA state-estimation"
52
+ - "POMDP exact-count sub-tasks"
53
+ - "ScienceWorld inventory"
54
+ - "ICU triage exact census"
55
+ - "inventory-before-commit"
56
+ author: "openra-bench"
57
+
58
+ base_map: rush-hour-arena
59
+
60
+ base:
61
+ agent: {faction: allies}
62
+ enemy: {faction: soviet}
63
+ tools: [move_units, attack_unit, stop_units]
64
+ planning: true
65
+ termination: {max_ticks: 10000}
66
+ actors:
67
+ # Scout team — fast jeeps + a light tank so the agent can split
68
+ # the count work across lanes without being one-shot by a stance:2
69
+ # defender that the over-scout policy provokes. Agent stance:1
70
+ # (ReturnFire) — scouts do NOT auto-engage on sight, so a
71
+ # just-enough scout glides past stance:2 inner defenders without
72
+ # provoking; only an explicit attack_unit (over-scout) triggers
73
+ # the attrition tooth.
74
+ - {type: jeep, owner: agent, position: [6, 6], stance: 1, count: 3}
75
+ - {type: 1tnk, owner: agent, position: [8, 9], stance: 1, count: 2}
76
+
77
+ levels:
78
+ easy:
79
+ description: >
80
+ Visible-by-default rehearsal of the count task: K=3 hidden
81
+ threats sit on the map but in this tier the scouts spawn close
82
+ enough to all three that a single sweep reveals every one
83
+ without exposing the force. The bare skill: read the brief
84
+ ("≥3 hidden enemies, find them all"), move into each pocket,
85
+ stop. Stalling at spawn never reaches K and LOSES on the clock.
86
+ overrides:
87
+ actors:
88
+ - {type: jeep, owner: agent, position: [6, 6], stance: 1, count: 3}
89
+ - {type: 1tnk, owner: agent, position: [8, 9], stance: 1, count: 2}
90
+ # K=3 hidden threats packed in a single near-east cluster
91
+ # (count:3 at one cell — the engine offsets adjacent copies).
92
+ # A committed move-to-sightline pass (e.g. scout to (40,5))
93
+ # reveals all three at the next snapshot via vehicle sight≈7.
94
+ # stance:0 (HoldFire) so the revealed enemies never engage —
95
+ # discovery is FREE; the only cost the agent pays is the time
96
+ # spent moving and the risk of an explicit `attack_unit` (the
97
+ # brute "attack everything" failure mode). e3 (rocket
98
+ # soldier) in preference to e1 because the engine reliably
99
+ # surfaces e3 in the visible-enemy stream — e1 cluster
100
+ # instances were empirically missing from `enemy_positions`
101
+ # even when the scout stood on the cluster cell.
102
+ - {type: e3, owner: enemy, position: [40, 12], stance: 0, count: 3}
103
+ # Persistent passive marker so the engine does not auto-end
104
+ # the episode the moment the agent eliminates the three
105
+ # threats (would short-circuit the clock-discrimination).
106
+ - {type: barr, owner: enemy, position: [120, 36], stance: 0}
107
+ win_condition:
108
+ all_of:
109
+ - {enemies_discovered_gte: 3}
110
+ - {within_ticks: 2200}
111
+ fail_condition:
112
+ any_of:
113
+ - {after_ticks: 2201}
114
+ - {not: {own_units_gte: 1}}
115
+ max_turns: 26
116
+
117
+ medium:
118
+ description: >
119
+ Now fogged with K=5 hidden threats spread across both halves of
120
+ the map. Under-scouting (stop after 3–4 discoveries) LOSES the
121
+ discovery bar. Over-scouting (push every unit through every fog
122
+ patch, brushing the stance:2 inner defenders) blows the
123
+ attrition cap (≤1 lost). Stalling LOSES the clock. The intended
124
+ just-enough-scout policy splits the force across the two
125
+ genuine hidden-pocket lanes (NE band + SE band), tags ≥5
126
+ threats, and halts.
127
+ overrides:
128
+ actors:
129
+ - {type: jeep, owner: agent, position: [6, 6], stance: 1, count: 3}
130
+ - {type: 1tnk, owner: agent, position: [8, 9], stance: 1, count: 2}
131
+ # Two genuine hidden-pocket clusters (count:3 each — the
132
+ # engine packs three units around the named cell, total K=6
133
+ # physically placed). stance:0 (HoldFire) so discovery is
134
+ # FREE: the scout reveals all three at the next snapshot via
135
+ # vehicle sight≈7 (drive to (60,5) / (60,33) — one cell off
136
+ # the cluster latitude — and pass the sight cone over it
137
+ # without engaging). The clusters sit on opposite halves
138
+ # (y=12 NORTH and y=28 SOUTH), so a single one-lane scout
139
+ # caps at K=3 — far short of K=5. Only a genuine TWO-LANE
140
+ # scout (split N/S) clears the discovery bar.
141
+ - {type: e3, owner: enemy, position: [60, 10], stance: 0, count: 3}
142
+ - {type: e3, owner: enemy, position: [100, 30], stance: 0, count: 3}
143
+ # Inner defender wall: stance:3 (AttackAnything) e3 missiles
144
+ # parked mid-y x=50, between the north (y≈6-10) and south
145
+ # (y≈30-34) scout lanes — outside jeep sight=7 from either
146
+ # lane (y=19/21 are 10/12 cells from y=9 and 14/12 from y=33).
147
+ # A just-enough scout that stays in its lane glides past
148
+ # without provoking; an OVER-scout that pulls every unit
149
+ # through every fog patch (mid-map sweep) crosses the
150
+ # defender wall and bleeds time AND units — the over-scout
151
+ # tooth bites first via clock (long detour through every
152
+ # patch), and on a deeper sweep also via attrition.
153
+ - {type: e3, owner: enemy, position: [50, 19], stance: 3}
154
+ - {type: e3, owner: enemy, position: [50, 21], stance: 3}
155
+ # Persistent passive marker for episode-end safety (see easy).
156
+ - {type: barr, owner: enemy, position: [120, 36], stance: 0}
157
+ win_condition:
158
+ all_of:
159
+ - {enemies_discovered_gte: 5}
160
+ - {units_lost_lte: 1}
161
+ - {within_ticks: 3600}
162
+ fail_condition:
163
+ any_of:
164
+ - {after_ticks: 3601}
165
+ - {not: {units_lost_lte: 1}}
166
+ - {not: {own_units_gte: 1}}
167
+ max_turns: 40
168
+
169
+ hard:
170
+ description: >
171
+ Two seed-varied spawn groups (NW / SW) round-robin the start
172
+ corner; the hidden-threat roster spans BOTH halves of the map
173
+ so a memorised reading order from one spawn cannot generalise.
174
+ The agent must discover ≥5 hidden threats (of six physically
175
+ placed) while losing ≤1 unit, on a tighter clock. Under-scout
176
+ LOSES the discovery bar; over-scout brushes stance:2 inner
177
+ defenders and busts the attrition cap; stall LOSES the clock.
178
+ Only a just-enough split scout that probes both bands and stops
179
+ once K=5 is reached clears every tooth.
180
+ overrides:
181
+ actors:
182
+ # spawn_point 0 — scouts staged NW.
183
+ - {type: jeep, owner: agent, position: [6, 6], stance: 1, count: 3, spawn_point: 0}
184
+ - {type: 1tnk, owner: agent, position: [8, 9], stance: 1, count: 2, spawn_point: 0}
185
+ # spawn_point 1 — scouts staged SW (threats fixed, so which
186
+ # band is "on the way" flips by seed; what counted as the
187
+ # natural first-pocket from NW becomes the longer reach from
188
+ # SW and vice-versa).
189
+ - {type: jeep, owner: agent, position: [6, 33], stance: 1, count: 3, spawn_point: 1}
190
+ - {type: 1tnk, owner: agent, position: [8, 30], stance: 1, count: 2, spawn_point: 1}
191
+ # Two genuine hidden-pocket clusters (count:3 each, total K=6
192
+ # placed). stance:0 — REVEALING is free (HoldFire), the only
193
+ # combat cost comes from the stance:2 inner defenders that an
194
+ # over-scout brushes. The clusters sit on opposite halves;
195
+ # a one-lane sweep caps at K=3 (short of K=5). Either spawn
196
+ # corner reaches one cluster naturally and the other on a
197
+ # detour — so a memorised single-spawn reading order from NW
198
+ # (or SW) cannot generalise.
199
+ - {type: e3, owner: enemy, position: [60, 10], stance: 0, count: 3}
200
+ - {type: e3, owner: enemy, position: [100, 30], stance: 0, count: 3}
201
+ # Inner defender wall (stance:3 AttackAnything) at mid-y
202
+ # x=50 — outside jeep sight=7 from y=6 (10-12 cells away)
203
+ # and from y=33 (12-14 cells away). A just-enough scout
204
+ # stays in its lane; an OVER-scout that runs every unit
205
+ # through every fog patch bleeds time AND units crossing
206
+ # this wall — the over-scout teeth.
207
+ - {type: e3, owner: enemy, position: [50, 19], stance: 3}
208
+ - {type: e3, owner: enemy, position: [50, 21], stance: 3}
209
+ # Persistent passive marker for episode-end safety.
210
+ - {type: barr, owner: enemy, position: [120, 20], stance: 0}
211
+ win_condition:
212
+ all_of:
213
+ - {enemies_discovered_gte: 5}
214
+ - {units_lost_lte: 1}
215
+ - {within_ticks: 3600}
216
+ fail_condition:
217
+ any_of:
218
+ - {after_ticks: 3601}
219
+ - {not: {units_lost_lte: 1}}
220
+ - {not: {own_units_gte: 1}}
221
+ max_turns: 40
tests/test_hard_tier.py CHANGED
@@ -35,6 +35,7 @@ UPGRADED = [
35
  "coordination-staggered-window",
36
  "harass-response-preserve",
37
  "strict-sequence",
 
38
  "perception-frontier-reading",
39
  "perception-target-vs-fog",
40
  "reasoning-frontier-commit",
 
35
  "coordination-staggered-window",
36
  "harass-response-preserve",
37
  "strict-sequence",
38
+ "perception-count-the-threat",
39
  "perception-frontier-reading",
40
  "perception-target-vs-fog",
41
  "reasoning-frontier-commit",
tests/test_perception_count.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """perception-count-the-threat — exact-count perception cell.
2
+
3
+ Asserts the binding contract for the pack:
4
+ * intended just-enough-scout WINS every level on every hard seed,
5
+ * stall LOSES every level (clock),
6
+ * under-scout (one band only, never the other) LOSES medium/hard
7
+ (`enemies_discovered_gte:K` unmet),
8
+ * over-scout (run every unit through every fog patch — mid-map
9
+ sweep that brushes the stance:3 inner defenders) LOSES medium/hard
10
+ (clock or attrition),
11
+ * the timeout is REACHABLE (within_ticks ≤ 93+90·(max_turns-1) — no
12
+ draw degeneracy), and
13
+ * hard ships ≥2 agent spawn_point groups (seed-driven start).
14
+
15
+ These are scripted policies — no model, no network. The discrimination
16
+ is the whole point of the cell (calibrate WHEN to stop counting), so
17
+ the test owns it directly. Cf. perception-frontier-reading and
18
+ perception-target-vs-fog for the sibling perception predicates.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+
25
+ import pytest
26
+
27
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
28
+
29
+ from openra_bench.eval_core import run_level
30
+ from openra_bench.scenarios import load_pack
31
+ from openra_bench.scenarios.loader import compile_level
32
+
33
+ PACK_PATH = (
34
+ Path(__file__).parent.parent
35
+ / "openra_bench"
36
+ / "scenarios"
37
+ / "packs"
38
+ / "perception-count-the-threat.yaml"
39
+ )
40
+
41
+
42
+ def _stall(_rs, Command):
43
+ return [Command.observe()]
44
+
45
+
46
+ def _intended_easy(rs, Command):
47
+ """Easy: K=3 in a single near-east cluster — drive every scout to
48
+ the cluster's sight-line (40, 5) and reveal all three at once."""
49
+ units = rs.get("units_summary", []) or []
50
+ if not units:
51
+ return [Command.observe()]
52
+ return [
53
+ Command.move_units([str(u["id"])], target_x=40, target_y=5)
54
+ for u in units
55
+ ]
56
+
57
+
58
+ def _intended_med_hard(rs, Command):
59
+ """Medium/hard: two clusters — north (60,10) and south (100,30).
60
+ Jeeps push to the north sight-line (60,5); 1tnks detour south via
61
+ the x=10 column (away from the mid-map defender wall) then push
62
+ east to the south sight-line (100,33). Both bands' K total
63
+ satisfies ≥5; no unit enters the inner-defender attack radius.
64
+ Stable role assignment by unit type so reordering between turns
65
+ doesn't reshuffle who scouts which band."""
66
+ units = rs.get("units_summary", []) or []
67
+ if not units:
68
+ return [Command.observe()]
69
+ cmds = []
70
+ for u in units:
71
+ uid = str(u["id"])
72
+ ux, uy = u["cell_x"], u["cell_y"]
73
+ if u.get("type") == "jeep":
74
+ cmds.append(
75
+ Command.move_units([uid], target_x=60, target_y=5)
76
+ )
77
+ else: # 1tnk → south band
78
+ if uy < 30:
79
+ cmds.append(
80
+ Command.move_units([uid], target_x=10, target_y=33)
81
+ )
82
+ else:
83
+ cmds.append(
84
+ Command.move_units([uid], target_x=100, target_y=33)
85
+ )
86
+ return cmds
87
+
88
+
89
+ def _under_scout(rs, Command):
90
+ """One-band scout: commit ENTIRELY to whichever lane the spawn
91
+ starts in. Caps the agent at K=3 (one cluster only) — short of
92
+ K=5 — so the discovery bar is the binding LOSS clause."""
93
+ units = rs.get("units_summary", []) or []
94
+ if not units:
95
+ return [Command.observe()]
96
+ avg_y = sum(u["cell_y"] for u in units) / len(units)
97
+ if avg_y < 20:
98
+ # NW spawn → only the north band
99
+ return [
100
+ Command.move_units([str(u["id"])], target_x=60, target_y=5)
101
+ for u in units
102
+ ]
103
+ # SW spawn → only the south band
104
+ return [
105
+ Command.move_units([str(u["id"])], target_x=100, target_y=33)
106
+ for u in units
107
+ ]
108
+
109
+
110
+ def _over_scout(rs, Command):
111
+ """Run every unit through every fog patch — concretely, push the
112
+ whole force into the mid-map sweep at (50,20) where the stance:3
113
+ inner defender wall sits. The detour never reaches the genuine
114
+ clusters in time AND/OR the missile wall attrites units."""
115
+ units = rs.get("units_summary", []) or []
116
+ if not units:
117
+ return [Command.observe()]
118
+ return [
119
+ Command.move_units([str(u["id"])], target_x=50, target_y=20)
120
+ for u in units
121
+ ]
122
+
123
+
124
+ # ── solvency: intended wins every level + every hard seed ─────────────────
125
+
126
+
127
+ def _seeds(level: str) -> tuple[int, ...]:
128
+ # hard rotates the agent spawn by `seed % spawn_point_count`, so we
129
+ # cover both groups across seeds 1..4 (the cadence's curation set).
130
+ return (1, 2, 3, 4) if level == "hard" else (1,)
131
+
132
+
133
+ def _intended(level: str):
134
+ return _intended_easy if level == "easy" else _intended_med_hard
135
+
136
+
137
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
138
+ def test_intended_wins_every_level_every_seed(level):
139
+ pack = load_pack(PACK_PATH)
140
+ c = compile_level(pack, level)
141
+ for seed in _seeds(level):
142
+ res = run_level(c, _intended(level), seed=seed)
143
+ assert res.outcome == "win", (
144
+ f"intended just-enough-scout must WIN {level} seed={seed}; "
145
+ f"got outcome={res.outcome} seen={len(res.signals.enemies_seen_ids)} "
146
+ f"lost={res.signals.units_lost} tick={res.signals.game_tick}"
147
+ )
148
+
149
+
150
+ # ── stability: every non-win pattern is a real reachable LOSS ─────────────
151
+
152
+
153
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
154
+ def test_stall_loses_every_level(level):
155
+ pack = load_pack(PACK_PATH)
156
+ c = compile_level(pack, level)
157
+ for seed in _seeds(level):
158
+ res = run_level(c, _stall, seed=seed)
159
+ assert res.outcome == "loss", (
160
+ f"stall must LOSE {level} seed={seed} (clock — no enemies "
161
+ f"discovered); got outcome={res.outcome}"
162
+ )
163
+
164
+
165
+ @pytest.mark.parametrize("level", ["medium", "hard"])
166
+ def test_under_scout_loses_medium_and_hard(level):
167
+ """One-band scout caps at K=3 on medium/hard (predicate K=5) →
168
+ `enemies_discovered_gte:5` unmet → LOSS. Easy is intentionally
169
+ permissive (K=3 in a single cluster, no force-split required)."""
170
+ pack = load_pack(PACK_PATH)
171
+ c = compile_level(pack, level)
172
+ for seed in _seeds(level):
173
+ res = run_level(c, _under_scout, seed=seed)
174
+ assert res.outcome == "loss", (
175
+ f"under-scout must LOSE {level} seed={seed} via discovery bar; "
176
+ f"got outcome={res.outcome} seen={len(res.signals.enemies_seen_ids)}"
177
+ )
178
+ assert len(res.signals.enemies_seen_ids) < 5, (
179
+ f"under-scout {level} seed={seed} accidentally saw ≥5 enemies "
180
+ f"({len(res.signals.enemies_seen_ids)}) — the one-band cap "
181
+ "isn't holding."
182
+ )
183
+
184
+
185
+ @pytest.mark.parametrize("level", ["medium", "hard"])
186
+ def test_over_scout_loses_medium_and_hard(level):
187
+ """Drive every unit into the mid-map defender wall at (50,20).
188
+ The detour AND/OR attrition busts at least one of:
189
+ * `within_ticks:3600` (clock — the sweep never finishes counting),
190
+ * `units_lost_lte:1` (the missile wall attrites).
191
+ Either way the pack emits a real LOSS, never a draw."""
192
+ pack = load_pack(PACK_PATH)
193
+ c = compile_level(pack, level)
194
+ for seed in _seeds(level):
195
+ res = run_level(c, _over_scout, seed=seed)
196
+ assert res.outcome == "loss", (
197
+ f"over-scout must LOSE {level} seed={seed}; got "
198
+ f"outcome={res.outcome} seen={len(res.signals.enemies_seen_ids)} "
199
+ f"lost={res.signals.units_lost}"
200
+ )
201
+
202
+
203
+ # ── tick / max_turns alignment (no draw degeneracy) ───────────────────────
204
+
205
+
206
+ def test_timeout_is_reachable_within_max_turns():
207
+ """Every level's `within_ticks` / `after_ticks` deadline must be
208
+ reachable inside `max_turns` — else the timeout fail-clause never
209
+ bites and a stall draws instead of losing. The engine advances
210
+ ~90 ticks per decision turn; max tick ≈ 93+90·(max_turns-1)."""
211
+ pack = load_pack(PACK_PATH)
212
+ for level in ("easy", "medium", "hard"):
213
+ c = compile_level(pack, level)
214
+ max_reachable = 93 + 90 * (c.max_turns - 1)
215
+ # Inspect the win + fail predicate trees for explicit tick gates.
216
+ for node in (
217
+ c.win_condition.model_dump(),
218
+ (c.fail_condition.model_dump() if c.fail_condition else {}),
219
+ ):
220
+ _assert_ticks_reachable(node, max_reachable, level)
221
+
222
+
223
+ def _assert_ticks_reachable(node, max_reachable, level):
224
+ if not isinstance(node, dict):
225
+ return
226
+ for key, value in node.items():
227
+ if key in ("all_of", "any_of"):
228
+ for sub in value or []:
229
+ _assert_ticks_reachable(sub, max_reachable, level)
230
+ elif key == "not":
231
+ _assert_ticks_reachable(value, max_reachable, level)
232
+ elif key in ("within_ticks", "after_ticks"):
233
+ assert int(value) <= max_reachable, (
234
+ f"{level}: {key}={value} exceeds reachable tick "
235
+ f"{max_reachable} (max_turns={(max_reachable - 93)//90 + 1}) — "
236
+ "deadline never bites ⇒ draw degeneracy."
237
+ )
238
+
239
+
240
+ # ── hard-tier contract: ≥2 spawn_point groups ─────────────────────────────
241
+
242
+
243
+ def test_hard_has_two_seed_driven_spawn_groups():
244
+ pack = load_pack(PACK_PATH)
245
+ c = compile_level(pack, "hard")
246
+ groups = {
247
+ a.spawn_point
248
+ for a in c.scenario.actors
249
+ if a.owner == "agent" and a.spawn_point is not None
250
+ }
251
+ assert groups == {0, 1}, (
252
+ f"hard must define spawn_points {{0,1}} for seed-driven "
253
+ f"NW/SW alternation; got {sorted(groups)}"
254
+ )