opal-optimizer 0.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/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
|