codereview-loop 0.13.0__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.
codereview/bench.py ADDED
@@ -0,0 +1,881 @@
1
+ """Benchmark harness (Epic A): run review configurations over a
2
+ labelled corpus and score them against ground truth.
3
+
4
+ The corpus is data, not code: a directory of cases, each a folder
5
+ holding `case.json` (metadata + ground-truth expected findings) and
6
+ `diff.patch` (the change under review). A *configuration* is a named
7
+ panel (provider specs and/or scripted replay seats) described by a
8
+ small JSON file, e.g. bench/configs/*.json.
9
+
10
+ Scoring definitions (kept precise on purpose — these numbers are
11
+ the product's public proof):
12
+
13
+ - A reported finding *matches* an expected defect when they name
14
+ the same file AND either their line numbers are within
15
+ LINE_TOLERANCE, or (when lines are missing or far apart) the
16
+ finding's title shares enough word tokens with the expected
17
+ defect's title/keywords (Jaccard >= TOKEN_OVERLAP_MIN). Severity
18
+ and category are NOT required to match for a match — ground truth
19
+ is severity + location, per the benchmark spec — but a category
20
+ mismatch is recorded on the match.
21
+ - Each expected defect is matched by at most one finding, and each
22
+ finding matches at most one expected defect (greedy, worst
23
+ finding severity first, then confidence).
24
+ - recall = matched defects / total expected defects.
25
+ - A defect *escapes* when no matching finding carries a
26
+ gate-blocking severity (blocker or major — the default gate's
27
+ blocking set): the change would have shipped with the defect,
28
+ either because nobody saw it or because it was only noted as a
29
+ minor/nit. escaped_defect_rate = escaped / total expected.
30
+ - precision = matched findings / all reported findings.
31
+ - false positives = reported findings matching no expected defect.
32
+ On clean cases (no expected defects) every finding is a false
33
+ positive; clean_fp_rate = clean cases with >= 1 finding / clean
34
+ cases.
35
+ - cost per verdict = summed seat cost (attempts x the config's
36
+ cost_per_call_usd) / cases run. Latency is wall time per case;
37
+ p50/p95 use the nearest-rank method.
38
+
39
+ Scripted seats (`{"name": ..., "script": "file.json"}` in a config)
40
+ replay canned reviewer responses keyed by case id. They exist so
41
+ the harness and scoring run deterministically in CI with zero
42
+ provider calls; their scores validate the harness, not any model.
43
+ Real configurations name ordinary provider specs and go through
44
+ providers.build_providers like any other run.
45
+
46
+ The holdout split is private: `build_report(..., public_only=True)`
47
+ drops every holdout record before scoring, so holdout case ids,
48
+ titles, and per-case rows never appear in a public report.
49
+
50
+ Runs are resumable: results append to <out>/<config>.jsonl, and a
51
+ (config, case) pair already recorded is skipped unless forced.
52
+ Stored records are re-validated on resume against the current
53
+ corpus and config (diff hash + config hash); stale ones are
54
+ dropped and the file rewritten, never silently scored.
55
+ """
56
+
57
+ import hashlib
58
+ import json
59
+ import os
60
+ import re
61
+ import time
62
+
63
+ from . import core
64
+ from . import probe as probe_mod
65
+ from . import providers as providers_mod
66
+
67
+ CASE_SCHEMA = "codereview.benchcase/1"
68
+ RESULT_SCHEMA = "codereview.benchresult/1"
69
+ REPORT_SCHEMA = "codereview.benchreport/1"
70
+
71
+ SPLITS = ("public", "holdout")
72
+ BLOCKING_SEVERITIES = ("blocker", "major")
73
+ LINE_TOLERANCE = 3
74
+ TOKEN_OVERLAP_MIN = 0.3
75
+
76
+ _CASE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
77
+
78
+
79
+ # ------------------------------------------------------------------ corpus
80
+
81
+ def _validate_expected(case_id, exp, where):
82
+ if not isinstance(exp, dict):
83
+ raise ValueError(f"{where}: case {case_id}: expected entry "
84
+ f"is not an object")
85
+ sev = str(exp.get("severity") or "").lower()
86
+ if sev not in core.SEVERITIES:
87
+ raise ValueError(f"{where}: case {case_id}: expected severity "
88
+ f"{sev!r} not in {core.SEVERITIES}")
89
+ if not str(exp.get("file") or "").strip():
90
+ raise ValueError(f"{where}: case {case_id}: expected entry "
91
+ f"needs a file")
92
+ if not str(exp.get("title") or "").strip():
93
+ raise ValueError(f"{where}: case {case_id}: expected entry "
94
+ f"needs a title")
95
+ line = exp.get("line")
96
+ if line is not None:
97
+ try:
98
+ line = int(line)
99
+ except (TypeError, ValueError):
100
+ raise ValueError(f"{where}: case {case_id}: expected line "
101
+ f"{line!r} is not an integer")
102
+ return {
103
+ "severity": sev,
104
+ "category": str(exp.get("category") or "correctness").lower(),
105
+ "file": str(exp["file"]).strip(),
106
+ "line": line,
107
+ "title": str(exp["title"]).strip(),
108
+ "keywords": [str(k).lower() for k in exp.get("keywords") or []],
109
+ }
110
+
111
+
112
+ def load_case(case_dir):
113
+ """Load and validate one corpus case directory."""
114
+ meta_path = os.path.join(case_dir, "case.json")
115
+ diff_path = os.path.join(case_dir, "diff.patch")
116
+ with open(meta_path, encoding="utf-8") as fh:
117
+ meta = json.load(fh)
118
+ where = meta_path
119
+ case_id = str(meta.get("id") or "")
120
+ if not _CASE_ID_RE.match(case_id):
121
+ raise ValueError(f"{where}: id {case_id!r} must be lowercase "
122
+ f"letters, digits, and hyphens")
123
+ if os.path.basename(os.path.normpath(case_dir)) != case_id:
124
+ raise ValueError(f"{where}: directory name must equal the "
125
+ f"case id {case_id!r}")
126
+ for field in ("title", "category", "source", "license"):
127
+ if not str(meta.get(field) or "").strip():
128
+ raise ValueError(f"{where}: case {case_id}: missing "
129
+ f"{field}")
130
+ split = str(meta.get("split") or "")
131
+ if split not in SPLITS:
132
+ raise ValueError(f"{where}: case {case_id}: split must be "
133
+ f"one of {SPLITS}")
134
+ if not os.path.isfile(diff_path):
135
+ raise ValueError(f"{where}: case {case_id}: diff.patch "
136
+ f"missing")
137
+ with open(diff_path, encoding="utf-8", errors="replace") as fh:
138
+ diff = fh.read()
139
+ if not diff.strip():
140
+ raise ValueError(f"{where}: case {case_id}: diff.patch is "
141
+ f"empty")
142
+ expected = [_validate_expected(case_id, e, where)
143
+ for e in meta.get("expected") or []]
144
+ return {
145
+ "id": case_id,
146
+ "title": str(meta["title"]).strip(),
147
+ "category": str(meta["category"]).strip(),
148
+ "split": split,
149
+ "source": str(meta["source"]).strip(),
150
+ "license": str(meta["license"]).strip(),
151
+ "context": str(meta.get("context") or ""),
152
+ "expected": expected,
153
+ "diff": diff,
154
+ "dir": case_dir,
155
+ }
156
+
157
+
158
+ def load_corpus(corpus_dir):
159
+ """Load every case under corpus_dir (one subdirectory per case).
160
+
161
+ When a manifest.json is present its case list must agree with
162
+ the directories on disk — a case nobody listed, or a listed case
163
+ that is missing, is a loud error, never a silent skip."""
164
+ if not os.path.isdir(corpus_dir):
165
+ raise ValueError(f"corpus directory not found: {corpus_dir}")
166
+ ids = sorted(d for d in os.listdir(corpus_dir)
167
+ if os.path.isdir(os.path.join(corpus_dir, d))
168
+ and os.path.isfile(os.path.join(corpus_dir, d,
169
+ "case.json")))
170
+ cases = []
171
+ seen = set()
172
+ for cid in ids:
173
+ case = load_case(os.path.join(corpus_dir, cid))
174
+ if case["id"] in seen:
175
+ raise ValueError(f"duplicate corpus case id {case['id']}")
176
+ seen.add(case["id"])
177
+ cases.append(case)
178
+ manifest_path = os.path.join(corpus_dir, "manifest.json")
179
+ if os.path.isfile(manifest_path):
180
+ with open(manifest_path, encoding="utf-8") as fh:
181
+ manifest = json.load(fh)
182
+ listed = sorted(str(c.get("id") or "")
183
+ for c in manifest.get("cases") or [])
184
+ if listed != sorted(seen):
185
+ raise ValueError(
186
+ f"manifest {manifest_path} disagrees with the case "
187
+ f"directories (manifest-only: "
188
+ f"{sorted(set(listed) - seen)}, dir-only: "
189
+ f"{sorted(seen - set(listed))})")
190
+ return cases
191
+
192
+
193
+ # ------------------------------------------------------------------ scoring
194
+
195
+ def _tokens(text):
196
+ return set(re.findall(r"[a-z0-9]{3,}", (text or "").lower()))
197
+
198
+
199
+ def _finding_matches(exp, finding):
200
+ """(matches, category_match) per the definitions in the module
201
+ docstring."""
202
+ if (finding.get("file") or "").strip().lstrip("./") != \
203
+ exp["file"].lstrip("./"):
204
+ return False, False
205
+ category_match = (finding.get("category") or "").lower() == \
206
+ exp["category"]
207
+ eline, fline = exp.get("line"), finding.get("line")
208
+ if eline is not None and fline is not None and \
209
+ abs(int(fline) - int(eline)) <= LINE_TOLERANCE:
210
+ return True, category_match
211
+ exp_tokens = _tokens(exp["title"]) | set(exp["keywords"])
212
+ fin_tokens = _tokens(finding.get("title"))
213
+ if exp_tokens and fin_tokens:
214
+ overlap = len(exp_tokens & fin_tokens) / \
215
+ len(exp_tokens | fin_tokens)
216
+ if overlap >= TOKEN_OVERLAP_MIN:
217
+ return True, category_match
218
+ return False, category_match
219
+
220
+
221
+ def score_case(case, findings):
222
+ """Score one case's reported findings against its ground truth.
223
+
224
+ Returns counts and per-defect detail; findings are the merged
225
+ report findings exactly as the gate saw them (deterministic scan
226
+ findings included — the bench measures the whole pipeline)."""
227
+ findings = list(findings or [])
228
+ order = sorted(
229
+ range(len(findings)),
230
+ key=lambda i: (core._SEV_RANK.get(findings[i].get("severity"),
231
+ len(core.SEVERITIES)),
232
+ -float(findings[i].get("confidence") or 0)))
233
+ used = set()
234
+ defect_rows = []
235
+ matched_findings = 0
236
+ for exp in sorted(case["expected"],
237
+ key=lambda e: core._SEV_RANK[e["severity"]]):
238
+ hit = None
239
+ for i in order:
240
+ if i in used:
241
+ continue
242
+ ok, cat_ok = _finding_matches(exp, findings[i])
243
+ if ok:
244
+ hit = (i, cat_ok)
245
+ break
246
+ if hit is None:
247
+ defect_rows.append({
248
+ "expected": exp, "matched": False, "escaped": True,
249
+ "finding_severity": None, "category_match": None})
250
+ else:
251
+ i, cat_ok = hit
252
+ used.add(i)
253
+ matched_findings += 1
254
+ sev = findings[i].get("severity")
255
+ defect_rows.append({
256
+ "expected": exp, "matched": True,
257
+ "escaped": sev not in BLOCKING_SEVERITIES,
258
+ "finding_severity": sev, "category_match": cat_ok})
259
+ unmatched = [findings[i] for i in range(len(findings))
260
+ if i not in used]
261
+ return {
262
+ "expected_total": len(case["expected"]),
263
+ "matched": sum(1 for r in defect_rows if r["matched"]),
264
+ "escaped": sum(1 for r in defect_rows if r["escaped"]),
265
+ "findings_total": len(findings),
266
+ "matched_findings": matched_findings,
267
+ "false_positives": len(unmatched),
268
+ "defects": defect_rows,
269
+ }
270
+
271
+
272
+ def percentile(values, pct):
273
+ """Nearest-rank percentile; None for an empty sample."""
274
+ if not values:
275
+ return None
276
+ vals = sorted(values)
277
+ rank = max(1, -(-len(vals) * pct // 100))
278
+ return vals[min(rank, len(vals)) - 1]
279
+
280
+
281
+ def aggregate_scores(case_scores):
282
+ """Aggregate per-case score dicts into config-level metrics."""
283
+ total_expected = sum(s["expected_total"] for s in case_scores)
284
+ matched = sum(s["matched"] for s in case_scores)
285
+ escaped = sum(s["escaped"] for s in case_scores)
286
+ total_findings = sum(s["findings_total"] for s in case_scores)
287
+ matched_findings = sum(s["matched_findings"] for s in case_scores)
288
+ clean = [s for s in case_scores if s["expected_total"] == 0]
289
+ return {
290
+ "expected_defects": total_expected,
291
+ "matched_defects": matched,
292
+ "escaped_defects": escaped,
293
+ "recall": (matched / total_expected) if total_expected else None,
294
+ "escaped_defect_rate": (escaped / total_expected)
295
+ if total_expected else None,
296
+ "findings_total": total_findings,
297
+ "false_positives": total_findings - matched_findings,
298
+ "precision": (matched_findings / total_findings)
299
+ if total_findings else None,
300
+ "clean_cases": len(clean),
301
+ "clean_cases_with_fp": sum(1 for s in clean
302
+ if s["false_positives"] > 0),
303
+ "clean_fp_rate": (sum(1 for s in clean
304
+ if s["false_positives"] > 0) / len(clean))
305
+ if clean else None,
306
+ }
307
+
308
+
309
+ # ------------------------------------------------------------------ configs
310
+
311
+ def resolve_config_path(spec, corpus_dir=None):
312
+ """A config spec is a path to a JSON file, or a name resolved
313
+ against <corpus>/../configs/<name>.json, then ./bench/configs."""
314
+ if os.path.isfile(spec):
315
+ return spec
316
+ name = spec if spec.endswith(".json") else spec + ".json"
317
+ candidates = []
318
+ if corpus_dir:
319
+ candidates.append(os.path.join(corpus_dir, os.pardir,
320
+ "configs", name))
321
+ candidates.append(os.path.join("bench", "configs", name))
322
+ for cand in candidates:
323
+ if os.path.isfile(cand):
324
+ return cand
325
+ raise ValueError(f"bench config not found: {spec!r} (tried "
326
+ f"{', '.join(candidates) or 'no candidates'})")
327
+
328
+
329
+ def load_config(path):
330
+ with open(path, encoding="utf-8") as fh:
331
+ cfg = json.load(fh)
332
+ name = str(cfg.get("name") or
333
+ os.path.splitext(os.path.basename(path))[0])
334
+ if not cfg.get("providers"):
335
+ raise ValueError(f"bench config {path}: 'providers' must be "
336
+ f"a non-empty list")
337
+ # S1 (void solution §6): the panel's seat->lens binding is
338
+ # EXPLICIT. A config may declare `seat_lenses`; when it does
339
+ # not, the binding is derived ONCE here from the provider
340
+ # order (the historical assignment rule), recorded on the
341
+ # loaded config, and treated as explicit thereafter — so a
342
+ # seat dropped before a run leaves its lens unserved instead
343
+ # of silently remapping lenses onto the survivors. Provider
344
+ # entries may be spec strings or {name, script} objects; the
345
+ # binding keys the SEAT NAMES those entries produce.
346
+ if cfg.get("seat_lenses"):
347
+ seat_lenses = core.normalize_seat_lenses(cfg["seat_lenses"])
348
+ seat_lenses_source = "explicit"
349
+ else:
350
+ seat_lenses = core.derive_seat_lenses(
351
+ _provider_seat_names(cfg["providers"]))
352
+ seat_lenses_source = "derived"
353
+ return {
354
+ "name": name,
355
+ "providers": cfg["providers"],
356
+ "seat_meta": cfg.get("seat_meta") or {},
357
+ "allow_major": bool(cfg.get("allow_major", False)),
358
+ "strict": bool(cfg.get("strict", False)),
359
+ # B3 pilot scaffolding: a config may opt into the probe
360
+ # channel ("probes": true). Default False — an absent key
361
+ # is exactly the historical behaviour.
362
+ "probes": bool(cfg.get("probes", False)),
363
+ "cost_per_call_usd": cfg.get("cost_per_call_usd") or 0,
364
+ "seat_lenses": seat_lenses,
365
+ "seat_lenses_source": seat_lenses_source,
366
+ "path": path,
367
+ }
368
+
369
+
370
+ def _provider_seat_names(providers):
371
+ """Seat names a config's provider entries produce: a spec
372
+ string names its seat; a {name, script} object names its
373
+ scripted seat (default "scripted", as build_config_providers
374
+ has it)."""
375
+ names = []
376
+ for entry in providers or []:
377
+ if isinstance(entry, str):
378
+ names.append(entry)
379
+ elif isinstance(entry, dict):
380
+ names.append(str(entry.get("name") or "scripted"))
381
+ else:
382
+ names.append(str(entry))
383
+ return names
384
+
385
+
386
+ def _config_sha(cfg):
387
+ """Identity hash of a loaded bench config (the file path is
388
+ excluded — the same config at a different location is the same
389
+ config). Stored on every result record; on resume, records
390
+ whose config hash differs are stale and are dropped."""
391
+ identity = {k: cfg.get(k) for k in (
392
+ "name", "providers", "seat_meta", "allow_major", "strict",
393
+ "cost_per_call_usd")}
394
+ # S1: an EXPLICIT seat_lenses binding is part of the config's
395
+ # identity (two panels with the same providers but different
396
+ # bindings are different panels). A DERIVED binding is a pure
397
+ # function of `providers`, which is already in the identity —
398
+ # including it would move the hash of every pre-S1 config and
399
+ # orphan its stored records, so it stays out.
400
+ if cfg.get("seat_lenses_source") == "explicit":
401
+ identity["seat_lenses"] = cfg.get("seat_lenses")
402
+ # Same rule for the probe opt-in: only an ENABLED probe
403
+ # channel changes the config's identity, so every pre-B3
404
+ # config keeps its historical hash and stored records.
405
+ if cfg.get("probes"):
406
+ identity["probes"] = True
407
+ return hashlib.sha256(json.dumps(
408
+ identity, sort_keys=True).encode("utf-8")).hexdigest()
409
+
410
+
411
+ _CASE_MARKER_RE = re.compile(r"\[bench-case:\s*([a-z0-9-]+)\]")
412
+
413
+
414
+ def _scripted_seat(script_path):
415
+ """A replay seat: canned responses keyed by case id.
416
+
417
+ Script format: {"<case-id>": <response object>, "default":
418
+ <response object>}; a case entry may instead be {"by_lens":
419
+ {"correctness": <response>, "operations": <response>}}. The
420
+ response object is the reviewer's normal {"verdict", "summary",
421
+ "findings"} JSON. This is a harness-validation device — it is
422
+ never a model measurement."""
423
+ with open(script_path, encoding="utf-8") as fh:
424
+ script = json.load(fh)
425
+
426
+ def seat(prompt):
427
+ m = _CASE_MARKER_RE.search(prompt or "")
428
+ entry = None
429
+ if m:
430
+ entry = script.get(m.group(1))
431
+ if entry is None:
432
+ entry = script.get("default") or {"findings": []}
433
+ if isinstance(entry, dict) and "by_lens" in entry:
434
+ lens = "correctness" if "CORRECTNESS" in (prompt or "") \
435
+ else "operations"
436
+ entry = entry["by_lens"].get(lens) or \
437
+ entry["by_lens"].get("default") or {"findings": []}
438
+ return json.dumps(entry)
439
+
440
+ seat.__name__ = "scripted"
441
+ return seat
442
+
443
+
444
+ def build_config_providers(cfg):
445
+ """Config providers -> {seat name: callable} for run_review.
446
+
447
+ String entries are ordinary provider specs (built through
448
+ providers.build_providers so presets, custom endpoints, agent
449
+ seats, and account pools all work). Object entries with a
450
+ "script" key build a scripted replay seat; script paths resolve
451
+ relative to the config file."""
452
+ specs = []
453
+ seats = {}
454
+ for entry in cfg["providers"]:
455
+ if isinstance(entry, str):
456
+ specs.append(entry)
457
+ elif isinstance(entry, dict) and entry.get("script"):
458
+ name = str(entry.get("name") or "scripted")
459
+ if name in seats:
460
+ raise ValueError(f"bench config {cfg['name']}: "
461
+ f"duplicate scripted seat {name!r}")
462
+ script_path = entry["script"]
463
+ if not os.path.isabs(script_path):
464
+ script_path = os.path.join(
465
+ os.path.dirname(os.path.abspath(cfg["path"])),
466
+ script_path)
467
+ seats[name] = _scripted_seat(script_path)
468
+ else:
469
+ raise ValueError(f"bench config {cfg['name']}: provider "
470
+ f"entry must be a spec string or a "
471
+ f"{{name, script}} object: {entry!r}")
472
+ if specs:
473
+ built = providers_mod.build_providers(specs)
474
+ for name, fn in built.items():
475
+ if name in seats:
476
+ raise ValueError(f"bench config {cfg['name']}: seat "
477
+ f"name {name!r} used by both a "
478
+ f"scripted seat and a provider spec")
479
+ seats[name] = fn
480
+ return seats
481
+
482
+
483
+ def _seat_cost(cfg, seat_name):
484
+ rate = cfg["cost_per_call_usd"]
485
+ if isinstance(rate, dict):
486
+ return float(rate.get(seat_name, rate.get("default", 0)) or 0)
487
+ return float(rate or 0)
488
+
489
+
490
+ # ------------------------------------------------------------------ runner
491
+
492
+ def _read_results(path):
493
+ records = []
494
+ if os.path.isfile(path):
495
+ with open(path, encoding="utf-8") as fh:
496
+ for line in fh:
497
+ line = line.strip()
498
+ if line:
499
+ records.append(json.loads(line))
500
+ return records
501
+
502
+
503
+ def _probe_outcomes(report):
504
+ """Probe outcomes a run produced, extracted from its findings.
505
+
506
+ Probes exist only inside adjudication (core's Tech Lead loop,
507
+ bounded by core.MAX_PROBE_ROUNDS): when the Tech Lead sends a
508
+ disputed finding back for a builder-probe round, the outcome
509
+ lands on the finding's adjudication entry. This is a pure
510
+ read of the finished report — collecting outcomes can never
511
+ feed back into the verdict they rode along with."""
512
+ out = []
513
+ for f in report.get("findings") or []:
514
+ probe_rec = (f.get("adjudication") or {}).get("probe")
515
+ if not isinstance(probe_rec, dict):
516
+ probe_rec = f.get("probe")
517
+ if isinstance(probe_rec, dict):
518
+ out.append({
519
+ "finding_id": f.get("id"),
520
+ "outcome": str(probe_rec.get("outcome")
521
+ or "inconclusive"),
522
+ "evidence_quote": str(
523
+ probe_rec.get("evidence_quote") or ""),
524
+ "detail": str(probe_rec.get("detail") or ""),
525
+ })
526
+ return out
527
+
528
+
529
+ def run_bench(corpus_dir, config_specs, out_dir, split="all",
530
+ force=False, providers_by_config=None,
531
+ probes=False, probe_tree_dir=None):
532
+ """Run each config over the corpus; append per-case records to
533
+ <out_dir>/<config>.jsonl. Returns {config name: [records]} with
534
+ ALL records on disk (including ones from earlier runs), so a
535
+ resumed run reports the full picture. `providers_by_config`
536
+ lets an embedding runner (e.g. the internal swarm runner) inject
537
+ prebuilt provider callables per config name.
538
+
539
+ `probes` (B3 pilot scaffolding) wires the existing probe
540
+ harness into the run: when enabled — here, or by the config's
541
+ own "probes": true — each case's review gets a probe_fn built
542
+ by probe.make_probe_fn over `probe_tree_dir` (default: the
543
+ case's own directory; bench cases are diff-only, so a probe
544
+ executes against a temp copy of that directory, never the
545
+ corpus itself). The channel is the adjudication one
546
+ run_review already consumes: it can only rebut, it is bounded
547
+ by core.MAX_PROBE_ROUNDS, and wiring it changes NO verdict —
548
+ the outcomes are collected onto the record ("probes_enabled"
549
+ + "probe_outcomes") as data for the B3 pilot's calibration.
550
+ Default OFF: run_review is called with exactly the historical
551
+ arguments and records carry no probe fields."""
552
+ if split not in ("all",) + SPLITS:
553
+ raise ValueError(f"split must be all, public, or holdout")
554
+ all_cases = load_corpus(corpus_dir)
555
+ # Hash of every case's diff, across all splits: a stored result
556
+ # record is only a valid resume point when the case it was
557
+ # produced for still exists with the SAME diff (its recorded
558
+ # patch_sha256 is the hash of the diff that was reviewed) and
559
+ # the config that produced it is unchanged. Anything else is
560
+ # stale — trusting it would score findings gathered against a
561
+ # different change or a different panel (review-v11 finding).
562
+ diff_shas = {c["id"]: hashlib.sha256(
563
+ c["diff"].encode("utf-8")).hexdigest() for c in all_cases}
564
+ cases = all_cases
565
+ if split != "all":
566
+ cases = [c for c in cases if c["split"] == split]
567
+ os.makedirs(out_dir, exist_ok=True)
568
+ results = {}
569
+ for spec in config_specs:
570
+ cfg = load_config(resolve_config_path(spec, corpus_dir))
571
+ if providers_by_config and cfg["name"] in providers_by_config:
572
+ seats = providers_by_config[cfg["name"]]
573
+ else:
574
+ seats = build_config_providers(cfg)
575
+ # S1: the config binding governs when the seats actually
576
+ # serving are the config's own seats (a subset is fine —
577
+ # a dropped seat is exactly the case the binding exists
578
+ # for). An embedding runner may inject seats under names
579
+ # the config never declared; a DERIVED binding then falls
580
+ # back to the seat-order rule over the actual seats (the
581
+ # pre-S1 behaviour for that mechanism), while an EXPLICIT
582
+ # binding the runner cannot honour is a loud error, never
583
+ # a silent remap.
584
+ seat_lenses = cfg["seat_lenses"]
585
+ if not set(seats) <= set(_provider_seat_names(
586
+ cfg["providers"])):
587
+ if cfg["seat_lenses_source"] == "explicit":
588
+ raise ValueError(
589
+ f"bench config {cfg['name']}: explicit "
590
+ f"seat_lenses binding names "
591
+ f"{sorted(cfg['seat_lenses'])} but the runner "
592
+ f"provided seats {sorted(seats)} — refusing "
593
+ f"to remap lenses silently")
594
+ seat_lenses = core.derive_seat_lenses(list(seats))
595
+ path = os.path.join(out_dir, f"{cfg['name']}.jsonl")
596
+ cfg_sha = _config_sha(cfg)
597
+ records = []
598
+ if not force:
599
+ stale = 0
600
+ for r in _read_results(path):
601
+ if r.get("patch_sha256") == diff_shas.get(
602
+ r.get("case_id")) \
603
+ and r.get("config_sha256") == cfg_sha:
604
+ records.append(r)
605
+ else:
606
+ stale += 1
607
+ if stale:
608
+ # Rewrite without the stale records so `bench
609
+ # report` never scores results produced for a
610
+ # different corpus or config than the current one.
611
+ with open(path, "w", encoding="utf-8") as fh:
612
+ for r in records:
613
+ fh.write(json.dumps(r) + "\n")
614
+ done = {(r["config"], r["case_id"]) for r in records}
615
+ if force:
616
+ # --force means the stored records are REPLACED, not
617
+ # appended to (appending stacked a duplicate record
618
+ # per (config, case) on every forced rerun —
619
+ # review-v12 finding). The replacements stream to a
620
+ # sibling temp file, swapped in only when this
621
+ # config's run completes: a crash mid-run leaves the
622
+ # previous results file intact instead of a truncated
623
+ # stub (the fix diff's own internal review).
624
+ write_path, write_mode = path + ".tmp", "w"
625
+ else:
626
+ write_path, write_mode = path, "a"
627
+ with open(write_path, write_mode, encoding="utf-8") as fh:
628
+ for case in cases:
629
+ key = (cfg["name"], case["id"])
630
+ if key in done:
631
+ continue
632
+ context = (f"[bench-case: {case['id']}]\n"
633
+ f"{case['title']}\n{case['context']}"
634
+ ).strip()
635
+ started = time.monotonic()
636
+ review_kwargs = {}
637
+ if probes or cfg["probes"]:
638
+ review_kwargs["probe_fn"] = \
639
+ probe_mod.make_probe_fn(
640
+ probe_tree_dir or case["dir"])
641
+ report = core.run_review(
642
+ case["diff"], seats, context=context,
643
+ allow_major=cfg["allow_major"],
644
+ strict=cfg["strict"],
645
+ seat_meta=cfg["seat_meta"] or None,
646
+ seat_lenses=seat_lenses,
647
+ **review_kwargs)
648
+ duration_ms = int((time.monotonic() - started) * 1000)
649
+ calls = 0
650
+ cost = 0.0
651
+ for rep in report["reviewer_reports"]:
652
+ attempts = int(rep.get("attempts") or 1)
653
+ calls += attempts
654
+ cost += attempts * _seat_cost(cfg,
655
+ rep["provider"])
656
+ record = {
657
+ "schema": RESULT_SCHEMA,
658
+ "config": cfg["name"],
659
+ "config_sha256": cfg_sha,
660
+ "seat_lenses": report.get("seat_lenses"),
661
+ "case_id": case["id"],
662
+ "split": case["split"],
663
+ "category": case["category"],
664
+ "created_at": core._now_iso(),
665
+ "gate": report["gate"],
666
+ "degraded": report["degraded"],
667
+ "duration_ms": duration_ms,
668
+ "calls": calls,
669
+ "cost_usd": round(cost, 6),
670
+ "patch_sha256": report["patch_sha256"],
671
+ "findings": report["findings"],
672
+ # Shared persisted form (core
673
+ # .reviewer_report_entry): errored reports
674
+ # keep their full error string, HTTP status,
675
+ # failure classes, duration and prompt size —
676
+ # trimming them here is what made the voided
677
+ # confirmation attempts' failure classes
678
+ # unrecoverable (void forensics rec. 3).
679
+ "reviewer_reports": [
680
+ core.reviewer_report_entry(r)
681
+ for r in report["reviewer_reports"]],
682
+ }
683
+ if review_kwargs:
684
+ # Probe-channel records exist only on runs
685
+ # that wired the channel, so a default run's
686
+ # record shape is byte-identical to before.
687
+ record["probes_enabled"] = True
688
+ record["probe_outcomes"] = \
689
+ _probe_outcomes(report)
690
+ fh.write(json.dumps(record) + "\n")
691
+ fh.flush()
692
+ records.append(record)
693
+ done.add(key)
694
+ if force:
695
+ # The swap is earned by a HEALTHY run only: run_review
696
+ # absorbs provider failures into degraded records
697
+ # instead of raising, so a force run made while a
698
+ # provider is down would otherwise replace good stored
699
+ # results with all-degraded ones (review-v14 finding
700
+ # [2] — the crash leg was already safe: an exception
701
+ # mid-run never reaches this line and the previous file
702
+ # stays intact). Refuse the swap, drop the temp file,
703
+ # and say so loudly; the operator re-runs when the
704
+ # providers are back.
705
+ degraded = [r for r in records if r.get("degraded")]
706
+ if degraded:
707
+ try:
708
+ os.remove(write_path)
709
+ except OSError:
710
+ pass
711
+ raise ValueError(
712
+ f"bench --force: {len(degraded)} of "
713
+ f"{len(records)} case(s) for config "
714
+ f"{cfg['name']!r} came back degraded (a "
715
+ "provider failed); the previous results file "
716
+ "was left intact — re-run when the providers "
717
+ "are healthy")
718
+ os.replace(write_path, path)
719
+ results[cfg["name"]] = records
720
+ return results
721
+
722
+
723
+ # ------------------------------------------------------------------ report
724
+
725
+ def load_results(results_dir):
726
+ """All bench result records in a directory, keyed by config."""
727
+ out = {}
728
+ if not os.path.isdir(results_dir):
729
+ raise ValueError(f"results directory not found: {results_dir}")
730
+ for name in sorted(os.listdir(results_dir)):
731
+ if name.endswith(".jsonl"):
732
+ for rec in _read_results(os.path.join(results_dir, name)):
733
+ out.setdefault(rec["config"], []).append(rec)
734
+ return out
735
+
736
+
737
+ def build_report(results, corpus_dir, public_only=False):
738
+ """Score result records against the corpus ground truth.
739
+
740
+ With public_only=True every holdout record is dropped BEFORE
741
+ scoring: no holdout id, title, or row can appear in the report
742
+ (Epic A5 — the holdout split is the overfit guard)."""
743
+ cases = {c["id"]: c for c in load_corpus(corpus_dir)}
744
+ case_shas = {cid: hashlib.sha256(
745
+ c["diff"].encode("utf-8")).hexdigest()
746
+ for cid, c in cases.items()}
747
+ # Current config hashes, when the config file is resolvable:
748
+ # a standalone `bench report` must apply the same config
749
+ # staleness rule as a resumed run, or results produced under
750
+ # an older config get scored as if they were current.
751
+ cfg_shas = {}
752
+ for name in results:
753
+ try:
754
+ cfg_shas[name] = _config_sha(load_config(
755
+ resolve_config_path(name, corpus_dir)))
756
+ except Exception: # noqa: BLE001 — unresolvable: unjudged
757
+ cfg_shas[name] = None
758
+ stale_dropped = 0
759
+ configs = {}
760
+ for name, records in sorted(results.items()):
761
+ rows = []
762
+ scores = []
763
+ latencies = []
764
+ total_cost = 0.0
765
+ gates = {}
766
+ degraded = 0
767
+ for rec in records:
768
+ case = cases.get(rec["case_id"])
769
+ if case is None:
770
+ raise ValueError(
771
+ f"result references unknown corpus case "
772
+ f"{rec['case_id']!r}")
773
+ if public_only and case["split"] == "holdout":
774
+ continue
775
+ if rec.get("patch_sha256") is not None \
776
+ and rec.get("patch_sha256") != case_shas[case["id"]]:
777
+ # The record was produced for a different diff
778
+ # than the case's current one; scoring it would
779
+ # grade findings against the wrong change.
780
+ stale_dropped += 1
781
+ continue
782
+ if rec.get("config_sha256") is not None \
783
+ and cfg_shas.get(name) is not None \
784
+ and rec.get("config_sha256") != cfg_shas[name]:
785
+ # Same rule for the producing config: results
786
+ # gathered under a different panel definition are
787
+ # not this config's results.
788
+ stale_dropped += 1
789
+ continue
790
+ scored = score_case(case, rec.get("findings"))
791
+ scores.append(scored)
792
+ latencies.append(int(rec.get("duration_ms") or 0))
793
+ total_cost += float(rec.get("cost_usd") or 0)
794
+ gates[rec.get("gate")] = gates.get(rec.get("gate"), 0) + 1
795
+ degraded += 1 if rec.get("degraded") else 0
796
+ rows.append({
797
+ "case_id": case["id"],
798
+ "split": case["split"],
799
+ "category": case["category"],
800
+ "gate": rec.get("gate"),
801
+ "degraded": bool(rec.get("degraded")),
802
+ "duration_ms": int(rec.get("duration_ms") or 0),
803
+ "matched": scored["matched"],
804
+ "expected": scored["expected_total"],
805
+ "escaped": scored["escaped"],
806
+ "false_positives": scored["false_positives"],
807
+ })
808
+ metrics = aggregate_scores(scores)
809
+ metrics.update({
810
+ "cases_run": len(rows),
811
+ "defect_cases": sum(1 for r in rows if r["expected"] > 0),
812
+ "gates": gates,
813
+ "degraded_cases": degraded,
814
+ "total_cost_usd": round(total_cost, 6),
815
+ "cost_per_verdict_usd": (round(total_cost / len(rows), 6)
816
+ if rows else None),
817
+ "latency_p50_ms": percentile(latencies, 50),
818
+ "latency_p95_ms": percentile(latencies, 95),
819
+ })
820
+ configs[name] = {"metrics": metrics, "cases": rows}
821
+ return {
822
+ "schema": REPORT_SCHEMA,
823
+ "generated_at": core._now_iso(),
824
+ "public_only": bool(public_only),
825
+ "holdout_excluded": bool(public_only),
826
+ "corpus_cases": len(cases),
827
+ "stale_records_dropped": stale_dropped,
828
+ "configs": configs,
829
+ }
830
+
831
+
832
+ def _fmt(value, spec="{:.3f}"):
833
+ return "—" if value is None else spec.format(value)
834
+
835
+
836
+ def render_markdown(report):
837
+ lines = ["# CodeReview benchmark report", ""]
838
+ lines.append(f"Generated: {report['generated_at']} · "
839
+ f"corpus cases known: {report['corpus_cases']}")
840
+ if report["public_only"]:
841
+ lines.append("**Public report — holdout split excluded.** "
842
+ "Holdout cases are the overfit guard; their "
843
+ "ids, rows, and scores never appear here.")
844
+ lines.append("")
845
+ lines.append("| Config | Cases | Recall | Escaped-defect rate "
846
+ "| Precision | Clean-case FP rate | Cost/verdict "
847
+ "| p50 | p95 |")
848
+ lines.append("|---|---:|---:|---:|---:|---:|---:|---:|---:|")
849
+ for name, entry in report["configs"].items():
850
+ m = entry["metrics"]
851
+ lines.append(
852
+ f"| {name} | {m['cases_run']} "
853
+ f"| {_fmt(m['recall'])} "
854
+ f"| {_fmt(m['escaped_defect_rate'])} "
855
+ f"| {_fmt(m['precision'])} "
856
+ f"| {_fmt(m['clean_fp_rate'])} "
857
+ f"| {_fmt(m['cost_per_verdict_usd'], '${:.4f}')} "
858
+ f"| {_fmt(m['latency_p50_ms'], '{:.0f} ms')} "
859
+ f"| {_fmt(m['latency_p95_ms'], '{:.0f} ms')} |")
860
+ for name, entry in report["configs"].items():
861
+ m = entry["metrics"]
862
+ lines += ["", f"## {name}", "",
863
+ f"Defects: {m['matched_defects']}/"
864
+ f"{m['expected_defects']} matched, "
865
+ f"{m['escaped_defects']} escaped · findings: "
866
+ f"{m['findings_total']} ({m['false_positives']} "
867
+ f"false positives) · gates: "
868
+ f"{json.dumps(m['gates'], sort_keys=True)} · "
869
+ f"degraded cases: {m['degraded_cases']}", "",
870
+ "| Case | Split | Category | Gate | Matched | "
871
+ "Escaped | FPs | ms |",
872
+ "|---|---|---|---|---:|---:|---:|---:|"]
873
+ for row in entry["cases"]:
874
+ lines.append(
875
+ f"| {row['case_id']} | {row['split']} "
876
+ f"| {row['category']} | {row['gate']} "
877
+ f"| {row['matched']}/{row['expected']} "
878
+ f"| {row['escaped']} | {row['false_positives']} "
879
+ f"| {row['duration_ms']} |")
880
+ lines.append("")
881
+ return "\n".join(lines)