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/wrapper.py ADDED
@@ -0,0 +1,1674 @@
1
+ """The wrapper: a torch optimizer whose update is a small program that a coding agent rewrites while training runs.
2
+
3
+ This file is the main loop: build and swap programs, step, check, ask the agent. Checkpoints, recovery from a failing
4
+ program, DDP collectives and one-time setup live in opal/misc/.
5
+
6
+ Cost rules (DESIGN.md section 9): training waits for the agent at each decision by default (blocking=True); with
7
+ blocking=False it waits only when the loss turns non-finite, with "auto" only when an answer would land late; an
8
+ unchanged answer costs nothing, and an analysis-only change never rebuilds the updater; the wrapper
9
+ adds no per-update host sync (losses are copied into a device buffer and read at check steps, every `every` updates)
10
+ and, in DDP, no per-update collective outside the short trial window after a swap (swaps are agreed every `sync_every`
11
+ updates over a separate gloo group, asynchronously; one small broadcast per check step); no hooks touch the model
12
+ unless activations are requested.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import atexit
18
+ import copy
19
+ import datetime
20
+ import json
21
+ import math
22
+ import numpy as np
23
+ import shutil
24
+ import sys
25
+ import tempfile
26
+ import time
27
+ import traceback
28
+ import types
29
+ import weakref
30
+ from collections import deque
31
+ from pathlib import Path
32
+
33
+ import torch
34
+ import torch.distributed as dist
35
+ from torch.distributed.algorithms.join import Join, Joinable
36
+
37
+ from . import context, defaults, estimate, programs
38
+ from .agent import AgentWorker, Run, new_run_dir, now_stamp
39
+ from .backends import make_backend
40
+ from .backends._privacy import redact_secrets
41
+ from .misc import audit, boundary, integrity, neutral_analysis, waiting
42
+ from .misc.checkpoint import CheckpointMixin
43
+ from .misc.ddp import DDPMixin
44
+ from .misc.precision import PrecisionMixin
45
+ from .misc.recovery import RecoveryMixin
46
+ from .misc.schedulers import ScheduleMixin
47
+ from .misc.schedulers import _rebuild_scheduler # noqa: F401 (checkpoints of v0.1.0 pickle this path)
48
+ from .misc.setup import SetupMixin
49
+ from .misc.setup import resolve_prompt as setup_prompt
50
+ from .misc.skip import SkipMixin, takes_ctx
51
+ from .report import LiveReport
52
+ from . import schedule as decision_schedule
53
+ from .summaries import Summarizer
54
+
55
+
56
+ class _AccelerateCheckpointAdapter:
57
+ """Keep Accelerate's public optimizer API on the original optimizer; adapt checkpoint I/O only."""
58
+
59
+ def __init__(self, optimizer, opal):
60
+ object.__setattr__(self, "_optimizer", optimizer)
61
+ object.__setattr__(self, "_opal", opal)
62
+
63
+ def __getattr__(self, name):
64
+ return getattr(self._optimizer, name)
65
+
66
+ def __setattr__(self, name, value):
67
+ setattr(self._optimizer, name, value)
68
+
69
+ def state_dict(self):
70
+ return self._opal.state_dict()
71
+
72
+ def load_state_dict(self, state):
73
+ if state.get("opal") == 1:
74
+ return self._opal.load_state_dict(state)
75
+ if "state" in state and "param_groups" in state:
76
+ key = "accelerate-legacy-state"
77
+ if key not in self._opal._warned:
78
+ self._opal._warned.add(key)
79
+ print(
80
+ "[opal] WARNING: the legacy Accelerate checkpoint contains only the original optimizer state; "
81
+ "it holds no OPAL program or progress. Load a separately saved OPAL state_dict to restore them.",
82
+ file=sys.stderr, flush=True,
83
+ )
84
+ with programs.keep_param_group_identity():
85
+ return self._optimizer.load_state_dict(state)
86
+ return self._opal.load_state_dict(state)
87
+
88
+
89
+ # ----- public entry points ------------------------------------------------------------------------------------
90
+
91
+
92
+ def wrap(optimizer, model, *, total_steps, goal, scheduler=None, decisions=None, schedule="front", **kw):
93
+ """Wrap a torch optimizer (or a list of them) so that a coding agent can rewrite the update during training.
94
+
95
+ optimizer the training script's optimizer(s); the starting program calls them unchanged. With Accelerate,
96
+ wrap after accelerator.prepare (the torch optimizer inside is wrapped; no GradScaler, no
97
+ accumulation through Accelerate) and call the wrapped optimizer's step(loss=...). The prepared
98
+ optimizer delegates checkpoint save/load to OPAL, including accelerator.save_state/load_state
99
+ model the model being trained, bare or wrapped by DDP / torch.compile (names, shapes, source code)
100
+ total_steps number of updates in the run (opt.step() calls that apply an update)
101
+ goal one sentence: what the run trains and what it is judged by
102
+ scheduler the training script's LR scheduler(s); their step() runs while the program follows them
103
+ backend "claude" (default), "codex", "mailbox:<dir>", "keep" (no agent, every decision keeps the
104
+ programs), or a function(workspace, message, system) -> rationale str that may edit the files
105
+ backend_options claude: model= (default "claude-opus-5-5") and effort= (default "high"), the agent of the blog's
106
+ runs; another model or effort prints one line at the start saying the blog's results may not be
107
+ reproduced with it. unavailable_timeout= (claude and mailbox; default None, no deadline): an agent
108
+ that cannot work at all (not logged in, a usage or spend limit, the API overloaded, no network,
109
+ no broker answering the mailbox) is waited for, with a line when the wait starts and ends, and the
110
+ same decision goes on once it is back; with a number of seconds the decision fails after that
111
+ long. timeout= (seconds per decision), retries= (a failed call is made again
112
+ after backoff=15 s, then twice as long, ... at most backoff_max=600 s apart, while the decision's
113
+ timeout allows; None, the default: as often as it allows; 0: never), shell_timeout= (seconds per
114
+ shell command of the agent); mailbox: poll=, broker_wait= (seconds without a broker before it
115
+ counts as unavailable), timeout=
116
+ decisions how many times the agent is asked over the run (default 50). Each time it may change the programs
117
+ or keep everything as it is: this is the number of chances it gets, not the number of changes it
118
+ makes. Its analysis may ask for more (wake-ups); max_decisions (default: twice the scheduled
119
+ ones) caps them all. A short run gets fewer (decisions fall on checks, see every). Not set, a run
120
+ whose training waits for the agent asks at most as many as keep the expected waiting within 4x
121
+ its own training time (measured at its first check; at least one, in the same schedule), and says
122
+ so; a decisions you set is kept, with a line when its waiting is above 4x (opal/estimate.py;
123
+ `opal estimate` before launch)
124
+ schedule when those decisions are asked (not the learning-rate schedule, which is `scheduler`):
125
+ "front" (default): a fifth of them evenly over the first 5% of training, the rest evenly after;
126
+ "uniform": every total_steps // decisions updates; [(0.05, 20), (1.0, 30)]: segments of
127
+ (end as a fraction of training, count), here 20 evenly over the first 5% and then 30 evenly over
128
+ the rest; [500, 1000, 5000]: these updates. A list gives the count itself (leave decisions
129
+ out, or make it agree). Each is moved up to the next check (opal/schedule.py)
130
+ max_cost_usd stop asking when the remaining USD budget cannot cover another estimated call (since 0.3.4);
131
+ training continues with the current programs. Codex reports tokens and cannot enforce a USD cap
132
+ every how often OPAL looks at the run, in updates (default 50). At each check it reads the training
133
+ losses, looks for a non-finite one, asks a scheduled decision when one is due (decisions happen
134
+ only at checks), and runs the agent's analysis program (whose own interval, EVERY, the agent may
135
+ set). Between checks OPAL only runs the update
136
+ blocking True (default): training waits at each decision. False: it continues while the agent thinks.
137
+ "auto": it waits only when an answer would land more than max_lag_updates (50) updates late,
138
+ judged from the time per update and the agent's recent answer times, and at the first decision
139
+ share_code False keeps the training script and model source out of what the agent (and its provider) sees
140
+ clip_grad_norm if your loop clips gradients, hand the clipping to OPAL: delete the loop's
141
+ clip_grad_norm_(model.parameters(), x) and pass clip_grad_norm=x. The starting program then clips
142
+ the same way (the run starts identical, bitwise when the optimizer holds all of the model's
143
+ trainable parameters), and the agent, which sees the unclipped gradients, may change it. Clipping
144
+ left in the loop happens outside the optimizer: the agent's program gets clipped gradients and
145
+ cannot change the threshold
146
+ run_dir where the run's records go (default ./opal_runs/<time>-<pid>-<random>); report.html there is
147
+ rewritten after each decision and at the end
148
+ summarize True (default): with backend "claude", a language model (claude-opus-5-5 at low effort; a model
149
+ name here picks another) describes and tags each code change for the report, in a background thread. False: off
150
+ mode "code" (default, and the only mode): the agent writes the update rule. "knobs" (a fixed updater
151
+ with a menu of settings, up to 0.3.3) is not supported in this release and raises
152
+ Advanced: sync_every (20), trial_steps (20), rank_check_every (100; also after the last update), pause_on_nonfinite (True),
153
+ activations (module names to capture, eager models only), prompt ("neutral", the default; "aggressive", the
154
+ neutral prompt with a review of every group's hyperparameters at each decision (misc/aggressive.py); or the path of
155
+ a template file, whose {fields} are filled as in prompts/neutral.md), program (replay folder),
156
+ epochs, train_size, batch_size (ints or strings such as "223M tokens"), same_order_each_epoch: facts the
157
+ neutral prompt states in its TASK paragraph (a fact not given is left out),
158
+ snapshot_limit_gb (2.0: largest CPU copy kept to undo a failing update exactly).
159
+ validation OPAL 0.1.1-validation: N (an int) means the training script evaluates the model on a
160
+ development set held out from training about every N updates, at times it does not announce, and
161
+ reports it with opt.report_dev(loss=..., accuracy=...). The agent sees every measurement
162
+ (obs.metrics, and a line with their history in each decision message) and the neutral prompt says
163
+ so in its TASK paragraph ("about every N updates, at times that are not announced");
164
+ validation_size (e.g. "5,000 images") names the set's size there. None (default): off, and the
165
+ prompt is unchanged. N and the irregular times are your promise: OPAL does not run the
166
+ evaluation (examples/overfit draws one time at random in each epoch), and warns you if the times
167
+ are regular. Needs a prompt that states it (the neutral one, or a template with
168
+ {validation_note}). Never report the test set the run is judged by this way.
169
+ eval_integrity 0.2.2: check that the program does not move the parameters to another point for a development
170
+ evaluation and move them back after it (misc/integrity.py). None (default): on with validation=N;
171
+ True/False: on/off. A run that does is flagged (an eval_integrity event, the report, stderr); the
172
+ agent is not told of the check. eval_integrity_threshold (5): how many times a tensor's typical
173
+ change per update a jump must be. Cost: one sampled copy per update (at most 32K entries).
174
+ """
175
+ return AgentOptimizer(
176
+ optimizer,
177
+ model,
178
+ total_steps=total_steps,
179
+ goal=goal,
180
+ scheduler=scheduler,
181
+ decisions=decisions,
182
+ schedule=schedule,
183
+ **kw,
184
+ )
185
+
186
+
187
+ def replay(program, optimizer, model, *, total_steps, scheduler=None, **kw):
188
+ """Run a saved program with no agent: a run folder (its final program) or a programs/vNNN folder. Returns an
189
+ AgentOptimizer used like the wrapped one (opt.step(loss=...)); it writes its own run folder."""
190
+ return AgentOptimizer(
191
+ optimizer, model, total_steps=total_steps, goal="", scheduler=scheduler, program=program, backend=None, **kw
192
+ )
193
+
194
+
195
+ def final_program(path) -> Path:
196
+ path = Path(path)
197
+ if (path / "updater.py").exists():
198
+ return path
199
+ if (path / "FINAL").exists():
200
+ return path / "programs" / (path / "FINAL").read_text().strip()
201
+ versions = sorted((p for p in (path / "programs").glob("v*") if p.name[1:].isdigit()),
202
+ key=lambda p: int(p.name[1:]))
203
+ if not versions:
204
+ raise FileNotFoundError(f"no program found in {path}")
205
+ return versions[-1]
206
+
207
+
208
+ WAKE = "your analysis asked for a decision: "
209
+ MAX_REASONS = 10
210
+
211
+
212
+ def _reasons_shown(reasons):
213
+ """At most MAX_REASONS reasons: the scheduled decision, a non-finite loss and skip alarms always, then the most
214
+ recent wake-up requests of the analysis, and how many earlier ones are left out."""
215
+ reasons = list(dict.fromkeys(reasons))
216
+ if len(reasons) <= MAX_REASONS:
217
+ return reasons
218
+ keep = [r for r in reasons if not r.startswith(WAKE)][:MAX_REASONS]
219
+ wakes = [r for r in reasons if r.startswith(WAKE)]
220
+ room = MAX_REASONS - len(keep)
221
+ shown = keep + (wakes[-room:] if room > 0 else [])
222
+ return shown + [f"{len(wakes) - max(room, 0)} earlier requests from your analysis, not listed"]
223
+
224
+
225
+ def _plain_json(obj, bad, depth=0):
226
+ """A report as plain JSON values: one-element tensors and numpy scalars become numbers, small tensors lists;
227
+ anything else becomes its text, and its type is noted in `bad`."""
228
+ if obj is None or isinstance(obj, (bool, int, float, str)):
229
+ return obj
230
+ if depth > 20:
231
+ bad.append("nesting deeper than 20")
232
+ return str(obj)
233
+ if torch.is_tensor(obj):
234
+ if obj.numel() == 1:
235
+ return obj.detach().reshape(()).item()
236
+ if obj.numel() <= 10_000:
237
+ return obj.detach().cpu().tolist()
238
+ bad.append(f"a tensor of {obj.numel()} elements")
239
+ return f"tensor{tuple(obj.shape)}"
240
+ if isinstance(obj, dict):
241
+ return {str(k): _plain_json(v, bad, depth + 1) for k, v in obj.items()}
242
+ if isinstance(obj, (list, tuple)):
243
+ return [_plain_json(v, bad, depth + 1) for v in obj]
244
+ item = getattr(obj, "item", None)
245
+ if callable(item) and hasattr(obj, "dtype"): # numpy scalars
246
+ try:
247
+ return _plain_json(item(), bad, depth + 1)
248
+ except Exception:
249
+ pass
250
+ bad.append(type(obj).__name__)
251
+ return str(obj)
252
+
253
+
254
+ def _rounded(recs):
255
+ return [
256
+ {k: (float(f"{v:.4g}") if isinstance(v, float) else v) for k, v in r.items() if k != "tensor"} for r in recs
257
+ ]
258
+
259
+
260
+ def _weak_log(ref):
261
+ """The optimizer's log for objects that outlive it (the report writer stays registered at exit)."""
262
+ return lambda msg: ref() is not None and ref()._log(msg)
263
+
264
+
265
+ def _is_accelerate(obj):
266
+ return type(obj).__module__.startswith("accelerate.") and type(obj).__name__ in (
267
+ "AcceleratedOptimizer",
268
+ "AcceleratedScheduler",
269
+ )
270
+
271
+
272
+ def _inside_accelerate(opt):
273
+ """The torch optimizer inside Accelerate's AcceleratedOptimizer (wrap after accelerator.prepare): OPAL wraps that
274
+ one, and the loop calls the wrapped optimizer's step(). That is exact when Accelerate neither scales the gradients
275
+ (a GradScaler, for fp16) nor accumulates them over several step() calls; those are refused."""
276
+ if not _is_accelerate(opt):
277
+ return opt
278
+ if getattr(opt, "scaler", None) is not None:
279
+ raise ValueError(
280
+ "opal: this AcceleratedOptimizer scales fp16 gradients with a GradScaler, which OPAL cannot wrap exactly; "
281
+ "use bf16 or fp32 mixed precision with Accelerate"
282
+ )
283
+ steps = getattr(getattr(opt, "gradient_state", None), "num_steps", 1) or 1
284
+ if steps > 1:
285
+ raise ValueError(
286
+ f"opal: Accelerate accumulates gradients over {steps} step() calls here (gradient_accumulation_steps), "
287
+ "and OPAL's step() is an update each time; accumulate in your loop instead and call opt.step(loss=...) "
288
+ "once per update (Accelerator(gradient_accumulation_steps=1))"
289
+ )
290
+ return opt.optimizer
291
+
292
+
293
+ def _close_at_exit(ref):
294
+ opt = ref()
295
+ if opt is not None:
296
+ try:
297
+ opt.close(interrupted=opt.update < opt.total)
298
+ except Exception:
299
+ pass
300
+
301
+
302
+ class AgentOptimizer(
303
+ CheckpointMixin,
304
+ RecoveryMixin,
305
+ ScheduleMixin,
306
+ DDPMixin,
307
+ PrecisionMixin,
308
+ SetupMixin,
309
+ SkipMixin,
310
+ torch.optim.Optimizer,
311
+ Joinable,
312
+ ):
313
+ def __init__(
314
+ self,
315
+ optimizer,
316
+ model,
317
+ *,
318
+ total_steps,
319
+ goal,
320
+ scheduler=None,
321
+ backend="claude",
322
+ backend_options=None,
323
+ decisions=None,
324
+ max_decisions=None,
325
+ max_cost_usd=None,
326
+ blocking=True,
327
+ max_lag_updates=50,
328
+ pause_on_nonfinite=True,
329
+ every=50,
330
+ sync_every=20,
331
+ trial_steps=20,
332
+ rank_check_every=100,
333
+ activations=None,
334
+ run_dir=None,
335
+ prompt=None,
336
+ program=None,
337
+ share_code=True,
338
+ schedule="front",
339
+ show_next_decision=None,
340
+ task_facts=None,
341
+ epochs=None,
342
+ train_size=None,
343
+ batch_size=None,
344
+ same_order_each_epoch=None,
345
+ snapshot_limit_gb=2.0,
346
+ summarize=True,
347
+ clip_grad_norm=None,
348
+ mode="code",
349
+ validation=None,
350
+ validation_size=None,
351
+ eval_integrity=None,
352
+ eval_integrity_threshold=5.0,
353
+ verbose=True,
354
+ ):
355
+ Joinable.__init__(self)
356
+ self.user_optimizers = list(optimizer) if isinstance(optimizer, (list, tuple)) else [optimizer]
357
+ accelerated = [o for o in self.user_optimizers if _is_accelerate(o)]
358
+ self.user_optimizers = [_inside_accelerate(o) for o in self.user_optimizers]
359
+ self._user_parameter_ids = self._parameter_ids()
360
+ if scheduler is None:
361
+ self.user_schedulers = []
362
+ else:
363
+ self.user_schedulers = list(scheduler) if isinstance(scheduler, (list, tuple)) else [scheduler]
364
+ # Accelerate's AcceleratedScheduler steps the torch scheduler inside it, which OPAL follows
365
+ self.user_schedulers = [getattr(s, "scheduler", s) if _is_accelerate(s) else s for s in self.user_schedulers]
366
+ self.model, self.total, self.goal = model, int(total_steps), goal
367
+ self._share_code = bool(share_code)
368
+ self._clip_grad_norm = clip_grad_norm
369
+ if mode == "knobs":
370
+ raise ValueError('opal: mode="knobs" is not supported in this release; mode="code" (the default) is the only mode')
371
+ if mode != "code":
372
+ raise ValueError(f'opal: mode must be "code", got {mode!r}')
373
+ prompt = setup_prompt(prompt, validation if backend is not None else None)
374
+ if prompt is None:
375
+ prompt = "neutral" # the default prompt
376
+ self._aggressive = prompt == "aggressive" # OPAL 0.1.2-aggressive: the neutral prompt, edited
377
+ if show_next_decision is None: # on by default with the neutral prompts
378
+ show_next_decision = prompt in ("neutral", "aggressive")
379
+ self._show_next, self._task_facts = bool(show_next_decision), task_facts
380
+ n_decisions = decision_schedule.count(schedule, decisions) # raises on a bad schedule or a mismatch
381
+ self._schedule_kind = decision_schedule.kind(schedule) # "front", "uniform", "segments", "updates"
382
+ # decisions not set (and no list schedule): the first check may lower the count (opal/estimate.py)
383
+ self._may_cap = decisions is None and self._schedule_kind in ("front", "uniform")
384
+ self._max_decisions_given = max_decisions is not None
385
+ self._neutral = prompt in ("neutral", "aggressive")
386
+ # the neutral analysis compares with one epoch earlier only when the data repeat in the same order
387
+ self._neutral_epochs = epochs if same_order_each_epoch is True and isinstance(epochs, (int, float)) else None
388
+ self._run_facts = dict( # the neutral prompt's TASK sentence (misc/taskfacts.py)
389
+ epochs=epochs, train_size=train_size, batch_size=batch_size, same_order=same_order_each_epoch
390
+ )
391
+ if self.total < 1:
392
+ raise ValueError("opal: total_steps must be the number of updates in the run (>= 1)")
393
+ if backend is not None and not (isinstance(goal, str) and goal.strip()):
394
+ raise ValueError("opal: goal must say in one sentence what the run trains and what it is judged by")
395
+ if int(every) < 1 or int(sync_every) < 1:
396
+ raise ValueError("opal: every and sync_every must be >= 1")
397
+ self.mode = mode
398
+ if validation is not None and (
399
+ isinstance(validation, bool) or not isinstance(validation, int) or validation < 1
400
+ ):
401
+ raise ValueError(
402
+ "opal: validation must be None (off) or the number of updates between development evaluations "
403
+ f"(an int >= 1), got {validation!r}"
404
+ )
405
+ self._validation, self._validation_size = validation, validation_size # OPAL 0.1.1-validation
406
+ self._dev = [] # the development measurements so far: dict(update=, loss=, accuracy=)
407
+ if eval_integrity not in (None, True, False):
408
+ raise ValueError(
409
+ f"opal: eval_integrity must be None (on with validation), True or False, got {eval_integrity!r}"
410
+ )
411
+ if isinstance(eval_integrity_threshold, bool) or not (
412
+ isinstance(eval_integrity_threshold, (int, float)) and eval_integrity_threshold > 1
413
+ ):
414
+ raise ValueError(
415
+ f"opal: eval_integrity_threshold must be a number > 1, got {eval_integrity_threshold!r}"
416
+ )
417
+ self._integrity_on = validation is not None if eval_integrity is None else eval_integrity
418
+ self._integrity_threshold = float(eval_integrity_threshold)
419
+ self._integrity = None # misc/integrity.py, rank 0 (set up once the starting program is installed)
420
+ self.named = context.named_params(model, self.user_optimizers)
421
+ if not self.named:
422
+ raise ValueError("opal: the optimizer has no parameters of this model")
423
+ self._record_dtypes()
424
+ super().__init__([{"params": [p for _, p in self.named], "lr": float("nan")}], {})
425
+ self._own_groups = self.param_groups
426
+ self._initialised = True
427
+ self.device = self.named[0][1].device
428
+
429
+ self.dist = dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1
430
+ self.rank = dist.get_rank() if self.dist else 0
431
+ self.world = dist.get_world_size() if self.dist else 1
432
+ self.ctrl = dist.new_group(backend="gloo", timeout=datetime.timedelta(hours=12)) if self.dist else None
433
+ self.rank0 = self.rank == 0
434
+ self.verbose = verbose and self.rank0
435
+
436
+ if isinstance(blocking, str) and blocking != "auto":
437
+ raise ValueError(f'opal: blocking must be False, True or "auto", got {blocking!r}')
438
+ self.blocking, self.pause_on_nonfinite = (blocking != "auto" and bool(blocking), bool(pause_on_nonfinite))
439
+ self._auto = waiting.AutoWait(max_lag_updates) if blocking == "auto" else None # misc/waiting.py
440
+ self._block_now = False # this check's decision waits (blocking="auto")
441
+ self.default_every, self.sync_every = int(every), int(sync_every)
442
+ self.trial_steps, self.rank_check_every = (int(trial_steps), int(rank_check_every))
443
+ self.max_cost = max_cost_usd
444
+ self._cost_said = False # the line that max_cost_usd is reached was printed
445
+ self.snapshot_limit = snapshot_limit_gb * 2**30
446
+ # the scheduled decision updates, one list per segment (the prompt describes each), and all of them
447
+ self._schedule_segments = (
448
+ decision_schedule.decision_segments(self.total, n_decisions, self.default_every, schedule)
449
+ if backend is not None
450
+ else [[]]
451
+ )
452
+ self.schedule = [u for seg in self._schedule_segments for u in seg]
453
+ self.max_decisions = max_decisions if max_decisions is not None else 2 * len(self.schedule)
454
+
455
+ self.update = 0
456
+ self.version = 0
457
+ self.memory = {}
458
+ self.errors = deque(maxlen=50)
459
+ self.reports, self.metrics, self._metrics_new, self._shown_new = deque(maxlen=400), [], [], []
460
+ self._float_losses, self._loss_n = [], 0
461
+ self._loss_buf = torch.zeros(max(1, self.default_every), dtype=torch.float32, device=self.device)
462
+ self._losses_from = 1
463
+ self._pending_losses, self._pending_from = [], 1
464
+ self._pending_omitted = 0
465
+ self._analysis_losses, self._analysis_metrics, self._next_analysis = ([], [], self.default_every)
466
+ self._reasons, self._sched_i = [], 0
467
+ self._nonfinite_episode = False
468
+ self._last_applied, self._last_answer = None, None
469
+ self._prev_files, self._prev_updater, self._trial_until = None, None, 0
470
+ self._snapshot, self._hypers, self._first_pending = None, None, False
471
+ self._rank_snapshot, self._rank_trial_equal = None, False
472
+ self._rank_checked_at = None
473
+ self._ranks_repaired_at = []
474
+ # the tags (programs.load) of the running updater's and analysis' modules and of the fallback updater's; other
475
+ # versions' modules are dropped from sys.modules (_forget_retired)
476
+ self._updater_tag = self._analysis_tag = self._prev_tag = None
477
+ self._program_tags = set()
478
+ self.updater = None
479
+ self._ledger, self._install_rec, self._build_changes = None, None, None # misc/schedulers.py
480
+ self._resume_pending, self._observed_loss = None, None
481
+ self._in_updater = False
482
+ self._flag_work = None
483
+ self._timing = dict(
484
+ t=None,
485
+ u=None,
486
+ wall_ms_per_update=None,
487
+ program_ms=None,
488
+ program_ms_start=None,
489
+ analysis_ms=None,
490
+ audit=None,
491
+ audit_start=None,
492
+ )
493
+ self._audit_at = 2 # profile the second update of each new updater (rank 0)
494
+ self._step_loss, self._step_ctx, self._skips_in_a_row, self._skipped, self._skip_alarm = (
495
+ None,
496
+ False,
497
+ 0,
498
+ [],
499
+ False,
500
+ )
501
+ self._events = None
502
+ self._paused_s = 0.0 # seconds training waited for the agent since the last check (rank 0)
503
+ self._reports_made = 0 # analysis reports since the previous decision (the inbox keeps the last 400)
504
+ self._capture_next = False
505
+ self._estimate_due, self._estimated, self._schedule_changed = False, False, False # opal/estimate.py
506
+ self._t_mark, self._steps_here = None, 0 # (update, time) from which this run's time per update is measured
507
+ self._loss_hook = None # fn() -> this update's loss, for a framework that calls step() without it (opal/hf.py)
508
+ self.agent_wait_seconds = 0.0 # seconds training waited for the agent in this process (rank 0; opal/results.py)
509
+ self._closed = False
510
+ self._resume_note = ""
511
+ self._warned = set()
512
+ self.activations = {}
513
+
514
+ # run folder and records (rank 0); other ranks keep program files in a private temporary folder
515
+ if self.rank0:
516
+ self._auto_run_dir = run_dir is None
517
+ self.run = Run(Path(run_dir) if run_dir else new_run_dir(Path("opal_runs")))
518
+ self._prog_root = self.run.programs
519
+ self._live = LiveReport(self.run.dir, log=_weak_log(weakref.ref(self)))
520
+ atexit.register(self._live.finish) # runs after _close_at_exit (registered below): the last report
521
+ else:
522
+ self._live = None
523
+ self._auto_run_dir = False
524
+ self.run = None
525
+ self._prog_root = Path(tempfile.mkdtemp(prefix=f"opal-rank{self.rank}-"))
526
+
527
+ names_by_id = {id(p): n for n, p in self.named}
528
+ if program is not None:
529
+ files = programs.program(programs.read_dir(final_program(program)))
530
+ else:
531
+ files = programs.starting_files(
532
+ self.user_optimizers,
533
+ self.user_schedulers,
534
+ names_by_id,
535
+ self.default_every,
536
+ clip=clip_grad_norm,
537
+ sources=share_code,
538
+ )
539
+ if self._neutral: # its own starting analysis (misc/neutral_analysis.py)
540
+ files["analysis.py"] = neutral_analysis.text(self.default_every, self._neutral_epochs)
541
+ if self.dist: # every rank runs rank 0's program text (descriptions of user objects may differ per rank)
542
+ box = [files, str(self.run.dir) if self.rank0 else None]
543
+ dist.broadcast_object_list(box, src=0, group=self.ctrl)
544
+ files, self._run_path = box[0], Path(box[1])
545
+ else:
546
+ self._run_path = self.run.dir
547
+ self._starting_files = files
548
+ self._starting_digest = programs.side_digests(files)[0]
549
+ err = self._install(files, previous=None, first=True)
550
+ if err:
551
+ raise RuntimeError(f"opal: the starting program failed to build:\n{err}")
552
+ self._new_install_record()
553
+ self._patch_schedulers()
554
+ self._hook_user_optimizers()
555
+ self._watch(activations)
556
+ if self._integrity_on and self.rank0:
557
+ self._integrity = integrity.Integrity(self.named, self._integrity_threshold)
558
+ self._integrity_record(0) # the starting parameters
559
+
560
+ self.worker = None
561
+ self._backend_label = "none"
562
+ if backend is not None and self.rank0:
563
+ be = make_backend(backend, **(backend_options or {}))
564
+ be.preflight()
565
+ note = be.reproduction_note() if hasattr(be, "reproduction_note") else None
566
+ if note: # another model or effort than the blog's (backends/claude.py)
567
+ self._log(note)
568
+ if share_code:
569
+ refs = context.reference_files(model, self.user_optimizers, self.user_schedulers)
570
+ for name, text in refs.items():
571
+ (self.run.workspace / "reference" / name).write_text(text)
572
+ self._script_copied = any(n.startswith("training_script_") for n in refs) # not in a notebook
573
+ self._agent_shell = getattr(be, "sandbox", None) # False: the agent has file tools only
574
+ self._decision_timeout = getattr(be, "timeout", None) # the neutral prompt states the limit
575
+ self._prompt_name, self._names_by_id = prompt, names_by_id # to write the prompt again (_set_schedule)
576
+ self.worker = AgentWorker(self.run, be, self._system_prompt(prompt, names_by_id), log=self._log)
577
+ # the estimate of the waiting and the cost at the first check, measured from this run (opal/estimate.py)
578
+ self._estimate_due = getattr(be, "name", None) in ("claude", "mailbox")
579
+ self.worker.after_decision = self._after_decision
580
+ self._backend_label = backend if isinstance(backend, str) else getattr(be, "name", "custom")
581
+ if summarize and getattr(be, "name", None) == "claude":
582
+ model = summarize if isinstance(summarize, str) else None
583
+ self._live.summarizer = Summarizer(model=model, binary=getattr(be, "binary", "claude"))
584
+ checked = self._check_defaults(prompt, program) if self.rank0 and backend is not None else {}
585
+ if self.rank0:
586
+ self.run.event(
587
+ "start",
588
+ **checked,
589
+ goal=self.goal,
590
+ total_updates=self.total,
591
+ world=self.world,
592
+ blocking="auto" if self._auto else self.blocking,
593
+ **(dict(max_lag_updates=self._auto.max_lag) if self._auto else {}),
594
+ schedule=self.schedule,
595
+ backend=getattr(self.worker.backend, "name", None) if self.worker else None,
596
+ version=self.version,
597
+ **(dict(validation=self._validation) if self._validation else {}),
598
+ **(dict(eval_integrity=dict(threshold=self._integrity_threshold)) if self._integrity else {}),
599
+ )
600
+ atexit.register(_close_at_exit, weakref.ref(self))
601
+ # Accelerator retains these objects. Public state/param_groups/defaults still delegate to the raw
602
+ # optimizer (including after load_state_dict replaces state); only checkpoint I/O goes through OPAL.
603
+ for prepared in accelerated:
604
+ prepared.optimizer = _AccelerateCheckpointAdapter(prepared.optimizer, self)
605
+
606
+ def _check_defaults(self, prompt, program):
607
+ """OPAL's defaults are fixed (opal/defaults.py): say loudly when the files this run is about to use differ from
608
+ the released ones (an edited install), and say when it uses a custom prompt or starting program. Returns what
609
+ the start event records."""
610
+ named = prompt in ("neutral", "aggressive")
611
+ used = defaults.SHARED_FILES + (defaults.PROMPT_FILES if named else ()) + (
612
+ defaults.PROGRAM_FILES if program is None else ()
613
+ )
614
+ out = {}
615
+ changed = defaults.modified(used)
616
+ if changed:
617
+ out.update(defaults_modified=True, modified_files=changed)
618
+ print(
619
+ f"[opal] WARNING: OPAL's own defaults were modified ({', '.join(changed)}): these files differ from the "
620
+ "release, so this run's agent does not get the prompt or starting programs of the blog's and the README's results. "
621
+ "Do not edit files inside the OPAL package: reinstall OPAL, and customise through wrap()'s arguments "
622
+ "(prompt=<a template file>, program=<a program folder>, task_facts=).",
623
+ file=sys.stderr,
624
+ flush=True,
625
+ )
626
+ for what, value in (("prompt", None if named else prompt), ("starting program", program)):
627
+ if value is not None:
628
+ out["custom_" + what.split()[-1]] = str(value)
629
+ print(
630
+ f"[opal] this run uses a custom {what} ({value}); the blog's results used OPAL's defaults",
631
+ file=sys.stderr,
632
+ flush=True,
633
+ )
634
+ return out
635
+
636
+ def _announce(self):
637
+ """Printed at the first update, after any load_state_dict, so it names the folder the run really writes to."""
638
+ self._announced = True
639
+ if not self.rank0:
640
+ return
641
+ self._log(
642
+ f"run folder {self.run.dir}; {len(self.schedule)} scheduled decision{'s' * (len(self.schedule) != 1)}; "
643
+ f"{'auto-blocking' if self._auto else 'blocking' if self.blocking else 'non-blocking'}; "
644
+ f"backend {self._backend_label}"
645
+ )
646
+ describe = getattr(self.worker.backend, "describe", None) if self.worker else None
647
+ if describe:
648
+ self._log(describe())
649
+
650
+ def __repr__(self):
651
+ where = f", run {self.run.dir}" if self.run is not None else ""
652
+ return f"AgentOptimizer(program v{self.version:03d}, update {self.update}/{self.total}{where})"
653
+
654
+ # ----- program versions -----------------------------------------------------------------------------------
655
+
656
+ def _spec(self):
657
+ return dict(
658
+ total_updates=self.total,
659
+ update=self.update,
660
+ world_size=self.world,
661
+ rank=self.rank,
662
+ user_optimizers=self.user_optimizers,
663
+ user_schedulers=self.user_schedulers,
664
+ clip_grad_norm=None if self._clip_grad_norm is None else float(self._clip_grad_norm),
665
+ )
666
+
667
+ def _groups_of(self, updater):
668
+ """The groups the wrapper exposes: the updater's param_groups, plus one group (lr NaN) with every parameter
669
+ they leave out, so GradScaler unscales every gradient once and LR logging never meets a group without "lr"."""
670
+ groups = getattr(updater, "param_groups", None)
671
+ if not (isinstance(groups, list) and groups and all(isinstance(g, dict) and "params" in g for g in groups)):
672
+ return self._own_groups
673
+ ids = [id(p) for g in groups for p in g["params"]]
674
+ if len(ids) != len(set(ids)):
675
+ raise ValueError(
676
+ "param_groups lists a parameter in more than one group (a gradient scaler would unscale it twice)"
677
+ )
678
+ covered = set(ids)
679
+ rest = [p for _, p in self.named if id(p) not in covered]
680
+ return list(groups) + ([{"params": rest, "lr": float("nan")}] if rest else [])
681
+
682
+ @property
683
+ def param_groups(self):
684
+ """The groups the training script sees (GradScaler, LR logging): the running updater's param_groups, read
685
+ again whenever its group dicts change (a property that builds new dicts each call shows its current rates), plus
686
+ the group of the parameters it leaves out. Cached while the updater returns the same dicts."""
687
+ d = self.__dict__
688
+ up = d.get("updater")
689
+ if up is None:
690
+ return d["_param_groups"]
691
+ groups = getattr(up, "param_groups", None)
692
+ cache = d.get("_groups_cache")
693
+ same = (
694
+ cache is not None
695
+ and cache[0] is up
696
+ and isinstance(groups, list)
697
+ and isinstance(cache[1], list)
698
+ and len(groups) == len(cache[1])
699
+ and all(a is b for a, b in zip(groups, cache[1]))
700
+ )
701
+ if same or (cache is not None and cache[0] is up and groups is None and cache[1] is None):
702
+ return cache[2]
703
+ try:
704
+ exposed = self._groups_of(up)
705
+ except ValueError: # a program that lists a parameter twice was refused when built; keep what it had
706
+ return cache[2] if cache is not None and cache[0] is up else d["_param_groups"]
707
+ d["_groups_cache"] = (up, list(groups) if isinstance(groups, list) else None, exposed)
708
+ return exposed
709
+
710
+ @param_groups.setter
711
+ def param_groups(self, value): # torch.optim.Optimizer.__init__
712
+ self.__dict__["_param_groups"] = value
713
+
714
+ def _every_of(self, an):
715
+ e = getattr(an, "EVERY", self.default_every) if an is not None else self.default_every
716
+ if isinstance(e, bool) or not isinstance(e, int) or e < 1:
717
+ raise TypeError(f"EVERY must be a positive int, got {e!r}")
718
+ return e
719
+
720
+ def _build(self, files, version, previous, updater=True, restoring=False):
721
+ """Load `files` and, unless only the analysis side changed, build the updater. Raises on any problem and
722
+ registers nothing on failure."""
723
+ if self.rank0:
724
+ boundary.record(files, version, self.run)
725
+ d = self._prog_root / f"v{version:03d}"
726
+ programs.write_dir(d, files)
727
+ tag = f"r{self.rank}v{version}"
728
+ try:
729
+ an = programs.load(files, "analysis.py", tag, d)
730
+ if an is not None and not callable(getattr(an, "analyze", None)):
731
+ raise TypeError("analysis.py must define analyze(obs, memory); this one does not, so it would never run")
732
+ every, up, groups = self._every_of(an), None, None
733
+ if updater:
734
+ mod = programs.load(files, "updater.py", tag, d)
735
+ with programs.keep_param_group_identity(), self._dtypes_kept():
736
+ up = mod.build(self.named, previous=previous, spec=self._spec())
737
+ starting = programs.side_digests(files)[0] == self._starting_digest
738
+ methods = ("step",) if starting else ("step", "state_dict", "load_state_dict")
739
+ for method in methods:
740
+ if not callable(getattr(up, method, None)):
741
+ raise TypeError(f"build() must return an updater with a callable {method} method")
742
+ bad = programs.unpicklable(programs.normalize_state(up.state_dict())) if not (starting or restoring) else []
743
+ if bad:
744
+ raise TypeError(
745
+ "state_dict() must contain tensors and weights-only-safe values in acyclic containers, "
746
+ "so that checkpoints load with weights_only=True; found: "
747
+ + "; ".join(bad)
748
+ )
749
+ groups = self._groups_of(up)
750
+ except BaseException:
751
+ programs.forget(tag)
752
+ if self.rank0:
753
+ shutil.move(str(d), str(self._prog_root / f"rejected-v{version:03d}-{now_stamp()}"))
754
+ else:
755
+ shutil.rmtree(d, ignore_errors=True)
756
+ raise
757
+ return an, up, every, groups
758
+
759
+ def _install(self, files, previous, first=False, updater=True, handover=True, restoring=False):
760
+ """Make `files` the next version. updater=False: only analysis.py (or its helpers) changed, so the running
761
+ updater object and its state stay. On failure on any rank, keep the current programs (with their
762
+ hyperparameters as they were) and return the error text. handover=False: the caller puts the user's schedule
763
+ in place itself (a revert, a resume)."""
764
+ version = 0 if first else self.version + 1
765
+ swap = updater and not first
766
+ hypers = self._hyper_state() if swap else None
767
+ before = self._schedule_state() if swap else None # the user's schedule as the running program left it
768
+ ledger = copy.deepcopy(self._ledger) if swap else None
769
+ user_dtypes = self._user_state_dtypes() if swap else None # misc/precision.py
770
+ err = None
771
+ try:
772
+ an, up, every, groups = self._build(files, version, previous, updater, restoring=restoring)
773
+ except programs.AGENT_ERRORS:
774
+ err = traceback.format_exc(limit=6)[-3000:]
775
+ if not self._agree(err is None):
776
+ if err is None: # built here, failed on another rank
777
+ programs.forget(f"r{self.rank}v{version}")
778
+ self._restore_hypers(hypers)
779
+ if before is not None:
780
+ self._set_schedule_state(before)
781
+ return err or "failed on another rank"
782
+ digests = programs.side_digests(files)
783
+ # an analysis stopped for changing training stays stopped until analysis.py (or a module it imports) changes
784
+ off = not first and getattr(self, "_analysis_off", False) and digests[1] == self.digests[1]
785
+ self.files, self.version, self.digests = files, version, digests
786
+ self.analysis, self.every, self._analysis_off = an, every, off
787
+ tag = f"r{self.rank}v{version}"
788
+ self._analysis_tag = tag
789
+ self._program_tags.add(tag)
790
+ if updater:
791
+ old = self.updater
792
+ self.updater, self._hypers = up, hypers # param_groups follows the updater (the property below)
793
+ self._updater_tag = tag
794
+ self._step_ctx, self._skips_in_a_row, self._ctx_any = takes_ctx(up), 0, None
795
+ if swap:
796
+ self._build_changes = self._changed_settings(before) # what build() set on the user's groups
797
+ if handover:
798
+ self._schedule_handover(old, up, before, self._build_changes)
799
+ self._install_rec = dict(before=before, ledger=ledger, calls=[], user_dtypes=user_dtypes)
800
+ if not first:
801
+ self._next_analysis = min(self._next_analysis, self.update + every)
802
+ if self.rank0:
803
+ (self.run.dir / "FINAL").write_text(f"v{version:03d}\n")
804
+ return None
805
+
806
+ def _forget_retired(self):
807
+ """Drop from sys.modules the modules of the versions that can no longer run (all but the running updater and
808
+ analysis and the fallback updater of a trial window), so that what their module globals hold is freed."""
809
+ keep = {self._updater_tag, self._analysis_tag, self._prev_tag if self._prev_updater is not None else None}
810
+ for tag in self._program_tags - keep:
811
+ programs.forget(tag)
812
+ self._program_tags &= keep
813
+
814
+ def _apply(self, resp, u):
815
+ """Swap in an agent's answer at the start of update u (every rank calls this with the same resp)."""
816
+ if not resp:
817
+ return
818
+ k = resp.get("k")
819
+ rec = dict(k=k, requested_at=resp.get("requested_at"), applied_at=u)
820
+ rec.update({x: True for x in ("gave_up", "late") if resp.get(x)}) # backend stopped waiting / answered after
821
+ if resp.get("backend_error") is not None:
822
+ rec["failed"] = True
823
+ if self._auto is not None and self.rank0:
824
+ self._auto.answered(resp)
825
+ for e in resp.get("errors") or []:
826
+ self.errors.append(dict(update=u, where=f"your answer to decision {k} (previous programs kept)", error=e))
827
+ if resp.get("backend_error") is not None:
828
+ err = str(resp["backend_error"]) or 'the agent call failed'
829
+ self.errors.append(dict(update=u, where=f"agent backend (decision {k})", error=err[-1500:]))
830
+ self._log(f"decision {k}: the agent backend failed: {err[:300]}")
831
+ if resp.get("errors"):
832
+ return self._outcome("rejected", rec, resp)
833
+ agent, base = resp.get("files"), resp.get("base") or {}
834
+ if not agent:
835
+ return self._outcome("kept", rec, resp)
836
+ # three-way merge at file level: apply only what the agent changed relative to the files it was shown, so an
837
+ # answer computed before a revert does not bring the reverted program back
838
+ files = dict(self.files)
839
+ for name in set(agent) | set(base):
840
+ if agent.get(name) != base.get(name):
841
+ if name in agent:
842
+ files[name] = agent[name]
843
+ else:
844
+ files.pop(name, None)
845
+ files = programs.program(files)
846
+ up_d, an_d = programs.side_digests(files)
847
+ if (up_d, an_d) == self.digests:
848
+ return self._outcome("kept", rec, resp)
849
+ rank_equal = False
850
+ if self.dist and up_d != self.digests[0]:
851
+ version = self.version
852
+ rank_equal = not self._check_ranks(u - 1) # before build() can change shared parameters or state
853
+ if self.version != version: # the outgoing trial failed; merge the answer against the fallback instead
854
+ return self._apply(resp, u)
855
+ changed = sorted(n for n in set(files) | set(self.files) if files.get(n) != self.files.get(n))
856
+ t0 = time.time()
857
+ old_files, old_updater, old_tag = self.files, self.updater, self._updater_tag
858
+ new_updater = up_d != self.digests[0]
859
+ # the old updater is still in its trial window: the fallback stays the program before it, which passed one,
860
+ # with its group settings and the record of the user's schedule since it ran
861
+ held = (self._hypers, self._install_rec) if new_updater and u <= self._trial_until else None
862
+ # taken before build(), so that a failing build or first update is undone exactly (misc/recovery.py)
863
+ snap = self._take_snapshot() if new_updater else None
864
+ err = self._install(files, previous=old_updater, updater=new_updater)
865
+ if err:
866
+ if isinstance(snap, dict):
867
+ self._put_back_placement()
868
+ self._restore_snapshot(snap, old_updater)
869
+ self.errors.append(dict(update=u, where="loading your new programs (previous programs kept)", error=err))
870
+ return self._outcome("load_failed", rec, resp, "new programs failed to load; previous kept", error=err)
871
+ self._last_applied = rec
872
+ if new_updater: # trial window: a failure in it goes back to the old updater, the first update exactly
873
+ self._timing["program_ms"], self._events = None, None
874
+ if held is None:
875
+ self._prev_files, self._prev_updater, self._prev_tag = old_files, old_updater, old_tag
876
+ self._rank_snapshot, self._rank_trial_equal = (snap, rank_equal) if self.dist else (None, False)
877
+ else: # the old updater is dropped (it never passed its trial window); the fallback stays
878
+ self._rank_trial_equal = self._rank_trial_equal and rank_equal
879
+ self._hypers, self._install_rec = held
880
+ if self._first_pending and isinstance(self._snapshot, dict):
881
+ snap = self._snapshot # the old updater never stepped: the copy taken before it undoes both builds
882
+ elif isinstance(snap, dict):
883
+ snap["copies"]["old"] = None # the old updater's state, which is not the fallback's
884
+ self._trial_until, self._snapshot, self._first_pending = u + self.trial_steps, snap, True
885
+ self._timing["audit"], self._audit_at = None, u + 1
886
+ self._forget_retired()
887
+ note = (
888
+ f"applied at update {u} as v{self.version:03d} (requested at {rec['requested_at']}): {', '.join(changed)}"
889
+ )
890
+ self._outcome(
891
+ "applied",
892
+ rec,
893
+ resp,
894
+ note,
895
+ version=self.version,
896
+ changed=changed,
897
+ rebuilt_updater=new_updater,
898
+ swap_seconds=round(time.time() - t0, 3),
899
+ )
900
+
901
+ def _outcome(self, kind, rec, resp, note=None, **extra):
902
+ # what became of this answer: the next message says it in one line (agent.previous_answer)
903
+ self._last_answer = dict(rec, outcome=kind, **{x: resp.get(x) for x in ("backend_error", "seconds")})
904
+ if self.rank0:
905
+ lag = None if rec.get("applied_at") is None else rec["applied_at"] - (rec.get("requested_at") or 0)
906
+ waited = resp.get("unavailable_seconds") # waiting for an unavailable agent, apart from its own time
907
+ self.run.event(
908
+ kind,
909
+ **rec,
910
+ **extra,
911
+ lag_updates=lag,
912
+ seconds=resp.get("seconds"),
913
+ cost_usd=resp.get("cost_usd"),
914
+ **({"unavailable_seconds": waited} if waited else {}),
915
+ )
916
+ cost = resp.get("cost_usd") or 0
917
+ cost_text = "USD unavailable" if "usage" in resp and resp.get("cost_usd") is None else f"${cost:.2f}"
918
+ then = f", after {waited:.0f} s waiting for the agent to be available" if waited else ""
919
+ self._log(f"decision {rec['k']}: {note or 'programs ' + kind} ({resp.get('seconds')} s{then}, {cost_text})")
920
+ if self.worker is not None:
921
+ self._cost_reached() # said as soon as an answer spends the rest
922
+ self._live.refresh() # only queued: the report is written in its own thread
923
+
924
+ def _after_decision(self, k, req, resp):
925
+ """In the agent's thread, once a decision's response is recorded: queue the summary of a proposed code change
926
+ (when there is a summarizer) and a new report."""
927
+ if resp.get("files"):
928
+ self._live.summarize(k, resp.get("text"), req["files"], resp["files"], self.total)
929
+ if self._closed:
930
+ self.run.event(
931
+ "cost_settled", k=k,
932
+ cost_usd=self.worker.recorded_cost,
933
+ unknown_cost_decisions=sorted(self.worker.unknown_cost_decisions),
934
+ )
935
+ self._live.refresh()
936
+
937
+ # ----- the update -------------------------------------------------------------------------------------------
938
+
939
+ def _parameter_ids(self):
940
+ return tuple(frozenset(id(p) for g in o.param_groups for p in g["params"]) for o in self.user_optimizers)
941
+
942
+ def _check_user_parameters(self):
943
+ if self._parameter_ids() != self._user_parameter_ids:
944
+ raise RuntimeError(
945
+ "opal: the raw optimizer's parameter set changed after wrapping; "
946
+ "create all parameter groups before opal.wrap"
947
+ )
948
+
949
+ @property
950
+ def _step_supports_amp_scaling(self):
951
+ # GradScaler queries this before unscaling or deciding whether to skip the wrapper's step.
952
+ if len(self.user_optimizers) > 1:
953
+ raise RuntimeError(
954
+ "opal: multiple optimizers with GradScaler are unsupported: "
955
+ "a combined step cannot preserve each optimizer's independent overflow handling; "
956
+ "use a single optimizer or disable gradient scaling"
957
+ )
958
+ self._check_user_parameters()
959
+ return False
960
+
961
+ def observe(self, loss):
962
+ """Record this update's loss when a framework calls step() for you (Accelerate, Lightning, HF Trainer). Called
963
+ before step(), it is also the loss a step(grads, update, ctx) program gets as ctx.loss."""
964
+ self._push_loss(loss)
965
+ self._observed_loss = loss
966
+
967
+ def _push_loss(self, loss):
968
+ if torch.is_tensor(loss):
969
+ v = loss.detach()
970
+ if v.numel() != 1:
971
+ self._warn("loss-vector", f"the loss passed to step() has {v.numel()} elements; its mean is used")
972
+ v = v.float().mean()
973
+ if self._loss_n < self._loss_buf.numel():
974
+ self._loss_buf[self._loss_n].copy_(v.reshape(()).float()) # async, CUDA-graph safe
975
+ self._loss_n += 1
976
+ else:
977
+ self._float_losses.append((self._loss_n, float(loss)))
978
+
979
+ @torch.no_grad()
980
+ def step(self, closure=None, loss=None):
981
+ Join.notify_join_context(self)
982
+ self._check_join_inputs()
983
+ self._check_user_parameters()
984
+ if closure is not None:
985
+ with torch.enable_grad():
986
+ loss = closure()
987
+ if loss is None and self._observed_loss is None and self._loss_hook is not None:
988
+ loss = self._loss_hook() # opal/hf.py: the update's loss, which the Trainer does not pass to step()
989
+ if not getattr(self, "_announced", False):
990
+ self._announce()
991
+ if getattr(self.step, "_wrapped_by_lr_sched", False):
992
+ self._warn(
993
+ "sched-on-wrapper",
994
+ "a scheduler was built on the wrapper itself; pass your scheduler to wrap() "
995
+ "instead, or it will overwrite the learning rates the agent's program sets",
996
+ )
997
+ if self._resume_pending is not None:
998
+ self._finish_resume()
999
+ self.update += 1
1000
+ u = self.update
1001
+ if self._estimate_due and self._t_mark is None and self._steps_here == 1:
1002
+ # The second update: warm-up is behind; agent waiting is not training time.
1003
+ self._t_mark = (u, time.perf_counter() - self.agent_wait_seconds)
1004
+ self._steps_here += 1
1005
+ if u == self.total + 1:
1006
+ self._warn(
1007
+ "past-total",
1008
+ f"step() called past total_steps={self.total}; programs written as functions of "
1009
+ "update / total_updates may misbehave from here (check total_steps)",
1010
+ )
1011
+ self._maybe_swap(u)
1012
+ self._step_loss = loss if loss is not None else self._observed_loss
1013
+ self._observed_loss = None
1014
+ if loss is not None:
1015
+ self._push_loss(loss)
1016
+ analyze = u >= self._next_analysis or u == self.total
1017
+ if analyze or u % self.default_every == 0:
1018
+ self._at_check(u, analyze)
1019
+ self._do_update(u)
1020
+ if self._integrity is not None:
1021
+ self._integrity_record(u)
1022
+ if self.dist and self.rank_check_every and self._rank_checked_at != u and (
1023
+ u % self.rank_check_every == 0 or u == self.total
1024
+ ):
1025
+ self._check_ranks(u)
1026
+ self._capture_next = self.rank0 and bool(self._hooks) and u + 1 >= self._next_analysis
1027
+ if u == self.total:
1028
+ self.close()
1029
+ return loss
1030
+
1031
+ def _do_update(self, u):
1032
+ grads = {n: p.grad for n, p in self.named}
1033
+ trial = u <= self._trial_until
1034
+ first = trial and self._first_pending
1035
+ profile = self.rank0 and u == self._audit_at
1036
+ # one update per check, and the run's first updates, so that the starting program is measured before any swap
1037
+ measure = ((u - 1) % self.default_every == 0 or u <= 3) and not profile
1038
+ if measure:
1039
+ if self.device.type == "cuda":
1040
+ ev = (torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True))
1041
+ ev[0].record()
1042
+ else:
1043
+ if self.device.type == "mps":
1044
+ torch.mps.synchronize() # asynchronous like CUDA: time the work, not the launches
1045
+ t0 = time.perf_counter()
1046
+ err = None
1047
+ self._in_updater = True
1048
+ try:
1049
+ if profile:
1050
+ self._record_audit(audit.audited(lambda: self._call_updater(grads, u), self.device), u)
1051
+ else:
1052
+ self._call_updater(grads, u)
1053
+ except programs.AGENT_ERRORS:
1054
+ err = traceback.format_exc(limit=6)[-3000:]
1055
+ finally:
1056
+ self._in_updater = False
1057
+ if err is None: # a changed dtype fails like an exception (misc/precision.py); the state at some updates only
1058
+ err = self._precision_error(grads, first, state=trial or u % self.default_every == 0)
1059
+ failed = (not self._agree(err is None)) if trial else err is not None
1060
+ if failed:
1061
+ self._recover(u, grads, err or "raised on another rank", trial, first)
1062
+ return
1063
+ if self.dist and trial and (first or u == self._trial_until or u == self.total):
1064
+ version = self.version
1065
+ self._check_ranks(u)
1066
+ if self.version != version: # exact rollback; do not step on gradients from the rejected trajectory
1067
+ return
1068
+ if first:
1069
+ self._snapshot, self._first_pending = None, False # the exact undo is only for the first update
1070
+ if trial and u == self._trial_until:
1071
+ self._prev_updater = self._hypers = None # trial passed: release the old updater
1072
+ self._rank_snapshot, self._rank_trial_equal = None, False
1073
+ self._forget_retired()
1074
+ if measure:
1075
+ starting = self.digests[0] == self._starting_digest
1076
+ if self.device.type == "cuda":
1077
+ ev[1].record()
1078
+ self._events = (ev, self.version, u, starting)
1079
+ else:
1080
+ if self.device.type == "mps":
1081
+ torch.mps.synchronize()
1082
+ self._set_program_ms((time.perf_counter() - t0) * 1000, self.version, u, starting)
1083
+
1084
+ def _set_program_ms(self, ms, version, measured_at, starting):
1085
+ """Updater time per update, measured at one update per check and at the run's first updates. The first
1086
+ measurement of a run includes warm-up and is not kept; the starting program's figure is its latest measurement
1087
+ (also after a revert to it), so while it runs the two agree."""
1088
+ if measured_at <= 1:
1089
+ return
1090
+ if version == self.version:
1091
+ self._timing["program_ms"] = round(ms, 4)
1092
+ if starting:
1093
+ self._timing["program_ms_start"] = round(ms, 4)
1094
+
1095
+ def _record_audit(self, counts, u):
1096
+ self._timing["audit"] = counts
1097
+ if self.digests[0] == self._starting_digest:
1098
+ self._timing["audit_start"] = counts
1099
+ if self.rank0 and counts:
1100
+ self.run.event("audit", update=u, version=self.version, **counts)
1101
+
1102
+ def _read_losses(self):
1103
+ """This rank's losses since the last check (rank 0's are what the agent sees; no collective)."""
1104
+ n, self._loss_n = self._loss_n, 0
1105
+ floats, self._float_losses = self._float_losses, []
1106
+ if not self.rank0:
1107
+ return []
1108
+ tensors = self._loss_buf[:n].tolist() if n else []
1109
+ if not floats:
1110
+ return tensors
1111
+ losses, start = [], 0
1112
+ for at, value in floats:
1113
+ losses.extend(tensors[start:at])
1114
+ losses.append(value)
1115
+ start = at
1116
+ losses.extend(tensors[start:])
1117
+ return losses
1118
+
1119
+ def _at_check(self, u, analyze):
1120
+ """Every `every` updates (and whenever the agent's analysis is due): rank 0 reads its losses, watches for a
1121
+ non-finite loss and for scheduled decisions, runs analysis.py when it is due and may request a decision. Ranks
1122
+ learn from rank 0 whether to pause, in one small broadcast."""
1123
+ losses_from = self._losses_from
1124
+ losses = self._read_losses()
1125
+ self._losses_from = u + 1
1126
+ new_episode = False
1127
+ if self.rank0:
1128
+ if not losses and u >= self.default_every:
1129
+ self._warn(
1130
+ "no-loss", "no loss received: call opt.step(loss=loss) (or opt.observe(loss)) so the agent sees it"
1131
+ )
1132
+ nonfinite = any(not math.isfinite(x) for x in losses)
1133
+ new_episode = nonfinite and not self._nonfinite_episode
1134
+ if losses:
1135
+ self._nonfinite_episode = nonfinite
1136
+ now = time.perf_counter()
1137
+ if self._timing["t"] is not None and u > self._timing["u"]: # the pauses for the agent left out
1138
+ busy = max(0.0, now - self._timing["t"] - self._paused_s)
1139
+ self._timing["wall_ms_per_update"] = round(1000 * busy / (u - self._timing["u"]), 2)
1140
+ self._timing["t"], self._timing["u"], self._paused_s = now, u, 0.0
1141
+ if self._auto is not None:
1142
+ self._auto.tick(u, now)
1143
+ if self._events is not None and analyze:
1144
+ (a, b), v, mu, starting = self._events
1145
+ b.synchronize()
1146
+ self._set_program_ms(a.elapsed_time(b), v, mu, starting)
1147
+ self._events = None
1148
+ if self.rank0:
1149
+ self._rank0_check(u, analyze, losses, losses_from, new_episode)
1150
+ else:
1151
+ self._next_analysis = u + self.every if analyze else self._next_analysis
1152
+ pause = self._from_rank0(self.blocking or self._block_now or (new_episode and self.pause_on_nonfinite))
1153
+ if pause:
1154
+ self._blocking_point(u)
1155
+ # a non-finite loss found while an older decision was in flight: ask about it now, and wait again
1156
+ nan_left = self.rank0 and any(r.startswith("non-finite") for r in self._reasons)
1157
+ if nan_left and self.worker is not None and not self.worker.busy and self._budget_left():
1158
+ self._submit(u, pause=True)
1159
+ if self._from_rank0(nan_left):
1160
+ self._blocking_point(u)
1161
+ self._block_now = False
1162
+
1163
+ def _rank0_check(self, u, analyze, losses, losses_from, new_episode):
1164
+ lrs = []
1165
+ for g in self.param_groups:
1166
+ try:
1167
+ lrs.append(float(g.get("lr")))
1168
+ except (TypeError, ValueError, AttributeError):
1169
+ pass
1170
+ self.run.append("losses.jsonl", dict(from_update=losses_from, to_update=u, losses=losses, lr=lrs))
1171
+ new_metrics = [self._plain(m) for m in self._metrics_new]
1172
+ self._metrics_new = []
1173
+ for m in new_metrics:
1174
+ self.run.append("metrics.jsonl", m)
1175
+ self._analysis_losses.extend(losses)
1176
+ self._analysis_metrics.extend(self._plain(m) for m in self._shown_new) # only values shown to the agent
1177
+ self._shown_new = []
1178
+ del self._analysis_losses[:-100_000]
1179
+ wake = self._run_analysis(u) if analyze else None
1180
+ self._pending_losses.extend(losses)
1181
+ dropped = max(0, len(self._pending_losses) - 100_000)
1182
+ self._pending_from += dropped
1183
+ self._pending_omitted += dropped
1184
+ del self._pending_losses[:-100_000]
1185
+ if new_episode:
1186
+ bad = sum(1 for _, p in self.named if not bool(torch.isfinite(p.detach()).all()))
1187
+ self._reasons.append(
1188
+ f"non-finite training loss before update {u:,} ({bad} of {len(self.named)} parameter tensors are "
1189
+ "non-finite now)"
1190
+ )
1191
+ if self._estimate_due and self._t_mark is not None and u - self._t_mark[0] >= 2:
1192
+ self._first_estimate(u)
1193
+ while self._sched_i < len(self.schedule) and u >= self.schedule[self._sched_i]:
1194
+ if "scheduled decision" not in self._reasons:
1195
+ self._reasons.append("scheduled decision")
1196
+ self._sched_i += 1
1197
+ if wake:
1198
+ self._reasons.append(f"{WAKE}{str(wake)[:300]}")
1199
+ if self._skip_alarm:
1200
+ self._skip_alarm = False
1201
+ self._reasons.append("your program kept skipping updates (see errors)")
1202
+ if self._reasons and self.total - u < self.default_every:
1203
+ self._reasons = [] # an answer could no longer act on this run
1204
+ if self.worker is not None:
1205
+ self.worker.collect_late() # an answer that came after the backend stopped waiting, before a newer request
1206
+ if self.worker is None or self.worker.k >= self.max_decisions or (
1207
+ not self.worker.busy and not self._budget_left()
1208
+ ):
1209
+ self._reasons = [] # no future call can consume these; keep analysis reports, not unbounded wake strings
1210
+ wakes_only = all(r.startswith(WAKE) for r in self._reasons)
1211
+ if self._reasons and self.worker is not None and not self.worker.busy and self._budget_left(wakes_only):
1212
+ self._submit(u, pause=new_episode and self.pause_on_nonfinite)
1213
+
1214
+ def _first_estimate(self, u):
1215
+ """At the first check (rank 0): print the estimate of the waiting and the cost from this run's own time per
1216
+ update. With decisions not set, a blocking run keeps the expected waiting within estimate.MAX_WAIT_FACTOR (4)
1217
+ times its own training time by scheduling fewer decisions (at least one), in the same shape."""
1218
+ self._estimate_due, self._estimated = False, True
1219
+ since_u, since_t = self._t_mark
1220
+ sps = max(1e-9, (time.perf_counter() - self.agent_wait_seconds - since_t) / (u - since_u))
1221
+ blocking = "auto" if self._auto is not None else self.blocking
1222
+ max_lag = self._auto.max_lag if self._auto is not None else 50
1223
+ n, capped_from = len(self.schedule), None
1224
+ if self._may_cap and blocking is not False and n > 1:
1225
+ k = estimate.capped_decisions(n, self.total * sps, blocking, sps, max_lag)
1226
+ if k < n or self.worker.k > self._sched_i:
1227
+ capped_from = n
1228
+ if self._sched_i or self.worker.k:
1229
+ # Keep consumed checks; thin only the rest, counting calls already asked (including wake-ups).
1230
+ remaining = self.schedule[self._sched_i :]
1231
+ left = min(len(remaining), max(0, k - self.worker.k))
1232
+ keep = set(self.schedule[: self._sched_i])
1233
+ keep.update(remaining[i * len(remaining) // left] for i in range(left))
1234
+ segments = [[x for x in seg if x in keep] for seg in self._schedule_segments]
1235
+ else:
1236
+ segments = decision_schedule.decision_segments(self.total, k, self.default_every, self._schedule_kind)
1237
+ # A one-decision uniform grid lands at the excluded final update. Keep a usable original check.
1238
+ self._set_schedule(segments if any(segments) else [self.schedule[:1]])
1239
+ be = self.worker.backend
1240
+ mailbox = getattr(be, "name", None) == "mailbox"
1241
+ model = estimate.MODEL if mailbox else getattr(be, "model", None) or "the CLI's default model"
1242
+ effort = estimate.EFFORT if mailbox else getattr(be, "effort", None) or "default"
1243
+ said = estimate.lines(
1244
+ self.total, sps, len(self.schedule), blocking, max_lag, model, effort, mailbox, capped_from,
1245
+ explicit=not self._may_cap,
1246
+ ) # fmt: skip
1247
+ self._log(
1248
+ f"estimate from this run's updates {since_u:,} to {u - 1:,} ({sps:.3g} s each) and our runs of "
1249
+ f"{estimate.MODEL} at effort {estimate.EFFORT}:"
1250
+ )
1251
+ for line in said:
1252
+ self._log(" " + line)
1253
+ self.run.event(
1254
+ "estimate",
1255
+ update=u,
1256
+ sec_per_update=round(sps, 6),
1257
+ decisions=len(self.schedule),
1258
+ **(dict(capped_from=capped_from, schedule=self.schedule) if capped_from is not None else {}),
1259
+ )
1260
+
1261
+ def _set_schedule(self, segments):
1262
+ """Replace the scheduled decisions (the first check's cap, or a resume of a capped run) and write the prompt
1263
+ again, which states them."""
1264
+ self._schedule_segments, self._schedule_changed = segments, True
1265
+ self.schedule = [x for seg in segments for x in seg]
1266
+ if not self._max_decisions_given:
1267
+ self.max_decisions = 2 * len(self.schedule)
1268
+ if self.worker is not None:
1269
+ self.worker.system = self._system_prompt(self._prompt_name, self._names_by_id)
1270
+ (self.run.dir / "system.md").write_text(self.worker.system)
1271
+
1272
+ def _run_analysis(self, u):
1273
+ """Call the agent's analysis.analyze(obs, memory); keep its report, return its wake request (if any)."""
1274
+ self._next_analysis = u + self.every
1275
+ wake = None
1276
+ if self.analysis is not None and not self._analysis_off:
1277
+ obs = types.SimpleNamespace(
1278
+ update=u,
1279
+ total_updates=self.total,
1280
+ losses=self._analysis_losses,
1281
+ params={n: p for n, p in self.named},
1282
+ grads={n: p.grad for n, p in self.named},
1283
+ updater=self.updater,
1284
+ metrics=self._analysis_metrics,
1285
+ program_ms_per_update=self._timing["program_ms"],
1286
+ wall_ms_per_update=self._timing["wall_ms_per_update"],
1287
+ activations=self.activations,
1288
+ )
1289
+ marks = self._analysis_marks()
1290
+ t0 = time.perf_counter()
1291
+ try:
1292
+ res = self.analysis.analyze(obs, self.memory) or {}
1293
+ except programs.AGENT_ERRORS:
1294
+ self.errors.append(
1295
+ dict(update=u, where="analysis.analyze", error=traceback.format_exc(limit=6)[-3000:])
1296
+ )
1297
+ res = {}
1298
+ self._timing["analysis_ms"] = round(1000 * (time.perf_counter() - t0), 2)
1299
+ touched = self._analysis_changes(marks)
1300
+ if touched: # it changed training; it cannot be undone, so it is stopped and the agent told
1301
+ self._put_back_placement()
1302
+ self._analysis_off = True
1303
+ self.errors.append(
1304
+ dict(
1305
+ update=u,
1306
+ where="analysis.analyze (not run again until analysis.py changes)",
1307
+ error="analysis.analyze changed parameters or gradients (in place, or their dtype or device), "
1308
+ "which it may not do: " + ", ".join(touched[:8]) + (f" ({len(touched)} in all)" if len(touched) > 8 else ""),
1309
+ )
1310
+ )
1311
+ if isinstance(res, dict):
1312
+ if "report" in res:
1313
+ bad = []
1314
+ report = _plain_json(res["report"], bad) # converted now, not when the message is written
1315
+ if bad:
1316
+ self.errors.append(
1317
+ dict(
1318
+ update=u,
1319
+ where="analysis.analyze report",
1320
+ error="values that are not JSON were stored as text: " + ", ".join(sorted(set(bad))[:8]),
1321
+ )
1322
+ )
1323
+ self.reports.append(dict(update=u, report=report))
1324
+ self._reports_made += 1
1325
+ self.run.append("reports.jsonl", dict(update=u, version=self.version, report=report))
1326
+ wake = res.get("wake")
1327
+ self._analysis_losses, self._analysis_metrics = [], []
1328
+ self.activations = {}
1329
+ return wake
1330
+
1331
+ def _budget_left(self, wakes_only=False):
1332
+ """Whether one more decision may be asked. Wake-ups of the analysis alone may not use the decisions the
1333
+ remaining scheduled ones need."""
1334
+ left = self.max_decisions - self.worker.k
1335
+ if left <= 0 or (wakes_only and left <= len(self.schedule) - self._sched_i):
1336
+ return False
1337
+ return not self._cost_reached()
1338
+
1339
+ def _cost_reached(self):
1340
+ """True once max_cost_usd cannot cover another estimated call; says so on stdout the first time."""
1341
+ if self.max_cost is None:
1342
+ return False
1343
+ left = self.max_cost - self.worker.cost
1344
+ needed = max(0.01, self.worker.cost / max(1, self.worker.k))
1345
+ if left >= needed:
1346
+ return False
1347
+ if not self._cost_said:
1348
+ self._cost_said = True
1349
+ why = f", ${left:.3g} remaining is below the ${needed:.3g} call estimate" if left > 0 else ""
1350
+ self._notice(
1351
+ f"max_cost_usd reached (${self.worker.cost:.2f} spent, cap ${self.max_cost:.2f}{why}); training continues "
1352
+ "with the current programs, no more agent calls."
1353
+ )
1354
+ return True
1355
+
1356
+ def _submit(self, u, pause=False):
1357
+ """Ask a decision at update u. pause: training waits for it whatever the waiting mode (a non-finite loss)."""
1358
+ inbox = dict(
1359
+ update=u,
1360
+ losses=self._pending_losses,
1361
+ losses_omitted=self._pending_omitted,
1362
+ losses_from=self._pending_from,
1363
+ reports=list(self.reports),
1364
+ reports_made=self._reports_made,
1365
+ errors=list(self.errors),
1366
+ metrics=self._metrics_since(consume=True),
1367
+ skipped=list(self._skipped),
1368
+ timing=dict(self._timing, t=None),
1369
+ **({"development": list(self._dev)} if self._validation else {}),
1370
+ )
1371
+ notes = self.run.new_notes()
1372
+ if self._resume_note:
1373
+ notes, self._resume_note = ((self._resume_note + (" " + notes if notes else "")), "")
1374
+ wait = self._auto.decide() if self._auto is not None else {} # blocking="auto": wait at this decision?
1375
+ self._block_now = wait.get("wait") == "waited"
1376
+ left = self.max_decisions - (self.worker.k + 1) # decisions that may still be asked after this one
1377
+ upcoming = self.schedule[self._sched_i :][: max(0, left)]
1378
+ if self.max_cost is not None: # what the run may still spend, for a backend that can cap one call
1379
+ self.worker.backend.budget_left = max(0.0, self.max_cost - self.worker.cost)
1380
+ req = dict(
1381
+ update=u,
1382
+ total_updates=self.total,
1383
+ blocking=self.blocking or self._block_now or pause,
1384
+ reasons=_reasons_shown(self._reasons),
1385
+ last_applied=self._last_applied,
1386
+ previous=self._last_answer,
1387
+ files=self.files,
1388
+ inbox=inbox,
1389
+ timing=self._timing,
1390
+ notes=notes,
1391
+ scheduled_left=len(upcoming),
1392
+ **({"last": True} if left <= 0 else {}),
1393
+ **({"next_decision": next(iter(upcoming), None)} if self._show_next else {}),
1394
+ **({"neutral": True} if self._neutral else {}),
1395
+ **({"aggressive": True} if self._aggressive else {}),
1396
+ **({"epochs": self._run_facts["epochs"]} if isinstance(self._run_facts["epochs"], (int, float)) else {}),
1397
+ **({"development": list(self._dev)} if self._validation else {}),
1398
+ shared_code=self._share_code,
1399
+ script_shared=getattr(self, "_script_copied", False),
1400
+ cost_so_far=self.worker.cost,
1401
+ )
1402
+ k = self.worker.submit(req)
1403
+ self.run.event("requested", k=k, update=u, reasons=req["reasons"], **wait)
1404
+ if getattr(self.worker.backend, "name", None) != "keep": # keep answers at once: no line to wait on
1405
+ then = "training waits for the answer" if req["blocking"] else "training continues meanwhile"
1406
+ self._log(f"decision {k}: sent to the agent at update {u} ({then})")
1407
+ self.reports.clear()
1408
+ self.errors.clear()
1409
+ self._reasons, self._last_applied, self._last_answer = [], None, None
1410
+ self._pending_losses, self._pending_from = [], u + 1
1411
+ self._pending_omitted = 0
1412
+ self._skipped, self._reports_made = [], 0
1413
+
1414
+ def _blocking_point(self, u):
1415
+ """Training waits here (all ranks) for the decision in flight, then applies it before update u's step."""
1416
+ busy = self._from_rank0(self.rank0 and self.worker is not None and self.worker.busy)
1417
+ if not busy:
1418
+ return
1419
+ resp = None
1420
+ if self.rank0:
1421
+ t0 = time.perf_counter()
1422
+ self.worker.wait()
1423
+ resp = self.worker.take()
1424
+ self._paused_s += time.perf_counter() - t0
1425
+ self.agent_wait_seconds += time.perf_counter() - t0
1426
+ if self._auto is not None:
1427
+ self._auto.paused(time.perf_counter() - t0)
1428
+ if self.dist:
1429
+ box = [resp]
1430
+ dist.broadcast_object_list(box, src=0, group=self.ctrl)
1431
+ resp = box[0]
1432
+ self._apply(resp, u)
1433
+
1434
+ def _maybe_swap(self, u):
1435
+ """Non-blocking swaps happen at update boundaries. One process: as soon as the answer is there. DDP: rank 0
1436
+ announces readiness in an asynchronous broadcast every sync_every updates; every rank reads the announcement
1437
+ one interval later, so no rank waits for the host or the network in between."""
1438
+ if not self.dist:
1439
+ if self.worker is not None and self.worker.ready is not None:
1440
+ self._apply(self.worker.take(), u)
1441
+ return
1442
+ if u % self.sync_every:
1443
+ return
1444
+ if self._flag_work is not None:
1445
+ self._flag_work.wait()
1446
+ if int(self._flag_buf.item()):
1447
+ box = [self.worker.take() if self.rank0 and self.worker is not None else None]
1448
+ dist.broadcast_object_list(box, src=0, group=self.ctrl)
1449
+ self._apply(box[0], u)
1450
+ ready = self.rank0 and self.worker is not None and self.worker.ready is not None
1451
+ self._flag_buf = torch.tensor([1 if ready else 0])
1452
+ self._flag_work = dist.broadcast(self._flag_buf, src=0, group=self.ctrl, async_op=True)
1453
+
1454
+ # ----- user-facing extras -------------------------------------------------------------------------------------
1455
+
1456
+ def log(self, values=None, *, show_agent=False, **kw):
1457
+ """Record values (validation loss, accuracy, ...) in metrics.jsonl and the report: opt.log(test_acc=a), or a
1458
+ dict as in wandb, opt.log({"test_acc": a}). The agent does not see them
1459
+ unless show_agent=True: then its next analysis call sees them in obs.metrics and its next decision message lists
1460
+ them, and it may tune the run to them (show only a split you accept that for, never the test set). Scalars only
1461
+ (numbers or one-element tensors); tensors are read on the host at the next check step, not here. Only rank 0's
1462
+ values are kept."""
1463
+ if not self.rank0:
1464
+ return
1465
+ if values is not None and not isinstance(values, dict):
1466
+ raise TypeError("opt.log takes a dict of values or keyword values, e.g. opt.log(test_acc=a)")
1467
+ values = dict(values or {}, **kw)
1468
+ rec = dict(update=self.update)
1469
+ for k, v in values.items():
1470
+ if torch.is_tensor(v):
1471
+ if v.numel() != 1:
1472
+ self._warn(f"log-{k}", f"opt.log: {k} has {v.numel()} elements; only scalars are kept")
1473
+ continue
1474
+ rec[k] = v.detach()
1475
+ elif isinstance(v, (int, float, np.integer, np.floating)) and not isinstance(v, bool):
1476
+ rec[k] = int(v) if isinstance(v, (int, np.integer)) else float(v)
1477
+ else:
1478
+ self._warn(f"log-{k}", f"opt.log: {k} is {type(v).__name__}; only numbers are kept")
1479
+ if len(rec) == 1:
1480
+ self._warn("log-empty", "opt.log was called with no number to keep; nothing was recorded")
1481
+ return
1482
+ if self._closed: # e.g. the final evaluation after the last update: keep it in the records and the report
1483
+ self.run.append("metrics.jsonl", self._plain(rec))
1484
+ self._live.refresh()
1485
+ return
1486
+ self._metrics_new.append(rec)
1487
+ if show_agent:
1488
+ self.metrics.append(rec)
1489
+ self._shown_new.append(rec)
1490
+ if len(self.metrics) > 1000:
1491
+ self.metrics = self.metrics[-1000:]
1492
+
1493
+ def report_dev(self, loss=None, accuracy=None, update=None):
1494
+ """OPAL 0.1.1-validation: report one evaluation on the development set (held out from training; never the
1495
+ test set the run is judged by). With wrap(validation=N) the agent sees it in obs.metrics (as dev_loss and
1496
+ dev_accuracy) and, with the history of earlier measurements, in each decision message; it is also kept in
1497
+ metrics.jsonl. Without validation= it is only recorded. update defaults to the number of updates applied so
1498
+ far. Values are read on the host here (a development evaluation synchronises anyway). Rank 0 only.
1499
+
1500
+ Call it right after evaluating the parameters as they are after the latest update: with eval_integrity on,
1501
+ it opens the check of that evaluation (misc/integrity.py)."""
1502
+ if not self.rank0:
1503
+ return
1504
+ if self._integrity is not None and not self._closed:
1505
+ self._integrity.open(self.update, version=self.version)
1506
+ rec = dict(update=int(self.update if update is None else update))
1507
+ for key, v in (("loss", loss), ("accuracy", accuracy)):
1508
+ if v is not None:
1509
+ rec[key] = float(v.detach().float().mean()) if torch.is_tensor(v) else float(v)
1510
+ shown = dict(update=rec["update"], **{f"dev_{k}": v for k, v in rec.items() if k != "update"})
1511
+ if not self._validation:
1512
+ self._warn("dev-off", "opt.report_dev without wrap(validation=N): recorded only; the agent does not see it")
1513
+ self.log(**{k: v for k, v in shown.items() if k != "update"})
1514
+ return
1515
+ if self._closed:
1516
+ self.run.append("metrics.jsonl", shown)
1517
+ return
1518
+ gaps = [b["update"] - a["update"] for a, b in zip(self._dev[-3:], self._dev[-2:] + [rec])]
1519
+ if len(gaps) == 3 and len(set(gaps)) == 1:
1520
+ self._warn(
1521
+ "dev-regular",
1522
+ f"opt.report_dev: the last three evaluations were {gaps[0]} updates apart; the agent is told the "
1523
+ f"script evaluates about every {self._validation} updates at times that are not announced, and it sees "
1524
+ "the update of each measurement, so regular times let it predict the next one (draw each time at "
1525
+ "random, as examples/overfit does)",
1526
+ )
1527
+ mean_gap = (rec["update"] - self._dev[0]["update"]) / len(self._dev) if self._dev else None
1528
+ if len(self._dev) >= 4 and not 0.5 <= mean_gap / self._validation <= 2:
1529
+ self._warn(
1530
+ "dev-interval",
1531
+ f"opt.report_dev: {len(self._dev) + 1} evaluations from update {self._dev[0]['update']} to "
1532
+ f"{rec['update']}; the agent is told the script evaluates about every {self._validation} updates "
1533
+ "(wrap(validation=N) is your promise of that: OPAL does not run the evaluation)",
1534
+ )
1535
+ self._dev.append(rec)
1536
+ self._metrics_new.append(dict(shown))
1537
+ self._shown_new.append(dict(shown))
1538
+
1539
+ # ----- evaluation integrity (misc/integrity.py; rank 0; the agent is not told) ------------------------------
1540
+
1541
+ def _integrity_record(self, u):
1542
+ """Sample the parameters after update u, and check the evaluations whose window is complete. A failure turns the
1543
+ check off, with a note to the person running the training, and never stops training."""
1544
+ try:
1545
+ self._integrity.record(u)
1546
+ if self._integrity.due(u):
1547
+ self._integrity_report(self._integrity.resolve(u))
1548
+ except Exception:
1549
+ self._integrity_failed()
1550
+
1551
+ def _integrity_report(self, results):
1552
+ for t, meta, trips, jumps in results:
1553
+ if jumps: # informational: a lone change that stayed (a weight average swapped in for good)
1554
+ self.run.event("eval_jump", update=t, **meta, n_tensors=len(jumps), tensors=_rounded(jumps[:20]))
1555
+ if not trips:
1556
+ continue
1557
+ self.run.event(
1558
+ "eval_integrity", update=t, flagged=True, **meta, n_tensors=len(trips), tensors=_rounded(trips[:20])
1559
+ )
1560
+ names = ", ".join(
1561
+ f"{r['name']} (norm x{r['norm_factor']:.3g}, changed at update {r['changed_at']:,}, back at "
1562
+ f"{r['restored_at']:,})"
1563
+ for r in trips[:4]
1564
+ )
1565
+ more = f" and {len(trips) - 4} more tensors" if len(trips) > 4 else ""
1566
+ print(
1567
+ f"[opal] evaluation integrity: for the development evaluation after update {t:,}, the program "
1568
+ f"(v{meta.get('version', self.version):03d}) moved {names}{more} to another point and moved them back "
1569
+ "after the evaluation. The run is flagged (eval_integrity in events.jsonl, report.html); read "
1570
+ "programs/ before trusting its development or test results.",
1571
+ file=sys.stderr,
1572
+ flush=True,
1573
+ )
1574
+
1575
+ def _integrity_failed(self):
1576
+ err = traceback.format_exc(limit=3)
1577
+ self._integrity = None
1578
+ if self.rank0:
1579
+ self.run.event("eval_integrity_off", update=self.update, error=err[-1500:])
1580
+ print(
1581
+ "[opal] the evaluation integrity check failed and is off for the rest of this run: "
1582
+ + (err.strip().splitlines() or ["?"])[-1],
1583
+ file=sys.stderr,
1584
+ flush=True,
1585
+ )
1586
+
1587
+ @staticmethod
1588
+ def _plain(m):
1589
+ return {k: (float(v) if torch.is_tensor(v) else v) for k, v in m.items()}
1590
+
1591
+ def _metrics_since(self, consume=False):
1592
+ out = [self._plain(m) for m in self.metrics]
1593
+ if consume:
1594
+ self.metrics = []
1595
+ return out
1596
+
1597
+ def zero_grad(self, set_to_none=True):
1598
+ self._check_user_parameters()
1599
+ for _, p in self.named:
1600
+ if p.grad is not None:
1601
+ if set_to_none:
1602
+ p.grad = None
1603
+ else:
1604
+ if p.grad.grad_fn is not None:
1605
+ p.grad.detach_()
1606
+ else:
1607
+ p.grad.requires_grad_(False)
1608
+ p.grad.zero_()
1609
+
1610
+ def add_param_group(self, param_group):
1611
+ if getattr(self, "_initialised", False):
1612
+ raise NotImplementedError("opal: create all parameter groups before opal.wrap")
1613
+ super().add_param_group(param_group)
1614
+
1615
+ def close(self, interrupted=False):
1616
+ """End of the run: called at the last update, by you after early stopping, and at interpreter exit (where a run
1617
+ that stopped before total_steps counts as interrupted: a mailbox broker then keeps waiting for the resumed
1618
+ run). Marks the final program and stops a decision in flight."""
1619
+ if self._closed:
1620
+ return
1621
+ self._closed = True
1622
+ self._rank_snapshot = None # early stopping can end a trial before its usual release point
1623
+ if not self.rank0:
1624
+ shutil.rmtree(self._prog_root, ignore_errors=True)
1625
+ return
1626
+ for m in self._metrics_new:
1627
+ self.run.append("metrics.jsonl", self._plain(m))
1628
+ self._metrics_new = []
1629
+ be = self.worker.backend if self.worker else None
1630
+ if self.worker is not None and self.worker.busy and be is not None:
1631
+ be.cancel() # its answer could no longer be applied
1632
+ self.run.event("cancelled", k=self.worker.k)
1633
+ if self.worker.thread is not None:
1634
+ self.worker.thread.join(timeout=1.0) # bounded settlement; slower answers record cost_settled
1635
+ settling = self.worker is not None and self.worker.thread is not None and self.worker.thread.is_alive()
1636
+ checked = None
1637
+ if self._integrity is not None: # the evaluations of the last updates, with what is left of their window
1638
+ try:
1639
+ self._integrity_report(self._integrity.resolve(self.update, final=True))
1640
+ checked = dict(checked=self._integrity.checks, flagged=self._integrity.flags)
1641
+ except Exception:
1642
+ self._integrity_failed()
1643
+ self.run.event(
1644
+ "interrupted" if interrupted else "end",
1645
+ update=self.update,
1646
+ version=self.version,
1647
+ decisions=self.worker.k if self.worker else 0,
1648
+ cost_usd=self.worker.recorded_cost if self.worker else 0,
1649
+ unknown_cost_decisions=sorted(self.worker.unknown_cost_decisions) if self.worker else [],
1650
+ **(dict(pending_decision=self.worker.k) if settling else {}),
1651
+ **(dict(usage=self.worker.usage) if self.worker and self.worker.usage is not None else {}),
1652
+ **(dict(eval_integrity=checked) if checked else {}),
1653
+ )
1654
+ if be is not None:
1655
+ be.finish(interrupted=interrupted)
1656
+ self._live.finish(timeout=30.0) # the final report is on disk when close() returns
1657
+ self._log(
1658
+ f"{'stopped' if interrupted else 'done'} at update {self.update}: program v{self.version:03d} in "
1659
+ f"{self.run.dir / 'programs' / f'v{self.version:03d}'}"
1660
+ )
1661
+
1662
+ def _log(self, msg):
1663
+ if self.verbose:
1664
+ print(f"[opal] {redact_secrets(msg)}", file=sys.stderr, flush=True)
1665
+
1666
+ def _notice(self, msg):
1667
+ """A line the user should not miss (the agent will not be asked again): on stdout."""
1668
+ if self.verbose:
1669
+ print(f"[opal] {msg}", flush=True)
1670
+
1671
+ def _warn(self, key, msg):
1672
+ if key not in self._warned:
1673
+ self._warned.add(key)
1674
+ self._log(msg)