Spaces:
Running
Eval resilience layer for real OpenRouter runs
Browse filesresilience.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 +375 -0
- openra_bench/agent.py +26 -1
- openra_bench/providers.py +77 -9
- openra_bench/resilience.py +205 -0
- openra_bench/run_eval.py +211 -20
- tests/test_resilience.py +168 -0
- tests/test_run_eval.py +4 -2
|
@@ -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).
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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",
|
|
@@ -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 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
| 120 |
)
|
| 121 |
-
resp.
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 186 |
if cfg.provider == "bedrock":
|
| 187 |
return BedrockProvider(cfg)
|
| 188 |
if cfg.provider in ("openai", "vllm", "openrouter"):
|
| 189 |
-
return OpenAICompatibleProvider(
|
|
|
|
|
|
|
| 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}")
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
by_cell: dict[str, list] = {}
|
| 184 |
public_scores: list = []
|
| 185 |
held_scores: list = []
|
| 186 |
episodes: list[dict] = []
|
| 187 |
-
for r in
|
| 188 |
-
sc =
|
| 189 |
-
|
|
|
|
| 190 |
by_cell.setdefault(r["cell"], []).append(sc)
|
| 191 |
public_scores.append(sc)
|
| 192 |
else:
|
| 193 |
held_scores.append(sc)
|
| 194 |
-
episodes.append(
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 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),
|
|
|
|
| 204 |
)
|
| 205 |
|
| 206 |
out = {
|
| 207 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|
|
@@ -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
|
|
@@ -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 |
-
|
| 96 |
-
|
|
|
|
|
|
|
| 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)
|