yxc20098 commited on
Commit
07dfe2e
·
1 Parent(s): cb15568

Paper-experiment prep: human-study harness + pass^k + paper plan

Browse files

A coordinated batch landing the infrastructure the paper experiments
need before the full 200-pack run.

PAPER_PLAN.md — the working paper plan: central thesis, the
"perception not planning" framing, contributions, paper structure,
the three findings with sharpened claims + experiments + reviewer
attacks/defenses, §5 strategy-diversity (1v1 trajectory-embedding
figure), §12 pre-full-run audits (quality / coverage / pass^k),
limitations, stretch ideas.

Human-baseline study (the apples-to-apple human reference):
- openra_bench/human_study.py — frozen stratified 24-pack subset
(one pack per scenario family, 8 easy / 8 medium / 8 hard),
3 conditions (vision-fog / vision-clear / handoff-bad), a per-
player counterbalanced 72-cell playlist, and an
open_study_session() constructor that configures each condition
(vision-clear -> reveal_map; handoff-bad -> HANDOFF_K=3 stall
prefix auto-played).
- openra_bench/human_labeling.py — InteractiveSession.from_pack
gains a fog_mode param so the human Play path can run the no-fog
condition.
- app.py — Play-tab "Human-study mode" accordion: player id,
Begin study / Next scenario, progress tracker; walks a recruited
player through all 72 cells. Every game auto-saves apples-to-
apple with model runs.
- tests/test_human_study.py — subset packs verified to exist; the
three conditions verified live (vision-clear reveals 100% +
every enemy; handoff-bad starts HANDOFF_K turns deep); the
study session persists in the standard Playback format.

Multi-run reliability (the missing variance metric):
- openra_bench/run_eval.py — --repeats N runs each (cell, seed) N
times varying only model nondeterminism, so the report can carry
mean +- CI and pass^k (all-k wins) alongside pass@k. Each record
carries a repeat index; only rep=0 writes a Playback (no per-turn
dump bloat). --temperature T threads through ProviderConfig so
the repeats are meaningful.
- tests/test_run_eval_repeats.py — tasks multiply by N; records
carry the repeat index.

Full suite 8098 passed, 1 skipped, 3 xfailed, 2 xpassed.

PAPER_PLAN.md ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-Bench — Paper Plan & Critique
2
+
3
+ Working document. Captures the central thesis, the paper framing, the
4
+ three findings, the experiment program, and the open critiques /
5
+ reviewer-defenses. Living doc — update as experiments land.
6
+
7
+ ---
8
+
9
+ ## 1. Central thesis (as stated)
10
+
11
+ We build a benchmark on the Real-Time Strategy game *Red Alert* and
12
+ measure LLM performance on **adversarial, multi-modal, long-horizon
13
+ strategic planning and execution**. Three findings:
14
+
15
+ 1. **The major gap is image-based perception.** In pure-text
16
+ battlefields models execute complex strategies; in mixed modality
17
+ — where own and enemy positions must be read off a minimap — they
18
+ struggle. We show this by SFT on a small set of text-modality
19
+ results with the image attached, and observe improvement on this
20
+ benchmark *and* on external vision benchmarks (ERQA).
21
+ 2. **Models panic in unfavorable / uncertain situations.** Scores
22
+ drop sharply under fog of war while humans hold a baseline. Under
23
+ heavy losses, models choose `observe`/`stop` instead of actively
24
+ redirecting units. Hypothesis: a transfer of behavior from
25
+ code-heavy training, where the rewarded move in an unfavorable
26
+ state is to stop and wait for human intervention.
27
+ 3. **Bridging both gaps via simple SFT** lets a *smaller* model beat
28
+ large reasoning models on 1v1 ELO.
29
+
30
+ Contributions: 200 research-grounded scenarios; an active 1v1
31
+ battleground; evidence that models form complex, diverse strategies
32
+ despite the perception and adversity gaps (and that strategy varies
33
+ with temperature and is model-characteristic — turtle / balanced /
34
+ aggressive).
35
+
36
+ ---
37
+
38
+ ## 2. Framing & positioning
39
+
40
+ **The hook is the inversion:** LLMs can do the *hard* part
41
+ (long-horizon adversarial strategy) but fail the "easy" parts
42
+ (reading a map, holding their nerve). Lead with that — it inverts the
43
+ usual "LLMs can't plan" narrative, and the failures are specific and
44
+ *fixable*.
45
+
46
+ Position OpenRA-Bench as a **diagnostic instrument**, not a
47
+ leaderboard. The methodological novelty vs. the crowded
48
+ SC2 / TextStarCraft LLM-agent space is the **ablation methodology that
49
+ decomposes failure** into perception / reasoning / action / adversity.
50
+ Arc of the paper: **diagnosis → localization → treatment → transfer.**
51
+
52
+ Title candidates:
53
+ - *Map-Blind and Panic-Prone: Diagnosing LLM Strategic Agents in
54
+ Real-Time Strategy*
55
+ - *The Bottleneck Is Perception, Not Planning: A Diagnostic RTS
56
+ Benchmark*
57
+ - *LLMs Can Strategize But Cannot See: Decomposing Agent Failure in
58
+ Red Alert*
59
+
60
+ Venue: strong enough for a main track (ICLR/NeurIPS/ICML) on the
61
+ diagnosis+treatment+transfer arc — not only Datasets & Benchmarks.
62
+
63
+ ---
64
+
65
+ ## 3. Contributions (sharpened)
66
+
67
+ 1. **OpenRA-Bench** — 200 controlled RTS scenarios on a deterministic
68
+ engine, each anchored to a named real-world capability / external
69
+ benchmark, built to a **no-cheat / no-defect** bar (every lazy /
70
+ brute / stall policy provably loses on every level and seed). The
71
+ validation rigor is itself a contribution: it is why the benchmark
72
+ measures the *intended* capability.
73
+ 2. **An ablation methodology that decomposes failure** — the
74
+ perception grid (channel × fog) and the handoff sweep — turning
75
+ "the model lost" into "the model lost *because of perception /
76
+ adversity / planning*."
77
+ 3. **A 1v1 self-play battleground** with ELO.
78
+ 4. **Two localized, fixable gaps** — perception-binding and
79
+ inaction-under-adversity — each with an SFT remedy, cross-benchmark
80
+ transfer (ERQA), and a small-beats-large result.
81
+
82
+ ---
83
+
84
+ ## 4. Paper structure
85
+
86
+ §1 Intro (the inversion) · §2 Related work · §3 OpenRA-Bench (engine,
87
+ 200 scenarios, no-cheat design, ablation axes, 1v1/ELO,
88
+ human-labeling) · §4 Setup (models, sweeps, metrics) · **§5 Models
89
+ *can* strategize** (the diversity control) · §6 Finding 1: perception
90
+ gap · §7 Finding 2: panic · §8 Finding 3: SFT remedy + transfer ·
91
+ §9 Limitations · §10 Conclusion.
92
+
93
+ **Key structural move:** promote the "diverse strategies" result from
94
+ a bonus to **§5, before the failure findings.** Showing models produce
95
+ coherent, diverse, model-characteristic strategies is what licenses
96
+ the claim that the later failures are *not* failures of strategic
97
+ reasoning. Without it a reviewer says "maybe they just can't plan."
98
+
99
+ ---
100
+
101
+ ## 5. §5 control — models *can* strategize
102
+
103
+ Promote from "bonus" to a load-bearing control.
104
+
105
+ - Classify each game's strategy (turtle / balanced / aggressive) via a
106
+ trajectory classifier (first-attack tick, army-vs-econ ratio,
107
+ expansion count, aggression index, build order). Show per-model
108
+ distributions differ.
109
+ - Temperature sweep: strategy entropy vs. temperature for one model.
110
+ - Show strategies are *coherent* (executed consistently once chosen),
111
+ not random.
112
+
113
+ **The headline figure — the strategy-embedding scatter, in 1v1.** Each
114
+ game's trajectory is featurized and embedded to 2D (PCA / UMAP);
115
+ points colored by model. The plot shows, at a glance:
116
+ - **inter-model clusters** — model A lives in the aggressive region,
117
+ model B turtles — models have characteristic strategy *priors*;
118
+ - **intra-model spread** — the same model across games (especially at
119
+ high temperature) scatters — one model generates *diverse* play.
120
+ 1v1 is the right venue: full-macro adversarial games make strategy
121
+ legible in a way single-scenario tasks do not. A bigger model roster
122
+ (8–12, Together + Bedrock) makes the clustering visually striking.
123
+
124
+ This is the evidence that planning is not the bottleneck — and a
125
+ genuinely compelling standalone result.
126
+
127
+ ---
128
+
129
+ ## 6. Finding 1 — Perception gap
130
+
131
+ **Claim, sharpened.** Decompose perception into **extraction** (read
132
+ state from the image) and **binding** (act on image-derived state).
133
+ The perception sweep already separates these: `image`-channel on
134
+ perception packs (count / locate) isolates extraction; `image` on
135
+ action packs isolates binding. Report *which* breaks. The result is
136
+ "the perception→action **binding** fails while pure planning and pure
137
+ extraction are intact" — not the weak "mixed modality is hard."
138
+
139
+ **Experiments.**
140
+ - Perception sweep (6 cells) × model roster × scenario set × seeds →
141
+ the modality-gap number per model (`structured − image`).
142
+ - A "perceive-then-act" scaffold (force the model to transcribe
143
+ positions first) — if it rescues performance, the bottleneck is
144
+ binding, not extraction.
145
+ - **Cross-modal action distillation SFT**: text-mode *winning*
146
+ trajectories + the attached minimap → train image→action. Eval
147
+ `image` channel on **held-out** scenarios.
148
+ - **Transfer**: ERQA + ≥2 other spatial / vision benchmarks
149
+ (e.g. BLINK, a spatial-VQA set), before / after SFT.
150
+
151
+ **Why transfer is the crown jewel.** Training image→action where the
152
+ action is good could teach good *action priors* without the model
153
+ ever using the image. The cross-benchmark transfer is what proves the
154
+ SFT taught the model to *see*. Without it, finding 1 is "task
155
+ finetuning helps task." With it, it is "agentic RTS data improves
156
+ general visual spatial reasoning." Use ≥2 external benchmarks so it
157
+ cannot be called cherry-picked.
158
+
159
+ **Reviewer attack → defense.** "VLMs are known to be bad at dense
160
+ small images — this is just OCR." → We measure it in a *consequential
161
+ agentic loop*, we *localize* extraction vs binding, and we show a fix
162
+ that *transfers*. Plus a render-style mini-ablation (the
163
+ `constant_colors` / scale knobs) so the gap is not an artifact of one
164
+ minimap style.
165
+
166
+ ---
167
+
168
+ ## 7. Finding 2 — Panic / inaction under adversity
169
+
170
+ **Claim.** Models degrade far more than humans under fog; under heavy
171
+ losses they default to `observe` / `stop` instead of active redirect.
172
+ Operational definition = the **`passivity` metric** (fraction of the
173
+ model's turns spent on `observe` / `stop` only). Keep "panic" only as
174
+ an informal label; formal text says "inaction bias under adversity."
175
+
176
+ **Experiments.**
177
+ - Fog ablation × models × **humans** — report the *fog-penalty gap*
178
+ (human degrades little, model degrades a lot), not raw fog scores.
179
+ - Handoff bad-prefix → passivity, models vs. human. Models freeze
180
+ (high passivity), humans redirect (low).
181
+ - Show passivity is *causally costly* — within-model, passive turns
182
+ predict worse outcomes controlling for position quality.
183
+
184
+ **The coding-bias hypothesis is the spiciest claim — handle with
185
+ care.** Cannot be asserted as fact. Keep as a hypothesis AND support
186
+ with ≥1 of:
187
+ - **base vs. RLHF / instruct** versions of the same model — if base
188
+ models panic less, post-training is implicated;
189
+ - a **loss-aware prompt intervention** ("inaction is costly; never
190
+ just observe when losing") — if a prompt largely fixes it, the
191
+ behavior is a shallow prior, consistent with the hypothesis;
192
+ - passivity vs. known code / RLHF intensity across the roster.
193
+
194
+ **Critical-path risk.** The human baseline needs *real human data at
195
+ scale*. The Play tab is built; data is not collected. Scope: a
196
+ representative scenario subset, several humans, disclosed RTS skill. A
197
+ strong scripted policy can serve as a secondary "competent
198
+ non-panicking" reference if human N is thin.
199
+
200
+ ---
201
+
202
+ ## 8. Finding 3 — SFT remedy, small beats large
203
+
204
+ **Reviewer attack (serious).** "A task-finetuned small model beating a
205
+ zero-shot large model is trivial." **Defenses to build in:**
206
+ - The SFT is **small and targeted** (perception grounding + active
207
+ recovery), explicitly *not* trained on eval scenarios — state the
208
+ train / eval split loudly.
209
+ - **Also SFT the large model** — show the gap is real and fixable at
210
+ every scale. Headline becomes: "the perception+panic gap is large
211
+ enough that closing it in a small model *outweighs a 10× reasoning
212
+ advantage* — and it was never a reasoning gap."
213
+ - Ablate the SFT: perception-only / recovery-only / both — each
214
+ component contributes.
215
+ - ELO with enough games + confidence intervals; held-out 1v1 maps.
216
+
217
+ **Infrastructure synergy (state explicitly).** The handoff
218
+ `TrajectoryController` + the human-Playback format *are* the SFT data
219
+ pipeline — recovered bad-prefix episodes are active-recovery
220
+ exemplars; text-mode wins are perception-distillation data. The
221
+ ablation infrastructure doubles as the training-data factory.
222
+
223
+ ---
224
+
225
+ ## 9. Experiment program
226
+
227
+ | # | Experiment | Infra | Status |
228
+ |---|---|---|---|
229
+ | §5 strategy diversity | classify 1v1 games; temp sweep; per-model dists | 1v1 harness ✓; needs strategy classifier | TODO |
230
+ | Perception sweep | 6-cell × roster × scenarios × seeds | ✓ `--perception-sweep` | **run** |
231
+ | Handoff / passivity | base/bad/good × roster | ✓ `--handoff-sweep` | **run** |
232
+ | Human baseline | fog × scenario subset × humans | Play tab ✓, **no data** | **highest-risk** |
233
+ | Cross-modal SFT + transfer | distill text→image; ERQA + 2 more | data pipeline ✓, needs finetuning | **biggest compute** |
234
+ | 1v1 ELO tournament | round-robin + CIs | harness ✓ | run |
235
+ | Recovery SFT | active-recovery exemplars → finetune | handoff bank ✓ | run |
236
+
237
+ Critical path: human baseline (logistics) and the SFT (compute).
238
+
239
+ ---
240
+
241
+ ## 10. Metrics
242
+
243
+ Win-rate / outcome · composite P/R/A score · objective-progress
244
+ (continuous) · ELO (1v1, with CIs) · **passivity** (freeze metric) ·
245
+ generalization gap (public vs. held-out seeds) · strategy class /
246
+ entropy · human-normalized score (model / human) · derived gaps:
247
+ modality gap = `score(structured) − score(image)`, fog penalty =
248
+ `score(clear) − score(fog)`, per model and per human.
249
+
250
+ ---
251
+
252
+ ## 11. Threats to validity / limitations to preempt
253
+
254
+ - **One game (RA).** Lean on the capability taxonomy
255
+ (`meta.benchmark_anchor`) + the ERQA transfer for generality.
256
+ - **Engine is a reimplementation.** Deterministic + validated is the
257
+ answer.
258
+ - **One minimap render style.** Render-robustness ablation.
259
+ - **Human skill / N.** Disclose; representative subset.
260
+ - **"Panic = code training."** Hypothesis, not claim — support with
261
+ the probes in §7.
262
+ - **SFT leakage.** Loud train/eval scenario split.
263
+ - **ELO methodology.** Game count, pairing, confidence intervals.
264
+
265
+ ---
266
+
267
+ ## 12. Pre-full-run audits (must land before the 200-pack sweep)
268
+
269
+ After the pilot finishes and *before* committing compute to the full
270
+ 200-pack run, three audits gate the rigor of the headline numbers:
271
+
272
+ ### 12.1 Scenario quality audit
273
+ Two layers:
274
+ - **Static** — re-run the scripted-policy bar (`stall` / `brute` /
275
+ `intended`) across all 200 packs. Engine fixes may have drifted a
276
+ pack since authoring (a lazy policy now wins, or `intended` now
277
+ loses). Catches benchmark rot.
278
+ - **Empirical** — from pilot/full-run data, flag packs where *every*
279
+ model wins (too easy / a trivial idiom dominates — task #43) or
280
+ *every* model loses (unsolvable or a predicate is mis-tuned —
281
+ task #44). Discriminative packs are the only useful ones.
282
+
283
+ Paper payoff: a post-hoc audit table converts the "no-defect bar"
284
+ claim into something you can *show* — a strong methodology subsection.
285
+
286
+ ### 12.2 Coverage map — RTS phase × decision-divergence
287
+ Map all 200 packs (plus the 1v1 battleground) onto the **RTS phase ×
288
+ decision-divergence matrix** from the original plan
289
+ (opening / early-mid / mid / mid-late / late × the canonical decisions
290
+ in each). Produce a coverage heatmap; flag empty / thin cells. Surface
291
+ the `meta.capability`-tag imbalance (`adversarial`=1 pack — full
292
+ end-to-end macro lives in the 1v1 battleground, both belong on the
293
+ map). Paper payoff: a figure showing the bench spans the *real* game,
294
+ not just easy probes.
295
+
296
+ ### 12.3 Multi-run reliability — `pass^k`
297
+ Each (cell, seed) is run N times varying only model nondeterminism
298
+ (requires temperature > 0). Report mean ± CI **and** `pass^k`
299
+ (all-k-wins). A model that wins 5/10 identical runs is a fundamentally
300
+ different finding than 10/10. `--repeats N` in `run_eval`; default
301
+ `k=5` (Codex / SWE-bench convention). Paper payoff: mean-only is
302
+ fragile; reliability is itself a possible headline result.
303
+
304
+ ## 13. Stretch ideas
305
+
306
+ - **Pivotal-turn analysis** — single-turn counterfactual swaps to show
307
+ RTS losses are 1–2 catastrophic decisions, not uniform decay.
308
+
309
+ ---
310
+
311
+ ## 13. Ablation infrastructure already built (this is real, today)
312
+
313
+ - **Fog axis** — engine `reveal_map` no-fog flag (`OpenRA-Rust`),
314
+ the `-clear` perception cells.
315
+ - **Modality axis** — `structured` / `vision` / `image` (image-
316
+ primary, text redacted, labelled minimap) channels;
317
+ `run_eval --perception-sweep` expands `pack:level` into the 6
318
+ modality cells.
319
+ - **Handoff axis** — `openra_bench/handoff.py`
320
+ (`HandoffController`, `TrajectoryController`), `run_eval
321
+ --handoff-sweep`; the `passivity` metric on every result.
322
+ - **1v1 battleground + ELO** — `one_v_one.py`, scripted ladder.
323
+ - **Human-labeling** — the Play tab persists human runs in the
324
+ standard `Playback` format (apples-to-apples with model runs).
325
+ - **200 scenario packs** — no-cheat-validated, capability-anchored.
326
+
327
+ ---
328
+
329
+ ## 14. Open decisions
330
+
331
+ - Model roster for the sweeps (which models; vision-capable required
332
+ for `vision` / `image` channels).
333
+ - Compute / API budget for the full sweeps.
334
+ - Human-study scope (how many humans, which scenario subset).
335
+ - SFT base model(s) and the small/large pairing for finding 3.
336
+ - Strategy-classifier definition for §5.
app.py CHANGED
@@ -1242,6 +1242,76 @@ def _play_start(prev_sess, pack, level, seed):
1242
  )
1243
 
1244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1245
  def _play_click(sess, sel, queue, evt: gr.SelectData):
1246
  """Contextual minimap click — classic RTS interaction, no mode:
1247
 
@@ -1567,6 +1637,29 @@ def build_app() -> gr.Blocks:
1567
  play_sess = gr.State(None)
1568
  play_sel = gr.State([])
1569
  play_queue = gr.State([])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1570
  with gr.Row():
1571
  play_scen = gr.Dropdown(
1572
  choices=_play_scenarios(), label="Scenario",
@@ -1613,6 +1706,19 @@ def build_app() -> gr.Blocks:
1613
  play_img, play_brief, play_status, play_units,
1614
  ],
1615
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
1616
  play_img.select(
1617
  _play_click,
1618
  inputs=[play_sess, play_sel, play_queue],
 
1242
  )
1243
 
1244
 
1245
+ # ── Human-study mode ─────────────────────────────────────────────────
1246
+ # Walks a recruited player through the fixed 24-pack study subset under
1247
+ # 3 conditions (72 games, per-player counterbalanced). Every game saves
1248
+ # to the standard Playback format — apples-to-apple with model runs.
1249
+
1250
+ def _study_progress_md(st: dict) -> str:
1251
+ pl = st.get("playlist", [])
1252
+ i = st.get("idx", 0)
1253
+ if not pl:
1254
+ return "_Enter your name and click **Begin study**._"
1255
+ if i >= len(pl):
1256
+ return (
1257
+ f"**✅ Study complete** — all {len(pl)} games done. "
1258
+ f"Thank you, `{st['player']}`!"
1259
+ )
1260
+ pack, level, cond = pl[i]
1261
+ return (
1262
+ f"**Study — game {i + 1} / {len(pl)}** · player `{st['player']}`\n\n"
1263
+ f"`{pack}` [{level}] · condition: **{cond}** \n"
1264
+ f"_Play to game-over, then click **Next scenario ▶**._"
1265
+ )
1266
+
1267
+
1268
+ def _study_render(st: dict, prev_sess):
1269
+ """Open the study session for st's current cell and render it."""
1270
+ if prev_sess is not None:
1271
+ try:
1272
+ prev_sess.close()
1273
+ except Exception: # noqa: BLE001
1274
+ pass
1275
+ empty = _play_units_df(None, [])
1276
+ pl = st.get("playlist", [])
1277
+ if st.get("idx", 0) >= len(pl):
1278
+ return (None, [], [], "", None, "", "", empty, st,
1279
+ _study_progress_md(st))
1280
+ pack, level, cond = pl[st["idx"]]
1281
+ try:
1282
+ from openra_bench.human_study import open_study_session
1283
+
1284
+ sess = open_study_session(
1285
+ pack, level, cond, player=st["player"], seed=1
1286
+ )
1287
+ except Exception as e: # noqa: BLE001
1288
+ return (None, [], [], "", None, f"⚠️ {e}",
1289
+ "_study load failed_", empty, st, _study_progress_md(st))
1290
+ img, brief, status, units = _play_render(sess, [], [])
1291
+ return (sess, [], [], _play_objective_md(sess), img, brief, status,
1292
+ units, st, _study_progress_md(st))
1293
+
1294
+
1295
+ def _study_begin(prev_sess, player):
1296
+ import hashlib
1297
+
1298
+ from openra_bench.human_study import study_playlist
1299
+
1300
+ player = (player or "").strip() or "anon"
1301
+ # Per-player counterbalancing — a stable seed from the name.
1302
+ seed = int(hashlib.md5(player.encode()).hexdigest()[:8], 16)
1303
+ st = {"player": player, "playlist": study_playlist(seed), "idx": 0}
1304
+ return _study_render(st, prev_sess)
1305
+
1306
+
1307
+ def _study_next(prev_sess, st):
1308
+ if not st or "playlist" not in st:
1309
+ return _study_render({"player": "anon", "playlist": []}, prev_sess)
1310
+ st = dict(st)
1311
+ st["idx"] = st.get("idx", 0) + 1
1312
+ return _study_render(st, prev_sess)
1313
+
1314
+
1315
  def _play_click(sess, sel, queue, evt: gr.SelectData):
1316
  """Contextual minimap click — classic RTS interaction, no mode:
1317
 
 
1637
  play_sess = gr.State(None)
1638
  play_sel = gr.State([])
1639
  play_queue = gr.State([])
1640
+ study_state = gr.State({})
1641
+ with gr.Accordion(
1642
+ "📋 Human-study mode — 24-pack subset, 3 conditions",
1643
+ open=False,
1644
+ ):
1645
+ gr.Markdown(
1646
+ "For the **human-baseline study**. Enter your name "
1647
+ "and click **Begin study** — you'll be walked "
1648
+ "through 72 games (24 scenarios × fog / no-fog / "
1649
+ "handoff-deficit), counterbalanced per player. "
1650
+ "Play each to game-over, then **Next scenario ▶**. "
1651
+ "Every game auto-saves apples-to-apple with the "
1652
+ "model runs."
1653
+ )
1654
+ with gr.Row():
1655
+ study_player = gr.Textbox(
1656
+ label="Your name / id", scale=2,
1657
+ )
1658
+ study_begin_btn = gr.Button("Begin study", scale=1)
1659
+ study_next_btn = gr.Button(
1660
+ "Next scenario ▶", variant="primary", scale=1,
1661
+ )
1662
+ study_progress = gr.Markdown()
1663
  with gr.Row():
1664
  play_scen = gr.Dropdown(
1665
  choices=_play_scenarios(), label="Scenario",
 
1706
  play_img, play_brief, play_status, play_units,
1707
  ],
1708
  )
1709
+ _study_outputs = [
1710
+ play_sess, play_sel, play_queue, play_objective,
1711
+ play_img, play_brief, play_status, play_units,
1712
+ study_state, study_progress,
1713
+ ]
1714
+ study_begin_btn.click(
1715
+ _study_begin, inputs=[play_sess, study_player],
1716
+ outputs=_study_outputs,
1717
+ )
1718
+ study_next_btn.click(
1719
+ _study_next, inputs=[play_sess, study_state],
1720
+ outputs=_study_outputs,
1721
+ )
1722
  play_img.select(
1723
  _play_click,
1724
  inputs=[play_sess, play_sel, play_queue],
openra_bench/human_labeling.py CHANGED
@@ -535,14 +535,19 @@ class InteractiveSession:
535
  record: bool = True,
536
  playback_root: Any = None,
537
  player: str = "Human",
 
538
  ) -> "InteractiveSession":
539
- """Compile a pack by id and open a session on it."""
 
 
 
540
  from .scenarios import load_pack
541
  from .scenarios.loader import PACKS_DIR, compile_level
542
 
543
  compiled = compile_level(
544
  load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
545
  )
 
546
  return cls(
547
  compiled, seed=seed, record=record,
548
  playback_root=playback_root, player=player,
 
535
  record: bool = True,
536
  playback_root: Any = None,
537
  player: str = "Human",
538
+ fog_mode: str = "vision",
539
  ) -> "InteractiveSession":
540
+ """Compile a pack by id and open a session on it. `fog_mode`
541
+ selects the observation modality — `vision-clear` (or any
542
+ `-clear` mode) reveals the whole map (engine `reveal_map`), the
543
+ no-fog condition of the human-study fog-penalty pair."""
544
  from .scenarios import load_pack
545
  from .scenarios.loader import PACKS_DIR, compile_level
546
 
547
  compiled = compile_level(
548
  load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
549
  )
550
+ compiled.fog_mode = fog_mode
551
  return cls(
552
  compiled, seed=seed, record=record,
553
  playback_root=playback_root, player=player,
openra_bench/human_study.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Human-baseline study — the apples-to-apple human reference.
2
+
3
+ External tournament replays cannot be scored on this benchmark: a real
4
+ RTS game is a different engine, continuous-time, a different action
5
+ space, and not one of the constructed tasks. The ONLY comparable human
6
+ data comes through the Play tab — same engine, scenario, observation,
7
+ tool API, win predicate, and `Playback` format the models use.
8
+
9
+ This module defines a fixed, stratified 24-pack subset (one pack per
10
+ scenario family, difficulty spread 8 easy / 8 medium / 8 hard) and the
11
+ three conditions a player plays each under:
12
+
13
+ * `vision-fog` — the canonical fogged minimap (normal play).
14
+ * `vision-clear` — no fog (engine `reveal_map`): the fog-penalty pair.
15
+ * `handoff-bad` — the player inherits a losing position (a `stall`
16
+ prefix of `HANDOFF_K` turns), then plays on — the
17
+ recovery / freeze comparison.
18
+
19
+ A per-player counterbalanced playlist walks all 24 x 3 = 72 cells.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import random
25
+ from typing import Any
26
+
27
+ # Frozen subset — one representative pack per scenario family, levels
28
+ # spread across difficulty. Stratified by family (not by the lopsided
29
+ # `meta.capability` tag). Keep this list STABLE — it pins the baseline.
30
+ STUDY_SUBSET: list[tuple[str, str]] = [
31
+ ("combat-pincer-coordination", "easy"),
32
+ ("econ-overflow-to-silos", "medium"),
33
+ ("def-pre-position-mobile-reserve", "hard"),
34
+ ("build-rally-point-management", "easy"),
35
+ ("scout-detect-incoming-army", "medium"),
36
+ ("lh-opening-to-defense-to-counter", "hard"),
37
+ ("proc-only-defend-no-attack", "easy"),
38
+ ("mfb-supply-line-link-between-bases", "medium"),
39
+ ("rob-objective-shift-with-or-clause", "hard"),
40
+ ("coord-mutual-support", "easy"),
41
+ ("tp-rush-objective-very-fast", "medium"),
42
+ ("mcv-deploy-relocate-under-pressure", "hard"),
43
+ ("artofwar-lure-the-tiger", "easy"),
44
+ ("economy-harvest-timebox", "medium"),
45
+ ("perception-frontier-reading", "hard"),
46
+ ("strategy-trilemma", "easy"),
47
+ ("tech-production-planning", "medium"),
48
+ ("expansion-balanced-2-base-defended", "hard"),
49
+ ("mid-economy-under-fire", "easy"),
50
+ ("strict-sequence", "medium"),
51
+ ("action-sequenced-execution", "hard"),
52
+ ("adv-rps-counter-pick", "easy"),
53
+ ("coordination-staggered-window", "medium"),
54
+ ("maint-sell-and-recoup-cash", "hard"),
55
+ ]
56
+
57
+ STUDY_CONDITIONS: tuple[str, ...] = ("vision-fog", "vision-clear", "handoff-bad")
58
+
59
+ # Prefix length for the `handoff-bad` condition — the player inherits a
60
+ # game already `HANDOFF_K` observe-only (stall) turns deep.
61
+ HANDOFF_K = 3
62
+
63
+ # fog_mode each condition compiles the scenario under.
64
+ _CONDITION_FOG = {
65
+ "vision-fog": "vision",
66
+ "vision-clear": "vision-clear",
67
+ "handoff-bad": "vision",
68
+ }
69
+
70
+
71
+ def study_playlist(player_seed: int = 0) -> list[tuple[str, str, str]]:
72
+ """The 72-cell playlist — every (pack, level) x condition — in a
73
+ per-player counterbalanced (deterministically shuffled) order, so
74
+ condition ordering is not confounded across players."""
75
+ cells = [
76
+ (pack, level, cond)
77
+ for pack, level in STUDY_SUBSET
78
+ for cond in STUDY_CONDITIONS
79
+ ]
80
+ random.Random(player_seed).shuffle(cells)
81
+ return cells
82
+
83
+
84
+ def open_study_session(
85
+ pack: str,
86
+ level: str,
87
+ condition: str,
88
+ player: str,
89
+ seed: int = 1,
90
+ playback_root: Any = None,
91
+ ):
92
+ """Open an `InteractiveSession` for one study cell, configured for
93
+ `condition`. For `handoff-bad` the engine is advanced `HANDOFF_K`
94
+ observe-only turns BEFORE the player takes over — so the player
95
+ inherits a real deficit, exactly like the model handoff ablation.
96
+
97
+ The run persists to the standard `Playback` format (apples-to-apple
98
+ with model runs); `playback_root` defaults to a per-condition dir
99
+ so the condition is recoverable from the path."""
100
+ from pathlib import Path
101
+
102
+ from .human_labeling import InteractiveSession
103
+
104
+ if condition not in STUDY_CONDITIONS:
105
+ raise ValueError(f"unknown study condition {condition!r}")
106
+ if playback_root is None:
107
+ playback_root = Path("playback/human_study") / condition
108
+
109
+ sess = InteractiveSession.from_pack(
110
+ pack, level, seed,
111
+ record=True, playback_root=playback_root, player=player,
112
+ fog_mode=_CONDITION_FOG[condition],
113
+ )
114
+ if condition == "handoff-bad":
115
+ # Stall prefix — the player inherits the resulting losing board.
116
+ for _ in range(HANDOFF_K):
117
+ if sess.done:
118
+ break
119
+ sess.submit_turn([])
120
+ return sess
openra_bench/run_eval.py CHANGED
@@ -165,6 +165,7 @@ def evaluate(
165
  handoff_sweep: bool = False,
166
  handoff_k: int = 3,
167
  handoff_bank: str | Path | None = None,
 
168
  ) -> dict:
169
  """Run packs×levels×seeds. If `held_out_seeds` is given, those are
170
  run too and tagged split='held_out'; the report adds
@@ -184,6 +185,11 @@ def evaluate(
184
  position replayed from a `handoff_bank` trajectory (`good` — the
185
  capitalize-on-advantage test). `handoff_k` is the prefix length.
186
  Each record carries a `passivity` stat (observe/stop-only fraction).
 
 
 
 
 
187
  """
188
  from .resilience import (
189
  BudgetExceeded,
@@ -309,12 +315,16 @@ def evaluate(
309
  continue
310
  for split, slist in (("public", seeds), ("held_out", held_out_seeds)):
311
  for seed in slist:
312
- tasks.append((compiled, cell, split, seed))
 
313
 
314
  def _run_one(task: tuple) -> dict:
315
- compiled, cell, split, seed = task
316
  pb = None
317
- if playback_root is not None:
 
 
 
318
  from .playback import Playback
319
 
320
  pb = Playback(
@@ -359,6 +369,7 @@ def evaluate(
359
  "capability": compiled.meta.capability,
360
  "split": split,
361
  "seed": seed,
 
362
  "outcome": sc.outcome,
363
  "composite": sc.composite,
364
  "perception": sc.perception,
@@ -446,7 +457,7 @@ def evaluate(
446
  # not abort a multi-hour sweep or lose the report — record
447
  # it as outcome="error" and continue. Budget is the only
448
  # signal that intentionally stops the whole run.
449
- compiled, cell, split, seed = task
450
  try:
451
  return _run_one(task)
452
  except BudgetExceeded:
@@ -458,6 +469,7 @@ def evaluate(
458
  "capability": compiled.meta.capability,
459
  "split": split,
460
  "seed": seed,
 
461
  "outcome": "error",
462
  "composite": 0.0,
463
  "perception": 0.0,
@@ -688,6 +700,14 @@ def main(argv: list[str]) -> int:
688
  ap.add_argument("--handoff-bank", default=None,
689
  help="dir of Playback runs — source of winning "
690
  "trajectories for the handoff-good prefix")
 
 
 
 
 
 
 
 
691
  a = ap.parse_args(argv[1:])
692
 
693
  cfg = None
@@ -704,7 +724,7 @@ def main(argv: list[str]) -> int:
704
  if quant:
705
  pr["quantizations"] = [quant]
706
  extra_body["provider"] = pr
707
- cfg = ProviderConfig(
708
  provider=a.provider,
709
  model=a.model,
710
  base_url=a.base_url,
@@ -713,6 +733,9 @@ def main(argv: list[str]) -> int:
713
  fog_mode=a.fog_mode,
714
  extra_body=extra_body,
715
  )
 
 
 
716
 
717
  stats = evaluate(
718
  _resolve_packs(a.packs),
@@ -733,6 +756,7 @@ def main(argv: list[str]) -> int:
733
  handoff_sweep=a.handoff_sweep,
734
  handoff_k=a.handoff_k,
735
  handoff_bank=a.handoff_bank,
 
736
  progress=lambda d, n, rec, c: print(
737
  f"[{d}/{n}] {rec['cell']}:{rec['split']}#{rec['seed']} "
738
  f"{rec['outcome']} comp={rec['composite']} "
 
165
  handoff_sweep: bool = False,
166
  handoff_k: int = 3,
167
  handoff_bank: str | Path | None = None,
168
+ repeats: int = 1,
169
  ) -> dict:
170
  """Run packs×levels×seeds. If `held_out_seeds` is given, those are
171
  run too and tagged split='held_out'; the report adds
 
185
  position replayed from a `handoff_bank` trajectory (`good` — the
186
  capitalize-on-advantage test). `handoff_k` is the prefix length.
187
  Each record carries a `passivity` stat (observe/stop-only fraction).
188
+
189
+ `repeats` runs each (cell, seed) `N` times, varying only model
190
+ nondeterminism (assumes temperature > 0). Records carry a `repeat`
191
+ index 0..N-1, so aggregation can report mean ± CI and `pass^k`
192
+ (all-k wins) alongside `pass@k` — the reliability metric.
193
  """
194
  from .resilience import (
195
  BudgetExceeded,
 
315
  continue
316
  for split, slist in (("public", seeds), ("held_out", held_out_seeds)):
317
  for seed in slist:
318
+ for rep in range(max(1, repeats)):
319
+ tasks.append((compiled, cell, split, seed, rep))
320
 
321
  def _run_one(task: tuple) -> dict:
322
+ compiled, cell, split, seed, rep = task
323
  pb = None
324
+ # Only the first repeat writes a Playback — the records (the
325
+ # lightweight per-rep results) carry the pass^k data; saving N
326
+ # full per-turn dumps per cell would just bloat disk.
327
+ if playback_root is not None and rep == 0:
328
  from .playback import Playback
329
 
330
  pb = Playback(
 
369
  "capability": compiled.meta.capability,
370
  "split": split,
371
  "seed": seed,
372
+ "repeat": rep,
373
  "outcome": sc.outcome,
374
  "composite": sc.composite,
375
  "perception": sc.perception,
 
457
  # not abort a multi-hour sweep or lose the report — record
458
  # it as outcome="error" and continue. Budget is the only
459
  # signal that intentionally stops the whole run.
460
+ compiled, cell, split, seed, rep = task
461
  try:
462
  return _run_one(task)
463
  except BudgetExceeded:
 
469
  "capability": compiled.meta.capability,
470
  "split": split,
471
  "seed": seed,
472
+ "repeat": rep,
473
  "outcome": "error",
474
  "composite": 0.0,
475
  "perception": 0.0,
 
700
  ap.add_argument("--handoff-bank", default=None,
701
  help="dir of Playback runs — source of winning "
702
  "trajectories for the handoff-good prefix")
703
+ ap.add_argument("--repeats", type=int, default=1,
704
+ help="run each (cell, seed) N times varying only "
705
+ "model nondeterminism — enables mean +- CI and "
706
+ "pass^k reliability metrics (needs temperature > 0)")
707
+ ap.add_argument("--temperature", type=float, default=None,
708
+ help="sampling temperature for the model "
709
+ "(overrides ProviderConfig.temperature). Set > 0 "
710
+ "to make --repeats meaningful.")
711
  a = ap.parse_args(argv[1:])
712
 
713
  cfg = None
 
724
  if quant:
725
  pr["quantizations"] = [quant]
726
  extra_body["provider"] = pr
727
+ cfg_kw = dict(
728
  provider=a.provider,
729
  model=a.model,
730
  base_url=a.base_url,
 
733
  fog_mode=a.fog_mode,
734
  extra_body=extra_body,
735
  )
736
+ if a.temperature is not None:
737
+ cfg_kw["temperature"] = a.temperature
738
+ cfg = ProviderConfig(**cfg_kw)
739
 
740
  stats = evaluate(
741
  _resolve_packs(a.packs),
 
756
  handoff_sweep=a.handoff_sweep,
757
  handoff_k=a.handoff_k,
758
  handoff_bank=a.handoff_bank,
759
+ repeats=a.repeats,
760
  progress=lambda d, n, rec, c: print(
761
  f"[{d}/{n}] {rec['cell']}:{rec['split']}#{rec['seed']} "
762
  f"{rec['outcome']} comp={rec['composite']} "
tests/test_human_study.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The human-baseline study harness — the apples-to-apple human
2
+ reference: a fixed 24-pack subset, three conditions, a per-player
3
+ counterbalanced playlist, run through the same Play-tab `Playback`
4
+ pipeline the models use."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
13
+
14
+ from openra_bench.human_study import (HANDOFF_K, STUDY_CONDITIONS,
15
+ STUDY_SUBSET, open_study_session,
16
+ study_playlist)
17
+
18
+ PACKS = Path(__file__).parent.parent / "openra_bench" / "scenarios" / "packs"
19
+
20
+
21
+ def test_subset_is_24_packs_that_all_exist():
22
+ assert len(STUDY_SUBSET) == 24
23
+ for pack, level in STUDY_SUBSET:
24
+ assert (PACKS / f"{pack}.yaml").exists(), pack
25
+ assert level in ("easy", "medium", "hard"), (pack, level)
26
+ # difficulty is stratified, not all one level
27
+ levels = {lvl for _, lvl in STUDY_SUBSET}
28
+ assert levels == {"easy", "medium", "hard"}
29
+
30
+
31
+ def test_playlist_is_full_grid_and_counterbalanced():
32
+ pl0 = study_playlist(player_seed=0)
33
+ assert len(pl0) == len(STUDY_SUBSET) * len(STUDY_CONDITIONS) == 72
34
+ # every (pack, level, condition) cell appears exactly once
35
+ assert len(set(pl0)) == 72
36
+ for pack, level, cond in pl0:
37
+ assert (pack, level) in STUDY_SUBSET
38
+ assert cond in STUDY_CONDITIONS
39
+ # different players get a different order (counterbalancing)
40
+ assert study_playlist(1) != pl0
41
+
42
+
43
+ @pytest.mark.parametrize("cond", STUDY_CONDITIONS)
44
+ def test_open_study_session_each_condition(cond, tmp_path):
45
+ sess = open_study_session(
46
+ "perception-frontier-reading", "hard", cond,
47
+ player="tester", seed=1, playback_root=tmp_path,
48
+ )
49
+ try:
50
+ rs = sess.render_state()
51
+ if cond == "vision-clear":
52
+ # no fog — engine reveal_map: whole map explored
53
+ assert rs.get("explored_percent", 0) > 99.0
54
+ assert sess.compiled.fog_mode == "vision-clear"
55
+ elif cond == "handoff-bad":
56
+ # the player inherits a HANDOFF_K-turn-deep deficit
57
+ assert sess.turn == HANDOFF_K
58
+ assert sess.compiled.fog_mode == "vision"
59
+ else: # vision-fog — normal fogged start
60
+ assert sess.turn == 0
61
+ assert rs.get("explored_percent", 100) < 50.0
62
+ finally:
63
+ sess.close()
64
+
65
+
66
+ def test_study_session_persists_playback(tmp_path):
67
+ """A study game must save in the standard Playback format so it is
68
+ apples-to-apple with model runs."""
69
+ sess = open_study_session(
70
+ "perception-frontier-reading", "easy", "vision-fog",
71
+ player="tester", seed=1, playback_root=tmp_path,
72
+ )
73
+ try:
74
+ sess.submit_turn([]) # one observe turn
75
+ assert sess._playback is not None
76
+ assert sess._playback.dir.exists()
77
+ finally:
78
+ sess.close()
tests/test_run_eval_repeats.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """--repeats N: run each (cell, seed) N times so the report carries
2
+ the variance + pass^k a single inference per cell cannot give."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ pytest.importorskip("openra_rl_training", reason="Rust env wheel not installed")
11
+
12
+ from openra_bench.run_eval import evaluate
13
+
14
+ PACK = (
15
+ Path(__file__).parent.parent
16
+ / "openra_bench" / "scenarios" / "packs"
17
+ / "perception-count-the-threat.yaml"
18
+ )
19
+
20
+
21
+ def test_repeats_multiplies_the_task_count():
22
+ base = evaluate([PACK], levels=["easy"], seeds=[1, 2],
23
+ repeats=1, dry_run=True)
24
+ rep3 = evaluate([PACK], levels=["easy"], seeds=[1, 2],
25
+ repeats=3, dry_run=True)
26
+ assert rep3["tasks"] == base["tasks"] * 3
27
+
28
+
29
+ def test_records_carry_repeat_index():
30
+ stats = evaluate([PACK], levels=["easy"], seeds=[1], repeats=3)
31
+ eps = stats.get("episodes", [])
32
+ assert len(eps) == 3
33
+ assert {e["repeat"] for e in eps} == {0, 1, 2}
34
+ # all repeats stay at the same (cell, seed) — same key, different rep
35
+ assert len({(e["cell"], e["seed"]) for e in eps}) == 1