fold/bench/score.py
trvon e30764fb19
bench: extend fold_eval scoring with labeled negative cases
Add negative_cases_from_universe to expand an explicit file-by-rule universe
into labeled negatives (enabling TP/FP/TN/FN, not just TP/FP), plus project-eval
harness updates in run_project_eval and the score/score_fp/quality-gate helpers.
2026-08-16 16:43:51 -06:00

141 lines
4.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Compute precision/recall/F1 of a tool run against a gold standard.
Gold standard schema (bench/gold/<corpus>.json):
{
"name": "<corpus-name>",
"description": "...",
"expected": [
{"kind": "clone|equiv|dead|class-dup",
"files": ["a.cpp", "b.cpp"], # group that should be reported
"id": "<stable-key>"}
],
"universe": {
"files": ["all/evaluated.cpp"],
"kinds": ["all-evaluated-rules"]
}
}
A tool finding "covers" a gold entry if:
- The kinds match (or the gold entry's kind is "any"), AND
- Each expected file appears as a `file` in at least one finding location
sharing the same `id` family (kind prefix match).
When ``universe`` is present, every file/rule pair not listed in ``expected``
is an explicit negative case. This permits honest FP/TN and specificity
measurement without treating arbitrary source locations as negatives.
Output: bench/results/<run>/summary.md with a per-tool precision/recall table.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts" / "benchmarks"))
from fold_eval.scoring import (
negative_cases_from_universe,
score_grouped_findings,
score_labeled_cases,
)
def load_gold(gold_path: Path) -> dict:
return json.loads(gold_path.read_text())
def score_tool(
findings: list[dict],
gold_entries: list[dict],
negative_entries: list[dict] | None = None,
) -> dict:
"""Compatibility wrapper around the canonical one-to-one evaluator."""
if negative_entries is not None:
return score_labeled_cases(findings, gold_entries, negative_entries)
return score_grouped_findings(findings, gold_entries)
def render(rows: list[dict], gold_name: str) -> str:
out = [
f"# fold benchmark — {gold_name}",
"",
"| Tool | TP | FP | FN | TN | Findings | Cases | Precision | Recall | Specificity | F1 | Elapsed |",
"|------|---:|---:|---:|---:|---------:|------:|----------:|-------:|------------:|---:|--------:|",
]
for r in rows:
score = r["score"]
true_negative = score.get("true_negative")
specificity = score.get("specificity")
out.append(
f"| {r['tool']} | {score['true_positive']} | "
f"{score['false_positive']} | {score['false_negative']} | "
f"{true_negative if true_negative is not None else '-'} | "
f"{score['finding_groups']} | "
f"{score.get('positive_total', score.get('gold_total', 0)) + score.get('negative_total', 0)} | "
f"{score['precision']:.2%} | {score['recall']:.2%} | "
f"{f'{specificity:.2%}' if specificity is not None else '-'} | "
f"{score['f1']:.2%} | {r['elapsed_s']:.1f}s |"
)
if any(r["score"]["missed_gold_indices"] for r in rows):
out.append("")
out.append("## Missed gold entries (by tool)")
for r in rows:
missed = r["score"]["missed_gold_indices"]
if not missed:
continue
out.append(f"- **{r['tool']}** missed: {missed}")
return "\n".join(out) + "\n"
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--run-dir", required=True,
help="A bench/results/<stamp>_<name>/ directory")
p.add_argument("--gold", required=True,
help="Path to gold standard JSON")
args = p.parse_args()
run_dir = Path(args.run_dir).resolve()
if not run_dir.is_dir():
print(f"run-dir not found: {run_dir}", file=sys.stderr)
return 2
gold = load_gold(Path(args.gold))
rows = []
for tool_json in sorted(run_dir.glob("*.json")):
# Skip support files written by run_bench.py.
if tool_json.name in {"meta.json", "compile_commands.json"}:
continue
try:
payload = json.loads(tool_json.read_text())
except json.JSONDecodeError:
continue
# Tool payloads are objects; skip anything that isn't (e.g. the
# compile DB array if it wasn't filtered above).
if not isinstance(payload, dict) or "findings" not in payload:
continue
negative = (
negative_cases_from_universe(gold["expected"], gold["universe"])
if "universe" in gold
else gold.get("negative")
)
score = score_tool(payload.get("findings", []), gold["expected"], negative)
rows.append({
"tool": payload["tool"],
"elapsed_s": payload.get("elapsed_s", 0.0),
"score": score,
})
summary = render(rows, gold.get("name", "unknown"))
(run_dir / "summary.md").write_text(summary)
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())