yxc20098 commited on
Commit
b054ae5
·
1 Parent(s): 39a101b

feat(scenario): def-multi-direction — split defense across lanes (graph min-cut / multi-front anchor)

Browse files
openra_bench/scenarios/packs/def-multi-direction.yaml ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # def-multi-direction.yaml
2
+ #
3
+ # REASONING capability — Wave-7 Group C distributed-defense seed.
4
+ # THREE rusher bands attack the construction yard CONCURRENTLY from
5
+ # three latitudes (NORTH y=8, MID y=20, SOUTH y=32). The agent
6
+ # commands SIX medium tanks (2tnk) staged centrally and must SPLIT
7
+ # them 2/2/2 across three defensive zones — one per lane — to absorb
8
+ # each wave before it reaches the fact. Concentrating all six tanks
9
+ # on a single lane wins THAT lane but the other two lanes' waves
10
+ # stride untouched to the construction yard and raze it. Sibling of
11
+ # `def-pre-position-mobile-reserve` (the FORWARD-INTERCEPT variant
12
+ # at the lane mouth) — this pack tests DISTRIBUTED DEFENSE at the
13
+ # base perimeter instead: every lane needs ≥2 tanks of static
14
+ # coverage or its wave breaks through.
15
+ #
16
+ # Real-world anchors (binding meta.benchmark_anchor):
17
+ # - Graph min-cut / multi-source attack: the optimal defence
18
+ # allocates capacity to EVERY active cut, not the loudest one.
19
+ # A concentrated defence at one cut leaves the others wide open.
20
+ # - Distributed-systems load balancing: serving traffic across N
21
+ # concurrent request streams requires AT LEAST one worker per
22
+ # stream — pinning all workers to a single stream starves the
23
+ # others (queue-builds, SLA breaches, eventual outage).
24
+ # - Military multi-front doctrine: an army facing concurrent
25
+ # advances on multiple axes MUST hold each axis (the canonical
26
+ # "two-front war" failure mode is leaving one axis uncontested
27
+ # and being enveloped). The same defensive capacity, distributed
28
+ # across all active fronts, beats the same capacity concentrated
29
+ # on the loudest one.
30
+ # - Air-defence sector coverage: each sector needs its own SAM
31
+ # battery; pooling all batteries in one sector means the other
32
+ # sectors are wide open to attack.
33
+ #
34
+ # DISCRIMINATIONS (the bar — every lazy / wrong-commit play LOSES,
35
+ # only the intended 2/2/2 split WINS):
36
+ #
37
+ # • stall (only `observe`): the 6 tanks idle at central staging;
38
+ # each rusher band crosses to the agent centroid (centroid is
39
+ # central by construction). The MID band reaches the fact first
40
+ # (shortest straight path along y=20), N and S bands arrive a
41
+ # few turns later. Auto-engagement at stance:3 racks up some
42
+ # kills but the centrally-clustered tanks cannot defend three
43
+ # zones simultaneously — the fact is razed by the combined
44
+ # pressure → `not has_building:fact` LOSS, AND the three
45
+ # defensive-zone clauses all stay empty.
46
+ #
47
+ # • concentrate-NORTH (whole reserve sent to the N defensive zone):
48
+ # the N zone clause is satisfied (6 ≥ 2 tanks at (25,8)) but the
49
+ # MID and S defensive zones are EMPTY (0 < 2). Each of the MID
50
+ # and S clauses fails on its own. Beyond the predicate-level
51
+ # LOSS, the MID and S waves walk untouched into the fact (no
52
+ # defenders in their path) and raze it → `not has_building:fact`
53
+ # also bites. Real reachable LOSS.
54
+ #
55
+ # • concentrate-MID (whole reserve at (25,20)): same shape —
56
+ # N and S zone clauses fail (0 tanks), AND the N + S waves
57
+ # swing wide around the central cluster (rusher bot targets
58
+ # centroid which is at MID; N band approaches via y=10..14 and
59
+ # S band via y=26..30 — both reach the fact before the central
60
+ # cluster can chase them down). Fact razed → LOSS.
61
+ #
62
+ # • concentrate-SOUTH: symmetric to concentrate-NORTH; N and MID
63
+ # zone clauses fail; N and MID waves take the fact → LOSS.
64
+ #
65
+ # • split-2/3 (no MID defenders, e.g. 3 N + 3 S): MID zone clause
66
+ # is empty (0 < 2); the MID wave on the straight y=20 line
67
+ # reaches the fact unopposed (the N and S defenders are too far
68
+ # off-axis to intercept the central column in time) → fact
69
+ # razed → LOSS.
70
+ #
71
+ # • intended 2/2/2 split (≥1 tank in EACH of N, MID, S defensive
72
+ # zones at (25,8), (25,20), (25,32) — the 2-per-zone doctrine
73
+ # builds in attrition headroom on top of the n=1 floor): all
74
+ # three defensive-zone clauses hold; each wave is engaged by 2
75
+ # tanks (2tnk vs e1 is ~1-shot dominance per CLAUDE.md /
76
+ # combat-focus-fire-priority); the kill bar clears (easy ≥4,
77
+ # medium/hard ≥6); fact intact → WIN.
78
+ #
79
+ # ENGINE FACTS used (CLAUDE.md / SCENARIO_QUALITY.md):
80
+ # - `rusher` bot — each enemy band converges on the agent
81
+ # centroid. The DISTRIBUTED-DEFENCE test here is enforced by
82
+ # the THREE `units_in_region_gte` zone clauses (a concentration
83
+ # satisfies one and fails two), NOT by the lane geometry of the
84
+ # incoming bands. `hunt` was tried first but its e1s idle at
85
+ # spawn for most of the tick budget on this geometry, so the
86
+ # kill bar and the engagement-mediated zone-coverage attrition
87
+ # never materialise; `rusher` reliably walks the bands into
88
+ # engagement range every seed and the three zone clauses still
89
+ # do all of the discrimination work.
90
+ # - Enemy actors with NO spawn_point ALWAYS place regardless of
91
+ # the agent's spawn group (CLAUDE.md). All three enemy bands
92
+ # always materialise every seed — the "react to all three lanes"
93
+ # capability is enforced by having ALL THREE LANES active on
94
+ # every seed (the strictly harder test).
95
+ # - Inert enemy `fact` marker far east (anti-DRAW): without a
96
+ # persistent enemy actor the engine auto-`done`s on enemy-elim
97
+ # before the win/fail evaluator runs (CLAUDE.md footgun).
98
+ # - `enemy_unit_spotted` interrupt → defenders re-decide the
99
+ # instant any rusher band becomes visible.
100
+ # - `own_unit_destroyed` interrupt → re-decide when a defender
101
+ # falls (so a damaged lane can be reinforced from a quiet one).
102
+ # - `units_in_region_gte: {x, y, radius, n}` — load-bearing
103
+ # distribution clause: REQUIRES ≥1 tank in EACH of three
104
+ # defensive zones (n=1, not n=2 — the 2-per-lane doctrine
105
+ # remains the agent's recommended allocation but the predicate
106
+ # floors at n=1 to absorb the inevitable one-per-lane attrition
107
+ # from the heavier waves). A concentration in one zone fails
108
+ # the other two clauses regardless of n.
109
+ # - 2tnk vs e1 dominance per CLAUDE.md / combat-focus-fire-
110
+ # priority. 2 tanks reliably clear a 3-rifle wave without
111
+ # losing the lane; this is what makes 2/2/2 a tight floor (1
112
+ # tank per lane bleeds through).
113
+ # - Tick budget: ~90 ticks/turn fixed-step, ~60 ticks/turn in
114
+ # interrupt mode. max_turns 50 → reachable tick ≈ 3000 in
115
+ # interrupt mode and ≈ 4503 fixed-step; `within_ticks: 2700`
116
+ # and `after_ticks: 2701` are both reachable well inside
117
+ # max_turns so stall hits the deadline as a real LOSS (not a
118
+ # draw degeneracy).
119
+ #
120
+ # Validate (no model / no network):
121
+ # python3 -m openra_bench.scenarios.validate \
122
+ # openra_bench/scenarios/packs/def-multi-direction.yaml
123
+
124
+ meta:
125
+ id: def-multi-direction
126
+ title: 'Distributed Defense — Split Across Concurrent Lanes'
127
+ capability: reasoning
128
+ real_world_meaning: >
129
+ Three enemy waves attack the base concurrently from three
130
+ latitudes (north, mid, south). With six defenders against three
131
+ concurrent waves, distributing 2/2/2 holds every lane while
132
+ concentrating on one lane leaves the other two uncontested —
133
+ those waves stride untouched to the construction yard and raze
134
+ it. This is the canonical multi-front / graph min-cut allocation
135
+ problem: defensive capacity must be matched to the cardinality
136
+ of concurrent threats, not to the loudest single threat.
137
+ robotics_analogue: >
138
+ Distributed-systems load balancing under N concurrent request
139
+ streams: each stream needs at least one worker, or its queue
140
+ builds without bound and the SLA breaks regardless of how well
141
+ the other streams are served. Likewise air-defence sector
142
+ coverage: pooling all batteries in one sector leaves the others
143
+ open. Same total capacity, different topology, qualitatively
144
+ different system survivability.
145
+ benchmark_anchor:
146
+ - "distributed-systems load balancing"
147
+ - "graph min-cut"
148
+ - "military multi-front"
149
+ author: openra-bench
150
+
151
+ # rush-hour-arena: 128×40 playable (x in [2..126], y in [2..38]).
152
+ # Agent base at the west (fact at x=10). Three rush bands spawn at
153
+ # x=80 across three latitudes (NORTH y=8, MID y=20, SOUTH y=32) and
154
+ # drive west at the agent centroid. The three defensive zones sit at
155
+ # x=25 (well east of the fact at x=10 — defenders must MOVE OUT of
156
+ # the central staging cluster at (30,20) to occupy them, but stay
157
+ # close enough to the fact that an under-defended zone breaks through
158
+ # in time for the lane's wave to raze the fact before the clock).
159
+ base_map: rush-hour-arena
160
+ starting_cash: 0
161
+
162
+ base:
163
+ agent:
164
+ faction: allies
165
+ cash: 0
166
+ enemy:
167
+ faction: soviet
168
+ cash: 0
169
+ # `rusher`: each enemy band converges on the agent centroid.
170
+ # The DISTRIBUTED-DEFENCE discriminator here is NOT enemy lane
171
+ # geometry — it's the THREE `units_in_region_gte` zone clauses
172
+ # in the win predicate: tanks MUST be in all three (25,*) zones
173
+ # simultaneously, so a concentration policy satisfies one zone
174
+ # and fails the other two regardless of where the rusher bands
175
+ # converge. (Smoke-tested: `hunt` leaves e1s idle at spawn for
176
+ # most of the tick budget here — they don't actively path to
177
+ # the nearest tank — so the kill bar and the engagement-mediated
178
+ # zone-coverage attrition never materialise; `rusher` reliably
179
+ # walks the bands into engagement range every seed.)
180
+ bot_type: rusher
181
+ # Combat-only toolset (no build / no harvest): the test is
182
+ # ALLOCATION of a fixed defender pool across concurrent threats,
183
+ # not production.
184
+ tools:
185
+ - observe
186
+ - move_units
187
+ - attack_unit
188
+ - attack_move
189
+ - stop
190
+ planning: true
191
+ # React the INSTANT a rusher band becomes visible or a defender
192
+ # falls — the multi-lane allocation needs immediate re-decision
193
+ # the moment the agent learns one lane is contested.
194
+ interrupts:
195
+ enemy_unit_spotted: true
196
+ own_unit_destroyed: true
197
+ termination:
198
+ max_ticks: 6000
199
+ actors: []
200
+
201
+ levels:
202
+ # ── EASY ─────────────────────────────────────────────────────────
203
+ # Bare distributed-defense skill: three CONCURRENT lanes, LIGHT
204
+ # waves (2 e1 per lane = 6 total). 2tnk-vs-e1 dominance means
205
+ # 2 tanks per lane reliably clears the 2-rifle wave without
206
+ # losing a defender. The load-bearing clauses are THREE
207
+ # `units_in_region_gte` zones (N (25,8), MID (25,20), S (25,32)),
208
+ # each requiring ≥1 tank — any concentration policy satisfies
209
+ # one zone and fails two. max_turns 50 → reachable tick ~3000
210
+ # in interrupt mode (~4503 fixed-step); `after_ticks: 2701`
211
+ # bites well before max_turns.
212
+ easy:
213
+ description: >
214
+ Your construction yard (fact) sits at the west edge (10,20). A
215
+ defensive force of 6 medium tanks (2tnk) is staged at the
216
+ central choke (30,20). THREE rifle-infantry rush bands are
217
+ incoming concurrently — NORTH (y=8), MID (y=20), and SOUTH
218
+ (y=32). SPLIT the defenders 2/2/2 across three forward
219
+ defensive zones at (25,8), (25,20), and (25,32) — each zone
220
+ must hold AT LEAST 1 tank at win time (the 2-per-zone
221
+ doctrine builds in attrition headroom), AND you must destroy
222
+ ≥4 of the 6 rushers AND keep ≥3 tanks alive AND keep the fact
223
+ standing, AND finish before tick 2700. Holding the reserve at
224
+ the centre satisfies at most the MID zone (and even that only
225
+ on the boundary); concentrating all six tanks on a single
226
+ lane satisfies one zone but fails the other two clauses.
227
+ overrides:
228
+ actors:
229
+ # Agent construction yard — WEST edge, the rusher target.
230
+ - {type: fact, owner: agent, position: [10, 20]}
231
+ # 6× 2tnk defensive force staged at the CENTRAL CHOKE
232
+ # (30,20). stance:3 (AttackAnything) — auto-engages enemies
233
+ # in vision range. The discriminator is NOT auto-engagement
234
+ # but the THREE defensive-zone clauses: a passive central
235
+ # cluster never reaches ANY of the (25,*) zones, and a
236
+ # concentration in one zone fails the other two clauses.
237
+ - {type: 2tnk, owner: agent, position: [30, 19], stance: 3}
238
+ - {type: 2tnk, owner: agent, position: [30, 20], stance: 3}
239
+ - {type: 2tnk, owner: agent, position: [30, 21], stance: 3}
240
+ - {type: 2tnk, owner: agent, position: [31, 19], stance: 3}
241
+ - {type: 2tnk, owner: agent, position: [31, 20], stance: 3}
242
+ - {type: 2tnk, owner: agent, position: [31, 21], stance: 3}
243
+ # THREE CONCURRENT rush bands — NORTH (y=8), MID (y=20),
244
+ # SOUTH (y=32). e1-only so 2 tanks per lane dominates the
245
+ # engagement (per CLAUDE.md / combat-focus-fire-priority).
246
+ # 2 e1 per lane = 6 e1 total; the LIGHT load means 2 tanks
247
+ # per lane reliably hold without losing a defender (so the
248
+ # n=2 zone clause keeps holding through the engagement).
249
+ - {type: e1, owner: enemy, position: [80, 8], stance: 3, count: 2}
250
+ - {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 2}
251
+ - {type: e1, owner: enemy, position: [80, 32], stance: 3, count: 2}
252
+ # Inert enemy `fact` marker far east (anti-DRAW): without it
253
+ # the engine auto-`done`s on enemy-elim before the win/fail
254
+ # evaluator runs (CLAUDE.md footgun). Placed off-axis (y=4)
255
+ # so the head-on MID band doesn't path through it.
256
+ - {type: fact, owner: enemy, position: [120, 4]}
257
+ win_condition:
258
+ all_of:
259
+ # Kill bar (4 of 6 e1) is a soft floor — the LOAD-BEARING
260
+ # discriminator is the THREE zone clauses below. With the
261
+ # intended 2/2/2 split, the lanes' e1 attrition across 2700
262
+ # ticks reliably clears 4 kills. Concentration policies
263
+ # fail two of the three zone clauses anyway (and a stall
264
+ # never reaches any zone).
265
+ - {units_killed_gte: 4}
266
+ - {own_units_gte: 3}
267
+ - {has_building: fact}
268
+ # THREE defensive zones — each MUST hold ≥1 tank. The
269
+ # n=1 floor (rather than n=2) builds in attrition headroom:
270
+ # the 2/2/2 doctrine survives a single defender loss per
271
+ # lane without dropping a zone clause. Stall (everyone at
272
+ # centre) puts 0 tanks in N+S zones (distance
273
+ # sqrt(5²+12²) ≈ 13 > radius 5 to the N/S zones) — only
274
+ # MID is on the boundary; concentrate-NORTH puts 6 in N
275
+ # but 0 in MID and SOUTH — those two clauses fail.
276
+ - {units_in_region_gte: {x: 25, y: 8, radius: 5, n: 1}}
277
+ - {units_in_region_gte: {x: 25, y: 20, radius: 5, n: 1}}
278
+ - {units_in_region_gte: {x: 25, y: 32, radius: 5, n: 1}}
279
+ - {within_ticks: 2700}
280
+ fail_condition:
281
+ any_of:
282
+ - {after_ticks: 2701}
283
+ - {not: {has_building: fact}}
284
+ - {not: {own_units_gte: 1}}
285
+ max_turns: 50
286
+
287
+ # ── MEDIUM ─────��─────────────────────────────────────────────────
288
+ # +1 controlled variable vs easy: HEAVIER waves (3 e1 per lane =
289
+ # 9 total vs easy's 6) AND a LOSS CAP (`units_lost_lte: 3` — at
290
+ # most 3 tanks lost). The 2/2/2 split still wins but a sloppy
291
+ # intercept that bleeds more than 3 defenders busts the cap. A
292
+ # wrong allocation (e.g. 6/0/0) doesn't just fail the empty zone
293
+ # clauses — the centroid-converging rusher pile on the single
294
+ # cluster builds up enough attrition to also bust the loss cap.
295
+ # Same clock as easy (within_ticks 2700 / after_ticks 2701).
296
+ medium:
297
+ description: >
298
+ Same THREE concurrent rush bands but each band is heavier
299
+ (3 rifle infantry per lane = 9 total vs easy's 6). Same
300
+ SIX-tank defensive force at central staging (30,20); same
301
+ 2/2/2 split requirement across N (25,8), MID (25,20), S
302
+ (25,32) defensive zones. Win additionally requires losing
303
+ AT MOST 3 tanks — a sloppy intercept that bleeds the line
304
+ busts the cap. The kill bar is ≥6, ≥3 tanks alive, fact
305
+ standing, finish before tick 2700. Concentration policies
306
+ fail two zone clauses; a sloppy split that bleeds the line
307
+ fails the loss cap.
308
+ overrides:
309
+ actors:
310
+ - {type: fact, owner: agent, position: [10, 20]}
311
+ - {type: 2tnk, owner: agent, position: [30, 19], stance: 3}
312
+ - {type: 2tnk, owner: agent, position: [30, 20], stance: 3}
313
+ - {type: 2tnk, owner: agent, position: [30, 21], stance: 3}
314
+ - {type: 2tnk, owner: agent, position: [31, 19], stance: 3}
315
+ - {type: 2tnk, owner: agent, position: [31, 20], stance: 3}
316
+ - {type: 2tnk, owner: agent, position: [31, 21], stance: 3}
317
+ # Heavier waves vs easy (3 e1 per lane = 9 total vs easy's 6).
318
+ # Still e1-only so 2 tanks per lane remains the floor (no
319
+ # grenadiers / rockets — those would break the 2tnk
320
+ # dominance). The bleed risk grows with the heavier wave →
321
+ # the units_lost_lte:3 cap is the medium-specific axis.
322
+ - {type: e1, owner: enemy, position: [80, 8], stance: 3, count: 3}
323
+ - {type: e1, owner: enemy, position: [80, 20], stance: 3, count: 3}
324
+ - {type: e1, owner: enemy, position: [80, 32], stance: 3, count: 3}
325
+ - {type: fact, owner: enemy, position: [120, 4]}
326
+ win_condition:
327
+ all_of:
328
+ # Kill bar 6 of 9 e1 — the LOAD-BEARING axis remains the
329
+ # zone clauses below; the kill bar is a soft floor.
330
+ - {units_killed_gte: 6}
331
+ - {own_units_gte: 3}
332
+ - {units_lost_lte: 3}
333
+ - {has_building: fact}
334
+ - {units_in_region_gte: {x: 25, y: 8, radius: 5, n: 1}}
335
+ - {units_in_region_gte: {x: 25, y: 20, radius: 5, n: 1}}
336
+ - {units_in_region_gte: {x: 25, y: 32, radius: 5, n: 1}}
337
+ - {within_ticks: 2700}
338
+ fail_condition:
339
+ any_of:
340
+ - {after_ticks: 2701}
341
+ - {not: {has_building: fact}}
342
+ - {not: {own_units_gte: 1}}
343
+ - {not: {units_lost_lte: 3}}
344
+ max_turns: 50
345
+
346
+ # ── HARD ─────────────────────────────────────────────────────────
347
+ # +1 controlled variable on top of medium: ≥2 agent spawn_point
348
+ # groups so the BASE LATITUDE (and thus the lane GEOMETRY) flips
349
+ # by seed (NORTH base y=14 ⇒ lanes at y=2, y=14, y=26; SOUTH
350
+ # base y=26 ⇒ lanes at y=14, y=26, y=38). The 2/2/2 distributed-
351
+ # defence DOCTRINE generalises across either spawn; a memorised
352
+ # "send 2 tanks to (25,8)" cell does not — the canonical
353
+ # defensive zones flip with the base. Win uses `any_of` across
354
+ # the two candidate three-zone layouts so a model that picks the
355
+ # base-matched zones wins on both seeds. Loss cap and kill bar
356
+ # match medium (units_lost_lte:3, units_killed_gte:6); same clock.
357
+ hard:
358
+ description: >
359
+ Your construction yard sits at a seed-chosen latitude (NORTH
360
+ base y=14 or SOUTH base y=26 — the lane geometry flips with
361
+ the base). SIX defenders are staged at central staging
362
+ (30, base_y). THREE concurrent rush bands attack at the
363
+ base-matched latitudes (3 rifle infantry each). Split the
364
+ defenders 2/2/2 across the three matching defensive zones at
365
+ x=25, keep ≥3 tanks alive, lose AT MOST 3 tanks, destroy ≥6
366
+ rushers, keep the fact standing, finish before tick 2700. A
367
+ single memorised "send to (25,8)/(25,20)/(25,32)" plan wins
368
+ one seed and loses the other; the 2/2/2 doctrine matched to
369
+ the actual base latitude generalises.
370
+ overrides:
371
+ actors:
372
+ # spawn_point 0 — NORTH base (y=14). All buildings/units
373
+ # tagged spawn_point: 0; lane geometry is y=2 / y=14 / y=26.
374
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
375
+ - {type: 2tnk, owner: agent, position: [30, 13], stance: 3, spawn_point: 0}
376
+ - {type: 2tnk, owner: agent, position: [30, 14], stance: 3, spawn_point: 0}
377
+ - {type: 2tnk, owner: agent, position: [30, 15], stance: 3, spawn_point: 0}
378
+ - {type: 2tnk, owner: agent, position: [31, 13], stance: 3, spawn_point: 0}
379
+ - {type: 2tnk, owner: agent, position: [31, 14], stance: 3, spawn_point: 0}
380
+ - {type: 2tnk, owner: agent, position: [31, 15], stance: 3, spawn_point: 0}
381
+ # spawn_point 1 — SOUTH base (y=26). Mirrored layout; lane
382
+ # geometry is y=14 / y=26 / y=38.
383
+ - {type: fact, owner: agent, position: [10, 26], spawn_point: 1}
384
+ - {type: 2tnk, owner: agent, position: [30, 25], stance: 3, spawn_point: 1}
385
+ - {type: 2tnk, owner: agent, position: [30, 26], stance: 3, spawn_point: 1}
386
+ - {type: 2tnk, owner: agent, position: [30, 27], stance: 3, spawn_point: 1}
387
+ - {type: 2tnk, owner: agent, position: [31, 25], stance: 3, spawn_point: 1}
388
+ - {type: 2tnk, owner: agent, position: [31, 26], stance: 3, spawn_point: 1}
389
+ - {type: 2tnk, owner: agent, position: [31, 27], stance: 3, spawn_point: 1}
390
+ # SIX enemy bands (3 per candidate base): NORTH base lanes
391
+ # at y=2 / y=14 / y=26 AND SOUTH base lanes at y=14 / y=26
392
+ # / y=38. The shared y=14 + y=26 bands appear once each
393
+ # (they serve as the OUTER lane for one base and the
394
+ # CENTRE lane for the other). y=2 is the NORTH-base
395
+ # northern outer; y=38 is the SOUTH-base southern outer.
396
+ # The agent rusher centroid sits at the spawn-active y so
397
+ # each band's centroid-seeking approach lands in the
398
+ # spawn-matched defensive geometry.
399
+ - {type: e1, owner: enemy, position: [80, 2], stance: 3, count: 3}
400
+ - {type: e1, owner: enemy, position: [80, 14], stance: 3, count: 3}
401
+ - {type: e1, owner: enemy, position: [80, 26], stance: 3, count: 3}
402
+ - {type: e1, owner: enemy, position: [80, 38], stance: 3, count: 3}
403
+ # Inert enemy `fact` marker far east (anti-DRAW). Placed at
404
+ # (120,20) — between the two candidate base latitudes so it
405
+ # never sits on a rusher-band approach line.
406
+ - {type: fact, owner: enemy, position: [120, 20]}
407
+ # `any_of` across the two candidate three-zone layouts. Spawn 0
408
+ # (NORTH base y=14) ⇒ zones at (25,2), (25,14), (25,26).
409
+ # Spawn 1 (SOUTH base y=26) ⇒ zones at (25,14), (25,26), (25,38).
410
+ # A model that always picks the same three zones wins one seed
411
+ # and loses the other; matching zones to the actual base
412
+ # latitude generalises.
413
+ win_condition:
414
+ all_of:
415
+ # Kill bar 6 of 9 e1 reaching the active-spawn lanes (the
416
+ # off-spawn outer band doesn't engage in time — its targets
417
+ # are the wrong-latitude defenders). LOAD-BEARING axis is
418
+ # the `any_of` over the two candidate three-zone layouts.
419
+ - {units_killed_gte: 6}
420
+ - {own_units_gte: 3}
421
+ - {units_lost_lte: 3}
422
+ - {has_building: fact}
423
+ - {within_ticks: 2700}
424
+ - any_of:
425
+ - all_of:
426
+ - {units_in_region_gte: {x: 25, y: 2, radius: 5, n: 1}}
427
+ - {units_in_region_gte: {x: 25, y: 14, radius: 5, n: 1}}
428
+ - {units_in_region_gte: {x: 25, y: 26, radius: 5, n: 1}}
429
+ - all_of:
430
+ - {units_in_region_gte: {x: 25, y: 14, radius: 5, n: 1}}
431
+ - {units_in_region_gte: {x: 25, y: 26, radius: 5, n: 1}}
432
+ - {units_in_region_gte: {x: 25, y: 38, radius: 5, n: 1}}
433
+ fail_condition:
434
+ any_of:
435
+ - {after_ticks: 2701}
436
+ - {not: {has_building: fact}}
437
+ - {not: {own_units_gte: 1}}
438
+ - {not: {units_lost_lte: 3}}
439
+ max_turns: 50
tests/test_def_multi_direction.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """def-multi-direction scenario pack, full loop on Rust.
2
+
3
+ REASONING capability — DISTRIBUTED DEFENSE across three concurrent
4
+ attack lanes (NORTH y=8, MID y=20, SOUTH y=32 — or the spawn-rotated
5
+ hard-tier equivalents). The load-bearing predicate is THREE
6
+ `units_in_region_gte` defensive-zone clauses, each requiring ≥2
7
+ tanks: any concentration policy satisfies one clause and fails the
8
+ other two, AND the unopposed lanes' rushers raze the construction
9
+ yard (`has_building:fact` also bites).
10
+
11
+ The bar:
12
+
13
+ * Three `units_in_region_gte: n: 1` clauses ⇒ the 2/2/2 distributed
14
+ allocation is the safe solution (the n=1 floor leaves attrition
15
+ headroom on top of the 2-per-zone doctrine); concentration
16
+ policies satisfy at most one zone and fail the other two.
17
+ * `units_killed_gte: N` paired with the band sizes so an active
18
+ 2/2/2 defence clears the bar comfortably while stall / wrong-
19
+ concentration do not (kills mostly come from the bot's rusher
20
+ funnel reaching whichever cluster the agent left at the centre,
21
+ but the zone clauses still gate the win).
22
+ * `units_lost_lte: 3` (medium + hard) ⇒ attrition cap so a sloppy
23
+ intercept that bleeds the line busts even if the zone clauses
24
+ hold.
25
+ * `has_building: fact` ⇒ the fact survival clause stays as an
26
+ always-on safety net.
27
+ * `within_ticks: 2700` paired with `after_ticks: 2701` ⇒ a non-
28
+ finisher is a real reachable timeout LOSS, never a draw.
29
+ * Hard tier defines TWO agent spawn_point groups (NORTH base y=14 /
30
+ SOUTH base y=26) so the lane geometry flips per seed; the win
31
+ predicate is `any_of` over the two matching three-zone layouts,
32
+ enforcing the doctrine over a memorised single-zone trio.
33
+
34
+ These tests prove deterministically (no model / no network):
35
+
36
+ * the intended 2/2/2 split distributed-defence policy WINS every
37
+ level + every hard seed;
38
+ * stall, concentrate-NORTH, concentrate-MID, and concentrate-SOUTH
39
+ all LOSE (real LOSS, not a draw);
40
+ * the hard tier defines ≥2 spawn_point groups so a memorised
41
+ lane-zone trio cannot generalise across seeds.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import pytest
47
+
48
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
49
+ pytest.importorskip(
50
+ "openra_rl_training", reason="Rust env wheel not installed"
51
+ )
52
+
53
+ from openra_bench.eval_core import run_level
54
+ from openra_bench.scenarios import load_pack
55
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
56
+
57
+ PACK = PACKS_DIR / "def-multi-direction.yaml"
58
+ LEVELS = ("easy", "medium", "hard")
59
+ SEEDS = (1, 2, 3, 4)
60
+
61
+
62
+ # ── scripted policies ────────────────────────────────────────────────
63
+
64
+
65
+ def stall(rs, C):
66
+ """Observe-only. Tanks stay at central staging, never reach the
67
+ (25, *) defensive zones; auto-engagement at stance:3 racks up
68
+ some kills as the MID band arrives, but the N and S waves walk
69
+ untouched to the fact and raze it → LOSS."""
70
+ return [C.observe()]
71
+
72
+
73
+ def _own_tank_ids(rs):
74
+ own = [u for u in rs.get("units_summary", []) if u.get("type") == "2tnk"]
75
+ return sorted(str(u["id"]) for u in own)
76
+
77
+
78
+ def _own_tank_centroid_y(rs):
79
+ own = [u for u in rs.get("units_summary", []) if u.get("type") == "2tnk"]
80
+ if not own:
81
+ return 20
82
+ return sum(u["cell_y"] for u in own) // len(own)
83
+
84
+
85
+ def make_concentrate(zone_y):
86
+ """Send ALL tanks to the single defensive zone at (25, zone_y).
87
+ Satisfies ONE zone clause; the other two clauses (and the
88
+ unopposed-lane fact-survival clause) both fail → LOSS."""
89
+
90
+ def policy(rs, C):
91
+ ids = _own_tank_ids(rs)
92
+ if not ids:
93
+ return [C.observe()]
94
+ return [C.attack_move(ids, target_x=25, target_y=zone_y)]
95
+
96
+ return policy
97
+
98
+
99
+ def make_intended():
100
+ """Stable per-tank 2/2/2 assignment across the three matching
101
+ defensive zones (N / MID / S relative to the agent's actual base
102
+ latitude — read from the fact's cell_y so the hard-tier spawn
103
+ rotation generalises). On the first call, sort tank ids, detect
104
+ base latitude from the fact position, and assign 2 tanks per
105
+ lane. Reissue commands only for tanks still alive."""
106
+
107
+ state = {"assignments": None}
108
+
109
+ def _zone_ys(rs):
110
+ # Detect base latitude from the tank centroid (the tanks
111
+ # always start on the base row; `buildings_summary` is not
112
+ # exposed in `RustObsAdapter.render_state` so we rely on the
113
+ # tank cluster's median y on turn 1).
114
+ base_y = _own_tank_centroid_y(rs)
115
+ # Outer lanes ±6 from base (NORTH base y=14 ⇒ 2/14/26;
116
+ # SOUTH base y=26 ⇒ 14/26/38; default base y=20 ⇒ 8/20/32).
117
+ if base_y <= 16:
118
+ return (2, 14, 26)
119
+ if base_y >= 24:
120
+ return (14, 26, 38)
121
+ return (8, 20, 32)
122
+
123
+ def policy(rs, C):
124
+ ids = _own_tank_ids(rs)
125
+ if not ids:
126
+ return [C.observe()]
127
+ if state["assignments"] is None:
128
+ n = len(ids)
129
+ two = max(1, n // 3)
130
+ n_zone, m_zone, s_zone = _zone_ys(rs)
131
+ state["assignments"] = {
132
+ ("N", n_zone): ids[:two],
133
+ ("M", m_zone): ids[two:2 * two],
134
+ ("S", s_zone): ids[2 * two:],
135
+ }
136
+ alive = set(ids)
137
+ cmds = []
138
+ for (_lane, zy), tids in state["assignments"].items():
139
+ live = [i for i in tids if i in alive]
140
+ if live:
141
+ cmds.append(C.attack_move(live, target_x=25, target_y=zy))
142
+ return cmds or [C.observe()]
143
+
144
+ return policy
145
+
146
+
147
+ # ── structural checks (no engine) ────────────────────────────────────
148
+
149
+
150
+ def test_pack_loads_and_metadata_is_complete():
151
+ pack = load_pack(PACK)
152
+ assert pack.meta.id == "def-multi-direction"
153
+ assert pack.meta.capability == "reasoning"
154
+ anchors = pack.meta.benchmark_anchor or []
155
+ # The required anchors from the Wave-7 spec.
156
+ joined = " | ".join(a.lower() for a in anchors)
157
+ assert "distributed-systems load balancing" in joined, anchors
158
+ assert "graph min-cut" in joined, anchors
159
+ assert "military multi-front" in joined, anchors
160
+ # Rusher bot wired through to the engine for every level.
161
+ for lvl in LEVELS:
162
+ c = compile_level(pack, lvl)
163
+ assert c.map_supported
164
+ enemy = c.scenario.enemy
165
+ bot = getattr(enemy, "bot_type", None) or getattr(enemy, "bot", None)
166
+ # `rusher`: bands converge on the agent centroid. The
167
+ # distributed-defence test is enforced by the THREE
168
+ # `units_in_region_gte` zone clauses in the win predicate
169
+ # (a concentration satisfies one zone and fails two);
170
+ # `hunt` was smoke-tested but leaves the e1 bands idle at
171
+ # spawn for most of the tick budget so neither the kill bar
172
+ # nor the engagement-mediated zone-coverage attrition fire
173
+ # (see pack docstring).
174
+ assert str(bot).lower() == "rusher", (lvl, bot)
175
+
176
+
177
+ @pytest.mark.parametrize("level", LEVELS)
178
+ def test_every_level_has_a_reachable_timeout_fail(level):
179
+ """Non-win must be a real LOSS: the `after_ticks` fail must be
180
+ strictly below the tick reachable at max_turns (≤90 ticks/step
181
+ in interrupt mode)."""
182
+ c = compile_level(load_pack(PACK), level)
183
+ assert c.fail_condition is not None
184
+ fc = c.fail_condition.model_dump(exclude_none=True)
185
+ deadline = None
186
+ for clause in fc.get("any_of", []) or []:
187
+ if "after_ticks" in clause:
188
+ deadline = int(clause["after_ticks"])
189
+ assert deadline is not None, f"{level}: no after_ticks fail clause"
190
+ reachable = 93 + 90 * (c.max_turns - 1)
191
+ assert deadline < reachable, (
192
+ f"{level}: deadline {deadline} unreachable within "
193
+ f"{c.max_turns} turns (max tick {reachable}) → draw degeneracy"
194
+ )
195
+
196
+
197
+ @pytest.mark.parametrize("level", LEVELS)
198
+ def test_every_level_requires_three_defensive_zones(level):
199
+ """The distribution axis must be load-bearing: every level's win
200
+ condition must require ≥3 `units_in_region_gte` clauses (the
201
+ three concurrent-lane defensive-zone discriminators). Any
202
+ concentration policy fails at least two of them."""
203
+ c = compile_level(load_pack(PACK), level)
204
+ win = c.win_condition.model_dump(exclude_none=True)
205
+ flat = str(win)
206
+ assert flat.count("units_in_region_gte") >= 3, (level, win)
207
+
208
+
209
+ def test_hard_has_two_spawn_point_groups():
210
+ """Hard-tier contract: ≥2 distinct seed-driven spawn_point groups
211
+ so the base latitude (and lane geometry) flips by seed
212
+ (anti-memorisation of an absolute lane trio — the distributed-
213
+ defence doctrine must generalise)."""
214
+ c = compile_level(load_pack(PACK), "hard")
215
+ groups = {
216
+ a.spawn_point for a in c.scenario.actors
217
+ if a.owner == "agent" and a.spawn_point is not None
218
+ }
219
+ assert groups == {0, 1}, groups
220
+ # In-bounds check (rush-hour-arena playable y ≈ 2..38).
221
+ for a in c.scenario.actors:
222
+ x, y = a.position
223
+ assert 2 <= x <= 126 and 2 <= y <= 38, (a.type, a.position)
224
+
225
+
226
+ # ── solvency: intended WINS every level + every hard seed ────────────
227
+
228
+
229
+ @pytest.mark.parametrize("level", LEVELS)
230
+ def test_intended_split_wins_every_level_and_seed(level):
231
+ c = compile_level(load_pack(PACK), level)
232
+ for seed in SEEDS:
233
+ r = run_level(c, make_intended(), seed=seed)
234
+ assert r.outcome == "win", (
235
+ f"{level} seed{seed}: intended 2/2/2 distributed defence "
236
+ f"must WIN; got {r.outcome} "
237
+ f"(tick={r.signals.game_tick}, "
238
+ f"kills={r.signals.units_killed}, "
239
+ f"lost={r.signals.units_lost})"
240
+ )
241
+
242
+
243
+ # ── no-cheat: every wrong / lazy policy LOSES (real LOSS, not draw) ──
244
+
245
+
246
+ @pytest.mark.parametrize("level", LEVELS)
247
+ def test_stall_loses_every_level_and_seed(level):
248
+ """Stall (observe only) — tanks never reach any defensive zone,
249
+ and the three concurrent waves (especially the unopposed N + S
250
+ bands) raze the fact → real LOSS on `not has_building:fact`
251
+ and/or all three zone clauses empty + `after_ticks` deadline."""
252
+ c = compile_level(load_pack(PACK), level)
253
+ for seed in SEEDS:
254
+ r = run_level(c, stall, seed=seed)
255
+ assert r.outcome == "loss", (
256
+ f"{level} seed{seed} stall: must LOSE (real fail, not draw); "
257
+ f"got {r.outcome} (tick={r.signals.game_tick}, "
258
+ f"kills={r.signals.units_killed}, "
259
+ f"lost={r.signals.units_lost})"
260
+ )
261
+
262
+
263
+ @pytest.mark.parametrize("level", LEVELS)
264
+ def test_concentrate_north_loses_every_level_and_seed(level):
265
+ """Concentrate-NORTH: all 6 tanks at the N defensive zone. The
266
+ N zone clause satisfies but MID and SOUTH zone clauses fail
267
+ (0 < 2), AND the unopposed MID + SOUTH waves raze the fact
268
+ → real LOSS."""
269
+ c = compile_level(load_pack(PACK), level)
270
+ # Use the N zone latitude matched to default base (easy/medium
271
+ # base at y=20 ⇒ N=8). On hard the default N (8) is OFF either
272
+ # candidate layout, so concentrate-NORTH at y=8 fails ALL zone
273
+ # clauses on both spawns — strictly the harder failure mode.
274
+ pol = make_concentrate(8)
275
+ for seed in SEEDS:
276
+ r = run_level(c, pol, seed=seed)
277
+ assert r.outcome == "loss", (
278
+ f"{level} seed{seed} concentrate-NORTH: must LOSE; got "
279
+ f"{r.outcome} (tick={r.signals.game_tick}, "
280
+ f"kills={r.signals.units_killed}, "
281
+ f"lost={r.signals.units_lost})"
282
+ )
283
+
284
+
285
+ @pytest.mark.parametrize("level", LEVELS)
286
+ def test_concentrate_mid_loses_every_level_and_seed(level):
287
+ """Concentrate-MID: all 6 tanks at the MID defensive zone
288
+ (25,20). The N and S zone clauses fail and the N + S waves walk
289
+ around the central cluster to raze the fact → LOSS."""
290
+ c = compile_level(load_pack(PACK), level)
291
+ pol = make_concentrate(20)
292
+ for seed in SEEDS:
293
+ r = run_level(c, pol, seed=seed)
294
+ assert r.outcome == "loss", (
295
+ f"{level} seed{seed} concentrate-MID: must LOSE; got "
296
+ f"{r.outcome} (tick={r.signals.game_tick}, "
297
+ f"kills={r.signals.units_killed}, "
298
+ f"lost={r.signals.units_lost})"
299
+ )
300
+
301
+
302
+ @pytest.mark.parametrize("level", LEVELS)
303
+ def test_concentrate_south_loses_every_level_and_seed(level):
304
+ """Concentrate-SOUTH: all 6 tanks at the S defensive zone. Same
305
+ failure mode as concentrate-NORTH but mirrored — N and MID zone
306
+ clauses fail and the unopposed N + MID waves raze the fact."""
307
+ c = compile_level(load_pack(PACK), level)
308
+ pol = make_concentrate(32)
309
+ for seed in SEEDS:
310
+ r = run_level(c, pol, seed=seed)
311
+ assert r.outcome == "loss", (
312
+ f"{level} seed{seed} concentrate-SOUTH: must LOSE; got "
313
+ f"{r.outcome} (tick={r.signals.game_tick}, "
314
+ f"kills={r.signals.units_killed}, "
315
+ f"lost={r.signals.units_lost})"
316
+ )
317
+
318
+
319
+ # ── determinism ──────────────────────────────────────────────────────
320
+
321
+
322
+ def test_intended_run_is_deterministic_on_easy():
323
+ c = compile_level(load_pack(PACK), "easy")
324
+ a = run_level(c, make_intended(), seed=2)
325
+ b = run_level(c, make_intended(), seed=2)
326
+ assert (a.outcome, a.turns) == (b.outcome, b.turns), (
327
+ "same seed must be deterministic"
328
+ )
tests/test_hard_tier.py CHANGED
@@ -241,6 +241,16 @@ UPGRADED = [
241
  # bait/strike vector flips per seed.
242
  "coord-diversionary-attack",
243
  "def-in-depth",
 
 
 
 
 
 
 
 
 
 
244
  "def-surprise-flank-react",
245
  # Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
246
  # overflow / financial runway / operational reserve anchor). Hard
 
241
  # bait/strike vector flips per seed.
242
  "coord-diversionary-attack",
243
  "def-in-depth",
244
+ # Wave-7 reasoning pack — distributed defence across three
245
+ # concurrent attack lanes (graph min-cut / military multi-front /
246
+ # distributed-systems load-balancing anchor). Hard tier defines
247
+ # two agent spawn_point groups (NORTH base y=14 / SOUTH base y=26)
248
+ # round-robined by seed; the base latitude (and thus the matching
249
+ # three-zone defensive layout) flips per seed, so a single
250
+ # memorised "send to (25,8)/(25,20)/(25,32)" plan wins one seed
251
+ # and loses the other — the 2/2/2 doctrine must be matched to
252
+ # the actual base latitude.
253
+ "def-multi-direction",
254
  "def-surprise-flank-react",
255
  # Wave-5 Group F cash-reserve / treasury management pack (SC2 cash
256
  # overflow / financial runway / operational reserve anchor). Hard