ossa 0.1.0a1__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.
- ossa/__init__.py +2 -0
- ossa/arena.py +591 -0
- ossa/audit.py +114 -0
- ossa/breaker.py +341 -0
- ossa/card.py +86 -0
- ossa/cli.py +852 -0
- ossa/config.py +170 -0
- ossa/context.py +43 -0
- ossa/dash.py +324 -0
- ossa/data/contribute.yml +135 -0
- ossa/data/optout.txt +4 -0
- ossa/diffpolicy.py +141 -0
- ossa/gh.py +1086 -0
- ossa/gitops.py +228 -0
- ossa/history.py +118 -0
- ossa/hunt.py +199 -0
- ossa/janitor.py +379 -0
- ossa/linked.py +154 -0
- ossa/platforms.py +36 -0
- ossa/policy.py +408 -0
- ossa/rank.py +127 -0
- ossa/replay.py +202 -0
- ossa/sandbox.py +282 -0
- ossa/schemas.py +301 -0
- ossa/stages/__init__.py +0 -0
- ossa/stages/_b_helpers.py +123 -0
- ossa/stages/s00_scout.py +328 -0
- ossa/stages/s01_assess.py +400 -0
- ossa/stages/s02_pick_issue.py +211 -0
- ossa/stages/s03_workspace.py +179 -0
- ossa/stages/s06_reproduce.py +94 -0
- ossa/stages/s07_claim.py +124 -0
- ossa/stages/s08_fix.py +107 -0
- ossa/stages/s13_closeout.py +106 -0
- ossa/starhistory.py +85 -0
- ossa/store.py +359 -0
- ossa/testparse.py +154 -0
- ossa/toolbox.py +1016 -0
- ossa/trust.py +138 -0
- ossa/verify.py +205 -0
- ossa/workspace.py +279 -0
- ossa-0.1.0a1.dist-info/METADATA +212 -0
- ossa-0.1.0a1.dist-info/RECORD +47 -0
- ossa-0.1.0a1.dist-info/WHEEL +5 -0
- ossa-0.1.0a1.dist-info/entry_points.txt +2 -0
- ossa-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- ossa-0.1.0a1.dist-info/top_level.txt +1 -0
ossa/__init__.py
ADDED
ossa/arena.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""Breaker Arena: score Breaker on real merged fixes and their plausible-but-wrong twins. No LLM here.
|
|
2
|
+
|
|
3
|
+
An instance is a merged PR that closes an issue opened after the models' training cutoff and adds
|
|
4
|
+
tests (replay.mine_instances). The human fix is the reference, and its tests stay hidden.
|
|
5
|
+
|
|
6
|
+
mine instances from a repo's merged PRs (hidden: runs/arena/hidden/<instance>/)
|
|
7
|
+
env a frozen copy of the repo at the instance's base (history ends there, so the later fix
|
|
8
|
+
can't be found) and an env attempt; Sherlock builds the sandbox on it with `ossa env`
|
|
9
|
+
prepare checks the hidden tests fail on base and pass with the human fix (F2P, P2P), then makes
|
|
10
|
+
wrong candidates: mutants of the human fix (flipped comparisons, booleans, constants,
|
|
11
|
+
arithmetic) and partial fixes (a hunk, a file or a line left out). A candidate counts
|
|
12
|
+
only if it is plausible (every P2P test still passes) and wrong (an F2P test fails).
|
|
13
|
+
next one candidate at a time (the human fix or a wrong one, in a shuffled order) becomes a
|
|
14
|
+
blind duel attempt: base + candidate as the "verified fix", the issue text, no tests.
|
|
15
|
+
Breaker attacks it with the ordinary `ossa attack`, as in a real duel.
|
|
16
|
+
score re-runs every attack against the hidden human fix (the verifiable reward):
|
|
17
|
+
caught fails on a wrong candidate, passes on the human fix +1
|
|
18
|
+
false_alarm fails on the human fix -1
|
|
19
|
+
missed holds on a wrong candidate 0
|
|
20
|
+
held holds on the human fix (as it should) 0
|
|
21
|
+
invalid never exercised the code, or flaky 0
|
|
22
|
+
solve Patch's side: an attempt at base from the issue alone; `grade` runs the hidden tests on
|
|
23
|
+
its verified fix (resolved = every F2P passes and no P2P breaks)
|
|
24
|
+
report catch rate (wrong candidates with at least one caught attack), false-alarm rate (human
|
|
25
|
+
fixes with at least one false alarm), by Breaker prompt version and model.
|
|
26
|
+
The crew never reads runs/arena/hidden/ (the guard blocks it); everything there is for scoring.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import random
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
from pathlib import Path, PurePosixPath
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from ossa.schemas import Attempt, IssueRef, State
|
|
39
|
+
from ossa.store import now
|
|
40
|
+
from ossa.toolbox import ToolError, _attempt, _pristine
|
|
41
|
+
|
|
42
|
+
WRONG_PER_INSTANCE = 3
|
|
43
|
+
MAX_TRIES = 12
|
|
44
|
+
HUNK = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$")
|
|
45
|
+
OPERATORS: list[tuple[str, str, Any]] = [ # (name, pattern, replacement): one change per mutant
|
|
46
|
+
("== to !=", r"==", "!="), ("!= to ==", r"!=", "=="),
|
|
47
|
+
("<= to <", r"<=", "<"), (">= to >", r">=", ">"),
|
|
48
|
+
("< to <=", r"(?<![<\-=])<(?![<=\-])", "<="), ("> to >=", r"(?<![>\-=])>(?![>=])", ">="),
|
|
49
|
+
("and to or", r"\band\b", "or"), ("or to and", r"\bor\b", "and"),
|
|
50
|
+
("&& to ||", r"&&", "||"), ("|| to &&", r"\|\|", "&&"),
|
|
51
|
+
("drop not", r"\bnot\s+", ""), ("drop !", r"!(?=[A-Za-z_(])", ""),
|
|
52
|
+
("+ to -", r"(?<=\s)\+(?=\s)", "-"), ("- to +", r"(?<=\s)-(?=\s)", "+"),
|
|
53
|
+
("True to False", r"\bTrue\b", "False"), ("False to True", r"\bFalse\b", "True"),
|
|
54
|
+
("true to false", r"\btrue\b", "false"), ("false to true", r"\bfalse\b", "true"),
|
|
55
|
+
("is None to is not None", r"\bis None\b", "is not None"), ("is not None to is None", r"\bis not None\b", "is None"),
|
|
56
|
+
("n to n+1", r"(?<![\w.])(\d{1,4})(?![\w.])", lambda m: str(int(m.group(1)) + 1)),
|
|
57
|
+
]
|
|
58
|
+
COMMENT = re.compile(r"^\s*(#|//|/\*|\*)")
|
|
59
|
+
OPENER = re.compile(r"(:|\{|\(|\[|,)\s*$|^\s*(\}|\)|\]|else\b|elif\b|except\b|finally\b|try\b|def |class |@|return\s*$)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _hash(text: str) -> str:
|
|
63
|
+
return hashlib.sha1(text.encode()).hexdigest()[:12]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _arena(ctx) -> Path:
|
|
67
|
+
return ctx.config.path("runs") / "arena"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def hidden_dir(ctx, iid: str) -> Path:
|
|
71
|
+
return _arena(ctx) / "hidden" / iid
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _row(ctx, iid: str) -> dict:
|
|
75
|
+
rows = ctx.store.query("SELECT * FROM arena_instances WHERE id=?", (iid,))
|
|
76
|
+
if not rows:
|
|
77
|
+
raise ToolError(f"no Arena instance {iid} (`ossa arena status` lists them)")
|
|
78
|
+
d = dict(rows[0])
|
|
79
|
+
d["f2p"] = json.loads(d.pop("f2p_json") or "[]")
|
|
80
|
+
d["p2p"] = json.loads(d.pop("p2p_json") or "[]")
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _update(ctx, iid: str, **fields: Any) -> None:
|
|
85
|
+
fields["updated_at"] = now()
|
|
86
|
+
sets = ", ".join(f"{k}=?" for k in fields)
|
|
87
|
+
ctx.store.execute(f"UPDATE arena_instances SET {sets} WHERE id=?", (*fields.values(), iid))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _instance(ctx, iid: str) -> dict:
|
|
91
|
+
return json.loads((hidden_dir(ctx, iid) / "instance.json").read_text())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _blind_issue(inst: dict) -> IssueRef:
|
|
95
|
+
"""The issue as it was reported: no thread, no URL (nothing that points at the fix)."""
|
|
96
|
+
i = IssueRef.model_validate(inst["issue"])
|
|
97
|
+
return i.model_copy(update={"comments": [], "url": ""})
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _move(a: Attempt, dest: Path) -> None:
|
|
101
|
+
"""Put an attempt's artifacts somewhere else (blind, hidden, solve folders) and remember it."""
|
|
102
|
+
old = Path(a.artifacts_dir)
|
|
103
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
if old.exists() and old != dest:
|
|
105
|
+
shutil.move(str(old), str(dest))
|
|
106
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
a.artifacts_dir = str(dest)
|
|
108
|
+
a.ctx.store.save_attempt(a)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------ mining
|
|
112
|
+
def mine(ctx, repo: str, n: int = 5, allow_contaminated: bool = False) -> list[dict]:
|
|
113
|
+
"""New instances from `repo`'s merged PRs (newest first). Already-mined ones are listed again."""
|
|
114
|
+
from ossa import replay
|
|
115
|
+
out = []
|
|
116
|
+
for inst in replay.mine_instances(ctx, repo, n, allow_contaminated=allow_contaminated):
|
|
117
|
+
iid = f"{repo.replace('/', '__')}-pr{inst['pr']}"
|
|
118
|
+
summary = {"instance": iid, "pr": inst["pr"], "issue": inst["issue"]["number"],
|
|
119
|
+
"contaminated": inst["contaminated"]}
|
|
120
|
+
if ctx.store.query("SELECT id FROM arena_instances WHERE id=?", (iid,)):
|
|
121
|
+
out.append({**summary, "new": False})
|
|
122
|
+
continue
|
|
123
|
+
h = hidden_dir(ctx, iid)
|
|
124
|
+
h.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
(h / "instance.json").write_text(json.dumps(inst, indent=1))
|
|
126
|
+
ctx.store.execute(
|
|
127
|
+
"INSERT INTO arena_instances(id, repo, pr, issue, base_sha, issue_created_at, contaminated, state, "
|
|
128
|
+
"created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
|
|
129
|
+
(iid, repo, inst["pr"], inst["issue"]["number"], inst["base_sha"], inst.get("issue_created_at"),
|
|
130
|
+
int(bool(inst["contaminated"])), "mined", now(), now()))
|
|
131
|
+
out.append({**summary, "new": True})
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ------------------------------------------------------------------ the frozen repo and the env
|
|
136
|
+
def freeze(ctx, repo: str, base_sha: str) -> Path:
|
|
137
|
+
"""A repo whose only history ends at `base_sha`. It borrows the upstream clone's objects
|
|
138
|
+
(git alternates), so it costs no disk, but no ref in it leads past the base."""
|
|
139
|
+
from ossa import gitops
|
|
140
|
+
from ossa.stages import s03_workspace as W
|
|
141
|
+
dest = W.frozen_dir(ctx, repo, base_sha)
|
|
142
|
+
if (dest / ".git").exists():
|
|
143
|
+
return dest
|
|
144
|
+
upstream = W.clone_dir(ctx, repo)
|
|
145
|
+
if not ((upstream / ".git").exists() and W._has_commit(upstream, base_sha)):
|
|
146
|
+
gitops.ensure_clone(W.clone_url(repo), upstream)
|
|
147
|
+
if not W._has_commit(upstream, base_sha):
|
|
148
|
+
raise ToolError(f"{base_sha[:12]} isn't in {repo}'s history")
|
|
149
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
gitops.git(dest, "init", "-q")
|
|
151
|
+
(dest / ".git" / "objects" / "info" / "alternates").write_text(f"{(upstream / '.git' / 'objects').resolve()}\n")
|
|
152
|
+
gitops.git(dest, "update-ref", "refs/heads/base", base_sha)
|
|
153
|
+
gitops.git(dest, "checkout", "-q", "--detach", base_sha)
|
|
154
|
+
return dest
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _copy_env(src: Attempt, dst: Attempt) -> None:
|
|
158
|
+
for name in ("env.json", "env/image.txt", "env/Dockerfile", "baseline.json"):
|
|
159
|
+
text = src.ctx.store.read_artifact(src, name)
|
|
160
|
+
if text is not None:
|
|
161
|
+
dst.ctx.store.write_artifact(dst, name, text)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _new(ctx, row: dict, kind: str, folder: Path, branch: str) -> Attempt:
|
|
165
|
+
a = ctx.store.new_attempt(row["repo"], f"{row['repo']}#{row['issue']}", kind=kind)
|
|
166
|
+
a.ctx, a.base_sha, a.branch = ctx, row["base_sha"], f"{branch}/{a.id[-10:].lower()}"
|
|
167
|
+
_move(a, folder / a.id)
|
|
168
|
+
ctx.store.write_artifact(a, "issue.json", _blind_issue(_instance(ctx, row["id"])).model_dump(mode="json"))
|
|
169
|
+
return a
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def env_attempt(ctx, iid: str) -> Attempt:
|
|
173
|
+
"""The attempt Sherlock builds the instance's sandbox on (`ossa env <attempt> ...`)."""
|
|
174
|
+
from ossa.stages import s03_workspace
|
|
175
|
+
row = _row(ctx, iid)
|
|
176
|
+
if row["env_attempt"]:
|
|
177
|
+
return _attempt(ctx, row["env_attempt"])
|
|
178
|
+
freeze(ctx, row["repo"], row["base_sha"])
|
|
179
|
+
a = _new(ctx, row, "arena-env", _arena(ctx) / "env", "arena-env")
|
|
180
|
+
s03_workspace.prepare_worktree(a, _blind_issue(_instance(ctx, iid)))
|
|
181
|
+
a.state = State.ISSUE_SELECTED
|
|
182
|
+
ctx.store.save_attempt(a)
|
|
183
|
+
_update(ctx, iid, env_attempt=a.id)
|
|
184
|
+
return a
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _env(ctx, row: dict) -> Attempt:
|
|
188
|
+
from ossa.stages import s03_workspace
|
|
189
|
+
if not row["env_attempt"]:
|
|
190
|
+
raise ToolError(f"no sandbox yet: `ossa arena env {row['id']}`, then `ossa env <attempt> ...`")
|
|
191
|
+
a = _attempt(ctx, row["env_attempt"])
|
|
192
|
+
try:
|
|
193
|
+
s03_workspace.load_env(a)
|
|
194
|
+
except Exception: # noqa: BLE001 - Blocked: env.json missing
|
|
195
|
+
raise ToolError(f"build the sandbox first: `ossa env {a.id} --dockerfile ... --test ...`") from None
|
|
196
|
+
return a
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _suite(env: Attempt, patches: list[str]):
|
|
200
|
+
"""The full suite on base + patches in a clean worktree; None when a patch doesn't apply."""
|
|
201
|
+
from ossa import gitops
|
|
202
|
+
from ossa.stages import s03_workspace
|
|
203
|
+
spec, image = s03_workspace.load_env(env)
|
|
204
|
+
wt = _pristine(env, with_repro=False)
|
|
205
|
+
for p in patches:
|
|
206
|
+
if p.strip():
|
|
207
|
+
ok, _ = gitops.apply_patch(wt, p)
|
|
208
|
+
if not ok:
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
return env.ctx.sandbox.run_tests(wt, image, spec)
|
|
212
|
+
finally:
|
|
213
|
+
gitops.git(wt, "clean", "-fdq", check=False)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------ wrong candidates
|
|
217
|
+
def recount(patch: str) -> str:
|
|
218
|
+
"""Fix every hunk header's line counts after lines were added or dropped."""
|
|
219
|
+
lines, out, i = patch.splitlines(keepends=True), [], 0
|
|
220
|
+
while i < len(lines):
|
|
221
|
+
m = HUNK.match(lines[i].rstrip("\n"))
|
|
222
|
+
if not m:
|
|
223
|
+
out.append(lines[i])
|
|
224
|
+
i += 1
|
|
225
|
+
continue
|
|
226
|
+
j, old, new = i + 1, 0, 0
|
|
227
|
+
while j < len(lines) and not lines[j].startswith(("@@", "diff --git")):
|
|
228
|
+
line = lines[j]
|
|
229
|
+
if line.startswith("\\"):
|
|
230
|
+
pass
|
|
231
|
+
elif line.startswith("+"):
|
|
232
|
+
new += 1
|
|
233
|
+
elif line.startswith("-"):
|
|
234
|
+
old += 1
|
|
235
|
+
else:
|
|
236
|
+
old += 1
|
|
237
|
+
new += 1
|
|
238
|
+
j += 1
|
|
239
|
+
out.append(f"@@ -{m.group(1)},{old} +{m.group(2)},{new} @@{m.group(3)}\n")
|
|
240
|
+
out.extend(lines[i + 1:j])
|
|
241
|
+
i = j
|
|
242
|
+
return "".join(out)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _sections(patch: str) -> list[str]:
|
|
246
|
+
"""Per-file sections of a git diff."""
|
|
247
|
+
parts = re.split(r"(?m)^(?=diff --git )", patch)
|
|
248
|
+
return [p for p in parts if p.strip()]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _hunks(section: str) -> tuple[str, list[str]]:
|
|
252
|
+
"""(file header, hunks) of one file section."""
|
|
253
|
+
parts = re.split(r"(?m)^(?=@@ )", section)
|
|
254
|
+
return parts[0], parts[1:]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _in_string(line: str, pos: int) -> bool:
|
|
258
|
+
before = line[:pos]
|
|
259
|
+
return (before.count('"') - before.count('\\"')) % 2 == 1 or (before.count("'") - before.count("\\'")) % 2 == 1
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def mutants(patch: str) -> list[tuple[str, str]]:
|
|
263
|
+
"""(label, patch) for every single-change mutant and partial fix of `patch`, deduplicated."""
|
|
264
|
+
out: dict[str, str] = {}
|
|
265
|
+
sections = _sections(patch)
|
|
266
|
+
if len(sections) > 1:
|
|
267
|
+
for i, sec in enumerate(sections):
|
|
268
|
+
path = re.search(r"^diff --git a/(\S+)", sec, re.M)
|
|
269
|
+
out.setdefault("".join(sections[:i] + sections[i + 1:]),
|
|
270
|
+
f"partial: leaves out {path.group(1) if path else f'file {i + 1}'}")
|
|
271
|
+
for si, sec in enumerate(sections):
|
|
272
|
+
head, hunks = _hunks(sec)
|
|
273
|
+
if len(hunks) > 1:
|
|
274
|
+
for hi in range(len(hunks)):
|
|
275
|
+
rest = head + "".join(hunks[:hi] + hunks[hi + 1:])
|
|
276
|
+
out.setdefault("".join(sections[:si] + [rest] + sections[si + 1:]), f"partial: leaves out hunk {hi + 1}")
|
|
277
|
+
lines = patch.splitlines(keepends=True)
|
|
278
|
+
in_hunk = False
|
|
279
|
+
for idx, line in enumerate(lines):
|
|
280
|
+
if line.startswith("@@"):
|
|
281
|
+
in_hunk = True
|
|
282
|
+
continue
|
|
283
|
+
if line.startswith("diff --git"):
|
|
284
|
+
in_hunk = False
|
|
285
|
+
if not in_hunk or not line.startswith("+") or line.startswith("+++"):
|
|
286
|
+
continue
|
|
287
|
+
body = line[1:]
|
|
288
|
+
if not body.strip() or COMMENT.match(body):
|
|
289
|
+
continue
|
|
290
|
+
for name, pattern, repl in OPERATORS:
|
|
291
|
+
m = next((m for m in re.finditer(pattern, body) if not _in_string(body, m.start())), None)
|
|
292
|
+
if m is None:
|
|
293
|
+
continue
|
|
294
|
+
new = body[:m.start()] + (repl(m) if callable(repl) else repl) + body[m.end():]
|
|
295
|
+
text = "".join(lines[:idx] + ["+" + new] + lines[idx + 1:])
|
|
296
|
+
out.setdefault(text, f"mutant: {name} on line {idx + 1}")
|
|
297
|
+
if not OPENER.search(body.rstrip("\n")):
|
|
298
|
+
text = recount("".join(lines[:idx] + lines[idx + 1:]))
|
|
299
|
+
out.setdefault(text, f"partial: drops line {idx + 1}")
|
|
300
|
+
out.pop(patch, None)
|
|
301
|
+
return [(label, text) for text, label in out.items() if not is_noop(text)]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def is_noop(patch: str) -> bool:
|
|
305
|
+
"""True when every hunk adds back exactly the lines it removes (a "fix" that changes nothing)."""
|
|
306
|
+
for sec in _sections(patch):
|
|
307
|
+
for hunk in _hunks(sec)[1]:
|
|
308
|
+
rows = hunk.splitlines()[1:]
|
|
309
|
+
if sorted(r[1:] for r in rows if r.startswith("-")) != sorted(r[1:] for r in rows if r.startswith("+")):
|
|
310
|
+
return False
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def plausible_and_wrong(tests: dict[str, str], f2p: list[str], p2p: list[str]) -> bool:
|
|
315
|
+
return all(tests.get(t) == "passed" for t in p2p) and any(tests.get(t) != "passed" for t in f2p)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def prepare(ctx, iid: str, wrong: int = WRONG_PER_INSTANCE, tries: int = MAX_TRIES) -> dict:
|
|
319
|
+
"""Validate the instance in the sandbox and make its candidates (shuffled; labels stay hidden)."""
|
|
320
|
+
from ossa import replay
|
|
321
|
+
row = _row(ctx, iid)
|
|
322
|
+
env, inst = _env(ctx, row), _instance(ctx, iid)
|
|
323
|
+
human, tests = inst["human_patch"], inst["human_tests_patch"]
|
|
324
|
+
on_base, with_fix = _suite(env, [tests]), _suite(env, [human, tests])
|
|
325
|
+
if on_base is None or with_fix is None:
|
|
326
|
+
_update(ctx, iid, state="invalid", note="the human patch or tests don't apply to base")
|
|
327
|
+
return {"instance": iid, "valid": False, "why": "the human patch or tests don't apply to base"}
|
|
328
|
+
f2p, p2p = replay.f2p_p2p(on_base.tests, with_fix.tests)
|
|
329
|
+
if not f2p:
|
|
330
|
+
_update(ctx, iid, state="invalid", note="no hidden test fails on base and passes with the fix")
|
|
331
|
+
return {"instance": iid, "valid": False, "why": "no hidden test fails on base and passes with the fix"}
|
|
332
|
+
rng = random.Random(iid)
|
|
333
|
+
pool = mutants(human)
|
|
334
|
+
rng.shuffle(pool)
|
|
335
|
+
kept, tried = [], 0
|
|
336
|
+
for label, patch in pool[:tries]:
|
|
337
|
+
if len(kept) >= wrong:
|
|
338
|
+
break
|
|
339
|
+
tried += 1
|
|
340
|
+
result = _suite(env, [patch, tests])
|
|
341
|
+
if result is not None and plausible_and_wrong(result.tests, f2p, p2p):
|
|
342
|
+
kept.append((label, patch, "partial" if label.startswith("partial") else "mutant"))
|
|
343
|
+
cands = [("human fix", human, "human")] + kept
|
|
344
|
+
rng.shuffle(cands)
|
|
345
|
+
ctx.store.execute("DELETE FROM arena_candidates WHERE instance_id=? AND state='waiting'", (iid,))
|
|
346
|
+
cdir = hidden_dir(ctx, iid) / "candidates"
|
|
347
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
for k, (label, patch, kind) in enumerate(cands, 1):
|
|
349
|
+
(cdir / f"{k}.patch").write_text(patch)
|
|
350
|
+
ctx.store.execute("INSERT OR REPLACE INTO arena_candidates(instance_id, k, kind, label, patch_hash, state) "
|
|
351
|
+
"VALUES(?,?,?,?,?,?)", (iid, k, kind, label, _hash(patch), "waiting"))
|
|
352
|
+
_update(ctx, iid, state="ready", f2p_json=json.dumps(f2p), p2p_json=json.dumps(p2p), note="")
|
|
353
|
+
return {"instance": iid, "valid": True, "f2p": len(f2p), "p2p": len(p2p), "candidates": len(cands),
|
|
354
|
+
"mutants_tried": tried}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def add_candidate(ctx, iid: str, patch_file: str, label: str) -> dict:
|
|
358
|
+
"""Add a wrong fix by hand (for example one of Patch's failed candidates). It must be plausible
|
|
359
|
+
and wrong, like the generated ones."""
|
|
360
|
+
row = _row(ctx, iid)
|
|
361
|
+
if row["state"] != "ready":
|
|
362
|
+
raise ToolError("prepare the instance first: `ossa arena prepare`")
|
|
363
|
+
patch = Path(patch_file).read_text()
|
|
364
|
+
result = _suite(_env(ctx, row), [patch, _instance(ctx, iid)["human_tests_patch"]])
|
|
365
|
+
if result is None or not plausible_and_wrong(result.tests, row["f2p"], row["p2p"]):
|
|
366
|
+
raise ToolError("not a usable wrong candidate: it must apply, keep every P2P test passing and fail an F2P test")
|
|
367
|
+
k = ctx.store.query("SELECT COALESCE(MAX(k), 0) AS m FROM arena_candidates WHERE instance_id=?", (iid,))[0]["m"] + 1
|
|
368
|
+
(hidden_dir(ctx, iid) / "candidates" / f"{k}.patch").write_text(patch)
|
|
369
|
+
ctx.store.execute("INSERT INTO arena_candidates(instance_id, k, kind, label, patch_hash, state) VALUES(?,?,?,?,?,?)",
|
|
370
|
+
(iid, k, "added", label[:200], _hash(patch), "waiting"))
|
|
371
|
+
return {"instance": iid, "added": True}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# ------------------------------------------------------------------ rounds
|
|
375
|
+
def next_candidate(ctx, iid: str = "") -> dict:
|
|
376
|
+
"""Turn the next waiting candidate into a blind duel attempt for Breaker. One at a time."""
|
|
377
|
+
from ossa import gitops
|
|
378
|
+
from ossa.stages import s03_workspace
|
|
379
|
+
active = ctx.store.query("SELECT attempt_id FROM arena_candidates WHERE state='active'")
|
|
380
|
+
if active:
|
|
381
|
+
raise ToolError(f"score the current candidate first: `ossa arena score {active[0]['attempt_id']}`")
|
|
382
|
+
sql = ("SELECT c.instance_id, c.k FROM arena_candidates c JOIN arena_instances i ON i.id=c.instance_id "
|
|
383
|
+
"WHERE c.state='waiting' AND i.state='ready'" + (" AND c.instance_id=?" if iid else "")
|
|
384
|
+
+ " ORDER BY i.created_at, c.k LIMIT 1")
|
|
385
|
+
rows = ctx.store.query(sql, (iid,) if iid else ())
|
|
386
|
+
if not rows:
|
|
387
|
+
return {"attempt": None, "note": "no candidates waiting: mine and prepare more instances"}
|
|
388
|
+
iid, k = rows[0]["instance_id"], rows[0]["k"]
|
|
389
|
+
row = _row(ctx, iid)
|
|
390
|
+
env = _env(ctx, row)
|
|
391
|
+
patch = (hidden_dir(ctx, iid) / "candidates" / f"{k}.patch").read_text()
|
|
392
|
+
a = _new(ctx, row, "arena", _arena(ctx) / "blind", "arena")
|
|
393
|
+
_copy_env(env, a)
|
|
394
|
+
for name in ("fix.patch", "final.patch"):
|
|
395
|
+
ctx.store.write_artifact(a, name, patch)
|
|
396
|
+
ctx.store.write_artifact(a, "verify.json", {"verified": True})
|
|
397
|
+
wt = gitops.add_worktree(s03_workspace.repo_dir(a), s03_workspace.worktree_for(a), a.branch, a.base_sha)
|
|
398
|
+
ok, msg = gitops.apply_patch(wt, patch)
|
|
399
|
+
if not ok:
|
|
400
|
+
raise ToolError(f"candidate {k} of {iid} no longer applies: {msg[:200]}")
|
|
401
|
+
a.state = State.FIXED
|
|
402
|
+
ctx.store.save_attempt(a)
|
|
403
|
+
ctx.store.execute("UPDATE arena_candidates SET state='active', attempt_id=? WHERE instance_id=? AND k=?",
|
|
404
|
+
(a.id, iid, k))
|
|
405
|
+
return {"attempt": a.id, "worktree": str(wt), "artifacts": a.artifacts_dir,
|
|
406
|
+
"next": "start the breaker subagent with only the attempt id, the artifacts dir and the worktree; "
|
|
407
|
+
f"when its duel is done, `ossa arena score {a.id}`"}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _attack_on(env: Attempt, patch: str, dest: str, text: str, command: str) -> str:
|
|
411
|
+
"""passes | fails | bad: one attack test on base + `patch` (the hidden reference)."""
|
|
412
|
+
from ossa import gitops
|
|
413
|
+
from ossa.stages import s03_workspace
|
|
414
|
+
from ossa.stages.s06_reproduce import classify_failure
|
|
415
|
+
_, image = s03_workspace.load_env(env)
|
|
416
|
+
wt = _pristine(env, with_repro=False)
|
|
417
|
+
ok, _ = gitops.apply_patch(wt, patch)
|
|
418
|
+
if not ok:
|
|
419
|
+
return "bad"
|
|
420
|
+
(wt / dest).parent.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
(wt / dest).write_text(text)
|
|
422
|
+
r = env.ctx.sandbox.run(wt, image, command, network=False, timeout=900)
|
|
423
|
+
gitops.git(wt, "clean", "-fdq", check=False)
|
|
424
|
+
return classify_failure(r.exit_code, r.output, getattr(r, "timed_out", False))[0]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def verdict(kind: str, candidate_outcome: str, status: str, reference: str) -> str:
|
|
428
|
+
if status == "invalid" or candidate_outcome in ("invalid", "flaky") or reference == "bad":
|
|
429
|
+
return "invalid"
|
|
430
|
+
if reference == "fails":
|
|
431
|
+
return "false_alarm"
|
|
432
|
+
if kind == "human":
|
|
433
|
+
return "held"
|
|
434
|
+
return "caught" if candidate_outcome == "broke" else "missed"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def score(ctx, attempt_id: str) -> dict:
|
|
438
|
+
"""Score the active candidate's attacks against the hidden human fix, then hide the candidate."""
|
|
439
|
+
from ossa import breaker, gitops
|
|
440
|
+
from ossa.stages import s03_workspace
|
|
441
|
+
a = _attempt(ctx, attempt_id)
|
|
442
|
+
rows = ctx.store.query("SELECT * FROM arena_candidates WHERE attempt_id=?", (a.id,))
|
|
443
|
+
if not rows or rows[0]["state"] != "active":
|
|
444
|
+
raise ToolError(f"{a.id} is not an Arena candidate waiting to be scored")
|
|
445
|
+
cand = dict(rows[0])
|
|
446
|
+
attacks = breaker.attacks(ctx, a.id)
|
|
447
|
+
if not attacks:
|
|
448
|
+
raise ToolError("no attacks on this candidate yet: Breaker attacks first")
|
|
449
|
+
iid, kind = cand["instance_id"], cand["kind"]
|
|
450
|
+
env, human = _env(ctx, _row(ctx, iid)), _instance(ctx, iid)["human_patch"]
|
|
451
|
+
verdicts = []
|
|
452
|
+
for r in attacks:
|
|
453
|
+
reference = ""
|
|
454
|
+
if r["status"] != "invalid" and r["outcome"] not in ("invalid", "flaky"):
|
|
455
|
+
text = ctx.store.read_artifact(a, f"attacks/{r['n']}/{PurePosixPath(r['test_path']).name}") or ""
|
|
456
|
+
reference = _attack_on(env, human, r["test_path"], text, r["command"])
|
|
457
|
+
v = verdict(kind, r["outcome"], r["status"], reference)
|
|
458
|
+
verdicts.append(v)
|
|
459
|
+
ctx.store.execute(
|
|
460
|
+
"INSERT OR REPLACE INTO arena_scores(attempt_id, n, instance_id, k, kind, category, candidate_outcome, "
|
|
461
|
+
"reference_outcome, verdict, breaker_version, model, ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
462
|
+
(a.id, r["n"], iid, cand["k"], kind, r["category"], r["outcome"], reference, v,
|
|
463
|
+
r.get("breaker_version") or "", r.get("model") or "", now()))
|
|
464
|
+
if kind == "human":
|
|
465
|
+
result = "false_alarm" if "false_alarm" in verdicts else "clean"
|
|
466
|
+
else:
|
|
467
|
+
result = "caught" if "caught" in verdicts else "missed"
|
|
468
|
+
duel = breaker.duel(ctx, a.id)["verdict"]
|
|
469
|
+
ctx.store.execute("UPDATE arena_candidates SET state='scored', result=?, duel_verdict=? WHERE attempt_id=?",
|
|
470
|
+
(result, duel, a.id))
|
|
471
|
+
for wt in (s03_workspace.worktree_for(a), s03_workspace.worktree_for(a, 0)):
|
|
472
|
+
gitops.remove_worktree(s03_workspace.repo_dir(a), wt)
|
|
473
|
+
_move(a, hidden_dir(ctx, iid) / "scored" / a.id)
|
|
474
|
+
a.state = State.CLOSED
|
|
475
|
+
ctx.store.save_attempt(a)
|
|
476
|
+
left = ctx.store.query("SELECT COUNT(*) AS n FROM arena_candidates WHERE instance_id=? AND state='waiting'",
|
|
477
|
+
(iid,))[0]["n"]
|
|
478
|
+
return {"scored": a.id, "attacks": len(attacks), "candidates_left": int(left),
|
|
479
|
+
"next": "`ossa arena next`" if left else "`ossa arena report`"}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ------------------------------------------------------------------ Patch's side
|
|
483
|
+
def solve(ctx, iid: str) -> Attempt:
|
|
484
|
+
"""An attempt at the instance's base with only the issue: Sherlock reproduces, Patch fixes,
|
|
485
|
+
`ossa verify` checks it, and `ossa arena grade` scores it with the hidden tests."""
|
|
486
|
+
from ossa.stages import s03_workspace
|
|
487
|
+
row = _row(ctx, iid)
|
|
488
|
+
if row["state"] != "ready":
|
|
489
|
+
raise ToolError("prepare the instance first: `ossa arena prepare`")
|
|
490
|
+
env = _env(ctx, row)
|
|
491
|
+
a = _new(ctx, row, "arena-solve", _arena(ctx) / "solve", "arena-solve")
|
|
492
|
+
_copy_env(env, a)
|
|
493
|
+
s03_workspace.prepare_worktree(a, _blind_issue(_instance(ctx, iid)))
|
|
494
|
+
a.state = State.WORKSPACE_READY
|
|
495
|
+
ctx.store.save_attempt(a)
|
|
496
|
+
ctx.store.execute("INSERT INTO arena_solves(attempt_id, instance_id, ts) VALUES(?,?,?)", (a.id, iid, now()))
|
|
497
|
+
return a
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def grade(ctx, attempt_id: str) -> dict:
|
|
501
|
+
from ossa import replay
|
|
502
|
+
from ossa.gitops import diff_stats
|
|
503
|
+
a = _attempt(ctx, attempt_id)
|
|
504
|
+
if a.kind != "arena-solve":
|
|
505
|
+
raise ToolError(f"{a.id} is not an Arena solve attempt")
|
|
506
|
+
if not (ctx.store.read_json(a, "verify.json") or {}).get("verified"):
|
|
507
|
+
raise ToolError("not verified yet: `ossa verify` first")
|
|
508
|
+
iid = ctx.store.query("SELECT instance_id FROM arena_solves WHERE attempt_id=?", (a.id,))[0]["instance_id"]
|
|
509
|
+
row = _row(ctx, iid)
|
|
510
|
+
fix = ctx.store.read_artifact(a, "fix.patch") or ""
|
|
511
|
+
result = _suite(_env(ctx, row), [fix, _instance(ctx, iid)["human_tests_patch"]])
|
|
512
|
+
tests = result.tests if result else {}
|
|
513
|
+
resolved = bool(result) and replay.is_resolved(row["f2p"], row["p2p"], tests)
|
|
514
|
+
out = {"attempt": a.id, "instance": iid, "resolved": resolved,
|
|
515
|
+
"f2p_passed": sum(tests.get(t) == "passed" for t in row["f2p"]), "f2p_total": len(row["f2p"]),
|
|
516
|
+
"p2p_broken": sum(tests.get(t) != "passed" for t in row["p2p"]), "lines": diff_stats(fix)[0],
|
|
517
|
+
"applies": result is not None}
|
|
518
|
+
ctx.store.execute("INSERT OR REPLACE INTO arena_solves VALUES(?,?,?,?,?,?,?,?)",
|
|
519
|
+
(a.id, iid, int(resolved), out["f2p_passed"], out["f2p_total"], out["p2p_broken"],
|
|
520
|
+
out["lines"], now()))
|
|
521
|
+
return out
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------ status and report
|
|
525
|
+
def status(ctx) -> list[dict]:
|
|
526
|
+
"""Instances and how far each got (no labels: those stay hidden)."""
|
|
527
|
+
out = []
|
|
528
|
+
for r in ctx.store.query("SELECT * FROM arena_instances ORDER BY created_at"):
|
|
529
|
+
counts = {s: 0 for s in ("waiting", "active", "scored")}
|
|
530
|
+
for c in ctx.store.query("SELECT state, COUNT(*) AS n FROM arena_candidates WHERE instance_id=? GROUP BY state",
|
|
531
|
+
(r["id"],)):
|
|
532
|
+
counts[c["state"]] = c["n"]
|
|
533
|
+
out.append({"instance": r["id"], "repo": r["repo"], "pr": r["pr"], "issue": r["issue"], "state": r["state"],
|
|
534
|
+
"env_attempt": r["env_attempt"], "contaminated": bool(r["contaminated"]), "note": r["note"],
|
|
535
|
+
"candidates": counts})
|
|
536
|
+
return out
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def report(ctx) -> dict:
|
|
540
|
+
"""Catch and false-alarm rates by Breaker version and model; Patch's resolve rate."""
|
|
541
|
+
cands = [dict(r) for r in ctx.store.query("SELECT * FROM arena_candidates WHERE state='scored'")]
|
|
542
|
+
scores = [dict(r) for r in ctx.store.query("SELECT * FROM arena_scores")]
|
|
543
|
+
by: dict[tuple[str, str], dict] = {}
|
|
544
|
+
for c in cands:
|
|
545
|
+
mine = [s for s in scores if s["attempt_id"] == c["attempt_id"]]
|
|
546
|
+
key = (mine[0]["breaker_version"] if mine else "", mine[0]["model"] if mine else "")
|
|
547
|
+
g = by.setdefault(key, {"wrong": 0, "caught": 0, "human": 0, "false_alarm": 0, "ship_on_wrong": 0,
|
|
548
|
+
"attacks": 0, "verdicts": {}, "categories": {}})
|
|
549
|
+
g["attacks"] += len(mine)
|
|
550
|
+
for s in mine:
|
|
551
|
+
g["verdicts"][s["verdict"]] = g["verdicts"].get(s["verdict"], 0) + 1
|
|
552
|
+
cat = g["categories"].setdefault(s["category"] or "?", {"attacks": 0, "caught": 0, "false_alarm": 0})
|
|
553
|
+
cat["attacks"] += 1
|
|
554
|
+
if s["verdict"] in ("caught", "false_alarm"):
|
|
555
|
+
cat[s["verdict"]] += 1
|
|
556
|
+
if c["kind"] == "human":
|
|
557
|
+
g["human"] += 1
|
|
558
|
+
g["false_alarm"] += c["result"] == "false_alarm"
|
|
559
|
+
else:
|
|
560
|
+
g["wrong"] += 1
|
|
561
|
+
g["caught"] += c["result"] == "caught"
|
|
562
|
+
g["ship_on_wrong"] += c["duel_verdict"] == "SHIP"
|
|
563
|
+
rows = []
|
|
564
|
+
for (version, model), g in sorted(by.items()):
|
|
565
|
+
rows.append({"breaker_version": version, "model": model, **g,
|
|
566
|
+
"catch_rate": round(g["caught"] / g["wrong"], 3) if g["wrong"] else None,
|
|
567
|
+
"false_alarm_rate": round(g["false_alarm"] / g["human"], 3) if g["human"] else None})
|
|
568
|
+
solves = ctx.store.query("SELECT COUNT(*) AS n, COALESCE(SUM(resolved), 0) AS r FROM arena_solves "
|
|
569
|
+
"WHERE resolved IS NOT NULL")[0]
|
|
570
|
+
inst = {s: n for s, n in ((r["state"], r["n"]) for r in ctx.store.query(
|
|
571
|
+
"SELECT state, COUNT(*) AS n FROM arena_instances GROUP BY state"))}
|
|
572
|
+
out = {"instances": inst, "breaker": rows,
|
|
573
|
+
"patch": {"solves": solves["n"], "resolved": solves["r"],
|
|
574
|
+
"resolve_rate": round(solves["r"] / solves["n"], 3) if solves["n"] else None}}
|
|
575
|
+
_arena(ctx).mkdir(parents=True, exist_ok=True)
|
|
576
|
+
(_arena(ctx) / "REPORT.md").write_text(render(out))
|
|
577
|
+
return out
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def render(rep: dict) -> str:
|
|
581
|
+
pct = lambda x: "-" if x is None else f"{x:.0%}" # noqa: E731
|
|
582
|
+
lines = ["# Breaker Arena", "", "Instances: " + (", ".join(f"{n} {s}" for s, n in rep["instances"].items()) or "none"),
|
|
583
|
+
"", "| Breaker version | model | wrong fixes | caught | catch rate | human fixes | false alarms "
|
|
584
|
+
"| false-alarm rate | SHIP on a wrong fix | attacks |", "|---|---|---|---|---|---|---|---|---|---|"]
|
|
585
|
+
for r in rep["breaker"]:
|
|
586
|
+
lines.append(f"| {r['breaker_version'] or '?'} | {r['model'] or '?'} | {r['wrong']} | {r['caught']} | "
|
|
587
|
+
f"{pct(r['catch_rate'])} | {r['human']} | {r['false_alarm']} | {pct(r['false_alarm_rate'])} | "
|
|
588
|
+
f"{r['ship_on_wrong']} | {r['attacks']} |")
|
|
589
|
+
p = rep["patch"]
|
|
590
|
+
lines += ["", f"Patch: {p['resolved']} of {p['solves']} solves resolved ({pct(p['resolve_rate'])})."]
|
|
591
|
+
return "\n".join(lines) + "\n"
|