opal-optimizer 0.3.5__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.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/report.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
"""One self-contained HTML page per run: the loss, when each decision was asked and applied, a table of the code
|
|
2
|
+
changes, what the agent said, and the code it changed. No network access needed to view it. During a run, rank 0
|
|
3
|
+
rewrites it after each decision (LiveReport); until the run ends the page reloads itself every minute."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import datetime
|
|
9
|
+
import difflib
|
|
10
|
+
import html
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import summaries
|
|
20
|
+
|
|
21
|
+
_CONST = re.compile(r"[A-Z][A-Z0-9_]*\Z")
|
|
22
|
+
_NOT_LITERAL = object()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_READ = {} # path -> (bytes parsed so far, the last bytes of them, records): the live report reads only what is new
|
|
26
|
+
_READ_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _jsonl(path):
|
|
30
|
+
try:
|
|
31
|
+
return _read_jsonl(path)
|
|
32
|
+
except OSError:
|
|
33
|
+
return [] # absent, unreadable, or replaced between exists() and open()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_jsonl(path):
|
|
37
|
+
"""Records of a .jsonl file; a line being written right now (or broken) is skipped. Appended files are parsed from
|
|
38
|
+
where the previous call stopped; a file that was rewritten (shorter, or other bytes before that point) from the
|
|
39
|
+
start."""
|
|
40
|
+
path = Path(path)
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return []
|
|
43
|
+
with _READ_LOCK, open(path, "rb") as f:
|
|
44
|
+
key = str(path.resolve())
|
|
45
|
+
done, tail, records = _READ.get(key, (0, b"", []))
|
|
46
|
+
size = os.fstat(f.fileno()).st_size
|
|
47
|
+
if done:
|
|
48
|
+
f.seek(max(0, done - len(tail)))
|
|
49
|
+
if size < done or f.read(len(tail)) != tail:
|
|
50
|
+
done, tail, records = 0, b"", []
|
|
51
|
+
f.seek(done)
|
|
52
|
+
data = f.read()
|
|
53
|
+
end = data.rfind(b"\n") + 1 # complete lines only; the rest is read again next time
|
|
54
|
+
records = list(records)
|
|
55
|
+
for line in data[:end].splitlines():
|
|
56
|
+
try:
|
|
57
|
+
record = json.loads(line)
|
|
58
|
+
if isinstance(record, dict):
|
|
59
|
+
records.append(record)
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass
|
|
62
|
+
done += end
|
|
63
|
+
_READ[key] = (done, (tail + data[:end])[-64:], records)
|
|
64
|
+
out = list(records)
|
|
65
|
+
if data[end:].strip(): # a last line without its newline: complete in a file that is no longer written
|
|
66
|
+
try:
|
|
67
|
+
record = json.loads(data[end:])
|
|
68
|
+
if isinstance(record, dict):
|
|
69
|
+
out.append(record)
|
|
70
|
+
except ValueError:
|
|
71
|
+
pass
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _diff(old: dict, new: dict) -> str:
|
|
76
|
+
out = []
|
|
77
|
+
for line in summaries.unified_diff(old, new).splitlines():
|
|
78
|
+
cls = (
|
|
79
|
+
"a"
|
|
80
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
81
|
+
else "r"
|
|
82
|
+
if line.startswith("-") and not line.startswith("---")
|
|
83
|
+
else ""
|
|
84
|
+
)
|
|
85
|
+
out.append(f'<span class="{cls}">{html.escape(line)}</span>' if cls else html.escape(line))
|
|
86
|
+
return "\n".join(out)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _js(obj) -> str:
|
|
90
|
+
"""JSON for inside a <script> element: agent text must not be able to close the element."""
|
|
91
|
+
return json.dumps(obj, allow_nan=False).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def constants(text: str) -> dict:
|
|
95
|
+
"""Module-level UPPER_CASE assignments with a literal value; a name ever bound to anything else is left out."""
|
|
96
|
+
try:
|
|
97
|
+
tree = ast.parse(text)
|
|
98
|
+
except SyntaxError:
|
|
99
|
+
return {}
|
|
100
|
+
out = {}
|
|
101
|
+
for node in tree.body:
|
|
102
|
+
if isinstance(node, ast.Assign):
|
|
103
|
+
targets, value = node.targets, node.value
|
|
104
|
+
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
105
|
+
targets, value = [node.target], node.value
|
|
106
|
+
else:
|
|
107
|
+
continue
|
|
108
|
+
for t in targets:
|
|
109
|
+
if isinstance(t, ast.Name) and _CONST.match(t.id) and out.get(t.id) is not _NOT_LITERAL:
|
|
110
|
+
try:
|
|
111
|
+
out[t.id] = ast.literal_eval(value)
|
|
112
|
+
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
|
|
113
|
+
out[t.id] = _NOT_LITERAL
|
|
114
|
+
return {k: v for k, v in out.items() if v is not _NOT_LITERAL}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _short(v, n=48):
|
|
118
|
+
s = repr(v)
|
|
119
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def file_changes(old: dict, new: dict) -> list:
|
|
123
|
+
"""One entry per changed file: name, lines added and removed, and whether it is new or deleted."""
|
|
124
|
+
out = []
|
|
125
|
+
for name in sorted(set(old) | set(new)):
|
|
126
|
+
a, b = old.get(name, "").splitlines(), new.get(name, "").splitlines()
|
|
127
|
+
if a == b:
|
|
128
|
+
continue
|
|
129
|
+
lines = list(difflib.unified_diff(a, b, lineterm="", n=0))
|
|
130
|
+
add = sum(1 for x in lines if x.startswith("+") and not x.startswith("+++"))
|
|
131
|
+
rem = sum(1 for x in lines if x.startswith("-") and not x.startswith("---"))
|
|
132
|
+
status = "new" if name not in old else "deleted" if name not in new else ""
|
|
133
|
+
out.append(dict(name=name, added=add, removed=rem, status=status))
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def constant_changes(old: dict, new: dict) -> list:
|
|
138
|
+
"""(file, name, old value, new value) for each module-level constant that changed; None where it is absent."""
|
|
139
|
+
out = []
|
|
140
|
+
for name in sorted(set(old) | set(new)):
|
|
141
|
+
if old.get(name) == new.get(name):
|
|
142
|
+
continue
|
|
143
|
+
a, b = constants(old.get(name, "")), constants(new.get(name, ""))
|
|
144
|
+
for c in sorted(set(a) | set(b)):
|
|
145
|
+
ra, rb = (repr(a[c]) if c in a else None), (repr(b[c]) if c in b else None)
|
|
146
|
+
if ra != rb:
|
|
147
|
+
out.append((name, c, _short(a[c]) if c in a else None, _short(b[c]) if c in b else None))
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _number(value):
|
|
152
|
+
try:
|
|
153
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
154
|
+
except OverflowError:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _index(value):
|
|
159
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _text(value):
|
|
163
|
+
return value if isinstance(value, str) else ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _list(value):
|
|
167
|
+
return value if isinstance(value, list) else []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _events(run):
|
|
171
|
+
return [
|
|
172
|
+
e
|
|
173
|
+
for e in _jsonl(Path(run) / "events.jsonl")
|
|
174
|
+
if isinstance(e.get("kind"), str) and ("k" not in e or _index(e["k"]))
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _read_text(path):
|
|
179
|
+
try:
|
|
180
|
+
return Path(path).read_text(errors="replace")
|
|
181
|
+
except OSError:
|
|
182
|
+
return ""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _object(path):
|
|
186
|
+
try:
|
|
187
|
+
obj = json.loads(_read_text(path))
|
|
188
|
+
return obj if isinstance(obj, dict) else {}
|
|
189
|
+
except ValueError:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cached_summary(record):
|
|
194
|
+
obj = _object(record / "summary.json")
|
|
195
|
+
if not _text(obj.get("summary")).strip():
|
|
196
|
+
return None
|
|
197
|
+
return dict(obj, tags=[t for t in _list(obj.get("tags")) if t in summaries.TAGS])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _versions(run: Path) -> dict:
|
|
201
|
+
# Static text only: never import agent programs to make a report. Include old knobs-mode snapshots.
|
|
202
|
+
out = {}
|
|
203
|
+
for d in sorted((run / "programs").glob("v[0-9]*")):
|
|
204
|
+
if not re.fullmatch(r"v[0-9]+", d.name) or not d.is_dir() or d.is_symlink():
|
|
205
|
+
continue
|
|
206
|
+
out[d.name] = {
|
|
207
|
+
p.name: _read_text(p)
|
|
208
|
+
for p in [*sorted(d.glob("*.py")), d / "knobs.json"]
|
|
209
|
+
if p.is_file() and not p.is_symlink()
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _response(run: Path, k: int) -> dict:
|
|
215
|
+
return _object(run / "decisions" / f"{k:03d}" / "response.json")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _agent_metadata(record):
|
|
219
|
+
# Backend transcripts can be large. Only retain explicit init metadata, not tool messages or token usage.
|
|
220
|
+
try:
|
|
221
|
+
with (record / "backend.jsonl").open(errors="replace") as f:
|
|
222
|
+
for line in f:
|
|
223
|
+
try:
|
|
224
|
+
obj = json.loads(line)
|
|
225
|
+
except ValueError:
|
|
226
|
+
continue
|
|
227
|
+
if isinstance(obj, dict) and obj.get("type") == "system" and obj.get("subtype") == "init":
|
|
228
|
+
return {k: obj[k] for k in ("model", "effort", "cwd", "hostname", "username") if k in obj}
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def decision_rows(run, events=None, versions=None) -> list:
|
|
235
|
+
"""One row per decision that changed the programs, in order: when it was asked and applied, the files and
|
|
236
|
+
constants it changed, its rationale, its cached summary (or None), and the two program versions."""
|
|
237
|
+
run = Path(run)
|
|
238
|
+
events = _events(run) if events is None else events
|
|
239
|
+
versions = _versions(run) if versions is None else versions
|
|
240
|
+
requested = {e["k"]: e for e in events if e.get("kind") == "requested" and _index(e.get("k"))}
|
|
241
|
+
start = next((e for e in events if e.get("kind") == "start"), {})
|
|
242
|
+
rows = []
|
|
243
|
+
for e in events:
|
|
244
|
+
if e.get("kind") != "applied" or not _index(e.get("k")):
|
|
245
|
+
continue
|
|
246
|
+
k, v = e["k"], e.get("version")
|
|
247
|
+
old_key, new_key = (f"v{v - 1:03d}", f"v{v:03d}") if _index(v) else (None, None)
|
|
248
|
+
available = old_key in versions and new_key in versions
|
|
249
|
+
old, new = (versions[old_key], versions[new_key]) if available else ({}, {})
|
|
250
|
+
rows.append(
|
|
251
|
+
dict(
|
|
252
|
+
k=k,
|
|
253
|
+
requested=e.get("requested_at", requested.get(k, {}).get("update")),
|
|
254
|
+
applied=e.get("applied_at"),
|
|
255
|
+
version=v,
|
|
256
|
+
snapshots_available=available,
|
|
257
|
+
changed=_list(e.get("changed")),
|
|
258
|
+
analysis_only=e.get("rebuilt_updater") is False or e.get("changed") == ["analysis.py"],
|
|
259
|
+
files=file_changes(old, new),
|
|
260
|
+
constants=constant_changes(old, new),
|
|
261
|
+
rationale=_text(_response(run, k).get("text")).strip(),
|
|
262
|
+
summary=_cached_summary(run / "decisions" / f"{k:03d}"),
|
|
263
|
+
total=start.get("total_updates"),
|
|
264
|
+
old=old,
|
|
265
|
+
new=new,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return rows
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def report_series(run, min_reports=3, limit=12):
|
|
272
|
+
"""Numbers the agent's analysis reported (reports.jsonl), as series keyed by path up to depth 2 (`grad_norm.embed`):
|
|
273
|
+
those present in at least `min_reports` reports, at most `limit` of them in order of first appearance. Returns the
|
|
274
|
+
kept series and how many qualified."""
|
|
275
|
+
series = {}
|
|
276
|
+
for r in _jsonl(Path(run) / "reports.jsonl"):
|
|
277
|
+
rep, u = r.get("report"), r.get("update")
|
|
278
|
+
if not isinstance(rep, dict) or not _number(u):
|
|
279
|
+
continue
|
|
280
|
+
for key, v in rep.items():
|
|
281
|
+
if key == "update": # the x axis itself
|
|
282
|
+
continue
|
|
283
|
+
leaves = v.items() if isinstance(v, dict) else [(None, v)]
|
|
284
|
+
for sub, x in leaves:
|
|
285
|
+
if _number(x):
|
|
286
|
+
series.setdefault(str(key) if sub is None else f"{key}.{sub}", []).append([u, x])
|
|
287
|
+
keep = [(k, pts) for k, pts in series.items() if len(pts) >= min_reports and len({x for _, x in pts}) > 1]
|
|
288
|
+
return keep[:limit], len(keep)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _finished(events) -> bool:
|
|
292
|
+
last = lambda kinds: max((i for i, e in enumerate(events) if e.get("kind") in kinds), default=-1) # noqa: E731
|
|
293
|
+
return last(("end", "interrupted")) > last(("resumed",))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
OUTCOMES = ("applied", "kept", "rejected", "load_failed")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def decision_counts(events) -> dict:
|
|
300
|
+
"""Scheduled, asked, answered and applied decisions of a run, so a run whose agent was mostly not asked shows at a
|
|
301
|
+
glance. Answered: the agent's answer came (applied, kept or rejected), not a failed or abandoned backend call.
|
|
302
|
+
Applied: it changed the programs. With blocking="auto", how many decisions waited, and the median lag in updates
|
|
303
|
+
from request to effect of the answered ones."""
|
|
304
|
+
start = next((e for e in events if e.get("kind") == "start"), {})
|
|
305
|
+
asked = {e["k"] for e in events if e.get("kind") == "requested" and _index(e.get("k"))}
|
|
306
|
+
schedule = next((e["schedule"] for e in reversed(events)
|
|
307
|
+
if e.get("kind") in ("start", "estimate", "resumed") and "schedule" in e), [])
|
|
308
|
+
outcomes = [e for e in events if e.get("kind") in OUTCOMES and _index(e.get("k"))]
|
|
309
|
+
answered = [e for e in outcomes if not (e.get("gave_up") or e.get("failed"))]
|
|
310
|
+
lags = sorted(e["lag_updates"] for e in answered if _number(e.get("lag_updates")))
|
|
311
|
+
waits = [e["wait"] for e in events if e.get("kind") == "requested" and e.get("wait")]
|
|
312
|
+
return dict(
|
|
313
|
+
scheduled=len(_list(schedule)),
|
|
314
|
+
asked=len(asked),
|
|
315
|
+
answered=len({e["k"] for e in answered}),
|
|
316
|
+
applied=len({e["k"] for e in outcomes if e.get("kind") == "applied"}),
|
|
317
|
+
failed=len({e["k"] for e in outcomes if e.get("failed") and not e.get("gave_up")}),
|
|
318
|
+
unavailable_seconds=sum(e["unavailable_seconds"] for e in outcomes if _number(e.get("unavailable_seconds"))),
|
|
319
|
+
waited=waits.count("waited"),
|
|
320
|
+
did_not_wait=waits.count("did not wait"),
|
|
321
|
+
median_lag_updates=lags[len(lags) // 2] if lags else None,
|
|
322
|
+
mode=start.get("blocking"),
|
|
323
|
+
max_lag_updates=start.get("max_lag_updates"),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def describe_counts(c) -> str:
|
|
328
|
+
s = (
|
|
329
|
+
f"Decisions: {c['scheduled']} scheduled, {c['asked']} asked, {c['answered']} answered, {c['applied']} applied "
|
|
330
|
+
"(changed the programs)"
|
|
331
|
+
)
|
|
332
|
+
if c.get("failed"):
|
|
333
|
+
s += f"; {c['failed']} agent call{'s' if c['failed'] > 1 else ''} failed"
|
|
334
|
+
if c.get("unavailable_seconds"):
|
|
335
|
+
s += f"; {c['unavailable_seconds'] / 60:.1f} min waiting for the agent to be available"
|
|
336
|
+
|
|
337
|
+
if c["mode"] == "auto":
|
|
338
|
+
s += (
|
|
339
|
+
f"; training waited at {c['waited']} and did not wait at {c['did_not_wait']} (blocking=auto, limit "
|
|
340
|
+
f"{c['max_lag_updates']} updates)"
|
|
341
|
+
)
|
|
342
|
+
if c["median_lag_updates"] is not None:
|
|
343
|
+
s += f"; median lag from request to effect {c['median_lag_updates']:,} updates"
|
|
344
|
+
return s + "."
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def defaults_flag(events) -> str:
|
|
348
|
+
"""The banner of a run whose OPAL install had its default prompt or starting programs edited (opal/defaults.py),
|
|
349
|
+
or that used a custom prompt or starting program; empty otherwise."""
|
|
350
|
+
start = next((e for e in events if e.get("kind") == "start"), {})
|
|
351
|
+
out = ""
|
|
352
|
+
if start.get("defaults_modified"):
|
|
353
|
+
out += (
|
|
354
|
+
'<p class="flag"><b>OPAL\'s own defaults were modified</b> in the install that ran this: '
|
|
355
|
+
+ html.escape(", ".join(str(f) for f in _list(start.get("modified_files"))))
|
|
356
|
+
+ " differ from the release, so the agent did not get the default prompt or starting programs.</p>\n"
|
|
357
|
+
)
|
|
358
|
+
for key, what in (("custom_prompt", "a custom prompt"), ("custom_program", "a custom starting program")):
|
|
359
|
+
if start.get(key):
|
|
360
|
+
out += f"<p>This run used {what} ({html.escape(str(start[key]))}); the blog's results used OPAL's defaults.</p>\n"
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def rank_repair_summary(events) -> str:
|
|
365
|
+
"""A persistent warning, including repairs from earlier attempts in a resumed run folder."""
|
|
366
|
+
updates = {e["update"] for e in events if e.get("kind") == "ranks_resynced" and _index(e.get("update"))}
|
|
367
|
+
for e in events:
|
|
368
|
+
if e.get("kind") == "resumed":
|
|
369
|
+
updates.update(u for u in _list(e.get("ranks_repaired_at")) if _index(u))
|
|
370
|
+
return "ranks repaired at updates " + ", ".join(str(u) for u in sorted(updates)) if updates else ""
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def rank_flag(events) -> str:
|
|
374
|
+
summary = rank_repair_summary(events)
|
|
375
|
+
return (
|
|
376
|
+
f'<p class="flag"><b>{html.escape(summary)}.</b> Parameters were broadcast from rank 0; '
|
|
377
|
+
"optimizer state was not repaired. This run's trajectory was changed.</p>\n"
|
|
378
|
+
if summary else ""
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def integrity_flag(events) -> str:
|
|
383
|
+
"""The banner of a run whose program moved the parameters for development evaluations and moved them back
|
|
384
|
+
(eval_integrity events, misc/integrity.py); empty otherwise."""
|
|
385
|
+
found = [e for e in events if e.get("kind") == "eval_integrity" and e.get("flagged")]
|
|
386
|
+
if not found:
|
|
387
|
+
return ""
|
|
388
|
+
items = []
|
|
389
|
+
for e in found[:6]:
|
|
390
|
+
tensors_record = [
|
|
391
|
+
r
|
|
392
|
+
for r in _list(e.get("tensors"))
|
|
393
|
+
if isinstance(r, dict)
|
|
394
|
+
and isinstance(r.get("name"), str)
|
|
395
|
+
and _number(r.get("norm_factor", 1))
|
|
396
|
+
and _number(r.get("changed_at"))
|
|
397
|
+
and _number(r.get("restored_at"))
|
|
398
|
+
]
|
|
399
|
+
tensors = ", ".join(
|
|
400
|
+
f"{r['name']} (x{r.get('norm_factor', 1):.3g} in norm, changed at update {r['changed_at']:,}, back at "
|
|
401
|
+
f"{r['restored_at']:,})"
|
|
402
|
+
for r in tensors_record[:3]
|
|
403
|
+
)
|
|
404
|
+
more = f" and {e['n_tensors'] - 3} more" if _number(e.get("n_tensors")) and e["n_tensors"] > 3 else ""
|
|
405
|
+
items.append(f"update {_n(e.get('update'))}: {tensors or 'tensor details not recorded'}{more}")
|
|
406
|
+
rest = f"; {len(found) - 6} more in events.jsonl" if len(found) > 6 else ""
|
|
407
|
+
return (
|
|
408
|
+
f'<p class="flag"><b>Flagged: evaluation integrity.</b> At {len(found)} development evaluation'
|
|
409
|
+
f"{'s' if len(found) > 1 else ''} the program had moved parameters to another point and moved them back "
|
|
410
|
+
f"after the evaluation, so those measurements do not describe the trained model: "
|
|
411
|
+
+ html.escape("; ".join(items) + rest)
|
|
412
|
+
+ ".</p>\n"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _bi(en, zh):
|
|
417
|
+
return f'<span data-lang="en">{html.escape(en)}</span><span data-lang="zh">{html.escape(zh)}</span>'
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _n(value):
|
|
421
|
+
return f"{value:,}" if _number(value) else "?"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _sentences(text):
|
|
425
|
+
text = re.sub(r"(?m)^\s*(?:#+\s*|\d+[.)]\s*|[-*]\s+)", "", _text(text))
|
|
426
|
+
text = " ".join(text.replace("**", "").replace("`", "").split())
|
|
427
|
+
return re.split(r'(?<=[.!?。])\s+(?=[A-Z0-9“"\'])', text) if text else []
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _excerpt(text, limit=180, reason=False):
|
|
431
|
+
sentences = _sentences(text)
|
|
432
|
+
if reason:
|
|
433
|
+
# Extract the agent's words; never turn a prediction into a measured explanation.
|
|
434
|
+
introductory = r"\b(because|reason|observ\w*|showed|plateau\w*|stalled|stopped|slow\w*|taking|so I|so we)\b"
|
|
435
|
+
early = [s for s in sentences[:3] if len(s) >= 35 and re.search(introductory, s, re.I)]
|
|
436
|
+
for pattern in (r"\b(because|reason|so that)\b", r"\b(observ\w*|showed|plateau\w*|stalled|indicat\w*)\b"):
|
|
437
|
+
matches = early or [
|
|
438
|
+
s
|
|
439
|
+
for s in sentences
|
|
440
|
+
if len(s) >= 35 and re.search(pattern, s, re.I) and not re.match(r"(?:That|This|It|They)\b", s)
|
|
441
|
+
]
|
|
442
|
+
if matches:
|
|
443
|
+
sentences = matches
|
|
444
|
+
break
|
|
445
|
+
text = sentences[0] if sentences else ""
|
|
446
|
+
if len(text) > limit:
|
|
447
|
+
text = text[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
|
448
|
+
return text
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _change(row):
|
|
452
|
+
if row["summary"]:
|
|
453
|
+
text = _text(row["summary"]["summary"])
|
|
454
|
+
first = _excerpt(text, limit=500)
|
|
455
|
+
if len(first) > 140:
|
|
456
|
+
first = " ".join(re.sub(r"\([^()]*\)", "", first).split())
|
|
457
|
+
for separator in (": ", "; ", ", while "):
|
|
458
|
+
clause = first.split(separator, 1)[0]
|
|
459
|
+
if 45 <= len(clause) <= 140 and len(clause) < len(first):
|
|
460
|
+
return clause.rstrip(".,") + "…"
|
|
461
|
+
return _excerpt(first, limit=140)
|
|
462
|
+
parts = []
|
|
463
|
+
for _, name, old, new in row["constants"][:2]:
|
|
464
|
+
parts.append(
|
|
465
|
+
f"{name} = {new}" if old is None else f"Remove {name}" if new is None else f"{name}: {old} → {new}"
|
|
466
|
+
)
|
|
467
|
+
if parts:
|
|
468
|
+
return "; ".join(parts)
|
|
469
|
+
names = [f["name"] for f in row["files"]] or [str(n) for n in row["changed"]]
|
|
470
|
+
return "Changed " + (", ".join(names) or "programs") + " (summary unavailable)"
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _duration(value, zh=False):
|
|
474
|
+
if not _number(value):
|
|
475
|
+
return "未记录" if zh else "not recorded"
|
|
476
|
+
if value < 60:
|
|
477
|
+
return f"{value:.1f} 秒" if zh else f"{value:.1f} s"
|
|
478
|
+
return f"{value / 60:.1f} 分钟" if zh else f"{value / 60:.1f} min"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _wall_span(events):
|
|
482
|
+
stamps = []
|
|
483
|
+
for e in events:
|
|
484
|
+
try:
|
|
485
|
+
t = datetime.datetime.fromisoformat(_text(e.get("utc")).replace("Z", "+00:00"))
|
|
486
|
+
if t.tzinfo is not None:
|
|
487
|
+
stamps.append(t)
|
|
488
|
+
except ValueError:
|
|
489
|
+
pass
|
|
490
|
+
return (max(stamps) - min(stamps)).total_seconds() if len(stamps) > 1 else None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _table(rows):
|
|
494
|
+
if not rows:
|
|
495
|
+
return (
|
|
496
|
+
"<h2>"
|
|
497
|
+
+ _bi("Changes", "改动")
|
|
498
|
+
+ "</h2><p>"
|
|
499
|
+
+ _bi("No applied changes recorded.", "未记录已应用的改动。")
|
|
500
|
+
+ "</p>"
|
|
501
|
+
)
|
|
502
|
+
body = []
|
|
503
|
+
for r in rows:
|
|
504
|
+
reason = _excerpt(r["rationale"], reason=True)
|
|
505
|
+
why = f"“{html.escape(reason)}”" if reason else _bi("No rationale recorded.", "未记录理由。")
|
|
506
|
+
tags = ", ".join(r["summary"].get("tags", [])) if r["summary"] else ""
|
|
507
|
+
note = _bi("Analysis only", "仅改分析") if r["analysis_only"] else ""
|
|
508
|
+
if r.get("reverted_at") is not None:
|
|
509
|
+
note += " " + _bi(f"Reverted at update {_n(r['reverted_at'])}", f"在更新步 {_n(r['reverted_at'])} 回退")
|
|
510
|
+
body.append(
|
|
511
|
+
f'<tr><td class="number"><a href="#d{r["k"]}">{r["k"]}</a></td>'
|
|
512
|
+
f'<td class="when" data-label="Update">{_n(r["requested"])} → {_n(r["applied"])}</td>'
|
|
513
|
+
f'<td class="what">{html.escape(r["change"])}<div class="muted">{note}</div></td>'
|
|
514
|
+
f'<td class="why" data-label="Why">{why}</td><td class="tags" data-label="Tags">{html.escape(tags) or "—"}</td></tr>'
|
|
515
|
+
)
|
|
516
|
+
heads = [
|
|
517
|
+
("#", "#"),
|
|
518
|
+
("Update requested → applied", "更新步:请求 → 应用"),
|
|
519
|
+
("What changed", "改动"),
|
|
520
|
+
("Why, in the agent’s words", "理由,摘自 agent 原话"),
|
|
521
|
+
("Tags", "标签"),
|
|
522
|
+
]
|
|
523
|
+
return (
|
|
524
|
+
"<h2>"
|
|
525
|
+
+ _bi("Changes", "改动")
|
|
526
|
+
+ "</h2><table><thead><tr>"
|
|
527
|
+
+ "".join("<th>" + _bi(en, zh) + "</th>" for en, zh in heads)
|
|
528
|
+
+ "</tr></thead><tbody>"
|
|
529
|
+
+ "".join(body)
|
|
530
|
+
+ '</tbody></table><p class="muted">'
|
|
531
|
+
+ _bi(
|
|
532
|
+
"Descriptions use saved summaries when available, otherwise literal file or constant changes. Reasons are shortened quotations; tags are saved model labels, not quality judgments.",
|
|
533
|
+
"改动描述优先使用已保存的摘要,否则列出文件或常量改动。理由是原话的缩短摘录;标签来自已保存的模型标注,不代表质量判断。",
|
|
534
|
+
)
|
|
535
|
+
+ "</p>"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _overview(rows, counts, start, raw, metrics, cost, summary_cost, span, waited):
|
|
540
|
+
scheduled = f" of {counts['scheduled']} scheduled" if isinstance(start.get("schedule"), list) else ""
|
|
541
|
+
n_an = sum(r["analysis_only"] for r in rows)
|
|
542
|
+
lag = (
|
|
543
|
+
f"; median request-to-effect lag {_n(counts['median_lag_updates'])} updates"
|
|
544
|
+
if counts["median_lag_updates"] is not None
|
|
545
|
+
else ""
|
|
546
|
+
)
|
|
547
|
+
change_word = "change" if counts["applied"] == 1 else "changes"
|
|
548
|
+
en = f"The agent was asked {counts['asked']}{scheduled} decisions, answered {counts['answered']} and applied {counts['applied']} {change_word} ({len(rows) - n_an} to the updater, {n_an} only to the analysis){lag}."
|
|
549
|
+
schedule_zh = f"计划 {counts['scheduled']} 次决策," if isinstance(start.get("schedule"), list) else ""
|
|
550
|
+
lag_zh = (
|
|
551
|
+
f";请求到生效的更新步延迟中位数为 {_n(counts['median_lag_updates'])}"
|
|
552
|
+
if counts["median_lag_updates"] is not None
|
|
553
|
+
else ""
|
|
554
|
+
)
|
|
555
|
+
zh = f"{schedule_zh}agent 收到 {counts['asked']} 次请求,回答 {counts['answered']} 次,应用 {counts['applied']} 次改动(更新器 {len(rows) - n_an} 次,仅分析 {n_an} 次){lag_zh}。"
|
|
556
|
+
sentences = [_bi(en, zh)]
|
|
557
|
+
updater = [r for r in rows if not r["analysis_only"]]
|
|
558
|
+
highlights = updater or rows
|
|
559
|
+
if highlights:
|
|
560
|
+
chosen = [highlights[0]] + ([highlights[-1]] if len(highlights) > 1 else [])
|
|
561
|
+
for r in chosen:
|
|
562
|
+
reason = _excerpt(r["rationale"], 150, reason=True)
|
|
563
|
+
why = f"; the agent wrote “{reason.rstrip('.')}”" if reason else "; no rationale was recorded"
|
|
564
|
+
why_zh = f";agent 写道:“{reason.rstrip('.')}”" if reason else ";未记录理由"
|
|
565
|
+
when = f"At update {_n(r['applied'])}" if _number(r["applied"]) else "At an unrecorded update"
|
|
566
|
+
suffix = f" (reverted at update {_n(r['reverted_at'])})" if r.get("reverted_at") is not None else ""
|
|
567
|
+
sentences.append(
|
|
568
|
+
_bi(
|
|
569
|
+
f"{when}, decision {r['k']}: {r['change'].rstrip('.')}{suffix}{why}.",
|
|
570
|
+
f"更新步 {_n(r['applied'])} 的决策 {r['k']}:{r['change'].rstrip('.')}{suffix}{why_zh}。",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
finite = [(u, v) for u, v in raw if _number(v)]
|
|
574
|
+
if finite:
|
|
575
|
+
(u0, v0), (u1, v1) = finite[0], finite[-1]
|
|
576
|
+
metric = f"Recorded training loss went from {v0:.5g} at update {_n(u0)} to {v1:.5g} at update {_n(u1)}."
|
|
577
|
+
metric_zh = f"记录的训练损失从更新步 {_n(u0)} 的 {v0:.5g} 到更新步 {_n(u1)} 的 {v1:.5g}。"
|
|
578
|
+
else:
|
|
579
|
+
metric, metric_zh = "No training loss was recorded.", "未记录训练损失。"
|
|
580
|
+
if metrics:
|
|
581
|
+
# Prefer an explicitly named accuracy; otherwise use the first opt.log metric. Do not infer units.
|
|
582
|
+
key = next((k for k in metrics if re.search(r"(?:accuracy|acc)(?:$|[_/.])", k, re.I)), next(iter(metrics)))
|
|
583
|
+
points = metrics[key]
|
|
584
|
+
(a, x), (b, y) = points[0], points[-1]
|
|
585
|
+
logged = f"; {key}: {x:.5g} → {y:.5g} (updates {_n(a)} → {_n(b)})"
|
|
586
|
+
metric = metric.rstrip(".") + logged + "."
|
|
587
|
+
metric_zh = metric_zh.rstrip("。") + f";{key}:{x:.5g} → {y:.5g}(更新步 {_n(a)} → {_n(b)})。"
|
|
588
|
+
sentences.append(_bi(metric, metric_zh))
|
|
589
|
+
cost_text = f"${cost:.4f}" if _number(cost) else "not recorded"
|
|
590
|
+
summary_text = f"; saved summaries cost ${summary_cost:.4f} separately" if _number(summary_cost) else ""
|
|
591
|
+
summary_zh = f";已保存的摘要另计 ${summary_cost:.4f}" if _number(summary_cost) else ""
|
|
592
|
+
cost_zh = f"${cost:.4f}" if _number(cost) else "未记录"
|
|
593
|
+
sentences.append(
|
|
594
|
+
_bi(f"Recorded agent cost: {cost_text}{summary_text}.", f"记录的 agent 费用:{cost_zh}{summary_zh}。")
|
|
595
|
+
)
|
|
596
|
+
availability = counts["unavailable_seconds"]
|
|
597
|
+
available_en = f"; waiting for agent availability: {_duration(availability)}" if availability else ""
|
|
598
|
+
available_zh = f";等待 agent 恢复可用:{_duration(availability, zh=True)}" if availability else ""
|
|
599
|
+
sentences.append(
|
|
600
|
+
_bi(
|
|
601
|
+
f"Wall span: {_duration(span)} (first to last timestamp, including any gaps); training waiting time: {_duration(waited)}{available_en}.",
|
|
602
|
+
f"运行跨度:{_duration(span, zh=True)}(首末时间戳之差,包含中间停顿);训练等待时间:{_duration(waited, zh=True)}{available_zh}。",
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
# At most six sentences, grouped into three short paragraphs.
|
|
606
|
+
return "".join("<p>" + " ".join(sentences[i : i + 2]) + "</p>" for i in range(0, len(sentences), 2))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _fact(en, zh, value):
|
|
610
|
+
shown = _bi("Not recorded", "未记录") if value is None else html.escape(str(value))
|
|
611
|
+
return "<dt>" + _bi(en, zh) + "</dt><dd>" + shown + "</dd>"
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def boundary_flag(events) -> str:
|
|
615
|
+
"""The banner of a run whose program sources crossed OPAL's static boundary check (boundary events,
|
|
616
|
+
misc/boundary.py); empty otherwise. Flags are diagnostics, not a sandbox."""
|
|
617
|
+
found = [e for e in events if e.get("kind") == "boundary"]
|
|
618
|
+
if not found:
|
|
619
|
+
return ""
|
|
620
|
+
locations = "; ".join(
|
|
621
|
+
f"v{e['version']:03d} {e.get('file')}:{e.get('line')} ({e.get('category')})" if isinstance(e.get("version"), int)
|
|
622
|
+
else f"{e.get('file')}:{e.get('line')} ({e.get('category')})"
|
|
623
|
+
for e in found[:6]
|
|
624
|
+
)
|
|
625
|
+
return (f'<p class="flag"><b>Boundary flags:</b> {len(found)} static findings: {html.escape(locations)}. '
|
|
626
|
+
'Review events.jsonl and programs/ for details; flags do not block execution or provide a sandbox.</p>\n')
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def write_report(run) -> Path:
|
|
630
|
+
"""Read existing records and atomically replace one offline HTML file; no model calls or training imports."""
|
|
631
|
+
from .report_page import render
|
|
632
|
+
from .report_privacy import Privacy
|
|
633
|
+
|
|
634
|
+
run = Path(run)
|
|
635
|
+
events = _events(run)
|
|
636
|
+
if not events:
|
|
637
|
+
raise FileNotFoundError("not a run folder (no readable events.jsonl)")
|
|
638
|
+
start = next((e for e in events if e.get("kind") == "start"), {})
|
|
639
|
+
versions = _versions(run)
|
|
640
|
+
rows = decision_rows(run, events, versions)
|
|
641
|
+
requested = {e["k"]: e for e in events if e.get("kind") == "requested" and _index(e.get("k"))}
|
|
642
|
+
outcomes = {e["k"]: e for e in events if e.get("kind") in (*OUTCOMES, "cancelled") and _index(e.get("k"))}
|
|
643
|
+
# An old/partial folder may have an outcome but have lost its request line. Do not lose the answer too.
|
|
644
|
+
decisions = {}
|
|
645
|
+
for k in sorted(requested.keys() | outcomes.keys()):
|
|
646
|
+
record = run / "decisions" / f"{k:03d}"
|
|
647
|
+
decisions[k] = dict(
|
|
648
|
+
request=requested.get(k, {}),
|
|
649
|
+
outcome=outcomes.get(k, {}),
|
|
650
|
+
response=_response(run, k),
|
|
651
|
+
message=_read_text(record / "message.txt"),
|
|
652
|
+
metadata=_agent_metadata(record),
|
|
653
|
+
)
|
|
654
|
+
raw, lr = {}, {}
|
|
655
|
+
for r in _jsonl(run / "losses.jsonl"):
|
|
656
|
+
u = r.get("from_update")
|
|
657
|
+
if not _number(u):
|
|
658
|
+
continue
|
|
659
|
+
for i, v in enumerate(_list(r.get("losses"))):
|
|
660
|
+
raw[u + i] = v if _number(v) else None
|
|
661
|
+
if _number(r.get("to_update")):
|
|
662
|
+
for g, v in enumerate(_list(r.get("lr"))):
|
|
663
|
+
if _number(v):
|
|
664
|
+
lr.setdefault(g, []).append([r["to_update"], v])
|
|
665
|
+
raw = sorted(raw.items())
|
|
666
|
+
small = []
|
|
667
|
+
if lr:
|
|
668
|
+
distinct = {json.dumps(pts): (g, pts) for g, pts in reversed(list(lr.items()))}
|
|
669
|
+
small.append(
|
|
670
|
+
dict(
|
|
671
|
+
label="Learning rate (exposed parameter groups)",
|
|
672
|
+
series=[dict(name=f"group {g}", points=pts) for g, pts in list(distinct.values())[:6]],
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
metrics = {}
|
|
676
|
+
for r in _jsonl(run / "metrics.jsonl"):
|
|
677
|
+
if _number(r.get("update")):
|
|
678
|
+
for key, v in r.items():
|
|
679
|
+
if key != "update" and _number(v):
|
|
680
|
+
metrics.setdefault(key, []).append([r["update"], v])
|
|
681
|
+
small.extend(
|
|
682
|
+
dict(label=f"{key} (logged with opt.log)", series=[dict(name=key, points=pts)]) for key, pts in metrics.items()
|
|
683
|
+
)
|
|
684
|
+
extra, n_extra = report_series(run)
|
|
685
|
+
for i, (key, pts) in enumerate(extra):
|
|
686
|
+
more = f"; {n_extra - len(extra)} more series not shown" if i == len(extra) - 1 and n_extra > len(extra) else ""
|
|
687
|
+
small.append(dict(label=f"{key} (reported by the agent's analysis{more})", series=[dict(name=key, points=pts)]))
|
|
688
|
+
result = _object(run / "result.json")
|
|
689
|
+
data = dict(events=events, start=start, rows=rows, decisions=decisions, small=small, metrics=metrics, result=result)
|
|
690
|
+
data = Privacy(run, data).clean(data)
|
|
691
|
+
events, start, rows, decisions, small, result = (
|
|
692
|
+
data[k] for k in ("events", "start", "rows", "decisions", "small", "result")
|
|
693
|
+
)
|
|
694
|
+
metrics = data["metrics"]
|
|
695
|
+
counts = decision_counts(events)
|
|
696
|
+
for row in rows:
|
|
697
|
+
row["change"] = _change(row)
|
|
698
|
+
row["reverted_at"] = next(
|
|
699
|
+
(e.get("update") for e in events if e.get("kind") == "reverted" and e.get("version") == row["version"]),
|
|
700
|
+
None,
|
|
701
|
+
)
|
|
702
|
+
by_k = {r["k"]: r for r in rows}
|
|
703
|
+
costs = [
|
|
704
|
+
next((obj["cost_usd"] for obj in (d["response"], d["outcome"]) if _number(obj.get("cost_usd"))), None)
|
|
705
|
+
for d in decisions.values()
|
|
706
|
+
]
|
|
707
|
+
costs = [v for v in costs if _number(v)]
|
|
708
|
+
settled = next(
|
|
709
|
+
(
|
|
710
|
+
e
|
|
711
|
+
for e in reversed(events)
|
|
712
|
+
if _finished(events) and e.get("kind") in ("end", "interrupted", "cost_settled")
|
|
713
|
+
),
|
|
714
|
+
{},
|
|
715
|
+
)
|
|
716
|
+
cost = settled.get("cost_usd")
|
|
717
|
+
if costs:
|
|
718
|
+
cost = max(cost, sum(costs)) if _number(cost) else sum(costs)
|
|
719
|
+
pending = settled.get("pending_decision")
|
|
720
|
+
unknown = settled.get("unknown_cost_decisions", [])
|
|
721
|
+
if unknown or (pending is not None and not _number(decisions.get(pending, {}).get("response", {}).get("cost_usd"))):
|
|
722
|
+
cost = None # pending/unreported is not free, even after the response arrived
|
|
723
|
+
elif "unknown_cost_decisions" not in settled and any(
|
|
724
|
+
e.get("kind") == "cancelled" and not any(
|
|
725
|
+
_number(decisions.get(e.get("k"), {}).get(field, {}).get("cost_usd"))
|
|
726
|
+
for field in ("response", "outcome")
|
|
727
|
+
) for e in events
|
|
728
|
+
):
|
|
729
|
+
cost = None # legacy cancelled records do not carry the persistent unknown set
|
|
730
|
+
summary_costs = [
|
|
731
|
+
r["summary"].get("cost_usd") for r in rows if r["summary"] and _number(r["summary"].get("cost_usd"))
|
|
732
|
+
]
|
|
733
|
+
summary_cost = sum(summary_costs) if summary_costs else None
|
|
734
|
+
span = _wall_span(events)
|
|
735
|
+
waited = next(
|
|
736
|
+
(obj["agent_wait_seconds"] for obj in [result, *reversed(events)] if _number(obj.get("agent_wait_seconds"))),
|
|
737
|
+
None,
|
|
738
|
+
)
|
|
739
|
+
ticks, blocks, diffs = [], [], []
|
|
740
|
+
for k, d in decisions.items():
|
|
741
|
+
rq, oc, resp = d["request"], d["outcome"], d["response"]
|
|
742
|
+
r = by_k.get(k)
|
|
743
|
+
requested_at = rq.get("update", oc.get("requested_at"))
|
|
744
|
+
applied = oc.get("applied_at")
|
|
745
|
+
status = "failed" if oc.get("failed") else oc.get("kind", "pending" if not _finished(events) else "unanswered")
|
|
746
|
+
reason = _excerpt(_text(resp.get("text")), reason=True)
|
|
747
|
+
change = r["change"] if r else status
|
|
748
|
+
label = f"Update {_n(requested_at)} → {_n(applied)}: {change}" + (f" · “{reason}”" if reason else "")
|
|
749
|
+
ticks.append(
|
|
750
|
+
dict(
|
|
751
|
+
k=k,
|
|
752
|
+
requested=requested_at if _number(requested_at) else None,
|
|
753
|
+
applied=applied if _number(applied) else None,
|
|
754
|
+
changed=r is not None and _number(applied),
|
|
755
|
+
status=status,
|
|
756
|
+
label=label,
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
metadata = {
|
|
760
|
+
key: resp[key]
|
|
761
|
+
for key in ("seconds", "cost_usd", "returncode", "backend_error", "errors", "unavailable_seconds")
|
|
762
|
+
if key in resp
|
|
763
|
+
}
|
|
764
|
+
metadata.update(request=rq, outcome=oc)
|
|
765
|
+
if rq.get("wait"):
|
|
766
|
+
metadata.update(wait=rq["wait"], wait_reason=rq.get("wait_reason"))
|
|
767
|
+
blocks.append(
|
|
768
|
+
f'<details id="d{k}"><summary>'
|
|
769
|
+
+ _bi(f"Decision {k}", f"决策 {k}")
|
|
770
|
+
+ f" · {_n(requested_at)} → {_n(applied)} · {html.escape(str(status))}</summary><h3>"
|
|
771
|
+
+ _bi("Request", "请求")
|
|
772
|
+
+ "</h3><pre>"
|
|
773
|
+
+ html.escape(d["message"] or "(not recorded)")
|
|
774
|
+
+ "</pre><h3>"
|
|
775
|
+
+ _bi("Answer", "回答")
|
|
776
|
+
+ "</h3><pre>"
|
|
777
|
+
+ html.escape(_text(resp.get("text")) or "(not recorded)")
|
|
778
|
+
+ "</pre><pre>"
|
|
779
|
+
+ html.escape(json.dumps(metadata, indent=2, ensure_ascii=False))
|
|
780
|
+
+ "</pre></details>"
|
|
781
|
+
)
|
|
782
|
+
if r:
|
|
783
|
+
file_text = "\n".join(f"{f['name']}: +{f['added']} −{f['removed']} {f['status']}" for f in r["files"])
|
|
784
|
+
const_text = "\n".join(f"{name}: {c}: {a} → {b}" for name, c, a, b in r["constants"])
|
|
785
|
+
diff = (
|
|
786
|
+
_diff(r["old"], r["new"])
|
|
787
|
+
if r["snapshots_available"]
|
|
788
|
+
else html.escape("Program snapshots missing; diff unavailable.")
|
|
789
|
+
)
|
|
790
|
+
cached = _text((r["summary"] or {}).get("summary")) or "summary unavailable"
|
|
791
|
+
diffs.append(
|
|
792
|
+
"<details><summary>"
|
|
793
|
+
+ _bi(f"Decision {k}", f"决策 {k}")
|
|
794
|
+
+ "</summary><p>"
|
|
795
|
+
+ html.escape(cached)
|
|
796
|
+
+ "</p><pre>"
|
|
797
|
+
+ html.escape(file_text + "\n" + const_text)
|
|
798
|
+
+ '</pre><pre class="diff">'
|
|
799
|
+
+ diff
|
|
800
|
+
+ "</pre></details>"
|
|
801
|
+
)
|
|
802
|
+
bins = max(1, math.ceil(len(raw) / 300))
|
|
803
|
+
losses = []
|
|
804
|
+
for i in range(0, len(raw), bins):
|
|
805
|
+
batch = raw[i : i + bins]
|
|
806
|
+
values = [v for _, v in batch if v is not None]
|
|
807
|
+
losses.append(
|
|
808
|
+
[batch[len(batch) // 2][0], sum(v / len(values) for v in values) if len(values) == len(batch) else None]
|
|
809
|
+
)
|
|
810
|
+
models = sorted(
|
|
811
|
+
{_text(obj.get("model")) for d in decisions.values() for obj in (d["metadata"], d["response"])} - {""}
|
|
812
|
+
)
|
|
813
|
+
effort = sorted({_text(obj.get("effort")) for obj in [start, *[d["metadata"] for d in decisions.values()]]} - {""})
|
|
814
|
+
model = start.get("model") or ", ".join(models) or None
|
|
815
|
+
schedule = next((e["schedule"] for e in reversed(events)
|
|
816
|
+
if e.get("kind") in ("start", "estimate", "resumed") and "schedule" in e), None)
|
|
817
|
+
schedule = ", ".join(_n(v) for v in schedule) if isinstance(schedule, list) else None
|
|
818
|
+
facts = "<p>" + html.escape(describe_counts(counts)) + "</p><dl>"
|
|
819
|
+
for en, zh, value in [
|
|
820
|
+
("OPAL commit", "OPAL 提交", start.get("opal_commit") or result.get("opal_commit")),
|
|
821
|
+
("Agent model", "Agent 模型", model),
|
|
822
|
+
("Agent effort", "Agent 推理强度", start.get("effort") or ", ".join(effort) or None),
|
|
823
|
+
("Schedule (updates)", "计划决策的更新步", schedule),
|
|
824
|
+
("Blocking", "是否阻塞训练", start.get("blocking")),
|
|
825
|
+
("Maximum lag (updates)", "最大延迟(更新步)", start.get("max_lag_updates")),
|
|
826
|
+
("Training waiting time", "训练等待时间", _duration(waited) if waited is not None else None),
|
|
827
|
+
(
|
|
828
|
+
"Waiting for agent availability",
|
|
829
|
+
"等待 agent 恢复可用",
|
|
830
|
+
_duration(counts["unavailable_seconds"]) if any("unavailable_seconds" in e for e in events) else None,
|
|
831
|
+
),
|
|
832
|
+
(
|
|
833
|
+
"First / last recorded timestamp",
|
|
834
|
+
"首末记录时间戳",
|
|
835
|
+
" → ".join([_text(events[0].get("utc")), _text(events[-1].get("utc"))])
|
|
836
|
+
if events[0].get("utc") and events[-1].get("utc")
|
|
837
|
+
else None,
|
|
838
|
+
),
|
|
839
|
+
]:
|
|
840
|
+
facts += _fact(en, zh, value)
|
|
841
|
+
facts += "</dl>" + defaults_flag(events) + integrity_flag(events) + boundary_flag(events)
|
|
842
|
+
notable = [
|
|
843
|
+
e for e in events if e.get("kind") in ("resumed", "interrupted", "reverted", "crashed", "rank_divergence", "ranks_resynced", "state_dropped")
|
|
844
|
+
]
|
|
845
|
+
if notable:
|
|
846
|
+
facts += "<pre>" + html.escape(json.dumps(notable, indent=2, ensure_ascii=False)) + "</pre>"
|
|
847
|
+
at = max([u for u, _ in raw] + [e["update"] for e in events if _number(e.get("update"))] + [0])
|
|
848
|
+
live = not _finished(events)
|
|
849
|
+
if live:
|
|
850
|
+
facts = (
|
|
851
|
+
"<p>"
|
|
852
|
+
+ _bi(
|
|
853
|
+
f"Live: the run is at update {_n(at)}; this page reloads each minute.",
|
|
854
|
+
f"运行中:当前更新步 {_n(at)};此页每分钟刷新。",
|
|
855
|
+
)
|
|
856
|
+
+ "</p>"
|
|
857
|
+
+ facts
|
|
858
|
+
)
|
|
859
|
+
caption = _bi(
|
|
860
|
+
f"Numbers mark applied decisions; faint ticks mark kept decisions. Loss points average up to {bins} update(s); gaps mark non-finite or invalid losses. The vertical axis is not clipped.",
|
|
861
|
+
f"编号标记改动生效的更新步;淡刻度标记保留原程序的决策。每个损失点平均至多 {bins} 个更新步;非有限或无效损失处断开,纵轴不截断。",
|
|
862
|
+
)
|
|
863
|
+
page = render(
|
|
864
|
+
dict(
|
|
865
|
+
refresh='<meta http-equiv="refresh" content="60">\n' if live else "",
|
|
866
|
+
goal=html.escape(_text(start.get("goal"))),
|
|
867
|
+
overview=rank_flag(events) + _overview(rows, counts, start, raw, metrics, cost, summary_cost, span, waited),
|
|
868
|
+
caption=caption,
|
|
869
|
+
table=_table(rows),
|
|
870
|
+
decisions="".join(blocks) or _bi("No decisions recorded.", "未记录决策。"),
|
|
871
|
+
diffs="".join(diffs) or _bi("No applied code diffs recorded.", "未记录已应用的代码改动。"),
|
|
872
|
+
facts=facts,
|
|
873
|
+
losses=_js(losses),
|
|
874
|
+
ticks=_js(ticks),
|
|
875
|
+
small=_js(small),
|
|
876
|
+
total=_js(start.get("total_updates") if _number(start.get("total_updates")) else None),
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
out = run / "report.html"
|
|
880
|
+
tmp = run / f".report.html.{os.getpid()}.{threading.get_ident()}.tmp"
|
|
881
|
+
try:
|
|
882
|
+
tmp.write_text(page)
|
|
883
|
+
os.replace(tmp, out)
|
|
884
|
+
finally:
|
|
885
|
+
tmp.unlink(missing_ok=True)
|
|
886
|
+
return out
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def summarize_missing(run, summarizer, log=print) -> int:
|
|
890
|
+
"""Make the summaries a run's code changes lack (one call each); returns how many failed."""
|
|
891
|
+
run, failed = Path(run), 0
|
|
892
|
+
rows = [r for r in decision_rows(run) if r["summary"] is None]
|
|
893
|
+
for i, r in enumerate(rows):
|
|
894
|
+
rec = run / "decisions" / f"{r['k']:03d}"
|
|
895
|
+
if not r.get("snapshots_available"):
|
|
896
|
+
failed += 1
|
|
897
|
+
log(f"decision {r['k']}: summary unavailable (program snapshots missing)")
|
|
898
|
+
continue
|
|
899
|
+
out = summaries.ensure(rec, summarizer, r["k"], r["rationale"], r["old"], r["new"], r["total"])
|
|
900
|
+
failed += out is None
|
|
901
|
+
log(f"decision {r['k']}: {'summary failed' if out is None else 'summarised'} ({i + 1} of {len(rows)})")
|
|
902
|
+
return failed
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
class LiveReport:
|
|
906
|
+
"""Rank 0's report writer, in a thread of its own so that neither the report nor a summary call runs in the
|
|
907
|
+
training step or holds up the next decision: it makes the summaries of new code changes (when it has a
|
|
908
|
+
summarizer) and rewrites report.html after each decision and at the end. Failures never reach training."""
|
|
909
|
+
|
|
910
|
+
def __init__(self, run_dir, summarizer=None, log=print, idle=30.0):
|
|
911
|
+
self.dir, self.summarizer, self.log, self.idle = Path(run_dir), summarizer, log, idle
|
|
912
|
+
self.q, self.lock, self.thread, self._warned = queue.Queue(), threading.Lock(), None, set()
|
|
913
|
+
self.write_lock = threading.Lock()
|
|
914
|
+
|
|
915
|
+
def summarize(self, k, rationale, old, new, total=None):
|
|
916
|
+
if self.summarizer is not None:
|
|
917
|
+
self._guard(
|
|
918
|
+
"summary", self._put, ("summary", dict(k=k, rationale=rationale, old=old, new=new, total=total))
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
def refresh(self):
|
|
922
|
+
self._guard("report", self._put, ("report", None))
|
|
923
|
+
|
|
924
|
+
def finish(self, timeout=120.0):
|
|
925
|
+
"""At exit: wait for queued summaries and the last report; past `timeout`, write the report without them."""
|
|
926
|
+
self._guard("report", self._finish, timeout)
|
|
927
|
+
|
|
928
|
+
def _finish(self, timeout):
|
|
929
|
+
done = threading.Event()
|
|
930
|
+
self._put(("report", None))
|
|
931
|
+
self._put(("done", done))
|
|
932
|
+
if not done.wait(timeout):
|
|
933
|
+
self._write()
|
|
934
|
+
|
|
935
|
+
def _put(self, job):
|
|
936
|
+
with self.lock:
|
|
937
|
+
self.q.put(job)
|
|
938
|
+
if self.thread is None:
|
|
939
|
+
self.thread = threading.Thread(target=self._loop, name="opal-report", daemon=True)
|
|
940
|
+
try:
|
|
941
|
+
self.thread.start()
|
|
942
|
+
except (Exception, SystemExit):
|
|
943
|
+
self.thread = None
|
|
944
|
+
raise
|
|
945
|
+
|
|
946
|
+
def _loop(self):
|
|
947
|
+
try:
|
|
948
|
+
self._guard("report", self._work)
|
|
949
|
+
finally:
|
|
950
|
+
# An unexpected queue/thread failure must release finish() too. Future refreshes can retry.
|
|
951
|
+
with self.lock:
|
|
952
|
+
if self.thread is threading.current_thread():
|
|
953
|
+
self.thread = None
|
|
954
|
+
while not self.q.empty():
|
|
955
|
+
kind, job = self.q.get_nowait()
|
|
956
|
+
if kind == "done":
|
|
957
|
+
job.set()
|
|
958
|
+
|
|
959
|
+
def _work(self):
|
|
960
|
+
while True:
|
|
961
|
+
try:
|
|
962
|
+
jobs = [self.q.get(timeout=self.idle)]
|
|
963
|
+
except queue.Empty:
|
|
964
|
+
with self.lock:
|
|
965
|
+
if self.q.empty():
|
|
966
|
+
self.thread = None
|
|
967
|
+
return
|
|
968
|
+
continue
|
|
969
|
+
while not self.q.empty():
|
|
970
|
+
jobs.append(self.q.get_nowait())
|
|
971
|
+
for kind, job in jobs:
|
|
972
|
+
if kind == "summary":
|
|
973
|
+
self._guard("summary", self._summary, job)
|
|
974
|
+
if any(kind != "done" for kind, _ in jobs):
|
|
975
|
+
self._write()
|
|
976
|
+
for kind, job in jobs:
|
|
977
|
+
if kind == "done":
|
|
978
|
+
job.set()
|
|
979
|
+
|
|
980
|
+
def _summary(self, job):
|
|
981
|
+
summaries.ensure(self.dir / "decisions" / f"{job['k']:03d}", self.summarizer, **job)
|
|
982
|
+
|
|
983
|
+
def _write(self):
|
|
984
|
+
self._guard("report", self._write_if_started)
|
|
985
|
+
|
|
986
|
+
def _write_if_started(self):
|
|
987
|
+
if (self.dir / "events.jsonl").exists(): # not before the run has started
|
|
988
|
+
with self.write_lock:
|
|
989
|
+
self._guard("report", write_report, self.dir)
|
|
990
|
+
|
|
991
|
+
def _guard(self, what, fn, *args):
|
|
992
|
+
try:
|
|
993
|
+
return fn(*args)
|
|
994
|
+
except (Exception, SystemExit) as exc: # noqa: BLE001 - the report is never worth a failed run
|
|
995
|
+
if what not in self._warned:
|
|
996
|
+
self._warned.add(what)
|
|
997
|
+
try:
|
|
998
|
+
message = " ".join(str(exc).split())
|
|
999
|
+
self.log(f"could not write the {what} ({type(exc).__name__}: {message}); training is not affected")
|
|
1000
|
+
except (Exception, SystemExit):
|
|
1001
|
+
pass # even a broken log callback must not affect training
|