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.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- 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)
|