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 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"