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/summaries.py ADDED
@@ -0,0 +1,224 @@
1
+ """A short description and tags for each code change, written by a cheap model through the Claude Code CLI with no
2
+ tools. One call per applied decision, cached in decisions/NNN/summary.json; the report shows them in its table."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import datetime
7
+ import difflib
8
+ import json
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from .backends.base import agent_env
16
+ from .backends._privacy import redact_secrets
17
+
18
+ TAGS = (
19
+ "common practice",
20
+ "niche practice",
21
+ "block-specific",
22
+ "observation-originated",
23
+ "time-step-specific",
24
+ "fix or revert",
25
+ )
26
+ DEFAULT_MODEL = (
27
+ "claude-opus-5-5" # at effort low: on 95 decisions it mislabelled less than Haiku
28
+ )
29
+ DEFAULT_EFFORT = "low"
30
+ _KEY = {re.sub(r"[^a-z]", "", t): t for t in TAGS}
31
+
32
+ SYSTEM = """You describe a change that a coding agent made to the optimizer program of a neural-network training run.
33
+ You get the agent's own rationale and the unified diff of the program files. Reply with one JSON object and nothing
34
+ else: {"summary": "...", "tags": ["..."]}.
35
+
36
+ summary: one paragraph of 2 to 4 plain sentences. Start with what the change does (not with the decision number or
37
+ the update), concretely (which technique, which
38
+ parameters, groups or layers, which values, from or until which update), then the reason the agent gives for it. Use
39
+ only what the rationale and the diff show. Describe; do not judge whether the change is good, new or likely to help.
40
+
41
+ tags: every label below that applies to the change, spelled exactly as written: none, one or several. Judge each label
42
+ on its own; they overlap.
43
+ - "common practice": the change uses a technique that is standard in current training recipes (for example Muon on
44
+ matrix parameters, learning-rate warmup, cosine or linear decay, gradient clipping, AdamW betas or weight decay).
45
+ - "niche practice": the change uses a technique that is published or known but uncommon (for example SOAP or Shampoo,
46
+ per-tensor learning-rate controllers, NorMuon).
47
+ - "block-specific": some parameter groups, layers or blocks are treated differently from the others (one rule applied
48
+ the same way to every tensor, even with per-tensor state, does not count).
49
+ - "observation-originated": triggered by something the agent saw in this run and cites in its rationale (for example a
50
+ loss spike, a plateau, a gradient statistic).
51
+ - "time-step-specific": the program treats a particular update or phase of training differently (for example "start
52
+ Muon at update 801", a warmup, a switch at some update). Every change is made at some update; that alone does not
53
+ count.
54
+ - "fix or revert": the change repairs or undoes the agent's own earlier change (for example "reverted the per-tensor
55
+ LR controller from decision 4", "fixed the NaN guard I added")."""
56
+
57
+ _OPTIONAL = ("--safe-mode", "--no-chrome", "--setting-sources", "--strict-mcp-config", "--max-budget-usd", "--effort")
58
+
59
+
60
+ def unified_diff(old: dict, new: dict) -> str:
61
+ out = []
62
+ for name in sorted(set(old) | set(new)):
63
+ a, b = old.get(name, "").splitlines(), new.get(name, "").splitlines()
64
+ if a != b:
65
+ out += difflib.unified_diff(a, b, f"a/{name}", f"b/{name}", lineterm="", n=2)
66
+ return "\n".join(out)
67
+
68
+
69
+ def message(k, rationale, diff, total=None, max_rationale=6000, max_diff=12000) -> str:
70
+ """The input for one summary. The update at which the decision was made is left out: the table shows it, and
71
+ given to the model it makes every change look tied to a time step."""
72
+ run = f" The training run has {total:,} updates." if total else ""
73
+ rationale = (rationale or "").strip() or "(the agent gave no rationale)"
74
+ if len(rationale) > max_rationale:
75
+ rationale = "[...]\n" + rationale[-max_rationale:]
76
+ if len(diff) > max_diff:
77
+ diff = diff[:max_diff] + f"\n[... diff truncated: {len(diff) - max_diff:,} more characters]"
78
+ return (
79
+ f"The agent's decision number {k}.{run}\n\n<rationale>\n{rationale}\n</rationale>\n\n<diff>\n{diff}\n</diff>"
80
+ "\n\nReply with the JSON object only."
81
+ )
82
+
83
+
84
+ def parse(text: str):
85
+ """The model's reply as (summary, tags, dropped tags); raises ValueError if it is not the asked-for JSON."""
86
+ text = (text or "").strip()
87
+ i, j = text.find("{"), text.rfind("}")
88
+ if i < 0 or j <= i:
89
+ raise ValueError("no JSON object in the reply")
90
+ obj = json.loads(text[i : j + 1])
91
+ summary = obj.get("summary") if isinstance(obj, dict) else None
92
+ if not isinstance(summary, str) or not summary.strip():
93
+ raise ValueError("the reply has no summary")
94
+ raw = obj.get("tags") or []
95
+ raw = [raw] if isinstance(raw, str) else raw if isinstance(raw, list) else []
96
+ tags, dropped = [], []
97
+ for t in raw:
98
+ tag = _KEY.get(re.sub(r"[^a-z]", "", str(t).lower()))
99
+ (tags if tag else dropped).append(tag or str(t))
100
+ return " ".join(summary.split()), [t for t in TAGS if t in tags], dropped
101
+
102
+
103
+ def _run_cli(cmd, text, cwd, env, timeout):
104
+ """One CLI call: (return code, stdout, stderr). Tests replace this."""
105
+ p = subprocess.run(cmd, input=text, capture_output=True, text=True, cwd=cwd, env=env, timeout=timeout)
106
+ return p.returncode, p.stdout, p.stderr
107
+
108
+
109
+ class Summarizer:
110
+ """Calls `binary --print` with no tools, no session file and the user's customisations off, in an empty folder."""
111
+
112
+ def __init__(self, model=None, binary="claude", timeout=180, max_budget_usd=0.5):
113
+ self.model, self.binary, self.timeout, self.budget = model or DEFAULT_MODEL, binary, timeout, max_budget_usd
114
+ self._flags = None
115
+
116
+ def _env(self, cwd):
117
+ """The claude backend's environment: secrets removed except the login, no git context, CLAUDE.md or memory."""
118
+ from .backends.claude import _login_prefixes
119
+
120
+ isolate = {
121
+ "TMPDIR": str(cwd),
122
+ "GIT_CEILING_DIRECTORIES": str(Path(cwd).parent),
123
+ "CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
124
+ "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1",
125
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
126
+ "CLAUDE_CODE_DISABLE_BACKGROUND_TASKS": "1",
127
+ }
128
+ return agent_env(_login_prefixes(), isolate)
129
+
130
+ def _supported(self, cwd):
131
+ if self._flags is None:
132
+ try:
133
+ out = _run_cli([self.binary, "--help"], "", cwd, self._env(cwd), 60)[1]
134
+ except (OSError, subprocess.TimeoutExpired):
135
+ out = ""
136
+ self._flags = {f for f in _OPTIONAL if f in out} if out else set(_OPTIONAL)
137
+ return self._flags
138
+
139
+ def command(self, cwd) -> list:
140
+ ok = self._supported(cwd)
141
+ cmd = [self.binary, "--print", "--output-format", "json", "--model", self.model, "--tools", ""]
142
+ cmd += ["--no-session-persistence", "--system-prompt", SYSTEM]
143
+ cmd += [f for f in ("--safe-mode", "--no-chrome") if f in ok]
144
+ if "--setting-sources" in ok:
145
+ cmd += ["--setting-sources", ""]
146
+ if "--strict-mcp-config" in ok:
147
+ cmd += ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}']
148
+ if "--effort" in ok and self.model == DEFAULT_MODEL:
149
+ cmd += ["--effort", DEFAULT_EFFORT]
150
+ if "--max-budget-usd" in ok and self.budget:
151
+ cmd += ["--max-budget-usd", str(self.budget)]
152
+ return cmd
153
+
154
+ def __call__(self, text: str) -> dict:
155
+ """One description: dict(summary, tags, dropped_tags, cost_usd, seconds) or dict(error, cost_usd, seconds)."""
156
+ t0, cost, reply = time.time(), None, ""
157
+ try:
158
+ with tempfile.TemporaryDirectory(prefix="opal-summary-") as tmp:
159
+ code, out, err = _run_cli(self.command(tmp), text, tmp, self._env(tmp), self.timeout)
160
+ result = _result(out)
161
+ cost = result.get("total_cost_usd")
162
+ reply = result.get("result") or ""
163
+ if code != 0 or result.get("is_error"):
164
+ raise RuntimeError(f"return code {code}: {(reply or err or out or '')[-500:]}")
165
+ summary, tags, dropped = parse(reply)
166
+ return dict(summary=summary, tags=tags, dropped_tags=dropped, cost_usd=cost, seconds=_since(t0))
167
+ except FileNotFoundError:
168
+ return dict(error=f"{self.binary!r} not found", cost_usd=cost, seconds=_since(t0))
169
+ except Exception as exc: # noqa: BLE001 - a summary must never fail the caller
170
+ return dict(
171
+ error=f"{type(exc).__name__}: {exc}"[:800], reply=reply[:2000], cost_usd=cost, seconds=_since(t0)
172
+ )
173
+
174
+
175
+ def _result(out):
176
+ """The CLI's result object: one JSON object, a JSON list of messages, or JSON lines (versions differ)."""
177
+ out = (out or "").strip()
178
+ try:
179
+ items = json.loads(out)
180
+ except ValueError:
181
+ items = []
182
+ for line in out.splitlines():
183
+ try:
184
+ items.append(json.loads(line))
185
+ except ValueError:
186
+ pass
187
+ items = items if isinstance(items, list) else [items]
188
+ return next((x for x in reversed(items) if isinstance(x, dict) and x.get("type") == "result"), {})
189
+
190
+
191
+ def _since(t0):
192
+ return round(time.time() - t0, 1)
193
+
194
+
195
+ def load(record_dir: Path):
196
+ f = Path(record_dir) / "summary.json"
197
+ try:
198
+ return json.loads(f.read_text()) if f.exists() else None
199
+ except (OSError, ValueError):
200
+ return None
201
+
202
+
203
+ def ensure(record_dir, summarizer, k, rationale, old: dict, new: dict, total=None):
204
+ """The decision's summary from its cache, or from one call that fills the cache. A failed call is not cached (the
205
+ next `opal report --summarize` tries again); its error goes to summary-error.json. Returns the summary,
206
+ or None."""
207
+ record_dir = Path(record_dir)
208
+ cached = load(record_dir)
209
+ if cached is not None or summarizer is None:
210
+ return cached
211
+ out = summarizer(message(k, rationale, unified_diff(old, new), total))
212
+ for field in ("error", "reply", "summary"):
213
+ if out.get(field) is not None:
214
+ out[field] = redact_secrets(out[field])
215
+ if out.get("dropped_tags"):
216
+ out["dropped_tags"] = [redact_secrets(tag) for tag in out["dropped_tags"]]
217
+ out.update(model=summarizer.model, utc=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
218
+ record_dir.mkdir(parents=True, exist_ok=True)
219
+ if "error" in out:
220
+ (record_dir / "summary-error.json").write_text(json.dumps(out, indent=1))
221
+ return None
222
+ (record_dir / "summary.json").write_text(json.dumps(out, indent=1))
223
+ (record_dir / "summary-error.json").unlink(missing_ok=True)
224
+ return out