fold/bench/measure_bailout_precision.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

299 lines
11 KiB
Python

#!/usr/bin/env python3
"""Measure Juliet precision impact of encoder bail-out categories.
This script runs fold with `--profile-reach` on Juliet testcase files one at a
time, parses the emitted reachability report, and correlates bailout categories
with file-level TP/miss outcomes. Because `--profile-reach` is aggregate per
scan rather than per-finding, the attribution is necessarily testcase-level:
every finding from a scan is treated as affected by the bailout categories
reported for that testcase file.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shlex
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
from run_fold_on_juliet import ( # type: ignore
CLANG,
CLANGXX,
CWE_CONFIG,
DEFAULT_FOLD,
GOLD_ROOT,
JULIET_ROOT,
LLVM_BIN,
RESULTS_ROOT,
file_basename,
load_findings,
now_iso,
)
REACH_ROW_RE = re.compile(
r"^\s*(?P<node>\w+):\s+encoded=(?P<encoded>\d+)\s+bailed=(?P<bailed>\d+)"
r"\s+bail_rate=(?P<rate>[0-9.]+)%"
r"(?:\s+top_bails=(?P<top>.*))?$"
)
TOP_REASON_RE = re.compile(r"(?P<reason>.+?)\((?P<count>\d+)\)")
def latest_full_run() -> Path | None:
candidates = sorted(RESULTS_ROOT.glob("*_fold-juliet"))
if not candidates:
return None
return candidates[-1]
def load_full_scores(full_run_dir: Path) -> dict[str, dict]:
scores: dict[str, dict] = {}
for cwe in CWE_CONFIG:
payload = full_run_dir / cwe / "fold.json"
if not payload.exists():
continue
doc = json.loads(payload.read_text())
scores[cwe] = doc["score"]
return scores
def load_gold_files(cwe: str) -> list[Path]:
gold = json.loads((GOLD_ROOT / f"juliet-{cwe}.json").read_text())
return [JULIET_ROOT / cwe / entry["files"][0] for entry in gold["expected"]]
def build_single_file_db(src: Path, corpus_dir: Path, db_path: Path) -> None:
include_dirs = set()
for path in sorted(corpus_dir.rglob("*")):
if path.is_dir() and path.name == "testcasesupport":
include_dirs.add(str(path))
if path.is_dir() and any(path.glob("*.h")):
include_dirs.add(str(path))
inc_flags = [flag for d in sorted(include_dirs) for flag in ("-I", d)]
compiler = CLANGXX if src.suffix == ".cpp" else CLANG
std = "-std=c++17" if src.suffix == ".cpp" else "-std=c11"
entry = {
"directory": str(corpus_dir),
"command": shlex.join([str(compiler), std, *inc_flags, "-c", str(src)]),
"file": str(src),
}
db_path.write_text(json.dumps([entry], indent=2) + "\n")
def category_for_reason(node: str, reason: str) -> str:
lower = reason.lower()
if "function call" in lower or "constructor" in lower:
return "Call"
if "pointer dereference" in lower or "member expr" in lower:
return "Pointer"
if "unsupported local type" in lower:
return "UnsupportedType"
if "floatingliteral" in lower:
return "Floating"
if "cxxnewexpr" in lower or "cxxdeleteexpr" in lower:
return "Heap"
if "subscript value opaque" in lower:
return "OverApprox"
if lower.startswith("expr kind ") or lower.startswith("stmt kind "):
return "UnsupportedNode"
if "assign target" in lower or "assign non-var" in lower:
return "UnsupportedNode"
if "unary op" in lower or "non-var declref" in lower or "encode recursion" in lower:
return "UnsupportedNode"
if node in {"ArraySubscriptExpr", "MemberExpr", "UnaryOperator"}:
return "OverApprox"
return "Other"
def parse_reach_categories(log_text: str) -> dict[str, int]:
categories: dict[str, int] = defaultdict(int)
in_reach = False
for line in log_text.splitlines():
if line.startswith("[reach-profile] reachability"):
in_reach = True
continue
if not in_reach:
continue
if line.startswith("[") and not line.startswith("[reach-profile]"):
break
content = line.replace("[reach-profile]", "", 1).rstrip()
if not content.strip():
continue
match = REACH_ROW_RE.match(content)
if not match:
continue
node = match.group("node")
if int(match.group("bailed")) == 0:
continue
top = match.group("top") or ""
if top:
for reason_match in TOP_REASON_RE.finditer(top):
reason = reason_match.group("reason").strip().rstrip(",")
count = int(reason_match.group("count"))
categories[category_for_reason(node, reason)] += count
else:
categories[category_for_reason(node, node)] += int(match.group("bailed"))
return dict(categories)
def finding_files_for_scan(cwe_out: Path, kind: str) -> set[str]:
findings, _ = load_findings(cwe_out)
return {file_basename(item["file"]) for item in findings if item.get("kind") == kind}
def run_file(src: Path, cwe: str, fold_bin: Path, cache_dir: Path) -> dict:
rel_key = f"{cwe}:{src.relative_to(JULIET_ROOT / cwe)}"
digest = hashlib.sha256(rel_key.encode("utf-8")).hexdigest()[:16]
cache_key = f"{cwe}-{src.stem}-{digest}.json"
cache_path = cache_dir / cache_key
if cache_path.exists():
return json.loads(cache_path.read_text())
corpus_dir = JULIET_ROOT / cwe
work_dir = cache_dir / f"{cwe}-{src.stem}-{digest}"
work_dir.mkdir(parents=True, exist_ok=True)
db_path = work_dir / "compile_commands.json"
sarif_path = work_dir / "fold.sarif"
build_single_file_db(src, corpus_dir, db_path)
config = CWE_CONFIG[cwe]
command = [
str(fold_bin), "scan",
"--compile-db", str(db_path),
"--format", "sarif",
"--output", str(sarif_path),
"--min-body-stmts", "1",
"--processes", "1",
"--profile-reach",
"--detectors", config["detector"],
]
for pattern in config.get("exclude_globs", []):
command.extend(["--exclude-glob", pattern])
env = os.environ.copy()
env["PATH"] = f"{LLVM_BIN}:{env.get('PATH', '')}"
start = time.monotonic()
proc = subprocess.run(command, capture_output=True, text=True, env=env)
elapsed = time.monotonic() - start
combined_log = proc.stdout + "\n" + proc.stderr
categories = parse_reach_categories(combined_log)
found = file_basename(src.name) in finding_files_for_scan(work_dir, config["kind"])
result = {
"file": str(src),
"cwe": cwe,
"kind": config["kind"],
"elapsed_s": elapsed,
"exit_code": proc.returncode,
"found": found,
"categories": categories,
}
cache_path.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n")
return result
def summarize(records: list[dict], full_scores: dict[str, dict]) -> tuple[str, list[dict]]:
total_findings = sum(1 for record in records if record["found"])
grouped: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
for record in records:
categories = set(record["categories"])
for category in categories:
grouped[category]["functions"] += 1
if record["found"]:
grouped[category]["findings"] += 1
grouped[category]["tp"] += 1
else:
grouped[category]["missed_gold"] += 1
grouped[category]["gold_total"] += 1
baseline_by_cwe = {cwe: score.get("recall", 0.0) for cwe, score in full_scores.items()}
overall_baseline = sum(baseline_by_cwe.values()) / len(baseline_by_cwe) if baseline_by_cwe else 0.0
rows = []
for category, stats in sorted(grouped.items(), key=lambda item: (-item[1]["functions"], item[0])):
gold_total = stats["gold_total"]
tp = stats["tp"]
findings = stats["findings"]
category_recall = tp / gold_total if gold_total else 0.0
tp_rate = tp / findings if findings else 0.0
recall_loss_pp = max(0.0, (overall_baseline - category_recall) * 100.0)
rows.append({
"category": category,
"functions": stats["functions"],
"findings_pct": (100.0 * findings / total_findings) if total_findings else 0.0,
"tp_rate": tp_rate,
"recall_loss_pp": recall_loss_pp,
})
lines = [
"Bail-out Precision Impact on Juliet",
"=====================================",
"Category | Functions | % Findings | TP Rate | Recall Loss",
"------------------|-----------|------------|---------|------------",
]
for row in rows:
lines.append(
f"{row['category']:<18}| {row['functions']:>9} | {row['findings_pct']:>9.1f}% | "
f"{row['tp_rate']:>6.1%} | {row['recall_loss_pp']:>9.1f}pp"
)
return "\n".join(lines) + "\n", rows
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cwes", nargs="+", default=["476", "369", "190", "121"])
parser.add_argument("--fold-bin", default=str(DEFAULT_FOLD))
parser.add_argument("--full-run-dir", default="")
parser.add_argument("--out-root", default=str(RESULTS_ROOT))
parser.add_argument("--limit-files", type=int, default=0, help="Debug/smoke limit across the selected Juliet files")
args = parser.parse_args()
fold_bin = Path(args.fold_bin).resolve()
if not fold_bin.exists():
print(f"fold binary not found: {fold_bin}", file=sys.stderr)
return 2
if not CLANG.exists() or not CLANGXX.exists():
print(f"clang/clang++ not found under {LLVM_BIN}", file=sys.stderr)
return 2
full_run_dir = Path(args.full_run_dir).resolve() if args.full_run_dir else latest_full_run()
if full_run_dir is None or not full_run_dir.is_dir():
print("no existing full Juliet run found; pass --full-run-dir", file=sys.stderr)
return 2
full_scores = load_full_scores(full_run_dir)
out_root = Path(args.out_root).resolve() / f"{now_iso()}_fold-juliet-bailouts"
out_root.mkdir(parents=True, exist_ok=True)
cache_dir = out_root / "per-file"
cache_dir.mkdir(parents=True, exist_ok=True)
files: list[tuple[str, Path]] = []
for cwe in args.cwes:
for src in load_gold_files(cwe):
files.append((cwe, src))
if args.limit_files > 0:
files = files[:args.limit_files]
records = []
for index, (cwe, src) in enumerate(files, start=1):
print(f"[{index}/{len(files)}] profiling CWE-{cwe} {src.name}", file=sys.stderr)
records.append(run_file(src, cwe, fold_bin, cache_dir))
table, rows = summarize(records, full_scores)
(out_root / "summary.txt").write_text(table)
(out_root / "summary.json").write_text(json.dumps(rows, indent=2, sort_keys=True) + "\n")
print(table)
print(f"results written to {out_root}")
return 0
if __name__ == "__main__":
raise SystemExit(main())