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/contract.py ADDED
@@ -0,0 +1,483 @@
1
+ """Contract schema and loader for a target repo's contract file (`.outerloop.yaml`).
2
+
3
+ The contract is the opt-in declaration: benchmarks, budgets, scope. The loader
4
+ enforces invariants no YAML can override (see the threat model in
5
+ docs/design/architecture.md): autoresearch is never a target of itself, and the
6
+ contract file, the target's roadmap, and `.github/` are always forbidden write
7
+ paths, regardless of what `scope.allowed` says.
8
+
9
+ Contracts live in target repos and are therefore untrusted input: parsing
10
+ rejects aliases, duplicate keys, and oversized documents.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import posixpath
16
+ import re
17
+ from collections.abc import Callable
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Any, Literal
20
+
21
+ import yaml
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
23
+
24
+ SELF_REPO = "outerloop-science/outerloop"
25
+ # The contract's filename in the target repo. New adopters write `.outerloop.yaml`;
26
+ # `.autoresearch.yaml` (targets written before the rename) is still honored. Every
27
+ # read goes through `find_contract`, which tries the new name first. Neither is
28
+ # ever a writable path for the agent.
29
+ CONTRACT_NAMES: tuple[str, ...] = (".outerloop.yaml", ".autoresearch.yaml")
30
+ CONTRACT_NAME = CONTRACT_NAMES[0] # what the docs and new contracts use
31
+ ALWAYS_FORBIDDEN: tuple[str, ...] = (".github", *CONTRACT_NAMES)
32
+ MAX_CONTRACT_BYTES = 64 * 1024
33
+ _GLOB_CHARS = set("*?[]!")
34
+
35
+
36
+ def find_contract(read: Callable[[str], str | None]) -> tuple[str, str] | None:
37
+ """(name, text) of the first contract file `read` yields, new name first; None
38
+ when the target has neither. `read(name)` returns the file's text, or None when
39
+ that name is absent — wrap a reader that raises instead."""
40
+ for name in CONTRACT_NAMES:
41
+ text = read(name)
42
+ if text is not None:
43
+ return name, text
44
+ return None
45
+
46
+
47
+ def contract_in_tree(tree: Path) -> tuple[str, str] | None:
48
+ """The contract in a checked-out tree: (name, text), or None."""
49
+ return find_contract(lambda n: (tree / n).read_text() if (tree / n).is_file() else None)
50
+
51
+
52
+ def contract_text_in_tree(tree: Path) -> str:
53
+ """The contract's text in a checked-out tree; raises FileNotFoundError (as a
54
+ direct read did) naming both candidates when the tree has neither."""
55
+ found = contract_in_tree(tree)
56
+ if found is None:
57
+ raise FileNotFoundError(f"no contract in {tree} ({' or '.join(CONTRACT_NAMES)})")
58
+ return found[1]
59
+
60
+
61
+ class ContractError(ValueError):
62
+ """Base class for contract rejections."""
63
+
64
+
65
+ class SelfTargetError(ContractError):
66
+ """Raised when a contract names autoresearch itself as the target."""
67
+
68
+
69
+ class ScopeError(ContractError):
70
+ """Raised when an allowed path is unsafe or overlaps a forbidden path."""
71
+
72
+
73
+ class _SafeLoader(yaml.SafeLoader):
74
+ """SafeLoader that refuses alias expansion and duplicate mapping keys."""
75
+
76
+ def compose_node(self, parent: Any, index: Any) -> Any:
77
+ if self.check_event(yaml.events.AliasEvent):
78
+ raise ContractError("YAML aliases are not allowed in contracts")
79
+ return super().compose_node(parent, index)
80
+
81
+ def construct_mapping(self, node: Any, deep: bool = False) -> dict[Any, Any]:
82
+ seen = set()
83
+ for key_node, _ in node.value:
84
+ key = self.construct_object(key_node, deep=deep)
85
+ if key in seen:
86
+ raise ContractError(f"duplicate key in contract: {key!r}")
87
+ seen.add(key)
88
+ return super().construct_mapping(node, deep=deep)
89
+
90
+
91
+ class _StrictModel(BaseModel):
92
+ # Typos in a contract must fail loudly, never be silently ignored.
93
+ model_config = ConfigDict(extra="forbid")
94
+
95
+
96
+ class Benchmark(_StrictModel):
97
+ # Slug shape only: the name reaches branch names, ledger keys, and log
98
+ # labels — contract text must not shape refs or paths beyond a slug.
99
+ name: str = Field(min_length=1, pattern=r"^[A-Za-z0-9_.-]{1,64}$")
100
+ command: str = Field(min_length=1)
101
+ metric: str = Field(min_length=1)
102
+ direction: Literal["min", "max"]
103
+ # Significant digits for HUMAN surfaces (PR tables, BENCHMARKS.md,
104
+ # replies) per the benchmark's community convention. Full precision
105
+ # lives only in results/leader.json, the machine ledger (maintainer
106
+ # decision 2026-08-09). Display-only: comparisons always use full
107
+ # floats, so this can never hide or fake an improvement.
108
+ display_digits: int | None = Field(default=None, ge=2, le=12)
109
+ # Resampled-pool benchmarks: the env var the eval reads its run seed
110
+ # from (e.g. PILOT_REACH_SEED). When set, the orchestrator draws ONE
111
+ # fresh seed per measurement pass and pins BOTH sides of a comparison
112
+ # to it (paired, common random numbers), then records it in the ledger
113
+ # row — the number becomes re-derivable instead of pool luck. Strict
114
+ # env-var shape: this string reaches a subprocess environment. RULER
115
+ # INVARIANT the eval must uphold: emit the seed to stdout only, never
116
+ # persist it into the tree — a seed artifact in the workspace would be
117
+ # readable by the solver session that runs between the paired evals
118
+ # (the verifier's ruler read covers this).
119
+ seed_env: str | None = Field(default=None, pattern=r"^[A-Z][A-Z0-9_]{0,63}$")
120
+ # Expected eval duration, minutes — a HINT that turns on dispatched
121
+ # evals (docs/design/dispatcher.md): above the in-job threshold the
122
+ # orchestrator runs this benchmark's evals as their own jobs. Clamped
123
+ # by dispatch.EVAL_JOB_MINUTES_CEILING (our spend cap); it governs only
124
+ # the first eval once measured durations exist. None = in-job (today's
125
+ # behavior).
126
+ eval_minutes: int | None = Field(default=None, ge=1)
127
+ # GPUs for every dispatched job of this benchmark — gate measures and
128
+ # author launches alike. 0 (default) = CPU. A GPU benchmark needs the
129
+ # deployment to name a GPU lane (AUTORESEARCH_GPU_PARTITION, optionally
130
+ # AUTORESEARCH_GPU_ACCOUNT); without one the tick refuses to launch
131
+ # attempts on it rather than queue evals that can never run. Bounded at
132
+ # one node's worth: multi-node evals are not a shape the jail supports.
133
+ gpus: int = Field(default=0, ge=0, le=8)
134
+ # How the gate obtains the baseline number it compares a candidate to.
135
+ # paired (default): re-measure the base tree next to every candidate,
136
+ # both under one fresh seed (common random numbers) — the noise-
137
+ # minimal comparison, at two evals per attempt.
138
+ # cached: measure the base tree ONCE per (benchmark, base sha) into a
139
+ # cache shared by every attempt on that base, then run only the
140
+ # candidate at a fresh seed. Halves eval spend; the comparison is
141
+ # unpaired, so the contract's min_delta must cover cross-seed noise
142
+ # on its own (calibrate it). The ledger row says which baseline
143
+ # measurement (and seed) a credited delta was taken against.
144
+ baseline: Literal["paired", "cached"] = "paired"
145
+ # Depth budget (docs/design/research-loop.md, "one syscall, author-directed"):
146
+ # how many external experiment jobs the author may LAUNCH within one attempt.
147
+ # A generous meter on actions, not a loop the kernel drives — the author
148
+ # decides what each launch is for. Per-benchmark so "does depth pay here?"
149
+ # is answerable per benchmark. The syscalls are CONTRACT-DRIVEN and on by
150
+ # default wherever the deployment can deliver them (dispatch coords + a
151
+ # resumable backend); `depth_k: 0` is a benchmark's opt-out — the tool is
152
+ # then not offered at all. Weekly spend stays bounded by `runs_per_week`.
153
+ depth_k: int = Field(default=10, ge=0, le=16)
154
+ # Sleep budget, the sibling knob: how many dispatch->sleep->wake cycles the
155
+ # author may spend. Independent of depth_k — launches meter external compute,
156
+ # sleeps meter wake cycles (without this an author could checkpoint-refresh
157
+ # its session clock forever and never launch). Batching is rewarded: many
158
+ # launches under one sleep burn one sleep; a `submit` also rides one sleep.
159
+ sleep_k: int = Field(default=20, ge=1, le=32)
160
+ # Research lines (docs/design/research-lines.md): each agent slot works on
161
+ # its own persistent branch `agents/<agent-id>` — checked out at run start
162
+ # with the base branch merged in and instruction-bearing files reset to the
163
+ # base branch's reviewed versions. Off (default) = today's fork-main-only
164
+ # behavior; the branch substrate is inert until a contract opts in.
165
+ lines: bool = False
166
+
167
+ # Pure loop-steering dials: how often/deep the fleet iterates and how a
168
+ # number renders. Everything else — the command, metric, seed, GPUs,
169
+ # walltime (it selects the execution route), direction, floors, and
170
+ # baseline protocol — defines the measurement or the claim's meaning.
171
+ _WORKFLOW_DIALS = frozenset({"lines", "depth_k", "sleep_k", "display_digits"})
172
+
173
+ def measurement_signature(self) -> tuple:
174
+ """The fields that determine how this benchmark is measured and what
175
+ a claim about it means — every field EXCEPT the pure workflow dials,
176
+ so a future field joins the signature by default and the base-sync
177
+ skip fails toward re-measuring."""
178
+ data = self.model_dump()
179
+ return tuple(sorted((k, repr(v)) for k, v in data.items() if k not in self._WORKFLOW_DIALS))
180
+
181
+ @field_validator("seed_env")
182
+ @classmethod
183
+ def _seed_env_never_managed(cls, value: str | None) -> str | None:
184
+ from outerloop.orchestrator import managed_eval_env
185
+
186
+ if value is not None and managed_eval_env(value):
187
+ raise ValueError(
188
+ f"seed_env must not name or prefix the evaluator's managed environment ({value!r})"
189
+ )
190
+ return value
191
+
192
+ @model_validator(mode="after")
193
+ def _gpu_benchmarks_dispatch(self) -> Benchmark:
194
+ # GPUs only exist on dispatched jobs: the in-job evaluator runs inside
195
+ # the CPU climb job with no allocation and no --nv, so a GPU benchmark
196
+ # under the in-job threshold would measure on a machine with no GPU
197
+ # (terra #174 r2). Make the contract say so up front.
198
+ from outerloop.dispatch import should_dispatch
199
+
200
+ if self.gpus > 0 and not should_dispatch(self.eval_minutes):
201
+ raise ValueError(
202
+ f"benchmark {self.name!r} asks for {self.gpus} GPU(s) but its evals "
203
+ "would run in-job: set eval_minutes above the in-job threshold so they dispatch"
204
+ )
205
+ floor_set = (self.min_delta or 0) > 0 or (self.min_delta_rel or 0) > 0
206
+ if self.baseline == "cached" and not floor_set:
207
+ # an unpaired comparison has no built-in noise cancellation: the
208
+ # floor is the only thing standing between seed luck and a record
209
+ # — and benchmark_floor treats 0 as "no floor", so it must be > 0
210
+ raise ValueError(
211
+ f"benchmark {self.name!r} uses a cached baseline (unpaired comparison) "
212
+ "but declares no positive significance floor: set min_delta or "
213
+ "min_delta_rel (> 0) from a seed-variance calibration"
214
+ )
215
+ return self
216
+
217
+ # Cross-seed noise floor. A comparison against the RECORDED best was
218
+ # measured under a different seed, so a delta inside the floor is noise,
219
+ # not progress; same-seed paired comparisons are exempt by construction.
220
+ # Two forms. min_delta is absolute metric units, right for a bounded
221
+ # metric (a success rate). min_delta_rel is a fraction of the recorded
222
+ # level, right for an unbounded metric (wall-clock timing) whose level
223
+ # drifts with hardware, so an absolute number goes stale. Set either or
224
+ # both; both means the more conservative of the two applies.
225
+ min_delta: float | None = Field(default=None, ge=0)
226
+ # capped at 1.0: a noise floor is a small fraction (e.g. 0.13), so a
227
+ # value above the level itself is a typo (13 meaning 13%), and a floor
228
+ # of 13x would freeze the benchmark. A relative floor assumes a
229
+ # non-zero level; pair it with a small absolute min_delta as a backstop
230
+ # for a metric that can reach 0.
231
+ min_delta_rel: float | None = Field(default=None, ge=0, le=1.0)
232
+
233
+
234
+ class SuiteAggregate(_StrictModel):
235
+ """Unified-benchmark targets: one change is evaluated on every benchmark
236
+ and reported per-env plus this aggregate (no cherry-picking)."""
237
+
238
+ metric: str = Field(min_length=1)
239
+ direction: Literal["min", "max"]
240
+
241
+
242
+ class Budgets(_StrictModel):
243
+ # The attempt's compute allowance, METERED at the syscall for GPU
244
+ # benchmarks: every author launch (minutes x gpus) and a submit's two
245
+ # paired gate evals (2 x eval walltime x gpus) draw on it, and an
246
+ # over-budget request is refused with the numbers. The author may
247
+ # declare its own eval walltime at submit (`submit --minutes`), so a
248
+ # candidate whose eval runs longer is paid for out of this budget rather
249
+ # than killed by a fixed limit — walltime is never the metric; compute
250
+ # is priced here. 0 for CPU benchmarks (nothing to meter).
251
+ gpu_hours_per_run: float = Field(ge=0)
252
+ runs_per_week: int = Field(gt=0)
253
+ # Optional per-repo shaping of the orchestrator's session/job limits.
254
+ # These are WISHES, not grants: limits.effective_limits clamps every
255
+ # value into orchestrator-side [floor, ceiling] bounds, so a target can
256
+ # spend less of us, never more. Absent = orchestrator defaults.
257
+ session_max_turns: int | None = Field(default=None, gt=0)
258
+ session_minutes: int | None = Field(default=None, gt=0)
259
+ attempt_job_minutes: int | None = Field(default=None, gt=0)
260
+ followup_job_minutes: int | None = Field(default=None, gt=0)
261
+ # Per-benchmark self-initiated cooldown, in minutes. Default (unset) is
262
+ # the orchestrator's 6h — right for a standard research repo. An RSI /
263
+ # hot-loop target sets 0: the loop re-dispatches back-to-back and
264
+ # `runs_per_week` becomes the spend guard (Mengye: "for the RSI
265
+ # experiment — no cooldown; make sure the cluster is hot"). Still
266
+ # SERIAL per target until the width dial lands.
267
+ attempt_cooldown_minutes: int | None = Field(default=None, ge=0)
268
+ # THE WIDTH DIAL: concurrent self-initiated attempts per target. Default
269
+ # (unset) = 1, today's serial behavior. A hot-loop target raises it
270
+ # to run N authors abreast — each slot gets its own agent identity
271
+ # (agent-01..agent-0N), so branches, ledger rows, and reports stay
272
+ # distinct. runs_per_week and gpu_hours_per_run remain the spend guards.
273
+ max_active_attempts: int | None = Field(default=None, ge=1)
274
+
275
+ @model_validator(mode="before")
276
+ @classmethod
277
+ def _accept_legacy_climb_job_minutes(cls, data: Any) -> Any:
278
+ # TRANSITIONAL: the field was `climb_job_minutes`. Map the legacy key
279
+ # to the new name before validation (new name wins if BOTH appear, so
280
+ # a mid-migration contract never fails), and consume it so extra=forbid
281
+ # does not reject it. Drop this once the (two) live contracts migrate.
282
+ if isinstance(data, dict) and "climb_job_minutes" in data:
283
+ data = dict(data)
284
+ legacy = data.pop("climb_job_minutes")
285
+ data.setdefault("attempt_job_minutes", legacy)
286
+ return data
287
+
288
+
289
+ class Scope(_StrictModel):
290
+ allowed: list[str] = Field(min_length=1)
291
+ # Shared code paths (encoder / world model / training loop — code every
292
+ # benchmark exercises, as opposed to env-specific solver code). A solver
293
+ # diff touching any of these is suite-gated: the orchestrator re-measures
294
+ # EVERY sibling benchmark on both sides and refuses credit if one
295
+ # regresses beyond its own floor. Env-specific diffs stay cheap — only
296
+ # their benchmark is measured.
297
+ shared: list[str] = Field(default_factory=list)
298
+
299
+
300
+ # The orchestrator's ledger: no agent scope — solver OR steward — may
301
+ # contain these; their numbers carry orchestrator provenance only.
302
+ RECORD_PATHS = ("BENCHMARKS.md", "results/leader.json")
303
+
304
+
305
+ class StewardScope(_StrictModel):
306
+ """Paths the BENCHMARK STEWARD may edit: env generators, the eval
307
+ harness, tests, and reference data — NEVER the record ledger
308
+ (BENCHMARKS.md, results/leader.json; the orchestrator writes those
309
+ with its own measurements, and load_contract rejects a steward scope
310
+ that includes them). The solver's `scope.allowed` is implicitly
311
+ forbidden to the steward — the roles' territories must not overlap
312
+ (collusion structure, design/meta.md) — and the always-forbidden set
313
+ (this contract, `.github/`, the roadmap) binds the steward too."""
314
+
315
+ allowed: list[str] = Field(min_length=1)
316
+
317
+
318
+ class Contract(_StrictModel):
319
+ benchmarks: list[Benchmark] = Field(min_length=1)
320
+ budgets: Budgets
321
+ scope: Scope
322
+ roadmap: str = Field(min_length=1)
323
+ suite: SuiteAggregate | None = None
324
+ steward: StewardScope | None = None
325
+ # MERGE POLICY — the autonomy mode dial (docs/design/headline.md), the
326
+ # target owner's declaration like a harness permission mode:
327
+ # manual (default): the bot opens PRs and arms auto-merge only when a
328
+ # required human review stands between arming and merging.
329
+ # auto: a gate+panel-clean PR merges itself. Repo prerequisites the
330
+ # owner sets alongside this knob: "Allow auto-merge" on; branch
331
+ # protection whose required checks are the repo's own CI with
332
+ # STRICT up-to-date enforcement (the branch must match the base —
333
+ # this is what closes the race where the base moves between our
334
+ # freshness check and a direct merge: GitHub itself refuses a
335
+ # stale-base merge); NO required review. The gate is the whole
336
+ # conscience in auto mode — the floor (min_delta), the suite
337
+ # no-regression phase, and the panel's taste rubric all bind
338
+ # BEFORE publish.
339
+ merge: Literal["manual", "auto"] = "manual"
340
+
341
+ @field_validator("benchmarks")
342
+ @classmethod
343
+ def _unique_names(cls, benchmarks: list[Benchmark]) -> list[Benchmark]:
344
+ # Benchmark names are IDENTITIES: they key branch names, ledger rows,
345
+ # and dispatched measure/job names. A duplicate silently collides all
346
+ # three (two measures share an eval dir; one result is lost).
347
+ names = [b.name for b in benchmarks]
348
+ dupes = sorted({n for n in names if names.count(n) > 1})
349
+ if dupes:
350
+ raise ValueError(f"duplicate benchmark name(s): {', '.join(dupes)}")
351
+ return benchmarks
352
+
353
+
354
+ _HOST_PREFIX = re.compile(r"^(?:[a-z+]+://)?(?:[^@/]*@)?(?:www\.)?github\.com[:/]+")
355
+
356
+
357
+ def normalize_repo(target_repo: str) -> str:
358
+ """Reduce a repo reference to `owner/name`, casefolded.
359
+
360
+ Accepts bare `owner/name`, trailing `/` or `.git`, and any scheme/userinfo
361
+ URL spelling, so the self-target check can't be dodged by spelling.
362
+ """
363
+ ref = target_repo.strip().casefold()
364
+ ref = _HOST_PREFIX.sub("", ref)
365
+ ref = re.sub(r"/{2,}", "/", ref).strip("/")
366
+ if ref.endswith(".git"):
367
+ ref = ref[: -len(".git")]
368
+ return ref
369
+
370
+
371
+ def normalize_path(entry: str) -> PurePosixPath:
372
+ """Normalize a scope entry, rejecting anything that can escape the repo."""
373
+ raw = entry.strip()
374
+ if not raw or raw in {".", "./"}:
375
+ raise ScopeError("allowing the repository root is never permitted")
376
+ if raw.startswith("/") or ":" in raw or "\\" in raw:
377
+ raise ScopeError(f"path must be repo-relative and POSIX: {entry!r}")
378
+ if _GLOB_CHARS & set(raw):
379
+ raise ScopeError(f"glob patterns are not allowed in scope: {entry!r}")
380
+ normalized = posixpath.normpath(raw)
381
+ path = PurePosixPath(normalized)
382
+ if normalized in {".", ""} or any(part == ".." for part in path.parts):
383
+ raise ScopeError(f"path escapes the repository: {entry!r}")
384
+ if any(part.casefold() == ".git" for part in path.parts):
385
+ raise ScopeError(f"the git directory is never writable: {entry!r}")
386
+ return path
387
+
388
+
389
+ def forbidden_paths(contract: Contract) -> tuple[str, ...]:
390
+ """Write paths forbidden for this contract: the hard-coded set plus the
391
+ target's roadmap."""
392
+ return (*ALWAYS_FORBIDDEN, str(normalize_path(contract.roadmap)))
393
+
394
+
395
+ def _fold(path: PurePosixPath) -> PurePosixPath:
396
+ """Casefold components: `.GITHUB/x` must be as forbidden as `.github/x`."""
397
+ return PurePosixPath(*[part.casefold() for part in path.parts])
398
+
399
+
400
+ def path_is_forbidden(candidate: str, contract: Contract) -> bool:
401
+ """True if `candidate` (a repo-relative file path) may not be written.
402
+
403
+ Component-wise and case-insensitive, so `README.mdx` is not shadowed by
404
+ roadmap `README.md` but `.GITHUB/` is still blocked. Anything
405
+ unnormalizable counts as forbidden.
406
+ """
407
+ try:
408
+ path = _fold(normalize_path(candidate))
409
+ except ScopeError:
410
+ return True
411
+ if any(part == ".git" for part in path.parts):
412
+ return True # never write into the git directory itself
413
+ for forbidden in forbidden_paths(contract):
414
+ f = _fold(PurePosixPath(forbidden))
415
+ if path == f or f in path.parents:
416
+ return True
417
+ return False
418
+
419
+
420
+ def _overlaps(allowed: PurePosixPath, forbidden: PurePosixPath) -> bool:
421
+ a, f = _fold(allowed), _fold(forbidden)
422
+ return a == f or f in a.parents or a in f.parents
423
+
424
+
425
+ def load_contract(text: str, target_repo: str) -> Contract:
426
+ """Parse and validate a contract for `target_repo`."""
427
+ if normalize_repo(target_repo) == SELF_REPO:
428
+ raise SelfTargetError("autoresearch is never a valid target of itself")
429
+ if len(text.encode()) > MAX_CONTRACT_BYTES:
430
+ raise ContractError(f"contract exceeds {MAX_CONTRACT_BYTES} bytes")
431
+ try:
432
+ data = yaml.load(text, Loader=_SafeLoader)
433
+ except ContractError:
434
+ raise
435
+ except (yaml.YAMLError, TypeError, ValueError) as exc:
436
+ raise ContractError(f"unparseable contract: {type(exc).__name__}") from None
437
+ if not isinstance(data, dict):
438
+ raise ContractError("contract must be a YAML mapping")
439
+ contract = Contract.model_validate(data)
440
+ forbidden = [PurePosixPath(p) for p in forbidden_paths(contract)]
441
+ for entry in contract.scope.allowed:
442
+ allowed = normalize_path(entry)
443
+ for path in forbidden:
444
+ if _overlaps(allowed, path):
445
+ raise ScopeError(f"allowed path {entry!r} overlaps forbidden {str(path)!r}")
446
+ # Shared paths route the suite gate off changed paths, which are already
447
+ # scope-checked — but a malformed entry would silently never match, so
448
+ # the same load-time rigor applies. A shared path the agent can never
449
+ # touch (no overlap with any allowed path) is dead config that reads as
450
+ # protection: refused loudly, like any contract typo.
451
+ solver_allowed = [normalize_path(entry) for entry in contract.scope.allowed]
452
+ for entry in contract.scope.shared:
453
+ shared = normalize_path(entry)
454
+ for path in forbidden:
455
+ if _overlaps(shared, path):
456
+ raise ScopeError(f"shared path {entry!r} overlaps forbidden {str(path)!r}")
457
+ if not any(_overlaps(shared, a) for a in solver_allowed):
458
+ raise ScopeError(
459
+ f"shared path {entry!r} overlaps no allowed path — the suite "
460
+ f"gate could never trigger on it"
461
+ )
462
+ if contract.steward is not None:
463
+ # same rigor as the solver scope, plus the role-separation invariant:
464
+ # steward and solver territories must not overlap AT LOAD TIME — a
465
+ # malformed or colliding entry is a contract error, never a
466
+ # mid-run surprise
467
+ solver = [normalize_path(entry) for entry in contract.scope.allowed]
468
+ records = [PurePosixPath(p) for p in RECORD_PATHS]
469
+ for entry in contract.steward.allowed:
470
+ allowed = normalize_path(entry)
471
+ for path in forbidden:
472
+ if _overlaps(allowed, path):
473
+ raise ScopeError(f"steward path {entry!r} overlaps forbidden {str(path)!r}")
474
+ for sp in solver:
475
+ if _overlaps(allowed, sp):
476
+ raise ScopeError(f"steward path {entry!r} overlaps solver scope {str(sp)!r}")
477
+ for rp in records:
478
+ if _overlaps(allowed, rp):
479
+ raise ScopeError(
480
+ f"steward path {entry!r} overlaps the record ledger "
481
+ f"{str(rp)!r} (orchestrator-owned)"
482
+ )
483
+ return contract
@@ -0,0 +1,63 @@
1
+ """Validate a contract file (`.outerloop.yaml`) before you push it.
2
+
3
+ uv run python -m outerloop.contract_cli .outerloop.yaml
4
+
5
+ Prints what the agent would be allowed to do, or exactly what is wrong.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from outerloop.contract import (
14
+ CONTRACT_NAME,
15
+ CONTRACT_NAMES,
16
+ ContractError,
17
+ forbidden_paths,
18
+ load_contract,
19
+ )
20
+
21
+
22
+ def main(argv: list[str] | None = None) -> int:
23
+ args = argv if argv is not None else sys.argv[1:]
24
+ # no arg: whichever contract name the cwd has, new name first
25
+ path = (
26
+ Path(args[0])
27
+ if args
28
+ else next((Path(n) for n in CONTRACT_NAMES if Path(n).is_file()), Path(CONTRACT_NAME))
29
+ )
30
+ repo = args[1] if len(args) > 1 else "your-org/your-repo"
31
+
32
+ if not path.is_file():
33
+ print(f"✗ no contract at {path}")
34
+ return 2
35
+ try:
36
+ contract = load_contract(path.read_text(), repo)
37
+ except ContractError as exc:
38
+ print(f"✗ {path}: {exc}")
39
+ return 1
40
+
41
+ print(f"✓ {path} is valid\n")
42
+ print("Benchmarks the agent will try to improve:")
43
+ for benchmark in contract.benchmarks:
44
+ arrow = "↓ lower is better" if benchmark.direction == "min" else "↑ higher is better"
45
+ print(f" • {benchmark.name}: {benchmark.metric} ({arrow})")
46
+ print(f" $ {benchmark.command}")
47
+ if contract.suite is not None:
48
+ print(f"\nSuite aggregate: {contract.suite.metric} ({contract.suite.direction})")
49
+ print("\nThe agent may write only to:")
50
+ for allowed in contract.scope.allowed:
51
+ print(f" • {allowed}")
52
+ print("\nAlways forbidden, whatever the contract says:")
53
+ for forbidden in forbidden_paths(contract):
54
+ print(f" • {forbidden}")
55
+ print(
56
+ f"\nBudget: {contract.budgets.gpu_hours_per_run} GPU-hours per run, "
57
+ f"{contract.budgets.runs_per_week} runs per week"
58
+ )
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ sys.exit(main())