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.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. 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