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