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/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")))