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.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. 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)