Skip to content

Commit 3a97ee5

Browse files
alanshurafaclaude
andcommitted
Track effort per arm and report unfinished runs as unfinished
A leaderboard that shows only a score answers half the question. Wall time, reported cost, and cost per resolved task are what both reference benchmarks lead with, and they were missing or wrong here: Codex time and tokens were never read at all, so an arm that spent twenty minutes of Codex effort showed a blank. Both are now taken from the log each phase already writes. An unfinished run was the worse problem. Scoring an arm that has run 21 of 50 tasks against 50 reports it as a 42% failure when it has actually solved everything it reached. An arm is now scored against the tasks it ran, labelled in progress, and the page says plainly that arms which have run different numbers of tasks cannot be compared to each other. A cost figure that covers only an arm's Claude phases is marked, because Codex, GLM and Kimi report no cost and rendering that as $0.00 reads as free rather than as unmeasured. Evaluator reports now carry a run label. Named only by timestamp, reports from two runs of one condition were indistinguishable and a page built across them silently mixed a 50-task subset with a 1-task one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9eedc40 commit 3a97ee5

8 files changed

Lines changed: 5300 additions & 275 deletions

File tree

benchmarks/code/scripts/evaluate-swebench.sh

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,23 @@ case "$MODE" in
4343
predictions)
4444
predictions="${1:-}"
4545
[[ -n "$predictions" ]] || { code_die "predictions mode needs a JSONL file"; exit 2; }
46+
shift || true
47+
# The evaluator names its report after the run id and nothing else, so two
48+
# benchmark runs of the same condition are indistinguishable afterwards and
49+
# a page built from them silently mixes subsets. --label stamps the batch
50+
# into the run id so a report can be traced back to the run that made it.
51+
LABEL="code-bench"
52+
while (( $# > 0 )); do
53+
case "$1" in
54+
--label) LABEL="${2:?--label needs a value}"; shift 2 ;;
55+
*) code_die "unknown evaluate option: $1"; exit 2 ;;
56+
esac
57+
done
58+
[[ "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]] || { code_die "--label must be filesystem-safe"; exit 2; }
4659
bash "$CODE_DIR/validate-predictions.sh" "$predictions" "$SUITE"
4760
predictions_dir=$(cd "$(dirname "$predictions")" && pwd -P)
4861
predictions="$predictions_dir/$(basename "$predictions")"
49-
run_id="code-bench-$(date -u +%Y%m%dT%H%M%SZ)"
62+
run_id="$LABEL-$(date -u +%Y%m%dT%H%M%SZ)"
5063
(cd "$EVAL_ROOT" && "$CLI" eval verified -p "$predictions" --run-id "$run_id" -j "${CODE_BENCH_EVAL_JOBS:-1}")
5164
;;
5265
*)

benchmarks/site/aggregate.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
1010
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
1111
RESULTS_ROOT="${CODE_BENCH_RESULTS_ROOT:-$REPO_ROOT/benchmarks/results/code}"
1212
SUITE="swebench-verified-canary"
13+
RUN_LABEL=""
1314
OUTPUT="$RESULTS_ROOT/site/leaderboard.json"
1415

1516
while (( $# > 0 )); do
1617
case "$1" in
1718
--suite) SUITE="${2:?--suite needs a value}"; shift 2 ;;
19+
--run-label) RUN_LABEL="${2:?--run-label needs a value}"; shift 2 ;;
1820
--output) OUTPUT="${2:?--output needs a value}"; shift 2 ;;
1921
*) printf 'ERROR: unknown aggregate option: %s\n' "$1" >&2; exit 2 ;;
2022
esac
@@ -23,11 +25,15 @@ done
2325
command -v python >/dev/null 2>&1 || { printf 'ERROR: python is required\n' >&2; exit 1; }
2426
[[ -d "$RESULTS_ROOT/evaluation" ]] || { printf 'ERROR: no evaluation directory under %s\n' "$RESULTS_ROOT" >&2; exit 1; }
2527

28+
label_args=()
29+
[[ -n "$RUN_LABEL" ]] && label_args=(--run-label "$RUN_LABEL")
30+
2631
python "$SCRIPT_DIR/build-site-data.py" \
2732
--repo-root "$REPO_ROOT" \
2833
--results-root "$RESULTS_ROOT" \
2934
--suite "$SUITE" \
3035
--output "$OUTPUT" \
36+
${label_args[@]+"${label_args[@]}"} \
3137
--generated-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
3238

3339
python "$SCRIPT_DIR/render-page.py" --data "$OUTPUT" --output "${OUTPUT%%.json}.html"

benchmarks/site/build-site-data.py

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,22 @@ def harness_dirty(root):
5353
return bool(proc.stdout.strip()) if proc.returncode == 0 else None
5454

5555

56-
def newest_reports(eval_dir):
57-
"""Latest evaluator report per condition, plus the ones it supersedes."""
56+
def newest_reports(eval_dir, run_label=None):
57+
"""Latest evaluator report per condition, plus the ones it supersedes.
58+
59+
A report is named only for the evaluator run that produced it, so reports
60+
from two benchmark runs of the same condition are indistinguishable and a
61+
page built across them silently mixes subsets. run_label restricts the scan
62+
to one batch, which is what makes a per-run page possible.
63+
"""
5864
latest, superseded = {}, []
5965
for path in sorted(glob.glob(os.path.join(eval_dir, '*.json'))):
6066
match = REPORT_NAME_RE.match(os.path.basename(path))
6167
if not match:
6268
continue
6369
cond, run_id = match.group('cond'), match.group('run_id')
70+
if run_label is not None and not run_id.startswith(run_label + '-'):
71+
continue
6472
previous = latest.get(cond)
6573
if previous is None or run_id > previous[0]:
6674
if previous is not None:
@@ -184,6 +192,8 @@ def cell_telemetry(cell_dir):
184192
'claude_output_tokens': 0,
185193
'claude_wall_seconds': 0,
186194
'codex_phases': 0,
195+
'codex_wall_seconds': 0,
196+
'codex_tokens': 0,
187197
'single_shot_attempts': None,
188198
'sandbox': None,
189199
}
@@ -220,9 +230,55 @@ def cell_telemetry(cell_dir):
220230
# Each Codex phase writes both a transcript and a stderr log.
221231
# Counting every .log double-counted every phase.
222232
out['codex_phases'] += 1
233+
elif name.startswith('codex-') and name.endswith('.stderr.log'):
234+
seconds, tokens = codex_effort(path)
235+
out['codex_wall_seconds'] += seconds
236+
out['codex_tokens'] += tokens
223237
return out
224238

225239

240+
CODEX_TS_RE = re.compile(r'^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d+)?Z')
241+
CODEX_TOKENS_RE = re.compile(r'^([\d,]+)$')
242+
243+
244+
def codex_effort(stderr_path):
245+
"""Wall seconds and token count for one Codex phase, from its own log.
246+
247+
The Codex CLI reports no cost, so tokens and elapsed time are the only
248+
honest units of effort available for it. Both are read out of the log the
249+
phase already wrote: the span between its first and last timestamp, and the
250+
figure it prints under "tokens used".
251+
"""
252+
first = last = None
253+
tokens = 0
254+
want_tokens = False
255+
try:
256+
with open(stderr_path, encoding='utf-8', errors='replace') as handle:
257+
for line in handle:
258+
match = CODEX_TS_RE.match(line)
259+
if match:
260+
if first is None:
261+
first = match.group(1)
262+
last = match.group(1)
263+
stripped = line.strip()
264+
if want_tokens:
265+
count = CODEX_TOKENS_RE.match(stripped)
266+
if count:
267+
tokens += int(count.group(1).replace(',', ''))
268+
want_tokens = False
269+
elif stripped == 'tokens used':
270+
want_tokens = True
271+
except OSError:
272+
return 0, 0
273+
seconds = 0
274+
if first and last and last >= first:
275+
import datetime
276+
fmt = '%Y-%m-%dT%H:%M:%S'
277+
seconds = int((datetime.datetime.strptime(last, fmt)
278+
- datetime.datetime.strptime(first, fmt)).total_seconds())
279+
return seconds, tokens
280+
281+
226282
def main():
227283
ap = argparse.ArgumentParser()
228284
ap.add_argument('--repo-root', required=True)
@@ -231,6 +287,8 @@ def main():
231287
ap.add_argument('--output', required=True)
232288
ap.add_argument('--generated-at', required=True,
233289
help='UTC timestamp supplied by the caller')
290+
ap.add_argument('--run-label', default=None,
291+
help='only read evaluator reports from this labelled batch')
234292
args = ap.parse_args()
235293

236294
root = os.path.abspath(args.repo_root)
@@ -250,7 +308,7 @@ def main():
250308
repos = {row['instance_id']: row['repo'] for row in subset['instances']}
251309
lock = read_json(os.path.join(code_dir, 'external-sources.lock.json'))
252310

253-
latest, superseded = newest_reports(eval_dir)
311+
latest, superseded = newest_reports(eval_dir, args.run_label)
254312
cells = index_cells(runs_root)
255313
attempts_index = index_attempts(runs_root)
256314

@@ -276,6 +334,8 @@ def main():
276334
'claude_output_tokens': 0,
277335
'claude_wall_seconds': 0,
278336
'codex_phases': 0,
337+
'codex_wall_seconds': 0,
338+
'codex_tokens': 0,
279339
'cells_linked': 0,
280340
'sandbox_modes': [],
281341
'single_shot_attempts': [],
@@ -334,6 +394,8 @@ def main():
334394
row['telemetry']['claude_output_tokens'] += telemetry['claude_output_tokens']
335395
row['telemetry']['claude_wall_seconds'] += telemetry['claude_wall_seconds']
336396
row['telemetry']['codex_phases'] += telemetry['codex_phases']
397+
row['telemetry']['codex_wall_seconds'] += telemetry['codex_wall_seconds']
398+
row['telemetry']['codex_tokens'] += telemetry['codex_tokens']
337399
if telemetry['sandbox']:
338400
sandboxes.add(telemetry['sandbox'])
339401
if telemetry['single_shot_attempts'] is not None:
@@ -354,6 +416,35 @@ def main():
354416
if attempt else None),
355417
})
356418
row['telemetry']['claude_cost_usd'] = round(row['telemetry']['claude_cost_usd'], 4)
419+
420+
# A task the arm actually ran, whether or not it yielded a patch. The
421+
# distinction matters: a task that ran and produced nothing is a zero,
422+
# but a task that was never reached is not a result at all. Scoring an
423+
# unreached task as zero understates an interrupted run as badly as
424+
# dropping a failed one would flatter a finished one.
425+
ran = [task for task in row['per_task']
426+
if task['status'] in ('resolved', 'unresolved', 'no-patch')]
427+
row['attempted_count'] = len(ran)
428+
row['complete'] = len(ran) == len(instances)
429+
430+
# Effort per arm. Codex, GLM and Kimi report no cost, so an arm that
431+
# uses them has a dollar figure covering only its Claude phases; saying
432+
# so is the difference between a partial figure and a wrong one.
433+
telemetry = row['telemetry']
434+
telemetry['total_wall_seconds'] = (telemetry['claude_wall_seconds']
435+
+ telemetry['codex_wall_seconds'])
436+
telemetry['cost_is_complete'] = (
437+
telemetry['codex_phases'] == 0
438+
and not telemetry['single_shot_attempts'])
439+
resolved = row['resolved'] or 0
440+
# No reported cost is not zero cost: Codex, GLM and Kimi bill elsewhere.
441+
# Emitting 0.0 here would render as "$0.00 per resolved task", which
442+
# reads as free rather than as unmeasured.
443+
telemetry['cost_per_resolved'] = (
444+
round(telemetry['claude_cost_usd'] / resolved, 4)
445+
if resolved and telemetry['claude_cost_usd'] else None)
446+
telemetry['seconds_per_resolved'] = (
447+
round(telemetry['total_wall_seconds'] / resolved) if resolved else None)
357448
rows.append(row)
358449

359450
payload = {

0 commit comments

Comments
 (0)