outerloop-science 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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 (45) hide show
  1. outerloop/__init__.py +2 -2
  2. outerloop/appauth.py +17 -0
  3. outerloop/attempt.py +376 -101
  4. outerloop/brief.py +38 -25
  5. outerloop/cli.py +104 -6
  6. outerloop/climbboard.py +67 -22
  7. outerloop/compute.py +148 -53
  8. outerloop/contract.py +8 -0
  9. outerloop/dispatch.py +63 -18
  10. outerloop/evalcache.py +147 -0
  11. outerloop/followup.py +40 -25
  12. outerloop/github.py +67 -22
  13. outerloop/harness.py +22 -47
  14. outerloop/housekeeping.py +1 -17
  15. outerloop/image.py +0 -4
  16. outerloop/init.py +45 -2
  17. outerloop/intake.py +4 -7
  18. outerloop/launchlog.py +239 -0
  19. outerloop/maintain.py +353 -0
  20. outerloop/maintain_agent_cli.py +81 -0
  21. outerloop/maintain_post_cli.py +140 -0
  22. outerloop/measure.py +6 -0
  23. outerloop/orchestrator.py +141 -31
  24. outerloop/panel.py +3 -3
  25. outerloop/review.py +4 -0
  26. outerloop/review_agent.py +7 -7
  27. outerloop/review_agent_cli.py +2 -2
  28. outerloop/review_post_cli.py +2 -2
  29. outerloop/review_summarize_cli.py +8 -6
  30. outerloop/roles.py +27 -0
  31. outerloop/rolespec.py +3 -1
  32. outerloop/steward.py +7 -14
  33. outerloop/syscall.py +261 -47
  34. outerloop/syscall_cli.py +243 -12
  35. outerloop/tick.py +274 -313
  36. outerloop/verify_agent.py +8 -6
  37. outerloop/verify_post_cli.py +2 -2
  38. outerloop/watcher.py +203 -0
  39. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/METADATA +4 -1
  40. outerloop_science-0.1.0.dev4.dist-info/RECORD +59 -0
  41. outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
  42. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/WHEEL +0 -0
  43. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/entry_points.txt +0 -0
  44. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
  45. {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev4.dist-info}/licenses/NOTICE +0 -0
outerloop/compute.py CHANGED
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
  import contextlib
19
19
  import logging
20
20
  import os
21
+ import re
21
22
  import shlex
22
23
  import signal
23
24
  import subprocess
@@ -67,7 +68,9 @@ Runner = Callable[[Sequence[str], int], CommandResult]
67
68
 
68
69
 
69
70
  def _subprocess_runner(argv: Sequence[str], timeout_s: int) -> CommandResult:
70
- completed = subprocess.run(list(argv), capture_output=True, text=True, timeout=timeout_s)
71
+ completed = subprocess.run(
72
+ list(argv), capture_output=True, text=True, timeout=timeout_s, check=False
73
+ )
71
74
  return CommandResult(completed.returncode, completed.stdout, completed.stderr)
72
75
 
73
76
 
@@ -91,14 +94,16 @@ class JobSpec:
91
94
  mem: str = "2G"
92
95
  gpus: int = 0
93
96
  qos: str = ""
97
+ # a positive nice LOWERS priority (Slurm, like Unix): experiments yield to
98
+ # the kernel's own evals and re-measures when a slot frees
99
+ nice: int = 0
94
100
  output: str = "/dev/null"
95
101
  # Slurm scheduling controls
96
102
  dependency: str = "" # e.g. "afterany:12345" or "singleton"
97
103
  begin: str = "" # e.g. "now+30" or an absolute "YYYY-MM-DDTHH:MM:SS"
98
- # submitted held (PENDING, reason JobHeldUser) until `release`: the
99
- # tick's launch admission lets GPU launches into the queue in order,
100
- # under the per-user cap, instead of queueing them all at once
101
- hold: bool = False
104
+ # a job array: "0-15%4" runs tasks 0..15, at most 4 at a time; the queue
105
+ # holds one entry and squeue names its tasks `<id>_<k>`
106
+ array: str = ""
102
107
  extra: tuple[str, ...] = ()
103
108
 
104
109
  def to_argv(self) -> list[str]:
@@ -125,12 +130,14 @@ class JobSpec:
125
130
  argv.append(f"--gpus-per-node={self.gpus}")
126
131
  if self.qos:
127
132
  argv.append(f"--qos={self.qos}")
128
- if self.hold:
129
- argv.append("--hold")
133
+ if self.nice:
134
+ argv.append(f"--nice={self.nice}")
130
135
  if self.dependency:
131
136
  argv.append(f"--dependency={self.dependency}")
132
137
  if self.begin:
133
138
  argv.append(f"--begin={self.begin}")
139
+ if self.array:
140
+ argv.append(f"--array={self.array}")
134
141
  argv.extend(self.extra)
135
142
  if self.command:
136
143
  argv.append(f"--wrap={self.command}")
@@ -140,9 +147,19 @@ class JobSpec:
140
147
  return argv
141
148
 
142
149
 
143
- # `reason` and `gres` feed launch admission (why a job waits, how many GPUs it
144
- # asks for); the board reads the first six by key and ignores the rest
145
- QUEUE_FIELDS = ("id", "name", "state", "elapsed", "partition", "submitted", "reason", "gres")
150
+ # `reason`, `gres` and `limit` feed the queue view: why a job waits, what it holds
151
+ # waits, whether it holds GPUs); the board reads the first six by key
152
+ QUEUE_FIELDS = (
153
+ "id",
154
+ "name",
155
+ "state",
156
+ "elapsed",
157
+ "partition",
158
+ "submitted",
159
+ "reason",
160
+ "gres",
161
+ "limit",
162
+ )
146
163
 
147
164
 
148
165
  class Compute(Protocol):
@@ -155,9 +172,9 @@ class Compute(Protocol):
155
172
  def job_partition(self, job_id: str) -> str: ...
156
173
  def active_job_names(self) -> list[str]: ...
157
174
  def queue_snapshot(self) -> list[dict[str, str]]: ...
175
+ def lane_load(self, partition: str) -> dict[str, int]: ...
158
176
  def job_id_for_name(self, name: str) -> str: ...
159
- def cancel(self, job_id: str) -> None: ...
160
- def release(self, job_id: str) -> None: ...
177
+ def cancel(self, job_id: str) -> bool: ...
161
178
 
162
179
 
163
180
  def local_mode() -> bool:
@@ -174,6 +191,47 @@ def compute_from_env() -> SlurmCompute | LocalCompute:
174
191
  return LocalCompute() if local_mode() else SlurmCompute()
175
192
 
176
193
 
194
+ _JOB_ID = re.compile(r"^\d+(_\d+)?$") # a job, or one task of a job array (`<id>_<k>`)
195
+
196
+
197
+ def _check_job_id(job_id: str) -> None:
198
+ if not _JOB_ID.match(job_id):
199
+ raise ValueError(f"not a job id: {job_id!r}")
200
+
201
+
202
+ def array_indices(spec: str) -> list[int]:
203
+ """The task indices of an array spec: "0-15%4" -> 0..15 (the %K throttle
204
+ is the scheduler's concern); "" -> none."""
205
+ body = spec.split("%", 1)[0].strip()
206
+ if not body:
207
+ return []
208
+ lo, sep, hi = body.partition("-")
209
+ if not sep:
210
+ return [int(lo)] if lo.isdigit() else []
211
+ if not (lo.isdigit() and hi.isdigit()):
212
+ return []
213
+ return list(range(int(lo), int(hi) + 1))
214
+
215
+
216
+ def combine_states(states: Sequence[str]) -> str:
217
+ """One state for a job array from its tasks' states: running while any
218
+ task runs, pending while any task waits, terminal only when every task
219
+ is — COMPLETED if all are, else the first other terminal state (FAILED,
220
+ TIMEOUT, CANCELLED...), so a sweep with one dead task reads as failed."""
221
+ if len(states) == 1:
222
+ return states[0]
223
+ for want in ("RUNNING", "COMPLETING"):
224
+ if any(s.startswith(want) for s in states):
225
+ return want
226
+ if any(is_pending(s) for s in states):
227
+ return "PENDING"
228
+ live = [s for s in states if not is_terminal(s)]
229
+ if live:
230
+ return live[0]
231
+ bad = [s for s in states if not s.startswith("COMPLETED")]
232
+ return bad[0] if bad else "COMPLETED"
233
+
234
+
177
235
  @dataclass
178
236
  class SlurmCompute:
179
237
  """The three verbs, plus afterany for wake jobs."""
@@ -195,8 +253,7 @@ class SlurmCompute:
195
253
  def status(self, job_id: str) -> str:
196
254
  """The job's Slurm state, or GONE when a *successful* query finds no
197
255
  record. Raises SlurmQueryError when the query itself fails."""
198
- if not job_id.isdigit():
199
- raise ValueError(f"not a job id: {job_id!r}")
256
+ _check_job_id(job_id)
200
257
  try:
201
258
  result = self.runner(
202
259
  ["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "State"],
@@ -206,14 +263,14 @@ class SlurmCompute:
206
263
  raise SlurmQueryError(f"sacct did not run: {exc}") from exc
207
264
  if result.returncode != 0:
208
265
  raise SlurmQueryError(f"sacct failed ({result.returncode}): {result.stderr.strip()}")
209
- state = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
210
- return state if state else GONE
266
+ # a job array answers one line per task; the array's state is theirs combined
267
+ states = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
268
+ return combine_states(states) if states else GONE
211
269
 
212
270
  def elapsed_seconds(self, job_id: str) -> int | None:
213
271
  """How long the job actually ran (sacct Elapsed), or None when sacct
214
272
  has no record. Raises SlurmQueryError when the query itself fails."""
215
- if not job_id.isdigit():
216
- raise ValueError(f"not a job id: {job_id!r}")
273
+ _check_job_id(job_id)
217
274
  try:
218
275
  result = self.runner(
219
276
  ["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "Elapsed"],
@@ -230,8 +287,7 @@ class SlurmCompute:
230
287
  """Why a PENDING job is pending — Slurm's reason (`Dependency`,
231
288
  `DependencyNeverSatisfied`, `Priority`, ...), or "" when squeue no
232
289
  longer lists it. Raises SlurmQueryError when the query itself fails."""
233
- if not job_id.isdigit():
234
- raise ValueError(f"not a job id: {job_id!r}")
290
+ _check_job_id(job_id)
235
291
  try:
236
292
  result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%r"], self.command_timeout_s)
237
293
  except (OSError, subprocess.TimeoutExpired) as exc:
@@ -245,8 +301,7 @@ class SlurmCompute:
245
301
  them, or "" when squeue no longer lists it. A site can MOVE a pending
246
302
  job off the partition it was submitted to (Torch does, under
247
303
  congestion); callers compare this with what they asked for."""
248
- if not job_id.isdigit():
249
- raise ValueError(f"not a job id: {job_id!r}")
304
+ _check_job_id(job_id)
250
305
  try:
251
306
  result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%P"], self.command_timeout_s)
252
307
  except (OSError, subprocess.TimeoutExpired) as exc:
@@ -277,7 +332,7 @@ class SlurmCompute:
277
332
  on failure, like active_job_names."""
278
333
  try:
279
334
  result = self.runner(
280
- ["squeue", "--me", "--noheader", "-o", "%i|%j|%T|%M|%P|%V|%r|%b"],
335
+ ["squeue", "--me", "--noheader", "-o", "%i|%j|%T|%M|%P|%V|%r|%b|%l"],
281
336
  self.command_timeout_s,
282
337
  )
283
338
  except (OSError, subprocess.TimeoutExpired) as exc:
@@ -291,6 +346,30 @@ class SlurmCompute:
291
346
  rows.append(dict(zip(QUEUE_FIELDS, parts, strict=True)))
292
347
  return rows
293
348
 
349
+ def lane_load(self, partition: str) -> dict[str, int]:
350
+ """Node counts by state on a lane (a partition or a comma-separated
351
+ list), from sinfo: {"idle": 3, "mixed": 20, "allocated": 11}. Context
352
+ for the queue view, nothing a caller acts on. Empty when no lane is
353
+ named; raises SlurmQueryError on a failed query."""
354
+ if not partition:
355
+ return {}
356
+ try:
357
+ result = self.runner(
358
+ ["sinfo", "--noheader", "-p", partition, "-o", "%T %D"], self.command_timeout_s
359
+ )
360
+ except (OSError, subprocess.TimeoutExpired) as exc:
361
+ raise SlurmQueryError(f"sinfo did not run: {exc}") from exc
362
+ if result.returncode != 0:
363
+ raise SlurmQueryError(f"sinfo failed ({result.returncode}): {result.stderr.strip()}")
364
+ load: dict[str, int] = {}
365
+ for line in result.stdout.splitlines():
366
+ parts = line.split()
367
+ if len(parts) != 2 or not parts[1].isdigit():
368
+ continue
369
+ state = parts[0].rstrip("*~#!%$@^-") # sinfo's state flags (draining, no-respond...)
370
+ load[state] = load.get(state, 0) + int(parts[1])
371
+ return load
372
+
294
373
  def job_id_for_name(self, name: str) -> str:
295
374
  """The id of this user's PENDING/RUNNING job with exactly `name`, or
296
375
  "" if none. Authoritative for "is this still live" independent of any
@@ -309,22 +388,16 @@ class SlurmCompute:
309
388
  ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
310
389
  return ids[0] if ids else ""
311
390
 
312
- def cancel(self, job_id: str) -> None:
313
- """Cancel; idempotent (cancelling a finished job is not an error)."""
314
- if not job_id.isdigit():
315
- raise ValueError(f"not a job id: {job_id!r}")
391
+ def cancel(self, job_id: str) -> bool:
392
+ """Cancel; idempotent (cancelling a finished job is not an error).
393
+ False when scancel itself failed, so a caller that must know (the
394
+ sweep's cancel-on-end) can try again; most callers are best-effort."""
395
+ _check_job_id(job_id)
316
396
  result = self.runner(["scancel", job_id], self.command_timeout_s)
317
397
  if result.returncode != 0:
318
398
  log.warning("scancel %s: %s", job_id, result.stderr.strip())
319
-
320
- def release(self, job_id: str) -> None:
321
- """Release a job submitted with `hold` so the scheduler may start it.
322
- Releasing a job that is not held is not an error."""
323
- if not job_id.isdigit():
324
- raise ValueError(f"not a job id: {job_id!r}")
325
- result = self.runner(["scontrol", "release", job_id], self.command_timeout_s)
326
- if result.returncode != 0:
327
- log.warning("scontrol release %s: %s", job_id, result.stderr.strip())
399
+ return False
400
+ return True
328
401
 
329
402
 
330
403
  # Local job ids start far above any real Slurm id so the two can never be
@@ -389,6 +462,33 @@ class LocalCompute:
389
462
  if k in ("PATH", "HOME", "LANG", "TMPDIR", "SLURM_TMPDIR", "USER", "LOGNAME")
390
463
  or (k.startswith(("OUTERLOOP_", "REVIEW_HERMES_")) and not _secret_name(k))
391
464
  }
465
+ indices = array_indices(spec.array)
466
+ if indices:
467
+ # a job array runs its tasks in turn — there is no queue here to
468
+ # throttle; each task keeps its own state and output under
469
+ # `<id>_<k>`, and the array's own state is theirs combined
470
+ states = [
471
+ self._run_and_record(
472
+ spec, argv, {**job_env, "SLURM_ARRAY_TASK_ID": str(i)}, f"{job_id}_{i}"
473
+ )
474
+ for i in indices
475
+ ]
476
+ state = combine_states(states)
477
+ self._record(spec, job_id, state, "")
478
+ else:
479
+ state = self._run_and_record(spec, argv, job_env, job_id)
480
+ state_dir = _local_state_dir()
481
+ where = (
482
+ f"; output in {state_dir / (job_id + '.out')}"
483
+ if state_dir and state != "COMPLETED"
484
+ else ""
485
+ )
486
+ log.info("ran %s locally as job %s: %s%s", spec.job_name, job_id, state, where)
487
+ return job_id
488
+
489
+ def _run_and_record(
490
+ self, spec: JobSpec, argv: list[str], job_env: dict[str, str], job_id: str
491
+ ) -> str:
392
492
  try:
393
493
  # the job runs in its OWN session (= process group), so the
394
494
  # walltime kill takes the whole tree — a job script waiting on
@@ -425,6 +525,10 @@ class LocalCompute:
425
525
  "local job %s: an escaped child survived the walltime kill", spec.job_name
426
526
  )
427
527
  state = "TIMEOUT"
528
+ self._record(spec, job_id, state, output)
529
+ return state
530
+
531
+ def _record(self, spec: JobSpec, job_id: str, state: str, output: str) -> None:
428
532
  state_dir = _local_state_dir()
429
533
  if state_dir is not None:
430
534
  try:
@@ -453,22 +557,14 @@ class LocalCompute:
453
557
  log.warning("local job %s: state persist failed: %s", spec.job_name, exc)
454
558
  if spec.output and spec.output != "/dev/null":
455
559
  try:
456
- with open(spec.output, "w") as fh:
560
+ with open(spec.output, "a" if "_" in job_id else "w") as fh:
457
561
  fh.write(output)
458
562
  except OSError as exc:
459
563
  log.warning("local job %s: output write failed: %s", spec.job_name, exc)
460
564
  self._states[job_id] = state
461
- where = (
462
- f"; output in {state_dir / (job_id + '.out')}"
463
- if state_dir and state != "COMPLETED"
464
- else ""
465
- )
466
- log.info("ran %s locally as job %s: %s%s", spec.job_name, job_id, state, where)
467
- return job_id
468
565
 
469
566
  def status(self, job_id: str) -> str:
470
- if not job_id.isdigit():
471
- raise ValueError(f"not a job id: {job_id!r}")
567
+ _check_job_id(job_id)
472
568
  state = self._states.get(job_id, "")
473
569
  if state:
474
570
  return state
@@ -494,16 +590,15 @@ class LocalCompute:
494
590
  def queue_snapshot(self) -> list[dict[str, str]]:
495
591
  return []
496
592
 
593
+ def lane_load(self, partition: str) -> dict[str, int]:
594
+ return {} # no lanes in the monolith
595
+
497
596
  def job_id_for_name(self, name: str) -> str:
498
597
  return ""
499
598
 
500
- def cancel(self, job_id: str) -> None:
501
- if not job_id.isdigit():
502
- raise ValueError(f"not a job id: {job_id!r}")
503
- # already terminal; cancelling a finished job is not an error
504
-
505
- def release(self, job_id: str) -> None:
506
- """Local jobs run synchronously at submit; nothing is ever held."""
599
+ def cancel(self, job_id: str) -> bool:
600
+ _check_job_id(job_id)
601
+ return True # already terminal; cancelling a finished job is not an error
507
602
 
508
603
 
509
604
  def parse_elapsed(text: str) -> int | None:
outerloop/contract.py CHANGED
@@ -272,6 +272,14 @@ class Budgets(_StrictModel):
272
272
  # (agent-01..agent-0N), so branches, ledger rows, and reports stay
273
273
  # distinct. runs_per_week and gpu_hours_per_run remain the spend guards.
274
274
  max_active_attempts: int | None = Field(default=None, ge=1)
275
+ # The pace ceiling for an author's sweeps, in GPUs: one launch may hold at
276
+ # most this many at once, so a sweep of N tasks runs
277
+ # max_concurrent_gpus // gpus of them at a time (`--array=0-N%K`). Lenient
278
+ # by design — not the cap divided by the agent count (agents rarely launch
279
+ # at the same moment, and an idle share is wasted GPU); on a 16-GPU cap 12
280
+ # lets one sweep use most of the machine while a sibling's job still gets
281
+ # in. Unset: the author's own pace, the whole array by default.
282
+ max_concurrent_gpus: int | None = Field(default=None, ge=1)
275
283
 
276
284
  @model_validator(mode="before")
277
285
  @classmethod
outerloop/dispatch.py CHANGED
@@ -36,8 +36,14 @@ from pathlib import Path
36
36
  from uuid import uuid4
37
37
 
38
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
39
+ from outerloop.github import (
40
+ SAFE_GIT_FLAGS,
41
+ GitError,
42
+ Workspace,
43
+ ensure_regular_git_dir,
44
+ git_identity,
45
+ )
46
+ from outerloop.orchestrator import EvalError, managed_eval_env, metric_from_output
41
47
 
42
48
  log = logging.getLogger(__name__)
43
49
 
@@ -94,7 +100,11 @@ class Snapshot:
94
100
 
95
101
 
96
102
  def snapshot_tree(
97
- ws: Workspace, base_sha: str, exclude: tuple[str, ...] = (), force: tuple[str, ...] = ()
103
+ ws: Workspace,
104
+ base_sha: str,
105
+ exclude: tuple[str, ...] = (),
106
+ force: tuple[str, ...] = (),
107
+ author: str = "",
98
108
  ) -> Snapshot:
99
109
  """Snapshot the workspace's current CONTENT as a commit parented on
100
110
  `base_sha`, without touching the working index, and retain it under a
@@ -105,7 +115,8 @@ def snapshot_tree(
105
115
  keeps it (docs/design/research-lines.md). `force` adds those paths even
106
116
  when the target's ignore rules match them — the notebook seal uses it so
107
117
  a .gitignore entry cannot silently discard session memory; callers pass
108
- only paths that exist.
118
+ only paths that exist. `author` is the bot login the seal commit is
119
+ made as (empty: OUTERLOOP_BOT_LOGIN).
109
120
  """
110
121
  # the snapshot writes an index, a tree, a commit, and a ref into this
111
122
  # repository: a session-reshaped .git is refused first, like every other
@@ -163,10 +174,7 @@ def snapshot_tree(
163
174
  commit = run(
164
175
  [
165
176
  *git,
166
- "-c",
167
- "user.name=dispatch",
168
- "-c",
169
- "user.email=dispatch@localhost",
177
+ *git_identity(author),
170
178
  "commit-tree",
171
179
  tree,
172
180
  "-p",
@@ -205,6 +213,7 @@ def _filter_neutral_env(base_git: list[str], env: dict[str, str]) -> dict[str, s
205
213
  capture_output=True,
206
214
  text=True,
207
215
  timeout=30,
216
+ check=False,
208
217
  )
209
218
  # -z: NUL-separated records, each "key\nvalue" — so a value containing a
210
219
  # newline can never masquerade as a second record.
@@ -281,9 +290,22 @@ def write_eval_job(
281
290
  artifacts: tuple[str, ...] = (),
282
291
  artifact_max_bytes: int = 0,
283
292
  gpus: int = 0,
293
+ array: int = 1,
294
+ seed_cache: Path | None = None,
284
295
  ) -> Path:
285
296
  """Write the orchestrator-authored job script for one dispatched eval.
286
297
 
298
+ `seed_cache` names the kernel-warmed seed for this target
299
+ (docs/design/eval-cache.md): the job copies its contents into its own
300
+ scratch cache before `uv` runs — a copy, never a bind or a hardlink, so the
301
+ job's cache is its own and the seed is never written; a missing seed or a
302
+ failed copy costs a download, never the eval.
303
+
304
+ `array` > 1 writes ONE script for a Slurm job array: each task derives its
305
+ own job dir `eval-<name>.<k>` from SLURM_ARRAY_TASK_ID (SWEEP_INDEX under a
306
+ local run) and sees the index as SWEEP_INDEX; the task dirs are created
307
+ and cleared here, the script lives under `eval-<name>/`.
308
+
287
309
  `gpus` > 0 adds `--nv` to the jail so the job's allocated GPUs (the
288
310
  JobSpec requests them) are visible inside the container; nothing else
289
311
  about the containment changes.
@@ -310,13 +332,16 @@ def write_eval_job(
310
332
  """
311
333
  ev = run_dir / f"eval-{name}"
312
334
  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)
335
+ task_dirs = [run_dir / f"eval-{name}.{k}" for k in range(array)] if array > 1 else [ev]
336
+ for task_dir in task_dirs:
337
+ task_dir.mkdir(parents=True, exist_ok=True)
338
+ # a resubmitted eval must never be read as its predecessor: every prior
339
+ # artifact — including a leftover extracted tree — goes before submission
340
+ for stale in ("exit-code", "stdout", "stderr", "setup.log", "submitted", "artifacts.log"):
341
+ (task_dir / stale).unlink(missing_ok=True)
342
+ shutil.rmtree(task_dir / "tree", ignore_errors=True)
343
+ shutil.rmtree(task_dir / "artifacts", ignore_errors=True)
344
+ (task_dir / "command.txt").write_text(command)
320
345
  # extra_env matches the in-job evaluator's contract: managed keys (HOME,
321
346
  # UV_*, PATH...) are DROPPED, never allowed to override the isolation, and
322
347
  # keys must be shell-identifier shaped (they are exported unquoted).
@@ -330,10 +355,20 @@ def write_eval_job(
330
355
  # as the snapshot, injected as GIT_CONFIG_* env (robust to '=' in a driver
331
356
  # name, unlike -c) so a smudge filter cannot execute during checkout
332
357
  neutral = _filter_neutral_env(["git", "-C", str(repo_root), *SAFE_GIT_FLAGS], {})
358
+ if array > 1:
359
+ # the task picks its own job dir; under Slurm the index is the array
360
+ # task id, under a local run the caller exports SWEEP_INDEX
361
+ ev_lines = [
362
+ 'TASK="${SLURM_ARRAY_TASK_ID:-${SWEEP_INDEX:-0}}"',
363
+ f'EV={shlex.quote(str(ev))}."$TASK"',
364
+ 'export SWEEP_INDEX="$TASK" APPTAINERENV_SWEEP_INDEX="$TASK"',
365
+ ]
366
+ else:
367
+ ev_lines = [f"EV={shlex.quote(str(ev))}"]
333
368
  lines = [
334
369
  "#!/bin/sh",
335
370
  "set -u",
336
- f"EV={shlex.quote(str(ev))}",
371
+ *ev_lines,
337
372
  f"REPO={shlex.quote(str(repo_root))}",
338
373
  # the extracted tree lives on NODE-LOCAL scratch, not the shared run
339
374
  # dir: it dies with the job (nothing to reap on the shared FS), and
@@ -361,6 +396,11 @@ def write_eval_job(
361
396
  ]
362
397
  for k, v in neutral.items():
363
398
  lines.append(f"export {k}={shlex.quote(v)}")
399
+ if seed_cache is not None:
400
+ q = shlex.quote(str(seed_cache))
401
+ lines.append(
402
+ f'if [ -d {q} ]; then cp -a {q}/. "$SCRATCH/cache"/ 2>> "$EV/setup.log" || true; fi'
403
+ )
364
404
  lines += [
365
405
  # Materialize the snapshot by CHECKOUT, not `git archive`: a checkout
366
406
  # reproduces content faithfully — INCLUDING .gitattributes — and does
@@ -478,6 +518,8 @@ def eval_job_spec(
478
518
  cpus: int = 4,
479
519
  mem: str = "8G",
480
520
  gpus: int = 0,
521
+ nice: int = 0,
522
+ array: str = "",
481
523
  ) -> JobSpec:
482
524
  """The JobSpec for one dispatched eval: the hint CLAMPED to our ceiling
483
525
  plus setup slack — a contract value above EVAL_JOB_MINUTES_CEILING must
@@ -486,7 +528,8 @@ def eval_job_spec(
486
528
  the GPU lane (DispatchSettings.placement) when it is nonzero, and the
487
529
  job is sized for it: a GPU eval gets at least EVAL_CPUS_PER_GPU cores
488
530
  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)."""
531
+ torch.compile workers do not fit the CPU eval's 4 cores / 8 GB). `nice`
532
+ lowers the job's priority below the kernel's evals (the launcher sets it)."""
490
533
  if gpus > 0:
491
534
  cpus = max(cpus, EVAL_CPUS_PER_GPU * gpus)
492
535
  given = _mem_gb(mem)
@@ -503,6 +546,8 @@ def eval_job_spec(
503
546
  cpus=cpus,
504
547
  mem=mem,
505
548
  gpus=gpus,
549
+ nice=nice,
550
+ array=array,
506
551
  )
507
552
 
508
553
 
@@ -541,7 +586,7 @@ def read_eval_result(run_dir: Path, name: str, metric: str) -> float:
541
586
  with contextlib.suppress(OSError, ValueError):
542
587
  tail = (ev / "stderr").read_text(errors="replace")[-300:]
543
588
  raise EvalError(f"dispatched eval {name} failed ({code}): {tail}")
544
- value = _metric_from_output(stdout, metric)
589
+ value = metric_from_output(stdout, metric)
545
590
  if value is None:
546
591
  raise EvalError(f"dispatched eval {name}: no readable {metric!r} in output")
547
592
  if not math.isfinite(value):