Spaces:
Running
feat(scenario): perception-count-the-threat — exact-count state estimation (ERQA / POMDP anchor)
Browse filesAdds 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.
|
@@ -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
|
|
@@ -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",
|
|
@@ -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 |
+
)
|