yxc20098 commited on
Commit
9000fe3
·
1 Parent(s): 4ebeee5

Phase 4 data plumbing + 3rd defect partial fix

Browse files

PHASE 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).

openra_bench/full_playback.py CHANGED
@@ -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
- tail_n = min(size, 65536)
326
- fh.seek(size - tail_n)
327
- tail = fh.read().decode("utf-8", errors="replace")
328
- last = tail.strip().splitlines()[-1] if tail.strip() else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
openra_bench/scenarios/packs/mid-economy-under-fire.yaml CHANGED
@@ -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: 2}
107
- - {type: '1tnk', owner: agent, position: [26, 20], stance: 2}
108
- - {type: '1tnk', owner: agent, position: [26, 22], stance: 2}
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: 2}
176
- - {type: '1tnk', owner: agent, position: [26, 20], stance: 2}
177
- - {type: '1tnk', owner: agent, position: [26, 22], stance: 2}
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:
scripts/collect_eval_data.py CHANGED
@@ -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
- p = Path(spec)
127
- if p.is_dir():
128
- return sorted(x.stem for x in p.glob("*.yaml"))
129
- return [s.strip() for s in spec.split(",") if s.strip()]
 
 
 
 
 
 
 
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]: