commitarena 0.1.1__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.
- commitarena/__init__.py +40 -0
- commitarena/__main__.py +3 -0
- commitarena/agent.py +205 -0
- commitarena/cli.py +313 -0
- commitarena/demo/LICENSE-sqlparse +25 -0
- commitarena/demo/results.jsonl +33 -0
- commitarena/demo/run.json +36 -0
- commitarena/demo/tasks.json +5603 -0
- commitarena/errors.py +40 -0
- commitarena/llm.py +206 -0
- commitarena/mine.py +248 -0
- commitarena/report.py +132 -0
- commitarena/report_template.html +394 -0
- commitarena/runner.py +298 -0
- commitarena/sandbox.py +180 -0
- commitarena/tasks.py +229 -0
- commitarena/testrun.py +77 -0
- commitarena-0.1.1.dist-info/METADATA +242 -0
- commitarena-0.1.1.dist-info/RECORD +22 -0
- commitarena-0.1.1.dist-info/WHEEL +4 -0
- commitarena-0.1.1.dist-info/entry_points.txt +2 -0
- commitarena-0.1.1.dist-info/licenses/LICENSE +21 -0
commitarena/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""User-facing errors. The CLI prints these without a traceback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommitArenaError(Exception):
|
|
7
|
+
"""Base class. `str(err)` is a complete, actionable message."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MissingAPIKey(CommitArenaError):
|
|
11
|
+
def __init__(self, provider: str, env_var: str):
|
|
12
|
+
super().__init__(
|
|
13
|
+
f"Missing API key for provider '{provider}'. Set the {env_var} environment variable "
|
|
14
|
+
f"(for example in your shell or a .env file you source), or use a local model such as "
|
|
15
|
+
f"'ollama/qwen3:4b'."
|
|
16
|
+
)
|
|
17
|
+
self.provider = provider
|
|
18
|
+
self.env_var = env_var
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DockerUnavailable(CommitArenaError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelUnavailable(CommitArenaError):
|
|
26
|
+
"""Model not found, or the provider (e.g. Ollama) cannot be reached."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BudgetExceeded(CommitArenaError):
|
|
30
|
+
def __init__(self, spent: float, max_cost: float):
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Budget exceeded: spent ${spent:.4f} of --max-cost ${max_cost:.2f}. Stopped before the next "
|
|
33
|
+
f"model call. Partial results were kept; raise --max-cost to continue."
|
|
34
|
+
)
|
|
35
|
+
self.spent = spent
|
|
36
|
+
self.max_cost = max_cost
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskError(CommitArenaError):
|
|
40
|
+
pass
|
commitarena/llm.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""One OpenAI-compatible client with provider presets, pricing and a spend cap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import BudgetExceeded, CommitArenaError, MissingAPIKey, ModelUnavailable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _ollama_base() -> str:
|
|
15
|
+
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434").strip().rstrip("/")
|
|
16
|
+
if not host.startswith(("http://", "https://")):
|
|
17
|
+
host = "http://" + host
|
|
18
|
+
if host.startswith("http://0.0.0.0"):
|
|
19
|
+
host = host.replace("0.0.0.0", "localhost", 1)
|
|
20
|
+
return host + "/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# provider -> (base_url, env var with the key or None for local)
|
|
24
|
+
PROVIDERS: dict[str, tuple[str, str | None]] = {
|
|
25
|
+
"openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
|
|
26
|
+
"openai": ("https://api.openai.com/v1", "OPENAI_API_KEY"),
|
|
27
|
+
"mistral": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY"),
|
|
28
|
+
"ollama": ("", None), # resolved at call time from OLLAMA_HOST
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# USD per million tokens (input, output). List prices from docs.mistral.ai/inference/pricing,
|
|
32
|
+
# checked 2026-11-03. Override with --price or ModelSpec(price=...).
|
|
33
|
+
PRICES: dict[str, tuple[float, float]] = {
|
|
34
|
+
"mistral/codestral-latest": (0.30, 0.90),
|
|
35
|
+
"mistral/codestral-2508": (0.30, 0.90),
|
|
36
|
+
"mistral/mistral-small-latest": (0.15, 0.60),
|
|
37
|
+
"mistral/mistral-small-2603": (0.15, 0.60),
|
|
38
|
+
"mistral/ministral-8b-latest": (0.15, 0.15),
|
|
39
|
+
"mistral/ministral-8b-2512": (0.15, 0.15),
|
|
40
|
+
"mistral/mistral-large-4": (0.70, 2.10),
|
|
41
|
+
"mistral/mistral-large-4-0": (0.70, 2.10),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ModelSpec:
|
|
47
|
+
"""A model id of the form ``provider/name``, e.g. ``ollama/qwen3:4b``."""
|
|
48
|
+
|
|
49
|
+
provider: str
|
|
50
|
+
name: str
|
|
51
|
+
price: tuple[float, float] | None = None # USD per 1M (input, output)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def parse(cls, spec: str, price: tuple[float, float] | None = None) -> ModelSpec:
|
|
55
|
+
if "/" not in spec:
|
|
56
|
+
raise CommitArenaError(
|
|
57
|
+
f"Model '{spec}' must look like provider/name, e.g. ollama/qwen3:4b or "
|
|
58
|
+
f"mistral/codestral-latest. Providers: {', '.join(PROVIDERS)}."
|
|
59
|
+
)
|
|
60
|
+
provider, name = spec.split("/", 1)
|
|
61
|
+
if provider not in PROVIDERS:
|
|
62
|
+
raise CommitArenaError(f"Unknown provider '{provider}'. Use one of: {', '.join(PROVIDERS)}.")
|
|
63
|
+
return cls(provider, name, price)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def id(self) -> str:
|
|
67
|
+
return f"{self.provider}/{self.name}"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_local(self) -> bool:
|
|
71
|
+
return self.provider == "ollama"
|
|
72
|
+
|
|
73
|
+
def unit_price(self) -> tuple[float, float] | None:
|
|
74
|
+
if self.price is not None:
|
|
75
|
+
return self.price
|
|
76
|
+
if self.is_local:
|
|
77
|
+
return (0.0, 0.0)
|
|
78
|
+
return PRICES.get(self.id)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Budget:
|
|
83
|
+
"""Shared spend cap in USD. Checked before every model call; local models cost 0."""
|
|
84
|
+
|
|
85
|
+
max_cost: float = 1.0
|
|
86
|
+
spent: float = 0.0
|
|
87
|
+
|
|
88
|
+
def check(self) -> None:
|
|
89
|
+
if self.spent >= self.max_cost:
|
|
90
|
+
raise BudgetExceeded(self.spent, self.max_cost)
|
|
91
|
+
|
|
92
|
+
def add(self, usd: float) -> None:
|
|
93
|
+
self.spent += usd
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Usage:
|
|
98
|
+
tokens_in: int = 0
|
|
99
|
+
tokens_out: int = 0
|
|
100
|
+
cost_usd: float = 0.0
|
|
101
|
+
calls: int = 0
|
|
102
|
+
|
|
103
|
+
def add(self, other: Usage) -> None:
|
|
104
|
+
self.tokens_in += other.tokens_in
|
|
105
|
+
self.tokens_out += other.tokens_out
|
|
106
|
+
self.cost_usd += other.cost_usd
|
|
107
|
+
self.calls += other.calls
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ChatClient:
|
|
112
|
+
"""Thin wrapper over the `openai` SDK. Retries 429/5xx with exponential backoff."""
|
|
113
|
+
|
|
114
|
+
model: ModelSpec
|
|
115
|
+
budget: Budget = field(default_factory=Budget)
|
|
116
|
+
max_retries: int = 12
|
|
117
|
+
timeout: float = 600.0
|
|
118
|
+
_client: Any = None
|
|
119
|
+
_connected: bool = False # a local server that answered once gets retries on later drops
|
|
120
|
+
|
|
121
|
+
def __post_init__(self) -> None:
|
|
122
|
+
if self.model.unit_price() is None and self.model.provider != "openrouter":
|
|
123
|
+
raise CommitArenaError(
|
|
124
|
+
f"No price known for {self.model.id}, so the budget cannot be enforced. Pass "
|
|
125
|
+
f"--price '{self.model.id}=IN,OUT' (USD per million tokens)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _sdk(self):
|
|
129
|
+
if self._client is None:
|
|
130
|
+
from openai import OpenAI
|
|
131
|
+
|
|
132
|
+
base, env = PROVIDERS[self.model.provider]
|
|
133
|
+
if self.model.provider == "ollama":
|
|
134
|
+
base, key = _ollama_base(), "ollama"
|
|
135
|
+
self.timeout = max(self.timeout, 1800.0) # a busy local server can be slow
|
|
136
|
+
else:
|
|
137
|
+
key = os.environ.get(env or "", "")
|
|
138
|
+
if not key:
|
|
139
|
+
raise MissingAPIKey(self.model.provider, env or "")
|
|
140
|
+
self._client = OpenAI(base_url=base, api_key=key, timeout=self.timeout, max_retries=0)
|
|
141
|
+
return self._client
|
|
142
|
+
|
|
143
|
+
def chat(self, messages: list[dict], tools: list[dict] | None = None, **kw) -> tuple[Any, Usage]:
|
|
144
|
+
import openai
|
|
145
|
+
|
|
146
|
+
self.budget.check()
|
|
147
|
+
client = self._sdk()
|
|
148
|
+
extra: dict[str, Any] = {}
|
|
149
|
+
if self.model.provider == "openrouter":
|
|
150
|
+
extra["extra_body"] = {"usage": {"include": True}}
|
|
151
|
+
delay = 2.0
|
|
152
|
+
for attempt in range(self.max_retries + 1):
|
|
153
|
+
try:
|
|
154
|
+
resp = client.chat.completions.create(
|
|
155
|
+
model=self.model.name, messages=messages, tools=tools, **extra, **kw
|
|
156
|
+
)
|
|
157
|
+
self._connected = True
|
|
158
|
+
break
|
|
159
|
+
except openai.AuthenticationError as e:
|
|
160
|
+
_, env = PROVIDERS[self.model.provider]
|
|
161
|
+
raise CommitArenaError(
|
|
162
|
+
f"{self.model.provider} rejected the API key in {env} (401). Check the key."
|
|
163
|
+
) from e
|
|
164
|
+
except openai.NotFoundError as e:
|
|
165
|
+
hint = (
|
|
166
|
+
f" Pull it first: ollama pull {self.model.name}"
|
|
167
|
+
if self.model.is_local
|
|
168
|
+
else " Check the model name with the provider."
|
|
169
|
+
)
|
|
170
|
+
raise ModelUnavailable(f"Model '{self.model.id}' was not found.{hint}") from e
|
|
171
|
+
except openai.APIConnectionError as e:
|
|
172
|
+
if self.model.is_local and (attempt >= 3 or not self._connected):
|
|
173
|
+
raise ModelUnavailable(
|
|
174
|
+
f"Cannot reach Ollama at {_ollama_base()}. Start it with 'ollama serve' "
|
|
175
|
+
f"or set OLLAMA_HOST."
|
|
176
|
+
) from e
|
|
177
|
+
if attempt == self.max_retries:
|
|
178
|
+
raise ModelUnavailable(f"Cannot reach {self.model.provider}: {e}") from e
|
|
179
|
+
except (openai.RateLimitError, openai.InternalServerError, openai.APITimeoutError) as e:
|
|
180
|
+
if attempt == self.max_retries:
|
|
181
|
+
raise ModelUnavailable(
|
|
182
|
+
f"{self.model.provider} kept failing after {attempt + 1} attempts "
|
|
183
|
+
f"(last error: {type(e).__name__}). Try later or use a local model."
|
|
184
|
+
) from e
|
|
185
|
+
except openai.APIStatusError as e:
|
|
186
|
+
if e.status_code == 402:
|
|
187
|
+
raise CommitArenaError(f"{self.model.provider} reports no credit left (402).") from e
|
|
188
|
+
raise CommitArenaError(f"{self.model.provider} error {e.status_code}: {e.message}") from e
|
|
189
|
+
time.sleep(min(delay, 90.0) * (0.75 + random.random() / 2))
|
|
190
|
+
delay *= 2
|
|
191
|
+
usage = self._usage(resp)
|
|
192
|
+
self.budget.add(usage.cost_usd)
|
|
193
|
+
return resp, usage
|
|
194
|
+
|
|
195
|
+
def _usage(self, resp: Any) -> Usage:
|
|
196
|
+
u = getattr(resp, "usage", None)
|
|
197
|
+
tin = int(getattr(u, "prompt_tokens", 0) or 0)
|
|
198
|
+
tout = int(getattr(u, "completion_tokens", 0) or 0)
|
|
199
|
+
cost = None
|
|
200
|
+
if u is not None and self.model.provider == "openrouter":
|
|
201
|
+
extra = getattr(u, "model_extra", None) or {}
|
|
202
|
+
cost = extra.get("cost")
|
|
203
|
+
if cost is None:
|
|
204
|
+
pin, pout = self.model.unit_price() or (0.0, 0.0)
|
|
205
|
+
cost = (tin * pin + tout * pout) / 1_000_000
|
|
206
|
+
return Usage(tin, tout, float(cost), 1)
|
commitarena/mine.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Mine validated FAIL_TO_PASS tasks from a repository's git history (Python + pytest)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .sandbox import EnvSpec, Sandbox, SandboxConfig, build_image, ensure_docker, image_exists
|
|
13
|
+
from .tasks import (
|
|
14
|
+
Task,
|
|
15
|
+
build_workspace,
|
|
16
|
+
extract_commit,
|
|
17
|
+
git,
|
|
18
|
+
is_doc_path,
|
|
19
|
+
is_test_path,
|
|
20
|
+
repo_slug,
|
|
21
|
+
resolve_repo,
|
|
22
|
+
unified_diff,
|
|
23
|
+
)
|
|
24
|
+
from .testrun import run_tests
|
|
25
|
+
|
|
26
|
+
MAX_TEST_DIFF_CHARS = 12000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Candidate:
|
|
31
|
+
sha: str
|
|
32
|
+
parent: str
|
|
33
|
+
date: str
|
|
34
|
+
test_paths: list[str]
|
|
35
|
+
source_paths: list[str]
|
|
36
|
+
source_lines: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_candidates(
|
|
40
|
+
repo: Path,
|
|
41
|
+
rev: str = "HEAD",
|
|
42
|
+
since: str | None = None,
|
|
43
|
+
until: str | None = None,
|
|
44
|
+
limit: int = 200,
|
|
45
|
+
max_source_files: int = 4,
|
|
46
|
+
max_source_lines: int = 200,
|
|
47
|
+
) -> tuple[list[Candidate], Counter]:
|
|
48
|
+
"""Non-merge commits that change both test files and .py source files."""
|
|
49
|
+
args = ["log", "--no-merges", "--format=%H%x09%P%x09%cI", f"-n{limit}"]
|
|
50
|
+
if since:
|
|
51
|
+
args.append(f"--since={since}")
|
|
52
|
+
if until:
|
|
53
|
+
args.append(f"--until={until}")
|
|
54
|
+
args.append(rev)
|
|
55
|
+
skipped: Counter = Counter()
|
|
56
|
+
out: list[Candidate] = []
|
|
57
|
+
for line in git(repo, *args).splitlines():
|
|
58
|
+
sha, parents, date = line.split("\t")
|
|
59
|
+
if not parents or " " in parents:
|
|
60
|
+
skipped["root or merge commit"] += 1
|
|
61
|
+
continue
|
|
62
|
+
numstat = git(repo, "diff", "--numstat", parents, sha)
|
|
63
|
+
tests, sources, lines = [], [], 0
|
|
64
|
+
for row in numstat.splitlines():
|
|
65
|
+
added, deleted, path = row.split("\t", 2)
|
|
66
|
+
if is_test_path(path):
|
|
67
|
+
tests.append(path)
|
|
68
|
+
elif path.endswith(".py") and not is_doc_path(path):
|
|
69
|
+
sources.append(path)
|
|
70
|
+
if added != "-":
|
|
71
|
+
lines += int(added) + int(deleted)
|
|
72
|
+
if not tests or not sources:
|
|
73
|
+
skipped["does not touch both tests and source"] += 1
|
|
74
|
+
continue
|
|
75
|
+
if len(sources) > max_source_files or lines > max_source_lines:
|
|
76
|
+
skipped["fix too large"] += 1
|
|
77
|
+
continue
|
|
78
|
+
out.append(Candidate(sha, parents, date, tests, sources, lines))
|
|
79
|
+
return out, skipped
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _test_side_files(repo: Path, cand: Candidate) -> dict[str, str | None]:
|
|
83
|
+
files: dict[str, str | None] = {}
|
|
84
|
+
names = git(repo, "diff", "--name-status", "--no-renames", cand.parent, cand.sha)
|
|
85
|
+
for row in names.splitlines():
|
|
86
|
+
status, path = row.split("\t", 1)
|
|
87
|
+
if not is_test_path(path):
|
|
88
|
+
continue
|
|
89
|
+
if status.startswith("D"):
|
|
90
|
+
files[path] = None
|
|
91
|
+
else:
|
|
92
|
+
try:
|
|
93
|
+
files[path] = git(repo, "show", f"{cand.sha}:{path}", text=False).decode()
|
|
94
|
+
except UnicodeDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_prompt(f2p: list[str], test_diff: str, messages: dict[str, str], test_paths: list[str]) -> str:
|
|
100
|
+
"""Task description from the failing tests only. The commit message is never used."""
|
|
101
|
+
shown = f2p[:25]
|
|
102
|
+
lines = [
|
|
103
|
+
"The repository in /work has a bug or a missing behavior. These tests currently fail and must pass "
|
|
104
|
+
"after your change:",
|
|
105
|
+
"",
|
|
106
|
+
*[f"- {t}" for t in shown],
|
|
107
|
+
]
|
|
108
|
+
if len(f2p) > len(shown):
|
|
109
|
+
lines.append(f"- ... and {len(f2p) - len(shown)} more")
|
|
110
|
+
if len(test_diff) > MAX_TEST_DIFF_CHARS:
|
|
111
|
+
test_diff = test_diff[:MAX_TEST_DIFF_CHARS] + "\n... [test diff truncated]\n"
|
|
112
|
+
lines += [
|
|
113
|
+
"",
|
|
114
|
+
"The tests were written to describe the expected behavior:",
|
|
115
|
+
"",
|
|
116
|
+
"```diff",
|
|
117
|
+
test_diff.rstrip(),
|
|
118
|
+
"```",
|
|
119
|
+
]
|
|
120
|
+
msgs = [f"{t}:\n{messages[t][:800]}" for t in shown[:3] if t in messages]
|
|
121
|
+
if msgs:
|
|
122
|
+
lines += ["", "Current failure output (truncated):", "", "```", "\n\n".join(msgs), "```"]
|
|
123
|
+
py_tests = [p for p in test_paths if p.endswith(".py")]
|
|
124
|
+
lines += [
|
|
125
|
+
"",
|
|
126
|
+
"Change the source code, not the tests: test files are restored before scoring. Do not break the "
|
|
127
|
+
"rest of the test suite.",
|
|
128
|
+
]
|
|
129
|
+
if py_tests:
|
|
130
|
+
lines.append(f"Run the relevant tests with: python -m pytest -q {' '.join(py_tests)}")
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def mine(
|
|
135
|
+
repo: str,
|
|
136
|
+
*,
|
|
137
|
+
out: Path,
|
|
138
|
+
rev: str = "HEAD",
|
|
139
|
+
since: str | None = None,
|
|
140
|
+
until: str | None = None,
|
|
141
|
+
scan: int = 200,
|
|
142
|
+
max_tasks: int = 10,
|
|
143
|
+
env: EnvSpec | None = None,
|
|
144
|
+
sandbox: SandboxConfig | None = None,
|
|
145
|
+
test_timeout: int = 600,
|
|
146
|
+
on_event: Callable[[str, dict], None] | None = None,
|
|
147
|
+
) -> tuple[list[Task], dict]:
|
|
148
|
+
"""Find candidate commits and keep those whose new tests fail before the fix and pass after it."""
|
|
149
|
+
from .tasks import save_tasks
|
|
150
|
+
|
|
151
|
+
emit = on_event or (lambda kind, data: None)
|
|
152
|
+
ensure_docker()
|
|
153
|
+
env = env or EnvSpec()
|
|
154
|
+
sandbox = sandbox or SandboxConfig(command_timeout=test_timeout)
|
|
155
|
+
path = resolve_repo(repo)
|
|
156
|
+
slug = repo_slug(repo)
|
|
157
|
+
tag = env.image_tag(slug)
|
|
158
|
+
work = Path(tempfile.mkdtemp(prefix="commitarena-mine-"))
|
|
159
|
+
try:
|
|
160
|
+
if not image_exists(tag):
|
|
161
|
+
emit("build", {"image": tag})
|
|
162
|
+
snap = work / "head"
|
|
163
|
+
extract_commit(path, rev, snap)
|
|
164
|
+
build_image(tag, env, snap, work)
|
|
165
|
+
cands, skipped = find_candidates(path, rev, since, until, scan)
|
|
166
|
+
emit("candidates", {"count": len(cands), "skipped": dict(skipped)})
|
|
167
|
+
tasks: list[Task] = []
|
|
168
|
+
rejected: Counter = Counter()
|
|
169
|
+
for cand in cands:
|
|
170
|
+
if len(tasks) >= max_tasks:
|
|
171
|
+
break
|
|
172
|
+
emit("validate", {"sha": cand.sha[:10], "date": cand.date})
|
|
173
|
+
test_files = _test_side_files(path, cand)
|
|
174
|
+
stub = Task(
|
|
175
|
+
id="", repo=repo, commit=cand.sha, parent=cand.parent, date=cand.date, prompt="",
|
|
176
|
+
fail_to_pass=[], pass_to_pass=[], test_files=test_files, gold_patch="", image=tag,
|
|
177
|
+
) # fmt: skip
|
|
178
|
+
before = build_workspace(stub, work / f"{cand.sha[:10]}-before", with_git=False)
|
|
179
|
+
after = work / f"{cand.sha[:10]}-after"
|
|
180
|
+
extract_commit(path, cand.sha, after)
|
|
181
|
+
with Sandbox(tag, sandbox) as sb:
|
|
182
|
+
sb.copy_in(before)
|
|
183
|
+
res_before = run_tests(sb, test_timeout)
|
|
184
|
+
with Sandbox(tag, sandbox) as sb:
|
|
185
|
+
sb.copy_in(after)
|
|
186
|
+
res_after = run_tests(sb, test_timeout)
|
|
187
|
+
reason = None
|
|
188
|
+
if not res_after.ok or not res_before.ok:
|
|
189
|
+
reason = "test run crashed or timed out"
|
|
190
|
+
f2p = sorted(res_after.passed() - res_before.passed())
|
|
191
|
+
p2p = sorted(res_after.passed() & res_before.passed())
|
|
192
|
+
broken = res_before.passed() - res_after.passed()
|
|
193
|
+
if reason is None and not f2p:
|
|
194
|
+
reason = "no test goes from failing to passing"
|
|
195
|
+
if reason is None and broken:
|
|
196
|
+
reason = "fix breaks previously passing tests"
|
|
197
|
+
if reason:
|
|
198
|
+
rejected[reason] += 1
|
|
199
|
+
emit("reject", {"sha": cand.sha[:10], "reason": reason})
|
|
200
|
+
else:
|
|
201
|
+
parent_tree = work / f"{cand.sha[:10]}-parent"
|
|
202
|
+
extract_commit(path, cand.parent, parent_tree)
|
|
203
|
+
test_diff = unified_diff(parent_tree, after, only=sorted(test_files))
|
|
204
|
+
gold_paths = [
|
|
205
|
+
p for p in set(_all_changed(path, cand)) if not is_test_path(p) and not is_doc_path(p)
|
|
206
|
+
]
|
|
207
|
+
gold = unified_diff(before, after, only=gold_paths)
|
|
208
|
+
task = Task(
|
|
209
|
+
id=f"{slug}-{cand.sha[:8]}",
|
|
210
|
+
repo=repo,
|
|
211
|
+
commit=cand.sha,
|
|
212
|
+
parent=cand.parent,
|
|
213
|
+
date=cand.date,
|
|
214
|
+
prompt=build_prompt(f2p, test_diff, res_before.messages, sorted(test_files)),
|
|
215
|
+
fail_to_pass=f2p,
|
|
216
|
+
pass_to_pass=p2p,
|
|
217
|
+
test_files=test_files,
|
|
218
|
+
gold_patch=gold,
|
|
219
|
+
image=tag,
|
|
220
|
+
env=env.__dict__.copy(),
|
|
221
|
+
source_files=cand.source_paths,
|
|
222
|
+
title_hint=", ".join(cand.source_paths),
|
|
223
|
+
)
|
|
224
|
+
tasks.append(task)
|
|
225
|
+
emit("task", {"id": task.id, "f2p": len(f2p), "p2p": len(p2p)})
|
|
226
|
+
shutil.rmtree(parent_tree, ignore_errors=True)
|
|
227
|
+
shutil.rmtree(before, ignore_errors=True)
|
|
228
|
+
shutil.rmtree(after, ignore_errors=True)
|
|
229
|
+
meta = {
|
|
230
|
+
"repo": repo,
|
|
231
|
+
"rev": rev,
|
|
232
|
+
"image": tag,
|
|
233
|
+
"env": env.__dict__.copy(),
|
|
234
|
+
"candidates": len(cands),
|
|
235
|
+
"skipped": dict(skipped),
|
|
236
|
+
"rejected": dict(rejected),
|
|
237
|
+
"validated": len(tasks),
|
|
238
|
+
}
|
|
239
|
+
save_tasks(out, tasks, meta)
|
|
240
|
+
return tasks, meta
|
|
241
|
+
finally:
|
|
242
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _all_changed(repo: Path, cand: Candidate) -> list[str]:
|
|
246
|
+
return [
|
|
247
|
+
p for p in git(repo, "diff", "--name-only", "--no-renames", cand.parent, cand.sha).splitlines() if p
|
|
248
|
+
]
|
commitarena/report.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Static HTML report and leaderboard aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .tasks import load_tasks
|
|
11
|
+
|
|
12
|
+
# Upper bounds used when no --cutoff is given: the model's public release date.
|
|
13
|
+
# Anything committed after it cannot be in its training data. Override per run with --cutoff.
|
|
14
|
+
DEFAULT_CUTOFFS: dict[str, str] = {
|
|
15
|
+
"ollama/qwen3:4b": "2025-04-29", # Qwen3 release (Qwen blog, 2025-04-29)
|
|
16
|
+
"ollama/qwen2.5-coder:7b": "2024-09-19", # Qwen2.5-Coder release
|
|
17
|
+
"mistral/codestral-latest": "2025-07-31", # codestral-2508, "released end of July 2025" (Mistral docs)
|
|
18
|
+
"mistral/codestral-2508": "2025-07-31",
|
|
19
|
+
"mistral/mistral-small-latest": "2026-03-01", # mistral-small-2603 (v26.03)
|
|
20
|
+
"mistral/mistral-small-2603": "2026-03-01",
|
|
21
|
+
"mistral/ministral-8b-latest": "2025-12-01", # ministral-8b-2512 (Ministral 3, v25.12)
|
|
22
|
+
"mistral/ministral-8b-2512": "2025-12-01",
|
|
23
|
+
"mistral/mistral-large-4": "2026-10-01", # Mistral Large 4, v26.10 (Mistral docs)
|
|
24
|
+
"mistral/mistral-large-4-0": "2026-10-01",
|
|
25
|
+
"ollama/qwen3:4b-16k": "2025-04-29", # same weights as qwen3:4b, larger context window
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _split():
|
|
30
|
+
return {"passed": 0, "total": 0}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def leaderboard(results: list[dict], cutoffs: dict[str, str]) -> list[dict]:
|
|
34
|
+
"""Aggregate per model. 'before' = tasks dated before the model's cutoff (may be memorized)."""
|
|
35
|
+
rows: dict[str, dict] = {}
|
|
36
|
+
for r in results:
|
|
37
|
+
m = rows.setdefault(
|
|
38
|
+
r["model"],
|
|
39
|
+
{"model": r["model"], "passed": 0, "total": 0, "cost": 0.0, "tokens": 0, "time": 0.0, "steps": 0,
|
|
40
|
+
"before": _split(), "after": _split(), "cutoff": cutoffs.get(r["model"]), "stops": {}},
|
|
41
|
+
) # fmt: skip
|
|
42
|
+
m["total"] += 1
|
|
43
|
+
m["passed"] += int(bool(r["passed"]))
|
|
44
|
+
m["cost"] += r.get("cost_usd", 0.0)
|
|
45
|
+
m["tokens"] += r.get("tokens_in", 0) + r.get("tokens_out", 0)
|
|
46
|
+
m["time"] += r.get("wall_time_s", 0.0)
|
|
47
|
+
m["steps"] += r.get("steps", 0)
|
|
48
|
+
stop = r.get("stop_reason") or "unknown"
|
|
49
|
+
m["stops"][stop] = m["stops"].get(stop, 0) + 1
|
|
50
|
+
cut = cutoffs.get(r["model"])
|
|
51
|
+
if cut and r.get("task_date"):
|
|
52
|
+
side = "before" if r["task_date"][:10] < cut else "after"
|
|
53
|
+
m[side]["total"] += 1
|
|
54
|
+
m[side]["passed"] += int(bool(r["passed"]))
|
|
55
|
+
out = []
|
|
56
|
+
for m in rows.values():
|
|
57
|
+
n = m["total"] or 1
|
|
58
|
+
m["rate"] = m["passed"] / n
|
|
59
|
+
m["avg_time"] = m["time"] / n
|
|
60
|
+
m["avg_steps"] = m["steps"] / n
|
|
61
|
+
out.append(m)
|
|
62
|
+
out.sort(key=lambda m: (-m["rate"], m["cost"]))
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_run(run_dir: Path, cutoff_overrides: dict[str, str] | None = None) -> dict:
|
|
67
|
+
meta = json.loads((run_dir / "run.json").read_text()) if (run_dir / "run.json").exists() else {}
|
|
68
|
+
results = [
|
|
69
|
+
json.loads(line) for line in (run_dir / "results.jsonl").read_text().splitlines() if line.strip()
|
|
70
|
+
]
|
|
71
|
+
tasks = load_tasks(run_dir / "tasks.json") if (run_dir / "tasks.json").exists() else []
|
|
72
|
+
cutoffs = {**DEFAULT_CUTOFFS, **meta.get("cutoffs", {}), **(cutoff_overrides or {})}
|
|
73
|
+
return {"meta": meta, "results": results, "tasks": tasks, "cutoffs": cutoffs}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _commit_url(repo: str, sha: str) -> str | None:
|
|
77
|
+
r = repo.removesuffix(".git").rstrip("/")
|
|
78
|
+
if r.startswith("https://github.com/") or r.startswith("https://gitlab.com/"):
|
|
79
|
+
return f"{r}/commit/{sha}"
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_payload(run_dir: Path, cutoff_overrides: dict[str, str] | None = None) -> dict:
|
|
84
|
+
data = load_run(run_dir, cutoff_overrides)
|
|
85
|
+
used = {r["task_id"] for r in data["results"]}
|
|
86
|
+
tasks = [
|
|
87
|
+
{
|
|
88
|
+
"id": t.id,
|
|
89
|
+
"date": t.date,
|
|
90
|
+
"files": t.source_files,
|
|
91
|
+
"f2p": len(t.fail_to_pass),
|
|
92
|
+
"p2p": len(t.pass_to_pass),
|
|
93
|
+
"gold": t.gold_patch,
|
|
94
|
+
"url": _commit_url(t.repo, t.commit),
|
|
95
|
+
"commit": t.commit[:10],
|
|
96
|
+
}
|
|
97
|
+
for t in sorted(data["tasks"], key=lambda t: t.date)
|
|
98
|
+
if t.id in used
|
|
99
|
+
]
|
|
100
|
+
models = sorted({r["model"] for r in data["results"]})
|
|
101
|
+
return {
|
|
102
|
+
"meta": {**data["meta"], "report_version": __version__},
|
|
103
|
+
"tasks": tasks,
|
|
104
|
+
"models": models,
|
|
105
|
+
"cutoffs": {m: data["cutoffs"].get(m) for m in models},
|
|
106
|
+
"board": leaderboard(data["results"], data["cutoffs"]),
|
|
107
|
+
"results": [
|
|
108
|
+
{k: r.get(k) for k in ("task_id", "model", "passed", "fail_to_pass_passed", "fail_to_pass_total",
|
|
109
|
+
"pass_to_pass_failed", "cost_usd", "tokens_in", "tokens_out", "steps",
|
|
110
|
+
"wall_time_s", "stop_reason", "patch", "ignored_test_edits")}
|
|
111
|
+
for r in data["results"]
|
|
112
|
+
],
|
|
113
|
+
} # fmt: skip
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render(payload: dict) -> str:
|
|
117
|
+
template = resources.files("commitarena").joinpath("report_template.html").read_text()
|
|
118
|
+
blob = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
|
119
|
+
return template.replace("/*__DATA__*/{}", blob)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def write_report(
|
|
123
|
+
run_dir: Path, out: Path | None = None, cutoff_overrides: dict[str, str] | None = None
|
|
124
|
+
) -> Path:
|
|
125
|
+
out = out or run_dir / "report.html"
|
|
126
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
out.write_text(render(build_payload(run_dir, cutoff_overrides)))
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def demo_dir() -> Path:
|
|
132
|
+
return Path(str(resources.files("commitarena").joinpath("demo")))
|