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/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
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)
|