pluginbench 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
@@ -0,0 +1,518 @@
1
+ """A read-only projection of one run directory, for `pluginbench status` and its dashboard.
2
+
3
+ It reads the ledger (read-only), evidence receipts, events.jsonl and the report, and never
4
+ writes anything. Runs from before the ledger fall back to evidence/run.json.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shlex
11
+ import sqlite3
12
+ import subprocess
13
+ from collections import Counter
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from pluginbench import run_lock, trial_containers
19
+ from pluginbench.evaluation_report import correctness_state
20
+ from pluginbench.ledger import LEDGER_NAME, Ledger
21
+
22
+ # Bump on any change that removes or renames a field; adding a field keeps the version.
23
+ SCHEMA_VERSION = 1
24
+
25
+ TOKEN_FIELDS = (
26
+ "input_tokens",
27
+ "cached_input_tokens",
28
+ "output_tokens",
29
+ "reasoning_output_tokens",
30
+ "total_tokens",
31
+ )
32
+ # Native attempts have one stage; its receipts live under attempts/<id>/handoff/.
33
+ _STAGE = "handoff"
34
+ # sha256 of no bytes: the verifier graded an empty patch.
35
+ _EMPTY_PATCH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
36
+
37
+
38
+ def _arm(name: str) -> str:
39
+ """Runs before the rename call the vanilla arm `baseline`."""
40
+ return "vanilla" if name == "baseline" else name
41
+
42
+
43
+ def _read(path: Path) -> Any:
44
+ return json.loads(path.read_text()) if path.is_file() else None
45
+
46
+
47
+ def _usage(execution: dict[str, Any] | None) -> tuple[dict[str, int] | None, str]:
48
+ """Token usage and how far to trust it: exact, lower_bound, or unknown."""
49
+ if execution is None:
50
+ return None, "unknown"
51
+ usage, status = execution.get("usage"), "exact"
52
+ if not isinstance(usage, dict):
53
+ usage = (execution.get("usage_accounting") or {}).get("observed_usage_lower_bound")
54
+ status = "lower_bound"
55
+ if not isinstance(usage, dict):
56
+ return None, "unknown"
57
+ if isinstance(usage.get("total_tokens"), int):
58
+ return usage, status
59
+ # Receipts from runs without subagents carry no total. Codex counts total as input plus
60
+ # output; cached input and reasoning output are subsets of those.
61
+ parts = (usage.get("input_tokens"), usage.get("output_tokens"))
62
+ if all(isinstance(part, int) for part in parts):
63
+ return {**usage, "total_tokens": sum(part for part in parts if part)}, status
64
+ return usage, "lower_bound"
65
+
66
+
67
+ def _add(total: dict[str, int], usage: dict[str, int] | None) -> None:
68
+ for field in TOKEN_FIELDS:
69
+ value = (usage or {}).get(field)
70
+ if isinstance(value, int):
71
+ total[field] = total.get(field, 0) + value
72
+
73
+
74
+ def _tokens(rows: list[dict[str, Any]]) -> dict[str, Any]:
75
+ """Summed usage; a lower bound when any started attempt lacks exact final usage."""
76
+ total: dict[str, int] = {}
77
+ for row in rows:
78
+ _add(total, row["tokens"])
79
+ return {
80
+ **{field: total.get(field, 0) for field in TOKEN_FIELDS},
81
+ "lower_bound": any(row["tokens_status"] in ("lower_bound", "unknown") for row in rows),
82
+ }
83
+
84
+
85
+ def _outcome(handoff: Path) -> tuple[str, str | None]:
86
+ """pass, overlap_pass, fail, empty, or unknown with the verifier's reason, from the receipts."""
87
+ verifier = _read(handoff / "verifier-result.json") or {}
88
+ if verifier.get("patch_sha256") == _EMPTY_PATCH:
89
+ return "empty", None
90
+ behavior = ((_read(handoff / "correctness.json") or {}).get("checks") or {}).get("behavior")
91
+ if behavior is True:
92
+ return "pass", None
93
+ if behavior is False:
94
+ return "fail", None
95
+ outcome = correctness_state("unknown", verifier)
96
+ if outcome != "unknown":
97
+ return outcome, None
98
+ # An attempt with no graded submission keeps why: a failed call names its error here.
99
+ failure = (_read(handoff / "unsupported-submission.json") or {}).get("error")
100
+ if isinstance(failure, dict): # Harbor's exception_info for the agent.
101
+ failure = f"{failure.get('exception_type')}: {failure.get('exception_message')}"
102
+ return "unknown", verifier.get("reason") or failure or "no correctness receipt"
103
+
104
+
105
+ def _slots(evidence: Path, run: dict[str, Any] | None) -> tuple[list[dict[str, Any]], str]:
106
+ """Live attempts in slot order, and where their states came from."""
107
+ path = evidence / LEDGER_NAME
108
+ if path.is_file():
109
+ try:
110
+ ledger = Ledger.read(path)
111
+ except (sqlite3.Error, ValueError):
112
+ pass # Busy, or a hot journal a reader cannot roll back: use run.json instead.
113
+ else:
114
+ try:
115
+ rows = ledger.attempts()
116
+ except sqlite3.Error:
117
+ rows = None
118
+ finally:
119
+ ledger.close()
120
+ if rows is not None:
121
+ live = [row for row in rows if row["state"] != "discarded-interrupted"]
122
+ return live, "ledger"
123
+ plan = _read(evidence / "plan.json")
124
+ if plan is None or run is None:
125
+ return [], "none"
126
+ states = run.get("attempt_states", {})
127
+ return [
128
+ {**entry, "slot": slot, "state": states.get(entry["id"], "pending")}
129
+ for slot, entry in enumerate(plan["entries"])
130
+ ], "run.json"
131
+
132
+
133
+ def _measures(report: dict[str, Any] | None) -> tuple[dict[str, dict[str, Any]], dict[str, Any]]:
134
+ """Per-attempt observations and per-task reference values from report/results.json."""
135
+ if report is None:
136
+ return {}, {}
137
+ by_attempt: dict[str, dict[str, Any]] = {}
138
+ for row in report.get("observations", []):
139
+ by_attempt.setdefault(row["attempt_id"], {})[row["metric"]] = {
140
+ key: row.get(key) for key in ("status", "value", "unit", "reason")
141
+ }
142
+ references: dict[str, dict[str, Any]] = {}
143
+ for row in report.get("reference_values", []):
144
+ references.setdefault(row["task"], {})[row["metric"]] = {
145
+ key: row.get(key) for key in ("status", "value", "reason")
146
+ }
147
+ return by_attempt, references
148
+
149
+
150
+ def _revisions(directory: Path, state_name: str, selected: object) -> list[dict[str, Any]]:
151
+ rows = []
152
+ for path in sorted(directory.glob(f"*/{state_name}")):
153
+ state = _read(path) or {}
154
+ row: dict[str, Any] = {
155
+ "revision": path.parent.name,
156
+ "status": state.get("status"),
157
+ "selected": path.parent.name == selected,
158
+ }
159
+ if "assigned" in state:
160
+ row["assigned"] = state["assigned"]
161
+ row["reviewed"] = sum(
162
+ record.get("status") == "reviewed" for record in state.get("records", [])
163
+ )
164
+ rows.append(row)
165
+ return rows
166
+
167
+
168
+ def _events(root: Path) -> list[dict[str, Any]]:
169
+ path = root / "events.jsonl"
170
+ if not path.is_file():
171
+ return []
172
+ events = []
173
+ for line in path.read_text().splitlines():
174
+ try:
175
+ events.append(json.loads(line))
176
+ except json.JSONDecodeError:
177
+ continue # A line the writer is still appending; the next poll reads it whole.
178
+ return events
179
+
180
+
181
+ def _error(events: list[dict[str, Any]], run: dict[str, Any] | None) -> str | None:
182
+ """Why the run failed. A stop is not a failure; runs without events only have run.json."""
183
+ if events:
184
+ return events[-1].get("error") if events[-1].get("event") == "failed" else None
185
+ return (run or {}).get("error")
186
+
187
+
188
+ def _heartbeat(root: Path, evidence: Path, now: datetime) -> dict[str, Any] | None:
189
+ """The most recent write to any file the run updates as it works."""
190
+ times = [
191
+ path.stat().st_mtime
192
+ for path in (
193
+ root / "events.jsonl",
194
+ root / "run.log",
195
+ evidence / LEDGER_NAME,
196
+ evidence / "run.json",
197
+ )
198
+ if path.is_file()
199
+ ]
200
+ if not times:
201
+ return None
202
+ at = datetime.fromtimestamp(max(times), UTC)
203
+ return {"at": at.isoformat(), "age_seconds": round((now - at).total_seconds())}
204
+
205
+
206
+ def _pending(root: Path, selection: dict[str, Any], run: dict[str, Any] | None) -> str | None:
207
+ """The first phase the run has not finished, or None when nothing is left."""
208
+ if run is None:
209
+ return "starting"
210
+ if run.get("status") != "complete":
211
+ return "coding"
212
+ if selection.get("measurement_required") and selection.get("measurement_revision") is None:
213
+ return "measuring"
214
+ if selection.get("reviewer") and selection.get("review_revision") is None:
215
+ return "reviewing"
216
+ if not (root / "report/report.html").is_file():
217
+ return "reporting"
218
+ return None
219
+
220
+
221
+ def _phase(
222
+ *,
223
+ pending: str | None,
224
+ run: dict[str, Any] | None,
225
+ active: bool,
226
+ events: list[dict[str, Any]],
227
+ ) -> str:
228
+ started = [event for event in events if event.get("event") == "started"]
229
+ if active and started and started[-1].get("command") == "review":
230
+ return "reviewing"
231
+ if active and pending:
232
+ return pending
233
+ # The latest operation, such as a re-review of a finished run, may have stopped or failed.
234
+ if events and events[-1].get("event") in ("stopped", "failed"):
235
+ return str(events[-1]["event"])
236
+ # A worker records how it ended before it releases the run. One that was killed, or lost
237
+ # with its host, leaves `started` or `progress` as the last event.
238
+ if not active and events and events[-1].get("event") in ("started", "progress"):
239
+ return "interrupted"
240
+ if pending is None:
241
+ return "complete"
242
+ return "not_started" if run is None else "stopped"
243
+
244
+
245
+ def _left_running(evidence: Path) -> dict[str, Any]:
246
+ """Containers of this run's trials still running with no live owner, or why none was read."""
247
+ try:
248
+ # Status only reports, so it uses the selected Docker context: the engine a coding
249
+ # attempt picks. Pinning the saved runtime would hide them once the Docker CLI changed.
250
+ containers = trial_containers.running(evidence, expected_runtime=None)
251
+ except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
252
+ return {"checked": False, "error": str(exc), "containers": [], "stop_command": None}
253
+ return {
254
+ "checked": True,
255
+ "error": None,
256
+ "containers": containers,
257
+ "stop_command": shlex.join(["docker", "stop", *(row["id"] for row in containers)])
258
+ if containers
259
+ else None,
260
+ }
261
+
262
+
263
+ def run_status(root: Path, *, now: datetime | None = None) -> dict[str, Any]:
264
+ """Everything `status` shows, as the documented JSON object; reads only."""
265
+ root = root.expanduser().absolute()
266
+ selection = _read(root / "evaluation.json")
267
+ if not isinstance(selection, dict):
268
+ raise ValueError(
269
+ f"{root} has no evaluation.json, so it is not a prepared run; give the run "
270
+ "directory that `pluginbench run` printed"
271
+ )
272
+ now = now or datetime.now(UTC)
273
+ evidence = root / "evidence"
274
+ run = _read(evidence / "run.json")
275
+ try:
276
+ owner = run_lock.owner(root)
277
+ except ValueError:
278
+ owner = {} # Held, but its record is unreadable mid-write.
279
+ active = owner is not None
280
+ events = _events(root)
281
+ pending = _pending(root, selection, run)
282
+ slots, source = _slots(evidence, run)
283
+ plan = _read(evidence / "plan.json") or {}
284
+ decisions = plan.get("baseline_cache") or {}
285
+ origins = (run or {}).get("baseline_origin", {})
286
+ report = _read(root / "report/results.json")
287
+ measured, references = _measures(report)
288
+
289
+ attempts = []
290
+ for row in slots:
291
+ handoff = evidence / "attempts" / row["id"] / _STAGE
292
+ execution = _read(handoff / "execution.json")
293
+ tokens, tokens_status = _usage(execution)
294
+ if row["state"] == "pending":
295
+ tokens_status = "not_started"
296
+ elif row["state"] in ("running", "interrupted") and tokens_status == "exact":
297
+ tokens_status = "lower_bound" # Usage saved so far by an unfinished attempt.
298
+ outcome, reason = _outcome(handoff) if row["state"] == "complete" else (None, None)
299
+ elapsed = (execution or {}).get("elapsed_seconds")
300
+ attempts.append(
301
+ {
302
+ "id": row["id"],
303
+ "slot": row["slot"],
304
+ "task": row["task_id"],
305
+ "arm": _arm(row["arm"]),
306
+ "repeat": row["attempt"],
307
+ "state": row["state"],
308
+ "outcome": outcome,
309
+ "outcome_reason": reason,
310
+ "reused": (origins.get(row["id"]) or {}).get("status") == "hit",
311
+ "tokens": tokens,
312
+ "tokens_status": tokens_status,
313
+ "minutes": round(elapsed / 60, 1) if isinstance(elapsed, int | float) else None,
314
+ "measures": measured.get(row["id"], {}),
315
+ }
316
+ )
317
+
318
+ discarded = []
319
+ for path in sorted((evidence / "discarded").glob("*.json")):
320
+ receipt = json.loads(path.read_text())
321
+ stage = (receipt.get("stages") or {}).get(_STAGE) or {}
322
+ reference = stage.get("execution")
323
+ execution = _read(evidence / reference["path"]) if isinstance(reference, dict) else None
324
+ tokens, tokens_status = _usage(execution)
325
+ discarded.append(
326
+ {
327
+ "id": receipt["id"],
328
+ "slot": receipt.get("slot"),
329
+ "task": receipt["task_id"],
330
+ "arm": _arm(receipt["arm"]),
331
+ "repeat": receipt["attempt"],
332
+ "replaced_by": receipt.get("replaced_by"),
333
+ "tokens": tokens,
334
+ # Usage the attempt saved before it was interrupted is at most a floor.
335
+ "tokens_status": "lower_bound" if tokens_status == "exact" else tokens_status,
336
+ }
337
+ )
338
+
339
+ arms: dict[str, dict[str, Any]] = {}
340
+ names = dict.fromkeys(attempt["arm"] for attempt in attempts)
341
+ for name in sorted(names, key=lambda arm: arm != "vanilla"):
342
+ rows = [attempt for attempt in attempts if attempt["arm"] == name]
343
+ arms[name] = {
344
+ "attempts": len(rows),
345
+ "states": dict(Counter(row["state"] for row in rows)),
346
+ "outcomes": dict(Counter(row["outcome"] for row in rows if row["outcome"])),
347
+ "reused": sum(row["reused"] for row in rows),
348
+ "tokens": _tokens(rows),
349
+ }
350
+ log = root / "run.log"
351
+ return {
352
+ "schema_version": SCHEMA_VERSION,
353
+ "run": str(root),
354
+ "name": selection.get("name"),
355
+ "phase": _phase(pending=pending, run=run, active=active, events=events),
356
+ # Whether the saved state can continue is `run`'s decision: it checks first and refuses
357
+ # before any model call. Status only names the command for an unfinished, unowned run.
358
+ "resume_command": shlex.join(["pluginbench", "run", str(root)])
359
+ if pending and not active
360
+ else None,
361
+ "active": active,
362
+ "owner": {key: owner.get(key) for key in ("pid", "pgid", "started_at")} if owner else None,
363
+ "heartbeat": _heartbeat(root, evidence, now),
364
+ "last_event": events[-1] if events else None,
365
+ "error": _error(events, run),
366
+ "states_source": source,
367
+ "attempts_total": len(attempts),
368
+ "states": dict(Counter(attempt["state"] for attempt in attempts)),
369
+ "outcomes": dict(Counter(attempt["outcome"] for attempt in attempts if attempt["outcome"])),
370
+ "tokens": _tokens([attempt for attempt in attempts if not attempt["reused"]] + discarded),
371
+ "reuse": {
372
+ "vanilla_attempts": sum(attempt["arm"] == "vanilla" for attempt in attempts),
373
+ "planned_hits": sum(
374
+ (decision or {}).get("status") == "hit" for decision in decisions.values()
375
+ ),
376
+ "reused": sum(attempt["reused"] for attempt in attempts),
377
+ },
378
+ "discarded": discarded,
379
+ "arms": arms,
380
+ "measures": {
381
+ "selected": selection.get("measurements", []),
382
+ "measurement_revisions": _revisions(
383
+ evidence / "observer-revisions",
384
+ "revision.json",
385
+ selection.get("measurement_revision"),
386
+ ),
387
+ "review_revisions": _revisions(
388
+ evidence / "review-revisions",
389
+ "review-state.json",
390
+ selection.get("review_revision"),
391
+ ),
392
+ "references": references,
393
+ # Saved by check; a run saved before that list existed has none.
394
+ "not_measured": selection.get("not_measured", []),
395
+ },
396
+ "report": str(root / "report/report.html")
397
+ if (root / "report/report.html").is_file()
398
+ else None,
399
+ "log": str(log) if log.is_file() else None,
400
+ # Only the run.lock holder stops them, so they are named while no process owns the run.
401
+ "left_running": None if active else _left_running(evidence),
402
+ "attempts": attempts,
403
+ }
404
+
405
+
406
+ def _count(value: int) -> str:
407
+ for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
408
+ if value >= size:
409
+ return f"{value / size:.1f}{suffix}"
410
+ return str(value)
411
+
412
+
413
+ def _age(seconds: int) -> str:
414
+ if seconds < 120:
415
+ return f"{seconds}s"
416
+ if seconds < 7200:
417
+ return f"{seconds // 60}m"
418
+ return f"{seconds // 3600}h"
419
+
420
+
421
+ _RESULTS = ("pass", "overlap_pass", "fail", "unknown", "empty", "running", "interrupted", "pending")
422
+
423
+
424
+ def _shown_state(state: str, active: bool) -> str:
425
+ """Work recorded as in progress in a run no one owns is stale, so it shows as interrupted."""
426
+ return "interrupted" if state in ("running", "collecting") and not active else state
427
+
428
+
429
+ def _result(row: dict[str, Any], active: bool) -> str:
430
+ """An attempt's outcome, or the state shown for it."""
431
+ return str(row["outcome"] or _shown_state(row["state"], active))
432
+
433
+
434
+ def format_status(status: dict[str, Any]) -> str:
435
+ """The terminal view of run_status()."""
436
+ lines = [f"Run: {status['run']}" + (f" ({status['name']})" if status["name"] else "")]
437
+ phase = f"Phase: {status['phase']}"
438
+ if status["phase"] == "interrupted":
439
+ phase += " (no exit record)"
440
+ if status["owner"]:
441
+ phase += f" process {status['owner']['pid']} since {status['owner']['started_at']}"
442
+ if status["heartbeat"]:
443
+ phase += f" last activity {_age(status['heartbeat']['age_seconds'])} ago"
444
+ lines.append(phase)
445
+ if status["error"]:
446
+ lines.append(f"Error: {status['error']}")
447
+ if status["resume_command"]:
448
+ lines.append(f"Resume: {status['resume_command']}")
449
+ left = status["left_running"]
450
+ if left and not left["checked"]:
451
+ lines.append(f"Containers: could not check for ones left running: {left['error']}")
452
+ elif left and left["containers"]:
453
+ projects = ", ".join(dict.fromkeys(row["project"] for row in left["containers"]))
454
+ lines.append(f"Containers left running with no live owner: {projects}")
455
+ lines.append(f"Stop them: {left['stop_command']}")
456
+ states: Counter[str] = Counter()
457
+ for state, count in status["states"].items():
458
+ states[_shown_state(state, status["active"])] += count
459
+ summary = ", ".join(f"{count} {state}" for state, count in sorted(states.items()))
460
+ lines.append(
461
+ f"Attempts: {status['attempts_total']} slots" + (f": {summary}" if summary else "")
462
+ )
463
+ if status["discarded"]:
464
+ lines.append(f"Discarded: {len(status['discarded'])} interrupted attempts, replaced")
465
+ reuse = status["reuse"]
466
+ if reuse["vanilla_attempts"]:
467
+ lines.append(
468
+ f"Reuse: {reuse['reused']} of {reuse['vanilla_attempts']} vanilla attempts from the "
469
+ f"cache ({reuse['planned_hits']} planned)"
470
+ )
471
+ tokens = status["tokens"]
472
+ lines.append(
473
+ f"Tokens spent: {_count(tokens['total_tokens'])}"
474
+ + (
475
+ " (lower bound: some usage is partial or not yet saved)"
476
+ if tokens["lower_bound"]
477
+ else ""
478
+ )
479
+ )
480
+ if status["attempts"]:
481
+ width = max(len("arm"), *(len(arm) for arm in status["arms"]))
482
+ columns = (*_RESULTS, "tokens")
483
+ sizes = [len(column) + 2 for column in columns]
484
+ lines += ["", "arm".ljust(width) + "".join(c.rjust(n) for c, n in zip(columns, sizes))]
485
+ for arm, summary_row in status["arms"].items():
486
+ counts = Counter(
487
+ _result(row, status["active"]) for row in status["attempts"] if row["arm"] == arm
488
+ )
489
+ total = summary_row["tokens"]
490
+ lines.append(
491
+ arm.ljust(width)
492
+ + "".join(str(counts.get(r, 0)).rjust(n) for r, n in zip(_RESULTS, sizes))
493
+ + (_count(total["total_tokens"]) + ("+" if total["lower_bound"] else "")).rjust(
494
+ max(sizes[-1], 8)
495
+ )
496
+ )
497
+ task_width = max(len(row["task"]) for row in status["attempts"])
498
+ lines.append("")
499
+ results: dict[tuple[str, str], list[str]] = {}
500
+ for row in sorted(status["attempts"], key=lambda row: row["repeat"]):
501
+ results.setdefault((row["task"], row["arm"]), []).append(_result(row, status["active"]))
502
+ for (task, arm), values in sorted(results.items()):
503
+ lines.append(f" {task.ljust(task_width)} {arm.ljust(width)} {' '.join(values)}")
504
+ measures = status["measures"]
505
+ lines.append("")
506
+ for label, key in (("Measurement", "measurement_revisions"), ("Review", "review_revisions")):
507
+ for row in measures[key]:
508
+ progress = f" {row['reviewed']}/{row['assigned']}" if "assigned" in row else ""
509
+ selected = " (selected)" if row["selected"] else ""
510
+ shown = _shown_state(row["status"], status["active"])
511
+ lines.append(f"{label}: {row['revision']} {shown}{progress}{selected}")
512
+ for row in measures["not_measured"]:
513
+ lines.append(f"Warning: {row['task']}: {row['measure']} not measured: {row['reason']}")
514
+ if status["report"]:
515
+ lines.append(f"Report: {status['report']}")
516
+ if status["log"]:
517
+ lines.append(f"Log: {status['log']}")
518
+ return "\n".join(lines)