yxc20098 commited on
Commit
1beda20
·
1 Parent(s): 0f48535

test(integ): end-to-end scenario→engine→score→leaderboard pipeline tests (task #13)

Browse files
Files changed (1) hide show
  1. tests/test_integ_pipeline.py +348 -0
tests/test_integ_pipeline.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end integration tests for the scenario→engine→score→leaderboard
2
+ pipeline (task #13).
3
+
4
+ Where the per-pack tests (test_combat_hold_chokepoint, test_perception_count,
5
+ …) each pin ONE pack's win/loss bar, this suite exercises the WHOLE pipeline
6
+ as a single flow and is designed to catch a regression at ANY stage:
7
+
8
+ 1. scenario → engine : `load_pack` + `compile_level` → `run_level` with a
9
+ scripted policy produces a terminal outcome (win/loss) with sane signals.
10
+ 2. engine → score : the `EpisodeResult` feeds `score_episode` (composite /
11
+ weakest-link / speed bonus); the ScoreCard has the expected shape and
12
+ bounded values.
13
+ 3. score → leaderboard: a full `run_eval.evaluate` report → `ingest_run` /
14
+ `build_table` (the same path `app.load_capability_leaderboard` calls)
15
+ produces a well-formed leaderboard row (model, per-capability means,
16
+ win_rate).
17
+
18
+ Deterministic — scripted policies only, small seed set, no model / no network.
19
+ Two representative, recently hardened packs are used as stable fixtures:
20
+
21
+ * combat-hold-chokepoint (capability: action) — a known-WINnable pack
22
+ with its intended hold-the-choke policy, and a known-LOSS stall policy.
23
+ * perception-count-the-threat (capability: perception) — a known-WINnable
24
+ pack with its intended just-enough-scout policy, and a known-LOSS stall.
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
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
35
+
36
+ from openra_bench.eval_core import EpisodeResult, run_level
37
+ from openra_bench.leaderboard import build_table, ingest_run
38
+ from openra_bench.run_eval import evaluate
39
+ from openra_bench.scenarios import load_pack
40
+ from openra_bench.scenarios.loader import compile_level
41
+ from openra_bench.scoring import ScoreCard, score_episode
42
+
43
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
44
+
45
+ # ── stable pack fixtures ─────────────────────────────────────────────────
46
+ # Both packs were no-cheat-redesigned and verified (every level/seed) on
47
+ # 2026-05-20; their intended-WIN and stall-LOSS scripted policies are the
48
+ # documented bar in the per-pack tests. Used here as deterministic anchors.
49
+ CHOKE = PACKS / "combat-hold-chokepoint.yaml"
50
+ PERCEPTION = PACKS / "perception-count-the-threat.yaml"
51
+
52
+
53
+ # ── scripted policies (copied verbatim from the per-pack tests so this
54
+ # suite stays self-contained and won't silently drift if those move) ─────
55
+
56
+
57
+ def _stall(_rs, Command):
58
+ """Burn the clock — only observe. Loses every winnable pack."""
59
+ return [Command.observe()]
60
+
61
+
62
+ def _choke_hold_policy(rs, Command):
63
+ """combat-hold-chokepoint intended WIN: keep the squad anchored in the
64
+ corridor and focus-fire the frontmost (lowest cell_x) light tank."""
65
+ units = rs.get("units_summary", []) or []
66
+ enemies = [
67
+ e
68
+ for e in (rs.get("enemy_summary", []) or [])
69
+ if not e.get("is_building")
70
+ and (e.get("type") or "").lower() == "1tnk"
71
+ ]
72
+ if not units or not enemies:
73
+ return [Command.observe()]
74
+ front = min(enemies, key=lambda e: e["cell_x"])
75
+ return [
76
+ Command.attack_unit([str(u["id"])], str(front["id"]))
77
+ for u in units
78
+ ]
79
+
80
+
81
+ def _perception_scout_policy(rs, Command):
82
+ """perception-count-the-threat (easy) intended WIN: drive every scout
83
+ to the single near-east cluster's sight-line and reveal all of it."""
84
+ units = rs.get("units_summary", []) or []
85
+ if not units:
86
+ return [Command.observe()]
87
+ return [
88
+ Command.move_units([str(u["id"])], target_x=40, target_y=5)
89
+ for u in units
90
+ ]
91
+
92
+
93
+ # ─────────────────────────────────────────────────────────────────────────
94
+ # Stage 1+2 — scenario → engine → score: a known-winnable pack with the
95
+ # intended scripted policy must produce a terminal WIN scored > 0.
96
+ # ─────────────────────────────────────────────────────────────────────────
97
+
98
+
99
+ @pytest.mark.parametrize(
100
+ "pack_path, level, policy",
101
+ [
102
+ (CHOKE, "easy", _choke_hold_policy),
103
+ (PERCEPTION, "easy", _perception_scout_policy),
104
+ ],
105
+ )
106
+ def test_winnable_pack_intended_policy_wins_and_scores_positive(
107
+ pack_path, level, policy
108
+ ):
109
+ """Full scenario→engine→score flow on a winnable pack: the intended
110
+ scripted policy yields a terminal WIN; scoring the EpisodeResult
111
+ produces a well-shaped ScoreCard with a bounded, positive composite."""
112
+ compiled = compile_level(load_pack(pack_path), level)
113
+ assert compiled.map_supported, "fixture pack must be Rust-loadable"
114
+
115
+ # scenario → engine
116
+ res = run_level(compiled, policy, seed=1)
117
+ assert isinstance(res, EpisodeResult)
118
+ assert res.outcome == "win", (
119
+ f"{pack_path.stem}:{level} intended policy must WIN; got "
120
+ f"{res.outcome} (killed={res.signals.units_killed} "
121
+ f"lost={res.signals.units_lost} tick={res.signals.game_tick})"
122
+ )
123
+ # sane episode signals — a real terminated episode, not a degenerate one
124
+ assert 1 <= res.turns <= compiled.max_turns
125
+ assert res.signals.game_tick > 0
126
+ assert res.actions_issued >= res.turns # ≥1 command per decision turn
127
+ assert res.signals.units_killed >= 0 and res.signals.units_lost >= 0
128
+ assert res.signals.outcome == 1.0 # win maps to 1.0
129
+
130
+ # engine → score
131
+ card = score_episode(compiled, res)
132
+ assert isinstance(card, ScoreCard)
133
+ assert card.outcome == "win"
134
+ assert 0.0 < card.composite <= 1.0
135
+ # a WIN must score strictly above its own pre-speed-bonus base only if
136
+ # the win was fast; either way the bonus is bounded and never negative.
137
+ assert card.composite >= card.composite_base
138
+ assert 0.0 <= card.speed <= 1.0
139
+ assert card.composite - card.composite_base <= 0.05 + 1e-9 # SPEED_BONUS
140
+ # P/R/A diagnostics all in [0,1]; weakest_link names one of them.
141
+ for link in ("perception", "reasoning", "action"):
142
+ assert 0.0 <= getattr(card, link) <= 1.0
143
+ assert card.weakest_link in ("perception", "reasoning", "action")
144
+ # speed-bonus accounting is self-consistent on a win.
145
+ assert card.win_tick > 0 and card.win_turns > 0 and card.win_budget > 0
146
+
147
+
148
+ # ─────────────────────────────────────────────────────────────────────────
149
+ # Stage 1+2 — a stall policy on a winnable pack must produce a terminal
150
+ # LOSS scored low (well below the intended-WIN composite).
151
+ # ─────────────────────────────────────────────────────────────────────────
152
+
153
+
154
+ @pytest.mark.parametrize(
155
+ "pack_path, level", [(CHOKE, "easy"), (PERCEPTION, "easy")]
156
+ )
157
+ def test_stall_policy_loses_and_scores_low(pack_path, level):
158
+ """A stall policy on a winnable pack must terminate as a real LOSS,
159
+ and its composite must score strictly below the intended WIN — the
160
+ score path must discriminate effort, not collapse win/loss together."""
161
+ compiled = compile_level(load_pack(pack_path), level)
162
+
163
+ loss = run_level(compiled, _stall, seed=1)
164
+ assert loss.outcome == "loss", (
165
+ f"{pack_path.stem}:{level} stall must LOSE; got {loss.outcome}"
166
+ )
167
+ assert loss.signals.outcome == 0.0
168
+ loss_card = score_episode(compiled, loss)
169
+ assert loss_card.outcome == "loss"
170
+ assert 0.0 <= loss_card.composite <= 1.0
171
+ # a loss earns no speed bonus
172
+ assert loss_card.speed == 0.0
173
+ assert loss_card.win_tick == 0 and loss_card.win_turns == 0
174
+ assert loss_card.composite == loss_card.composite_base
175
+
176
+ # the intended winning policy on the SAME pack must out-score the stall.
177
+ policy = _choke_hold_policy if pack_path == CHOKE else _perception_scout_policy
178
+ win = run_level(compiled, policy, seed=1)
179
+ assert win.outcome == "win"
180
+ win_card = score_episode(compiled, win)
181
+ assert win_card.composite > loss_card.composite, (
182
+ f"{pack_path.stem}:{level} WIN composite {win_card.composite} must "
183
+ f"exceed stall LOSS composite {loss_card.composite}"
184
+ )
185
+
186
+
187
+ # ─────────────────────────────────────────────────────────────────────────
188
+ # Stage 1 — a malformed / edge pack must fail gracefully, not crash.
189
+ # ─────────────────────────────────────────────────────────────────────────
190
+
191
+
192
+ def test_malformed_pack_raises_clean_error_not_crash(tmp_path):
193
+ """A structurally invalid pack YAML must raise a clean, contextual
194
+ error from `load_pack` (ValueError with the file path) — never an
195
+ uncaught crash or a silently mis-compiled scenario."""
196
+ bad = tmp_path / "broken.yaml"
197
+ bad.write_text("meta: {id: broken}\nthis is: not a valid pack\n")
198
+ with pytest.raises((ValueError, Exception)) as exc:
199
+ load_pack(bad)
200
+ # the loader wraps the underlying validation error with file context.
201
+ assert "broken.yaml" in str(exc.value) or "broken" in str(exc.value)
202
+
203
+
204
+ def test_empty_pack_raises_not_crash(tmp_path):
205
+ """An empty pack file must raise, not produce a half-built object."""
206
+ empty = tmp_path / "empty.yaml"
207
+ empty.write_text("")
208
+ with pytest.raises(Exception):
209
+ load_pack(empty)
210
+
211
+
212
+ def test_unsupported_map_pack_is_skipped_gracefully(tmp_path):
213
+ """A pack pointing at a non-Rust-loadable base map must be reported
214
+ as skipped by `evaluate`, never raise — the pipeline degrades, the
215
+ leaderboard ingestion still produces a valid (empty) report."""
216
+ src = (PACKS / "perception-count-the-threat.yaml").read_text()
217
+ # repoint the base map at a name no .oramap resolves.
218
+ import re
219
+
220
+ mangled = re.sub(
221
+ r"base_map:\s*\S+", "base_map: no-such-map-xyz", src, count=1
222
+ )
223
+ assert "no-such-map-xyz" in mangled
224
+ f = tmp_path / "future.yaml"
225
+ f.write_text(mangled)
226
+ stats = evaluate(packs=[f], levels=["easy"], seeds=[1])
227
+ assert stats["overall"]["n"] == 0
228
+ assert any("not Rust-loadable" in s for s in stats["skipped"])
229
+ # the report is still well-formed and leaderboard-ingestible.
230
+ rec = ingest_run(stats, "degraded-run", tmp_path / "lb.jsonl")
231
+ assert rec["episodes"] == 0
232
+
233
+
234
+ # ─────────────────────────────────────────────────────────────────────────
235
+ # Stage 3 — score → leaderboard: a full evaluate() report over ≥2 episodes
236
+ # ingests into a well-formed leaderboard row.
237
+ # ─────────────────────────────────────────────────────────────────────────
238
+
239
+
240
+ def test_full_pipeline_evaluate_to_leaderboard_row(tmp_path):
241
+ """End-to-end: evaluate a winnable pack over ≥2 seeds → ingest the
242
+ report → build_table produces one well-formed leaderboard row with a
243
+ bounded win_rate, composite, and per-capability breakdown."""
244
+ stats = evaluate(
245
+ packs=[CHOKE],
246
+ levels=["easy"],
247
+ seeds=[1, 2],
248
+ agent_factory=lambda _c: _choke_hold_policy,
249
+ run_id="integ-test",
250
+ )
251
+ # the eval report itself is well-formed.
252
+ assert stats["overall"]["n"] == 2
253
+ assert len(stats["episodes"]) == 2
254
+ o = stats["overall"]
255
+ assert 0.0 <= o["win_rate"] <= 1.0
256
+ assert 0.0 <= o["composite_mean"] <= 1.0
257
+ # the intended policy wins both seeds → win_rate 1.0.
258
+ assert o["win_rate"] == 1.0, f"intended policy must sweep; got {o}"
259
+ for ep in stats["episodes"]:
260
+ assert ep["cell"] == "combat-hold-chokepoint:easy"
261
+ assert ep["capability"] == "action"
262
+ assert ep["outcome"] == "win"
263
+ assert 0.0 < ep["composite"] <= 1.0
264
+
265
+ # score → leaderboard ingestion (the path app.load_capability_leaderboard
266
+ # exercises).
267
+ store = tmp_path / "lb.jsonl"
268
+ rec = ingest_run(stats, "integ-model", store)
269
+ assert rec["model"] == "integ-model"
270
+ assert rec["episodes"] == 2
271
+ assert rec["win_rate"] == 1.0
272
+ assert 0.0 < rec["composite"] <= 1.0
273
+ # per-capability means: the only pack is `action`.
274
+ assert "action" in rec["by_capability"]
275
+ cap = rec["by_capability"]["action"]
276
+ assert cap["n"] == 2
277
+ assert cap["win_rate"] == 1.0
278
+ assert 0.0 <= cap["composite"] <= 1.0
279
+
280
+ # build_table → the ranked leaderboard row (min_episodes=1: 2 < default 5).
281
+ table = build_table(store, min_episodes=1)
282
+ assert len(table) == 1
283
+ row = table[0]
284
+ assert row["rank"] == 1
285
+ assert row["model"] == "integ-model"
286
+ assert row["win_rate"] == 1.0
287
+ assert 0.0 < row["composite"] <= 1.0
288
+ assert row["weakest_link"] in ("perception", "reasoning", "action", "n/a")
289
+ # P/R/A means surface on the row, each bounded.
290
+ for link in ("perception", "reasoning", "action"):
291
+ assert 0.0 <= row[link] <= 1.0
292
+
293
+
294
+ def test_leaderboard_aggregates_and_ranks_two_models(tmp_path):
295
+ """Leaderboard aggregation over ≥2 episodes AND ≥2 models: a strong
296
+ (intended-WIN) run must out-rank a weak (stall-LOSS) run on the same
297
+ pack — the score→leaderboard path must preserve the win/loss signal
298
+ through aggregation and ranking."""
299
+ store = tmp_path / "lb.jsonl"
300
+
301
+ strong = evaluate(
302
+ packs=[CHOKE],
303
+ levels=["easy"],
304
+ seeds=[1, 2],
305
+ agent_factory=lambda _c: _choke_hold_policy,
306
+ run_id="strong",
307
+ )
308
+ weak = evaluate(
309
+ packs=[CHOKE],
310
+ levels=["easy"],
311
+ seeds=[1, 2],
312
+ agent_factory=lambda _c: _stall,
313
+ run_id="weak",
314
+ )
315
+ assert strong["overall"]["win_rate"] == 1.0
316
+ assert weak["overall"]["win_rate"] == 0.0
317
+
318
+ ingest_run(strong, "strong-model", store)
319
+ ingest_run(weak, "weak-model", store)
320
+
321
+ table = build_table(store, min_episodes=1)
322
+ assert [r["model"] for r in table] == ["strong-model", "weak-model"], (
323
+ "the intended-WIN run must rank above the stall-LOSS run"
324
+ )
325
+ assert table[0]["rank"] == 1 and table[1]["rank"] == 2
326
+ assert table[0]["composite"] > table[1]["composite"]
327
+ assert table[0]["win_rate"] == 1.0 and table[1]["win_rate"] == 0.0
328
+ # ranking is deterministic across rebuilds.
329
+ again = build_table(store, min_episodes=1)
330
+ assert [r["model"] for r in again] == [r["model"] for r in table]
331
+
332
+
333
+ def test_pipeline_is_deterministic_end_to_end():
334
+ """The same (pack, policy, seed) must produce a bit-identical
335
+ EpisodeResult outcome + ScoreCard composite across runs — the whole
336
+ scenario→engine→score path is deterministic, so a regression at any
337
+ stage is reproducible."""
338
+ compiled = compile_level(load_pack(CHOKE), "easy")
339
+ r1 = run_level(compiled, _choke_hold_policy, seed=1)
340
+ r2 = run_level(compiled, _choke_hold_policy, seed=1)
341
+ assert r1.outcome == r2.outcome
342
+ assert r1.signals.units_killed == r2.signals.units_killed
343
+ assert r1.signals.game_tick == r2.signals.game_tick
344
+ assert r1.turns == r2.turns
345
+ c1 = score_episode(compiled, r1)
346
+ c2 = score_episode(compiled, r2)
347
+ assert c1.composite == c2.composite
348
+ assert c1.weakest_link == c2.weakest_link