outerloop-science 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
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
|
+
)
|