Skip to content

Commit dfd3157

Browse files
feat(benchmark): add the evaluator the README already points at (#131)
* feat(benchmark): add the evaluator the README already points at The README says "Benchmark assets, logs, evaluator, and generated projects live in examples/agent-comparison/" — but no evaluator is there, so the score table (95/73/62/59) cannot currently be re-derived from anything in the repo. This adds a deterministic scorer for the published rubric so anyone can re-run the comparison on any agent's output and get the same numbers. What it scores and how honestly it does it: - Structure (20), Hygiene (20), Git (15): recomputed from the project directory, one evidence line per check. A disputed score becomes an argument about a file listing or a git log, not about taste. - Functional (30): actually runs npm install + npm test, opt-in via --run-tests because it executes the project's code. - Quality (15): never auto-scored. It is a judgment call, so the script reports the inputs to that judgment (README, package metadata, custom error types) and assigns no points. A deterministic scorer that pretended to measure "quality" would just be an opinion with extra steps. - Unscorable is never zero: the vendored artifacts had .git stripped when they were copied in, so the Git dimension reports UNSCORABLE on them and drops out of the denominator instead of silently failing. Stdlib-only, no new dependencies. tests/test_benchmark_scorer.py covers the full-marks fixture, each defect docking exactly its own check, the unscorable-vs-zero distinction, and a smoke run against the vendored claude-code-sonnet artifact (8 tests, pass under the CI env guard). * fix(benchmark): address review — prune all JUNK_DIRS, never traceback, harden subprocess calls, parse .gitignore lines Review items from #131, each pinned by a test: - _list_files prunes everything JUNK_DIRS names: a checked-in coverage/ no longer counts as project modules for Structure while Hygiene docks it as junk — one directory, one verdict. - FileNotFoundError/TimeoutExpired can't crash the scorer. Missing npm is UNSCORABLE (the scorer's toolchain gap is not the project's failure); a hung npm test is a failure with evidence (a suite that never finishes is a property of the project). Missing/hung git routes every git-backed check to UNSCORABLE — including hygiene's clean-status, whose empty stdout would otherwise read as "clean". - npm install runs with --ignore-scripts (running the suite is the opt-in; lifecycle scripts are not part of that deal) and every git call clears core.fsmonitor. - .gitignore coverage is parsed per line: comments excluded, and node_modules / /node_modules / node_modules/ / **/node_modules all count. A commented-out line no longer earns the 5 points.
1 parent 886e915 commit dfd3157

3 files changed

Lines changed: 643 additions & 0 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Benchmark evaluator
2+
3+
Deterministic scorer for the benchmark rubric published in the root README
4+
(Functional 30 / Structure 20 / Hygiene 20 / Git 15 / Quality 15). Run it
5+
against any agent's output directory and get the same numbers anyone else
6+
would:
7+
8+
```bash
9+
python score.py <project_dir> # Structure + Hygiene + Git (55 pts)
10+
python score.py <project_dir> --run-tests # + Functional (30 pts, executes npm test)
11+
python score.py <project_dir> --json # machine-readable report
12+
```
13+
14+
Every check prints its evidence, so a disputed score is an argument about a
15+
file listing or a git log, not about taste.
16+
17+
Honest boundaries, by design:
18+
19+
- **Quality (15 pts) is never auto-scored.** It is a judgment call; the
20+
script reports the inputs to that judgment (README, package metadata,
21+
custom error types) and assigns no points.
22+
- **Unscorable ≠ zero.** The vendored artifacts in `../` had their `.git`
23+
stripped when they were copied in, so the Git dimension (and clean-status
24+
check) is reported as UNSCORABLE on them. Comparable scores must be
25+
produced from each agent's original output directory, including its
26+
`.git`.
27+
28+
Tested in `tests/test_benchmark_scorer.py`.
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
#!/usr/bin/env python3
2+
"""Deterministic scorer for the README benchmark rubric.
3+
4+
The benchmark table in the README scores five dimensions (Functional 30,
5+
Structure 20, Hygiene 20, Git 15, Quality 15). Four of them are mechanical
6+
properties of the generated project, so they should not require a human --
7+
or trust in one. This script recomputes them from a project directory and
8+
prints per-check evidence, so anyone can re-run the comparison on any
9+
agent's output and get the same numbers.
10+
11+
What it will and will not do:
12+
13+
- Structure / Hygiene / Git (55 pts) are scored deterministically. Git
14+
checks need the project's real ``.git`` history; on the checked-in
15+
artifacts (history stripped when they were copied into examples/) they
16+
are reported as UNSCORABLE rather than silently zeroed.
17+
- Functional (30 pts) is scored by actually running ``npm install`` and
18+
``npm test``, opt-in via ``--run-tests`` because it executes the
19+
project's code.
20+
- Quality (15 pts) is a judgment call. This script does not fake one: it
21+
reports observations (README, package metadata, custom error types) and
22+
assigns no points. A deterministic scorer that pretended to measure
23+
"quality" would just be an opinion with extra steps.
24+
25+
So a full run reports up to 85 recomputable points and clearly labels the
26+
remaining 15 as judgment. Scores are only comparable when produced from
27+
each agent's original output directory, including its ``.git``.
28+
29+
Usage:
30+
python score.py <project_dir> [--run-tests] [--json]
31+
32+
Exit code is 0 unless the directory is missing or not a project.
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import argparse
38+
import json
39+
import os
40+
import re
41+
import subprocess
42+
import sys
43+
from dataclasses import dataclass, field, asdict
44+
from pathlib import Path
45+
46+
SOURCE_DIRS = ("src", "lib", "bin")
47+
TEST_DIRS = ("test", "tests", "__tests__")
48+
JUNK_NAMES = (".DS_Store",)
49+
JUNK_DIRS = ("node_modules", "coverage")
50+
JUNK_GLOBS = ("*.log",)
51+
# Files the todo-app prompt tends to leave behind: persisted runtime data.
52+
JUNK_DATA = ("todos.json", "todo.json", "data.json")
53+
54+
TRIVIAL_SUBJECT = re.compile(r"^(wip|fix|update|tmp|temp|changes|stuff|misc)\.?$", re.I)
55+
56+
57+
@dataclass
58+
class Check:
59+
dimension: str
60+
name: str
61+
points: int
62+
# True = earned, False = not earned, None = unscorable in this context.
63+
passed: bool | None
64+
evidence: str
65+
66+
67+
@dataclass
68+
class Report:
69+
project: str
70+
checks: list[Check] = field(default_factory=list)
71+
observations: list[str] = field(default_factory=list)
72+
73+
def add(self, dimension: str, name: str, points: int, passed: bool | None, evidence: str) -> None:
74+
self.checks.append(Check(dimension, name, points, passed, evidence))
75+
76+
def earned(self) -> int:
77+
return sum(c.points for c in self.checks if c.passed is True)
78+
79+
def scoreable(self) -> int:
80+
return sum(c.points for c in self.checks if c.passed is not None)
81+
82+
83+
def _list_files(root: Path) -> list[Path]:
84+
out: list[Path] = []
85+
for dirpath, dirnames, filenames in os.walk(root):
86+
# Prune everything JUNK_DIRS names, not a hand-picked subset: a
87+
# checked-in coverage/ must not be counted as project modules by
88+
# Structure while Hygiene docks it as junk — one directory, one verdict.
89+
dirnames[:] = [d for d in dirnames if d != ".git" and d not in JUNK_DIRS]
90+
for f in filenames:
91+
out.append(Path(dirpath, f).relative_to(root))
92+
return out
93+
94+
95+
def _git(root: Path, *args: str) -> subprocess.CompletedProcess:
96+
# core.fsmonitor is cleared so scoring a repo can never execute a
97+
# repo-configured monitor daemon; a scorer must read the project, not
98+
# run it (running is what --run-tests opts into).
99+
cmd = ["git", "-c", "core.fsmonitor=", "-C", str(root), *args]
100+
try:
101+
return subprocess.run(cmd, capture_output=True, text=True, timeout=60)
102+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
103+
# No git binary / a hung git is "couldn't look", not a property of
104+
# the project. Surface it as a failed CompletedProcess so every
105+
# caller's returncode check routes to UNSCORABLE with evidence.
106+
return subprocess.CompletedProcess(cmd, returncode=127, stdout="", stderr=str(e))
107+
108+
109+
# ---------------------------------------------------------------- structure
110+
111+
def score_structure(root: Path, report: Report) -> None:
112+
files = _list_files(root)
113+
js_at_root = [f for f in files if f.parent == Path(".") and f.suffix in (".js", ".mjs", ".cjs", ".ts")]
114+
src_dirs = sorted({f.parts[0] for f in files if f.parts[0] in SOURCE_DIRS})
115+
test_dirs = sorted({f.parts[0] for f in files if f.parts[0] in TEST_DIRS})
116+
117+
# Source lives in a dedicated directory, not flat at the project root.
118+
# A lone root cli.js entry point alongside a src/ dir is conventional and
119+
# does not count against it; three root modules and no src/ does.
120+
modular = bool(src_dirs) and len(js_at_root) <= 1
121+
report.add(
122+
"structure", "source in dedicated dir", 7, modular,
123+
f"source dirs: {src_dirs or 'none'}; root-level modules: {[str(f) for f in js_at_root] or 'none'}",
124+
)
125+
126+
report.add(
127+
"structure", "tests in dedicated dir", 7, bool(test_dirs),
128+
f"test dirs: {test_dirs or 'none'}",
129+
)
130+
131+
modules = [f for f in files if f.suffix in (".js", ".mjs", ".cjs", ".ts")
132+
and not any(part in TEST_DIRS for part in f.parts)]
133+
report.add(
134+
"structure", "more than one source module", 6, len(modules) >= 2,
135+
f"{len(modules)} source modules",
136+
)
137+
138+
139+
# ------------------------------------------------------------------ hygiene
140+
141+
def _gitignore_covers(gitignore: Path, target: str) -> bool:
142+
"""True when a non-comment line actually ignores `target`. A substring
143+
scan would award the points to a commented-out line."""
144+
for raw in gitignore.read_text(errors="replace").splitlines():
145+
line = raw.strip()
146+
if not line or line.startswith("#"):
147+
continue
148+
# Normalize the common spellings: node_modules, /node_modules,
149+
# node_modules/, **/node_modules — all ignore the directory.
150+
normalized = line.strip("/")
151+
if normalized.startswith("**/"):
152+
normalized = normalized[3:]
153+
if normalized == target:
154+
return True
155+
return False
156+
157+
158+
def score_hygiene(root: Path, report: Report) -> None:
159+
gitignore = root / ".gitignore"
160+
report.add("hygiene", ".gitignore exists", 5, gitignore.is_file(),
161+
"present" if gitignore.is_file() else "absent")
162+
163+
covers = gitignore.is_file() and _gitignore_covers(gitignore, "node_modules")
164+
report.add(
165+
"hygiene", ".gitignore covers node_modules", 5,
166+
covers if gitignore.is_file() else False,
167+
"listed" if covers else "not listed (or no .gitignore)",
168+
)
169+
170+
junk: list[str] = []
171+
for d in JUNK_DIRS:
172+
if (root / d).is_dir():
173+
junk.append(d + "/")
174+
files = _list_files(root)
175+
for f in files:
176+
if f.name in JUNK_NAMES or f.name in JUNK_DATA or any(f.match(g) for g in JUNK_GLOBS):
177+
junk.append(str(f))
178+
report.add(
179+
"hygiene", "no junk artifacts in tree", 5, not junk,
180+
f"junk found: {junk}" if junk else "clean",
181+
)
182+
183+
if (root / ".git").exists():
184+
status = _git(root, "status", "--porcelain")
185+
if status.returncode != 0:
186+
# git missing/failed: empty stdout must not read as "clean".
187+
report.add("hygiene", "clean git status", 5, None,
188+
f"UNSCORABLE: git status failed: {status.stderr.strip()[:200]}")
189+
else:
190+
dirty = status.stdout.strip()
191+
report.add(
192+
"hygiene", "clean git status", 5, not dirty,
193+
f"{len(dirty.splitlines())} dirty paths" if dirty else "clean",
194+
)
195+
else:
196+
report.add("hygiene", "clean git status", 5, None, "UNSCORABLE: no .git in this copy")
197+
198+
199+
# ---------------------------------------------------------------------- git
200+
201+
def score_git(root: Path, report: Report) -> None:
202+
if not (root / ".git").exists():
203+
for name, pts in (("history has >= 3 commits", 5),
204+
("commit subjects are descriptive", 5),
205+
("no duplicated subjects", 5)):
206+
report.add("git", name, pts, None,
207+
"UNSCORABLE: no .git in this copy — score from the agent's original output")
208+
return
209+
210+
log = _git(root, "log", "--format=%s")
211+
if log.returncode != 0:
212+
for name, pts in (("history has >= 3 commits", 5),
213+
("commit subjects are descriptive", 5),
214+
("no duplicated subjects", 5)):
215+
report.add("git", name, pts, None, f"UNSCORABLE: git log failed: {log.stderr.strip()}")
216+
return
217+
218+
subjects = [s for s in log.stdout.splitlines() if s.strip()]
219+
report.add("git", "history has >= 3 commits", 5, len(subjects) >= 3, f"{len(subjects)} commits")
220+
221+
weak = [s for s in subjects if len(s) < 10 or TRIVIAL_SUBJECT.match(s.strip())]
222+
report.add(
223+
"git", "commit subjects are descriptive", 5, bool(subjects) and not weak,
224+
f"weak subjects: {weak}" if weak else "all subjects >= 10 chars and non-trivial",
225+
)
226+
227+
dupes = sorted({s for s in subjects if subjects.count(s) > 1})
228+
report.add(
229+
"git", "no duplicated subjects", 5, bool(subjects) and not dupes,
230+
f"duplicated: {dupes}" if dupes else "all unique",
231+
)
232+
233+
234+
# --------------------------------------------------------------- functional
235+
236+
def score_functional(root: Path, report: Report, run_tests: bool) -> None:
237+
if not run_tests:
238+
report.add("functional", "npm test passes", 30, None,
239+
"NOT RUN: pass --run-tests to execute the project's own suite")
240+
return
241+
if not (root / "package.json").is_file():
242+
report.add("functional", "npm test passes", 30, False, "no package.json")
243+
return
244+
env = {**os.environ, "CI": "1"}
245+
try:
246+
# --ignore-scripts: running the project's suite is the explicit
247+
# opt-in here; package lifecycle scripts are not part of that deal.
248+
install = subprocess.run(
249+
["npm", "install", "--no-audit", "--no-fund", "--silent", "--ignore-scripts"],
250+
cwd=root, capture_output=True, text=True, timeout=600, env=env)
251+
except FileNotFoundError:
252+
# "unscorable is never zero" applies to the scorer's own toolchain
253+
# too: a machine without npm couldn't look, the project didn't fail.
254+
report.add("functional", "npm test passes", 30, None, "UNSCORABLE: npm is not installed")
255+
return
256+
except subprocess.TimeoutExpired:
257+
report.add("functional", "npm test passes", 30, False, "npm install timed out after 600s")
258+
return
259+
if install.returncode != 0:
260+
report.add("functional", "npm test passes", 30, False,
261+
f"npm install failed: {install.stderr.strip()[:200]}")
262+
return
263+
try:
264+
test = subprocess.run(["npm", "test", "--silent"],
265+
cwd=root, capture_output=True, text=True, timeout=600, env=env)
266+
except subprocess.TimeoutExpired:
267+
# A suite that never finishes IS a property of the project.
268+
report.add("functional", "npm test passes", 30, False, "npm test timed out after 600s")
269+
return
270+
lines = [ln for ln in (test.stdout + test.stderr).splitlines() if ln.strip()]
271+
tail = lines[-1] if lines else "(no output)"
272+
report.add("functional", "npm test passes", 30, test.returncode == 0,
273+
f"exit {test.returncode}: {tail[:200]}")
274+
275+
276+
# ------------------------------------------------------ quality observations
277+
278+
def observe_quality(root: Path, report: Report) -> None:
279+
"""Quality (15) is judgment. Report inputs to that judgment; score nothing."""
280+
report.observations.append(
281+
f"README present: {(root / 'README.md').is_file()}"
282+
)
283+
pkg = root / "package.json"
284+
if pkg.is_file():
285+
try:
286+
meta = json.loads(pkg.read_text())
287+
have = [k for k in ("description", "license", "author") if meta.get(k)]
288+
report.observations.append(f"package.json metadata present: {have or 'none'}")
289+
except json.JSONDecodeError:
290+
report.observations.append("package.json: not valid JSON")
291+
error_types: set[str] = set()
292+
for f in _list_files(root):
293+
if f.suffix in (".js", ".mjs", ".cjs", ".ts"):
294+
text = (root / f).read_text(errors="replace")
295+
error_types.update(re.findall(r"class\s+(\w*Error)\s+extends", text))
296+
report.observations.append(f"custom error types: {sorted(error_types) or 'none'}")
297+
298+
299+
# ------------------------------------------------------------------- output
300+
301+
def render(report: Report) -> str:
302+
lines = [f"project: {report.project}", ""]
303+
by_dim: dict[str, list[Check]] = {}
304+
for c in report.checks:
305+
by_dim.setdefault(c.dimension, []).append(c)
306+
for dim, checks in by_dim.items():
307+
earned = sum(c.points for c in checks if c.passed is True)
308+
scoreable = sum(c.points for c in checks if c.passed is not None)
309+
total = sum(c.points for c in checks)
310+
head = f"{dim}{earned}/{scoreable} scoreable"
311+
if scoreable < total:
312+
head += f" ({total - scoreable} pts unscorable here)"
313+
lines.append(head)
314+
for c in checks:
315+
mark = {True: "PASS", False: "fail", None: " — "}[c.passed]
316+
lines.append(f" [{mark}] ({c.points:>2}) {c.name}: {c.evidence}")
317+
lines.append("")
318+
lines.append(f"deterministic total: {report.earned()}/{report.scoreable()} scoreable points")
319+
lines.append("quality (15 pts): judgment — observations only:")
320+
for o in report.observations:
321+
lines.append(f" - {o}")
322+
return "\n".join(lines)
323+
324+
325+
def score(root: Path, run_tests: bool = False) -> Report:
326+
report = Report(project=str(root))
327+
score_structure(root, report)
328+
score_hygiene(root, report)
329+
score_git(root, report)
330+
score_functional(root, report, run_tests)
331+
observe_quality(root, report)
332+
return report
333+
334+
335+
def main(argv: list[str] | None = None) -> int:
336+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
337+
parser.add_argument("project_dir", type=Path)
338+
parser.add_argument("--run-tests", action="store_true",
339+
help="execute npm install + npm test for the Functional dimension")
340+
parser.add_argument("--json", action="store_true", help="emit the report as JSON")
341+
args = parser.parse_args(argv)
342+
343+
root = args.project_dir
344+
if not root.is_dir():
345+
print(f"error: {root} is not a directory", file=sys.stderr)
346+
return 2
347+
report = score(root, run_tests=args.run_tests)
348+
if args.json:
349+
print(json.dumps(asdict(report), indent=2))
350+
else:
351+
print(render(report))
352+
return 0
353+
354+
355+
if __name__ == "__main__":
356+
raise SystemExit(main())

0 commit comments

Comments
 (0)