miroir 0.0.1__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.
- miroir/__init__.py +430 -0
- miroir/calibrate.py +212 -0
- miroir/cli.py +113 -0
- miroir/derive.py +544 -0
- miroir/pool.py +261 -0
- miroir/review.py +378 -0
- miroir/trl.py +340 -0
- miroir-0.0.1.dist-info/METADATA +31 -0
- miroir-0.0.1.dist-info/RECORD +13 -0
- miroir-0.0.1.dist-info/WHEEL +4 -0
- miroir-0.0.1.dist-info/entry_points.txt +3 -0
- miroir-0.0.1.dist-info/licenses/LICENSE +202 -0
- miroir_verifiers/__init__.py +239 -0
miroir/__init__.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Client of Miroir envs: lease a container, run episodes, and what the adapters share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime
|
|
7
|
+
import functools
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import random
|
|
11
|
+
import socket
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.parse
|
|
16
|
+
import urllib.request
|
|
17
|
+
import uuid
|
|
18
|
+
import warnings
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus
|
|
25
|
+
from websockets.sync.client import ClientConnection, connect
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CoworkerModel",
|
|
29
|
+
"MiroirError",
|
|
30
|
+
"Session",
|
|
31
|
+
"answer_coworkers",
|
|
32
|
+
"answerable",
|
|
33
|
+
"answering",
|
|
34
|
+
"metrics",
|
|
35
|
+
"prompt",
|
|
36
|
+
"read_urls",
|
|
37
|
+
"save_record",
|
|
38
|
+
"tasks",
|
|
39
|
+
"tools",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
PING_INTERVAL_S = 20.0
|
|
43
|
+
PING_TIMEOUT_S = 60.0
|
|
44
|
+
HTTP_TIMEOUT_S = 30.0
|
|
45
|
+
HEALTH_TIMEOUT_S = 2.0
|
|
46
|
+
HEALTH_CHECKS = 32
|
|
47
|
+
HEALTH_WORKERS = 512
|
|
48
|
+
RETRY_EVERY_S = 1.0
|
|
49
|
+
COWORKER_TIMEOUT_S = 30.0
|
|
50
|
+
TRACES_STARTED = datetime.datetime.now(datetime.UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
51
|
+
_traces_lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MiroirError(Exception):
|
|
55
|
+
"""An error from the env or from reaching it, with its code."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, code: str, message: str) -> None:
|
|
58
|
+
super().__init__(f"{code}: {message}")
|
|
59
|
+
self.code = code
|
|
60
|
+
self.message = message
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Unreachable(Exception):
|
|
64
|
+
"""A container that answered its health check, then failed the session's handshake, as
|
|
65
|
+
when a gateway before it errs: another container may take the lease, or it may later."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Session:
|
|
69
|
+
"""A WebSocket session on one env container, which it holds until closed."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, url: str, socket: ClientConnection) -> None:
|
|
72
|
+
self.url = url
|
|
73
|
+
self._socket = socket
|
|
74
|
+
self._broken: MiroirError | None = None
|
|
75
|
+
self._episode_id: str | None = None
|
|
76
|
+
self._last: dict[str, Any] | None = None
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def lease(
|
|
81
|
+
cls,
|
|
82
|
+
urls: Sequence[str] | os.PathLike[str],
|
|
83
|
+
token: str | None = None,
|
|
84
|
+
*,
|
|
85
|
+
wait_busy: bool,
|
|
86
|
+
timeout_s: float = 600.0,
|
|
87
|
+
) -> Session:
|
|
88
|
+
"""Open a session on a free, healthy container among `urls`, a list or a URL file.
|
|
89
|
+
|
|
90
|
+
Raises `NO_FREE_CONTAINER` at `timeout_s`, or at once if all are leased and not
|
|
91
|
+
`wait_busy`.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(urls, str) or not urls:
|
|
94
|
+
raise ValueError("urls must be a non-empty list of base URLs, or a file of them")
|
|
95
|
+
deadline = time.monotonic() + timeout_s
|
|
96
|
+
while True:
|
|
97
|
+
listed = read_urls(urls) if isinstance(urls, os.PathLike) else list(urls)
|
|
98
|
+
start = random.randrange(len(listed))
|
|
99
|
+
order = [*listed[start:], *listed[:start]]
|
|
100
|
+
busy, down = 0, []
|
|
101
|
+
for at in range(0, len(order), HEALTH_CHECKS):
|
|
102
|
+
batch = order[at : at + HEALTH_CHECKS]
|
|
103
|
+
for url, health in zip(batch, _check_pool().map(_health, batch), strict=True):
|
|
104
|
+
if health is None or health.get("status") != "healthy":
|
|
105
|
+
down.append(url)
|
|
106
|
+
continue
|
|
107
|
+
if health.get("leased"):
|
|
108
|
+
busy += 1
|
|
109
|
+
continue
|
|
110
|
+
try:
|
|
111
|
+
socket = _open_session(url, token)
|
|
112
|
+
except _Unreachable:
|
|
113
|
+
down.append(url)
|
|
114
|
+
continue
|
|
115
|
+
if socket is not None:
|
|
116
|
+
return cls(url, socket)
|
|
117
|
+
busy += 1
|
|
118
|
+
if (busy and not down and not wait_busy) or time.monotonic() >= deadline:
|
|
119
|
+
shown = f" ({', '.join(down[:3])})" if down else ""
|
|
120
|
+
raise MiroirError(
|
|
121
|
+
"NO_FREE_CONTAINER",
|
|
122
|
+
f"{busy} of {len(listed)} containers leased, {len(down)} not healthy"
|
|
123
|
+
f"{shown}; start more containers, or run fewer rollouts at once",
|
|
124
|
+
)
|
|
125
|
+
time.sleep(RETRY_EVERY_S)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def episode_id(self) -> str | None:
|
|
129
|
+
"""The running episode's id, or None."""
|
|
130
|
+
return self._episode_id
|
|
131
|
+
|
|
132
|
+
def reset(
|
|
133
|
+
self, task_id: str, max_seconds: int | None = None, coworkers: bool = False
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""Start an episode on a task; returns the reset observation."""
|
|
136
|
+
self._episode_id, self._last = None, None
|
|
137
|
+
episode_id = uuid.uuid4().hex
|
|
138
|
+
data = {
|
|
139
|
+
"task_id": task_id,
|
|
140
|
+
"episode_id": episode_id,
|
|
141
|
+
"max_seconds": max_seconds,
|
|
142
|
+
"coworkers": coworkers,
|
|
143
|
+
}
|
|
144
|
+
reply = self._exchange({"type": "reset", "data": data})
|
|
145
|
+
self._episode_id = episode_id
|
|
146
|
+
return reply["observation"]
|
|
147
|
+
|
|
148
|
+
def step(self, tool: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
|
149
|
+
"""Call a tool; returns the step's observation, reward and done."""
|
|
150
|
+
if self._episode_id is None:
|
|
151
|
+
raise MiroirError("NO_EPISODE", "no episode is running; reset first")
|
|
152
|
+
action = {
|
|
153
|
+
"type": "call_tool",
|
|
154
|
+
"tool_name": tool,
|
|
155
|
+
"arguments": dict(arguments),
|
|
156
|
+
"metadata": {"episode_id": self._episode_id},
|
|
157
|
+
}
|
|
158
|
+
self._last = self._exchange({"type": "step", "data": action})
|
|
159
|
+
return self._last
|
|
160
|
+
|
|
161
|
+
def coworker_turns(self) -> list[dict[str, Any]]:
|
|
162
|
+
"""The coworkers who owe a reply, each with the chat to answer."""
|
|
163
|
+
return self._exchange({"type": "coworkers"})["turns"]
|
|
164
|
+
|
|
165
|
+
def coworker_reply(self, coworker: str, text: str) -> int | None:
|
|
166
|
+
"""Post a coworker's reply; returns the app's HTTP status."""
|
|
167
|
+
data = {"coworker": coworker, "text": text}
|
|
168
|
+
return self._exchange({"type": "coworker_reply", "data": data})["status"]
|
|
169
|
+
|
|
170
|
+
def state(self) -> dict[str, Any]:
|
|
171
|
+
"""The env's state frame."""
|
|
172
|
+
return self._exchange({"type": "state"})
|
|
173
|
+
|
|
174
|
+
def finish(self) -> dict[str, Any]:
|
|
175
|
+
"""Submit if the episode still runs; returns its reward, success, metadata and record."""
|
|
176
|
+
if self._episode_id is None:
|
|
177
|
+
raise MiroirError("NO_EPISODE", "no episode is running; reset first")
|
|
178
|
+
if (self._last is None or not self._last["done"]) and not self.state()["done"]:
|
|
179
|
+
try:
|
|
180
|
+
last = self.step("submit", {})
|
|
181
|
+
except MiroirError:
|
|
182
|
+
if not self.state()["done"]:
|
|
183
|
+
raise
|
|
184
|
+
else:
|
|
185
|
+
if not last["done"]:
|
|
186
|
+
error = last["observation"].get("error") or {}
|
|
187
|
+
message = error.get("message", error)
|
|
188
|
+
raise MiroirError("NOT_GRADED", f"submit failed: {message}")
|
|
189
|
+
record = self._exchange({"type": "record", "data": {"episode_id": self._episode_id}})
|
|
190
|
+
return {
|
|
191
|
+
"reward": record["reward"],
|
|
192
|
+
"success": bool(record["metadata"].get("success")),
|
|
193
|
+
"metadata": record["metadata"],
|
|
194
|
+
"record": record,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
"""Close the session, which frees the container."""
|
|
199
|
+
self._socket.close()
|
|
200
|
+
|
|
201
|
+
def _exchange(self, frame: dict[str, Any]) -> dict[str, Any]:
|
|
202
|
+
with self._lock:
|
|
203
|
+
if self._broken is not None:
|
|
204
|
+
raise self._broken
|
|
205
|
+
try:
|
|
206
|
+
self._socket.send(json.dumps(frame))
|
|
207
|
+
reply = json.loads(self._socket.recv())
|
|
208
|
+
except (ConnectionClosed, TimeoutError, OSError) as e:
|
|
209
|
+
self._broken = MiroirError("CONNECTION_LOST", f"{self.url}: {e!r}")
|
|
210
|
+
self._socket.close()
|
|
211
|
+
raise self._broken from e
|
|
212
|
+
if reply["type"] == "error":
|
|
213
|
+
raise MiroirError(reply["data"]["code"], reply["data"]["message"])
|
|
214
|
+
return reply["data"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclasses.dataclass(frozen=True)
|
|
218
|
+
class CoworkerModel:
|
|
219
|
+
"""An OpenAI-compatible chat model that plays the coworkers."""
|
|
220
|
+
|
|
221
|
+
base_url: str
|
|
222
|
+
model: str
|
|
223
|
+
api_key: str | None = dataclasses.field(default=None, repr=False)
|
|
224
|
+
temperature: float | None = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def answerable(tasks: list[dict[str, Any]], coworkers: bool) -> list[dict[str, Any]]:
|
|
228
|
+
"""Leave out the tasks with coworkers unless a coworker model will answer them."""
|
|
229
|
+
if coworkers:
|
|
230
|
+
return tasks
|
|
231
|
+
kept = [task for task in tasks if not task.get("coworkers")]
|
|
232
|
+
if len(kept) < len(tasks):
|
|
233
|
+
warnings.warn(
|
|
234
|
+
f"left out the tasks with coworkers ({len(tasks) - len(kept)}), which need a coworker "
|
|
235
|
+
"model to answer for them",
|
|
236
|
+
stacklevel=2,
|
|
237
|
+
)
|
|
238
|
+
return kept
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def answer_coworkers(session: Session, model: CoworkerModel) -> int:
|
|
242
|
+
"""Answer every owed coworker turn with the model; returns how many."""
|
|
243
|
+
turns = session.coworker_turns()
|
|
244
|
+
for turn in turns:
|
|
245
|
+
session.coworker_reply(turn["coworker"], _complete(model, turn["messages"]))
|
|
246
|
+
return len(turns)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _complete(model: CoworkerModel, messages: list[dict[str, Any]]) -> str:
|
|
250
|
+
body: dict[str, Any] = {"model": model.model, "messages": messages}
|
|
251
|
+
if model.temperature is not None:
|
|
252
|
+
body["temperature"] = model.temperature
|
|
253
|
+
headers = {"content-type": "application/json"}
|
|
254
|
+
if model.api_key:
|
|
255
|
+
headers["authorization"] = f"Bearer {model.api_key}"
|
|
256
|
+
url = f"{model.base_url.rstrip('/')}/chat/completions"
|
|
257
|
+
request = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
|
|
258
|
+
try:
|
|
259
|
+
with urllib.request.urlopen(request, timeout=COWORKER_TIMEOUT_S) as response:
|
|
260
|
+
reply = json.load(response)
|
|
261
|
+
except (OSError, ValueError) as e:
|
|
262
|
+
raise MiroirError("COWORKER_FAILED", f"{url} with {model.model}: {e}") from e
|
|
263
|
+
try:
|
|
264
|
+
text = reply["choices"][0]["message"]["content"]
|
|
265
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
266
|
+
raise MiroirError("COWORKER_FAILED", f"{model.model} sent no answer: {reply!r:.300}") from e
|
|
267
|
+
if not isinstance(text, str) or not text.strip():
|
|
268
|
+
raise MiroirError("COWORKER_FAILED", f"{model.model} answered with no text")
|
|
269
|
+
return text
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def read_urls(path: str | os.PathLike[str]) -> list[str]:
|
|
273
|
+
"""Read a URL file, as `miroir pool` writes it: a base URL per line, `#` comments."""
|
|
274
|
+
try:
|
|
275
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
276
|
+
except OSError as e:
|
|
277
|
+
raise MiroirError("URL_FILE", f"cannot read {path}: {e}; start `miroir pool`") from e
|
|
278
|
+
urls = [line.split("#", 1)[0].strip() for line in text.splitlines()]
|
|
279
|
+
urls = [url for url in urls if url]
|
|
280
|
+
for url in urls:
|
|
281
|
+
if not is_base_url(url):
|
|
282
|
+
raise MiroirError("URL_FILE", f"{path}: {url!r} is not an http:// or https:// URL")
|
|
283
|
+
if not urls:
|
|
284
|
+
raise MiroirError("URL_FILE", f"{path} lists no URL; start `miroir pool`")
|
|
285
|
+
return urls
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_base_url(url: str) -> bool:
|
|
289
|
+
"""Whether `url` is an http(s) URL with a host."""
|
|
290
|
+
parts = urllib.parse.urlsplit(url)
|
|
291
|
+
return parts.scheme in ("http", "https") and bool(parts.hostname)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def answering(urls: Sequence[str], token: str | None = None) -> str:
|
|
295
|
+
"""The first of `urls` whose env answers."""
|
|
296
|
+
failure: MiroirError | None = None
|
|
297
|
+
for url in urls:
|
|
298
|
+
try:
|
|
299
|
+
_request(url, "/list_environments", None, token)
|
|
300
|
+
except MiroirError as e:
|
|
301
|
+
failure = e
|
|
302
|
+
continue
|
|
303
|
+
return url
|
|
304
|
+
if failure is None:
|
|
305
|
+
raise ValueError("urls must be a non-empty list of base URLs")
|
|
306
|
+
raise failure
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def tasks(url: str, split: str = "train", token: str | None = None) -> list[dict[str, Any]]:
|
|
310
|
+
"""A split's tasks, as the env lists them."""
|
|
311
|
+
name = _request(url, "/list_environments", None, token)[0]
|
|
312
|
+
return _request(url, f"/{name}/tasks", {"split": split}, token)["tasks"]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def tools(url: str, token: str | None = None, surface: str | None = None) -> list[dict[str, Any]]:
|
|
316
|
+
"""The tools the env's MCP endpoint lists on a surface: api (the default), mcp or code."""
|
|
317
|
+
rpc = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
|
318
|
+
query = f"?surface={urllib.parse.quote(surface)}" if surface else ""
|
|
319
|
+
reply = _request(url, f"/mcp{query}", rpc, token)
|
|
320
|
+
if "error" in reply:
|
|
321
|
+
raise MiroirError("TOOLS", f"{url}: {reply['error']['message']}")
|
|
322
|
+
return reply["result"]["tools"]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def prompt(task: Mapping[str, Any], today: datetime.date) -> str:
|
|
326
|
+
"""The user prompt for a task: instruction, persona, step budget and today's date."""
|
|
327
|
+
persona = task["persona"]["description"]
|
|
328
|
+
acting = f"You act as {persona}. " if persona else ""
|
|
329
|
+
return (
|
|
330
|
+
f"{task['instruction']}\n\n"
|
|
331
|
+
f"{acting}You have {task['max_steps']} tool calls; call submit when you are done.\n\n"
|
|
332
|
+
f"Today is {today.isoformat()} (UTC)."
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def metrics(record: Mapping[str, Any]) -> dict[str, float]:
|
|
337
|
+
"""A record's metrics to log: success, harm, diagnostics and episode time."""
|
|
338
|
+
metadata = record["metadata"]
|
|
339
|
+
return {
|
|
340
|
+
"success": float(bool(metadata.get("success"))),
|
|
341
|
+
"harm": float(bool(metadata.get("harm"))),
|
|
342
|
+
**record["diagnostics"],
|
|
343
|
+
"episode_s": record["timings"]["episode_s"],
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def save_record(directory: str | os.PathLike[str], record: Mapping[str, Any]) -> None:
|
|
348
|
+
"""Append a record to this process's JSONL file in `directory`."""
|
|
349
|
+
folder = Path(directory)
|
|
350
|
+
path = folder / f"{TRACES_STARTED}-{socket.gethostname()}-{os.getpid()}.jsonl"
|
|
351
|
+
line = json.dumps(record) + "\n"
|
|
352
|
+
with _traces_lock:
|
|
353
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
with path.open("a", encoding="utf-8") as traces:
|
|
355
|
+
traces.write(line)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _headers(token: str | None) -> dict[str, str]:
|
|
359
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@functools.cache
|
|
363
|
+
def _check_pool() -> ThreadPoolExecutor:
|
|
364
|
+
return ThreadPoolExecutor(HEALTH_WORKERS, thread_name_prefix="miroir-health")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
os.register_at_fork(after_in_child=_check_pool.cache_clear)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _health(url: str) -> dict[str, Any] | None:
|
|
371
|
+
try:
|
|
372
|
+
try:
|
|
373
|
+
with urllib.request.urlopen(f"{url}/health", timeout=HEALTH_TIMEOUT_S) as response:
|
|
374
|
+
body = response.read()
|
|
375
|
+
except urllib.error.HTTPError as e:
|
|
376
|
+
if e.code != 503:
|
|
377
|
+
return None
|
|
378
|
+
body = e.read()
|
|
379
|
+
health = json.loads(body)
|
|
380
|
+
except (OSError, ValueError):
|
|
381
|
+
return None
|
|
382
|
+
return health if isinstance(health, dict) else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _open_session(url: str, token: str | None) -> ClientConnection | None:
|
|
386
|
+
if not url.startswith(("http://", "https://")):
|
|
387
|
+
raise ValueError(f"{url!r} must start with http:// or https://")
|
|
388
|
+
ws_url = "ws" + url.removeprefix("http") + "/ws"
|
|
389
|
+
try:
|
|
390
|
+
socket = connect(
|
|
391
|
+
ws_url,
|
|
392
|
+
additional_headers=_headers(token),
|
|
393
|
+
open_timeout=HTTP_TIMEOUT_S,
|
|
394
|
+
ping_interval=PING_INTERVAL_S,
|
|
395
|
+
ping_timeout=PING_TIMEOUT_S,
|
|
396
|
+
max_size=None,
|
|
397
|
+
).__enter__()
|
|
398
|
+
except InvalidStatus as e:
|
|
399
|
+
if e.response.status_code < 500:
|
|
400
|
+
raise MiroirError("CONNECTION_FAILED", f"{ws_url}: {e!r}") from e
|
|
401
|
+
raise _Unreachable(ws_url) from e
|
|
402
|
+
except (InvalidHandshake, OSError, TimeoutError) as e:
|
|
403
|
+
raise _Unreachable(ws_url) from e
|
|
404
|
+
try:
|
|
405
|
+
socket.send(json.dumps({"type": "state"}))
|
|
406
|
+
reply = json.loads(socket.recv(timeout=HTTP_TIMEOUT_S))
|
|
407
|
+
except (ConnectionClosed, TimeoutError, OSError):
|
|
408
|
+
socket.close()
|
|
409
|
+
return None
|
|
410
|
+
if reply["type"] == "error":
|
|
411
|
+
socket.close()
|
|
412
|
+
if reply["data"]["code"] == "CAPACITY_REACHED":
|
|
413
|
+
return None
|
|
414
|
+
raise MiroirError(reply["data"]["code"], reply["data"]["message"])
|
|
415
|
+
return socket
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _request(url: str, path: str, body: Any, token: str | None) -> Any:
|
|
419
|
+
data = None if body is None else json.dumps(body).encode()
|
|
420
|
+
headers = {**_headers(token), **({"Content-Type": "application/json"} if data else {})}
|
|
421
|
+
request = urllib.request.Request(f"{url}{path}", data=data, headers=headers)
|
|
422
|
+
try:
|
|
423
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_S) as response:
|
|
424
|
+
return json.loads(response.read())
|
|
425
|
+
except urllib.error.HTTPError as e:
|
|
426
|
+
raise MiroirError(
|
|
427
|
+
f"HTTP_{e.code}", f"{url}{path}: {e.read().decode(errors='replace')}"
|
|
428
|
+
) from e
|
|
429
|
+
except OSError as e:
|
|
430
|
+
raise MiroirError("CONNECTION_FAILED", f"{url}{path}: {e!r}") from e
|
miroir/calibrate.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""`miroir calibrate`: class each task as solved, learnable or out of reach from rollouts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import statistics
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from miroir.review import load
|
|
16
|
+
|
|
17
|
+
__all__ = ["CLASSES", "calibrated", "run_calibrate"]
|
|
18
|
+
|
|
19
|
+
CLASSES = ("solved", "learnable", "out_of_reach")
|
|
20
|
+
NAMED = 5
|
|
21
|
+
|
|
22
|
+
Entry = dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Tally:
|
|
27
|
+
"""A task's graded rollouts."""
|
|
28
|
+
|
|
29
|
+
task_hash: str
|
|
30
|
+
rewards: list[float] = field(default_factory=list)
|
|
31
|
+
successes: int = 0
|
|
32
|
+
harmed: int = 0
|
|
33
|
+
void: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_calibrate(traces: list[Path], model: str, out: Path, k: int | None) -> None:
|
|
37
|
+
"""Write the calibration of `model` from trace files, and print its table."""
|
|
38
|
+
if k is not None and k < 1:
|
|
39
|
+
raise SystemExit(f"-k {k}: k must be at least 1")
|
|
40
|
+
tallies = tally(load(traces))
|
|
41
|
+
kinds = {task_id: classify(found) for task_id, found in tallies.items()}
|
|
42
|
+
if all(kind is None for kind in kinds.values()):
|
|
43
|
+
raise SystemExit(
|
|
44
|
+
"no task has two graded rollouts, so none has a class: run more rollouts of each "
|
|
45
|
+
"task, such as eight"
|
|
46
|
+
)
|
|
47
|
+
k = k or max(len(found.rewards) for found in tallies.values())
|
|
48
|
+
entries = {task_id: entry(tallies[task_id], kinds[task_id], k) for task_id in sorted(tallies)}
|
|
49
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
calibration = {"model": model, "k": k, "tasks": entries}
|
|
51
|
+
out.write_text(json.dumps(calibration, indent=2) + "\n", encoding="utf-8")
|
|
52
|
+
print(table(entries, k))
|
|
53
|
+
print(summary(entries, model, k, out))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def tally(records: Iterable[dict[str, Any]]) -> dict[str, Tally]:
|
|
57
|
+
"""Tally records by task; refuses rollouts of two versions of a task."""
|
|
58
|
+
tallies: dict[str, Tally] = {}
|
|
59
|
+
for record in records:
|
|
60
|
+
task_id, task_hash = record["task_id"], record["metadata"]["task_hash"]
|
|
61
|
+
found = tallies.setdefault(task_id, Tally(task_hash))
|
|
62
|
+
if found.task_hash != task_hash:
|
|
63
|
+
raise SystemExit(
|
|
64
|
+
f"{task_id}: the traces hold rollouts of two versions of the task (task_hash "
|
|
65
|
+
f"{found.task_hash} and {task_hash}); calibrate rollouts of one version"
|
|
66
|
+
)
|
|
67
|
+
if record["reward"] is None:
|
|
68
|
+
found.void += 1
|
|
69
|
+
continue
|
|
70
|
+
found.rewards.append(record["reward"])
|
|
71
|
+
found.successes += bool(record["metadata"].get("success"))
|
|
72
|
+
found.harmed += bool(record["metadata"].get("harm"))
|
|
73
|
+
return tallies
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def classify(found: Tally) -> str | None:
|
|
77
|
+
"""Learnable when rewards vary, else solved or out of reach; None under 2 rollouts."""
|
|
78
|
+
rewards = found.rewards
|
|
79
|
+
if len(rewards) < 2:
|
|
80
|
+
return None
|
|
81
|
+
if max(rewards) > min(rewards):
|
|
82
|
+
return "learnable"
|
|
83
|
+
return "solved" if found.successes == len(rewards) else "out_of_reach"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def entry(found: Tally, kind: str | None, k: int) -> Entry:
|
|
87
|
+
n, c = len(found.rewards), found.successes
|
|
88
|
+
return {
|
|
89
|
+
"task_hash": found.task_hash,
|
|
90
|
+
"class": kind,
|
|
91
|
+
"rollouts": n,
|
|
92
|
+
"void": found.void,
|
|
93
|
+
"pass@1": c / n if n else None,
|
|
94
|
+
"pass@k": pass_at(n, c, k),
|
|
95
|
+
"pass^k": pass_hat(n, c, k),
|
|
96
|
+
"reward_mean": statistics.fmean(found.rewards) if n else None,
|
|
97
|
+
"reward_variance": statistics.pvariance(found.rewards) if n else None,
|
|
98
|
+
"harm_rate": found.harmed / n if n else None,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pass_at(n: int, c: int, k: int) -> float | None:
|
|
103
|
+
"""pass@k, the chance at least one of k rollouts succeeds; None when n < k."""
|
|
104
|
+
if n < k:
|
|
105
|
+
return None
|
|
106
|
+
return 1 - math.comb(n - c, k) / math.comb(n, k)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pass_hat(n: int, c: int, k: int) -> float | None:
|
|
110
|
+
"""pass^k, the chance all k rollouts succeed; None when n < k."""
|
|
111
|
+
if n < k:
|
|
112
|
+
return None
|
|
113
|
+
return math.comb(c, k) / math.comb(n, k)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def table(entries: Mapping[str, Entry], k: int) -> str:
|
|
117
|
+
stats = [
|
|
118
|
+
("pass@1", "pass@1"),
|
|
119
|
+
(f"pass@{k}", "pass@k"),
|
|
120
|
+
(f"pass^{k}", "pass^k"),
|
|
121
|
+
("reward", "reward_mean"),
|
|
122
|
+
("var", "reward_variance"),
|
|
123
|
+
("harm", "harm_rate"),
|
|
124
|
+
]
|
|
125
|
+
width = max(len(task_id) for task_id in [*entries, "task"])
|
|
126
|
+
heads = [f"{'task':<{width}}", f"{'n':>3}", f"{'void':>4}", *(f"{t:>7}" for t, _ in stats)]
|
|
127
|
+
lines = [" ".join([*heads, "class"])]
|
|
128
|
+
for task_id, found in entries.items():
|
|
129
|
+
rates = (f"{rate(found[key]):>7}" for _, key in stats)
|
|
130
|
+
counts = [f"{task_id:<{width}}", f"{found['rollouts']:>3}", f"{found['void']:>4}"]
|
|
131
|
+
lines.append(" ".join([*counts, *rates, found["class"] or "-"]))
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def rate(value: float | None) -> str:
|
|
136
|
+
return "-" if value is None else f"{value:.2f}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def summary(entries: Mapping[str, Entry], model: str, k: int, out: Path) -> str:
|
|
140
|
+
counts = Counter(found["class"] for found in entries.values())
|
|
141
|
+
tasks = f"{len(entries)} task{'' if len(entries) == 1 else 's'}"
|
|
142
|
+
classes = f"{counts['solved']} solved, {counts['learnable']} learnable"
|
|
143
|
+
lines = [f"{tasks}: {classes}, {counts['out_of_reach']} out of reach"]
|
|
144
|
+
unclassed = [task_id for task_id, found in entries.items() if found["class"] is None]
|
|
145
|
+
if unclassed:
|
|
146
|
+
lines.append(f"no class, with fewer than 2 graded rollouts: {named(unclassed)}")
|
|
147
|
+
void = sum(found["void"] for found in entries.values())
|
|
148
|
+
if void:
|
|
149
|
+
total = void + sum(found["rollouts"] for found in entries.values())
|
|
150
|
+
lines.append(f"{void} of {total} rollouts ({void / total:.1%}) void: miroir couldn't grade")
|
|
151
|
+
lines.append(f"wrote the calibration of {model} at k={k} to {out}")
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def calibrated(
|
|
156
|
+
tasks: Sequence[dict[str, Any]],
|
|
157
|
+
path: str | os.PathLike[str],
|
|
158
|
+
keep: Sequence[str] = ("learnable",),
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""The tasks the calibration file classes as one of `keep`; refuses missing or stale
|
|
161
|
+
entries.
|
|
162
|
+
"""
|
|
163
|
+
if not keep or set(keep) - set(CLASSES):
|
|
164
|
+
raise ValueError(f"keep must name classes among {list(CLASSES)}, not {list(keep)}")
|
|
165
|
+
entries = read_calibration(path)
|
|
166
|
+
missing = [task["id"] for task in tasks if task["id"] not in entries]
|
|
167
|
+
changed = [
|
|
168
|
+
task["id"]
|
|
169
|
+
for task in tasks
|
|
170
|
+
if task["id"] in entries and entries[task["id"]]["task_hash"] != task["task_hash"]
|
|
171
|
+
]
|
|
172
|
+
problems = []
|
|
173
|
+
if missing:
|
|
174
|
+
problems.append(f"no entry for {named(missing)}")
|
|
175
|
+
if changed:
|
|
176
|
+
problems.append(f"{named(changed)} changed since it was written")
|
|
177
|
+
if problems:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"{path}: {'; '.join(problems)}. Calibrate these tasks again: run rollouts of the "
|
|
180
|
+
"split, then `miroir calibrate` on their traces"
|
|
181
|
+
)
|
|
182
|
+
kept = [task for task in tasks if entries[task["id"]]["class"] in keep]
|
|
183
|
+
if not kept:
|
|
184
|
+
counts = Counter(entries[task["id"]]["class"] or "without a class" for task in tasks)
|
|
185
|
+
shown = ", ".join(f"{count} {kind}" for kind, count in sorted(counts.items()))
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"none of the {len(tasks)} tasks is {' or '.join(keep)} in {path}: {shown}"
|
|
188
|
+
)
|
|
189
|
+
return kept
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def read_calibration(path: str | os.PathLike[str]) -> dict[str, Entry]:
|
|
193
|
+
try:
|
|
194
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
195
|
+
except OSError as e:
|
|
196
|
+
raise ValueError(f"cannot read the calibration {path}: {e}") from e
|
|
197
|
+
not_one = f"{path} is not a calibration that `miroir calibrate` wrote"
|
|
198
|
+
try:
|
|
199
|
+
entries = json.loads(text)["tasks"]
|
|
200
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
201
|
+
raise ValueError(f"{not_one}: {e!r}") from e
|
|
202
|
+
if not isinstance(entries, dict) or not all(
|
|
203
|
+
isinstance(found, dict) and {"task_hash", "class"} <= found.keys()
|
|
204
|
+
for found in entries.values()
|
|
205
|
+
):
|
|
206
|
+
raise ValueError(f"{not_one}: each task needs a task_hash and a class")
|
|
207
|
+
return entries
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def named(task_ids: Sequence[str]) -> str:
|
|
211
|
+
shown = ", ".join(task_ids[:NAMED])
|
|
212
|
+
return shown if len(task_ids) <= NAMED else f"{shown} and {len(task_ids) - NAMED} more"
|