opal-optimizer 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/report_page.py ADDED
@@ -0,0 +1,175 @@
1
+ """Self-contained report presentation. Used only by opal.report."""
2
+
3
+ import re
4
+
5
+
6
+ def render(values):
7
+ # One pass: record text containing a placeholder must remain literal text.
8
+ return re.sub(r"@([a-z_]+)@", lambda m: values[m[1]], PAGE)
9
+
10
+
11
+ PAGE = r"""<!doctype html>
12
+ <html lang="en"><head><meta charset="utf-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1">
14
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
15
+ @refresh@<title>OPAL run report</title>
16
+ <style>
17
+ :root {color-scheme:light dark; --bg:#fff; --ink:#202124; --muted:#63676d; --rule:#d9dce0; --accent:#1f4e8c; --code:#f5f6f7; --add:#e8f2ea; --del:#f8e9e7}
18
+ @media(prefers-color-scheme:dark) {:root {--bg:#17191c; --ink:#eaedf0; --muted:#b0b5bc; --rule:#3c4249; --accent:#94baf0; --code:#202429; --add:#223d2c; --del:#482a29}}
19
+ * {box-sizing:border-box}
20
+ body {margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 ui-sans-serif,system-ui,-apple-system,sans-serif}
21
+ main {max-width:1040px; margin:auto; padding:30px 22px 65px; overflow-wrap:anywhere}
22
+ header {display:flex; justify-content:space-between; align-items:baseline; gap:12px}
23
+ h1 {font-size:1.65rem; line-height:1.25; margin:0; font-weight:650}
24
+ h2 {font-size:1.15rem; margin:1.7rem 0 .6rem; font-weight:650}
25
+ h3 {font-size:1rem; margin:1.2rem 0 .3rem}
26
+ p {margin:.55rem 0}
27
+ .goal {color:var(--muted); margin:.4rem 0 1.2rem}
28
+ #overview p {margin:.65rem 0; max-width:92ch}
29
+ a {color:var(--accent); text-underline-offset:3px}
30
+ button {font:inherit; color:var(--accent); background:none; border:0; padding:0; cursor:pointer; text-decoration:underline; text-underline-offset:3px}
31
+ button[aria-pressed=true] {color:var(--ink); text-decoration:none}
32
+ nav {white-space:nowrap; font-size:.85rem}
33
+ [data-lang=zh] {display:none}
34
+ html[lang=zh-CN] [data-lang=en] {display:none}
35
+ html[lang=zh-CN] [data-lang=zh] {display:revert}
36
+ figure {margin:.5rem 0 1.4rem}
37
+ svg {display:block; width:100%; height:auto; overflow:visible}
38
+ svg text {font:12px ui-sans-serif,system-ui,sans-serif; fill:var(--muted)}
39
+ .grid {stroke:var(--rule); stroke-width:.6}
40
+ .loss {fill:none; stroke:var(--ink); stroke-width:1.5; stroke-linejoin:round}
41
+ .loss.alt {stroke:var(--accent)}
42
+ .mark {cursor:pointer; outline-offset:3px}
43
+ .mark line {stroke:var(--accent); stroke-width:1; opacity:.5}
44
+ .mark circle {stroke:var(--accent); stroke-width:1.5; fill:var(--bg)}
45
+ .mark text {fill:var(--accent); font-size:11px; font-weight:650; pointer-events:none}
46
+ .mark:hover circle,.mark:focus circle {stroke-width:3}
47
+ .kept {stroke:var(--muted); opacity:.3}
48
+ #readout,figcaption,.muted {font-size:.85rem; color:var(--muted)}
49
+ #readout {min-height:3.2em; margin:.4rem 0}
50
+ #readout a {margin-left:.5em; white-space:nowrap}
51
+ details {border-top:1px solid var(--rule); padding:.85rem 0}
52
+ details details {margin-top:.5rem; padding:.65rem 0}
53
+ summary {cursor:pointer; font-weight:550}
54
+ summary .muted {font-weight:400}
55
+ pre {white-space:pre-wrap; overflow-wrap:anywhere; font:12px/1.55 ui-monospace,Menlo,monospace; background:var(--code); padding:12px; margin:.5rem 0}
56
+ pre.diff .a {background:var(--add); display:block}
57
+ pre.diff .r {background:var(--del); display:block}
58
+ .flag b {color:var(--ink)}
59
+ table {width:100%; border-collapse:collapse; font-size:.86rem; line-height:1.5}
60
+ th {text-align:left; font-weight:600; border-bottom:1px solid var(--ink); padding:.55rem .45rem; vertical-align:bottom}
61
+ td {vertical-align:top; border-bottom:1px solid var(--rule); padding:.7rem .45rem}
62
+ td.number,td.when {white-space:nowrap}
63
+ td.what {width:35%} td.why {width:32%} td.tags {width:14%; color:var(--muted); font-size:.8rem}
64
+ dt {font-weight:550; margin-top:.7rem} dd {margin:.1rem 0 .5rem; font-size:.9rem}
65
+ noscript {display:block; color:var(--muted)}
66
+ @media(max-width:640px) {
67
+ main {padding:22px 16px 50px} h1 {font-size:1.4rem} body {font-size:15px}
68
+ table thead {display:none} table tr {display:grid; grid-template-columns:2.2em 1fr; padding:.55rem 0; border-bottom:1px solid var(--rule)}
69
+ table td {display:block; border:0; padding:.15rem 0; width:auto!important}
70
+ td.number {grid-column:1; grid-row:1 / span 4} td.when,td.what,td.why,td.tags {grid-column:2; white-space:normal}
71
+ td[data-label]::before {content:attr(data-label) ": "; color:var(--muted)}
72
+ #readout {min-height:4.5em}
73
+ }
74
+ </style></head><body><main>
75
+ <header><h1><span data-lang="en">OPAL run report</span><span data-lang="zh">OPAL 运行报告</span></h1>
76
+ </header>
77
+ <p class="goal">@goal@</p>
78
+ <section id="overview" aria-label="Run overview">@overview@</section>
79
+ <h2><span data-lang="en">Training loss</span><span data-lang="zh">训练损失</span></h2>
80
+ <figure>
81
+ <svg id="chart" role="group" aria-label="Training loss and applied decisions"></svg>
82
+ <p id="readout" role="status" aria-live="polite"></p>
83
+ <figcaption>@caption@</figcaption>
84
+ <noscript>Enable JavaScript to draw the loss and analysis plots. The overview, changes and full records below are available without it.</noscript>
85
+ </figure>
86
+ @table@
87
+ <details id="analysis"><summary><span data-lang="en">Analysis plots</span><span data-lang="zh">分析图表</span></summary><div id="small"></div></details>
88
+ <details id="decision-details"><summary><span data-lang="en">Decision messages and answers</span><span data-lang="zh">决策消息与回答</span></summary>@decisions@</details>
89
+ <details id="code-details"><summary><span data-lang="en">Code diffs</span><span data-lang="zh">代码改动</span></summary>@diffs@</details>
90
+ <details id="facts"><summary><span data-lang="en">Run facts</span><span data-lang="zh">运行记录</span></summary>@facts@</details>
91
+ </main>
92
+ <script>
93
+ 'use strict';
94
+ const L = @losses@, D = @ticks@, S = @small@, T = @total@;
95
+ const ns = 'http://www.w3.org/2000/svg';
96
+ let lang = 'en', selected = null;
97
+ const tr = (en, zh) => lang === 'zh' ? zh : en;
98
+ const fmt = v => Math.abs(v) > 0 && (Math.abs(v) < .001 || Math.abs(v) >= 1e5) ? v.toExponential(2) : Number(v.toPrecision(4)).toString();
99
+ function readout(d) {
100
+ const out = document.getElementById('readout'); out.replaceChildren();
101
+ if (!d) {out.textContent = tr('Hover, focus or tap a number for the change and the agent’s reason.', '悬停、聚焦或轻触编号,查看改动与 agent 的理由。'); return;}
102
+ out.textContent = tr('Decision ', '决策 ') + d.k + ': ' + d.label;
103
+ const link = document.createElement('a'); link.href = '#d' + d.k; link.textContent = tr('Read full decision', '查看完整决策'); out.append(link);
104
+ }
105
+ function draw(svg, series, label, main) {
106
+ svg.replaceChildren();
107
+ const W = Math.max(280, svg.clientWidth || 860), H = main ? 300 : 205;
108
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
109
+ const m = {l:59, r:18, t:main ? 64 : 26, b:42};
110
+ const el = (t, a, txt, parent=svg) => {const e=document.createElementNS(ns,t); for(const k in a)e.setAttribute(k,a[k]); if(txt!=null)e.textContent=txt; parent.appendChild(e); return e;};
111
+ const pts = series.flatMap(s=>s.points).filter(p=>Number.isFinite(p[0]) && Number.isFinite(p[1]));
112
+ if (!pts.length) {el('text',{x:8,y:36},tr('No training loss recorded.', '未记录训练损失。')); return;}
113
+ // Use the full recorded range; do not clip spikes or replace non-finite losses with zero.
114
+ let x1=1, y0=Infinity, y1=-Infinity;
115
+ for(const p of pts) {x1=Math.max(x1,p[0]); y0=Math.min(y0,p[1]); y1=Math.max(y1,p[1]);}
116
+ if(main)for(const d of D)for(const x of [d.requested,d.applied])if(Number.isFinite(x))x1=Math.max(x1,x);
117
+ x1=Math.max(x1,T || 0);
118
+ if(y0===y1) {const pad=Math.abs(y0)*.05 || .5; y0-=pad; y1+=pad;}
119
+ const X=v=>m.l+v/x1*(W-m.l-m.r), Y=v=>H-m.b-(v-y0)/(y1-y0)*(H-m.t-m.b);
120
+ for(let i=0;i<=3;i++) {const v=y0+(y1-y0)*i/3; el('line',{x1:m.l,x2:W-m.r,y1:Y(v),y2:Y(v),class:'grid'}); el('text',{x:m.l-7,y:Y(v)+4,'text-anchor':'end'},fmt(v));}
121
+ const nx=W<500?2:4;
122
+ for(let i=0;i<=nx;i++) {const v=Math.round(x1*i/nx); el('text',{x:X(v),y:H-23,'text-anchor':'middle'},v.toLocaleString());}
123
+ el('text',{x:m.l,y:15},label);
124
+ el('text',{x:(m.l+W-m.r)/2,y:H-3,'text-anchor':'middle'},tr('Update','更新步'));
125
+ for(let i=0;i<series.length;i++) {
126
+ let path='', pen=false;
127
+ for(const p of series[i].points) {
128
+ if(!Number.isFinite(p[0]) || !Number.isFinite(p[1])) {pen=false; continue;}
129
+ path+=(pen?'L':'M')+X(p[0]).toFixed(2)+','+Y(p[1]).toFixed(2); pen=true;
130
+ }
131
+ el('path',{class:i?'loss alt':'loss',d:path});
132
+ if(pts.length===1)el('circle',{cx:X(pts[0][0]),cy:Y(pts[0][1]),r:3,fill:'var(--ink)'});
133
+ if(series.length>1) {const last=series[i].points.filter(p=>Number.isFinite(p[1])).at(-1); if(last)el('text',{x:W-m.r,y:Y(last[1])-5,'text-anchor':'end'},series[i].name);}
134
+ }
135
+ if(!main)return;
136
+ let previous=-Infinity, lane=0;
137
+ for(const d of D) {
138
+ const u=d.applied ?? d.requested; if(!Number.isFinite(u))continue;
139
+ const x=X(u);
140
+ if(!d.changed) {
141
+ if(d.status==='kept') {const tick=el('line',{x1:x,x2:x,y1:H-m.b-7,y2:H-m.b,class:'kept'}); el('title',{},tr('Decision ','决策 ')+d.k+': '+d.label,tick);}
142
+ continue;
143
+ }
144
+ lane=x-previous<28?(lane+1)%2:0; previous=x;
145
+ const y=32+lane*24;
146
+ const near=pts.reduce((a,p)=>Math.abs(p[0]-u)<Math.abs(a[0]-u)?p:a,pts[0]);
147
+ const g=el('g',{class:'mark',tabindex:0,role:'button','aria-label':tr('Decision ','决策 ')+d.k+': '+d.label});
148
+ el('title',{},d.label,g);
149
+ el('line',{x1:x,x2:x,y1:y+10,y2:Y(near[1])},null,g);
150
+ el('circle',{cx:x,cy:y,r:11},null,g); el('text',{x:x,y:y+4,'text-anchor':'middle'},d.k,g);
151
+ const show=()=>{selected=d; readout(d);};
152
+ g.addEventListener('mouseenter',show); g.addEventListener('focus',show); g.addEventListener('click',show);
153
+ g.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' ') {e.preventDefault(); show();}});
154
+ }
155
+ }
156
+ function charts() {
157
+ draw(document.getElementById('chart'),[{name:'loss',points:L}],tr('Training loss ↓','训练损失 ↓'),true);
158
+ const box=document.getElementById('small'); box.replaceChildren();
159
+ if(document.getElementById('analysis').open) {
160
+ if(!S.length) {const p=document.createElement('p'); p.textContent=tr('No additional metric series recorded.','未记录其他指标序列。'); box.append(p);}
161
+ for(const s of S) {const svg=document.createElementNS(ns,'svg'); svg.setAttribute('role','img'); svg.setAttribute('aria-label',s.label); box.append(svg); draw(svg,s.series,s.label,false);}
162
+ }
163
+ readout(selected);
164
+ }
165
+ function language(value) {
166
+ lang=value; document.documentElement.lang=value==='zh'?'zh-CN':'en';
167
+ charts();
168
+ }
169
+ function reveal(id) {const target=document.getElementById(id); if(!target)return; let p=target; while(p) {if(p.tagName==='DETAILS')p.open=true; p=p.parentElement;} target.scrollIntoView({block:'start'});}
170
+ document.addEventListener('click',e=>{const a=e.target.closest('a[href^="#d"]'); if(a) {e.preventDefault(); reveal(a.getAttribute('href').slice(1));}});
171
+ document.getElementById('analysis').addEventListener('toggle',charts);
172
+ let resize; window.addEventListener('resize',()=>{clearTimeout(resize); resize=setTimeout(charts,100);});
173
+ language(lang);
174
+ if(location.hash)reveal(decodeURIComponent(location.hash.slice(1)));
175
+ </script></body></html>"""
opal/report_privacy.py ADDED
@@ -0,0 +1,159 @@
1
+ """Publication filtering for report text only; never changes the run's source records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from urllib.parse import unquote, urlsplit
8
+
9
+ from .backends._privacy import redact_secrets
10
+
11
+
12
+ # Match locations before HTML escaping, including paths inside quoted code/JSON.
13
+ _REMOTE = re.compile(r"\b[a-zA-Z][\w+.-]*://[^\s<>\"'`]+|\barn:aws[^\s<>\"'`]+")
14
+ _HOME = re.compile(r"(?:/Users/|/home/|/homes/|[A-Za-z]:[\\/]Users[\\/])([^/\\\n\"'`]+)[/\\]")
15
+ _PATH = re.compile(
16
+ r"(?<![\w./])(?:/(?:Users|home|homes)/[^/\n\"'`]+/[^\s<>\"'`|;,(){}\[\]]*|"
17
+ r"(?:[A-Za-z]:[\\/]|~[\w.-]*[/\\]|/)[^\s<>\"'`|;,(){}\[\]]+)"
18
+ )
19
+ _LOGIN = re.compile(r"\b([\w.+-]+)@([\w.-]+)\b")
20
+ _UNC = re.compile(r'\\\\([^\s\\/]+)[\\/][^\s<>"\'`]+')
21
+ _LABEL = re.compile(
22
+ r"\b(?:hostname|host_name|host|remote_host|username|user_name|user|login|bucket|bucket_name|s3_bucket)\s*[:=]\s*[\"']?([\w.@+-]+)",
23
+ re.I,
24
+ )
25
+ _HOST = re.compile(
26
+ r"\b(?:[\w-]+\.)+(?:com|edu|net|org|io|local|internal|lan|ac\.[a-z]{2})\b|"
27
+ r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])",
28
+ re.I,
29
+ )
30
+ _IDENTITY_KEYS = {
31
+ "host",
32
+ "hostname",
33
+ "remotehost",
34
+ "sshhost",
35
+ "user",
36
+ "username",
37
+ "userfullname",
38
+ "login",
39
+ "owner",
40
+ "author",
41
+ "bucket",
42
+ "bucketname",
43
+ "s3bucket",
44
+ }
45
+ _RUN_KEYS = {"run_dir", "run_folder", "opal_run"}
46
+ _RUN_CHILD = re.compile(r"[/\\](?:workspace|decisions|programs)(?:[/\\]|$)")
47
+
48
+
49
+ class Privacy:
50
+ """Remember identities wherever they occur in the rendered inputs, then remove repeated mentions too.
51
+
52
+ Paths within this run (including its original location before copying) become relative paths. Other
53
+ locations, remote authorities, login names and explicitly recorded user/host/bucket identifiers are omitted.
54
+ The renderer uses this on every string, including dictionary keys, full messages, code and plot labels.
55
+ """
56
+
57
+ def __init__(self, run, records):
58
+ self.roots = {str(Path(run).absolute()).rstrip("/"), str(Path(run).resolve()).rstrip("/")}
59
+ self.identities = set()
60
+ self._learn(records)
61
+ self.roots = {root for root in self.roots if len(root) > 1}
62
+ self.run_path = re.compile(
63
+ r"(?<![\w./])(?:"
64
+ + "|".join(re.escape(root) for root in sorted(self.roots, key=len, reverse=True))
65
+ + r")(?=$|[/\\\s<>\"'`|;,(){}\[\]])(?:[/\\][^\s<>\"'`|;,(){}\[\]]*)?"
66
+ )
67
+ # A username discovered in one file must disappear from all other displayed files as well.
68
+ values = sorted((s for s in self.identities if s), key=len, reverse=True)
69
+ self.identity = (
70
+ re.compile(r"(?<![\w])(?:" + "|".join(re.escape(s) for s in values) + r")(?![\w])", re.I)
71
+ if values
72
+ else None
73
+ )
74
+
75
+ def _learn(self, obj):
76
+ if isinstance(obj, dict):
77
+ for key, value in obj.items():
78
+ if re.sub(r"[_-]", "", str(key).lower()) in _IDENTITY_KEYS and isinstance(value, str):
79
+ self.identities.add(value)
80
+ if (
81
+ str(key).lower() in _RUN_KEYS
82
+ and isinstance(value, str)
83
+ and (value.startswith("/") or re.match(r"[A-Za-z]:[\\/]", value))
84
+ ):
85
+ self.roots.add(value.rstrip("/\\"))
86
+ if str(key).lower() == "cwd" and isinstance(value, str):
87
+ child = _RUN_CHILD.search(value)
88
+ if child:
89
+ self.roots.add(value[: child.start()])
90
+ self._learn(str(key))
91
+ self._learn(value)
92
+ elif isinstance(obj, (list, tuple)):
93
+ for value in obj:
94
+ self._learn(value)
95
+ elif isinstance(obj, str):
96
+ for match in re.finditer(r"~([\w.-]+)[/\\]", obj):
97
+ self.identities.add(match[1])
98
+ for match in _UNC.finditer(obj):
99
+ self.identities.add(match[1])
100
+ for match in _REMOTE.finditer(obj):
101
+ if "://" in match[0]:
102
+ authority = match[0].split("://", 1)[1].split("/", 1)[0]
103
+ self.identities.add(authority)
104
+ try:
105
+ location = urlsplit(match[0])
106
+ self.identities.update(unquote(v) for v in (location.hostname, location.username) if v)
107
+ bucket = re.match(r"(.+?)\.s3[.-]", location.hostname or "")
108
+ if bucket:
109
+ self.identities.add(bucket[1])
110
+ except ValueError:
111
+ pass
112
+ elif ":::" in match[0]:
113
+ self.identities.add(match[0].split(":::", 1)[1].split("/", 1)[0])
114
+ for match in _HOME.finditer(obj):
115
+ self.identities.add(match[1])
116
+ for match in _LOGIN.finditer(obj):
117
+ self.identities.update(match.groups())
118
+ for match in _LABEL.finditer(obj):
119
+ self.identities.add(match[1])
120
+ for match in _PATH.finditer(obj):
121
+ child = _RUN_CHILD.search(match[0])
122
+ if child:
123
+ self.roots.add(match[0][: child.start()])
124
+
125
+ def _path(self, match):
126
+ path = match[0]
127
+ # Avoid eating arithmetic such as `1 / 2`, or regex syntax such as /\w+/.
128
+ if path in ("/", "//"):
129
+ return path
130
+ for root in sorted(self.roots, key=len, reverse=True):
131
+ if path == root:
132
+ return "."
133
+ if path.startswith(root + "/") or path.startswith(root + "\\"):
134
+ rel = path[len(root) + 1 :].replace("\\", "/")
135
+ # A path leaving the run must not masquerade as a relative path within it.
136
+ if ".." not in rel.split("/"):
137
+ return rel
138
+ return "[path omitted]"
139
+
140
+ def text(self, text):
141
+ text = redact_secrets(text)
142
+ text = _REMOTE.sub("[remote location omitted]", text)
143
+ text = _UNC.sub("[path omitted]", text)
144
+ text = self.run_path.sub(self._path, text)
145
+ text = _PATH.sub(self._path, text)
146
+ text = _LOGIN.sub("[login omitted]", text)
147
+ text = _HOST.sub("[host omitted]", text)
148
+ if self.identity:
149
+ text = self.identity.sub("[identity omitted]", text)
150
+ return text
151
+
152
+ def clean(self, obj):
153
+ if isinstance(obj, str):
154
+ return self.text(obj)
155
+ if isinstance(obj, dict):
156
+ return {self.text(k) if isinstance(k, str) else k: self.clean(v) for k, v in obj.items()}
157
+ if isinstance(obj, (list, tuple)):
158
+ return [self.clean(v) for v in obj]
159
+ return obj
opal/results.py ADDED
@@ -0,0 +1,80 @@
1
+ """The fields every example writes to its result.json, so that a run can be set beside ours (docs/GUIDE.md, "Comparing
2
+ with our runs"): the metric (name, value, direction), the training time and the time training waited for the agent,
3
+ the agent's cost, the decisions asked, applied and failed, the hardware, the OPAL commit, the full command and the OPAL
4
+ run folder. A plain run has the same fields, with the OPAL ones None. save_weights() writes final weights for --save."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import shlex
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import torch
15
+
16
+
17
+ def hardware(device=None) -> str:
18
+ dev = torch.device(device) if device is not None else None
19
+ if dev is not None and dev.type == "cuda":
20
+ n = torch.cuda.device_count()
21
+ return f"{n} x {torch.cuda.get_device_name(dev)}"
22
+ return f"{platform.machine()} CPU ({platform.processor() or platform.system()})" + (
23
+ f", {dev.type}" if dev is not None and dev.type not in ("cpu", "cuda") else ""
24
+ )
25
+
26
+
27
+ def opal_commit() -> str | None:
28
+ """The git commit of the OPAL checkout that ran (with "+changes" when it has local edits), else its version."""
29
+ from . import __version__
30
+
31
+ here = Path(__file__).parent
32
+ try:
33
+ head = subprocess.run(["git", "-C", str(here), "rev-parse", "--short=12", "HEAD"], capture_output=True,
34
+ text=True, timeout=10) # fmt: skip
35
+ if head.returncode != 0:
36
+ return __version__
37
+ dirty = subprocess.run(["git", "-C", str(here), "status", "--porcelain", "--", "."], capture_output=True,
38
+ text=True, timeout=10).stdout.strip() # fmt: skip
39
+ return head.stdout.strip() + ("+changes" if dirty else "")
40
+ except (OSError, subprocess.TimeoutExpired):
41
+ return __version__
42
+
43
+
44
+ def summary(metric, value, direction, train_seconds, opt=None, device=None) -> dict:
45
+ """The uniform fields. direction: "higher" or "lower" is better. opt: the AgentOptimizer of an OPAL run."""
46
+ out = dict(
47
+ metric=dict(name=metric, value=value, direction=direction),
48
+ train_seconds=round(float(train_seconds), 1),
49
+ agent_wait_seconds=None,
50
+ agent_cost_usd=None,
51
+ decisions=None,
52
+ hardware=hardware(device),
53
+ opal_commit=None,
54
+ command=" ".join(shlex.quote(x) for x in [sys.executable, *sys.argv]),
55
+ opal_run=None,
56
+ )
57
+ run = getattr(opt, "run", None)
58
+ if run is not None:
59
+ from .report import _jsonl, decision_counts
60
+
61
+ c = decision_counts(_jsonl(run.dir / "events.jsonl"))
62
+ out.update(
63
+ agent_wait_seconds=round(getattr(opt, "agent_wait_seconds", 0.0), 1),
64
+ agent_cost_usd=opt.worker.recorded_cost if opt.worker is not None else 0.0,
65
+ decisions=dict(asked=c["asked"], applied=c["applied"], failed=c["failed"]),
66
+ opal_commit=opal_commit(),
67
+ opal_run=str(run.dir),
68
+ )
69
+ if opt.worker is not None and (opt.worker.usage is not None or opt.worker.backend.name == "codex"):
70
+ out.update(agent_cost_usd=None, agent_tokens=opt.worker.usage)
71
+ return out
72
+
73
+
74
+ def save_weights(folder, **state_dicts):
75
+ """--save: each state dict to <folder>/<name>.pt (model.pt, ema.pt, ...)."""
76
+ folder = Path(folder)
77
+ folder.mkdir(parents=True, exist_ok=True)
78
+ for name, state in state_dicts.items():
79
+ torch.save(state, folder / f"{name}.pt")
80
+ return folder
opal/schedule.py ADDED
@@ -0,0 +1,134 @@
1
+ """When the agent is asked: wrap(decisions=..., schedule=...).
2
+
3
+ schedule (the decision updates; it has nothing to do with the learning-rate schedule):
4
+ "front" (default) a fifth of the decisions evenly over the first 5% of training (where our agents made most of
5
+ their code changes: SiT, 49 of 125 decisions before update 50K changed a file, 6 of 310 after),
6
+ the rest evenly over the remainder. The same as [(0.05, a fifth), (1.0, the rest)].
7
+ "uniform" every total // decisions updates, as in the v1 harness (646, 1292, ... on 64,626 updates with
8
+ 100 decisions).
9
+ [(0.05, 20), (1.0, 30)]
10
+ segments, each (end, count) with end a fraction of training: 20 decisions evenly over the first
11
+ 5%, then 30 evenly over the rest. Within a segment the gaps are equal and the last decision falls
12
+ on the segment's end, except in a segment that ends the run, whose last decision falls one gap
13
+ before the end (an answer at the last update could not act on the run).
14
+ [500, 1000, 5000] these updates.
15
+ decisions is the count for "front" and "uniform" (50 by default); a list gives its own count (the sum of the segments'
16
+ counts, or the number of updates), and a decisions= that disagrees with it is an error.
17
+
18
+ Decisions are asked only at checks (every `every` updates), so each update is moved up to the next check, two that
19
+ land on the same check become one, and none falls in the last `every` updates (an answer could no longer act); the run
20
+ has as many scheduled decisions as are left. Besides the schedule, the agent's own analysis can ask for a decision
21
+ ({"wake": reason}), and a non-finite loss does.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ DEFAULT_DECISIONS = 50
27
+ NAMED = ("front", "uniform")
28
+ EARLY_FRACTION, EARLY_SHARE = 0.05, 0.2 # "front": a fifth of the decisions in the first 5% of training
29
+
30
+ HELP = (
31
+ 'schedule must be "front", "uniform", a list of (end_fraction, count) segments such as [(0.05, 20), (1.0, 30)], '
32
+ "or a list of update numbers such as [500, 1000, 5000]"
33
+ )
34
+
35
+
36
+ def kind(schedule) -> str:
37
+ """'front', 'uniform', 'segments' or 'updates'; raises ValueError for anything else."""
38
+ if isinstance(schedule, str):
39
+ if schedule in NAMED:
40
+ return schedule
41
+ raise ValueError(f"opal: {HELP}, got {schedule!r}")
42
+ if not isinstance(schedule, (list, tuple)):
43
+ raise ValueError(f"opal: {HELP}, got {schedule!r}")
44
+ if all(_is_int(u) for u in schedule):
45
+ return "updates"
46
+ if all(isinstance(s, (list, tuple)) and len(s) == 2 for s in schedule):
47
+ return "segments"
48
+ raise ValueError(f"opal: {HELP}, got {schedule!r}")
49
+
50
+
51
+ def _is_int(x):
52
+ return isinstance(x, int) and not isinstance(x, bool)
53
+
54
+
55
+ def _segments(schedule):
56
+ """Checked (end_fraction, count) pairs."""
57
+ out, prev = [], 0.0
58
+ for s in schedule:
59
+ end, count = s
60
+ if isinstance(end, bool) or not isinstance(end, (int, float)) or not prev < end <= 1:
61
+ raise ValueError(
62
+ f"opal: schedule segment {tuple(s)!r}: each end must be a fraction of training in (0, 1], larger than "
63
+ "the previous segment's end"
64
+ )
65
+ if not _is_int(count) or count < 0:
66
+ raise ValueError(f"opal: schedule segment {tuple(s)!r}: the count must be an int >= 0")
67
+ out.append((float(end), count))
68
+ prev = end
69
+ return out
70
+
71
+
72
+ def count(schedule, decisions=None) -> int:
73
+ """How many decisions the schedule asks for before snapping to checks. decisions=None means the default (50) for
74
+ "front" and "uniform" and the list's own count otherwise; a decisions that disagrees with a list is an error."""
75
+ k = kind(schedule)
76
+ if decisions is not None and (not _is_int(decisions) or decisions < 0):
77
+ raise ValueError(f"opal: decisions must be an int >= 0, got {decisions!r}")
78
+ if k in NAMED:
79
+ return DEFAULT_DECISIONS if decisions is None else decisions
80
+ if k == "segments":
81
+ given = sum(c for _, c in _segments(schedule))
82
+ what = " + ".join(str(c) for _, c in schedule) if len(schedule) > 1 else str(given)
83
+ else:
84
+ given, what = len(schedule), f"{len(schedule)} updates"
85
+ if decisions is not None and decisions != given:
86
+ raise ValueError(
87
+ f"opal: decisions={decisions} but schedule gives {given} ({what}); leave decisions out, or make them agree"
88
+ )
89
+ return given
90
+
91
+
92
+ def decision_updates(total: int, n: int, cadence=1, schedule="front") -> list:
93
+ """Scheduled decision updates, snapped up to the check cadence (decisions can only happen at check steps) and
94
+ without the last `cadence` updates, so the count is the number that can actually happen. n: the count for "front"
95
+ and "uniform" (ignored for a list, which gives its own)."""
96
+ return [u for seg in decision_segments(total, n, cadence, schedule) for u in seg]
97
+
98
+
99
+ def decision_segments(total: int, n: int, cadence=1, schedule="front") -> list:
100
+ """decision_updates, as one list per segment: two for "front" (the first 5% and the rest), one for "uniform" and
101
+ for a list of updates, one per (end, count) segment. A check claimed by an earlier segment is not repeated."""
102
+ k = kind(schedule)
103
+ if k == "updates":
104
+ for u in schedule:
105
+ if not 1 <= u <= total:
106
+ raise ValueError(f"opal: schedule update {u} is outside the run (1 to total_steps={total})")
107
+ if total <= 1:
108
+ return [[]]
109
+ if k == "uniform":
110
+ return [_snap([max(1, total // n) * (i + 1) for i in range(n)], total, cadence) if n > 0 else []]
111
+ if k == "updates":
112
+ return [_snap(list(schedule), total, cadence)]
113
+ if k == "front":
114
+ if n <= 0:
115
+ return [[]]
116
+ n_early = max(1, round(n * EARLY_SHARE))
117
+ segments = [(EARLY_FRACTION, n_early), (1.0, max(0, n - n_early))]
118
+ else:
119
+ segments = _segments(schedule)
120
+ out, seen, start = [], set(), 0
121
+ for end_fraction, c in segments:
122
+ end = total if end_fraction == 1.0 else max(1, int(total * end_fraction))
123
+ span, parts = end - start, c + 1 if end == total else c
124
+ raw = [start + round(span * (i + 1) / parts) for i in range(c)] if c else []
125
+ seg = [u for u in _snap(raw, total, cadence) if u not in seen]
126
+ seen.update(seg)
127
+ out.append(seg)
128
+ start = end
129
+ return out
130
+
131
+
132
+ def _snap(updates, total, cadence):
133
+ snapped = {-(-u // cadence) * cadence for u in updates}
134
+ return sorted(u for u in snapped if 0 < u and total - u >= cadence)