Spaces:
Running
Phase 4 data plumbing + 3rd defect partial fix
Browse filesPHASE 4 PREP: full untruncated execution data collection
* openra_bench/full_playback.py — per-cell JSONL recorder; one line
per turn carrying full obs, briefing, system_prompt, request,
response, commands, signals, terminal; per-turn PNG dir alongside.
is_complete_cell() for resume safety.
* scripts/collect_eval_data.py — Phase 4 driver: plan expansion,
--dry-run, --cost-estimate, --parallel-cells, --resume.
* scripts/COLLECT_EVAL_DATA.md — invocation + cost guide.
* tests/test_data_collection.py — dry-run / live 1-cell / resume.
* Modified: providers (request_log audit hook), agent (audit_capture
attrs), eval_core (full_playback arg in run_level), run_eval
(--full-playback CLI + 6-mode --fog-mode), playback_view (audit
JSONL load).
MEDIUM-PROFILE COST: 2400 cells (50 packs × 3 levels × 4 seeds × 4
models × 1 fog_mode) ≈ $87 USD on Together (Qwen3.5-9B, Qwen3.6-Plus,
gemma-4-31B-it, Kimi-K2.6). May 2026 pricing snapshot.
DEFECT FIX (partial): mid-economy-under-fire
* Flipped pre-placed perimeter 1tnks from stance:2 → stance:0 so the
defender ring doesn't auto-engage the raider for free.
* Added units_killed_gte clauses to all 3 tiers' win predicates so
a do-nothing policy can't pass the bar even when the harvs
passively reach the cash threshold.
* easy + medium now stall LOSS as intended. Hard tier still stall
WIN — mystery kills credited to the agent (kill=3 at turn 11
despite stance:0 defenders). Needs deeper investigation
(probably a defensive building's auto-fire, or perimeter geometry
allowing units_killed to roll up from raider self-destruct).
|
@@ -315,17 +315,37 @@ def is_complete_cell(jsonl_path: str | Path) -> bool:
|
|
| 315 |
p = Path(jsonl_path)
|
| 316 |
if not p.exists() or p.stat().st_size == 0:
|
| 317 |
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
try:
|
| 319 |
-
# Tail read: open, seek near end, find the last newline-bounded
|
| 320 |
-
# line. JSONL turn records are small (~few KB at most), so 64KB
|
| 321 |
-
# is plenty for the last line of even a long episode.
|
| 322 |
with open(p, "rb") as fh:
|
| 323 |
fh.seek(0, 2)
|
| 324 |
size = fh.tell()
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
if not last:
|
| 330 |
return False
|
| 331 |
rec = json.loads(last)
|
|
|
|
| 315 |
p = Path(jsonl_path)
|
| 316 |
if not p.exists() or p.stat().st_size == 0:
|
| 317 |
return False
|
| 318 |
+
# Tail read: locate the last newline boundary so we can parse just
|
| 319 |
+
# the trailing line without loading the whole file. The terminal
|
| 320 |
+
# line carries the FULL final_obs (which on a long episode with
|
| 321 |
+
# spatial tensors can be many MB), so we walk back from EOF until
|
| 322 |
+
# we cross a newline rather than guessing a fixed window.
|
| 323 |
try:
|
|
|
|
|
|
|
|
|
|
| 324 |
with open(p, "rb") as fh:
|
| 325 |
fh.seek(0, 2)
|
| 326 |
size = fh.tell()
|
| 327 |
+
# Walk back in 256KB chunks until we hit a newline OR start.
|
| 328 |
+
chunk = 256 * 1024
|
| 329 |
+
buf = b""
|
| 330 |
+
pos = size
|
| 331 |
+
while pos > 0:
|
| 332 |
+
step = min(chunk, pos)
|
| 333 |
+
pos -= step
|
| 334 |
+
fh.seek(pos)
|
| 335 |
+
buf = fh.read(step) + buf
|
| 336 |
+
# The last line begins right after the LAST newline in
|
| 337 |
+
# buf (excluding a trailing newline). We need TWO
|
| 338 |
+
# newlines (or start-of-file + one) to be sure we have
|
| 339 |
+
# the complete last line.
|
| 340 |
+
stripped = buf.rstrip(b"\n")
|
| 341 |
+
nl = stripped.rfind(b"\n")
|
| 342 |
+
if nl != -1:
|
| 343 |
+
last_bytes = stripped[nl + 1:]
|
| 344 |
+
break
|
| 345 |
+
if pos == 0:
|
| 346 |
+
last_bytes = stripped
|
| 347 |
+
break
|
| 348 |
+
last = last_bytes.decode("utf-8", errors="replace").strip()
|
| 349 |
if not last:
|
| 350 |
return False
|
| 351 |
rec = json.loads(last)
|
|
@@ -103,9 +103,9 @@ base:
|
|
| 103 |
# the enemy's last MustBeDestroyed building falls). stance:2 holds
|
| 104 |
# the ring on the patch so the episode runs to the win/fail
|
| 105 |
# predicate.
|
| 106 |
-
- {type: '1tnk', owner: agent, position: [26, 18], stance:
|
| 107 |
-
- {type: '1tnk', owner: agent, position: [26, 20], stance:
|
| 108 |
-
- {type: '1tnk', owner: agent, position: [26, 22], stance:
|
| 109 |
# Two ore patches east of the proc — sustained harvesting target.
|
| 110 |
- {type: mine, owner: neutral, position: [22, 18]}
|
| 111 |
- {type: mine, owner: neutral, position: [22, 22]}
|
|
@@ -140,6 +140,7 @@ levels:
|
|
| 140 |
all_of:
|
| 141 |
- {economy_value_gte: 2000}
|
| 142 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
|
|
|
| 143 |
- {within_ticks: 5400}
|
| 144 |
# after_ticks 5401 ≤ 93+90·60 = 5493 ⇒ deadline bites inside
|
| 145 |
# max_turns. Loss is reachable: either EV/harv bar unmet at the
|
|
@@ -172,9 +173,9 @@ levels:
|
|
| 172 |
- {type: harv, owner: agent, position: [14, 18]}
|
| 173 |
- {type: harv, owner: agent, position: [14, 20]}
|
| 174 |
- {type: harv, owner: agent, position: [14, 22]}
|
| 175 |
-
- {type: '1tnk', owner: agent, position: [26, 18], stance:
|
| 176 |
-
- {type: '1tnk', owner: agent, position: [26, 20], stance:
|
| 177 |
-
- {type: '1tnk', owner: agent, position: [26, 22], stance:
|
| 178 |
- {type: mine, owner: neutral, position: [22, 18]}
|
| 179 |
- {type: mine, owner: neutral, position: [22, 22]}
|
| 180 |
# Two staggered raiders — the second arrives ~9 turns after
|
|
@@ -187,6 +188,7 @@ levels:
|
|
| 187 |
all_of:
|
| 188 |
- {economy_value_gte: 3000}
|
| 189 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
|
|
|
| 190 |
- {within_ticks: 5400}
|
| 191 |
fail_condition:
|
| 192 |
any_of:
|
|
@@ -263,6 +265,7 @@ levels:
|
|
| 263 |
all_of:
|
| 264 |
- {economy_value_gte: 4000}
|
| 265 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
|
|
|
| 266 |
- {units_lost_lte: 2}
|
| 267 |
- {within_ticks: 5400}
|
| 268 |
fail_condition:
|
|
|
|
| 103 |
# the enemy's last MustBeDestroyed building falls). stance:2 holds
|
| 104 |
# the ring on the patch so the episode runs to the win/fail
|
| 105 |
# predicate.
|
| 106 |
+
- {type: '1tnk', owner: agent, position: [26, 18], stance: 0}
|
| 107 |
+
- {type: '1tnk', owner: agent, position: [26, 20], stance: 0}
|
| 108 |
+
- {type: '1tnk', owner: agent, position: [26, 22], stance: 0}
|
| 109 |
# Two ore patches east of the proc — sustained harvesting target.
|
| 110 |
- {type: mine, owner: neutral, position: [22, 18]}
|
| 111 |
- {type: mine, owner: neutral, position: [22, 22]}
|
|
|
|
| 140 |
all_of:
|
| 141 |
- {economy_value_gte: 2000}
|
| 142 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
| 143 |
+
- {units_killed_gte: 1}
|
| 144 |
- {within_ticks: 5400}
|
| 145 |
# after_ticks 5401 ≤ 93+90·60 = 5493 ⇒ deadline bites inside
|
| 146 |
# max_turns. Loss is reachable: either EV/harv bar unmet at the
|
|
|
|
| 173 |
- {type: harv, owner: agent, position: [14, 18]}
|
| 174 |
- {type: harv, owner: agent, position: [14, 20]}
|
| 175 |
- {type: harv, owner: agent, position: [14, 22]}
|
| 176 |
+
- {type: '1tnk', owner: agent, position: [26, 18], stance: 0}
|
| 177 |
+
- {type: '1tnk', owner: agent, position: [26, 20], stance: 0}
|
| 178 |
+
- {type: '1tnk', owner: agent, position: [26, 22], stance: 0}
|
| 179 |
- {type: mine, owner: neutral, position: [22, 18]}
|
| 180 |
- {type: mine, owner: neutral, position: [22, 22]}
|
| 181 |
# Two staggered raiders — the second arrives ~9 turns after
|
|
|
|
| 188 |
all_of:
|
| 189 |
- {economy_value_gte: 3000}
|
| 190 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
| 191 |
+
- {units_killed_gte: 2}
|
| 192 |
- {within_ticks: 5400}
|
| 193 |
fail_condition:
|
| 194 |
any_of:
|
|
|
|
| 265 |
all_of:
|
| 266 |
- {economy_value_gte: 4000}
|
| 267 |
- {unit_type_count_gte: {type: harv, n: 2}}
|
| 268 |
+
- {units_killed_gte: 3}
|
| 269 |
- {units_lost_lte: 2}
|
| 270 |
- {within_ticks: 5400}
|
| 271 |
fail_condition:
|
|
@@ -123,10 +123,17 @@ def _resolve_packs(spec: str | None) -> list[str]:
|
|
| 123 |
if line and not line.startswith("#"):
|
| 124 |
ids.append(line)
|
| 125 |
return ids
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
|
| 132 |
def _price_for(model: str) -> tuple[float, float]:
|
|
|
|
| 123 |
if line and not line.startswith("#"):
|
| 124 |
ids.append(line)
|
| 125 |
return ids
|
| 126 |
+
# Comma list short-circuits the dir check (avoids `File name too long`
|
| 127 |
+
# when a long comma list is passed).
|
| 128 |
+
if "," in spec:
|
| 129 |
+
return [s.strip() for s in spec.split(",") if s.strip()]
|
| 130 |
+
# Possibly a single pack id OR a directory path. Heuristic: if it
|
| 131 |
+
# contains a `/` OR exists on disk, treat as path.
|
| 132 |
+
if "/" in spec or len(spec) > 200 or Path(spec).exists():
|
| 133 |
+
p = Path(spec)
|
| 134 |
+
if p.is_dir():
|
| 135 |
+
return sorted(x.stem for x in p.glob("*.yaml"))
|
| 136 |
+
return [spec.strip()]
|
| 137 |
|
| 138 |
|
| 139 |
def _price_for(model: str) -> tuple[float, float]:
|