puremethod 0.1.0__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 (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
loop/core.py ADDED
@@ -0,0 +1,1160 @@
1
+ """The research cycle (03-loop.md). `Loop` drives a run from the ledger alone (INV-10).
2
+
3
+ Each `drive` iteration replays the ledger, does one unit of work, and records it. A unit is
4
+ an LLM call, an adapter call, or one stage of experiments; each one checks the run's limits
5
+ first (INV-5) and is recorded before or as it happens (INV-1). Outcomes are computed by
6
+ `decide.evaluate` from recorded metrics (INV-2).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import dataclasses
13
+ import functools
14
+ import json
15
+ import math
16
+ import subprocess
17
+ import time
18
+ from collections.abc import Awaitable, Callable
19
+ from dataclasses import dataclass, field
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Any, TypeVar
23
+
24
+ from executor import (
25
+ EnvRef,
26
+ EnvSpec,
27
+ Executor,
28
+ ExecutorError,
29
+ ExecutorInfo,
30
+ JobHandle,
31
+ JobResult,
32
+ JobSpec,
33
+ )
34
+ from ledger import CONFIRM_STAGE, SMOKE_STAGE, ConcurrentWrite, Entry, Ledger, LedgerRejection
35
+ from llm import LLM, InvalidOutput, LLMError, Message, PriceUnknown
36
+ from purescholar import REQUIRED_SCOPES, CitationRecord, PureScholar, PureScholarError
37
+ from puretrace import Artifacts, Assessment, PureTrace, PureTraceError
38
+ from writer import BuildResult, PaperOptions, Writer
39
+
40
+ from . import harness, prompts
41
+ from .config import RunConfig, next_stage, stage_budget_s
42
+ from .decide import evaluate
43
+ from .view import Hyp, RunView, build_view
44
+
45
+ T = TypeVar("T")
46
+
47
+ KILL_FACTOR = 1.1 # §5 step 5: an experiment is killed at 110% of its budget
48
+ GRACE_S = 30.0 # 05-executor.md §5 step 3: termination grace after the deadline
49
+ SMOKE_FIXES = 3 # §5 step 4.4
50
+ POLL_S = 2.0
51
+ QUOTE_CHARS = 20_000
52
+ ARMS = ("treatment", "baseline")
53
+
54
+ CredentialLookup = Callable[[str], str]
55
+ LLMFactory = Callable[[dict[str, dict[str, Any]], dict[str, str]], LLM]
56
+ ExecutorFactory = Callable[[str, CredentialLookup], Executor]
57
+ WriterFactory = Callable[[LLM, PureScholar | None], Writer]
58
+
59
+
60
+ class LimitReached(RuntimeError):
61
+ """An action's maximum cost does not fit the run's limits (INV-5)."""
62
+
63
+
64
+ class _Pause(Exception):
65
+ """A service, the executor or the LLM is unavailable: the run pauses (SYS-INV-7)."""
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class RunStatus:
70
+ run_id: str
71
+ status: str | None
72
+ phase: str | None
73
+ reason: str
74
+ spend_usd: float
75
+ money_limit_usd: float
76
+ elapsed_h: float
77
+ wall_clock_limit_h: float
78
+ hypotheses: dict[str, int]
79
+ open_checkpoints: list[str]
80
+
81
+
82
+ @dataclass
83
+ class _Session:
84
+ config: RunConfig
85
+ llm: LLM
86
+ executor: Executor
87
+ info: ExecutorInfo
88
+ envs: dict[str, EnvRef] = field(default_factory=dict)
89
+
90
+
91
+ def _utc_now() -> datetime:
92
+ return datetime.now(UTC)
93
+
94
+
95
+ def _quote(text: str, source: str) -> str:
96
+ return LLM.quote(text[:QUOTE_CHARS], source)
97
+
98
+
99
+ def _asdict(obj: Any) -> dict[str, Any]:
100
+ return dataclasses.asdict(obj)
101
+
102
+
103
+ class Loop:
104
+ """Built from a Ledger, a writer factory, client factories and a credential lookup
105
+ (03-loop.md §3). Keeps caches only; the run state is the ledger."""
106
+
107
+ def __init__(self, ledger: Ledger, writer: WriterFactory, *, llm: LLMFactory,
108
+ executor: ExecutorFactory, purescholar: Callable[[], PureScholar],
109
+ puretrace: Callable[[str, list[str]], PureTrace] | None,
110
+ credentials: CredentialLookup, output_root: str | Path,
111
+ now: Callable[[], datetime] = _utc_now,
112
+ sleep: Callable[[float], None] = time.sleep,
113
+ monotonic: Callable[[], float] = time.monotonic,
114
+ poll_s: float = POLL_S,
115
+ client_credentials: Callable[[RunConfig], list[str]] | None = None) -> None:
116
+ self._ledger = ledger
117
+ self._client_credentials = client_credentials
118
+ self._writer = writer
119
+ self._llm_factory = llm
120
+ self._executor_factory = executor
121
+ self._ps_factory = purescholar
122
+ self._pt_factory = puretrace
123
+ self._credentials = credentials
124
+ self._out = Path(output_root)
125
+ self._now = now
126
+ self._sleep = sleep
127
+ self._mono = monotonic
128
+ self._poll_s = poll_s
129
+ self._sessions: dict[str, _Session] = {}
130
+ self._secrets_ok: set[str] = set()
131
+ self._to_reap: dict[str, list[str]] = {}
132
+
133
+ # ==== interface =========================================================================
134
+
135
+ def start(self, config: RunConfig) -> str:
136
+ """Opens the run and runs preflight (§5 step 1)."""
137
+ secrets = self._secret_values(config)
138
+ run_id = self._ledger.open_run(config.model_dump(mode="json"), secrets)
139
+ self._secrets_ok.add(run_id)
140
+ self._status(run_id, "preflight", "preflight")
141
+ try:
142
+ self._sessions[run_id] = self._new_session(config)
143
+ except (KeyError, ValueError, ExecutorError) as exc:
144
+ self._status(run_id, "failed", "preflight", f"cannot build clients: {exc}")
145
+ return run_id
146
+ self._guarded(run_id, lambda: self._preflight(run_id, self._view(run_id)))
147
+ return run_id
148
+
149
+ def drive(self, run_id: str) -> RunStatus:
150
+ """Advances the run until it is delivered, failed, paused or waiting at a checkpoint."""
151
+ while True:
152
+ view = self._view(run_id)
153
+ if view.status in ("paused", "delivered", "failed"):
154
+ return self.status(run_id)
155
+ self._session(run_id)
156
+ if self._to_reap.get(run_id):
157
+ self._guarded(run_id, functools.partial(self._reap, run_id))
158
+ continue
159
+ before = self._ledger.state(run_id).last_seq
160
+ self._guarded(run_id, functools.partial(self._step, run_id, view))
161
+ if self._ledger.state(run_id).last_seq == before: # a step always records
162
+ self._fail(run_id, f"no progress in phase {view.phase}")
163
+
164
+ def pause(self, run_id: str, reason: str) -> None:
165
+ view = self._view(run_id)
166
+ if view.status not in ("preflight", "running", "stopping", "writing"):
167
+ raise ValueError(f"cannot pause a run that is {view.status}")
168
+ self._status(run_id, "paused", view.phase, f"user: {reason}", actor="user",
169
+ resume_status=view.status)
170
+
171
+ def resume(self, run_id: str) -> RunStatus:
172
+ """User resume. After a restart, the first `drive` in the new process rebuilds the
173
+ session and closes dangling experiments (§5 step 9); `resume` never does, so it is
174
+ safe while a worker still finishes a batch."""
175
+ view = self._view(run_id)
176
+ if view.status == "paused":
177
+ if view.open_checkpoint() is not None:
178
+ raise ValueError("the run waits at a checkpoint: decide it to continue (INV-7)")
179
+ self._status(run_id, view.resume_status or "running", view.phase, "resumed",
180
+ actor="user")
181
+ return self.status(run_id)
182
+
183
+ def stop(self, run_id: str) -> None:
184
+ """Stops exploring; the run moves to writing with what exists."""
185
+ view = self._view(run_id)
186
+ if view.phase not in ("preflight", "literature", "directions", "exploring") or \
187
+ view.status not in ("preflight", "running", "paused"):
188
+ raise ValueError(f"cannot stop a run that is {view.status} at {view.phase}")
189
+ cp = view.open_checkpoint()
190
+ if cp is not None and cp.id == "C2":
191
+ raise ValueError("the run already waits at C2, before writing")
192
+ self._status(run_id, "stopping", view.phase, "user stop", actor="user")
193
+
194
+ def decide_checkpoint(self, run_id: str, checkpoint_id: str, decision: Any) -> None:
195
+ view = self._view(run_id)
196
+ cp = view.checkpoints.get(checkpoint_id)
197
+ if cp is None or cp.decided is not None:
198
+ raise ValueError(f"checkpoint {checkpoint_id} is not open")
199
+ if not isinstance(decision, dict):
200
+ raise ValueError("a checkpoint decision is an object")
201
+ if checkpoint_id == "C1":
202
+ keep = list(decision.get("keep", list(view.ideas)))
203
+ add = [str(t) for t in decision.get("add", []) if str(t).strip()]
204
+ if set(keep) - set(view.ideas):
205
+ raise ValueError("keep lists an id that is not a proposed direction")
206
+ decided = self._append(run_id, "checkpoint.decided", {
207
+ "checkpoint_id": "C1", "decision": {"keep": keep, "add": add}},
208
+ [cp.requested, *keep], actor="user")
209
+ for text in add:
210
+ self._append(run_id, "idea.proposed", {"direction": text,
211
+ "parents": [decided.entry_id]},
212
+ [decided.entry_id], actor="user")
213
+ self._status(run_id, "stopping" if view.resume_status == "stopping" else "running",
214
+ "exploring", "C1 decided")
215
+ elif checkpoint_id == "C2":
216
+ if decision.get("approve") is not True:
217
+ raise ValueError("C2 takes {'approve': true}: writing starts")
218
+ self._append(run_id, "checkpoint.decided", {"checkpoint_id": "C2",
219
+ "decision": {"approve": True}},
220
+ [cp.requested], actor="user")
221
+ self._status(run_id, "writing", "writing", "C2 decided")
222
+ else:
223
+ raise ValueError(f"unknown checkpoint {checkpoint_id}")
224
+
225
+ def status(self, run_id: str) -> RunStatus:
226
+ view = self._view(run_id)
227
+ cfg = view.config
228
+ counts: dict[str, int] = {}
229
+ for h in view.treatments():
230
+ final = h.final()
231
+ if final is not None:
232
+ key = final["outcome"]
233
+ else:
234
+ stages = view.stages_of(h.id)
235
+ key = f"stage {stages[-1][0]}" if stages else "proposed"
236
+ counts[key] = counts.get(key, 0) + 1
237
+ cp = view.open_checkpoint()
238
+ return RunStatus(
239
+ run_id=run_id, status=view.status, phase=view.phase, reason=view.reason,
240
+ spend_usd=self._ledger.spend(run_id).total_usd, money_limit_usd=cfg.limits.money,
241
+ elapsed_h=view.active_elapsed_s(self._now()) / 3600,
242
+ wall_clock_limit_h=cfg.limits.wall_clock, hypotheses=counts,
243
+ open_checkpoints=[cp.id] if cp else [])
244
+
245
+ # ==== sessions, recording ===============================================================
246
+
247
+ def _new_session(self, config: RunConfig) -> _Session:
248
+ creds = {n: self._credentials(n) for n in config.credential_names()}
249
+ llm = self._llm_factory(config.llm, creds)
250
+ ex = self._executor_factory(config.executor.id, self._credentials)
251
+ return _Session(config=config, llm=llm, executor=ex, info=ex.describe())
252
+
253
+ def _ensure_secrets(self, run_id: str) -> RunConfig:
254
+ entries = list(self._ledger.entries(run_id, types=["run.created"]))
255
+ config = RunConfig.model_validate(entries[0].payload["config"])
256
+ if run_id not in self._secrets_ok:
257
+ self._ledger.register_secrets(run_id, self._secret_values(config))
258
+ self._secrets_ok.add(run_id)
259
+ return config
260
+
261
+ def _secret_values(self, config: RunConfig) -> list[str]:
262
+ """Run secrets (§3, KB-022): the config's LLM and job credentials and every name on
263
+ the caller's client-credential list. One that cannot be read raises."""
264
+ names = list(config.credential_names())
265
+ if self._client_credentials is not None:
266
+ names += [n for n in self._client_credentials(config) if n not in names]
267
+ return [self._credentials(n) for n in names]
268
+
269
+ def _session(self, run_id: str) -> _Session:
270
+ if run_id not in self._sessions:
271
+ config = self._ensure_secrets(run_id)
272
+ self._ledger.close_dangling(run_id) # INV-6: a restart closes open starts as lost
273
+ self._sessions[run_id] = self._new_session(config)
274
+ # §5 step 9 (KB-020): every `lost` job may still run or hold Docker state.
275
+ # Reaping is idempotent, so all of them, which also retries a failed reap.
276
+ self._to_reap[run_id] = [
277
+ e.payload["experiment"] for e in self._ledger.entries(
278
+ run_id, types=["experiment.finished"]) if e.payload["status"] == "lost"]
279
+ return self._sessions[run_id]
280
+
281
+ def _reap(self, run_id: str) -> None:
282
+ """A failure pauses the run (the executor is unavailable) and is retried."""
283
+ tags = self._to_reap.get(run_id, [])
284
+ try:
285
+ self._sessions[run_id].executor.reap(tags)
286
+ except (OSError, subprocess.SubprocessError) as exc:
287
+ raise ExecutorError(f"reaping lost jobs failed: {exc}") from exc
288
+ self._to_reap[run_id] = []
289
+
290
+ def _view(self, run_id: str) -> RunView:
291
+ return build_view(list(self._ledger.entries(run_id)))
292
+
293
+ def _append(self, run_id: str, type_: str, payload: dict[str, Any],
294
+ refs: list[str] | None = None, *, actor: str = "loop") -> Entry:
295
+ self._ensure_secrets(run_id) # writes need the run's secrets registered (INV-7)
296
+ for attempt in range(3):
297
+ try:
298
+ return self._ledger.append(run_id, type_, payload, list(dict.fromkeys(refs or [])),
299
+ actor=actor) # type: ignore[arg-type]
300
+ except ConcurrentWrite:
301
+ if attempt == 2:
302
+ raise
303
+ raise AssertionError("unreachable")
304
+
305
+ def _status(self, run_id: str, status: str, phase: str | None, reason: str = "", *,
306
+ actor: str = "loop", **extra: Any) -> Entry:
307
+ payload = {"status": status, "phase": phase, "reason": reason,
308
+ **{k: v for k, v in extra.items() if v is not None}}
309
+ return self._append(run_id, "run.status", payload, actor=actor)
310
+
311
+ def _put(self, run_id: str, data: bytes, kind: str) -> str | None:
312
+ self._ensure_secrets(run_id)
313
+ try:
314
+ return self._ledger.put_artifact(run_id, data, kind)
315
+ except LedgerRejection: # a registered secret: withheld; the ledger logged why
316
+ return None
317
+
318
+ def _record_llm(self, run_id: str, r: Any) -> str:
319
+ rec = r.to_dict()
320
+ request, response = rec.pop("request"), rec.pop("response")
321
+ metered = bool(getattr(r, "metered", True)) # an unmetered model (07 INV-3, KB-036)
322
+ e = self._append(run_id, "call.recorded", {
323
+ "source": "llm", "record": rec, "cost_usd": r.cost_usd if metered else None,
324
+ "metered": metered,
325
+ "request_artifact": self._put(run_id, json.dumps(request).encode(), "prompt"),
326
+ "response_artifact": self._put(run_id, (response or "").encode(), "response")})
327
+ return e.entry_id
328
+
329
+ def _record_http(self, run_id: str, source: str, r: Any,
330
+ citation_records: list[dict[str, Any]] | None = None) -> str:
331
+ rec = r.to_dict()
332
+ request, response = rec.pop("request_body"), rec.pop("response_body")
333
+ payload: dict[str, Any] = {
334
+ "source": source, "record": rec, "cost_usd": None, "metered": False,
335
+ "request_artifact": self._put(run_id, request.encode(), "prompt"),
336
+ "response_artifact": self._put(run_id, (response or "").encode(), "response")}
337
+ if citation_records is not None:
338
+ payload["citation_records"] = citation_records
339
+ return self._append(run_id, "call.recorded", payload).entry_id
340
+
341
+ def _record_all(self, run_id: str, source: str, records: list[Any],
342
+ citation_records: list[dict[str, Any]] | None = None) -> list[str]:
343
+ """Records every call; citation records go on the last one (§5 step 11)."""
344
+ ids = []
345
+ for i, r in enumerate(records):
346
+ last = i == len(records) - 1
347
+ ids.append(self._record_http(run_id, source, r, citation_records if last else None))
348
+ return ids
349
+
350
+ # ==== limits (INV-5) ====================================================================
351
+
352
+ def _fits(self, run_id: str, cost_usd: float, time_s: float, *, writing: bool) -> str | None:
353
+ view = self._view(run_id)
354
+ lim = view.config.limits
355
+ share = 1.0 if writing else 1.0 - lim.writing_reserve
356
+ spend = self._ledger.spend(run_id).total_usd
357
+ if spend + cost_usd > lim.money * share:
358
+ return (f"money: spent {spend:.4f} USD + up to {cost_usd:.4f} USD exceeds "
359
+ f"{lim.money * share:.4f} USD")
360
+ elapsed = view.active_elapsed_s(self._now())
361
+ if elapsed + time_s > lim.wall_clock * 3600 * share:
362
+ return (f"wall clock: {elapsed:.0f} s + up to {time_s:.0f} s exceeds "
363
+ f"{lim.wall_clock * 3600 * share:.0f} s")
364
+ return None
365
+
366
+ def _guard(self, run_id: str, cost_usd: float, time_s: float = 0.0, *,
367
+ writing: bool = False) -> None:
368
+ reason = self._fits(run_id, cost_usd, time_s, writing=writing)
369
+ if reason:
370
+ raise LimitReached(f"limit reached: {reason}")
371
+
372
+ # ==== calls =============================================================================
373
+
374
+ def _ask(self, run_id: str, task: str, instruction: str, data: str,
375
+ schema: dict[str, Any], *, writing: bool = False) -> dict[str, Any]:
376
+ s = self._session(run_id)
377
+ role = {"directions": "planner", "hypothesis": "planner", "rank": "planner",
378
+ "novelty": "analyst", "harness": "coder", "fix": "coder"}[task]
379
+ messages: list[Message] = [{"role": "system", "content": instruction},
380
+ {"role": "user", "content": data}]
381
+ max_tokens = prompts.MAX_TOKENS[task]
382
+ self._guard(run_id, s.llm.max_cost(role, messages, max_tokens, schema), writing=writing)
383
+ try:
384
+ result = s.llm.complete(role, messages, max_tokens=max_tokens, schema=schema)
385
+ except LLMError as exc:
386
+ for r in exc.records:
387
+ self._record_llm(run_id, r)
388
+ raise
389
+ for r in result.records:
390
+ self._record_llm(run_id, r)
391
+ return dict(result.parsed)
392
+
393
+ def _ps(self, run_id: str, fn: Callable[[PureScholar], Awaitable[T]]) -> T:
394
+ self._guard(run_id, 0.0) # PureScholar's cost is unmetered (01 §9); time still counts
395
+
396
+ async def go() -> T:
397
+ ps = self._ps_factory()
398
+ try:
399
+ return await fn(ps)
400
+ finally:
401
+ await ps.aclose()
402
+
403
+ try:
404
+ return asyncio.run(go())
405
+ except PureScholarError as exc:
406
+ self._record_all(run_id, "purescholar", exc.records)
407
+ raise
408
+
409
+ def _pt(self, run_id: str, fn: Callable[[PureTrace], Awaitable[T]]) -> T:
410
+ if self._pt_factory is None:
411
+ raise _Pause("PureTrace is not configured")
412
+ cfg = self._view(run_id).config
413
+ uses = [u for u in ("screening", "selfcheck") if getattr(cfg.puretrace, u)]
414
+ factory = self._pt_factory
415
+
416
+ async def go() -> T:
417
+ pt = factory(run_id, uses)
418
+ try:
419
+ return await fn(pt)
420
+ finally:
421
+ await pt.aclose()
422
+
423
+ try:
424
+ return asyncio.run(go())
425
+ except PureTraceError as exc:
426
+ self._record_all(run_id, "puretrace", exc.records)
427
+ raise
428
+
429
+ @staticmethod
430
+ def _citations(items: list[Any]) -> list[dict[str, Any]]:
431
+ out: list[dict[str, Any]] = []
432
+ for item in items:
433
+ rec = PureScholar.citation_for(item)
434
+ if isinstance(rec, CitationRecord) and all(r["key"] != rec.key for r in out):
435
+ out.append(_asdict(rec))
436
+ return out
437
+
438
+ # ==== error handling ====================================================================
439
+
440
+ def _guarded(self, run_id: str, action: Callable[[], None]) -> None:
441
+ """Runs one step; maps failures to a pause (services) or a failure (rule breaks)."""
442
+ try:
443
+ action()
444
+ except _Pause as exc:
445
+ self._pause_for(run_id, str(exc))
446
+ except PriceUnknown as exc:
447
+ self._fail(run_id, f"llm: {exc}")
448
+ except LLMError as exc:
449
+ self._pause_for(run_id, f"llm {type(exc).__name__}: {exc}")
450
+ except PureScholarError as exc:
451
+ self._pause_for(run_id, f"purescholar {type(exc).__name__}: {exc}")
452
+ except PureTraceError as exc:
453
+ self._pause_for(run_id, f"puretrace {type(exc).__name__}: {exc}")
454
+ except ExecutorError as exc:
455
+ self._pause_for(run_id, f"executor {type(exc).__name__}: {exc}")
456
+ except LedgerRejection as exc: # a loop bug: the action did not happen (INV-1)
457
+ self._fail(run_id, f"ledger refused an entry ({exc.rule}): {exc}")
458
+
459
+ def _pause_for(self, run_id: str, reason: str) -> None:
460
+ view = self._view(run_id)
461
+ self._status(run_id, "paused", view.phase, reason, resume_status=view.status)
462
+
463
+ def _fail(self, run_id: str, reason: str) -> None:
464
+ self._status(run_id, "failed", self._view(run_id).phase, reason)
465
+
466
+ # ==== phases ============================================================================
467
+
468
+ def _step(self, run_id: str, view: RunView) -> None:
469
+ phase = view.phase
470
+ if view.status == "stopping" and phase in ("preflight", "literature", "directions",
471
+ "exploring"):
472
+ self._close_open(run_id, view, "user_stopped", "the user stopped the run")
473
+ self._to_writing(run_id, self._view(run_id))
474
+ elif phase in ("writing", "selfcheck"):
475
+ self._write(run_id, view) # the writer's own limit stops are handled there
476
+ else:
477
+ try:
478
+ if phase == "preflight":
479
+ self._preflight(run_id, view)
480
+ elif phase == "literature":
481
+ self._literature(run_id, view)
482
+ elif phase == "directions":
483
+ self._directions(run_id, view)
484
+ elif phase == "exploring":
485
+ self._explore_once(run_id, view)
486
+ else:
487
+ raise AssertionError(f"unknown phase {phase!r}")
488
+ except LimitReached as exc: # §5 step 5: the action does not start; write
489
+ self._close_open(run_id, self._view(run_id), "limit_reached", str(exc))
490
+ self._to_writing(run_id, self._view(run_id))
491
+
492
+ def _preflight(self, run_id: str, view: RunView) -> None:
493
+ """§5 step 1, INV-8 (SYS-INV-4, SYS-INV-6)."""
494
+ s = self._session(run_id)
495
+ cfg = view.config
496
+ problems: list[str] = []
497
+ if cfg.puretrace.screening:
498
+ problems.append("PureTrace screening applies to ML runs only" if cfg.domain != "ml"
499
+ else "PureTrace screening is not supported by loop v0.1: no "
500
+ "adapter fetches the baseline paper's PDF")
501
+ if s.info.isolation == "none" and not cfg.executor.allow_unisolated:
502
+ problems.append(f"executor {s.info.id} has no isolation and allow_unisolated "
503
+ "is false (SYS-INV-6)")
504
+ if not s.info.metered and not cfg.accept_unmetered_compute:
505
+ problems.append(f"executor {s.info.id} cannot report cost and "
506
+ "accept_unmetered_compute is false (SYS-INV-4)")
507
+ problems += s.executor.preflight().problems
508
+ llm_report = s.llm.preflight()
509
+ for rec in getattr(llm_report, "records", []): # a self-hosted model's probe (INV-1)
510
+ self._record_llm(run_id, rec)
511
+ problems += llm_report.problems
512
+ unmetered = getattr(llm_report, "unmetered", [])
513
+ if unmetered and not cfg.accept_unmetered_compute:
514
+ problems.append(f"models {', '.join(unmetered)} cannot report cost and "
515
+ "accept_unmetered_compute is false (SYS-INV-4)")
516
+ identity = self._ps(run_id, lambda ps: ps.preflight())
517
+ self._record_all(run_id, "purescholar", identity.records)
518
+ missing = set(REQUIRED_SCOPES) - set(identity.scopes)
519
+ if missing:
520
+ problems.append(f"PureScholar key lacks scopes {sorted(missing)}")
521
+ if cfg.puretrace.selfcheck:
522
+ records = self._pt(run_id, lambda pt: pt.preflight())
523
+ self._record_all(run_id, "puretrace", records)
524
+ if problems:
525
+ self._status(run_id, "failed", "preflight", "; ".join(problems))
526
+ else:
527
+ self._status(run_id, "running", "literature", "preflight passed")
528
+
529
+ def _literature(self, run_id: str, view: RunView) -> None:
530
+ """§5 step 2."""
531
+ idea = view.config.idea
532
+ inputs = []
533
+ for name in ("literature_map", "find_gaps"):
534
+ g = self._ps(run_id, lambda ps, n=name: getattr(ps, n)(idea)) # type: ignore[misc]
535
+ ids = self._record_all(run_id, "purescholar", g.records, self._citations(g.citations))
536
+ inputs.append(ids[-1])
537
+ self._status(run_id, view.status or "running", "directions", "literature mapped",
538
+ inputs=inputs)
539
+
540
+ def _directions(self, run_id: str, view: RunView) -> None:
541
+ """§5 step 3; C1 in checkpoint mode."""
542
+ lit_ids = view.inputs
543
+ if not any(p.get("_actor") != "user" for p in view.ideas.values()):
544
+ texts = []
545
+ for cid in lit_ids:
546
+ digest = view.calls[cid].get("response_artifact")
547
+ body = self._ledger.get_artifact(run_id, digest).decode() if digest else ""
548
+ texts.append(_quote(body, f"PureScholar {view.calls[cid]['record']['endpoint']}"))
549
+ data = f"Research idea: {view.config.idea}\n\n" + "\n\n".join(texts)
550
+ out = self._ask(run_id, "directions", prompts.DIRECTIONS, data,
551
+ prompts.DIRECTIONS_SCHEMA)
552
+ for d in out["directions"]:
553
+ self._append(run_id, "idea.proposed", {"direction": d["direction"],
554
+ "rationale": d["rationale"],
555
+ "parents": lit_ids}, lit_ids)
556
+ view = self._view(run_id)
557
+ if view.config.checkpoint_on("C1") and "C1" not in view.checkpoints:
558
+ options = [{"id": i, "direction": p["direction"]} for i, p in view.ideas.items()]
559
+ self._append(run_id, "checkpoint.requested", {"checkpoint_id": "C1",
560
+ "options": options})
561
+ self._status(run_id, "paused", "directions", "waiting for checkpoint C1",
562
+ resume_status=view.status)
563
+ else:
564
+ self._status(run_id, view.status or "running", "exploring", "directions set")
565
+
566
+ # ---- exploring (§5 step 4) -------------------------------------------------------------
567
+
568
+ def _frontier(self, view: RunView) -> dict[str, str]:
569
+ """Item id -> description. Hypotheses in progress, and directions not yet tried."""
570
+ items: dict[str, str] = {}
571
+ for idea in view.active_directions():
572
+ hyps = [h for h in view.treatments() if h.idea == idea]
573
+ if not hyps:
574
+ items[f"new:{idea}"] = f"new pilot for direction: {view.ideas[idea]['direction']}"
575
+ for h in hyps:
576
+ if h.final() is None:
577
+ stages = view.stages_of(h.id)
578
+ at = stages[-1][0] if stages else "harness"
579
+ items[h.id] = f"hypothesis at {at}: {h.statement}"
580
+ return items
581
+
582
+ def _explore_once(self, run_id: str, view: RunView) -> None:
583
+ items = self._frontier(view)
584
+ if not items:
585
+ self._to_writing(run_id, view)
586
+ return
587
+ pick = next(iter(items))
588
+ if len(items) > 1:
589
+ pick = self._rank(run_id, view, items)
590
+ if pick.startswith("new:"):
591
+ self._hypothesis(run_id, view, pick.removeprefix("new:"))
592
+ else:
593
+ self._advance(run_id, view, view.hypotheses[pick])
594
+
595
+ def _rank(self, run_id: str, view: RunView, items: dict[str, str]) -> str:
596
+ """§5 step 4.9. The planner orders; code keeps only real ids (INV-6)."""
597
+ status = self.status(run_id)
598
+ data = (f"Remaining: {status.money_limit_usd - status.spend_usd:.2f} USD, "
599
+ f"{status.wall_clock_limit_h - status.elapsed_h:.2f} h.\n"
600
+ + json.dumps([{"id": k, "item": v} for k, v in items.items()]))
601
+ out = self._ask(run_id, "rank", prompts.RANK, _quote(data, "frontier"),
602
+ prompts.RANK_SCHEMA)
603
+ order = [str(k) for k in out["order"] if k in items]
604
+ return (order + list(items))[0]
605
+
606
+ def _results_so_far(self, view: RunView) -> str:
607
+ lines = []
608
+ for h in view.treatments():
609
+ final = h.final()
610
+ lines.append(f"- {h.statement}: {final['outcome'] if final else 'in progress'}"
611
+ + (f" ({final.get('reason_code')})" if final and final.get('reason_code')
612
+ else ""))
613
+ return "\n".join(lines) or "none yet"
614
+
615
+ def _hypothesis(self, run_id: str, view: RunView, idea: str,
616
+ reframe_of: Hyp | None = None, reframed: str | None = None) -> None:
617
+ """§5 step 4.1: a treatment hypothesis and its baseline."""
618
+ cfg = view.config
619
+ data = (f"Research idea: {cfg.idea}\nDomain: {cfg.domain}\n"
620
+ f"Pilot budget: {cfg.budget_ladder.pilot_minutes} minutes per run\n"
621
+ + _quote(view.ideas[idea]["direction"], "direction")
622
+ + (f"\nData hints: {', '.join(cfg.data_hints)}" if cfg.data_hints else "")
623
+ + "\nResults so far:\n" + _quote(self._results_so_far(view), "ledger summary"))
624
+ if reframed is not None:
625
+ data += "\n" + _quote(reframed, "reframed hypothesis to formalise")
626
+ plan = self._ask(run_id, "hypothesis", prompts.HYPOTHESIS, data,
627
+ prompts.HYPOTHESIS_SCHEMA)
628
+ if not math.isfinite(float(plan["threshold"])):
629
+ raise _Pause("the planner gave a non-finite threshold")
630
+ b = self._append(run_id, "hypothesis.proposed", {
631
+ "statement": plan["baseline"], "idea": idea, "kind": "baseline"}, [idea])
632
+ payload: dict[str, Any] = {
633
+ "statement": plan["treatment"], "idea": idea, "kind": "treatment",
634
+ "baseline": b.entry_id, "plan": {k: plan[k] for k in ("metric", "rule",
635
+ "threshold", "data")}}
636
+ refs = [idea, b.entry_id]
637
+ if reframe_of is not None:
638
+ payload["reframes"] = reframe_of.id
639
+ refs.append(reframe_of.id)
640
+ self._append(run_id, "hypothesis.proposed", payload, refs)
641
+
642
+ def _advance(self, run_id: str, view: RunView, h: Hyp) -> None:
643
+ if h.novelty is None:
644
+ self._novelty(run_id, view, h)
645
+ return
646
+ smoke_id = view.in_force.get((h.id, SMOKE_STAGE))
647
+ if smoke_id is None:
648
+ self._harness(run_id, view, h)
649
+ return
650
+ stages = view.stages_of(h.id)
651
+ if not stages:
652
+ self._smoke(run_id, view, h, smoke_id)
653
+ return
654
+ stage, pid = stages[-1]
655
+ decided = view.decision_at(h.id, stage)
656
+ if decided is None:
657
+ self._run_stage(run_id, view, h, stage, pid)
658
+ elif decided["outcome"] == "promote":
659
+ self._register(run_id, view, h, next_stage(view.config.budget_ladder, stage))
660
+
661
+ def _novelty(self, run_id: str, view: RunView, h: Hyp) -> None:
662
+ """§5 step 4.2. The judgement is recorded with its evidence; `overlap` discards
663
+ (`not_novel`) and, once, reframes."""
664
+ ev = self._ps(run_id, lambda ps: ps.novelty_evidence(h.statement))
665
+ closest = self._citations(ev.closest)
666
+ cited = self._citations(ev.answer.citations)
667
+ ids = self._record_all(run_id, "purescholar", ev.records,
668
+ closest + [c for c in cited if c not in closest])
669
+ works = "\n".join(f"- {p.title} ({p.year}, {p.venue}): {p.abstract or ''}"
670
+ for p in ev.closest)
671
+ data = (_quote(h.statement, "hypothesis") + "\n"
672
+ + _quote(works, "PureScholar closest works") + "\n"
673
+ + _quote(json.dumps(ev.answer.body)[:QUOTE_CHARS], "PureScholar ask"))
674
+ out = self._ask(run_id, "novelty", prompts.NOVELTY, data, prompts.NOVELTY_SCHEMA)
675
+ nov = self._append(run_id, "hypothesis.novelty", {
676
+ "hypothesis": h.id, "evidence": ids, "judgement": out["judgement"],
677
+ "reason": out["reason"], "closest": closest}, [h.id, *ids])
678
+ if out["judgement"] == "overlap":
679
+ self._decide(run_id, h, "0", "discard", "not_novel",
680
+ f"overlaps prior work: {out['reason']}", refs=[nov.entry_id])
681
+ reframed = str(out.get("reframed") or "").strip()
682
+ if reframed and h.reframes is None:
683
+ self._hypothesis(run_id, self._view(run_id), h.idea, h, reframed)
684
+
685
+ def _harness(self, run_id: str, view: RunView, h: Hyp, *, fix_of: str | None = None,
686
+ failure: str = "") -> None:
687
+ """§5 step 4.4: the coder's harness, registered for a smoke run."""
688
+ cfg = view.config
689
+ base = h.baseline or ""
690
+ data = (_quote(h.statement, "treatment hypothesis") + "\n"
691
+ + _quote(view.hypotheses[base].statement, "baseline hypothesis") + "\n"
692
+ + _quote(json.dumps(h.plan), "plan")
693
+ + (f"\nData hints: {', '.join(cfg.data_hints)}" if cfg.data_hints else ""))
694
+ task, instruction = "harness", prompts.HARNESS
695
+ if fix_of is not None:
696
+ prev = view.preregs[fix_of]
697
+ files = harness.unbundle(self._ledger.get_artifact(run_id, prev["code_digest"]))
698
+ data += ("\n" + _quote(json.dumps(files), "previous harness") + "\n"
699
+ + _quote(failure, "smoke failure and job output"))
700
+ task, instruction = "fix", prompts.FIX
701
+ try:
702
+ out = self._ask(run_id, task, instruction, data, prompts.HARNESS_SCHEMA)
703
+ code = harness.bundle(out["files"])
704
+ except (harness.HarnessInvalid, InvalidOutput) as exc:
705
+ self._decide(run_id, h, "0", "discard", "harness_error",
706
+ f"the coder gave no usable harness: {exc}")
707
+ return
708
+ digest = self._ledger.put_artifact(run_id, code, "code")
709
+ env = {"requirements_lock": out["requirements"]}
710
+ if fix_of is not None:
711
+ self._append(run_id, "prereg.amended", {
712
+ "supersedes": fix_of, "reason": f"harness fix after smoke failure: "
713
+ f"{failure[:500]}",
714
+ "changes": {"code_digest": digest, "entrypoint": out["entrypoint"], "env": env}},
715
+ [fix_of])
716
+ return
717
+ plan = h.plan
718
+ self._append(run_id, "prereg.registered", {
719
+ "hypothesis": h.id, "stage": SMOKE_STAGE, "metric": plan["metric"],
720
+ "comparison": {"baseline": base, "rule": plan["rule"],
721
+ "threshold": float(plan["threshold"])},
722
+ "evaluation_data": [{"source": plan["data"], "role": "selection",
723
+ "digest": "pending:smoke"}],
724
+ "budget": self._budget(view, SMOKE_STAGE), "seeds": self._seeds(view, SMOKE_STAGE),
725
+ "code_digest": digest, "entrypoint": out["entrypoint"], "env": env}, [h.id, base])
726
+
727
+ def _budget(self, view: RunView, stage: str) -> dict[str, Any]:
728
+ s = self._sessions[view.run_id]
729
+ wall = stage_budget_s(view.config.budget_ladder, stage)
730
+ jobs = len(ARMS) * len(self._seeds(view, stage)["values"])
731
+ rate = s.info.rate_usd_per_hour or 0.0
732
+ return {"wall_clock_s": wall, "kill_at_s": wall * KILL_FACTOR,
733
+ "money_usd_estimate": jobs * rate * (wall * KILL_FACTOR + GRACE_S) / 3600}
734
+
735
+ @staticmethod
736
+ def _seeds(view: RunView, stage: str) -> dict[str, Any]:
737
+ """§4: stages before `confirm` run the first seed; `confirm` runs the confirmation
738
+ seeds."""
739
+ if stage == CONFIRM_STAGE:
740
+ n = view.config.budget_ladder.confirmation_seeds
741
+ return {"policy": "confirmation", "values": list(range(1, n + 1))}
742
+ return {"policy": "first", "values": [1]}
743
+
744
+ def _smoke(self, run_id: str, view: RunView, h: Hyp, smoke_id: str) -> None:
745
+ """Run the smoke check if needed, then register stage 0, fix, or give up."""
746
+ pairs = self._missing(view, smoke_id)
747
+ if pairs:
748
+ self._run_jobs(run_id, view, smoke_id, SMOKE_STAGE, pairs)
749
+ view = self._view(run_id)
750
+ problems = self._smoke_problems(run_id, view, h, smoke_id)
751
+ if not problems:
752
+ self._register(run_id, view, h, "0")
753
+ return
754
+ if view.amendments.get((h.id, SMOKE_STAGE), 0) >= SMOKE_FIXES:
755
+ exps = [x.id for x in self._used(view, smoke_id)]
756
+ self._decide(run_id, h, "0", "discard", "harness_error",
757
+ f"smoke run failed after {SMOKE_FIXES} fixes: {problems[0]}",
758
+ refs=[smoke_id, *exps])
759
+ return
760
+ self._harness(run_id, view, h, fix_of=smoke_id, failure=self._failure_text(
761
+ run_id, view, smoke_id, problems))
762
+
763
+ def _smoke_problems(self, run_id: str, view: RunView, h: Hyp, smoke_id: str) -> list[str]:
764
+ metric = view.preregs[smoke_id]["metric"]["name"]
765
+ problems: list[str] = []
766
+ manifests = []
767
+ for x in self._used(view, smoke_id):
768
+ f = x.finished or {}
769
+ if f.get("status") != "ok":
770
+ problems.append(f"{x.arm} arm ended {f.get('status')}: "
771
+ f"{f.get('environment_problem') or 'see stderr'}")
772
+ continue
773
+ if f.get("metrics_problem"):
774
+ problems.append(f"{x.arm} arm: {f['metrics_problem']}")
775
+ elif not harness.metric_values(f.get("metrics", []), metric, "selection"):
776
+ problems.append(f"{x.arm} arm did not report {metric!r} on selection data")
777
+ sets, problem = self._manifest(run_id, f)
778
+ if problem:
779
+ problems.append(f"{x.arm} arm: {problem}")
780
+ manifests.append(harness.manifest_key(sets))
781
+ if len(manifests) == len(ARMS) and manifests[0] != manifests[1]:
782
+ problems.append("the two arms used different data")
783
+ return problems
784
+
785
+ def _failure_text(self, run_id: str, view: RunView, prereg: str, problems: list[str]) -> str:
786
+ parts = list(problems)
787
+ for x in self._used(view, prereg):
788
+ digest = (x.finished or {}).get("artifacts", {}).get("stderr")
789
+ if digest:
790
+ tail = self._ledger.get_artifact(run_id, digest).decode("utf-8", "replace")
791
+ parts.append(f"{x.arm} stderr (tail):\n{tail[-3000:]}")
792
+ return "\n".join(parts)
793
+
794
+ def _manifest(self, run_id: str, finished: dict[str, Any]
795
+ ) -> tuple[list[dict[str, Any]], str | None]:
796
+ digest = finished.get("artifacts", {}).get(harness.MANIFEST_FILE)
797
+ raw = self._ledger.get_artifact(run_id, digest) if digest else None
798
+ return harness.parse_manifest(raw)
799
+
800
+ def _register(self, run_id: str, view: RunView, h: Hyp, stage: str) -> None:
801
+ """§5 step 4.5: the stage's pre-registration, from the passed smoke run."""
802
+ smoke_id = view.in_force[(h.id, SMOKE_STAGE)]
803
+ smoke = view.preregs[smoke_id]
804
+ treat = next(x for x in self._used(view, smoke_id) if x.arm == "treatment")
805
+ sets, _ = self._manifest(run_id, treat.finished or {})
806
+ self._append(run_id, "prereg.registered", {
807
+ "hypothesis": h.id, "stage": stage, "metric": smoke["metric"],
808
+ "comparison": smoke["comparison"], "evaluation_data": sets,
809
+ "budget": self._budget(view, stage), "seeds": self._seeds(view, stage),
810
+ "code_digest": smoke["code_digest"], "entrypoint": smoke["entrypoint"],
811
+ "env": smoke["env"], "smoke": smoke_id},
812
+ [h.id, smoke["comparison"]["baseline"], smoke_id])
813
+
814
+ # ---- experiments (§5 steps 4.6, 5, 10) ---------------------------------------------------
815
+
816
+ def _missing(self, view: RunView, prereg_id: str) -> list[tuple[str, int]]:
817
+ """(arm, seed) pairs with no usable finished experiment. Lost ones are rerun as new
818
+ experiments under the same pre-registration (§5 step 9)."""
819
+ done = {(x.arm, x.seed) for x in view.under(prereg_id)
820
+ if x.finished is not None and x.finished["status"] != "lost"}
821
+ seeds = view.preregs[prereg_id]["seeds"]["values"]
822
+ return [(arm, s) for s in seeds for arm in ARMS if (arm, s) not in done]
823
+
824
+ @staticmethod
825
+ def _used(view: RunView, prereg_id: str) -> list[Any]:
826
+ """The latest usable finished experiment per (arm, seed)."""
827
+ best: dict[tuple[str, int], Any] = {}
828
+ for x in view.under(prereg_id):
829
+ if x.finished is not None and x.finished["status"] != "lost":
830
+ best[(x.arm, x.seed)] = x
831
+ return list(best.values())
832
+
833
+ def _env(self, run_id: str, prereg: dict[str, Any]) -> tuple[EnvRef, str | None]:
834
+ s = self._session(run_id)
835
+ spec = EnvSpec(requirements_lock=str(prereg.get("env", {}).get("requirements_lock", "")))
836
+ digest = spec.digest()
837
+ if digest not in s.envs or s.envs[digest].status != "ok":
838
+ s.envs[digest] = s.executor.build_env(spec)
839
+ env_art = self._put(run_id, json.dumps(dataclasses.asdict(spec), sort_keys=True).encode(),
840
+ "env")
841
+ return s.envs[digest], env_art
842
+
843
+ def _run_jobs(self, run_id: str, view: RunView, prereg_id: str, stage: str,
844
+ pairs: list[tuple[str, int]]) -> None:
845
+ s = self._session(run_id)
846
+ prereg = view.preregs[prereg_id]
847
+ budget = float(prereg["budget"]["wall_clock_s"])
848
+ deadline = budget * KILL_FACTOR
849
+ rate = s.info.rate_usd_per_hour or 0.0
850
+ each_usd = rate * (deadline + GRACE_S) / 3600
851
+ conc = max(1, s.info.concurrency)
852
+ waves = math.ceil(len(pairs) / conc)
853
+ self._guard(run_id, each_usd * len(pairs), waves * (deadline + GRACE_S))
854
+ env, env_art = self._env(run_id, prereg)
855
+ code = self._ledger.get_artifact(run_id, prereg["code_digest"])
856
+ if env.status != "ok":
857
+ if stage != SMOKE_STAGE:
858
+ raise _Pause(f"environment build failed after a passed smoke run: "
859
+ f"{env.log[-500:]}")
860
+ for arm, seed in pairs: # recorded as smoke experiments that never ran
861
+ started = self._start_entry(run_id, s, prereg_id, prereg, env, arm, seed, stage)
862
+ self._append(run_id, "experiment.finished", {
863
+ "experiment": started, "status": "failed", "metrics": [],
864
+ "artifacts": {"env": env_art} if env_art else {},
865
+ "environment_problem": f"environment build failed: {env.log[-2000:]}",
866
+ "cost_usd": None, "metered": False}, [started])
867
+ return
868
+ pending = list(pairs)
869
+ running: dict[str, tuple[JobHandle, float, bool]] = {}
870
+ try:
871
+ while pending or running:
872
+ while pending and len(running) < conc:
873
+ in_flight = each_usd * len(running)
874
+ self._guard(run_id, in_flight + each_usd, deadline + GRACE_S)
875
+ arm, seed = pending.pop(0)
876
+ started = self._start_entry(run_id, s, prereg_id, prereg, env, arm, seed,
877
+ stage)
878
+ try:
879
+ handle = s.executor.submit(JobSpec(
880
+ code=code, entrypoint=harness.command(
881
+ prereg["entrypoint"], arm, stage, budget),
882
+ env=env, seed=seed, deadline_s=deadline,
883
+ credentials=tuple(view.config.job_credentials), tag=started))
884
+ except (ExecutorError, ValueError) as exc:
885
+ self._append(run_id, "experiment.finished", {
886
+ "experiment": started, "status": "crashed", "metrics": [],
887
+ "artifacts": {}, "environment_problem": f"submit failed: {exc}",
888
+ "cost_usd": None, "metered": False}, [started])
889
+ raise _Pause(f"executor: submit failed: {exc}") from exc
890
+ running[started] = (handle, self._mono(), False)
891
+ self._sleep(self._poll_s)
892
+ for sid, (handle, t0, killed) in list(running.items()):
893
+ if s.executor.poll(handle) == "done":
894
+ status = "timeout" if killed else None
895
+ self._finish_entry(run_id, sid, s.executor.result(handle), env_art,
896
+ status)
897
+ del running[sid]
898
+ elif not killed and self._mono() - t0 > deadline:
899
+ s.executor.kill(handle) # §5 step 5: killed at 110% (`timeout`)
900
+ running[sid] = (handle, t0, True)
901
+ except BaseException as exc:
902
+ for sid, (handle, _, _) in running.items():
903
+ try:
904
+ s.executor.kill(handle)
905
+ except ExecutorError:
906
+ pass
907
+ if isinstance(exc, Exception): # the run pauses or fails; this process lives
908
+ self._append(run_id, "experiment.finished", {
909
+ "experiment": sid, "status": "lost", "metrics": [], "artifacts": {},
910
+ "environment_problem": f"abandoned: {type(exc).__name__}",
911
+ "cost_usd": None, "metered": False}, [sid])
912
+ if isinstance(exc, Exception) and running:
913
+ try: # KB-020: nothing of an abandoned job stays on the host
914
+ s.executor.reap(list(running))
915
+ except (ExecutorError, OSError, subprocess.SubprocessError):
916
+ self._to_reap[run_id] = list(running) # retried before the next step
917
+ raise
918
+
919
+ def _start_entry(self, run_id: str, s: _Session, prereg_id: str, prereg: dict[str, Any],
920
+ env: EnvRef, arm: str, seed: int, stage: str) -> str:
921
+ """INV-1: recorded before submission. INV-3: same executor, budget and seeds for
922
+ both arms."""
923
+ return self._append(run_id, "experiment.started", {
924
+ "prereg": prereg_id, "executor_id": s.info.id, "isolation": s.info.isolation,
925
+ "code_digest": prereg["code_digest"], "env_digest": env.digest, "seed": seed,
926
+ "arm": arm, "stage": stage}, [prereg_id]).entry_id
927
+
928
+ def _finish_entry(self, run_id: str, started: str, r: JobResult, env_art: str | None,
929
+ status: str | None) -> None:
930
+ artifacts: dict[str, str] = {}
931
+ withheld: list[str] = []
932
+ files = [("stdout", r.stdout, "stdout"), ("stderr", r.stderr, "stderr")]
933
+ files += [(name, data, "result") for name, data in sorted(r.outputs.items())]
934
+ for name, data, kind in files:
935
+ digest = self._put(run_id, data, kind)
936
+ if digest:
937
+ artifacts[name] = digest
938
+ else:
939
+ withheld.append(name)
940
+ if env_art:
941
+ artifacts["env"] = env_art
942
+ metrics, problem = harness.parse_metrics(r.metrics)
943
+ payload: dict[str, Any] = {
944
+ "experiment": started, "status": status or r.status, "metrics": metrics,
945
+ "artifacts": artifacts, "duration_s": r.duration_s, "cost_usd": r.cost_usd,
946
+ "metered": r.metered, "exit_code": r.exit_code}
947
+ if problem:
948
+ payload["metrics_problem"] = problem
949
+ if withheld:
950
+ payload["withheld"] = withheld
951
+ self._append(run_id, "experiment.finished", payload, [started])
952
+
953
+ # ---- decisions (§5 steps 4.7, 4.8; INV-2, INV-9) ------------------------------------------
954
+
955
+ def _run_stage(self, run_id: str, view: RunView, h: Hyp, stage: str, pid: str) -> None:
956
+ pairs = self._missing(view, pid)
957
+ if pairs:
958
+ self._run_jobs(run_id, view, pid, stage, pairs)
959
+ view = self._view(run_id)
960
+ prereg = view.preregs[pid]
961
+ used = self._used(view, pid)
962
+ exps = [x.id for x in used]
963
+ fins = [x.finished_id for x in used]
964
+ statuses = {x.finished["status"] for x in used}
965
+ refs = [pid, *exps, *fins]
966
+ if statuses & {"failed", "crashed"}:
967
+ self._decide(run_id, h, stage, "discard", "harness_error",
968
+ "an experiment failed or crashed: a harness error, not a refutation",
969
+ prereg=pid, experiments=exps, refs=refs)
970
+ return
971
+ if "timeout" in statuses:
972
+ self._decide(run_id, h, stage, "discard", "infeasible",
973
+ f"an experiment ran past {KILL_FACTOR:.0%} of its budget and was "
974
+ "killed", prereg=pid, experiments=exps, refs=refs)
975
+ return
976
+ if statuses != {"ok"}:
977
+ self._decide(run_id, h, stage, "inconclusive", "no_signal",
978
+ f"experiments ended {sorted(statuses)}", prereg=pid, experiments=exps,
979
+ refs=refs)
980
+ return
981
+ want = harness.manifest_key(prereg["evaluation_data"])
982
+ for x in used:
983
+ sets, problem = self._manifest(run_id, x.finished)
984
+ if problem or harness.manifest_key(sets) != want:
985
+ self._decide(run_id, h, stage, "inconclusive", "no_signal",
986
+ f"the {x.arm} arm's data manifest differs from the registered "
987
+ "digests", prereg=pid, experiments=exps, refs=refs)
988
+ return
989
+ role = "held_out" if stage == CONFIRM_STAGE else "selection"
990
+ metric = prereg["metric"]
991
+ values: dict[str, list[float]] = {arm: [] for arm in ARMS}
992
+ uses = []
993
+ for x in used:
994
+ found = harness.metric_values(x.finished.get("metrics", []), metric["name"], role)
995
+ if found:
996
+ values[x.arm].append(float(found[0]["value"]))
997
+ uses.append({"experiment": x.finished_id, "name": metric["name"],
998
+ "set": found[0]["set"]})
999
+ if any(len(values[arm]) != len(prereg["seeds"]["values"]) for arm in ARMS):
1000
+ self._decide(run_id, h, stage, "inconclusive", "no_signal",
1001
+ f"metric {metric['name']!r} missing on {role} data for an experiment",
1002
+ prereg=pid, experiments=exps, refs=refs)
1003
+ return
1004
+ comp = prereg["comparison"]
1005
+ v = evaluate(comp["rule"], metric["direction"], float(comp["threshold"]),
1006
+ values["treatment"], values["baseline"],
1007
+ confirm_stage=stage == CONFIRM_STAGE)
1008
+ self._decide(run_id, h, stage, v.outcome, v.reason_code, v.reason_text, prereg=pid,
1009
+ experiments=exps, metrics_used=uses if v.outcome != "inconclusive" else [],
1010
+ comparison={**v.comparison, "data_role": role}, refs=refs)
1011
+
1012
+ def _decide(self, run_id: str, h: Hyp, stage: str, outcome: str, reason_code: str | None,
1013
+ reason_text: str, *, prereg: str | None = None,
1014
+ experiments: list[str] | None = None,
1015
+ metrics_used: list[dict[str, Any]] | None = None,
1016
+ comparison: dict[str, Any] | None = None, refs: list[str] | None = None) -> None:
1017
+ payload: dict[str, Any] = {
1018
+ "hypothesis": h.id, "stage": stage, "outcome": outcome, "reason_text": reason_text,
1019
+ "experiments": experiments or [], "metrics_used": metrics_used or [],
1020
+ "comparison": comparison or {}}
1021
+ if reason_code is not None:
1022
+ payload["reason_code"] = reason_code
1023
+ if prereg is not None and experiments:
1024
+ payload["prereg"] = prereg
1025
+ self._append(run_id, "decision.made", payload, [h.id, *(refs or [])])
1026
+
1027
+ def _close_open(self, run_id: str, view: RunView, code: str, text: str) -> None:
1028
+ """Every hypothesis still open gets an honest end: `user_stopped` or
1029
+ `limit_reached` (INV-9)."""
1030
+ for h in view.treatments():
1031
+ if h.final() is None:
1032
+ stages = view.stages_of(h.id)
1033
+ stage = stages[-1][0] if stages else "0"
1034
+ self._decide(run_id, h, stage, "inconclusive", code, text)
1035
+
1036
+ def _to_writing(self, run_id: str, view: RunView) -> None:
1037
+ """C2 in checkpoint mode (INV-7), then writing."""
1038
+ if view.config.checkpoint_on("C2"):
1039
+ cp = view.checkpoints.get("C2")
1040
+ if cp is None:
1041
+ options: dict[str, list[dict[str, Any]]] = {"confirmed": [], "negative": []}
1042
+ for h in view.treatments():
1043
+ final = h.final() or {}
1044
+ item = {"id": h.id, "statement": h.statement,
1045
+ "outcome": final.get("outcome"),
1046
+ "reason_code": final.get("reason_code")}
1047
+ key = "confirmed" if final.get("outcome") == "confirm" else "negative"
1048
+ options[key].append(item)
1049
+ self._append(run_id, "checkpoint.requested", {"checkpoint_id": "C2",
1050
+ "options": options})
1051
+ if cp is None or cp.decided is None:
1052
+ self._status(run_id, "paused", "exploring", "waiting for checkpoint C2",
1053
+ resume_status="writing")
1054
+ return
1055
+ self._status(run_id, "writing", "writing", "exploration finished")
1056
+
1057
+ # ---- writing (§5 steps 7, 8) -------------------------------------------------------------
1058
+
1059
+ def _write(self, run_id: str, view: RunView) -> None:
1060
+ s = self._session(run_id)
1061
+ cfg = view.config
1062
+ findings = tuple(h.id for h in view.treatments()
1063
+ if (h.final() or {}).get("outcome") == "confirm") # INV-4
1064
+ others = tuple(h.id for h in view.treatments()
1065
+ if h.final() is not None and h.id not in findings)
1066
+ title = " ".join(cfg.idea.split())[:120]
1067
+ options = PaperOptions(title=title, template=cfg.paper.template,
1068
+ disclosure=cfg.paper.disclosure,
1069
+ kind="paper" if findings else "report", findings=findings,
1070
+ others=others)
1071
+ ps = self._ps_factory()
1072
+ try:
1073
+ writer = self._writer(s.llm, ps)
1074
+ ctx = _Context(self, run_id)
1075
+ try:
1076
+ build = writer.build(run_id, options, ctx)
1077
+ except LimitReached as exc:
1078
+ build = BuildResult(status="failed", reasons=[str(exc)], files={},
1079
+ trace_map={}, citation_keys=[])
1080
+ paper = self._record_paper(run_id, build) if build.status == "built" else None
1081
+ if paper and cfg.puretrace.selfcheck:
1082
+ self._status(run_id, "writing", "selfcheck", "self-check")
1083
+ build = self._selfcheck(run_id, writer, build, paper, title, ctx)
1084
+ dest = self._out / run_id / f"bundle-{self._ledger.state(run_id).last_seq}"
1085
+ writer.bundle(run_id, build, dest)
1086
+ finally:
1087
+ asyncio.run(ps.aclose())
1088
+ what = ("paper" if findings else "report") if build.status == "built" else None
1089
+ reason = (f"{what} built" if what else
1090
+ "no paper built: " + "; ".join(build.reasons)[:500]) + f"; bundle at {dest}"
1091
+ self._status(run_id, "delivered", "delivered", reason, bundle=str(dest))
1092
+
1093
+ def _record_paper(self, run_id: str, build: BuildResult) -> Entry:
1094
+ artifacts = {}
1095
+ for name, data in build.files.items():
1096
+ kind = ("figure" if name.startswith("fig-") else
1097
+ "paper" if name.endswith((".pdf", ".tex", ".bbl")) else "other")
1098
+ digest = self._put(run_id, data, kind)
1099
+ if digest:
1100
+ artifacts[name] = digest
1101
+ refs = sorted({i for ids in build.trace_map.values() for i in ids})
1102
+ return self._append(run_id, "paper.built", {
1103
+ "artifacts": artifacts, "trace_map": build.trace_map,
1104
+ "citation_keys": build.citation_keys}, refs)
1105
+
1106
+ def _selfcheck(self, run_id: str, writer: Writer, build: BuildResult, paper: Entry,
1107
+ title: str, ctx: _Context) -> BuildResult:
1108
+ """§5 step 8: assess, one revision with the open questions, assess again."""
1109
+ result = self._assess(run_id, build, paper, title)
1110
+ if not isinstance(result, Assessment) or not result.open_questions:
1111
+ return build
1112
+ try:
1113
+ revised = writer.revise(run_id, build, [q.prompt for q in result.open_questions],
1114
+ ctx)
1115
+ except LimitReached:
1116
+ return build
1117
+ if revised.status != "built":
1118
+ return build
1119
+ self._assess(run_id, revised, self._record_paper(run_id, revised), title)
1120
+ return revised
1121
+
1122
+ def _assess(self, run_id: str, build: BuildResult, paper: Entry, title: str) -> Any:
1123
+ pdf = build.pdf or b""
1124
+ result = self._pt(run_id, lambda pt: pt.assess(pdf, title, "puremethod", Artifacts(),
1125
+ None, use="selfcheck"))
1126
+ self._record_all(run_id, "puretrace", result.records)
1127
+ if isinstance(result, Assessment):
1128
+ summary: dict[str, Any] = {
1129
+ "paper_id": result.paper_id, "status": result.status,
1130
+ "paper_type": result.paper_type, "rubric_version": result.rubric_version,
1131
+ "overall": result.overall, "provisional": result.provisional,
1132
+ "dimensions": [{"key": d.key, "score": d.score} for d in result.dimensions],
1133
+ "open_questions": [q.label for q in result.open_questions]}
1134
+ else:
1135
+ summary = {"not_applicable": result.reason, "detected": result.detected}
1136
+ self._append(run_id, "selfcheck.result", {"paper": paper.payload["artifacts"]["main.pdf"],
1137
+ "assessment": summary}, [paper.entry_id])
1138
+ return result
1139
+
1140
+
1141
+ class _Context:
1142
+ """The `RunContext` given to `writer` (06-writer.md §3): limits before every call, with
1143
+ the writing reserve released; records after."""
1144
+
1145
+ def __init__(self, loop: Loop, run_id: str) -> None:
1146
+ self._loop = loop
1147
+ self._run_id = run_id
1148
+
1149
+ def guard(self, max_cost_usd: float) -> None:
1150
+ self._loop._guard(self._run_id, max_cost_usd, writing=True)
1151
+
1152
+ def record(self, call_record: Any, artifacts: dict[str, bytes], *,
1153
+ citation_records: list[dict[str, Any]] | None = None) -> None:
1154
+ if hasattr(call_record, "endpoint"):
1155
+ self._loop._record_http(self._run_id, "purescholar", call_record, citation_records)
1156
+ else:
1157
+ self._loop._record_llm(self._run_id, call_record)
1158
+
1159
+
1160
+ __all__ = ["LimitReached", "Loop", "RunStatus"]