outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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 CHANGED
@@ -2,17 +2,17 @@
2
2
 
3
3
  import os
4
4
 
5
- __version__ = "0.1.0.dev0"
5
+ __version__ = "0.1.0.dev2"
6
6
 
7
7
 
8
8
  def _bridge_legacy_env() -> None:
9
- """Accept the new `OUTERLOOP_*` names at the process boundary: copy each into
10
- its `AUTORESEARCH_*` twin when the twin is unset, so the kernel's internals —
11
- which still read `AUTORESEARCH_*` during the rename — see one name. The chain
12
- scripts do the same in shell. Removed with the final internal flip."""
9
+ """Accept the pre-rename `AUTORESEARCH_*` names at the process boundary for
10
+ one release: copy each into its `OUTERLOOP_*` twin when the twin is unset,
11
+ so the kernel reads one name. The chain scripts do the same in shell.
12
+ Removed in the release after 0.1."""
13
13
  for key, value in list(os.environ.items()):
14
- if key.startswith("OUTERLOOP_"):
15
- os.environ.setdefault("AUTORESEARCH_" + key[len("OUTERLOOP_") :], value)
14
+ if key.startswith("AUTORESEARCH_"):
15
+ os.environ.setdefault("OUTERLOOP_" + key[len("AUTORESEARCH_") :], value)
16
16
 
17
17
 
18
18
  _bridge_legacy_env()
outerloop/appauth.py CHANGED
@@ -3,7 +3,7 @@
3
3
  `AppInstallationTokenProvider` satisfies the `TokenProvider` protocol used
4
4
  throughout `github.py`. Role CLIs construct bot auth through
5
5
  `resolve_bot_auth`, which selects this provider when an App config file is
6
- given (`--github-app-file` / `AUTORESEARCH_GITHUB_APP_FILE`) and falls back
6
+ given (`--github-app-file` / `OUTERLOOP_GITHUB_APP_FILE`) and falls back
7
7
  to the PAT file otherwise — the cutover flag, revertible by unsetting the
8
8
  env. Each `token()` mints a short-lived JWT (RS256, signed by the App
9
9
  private key), exchanges it for a ~1h installation token scoped to the
@@ -172,8 +172,8 @@ def signer_from_private_key(pem_path: Path) -> Signer:
172
172
  from cryptography.hazmat.primitives.asymmetric import padding, rsa
173
173
  except ImportError as exc: # pragma: no cover - exercised only without the extra
174
174
  raise ImportError(
175
- "GitHub App auth needs the 'app-auth' extra (cryptography); "
176
- "install it before selecting the App token provider"
175
+ "GitHub App auth needs the cryptography package "
176
+ "(pip install cryptography), a base dependency since 0.1.0.dev2"
177
177
  ) from exc
178
178
 
179
179
  key = serialization.load_pem_private_key(pem_path.read_bytes(), password=None)
outerloop/appmanifest.py CHANGED
@@ -14,7 +14,7 @@ and GitHub redirects back to that page with a one-time code the page displays.
14
14
  The adopter pastes the code here; we exchange it for the app id + key, write
15
15
  `github_app.<slug>.json` + the PEM (both 0600), help install the App, and capture
16
16
  the installation id. The written files are what `resolve_bot_auth` reads via
17
- `AUTORESEARCH_GITHUB_APP_FILE`, the same path `outerloop start` uses.
17
+ `OUTERLOOP_GITHUB_APP_FILE`, the same path `outerloop start` uses.
18
18
  """
19
19
 
20
20
  from __future__ import annotations
@@ -25,12 +25,14 @@ import os
25
25
  import secrets
26
26
  import time
27
27
  import urllib.error
28
+ import urllib.parse
28
29
  import urllib.request
29
30
  from collections.abc import Callable
30
31
  from pathlib import Path
31
32
  from typing import Any
32
33
 
33
34
  from outerloop.appauth import API, build_app_jwt, signer_from_private_key
35
+ from outerloop.paths import write_private
34
36
 
35
37
  # The hosted helper page: it auto-POSTs the manifest to GitHub, then displays the
36
38
  # returned code. It is also the manifest's redirect_url, so GitHub sends the code
@@ -90,10 +92,15 @@ def request_manifest_code(
90
92
  state = secrets.token_urlsafe(16)
91
93
  manifest = build_manifest(name, homepage_url)
92
94
  url = build_setup_url(manifest, state, org)
93
- print_fn("Create your GitHub App — open this URL in a browser (any machine):")
95
+ where = f"the {org} organization" if org else "your account"
96
+ print_fn("Step 1 of 3: create the GitHub App.")
97
+ print_fn(" Open this link in a browser where you are signed in to GitHub (any machine):")
94
98
  print_fn(f"\n {url}\n")
95
- print_fn("Click 'Create GitHub App', then copy the code the page shows.")
96
- return input_fn("Paste the code here: ").strip()
99
+ print_fn(f" GitHub shows a page titled 'Create GitHub App' under {where}. Click the")
100
+ host = urllib.parse.urlsplit(SETUP_URL).netloc or SETUP_URL
101
+ print_fn(" green 'Create GitHub App' button. GitHub then sends you to")
102
+ print_fn(f" {host}, which shows a one-time code.")
103
+ return input_fn("Paste that code here: ").strip()
97
104
 
98
105
 
99
106
  def _default_transport(request: urllib.request.Request) -> Any:
@@ -136,10 +143,10 @@ def save_app_creds(
136
143
  config_dir.mkdir(parents=True, exist_ok=True)
137
144
  slug = str(conversion["slug"])
138
145
  pem_path = config_dir / f"{slug}-app.pem"
139
- pem_path.write_text(str(conversion["pem"]))
140
- pem_path.chmod(0o600)
146
+ write_private(pem_path, str(conversion["pem"]))
141
147
  app_json = config_dir / f"github_app.{slug}.json"
142
- app_json.write_text(
148
+ write_private(
149
+ app_json,
143
150
  json.dumps(
144
151
  {
145
152
  "app_id": int(conversion["id"]),
@@ -148,9 +155,8 @@ def save_app_creds(
148
155
  },
149
156
  indent=2,
150
157
  )
151
- + "\n"
158
+ + "\n",
152
159
  )
153
- app_json.chmod(0o600)
154
160
  return pem_path, app_json
155
161
 
156
162
 
@@ -194,5 +200,4 @@ def set_installation_id(app_json: Path, installation_id: int) -> None:
194
200
  """Fill the installation id into an already-written github_app.<slug>.json."""
195
201
  data = json.loads(app_json.read_text())
196
202
  data["installation_id"] = int(installation_id)
197
- app_json.write_text(json.dumps(data, indent=2) + "\n")
198
- app_json.chmod(0o600)
203
+ write_private(app_json, json.dumps(data, indent=2) + "\n")
outerloop/attempt.py CHANGED
@@ -44,7 +44,7 @@ from outerloop.github import (
44
44
  contract_at,
45
45
  ensure_regular_git_dir,
46
46
  )
47
- from outerloop.harness import Harness, SessionResult, redact
47
+ from outerloop.harness import Harness, SessionResult, default_binary, redact
48
48
  from outerloop.markers import has_marker
49
49
  from outerloop.measure import DispatchedMeasurer, DispatchSettings
50
50
  from outerloop.orchestrator import (
@@ -97,8 +97,11 @@ log = logging.getLogger(__name__)
97
97
  # tick preflights the panel key (and compares it against the author key —
98
98
  # role separation) before claiming/submitting.
99
99
  PANEL_KEY_DEFAULT = str(CONFIG_DIR / "verifier_key")
100
- HARNESS_KEY_DEFAULT = str(CONFIG_DIR / "harness_key") # the claude author key
101
- CODEX_KEY_DEFAULT = str(CONFIG_DIR / "codex_key") # the codex author key
100
+ # One rule for author keys: ~/.config/outerloop/<backend>_key. `harness_key` is
101
+ # the pre-rename name of the claude key; it is still read, never written.
102
+ CLAUDE_KEY_DEFAULT = str(CONFIG_DIR / "claude_key")
103
+ HARNESS_KEY_DEFAULT = str(CONFIG_DIR / "harness_key") # legacy claude key
104
+ CODEX_KEY_DEFAULT = str(CONFIG_DIR / "codex_key")
102
105
 
103
106
 
104
107
  def resolve_author_key_file(backend: str, explicit: str = "") -> str:
@@ -106,14 +109,28 @@ def resolve_author_key_file(backend: str, explicit: str = "") -> str:
106
109
  codex's both on disk), selected by backend — so the author backend is a
107
110
  config choice, not a key swap, and an in-flight run of either backend can
108
111
  still be woken/serviced after a fleet flip. An explicit path always wins;
109
- otherwise the per-backend env var, then the packaged default path. The result
110
- is always ~-expanded, so every caller gets a real path (an env value like
111
- "~/.config/..." must not reach the token provider verbatim)."""
112
+ otherwise the per-backend env var, then the default path. For claude the
113
+ legacy spellings (`AUTORESEARCH_HARNESS_KEY_FILE`, read through its bridged
114
+ `OUTERLOOP_` twin, and `harness_key`) are still
115
+ honored, so a machine set up before the rename keeps working; the new name
116
+ wins when both exist. The result is always ~-expanded, so every caller gets
117
+ a real path (an env value like "~/.config/..." must not reach the token
118
+ provider verbatim)."""
112
119
  if not explicit:
113
120
  if backend == "codex":
114
- explicit = os.environ.get("AUTORESEARCH_CODEX_KEY_FILE") or CODEX_KEY_DEFAULT
121
+ explicit = os.environ.get("OUTERLOOP_CODEX_KEY_FILE") or CODEX_KEY_DEFAULT
115
122
  else:
116
- explicit = os.environ.get("AUTORESEARCH_HARNESS_KEY_FILE") or HARNESS_KEY_DEFAULT
123
+ explicit = (
124
+ os.environ.get("OUTERLOOP_CLAUDE_KEY_FILE")
125
+ or os.environ.get("OUTERLOOP_HARNESS_KEY_FILE")
126
+ or ""
127
+ )
128
+ if not explicit:
129
+ explicit = CLAUDE_KEY_DEFAULT
130
+ if not os.path.exists(os.path.expanduser(explicit)) and os.path.exists(
131
+ os.path.expanduser(HARNESS_KEY_DEFAULT)
132
+ ):
133
+ explicit = HARNESS_KEY_DEFAULT
117
134
  return os.path.expanduser(explicit)
118
135
 
119
136
 
@@ -124,14 +141,14 @@ def codex_author_config_error(backend: str, model: str, image: str) -> str:
124
141
  a unit and never a fleet backend against a run's model. codex writes+executes,
125
142
  so it must be contained (--image) and needs a non-claude model."""
126
143
  if backend not in ("claude", "codex"):
127
- # a typo'd AUTORESEARCH_AUTHOR_BACKEND passes the env DEFAULT silently
144
+ # a typo'd OUTERLOOP_AUTHOR_BACKEND passes the env DEFAULT silently
128
145
  # (argparse validates the flag, not its default) and the climb rejects it
129
146
  # at build_harness — catch it on the tick host so a claimed intake
130
147
  # issue never strands on it
131
148
  return f"unknown author backend {backend!r} (expected 'claude' or 'codex')"
132
149
  if backend == "claude":
133
150
  # symmetric to the codex check: a claude harness 404s on a non-claude
134
- # model (e.g. AUTORESEARCH_AUTHOR_MODEL left on a codex id while the
151
+ # model (e.g. OUTERLOOP_AUTHOR_MODEL left on a codex id while the
135
152
  # backend is claude) — catch that misconfig before spend
136
153
  if model and not model.startswith("claude"):
137
154
  return f"author-backend claude needs a claude model (got {model!r})"
@@ -588,8 +605,15 @@ def _make_launcher(
588
605
  partially-submitted batch is reaped rather than orphaned. `gpus` is the
589
606
  benchmark's: an author's experiments run on the same lane as its evals."""
590
607
  account, partition = dispatch.placement(gpus)
608
+ # under launch admission a GPU launch enters the queue held; the tick
609
+ # releases it when the user's GPUs fit under the cap (tick.service_admission)
610
+ from outerloop.tick import max_launch_gpus_from_env
611
+
612
+ hold = gpus > 0 and not local_mode() and max_launch_gpus_from_env() > 0
591
613
 
592
614
  def launcher(sha: str, request: SyscallRequest) -> str:
615
+ from dataclasses import replace as _replace
616
+
593
617
  from outerloop.dispatch import eval_job_spec, write_eval_job
594
618
  from outerloop.syscall import launch_jobs
595
619
 
@@ -611,18 +635,15 @@ def _make_launcher(
611
635
  artifact_max_bytes=MAX_ARTIFACT_BYTES,
612
636
  gpus=gpus,
613
637
  )
614
- ids.append(
615
- dispatch.compute.submit(
616
- eval_job_spec(
617
- script,
618
- job_name=f"{run_id}-launch-{job_name}",
619
- account=account,
620
- partition=partition,
621
- eval_minutes=launch.minutes,
622
- gpus=gpus,
623
- )
624
- )
638
+ spec = eval_job_spec(
639
+ script,
640
+ job_name=f"{run_id}-launch-{job_name}",
641
+ account=account,
642
+ partition=partition,
643
+ eval_minutes=launch.minutes,
644
+ gpus=gpus,
625
645
  )
646
+ ids.append(dispatch.compute.submit(_replace(spec, hold=hold)))
626
647
  except Exception:
627
648
  # a partial batch must not orphan: no park record was written yet,
628
649
  # so nothing would ever wake or cancel the jobs that DID submit —
@@ -2155,7 +2176,7 @@ def _panel_lenses_from_args(args: Any) -> tuple[tuple[PanelLens, ...], tuple[str
2155
2176
  if backend == "codex":
2156
2177
  lens_key = _judge_lens_key(
2157
2178
  backend="codex",
2158
- key_file_env="AUTORESEARCH_PANEL_CODEX_KEY_FILE",
2179
+ key_file_env="OUTERLOOP_PANEL_CODEX_KEY_FILE",
2159
2180
  author_backend="codex",
2160
2181
  claude_panel_path=claude_panel_path,
2161
2182
  image=args.image,
@@ -2169,7 +2190,7 @@ def _panel_lenses_from_args(args: Any) -> tuple[tuple[PanelLens, ...], tuple[str
2169
2190
  # separate against the codex author key.
2170
2191
  lens_key = _judge_lens_key(
2171
2192
  backend="hermes",
2172
- key_file_env="AUTORESEARCH_PANEL_HERMES_KEY_FILE",
2193
+ key_file_env="OUTERLOOP_PANEL_HERMES_KEY_FILE",
2173
2194
  author_backend="codex",
2174
2195
  claude_panel_path=claude_panel_path,
2175
2196
  image=args.image,
@@ -2179,6 +2200,12 @@ def _panel_lenses_from_args(args: Any) -> tuple[tuple[PanelLens, ...], tuple[str
2179
2200
  else:
2180
2201
  lens_key = panel_key
2181
2202
  try:
2203
+ if not args.image:
2204
+ log.warning(
2205
+ "panel lens %s runs uncontained (local mode, no image): the judge "
2206
+ "shares this machine with the operator's keys",
2207
+ backend,
2208
+ )
2182
2209
  judge = build_harness(
2183
2210
  lens_key,
2184
2211
  reviewer_spec(),
@@ -3121,45 +3148,48 @@ def main() -> int:
3121
3148
  )
3122
3149
  parser.add_argument("--base-branch", default="main")
3123
3150
  # All three default from the chain env the tick sets on the climb job, so
3124
- # a contained run with AUTORESEARCH_{IMAGE,ACCOUNT,PARTITION} set selects
3125
- # dispatched measurement without extra flags. The image also containers the
3126
- # session + inline eval; absent any of the three, measurement stays inline
3127
- # regardless of the benchmark's eval hint.
3151
+ # a contained run with OUTERLOOP_{IMAGE,ACCOUNT,PARTITION} set selects
3152
+ # dispatched measurement without extra flags. Only the image is required
3153
+ # for dispatch (it also contains the session + inline eval); without it,
3154
+ # measurement stays inline regardless of the benchmark's eval hint. Empty
3155
+ # account/partition use Slurm's defaults.
3128
3156
  parser.add_argument(
3129
3157
  "--image",
3130
- default=os.environ.get("AUTORESEARCH_IMAGE", ""),
3158
+ default=os.environ.get("OUTERLOOP_IMAGE", ""),
3131
3159
  help="apptainer image for session+eval",
3132
3160
  )
3133
- parser.add_argument("--account", default=os.environ.get("AUTORESEARCH_ACCOUNT", ""))
3134
- parser.add_argument("--partition", default=os.environ.get("AUTORESEARCH_PARTITION", ""))
3161
+ parser.add_argument("--account", default=os.environ.get("OUTERLOOP_ACCOUNT", ""))
3162
+ parser.add_argument("--partition", default=os.environ.get("OUTERLOOP_PARTITION", ""))
3135
3163
  # the GPU lane for benchmarks with `gpus > 0` (evals + author launches);
3136
3164
  # empty = this deployment cannot place GPU jobs
3137
- parser.add_argument("--gpu-partition", default=os.environ.get("AUTORESEARCH_GPU_PARTITION", ""))
3138
- parser.add_argument("--gpu-account", default=os.environ.get("AUTORESEARCH_GPU_ACCOUNT", ""))
3165
+ parser.add_argument("--gpu-partition", default=os.environ.get("OUTERLOOP_GPU_PARTITION", ""))
3166
+ parser.add_argument("--gpu-account", default=os.environ.get("OUTERLOOP_GPU_ACCOUNT", ""))
3139
3167
  parser.add_argument(
3140
3168
  "--uncontained",
3141
3169
  action="store_true",
3142
3170
  help="run WITHOUT a container (dev only: sessions can then read "
3143
3171
  "same-user files, including credential files)",
3144
3172
  )
3145
- parser.add_argument("--claude-bin", default=os.path.expanduser("~/.local/bin/claude"))
3173
+ parser.add_argument(
3174
+ "--claude-bin",
3175
+ default=default_binary("claude"),
3176
+ help="host claude binary for the claude author (OUTERLOOP_CLAUDE_BIN, which init records)",
3177
+ )
3146
3178
  parser.add_argument(
3147
3179
  "--codex-bin",
3148
- default=os.path.expanduser(
3149
- os.environ.get("AUTORESEARCH_CODEX_BIN") or "~/.local/bin/codex"
3150
- ),
3180
+ default=default_binary("codex"),
3151
3181
  help="host codex binary for the codex author; bind-mounted into apptainer "
3152
3182
  "(must be an absolute path).",
3153
3183
  )
3154
3184
  parser.add_argument(
3155
- "--model", default=os.environ.get("AUTORESEARCH_AUTHOR_MODEL") or "claude-opus-5"
3185
+ "--model", default=os.environ.get("OUTERLOOP_AUTHOR_MODEL") or "claude-opus-5"
3156
3186
  )
3157
3187
  parser.add_argument(
3158
3188
  "--author-backend",
3159
3189
  choices=("claude", "codex"),
3160
- default=os.environ.get("AUTORESEARCH_AUTHOR_BACKEND") or "claude",
3190
+ default=os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude",
3161
3191
  help="agent backend for the author/editor role (config-driven: default "
3162
- "from AUTORESEARCH_AUTHOR_BACKEND). codex runs contained (apptainer + "
3192
+ "from OUTERLOOP_AUTHOR_BACKEND). codex runs contained (apptainer + "
3163
3193
  "--sandbox danger-full-access) and REQUIRES --image and a codex/openai "
3164
3194
  "--model (e.g. gpt-5.6-terra).",
3165
3195
  )
@@ -3203,7 +3233,7 @@ def main() -> int:
3203
3233
  parser.add_argument("--pat-file", default=str(CONFIG_DIR / "bot_pat"))
3204
3234
  parser.add_argument(
3205
3235
  "--github-app-file",
3206
- default=os.environ.get("AUTORESEARCH_GITHUB_APP_FILE", ""),
3236
+ default=os.environ.get("OUTERLOOP_GITHUB_APP_FILE", ""),
3207
3237
  help="GitHub App config (JSON: app_id, installation_id, private_key); "
3208
3238
  "when set, installation tokens replace the PAT",
3209
3239
  )
@@ -3211,7 +3241,7 @@ def main() -> int:
3211
3241
  "--key-file",
3212
3242
  default="",
3213
3243
  help="author key file; default resolves per backend (config-driven): "
3214
- "AUTORESEARCH_HARNESS_KEY_FILE for claude, AUTORESEARCH_CODEX_KEY_FILE for codex",
3244
+ "OUTERLOOP_CLAUDE_KEY_FILE for claude, OUTERLOOP_CODEX_KEY_FILE for codex",
3215
3245
  )
3216
3246
  parser.add_argument("--issue", type=int, default=0)
3217
3247
  parser.add_argument(
@@ -3240,12 +3270,11 @@ def main() -> int:
3240
3270
  # and re-enter the decision. The wake job the WakeDispatcher submits runs
3241
3271
  # exactly this.
3242
3272
  if args.resume:
3243
- placed = bool(args.account and args.partition) or local_mode()
3244
- if not (placed and args.image and Path(args.image).is_file()):
3273
+ if not (args.image and Path(args.image).is_file()):
3245
3274
  parser.error(
3246
- "--resume needs the cluster triple (--account/--partition/--image) "
3247
- "to rebuild the dispatched measurer (local compute waives the "
3248
- "account/partition pair, never the image)"
3275
+ "--resume needs --image to rebuild the dispatched measurer "
3276
+ "(account and partition are optional: empty ones use Slurm's "
3277
+ "defaults, and local compute has no placement)"
3249
3278
  )
3250
3279
  from outerloop.runstate import load_record
3251
3280
 
@@ -3410,13 +3439,12 @@ def main() -> int:
3410
3439
  except ValueError as exc:
3411
3440
  parser.error(str(exc))
3412
3441
 
3413
- # Dispatched measurement needs the full cluster triple AND a real image
3414
- # file to bind against; missing any, the climb measures inline (the tick
3415
- # sets these on the climb job's env, a bare CLI run leaves them empty).
3442
+ # Dispatched measurement needs a real image file to bind against; without
3443
+ # one the climb measures inline. Account and partition are optional: empty
3444
+ # ones use Slurm's defaults (the tick sets them on the climb job's env from
3445
+ # the chain's), and local compute has no placement.
3416
3446
  dispatch: DispatchSettings | None = None
3417
- if (bool(args.account and args.partition) or local_mode()) and (
3418
- args.image and Path(args.image).is_file()
3419
- ):
3447
+ if args.image and Path(args.image).is_file():
3420
3448
  dispatch = _dispatch_settings(args)
3421
3449
  try:
3422
3450
  try: