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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- 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"]
|