yxc20098 commited on
Commit
424da31
ยท
1 Parent(s): 8bb4b88

Eval resilience layer for real OpenRouter runs

Browse files

resilience.py (pure, thread-safe, fully unit-tested):
- RetryPolicy + retry_call: bounded exponential backoff, honors
Retry-After, transient(429/5xx/timeout) vs FatalProviderError
- RateLimiter: global min-interval throttle across the pool
- CostMeter: token/USD accumulation + hard cap โ†’ BudgetExceeded
- RunJournal: append-only completed-episode journal, torn-line tolerant

providers: ProviderConfig gains max_retries/retry_*/qps/price_*/
max_history_turns; ChatReply.usage; OpenAICompatibleProvider wraps the
POST in retry+ratelimit, captures usage, meters cost, raises on budget;
make_provider injects a shared limiter+meter

agent: ModelAgent._window โ€” sliding wire-history window (system + last
N user groups, pairing intact); self.history stays full for playback

run_eval.evaluate: shared meter/limiter + one shared provider;
checkpoint journal + --resume (lossless skip+refold via _finalize/
_ScoreShim); --max-spend graceful truncate-and-finalize; --smoke;
--dry-run; incremental report flush + progress heartbeat. CLI flags
wired. Determinism contract excludes the wall-clock run_id label.

tests: test_resilience.py (8) + pinned run_id in the concurrency
determinism test โ€” 345 passed, 1 skipped

SCENARIO_QUALITY.md ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRA-Bench โ€” Scenario Design-Quality Audit & Scoring
2
+
3
+ Analysis only. No scenario files changed. Prepared 2026-05-17.
4
+ Scope: the 48 `status: active` packs (every pack without
5
+ `status: quarantine`); TEMPLATE and all `cat-c*-02..05` quarantine
6
+ packs excluded. Grounded against `SCENARIO_AUDIT.md`,
7
+ `win_conditions.py`, `scoring.py`, `goal_tracker.py`,
8
+ `eval_core.run_level`, the schema, and `openra_env.game_data`
9
+ (verified unit/building costs, prerequisites, tech tree).
10
+
11
+ ---
12
+
13
+ ## 1. Executive Summary
14
+
15
+ The capability taxonomy and predicate grammar are sound, and the
16
+ hand-authored families (perception-*, reasoning-*, action-*,
17
+ artofwar-*, strict-*) are mostly genuine, well-grounded decision
18
+ problems. But the active set has four structural quality problems that
19
+ materially weaken it as a discriminating benchmark:
20
+
21
+ 1. **Loss is impossible in ~70% of active packs.** Only a
22
+ `fail_condition` can produce `loss`; `run_level` leaves the outcome
23
+ at `draw` when the win predicate is unmet (eval_core.py L174, L243).
24
+ Every perception-*, reasoning-*, action-*, economy-*, custom-map,
25
+ strict-sequence, building-and-planning, and most `cat-*` pack has
26
+ **no real fail_condition** (the `cat-*` ones carry
27
+ `units_lost_lte: -1`, which is *never* satisfiable because
28
+ `units_lost = max(0, โ€ฆ) โ‰ฅ 0` โ€” a dead no-op). Consequence: a
29
+ do-nothing agent and a near-miss agent both score `draw` (0.5
30
+ outcome); the binary outcome only separates win vs. not-win.
31
+ Partial credit via `objective_progress` rescues *some*
32
+ discrimination, but the headline outcome axis is degenerate on the
33
+ majority of the suite.
34
+
35
+ 2. **The `cat-c*` `-00`/`-01` kept representatives are near-exact
36
+ duplicates, not distinct decisions.** `diff cat-c1-00 cat-c1-01`
37
+ differs only in id/title and ยฑ1 `explored_pct` / ยฑ200 ticks.
38
+ c5โ†”c6, c7โ†”c8, c9โ†”c11, c4โ†”c3 are the same decision with relabelled
39
+ meta. The two kept variants per category add **zero** distinct
40
+ capability โ€” keep one, cut the second.
41
+
42
+ 3. **`always-movement-wins` degeneracy in the navigation/perception
43
+ `cat-*` and several hand-authored packs.** Where the only win
44
+ clauses are `explored_pct_gte` + `within_ticks` with no fail and a
45
+ generous clock, the *baseline scripted frontier agent*
46
+ (`scripted_explore_agent`) already wins โ€” these do not separate
47
+ strong from weak models, only "moves" from "doesn't move."
48
+
49
+ 4. **A genuine solvability defect in `strict-production-bom` hard**:
50
+ the budget cannot cover the required tech path (see ยง2). Several
51
+ "spend/build" packs are solvable but only test "spend the obvious
52
+ thing," not an allocation trade-off, because the engine has no
53
+ harvest income (documented S0/S1) so there is never scarcity
54
+ pressure beyond a single fixed `starting_cash`.
55
+
56
+ **Bottom line:** ~9 packs should be CUT now (8 redundant `cat-*`
57
+ duplicates + 1 unsolvable level needs a budget fix or cut), ~16 need a
58
+ FIX (almost all: "add a real fail_condition so lossโ‰ draw"), and the
59
+ hand-authored perception/reasoning/artofwar/strict-bom core is KEEP.
60
+ The single highest-leverage repo-wide fix is adding a real
61
+ `fail_condition` (timeout-loss or attrition-loss) to every active pack
62
+ so the outcome axis stops collapsing loss into draw.
63
+
64
+ ---
65
+
66
+ ## 2. Solvability spot-checks (verified against game_data)
67
+
68
+ Verified building/unit costs & prereqs: `powr` 300 (no prereq, +100
69
+ pwr), `tent`/`barr` 500 (prereq `powr`, โˆ’20 pwr), `proc` 1400 (prereq
70
+ `powr`), `pbox` 600 (prereq `tent`), `gun` 800 (prereq `weap`),
71
+ `tsla` 1200 (prereq `weap`, โˆ’75 pwr), `weap` War Factory (~2000),
72
+ `fact` 2000, e1 100 / e3 300 (prereq `barr|tent`).
73
+
74
+ - **strict-production-bom HARD โ€” UNSOLVABLE on budget.** Spec:
75
+ `tsla` + 3 e1 + 2 e3, `starting_cash: 4200`, agent faction soviet.
76
+ `tsla` requires `weap` (โ‰ˆ2000) which is **not pre-placed and not in
77
+ the actor list**. Minimum spend: barr 500 + weap 2000 + tsla 1200 +
78
+ 3ยทe1 300 + 2ยทe3 600 = **4600 > 4200**. Also tsla is โˆ’75 power with
79
+ only one `powr` (+100) minus barr/weap drain โ†’ likely power-starved.
80
+ โ†’ **FIX (raise hard `starting_cash` to ~5200 and pre-place or
81
+ budget `weap`) or CUT the hard level.** Easy/medium are solvable
82
+ (barr 500 + 3 e1; barr + 3 e1 + 2 e3 within 2200/3000) and a good
83
+ BFCL-style fidelity test.
84
+ - **building-and-planning** easy (build powr+tent, cash 6000),
85
+ medium (2 pbox in region, tent pre-placed, cash 5000: 2ยท600+powr),
86
+ hard (MCVโ†’deploy fact, 2 buildings near (60,20), cash 4000):
87
+ all solvable; hard is tight but feasible. KEEP.
88
+ - **economy-investment / economy-time-box / economy-force-buildup**:
89
+ all levels solvable on the given `starting_cash` (2nd proc 1400 +
90
+ 2nd powr 300 โ‰ค budgets; e1 at 100 makes `own_units_gte` trivially
91
+ affordable). Solvable but **decision-thin** โ€” no harvest income
92
+ means "wide vs deep" is not a real trade (both fit the budget); the
93
+ test reduces to "issue the build commands." FIX (see ยง4).
94
+ - **adversarial-* / artofwar-* / reasoning-risk-route /
95
+ reasoning-frontier-commit / perception-***: policies exist
96
+ (kite/focus-fire; bait then run; take the safe detour; commit to
97
+ the correct fog region). Solvable; these are the strongest packs.
98
+ - **cat-c3/c4** (`building_total_gte` + `power_surplus_gte: 0`):
99
+ solvable (powr is +100, free of prereq; spam powr). But
100
+ `power_surplus_gte: 0` + `building_total_gte` is trivially gamed by
101
+ building only power plants โ€” see Rigor notes.
102
+ - **rush-hour / strategy-gauntlet / strategy-twobody / strategy-
103
+ dilemma**: 14 scattered enemy infantry vs ~32 agent combat units,
104
+ `units_killed_gte` 3โ€“9; solvable, but with stance-3 auto-fighting
105
+ units the agent barely has to act โ€” see Discrimination.
106
+
107
+ ---
108
+
109
+ ## 3. Scored Table (1โ€“5; 5 = best)
110
+
111
+ solv = solvability ยท disc = discrimination ยท grnd = grounding ยท
112
+ rigor = non-gameable + principled scaling ยท ovr = overall
113
+
114
+ | pack id | capability | solv | disc | grnd | rigor | ovr | verdict | one-line note |
115
+ |---|---|---|---|---|---|---|---|---|
116
+ | action-multiunit-coordination | action | 5 | 4 | 5 | 4 | **4** | KEEP | Real parallel-control test; add fail_condition so non-winโ‰ draw. |
117
+ | action-sequenced-execution | action | 5 | 4 | 5 | 4 | **4** | KEEP | after_ticks gates encode ordering well; add timeout fail_condition. |
118
+ | adversarial-duel | adversarial | 4 | 4 | 5 | 4 | **4** | KEEP | Has real fail (own_units_gte:1); reactive enemy = unique RTS value. |
119
+ | adversarial-siege | adversarial | 4 | 4 | 5 | 4 | **4** | KEEP | Same model as duel; entrenched defender variant โ€” distinct enough. |
120
+ | adversarial-skirmish | adversarial | 4 | 4 | 5 | 4 | **4** | KEEP | Outnumbered/maneuver variant; genuine asymmetric tactic. |
121
+ | artofwar-decoy-sacrifice | reasoning | 4 | 5 | 5 | 5 | **5** | KEEP | Loss cap forces "spend bait, save army" โ€” true credit assignment. |
122
+ | artofwar-indirect-approach | reasoning | 4 | 5 | 5 | 5 | **5** | KEEP | units_lost_lte:0 + hazard short path = clean long-horizon test. |
123
+ | artofwar-lure-the-tiger | reasoning | 3 | 4 | 5 | 4 | **4** | FIX | Two-phase intent good, but win has no clause proving the lure happened โ€” reaching region by any survivable route also wins; add an ordering/after_ticks gate. |
124
+ | artofwar-sequenced-citadel | reasoning | 5 | 4 | 5 | 4 | **4** | KEEP | after_ticks+within_ticks band genuinely grades order; solid. |
125
+ | building-and-planning | reasoning | 4 | 4 | 5 | 4 | **4** | KEEP | 3 genuinely different decisions (tech dep / placement / relocate); add fail_condition. |
126
+ | custom-map-no-enemy | perception | 5 | 2 | 4 | 2 | **2** | FIX | Pure nav, no enemy, no fail โ†’ baseline move-agent wins easy/medium; only hard (all_units_in_region, tight clock) discriminates. Keep hard, cut/merge easy+medium or add fail. |
127
+ | economy-force-buildup | reasoning | 5 | 2 | 3 | 2 | **2** | FIX | e1@100 makes own_units_gte trivial; no scarcity (no harvest). Tests "click build," not allocation. Make win require a building mix + fail on idle. |
128
+ | economy-investment | reasoning | 5 | 3 | 4 | 3 | **3** | FIX | Best of the economy packs but "wide vs deep" isn't a real trade w/o income; both paths fit budget. Tighten cash so paths are mutually exclusive; add fail. |
129
+ | economy-time-box | reasoning | 5 | 2 | 3 | 2 | **2** | FIX | Near-duplicate of force-buildup + a building_total bar; same triviality. Merge into economy-investment or harden. |
130
+ | perception-frontier-reading | perception | 5 | 3 | 5 | 3 | **3** | FIX | Strong concept; but easy/medium = explored_pct only, no fail โ†’ baseline frontier agent already wins. Hard (decoys + units_lost_lte:1) is the real test. Add fail to easy/medium. |
131
+ | perception-target-vs-fog | perception | 5 | 4 | 5 | 4 | **4** | KEEP | buildings_discovered (not coverage) defeats the blind-sweep agent; medium/hard force inference. Add timeout fail. |
132
+ | reasoning-frontier-commit | reasoning | 5 | 4 | 5 | 4 | **4** | KEEP | Single scout = un-hedgeable commitment; decoy buildings vs units is a clean discriminator. |
133
+ | reasoning-risk-route | reasoning | 5 | 5 | 5 | 5 | **5** | KEEP | units_lost_lte:0 + lethal short path + clock = textbook non-gameable risk/route. Exemplary. |
134
+ | rush-hour | action | 5 | 2 | 3 | 2 | **2** | FIX | ~32 stance-3 auto-fighting agent units vs 14 scattered enemy; kill 3/6/9 in 40 turns is near-automatic. Low discrimination; not load-bearing. Cut to a real sweep (fewer units, coverage+kill, fail on timeout). |
135
+ | strategy-dilemma | reasoning | 4 | 2 | 3 | 2 | **2** | FIX | Win = buildings_discovered_gte:1 only โ†’ any scout that bumps the enemy base wins; "risk dilemma" framing not enforced by the predicate. Replace win with reach_region + units_lost_lte. |
136
+ | strategy-gauntlet | reasoning | 4 | 2 | 3 | 2 | **2** | FIX | Same defect: buildings_discovered_gte:1 with a huge friendly force; corridor never has to be solved. Re-spec to reach_region + attrition. |
137
+ | strategy-twobody | action | 4 | 2 | 3 | 2 | **2** | FIX | Two pre-split friendly blobs already near the enemy base; discover-1-building wins with no real coordination. Re-spec to all_units_in_region (true rendezvous). |
138
+ | strict-production-bom | action | 3 | 5 | 5 | 5 | **4** | FIX | Excellent BFCL-style exact-spec test (unit_type_count_eq + overproduction fail). HARD level unsolvable on 4200 cash (needs weap+tslaโ‰ˆ4600) โ€” raise cash or cut hard. |
139
+ | strict-sequence | action | 5 | 4 | 5 | 4 | **4** | KEEP | Tool allowlist + after_ticks band genuinely tests API-fidelity ordering; add timeout fail so a stalled agent loses, not draws. |
140
+ | cat-c1-00 (Frontier Scouting) | perception | 5 | 3 | 4 | 3 | **3** | KEEP | Representative of C1; explored_pct+clock, hard adds units_lost_lte:0. OK as the single C1 kept level. |
141
+ | cat-c1-01 | perception | 5 | 2 | 4 | 2 | **2** | CUT | ยฑ1 explored_pct / ยฑ200 ticks vs cat-c1-00. Pure duplicate. |
142
+ | cat-c2-00 (Threat Enumeration) | perception | 5 | 3 | 4 | 3 | **3** | KEEP | enemies_discovered + units_lost_lte is a real perception-under-decoy test; keep one. |
143
+ | cat-c2-01 | perception | 5 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c2-00 (only tick deltas). |
144
+ | cat-c3-00 (Tech Critical Path) | reasoning | 4 | 2 | 4 | 2 | **2** | FIX | building_total_gte + power_surplus_gte:0 is gamed by spamming powr (each +100, no prereq) โ€” doesn't test the tech *path*. Require has_building tent/proc to force the dependency. |
145
+ | cat-c3-01 | reasoning | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c3-00. |
146
+ | cat-c4-00 (Power-Budget Online) | reasoning | 4 | 2 | 4 | 2 | **2** | FIX | Same gameability as c3 (powr-spam satisfies both clauses); near-identical to c3. Merge c3+c4 into one fixed pack. |
147
+ | cat-c4-01 | reasoning | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c4-00 (and of c3). |
148
+ | cat-c5-00 (Budget Allocation) | reasoning | 5 | 2 | 3 | 2 | **2** | FIX | powr n:2 + building_total bar, no income โ†’ "build 5 cheap buildings"; not an indivisible-budget trade. Same as c6. |
149
+ | cat-c5-01 | reasoning | 5 | 2 | 3 | 2 | **2** | CUT | Duplicate of cat-c5-00. |
150
+ | cat-c6-00 (Time-Boxed Deploy) | reasoning | 5 | 2 | 3 | 2 | **2** | FIX | own_units_gte+building_total; e1@100 trivial; duplicate decision of c5/economy-time-box. Merge. |
151
+ | cat-c6-01 | reasoning | 5 | 2 | 3 | 2 | **2** | CUT | Duplicate of cat-c6-00. |
152
+ | cat-c7-00 (Defensive-Direction) | perception | 4 | 3 | 4 | 3 | **3** | KEEP | building_in_region for pbox forces a directional commit; pbox needs tent (pre-placed) โ€” solvable. Keep one. |
153
+ | cat-c7-01 | perception | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c7-00. |
154
+ | cat-c8-00 (Base-Placement) | perception | 4 | 3 | 4 | 3 | **3** | FIX | Same family as c7 (place building in inferred region). Distinct enough to keep one but merge c7/c8 conceptually; ensure region is reachable/buildable. |
155
+ | cat-c8-01 | perception | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c8-00. |
156
+ | cat-c9-00 (Commit-vs-Retreat) | reasoning | 4 | 3 | 4 | 3 | **3** | KEEP | units_killed + units_lost_lte is a real risk call; no real fail (dead โˆ’1) โ€” FIX-lite: make fail = units_lost_gte cap. |
157
+ | cat-c9-01 | reasoning | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c9-00. |
158
+ | cat-c10-00 (Force Coordination) | action | 5 | 3 | 4 | 3 | **3** | KEEP | all_units_in_region + units_lost_lte = genuine rendezvous; better-spec'd than strategy-twobody. Keep one. |
159
+ | cat-c10-01 | action | 5 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c10-00. |
160
+ | cat-c11-00 (Tempo Window) | reasoning | 4 | 3 | 4 | 3 | **3** | KEEP | after_ticks + units_killed + within_ticks genuinely encodes a window; only tempo coverage โ€” keep one. |
161
+ | cat-c11-01 | reasoning | 4 | 2 | 4 | 2 | **2** | CUT | Duplicate of cat-c11-00 (and decision-twin of c9). |
162
+ | cat-c12-00 (Error Recovery) | reasoning | 4 | 2 | 3 | 2 | **2** | FIX | "Replan after setback" but nothing is destroyed at start โ€” it's just a timed build with after_ticks:1200. Not a recovery test; either script a mid-episode loss or CUT. |
163
+ | cat-c12-01 | reasoning | 4 | 2 | 3 | 2 | **2** | CUT | Duplicate of cat-c12-00. |
164
+
165
+ Aggregate: KEEP 19 ยท FIX 18 ยท CUT 11. (TEMPLATE excluded.)
166
+
167
+ ---
168
+
169
+ ## 4. CUT list (concrete โ€” safe to delete now)
170
+
171
+ These add no distinct capability and/or are non-discriminating:
172
+
173
+ 1. **`cat-c1-01`** โ€” byte-near duplicate of cat-c1-00 (ยฑ1 pct/ยฑ200 tk).
174
+ 2. **`cat-c2-01`** โ€” duplicate of cat-c2-00.
175
+ 3. **`cat-c3-01`** โ€” duplicate of cat-c3-00.
176
+ 4. **`cat-c4-01`** โ€” duplicate of cat-c4-00 (and decision-twin of c3).
177
+ 5. **`cat-c5-01`** โ€” duplicate of cat-c5-00.
178
+ 6. **`cat-c6-01`** โ€” duplicate of cat-c6-00 (decision-twin of c5).
179
+ 7. **`cat-c7-01`** โ€” duplicate of cat-c7-00.
180
+ 8. **`cat-c8-01`** โ€” duplicate of cat-c8-00.
181
+ 9. **`cat-c9-01`** โ€” duplicate of cat-c9-00.
182
+ 10. **`cat-c10-01`** โ€” duplicate of cat-c10-00.
183
+ 11. **`cat-c11-01`** โ€” duplicate of cat-c11-00.
184
+ 12. **`cat-c12-01`** โ€” duplicate of cat-c12-00.
185
+
186
+ Net: keep exactly one representative per `cat-c*` category (the `-00`),
187
+ deleting all 12 `-01` actives โ€” removes 12 files with zero capability
188
+ loss. (Category-merge candidates, can be done after: fold c4โ†’c3, c6โ†’c5,
189
+ c8โ†’c7, c11โ†’c9 since each pair is the same predicate decision.)
190
+
191
+ Additionally **CUT or hard-FIX**: `cat-c12-00` (not actually an
192
+ error-recovery test โ€” nothing fails mid-episode; it is a delayed timed
193
+ build), and `strict-production-bom` HARD level (unsolvable on 4200
194
+ cash; either raise to ~5200 + pre-place `weap`, or delete the hard
195
+ level and keep easy/medium).
196
+
197
+ ---
198
+
199
+ ## 5. The repo-wide FIX (do this first, highest leverage)
200
+
201
+ **Add a real `fail_condition` to every active pack that lacks one.**
202
+ Today `run_level` only emits `loss` if a `fail_condition` evaluates
203
+ true; otherwise an unmet win stays `draw` (eval_core L174/L243). With
204
+ no fail, a do-nothing agent draws and a 95%-there agent draws โ€” the
205
+ outcome axis cannot rank effort. The `cat-*` `units_lost_lte: -1`
206
+ "fail" is a dead no-op (units_lost โ‰ฅ 0 always).
207
+
208
+ Mechanical, non-gameable fixes using the *existing* vocabulary:
209
+
210
+ - Timed tasks (perception-*, reasoning-*, action-*, economy-*,
211
+ strict-*, building-and-planning, cat-c1/c3/c4/c5/c6/c7/c8/c12):
212
+ `fail_condition: {not: {within_ticks: <max_ticks>}}` โ€” i.e. running
213
+ out the clock without meeting the win is a *loss*, not a draw.
214
+ - Attrition tasks (anything with a `units_lost_lte: N` win clause):
215
+ add `fail_condition: {not: {units_lost_lte: N}}` so exceeding the
216
+ cap loses immediately (also prunes wasted ticks).
217
+ - `cat-*`: replace the dead `units_lost_lte: -1` with the timeout-loss
218
+ above (one-line generator change; regenerate the kept `-00` files).
219
+
220
+ This single change restores a 3-way outcome (win/draw/loss) across the
221
+ suite and makes `objective_progress` partial credit meaningful instead
222
+ of every non-win collapsing to 0.5.
223
+
224
+ Secondary FIXes (per pack, already noted in the table):
225
+
226
+ - **strategy-dilemma / strategy-gauntlet / strategy-twobody**: win is
227
+ `buildings_discovered_gte: 1` โ€” satisfied by *seeing* the pre-placed
228
+ enemy base, which a huge friendly stance-3 force does incidentally.
229
+ Re-spec to `reach_region` (the objective) + `units_lost_lte`
230
+ (enforce the risk/coordination the title claims).
231
+ - **cat-c3/c4**: add `has_building: tent` (or `proc`) to the win so
232
+ `building_total_gte` can't be met by powr-spam; this restores the
233
+ tech-*dependency* the pack claims to test.
234
+ - **economy-investment / -time-box / -force-buildup**: tighten
235
+ `starting_cash` so the wide and deep paths are mutually exclusive
236
+ (currently both fit), and add the timeout fail. Until engine S0/S1
237
+ (ore income) lands these can only ever be "spend the obvious thing."
238
+ - **artofwar-lure-the-tiger**: add an `after_ticks` gate or a
239
+ staging `reach_region` on the lure so the win actually requires the
240
+ two-phase plan, not just any survivable route to the objective.
241
+ - **rush-hour**: drop it from the "strategy" claim; reduce the
242
+ friendly force and require coverage (`explored_pct_gte`) + kills +
243
+ timeout-fail so it tests coordinated sweep, not auto-battle.
244
+
245
+ ---
246
+
247
+ ## 6. ADD list (top 10 new scenarios to author)
248
+
249
+ Gaps the active set still has, with win/fail sketches in the existing
250
+ predicate grammar and the external benchmark each parallels.
251
+
252
+ 1. **adversarial-counterstrategy-read** (capability: adversarial).
253
+ Opponent commits one of two observable openings (rush near-lane vs
254
+ tech far-corner); agent must scout then commit the dominant
255
+ counter. Win: `all_of[{buildings_discovered_gte:1},{units_killed_gte:N},{within_ticks:T}]`;
256
+ fail: `{not:{own_units_gte:1}}` or timeout. Parallel:
257
+ StarCraft-II-Arena, game-theoretic LLM evals. (Fills the
258
+ opponent-modeling gap โ€” the unique RTS value prop.)
259
+
260
+ 2. **longhorizon-opening-to-assault** (reasoning). One episode,
261
+ 40k+ ticks: scout (explored_pct) โ†’ tech (`has_building: proc`) โ†’
262
+ army (`own_units_gte`) โ†’ strike (`reach_region` enemy base) โ€” all
263
+ four as `all_of`, terminal-only credit, timeout fail. Parallel:
264
+ Terminal-Bench 2.0 / OSWorld 50-step. (Fills long-horizon credit
265
+ assignment โ€” currently thin.)
266
+
267
+ 3. **strict-toolban-fidelity** (action). Achieve `reach_region` but
268
+ the allowlist excludes `attack*`; win additionally requires
269
+ `units_lost_lte: 0` AND a `not{after_ticks:T0}`-then-`reach`
270
+ ordering. Any disallowed-tool call โ†’ fail. Parallel: BFCL V4
271
+ relevance / ฯ„ยฒ-bench. (Isolates API fidelity as the objective.)
272
+
273
+ 4. **economy-throughput-real** (reasoning) โ€” *gated on engine S0/S1*.
274
+ Once ore income exists: `economy_value_gte: V within_ticks:T`,
275
+ wide (2 proc) vs deep (1 proc + harvesters) genuinely mutually
276
+ exclusive. Parallel: PlanBench cost-optimal. (Replaces the 5
277
+ non-discriminating economy/cat-c5/c6 packs with one real test.)
278
+
279
+ 5. **perception-count-the-threat** (perception). Win:
280
+ `enemies_discovered_gte: K` where K = exact hidden count and
281
+ over/under-scouting wastes the clock; fail timeout +
282
+ `units_lost_lte`. Parallel: ERQA state-estimation. (Strengthens
283
+ the validated transfer target with a counting variant.)
284
+
285
+ 6. **reasoning-replan-after-loss** (reasoning) โ€” a *true* error-
286
+ recovery pack (what cat-c12 only pretends to be). Pre-place the
287
+ agent base in the path of a scripted stance-3 enemy push that
288
+ destroys 1โ€“2 buildings early; win =
289
+ `all_of[{building_total_gte:N},{has_building:tent},{after_ticks:t_loss},{within_ticks:T}]`.
290
+ Parallel: PlanBench replanning. (Requires interrupt/scripted-loss
291
+ support โ€” see ยง7.)
292
+
293
+ 7. **adversarial-feint-handling** (adversarial). Opponent shows a
294
+ decoy force on one axis; win keyed to committing against the
295
+ *real* axis (`building_in_region`/`units_killed_gte` at the true
296
+ threat), losing if you commit to the decoy (timeout). Parallel:
297
+ opponent modeling / adversarial robustness.
298
+
299
+ 8. **coordination-staggered-window** (action). Two squads must each
300
+ be in *different* regions within *different* tick bands
301
+ (`all_of` of two `reach_region` + paired `after_ticks`/`within_ticks`),
302
+ forcing true parallel scheduling, not a single column. Parallel:
303
+ Watch-And-Help / multi-robot dispatch. (Upgrades the thin
304
+ strategy-twobody/cat-c10 coordination bucket.)
305
+
306
+ 9. **tempo-double-window** (reasoning). Two separate strike windows
307
+ in one episode (`after_ticks:t1`+`units_killed_gte:a` then a
308
+ forced lull then `after_ticks:t2`+`units_killed_gte:b`), so acting
309
+ continuously fails. Parallel: TextStarCraft II tempo. (Tempo is
310
+ currently a single cat-c11 category.)
311
+
312
+ 10. **navigation-confined-hard-only** (perception). Replace the
313
+ trivial custom-map easy/medium with one hard map-read:
314
+ `all_units_in_region` in a far bounded corner, tight clock,
315
+ `fail_condition` on timeout โ€” a real ARC-/ERQA-style spatial
316
+ commit with no degenerate easy tier.
317
+
318
+ ---
319
+
320
+ ## 7. Predicate-vocabulary additions needed
321
+
322
+ The current leaf set is adequate for ยง6 #1,2,5,7,8,9,10. To fully
323
+ author the others, add:
324
+
325
+ - **`tool_called`/`tool_not_called: <name>`** (or a scenario-level
326
+ `forbidden_tools` enforced as an automatic `fail`) โ€” needed for
327
+ #3 strict-toolban-fidelity; today the only tool-fidelity signal is
328
+ the cross-cutting `actions_warned/issued` score, not a win/fail
329
+ predicate. This is the most leaderboard-relevant missing primitive
330
+ (BFCL/ฯ„ยฒ-bench relevance detection).
331
+ - **`building_destroyed_gte` / `own_building_lost_gte`** โ€” needed for
332
+ #6 (detect the scripted setback) and to give offensive packs a real
333
+ "took the base" predicate instead of proxying with
334
+ `buildings_discovered`/`reach_region`.
335
+ - **An ordering composite (e.g. `then: [A, B]` meaning A must have
336
+ held true at some tick strictly before B)** โ€” `after_ticks` only
337
+ approximates sequencing by wall-clock; a true happened-before would
338
+ let artofwar-lure-the-tiger / sequenced-citadel grade *order*
339
+ rather than *timing*, and make #2/#6 non-gameable.
340
+ - A scripted-adversary / mid-episode-event hook (engine side, already
341
+ flagged in SCENARIO_AUDIT as Phase-1) is the prerequisite for #1,
342
+ #6, #7 to be more than scripted-stance encounters.
343
+
344
+ ---
345
+
346
+ ## 8. Prioritized action summary
347
+
348
+ **Cut now (12 files, zero capability loss):** all `cat-c*-01` actives
349
+ (c1โ€“c12 `-01`). Optionally also retire `cat-c12-00` (mislabelled) and
350
+ the `strict-production-bom` hard level (unsolvable) unless budgeted.
351
+
352
+ **Fix now (one mechanical pass):** add a real `fail_condition`
353
+ (timeout-loss; attrition-loss where a `units_lost_lte` win clause
354
+ exists) to all ~36 active packs lacking one, and replace the dead
355
+ `units_lost_lte:-1` in the kept `cat-*` files. This is the single
356
+ change that most improves benchmark discrimination.
357
+
358
+ **Fix next (per-pack, ~6 packs):** strategy-dilemma/gauntlet/twobody
359
+ (win predicate doesn't enforce the claimed decision), cat-c3/c4
360
+ (powr-spam gameable โ€” add `has_building`), economy-* (tighten cash so
361
+ allocation is a real trade), artofwar-lure-the-tiger (add ordering
362
+ gate), rush-hour (re-spec or stop counting it as strategy),
363
+ strict-production-bom hard (raise budget or cut).
364
+
365
+ **Top 10 to add (ranked):** #1 adversarial-counterstrategy-read,
366
+ #2 longhorizon-opening-to-assault, #3 strict-toolban-fidelity,
367
+ #6 reasoning-replan-after-loss, #7 adversarial-feint-handling,
368
+ #8 coordination-staggered-window, #5 perception-count-the-threat,
369
+ #9 tempo-double-window, #4 economy-throughput-real (after S0/S1),
370
+ #10 navigation-confined-hard-only.
371
+
372
+ **Predicate additions (in priority order):** (1) tool-call
373
+ fidelity predicate / `forbidden_tools` auto-fail; (2)
374
+ `building_destroyed_gte`; (3) a happened-before ordering composite;
375
+ (4) scripted mid-episode adversary hook (engine, Phase-1).
openra_bench/agent.py CHANGED
@@ -476,6 +476,28 @@ class ModelAgent:
476
  }
477
  return {"role": "user", "content": text}
478
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
479
  @staticmethod
480
  def _strip_old_images(history: list[dict]) -> None:
481
  """Keep only the latest image to bound ViT token cost (mirrors
@@ -495,7 +517,10 @@ class ModelAgent:
495
  self.stats["turns"] += 1
496
  self.history.append(self._user_message(render_state))
497
  self._strip_old_images(self.history)
498
- reply = self.provider.complete(self.history, self.tools)
 
 
 
499
  self.history.append(
500
  {
501
  "role": "assistant",
 
476
  }
477
  return {"role": "user", "content": text}
478
 
479
+ @staticmethod
480
+ def _window(history: list[dict], max_turns: int) -> list[dict]:
481
+ """Wire-history sliding window: keep all leading system
482
+ messages + the last `max_turns` user-led groups. Slicing on a
483
+ user boundary keeps every assistantโ†”tool pairing intact (only
484
+ whole older groups are dropped, so no dangling tool replies).
485
+ `self.history` itself is untouched โ€” playback keeps the full
486
+ transcript; only what's POSTED is bounded."""
487
+ if max_turns <= 0:
488
+ return history
489
+ lead = 0
490
+ while lead < len(history) and history[lead].get("role") == "system":
491
+ lead += 1
492
+ user_idx = [
493
+ i for i in range(lead, len(history))
494
+ if history[i].get("role") == "user"
495
+ ]
496
+ if len(user_idx) <= max_turns:
497
+ return history
498
+ cut = user_idx[-max_turns]
499
+ return history[:lead] + history[cut:]
500
+
501
  @staticmethod
502
  def _strip_old_images(history: list[dict]) -> None:
503
  """Keep only the latest image to bound ViT token cost (mirrors
 
517
  self.stats["turns"] += 1
518
  self.history.append(self._user_message(render_state))
519
  self._strip_old_images(self.history)
520
+ wire = self._window(
521
+ self.history, getattr(self.cfg, "max_history_turns", 16)
522
+ )
523
+ reply = self.provider.complete(wire, self.tools)
524
  self.history.append(
525
  {
526
  "role": "assistant",
openra_bench/providers.py CHANGED
@@ -52,6 +52,14 @@ class ProviderConfig:
52
  timeout_s: float = 120.0
53
  vision: bool = True
54
  extra_headers: dict[str, str] = field(default_factory=dict)
 
 
 
 
 
 
 
 
55
 
56
  def resolved_base_url(self) -> str:
57
  if self.base_url:
@@ -82,6 +90,7 @@ class ChatReply:
82
  text: str
83
  tool_calls: list[dict] # [{"name": str, "arguments": dict}]
84
  reasoning: str = "" # chain-of-thought, when the model/provider emits it
 
85
  raw: dict = field(default_factory=dict)
86
 
87
 
@@ -93,11 +102,58 @@ class ChatProvider:
93
  class OpenAICompatibleProvider(ChatProvider):
94
  """OpenAI /chat/completions with `tools`. vLLM + OpenRouter + OpenAI."""
95
 
96
- def __init__(self, cfg: ProviderConfig):
 
97
  self.cfg = cfg
98
  self._client = httpx.Client(timeout=cfg.timeout_s)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  def complete(self, messages: list[dict], tools: list[dict]) -> ChatReply:
 
 
101
  cfg = self.cfg
102
  headers = {
103
  "Authorization": f"Bearer {cfg.resolved_api_key()}",
@@ -113,13 +169,17 @@ class OpenAICompatibleProvider(ChatProvider):
113
  if tools:
114
  body["tools"] = tools
115
  body["tool_choice"] = "auto"
116
- resp = self._client.post(
117
- f"{cfg.resolved_base_url()}/chat/completions",
118
- headers=headers,
119
- json=body,
 
120
  )
121
- resp.raise_for_status()
122
- return self._reply_from_data(resp.json())
 
 
 
123
 
124
  # Keys the OpenAI Chat Completions wire format accepts per message.
125
  # `history` carries extra playback-only keys (notably "reasoning");
@@ -157,10 +217,15 @@ class OpenAICompatibleProvider(ChatProvider):
157
  rc = "".join(
158
  p.get("text", "") if isinstance(p, dict) else str(p) for p in rc
159
  )
 
160
  return ChatReply(
161
  text=msg.get("content") or "",
162
  tool_calls=calls,
163
  reasoning=str(rc),
 
 
 
 
164
  raw=data,
165
  )
166
 
@@ -182,9 +247,12 @@ class BedrockProvider(ChatProvider):
182
  )
183
 
184
 
185
- def make_provider(cfg: ProviderConfig) -> ChatProvider:
 
186
  if cfg.provider == "bedrock":
187
  return BedrockProvider(cfg)
188
  if cfg.provider in ("openai", "vllm", "openrouter"):
189
- return OpenAICompatibleProvider(cfg)
 
 
190
  raise ValueError(f"unknown provider {cfg.provider!r}")
 
52
  timeout_s: float = 120.0
53
  vision: bool = True
54
  extra_headers: dict[str, str] = field(default_factory=dict)
55
+ # Resilience (real OpenRouter runs): bounded retry, throttle, price.
56
+ max_retries: int = 5
57
+ retry_base_s: float = 1.0
58
+ retry_cap_s: float = 30.0
59
+ qps: float = 0.0 # 0 = unthrottled; shared limiter set by evaluate
60
+ max_history_turns: int = 16 # sliding wire-history window (0=unbounded)
61
+ price_in_per_m: float = 0.0 # USD / 1M prompt tokens
62
+ price_out_per_m: float = 0.0 # USD / 1M completion tokens
63
 
64
  def resolved_base_url(self) -> str:
65
  if self.base_url:
 
90
  text: str
91
  tool_calls: list[dict] # [{"name": str, "arguments": dict}]
92
  reasoning: str = "" # chain-of-thought, when the model/provider emits it
93
+ usage: dict = field(default_factory=dict) # prompt/completion tokens
94
  raw: dict = field(default_factory=dict)
95
 
96
 
 
102
  class OpenAICompatibleProvider(ChatProvider):
103
  """OpenAI /chat/completions with `tools`. vLLM + OpenRouter + OpenAI."""
104
 
105
+ def __init__(self, cfg: ProviderConfig, *, rate_limiter=None,
106
+ cost_meter=None):
107
  self.cfg = cfg
108
  self._client = httpx.Client(timeout=cfg.timeout_s)
109
+ from .resilience import CostMeter, RateLimiter, RetryPolicy
110
+
111
+ self._rl = rate_limiter or RateLimiter(cfg.qps)
112
+ self._cost = cost_meter or CostMeter(
113
+ cfg.price_in_per_m, cfg.price_out_per_m
114
+ )
115
+ self._policy = RetryPolicy(
116
+ max_attempts=max(1, cfg.max_retries),
117
+ base=cfg.retry_base_s,
118
+ cap=cfg.retry_cap_s,
119
+ )
120
+
121
+ @property
122
+ def cost_meter(self):
123
+ return self._cost
124
+
125
+ def _post_once(self, url, headers, body):
126
+ from .resilience import FatalProviderError
127
+
128
+ try:
129
+ resp = self._client.post(url, headers=headers, json=body)
130
+ except httpx.TimeoutException as e:
131
+ e.transient = True # type: ignore[attr-defined]
132
+ e.retry_after = None # type: ignore[attr-defined]
133
+ raise
134
+ except httpx.TransportError as e:
135
+ e.transient = True # type: ignore[attr-defined]
136
+ e.retry_after = None # type: ignore[attr-defined]
137
+ raise
138
+ if resp.status_code >= 400:
139
+ ra = resp.headers.get("retry-after")
140
+ try:
141
+ retry_after = float(ra) if ra is not None else None
142
+ except ValueError:
143
+ retry_after = None
144
+ transient = self._policy.is_transient_status(resp.status_code)
145
+ cls = RuntimeError if transient else FatalProviderError
146
+ exc = cls(
147
+ f"{resp.status_code} from provider: {resp.text[:200]}"
148
+ )
149
+ exc.transient = transient # type: ignore[attr-defined]
150
+ exc.retry_after = retry_after # type: ignore[attr-defined]
151
+ raise exc
152
+ return resp
153
 
154
  def complete(self, messages: list[dict], tools: list[dict]) -> ChatReply:
155
+ from .resilience import retry_call
156
+
157
  cfg = self.cfg
158
  headers = {
159
  "Authorization": f"Bearer {cfg.resolved_api_key()}",
 
169
  if tools:
170
  body["tools"] = tools
171
  body["tool_choice"] = "auto"
172
+ url = f"{cfg.resolved_base_url()}/chat/completions"
173
+
174
+ self._rl.acquire()
175
+ resp = retry_call(
176
+ lambda: self._post_once(url, headers, body), self._policy
177
  )
178
+ reply = self._reply_from_data(resp.json())
179
+ u = reply.usage or {}
180
+ self._cost.add(u.get("prompt_tokens", 0), u.get("completion_tokens", 0))
181
+ self._cost.check() # raises BudgetExceeded โ†’ evaluate finalizes
182
+ return reply
183
 
184
  # Keys the OpenAI Chat Completions wire format accepts per message.
185
  # `history` carries extra playback-only keys (notably "reasoning");
 
217
  rc = "".join(
218
  p.get("text", "") if isinstance(p, dict) else str(p) for p in rc
219
  )
220
+ usage = data.get("usage") or {}
221
  return ChatReply(
222
  text=msg.get("content") or "",
223
  tool_calls=calls,
224
  reasoning=str(rc),
225
+ usage={
226
+ "prompt_tokens": usage.get("prompt_tokens", 0),
227
+ "completion_tokens": usage.get("completion_tokens", 0),
228
+ },
229
  raw=data,
230
  )
231
 
 
247
  )
248
 
249
 
250
+ def make_provider(cfg: ProviderConfig, *, rate_limiter=None,
251
+ cost_meter=None) -> ChatProvider:
252
  if cfg.provider == "bedrock":
253
  return BedrockProvider(cfg)
254
  if cfg.provider in ("openai", "vllm", "openrouter"):
255
+ return OpenAICompatibleProvider(
256
+ cfg, rate_limiter=rate_limiter, cost_meter=cost_meter
257
+ )
258
  raise ValueError(f"unknown provider {cfg.provider!r}")
openra_bench/resilience.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resilience primitives for real (OpenRouter) end-to-end runs.
2
+
3
+ A long sweep is tens of thousands of API calls over hours; transient
4
+ 429/5xx/timeouts, credit exhaustion, and process death are *expected*,
5
+ not exceptional. These primitives are pure and thread-safe so the
6
+ evaluator can retry, throttle, cap spend, and resume losslessly.
7
+
8
+ Nothing here imports the engine or a provider โ€” fully unit-testable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+
20
+ class BudgetExceeded(RuntimeError):
21
+ """Raised when the cost meter passes the hard cap. The evaluator
22
+ catches it, finalizes from the journal, and marks the run truncated
23
+ (a partial result is always better than a lost 6-hour run)."""
24
+
25
+
26
+ class FatalProviderError(RuntimeError):
27
+ """A non-retryable provider failure (4xx other than 429)."""
28
+
29
+
30
+ # โ”€โ”€ retry / backoff โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
31
+
32
+ _TRANSIENT_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
33
+
34
+
35
+ @dataclass
36
+ class RetryPolicy:
37
+ max_attempts: int = 5
38
+ base: float = 1.0 # seconds; exponential: base * 2**(attempt-1)
39
+ cap: float = 30.0 # max single sleep
40
+ jitter: float = 0.1 # fraction of delay added deterministically*0
41
+
42
+ def is_transient_status(self, status: int) -> bool:
43
+ return status in _TRANSIENT_STATUS
44
+
45
+ def delay(self, attempt: int, retry_after: float | None = None) -> float:
46
+ """Sleep before retry `attempt` (1-based). Honors a server
47
+ Retry-After when present and larger than our backoff."""
48
+ backoff = min(self.cap, self.base * (2 ** max(0, attempt - 1)))
49
+ if retry_after is not None and retry_after > 0:
50
+ return min(self.cap, max(backoff, retry_after))
51
+ return backoff
52
+
53
+
54
+ def retry_call(fn, policy: RetryPolicy, *, on_retry=None, sleep=time.sleep):
55
+ """Call `fn()` with bounded exponential backoff.
56
+
57
+ `fn` raises to signal failure; it may attach `.transient` (bool)
58
+ and `.retry_after` (float|None) to the exception to steer policy.
59
+ Non-transient โ†’ re-raised immediately. Exhausted attempts โ†’
60
+ last exception re-raised.
61
+ """
62
+ last: Exception | None = None
63
+ for attempt in range(1, policy.max_attempts + 1):
64
+ try:
65
+ return fn()
66
+ except Exception as exc: # noqa: BLE001 โ€” policy decides
67
+ last = exc
68
+ transient = getattr(exc, "transient", True)
69
+ if not transient or attempt >= policy.max_attempts:
70
+ raise
71
+ d = policy.delay(attempt, getattr(exc, "retry_after", None))
72
+ if on_retry is not None:
73
+ on_retry(attempt, exc, d)
74
+ sleep(d)
75
+ assert last is not None
76
+ raise last
77
+
78
+
79
+ # โ”€โ”€ rate limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
80
+
81
+
82
+ class RateLimiter:
83
+ """Thread-safe minimum-interval limiter (โ‰ˆ qps cap) shared across
84
+ the concurrency pool. qps<=0 disables it."""
85
+
86
+ def __init__(self, qps: float = 0.0):
87
+ self._interval = 1.0 / qps if qps and qps > 0 else 0.0
88
+ self._lock = threading.Lock()
89
+ self._next = 0.0
90
+
91
+ def acquire(self, *, now=time.monotonic, sleep=time.sleep) -> float:
92
+ if self._interval <= 0:
93
+ return 0.0
94
+ with self._lock:
95
+ t = now()
96
+ wait = max(0.0, self._next - t)
97
+ self._next = max(t, self._next) + self._interval
98
+ if wait > 0:
99
+ sleep(wait)
100
+ return wait
101
+
102
+
103
+ # โ”€โ”€ cost / token metering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
104
+
105
+
106
+ @dataclass
107
+ class CostMeter:
108
+ """Thread-safe token + USD accumulator with a hard cap.
109
+
110
+ Pricing is per 1M tokens (OpenRouter-style). `max_usd<=0` disables
111
+ the cap; metering still runs so the report carries spend."""
112
+
113
+ price_in_per_m: float = 0.0
114
+ price_out_per_m: float = 0.0
115
+ max_usd: float = 0.0
116
+ prompt_tokens: int = 0
117
+ completion_tokens: int = 0
118
+ calls: int = 0
119
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
120
+
121
+ @property
122
+ def usd(self) -> float:
123
+ return round(
124
+ self.prompt_tokens / 1e6 * self.price_in_per_m
125
+ + self.completion_tokens / 1e6 * self.price_out_per_m,
126
+ 6,
127
+ )
128
+
129
+ def add(self, prompt: int, completion: int) -> None:
130
+ with self._lock:
131
+ self.prompt_tokens += int(prompt or 0)
132
+ self.completion_tokens += int(completion or 0)
133
+ self.calls += 1
134
+
135
+ def check(self) -> None:
136
+ if self.max_usd and self.max_usd > 0 and self.usd >= self.max_usd:
137
+ raise BudgetExceeded(
138
+ f"spend ${self.usd:.4f} โ‰ฅ cap ${self.max_usd:.2f} "
139
+ f"({self.calls} calls, {self.prompt_tokens}+"
140
+ f"{self.completion_tokens} tok)"
141
+ )
142
+
143
+ def snapshot(self) -> dict:
144
+ return {
145
+ "calls": self.calls,
146
+ "prompt_tokens": self.prompt_tokens,
147
+ "completion_tokens": self.completion_tokens,
148
+ "usd": self.usd,
149
+ "max_usd": self.max_usd,
150
+ }
151
+
152
+
153
+ # โ”€โ”€ checkpoint / resume journal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
154
+
155
+
156
+ def episode_key(pack: str, level: str, split: str, seed: int) -> str:
157
+ return f"{pack}|{level}|{split}|{seed}"
158
+
159
+
160
+ class RunJournal:
161
+ """Append-only JSONL of completed episodes. Resume = skip keys
162
+ already present; the aggregate is rebuilt from the journal so a
163
+ killed run continues losslessly."""
164
+
165
+ def __init__(self, path: str | Path):
166
+ self.path = Path(path)
167
+ self.path.parent.mkdir(parents=True, exist_ok=True)
168
+ self._lock = threading.Lock()
169
+
170
+ def done_keys(self) -> set[str]:
171
+ if not self.path.exists():
172
+ return set()
173
+ keys: set[str] = set()
174
+ for line in self.path.read_text().splitlines():
175
+ line = line.strip()
176
+ if not line:
177
+ continue
178
+ try:
179
+ keys.add(json.loads(line)["_key"])
180
+ except Exception: # noqa: BLE001 โ€” tolerate a torn last line
181
+ continue
182
+ return keys
183
+
184
+ def append(self, key: str, record: dict) -> None:
185
+ row = dict(record)
186
+ row["_key"] = key
187
+ line = json.dumps(row)
188
+ with self._lock:
189
+ with open(self.path, "a") as f:
190
+ f.write(line + "\n")
191
+ f.flush()
192
+
193
+ def records(self) -> list[dict]:
194
+ if not self.path.exists():
195
+ return []
196
+ out: list[dict] = []
197
+ for line in self.path.read_text().splitlines():
198
+ line = line.strip()
199
+ if not line:
200
+ continue
201
+ try:
202
+ out.append(json.loads(line))
203
+ except Exception: # noqa: BLE001
204
+ continue
205
+ return out
openra_bench/run_eval.py CHANGED
@@ -21,6 +21,7 @@ import statistics
21
  import sys
22
  import time
23
  from collections import Counter
 
24
  from pathlib import Path
25
  from typing import Callable
26
 
@@ -80,6 +81,13 @@ def evaluate(
80
  concurrency: int = 1,
81
  run_id: str | None = None,
82
  model: str | None = None,
 
 
 
 
 
 
 
83
  ) -> dict:
84
  """Run packsร—levelsร—seeds. If `held_out_seeds` is given, those are
85
  run too and tagged split='held_out'; the report adds
@@ -87,7 +95,42 @@ def evaluate(
87
  held-out composite) โ€” the anti-memorization metric the
88
  generalization literature (Procgen/SMACv2/lmgame-Bench) requires.
89
  """
90
- factory = agent_factory or _default_agent_factory(provider_cfg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  # Run/model identity so a single playback root can hold many runs
92
  # and the viewer can filter run โ†’ model โ†’ scenario.
93
  run_id = run_id or time.strftime("%Y%m%d-%H%M%S", time.gmtime())
@@ -169,48 +212,166 @@ def evaluate(
169
  "_sc": sc,
170
  }
171
 
172
- if concurrency > 1 and len(tasks) > 1:
173
- from concurrent.futures import ThreadPoolExecutor
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
- with ThreadPoolExecutor(max_workers=concurrency) as ex:
176
- results = list(ex.map(_run_one, tasks))
177
- else:
178
- results = [_run_one(t) for t in tasks]
 
 
 
 
 
 
 
 
 
179
 
180
- # Deterministic aggregation: sort so the report is identical
181
- # regardless of worker scheduling.
182
- results.sort(key=lambda r: (r["cell"], r["split"], r["seed"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  by_cell: dict[str, list] = {}
184
  public_scores: list = []
185
  held_scores: list = []
186
  episodes: list[dict] = []
187
- for r in results:
188
- sc = r.pop("_sc")
189
- if r["split"] == "public":
 
190
  by_cell.setdefault(r["cell"], []).append(sc)
191
  public_scores.append(sc)
192
  else:
193
  held_scores.append(sc)
194
- episodes.append(r)
195
 
196
- # Mean cumulative reward vector across public episodes โ€” the
197
- # scenario-agnostic progress signature, comparable across runs.
198
- pub = [r for r in episodes if r["split"] == "public" and r.get("reward_vector")]
199
  rv_mean: dict = {}
200
  if pub:
201
  for k in pub[0]["reward_vector"]:
202
  rv_mean[k] = round(
203
- statistics.fmean(r["reward_vector"].get(k, 0.0) for r in pub), 4
 
204
  )
205
 
206
  out = {
207
- "summary": {cell: _agg(scs) for cell, scs in by_cell.items()},
 
 
 
 
 
208
  "overall": _agg(public_scores),
209
  "reward_vector_mean": rv_mean,
210
  "episodes": episodes,
211
  "skipped": skipped,
212
  }
213
- # Adversarial spotlight: per-pack ladder ratings + headline mean.
214
  from .adversarial import adversarial_summary
215
 
216
  adv = adversarial_summary(out)
@@ -278,6 +439,19 @@ def main(argv: list[str]) -> int:
278
  help="publish this run to the leaderboard store (optional path; "
279
  "default data/leaderboard.jsonl)",
280
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  a = ap.parse_args(argv[1:])
282
 
283
  cfg = None
@@ -289,6 +463,7 @@ def main(argv: list[str]) -> int:
289
  model=a.model,
290
  base_url=a.base_url,
291
  vision=not a.no_vision,
 
292
  )
293
 
294
  stats = evaluate(
@@ -299,7 +474,23 @@ def main(argv: list[str]) -> int:
299
  held_out_seeds=[int(s) for s in a.held_out_seeds.split(",") if s.strip()],
300
  playback_root=a.playback,
301
  concurrency=a.concurrency,
 
 
 
 
 
 
 
 
 
 
 
 
302
  )
 
 
 
 
303
  write_report(stats, a.out)
304
  o = stats["overall"]
305
  print(f"\nwrote {a.out}")
 
21
  import sys
22
  import time
23
  from collections import Counter
24
+ from dataclasses import dataclass
25
  from pathlib import Path
26
  from typing import Callable
27
 
 
81
  concurrency: int = 1,
82
  run_id: str | None = None,
83
  model: str | None = None,
84
+ journal_path: str | Path | None = None,
85
+ resume: bool = False,
86
+ max_spend_usd: float = 0.0,
87
+ smoke: bool = False,
88
+ dry_run: bool = False,
89
+ report_path: str | Path | None = None,
90
+ progress=None,
91
  ) -> dict:
92
  """Run packsร—levelsร—seeds. If `held_out_seeds` is given, those are
93
  run too and tagged split='held_out'; the report adds
 
95
  held-out composite) โ€” the anti-memorization metric the
96
  generalization literature (Procgen/SMACv2/lmgame-Bench) requires.
97
  """
98
+ from .resilience import (
99
+ BudgetExceeded,
100
+ CostMeter,
101
+ RateLimiter,
102
+ RunJournal,
103
+ episode_key,
104
+ )
105
+
106
+ # One shared cost meter + rate limiter across the whole sweep, so
107
+ # the budget cap and throttle apply globally (not per episode).
108
+ meter = CostMeter(
109
+ getattr(provider_cfg, "price_in_per_m", 0.0),
110
+ getattr(provider_cfg, "price_out_per_m", 0.0),
111
+ max_usd=max_spend_usd,
112
+ )
113
+ limiter = RateLimiter(getattr(provider_cfg, "qps", 0.0) or 0.0)
114
+ if agent_factory is not None:
115
+ factory = agent_factory
116
+ elif provider_cfg is None:
117
+ factory = lambda _c: scripted_explore_agent # noqa: E731
118
+ else:
119
+ from .agent import ModelAgent
120
+ from .providers import make_provider
121
+
122
+ shared = make_provider(
123
+ provider_cfg, rate_limiter=limiter, cost_meter=meter
124
+ )
125
+
126
+ def factory(compiled: CompiledLevel):
127
+ return ModelAgent(
128
+ provider_cfg,
129
+ allowed_tools=compiled.scenario.tools,
130
+ objective=compiled.scenario.description,
131
+ provider=shared,
132
+ ).agent_fn
133
+
134
  # Run/model identity so a single playback root can hold many runs
135
  # and the viewer can filter run โ†’ model โ†’ scenario.
136
  run_id = run_id or time.strftime("%Y%m%d-%H%M%S", time.gmtime())
 
212
  "_sc": sc,
213
  }
214
 
215
+ # Pre-flight: dry-run validates compile/selection without engine or
216
+ # API spend; smoke runs exactly one episode.
217
+ if dry_run:
218
+ return {
219
+ "dry_run": True,
220
+ "run_id": run_id,
221
+ "model": model,
222
+ "tasks": len(tasks),
223
+ "skipped": skipped,
224
+ "cells": sorted({t[1] for t in tasks}),
225
+ }
226
+ if smoke:
227
+ tasks = tasks[:1]
228
+
229
+ # Checkpoint/resume: a journal of completed episodes. On resume we
230
+ # skip done (pack|level|split|seed) and fold prior records back in,
231
+ # so a killed multi-hour run continues losslessly.
232
+ jp = journal_path
233
+ if jp is None and playback_root is not None:
234
+ jp = Path(playback_root) / f"{run_id}__{_safe_model}" / "_journal.jsonl"
235
+ journal = RunJournal(jp) if jp is not None else None
236
+ prior: list[dict] = []
237
+ if journal is not None and resume:
238
+ done = journal.done_keys()
239
+ prior = journal.records()
240
+ tasks = [
241
+ t for t in tasks
242
+ if episode_key(t[0].meta.id, t[0].level, t[2], t[3]) not in done
243
+ ]
244
 
245
+ def _persist(rec: dict) -> None:
246
+ if journal is None:
247
+ return
248
+ slim = {k: v for k, v in rec.items() if k != "_sc"}
249
+ journal.append(
250
+ episode_key(
251
+ rec["cell"].rsplit(":", 1)[0],
252
+ rec["cell"].rsplit(":", 1)[1],
253
+ rec["split"],
254
+ rec["seed"],
255
+ ),
256
+ slim,
257
+ )
258
 
259
+ new_results: list[dict] = []
260
+ truncated = False
261
+ done_n = 0
262
+
263
+ def _record(rec: dict) -> None:
264
+ nonlocal done_n
265
+ _persist(rec)
266
+ new_results.append(rec)
267
+ done_n += 1
268
+ if progress is not None:
269
+ progress(done_n, len(tasks), rec, meter.snapshot())
270
+ if report_path is not None:
271
+ # Incremental flush so a long run is always inspectable.
272
+ try:
273
+ write_report(
274
+ _finalize(prior, new_results, skipped, run_id, model,
275
+ meter, truncated=False),
276
+ report_path,
277
+ )
278
+ except Exception: # noqa: BLE001 โ€” flush must never abort a run
279
+ pass
280
+
281
+ try:
282
+ if concurrency > 1 and len(tasks) > 1:
283
+ from concurrent.futures import ThreadPoolExecutor
284
+
285
+ with ThreadPoolExecutor(max_workers=concurrency) as ex:
286
+ futs = {ex.submit(_run_one, t): t for t in tasks}
287
+ from concurrent.futures import as_completed
288
+
289
+ for fu in as_completed(futs):
290
+ _record(fu.result())
291
+ else:
292
+ for t in tasks:
293
+ _record(_run_one(t))
294
+ except BudgetExceeded as e:
295
+ truncated = True
296
+ skipped.append(f"BUDGET STOP: {e}")
297
+
298
+ out = _finalize(prior, new_results, skipped, run_id, model, meter,
299
+ truncated=truncated)
300
+ if report_path is not None:
301
+ write_report(out, report_path)
302
+ return out
303
+
304
+
305
+ @dataclass
306
+ class _ScoreShim:
307
+ """Reconstruct the fields `_agg` needs from a journaled episode
308
+ dict, so resume aggregates prior + new identically to a fresh run."""
309
+
310
+ composite: float
311
+ outcome: str
312
+ perception: float
313
+ reasoning: float
314
+ action: float
315
+ weakest_link: str
316
+ dimensions: dict
317
+
318
+
319
+ def _shim(r: dict):
320
+ sc = r.get("_sc")
321
+ if sc is not None:
322
+ return sc
323
+ return _ScoreShim(
324
+ composite=r.get("composite", 0.0),
325
+ outcome=r.get("outcome", "draw"),
326
+ perception=r.get("perception", 0.0),
327
+ reasoning=r.get("reasoning", 0.0),
328
+ action=r.get("action", 0.0),
329
+ weakest_link=r.get("weakest_link", "n/a"),
330
+ dimensions={"objective": r.get("objective_progress", 0.0)},
331
+ )
332
+
333
+
334
+ def _finalize(prior: list[dict], new: list[dict], skipped: list[str],
335
+ run_id, model, meter, *, truncated: bool) -> dict:
336
+ rows = list(prior) + list(new)
337
+ rows.sort(key=lambda r: (r.get("cell", ""), r.get("split", ""),
338
+ r.get("seed", 0)))
339
  by_cell: dict[str, list] = {}
340
  public_scores: list = []
341
  held_scores: list = []
342
  episodes: list[dict] = []
343
+ for r in rows:
344
+ sc = _shim(r)
345
+ slim = {k: v for k, v in r.items() if k != "_sc"}
346
+ if r.get("split") == "public":
347
  by_cell.setdefault(r["cell"], []).append(sc)
348
  public_scores.append(sc)
349
  else:
350
  held_scores.append(sc)
351
+ episodes.append(slim)
352
 
353
+ pub = [r for r in episodes
354
+ if r.get("split") == "public" and r.get("reward_vector")]
 
355
  rv_mean: dict = {}
356
  if pub:
357
  for k in pub[0]["reward_vector"]:
358
  rv_mean[k] = round(
359
+ statistics.fmean(r["reward_vector"].get(k, 0.0) for r in pub),
360
+ 4,
361
  )
362
 
363
  out = {
364
+ "run_id": run_id,
365
+ "model": model,
366
+ "truncated": truncated,
367
+ "resumed": len(prior),
368
+ "cost": meter.snapshot() if meter is not None else {},
369
+ "summary": {c: _agg(s) for c, s in by_cell.items()},
370
  "overall": _agg(public_scores),
371
  "reward_vector_mean": rv_mean,
372
  "episodes": episodes,
373
  "skipped": skipped,
374
  }
 
375
  from .adversarial import adversarial_summary
376
 
377
  adv = adversarial_summary(out)
 
439
  help="publish this run to the leaderboard store (optional path; "
440
  "default data/leaderboard.jsonl)",
441
  )
442
+ # Resilience flags for real OpenRouter runs.
443
+ ap.add_argument("--resume", action="store_true",
444
+ help="skip episodes already in the run journal")
445
+ ap.add_argument("--journal", default=None,
446
+ help="checkpoint journal path (default: under --playback)")
447
+ ap.add_argument("--max-spend", type=float, default=0.0,
448
+ help="hard USD cap; the run finalizes when hit")
449
+ ap.add_argument("--qps", type=float, default=0.0,
450
+ help="global request/sec throttle (0 = unthrottled)")
451
+ ap.add_argument("--smoke", action="store_true",
452
+ help="run exactly one episode (live preflight)")
453
+ ap.add_argument("--dry-run", action="store_true",
454
+ help="validate/compile + list tasks, no engine/API")
455
  a = ap.parse_args(argv[1:])
456
 
457
  cfg = None
 
463
  model=a.model,
464
  base_url=a.base_url,
465
  vision=not a.no_vision,
466
+ qps=a.qps,
467
  )
468
 
469
  stats = evaluate(
 
474
  held_out_seeds=[int(s) for s in a.held_out_seeds.split(",") if s.strip()],
475
  playback_root=a.playback,
476
  concurrency=a.concurrency,
477
+ model=a.model if a.provider else None,
478
+ journal_path=a.journal,
479
+ resume=a.resume,
480
+ max_spend_usd=a.max_spend,
481
+ smoke=a.smoke,
482
+ dry_run=a.dry_run,
483
+ report_path=a.out,
484
+ progress=lambda d, n, rec, c: print(
485
+ f"[{d}/{n}] {rec['cell']}:{rec['split']}#{rec['seed']} "
486
+ f"{rec['outcome']} comp={rec['composite']} "
487
+ f"${c['usd']:.4f}", flush=True
488
+ ),
489
  )
490
+ if stats.get("dry_run"):
491
+ print(f"dry-run: {stats['tasks']} tasks over "
492
+ f"{len(stats['cells'])} cells; skipped {len(stats['skipped'])}")
493
+ return 0
494
  write_report(stats, a.out)
495
  o = stats["overall"]
496
  print(f"\nwrote {a.out}")
tests/test_resilience.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resilience layer: retry/backoff, throttle, cost cap, journal/resume,
2
+ bounded history, dry-run/smoke. All deterministic โ€” fake clocks/sleeps,
3
+ no network, scripted agent for the evaluate-integration paths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from openra_bench.resilience import (
14
+ BudgetExceeded,
15
+ CostMeter,
16
+ FatalProviderError,
17
+ RateLimiter,
18
+ RetryPolicy,
19
+ RunJournal,
20
+ episode_key,
21
+ retry_call,
22
+ )
23
+
24
+
25
+ # โ”€โ”€ retry / backoff โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
26
+
27
+
28
+ def test_retry_policy_delay_exponential_capped_and_retry_after():
29
+ p = RetryPolicy(base=1.0, cap=30.0, max_attempts=6)
30
+ assert [p.delay(a) for a in (1, 2, 3, 4, 10)] == [1.0, 2.0, 4.0, 8.0, 30.0]
31
+ # server Retry-After wins when larger; still capped
32
+ assert p.delay(1, retry_after=5.0) == 5.0
33
+ assert p.delay(1, retry_after=999) == 30.0
34
+ assert p.is_transient_status(429) and not p.is_transient_status(400)
35
+
36
+
37
+ def test_retry_call_succeeds_after_transient_then_stops_on_fatal():
38
+ slept = []
39
+ n = {"i": 0}
40
+
41
+ def flaky():
42
+ n["i"] += 1
43
+ if n["i"] < 3:
44
+ e = RuntimeError("503"); e.transient = True
45
+ raise e
46
+ return "ok"
47
+
48
+ assert retry_call(flaky, RetryPolicy(max_attempts=5),
49
+ sleep=slept.append) == "ok"
50
+ assert len(slept) == 2 # two backoffs before the 3rd attempt
51
+
52
+ def fatal():
53
+ e = FatalProviderError("400"); e.transient = False
54
+ raise e
55
+
56
+ with pytest.raises(FatalProviderError):
57
+ retry_call(fatal, RetryPolicy(max_attempts=5), sleep=slept.append)
58
+
59
+ def always():
60
+ e = RuntimeError("503"); e.transient = True
61
+ raise e
62
+
63
+ with pytest.raises(RuntimeError):
64
+ retry_call(always, RetryPolicy(max_attempts=3), sleep=lambda *_: None)
65
+
66
+
67
+ # โ”€โ”€ rate limiter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
68
+
69
+
70
+ def test_rate_limiter_enforces_min_interval():
71
+ clk = {"t": 0.0}
72
+ slept = []
73
+ rl = RateLimiter(qps=2.0) # 0.5s spacing
74
+
75
+ def now():
76
+ return clk["t"]
77
+
78
+ def slp(s):
79
+ slept.append(s)
80
+ clk["t"] += s
81
+
82
+ assert rl.acquire(now=now, sleep=slp) == 0.0 # first is free
83
+ w = rl.acquire(now=now, sleep=slp) # immediate 2nd waits
84
+ assert w == pytest.approx(0.5)
85
+ assert RateLimiter(0.0).acquire() == 0.0 # disabled
86
+
87
+
88
+ # โ”€โ”€ cost meter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
89
+
90
+
91
+ def test_cost_meter_accumulates_and_caps():
92
+ m = CostMeter(price_in_per_m=1.0, price_out_per_m=2.0, max_usd=0.01)
93
+ m.add(1000, 1000) # 0.001 + 0.002 = 0.003
94
+ m.check() # under cap
95
+ assert m.usd == pytest.approx(0.003)
96
+ m.add(2_000_000, 2_000_000) # blows the cap
97
+ with pytest.raises(BudgetExceeded):
98
+ m.check()
99
+ assert m.snapshot()["calls"] == 2
100
+
101
+
102
+ # โ”€โ”€ journal / resume โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
103
+
104
+
105
+ def test_journal_roundtrip_and_torn_line(tmp_path):
106
+ j = RunJournal(tmp_path / "j.jsonl")
107
+ assert j.done_keys() == set()
108
+ k = episode_key("p", "easy", "public", 1)
109
+ j.append(k, {"cell": "p:easy", "composite": 0.4})
110
+ j.append(episode_key("p", "hard", "public", 2), {"cell": "p:hard"})
111
+ assert k in j.done_keys() and len(j.done_keys()) == 2
112
+ with open(tmp_path / "j.jsonl", "a") as f:
113
+ f.write('{"_key": "torn"') # no newline / invalid
114
+ assert len(j.records()) == 2 # torn tail tolerated
115
+
116
+
117
+ # โ”€โ”€ bounded chat history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+
119
+
120
+ def test_model_agent_window_keeps_system_and_last_turns():
121
+ from openra_bench.agent import ModelAgent
122
+
123
+ h = [{"role": "system", "content": "S"}]
124
+ for t in range(5):
125
+ h.append({"role": "user", "content": f"u{t}"})
126
+ h.append({"role": "assistant", "content": f"a{t}",
127
+ "tool_calls": [{"id": f"c{t}"}]})
128
+ h.append({"role": "tool", "tool_call_id": f"c{t}", "content": "ok"})
129
+
130
+ w = ModelAgent._window(h, 2)
131
+ assert w[0]["role"] == "system"
132
+ users = [m for m in w if m["role"] == "user"]
133
+ assert [m["content"] for m in users] == ["u3", "u4"] # last 2 groups
134
+ # pairing intact: first non-system is a user (no dangling tool reply)
135
+ assert w[1]["role"] == "user"
136
+ # no trimming when within budget; passthrough when disabled
137
+ assert ModelAgent._window(h, 99) is h
138
+ assert ModelAgent._window(h, 0) is h
139
+
140
+
141
+ # โ”€โ”€ evaluate: dry-run / smoke / journal+resume (scripted, no API) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
142
+
143
+
144
+ def test_evaluate_dry_run_lists_without_running():
145
+ from openra_bench.run_eval import evaluate
146
+
147
+ PACK = Path("openra_bench/scenarios/packs/perception-frontier-reading.yaml")
148
+ out = evaluate([PACK], ["easy", "medium"], [1], dry_run=True)
149
+ assert out["dry_run"] and out["tasks"] == 2
150
+ assert "episodes" not in out # nothing executed
151
+
152
+
153
+ def test_evaluate_journal_resume_is_lossless(tmp_path):
154
+ pytest.importorskip("openra_train")
155
+ from openra_bench.run_eval import evaluate
156
+
157
+ PACK = Path("openra_bench/scenarios/packs/perception-frontier-reading.yaml")
158
+ jp = tmp_path / "j.jsonl"
159
+ a = evaluate([PACK], ["easy"], [1, 2], journal_path=jp)
160
+ assert a["overall"]["n"] == 2 and a["resumed"] == 0
161
+ n_lines = len(jp.read_text().splitlines())
162
+ assert n_lines == 2
163
+
164
+ # resume: both episodes already journaled โ†’ 0 new, same aggregate
165
+ b = evaluate([PACK], ["easy"], [1, 2], journal_path=jp, resume=True)
166
+ assert b["resumed"] == 2 and b["overall"]["n"] == 2
167
+ assert len(jp.read_text().splitlines()) == 2 # nothing re-appended
168
+ assert "cost" in b and "truncated" in b
tests/test_run_eval.py CHANGED
@@ -92,8 +92,10 @@ def test_concurrency_is_deterministic_and_isolated():
92
  PACKS / "perception-frontier-reading.yaml",
93
  PACKS / "reasoning-frontier-commit.yaml",
94
  ]
95
- seq = evaluate(packs, ["easy"], [1, 2, 3], concurrency=1)
96
- par = evaluate(packs, ["easy"], [1, 2, 3], concurrency=4)
 
 
97
  # Same report regardless of worker scheduling (episodes sorted,
98
  # aggregates order-independent) โ€” episodes ran in isolation.
99
  assert json.dumps(seq, sort_keys=True) == json.dumps(par, sort_keys=True)
 
92
  PACKS / "perception-frontier-reading.yaml",
93
  PACKS / "reasoning-frontier-commit.yaml",
94
  ]
95
+ # Pin run_id: it's a wall-clock label by design, not part of the
96
+ # determinism contract (scores/episodes/aggregates are).
97
+ seq = evaluate(packs, ["easy"], [1, 2, 3], concurrency=1, run_id="t")
98
+ par = evaluate(packs, ["easy"], [1, 2, 3], concurrency=4, run_id="t")
99
  # Same report regardless of worker scheduling (episodes sorted,
100
  # aggregates order-independent) โ€” episodes ran in isolation.
101
  assert json.dumps(seq, sort_keys=True) == json.dumps(par, sort_keys=True)