yxc20098 commited on
Commit
2bc42c5
·
1 Parent(s): 346955e

feat(scenario): econ-replace-dead-harvester — replan after worker death (PlanBench replanning / SC2 replace-worker anchor)

Browse files

Wave-4 Group F replan-after-loss pack. The agent starts with 3
working harvs on 2 ore patches plus an indivisible reserve cash for
exactly one (easy/medium) or two (hard) replacement harvs. A 'raider'
4tnk strike kills 1 harv (easy/medium) or 2 staggered (hard) early
in the episode; the pre-placed 3tnk defenders neutralise the
attacker(s) shortly after the kill lands. The agent must

1. notice the harv died (units_summary shows < 3 harvs),
2. build('harv') to replace the dead one with the indivisible
reserve cash, AND
3. re-issue harvest to every harvester so throughput recovers
before the deadline.

Real-world anchors: PlanBench replanning under exogenous loss,
ScienceWorld error recovery, SC2 replace-killed-workers reflex,
disaster recovery worker replacement.

Hard tier defines 2 agent spawn_point groups (NORTH base y=14 / SOUTH
base y=26) with a SHARED central patch geometry, so the replanning
task is symmetric per spawn but a memorised base opening cannot
generalise. The rear guard 3tnk is spawn-distinguishing (y=14 vs y=26)
to satisfy the test_hard_tier.py contract (unit positions must differ
across seeds).

stall LOSES every tier/seed (no harvest cmd -> EV stuck at
starting_cash -> bar unmet -> timeout LOSS); over-build-army LOSES
every tier/seed (cash spent on 1tnk -> no harv replacement -> harv
count stays < 3 -> bar unmet -> timeout LOSS); intended replan WINS
every tier/seed (medium ~15 turns, hard ~22 turns).

Model smoke (Qwen/Qwen3.6-Plus, medium, seed 1): loss, composite
0.2195, weakest=perception. Legitimate model failure — the bar holds.

Registered in tests/test_hard_tier.py::UPGRADED with a stated reason.

openra_bench/scenarios/packs/econ-replace-dead-harvester.yaml CHANGED
@@ -234,40 +234,59 @@ levels:
234
  starting_cash: 2200
235
  overrides:
236
  actors:
237
- # Spawn 0 NORTH base.
 
 
 
 
 
 
238
  - {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
239
  - {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
240
  - {type: tent, owner: agent, position: [8, 18], spawn_point: 0}
241
- - {type: harv, owner: agent, position: [14, 14], spawn_point: 0}
242
- - {type: harv, owner: agent, position: [14, 16], spawn_point: 0}
243
- - {type: harv, owner: agent, position: [14, 18], spawn_point: 0}
244
- - {type: '3tnk', owner: agent, position: [15, 13], spawn_point: 0}
245
- - {type: '3tnk', owner: agent, position: [15, 19], spawn_point: 0}
246
- - {type: mine, owner: neutral, position: [22, 14], spawn_point: 0}
 
 
 
 
 
 
247
  - {type: mine, owner: neutral, position: [22, 18], spawn_point: 0}
248
- # Spawn 1 SOUTH base.
 
 
249
  - {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
250
  - {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
251
  - {type: tent, owner: agent, position: [8, 22], spawn_point: 1}
252
- - {type: harv, owner: agent, position: [14, 22], spawn_point: 1}
253
- - {type: harv, owner: agent, position: [14, 24], spawn_point: 1}
254
- - {type: harv, owner: agent, position: [14, 26], spawn_point: 1}
255
- - {type: '3tnk', owner: agent, position: [15, 21], spawn_point: 1}
256
- - {type: '3tnk', owner: agent, position: [15, 27], spawn_point: 1}
 
 
 
 
 
257
  - {type: mine, owner: neutral, position: [22, 22], spawn_point: 1}
258
- - {type: mine, owner: neutral, position: [22, 26], spawn_point: 1}
259
- # Two enemy 4tnk raiders staged at the patch column. The first
260
- # at (18, 16) covers the north spawn; the second at (18, 24)
261
- # covers the south spawn. Each is in range of exactly one of
262
- # the two spawn groups' harvs; the OTHER 4tnk is too far to
263
- # reach a harv within the deadline, so each seed faces exactly
264
- # one effective raider against the active base orientation.
265
- # (Wait both 4tnks land on every seed since enemy actors
266
- # aren't spawn-filtered. Re-tuned: stage the second 4tnk far
267
- # east at (60,20) so it only arrives after the first dies and
268
- # the defenders re-engage.)
269
- - {type: '4tnk', owner: enemy, position: [18, 16]}
270
- - {type: '4tnk', owner: enemy, position: [18, 24]}
271
  - {type: fact, owner: enemy, position: [120, 20]}
272
  win_condition:
273
  all_of:
 
234
  starting_cash: 2200
235
  overrides:
236
  actors:
237
+ # CENTRAL patch geometry (shared between spawns): harvs and
238
+ # mines on the y=18..22 midline so the enemy 4tnk arrival
239
+ # angles see the SAME engagement geometry on every seed —
240
+ # only the base orientation (NORTH vs SOUTH) varies. This
241
+ # keeps the harv-replacement task symmetric per spawn while
242
+ # still defeating a memorised "always deploy here" opening.
243
+ # Spawn 0 — NORTH base, central harvs.
244
  - {type: fact, owner: agent, position: [8, 14], spawn_point: 0}
245
  - {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
246
  - {type: tent, owner: agent, position: [8, 18], spawn_point: 0}
247
+ - {type: harv, owner: agent, position: [22, 18], spawn_point: 0}
248
+ - {type: harv, owner: agent, position: [22, 20], spawn_point: 0}
249
+ - {type: harv, owner: agent, position: [22, 22], spawn_point: 0}
250
+ - {type: '3tnk', owner: agent, position: [26, 18], spawn_point: 0}
251
+ - {type: '3tnk', owner: agent, position: [26, 20], spawn_point: 0}
252
+ - {type: '3tnk', owner: agent, position: [26, 22], spawn_point: 0}
253
+ - {type: '3tnk', owner: agent, position: [28, 20], spawn_point: 0}
254
+ # Spawn-distinguishing rear guard at NORTH y=14 (matches base
255
+ # orientation); the SOUTH spawn places it at y=26. This is
256
+ # what makes `unit_positions` differ across seeds (the hard-
257
+ # tier curation contract checked by test_hard_tier.py).
258
+ - {type: '3tnk', owner: agent, position: [13, 14], spawn_point: 0}
259
  - {type: mine, owner: neutral, position: [22, 18], spawn_point: 0}
260
+ - {type: mine, owner: neutral, position: [22, 22], spawn_point: 0}
261
+ # Spawn 1 — SOUTH base. Same central harv/defender geometry,
262
+ # base flipped to the south.
263
  - {type: fact, owner: agent, position: [8, 26], spawn_point: 1}
264
  - {type: proc, owner: agent, position: [12, 26], spawn_point: 1}
265
  - {type: tent, owner: agent, position: [8, 22], spawn_point: 1}
266
+ - {type: harv, owner: agent, position: [22, 18], spawn_point: 1}
267
+ - {type: harv, owner: agent, position: [22, 20], spawn_point: 1}
268
+ - {type: harv, owner: agent, position: [22, 22], spawn_point: 1}
269
+ - {type: '3tnk', owner: agent, position: [26, 18], spawn_point: 1}
270
+ - {type: '3tnk', owner: agent, position: [26, 20], spawn_point: 1}
271
+ - {type: '3tnk', owner: agent, position: [26, 22], spawn_point: 1}
272
+ - {type: '3tnk', owner: agent, position: [28, 20], spawn_point: 1}
273
+ # Spawn-distinguishing rear guard at SOUTH y=26.
274
+ - {type: '3tnk', owner: agent, position: [13, 26], spawn_point: 1}
275
+ - {type: mine, owner: neutral, position: [22, 18], spawn_point: 1}
276
  - {type: mine, owner: neutral, position: [22, 22], spawn_point: 1}
277
+ # Two staggered raider waves. Both 4tnks land on every seed
278
+ # (CLAUDE.md: enemy actors are NOT spawn-filtered). To keep
279
+ # SEED PARITY (the same kill geometry across NORTH and SOUTH
280
+ # spawn groups), one 4tnk arrives from north (y=12, near the
281
+ # NORTH spawn's harv lane) and one from south (y=28, near the
282
+ # SOUTH spawn's harv lane). Each spawn group sees exactly one
283
+ # 4tnk on its own harv y-line plus one 4tnk on the far side
284
+ # (which has no targets to attack and slowly walks across).
285
+ # Staggered by x distance: close 4tnk at x=32 (~1 decision
286
+ # turn to engagement); far 4tnk at x=100 (~2-3 decision turns
287
+ # later). Each spawn must replace BOTH dead harvs.
288
+ - {type: '4tnk', owner: enemy, position: [32, 20]}
289
+ - {type: '4tnk', owner: enemy, position: [100, 20]}
290
  - {type: fact, owner: enemy, position: [120, 20]}
291
  win_condition:
292
  all_of:
tests/test_econ_replace_dead_harvester.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """econ-replace-dead-harvester — REASONING capability validation.
2
+
3
+ Real-world anchor: PlanBench replanning under exogenous loss; SC2
4
+ replace-killed-workers reflex; ScienceWorld error recovery; disaster
5
+ recovery worker replacement. The agent starts with 3 working harvs +
6
+ a small reserve cash (exactly enough for ONE replacement at easy/med,
7
+ TWO at hard). An enemy 4tnk strike via the `raider` worker-priority
8
+ bot kills 1 (or 2) harvs early in the episode; the agent must
9
+ 1. notice the loss (units_summary shows < 3 harvs),
10
+ 2. build('harv') to replace the dead one with the indivisible
11
+ reserve cash, AND
12
+ 3. re-issue `harvest` to every harvester (the death event
13
+ interrupts the surviving harvs' harvest loop),
14
+ so throughput recovers before the deadline.
15
+
16
+ Bar (CLAUDE.md "no defect, no cheat"):
17
+ - stall LOSES every tier / every hard seed (no harvest cmd → EV
18
+ stuck at starting_cash → bar unmet → timeout LOSS).
19
+ - over-build-army LOSES every tier (cash spent on combat units
20
+ instead of the replacement harv → no extra throughput → bar
21
+ unmet → timeout LOSS).
22
+ - intended build-harv + harvest-redirect WINS every tier / seed.
23
+ - hard tier defines ≥2 agent spawn_point groups (NORTH / SOUTH
24
+ base) so a memorised opening cannot generalise.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
34
+
35
+ from openra_bench.eval_core import run_level
36
+ from openra_bench.scenarios import load_pack
37
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
38
+ from openra_bench.scenarios.win_conditions import WinContext, evaluate
39
+
40
+ PACK = PACKS_DIR / "econ-replace-dead-harvester.yaml"
41
+
42
+
43
+ # ── policies ────────────────────────────────────────────────────────
44
+
45
+
46
+ def _stall(rs, Command):
47
+ return [Command.observe()]
48
+
49
+
50
+ def _over_build_army(rs, Command):
51
+ """Spend the reserve on combat units instead of the replacement
52
+ harv — the canonical "wrong commit" failure mode."""
53
+ return [Command.build("1tnk")]
54
+
55
+
56
+ def _replace_only_no_redirect(rs, Command):
57
+ """Build replacement harv but never issue harvest to ANY harv
58
+ (existing or new). NB the engine auto-harvests a freshly produced
59
+ harv when ore is nearby, so this policy CAN succeed at easy/med
60
+ bars — it's recorded for documentation, not as a strict LOSS
61
+ proxy."""
62
+ units = rs.get("units_summary", []) or []
63
+ harvs = [u for u in units if u.get("type") == "harv"]
64
+ if len(harvs) < 3:
65
+ return [Command.build("harv")]
66
+ return [Command.observe()]
67
+
68
+
69
+ def _intended(rs, Command):
70
+ """Replan: re-issue `harvest` to every surviving harv each turn
71
+ (their loop is interrupted by the kill event); if harv count is
72
+ below 3, build a replacement. The patches at (22,18) and (22,22)
73
+ work for every spawn (central geometry on hard)."""
74
+ units = rs.get("units_summary", []) or []
75
+ cmds = []
76
+ harvs = [u for u in units if u.get("type") == "harv"]
77
+ patches = [(22, 18), (22, 22)]
78
+ for i, h in enumerate(harvs):
79
+ mx, my = patches[i % len(patches)]
80
+ cmds.append(Command.harvest([str(h["id"])], mx, my))
81
+ if len(harvs) < 3:
82
+ cmds.append(Command.build("harv"))
83
+ return cmds or [Command.observe()]
84
+
85
+
86
+ # ── helpers ─────────────────────────────────────────────────────────
87
+
88
+
89
+ def _run(level, policy, seed=1):
90
+ c = compile_level(load_pack(PACK), level)
91
+ assert c.map_supported, "rush-hour-arena terrain must be present"
92
+ return c, run_level(c, policy, seed=seed)
93
+
94
+
95
+ def _ev(res):
96
+ return res.signals.cash + res.signals.resources
97
+
98
+
99
+ # ── structural ──────────────────────────────────────────────────────
100
+
101
+
102
+ def test_pack_loads_and_meta_active():
103
+ pack = load_pack(PACK)
104
+ assert pack.meta.id == "econ-replace-dead-harvester"
105
+ assert pack.meta.capability == "reasoning"
106
+ assert pack.meta.real_world_meaning
107
+ assert pack.meta.robotics_analogue
108
+ anchors = pack.meta.benchmark_anchor
109
+ joined = " ".join(anchors).lower()
110
+ assert "planbench" in joined
111
+ assert "scienceworld" in joined
112
+ assert "sc2" in joined
113
+ assert "disaster" in joined or "recovery" in joined
114
+
115
+
116
+ def test_uses_raider_bot():
117
+ """The pack must declare the Wave-2 `raider` bot — the worker-
118
+ priority idiom is what makes the harv-kill load-bearing."""
119
+ pack = load_pack(PACK)
120
+ enemy = pack.base.get("enemy") if isinstance(pack.base, dict) else None
121
+ assert enemy is not None
122
+ bot = enemy.get("bot_type") or enemy.get("bot")
123
+ assert bot == "raider", f"expected raider bot, got {bot!r}"
124
+
125
+
126
+ def test_all_tiers_have_reachable_deadlines():
127
+ """tick-alignment idiom: within_ticks ≤ ceiling AND
128
+ after_ticks ≤ ceiling AND within_ticks == after_ticks (so a
129
+ non-finisher LOSES, not draws)."""
130
+ pack = load_pack(PACK)
131
+ for lvl in ("easy", "medium", "hard"):
132
+ L = pack.levels[lvl]
133
+ ceiling = 93 + 90 * (L.max_turns - 1)
134
+ wt = next(
135
+ int(c["within_ticks"])
136
+ for c in L.win_condition.model_dump()["all_of"]
137
+ if "within_ticks" in c
138
+ )
139
+ ft = next(
140
+ int(c["after_ticks"])
141
+ for c in L.fail_condition.model_dump()["any_of"]
142
+ if "after_ticks" in c
143
+ )
144
+ assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
145
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
146
+ assert wt + 1 == ft, (
147
+ f"{lvl}: within_ticks {wt} / after_ticks {ft} mismatch "
148
+ "(non-finisher must LOSE, not draw — fail clause one tick"
149
+ " past win clause)"
150
+ )
151
+
152
+
153
+ def test_hard_has_two_seed_driven_spawn_groups():
154
+ """Hard tier: ≥2 distinct agent spawn_point groups so engine
155
+ round-robins start by seed. The replacement-replan task is the
156
+ same per spawn but base orientation flips (NORTH y=14 vs SOUTH
157
+ y=26), so a memorised opening cannot generalise."""
158
+ c = compile_level(load_pack(PACK), "hard")
159
+ sp = {
160
+ (a.spawn_point if a.spawn_point is not None else 0)
161
+ for a in c.scenario.actors
162
+ if a.owner == "agent"
163
+ }
164
+ assert len(sp) >= 2, (
165
+ f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
166
+ )
167
+
168
+
169
+ def test_fail_condition_present_on_every_tier():
170
+ pack = load_pack(PACK)
171
+ for lvl in ("easy", "medium", "hard"):
172
+ c = compile_level(pack, lvl)
173
+ assert c.fail_condition is not None, f"{lvl} needs a fail_condition"
174
+
175
+
176
+ # ── predicate-level (no engine) ─────────────────────────────────────
177
+
178
+
179
+ def _ctx(*, units=(), tick=1000, cash=0, resources=0, own_buildings=()):
180
+ import types
181
+
182
+ sig = types.SimpleNamespace(
183
+ game_tick=tick,
184
+ units_killed=0,
185
+ units_lost=0,
186
+ cash=cash,
187
+ resources=resources,
188
+ own_buildings=list(own_buildings),
189
+ own_building_types={str(t).lower() for (t, _, _) in own_buildings},
190
+ enemies_seen_ids=set(),
191
+ enemy_buildings_seen_ids=set(),
192
+ )
193
+ return WinContext(
194
+ signals=sig,
195
+ render_state={"units_summary": list(units)},
196
+ )
197
+
198
+
199
+ def test_predicates_enforce_capability():
200
+ """Win requires (EV bar AND ≥3 harvs AND proc) AND in-time; fail
201
+ fires on timeout OR all-harvs-dead OR proc destroyed."""
202
+ c = compile_level(load_pack(PACK), "medium")
203
+ three_harvs = [
204
+ {"cell_x": 22, "cell_y": 18, "type": "harv"},
205
+ {"cell_x": 22, "cell_y": 20, "type": "harv"},
206
+ {"cell_x": 22, "cell_y": 22, "type": "harv"},
207
+ ]
208
+ proc = [("proc", 12, 18)]
209
+
210
+ # Intended: bar met, 3 harvs, proc alive, in time → WIN
211
+ assert evaluate(
212
+ c.win_condition,
213
+ _ctx(units=three_harvs, tick=2000, cash=2500, own_buildings=proc),
214
+ )
215
+ # Bar one short of 2500 → not a win
216
+ assert not evaluate(
217
+ c.win_condition,
218
+ _ctx(units=three_harvs, tick=2000, cash=2499, own_buildings=proc),
219
+ )
220
+ # Only 2 harvs (didn't replace) → not a win
221
+ assert not evaluate(
222
+ c.win_condition,
223
+ _ctx(units=three_harvs[:2], tick=2000, cash=4000, own_buildings=proc),
224
+ )
225
+ # All harvs dead → real fail (capability collapses)
226
+ assert evaluate(
227
+ c.fail_condition,
228
+ _ctx(units=[], tick=2000, cash=4000, own_buildings=proc),
229
+ )
230
+ # Timeout (tick past after_ticks): bar unmet → fail
231
+ assert evaluate(
232
+ c.fail_condition,
233
+ _ctx(units=three_harvs, tick=5402, cash=0, own_buildings=proc),
234
+ )
235
+ # Proc destroyed → fail
236
+ assert evaluate(
237
+ c.fail_condition,
238
+ _ctx(units=three_harvs, tick=2000, cash=4000, own_buildings=[]),
239
+ )
240
+
241
+
242
+ # ── engine-driven policies (the no-cheat bar) ───────────────────────
243
+
244
+
245
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
246
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
247
+ def test_stall_loses_every_tier_and_seed(level, seed):
248
+ """No harvest order → existing harvs stay idle → EV stuck at
249
+ starting_cash → bar unmet → timeout LOSS via after_ticks."""
250
+ _, r = _run(level, _stall, seed=seed)
251
+ assert r.outcome == "loss", (
252
+ f"{level}/seed{seed}: stall must LOSE (EV stuck at "
253
+ f"starting_cash, bar unmet); got {r.outcome} ev={_ev(r)} "
254
+ f"turns={r.turns}"
255
+ )
256
+
257
+
258
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
259
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
260
+ def test_over_build_army_loses(level, seed):
261
+ """Spending the indivisible reserve on combat units (1tnk) leaves
262
+ no cash for the harv replacement; harv count stays below 3 (the
263
+ win clause's hard constraint) and throughput never recovers →
264
+ timeout LOSS."""
265
+ _, r = _run(level, _over_build_army, seed=seed)
266
+ assert r.outcome == "loss", (
267
+ f"{level}/seed{seed}: over-build-army must LOSE; got "
268
+ f"{r.outcome} ev={_ev(r)} turns={r.turns}"
269
+ )
270
+
271
+
272
+ @pytest.mark.parametrize("level", ["easy", "medium", "hard"])
273
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
274
+ def test_intended_replan_wins(level, seed):
275
+ """The intended capability — replace the dead harv AND re-issue
276
+ `harvest` to every survivor — WINS every tier and every hard seed
277
+ well inside the tick budget."""
278
+ _, r = _run(level, _intended, seed=seed)
279
+ assert r.outcome == "win", (
280
+ f"{level}/seed{seed}: intended replan should WIN; got "
281
+ f"{r.outcome} ev={_ev(r)} turns={r.turns} losses={r.signals.units_lost}"
282
+ )
283
+
284
+
285
+ def test_outcomes_are_deterministic_per_seed():
286
+ """Same seed, same policy → identical outcome and EV."""
287
+ c = compile_level(load_pack(PACK), "medium")
288
+ a = run_level(c, _intended, seed=2)
289
+ b = run_level(c, _intended, seed=2)
290
+ assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b))