Skip to content

Commit 02ddfe3

Browse files
varunursekarclaude
andcommitted
Capture per-trial token/latency metrics; enable trusted attribution on officeqa
Tokens (not dollars) are the reported unit: with a fixed target model, dollars are a downstream linear function of the (input, cached, output) triple, so they are not stored. Turn on the gateway's per-trial thread attribution for officeqa (request_log_attribution: true) so per_trial_tokens.py attributes gateway tokens to trials via stamped thread_ids instead of content-matching. Extend the aggregator with a multi-session grid rollup (flat CSV keyed by run/eval/task), per-trial wall time, and a gateway-vs-attributed-vs-agent-reported reconciliation. Document the token surfaces in CONFIGURATION.md and the run-benchmark skill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 899048c commit 02ddfe3

5 files changed

Lines changed: 289 additions & 34 deletions

File tree

harness-engineering-bench/CONFIGURATION.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ benchmark can be checked against the others at a glance.
2828
budget-blind ablation: enforcement is unchanged but all budget signal is
2929
hidden from the agent, including a redaction of `inference_*` metrics from
3030
agent-facing receipts and context (latency metrics stay visible).
31+
- **Per-trial token/latency accounting**: the trusted gateway meters each
32+
evaluation's usage as an input/cached/output/total split
33+
(`inference_input_tokens`, `inference_cached_input_tokens`,
34+
`inference_output_tokens`, `inference_total_tokens`, `inference_requests`) in
35+
`reward_metrics`; each trial's `result.json` also carries agent-self-reported
36+
tokens (`agent_reported_*`) and its wall window. With
37+
`inference_gateway.request_log_attribution: true` the gateway stamps every
38+
request-log record with a `thread_id`, so `scripts/per_trial_tokens.py`
39+
attributes gateway tokens to individual trials (trusted, versus a content-match
40+
fallback when off) and rolls a run — or a grid of runs — up to a flat CSV.
41+
Enabled on officeqa; extended across the suite as the grid rolls out. Because
42+
each scope's target model is fixed, **dollar cost is a linear function of the
43+
(input, cached, output) token triple** with a per-model rate vector — computed
44+
downstream, deliberately not stored anywhere in the run.
3145
- **Target model**: `fireworks_ai/deepseek-v4-flash` by default (see the
3246
per-benchmark table — a benchmark may pin a different evaluated model), fixed
3347
per run by the gateway's evaluation scope (the `evaluation.allowed_models`

harness-engineering-bench/officeqa/baseline/build.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ wandb:
108108
inference_gateway:
109109
upstream_api_key_env: OPENAI_API_KEY
110110
upstream_base_url_env: OPENAI_BASE_URL
111+
# Stamp request-log records with a thread_id so per_trial_tokens.py can attribute
112+
# gateway token usage to individual trials (trusted, vs. content-matching).
113+
request_log_attribution: true
111114
producer:
112115
allowed_models: ["${optimizer_model:-openai/gpt-5.4}"]
113116
max_concurrency: 8

harness-engineering-bench/scripts/per_trial_tokens.py

Lines changed: 169 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,20 @@
2424
with empty bodies are unrecoverable and land in the residual. Enable
2525
attribution at build time for complete coverage.
2626
27-
Usage: per_trial_tokens.py SESSION_DIR [--requests-dir DIR] [--tasks-dir DIR]
28-
[--json]
27+
Pass one session dir for a single run, or several to roll up a grid; ``--csv``
28+
writes a flat per-trial table across all of them (one row per
29+
run/evaluation/task) for spreadsheet analysis. Tokens are the reported unit;
30+
dollars are a downstream linear function of the (input, cached, output) triple
31+
with a per-model rate vector, so they are deliberately not computed here.
32+
33+
Usage: per_trial_tokens.py SESSION_DIR [SESSION_DIR ...] [--requests-dir DIR]
34+
[--tasks-dir DIR] [--json] [--csv OUT.csv]
2935
"""
3036

3137
from __future__ import annotations
3238

3339
import argparse
40+
import csv
3441
import json
3542
import re
3643
import sys
@@ -209,30 +216,27 @@ def assign_thread(
209216
return None
210217

211218

212-
def main() -> int:
213-
parser = argparse.ArgumentParser(description=__doc__)
214-
parser.add_argument("session", type=Path)
215-
parser.add_argument("--requests-dir", type=Path, default=None)
216-
parser.add_argument("--tasks-dir", type=Path, default=None)
217-
parser.add_argument("--json", action="store_true", dest="as_json")
218-
arguments = parser.parse_args()
219+
def trial_wall_seconds(trial: dict) -> float | None:
220+
started, finished = trial.get("started"), trial.get("finished")
221+
if started is None or finished is None:
222+
return None
223+
return round((finished - started).total_seconds(), 1)
219224

220-
session = arguments.session
221-
requests_dir = (
222-
arguments.requests_dir or session / "artifacts" / "inference" / "requests"
223-
)
224-
if not requests_dir.is_dir():
225-
print(f"no request log at {requests_dir}", file=sys.stderr)
226-
return 1
227225

226+
def analyze_session(
227+
session: Path, requests_dir: Path, task_texts: list[tuple[str, str]]
228+
) -> dict:
229+
"""Per evaluation id: per-trial token triples + latency + wall, with the
230+
trusted gateway envelope, the attributed sum, the independent agent-reported
231+
sum (for reconciliation), and the unattributed residual."""
228232
trials_by_evaluation = load_trials(session)
229233
threads_by_evaluation = load_threads(requests_dir)
230-
task_texts = load_task_texts(arguments.tasks_dir)
231234

232-
report = {}
235+
report: dict[str, dict] = {}
233236
for evaluation_id, threads in sorted(threads_by_evaluation.items()):
234237
trials = trials_by_evaluation.get(evaluation_id, [])
235238
per_trial: dict[str, dict] = {}
239+
seen: dict[str, set] = defaultdict(set)
236240
residual = {"requests": 0, **{key: 0 for key in TOKEN_KEYS}}
237241
for thread in threads.values():
238242
trial = assign_thread(thread, trials, task_texts)
@@ -241,11 +245,13 @@ def main() -> int:
241245
for key in TOKEN_KEYS:
242246
residual[key] += thread[key]
243247
continue
248+
task_name = trial["task_name"]
244249
entry = per_trial.setdefault(
245-
trial["task_name"],
250+
task_name,
246251
{
247252
"requests": 0,
248253
"latency_ms": 0.0,
254+
"wall_s": 0.0,
249255
"agent_reported": trial["agent_reported"],
250256
**{key: 0 for key in TOKEN_KEYS},
251257
},
@@ -254,53 +260,182 @@ def main() -> int:
254260
entry["latency_ms"] += thread["latency_ms"]
255261
for key in TOKEN_KEYS:
256262
entry[key] += thread[key]
257-
total = {
263+
# Wall is a per-trial property, so add each contributing trial once:
264+
# repeated passes of one task sum, but a trial's many threads don't
265+
# double-count its wall.
266+
trial_name = trial.get("trial_name")
267+
if trial_name not in seen[task_name]:
268+
seen[task_name].add(trial_name)
269+
wall = trial_wall_seconds(trial)
270+
if wall is not None:
271+
entry["wall_s"] = round(entry["wall_s"] + wall, 1)
272+
gateway_total = {
258273
key: sum(thread[key] for thread in threads.values()) for key in TOKEN_KEYS
259274
}
260275
attributed = {
261276
key: sum(entry[key] for entry in per_trial.values()) for key in TOKEN_KEYS
262277
}
278+
agent_reported_total = {
279+
key: sum(
280+
entry["agent_reported"].get(key, 0) for entry in per_trial.values()
281+
)
282+
for key in ("n_input_tokens", "n_cache_tokens", "n_output_tokens")
283+
}
263284
report[evaluation_id] = {
264285
"trials": per_trial,
265-
"gateway_total": total,
286+
"gateway_total": gateway_total,
266287
"attributed": attributed,
288+
"agent_reported_total": agent_reported_total,
267289
"residual": residual,
268290
"coverage_pct": (
269-
round(100.0 * attributed["total_tokens"] / total["total_tokens"], 1)
270-
if total["total_tokens"]
291+
round(
292+
100.0 * attributed["total_tokens"] / gateway_total["total_tokens"],
293+
1,
294+
)
295+
if gateway_total["total_tokens"]
271296
else None
272297
),
273298
}
299+
return report
274300

275-
if arguments.as_json:
276-
print(json.dumps(report, indent=1, default=str))
277-
return 0
278301

302+
_CSV_FIELDS = [
303+
"run",
304+
"evaluation",
305+
"task",
306+
"requests",
307+
"input_tokens",
308+
"cached_input_tokens",
309+
"output_tokens",
310+
"total_tokens",
311+
"latency_ms",
312+
"wall_s",
313+
"agent_reported_input_tokens",
314+
"agent_reported_cache_tokens",
315+
"agent_reported_output_tokens",
316+
]
317+
318+
319+
def csv_rows(run_label: str, report: dict):
320+
"""Flat per-trial rows (plus one residual row per evaluation) for the grid."""
279321
for evaluation_id, data in report.items():
280-
print(f"\n=== evaluation {evaluation_id} ===")
281-
header = f"{'task':<44} {'req':>5} {'input':>10} {'cached':>10} {'output':>8} {'agent-reported in/cache/out':>28}"
282-
print(header)
322+
for task_name, entry in sorted(data["trials"].items()):
323+
reported = entry["agent_reported"]
324+
yield {
325+
"run": run_label,
326+
"evaluation": evaluation_id,
327+
"task": task_name,
328+
"requests": entry["requests"],
329+
"input_tokens": entry["input_tokens"],
330+
"cached_input_tokens": entry["cached_input_tokens"],
331+
"output_tokens": entry["output_tokens"],
332+
"total_tokens": entry["total_tokens"],
333+
"latency_ms": round(entry["latency_ms"], 1),
334+
"wall_s": entry["wall_s"],
335+
"agent_reported_input_tokens": reported.get("n_input_tokens"),
336+
"agent_reported_cache_tokens": reported.get("n_cache_tokens"),
337+
"agent_reported_output_tokens": reported.get("n_output_tokens"),
338+
}
339+
residual = data["residual"]
340+
if residual["requests"]:
341+
yield {
342+
"run": run_label,
343+
"evaluation": evaluation_id,
344+
"task": "(unattributed)",
345+
"requests": residual["requests"],
346+
"input_tokens": residual["input_tokens"],
347+
"cached_input_tokens": residual["cached_input_tokens"],
348+
"output_tokens": residual["output_tokens"],
349+
"total_tokens": residual["total_tokens"],
350+
"latency_ms": "",
351+
"wall_s": "",
352+
"agent_reported_input_tokens": "",
353+
"agent_reported_cache_tokens": "",
354+
"agent_reported_output_tokens": "",
355+
}
356+
357+
358+
def print_report(run_label: str, report: dict) -> None:
359+
for evaluation_id, data in report.items():
360+
print(f"\n=== {run_label} / evaluation {evaluation_id} ===")
361+
print(
362+
f"{'task':<40} {'req':>5} {'input':>10} {'cached':>10} "
363+
f"{'output':>8} {'wall_s':>8} {'agent in/cache/out':>24}"
364+
)
283365
for task_name, entry in sorted(data["trials"].items()):
284366
reported = entry["agent_reported"]
285367
reported_text = "/".join(
286368
str(reported.get(key, "-"))
287369
for key in ("n_input_tokens", "n_cache_tokens", "n_output_tokens")
288370
)
289371
print(
290-
f"{str(task_name)[:44]:<44} {entry['requests']:>5} "
372+
f"{str(task_name)[:40]:<40} {entry['requests']:>5} "
291373
f"{entry['input_tokens']:>10} {entry['cached_input_tokens']:>10} "
292-
f"{entry['output_tokens']:>8} {reported_text:>28}"
374+
f"{entry['output_tokens']:>8} {entry['wall_s']:>8} {reported_text:>24}"
293375
)
294376
residual = data["residual"]
295377
print(
296-
f"{'(unattributed)':<44} {residual['requests']:>5} "
378+
f"{'(unattributed)':<40} {residual['requests']:>5} "
297379
f"{residual['input_tokens']:>10} {residual['cached_input_tokens']:>10} "
298380
f"{residual['output_tokens']:>8}"
299381
)
382+
gateway, attributed = data["gateway_total"], data["attributed"]
383+
agent = data["agent_reported_total"]
300384
print(
301-
f"coverage: {data['coverage_pct']}% of "
302-
f"{data['gateway_total']['total_tokens']} gateway-metered tokens attributed"
385+
f"coverage: {data['coverage_pct']}% of {gateway['total_tokens']} "
386+
f"gateway-metered tokens attributed to trials"
303387
)
388+
# Reconcile the trusted envelope against the two independent token sources.
389+
print(
390+
f" input tokens gateway {gateway['input_tokens']} | "
391+
f"attributed {attributed['input_tokens']} | "
392+
f"agent-reported {agent['n_input_tokens']}"
393+
)
394+
395+
396+
def main() -> int:
397+
parser = argparse.ArgumentParser(description=__doc__)
398+
parser.add_argument("sessions", type=Path, nargs="+", help="one or more session dirs")
399+
parser.add_argument(
400+
"--requests-dir", type=Path, default=None, help="single session only"
401+
)
402+
parser.add_argument("--tasks-dir", type=Path, default=None)
403+
parser.add_argument("--json", action="store_true", dest="as_json")
404+
parser.add_argument("--csv", type=Path, default=None, help="write a flat per-trial CSV")
405+
arguments = parser.parse_args()
406+
407+
task_texts = load_task_texts(arguments.tasks_dir)
408+
grid: dict[str, dict] = {}
409+
for session in arguments.sessions:
410+
if arguments.requests_dir is not None and len(arguments.sessions) == 1:
411+
requests_dir = arguments.requests_dir
412+
else:
413+
requests_dir = session / "artifacts" / "inference" / "requests"
414+
if not requests_dir.is_dir():
415+
print(f"no request log at {requests_dir}", file=sys.stderr)
416+
continue
417+
grid[session.name] = analyze_session(session, requests_dir, task_texts)
418+
419+
if not grid:
420+
return 1
421+
422+
if arguments.csv is not None:
423+
with open(arguments.csv, "w", newline="", encoding="utf-8") as handle:
424+
writer = csv.DictWriter(handle, fieldnames=_CSV_FIELDS)
425+
writer.writeheader()
426+
for run_label, report in grid.items():
427+
writer.writerows(csv_rows(run_label, report))
428+
print(f"wrote {arguments.csv}", file=sys.stderr)
429+
430+
if arguments.as_json:
431+
# One session keeps the original flat {evaluation: ...} schema; several
432+
# nest under their run label.
433+
payload = grid[arguments.sessions[0].name] if len(grid) == 1 else grid
434+
print(json.dumps(payload, indent=1, default=str))
435+
return 0
436+
437+
for run_label, report in grid.items():
438+
print_report(run_label, report)
304439
return 0
305440

306441

0 commit comments

Comments
 (0)