yxc20098 commited on
Commit
76ed940
·
1 Parent(s): c0ad5c9

feat(scenario): econ-multi-patch-allocation — balanced harv allocation across patches (SC2LE / OR Weber-multi anchor)

Browse files

Group F Wave-4 reasoning pack: 3 (or 4 at hard) ore patches at varied
distances from the single refinery, agent owns 3 harvesters. Optimal
allocation must deprioritise FAR/MID patches because round-trip travel
dominates throughput in this engine (verified yields: NEAR ~9000
cr/harv/4500t, MID ~2000, FAR ~1000 — ~9x ratio). The textbook 'one
harvester per source' uniform-split heuristic loses; any allocation
that concentrates harvs on the NEAREST patch wins.

Bar enforced (scripted validation, seeds 1-4):
EASY (2 patches, 2 harvs, bar 8000):
stall LOSS, both-far LOSS, both-near WIN, split WIN.
MEDIUM (3 patches, 3 harvs, bar 14000):
stall/all-far/all-mid/uniform-1-1-1/1-near-2-mid LOSS;
2-near-1-mid (intended balanced)/2-near-1-far/all-near WIN.
HARD (4 patches, 3 harvs, 2 spawn groups, bar 22000):
stall/all-far LOSS every seed; memorised-NORTH-near LOSS on
SOUTH-base seeds (and vice-versa); spawn-matched all-near WIN
every seed. ≥2 spawn_point groups (NORTH y=14 / SOUTH y=28) so
the NEAREST patch flips per seed — the capability is 'identify
your nearest patch from your start', not 'memorise a cell'.

Tick-aligned deadlines (within_ticks == after_ticks at engine
ceiling 93+90*(max_turns-1)) so a non-finisher LOSES, not draws.
Pre-placed inert e1 marker (stance:0, far from harvest envelope)
prevents premature engine auto-done on 'all enemies dead'.

Registered in tests/test_hard_tier.py::UPGRADED.

Benchmark anchors: SC2LE worker/mineral-patch allocation; OR
multi-source facility-location (Weber-multi-source); supply-chain
warehouse-to-supplier assignment; queueing-theory server allocation
across patches.

Model smoke (Qwen/Qwen3.6-Plus via together, medium seed 1): loss
composite 0.2452, objective progress 0.4996 — the discrimination
axis bites cleanly on a real frontier model.

openra_bench/scenarios/packs/econ-multi-patch-allocation.yaml ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ meta:
2
+ id: econ-multi-patch-allocation
3
+ title: Economy — Multi-Patch Harvester Allocation (Weber Multi-Source)
4
+ capability: reasoning
5
+ real_world_meaning: >
6
+ Multi-source resource allocation under heterogeneous transport
7
+ cost. The agent owns 3 harvesters and faces 3 (or 4 at hard)
8
+ pre-placed ore patches at varied distances from the single
9
+ refinery: a NEAR patch, a MID patch, and a FAR patch. Income per
10
+ harvester scales inversely with the round-trip travel time to the
11
+ refinery, so allocating a harvester to the FAR patch yields ~1000
12
+ cr over 4500 ticks while the NEAR patch yields ~9000 cr per
13
+ harvester over the same window. The naive "one harvester per
14
+ patch" uniform split — the textbook diversification heuristic from
15
+ a model with no transport-cost prior — is dominated by any
16
+ near-prioritising allocation. Stalling earns nothing and loses;
17
+ committing everything to FAR or MID also loses (travel kills
18
+ throughput). The capability under test is the OR Weber-multi /
19
+ SC2-mineral-patch insight: distance-weighted throughput, not
20
+ "spread parallelism", drives allocation.
21
+ robotics_analogue: >
22
+ Warehouse-to-supplier assignment in a multi-source supply chain
23
+ (Weber multi-source / facility-location): a fleet of collectors,
24
+ several supply nodes at heterogeneous distances from a single
25
+ depot, and the optimal policy assigns collectors to MINIMISE
26
+ weighted travel time, not to "use every source" out of false
27
+ diversification. Equivalently: server allocation across queueing-
28
+ theory stations where each station's service rate is already
29
+ set and the cost is the round-trip transit, so the lowest-
30
+ transit-cost station soaks the most capacity.
31
+ author: catalog-f3
32
+ benchmark_anchor:
33
+ - SC2LE worker / mineral patch allocation
34
+ - OR multi-source facility-location (Weber-multi-source)
35
+ - supply-chain warehouse-to-supplier assignment
36
+ - "queueing-theory: server allocation across patches"
37
+
38
+ # ENGINE NOTE (verified 2026-05-19 against installed openra_train wheel,
39
+ # post-S0/S1 harvest income — Task #14, scripted run_level seeds 1-4):
40
+ # 1. Per-harvester yields over 4500 ticks (50 turns) on rush-hour-arena,
41
+ # proc at (12,18), harvs prestaged at (14,18..22):
42
+ # NEAR (16,18) — ~9000 cr/harv (1 harv: 8000, 2: 18000, 3: 27325)
43
+ # MID (40,18) — ~2000 cr/harv (1 harv: 2000, 2: 4000, 3: 6000)
44
+ # FAR (80,18) — ~1000 cr/harv (1 harv: 1000, 2: 2000, 3: 3000)
45
+ # Yields scale linearly with harv count up to ~3 at NEAR (no
46
+ # contention observed); MID/FAR scale perfectly linearly because
47
+ # travel-time-bound, not ore-bound. The ~9x NEAR vs FAR ratio is
48
+ # the discrimination signal — the model must identify that distance
49
+ # dominates throughput, not "use every patch" diversification.
50
+ # 2. The smoke-tested allocation outcomes at 4500 ticks (medium budget):
51
+ # stall 0 cr LOSS at any bar > 0
52
+ # 3-to-FAR 3,000 cr LOSS at bar > 3000
53
+ # 3-to-MID 6,000 cr LOSS at bar > 6000
54
+ # 1-NEAR + 1-MID + 1-FAR (uniform) 11,000 cr LOSS at bar ≥ 12000
55
+ # 1-NEAR + 2-MID 12,000 cr LOSS at bar ≥ 13000
56
+ # 2-NEAR + 1-FAR 19,000 cr WIN at bar ≤ 19000
57
+ # 2-NEAR + 1-MID 20,000 cr WIN at bar ≤ 20000
58
+ # 3-to-NEAR 27,325 cr WIN at bar ≤ 27000
59
+ # With bar = 14000, the WIN set is "any 2 or 3 harvs on the NEAR
60
+ # patch" (plus the trivial 3-to-NEAR); the LOSS set is "uniform
61
+ # split", "MID/FAR-only", and "stall" — exactly the capability
62
+ # asked. The uniform "one per patch" heuristic LOSES by ~3000 cr.
63
+ # 3. The `harvest` order with an explicit target cell directs the harv
64
+ # to that specific patch (it does not auto-spread to other patches
65
+ # even after depleting; the order persists). This is the policy
66
+ # knob the model uses to pick a patch per harvester.
67
+ # 4. Tick budget: engine advances ~90 ticks per decision turn. Easy
68
+ # and medium both use max_turns=50 → ceiling 4503 → within_ticks
69
+ # = 4500. Hard uses max_turns=80 → ceiling 7203 → within_ticks =
70
+ # 7200. fail_condition.after_ticks sits at the same boundary so a
71
+ # non-finisher LOSES (not draws).
72
+ # 5. The pre-placed proc + fact + harvs trigger ConquestVictoryConditions
73
+ # — without a persistent enemy actor the engine would auto-`done`
74
+ # once "all enemies dead" trivially. An unarmed e1 at (120,36)
75
+ # with stance:0 sits well outside the agent's harvest envelope so
76
+ # it can't be killed and the win/fail predicate evaluates cleanly.
77
+ # 6. Hard tier spawn round-robin (verified seeds 1-4): seeds 1,3 pick
78
+ # spawn_point 1 (SOUTH base, proc at y=28); seeds 2,4 pick
79
+ # spawn_point 0 (NORTH base, proc at y=14). The four neutral
80
+ # mines place at the SAME cells across both spawns (CLAUDE.md:
81
+ # "spawn_point filter applies ONLY to AGENT actors"), but the
82
+ # NEAREST patch flips per seed — (16,14) for spawn 0 (NORTH),
83
+ # (16,28) for spawn 1 (SOUTH). A memorised "always send to
84
+ # (16,14)" policy loses on the SOUTH-base seeds (yield ~16500
85
+ # vs ~38000 on the matched-near patch), so the capability is
86
+ # "identify your nearest patch from your start, then allocate".
87
+ base_map: rush-hour-arena
88
+ starting_cash: 0
89
+
90
+ base:
91
+ agent:
92
+ faction: allies
93
+ enemy:
94
+ faction: soviet
95
+ tools:
96
+ - observe
97
+ - harvest
98
+ - move_units
99
+ - stop
100
+ planning: true
101
+ termination:
102
+ max_ticks: 40000
103
+ actors:
104
+ # Pre-placed agent base + 3 harvs centred on row y=18..22.
105
+ - {type: fact, owner: agent, position: [10, 22]}
106
+ - {type: proc, owner: agent, position: [12, 18]}
107
+ - {type: harv, owner: agent, position: [14, 18]}
108
+ - {type: harv, owner: agent, position: [14, 20]}
109
+ - {type: harv, owner: agent, position: [14, 22]}
110
+ # NEAR patch (~16 cells out — yields ~9000 cr/harv/4500t).
111
+ - {type: mine, owner: neutral, position: [16, 18]}
112
+ # MID patch (~28 cells from proc — yields ~2000 cr/harv/4500t).
113
+ - {type: mine, owner: neutral, position: [40, 18]}
114
+ # FAR patch (~68 cells from proc — yields ~1000 cr/harv/4500t).
115
+ - {type: mine, owner: neutral, position: [80, 18]}
116
+ # Inert enemy marker far from the harvest envelope keeps the
117
+ # episode from auto-terminating on "all enemies dead" before
118
+ # the win/fail predicate is evaluated.
119
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
120
+
121
+ levels:
122
+ easy:
123
+ description: >
124
+ Two patches (NEAR at (16,18), FAR at (80,18)) and 2 harvesters.
125
+ Sending BOTH to FAR yields only ~2000 cr — well below the 8000
126
+ bar; sending at least one to NEAR clears it (1 NEAR = 8000;
127
+ 2 NEAR = 18000). Stalling earns nothing and loses. The capability
128
+ asked is "identify the near patch and prioritise it" — any
129
+ allocation that uses NEAR ≥ once wins; the FAR-only allocation
130
+ loses.
131
+ starting_cash: 0
132
+ overrides:
133
+ actors:
134
+ - {type: fact, owner: agent, position: [10, 22]}
135
+ - {type: proc, owner: agent, position: [12, 18]}
136
+ - {type: harv, owner: agent, position: [14, 18]}
137
+ - {type: harv, owner: agent, position: [14, 20]}
138
+ - {type: mine, owner: neutral, position: [16, 18]}
139
+ - {type: mine, owner: neutral, position: [80, 18]}
140
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
141
+ win_condition:
142
+ all_of:
143
+ - economy_value_gte: 8000
144
+ - within_ticks: 4500
145
+ # ceiling 93 + 90*49 = 4503 ⇒ deadline bites; non-finisher LOSES.
146
+ fail_condition:
147
+ any_of:
148
+ - after_ticks: 4500
149
+ - not: {own_units_gte: 1}
150
+ - not: {has_building: proc}
151
+ max_turns: 50
152
+ medium:
153
+ description: >
154
+ Three patches (NEAR (16,18), MID (40,18), FAR (80,18)) and 3
155
+ harvesters. The naive "one harv per patch" uniform split yields
156
+ ~11000 cr — below the 14000 bar; allocations that prioritise
157
+ NEAR (2+ harvs on NEAR, or all 3) clear it cleanly. Allocating
158
+ ZERO harvs to NEAR (uniform 1/1/1, or any MID/FAR-only policy)
159
+ loses. The capability asked is the OR Weber-multi insight —
160
+ distance-weighted throughput, not parallelism per source.
161
+ starting_cash: 0
162
+ win_condition:
163
+ all_of:
164
+ - economy_value_gte: 14000
165
+ - within_ticks: 4500
166
+ # ceiling 93 + 90*49 = 4503 ⇒ deadline bites; non-finisher LOSES.
167
+ fail_condition:
168
+ any_of:
169
+ - after_ticks: 4500
170
+ - not: {own_units_gte: 1}
171
+ - not: {has_building: proc}
172
+ max_turns: 50
173
+ hard:
174
+ description: >
175
+ Four patches and 3 harvesters, with the base round-robined
176
+ between NORTH (y=14) and SOUTH (y=28) per seed. The neutral
177
+ mines stay at (16,14)/(16,28)/(40,18)/(80,18) — so the NEAREST
178
+ patch FLIPS per seed (NORTH spawn → (16,14); SOUTH spawn →
179
+ (16,28)). A memorised "always send to (16,14)" policy loses on
180
+ the SOUTH-base seeds (~16500 cr vs ~38000 cr on the matched-
181
+ near patch). Bar 22000 cr in 7200 ticks requires ≥2 harvs on
182
+ the spawn-matched NEAR patch; the naive uniform 1/1/1/0 split
183
+ across three patches yields ~16500-23000 cr (depending on
184
+ which patch is dropped) — too noisy to clear reliably. Stalling
185
+ and any FAR-only / wrong-NEAR allocation lose.
186
+ starting_cash: 0
187
+ overrides:
188
+ actors:
189
+ # spawn_point 0 — base NORTH (proc + harvs around y=14).
190
+ - {type: fact, owner: agent, position: [10, 14], spawn_point: 0}
191
+ - {type: proc, owner: agent, position: [12, 14], spawn_point: 0}
192
+ - {type: harv, owner: agent, position: [14, 13], spawn_point: 0}
193
+ - {type: harv, owner: agent, position: [14, 14], spawn_point: 0}
194
+ - {type: harv, owner: agent, position: [14, 15], spawn_point: 0}
195
+ # spawn_point 1 — base SOUTH (proc + harvs around y=28).
196
+ - {type: fact, owner: agent, position: [10, 28], spawn_point: 1}
197
+ - {type: proc, owner: agent, position: [12, 28], spawn_point: 1}
198
+ - {type: harv, owner: agent, position: [14, 27], spawn_point: 1}
199
+ - {type: harv, owner: agent, position: [14, 28], spawn_point: 1}
200
+ - {type: harv, owner: agent, position: [14, 29], spawn_point: 1}
201
+ # Four shared patches — identical to both spawns; the NEAREST
202
+ # flips per seed (NORTH → (16,14); SOUTH → (16,28)).
203
+ - {type: mine, owner: neutral, position: [16, 14]}
204
+ - {type: mine, owner: neutral, position: [16, 28]}
205
+ - {type: mine, owner: neutral, position: [40, 18]}
206
+ - {type: mine, owner: neutral, position: [80, 18]}
207
+ - {type: e1, owner: enemy, position: [120, 36], stance: 0}
208
+ win_condition:
209
+ all_of:
210
+ - economy_value_gte: 22000
211
+ - within_ticks: 7200
212
+ # ceiling 93 + 90*79 = 7203 ⇒ deadline bites; non-finisher LOSES.
213
+ fail_condition:
214
+ any_of:
215
+ - after_ticks: 7200
216
+ - not: {own_units_gte: 1}
217
+ - not: {has_building: proc}
218
+ max_turns: 80
tests/test_econ_multi_patch_allocation.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full contributor-loop validation for econ-multi-patch-allocation.
2
+
3
+ The pack tests Weber-multi-source / SC2-mineral-patch allocation: 3 (or
4
+ 4 at hard) ore patches at varied distances from a single refinery, the
5
+ agent owns 3 harvesters, and yield-per-harvester scales inversely with
6
+ round-trip travel. The capability under test is "distance dominates
7
+ throughput; prioritise the NEAR patch" — NOT naive "one harv per
8
+ source" diversification.
9
+
10
+ Bar (per CLAUDE.md "no defect, no cheat"):
11
+ - stall LOSES every tier.
12
+ - All-to-FAR LOSES every tier (~1000 cr/harv/4500t).
13
+ - All-to-MID LOSES medium/hard (~2000 cr/harv).
14
+ - Uniform 1-per-patch LOSES medium (~11000 cr < 14000 bar).
15
+ - Wrong-NEAR (memorised cell that matches one spawn but not the other)
16
+ LOSES on hard's mismatched seeds.
17
+ - Intended capability — 2+ harvs on the spawn-matched NEAR patch — WINS
18
+ every tier and every seed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pytest
24
+
25
+ pytest.importorskip("openra_train", reason="Rust env wheel not installed")
26
+
27
+ from openra_bench.eval_core import run_level
28
+ from openra_bench.scenarios import load_pack
29
+ from openra_bench.scenarios.loader import PACKS_DIR, compile_level
30
+
31
+ PACK = PACKS_DIR / "econ-multi-patch-allocation.yaml"
32
+
33
+ # Base/medium patch positions
34
+ NEAR = (16, 18)
35
+ MID = (40, 18)
36
+ FAR = (80, 18)
37
+
38
+ # Hard tier: 4 patches; NEAREST flips per seed.
39
+ P_NORTH = (16, 14) # NEAREST for spawn_point 0 (NORTH base)
40
+ P_SOUTH = (16, 28) # NEAREST for spawn_point 1 (SOUTH base)
41
+ H_MID = (40, 18)
42
+ H_FAR = (80, 18)
43
+
44
+
45
+ # ---------------------------------------------------------------- policies
46
+
47
+
48
+ def stall_policy(rs, Command):
49
+ return [Command.observe()]
50
+
51
+
52
+ def _make_alloc(targets):
53
+ """Send harv[i] (in id order) to targets[i] every turn. The
54
+ `harvest` order persists so re-issuing is idempotent."""
55
+ def f(rs, Command):
56
+ harvs = sorted(
57
+ (u for u in rs.get("units_summary", []) if u.get("type") == "harv"),
58
+ key=lambda u: u["id"],
59
+ )
60
+ cmds = [Command.harvest([str(h["id"])], *t) for h, t in zip(harvs, targets)]
61
+ return cmds or [Command.observe()]
62
+ return f
63
+
64
+
65
+ def _make_smart_hard():
66
+ """Hard-tier intended policy: identify the matched NEAR patch from
67
+ the harvs' Y row (NORTH base → harvs at y=13..15 → near is (16,14);
68
+ SOUTH base → y=27..29 → near is (16,28)), then allocate all 3."""
69
+ def f(rs, Command):
70
+ harvs = sorted(
71
+ (u for u in rs.get("units_summary", []) if u.get("type") == "harv"),
72
+ key=lambda u: u["id"],
73
+ )
74
+ if not harvs:
75
+ return [Command.observe()]
76
+ y = harvs[0]["cell_y"]
77
+ target = P_NORTH if y < 20 else P_SOUTH
78
+ return [Command.harvest([str(h["id"])], *target) for h in harvs]
79
+ return f
80
+
81
+
82
+ # ---------------------------------------------------------------- helpers
83
+
84
+
85
+ def _run(level, policy_factory, seed=1):
86
+ c = compile_level(load_pack(PACK), level)
87
+ assert c.map_supported, "rush-hour-arena terrain must be present"
88
+ policy = policy_factory() if callable(policy_factory) else policy_factory
89
+ return c, run_level(c, policy, seed=seed)
90
+
91
+
92
+ def _ev(res):
93
+ return res.signals.cash + res.signals.resources
94
+
95
+
96
+ # ---------------------------------------------------------------- structural
97
+
98
+
99
+ def test_pack_loads_and_meta_active():
100
+ pack = load_pack(PACK)
101
+ assert pack.meta.status == "active"
102
+ assert pack.meta.id == "econ-multi-patch-allocation"
103
+ assert pack.meta.capability == "reasoning"
104
+ anchors = pack.meta.benchmark_anchor
105
+ assert any("SC2LE" in a for a in anchors)
106
+ assert any("Weber" in a for a in anchors)
107
+ assert any("supply-chain" in a for a in anchors)
108
+ assert any("queueing" in a for a in anchors)
109
+
110
+
111
+ def test_all_tiers_have_reachable_deadlines():
112
+ """tick-alignment idiom: within_ticks ≤ ceiling AND
113
+ after_ticks ≤ ceiling AND within_ticks == after_ticks (so a
114
+ non-finisher LOSES, not draws)."""
115
+ pack = load_pack(PACK)
116
+ for lvl in ("easy", "medium", "hard"):
117
+ L = pack.levels[lvl]
118
+ ceiling = 93 + 90 * (L.max_turns - 1)
119
+ wt = next(
120
+ int(c["within_ticks"])
121
+ for c in L.win_condition.model_dump()["all_of"]
122
+ if "within_ticks" in c
123
+ )
124
+ ft = next(
125
+ int(c["after_ticks"])
126
+ for c in L.fail_condition.model_dump()["any_of"]
127
+ if "after_ticks" in c
128
+ )
129
+ assert wt <= ceiling, f"{lvl}: within_ticks {wt} > ceiling {ceiling}"
130
+ assert ft <= ceiling, f"{lvl}: after_ticks {ft} > ceiling {ceiling}"
131
+ assert wt == ft, (
132
+ f"{lvl}: within_ticks {wt} != after_ticks {ft} "
133
+ "(non-finisher must LOSE, not draw)"
134
+ )
135
+
136
+
137
+ def test_hard_has_two_seed_driven_spawn_groups():
138
+ """Hard tier must define ≥2 spawn_point groups so different seeds
139
+ place the agent at different starts (the capability test: identify
140
+ the NEAREST patch from YOUR base, don't memorise a fixed cell)."""
141
+ c = compile_level(load_pack(PACK), "hard")
142
+ sp = {
143
+ (a.spawn_point if a.spawn_point is not None else 0)
144
+ for a in c.scenario.actors
145
+ if a.owner == "agent"
146
+ }
147
+ assert len(sp) >= 2, (
148
+ f"hard must define ≥2 agent spawn_point groups; got {sorted(sp)}"
149
+ )
150
+
151
+
152
+ # ---------------------------------------------------------------- EASY
153
+
154
+
155
+ def test_easy_stall_loses():
156
+ _, res = _run("easy", lambda: stall_policy)
157
+ assert res.outcome == "loss", f"stall must LOSE easy; got {res.outcome} ev={_ev(res)}"
158
+
159
+
160
+ def test_easy_all_to_far_loses():
161
+ _, res = _run("easy", lambda: _make_alloc([FAR, FAR]))
162
+ assert res.outcome == "loss", (
163
+ f"all-to-FAR must LOSE easy (~2000 ev < 8000 bar); "
164
+ f"got {res.outcome} ev={_ev(res)}"
165
+ )
166
+
167
+
168
+ def test_easy_both_to_near_wins():
169
+ _, res = _run("easy", lambda: _make_alloc([NEAR, NEAR]))
170
+ assert res.outcome == "win", (
171
+ f"both-to-NEAR must WIN easy; got {res.outcome} ev={_ev(res)}"
172
+ )
173
+
174
+
175
+ def test_easy_split_near_far_wins():
176
+ """Even the inefficient split (1 NEAR + 1 FAR) clears 8000 because
177
+ the NEAR harv alone supplies ~8000 cr; this is the loose-bar easy
178
+ tier — any allocation that USES the NEAR patch passes."""
179
+ _, res = _run("easy", lambda: _make_alloc([NEAR, FAR]))
180
+ assert res.outcome == "win", (
181
+ f"split-NEAR+FAR must WIN easy; got {res.outcome} ev={_ev(res)}"
182
+ )
183
+
184
+
185
+ # ---------------------------------------------------------------- MEDIUM
186
+
187
+
188
+ def test_medium_stall_loses():
189
+ _, res = _run("medium", lambda: stall_policy)
190
+ assert res.outcome == "loss", (
191
+ f"stall must LOSE medium; got {res.outcome} ev={_ev(res)}"
192
+ )
193
+
194
+
195
+ def test_medium_all_to_far_loses():
196
+ _, res = _run("medium", lambda: _make_alloc([FAR, FAR, FAR]))
197
+ assert res.outcome == "loss", (
198
+ f"all-to-FAR must LOSE medium (~3000 ev < 14000 bar); "
199
+ f"got {res.outcome} ev={_ev(res)}"
200
+ )
201
+
202
+
203
+ def test_medium_all_to_mid_loses():
204
+ _, res = _run("medium", lambda: _make_alloc([MID, MID, MID]))
205
+ assert res.outcome == "loss", (
206
+ f"all-to-MID must LOSE medium (~6000 ev < 14000 bar); "
207
+ f"got {res.outcome} ev={_ev(res)}"
208
+ )
209
+
210
+
211
+ def test_medium_uniform_split_loses():
212
+ """The NAIVE one-harv-per-patch heuristic LOSES medium — the
213
+ capability test is "transport cost dominates, not parallelism"."""
214
+ _, res = _run("medium", lambda: _make_alloc([NEAR, MID, FAR]))
215
+ assert res.outcome == "loss", (
216
+ f"uniform 1/1/1 split must LOSE medium (~11000 ev < 14000 bar); "
217
+ f"got {res.outcome} ev={_ev(res)}"
218
+ )
219
+
220
+
221
+ def test_medium_one_near_two_mid_loses():
222
+ """A "diversify slightly towards MID" allocation still under-uses
223
+ the NEAR patch; medium's bar bites at this margin."""
224
+ _, res = _run("medium", lambda: _make_alloc([NEAR, MID, MID]))
225
+ assert res.outcome == "loss", (
226
+ f"1-NEAR+2-MID must LOSE medium (~12000 ev < 14000 bar); "
227
+ f"got {res.outcome} ev={_ev(res)}"
228
+ )
229
+
230
+
231
+ def test_medium_balanced_2near_1mid_wins():
232
+ """The intended balanced allocation (2 harvs on NEAR + 1 on MID)
233
+ wins cleanly — the textbook Weber-multi answer with this geometry."""
234
+ _, res = _run("medium", lambda: _make_alloc([NEAR, NEAR, MID]))
235
+ assert res.outcome == "win", (
236
+ f"2-NEAR+1-MID (intended) must WIN medium; got {res.outcome} "
237
+ f"ev={_ev(res)}"
238
+ )
239
+
240
+
241
+ def test_medium_2near_1far_wins():
242
+ """Variant balanced allocation also clears the bar (the NEAR
243
+ saturation is soft enough that an extra FAR harv adds ~1000 ev)."""
244
+ _, res = _run("medium", lambda: _make_alloc([NEAR, NEAR, FAR]))
245
+ assert res.outcome == "win", (
246
+ f"2-NEAR+1-FAR must WIN medium; got {res.outcome} ev={_ev(res)}"
247
+ )
248
+
249
+
250
+ def test_medium_all_to_near_wins():
251
+ """Concentrating ALL harvs on the NEAR patch is also a valid
252
+ optimum at this fleet size (3 harvs don't saturate the patch hard);
253
+ the bar discriminates "ignored NEAR" from "used NEAR", not from
254
+ "balanced vs concentrated"."""
255
+ _, res = _run("medium", lambda: _make_alloc([NEAR, NEAR, NEAR]))
256
+ assert res.outcome == "win", (
257
+ f"all-to-NEAR must WIN medium; got {res.outcome} ev={_ev(res)}"
258
+ )
259
+
260
+
261
+ # ---------------------------------------------------------------- HARD
262
+
263
+
264
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
265
+ def test_hard_stall_loses_every_seed(seed):
266
+ _, res = _run("hard", lambda: stall_policy, seed=seed)
267
+ assert res.outcome == "loss", (
268
+ f"stall must LOSE hard/seed{seed}; got {res.outcome} ev={_ev(res)}"
269
+ )
270
+
271
+
272
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
273
+ def test_hard_all_to_far_loses_every_seed(seed):
274
+ _, res = _run("hard", lambda: _make_alloc([H_FAR, H_FAR, H_FAR]), seed=seed)
275
+ assert res.outcome == "loss", (
276
+ f"all-to-FAR must LOSE hard/seed{seed} (~4500 ev < 22000 bar); "
277
+ f"got {res.outcome} ev={_ev(res)}"
278
+ )
279
+
280
+
281
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
282
+ def test_hard_uniform_1pn_1mid_1far_loses_every_seed(seed):
283
+ """The uniform "one per source" heuristic that drops one of the
284
+ two near patches loses every seed — too much load on transport-
285
+ expensive patches."""
286
+ _, res = _run("hard", lambda: _make_alloc([P_NORTH, H_MID, H_FAR]), seed=seed)
287
+ assert res.outcome == "loss", (
288
+ f"uniform 1-PN+1-MID+1-FAR must LOSE hard/seed{seed}; "
289
+ f"got {res.outcome} ev={_ev(res)}"
290
+ )
291
+
292
+
293
+ def test_hard_memorised_pn_loses_on_south_spawn_seeds():
294
+ """A model that memorises "always send to (16,14)" loses on
295
+ SOUTH-base seeds (1 and 3 per round-robin) — the matched NEAR
296
+ patch is (16,28), and (16,14) is now ~14 cells of vertical
297
+ travel from the proc, dropping yield to ~16500 ev < 22000."""
298
+ for seed in (1, 3):
299
+ _, res = _run("hard", lambda: _make_alloc([P_NORTH, P_NORTH, P_NORTH]), seed=seed)
300
+ assert res.outcome == "loss", (
301
+ f"memorised-PN must LOSE hard/seed{seed} (SOUTH spawn); "
302
+ f"got {res.outcome} ev={_ev(res)}"
303
+ )
304
+
305
+
306
+ def test_hard_memorised_ps_loses_on_north_spawn_seeds():
307
+ """Symmetric: memorising (16,28) loses on NORTH-base seeds 2 and 4."""
308
+ for seed in (2, 4):
309
+ _, res = _run("hard", lambda: _make_alloc([P_SOUTH, P_SOUTH, P_SOUTH]), seed=seed)
310
+ assert res.outcome == "loss", (
311
+ f"memorised-PS must LOSE hard/seed{seed} (NORTH spawn); "
312
+ f"got {res.outcome} ev={_ev(res)}"
313
+ )
314
+
315
+
316
+ @pytest.mark.parametrize("seed", [1, 2, 3, 4])
317
+ def test_hard_smart_spawn_matched_wins_every_seed(seed):
318
+ """The intended capability — identify the spawn-matched NEAR patch
319
+ from the agent's own base position, then concentrate harvs there —
320
+ WINS every seed cleanly."""
321
+ _, res = _run("hard", _make_smart_hard, seed=seed)
322
+ assert res.outcome == "win", (
323
+ f"SMART spawn-matched policy must WIN hard/seed{seed}; "
324
+ f"got {res.outcome} ev={_ev(res)}"
325
+ )
326
+
327
+
328
+ # ---------------------------------------------------------------- determinism
329
+
330
+
331
+ def test_outcomes_are_deterministic_per_seed():
332
+ """Same seed, same policy → identical outcome and ev."""
333
+ c = compile_level(load_pack(PACK), "medium")
334
+ a = run_level(c, _make_alloc([NEAR, NEAR, MID]), seed=2)
335
+ b = run_level(c, _make_alloc([NEAR, NEAR, MID]), seed=2)
336
+ assert (a.outcome, a.turns, _ev(a)) == (b.outcome, b.turns, _ev(b))
tests/test_hard_tier.py CHANGED
@@ -109,6 +109,13 @@ UPGRADED = [
109
  # (two 3tnk defenders + four harvs around proc 80,20) varies
110
  # per seed.
111
  "combat-harass-aggro-commit",
 
 
 
 
 
 
 
112
  ]
113
 
114
  # Consciously NOT spawn-varied, with the reason (keeps the curation
 
109
  # (two 3tnk defenders + four harvs around proc 80,20) varies
110
  # per seed.
111
  "combat-harass-aggro-commit",
112
+ # Group F econ reasoning (Wave-4): Weber multi-source / SC2
113
+ # mineral-patch allocation. Hard defines two agent spawn_point
114
+ # groups (NORTH base y=14 / SOUTH base y=28) round-robined by
115
+ # seed; the four neutral mines stay fixed but the NEAREST patch
116
+ # flips per seed ((16,14) for NORTH, (16,28) for SOUTH), so a
117
+ # memorised "always send to (16,14)" cannot generalise.
118
+ "econ-multi-patch-allocation",
119
  ]
120
 
121
  # Consciously NOT spawn-varied, with the reason (keeps the curation