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
outerloop/limits.py ADDED
@@ -0,0 +1,80 @@
1
+ """Effective session/job limits: contract wishes clamped by our ceilings.
2
+
3
+ Contracts live in TARGET repos and are untrusted input (contract.py's
4
+ threat model). A target may therefore SHAPE the orchestrator's spend on it
5
+ — shorter sessions, tighter job walltimes — but must never be able to
6
+ raise it: every contract value is clamped into [floor, ceiling], and the
7
+ ceilings are code on the orchestrator side, not configuration a target
8
+ can reach. Absent values fall back to the defaults the pilot has run with
9
+ all along.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ # (default, floor, ceiling) per knob. Floors keep a hostile-or-typo'd
18
+ # contract from starving runs into uselessness (a 1-turn session still
19
+ # spends money and reports nothing). CEILING == DEFAULT, deliberately:
20
+ # contracts are merged by TARGET-repo maintainers, not by us, so any
21
+ # ceiling above the default would let them raise our spend — the knobs
22
+ # shape strictly downward. Raising a target's budget is an
23
+ # orchestrator-side decision (config we control), not a contract edit.
24
+ # Raised from 60/60/90/60 on 2026-08-09 (maintainer decision): the first
25
+ # steward work order to BUILD an env burned its full 60-turn budget mid-
26
+ # work — session budgets sized for solver tweaks starve construction work.
27
+ # floor = session floor + overhead + self-deadline margin: even at the
28
+ # floors, a session must fit inside its job with the ending's runway.
29
+ # Public: the tick's AUTORESEARCH_MAX_JOB_MINUTES knob floors here too.
30
+ ATTEMPT_JOB_MINUTES_FLOOR = 40
31
+
32
+ # Public: the tick shrinks a capped job's session with the same floor the
33
+ # contract clamp uses.
34
+ SESSION_MINUTES_FLOOR = 10
35
+
36
+ _BOUNDS: dict[str, tuple[int, int, int]] = {
37
+ "session_max_turns": (120, 10, 120),
38
+ "session_minutes": (90, SESSION_MINUTES_FLOOR, 90),
39
+ "attempt_job_minutes": (120, ATTEMPT_JOB_MINUTES_FLOOR, 120),
40
+ "followup_job_minutes": (90, 20, 90),
41
+ }
42
+
43
+ # A climb job must outlive its session long enough for the orchestrator's
44
+ # own work around it (clone, two evals, publish, ending writes). Public:
45
+ # the tick's cap warning uses it as the no-runway threshold too.
46
+ ATTEMPT_OVERHEAD_MINUTES = 20
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class EffectiveLimits:
51
+ session_max_turns: int
52
+ session_minutes: int
53
+ attempt_job_minutes: int
54
+ followup_job_minutes: int
55
+
56
+
57
+ def _clamp(name: str, value: int | None) -> int:
58
+ default, floor, ceiling = _BOUNDS[name]
59
+ if value is None:
60
+ return default
61
+ return max(floor, min(int(value), ceiling))
62
+
63
+
64
+ def effective_limits(budgets: Any = None) -> EffectiveLimits:
65
+ """Resolve a contract's optional budget knobs into enforceable limits.
66
+
67
+ `budgets` is the contract's Budgets model (or None for pure defaults);
68
+ unknown/absent attributes read as None. The session is finally shrunk
69
+ to fit inside the climb job with room for the orchestrator's overhead —
70
+ a session that outlives its job ends as a kill, not a report.
71
+ """
72
+ values = {
73
+ name: _clamp(name, getattr(budgets, name, None) if budgets is not None else None)
74
+ for name in _BOUNDS
75
+ }
76
+ max_session = values["attempt_job_minutes"] - ATTEMPT_OVERHEAD_MINUTES
77
+ if values["session_minutes"] > max_session:
78
+ floor = _BOUNDS["session_minutes"][1]
79
+ values["session_minutes"] = max(floor, max_session)
80
+ return EffectiveLimits(**values)
outerloop/markers.py ADDED
@@ -0,0 +1,48 @@
1
+ """Body markers and labels the kernel writes and later recognizes.
2
+
3
+ The kernel finds its own past comments, issues, and claims by an HTML-comment
4
+ marker (`<!-- outerloop:advisory-review -->`), and routes work by labels
5
+ (`outerloop:review`). Both are WRITTEN under the new `outerloop:` prefix and
6
+ RECOGNIZED under both prefixes — a reviewer that failed to see its own earlier
7
+ `autoresearch:` comment would post a duplicate, and a target's existing
8
+ `autoresearch:steward` issues must keep routing. Reads go through `has_marker` /
9
+ `has_label`; writes and documentation use `marker` / `label_name`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+
16
+ NEW = "outerloop"
17
+ LEGACY = "autoresearch"
18
+ PREFIXES: tuple[str, ...] = (NEW, LEGACY)
19
+
20
+
21
+ def marker(kind: str) -> str:
22
+ """The marker we write for `kind`, e.g. `<!-- outerloop:followup -->`."""
23
+ return f"<!-- {NEW}:{kind} -->"
24
+
25
+
26
+ def legacy_marker(kind: str) -> str:
27
+ """The pre-rename marker for `kind`; only for finding old text of ours."""
28
+ return f"<!-- {LEGACY}:{kind} -->"
29
+
30
+
31
+ def has_marker(body: str, kind: str) -> bool:
32
+ """Does `body` carry the `kind` marker under either prefix?"""
33
+ return any(f"<!-- {prefix}:{kind} -->" in body for prefix in PREFIXES)
34
+
35
+
36
+ def label_name(kind: str) -> str:
37
+ """The label we apply and document for `kind`, e.g. `outerloop:review`."""
38
+ return f"{NEW}:{kind}"
39
+
40
+
41
+ def is_label(name: str, kind: str) -> bool:
42
+ """Is `name` the `kind` label under either prefix? Case-insensitive, as
43
+ GitHub label matching is."""
44
+ return name.casefold() in {f"{prefix}:{kind}" for prefix in PREFIXES}
45
+
46
+
47
+ def has_label(labels: Iterable[str], kind: str) -> bool:
48
+ return any(is_label(name, kind) for name in labels)
outerloop/measure.py ADDED
@@ -0,0 +1,523 @@
1
+ """Dispatched measurement: run a climb's evals as their own jobs, park, resume.
2
+
3
+ Stage B (part 1) of docs/design/dispatcher.md, built on the `dispatch`
4
+ primitive. The insight that makes a climb resumable across a process death:
5
+ the SESSION cannot be re-run on wake (it already made its edits), but the
6
+ MEASURE-AND-DECIDE phase after the candidate is committed is a pure function
7
+ of committed trees + contract — so every measurement is cacheable by its
8
+ identity and the whole phase re-runs idempotently.
9
+
10
+ A `DispatchedMeasurer` turns a set of `Measure`s (each a committed tree sha +
11
+ the contract command) into: submit every not-yet-done measure as its own
12
+ eval job (one afterany wake covers the set), PARK by raising
13
+ `MeasurementPending`; on the wake, `results()` reads every job's output from
14
+ the run directory — a completed measure returns instantly, so the resumed
15
+ phase flows straight through to the decision.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from outerloop.compute import GONE, Compute, JobSpec, is_terminal
28
+ from outerloop.dispatch import (
29
+ eval_job_spec,
30
+ read_eval_result,
31
+ write_eval_job,
32
+ )
33
+ from outerloop.orchestrator import EvalError
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ class MeasurementPending(Exception):
39
+ """Raised when one or more measures have not completed. Carries the wake
40
+ dependency (the colon-joined job ids: `afterany:<a>:<b>` in one wake job)
41
+ so the caller can park the run as `waiting` on exactly this set."""
42
+
43
+ def __init__(self, job_ids: tuple[str, ...]):
44
+ self.job_ids = job_ids
45
+ super().__init__(f"{len(job_ids)} measure(s) pending: {':'.join(job_ids)}")
46
+
47
+ def afterany(self) -> str:
48
+ """The Slurm dependency for the wake job, or "" when the pending set
49
+ carries no known ids (a transient query failure) — the caller then
50
+ relies on the tick sweep's deadline instead of an afterany wake."""
51
+ return "afterany:" + ":".join(self.job_ids) if self.job_ids else ""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Measure:
56
+ """One eval a climb needs: a COMMITTED tree sha measured by the contract
57
+ command. `name` keys its result file and must be unique within a climb
58
+ (e.g. `baseline`, `candidate`, `sib-tsp-base`). `extra_env` carries the
59
+ paired seed for suite comparisons."""
60
+
61
+ name: str
62
+ tree_sha: str
63
+ command: str
64
+ metric: str
65
+ extra_env: tuple[tuple[str, str], ...] = ()
66
+ # the measured BENCHMARK's GPUs — per measure, because a suite gate
67
+ # measures siblings that may need a different lane than the climbed one
68
+ gpus: int = 0
69
+
70
+ def env(self) -> dict[str, str]:
71
+ return dict(self.extra_env)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class SiblingSpec:
76
+ """One suite sibling's resolved measurement facts. Each sibling carries
77
+ its OWN seed variable — a sibling that resamples reads a DIFFERENT env var
78
+ than the climbed benchmark (`sib.seed_env`, not the benchmark's). The
79
+ in-job gate draws ONE `suite_seed` and hands that single value to every
80
+ sibling through its own var, so callers set every sibling's `seed` to that
81
+ one suite_seed; the pair (base and cand) always shares it (common random
82
+ numbers). The per-sibling `seed` field only exists so the var, not the
83
+ value, can differ."""
84
+
85
+ name: str
86
+ command: str
87
+ metric: str
88
+ seed_env: str = ""
89
+ seed: int = 0
90
+ gpus: int = 0
91
+
92
+ def env(self) -> tuple[tuple[str, str], ...]:
93
+ # inject only a REAL drawn seed: 0 is the ledger's "no seed recorded"
94
+ # sentinel (draw_run_seed returns 1+), so a seeded sibling with an
95
+ # unset (0) seed injects no var rather than a literal "0" that would
96
+ # read as a real seed. Guards key off truthiness, per the convention.
97
+ return ((self.seed_env, str(self.seed)),) if self.seed_env and self.seed else ()
98
+
99
+
100
+ def plan_measures(
101
+ command: str,
102
+ metric: str,
103
+ base_sha: str,
104
+ candidate_sha: str,
105
+ seed_env: str = "",
106
+ seed: int = 0,
107
+ siblings: tuple[SiblingSpec, ...] = (),
108
+ gpus: int = 0,
109
+ ) -> list[Measure]:
110
+ """The measures a climb needs, as a pure function of its committed shas
111
+ and contract facts — the same inputs a wake process reconstructs from the
112
+ run record, so the plan is identical before and after a park.
113
+
114
+ Always: `baseline` @ base_sha and `candidate` @ candidate_sha, paired on
115
+ the same `seed` (common random numbers) when the benchmark resamples.
116
+ For a suite gate, each `SiblingSpec` contributes a paired `sib-<name>-base`
117
+ @ base_sha and `sib-<name>-cand` @ candidate_sha — each on the SIBLING's
118
+ OWN seed_env and seed, exactly the 2N-paired comparison the in-job gate
119
+ computes, now dispatched.
120
+ """
121
+ # inject only a REAL drawn seed (>= 1); seed 0 is the "no seed recorded"
122
+ # sentinel, never a value to run under (see SiblingSpec.env / draw_run_seed).
123
+ env: tuple[tuple[str, str], ...] = ((seed_env, str(seed)),) if seed_env and seed else ()
124
+ plan = [
125
+ Measure("baseline", base_sha, command, metric, env, gpus=gpus),
126
+ Measure("candidate", candidate_sha, command, metric, env, gpus=gpus),
127
+ ]
128
+ for sib in siblings:
129
+ plan.append(
130
+ Measure(
131
+ f"sib-{sib.name}-base", base_sha, sib.command, sib.metric, sib.env(), gpus=sib.gpus
132
+ )
133
+ )
134
+ plan.append(
135
+ Measure(
136
+ f"sib-{sib.name}-cand",
137
+ candidate_sha,
138
+ sib.command,
139
+ sib.metric,
140
+ sib.env(),
141
+ gpus=sib.gpus,
142
+ )
143
+ )
144
+ return plan
145
+
146
+
147
+ def _baseline_cache_path(cache_dir: Path, benchmark: str, base_sha: str) -> Path:
148
+ return cache_dir / f"{benchmark}@{base_sha}.json"
149
+
150
+
151
+ def _no_result_note(job_id: str, state: str) -> str:
152
+ """The note for a job that ended with no result. A TIMEOUT is the walltime
153
+ the submit declared (or the contract's default), and the author must
154
+ hear that a slower run needs more minutes — not that measurement broke."""
155
+ if state.startswith("TIMEOUT"):
156
+ return (
157
+ f"job {job_id} hit its walltime (TIMEOUT) before producing a result; "
158
+ "a slower run needs more minutes than were declared for its eval"
159
+ )
160
+ if state and state != GONE and is_terminal(state):
161
+ return f"job {job_id} ended {state} without a result"
162
+ return "dispatched job vanished without a result"
163
+
164
+
165
+ def read_baseline_cache(
166
+ cache_dir: Path,
167
+ benchmark: str,
168
+ base_sha: str,
169
+ *,
170
+ image: str = "",
171
+ command: str = "",
172
+ metric: str = "",
173
+ seed_env: str = "",
174
+ gpus: int = 0,
175
+ ) -> dict[str, Any] | None:
176
+ """The cached base-tree measurement for (benchmark, base sha), or None.
177
+ The entry must have been measured under the SAME determinants the
178
+ candidate will be — eval image, contract command, metric key, seed
179
+ variable, GPU count: everything the measurer's own eval identity
180
+ carries except the tree sha (the key) and the seed VALUE (fresh per
181
+ attempt by design) — or it is stale (terra #178): a comparison across
182
+ determinants is not a comparison. A cache
183
+ entry is only ever written from an orchestrator-measured value (below),
184
+ never from anything an author produced."""
185
+ try:
186
+ data = json.loads(_baseline_cache_path(cache_dir, benchmark, base_sha).read_text())
187
+ except (OSError, ValueError):
188
+ return None
189
+ if not isinstance(data, dict) or "value" not in data:
190
+ return None
191
+ try:
192
+ float(data["value"])
193
+ except (TypeError, ValueError):
194
+ return None
195
+ if (
196
+ data.get("image", "") != image
197
+ or data.get("command", "") != command
198
+ or data.get("metric", "") != metric
199
+ or data.get("seed_env", "") != seed_env
200
+ or int(data.get("gpus", 0) or 0) != gpus
201
+ ):
202
+ return None
203
+ return data
204
+
205
+
206
+ def write_baseline_cache(
207
+ cache_dir: Path,
208
+ benchmark: str,
209
+ base_sha: str,
210
+ *,
211
+ value: float,
212
+ seed: int,
213
+ run_tag: str,
214
+ image: str = "",
215
+ command: str = "",
216
+ metric: str = "",
217
+ seed_env: str = "",
218
+ gpus: int = 0,
219
+ ) -> None:
220
+ """Record an orchestrator-measured baseline for every later attempt on
221
+ this base, with the determinants it was measured under. Atomic (a
222
+ unique tmp per writer + replace): two width slots measuring the same
223
+ base concurrently both land a valid file; last writer wins, and both
224
+ values are real measurements."""
225
+ import os
226
+ import tempfile
227
+
228
+ cache_dir.mkdir(parents=True, exist_ok=True)
229
+ path = _baseline_cache_path(cache_dir, benchmark, base_sha)
230
+ fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=cache_dir)
231
+ with os.fdopen(fd, "w") as fh:
232
+ json.dump(
233
+ {
234
+ "value": value,
235
+ "seed": seed,
236
+ "run": run_tag,
237
+ "base_sha": base_sha,
238
+ "image": image,
239
+ "command": command,
240
+ "metric": metric,
241
+ "seed_env": seed_env,
242
+ "gpus": gpus,
243
+ },
244
+ fh,
245
+ )
246
+ Path(tmp_name).replace(path)
247
+
248
+
249
+ @dataclass
250
+ class DispatchedMeasurer:
251
+ """Submits and reads a climb's measures as jobs on any `Compute` backend.
252
+ Stateless beyond the compute handle: all durable state is the eval job
253
+ output in the run dir, so a fresh process on wake reads exactly what the
254
+ parked one submitted. On a synchronous backend (`LocalCompute`) every job
255
+ is done by the time it is checked, so nothing parks and `results()` flows
256
+ straight through — the one measurer covers dispatched and local evals."""
257
+
258
+ compute: Compute
259
+ run_dir: Path
260
+ repo_root: Path
261
+ image: str
262
+ account: str
263
+ partition: str
264
+ eval_minutes: int
265
+ run_tag: str = "run" # disambiguates job names across runs on one account
266
+ # the GPU lane; a measure with `gpus > 0` is placed there at dispatch
267
+ # time (per MEASURE — a suite's siblings may differ from the climbed
268
+ # benchmark), everything else on account/partition
269
+ gpu_partition: str = ""
270
+ gpu_account: str = ""
271
+ # where a `baseline: cached` benchmark's base-tree measurements live
272
+ # (target-wide); None = no cache, every gate measures its own baseline
273
+ baseline_cache: Path | None = None
274
+
275
+ def _placement(self, m: Measure) -> tuple[str, str]:
276
+ if m.gpus <= 0:
277
+ return self.account, self.partition
278
+ if not self.gpu_partition:
279
+ raise ValueError(
280
+ f"measure {m.name} needs {m.gpus} GPU(s) but no GPU lane is configured "
281
+ "(set AUTORESEARCH_GPU_PARTITION)"
282
+ )
283
+ return self.gpu_account or self.account, self.gpu_partition
284
+
285
+ def _det(self, m: Measure) -> str:
286
+ # Everything a measure's RESULT depends on and that can vary across a
287
+ # PARK/RESUME (when a fresh measurer reads this run_dir): the container
288
+ # image, the measure's logical role, the code (tree_sha), and the
289
+ # contract facts it is evaluated under (command, metric, seeded env).
290
+ # A cache key missing any of these would return a value computed under
291
+ # DIFFERENT inputs — e.g. a resume that re-fetched the contract after
292
+ # its command changed, or ran under a rebuilt image, reading the stale
293
+ # pre-change result. (account / walltime don't change a result's value,
294
+ # only whether it completes.) NUL separators keep the parts unambiguous
295
+ # (`a`+`bc` != `ab`+`c`).
296
+ env = "".join(f"\0{k}={v}" for k, v in sorted(m.env().items()))
297
+ return f"{self.image}\0{m.name}\0{m.tree_sha}\0{m.command}\0{m.metric}{env}"
298
+
299
+ def _slot(self, m: Measure) -> str:
300
+ # Storage identity = the full determinant, with NOTHING truncated: the
301
+ # eval dir is the durable result cache, so any prefix could alias two
302
+ # distinct measurements into one stale read. Readable role + FULL sha
303
+ # (verbatim, debuggable) + the FULL sha1 hex of the whole determinant
304
+ # (the collision-free disambiguator for the contract facts the sha
305
+ # alone does not pin — command/metric/seed). A re-measure that changes
306
+ # the sha OR any contract input lands in a fresh dir; a resume with
307
+ # identical inputs reuses it. `m.name` stays the caller-facing key
308
+ # (results["candidate"]). A dir has 255 chars to spare (~91 used).
309
+ h = hashlib.sha1(self._det(m).encode()).hexdigest()
310
+ return f"{m.name}-{m.tree_sha}-{h}"
311
+
312
+ def _ev(self, m: Measure) -> Path:
313
+ return self.run_dir / f"eval-{self._slot(m)}"
314
+
315
+ def _job_name(self, m: Measure) -> str:
316
+ # A LIVENESS HINT, not a durable key: the cluster is asked "is this
317
+ # measure's job live" by name. Slurm caps name length, so this hash is
318
+ # necessarily bounded (16 hex = 64 bits) rather than full-width like the
319
+ # slot. That bound is safe because a job-name collision cannot cause a
320
+ # stale RESULT — results are read from the collision-free slot; the
321
+ # worst case is one measure seeing another's job as "live" and parking
322
+ # instead of dispatching, which the deadline sweep then re-checks. The
323
+ # hash covers the whole determinant (+ run_tag, which disambiguates
324
+ # jobs across runs sharing one Slurm account); the readable prefixes
325
+ # are for a human reading squeue.
326
+ h = hashlib.sha1(f"{self.run_tag}\0{self._det(m)}".encode()).hexdigest()[:16]
327
+ return f"eval-{self.run_tag[:10]}-{m.name[:12]}-{h}"
328
+
329
+ def _done(self, m: Measure) -> bool:
330
+ return (self._ev(m) / "exit-code").exists()
331
+
332
+ def _ended_without_result(self, m: Measure) -> str:
333
+ """Why a dispatched job produced no result; a TIMEOUT means the eval
334
+ needs more walltime."""
335
+ job_id = self._marker(m)
336
+ try:
337
+ state = self.compute.status(job_id) if job_id.isdigit() else ""
338
+ except Exception:
339
+ state = ""
340
+ return _no_result_note(job_id, state)
341
+
342
+ def _marker(self, m: Measure) -> str:
343
+ f = self._ev(m) / "submitted"
344
+ return f.read_text().strip() if f.exists() else ""
345
+
346
+ def _dispatch(self, m: Measure) -> str:
347
+ script = write_eval_job(
348
+ self.run_dir,
349
+ self._slot(m),
350
+ repo_root=self.repo_root,
351
+ snapshot_sha=m.tree_sha,
352
+ command=m.command,
353
+ image=self.image,
354
+ extra_env=m.env(),
355
+ gpus=m.gpus,
356
+ )
357
+ account, partition = self._placement(m)
358
+ spec: JobSpec = eval_job_spec(
359
+ script,
360
+ job_name=self._job_name(m),
361
+ account=account,
362
+ partition=partition,
363
+ eval_minutes=self.eval_minutes,
364
+ gpus=m.gpus,
365
+ )
366
+ job_id = self.compute.submit(spec)
367
+ (self._ev(m) / "submitted").write_text(job_id)
368
+ log.info("dispatched measure %s (sha %s) as job %s", m.name, m.tree_sha[:12], job_id)
369
+ return job_id
370
+
371
+ def results(self, measures: list[Measure]) -> dict[str, float]:
372
+ """Every measure's value, or PARK / FAIL. The CLUSTER is the source of
373
+ truth for liveness (a job named for the measure, found by squeue),
374
+ never just the local marker — so a submitter that died before writing
375
+ its id cannot cause a duplicate submit. Per not-yet-done measure:
376
+ * a live job with this name -> park on its id (authoritative);
377
+ * a marker but NO live job -> the job ran and vanished without a
378
+ result (SIGKILL / node death / GONE) -> EvalError, never resubmit;
379
+ * no live job and no marker -> never dispatched -> dispatch;
380
+ * squeue unavailable -> park (marker id if any) rather than risk a
381
+ duplicate; the wake set may be empty -> the sweep deadline retries.
382
+ """
383
+ pending: list[str] = []
384
+ blind = False
385
+ for m in measures:
386
+ if self._done(m):
387
+ continue
388
+ try:
389
+ live = self.compute.job_id_for_name(self._job_name(m))
390
+ except Exception:
391
+ # cannot tell if it is running: do NOT dispatch (would risk a
392
+ # duplicate) and do NOT declare it dead — re-check next wake
393
+ marker = self._marker(m)
394
+ if marker:
395
+ pending.append(marker)
396
+ else:
397
+ blind = True
398
+ continue
399
+ if live:
400
+ pending.append(live) # queued or running — the real job id
401
+ continue
402
+ if self._marker(m):
403
+ # was dispatched, not live, no result -> died before result
404
+ raise EvalError(f"measure {m.name}: {self._ended_without_result(m)}")
405
+ # No marker, not live, no result -> never dispatched -> dispatch.
406
+ # RESIDUAL (bounded, accepted): if a prior process died in the
407
+ # microsecond gap between sbatch returning and _dispatch writing
408
+ # the marker, AND that orphaned job then ran and died without a
409
+ # result, its name is gone from squeue and this redispatches once
410
+ # (never loops — the redispatch writes a marker). Cost is one
411
+ # wasted eval in a triple-failure conjunction; fully closing it
412
+ # needs sacct-by-name over job history, not worth that surface.
413
+ job_id = self._dispatch(m)
414
+ if self._done(m):
415
+ continue # a synchronous compute finished the job inside submit
416
+ try:
417
+ state = self.compute.status(job_id)
418
+ except Exception:
419
+ state = "" # status unknown right after submit is normal; park
420
+ if state and is_terminal(state):
421
+ # the job already ENDED without writing a result (a local
422
+ # timeout, an instant cluster failure): parking would wait on
423
+ # a job that will never deliver — fail like a vanished job
424
+ raise EvalError(f"measure {m.name}: {_no_result_note(job_id, state)}")
425
+ pending.append(job_id)
426
+ if pending or blind:
427
+ raise MeasurementPending(tuple(pending))
428
+ out: dict[str, float] = {}
429
+ for m in measures:
430
+ try:
431
+ out[m.name] = read_eval_result(self.run_dir, self._slot(m), m.metric)
432
+ except EvalError as exc:
433
+ raise EvalError(self._explain_signal_exit(m, str(exc))) from None
434
+ return out
435
+
436
+ def _explain_signal_exit(self, m: Measure, message: str) -> str:
437
+ """Exit 143 is the job script's record of a TERM: Slurm sends one at
438
+ the walltime, on scancel, and on preemption alike, so only its end
439
+ state for the job says which. The walltime case is the author's to
440
+ fix (more minutes); the others are the cluster's."""
441
+ try:
442
+ code = (self._ev(m) / "exit-code").read_text().strip()
443
+ except OSError:
444
+ return message
445
+ if code != "143":
446
+ return message
447
+ job_id = self._marker(m)
448
+ try:
449
+ state = self.compute.status(job_id) if job_id.isdigit() else ""
450
+ except Exception:
451
+ state = ""
452
+ if state.startswith("TIMEOUT"):
453
+ why = (
454
+ "was killed at its walltime (exit 143); a slower run needs more minutes "
455
+ "than were declared for its eval"
456
+ )
457
+ elif state.startswith("CANCELLED"):
458
+ why = "was cancelled (exit 143)"
459
+ elif state.startswith("PREEMPTED"):
460
+ why = "was preempted (exit 143); nothing about the tree is known"
461
+ else:
462
+ why = "was killed by a signal (exit 143)"
463
+ return f"measure {m.name} {why}; {message}"
464
+
465
+
466
+ @dataclass(frozen=True)
467
+ class DispatchSettings:
468
+ """The cluster coordinates a dispatched measurer needs, grouped so the
469
+ composition root (the climb CLI) reads them ONCE from its args/env and the
470
+ climb just carries them. The per-run pieces (run dir, snapshot repo, the
471
+ benchmark's eval hint, the run tag) are bound at build time by `measurer`,
472
+ so this stays a static description of WHERE to dispatch, not a live handle
473
+ to one run."""
474
+
475
+ compute: Compute
476
+ image: str
477
+ account: str
478
+ partition: str
479
+ # the GPU lane: where jobs of a benchmark with `gpus > 0` go. Empty
480
+ # gpu_partition = this deployment cannot place GPU jobs (the tick refuses
481
+ # to launch such benchmarks); empty gpu_account = same account as CPU jobs.
482
+ gpu_partition: str = ""
483
+ gpu_account: str = ""
484
+
485
+ def placement(self, gpus: int) -> tuple[str, str]:
486
+ """(account, partition) for a job needing `gpus` GPUs. Raises when a
487
+ GPU job has no lane — a queue that can never run is worse than a
488
+ loud refusal. Local compute has no lanes: jobs are subprocesses on
489
+ whatever GPUs the machine has, so placement is empty by design."""
490
+ from outerloop.compute import local_mode
491
+
492
+ if gpus <= 0 or local_mode():
493
+ return self.account, self.partition
494
+ if not self.gpu_partition:
495
+ raise ValueError(
496
+ f"benchmark needs {gpus} GPU(s) but no GPU lane is configured "
497
+ "(set AUTORESEARCH_GPU_PARTITION)"
498
+ )
499
+ return self.gpu_account or self.account, self.gpu_partition
500
+
501
+ def measurer(
502
+ self, run_dir: Path, repo_root: Path, eval_minutes: int, run_tag: str
503
+ ) -> DispatchedMeasurer:
504
+ """Bind these coordinates to one run's dispatched measurer. `repo_root`
505
+ is the workspace whose `refs/dispatch/*` snapshots the eval jobs check
506
+ out; `eval_minutes` is the benchmark's contract hint (clamped in the
507
+ job spec). GPUs are per MEASURE (Measure.gpus): the measurer carries
508
+ the lane and places each measure when it dispatches it."""
509
+ return DispatchedMeasurer(
510
+ compute=self.compute,
511
+ run_dir=run_dir,
512
+ repo_root=repo_root,
513
+ image=self.image,
514
+ account=self.account,
515
+ partition=self.partition,
516
+ eval_minutes=eval_minutes,
517
+ run_tag=run_tag,
518
+ gpu_partition=self.gpu_partition,
519
+ gpu_account=self.gpu_account,
520
+ # target-wide, beside the run dirs: every attempt on one base
521
+ # shares its cached baseline measurement (Benchmark.baseline)
522
+ baseline_cache=run_dir.parent / "baselines",
523
+ )