outerloop-science 0.1.0.dev0__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.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,1901 @@
|
|
|
1
|
+
"""Orchestrator v1: one climb attempt on one benchmark of one target.
|
|
2
|
+
|
|
3
|
+
Deliberately narrow: `attempt_once` runs a single
|
|
4
|
+
implement→evaluate→verify→PR cycle for the configured benchmark. Task
|
|
5
|
+
selection across benchmarks, the planner, experiment sbatch + wakes, and
|
|
6
|
+
notebook reports grow from here — each behind a seam that already exists.
|
|
7
|
+
|
|
8
|
+
The verification stance is the architecture's: the agent's claim is never
|
|
9
|
+
trusted. The orchestrator re-runs the benchmark command itself — baseline at
|
|
10
|
+
the pre-session tree, candidate after — and only a direction-consistent,
|
|
11
|
+
threshold-clearing delta opens a PR.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import subprocess
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from dataclasses import replace as dc_replace
|
|
23
|
+
from fractions import Fraction
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from secrets import randbits
|
|
26
|
+
from typing import TYPE_CHECKING, Protocol
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from outerloop.measure import Measure
|
|
30
|
+
|
|
31
|
+
from outerloop.brief import BriefInputs, BudgetState, Task, build_brief, render
|
|
32
|
+
from outerloop.contract import (
|
|
33
|
+
Benchmark,
|
|
34
|
+
Contract,
|
|
35
|
+
_fold,
|
|
36
|
+
load_contract,
|
|
37
|
+
normalize_path,
|
|
38
|
+
path_is_forbidden,
|
|
39
|
+
)
|
|
40
|
+
from outerloop.harness import Harness, SessionResult, budget_exhausted, outage, redact
|
|
41
|
+
from outerloop.panel import PanelVerdict
|
|
42
|
+
from outerloop.role_runner import run_role
|
|
43
|
+
from outerloop.roles import author_spec
|
|
44
|
+
from outerloop.rolespec import RoleSpec
|
|
45
|
+
from outerloop.syscall import (
|
|
46
|
+
SyscallError,
|
|
47
|
+
SyscallRequest,
|
|
48
|
+
evals_gpu_hours,
|
|
49
|
+
launches_gpu_hours,
|
|
50
|
+
)
|
|
51
|
+
from outerloop.syscall import budget_error as syscall_budget_error
|
|
52
|
+
from outerloop.syscall import read_request as read_syscall_request
|
|
53
|
+
from outerloop.syscall import render_refusal as render_syscall_refusal
|
|
54
|
+
|
|
55
|
+
log = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
EVAL_TIMEOUT_S = 1800
|
|
58
|
+
MAX_REPORT_BODY = 20_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Environment keys the evaluator manages itself; a contract's seed_env may
|
|
62
|
+
# never name one (validated at load; filtered again at injection).
|
|
63
|
+
PROTECTED_EVAL_ENV = frozenset(
|
|
64
|
+
{
|
|
65
|
+
"HOME",
|
|
66
|
+
"PATH",
|
|
67
|
+
"TMPDIR",
|
|
68
|
+
"LANG",
|
|
69
|
+
"VIRTUAL_ENV",
|
|
70
|
+
# interpreter/loader steering: a random-integer value cannot carry a
|
|
71
|
+
# payload, but a contract naming one of these would silently break
|
|
72
|
+
# every eval in a way that reads as measurement failure
|
|
73
|
+
"PYTHONPATH",
|
|
74
|
+
"PYTHONHOME",
|
|
75
|
+
"PYTHONSTARTUP",
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
# ...and whole families: any UV_* steers uv's env/cache resolution, and any
|
|
79
|
+
# APPTAINERENV_* is translated into the CONTAINER's environment by apptainer
|
|
80
|
+
# (APPTAINERENV_HOME becomes HOME inside), so exact-name checks cannot
|
|
81
|
+
# enumerate them.
|
|
82
|
+
# APPTAINER_* configures the HOST-side apptainer CLI (bind paths, home,
|
|
83
|
+
# containment) — same family logic, different side of the boundary.
|
|
84
|
+
# LD_/DYLD_ steer the dynamic loader; GIT_ redirects repo resolution.
|
|
85
|
+
# (PYTHONHASHSEED stays allowed — it IS a seed, and a legitimate seed_env.)
|
|
86
|
+
PROTECTED_ENV_PREFIXES = ("UV_", "APPTAINERENV_", "APPTAINER_", "LD_", "DYLD_", "GIT_")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def managed_eval_env(name: str) -> bool:
|
|
90
|
+
"""True when injecting `name` could disturb the eval's own isolation."""
|
|
91
|
+
return name in PROTECTED_EVAL_ENV or name.startswith(PROTECTED_ENV_PREFIXES)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EvalError(RuntimeError):
|
|
95
|
+
"""The benchmark command failed or produced no readable metric."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class RunParked(Exception):
|
|
99
|
+
"""A dispatched climb submitted its measures and must hibernate until they
|
|
100
|
+
finish. `attempt_once` raises it (a park is an exceptional exit); the caller,
|
|
101
|
+
which owns the run record and git, persists the fields below as the WAITING
|
|
102
|
+
stage — `afterany` among them, the dependency set the wake waits on — and
|
|
103
|
+
ends the run's turn, keeping the candidate snapshot alive so the wake can
|
|
104
|
+
read it. `phase` is WHICH park: `candidate` (after the session — the wake
|
|
105
|
+
decides) or `author-sleep` (the author launched work and slept). The caller
|
|
106
|
+
fills in the candidate snapshot ref (which it holds) when writing the
|
|
107
|
+
stage."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
phase: str,
|
|
113
|
+
afterany: str,
|
|
114
|
+
base_sha: str,
|
|
115
|
+
seed: int,
|
|
116
|
+
suite_seed: int,
|
|
117
|
+
candidate_sha: str = "",
|
|
118
|
+
session: SessionResult | None = None,
|
|
119
|
+
syscall: SyscallRequest | None = None,
|
|
120
|
+
launches_used: int = 0,
|
|
121
|
+
sleeps_used: int = 0,
|
|
122
|
+
submitted: bool = False,
|
|
123
|
+
gpu_hours_used: float = 0.0,
|
|
124
|
+
eval_minutes: int | None = None,
|
|
125
|
+
judged: tuple[str, AttemptResult] | None = None,
|
|
126
|
+
launch_afterany: str = "",
|
|
127
|
+
):
|
|
128
|
+
self.phase = phase
|
|
129
|
+
# the author's launch jobs alone (a candidate park's `afterany` also
|
|
130
|
+
# carries the gate's evals): the wake reconciles their charge
|
|
131
|
+
self.launch_afterany = launch_afterany
|
|
132
|
+
# the gate's last negative and the tree it judged, carried across an
|
|
133
|
+
# author-sleep so a wake ending on that tree reuses the verdict
|
|
134
|
+
self.judged = judged
|
|
135
|
+
self.afterany = afterany
|
|
136
|
+
self.base_sha = base_sha
|
|
137
|
+
self.seed = seed
|
|
138
|
+
self.suite_seed = suite_seed
|
|
139
|
+
self.candidate_sha = candidate_sha
|
|
140
|
+
self.session = session
|
|
141
|
+
# GPU-hours drawn so far (launches + gate evals), and the eval
|
|
142
|
+
# walltime the author declared at submit — the wake re-parks and
|
|
143
|
+
# re-floors on the same numbers
|
|
144
|
+
self.gpu_hours_used = gpu_hours_used
|
|
145
|
+
self.eval_minutes = eval_minutes
|
|
146
|
+
# Syscall parks (an author-sleep, or a SUBMITTED candidate — Phase B):
|
|
147
|
+
# the request the wake gathers results for, and the budget counts AFTER
|
|
148
|
+
# this park. `submitted` marks a candidate park raised by `submit`: the
|
|
149
|
+
# wake delivers gate + panel results back to the AUTHOR instead of
|
|
150
|
+
# deciding by policy.
|
|
151
|
+
self.syscall = syscall
|
|
152
|
+
self.launches_used = launches_used
|
|
153
|
+
self.sleeps_used = sleeps_used
|
|
154
|
+
self.submitted = submitted
|
|
155
|
+
super().__init__(f"climb parked at {phase} on {afterany or '(no dep)'}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Evaluator(Protocol):
|
|
159
|
+
"""Runs a benchmark command in a workspace, returns the metric value."""
|
|
160
|
+
|
|
161
|
+
def evaluate(
|
|
162
|
+
self, workspace: Path, command: str, metric: str, extra_env: dict[str, str] | None = None
|
|
163
|
+
) -> float: ...
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class SubprocessEvaluator:
|
|
168
|
+
"""Runs the contract's benchmark command and reads `metric` from its
|
|
169
|
+
JSON output (the contract requires commands to print their metrics).
|
|
170
|
+
|
|
171
|
+
The command executes AGENT-WRITTEN code — it is session-grade untrusted
|
|
172
|
+
execution and gets session-grade containment: with `container_image` set
|
|
173
|
+
(the production configuration), the command runs under `apptainer exec
|
|
174
|
+
--containall` seeing only the workspace, a throwaway tmpfs HOME, and no
|
|
175
|
+
host environment. Uncontained mode exists for tests and non-cluster dev,
|
|
176
|
+
with a scrubbed env that NEVER includes the real HOME (the orchestrator
|
|
177
|
+
account holds the bot PAT under it)."""
|
|
178
|
+
|
|
179
|
+
timeout_s: int = EVAL_TIMEOUT_S
|
|
180
|
+
container_image: str = ""
|
|
181
|
+
apptainer_binary: str = "apptainer"
|
|
182
|
+
|
|
183
|
+
def evaluate(
|
|
184
|
+
self, workspace: Path, command: str, metric: str, extra_env: dict[str, str] | None = None
|
|
185
|
+
) -> float:
|
|
186
|
+
|
|
187
|
+
# Throwaway HOME OUTSIDE the clone: never the orchestrator's real home
|
|
188
|
+
# (it shelters the PAT), and never the workspace — eval cache/state
|
|
189
|
+
# artifacts must not masquerade as agent edits in the diff. The
|
|
190
|
+
# CONTAINED eval needs it too (--home): apptainer's tmpfs home is
|
|
191
|
+
# size-capped and uv blows it extracting wheels.
|
|
192
|
+
# Fresh per-EVAL home (never reused): baseline and candidate cannot
|
|
193
|
+
# see each other's writes, and nothing survives to any later run.
|
|
194
|
+
import os
|
|
195
|
+
import tempfile
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
eval_home = Path(
|
|
199
|
+
tempfile.mkdtemp(
|
|
200
|
+
prefix=f"{workspace.name}-eval-home-", dir=workspace.resolve().parent
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
raise EvalError(f"could not create eval home: {exc}") from exc
|
|
205
|
+
# per-EVAL cache on node-local scratch: local IO (NFS caches flake),
|
|
206
|
+
# no state crossing evals (agent code runs during the candidate eval
|
|
207
|
+
# and must not poison later baselines), and only THIS directory is
|
|
208
|
+
# bound into the container — never the whole host /tmp.
|
|
209
|
+
try:
|
|
210
|
+
cache_dir = Path(
|
|
211
|
+
tempfile.mkdtemp(prefix="uv-cache-", dir=os.environ.get("TMPDIR", "/tmp"))
|
|
212
|
+
)
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
import shutil
|
|
215
|
+
|
|
216
|
+
shutil.rmtree(eval_home, ignore_errors=True)
|
|
217
|
+
raise EvalError(f"could not create eval cache dir: {exc}") from exc
|
|
218
|
+
try:
|
|
219
|
+
return self._measure(workspace, command, metric, eval_home, cache_dir, extra_env)
|
|
220
|
+
finally:
|
|
221
|
+
# bounded disk: each eval's home AND cache die with it
|
|
222
|
+
# (re-downloading wheels per eval is the accepted isolation cost)
|
|
223
|
+
import shutil
|
|
224
|
+
|
|
225
|
+
shutil.rmtree(eval_home, ignore_errors=True)
|
|
226
|
+
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
227
|
+
|
|
228
|
+
def _measure(
|
|
229
|
+
self,
|
|
230
|
+
workspace: Path,
|
|
231
|
+
command: str,
|
|
232
|
+
metric: str,
|
|
233
|
+
eval_home: Path,
|
|
234
|
+
cache_dir: Path,
|
|
235
|
+
extra_env: dict[str, str] | None = None,
|
|
236
|
+
) -> float:
|
|
237
|
+
return self._parse_measured(
|
|
238
|
+
self._run(workspace, command, eval_home, cache_dir, extra_env), metric
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _run(
|
|
242
|
+
self,
|
|
243
|
+
workspace: Path,
|
|
244
|
+
command: str,
|
|
245
|
+
eval_home: Path,
|
|
246
|
+
cache_dir: Path,
|
|
247
|
+
extra_env: dict[str, str] | None = None,
|
|
248
|
+
) -> str:
|
|
249
|
+
import os
|
|
250
|
+
import signal
|
|
251
|
+
|
|
252
|
+
if self.container_image:
|
|
253
|
+
# per evaluation, and gone with the cache dir the caller removes
|
|
254
|
+
workdir = cache_dir / "work"
|
|
255
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
argv = [
|
|
257
|
+
self.apptainer_binary,
|
|
258
|
+
"exec",
|
|
259
|
+
"--containall",
|
|
260
|
+
"--cleanenv",
|
|
261
|
+
"--bind",
|
|
262
|
+
f"{workspace}:{workspace}",
|
|
263
|
+
"--home",
|
|
264
|
+
f"{eval_home}:{eval_home}",
|
|
265
|
+
# node-local scratch for uv's cache: the container's own /tmp
|
|
266
|
+
# is a size-capped tmpfs, and shared-FS caches flake (NFS)
|
|
267
|
+
"--bind",
|
|
268
|
+
f"{cache_dir}:{cache_dir}",
|
|
269
|
+
"--pwd",
|
|
270
|
+
str(workspace),
|
|
271
|
+
# /tmp inside the jail on this evaluation's own scratch, not
|
|
272
|
+
# apptainer's tmpfs (the dispatched job script does the same)
|
|
273
|
+
"--workdir",
|
|
274
|
+
str(workdir),
|
|
275
|
+
self.container_image,
|
|
276
|
+
"sh",
|
|
277
|
+
"-c",
|
|
278
|
+
command,
|
|
279
|
+
]
|
|
280
|
+
else:
|
|
281
|
+
argv = ["sh", "-c", command]
|
|
282
|
+
env = {k: os.environ[k] for k in ("PATH", "LANG", "TMPDIR") if k in os.environ}
|
|
283
|
+
# uv's cache does heavy small-file IO; on shared filesystems (NFS)
|
|
284
|
+
# that flakes with stale-handle/copy errors. Keep the cache on
|
|
285
|
+
# node-local scratch and copy across filesystems.
|
|
286
|
+
env["UV_CACHE_DIR"] = str(cache_dir)
|
|
287
|
+
env["UV_LINK_MODE"] = "copy"
|
|
288
|
+
# PRIVATE project env per eval: the session builds ws/.venv for its
|
|
289
|
+
# own use, and a second process consuming a venv another process
|
|
290
|
+
# just wrote races NFS close-to-open consistency. The eval builds
|
|
291
|
+
# its own environment from the LOCKFILE on NODE-LOCAL scratch (beside the
|
|
292
|
+
# uv cache: fast IO, zero NFS in the venv path, dies with the
|
|
293
|
+
# eval) — no shared mutable state, and the orchestrator never
|
|
294
|
+
# executes session-authored entrypoints.
|
|
295
|
+
env["UV_PROJECT_ENVIRONMENT"] = str(cache_dir / "venv")
|
|
296
|
+
if self.container_image:
|
|
297
|
+
# --cleanenv drops the host env; APPTAINERENV_* survives it
|
|
298
|
+
env["APPTAINERENV_UV_CACHE_DIR"] = env["UV_CACHE_DIR"]
|
|
299
|
+
env["APPTAINERENV_UV_LINK_MODE"] = "copy"
|
|
300
|
+
env["APPTAINERENV_UV_PROJECT_ENVIRONMENT"] = env["UV_PROJECT_ENVIRONMENT"]
|
|
301
|
+
env["HOME"] = str(eval_home)
|
|
302
|
+
if extra_env:
|
|
303
|
+
# explicit injections only (the base env is a scrubbed
|
|
304
|
+
# allowlist): today this carries the benchmark's run seed.
|
|
305
|
+
# Managed keys are dropped, never overwritten — the contract
|
|
306
|
+
# validator already rejects them, this is defense in depth
|
|
307
|
+
# (an injected HOME/UV_* would defeat per-eval isolation)
|
|
308
|
+
for key, value in extra_env.items():
|
|
309
|
+
if managed_eval_env(key):
|
|
310
|
+
log.warning("refusing extra_env override of managed %s", key)
|
|
311
|
+
continue
|
|
312
|
+
env[key] = value
|
|
313
|
+
if self.container_image:
|
|
314
|
+
env[f"APPTAINERENV_{key}"] = value
|
|
315
|
+
try:
|
|
316
|
+
# process group, like the harness: a timed-out eval must not
|
|
317
|
+
# leave orphans mutating a workspace that later gets committed
|
|
318
|
+
process = subprocess.Popen(
|
|
319
|
+
argv,
|
|
320
|
+
cwd=workspace,
|
|
321
|
+
env=env,
|
|
322
|
+
stdout=subprocess.PIPE,
|
|
323
|
+
stderr=subprocess.PIPE,
|
|
324
|
+
text=True,
|
|
325
|
+
start_new_session=True,
|
|
326
|
+
)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
raise EvalError(f"eval could not start: {exc}") from exc
|
|
329
|
+
try:
|
|
330
|
+
stdout, stderr = process.communicate(timeout=self.timeout_s)
|
|
331
|
+
except subprocess.TimeoutExpired as exc:
|
|
332
|
+
import contextlib
|
|
333
|
+
|
|
334
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
335
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
336
|
+
try:
|
|
337
|
+
process.communicate(timeout=10)
|
|
338
|
+
except subprocess.TimeoutExpired:
|
|
339
|
+
process.kill()
|
|
340
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
341
|
+
process.communicate(timeout=5)
|
|
342
|
+
raise EvalError(f"eval timed out after {self.timeout_s}s") from exc
|
|
343
|
+
if process.returncode != 0:
|
|
344
|
+
raise EvalError(f"eval failed ({process.returncode}): {stderr[-500:]}")
|
|
345
|
+
return stdout
|
|
346
|
+
|
|
347
|
+
def check(self, workspace: Path, command: str) -> None:
|
|
348
|
+
"""Run `command` with eval-grade containment, requiring only exit 0.
|
|
349
|
+
|
|
350
|
+
The steward's validation suite (pytest, per-benchmark smoke runs)
|
|
351
|
+
executes STEWARD-written env code — same trust level as agent
|
|
352
|
+
code, same containment, no metric parsed."""
|
|
353
|
+
import shutil
|
|
354
|
+
import tempfile
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
eval_home = Path(
|
|
358
|
+
tempfile.mkdtemp(
|
|
359
|
+
prefix=f"{workspace.name}-check-home-", dir=workspace.resolve().parent
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
except OSError as exc:
|
|
363
|
+
raise EvalError(f"could not create check home: {exc}") from exc
|
|
364
|
+
cache_dir = Path(tempfile.mkdtemp(prefix="autoresearch-check-cache-"))
|
|
365
|
+
try:
|
|
366
|
+
self._run(workspace, command, eval_home, cache_dir)
|
|
367
|
+
finally:
|
|
368
|
+
shutil.rmtree(eval_home, ignore_errors=True)
|
|
369
|
+
shutil.rmtree(cache_dir, ignore_errors=True)
|
|
370
|
+
|
|
371
|
+
def _parse_measured(self, stdout: str, metric: str) -> float:
|
|
372
|
+
value = _metric_from_output(stdout, metric)
|
|
373
|
+
if value is None:
|
|
374
|
+
raise EvalError(f"metric {metric!r} not found in eval output")
|
|
375
|
+
if not math.isfinite(value):
|
|
376
|
+
raise EvalError(f"metric {metric!r} is not finite: {value}")
|
|
377
|
+
return value
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _metric_from_output(stdout: str, metric: str) -> float | None:
|
|
381
|
+
"""The metric from the LAST single-line JSON object that carries it.
|
|
382
|
+
|
|
383
|
+
No regex fallback: a fuzzy match that reads the wrong number (a progress
|
|
384
|
+
line, a prefixed metric name) is worse than a clean failure — the
|
|
385
|
+
contract requires eval commands to print their metrics as JSON."""
|
|
386
|
+
for line in reversed(stdout.strip().splitlines()):
|
|
387
|
+
line = line.strip()
|
|
388
|
+
if line.startswith("{"):
|
|
389
|
+
try:
|
|
390
|
+
data = json.loads(line)
|
|
391
|
+
except json.JSONDecodeError:
|
|
392
|
+
continue
|
|
393
|
+
if isinstance(data, dict) and metric in data:
|
|
394
|
+
try:
|
|
395
|
+
return float(data[metric])
|
|
396
|
+
except (TypeError, ValueError):
|
|
397
|
+
return None
|
|
398
|
+
# the {"metric": <name>, "value": <v>} shape (what the pilot's
|
|
399
|
+
# eval actually prints)
|
|
400
|
+
if isinstance(data, dict) and data.get("metric") == metric and "value" in data:
|
|
401
|
+
try:
|
|
402
|
+
return float(data["value"])
|
|
403
|
+
except (TypeError, ValueError):
|
|
404
|
+
return None
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _bot_login_default() -> str:
|
|
409
|
+
"""RunConfig's login default, resolved at construction from the one env
|
|
410
|
+
knob; github is imported here on purpose — this module's import graph
|
|
411
|
+
stays free of it."""
|
|
412
|
+
from outerloop.github import bot_login_from_env
|
|
413
|
+
|
|
414
|
+
return bot_login_from_env()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class RunConfig:
|
|
419
|
+
target: str # owner/repo
|
|
420
|
+
benchmark: str # the ONE benchmark this loop works on
|
|
421
|
+
agent_id: str = "agent-01"
|
|
422
|
+
# Commits are AUTHORED as the bot account (a real GitHub identity):
|
|
423
|
+
# a bare "agent-01" noreply address links to whoever owns that login.
|
|
424
|
+
# The agent id lives in a commit trailer instead.
|
|
425
|
+
bot_login: str = field(default_factory=_bot_login_default)
|
|
426
|
+
# relative improvement below this is noise, not a PR (ε is contract-
|
|
427
|
+
# configurable later; this is the loop-side floor)
|
|
428
|
+
min_relative_improvement: float = 0.005
|
|
429
|
+
budget: BudgetState = field(default_factory=lambda: BudgetState(0.0, 1))
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def branch_prefix(self) -> str:
|
|
433
|
+
# derived, never stored: every call site passed agent_id but left the
|
|
434
|
+
# old field at its default, so every PR branch said agent-01. An id
|
|
435
|
+
# that cannot shape a ref — empty, or a malformed value an old CLI
|
|
436
|
+
# accepted into a record — keeps the old spelling rather than
|
|
437
|
+
# handing git an invalid branch name at publish.
|
|
438
|
+
import re
|
|
439
|
+
|
|
440
|
+
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", self.agent_id or ""):
|
|
441
|
+
return f"feat/auto/{self.agent_id}"
|
|
442
|
+
return "feat/auto/agent-01"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass(frozen=True)
|
|
446
|
+
class SuiteMeasurement:
|
|
447
|
+
"""One sibling benchmark's paired measurement from the suite gate."""
|
|
448
|
+
|
|
449
|
+
name: str
|
|
450
|
+
baseline: float
|
|
451
|
+
candidate: float
|
|
452
|
+
regressed: bool
|
|
453
|
+
display_digits: int | None = None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@dataclass(frozen=True)
|
|
457
|
+
class AttemptResult:
|
|
458
|
+
"""What one attempt produced — the raw material of the run report."""
|
|
459
|
+
|
|
460
|
+
# improved | no-improvement | session-error | session-budget |
|
|
461
|
+
# session-outage | eval-error | scope-violation | suite-regression
|
|
462
|
+
outcome: str
|
|
463
|
+
baseline: float | None = None
|
|
464
|
+
candidate: float | None = None
|
|
465
|
+
branch: str = ""
|
|
466
|
+
# the exact paths that were scope-checked and then measured — the caller
|
|
467
|
+
# must refuse to commit anything beyond this set
|
|
468
|
+
measured_paths: tuple[str, ...] = ()
|
|
469
|
+
# the sealed candidate snapshot this result was measured on (set on
|
|
470
|
+
# `improved`); a caller can publish THIS tree (the wake path does,
|
|
471
|
+
# attempt.py) and a depth loop can select the best across passes by it.
|
|
472
|
+
# "" on non-improved outcomes.
|
|
473
|
+
candidate_sha: str = ""
|
|
474
|
+
session: SessionResult | None = None
|
|
475
|
+
note: str = ""
|
|
476
|
+
# the seed both measurements ran under (0 = benchmark has no seed_env):
|
|
477
|
+
# recorded in the ledger row so the number is re-derivable
|
|
478
|
+
run_seed: int = 0
|
|
479
|
+
# sibling measurements when the suite gate ran (shared paths touched);
|
|
480
|
+
# empty when the diff was env-specific or no shared paths are declared
|
|
481
|
+
suite: tuple[SuiteMeasurement, ...] = ()
|
|
482
|
+
# the seed every seeded sibling's pair ran under (0 = gate did not run)
|
|
483
|
+
suite_seed: int = 0
|
|
484
|
+
# the pre-PR panel's record: per-round transcript for the PR body, how
|
|
485
|
+
# many reads ran, whether blocking findings were still open at the cap,
|
|
486
|
+
# and whether the FINAL read was degraded (a lens with no verdict, an
|
|
487
|
+
# unsanitizable tree). Either flag means the caller opens a DRAFT PR
|
|
488
|
+
# and never arms auto-merge.
|
|
489
|
+
panel_transcript: str = ""
|
|
490
|
+
panel_rounds: int = 0
|
|
491
|
+
panel_blocking_open: bool = False
|
|
492
|
+
panel_degraded: bool = False
|
|
493
|
+
|
|
494
|
+
def report(self, config: RunConfig, redact_secrets: tuple[str, ...] = ()) -> str:
|
|
495
|
+
lines = [
|
|
496
|
+
f"# Run report — {config.target} / {config.benchmark}",
|
|
497
|
+
f"Outcome: **{self.outcome}**",
|
|
498
|
+
]
|
|
499
|
+
if self.baseline is not None:
|
|
500
|
+
lines.append(f"Baseline: {self.baseline}")
|
|
501
|
+
if self.candidate is not None:
|
|
502
|
+
lines.append(f"Candidate: {self.candidate}")
|
|
503
|
+
for row in self.suite:
|
|
504
|
+
verdict = "REGRESSED" if row.regressed else "ok"
|
|
505
|
+
lines.append(f"Suite {row.name}: {row.baseline} -> {row.candidate} ({verdict})")
|
|
506
|
+
if self.panel_rounds:
|
|
507
|
+
if self.panel_blocking_open:
|
|
508
|
+
state = "blocking findings OPEN at the cap"
|
|
509
|
+
elif self.panel_degraded:
|
|
510
|
+
state = "DEGRADED final read (a lens produced no verdict)"
|
|
511
|
+
else:
|
|
512
|
+
state = "clean"
|
|
513
|
+
lines.append(f"Panel: {self.panel_rounds} read(s), {state}")
|
|
514
|
+
if self.note:
|
|
515
|
+
lines.append(f"Note: {self.note}")
|
|
516
|
+
if self.session is not None:
|
|
517
|
+
lines += [
|
|
518
|
+
f"Session: cost=${self.session.cost_usd:.2f}, "
|
|
519
|
+
f"turns={self.session.num_turns}, stop={self.session.stop_reason}",
|
|
520
|
+
"",
|
|
521
|
+
"## Agent's report",
|
|
522
|
+
# redact BEFORE truncating: a secret straddling the cut would
|
|
523
|
+
# otherwise survive as an unmatchable prefix
|
|
524
|
+
redact(self.session.final_text, redact_secrets)[:MAX_REPORT_BODY],
|
|
525
|
+
]
|
|
526
|
+
return redact("\n".join(lines), redact_secrets)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _benchmark(contract: Contract, name: str):
|
|
530
|
+
for bench in contract.benchmarks:
|
|
531
|
+
if bench.name == name:
|
|
532
|
+
return bench
|
|
533
|
+
raise ValueError(
|
|
534
|
+
f"benchmark {name!r} not in contract ({[b.name for b in contract.benchmarks]})"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def out_of_scope(paths: Sequence[str], contract: Contract) -> list[str]:
|
|
539
|
+
"""Changed paths the contract does not allow the agent to touch.
|
|
540
|
+
|
|
541
|
+
Checked BEFORE the candidate eval: an out-of-scope edit could be to the
|
|
542
|
+
eval harness itself, and measuring a doctored ruler would turn "CI
|
|
543
|
+
re-verifies independently" into re-running the fraud."""
|
|
544
|
+
allowed = [normalize_path(entry) for entry in contract.scope.allowed]
|
|
545
|
+
violations = []
|
|
546
|
+
for path in paths:
|
|
547
|
+
if path_is_forbidden(path, contract):
|
|
548
|
+
violations.append(path)
|
|
549
|
+
continue
|
|
550
|
+
try:
|
|
551
|
+
candidate = normalize_path(path)
|
|
552
|
+
except Exception:
|
|
553
|
+
violations.append(path)
|
|
554
|
+
continue
|
|
555
|
+
if not any(candidate == a or a in candidate.parents for a in allowed):
|
|
556
|
+
violations.append(path)
|
|
557
|
+
return violations
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def shared_touched(paths: Sequence[str], contract: Contract) -> list[str]:
|
|
561
|
+
"""Changed paths under `scope.shared` — the suite-gate trigger. Runs on
|
|
562
|
+
paths that already passed `out_of_scope`, so unparseable entries are
|
|
563
|
+
simply not shared (they were rejected upstream). Case-folded like the
|
|
564
|
+
forbidden/steward checks: a `Model/` spelling must not dodge the gate."""
|
|
565
|
+
shared = [_fold(normalize_path(entry)) for entry in contract.scope.shared]
|
|
566
|
+
hits = []
|
|
567
|
+
for path in paths:
|
|
568
|
+
try:
|
|
569
|
+
candidate = _fold(normalize_path(path))
|
|
570
|
+
except Exception:
|
|
571
|
+
continue
|
|
572
|
+
if any(candidate == s or s in candidate.parents for s in shared):
|
|
573
|
+
hits.append(path)
|
|
574
|
+
return hits
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def steward_out_of_scope(paths: Sequence[str], contract: Contract) -> list[str]:
|
|
578
|
+
"""Changed paths the STEWARD may not touch.
|
|
579
|
+
|
|
580
|
+
The inversion of `out_of_scope`: the steward edits the env/ruler
|
|
581
|
+
territory (`contract.steward.allowed`) and may NEVER touch the solver's
|
|
582
|
+
territory (`contract.scope.allowed`) — the roles' separation is what
|
|
583
|
+
makes verifier-checked stewardship trustworthy. The always-forbidden
|
|
584
|
+
set (contract, `.github/`, roadmap) binds here too. No steward section
|
|
585
|
+
in the contract means everything is out of scope.
|
|
586
|
+
"""
|
|
587
|
+
if contract.steward is None:
|
|
588
|
+
return list(paths)
|
|
589
|
+
allowed = [_fold(normalize_path(entry)) for entry in contract.steward.allowed]
|
|
590
|
+
solver = [_fold(normalize_path(entry)) for entry in contract.scope.allowed]
|
|
591
|
+
violations = []
|
|
592
|
+
for path in paths:
|
|
593
|
+
if path_is_forbidden(path, contract):
|
|
594
|
+
violations.append(path)
|
|
595
|
+
continue
|
|
596
|
+
try:
|
|
597
|
+
candidate = _fold(normalize_path(path))
|
|
598
|
+
except Exception:
|
|
599
|
+
violations.append(path)
|
|
600
|
+
continue
|
|
601
|
+
# case-folded both directions, like path_is_forbidden: on a
|
|
602
|
+
# case-insensitive checkout, Solvers/ IS solvers/
|
|
603
|
+
if any(candidate == sp or sp in candidate.parents for sp in solver):
|
|
604
|
+
violations.append(path) # solver territory: never the steward's
|
|
605
|
+
continue
|
|
606
|
+
if not any(candidate == a or a in candidate.parents for a in allowed):
|
|
607
|
+
violations.append(path)
|
|
608
|
+
return violations
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def draw_run_seed() -> int:
|
|
612
|
+
"""A fresh measurement seed, never 0 — zero is the ledger's "no seed
|
|
613
|
+
recorded" sentinel, and the injection guards key off truthiness."""
|
|
614
|
+
return 1 + randbits(30)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def benchmark_floor(
|
|
618
|
+
prior_best: float, min_delta: float | None, min_delta_rel: float | None
|
|
619
|
+
) -> float:
|
|
620
|
+
"""The effective absolute cross-seed floor for a comparison against the
|
|
621
|
+
recorded best. The larger of the absolute floor and the relative floor
|
|
622
|
+
scaled to the level, so a benchmark that sets both gets the more
|
|
623
|
+
conservative one. Returns 0.0 when no floor is declared.
|
|
624
|
+
|
|
625
|
+
A relative-only floor scales to 0 at a recorded level of 0, which means
|
|
626
|
+
no floor. That is a real limit of a relative floor, not a bug: a metric
|
|
627
|
+
that can sit at 0 should pair min_delta_rel with a small absolute
|
|
628
|
+
min_delta as a backstop. Unbounded metrics that use a relative floor
|
|
629
|
+
(wall-clock timing) do not reach 0."""
|
|
630
|
+
floors = []
|
|
631
|
+
if min_delta:
|
|
632
|
+
floors.append(min_delta)
|
|
633
|
+
if min_delta_rel and math.isfinite(prior_best):
|
|
634
|
+
floors.append(min_delta_rel * abs(prior_best))
|
|
635
|
+
return max(floors) if floors else 0.0
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def reaches_floor(
|
|
639
|
+
prior: float,
|
|
640
|
+
candidate: float,
|
|
641
|
+
direction: str,
|
|
642
|
+
min_delta: float | None,
|
|
643
|
+
min_delta_rel: float | None,
|
|
644
|
+
) -> bool:
|
|
645
|
+
"""Inclusive floor test in exact decimal arithmetic. Metric values and
|
|
646
|
+
floors arrive as decimal text (eval JSON, the contract's YAML), so the
|
|
647
|
+
comparison is made on the decimals that were written, not on their
|
|
648
|
+
binary approximations: 0.3 - 0.2 is exactly 0.1 here, and there is no
|
|
649
|
+
tolerance for a short delta to hide in at any scale. Non-finite inputs
|
|
650
|
+
fail closed: an infinite floor (`min_delta: .inf` is valid YAML) is
|
|
651
|
+
never reached, and a NaN anywhere is not a measurement. The caller's
|
|
652
|
+
float floor is only for messages."""
|
|
653
|
+
if not all(math.isfinite(v) for v in (prior, candidate, min_delta or 0, min_delta_rel or 0)):
|
|
654
|
+
return False
|
|
655
|
+
p, c = Fraction(repr(prior)), Fraction(repr(candidate))
|
|
656
|
+
delta = c - p if direction == "max" else p - c
|
|
657
|
+
floors = []
|
|
658
|
+
if min_delta:
|
|
659
|
+
floors.append(Fraction(repr(min_delta)))
|
|
660
|
+
if min_delta_rel:
|
|
661
|
+
floors.append(Fraction(repr(min_delta_rel)) * abs(p))
|
|
662
|
+
return delta >= max(floors) if floors else True
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def clears_min_delta(
|
|
666
|
+
prior_best: float,
|
|
667
|
+
candidate: float,
|
|
668
|
+
direction: str,
|
|
669
|
+
min_delta: float | None,
|
|
670
|
+
min_delta_rel: float | None = None,
|
|
671
|
+
) -> bool:
|
|
672
|
+
"""Cross-seed comparisons on a resampled pool must reach the
|
|
673
|
+
benchmark's noise floor: the recorded best was measured under a
|
|
674
|
+
different seed, so a delta below the floor is pool luck, not progress.
|
|
675
|
+
The floor is INCLUSIVE — a delta equal to it is credited: the contract
|
|
676
|
+
declares the smallest movement it calls real, and on a quantized metric
|
|
677
|
+
(a step count measured every N steps) the floor IS a reachable value,
|
|
678
|
+
so a strict bar silently demands the next quantum (gpt-speedrun,
|
|
679
|
+
2026-09-03: three candidates measured exactly one floor better than the
|
|
680
|
+
base were all discarded). Same-seed paired comparisons never call this."""
|
|
681
|
+
if not (min_delta or min_delta_rel):
|
|
682
|
+
return True # no floor declared
|
|
683
|
+
if not (math.isfinite(prior_best) and math.isfinite(candidate)):
|
|
684
|
+
return False # a declared floor with non-finite inputs fails closed
|
|
685
|
+
return reaches_floor(prior_best, candidate, direction, min_delta, min_delta_rel)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def suite_regressed(
|
|
689
|
+
baseline: float,
|
|
690
|
+
candidate: float,
|
|
691
|
+
direction: str,
|
|
692
|
+
min_delta: float | None = None,
|
|
693
|
+
min_delta_rel: float | None = None,
|
|
694
|
+
) -> bool:
|
|
695
|
+
"""Did a sibling benchmark move the WRONG way beyond its own floor?
|
|
696
|
+
|
|
697
|
+
Both sides are same-seed paired, so with no floor declared any wrong-way
|
|
698
|
+
move counts (paired noise is ~0 by construction); a declared floor gives
|
|
699
|
+
a stochastic eval its honest tolerance. Non-finite values fail closed —
|
|
700
|
+
an unmeasurable sibling must never read as "no regression"."""
|
|
701
|
+
if not (math.isfinite(baseline) and math.isfinite(candidate)):
|
|
702
|
+
return True
|
|
703
|
+
drop = baseline - candidate if direction == "max" else candidate - baseline
|
|
704
|
+
if drop <= 0:
|
|
705
|
+
return False
|
|
706
|
+
return drop > benchmark_floor(baseline, min_delta, min_delta_rel)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def improved(baseline: float, candidate: float, direction: str, min_rel: float) -> bool:
|
|
710
|
+
"""Direction-aware, threshold-clearing improvement. Non-finite values
|
|
711
|
+
never count (the evaluator rejects them; this is defense in depth)."""
|
|
712
|
+
if not (math.isfinite(baseline) and math.isfinite(candidate)):
|
|
713
|
+
return False
|
|
714
|
+
if baseline == 0:
|
|
715
|
+
# no relative scale exists: apply the threshold absolutely
|
|
716
|
+
return candidate >= min_rel if direction == "max" else candidate <= -min_rel
|
|
717
|
+
rel = (candidate - baseline) / abs(baseline)
|
|
718
|
+
return rel >= min_rel if direction == "max" else rel <= -min_rel
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def make_task(
|
|
722
|
+
contract: Contract, benchmark_name: str, baseline: float | None, hypothesis: str = ""
|
|
723
|
+
) -> Task:
|
|
724
|
+
bench = _benchmark(contract, benchmark_name)
|
|
725
|
+
better = "lower" if bench.direction == "min" else "higher"
|
|
726
|
+
suite_gated = bool(contract.scope.shared) and len(contract.benchmarks) > 1
|
|
727
|
+
# ORIENTATION, not direction: the brief states the current score and how
|
|
728
|
+
# the metric reads as FACTS, and leaves the goal and the finish to the
|
|
729
|
+
# author (research-loop.md, author-directed). The GATE — never the brief —
|
|
730
|
+
# is the real bar: it re-measures both sides after the session, so a
|
|
731
|
+
# missing baseline (a benchmark's first run) just drops the reference
|
|
732
|
+
# number. Naming a target here would only invite optimizing that number.
|
|
733
|
+
current = f"currently {baseline}" if baseline is not None else "no score recorded yet"
|
|
734
|
+
return Task(
|
|
735
|
+
hypothesis=hypothesis
|
|
736
|
+
or (
|
|
737
|
+
f"The {bench.name} solver can be improved: study the current "
|
|
738
|
+
f"implementation and the evaluation, form ONE concrete hypothesis "
|
|
739
|
+
f"for why it underperforms, and implement it."
|
|
740
|
+
),
|
|
741
|
+
benchmark=bench.name,
|
|
742
|
+
# a fact about the metric, not a target to chase
|
|
743
|
+
expected_effect=f"{bench.metric} ({better} is better), {current}",
|
|
744
|
+
# the finish is the AUTHOR's call; the gate decides what publishes
|
|
745
|
+
done_criteria=(
|
|
746
|
+
"You decide when your result is worth publishing — and a negative "
|
|
747
|
+
"result reported clearly is a success. The orchestrator re-measures "
|
|
748
|
+
f"`{bench.command}` on a private seed to verify any improvement "
|
|
749
|
+
"claim, and the PR's CI runs the repository tests"
|
|
750
|
+
+ (
|
|
751
|
+
"; changes touching shared paths are suite-gated, so no sibling "
|
|
752
|
+
"benchmark may regress beyond its floor"
|
|
753
|
+
if suite_gated
|
|
754
|
+
else ""
|
|
755
|
+
)
|
|
756
|
+
+ "."
|
|
757
|
+
),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
class Measurer(Protocol):
|
|
762
|
+
"""Obtains a climb's measurements. `results` returns every measure's value
|
|
763
|
+
keyed by its name, or — for a dispatched backend — raises
|
|
764
|
+
`MeasurementPending` after submitting the not-yet-done jobs, for the caller
|
|
765
|
+
to park on. `measure.DispatchedMeasurer` runs each measure as a job on a
|
|
766
|
+
fresh checkout of its committed sha — on the cluster, or synchronously via
|
|
767
|
+
`LocalCompute`; the seam is what lets `measure_and_decide` be re-enterable
|
|
768
|
+
without knowing which."""
|
|
769
|
+
|
|
770
|
+
def results(self, measures: list[Measure]) -> dict[str, float]: ...
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@dataclass(frozen=True)
|
|
774
|
+
class MeasureOK:
|
|
775
|
+
"""A credited measurement: the candidate cleared the improvement threshold
|
|
776
|
+
and, when the diff touched shared code, no sibling regressed. The caller's
|
|
777
|
+
panel/PR path proceeds from here."""
|
|
778
|
+
|
|
779
|
+
baseline: float
|
|
780
|
+
candidate: float
|
|
781
|
+
suite: tuple[SuiteMeasurement, ...] = ()
|
|
782
|
+
suite_seed: int = 0
|
|
783
|
+
# how the baseline number was obtained when it was NOT measured beside
|
|
784
|
+
# this candidate (`baseline: cached`) — surfaces on the credited result
|
|
785
|
+
baseline_note: str = ""
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def measure_and_decide(
|
|
789
|
+
contract: Contract,
|
|
790
|
+
bench: Benchmark,
|
|
791
|
+
*,
|
|
792
|
+
base_sha: str,
|
|
793
|
+
candidate_sha: str,
|
|
794
|
+
seed: int,
|
|
795
|
+
suite_seed: int,
|
|
796
|
+
measured_paths: Sequence[str],
|
|
797
|
+
measurer: Measurer,
|
|
798
|
+
min_relative_improvement: float,
|
|
799
|
+
) -> AttemptResult | MeasureOK:
|
|
800
|
+
"""The post-session decision as a PURE function of committed shas and a
|
|
801
|
+
`Measurer` — the re-enterable core a wake reconstructs from the record.
|
|
802
|
+
|
|
803
|
+
Measures baseline@base_sha and candidate@candidate_sha (paired on `seed`,
|
|
804
|
+
common random numbers) and, when the diff touches shared code, each
|
|
805
|
+
sibling's paired base/cand (on the sibling's OWN seed var, all sharing the
|
|
806
|
+
single `suite_seed`), then applies the improvement threshold and the suite
|
|
807
|
+
gate. Returns a TERMINAL `AttemptResult` on any stop (scope-violation,
|
|
808
|
+
eval-error, no-improvement, suite-regression), or a `MeasureOK` carrying
|
|
809
|
+
the credited values. No session and no git: the same shas rebuild the same
|
|
810
|
+
plan and read cached results, so a wake re-enters here unchanged. A
|
|
811
|
+
dispatched measurer's `MeasurementPending` propagates untouched, for the
|
|
812
|
+
caller to write the waiting record and park on.
|
|
813
|
+
|
|
814
|
+
`suite_seed` is an INPUT, not drawn here: a wake must reproduce the seed the
|
|
815
|
+
first pass used, so the caller draws it once and persists it. And because
|
|
816
|
+
the baseline is a committed sha rather than a live pre-session workspace,
|
|
817
|
+
the gate can always measure its siblings.
|
|
818
|
+
"""
|
|
819
|
+
# deferred like contract.py's managed_eval_env import: measure -> dispatch
|
|
820
|
+
# -> orchestrator for the eval primitives, so orchestrator imports measure
|
|
821
|
+
# at call time to keep the module graph acyclic.
|
|
822
|
+
from outerloop.measure import (
|
|
823
|
+
SiblingSpec,
|
|
824
|
+
plan_measures,
|
|
825
|
+
read_baseline_cache,
|
|
826
|
+
write_baseline_cache,
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# Scope BEFORE measurement: an out-of-scope tree is never evaluated,
|
|
830
|
+
# because the out-of-scope edit could be to the ruler itself.
|
|
831
|
+
violations = out_of_scope(list(measured_paths), contract)
|
|
832
|
+
if violations:
|
|
833
|
+
return AttemptResult(
|
|
834
|
+
outcome="scope-violation",
|
|
835
|
+
note=f"out-of-scope paths: {', '.join(sorted(violations)[:10])}",
|
|
836
|
+
run_seed=seed,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
seed_env = bench.seed_env or ""
|
|
840
|
+
siblings = [b for b in contract.benchmarks if b.name != bench.name]
|
|
841
|
+
# Both seed guards fire UP FRONT, before any (expensive) measurement: a
|
|
842
|
+
# seed of 0 — the "no seed recorded" sentinel — would run a pair UNPAIRED,
|
|
843
|
+
# each side drawing its own internal seed, so eval noise could read as
|
|
844
|
+
# improvement. The caller must draw a real seed (and persist it, for the
|
|
845
|
+
# wake to reuse) whenever a benchmark or a sibling declares seed_env; a
|
|
846
|
+
# seeded sibling's suite_seed is a caller-contract precondition even on a
|
|
847
|
+
# diff that won't touch shared code (the next diff might).
|
|
848
|
+
if seed_env and not seed:
|
|
849
|
+
raise ValueError(
|
|
850
|
+
f"benchmark {bench.name!r} declares seed_env {seed_env!r} but seed is 0: "
|
|
851
|
+
"a seeded benchmark needs a drawn seed, or baseline and candidate run unpaired"
|
|
852
|
+
)
|
|
853
|
+
# only when the suite gate is STRUCTURALLY possible: a contract with an
|
|
854
|
+
# empty scope.shared can never trigger it (shared_touched always empty), so
|
|
855
|
+
# a missing suite_seed there is not a misconfiguration — do not over-reject.
|
|
856
|
+
if contract.scope.shared and any(b.seed_env for b in siblings) and not suite_seed:
|
|
857
|
+
raise ValueError(
|
|
858
|
+
"a seeded sibling needs a nonzero suite_seed (drawn once, persisted for the wake); "
|
|
859
|
+
"suite_seed 0 would run the sibling pair unpaired"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# PHASE 1 — baseline + candidate only. Siblings are NOT measured until the
|
|
863
|
+
# candidate has cleared the threshold: a non-improving candidate must never
|
|
864
|
+
# burn the (expensive) sibling evals, matching attempt_once's lazy order.
|
|
865
|
+
# `baseline: cached` — the base tree is measured ONCE per (benchmark, base
|
|
866
|
+
# sha) into the target's cache and reused by every attempt on that base;
|
|
867
|
+
# only the candidate runs. Unpaired, so the contract's floor carries the
|
|
868
|
+
# cross-seed noise (the loader requires one). A miss measures both and
|
|
869
|
+
# records the baseline for the next attempt.
|
|
870
|
+
cache_dir = getattr(measurer, "baseline_cache", None)
|
|
871
|
+
image = str(getattr(measurer, "image", ""))
|
|
872
|
+
cached = (
|
|
873
|
+
read_baseline_cache(
|
|
874
|
+
cache_dir,
|
|
875
|
+
bench.name,
|
|
876
|
+
base_sha,
|
|
877
|
+
image=image,
|
|
878
|
+
command=bench.command,
|
|
879
|
+
metric=bench.metric,
|
|
880
|
+
seed_env=bench.seed_env or "",
|
|
881
|
+
gpus=bench.gpus,
|
|
882
|
+
)
|
|
883
|
+
if bench.baseline == "cached" and cache_dir is not None
|
|
884
|
+
else None
|
|
885
|
+
)
|
|
886
|
+
plan = plan_measures(
|
|
887
|
+
bench.command,
|
|
888
|
+
bench.metric,
|
|
889
|
+
base_sha,
|
|
890
|
+
candidate_sha,
|
|
891
|
+
seed_env,
|
|
892
|
+
seed,
|
|
893
|
+
gpus=bench.gpus,
|
|
894
|
+
)
|
|
895
|
+
if cached is not None:
|
|
896
|
+
plan = [m for m in plan if m.name != "baseline"]
|
|
897
|
+
try:
|
|
898
|
+
main = measurer.results(plan)
|
|
899
|
+
except EvalError as exc:
|
|
900
|
+
return AttemptResult(outcome="eval-error", note=str(exc), run_seed=seed)
|
|
901
|
+
|
|
902
|
+
baseline_note = ""
|
|
903
|
+
if cached is not None:
|
|
904
|
+
baseline = float(cached["value"])
|
|
905
|
+
baseline_note = (
|
|
906
|
+
f"baseline {baseline} reused from the cache (measured at seed "
|
|
907
|
+
f"{cached.get('seed')} by run {cached.get('run')}); candidate at seed {seed}"
|
|
908
|
+
)
|
|
909
|
+
else:
|
|
910
|
+
baseline = main["baseline"]
|
|
911
|
+
if bench.baseline == "cached" and cache_dir is not None:
|
|
912
|
+
write_baseline_cache(
|
|
913
|
+
cache_dir,
|
|
914
|
+
bench.name,
|
|
915
|
+
base_sha,
|
|
916
|
+
value=baseline,
|
|
917
|
+
seed=seed,
|
|
918
|
+
run_tag=str(getattr(measurer, "run_tag", "")),
|
|
919
|
+
image=image,
|
|
920
|
+
command=bench.command,
|
|
921
|
+
metric=bench.metric,
|
|
922
|
+
seed_env=bench.seed_env or "",
|
|
923
|
+
gpus=bench.gpus,
|
|
924
|
+
)
|
|
925
|
+
candidate = main["candidate"]
|
|
926
|
+
if not improved(baseline, candidate, bench.direction, min_relative_improvement):
|
|
927
|
+
return AttemptResult(
|
|
928
|
+
outcome="no-improvement",
|
|
929
|
+
baseline=baseline,
|
|
930
|
+
candidate=candidate,
|
|
931
|
+
run_seed=seed,
|
|
932
|
+
)
|
|
933
|
+
# The contract's OWN significance floor, when the benchmark declares one.
|
|
934
|
+
# Same-seed pairing removes pool noise but not training stochasticity —
|
|
935
|
+
# a benchmark whose eval trains models calibrates min_delta to its
|
|
936
|
+
# cross-run sd, and the gate must speak that language too (yolo#16
|
|
937
|
+
# published at +0.0379 against a declared 0.04 floor because only the
|
|
938
|
+
# followup path read it). No declared floor -> the relative default
|
|
939
|
+
# above remains the whole bar.
|
|
940
|
+
floor = benchmark_floor(baseline, bench.min_delta, bench.min_delta_rel)
|
|
941
|
+
if floor:
|
|
942
|
+
delta = (candidate - baseline) if bench.direction == "max" else (baseline - candidate)
|
|
943
|
+
# INCLUSIVE, matching clears_min_delta: the floor is the smallest
|
|
944
|
+
# movement the contract calls real, so a delta equal to it clears
|
|
945
|
+
if not reaches_floor(
|
|
946
|
+
baseline, candidate, bench.direction, bench.min_delta, bench.min_delta_rel
|
|
947
|
+
):
|
|
948
|
+
return AttemptResult(
|
|
949
|
+
outcome="no-improvement",
|
|
950
|
+
note=(
|
|
951
|
+
f"delta {delta:+.6g} is inside the contract's significance "
|
|
952
|
+
f"floor ({floor:g}): real movement, not creditable progress"
|
|
953
|
+
),
|
|
954
|
+
baseline=baseline,
|
|
955
|
+
candidate=candidate,
|
|
956
|
+
run_seed=seed,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
# PHASE 2 — suite gate, only for a credited candidate whose diff touched
|
|
960
|
+
# shared code. A second measure set (a second park for a dispatched
|
|
961
|
+
# backend): an extra CPU wake, never a wasted GPU sibling eval.
|
|
962
|
+
if not (siblings and shared_touched(measured_paths, contract)):
|
|
963
|
+
return MeasureOK(baseline=baseline, candidate=candidate, baseline_note=baseline_note)
|
|
964
|
+
|
|
965
|
+
# every seeded sibling runs its pair under the ONE suite_seed, read through
|
|
966
|
+
# its own seed var (mirrors the in-job gate).
|
|
967
|
+
sib_specs = tuple(
|
|
968
|
+
SiblingSpec(
|
|
969
|
+
b.name,
|
|
970
|
+
b.command,
|
|
971
|
+
b.metric,
|
|
972
|
+
seed_env=b.seed_env or "",
|
|
973
|
+
seed=suite_seed,
|
|
974
|
+
gpus=b.gpus, # each sibling on ITS lane, not the climbed benchmark's
|
|
975
|
+
)
|
|
976
|
+
for b in siblings
|
|
977
|
+
)
|
|
978
|
+
sib_plan = [
|
|
979
|
+
m
|
|
980
|
+
for m in plan_measures(
|
|
981
|
+
bench.command,
|
|
982
|
+
bench.metric,
|
|
983
|
+
base_sha,
|
|
984
|
+
candidate_sha,
|
|
985
|
+
seed_env,
|
|
986
|
+
seed,
|
|
987
|
+
siblings=sib_specs,
|
|
988
|
+
gpus=bench.gpus,
|
|
989
|
+
)
|
|
990
|
+
if m.name.startswith("sib-") # baseline/candidate already measured (phase 1)
|
|
991
|
+
]
|
|
992
|
+
try:
|
|
993
|
+
vals = measurer.results(sib_plan)
|
|
994
|
+
except EvalError as exc:
|
|
995
|
+
# phase 1 already credited the main pair — carry it into the report.
|
|
996
|
+
return AttemptResult(
|
|
997
|
+
outcome="eval-error",
|
|
998
|
+
baseline=baseline,
|
|
999
|
+
candidate=candidate,
|
|
1000
|
+
note=str(exc),
|
|
1001
|
+
run_seed=seed,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
suite_rows: list[SuiteMeasurement] = []
|
|
1005
|
+
for b in siblings:
|
|
1006
|
+
sib_base = vals[f"sib-{b.name}-base"]
|
|
1007
|
+
sib_cand = vals[f"sib-{b.name}-cand"]
|
|
1008
|
+
suite_rows.append(
|
|
1009
|
+
SuiteMeasurement(
|
|
1010
|
+
name=b.name,
|
|
1011
|
+
baseline=sib_base,
|
|
1012
|
+
candidate=sib_cand,
|
|
1013
|
+
regressed=suite_regressed(
|
|
1014
|
+
sib_base, sib_cand, b.direction, b.min_delta, b.min_delta_rel
|
|
1015
|
+
),
|
|
1016
|
+
display_digits=b.display_digits,
|
|
1017
|
+
)
|
|
1018
|
+
)
|
|
1019
|
+
suite = tuple(suite_rows)
|
|
1020
|
+
regressed = [r for r in suite if r.regressed]
|
|
1021
|
+
if regressed:
|
|
1022
|
+
named = ", ".join(f"{r.name} {r.baseline} -> {r.candidate}" for r in regressed)
|
|
1023
|
+
return AttemptResult(
|
|
1024
|
+
outcome="suite-regression",
|
|
1025
|
+
baseline=baseline,
|
|
1026
|
+
candidate=candidate,
|
|
1027
|
+
note=f"shared-path diff regressed sibling benchmark(s): {named}",
|
|
1028
|
+
run_seed=seed,
|
|
1029
|
+
suite=suite,
|
|
1030
|
+
suite_seed=suite_seed,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
return MeasureOK(
|
|
1034
|
+
baseline=baseline,
|
|
1035
|
+
candidate=candidate,
|
|
1036
|
+
suite=suite,
|
|
1037
|
+
suite_seed=suite_seed, # reached only on the suite path
|
|
1038
|
+
baseline_note=baseline_note,
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def resume_attempt(
|
|
1043
|
+
contract: Contract,
|
|
1044
|
+
bench: Benchmark,
|
|
1045
|
+
*,
|
|
1046
|
+
base_sha: str,
|
|
1047
|
+
candidate_sha: str,
|
|
1048
|
+
seed: int,
|
|
1049
|
+
suite_seed: int,
|
|
1050
|
+
measured_paths: Sequence[str],
|
|
1051
|
+
session: SessionResult,
|
|
1052
|
+
measurer: Measurer,
|
|
1053
|
+
min_relative_improvement: float,
|
|
1054
|
+
) -> AttemptResult:
|
|
1055
|
+
"""Re-enter a parked climb's post-session decision — the WAKE side of a
|
|
1056
|
+
dispatched candidate park. The candidate is already committed (its sha is in
|
|
1057
|
+
the record), so the session does NOT re-run: its edits are captured in
|
|
1058
|
+
`candidate_sha` and it was reconstructed by the caller from the record
|
|
1059
|
+
(`session.final_text` is the saved write-up, and its cost/turns the saved
|
|
1060
|
+
spend) so the PR body and panel claim need no live session. `measured_paths`
|
|
1061
|
+
is the caller's re-derivation of the `base_sha..candidate_sha` diff.
|
|
1062
|
+
|
|
1063
|
+
The measurer reads the cached eval results and this returns the decision, OR
|
|
1064
|
+
the decision needs a measure not yet done — the suite pairs after an
|
|
1065
|
+
improving candidate, "another round of experiments" — and it re-parks by
|
|
1066
|
+
raising `RunParked`, exactly as the first pass did.
|
|
1067
|
+
|
|
1068
|
+
This is the re-entry seam the depth axis (docs/design/research-loop.md)
|
|
1069
|
+
builds on.
|
|
1070
|
+
"""
|
|
1071
|
+
from outerloop.measure import MeasurementPending
|
|
1072
|
+
|
|
1073
|
+
try:
|
|
1074
|
+
outcome = measure_and_decide(
|
|
1075
|
+
contract,
|
|
1076
|
+
bench,
|
|
1077
|
+
base_sha=base_sha,
|
|
1078
|
+
candidate_sha=candidate_sha,
|
|
1079
|
+
seed=seed,
|
|
1080
|
+
suite_seed=suite_seed,
|
|
1081
|
+
measured_paths=measured_paths,
|
|
1082
|
+
measurer=measurer,
|
|
1083
|
+
min_relative_improvement=min_relative_improvement,
|
|
1084
|
+
)
|
|
1085
|
+
except MeasurementPending as pending:
|
|
1086
|
+
# a measure is not done yet (the suite pairs this wake just dispatched):
|
|
1087
|
+
# re-park on the new afterany set, same shape as the first candidate park
|
|
1088
|
+
raise RunParked(
|
|
1089
|
+
phase="candidate",
|
|
1090
|
+
afterany=pending.afterany(),
|
|
1091
|
+
base_sha=base_sha,
|
|
1092
|
+
seed=seed,
|
|
1093
|
+
suite_seed=suite_seed,
|
|
1094
|
+
candidate_sha=candidate_sha,
|
|
1095
|
+
session=session,
|
|
1096
|
+
) from None
|
|
1097
|
+
if isinstance(outcome, AttemptResult):
|
|
1098
|
+
# a terminal measurement outcome (no-improvement / suite-regression /
|
|
1099
|
+
# eval-error): carry the reconstructed session, and give a bare
|
|
1100
|
+
# no-improvement the same framing the in-job path sets (a clear
|
|
1101
|
+
# negative is a success), so a resumed negative does not end note-less.
|
|
1102
|
+
note = outcome.note
|
|
1103
|
+
if outcome.outcome == "no-improvement" and not note:
|
|
1104
|
+
# only a BARE negative gets the generic framing — a specific
|
|
1105
|
+
# reason (e.g. inside the contract's significance floor) must
|
|
1106
|
+
# survive to the record and report
|
|
1107
|
+
note = "a negative result reported clearly is a success"
|
|
1108
|
+
return dc_replace(outcome, session=session, note=note)
|
|
1109
|
+
# credited: candidate cleared the threshold and no sibling regressed. The
|
|
1110
|
+
# caller sets `branch` when it opens the PR.
|
|
1111
|
+
return AttemptResult(
|
|
1112
|
+
outcome="improved",
|
|
1113
|
+
baseline=outcome.baseline,
|
|
1114
|
+
candidate=outcome.candidate,
|
|
1115
|
+
session=session,
|
|
1116
|
+
measured_paths=tuple(measured_paths),
|
|
1117
|
+
run_seed=seed,
|
|
1118
|
+
suite=outcome.suite,
|
|
1119
|
+
suite_seed=outcome.suite_seed,
|
|
1120
|
+
candidate_sha=candidate_sha,
|
|
1121
|
+
note=outcome.baseline_note,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _merge_afterany(*parts: str) -> str:
|
|
1126
|
+
"""Join afterany dependency strings ("afterany:1:2") into one; "" parts
|
|
1127
|
+
drop out. A submit's park waits on the gate's evals AND its sibling
|
|
1128
|
+
launches together."""
|
|
1129
|
+
ids = [t for part in parts for t in part.split(":")[1:] if t]
|
|
1130
|
+
return "afterany:" + ":".join(ids) if ids else ""
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def attempt_once(
|
|
1134
|
+
config: RunConfig,
|
|
1135
|
+
contract_text: str,
|
|
1136
|
+
workspace: Path,
|
|
1137
|
+
harness: Harness,
|
|
1138
|
+
measurer: Measurer,
|
|
1139
|
+
base_sha: str,
|
|
1140
|
+
snapshot: Callable[[], str],
|
|
1141
|
+
ruler: str,
|
|
1142
|
+
changed_paths: Callable[[], Sequence[str]],
|
|
1143
|
+
lessons: str = "",
|
|
1144
|
+
recent_reports: tuple[str, ...] = (),
|
|
1145
|
+
report_archive: bool = False,
|
|
1146
|
+
created: str = "",
|
|
1147
|
+
task_hypothesis: str = "",
|
|
1148
|
+
spec: RoleSpec | None = None,
|
|
1149
|
+
panel_runner: Callable[[float, float, str], PanelVerdict] | None = None,
|
|
1150
|
+
brief_baseline: float | None = None,
|
|
1151
|
+
line_ref: str = "",
|
|
1152
|
+
line_memory: str = "",
|
|
1153
|
+
line_divergence: str = "",
|
|
1154
|
+
resume_session_id: str = "",
|
|
1155
|
+
improve_prompt: str = "",
|
|
1156
|
+
launcher: Callable[[str, SyscallRequest], str] | None = None,
|
|
1157
|
+
launches_used: int = 0,
|
|
1158
|
+
sleeps_used: int = 0,
|
|
1159
|
+
gpu_hours_used: float = 0.0,
|
|
1160
|
+
tree_of: Callable[[str], str] | None = None,
|
|
1161
|
+
judged: tuple[str, AttemptResult] | None = None,
|
|
1162
|
+
) -> AttemptResult:
|
|
1163
|
+
"""One implement→evaluate→verify cycle in an existing clean workspace.
|
|
1164
|
+
|
|
1165
|
+
The caller owns the git side (clone before, diff/commit/push/PR after) —
|
|
1166
|
+
same split as the harness: this function owns the science loop only. It
|
|
1167
|
+
measures through a `Measurer` over committed shas: `base_sha` is the
|
|
1168
|
+
pre-session tree, and `snapshot()` — a caller callback, since snapshotting
|
|
1169
|
+
is git — commits the session's current workspace and returns its
|
|
1170
|
+
`candidate_sha`. The caller registers each sha's worktree with the measurer
|
|
1171
|
+
and owns the snapshot ref lifecycle. `changed_paths` reports every path the
|
|
1172
|
+
session touched (the caller wires it to `git add -A` + staged paths); scope
|
|
1173
|
+
is enforced on it BEFORE the candidate eval runs.
|
|
1174
|
+
|
|
1175
|
+
The session runs as the author role on the role-runner (`spec` defaults
|
|
1176
|
+
to `author_spec`; the caller that built the harness passes its spec so
|
|
1177
|
+
manifest and harness agree). Scope enforcement stays HERE, on the
|
|
1178
|
+
contract — the spec's scope is the manifest copy, filled from the same
|
|
1179
|
+
contract.
|
|
1180
|
+
|
|
1181
|
+
With `panel_runner` (docs/design/orchestrator-verify.md), a credited
|
|
1182
|
+
claim is read by the verification panel BEFORE it can become a PR. On a
|
|
1183
|
+
SUBMITTED claim (the author's `submit` syscall), blocking findings and the
|
|
1184
|
+
gate result go back to the AUTHOR — it revises and resubmits, or concludes
|
|
1185
|
+
(research-loop-buildout.md Phase B); on a plain finish, blocking findings
|
|
1186
|
+
set `panel_blocking_open` (the caller posts a DRAFT PR carrying them).
|
|
1187
|
+
The caller supplies the runner because the panel's checkouts are git work
|
|
1188
|
+
(this function owns no git).
|
|
1189
|
+
|
|
1190
|
+
With `launcher` (author syscalls, research-loop-buildout.md Phase A), a
|
|
1191
|
+
session that ends having asked to launch-and-sleep parks this climb as
|
|
1192
|
+
`author-sleep` instead of measuring: the tree is sealed via `snapshot()`,
|
|
1193
|
+
the launcher submits the jobs (caller-owned — it is compute work), and the
|
|
1194
|
+
RunParked carries the request plus the budget counts. A `submit` rides
|
|
1195
|
+
the same request: the gate (and any sibling launches) run on the sealed
|
|
1196
|
+
tree — a dispatched gate parks as a `candidate` with the `submitted`
|
|
1197
|
+
marker, an inline gate feeds back in-session. `launches_used` /
|
|
1198
|
+
`sleeps_used` are the counts so far (a wake passes them from the stage);
|
|
1199
|
+
the budgets come from the benchmark's `depth_k` / `sleep_k`.
|
|
1200
|
+
"""
|
|
1201
|
+
contract = load_contract(contract_text, config.target)
|
|
1202
|
+
bench = _benchmark(contract, config.benchmark)
|
|
1203
|
+
spec = spec or author_spec()
|
|
1204
|
+
if not spec.execution.can_execute:
|
|
1205
|
+
raise ValueError("attempt_once runs an editing role; the spec must allow execution")
|
|
1206
|
+
if not spec.scope:
|
|
1207
|
+
spec = dc_replace(spec, scope=tuple(contract.scope.allowed))
|
|
1208
|
+
|
|
1209
|
+
# the resume-entry (cumulative depth) is a COUPLED pair: it needs both a
|
|
1210
|
+
# session to resume and an instruction to resume with. Reject either alone
|
|
1211
|
+
# loudly — a lone improve_prompt would be silently discarded by the fresh-brief
|
|
1212
|
+
# branch (a depth pass turning into a fresh attempt behind the caller's back),
|
|
1213
|
+
# a lone session id would burn a promptless turn. And reject a resume on a
|
|
1214
|
+
# no-resume backend rather than let the climb end as `session-error`. The depth
|
|
1215
|
+
# loop (caller) owns WHEN to resume; this validates that choice — it never
|
|
1216
|
+
# silently falls back to a fresh brief.
|
|
1217
|
+
if bool(resume_session_id) != bool(improve_prompt):
|
|
1218
|
+
raise ValueError("resume_session_id and improve_prompt must be given together")
|
|
1219
|
+
if resume_session_id and not getattr(harness, "supports_resume", True):
|
|
1220
|
+
# same optional-attr idiom as the panel policy
|
|
1221
|
+
raise ValueError("resume_session_id given but the harness does not support resume")
|
|
1222
|
+
if resume_session_id and (
|
|
1223
|
+
task_hypothesis
|
|
1224
|
+
or lessons
|
|
1225
|
+
or recent_reports
|
|
1226
|
+
or report_archive
|
|
1227
|
+
or created
|
|
1228
|
+
or line_ref
|
|
1229
|
+
or line_memory
|
|
1230
|
+
or line_divergence
|
|
1231
|
+
or brief_baseline is not None
|
|
1232
|
+
):
|
|
1233
|
+
# a resume skips build_brief entirely (the session already carries this
|
|
1234
|
+
# context from its first pass), so these brief-only inputs would be
|
|
1235
|
+
# silently dropped — the same silent-discard hazard the coupling check
|
|
1236
|
+
# above prevents. Reject them loudly; a resume pass is lean by design.
|
|
1237
|
+
# This is the EXHAUSTIVE set of OPTIONAL brief-only params: a new one added
|
|
1238
|
+
# to build_brief must be added here too. Required params that also feed only
|
|
1239
|
+
# the brief are NOT guarded because a caller cannot omit them — `ruler`, and
|
|
1240
|
+
# the brief-only fields of the required `config` (e.g. `config.budget`) — so
|
|
1241
|
+
# they are unavoidably passed and simply ignored on a resume. `contract_text`
|
|
1242
|
+
# is NOT brief-only — scope/gate use it either way.
|
|
1243
|
+
raise ValueError(
|
|
1244
|
+
"resume_session_id resumes an existing session (no fresh brief); the brief-only "
|
|
1245
|
+
"inputs (task_hypothesis/lessons/recent_reports/report_archive/created/"
|
|
1246
|
+
"line_ref/line_memory/line_divergence/brief_baseline) have no effect on a "
|
|
1247
|
+
"resume — omit them"
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
# deferred like measure_and_decide's import (measure -> dispatch ->
|
|
1251
|
+
# orchestrator for the eval primitives).
|
|
1252
|
+
from outerloop.measure import MeasurementPending
|
|
1253
|
+
|
|
1254
|
+
run_seed = draw_run_seed() if bench.seed_env else 0
|
|
1255
|
+
siblings = [b for b in contract.benchmarks if b.name != bench.name]
|
|
1256
|
+
# ONE suite_seed for the whole climb, fixed up front so a wake reproduces it
|
|
1257
|
+
# (never a re-draw), and only when a seeded sibling could gate. It REUSES the
|
|
1258
|
+
# climbed benchmark's run_seed when there is one (as the in-job gate did),
|
|
1259
|
+
# drawing a fresh seed only for an unseeded benchmark.
|
|
1260
|
+
suite_seed = (
|
|
1261
|
+
(run_seed or draw_run_seed())
|
|
1262
|
+
if contract.scope.shared and any(b.seed_env for b in siblings)
|
|
1263
|
+
else 0
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# The baseline is NOT measured before the session — it is measured by the
|
|
1267
|
+
# GATE (`measure_and_decide`, base_sha vs candidate_sha) after the session,
|
|
1268
|
+
# so a dispatched climb has ONE park (the candidate), never a pre-session
|
|
1269
|
+
# baseline park. `brief_baseline` is the last-known score from the ledger,
|
|
1270
|
+
# for orienting the brief only ("improve from ~13.8"); it is None on a
|
|
1271
|
+
# benchmark's first run, and the gate re-measures either way.
|
|
1272
|
+
baseline: float | None = brief_baseline
|
|
1273
|
+
if resume_session_id:
|
|
1274
|
+
# a cumulative depth pass (research-loop-buildout.md, Phase 2a): resume the
|
|
1275
|
+
# prior session with the improve prompt instead of a fresh brief, so the
|
|
1276
|
+
# author builds on — and sees the measured result of — its own last pass.
|
|
1277
|
+
role_result = run_role(
|
|
1278
|
+
spec, harness, improve_prompt, workspace, resume_session_id=resume_session_id
|
|
1279
|
+
)
|
|
1280
|
+
else:
|
|
1281
|
+
task = make_task(contract, config.benchmark, baseline, hypothesis=task_hypothesis)
|
|
1282
|
+
brief = build_brief(
|
|
1283
|
+
BriefInputs(
|
|
1284
|
+
task=task,
|
|
1285
|
+
contract_text=contract_text,
|
|
1286
|
+
ruler=ruler,
|
|
1287
|
+
lessons=lessons,
|
|
1288
|
+
recent_reports=recent_reports,
|
|
1289
|
+
report_archive=report_archive,
|
|
1290
|
+
budget=config.budget,
|
|
1291
|
+
# the launch/sleep tool is advertised ONLY when it is wired
|
|
1292
|
+
# (never a tool the author cannot actually call)
|
|
1293
|
+
launch_budget=bench.depth_k if launcher is not None else 0,
|
|
1294
|
+
sleep_budget=bench.sleep_k if launcher is not None else 0,
|
|
1295
|
+
# GPU benchmarks: the compute meter the author budgets against
|
|
1296
|
+
gpu_hour_budget=(
|
|
1297
|
+
contract.budgets.gpu_hours_per_run
|
|
1298
|
+
if launcher is not None and bench.gpus
|
|
1299
|
+
else 0.0
|
|
1300
|
+
),
|
|
1301
|
+
eval_minutes_default=bench.eval_minutes or 0,
|
|
1302
|
+
line_ref=line_ref,
|
|
1303
|
+
memory=line_memory,
|
|
1304
|
+
line_divergence=line_divergence,
|
|
1305
|
+
),
|
|
1306
|
+
created=created,
|
|
1307
|
+
)
|
|
1308
|
+
role_result = run_role(spec, harness, render(brief), workspace)
|
|
1309
|
+
session = role_result.session
|
|
1310
|
+
if not role_result.ok:
|
|
1311
|
+
# the role-runner's verdict, not just the raw session flag (for a
|
|
1312
|
+
# schema-less role they coincide today, but any failure the runner
|
|
1313
|
+
# learns to report must not slip through as a clean run).
|
|
1314
|
+
# Our caps running out is a budget ending, not a malfunction; the
|
|
1315
|
+
# API refusing us is an outage — neither is the run's own failure.
|
|
1316
|
+
if outage(session):
|
|
1317
|
+
kind = "session-outage"
|
|
1318
|
+
elif budget_exhausted(session):
|
|
1319
|
+
kind = "session-budget"
|
|
1320
|
+
else:
|
|
1321
|
+
kind = "session-error"
|
|
1322
|
+
return AttemptResult(
|
|
1323
|
+
outcome=kind,
|
|
1324
|
+
baseline=baseline,
|
|
1325
|
+
session=session,
|
|
1326
|
+
note=role_result.error or session.error_detail or session.stop_reason,
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
# --- the decision loop (research-loop.md, "one syscall"; buildout A+B) ---
|
|
1330
|
+
# Each pass: honor the session's syscall request, then measure the tree.
|
|
1331
|
+
# An enabled author (the caller wired a `launcher`) may end its session —
|
|
1332
|
+
# or a resumed leg of it — having asked to LAUNCH work outside the sandbox
|
|
1333
|
+
# and SLEEP on it (seal the tree, submit through the launcher, park; the
|
|
1334
|
+
# wake re-enters THIS function through the resume-entry so the whole tail
|
|
1335
|
+
# composes unchanged), and/or to SUBMIT its candidate: the gate and any
|
|
1336
|
+
# sibling launches run on the sealed tree — a dispatched gate parks as a
|
|
1337
|
+
# `candidate` with the `submitted` marker (the wake delivers gate + panel
|
|
1338
|
+
# back to the author), an inline gate feeds back in-session and the loop
|
|
1339
|
+
# re-reads the author's next move. Over budget: wake the author ONCE with
|
|
1340
|
+
# a refusal so it can conclude honestly; a session that over-asks again
|
|
1341
|
+
# proceeds to measurement with the tree as it stands (bounded, never an
|
|
1342
|
+
# endless refuse/re-ask loop). With no launcher the feature is off and a
|
|
1343
|
+
# stray request file is just an untracked file (excluded from the diff
|
|
1344
|
+
# either way).
|
|
1345
|
+
panel_reads = 0
|
|
1346
|
+
panel_sections: list[str] = []
|
|
1347
|
+
panel_blocking_open = False
|
|
1348
|
+
panel_degraded = False
|
|
1349
|
+
candidate: float | None = baseline
|
|
1350
|
+
suite: tuple[SuiteMeasurement, ...] = ()
|
|
1351
|
+
suite_seed_ran = 0
|
|
1352
|
+
baseline_note = ""
|
|
1353
|
+
measured: tuple[str, ...] = ()
|
|
1354
|
+
refused_once = False
|
|
1355
|
+
# the gate's last negative and the sealed tree it judged: sealing the same
|
|
1356
|
+
# content again (the author concluded, or resubmitted untouched) reuses
|
|
1357
|
+
# that verdict rather than paying for a second, identical measurement
|
|
1358
|
+
# (`judged` is the wake's: the parked candidate the gate turned down)
|
|
1359
|
+
failed_gate: tuple[str, AttemptResult] | None = judged
|
|
1360
|
+
tree = tree_of or (lambda sha: sha)
|
|
1361
|
+
|
|
1362
|
+
def _resume(prompt: str) -> AttemptResult | None:
|
|
1363
|
+
"""Resume the author session with `prompt`: None on success (session
|
|
1364
|
+
advanced), else the terminal AttemptResult for the failed resume."""
|
|
1365
|
+
nonlocal session
|
|
1366
|
+
wake_result = run_role(
|
|
1367
|
+
spec, harness, prompt, workspace, resume_session_id=session.session_id
|
|
1368
|
+
)
|
|
1369
|
+
session = wake_result.session
|
|
1370
|
+
if wake_result.ok:
|
|
1371
|
+
return None
|
|
1372
|
+
if outage(session):
|
|
1373
|
+
kind = "session-outage"
|
|
1374
|
+
elif budget_exhausted(session):
|
|
1375
|
+
kind = "session-budget"
|
|
1376
|
+
else:
|
|
1377
|
+
kind = "session-error"
|
|
1378
|
+
return AttemptResult(
|
|
1379
|
+
outcome=kind,
|
|
1380
|
+
baseline=baseline,
|
|
1381
|
+
candidate=candidate,
|
|
1382
|
+
session=session,
|
|
1383
|
+
note=wake_result.error or session.error_detail or session.stop_reason,
|
|
1384
|
+
run_seed=run_seed,
|
|
1385
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1386
|
+
panel_rounds=panel_reads,
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
def _can_resume() -> bool:
|
|
1390
|
+
return bool(session.session_id) and getattr(harness, "supports_resume", True)
|
|
1391
|
+
|
|
1392
|
+
def _budgets_line() -> str:
|
|
1393
|
+
gpu = (
|
|
1394
|
+
f", {max(0.0, contract.budgets.gpu_hours_per_run - gpu_hours_used):.1f} GPU-hours"
|
|
1395
|
+
if bench.gpus
|
|
1396
|
+
else ""
|
|
1397
|
+
)
|
|
1398
|
+
return (
|
|
1399
|
+
f"Budgets: {max(0, bench.depth_k - launches_used)} launches and "
|
|
1400
|
+
f"{max(0, bench.sleep_k - sleeps_used)} sleeps{gpu} remaining."
|
|
1401
|
+
)
|
|
1402
|
+
|
|
1403
|
+
def _not_run_note(request: SyscallRequest | None) -> str:
|
|
1404
|
+
# inline gates never dispatch a submit's sibling launches (nothing
|
|
1405
|
+
# would gather them) — tell the author; their budget was not spent
|
|
1406
|
+
if request is None or not request.launches:
|
|
1407
|
+
return ""
|
|
1408
|
+
return (
|
|
1409
|
+
"Your sibling launches did NOT run (the gate completed inline); "
|
|
1410
|
+
"stage them again if still needed. "
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
while True:
|
|
1414
|
+
# the syscall request the session's last leg left, if any
|
|
1415
|
+
submitted: SyscallRequest | None = None
|
|
1416
|
+
evals_charge = 0.0 # GPU-hours this pass took for gate evals
|
|
1417
|
+
presealed = "" # a seal taken early to compare against the judged tree
|
|
1418
|
+
while launcher is not None:
|
|
1419
|
+
try:
|
|
1420
|
+
request = read_syscall_request(workspace)
|
|
1421
|
+
except SyscallError as exc:
|
|
1422
|
+
# loud, never silent: the author meant something by the file
|
|
1423
|
+
return AttemptResult(
|
|
1424
|
+
outcome="session-error",
|
|
1425
|
+
baseline=baseline,
|
|
1426
|
+
session=session,
|
|
1427
|
+
note=f"unhonorable syscall request: {exc}",
|
|
1428
|
+
)
|
|
1429
|
+
if request is None:
|
|
1430
|
+
break
|
|
1431
|
+
# suite siblings' paired evals are charged as if measured (the
|
|
1432
|
+
# suite phase decides at measurement; a budget over-charges)
|
|
1433
|
+
suite_gpus = tuple(b.gpus for b in contract.benchmarks if b.name != bench.name)
|
|
1434
|
+
# a `baseline: cached` gate with a warm cache runs ONE main eval
|
|
1435
|
+
# (the candidate); charge what will actually run (terra #178)
|
|
1436
|
+
main_evals = 2
|
|
1437
|
+
if request.submit and bench.baseline == "cached":
|
|
1438
|
+
from outerloop.measure import read_baseline_cache
|
|
1439
|
+
|
|
1440
|
+
cache_dir = getattr(measurer, "baseline_cache", None)
|
|
1441
|
+
if cache_dir is not None and read_baseline_cache(
|
|
1442
|
+
cache_dir,
|
|
1443
|
+
bench.name,
|
|
1444
|
+
base_sha,
|
|
1445
|
+
image=str(getattr(measurer, "image", "")),
|
|
1446
|
+
command=bench.command,
|
|
1447
|
+
metric=bench.metric,
|
|
1448
|
+
seed_env=bench.seed_env or "",
|
|
1449
|
+
gpus=bench.gpus,
|
|
1450
|
+
):
|
|
1451
|
+
main_evals = 1
|
|
1452
|
+
if request.submit and failed_gate is not None:
|
|
1453
|
+
# a resubmit of the tree the gate already turned down: nothing
|
|
1454
|
+
# to budget or charge — the verdict is reused below (the sleep
|
|
1455
|
+
# still counts, so unchanged resubmits stay bounded). An eval
|
|
1456
|
+
# that ERRORED is the exception: resubmitting is how the author
|
|
1457
|
+
# retries it (with more minutes, say), so that one runs. The
|
|
1458
|
+
# early seal keeps the later seal's guards: scope first, and a
|
|
1459
|
+
# failed snapshot is the eval error it always was.
|
|
1460
|
+
violations = out_of_scope(list(changed_paths()), contract)
|
|
1461
|
+
if violations:
|
|
1462
|
+
return AttemptResult(
|
|
1463
|
+
outcome="scope-violation",
|
|
1464
|
+
baseline=baseline,
|
|
1465
|
+
session=session,
|
|
1466
|
+
note=f"out-of-scope paths: {', '.join(sorted(violations)[:10])}",
|
|
1467
|
+
run_seed=run_seed,
|
|
1468
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1469
|
+
panel_rounds=panel_reads,
|
|
1470
|
+
)
|
|
1471
|
+
try:
|
|
1472
|
+
presealed = snapshot()
|
|
1473
|
+
except EvalError as exc:
|
|
1474
|
+
return AttemptResult(
|
|
1475
|
+
outcome="eval-error",
|
|
1476
|
+
baseline=baseline,
|
|
1477
|
+
session=session,
|
|
1478
|
+
note=f"snapshot: {exc}",
|
|
1479
|
+
run_seed=run_seed,
|
|
1480
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1481
|
+
panel_rounds=panel_reads,
|
|
1482
|
+
)
|
|
1483
|
+
if failed_gate[1].outcome != "eval-error" and tree(failed_gate[0]) == tree(
|
|
1484
|
+
presealed
|
|
1485
|
+
):
|
|
1486
|
+
submitted = request
|
|
1487
|
+
sleeps_used += 1
|
|
1488
|
+
break
|
|
1489
|
+
problem = syscall_budget_error(
|
|
1490
|
+
request,
|
|
1491
|
+
launches_used=launches_used,
|
|
1492
|
+
launch_budget=bench.depth_k,
|
|
1493
|
+
sleeps_used=sleeps_used,
|
|
1494
|
+
sleep_budget=bench.sleep_k,
|
|
1495
|
+
gpu_hours_used=gpu_hours_used,
|
|
1496
|
+
gpu_hour_budget=contract.budgets.gpu_hours_per_run,
|
|
1497
|
+
gpus=bench.gpus,
|
|
1498
|
+
eval_minutes_default=bench.eval_minutes or 0,
|
|
1499
|
+
suite_gpus=suite_gpus,
|
|
1500
|
+
main_evals=main_evals,
|
|
1501
|
+
)
|
|
1502
|
+
if not problem:
|
|
1503
|
+
if request.submit:
|
|
1504
|
+
# a submit rides the measurement below on the SEALED tree —
|
|
1505
|
+
# "a launch whose job is the gate" (buildout Phase B). The
|
|
1506
|
+
# sleep it rides on is counted now; sibling launches are
|
|
1507
|
+
# dispatched (and counted, and CHARGED) only if the gate
|
|
1508
|
+
# parks — an inline gate must never orphan launch jobs no
|
|
1509
|
+
# wake would gather. The gate's evals are charged here, at
|
|
1510
|
+
# the walltime THIS submit declares (else the contract's):
|
|
1511
|
+
# a resubmit without a declaration reverts to the default,
|
|
1512
|
+
# never inheriting a prior park's.
|
|
1513
|
+
submitted = request
|
|
1514
|
+
sleeps_used += 1
|
|
1515
|
+
evals_charge = evals_gpu_hours(
|
|
1516
|
+
request,
|
|
1517
|
+
gpus=bench.gpus,
|
|
1518
|
+
eval_minutes_default=bench.eval_minutes or 0,
|
|
1519
|
+
suite_gpus=suite_gpus,
|
|
1520
|
+
main_evals=main_evals,
|
|
1521
|
+
)
|
|
1522
|
+
gpu_hours_used += evals_charge
|
|
1523
|
+
if hasattr(measurer, "eval_minutes"):
|
|
1524
|
+
measurer.eval_minutes = request.eval_minutes or bench.eval_minutes or 0
|
|
1525
|
+
break
|
|
1526
|
+
# a launch park: its launches are dispatched right below, so
|
|
1527
|
+
# they are charged now
|
|
1528
|
+
gpu_hours_used += launches_gpu_hours(request, gpus=bench.gpus)
|
|
1529
|
+
# Scope BEFORE the snapshot, same invariant as the candidate
|
|
1530
|
+
# path below: an out-of-scope tree is never snapshotted OR
|
|
1531
|
+
# executed — the out-of-scope edit could be to the ruler
|
|
1532
|
+
# itself, and a launch runs code from this tree in an external
|
|
1533
|
+
# job. Same ending as the candidate path.
|
|
1534
|
+
violations = out_of_scope(list(changed_paths()), contract)
|
|
1535
|
+
if violations:
|
|
1536
|
+
return AttemptResult(
|
|
1537
|
+
outcome="scope-violation",
|
|
1538
|
+
baseline=baseline,
|
|
1539
|
+
session=session,
|
|
1540
|
+
note=(
|
|
1541
|
+
f"out-of-scope paths at launch: {', '.join(sorted(violations)[:10])}"
|
|
1542
|
+
),
|
|
1543
|
+
run_seed=run_seed,
|
|
1544
|
+
)
|
|
1545
|
+
sha = snapshot()
|
|
1546
|
+
launch_afterany = launcher(sha, request)
|
|
1547
|
+
raise RunParked(
|
|
1548
|
+
phase="author-sleep",
|
|
1549
|
+
judged=failed_gate,
|
|
1550
|
+
afterany=launch_afterany,
|
|
1551
|
+
launch_afterany=launch_afterany,
|
|
1552
|
+
base_sha=base_sha,
|
|
1553
|
+
seed=run_seed,
|
|
1554
|
+
suite_seed=suite_seed,
|
|
1555
|
+
candidate_sha=sha,
|
|
1556
|
+
session=session,
|
|
1557
|
+
syscall=request,
|
|
1558
|
+
launches_used=launches_used + len(request.launches),
|
|
1559
|
+
sleeps_used=sleeps_used + 1,
|
|
1560
|
+
gpu_hours_used=gpu_hours_used,
|
|
1561
|
+
)
|
|
1562
|
+
if refused_once or not _can_resume():
|
|
1563
|
+
log.warning("syscall request dropped after refusal (%s); measuring as-is", problem)
|
|
1564
|
+
break
|
|
1565
|
+
# the refusal burns no count (nothing was launched, nothing woke a
|
|
1566
|
+
# job); the refused_once bound is what stops a refuse/re-ask loop.
|
|
1567
|
+
refused_once = True
|
|
1568
|
+
presealed = "" # the refused author may edit the tree again
|
|
1569
|
+
failed = _resume(
|
|
1570
|
+
render_syscall_refusal(
|
|
1571
|
+
problem,
|
|
1572
|
+
launches_remaining=max(0, bench.depth_k - launches_used),
|
|
1573
|
+
sleeps_remaining=max(0, bench.sleep_k - sleeps_used),
|
|
1574
|
+
)
|
|
1575
|
+
)
|
|
1576
|
+
if failed is not None:
|
|
1577
|
+
return failed
|
|
1578
|
+
measured = tuple(changed_paths())
|
|
1579
|
+
# Scope BEFORE the snapshot: an out-of-scope tree is never snapshotted
|
|
1580
|
+
# OR measured — the out-of-scope edit could be to the ruler itself. This
|
|
1581
|
+
# early exit keeps the snapshot off a rejected tree; measure_and_decide
|
|
1582
|
+
# re-checks as the authoritative gate on every entry (including a wake,
|
|
1583
|
+
# which re-enters it directly).
|
|
1584
|
+
violations = out_of_scope(list(measured), contract)
|
|
1585
|
+
if violations:
|
|
1586
|
+
return AttemptResult(
|
|
1587
|
+
outcome="scope-violation",
|
|
1588
|
+
baseline=baseline,
|
|
1589
|
+
session=session,
|
|
1590
|
+
note=f"out-of-scope paths: {', '.join(sorted(violations)[:10])}",
|
|
1591
|
+
run_seed=run_seed,
|
|
1592
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1593
|
+
panel_rounds=panel_reads,
|
|
1594
|
+
)
|
|
1595
|
+
# Snapshot the session's current output to a committed sha the measurer
|
|
1596
|
+
# keys on; the caller (which owns git) registers its worktree and the
|
|
1597
|
+
# ref. A revision re-snapshots -> a NEW candidate_sha -> a fresh eval. A
|
|
1598
|
+
# snapshot failure is an eval failure (the session ran, the tree just
|
|
1599
|
+
# could not be captured), not a climb crash — same as a candidate eval
|
|
1600
|
+
# that raises.
|
|
1601
|
+
try:
|
|
1602
|
+
candidate_sha = presealed or snapshot()
|
|
1603
|
+
except EvalError as exc:
|
|
1604
|
+
return AttemptResult(
|
|
1605
|
+
outcome="eval-error",
|
|
1606
|
+
baseline=baseline,
|
|
1607
|
+
session=session,
|
|
1608
|
+
note=f"snapshot: {exc}",
|
|
1609
|
+
run_seed=run_seed,
|
|
1610
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1611
|
+
panel_rounds=panel_reads,
|
|
1612
|
+
)
|
|
1613
|
+
# the tree the gate already judged, sealed again: the verdict stands
|
|
1614
|
+
# when the author concluded, or resubmitted an unchanged tree after a
|
|
1615
|
+
# real negative. After an ERRORED eval only a resubmit runs it again.
|
|
1616
|
+
unchanged = (
|
|
1617
|
+
failed_gate is not None
|
|
1618
|
+
and tree(failed_gate[0]) == tree(candidate_sha)
|
|
1619
|
+
and not (failed_gate[1].outcome == "eval-error" and submitted is not None)
|
|
1620
|
+
)
|
|
1621
|
+
try:
|
|
1622
|
+
if unchanged:
|
|
1623
|
+
assert failed_gate is not None
|
|
1624
|
+
gpu_hours_used -= evals_charge # nothing ran
|
|
1625
|
+
outcome: AttemptResult | MeasureOK = failed_gate[1]
|
|
1626
|
+
elif not measured:
|
|
1627
|
+
# nothing to measure: no paths changed against base, so the
|
|
1628
|
+
# gate would compare base against itself (any benchmark,
|
|
1629
|
+
# metered or not; a SUBMIT of the unchanged tree feeds back
|
|
1630
|
+
# to the author below like any failed gate, charge refunded)
|
|
1631
|
+
gpu_hours_used -= evals_charge
|
|
1632
|
+
outcome = AttemptResult(
|
|
1633
|
+
outcome="no-improvement",
|
|
1634
|
+
baseline=baseline,
|
|
1635
|
+
session=session,
|
|
1636
|
+
note="unmeasured: the sealed tree is unchanged from base",
|
|
1637
|
+
run_seed=run_seed,
|
|
1638
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1639
|
+
panel_rounds=panel_reads,
|
|
1640
|
+
)
|
|
1641
|
+
elif (
|
|
1642
|
+
submitted is None
|
|
1643
|
+
and launcher is not None # feature off = the gate IS the measurement
|
|
1644
|
+
and bench.depth_k > 0
|
|
1645
|
+
and bench.gpus > 0
|
|
1646
|
+
and float(contract.budgets.gpu_hours_per_run or 0) > 0
|
|
1647
|
+
):
|
|
1648
|
+
# a METERED finish without a submit is panel-only, launches or
|
|
1649
|
+
# not: the author chose not to claim, and a human scientist
|
|
1650
|
+
# does not spend the full experimental budget re-verifying
|
|
1651
|
+
# their own negative before writing it in the notebook. (With
|
|
1652
|
+
# zero launches this also closes the refuse-twice bypass —
|
|
1653
|
+
# dropping a repeated bare submit must not buy the very
|
|
1654
|
+
# measurement the refusal denied.)
|
|
1655
|
+
outcome = AttemptResult(
|
|
1656
|
+
outcome="no-improvement",
|
|
1657
|
+
baseline=baseline,
|
|
1658
|
+
session=session,
|
|
1659
|
+
note=(
|
|
1660
|
+
"unmeasured finish: no submit was made, so the metered "
|
|
1661
|
+
"gate did not run (panel only)"
|
|
1662
|
+
),
|
|
1663
|
+
run_seed=run_seed,
|
|
1664
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1665
|
+
panel_rounds=panel_reads,
|
|
1666
|
+
)
|
|
1667
|
+
else:
|
|
1668
|
+
outcome = measure_and_decide(
|
|
1669
|
+
contract,
|
|
1670
|
+
bench,
|
|
1671
|
+
base_sha=base_sha,
|
|
1672
|
+
candidate_sha=candidate_sha,
|
|
1673
|
+
seed=run_seed,
|
|
1674
|
+
suite_seed=suite_seed,
|
|
1675
|
+
measured_paths=measured,
|
|
1676
|
+
measurer=measurer,
|
|
1677
|
+
min_relative_improvement=config.min_relative_improvement,
|
|
1678
|
+
)
|
|
1679
|
+
except MeasurementPending as pending:
|
|
1680
|
+
# PARK 2 (dispatched candidate/suite, after the session): the wake
|
|
1681
|
+
# reads the cached results and decides — or, on a SUBMITTED park,
|
|
1682
|
+
# delivers them back to the author. Carries the candidate sha
|
|
1683
|
+
# and the session so the caller persists the snapshot ref (drop at
|
|
1684
|
+
# the terminal state) and the resume session id. A submit's sibling
|
|
1685
|
+
# launches ride the SAME park, dispatched only HERE — once the gate
|
|
1686
|
+
# has proven dispatched — on the sealed sha (scope was checked
|
|
1687
|
+
# above, so a launch never runs out-of-scope code).
|
|
1688
|
+
launch_afterany = ""
|
|
1689
|
+
if submitted is not None and submitted.launches:
|
|
1690
|
+
assert launcher is not None # a submit only arrives through it
|
|
1691
|
+
launch_afterany = launcher(candidate_sha, submitted)
|
|
1692
|
+
launches_used += len(submitted.launches)
|
|
1693
|
+
gpu_hours_used += launches_gpu_hours(submitted, gpus=bench.gpus)
|
|
1694
|
+
raise RunParked(
|
|
1695
|
+
phase="candidate",
|
|
1696
|
+
afterany=_merge_afterany(pending.afterany(), launch_afterany),
|
|
1697
|
+
launch_afterany=launch_afterany,
|
|
1698
|
+
base_sha=base_sha,
|
|
1699
|
+
seed=run_seed,
|
|
1700
|
+
suite_seed=suite_seed,
|
|
1701
|
+
candidate_sha=candidate_sha,
|
|
1702
|
+
session=session,
|
|
1703
|
+
syscall=submitted,
|
|
1704
|
+
launches_used=launches_used,
|
|
1705
|
+
sleeps_used=sleeps_used,
|
|
1706
|
+
submitted=submitted is not None,
|
|
1707
|
+
gpu_hours_used=gpu_hours_used,
|
|
1708
|
+
eval_minutes=submitted.eval_minutes if submitted is not None else None,
|
|
1709
|
+
) from None
|
|
1710
|
+
if isinstance(outcome, AttemptResult):
|
|
1711
|
+
if (
|
|
1712
|
+
submitted is not None
|
|
1713
|
+
and outcome.outcome in ("no-improvement", "suite-regression", "eval-error")
|
|
1714
|
+
and _can_resume()
|
|
1715
|
+
and not (unchanged and sleeps_used > bench.sleep_k)
|
|
1716
|
+
):
|
|
1717
|
+
# a submitted candidate that failed the gate — including an
|
|
1718
|
+
# eval that errored — is FEEDBACK to the author: it revises and
|
|
1719
|
+
# resubmits, or concludes honestly (buildout Phase B) — never a
|
|
1720
|
+
# silent terminal. Rounds stay bounded by sleep_k.
|
|
1721
|
+
failed_gate = (candidate_sha, outcome)
|
|
1722
|
+
verdict_text = (
|
|
1723
|
+
f"{outcome.note or outcome.outcome} "
|
|
1724
|
+
f"(baseline {outcome.baseline}, candidate {outcome.candidate})."
|
|
1725
|
+
)
|
|
1726
|
+
if unchanged:
|
|
1727
|
+
lead = (
|
|
1728
|
+
"Your `submit` sealed a tree identical to the candidate the gate "
|
|
1729
|
+
"already measured, so nothing was run or paid; that verdict "
|
|
1730
|
+
f"stands: {verdict_text} "
|
|
1731
|
+
)
|
|
1732
|
+
else:
|
|
1733
|
+
lead = f"Your `submit` did NOT clear the gate: {verdict_text} "
|
|
1734
|
+
failed = _resume(
|
|
1735
|
+
f"{lead}{_not_run_note(submitted)}{_budgets_line()} "
|
|
1736
|
+
"Revise and submit again, run more "
|
|
1737
|
+
"experiments, or finish with an honest negative report."
|
|
1738
|
+
)
|
|
1739
|
+
if failed is not None:
|
|
1740
|
+
return failed
|
|
1741
|
+
continue
|
|
1742
|
+
# a terminal measurement outcome (scope-violation / eval-error /
|
|
1743
|
+
# no-improvement / suite-regression): add the session + panel
|
|
1744
|
+
# context this function owns. The baseline stays whatever the GATE
|
|
1745
|
+
# measured (None when it never got that far, e.g. scope-violation),
|
|
1746
|
+
# never overwritten with the ledger's brief number.
|
|
1747
|
+
note = outcome.note
|
|
1748
|
+
if outcome.outcome == "no-improvement" and not note:
|
|
1749
|
+
# only a BARE negative gets generic framing — a specific
|
|
1750
|
+
# reason (e.g. inside the contract's significance floor)
|
|
1751
|
+
# must survive to the record and report
|
|
1752
|
+
note = (
|
|
1753
|
+
"the revision addressing panel findings lost the improvement"
|
|
1754
|
+
if panel_reads
|
|
1755
|
+
else "a negative result reported clearly is a success"
|
|
1756
|
+
)
|
|
1757
|
+
return dc_replace(
|
|
1758
|
+
outcome,
|
|
1759
|
+
session=session,
|
|
1760
|
+
note=note,
|
|
1761
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1762
|
+
panel_rounds=panel_reads,
|
|
1763
|
+
)
|
|
1764
|
+
# credited: candidate cleared the threshold and no sibling regressed.
|
|
1765
|
+
baseline = outcome.baseline
|
|
1766
|
+
candidate = outcome.candidate
|
|
1767
|
+
suite = outcome.suite
|
|
1768
|
+
suite_seed_ran = outcome.suite_seed
|
|
1769
|
+
baseline_note = outcome.baseline_note # cached-baseline provenance, to the report
|
|
1770
|
+
|
|
1771
|
+
if panel_runner is None:
|
|
1772
|
+
break
|
|
1773
|
+
# the panel reads the CREDITED claim: improvement + suite gate passed
|
|
1774
|
+
panel_reads += 1
|
|
1775
|
+
verdict = panel_runner(baseline, candidate, session.final_text)
|
|
1776
|
+
panel_sections.append(verdict.transcript)
|
|
1777
|
+
# only the FINAL read's degradation matters: an earlier outage that a
|
|
1778
|
+
# later clean read supersedes is history, not state
|
|
1779
|
+
panel_degraded = verdict.degraded
|
|
1780
|
+
if verdict.blocking and submitted is not None and _can_resume():
|
|
1781
|
+
# blocking findings on a SUBMITTED claim go back to the AUTHOR —
|
|
1782
|
+
# it revises and resubmits, runs more experiments, or concludes
|
|
1783
|
+
# (buildout Phase B: the author drives the depth axis). The loop
|
|
1784
|
+
# then re-reads its next syscall; the revision re-measures from
|
|
1785
|
+
# scratch.
|
|
1786
|
+
failed = _resume(f"{verdict.wake_text}\n\n{_not_run_note(submitted)}{_budgets_line()}")
|
|
1787
|
+
if failed is not None:
|
|
1788
|
+
return failed
|
|
1789
|
+
continue
|
|
1790
|
+
# a plain finish (or an unresumable session): blocking findings stay
|
|
1791
|
+
# open — the caller drafts the PR for a human to triage.
|
|
1792
|
+
panel_blocking_open = bool(verdict.blocking)
|
|
1793
|
+
break
|
|
1794
|
+
|
|
1795
|
+
return AttemptResult(
|
|
1796
|
+
outcome="improved",
|
|
1797
|
+
baseline=baseline,
|
|
1798
|
+
candidate=candidate,
|
|
1799
|
+
session=session,
|
|
1800
|
+
branch=f"{config.branch_prefix}/{config.benchmark}",
|
|
1801
|
+
measured_paths=measured,
|
|
1802
|
+
candidate_sha=candidate_sha,
|
|
1803
|
+
run_seed=run_seed,
|
|
1804
|
+
suite=suite,
|
|
1805
|
+
suite_seed=suite_seed_ran,
|
|
1806
|
+
note=baseline_note,
|
|
1807
|
+
panel_transcript="\n\n".join(panel_sections),
|
|
1808
|
+
panel_rounds=panel_reads,
|
|
1809
|
+
panel_blocking_open=panel_blocking_open,
|
|
1810
|
+
panel_degraded=panel_degraded,
|
|
1811
|
+
)
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def pr_body(
|
|
1815
|
+
result: AttemptResult,
|
|
1816
|
+
config: RunConfig,
|
|
1817
|
+
redact_secrets: tuple[str, ...],
|
|
1818
|
+
display_digits: int | None = None,
|
|
1819
|
+
) -> str:
|
|
1820
|
+
"""The PR body for an improved run: results table + the agent's report.
|
|
1821
|
+
|
|
1822
|
+
Human surfaces render at the benchmark's conventional precision;
|
|
1823
|
+
full precision lives only in results/leader.json, and every
|
|
1824
|
+
comparison runs on full floats.
|
|
1825
|
+
"""
|
|
1826
|
+
from outerloop.progress import fmt_metric
|
|
1827
|
+
|
|
1828
|
+
if result.outcome != "improved" or result.baseline is None or result.candidate is None:
|
|
1829
|
+
raise ValueError("pr_body requires an improved result with both measurements")
|
|
1830
|
+
suite_lines: list[str] = []
|
|
1831
|
+
if result.suite:
|
|
1832
|
+
suite_lines = [
|
|
1833
|
+
"",
|
|
1834
|
+
"Shared code was touched, so every sibling benchmark was re-measured "
|
|
1835
|
+
"on both sides (paired seed): none regressed beyond its floor.",
|
|
1836
|
+
"",
|
|
1837
|
+
"| suite benchmark | baseline | candidate |",
|
|
1838
|
+
"| --- | --- | --- |",
|
|
1839
|
+
] + [
|
|
1840
|
+
f"| {row.name} | {fmt_metric(row.baseline, row.display_digits)} "
|
|
1841
|
+
f"| {fmt_metric(row.candidate, row.display_digits)} |"
|
|
1842
|
+
for row in result.suite
|
|
1843
|
+
]
|
|
1844
|
+
if result.panel_blocking_open:
|
|
1845
|
+
banner = [
|
|
1846
|
+
"> **Draft — the verification panel capped out with blocking "
|
|
1847
|
+
"findings still open.** They are listed under Pre-PR "
|
|
1848
|
+
"verification below; the human decides.",
|
|
1849
|
+
"",
|
|
1850
|
+
]
|
|
1851
|
+
elif result.panel_degraded:
|
|
1852
|
+
banner = [
|
|
1853
|
+
"> **Draft — the final panel read was degraded (a lens produced "
|
|
1854
|
+
"no verdict).** Not a certified pass; see Pre-PR verification "
|
|
1855
|
+
"below.",
|
|
1856
|
+
"",
|
|
1857
|
+
]
|
|
1858
|
+
else:
|
|
1859
|
+
banner = []
|
|
1860
|
+
panel_section = (
|
|
1861
|
+
["", "## Pre-PR verification", "", result.panel_transcript[:MAX_REPORT_BODY]]
|
|
1862
|
+
if result.panel_transcript
|
|
1863
|
+
else []
|
|
1864
|
+
)
|
|
1865
|
+
body = "\n".join(
|
|
1866
|
+
[
|
|
1867
|
+
*banner,
|
|
1868
|
+
f"Automated improvement attempt on `{config.benchmark}` "
|
|
1869
|
+
f"(agent `{config.agent_id}`, one hypothesis per PR).",
|
|
1870
|
+
"",
|
|
1871
|
+
"| | value |",
|
|
1872
|
+
"| --- | --- |",
|
|
1873
|
+
f"| baseline ({config.benchmark}) | {fmt_metric(result.baseline, display_digits)} |",
|
|
1874
|
+
f"| candidate | {fmt_metric(result.candidate, display_digits)} |",
|
|
1875
|
+
*suite_lines,
|
|
1876
|
+
"",
|
|
1877
|
+
"Both numbers were measured by the orchestrator re-running the "
|
|
1878
|
+
"contract's eval command — not taken from the session. CI "
|
|
1879
|
+
"re-verifies independently.",
|
|
1880
|
+
"",
|
|
1881
|
+
"## Research report",
|
|
1882
|
+
"",
|
|
1883
|
+
(
|
|
1884
|
+
"*This report came from the previous session in this line — no "
|
|
1885
|
+
"agent session ran for this attempt. It was written before the "
|
|
1886
|
+
"orchestrator measured; the table above contains the measured "
|
|
1887
|
+
"results.*"
|
|
1888
|
+
if result.session and result.session.stop_reason == "resumed"
|
|
1889
|
+
else "*Session prose, written before the orchestrator measured; "
|
|
1890
|
+
"the table above contains the measured results.*"
|
|
1891
|
+
),
|
|
1892
|
+
"",
|
|
1893
|
+
(
|
|
1894
|
+
redact(result.session.final_text, redact_secrets)[:MAX_REPORT_BODY]
|
|
1895
|
+
if result.session
|
|
1896
|
+
else ""
|
|
1897
|
+
),
|
|
1898
|
+
*panel_section,
|
|
1899
|
+
]
|
|
1900
|
+
)
|
|
1901
|
+
return redact(body, redact_secrets)
|