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/dispatch.py ADDED
@@ -0,0 +1,586 @@
1
+ """The dispatched-eval primitive: an eval as its own Slurm job.
2
+
3
+ This module owns the three pieces docs/design/dispatcher.md specifies,
4
+ and nothing else:
5
+
6
+ * snapshot_tree — a retained snapshot of a dirty workspace, taken
7
+ against a TEMPORARY unique index seeded from the base commit (working
8
+ index untouched, tracked-vs-ignored parity with the drift fingerprint),
9
+ kept reachable under a unique ref so gc cannot prune it before a queued
10
+ job runs. Release with drop_snapshot after the eval is read.
11
+ * write_eval_job — the orchestrator-authored job script: materialize the
12
+ snapshot by CHECKOUT (git worktree add, then delete the .git gitfile so
13
+ the jail sees a plain faithful directory — checkout keeps .gitattributes
14
+ and applies no export processing), run the contract command under the
15
+ SAME jail as the in-job evaluator, capture stdout OUTSIDE the containment
16
+ into the run directory (the jailed process never sees the run dir).
17
+ * read_eval_result — the wake side: exit code + the same last-JSON-line
18
+ metric contract the in-job evaluator parses.
19
+
20
+ Dispatch is chosen per benchmark: `eval_minutes` is a contract HINT with
21
+ its own code-side ceiling; under the in-job threshold nothing here runs.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import json
28
+ import logging
29
+ import math
30
+ import re
31
+ import shlex
32
+ import shutil
33
+ import subprocess
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from uuid import uuid4
37
+
38
+ from outerloop.compute import JobSpec
39
+ from outerloop.github import SAFE_GIT_FLAGS, GitError, Workspace, ensure_regular_git_dir
40
+ from outerloop.orchestrator import EvalError, _metric_from_output, managed_eval_env
41
+
42
+ log = logging.getLogger(__name__)
43
+
44
+ # extra_env keys are exported unquoted into the job script; keep them to a
45
+ # shell identifier shape (the values ARE shlex-quoted).
46
+ _SHELL_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
47
+
48
+ # In-job evals must fit the climb job's overhead runway (a few minutes each,
49
+ # see limits.ATTEMPT_OVERHEAD_MINUTES); anything longer is dispatched.
50
+ IN_JOB_EVAL_MINUTES = 5
51
+ # Ceiling for the contract's eval_minutes hint: OUR spend cap, not the
52
+ # target's to raise (same grammar as every budget ceiling).
53
+ # A BACKSTOP, not a policy knob: the eval walltime is the contract's hint
54
+ # or the author's own declaration at submit, and spend is metered in
55
+ # GPU-hours against gpu_hours_per_run (syscall.budget_error). This only
56
+ # caps a runaway value.
57
+ EVAL_JOB_MINUTES_CEILING = 1440
58
+ # Slack added to the job walltime beyond the eval itself: worktree
59
+ # materialization + venv build from the lockfile on node-local scratch.
60
+ EVAL_JOB_SETUP_MINUTES = 10
61
+ # a GPU eval trains: data loading + torch.compile workers need real cores
62
+ # and host RAM; sized per GPU so an 8-GPU eval scales the same way
63
+ EVAL_CPUS_PER_GPU = 8
64
+ EVAL_MEM_GB_PER_GPU = 64
65
+
66
+
67
+ def effective_eval_minutes(eval_minutes: int | None) -> int:
68
+ """The contract hint clamped into [1, ceiling]; None means in-job."""
69
+ if eval_minutes is None:
70
+ return 0
71
+ return max(1, min(int(eval_minutes), EVAL_JOB_MINUTES_CEILING))
72
+
73
+
74
+ def should_dispatch(eval_minutes: int | None) -> bool:
75
+ return effective_eval_minutes(eval_minutes) > IN_JOB_EVAL_MINUTES
76
+
77
+
78
+ def afterany_ids(afterany: str) -> list[str]:
79
+ """The job ids inside an ``afterany:<id>:<id>...`` dependency string, or
80
+ ``[]`` when there are none (a blind park carries an empty dependency)."""
81
+ return afterany.split(":")[1:] if afterany else []
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Snapshot:
86
+ """A retained snapshot of a dirty workspace. `ref` keeps the commit
87
+ reachable so gc cannot prune it while a queued job still needs it;
88
+ `tree` is the drift fingerprint. Release the ref via `drop_snapshot`
89
+ once the eval has been read."""
90
+
91
+ commit: str
92
+ tree: str
93
+ ref: str
94
+
95
+
96
+ def snapshot_tree(
97
+ ws: Workspace, base_sha: str, exclude: tuple[str, ...] = (), force: tuple[str, ...] = ()
98
+ ) -> Snapshot:
99
+ """Snapshot the workspace's current CONTENT as a commit parented on
100
+ `base_sha`, without touching the working index, and retain it under a
101
+ unique ref so gc cannot prune it before a queued job materializes it.
102
+ `exclude` drops those paths (files or whole directories) from the sealed
103
+ tree — research lines use it to keep the agent's memory out of every
104
+ MEASURABLE seal (gate candidates, launches) while the notebook seal
105
+ keeps it (docs/design/research-lines.md). `force` adds those paths even
106
+ when the target's ignore rules match them — the notebook seal uses it so
107
+ a .gitignore entry cannot silently discard session memory; callers pass
108
+ only paths that exist.
109
+ """
110
+ # the snapshot writes an index, a tree, a commit, and a ref into this
111
+ # repository: a session-reshaped .git is refused first, like every other
112
+ # kernel git call on a workspace
113
+ ensure_regular_git_dir(Path(ws.root))
114
+ # Unique per snapshot: two snapshots against the SAME base run
115
+ # concurrently (the design's paired baseline/candidate fan-out) and must
116
+ # not collide on one index file.
117
+ token = uuid4().hex
118
+ index = Path(ws.root) / ".git" / f"dispatch-index-{token}"
119
+ ref = f"refs/dispatch/{token}"
120
+ env = {"GIT_INDEX_FILE": str(index)}
121
+ # The workspace is AGENT-WRITTEN: every git call carries SAFE_GIT_FLAGS
122
+ # (hooks/fsmonitor/ssh/credential neutralizers), drops HOME (no
123
+ # ~/.gitconfig read), and disables agent-defined filters via
124
+ # _filter_neutral_env (GIT_CONFIG_* env, which handles driver names with
125
+ # '=' or dots that `-c` cannot). The neutralizers ride EVERY index op
126
+ # because the clean filter runs lazily at write-tree, not add.
127
+ base_git = ["git", "-C", str(ws.root), *SAFE_GIT_FLAGS]
128
+ try:
129
+ # Neutralize agent-defined filters via GIT_CONFIG_KEY_n/VALUE_n env
130
+ # injection, NOT `-c`: a driver name containing '=' (a legal git
131
+ # subsection char) defeats `-c filter.<driver>.clean=cat` because git
132
+ # splits `-c` at the FIRST '='. The env form takes key and value as
133
+ # SEPARATE strings, immune to that — and to dots. See _filter_neutral_env.
134
+ neutral = _filter_neutral_env(base_git, env)
135
+ run_env = {**env, **neutral}
136
+
137
+ def run(args: list[str], timeout: int) -> str:
138
+ return subprocess.run(
139
+ args,
140
+ env=_git_env(run_env),
141
+ check=True,
142
+ capture_output=True,
143
+ text=True,
144
+ timeout=timeout,
145
+ ).stdout.strip()
146
+
147
+ git = base_git # neutralizers now ride the ENV, not argv
148
+
149
+ # seed from the base so ignore rules apply as they do to a populated
150
+ # index (a fresh empty index would drop tracked-but-ignored files)
151
+ run([*git, "read-tree", base_sha], 60)
152
+ run([*git, "add", "-A"], 120)
153
+ if force:
154
+ run([*git, "add", "-f", "--", *force], 60)
155
+ if exclude:
156
+ run([*git, "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", *exclude], 60)
157
+ # .gitattributes are KEPT: the job materializes the tree by CHECKOUT
158
+ # (git worktree), which reproduces content faithfully — including
159
+ # .gitattributes — and does NOT apply export-ignore/export-subst
160
+ # (those are `git archive` only). So fidelity and integrity hold
161
+ # together; the filter side is already neutralized via GIT_CONFIG env.
162
+ tree = run([*git, "write-tree"], 60)
163
+ commit = run(
164
+ [
165
+ *git,
166
+ "-c",
167
+ "user.name=dispatch",
168
+ "-c",
169
+ "user.email=dispatch@localhost",
170
+ "commit-tree",
171
+ tree,
172
+ "-p",
173
+ base_sha,
174
+ "-m",
175
+ "dispatch snapshot",
176
+ ],
177
+ 60,
178
+ )
179
+ # retain: an unreachable commit-tree object can be pruned by gc while
180
+ # the eval job is still queued
181
+ run([*base_git, "update-ref", ref, commit], 30)
182
+ return Snapshot(commit=commit, tree=tree, ref=ref)
183
+ except subprocess.CalledProcessError as exc:
184
+ raise EvalError(f"snapshot failed: {exc.stderr.strip()[:300]}") from exc
185
+ except subprocess.TimeoutExpired as exc:
186
+ raise EvalError(f"snapshot timed out: {exc}") from exc
187
+ finally:
188
+ # the index and any .lock a timed-out git left beside it (unique per
189
+ # token, so it never blocks a future snapshot — just tidiness)
190
+ index.unlink(missing_ok=True)
191
+ index.with_name(index.name + ".lock").unlink(missing_ok=True)
192
+
193
+
194
+ def _filter_neutral_env(base_git: list[str], env: dict[str, str]) -> dict[str, str]:
195
+ """GIT_CONFIG_* env that overrides every configured filter driver to a
196
+ passthrough and disables attribute files. Robust where `-c` is not:
197
+ keys/values are separate env vars, so a driver name with '=' or dots is
198
+ handled correctly. Used by BOTH the snapshot (index ops) and the job
199
+ script's archive — the repo config and .gitattributes are agent-written
200
+ on both paths."""
201
+ pairs: list[tuple[str, str]] = [("core.attributesFile", "/dev/null")]
202
+ listing = subprocess.run(
203
+ [*base_git, "config", "-z", "--get-regexp", r"^filter\..*\.(clean|smudge|process)$"],
204
+ env=_git_env(env),
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=30,
208
+ )
209
+ # -z: NUL-separated records, each "key\nvalue" — so a value containing a
210
+ # newline can never masquerade as a second record.
211
+ for record in listing.stdout.split("\0"):
212
+ key = record.split("\n", 1)[0]
213
+ if not key.startswith("filter.") or "." not in key[len("filter.") :]:
214
+ continue
215
+ driver = key[len("filter.") : key.rindex(".")]
216
+ pairs += [
217
+ (f"filter.{driver}.clean", "cat"),
218
+ (f"filter.{driver}.smudge", "cat"),
219
+ (f"filter.{driver}.process", ""),
220
+ ]
221
+ out = {"GIT_CONFIG_COUNT": str(len(pairs))}
222
+ for i, (k, v) in enumerate(pairs):
223
+ out[f"GIT_CONFIG_KEY_{i}"] = k
224
+ out[f"GIT_CONFIG_VALUE_{i}"] = v
225
+ return out
226
+
227
+
228
+ def drop_snapshot(ws: Workspace, snapshot: Snapshot) -> None:
229
+ """Release the retaining ref (the commit becomes gc-eligible again). Called
230
+ once the eval result has been read. Best-effort — it never RAISES, so a
231
+ caller's ending sequence cannot hinge on it — but not SILENT: a ref that
232
+ fails to drop keeps its commit alive forever, so the failure is logged.
233
+ A session-reshaped .git is not written to at all (a symlinked refs dir
234
+ would carry the ref deletion outside the workspace): logged and left."""
235
+ try:
236
+ ensure_regular_git_dir(Path(ws.root))
237
+ except GitError as exc:
238
+ log.warning("snapshot ref %s not dropped: %s", snapshot.ref, exc)
239
+ return
240
+ try:
241
+ result = subprocess.run(
242
+ ["git", "-C", str(ws.root), *SAFE_GIT_FLAGS, "update-ref", "-d", snapshot.ref],
243
+ env=_git_env({}),
244
+ capture_output=True,
245
+ text=True,
246
+ timeout=30,
247
+ check=False,
248
+ )
249
+ if result.returncode != 0:
250
+ log.warning("snapshot ref drop failed for %s: %s", snapshot.ref, result.stderr[:200])
251
+ except Exception as exc:
252
+ log.warning("snapshot ref drop errored for %s: %s", snapshot.ref, exc)
253
+
254
+
255
+ def _git_env(extra: dict[str, str]) -> dict[str, str]:
256
+ import os
257
+
258
+ # No HOME, and global/system config pinned to /dev/null: git must honor
259
+ # ONLY the repo's local config while operating on an agent-written tree.
260
+ # A globally-configured filter driver (in the user's ~/.gitconfig or the
261
+ # system config) could otherwise be SELECTED by an agent-authored
262
+ # .gitattributes and execute on the host — dropping HOME alone misses the
263
+ # system config and XDG paths.
264
+ env = {k: os.environ[k] for k in ("PATH", "LANG") if k in os.environ}
265
+ env["GIT_CONFIG_GLOBAL"] = "/dev/null"
266
+ env["GIT_CONFIG_SYSTEM"] = "/dev/null"
267
+ env.update(extra)
268
+ return env
269
+
270
+
271
+ def write_eval_job(
272
+ run_dir: Path,
273
+ name: str,
274
+ *,
275
+ repo_root: Path,
276
+ snapshot_sha: str,
277
+ command: str,
278
+ image: str,
279
+ apptainer_binary: str = "apptainer",
280
+ extra_env: dict[str, str] | None = None,
281
+ artifacts: tuple[str, ...] = (),
282
+ artifact_max_bytes: int = 0,
283
+ gpus: int = 0,
284
+ ) -> Path:
285
+ """Write the orchestrator-authored job script for one dispatched eval.
286
+
287
+ `gpus` > 0 adds `--nv` to the jail so the job's allocated GPUs (the
288
+ JobSpec requests them) are visible inside the container; nothing else
289
+ about the containment changes.
290
+
291
+ Trust layout, matching the in-job evaluator exactly:
292
+ * the contract COMMAND goes into its own file, read back with
293
+ `sh -c "$(cat ...)"` INSIDE the jail — it never crosses sbatch or
294
+ shell quoting, and it executes only inside apptainer
295
+ --containall/--cleanenv with worktree-only binds;
296
+ * stdout/stderr are captured OUTSIDE the containment into the run
297
+ directory (the jailed process never sees the run dir);
298
+ * env is the evaluator's allowlist shape: uv cache + private venv on
299
+ node-local scratch, plus the call site's extra_env (paired seeds)
300
+ exported as APPTAINERENV_*.
301
+ Returns the script path; the caller submits it via JobSpec(script=...).
302
+
303
+ With `artifacts` (author-syscall launches, research-loop-buildout.md
304
+ Phase A), each declared repo-relative FILE the jailed command produced is
305
+ copied out of the throwaway tree into `<job dir>/artifacts/` — outside the
306
+ jail, after the command, size-capped at `artifact_max_bytes` — with every
307
+ skip recorded in `artifacts.log`. Callers validate the paths (relative, no
308
+ traversal) before passing them; this writer additionally quotes them so
309
+ they cross the script boundary inert.
310
+ """
311
+ ev = run_dir / f"eval-{name}"
312
+ ev.mkdir(parents=True, exist_ok=True)
313
+ # a resubmitted eval must never be read as its predecessor: every prior
314
+ # artifact — including a leftover extracted tree — goes before submission
315
+ for stale in ("exit-code", "stdout", "stderr", "setup.log", "submitted", "artifacts.log"):
316
+ (ev / stale).unlink(missing_ok=True)
317
+ shutil.rmtree(ev / "tree", ignore_errors=True)
318
+ shutil.rmtree(ev / "artifacts", ignore_errors=True)
319
+ (ev / "command.txt").write_text(command)
320
+ # extra_env matches the in-job evaluator's contract: managed keys (HOME,
321
+ # UV_*, PATH...) are DROPPED, never allowed to override the isolation, and
322
+ # keys must be shell-identifier shaped (they are exported unquoted).
323
+ injected = {
324
+ k: v
325
+ for k, v in (extra_env or {}).items()
326
+ if _SHELL_IDENT.match(k) and not managed_eval_env(k)
327
+ }
328
+ safe_git = " ".join(shlex.quote(f) for f in SAFE_GIT_FLAGS)
329
+ # the checkout runs on the agent-written repo too: same filter neutralizers
330
+ # as the snapshot, injected as GIT_CONFIG_* env (robust to '=' in a driver
331
+ # name, unlike -c) so a smudge filter cannot execute during checkout
332
+ neutral = _filter_neutral_env(["git", "-C", str(repo_root), *SAFE_GIT_FLAGS], {})
333
+ lines = [
334
+ "#!/bin/sh",
335
+ "set -u",
336
+ f"EV={shlex.quote(str(ev))}",
337
+ f"REPO={shlex.quote(str(repo_root))}",
338
+ # the extracted tree lives on NODE-LOCAL scratch, not the shared run
339
+ # dir: it dies with the job (nothing to reap on the shared FS), and
340
+ # the wake needs only stdout/exit-code, which stay in $EV
341
+ 'SCRATCH="${SLURM_TMPDIR:-${TMPDIR:-/tmp}}/dispatch-eval-$$"',
342
+ 'TREE="$SCRATCH/tree"',
343
+ 'mkdir -p "$SCRATCH/cache" "$SCRATCH/home" "$SCRATCH/work" "$TREE"',
344
+ # trap FIRST, before anything that can exit, so $SCRATCH never leaks.
345
+ # prune reaps the stale worktree admin entry in $REPO/.git/worktrees
346
+ # left when we deleted $TREE/.git (worktree remove can't run without it)
347
+ 'cleanup() { rm -rf "$SCRATCH"; '
348
+ f'git -C "$REPO" {safe_git} worktree prune >/dev/null 2>&1 || true; }}',
349
+ "trap 'cleanup' EXIT",
350
+ "trap 'echo 143 > \"$EV/exit-code\"; cleanup; trap - EXIT; exit 0' TERM INT HUP",
351
+ # the command file must exist and be non-empty, or sh -c "" would
352
+ # exit 0 with empty output and read as a clean eval that measured
353
+ # nothing
354
+ '[ -s "$EV/command.txt" ] || { echo 96 > "$EV/exit-code"; exit 0; }',
355
+ # git on the node runs with the job user's HOME present, so pin global
356
+ # AND system config to /dev/null: a globally-configured filter driver
357
+ # could otherwise be selected by the agent-authored .gitattributes and
358
+ # execute here, outside the jail. Local repo config still applies and
359
+ # is neutralized by the GIT_CONFIG_KEY_* overrides below.
360
+ "export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null",
361
+ ]
362
+ for k, v in neutral.items():
363
+ lines.append(f"export {k}={shlex.quote(v)}")
364
+ lines += [
365
+ # Materialize the snapshot by CHECKOUT, not `git archive`: a checkout
366
+ # reproduces content faithfully — INCLUDING .gitattributes — and does
367
+ # NOT apply export-ignore/export-subst (archive-only), so the measured
368
+ # tree equals both the workspace (fidelity) and the fingerprint
369
+ # (integrity). Smudge filters during checkout are neutralized by the
370
+ # GIT_CONFIG_* env above. The worktree's .git gitfile (which would
371
+ # point into $REPO/.git/worktrees, unbound in the jail) is DELETED
372
+ # after checkout, leaving a plain directory; the stale admin entry is
373
+ # pruned on cleanup.
374
+ f'if git -C "$REPO" {safe_git} worktree add --detach "$TREE" '
375
+ f'{shlex.quote(snapshot_sha)} >> "$EV/setup.log" 2>&1; then '
376
+ 'rm -f "$TREE/.git"; ' # plain dir now — nothing points back into $REPO
377
+ 'else echo 97 > "$EV/exit-code"; exit 0; fi',
378
+ 'export UV_CACHE_DIR="$SCRATCH/cache" UV_LINK_MODE=copy '
379
+ 'UV_PROJECT_ENVIRONMENT="$SCRATCH/cache/venv"',
380
+ 'export APPTAINERENV_UV_CACHE_DIR="$UV_CACHE_DIR" '
381
+ "APPTAINERENV_UV_LINK_MODE=copy "
382
+ 'APPTAINERENV_UV_PROJECT_ENVIRONMENT="$UV_PROJECT_ENVIRONMENT"',
383
+ ]
384
+ for key, value in injected.items():
385
+ lines.append(f"export {key}={shlex.quote(value)} APPTAINERENV_{key}={shlex.quote(value)}")
386
+ if image:
387
+ lines += [
388
+ # the jail: identical flags to SubprocessEvaluator._run; stdout is
389
+ # redirected OUTSIDE apptainer, so the result lands in the run dir
390
+ # without the jailed process ever seeing it
391
+ f"{shlex.quote(apptainer_binary)} exec --containall --cleanenv "
392
+ + ("--nv " if gpus > 0 else "")
393
+ + '--bind "$TREE:$TREE" --home "$SCRATCH/home:$SCRATCH/home" '
394
+ '--bind "$SCRATCH/cache:$SCRATCH/cache" --pwd "$TREE" '
395
+ # /tmp and /var/tmp inside the jail live on node-local scratch,
396
+ # not apptainer's small tmpfs: a command that writes compile caches
397
+ # or checkpoints to /tmp (any training script that was not told
398
+ # otherwise) must not die on a temp-disk limit
399
+ '--workdir "$SCRATCH/work" '
400
+ f"{shlex.quote(image)} "
401
+ 'sh -c "$(cat "$EV/command.txt")" '
402
+ '> "$EV/stdout" 2> "$EV/stderr"',
403
+ 'echo $? > "$EV/exit-code"',
404
+ ]
405
+ else:
406
+ # UNCONTAINED (dev/tests, no image): run directly in the throwaway
407
+ # tree under `env -i` with the same allowlist shape the uncontained
408
+ # evaluator used — the submitting process's env (which can hold live
409
+ # keys) must never reach the agent-authored command
410
+ bare_env = " ".join(
411
+ [
412
+ 'HOME="$SCRATCH/home"',
413
+ 'PATH="$PATH"',
414
+ 'LANG="${LANG:-C.UTF-8}"',
415
+ 'TMPDIR="$SCRATCH"',
416
+ 'UV_CACHE_DIR="$UV_CACHE_DIR"',
417
+ "UV_LINK_MODE=copy",
418
+ 'UV_PROJECT_ENVIRONMENT="$UV_PROJECT_ENVIRONMENT"',
419
+ ]
420
+ + [f"{key}={shlex.quote(value)}" for key, value in injected.items()]
421
+ )
422
+ lines += [
423
+ f'cd "$TREE" && env -i {bare_env} '
424
+ 'sh -c "$(cat "$EV/command.txt")" '
425
+ '> "$EV/stdout" 2> "$EV/stderr"',
426
+ 'echo $? > "$EV/exit-code"',
427
+ ]
428
+ if artifacts:
429
+ # copy-out runs OUTSIDE the jail, after the command: only declared,
430
+ # caller-validated repo-relative FILES, each size-capped; every skip is
431
+ # recorded so the wake can tell the author exactly what happened.
432
+ # Two hostile shapes to defeat, since the jailed (agent) command wrote
433
+ # this tree and picked these names:
434
+ # * INJECTION — a name like `$(cmd)` must never reach the shell as
435
+ # code. Every path goes in single-quoted (shlex.quote) argument
436
+ # position, and every log line is a single-quoted LITERAL (a name
437
+ # interpolated into a double-quoted `echo` would execute), so the
438
+ # name is inert everywhere.
439
+ # * ESCAPE via symlink — a declared file (or an intermediate dir) may
440
+ # be a symlink to a host file; the host-side cp would dereference
441
+ # it. `realpath` resolves the whole path and we copy ONLY when the
442
+ # resolved target stays under $TREE (and copy the RESOLVED path, so
443
+ # there is no resolve-then-copy gap). The command has already
444
+ # finished, so the tree is quiescent — no TOCTOU.
445
+ lines.append('mkdir -p "$EV/artifacts"')
446
+ lines.append('TREE_REAL=$(realpath "$TREE" 2>/dev/null || echo "$TREE")')
447
+ for art in artifacts:
448
+ q = shlex.quote(art) # safe in argument position (single-quoted)
449
+ skip_type = shlex.quote(f"skipped (not a regular file in the tree): {art}")
450
+ skip_big = shlex.quote(f"skipped (over {int(artifact_max_bytes)} bytes): {art}")
451
+ fail_cp = shlex.quote(f"copy failed: {art}")
452
+ lines.append(
453
+ f'AP=$(realpath "$TREE"/{q} 2>/dev/null || true); '
454
+ f'case "$AP" in "$TREE_REAL"/*) '
455
+ f'if [ -f "$AP" ] && [ "$(wc -c < "$AP")" -le {int(artifact_max_bytes)} ]; then '
456
+ f'mkdir -p "$EV/artifacts/$(dirname {q})" && cp "$AP" "$EV/artifacts"/{q} '
457
+ f'|| echo {fail_cp} >> "$EV/artifacts.log"; '
458
+ f'elif [ -f "$AP" ]; then echo {skip_big} >> "$EV/artifacts.log"; '
459
+ f'else echo {skip_type} >> "$EV/artifacts.log"; fi ;; '
460
+ f'*) echo {skip_type} >> "$EV/artifacts.log" ;; esac'
461
+ )
462
+ lines += [
463
+ "exit 0",
464
+ ]
465
+ script = ev / "job.sh"
466
+ script.write_text("\n".join(lines) + "\n")
467
+ script.chmod(0o755)
468
+ return script
469
+
470
+
471
+ def eval_job_spec(
472
+ script: Path,
473
+ *,
474
+ job_name: str,
475
+ account: str,
476
+ partition: str,
477
+ eval_minutes: int,
478
+ cpus: int = 4,
479
+ mem: str = "8G",
480
+ gpus: int = 0,
481
+ ) -> JobSpec:
482
+ """The JobSpec for one dispatched eval: the hint CLAMPED to our ceiling
483
+ plus setup slack — a contract value above EVAL_JOB_MINUTES_CEILING must
484
+ not create a longer Slurm job than the ceiling allows. `gpus` is the
485
+ benchmark's contract field; the caller has already placed the job on
486
+ the GPU lane (DispatchSettings.placement) when it is nonzero, and the
487
+ job is sized for it: a GPU eval gets at least EVAL_CPUS_PER_GPU cores
488
+ and EVAL_MEM_GB_PER_GPU GB per GPU (a training eval's data loading and
489
+ torch.compile workers do not fit the CPU eval's 4 cores / 8 GB)."""
490
+ if gpus > 0:
491
+ cpus = max(cpus, EVAL_CPUS_PER_GPU * gpus)
492
+ given = _mem_gb(mem)
493
+ # an explicit request is never SHRUNK: only a parseable value below
494
+ # the per-GPU floor is raised; anything unparseable passes through
495
+ if given is not None and given < EVAL_MEM_GB_PER_GPU * gpus:
496
+ mem = f"{EVAL_MEM_GB_PER_GPU * gpus}G"
497
+ return JobSpec(
498
+ job_name=job_name[:60],
499
+ account=account,
500
+ partition=partition,
501
+ time_minutes=effective_eval_minutes(eval_minutes) + EVAL_JOB_SETUP_MINUTES,
502
+ script=str(script),
503
+ cpus=cpus,
504
+ mem=mem,
505
+ gpus=gpus,
506
+ )
507
+
508
+
509
+ def _mem_gb(mem: str) -> int | None:
510
+ """A Slurm --mem value ("8G", "512M", "1T", "16") in whole GB, rounded
511
+ down; None when unparseable (the caller then leaves it alone)."""
512
+ text = mem.strip().upper()
513
+ scale = {"K": 1 / (1024 * 1024), "M": 1 / 1024, "G": 1.0, "T": 1024.0}
514
+ unit = text[-1:] if text[-1:] in scale else ""
515
+ number = text[:-1] if unit else text
516
+ try:
517
+ value = float(number)
518
+ except ValueError:
519
+ return None
520
+ if not math.isfinite(value) or value < 0:
521
+ return None # "nanG"/"infG": not a size — pass it through untouched
522
+ return int(value * (scale[unit] if unit else 1 / 1024)) # bare Slurm --mem is MB
523
+
524
+
525
+ def read_eval_result(run_dir: Path, name: str, metric: str) -> float:
526
+ """The wake side of one dispatched eval. Raises EvalError with the same
527
+ semantics as the in-job evaluator: nonzero exit or an unreadable metric
528
+ is an eval failure (outcome `eval-error`, ending `aborted`), and a
529
+ MISSING exit-code file means the job died before the wrapper ran —
530
+ also a failure, never a silent skip."""
531
+ ev = run_dir / f"eval-{name}"
532
+ try:
533
+ code = int((ev / "exit-code").read_text().strip())
534
+ except (OSError, ValueError) as exc:
535
+ raise EvalError(f"dispatched eval {name}: no exit code ({exc})") from exc
536
+ stdout = ""
537
+ with contextlib.suppress(OSError, ValueError):
538
+ stdout = (ev / "stdout").read_text(errors="replace")
539
+ if code != 0:
540
+ tail = ""
541
+ with contextlib.suppress(OSError, ValueError):
542
+ tail = (ev / "stderr").read_text(errors="replace")[-300:]
543
+ raise EvalError(f"dispatched eval {name} failed ({code}): {tail}")
544
+ value = _metric_from_output(stdout, metric)
545
+ if value is None:
546
+ raise EvalError(f"dispatched eval {name}: no readable {metric!r} in output")
547
+ if not math.isfinite(value):
548
+ # same rule as the in-job evaluator: json parses bare NaN/Infinity,
549
+ # and a NaN score entering a comparison is worse than a failure
550
+ raise EvalError(f"dispatched eval {name}: non-finite {metric!r} ({value})")
551
+ return value
552
+
553
+
554
+ def result_summary(run_dir: Path, name: str) -> str:
555
+ """One line for reports/logs; never raises."""
556
+ ev = run_dir / f"eval-{name}"
557
+ try:
558
+ code = (ev / "exit-code").read_text(errors="replace").strip()
559
+ except (OSError, ValueError):
560
+ code = "?"
561
+ try:
562
+ last = [
563
+ ln for ln in (ev / "stdout").read_text(errors="replace").splitlines() if ln.strip()
564
+ ][-1]
565
+ except (OSError, ValueError, IndexError):
566
+ last = ""
567
+ return f"eval-{name}: exit={code} {last[:160]}"
568
+
569
+
570
+ def parse_result_json(run_dir: Path, name: str) -> dict:
571
+ """The full JSON line (margins, per-encoder blocks) for report embedding;
572
+ empty dict when absent."""
573
+ ev = run_dir / f"eval-{name}"
574
+ try:
575
+ for line in reversed((ev / "stdout").read_text(errors="replace").splitlines()):
576
+ line = line.strip()
577
+ if line.startswith("{"):
578
+ try:
579
+ data = json.loads(line)
580
+ except json.JSONDecodeError:
581
+ continue
582
+ if isinstance(data, dict):
583
+ return data
584
+ except OSError:
585
+ pass
586
+ return {}