feat(benchmark): Phase 2 (task 4) — actionable per-rule signals, ledger, FP knob

Complete Phase 2: make benchmark output drive per-rule improvement, wire the
loop-owned ledger, and turn a confirmed false positive into a score knob. Zero
new dependencies; detection stays byte-identical (recall 44/47 = 93.6%, FP 0%)
and the 90/10 gate holds.

- Per-rule attribution (4.1): BenchmarkMiss/BenchmarkFalsePositive carry rule_id;
  BenchmarkMetrics.per_rule aggregates matched/missed/false_positives per rule.
- Recommendation delta (4.2): evaluate_benchmark emits RuleRecommendation
  (keep/shadow/loosen) written to rule_policy_delta.json; the inert
  next_improvement_candidate stub is now rule-aware ("rule:<id>" or
  "language_hunter").
- Loop ledger (4.3): cli loads the ruleset with the rule_policy.json overlay
  (empty -> byte-identical; an entry overlays a rule's status/floors at load);
  read_rule_ledger/write_rule_ledger added for the evolve loop. A disabled rule
  in the ledger no longer fires (covered by an integration test).
- FP -> reachability knob (4.4): a confirmed false-positive learning lowers a
  finding's effective reachability multiplier in the score
  (fp_reachability_overrides + scoring reachability_override) instead of deleting
  the rule, so recall stays high while the score stops over-penalizing.

93 tests pass; ruff + mypy clean. Spec tasks 4.1-4.4 checked off — Phase 2
(Core: rules-as-data + benchmark signals) is complete. The loop-driven decision
to apply ledger flips remains the gated evolve loop (Phase 3/4).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-06-22 15:41:17 +02:00
co-authored by Claude Opus 4.8
parent 444d8c9490
commit 2d7ef48e52
11 changed files with 316 additions and 16 deletions
+76 -1
View File
@@ -60,6 +60,7 @@ class BenchmarkMiss:
path: str
evidence: str
reason: str
rule_id: str | None = None
@dataclass(frozen=True)
@@ -79,6 +80,7 @@ class BenchmarkCalibrationRecord:
failed_stage: str
next_improvement_candidate: str
reason: str
rule_id: str | None = None
@dataclass(frozen=True)
@@ -87,6 +89,17 @@ class BenchmarkFalsePositive:
path: str
line: int | None
reason: str
rule_id: str = ""
@dataclass(frozen=True)
class RuleRecommendation:
rule_id: str
action: str # keep | shadow | loosen
matched: int
missed: int
false_positives: int
rationale: str
@dataclass(frozen=True)
@@ -101,6 +114,7 @@ class BenchmarkMetrics:
runtime_seconds: float | None
model_usage: dict[str, object]
artifact_links: dict[str, str]
per_rule: dict[str, dict[str, int]]
@dataclass(frozen=True)
@@ -123,6 +137,7 @@ class BenchmarkResult:
false_positives: list[BenchmarkFalsePositive]
baseline_deltas: list[BenchmarkBaselineDelta]
calibration_records: list[BenchmarkCalibrationRecord]
rule_recommendations: list[RuleRecommendation]
def load_benchmark_manifest(path: Path) -> BenchmarkManifest:
@@ -224,6 +239,8 @@ def evaluate_benchmark(
false_positives = [_false_positive(finding) for index, finding in enumerate(findings) if index not in matched_finding_indexes]
baseline_deltas = _baseline_deltas(run.manifest.expected, baseline_findings or [])
calibration_records = [_calibration_record(miss) for miss in misses]
per_rule = _per_rule_metrics(findings, matched_finding_indexes, misses)
rule_recommendations = _rule_recommendations(per_rule)
expected_total = len(run.manifest.expected)
actual_total = len(findings)
missed_total = len(misses)
@@ -249,11 +266,13 @@ def evaluate_benchmark(
runtime_seconds=runtime_seconds,
model_usage={},
artifact_links=_artifact_links(scan_run_dir),
per_rule=per_rule,
),
misses=misses,
false_positives=false_positives,
baseline_deltas=baseline_deltas,
calibration_records=calibration_records,
rule_recommendations=rule_recommendations,
)
@@ -266,6 +285,9 @@ def write_benchmark_artifacts(run: BenchmarkRun, manifest_path: Path, result: Be
(run.root / "external_baseline_deltas.json").write_text(
json.dumps(_jsonable([asdict(delta) for delta in result.baseline_deltas]), indent=2, sort_keys=True) + "\n"
)
(run.root / "rule_policy_delta.json").write_text(
json.dumps(_jsonable([asdict(rec) for rec in result.rule_recommendations]), indent=2, sort_keys=True) + "\n"
)
def _matching_finding_index(expected: ExpectedFinding, findings: list[StaticFinding], used_indexes: set[int]) -> int | None:
@@ -301,6 +323,7 @@ def _miss(expected: ExpectedFinding) -> BenchmarkMiss:
path=expected.path,
evidence=expected.evidence,
reason="no OpenUltraSAST finding matched the expected benchmark vulnerability",
rule_id=expected.rule_id,
)
@@ -310,9 +333,57 @@ def _false_positive(finding: StaticFinding) -> BenchmarkFalsePositive:
path=finding.path,
line=finding.line,
reason="finding did not match any expected benchmark vulnerability",
rule_id=_rule_id_of(finding.finding_id),
)
def _rule_id_of(finding_id: str) -> str:
return finding_id.split(":", 1)[0]
def _per_rule_metrics(
findings: list[StaticFinding],
matched_finding_indexes: set[int],
misses: list[BenchmarkMiss],
) -> dict[str, dict[str, int]]:
per_rule: dict[str, dict[str, int]] = {}
def bucket(rule_id: str) -> dict[str, int]:
return per_rule.setdefault(rule_id, {"matched": 0, "missed": 0, "false_positives": 0})
for index, finding in enumerate(findings):
rule_id = _rule_id_of(finding.finding_id)
key = "matched" if index in matched_finding_indexes else "false_positives"
bucket(rule_id)[key] += 1
for miss in misses:
if miss.rule_id:
bucket(miss.rule_id)["missed"] += 1
return dict(sorted(per_rule.items()))
def _rule_recommendations(per_rule: dict[str, dict[str, int]]) -> list[RuleRecommendation]:
recommendations: list[RuleRecommendation] = []
for rule_id, counts in per_rule.items():
matched, missed, fps = counts["matched"], counts["missed"], counts["false_positives"]
if fps > 0:
action, rationale = "shadow", "rule produced false positives; stage it before it drags precision"
elif missed > 0:
action, rationale = "loosen", "rule missed expected findings; widen or lower its evidence floor"
else:
action, rationale = "keep", "rule matched expected findings with no false positives"
recommendations.append(
RuleRecommendation(
rule_id=rule_id,
action=action,
matched=matched,
missed=missed,
false_positives=fps,
rationale=rationale,
)
)
return recommendations
def _baseline_deltas(expected_findings: list[ExpectedFinding], baseline_findings: list[BaselineFinding]) -> list[BenchmarkBaselineDelta]:
tools = sorted({finding.tool for finding in baseline_findings})
deltas: list[BenchmarkBaselineDelta] = []
@@ -359,14 +430,18 @@ def _baseline_matches_expected(expected: ExpectedFinding, finding: BaselineFindi
def _calibration_record(miss: BenchmarkMiss) -> BenchmarkCalibrationRecord:
# A miss tied to a known rule points the loop at that rule; an unattributed
# miss (no rule fired for the class) is a coverage gap for a language hunter.
candidate = f"rule:{miss.rule_id}" if miss.rule_id else "language_hunter"
return BenchmarkCalibrationRecord(
cwe=miss.cwe,
vulnerability_class=miss.vulnerability_class,
path=miss.path,
evidence=miss.evidence,
failed_stage="benchmark_ground_truth_matching",
next_improvement_candidate="rules_or_language_hunter",
next_improvement_candidate=candidate,
reason=miss.reason,
rule_id=miss.rule_id,
)
+24
View File
@@ -249,6 +249,30 @@ def learnings_from_verifications(
return learnings
# A confirmed false positive lowers a finding's effective reachability multiplier
# by this factor in the project score (it does not delete the rule).
FP_REACHABILITY_FACTOR = 0.4
_BASE_REACH_MULT = {"reachable": 1.0, "inferred-file-surface": 0.6, "unknown": 0.4}
def fp_reachability_overrides(
findings: list[StaticFinding],
learnings: list[FalsePositiveLearning],
) -> dict[str, float]:
"""Lowered score multipliers for findings whose scope has a confirmed FP.
Keyed by ``finding_id``; consumed by the scoring model so a confirmed false
positive reduces the score penalty instead of the rule being deleted.
"""
overrides: dict[str, float] = {}
for finding in findings:
vulnerability_class = _vulnerability_class(finding)
if any(_learning_applies(finding.path, learning) and learning.vulnerability_class == vulnerability_class for learning in learnings):
base = _BASE_REACH_MULT.get(finding.reachability_status, _BASE_REACH_MULT["unknown"])
overrides[finding.finding_id] = round(base * FP_REACHABILITY_FACTOR, 3)
return overrides
def _reason_from_verification(finding: StaticFinding, status: VerificationStatus) -> FalsePositiveReason:
if finding.reachability_status == "unknown":
return FalsePositiveReason.UNREACHABLE_PATH
+8 -2
View File
@@ -18,6 +18,7 @@ from .benchmark import (
from .calibration import (
FalsePositiveLearning,
calibrate_rankings,
fp_reachability_overrides,
learnings_from_verifications,
load_false_positive_learnings,
merge_false_positive_learnings,
@@ -105,7 +106,9 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
write_harness_config(config=config, processors=[], contract_mode="strict", path=run.root / "harness.json")
runtime.start(mode=mode, target=run.target)
policy = runtime.run_stage("policy_load", load_policy)
ruleset = runtime.run_stage("ruleset_load", lambda: load_ruleset(DEFAULT_RULESET_DIR))
ledger_path = run.target / CALIBRATION_DIR / "false_positive_learnings.json"
rule_ledger_path = run.target / CALIBRATION_DIR / "rule_policy.json"
ruleset = runtime.run_stage("ruleset_load", lambda: load_ruleset(DEFAULT_RULESET_DIR, rule_ledger_path))
runtime.run_stage("policy_check", lambda: assert_rules_resolve(ruleset, policy))
static_hints = runtime.run_stage("static_mapping", lambda: _load_static_hints(config.static_analysis.sarif_paths))
write_static_hints(static_hints, run.root / "mapping" / "static_hints.json")
@@ -118,7 +121,6 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
targets = attach_reachability_hints(targets, entry_points)
write_preprocess_artifact(snapshot, targets, run.root / "preprocess" / "file_targets.json")
rankings = runtime.run_stage("rank", lambda: rank_targets(targets))
ledger_path = run.target / CALIBRATION_DIR / "false_positive_learnings.json"
prior_learnings = load_false_positive_learnings(ledger_path)
rankings, calibrations = runtime.run_stage("calibrate", lambda: calibrate_rankings(rankings, prior_learnings))
applied_calibrations = [calibration for calibration in calibrations if calibration.applied_learning_ids]
@@ -168,6 +170,9 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
)
cwe_by_rule = {rule.rule_id: rule.cwe for rule in ruleset}
rule_cwe_by_id = {finding.finding_id: cwe_by_rule.get(finding.finding_id.split(":", 1)[0], "") for finding in findings}
# A confirmed false positive (prior-scan learning) lowers a finding's effective
# reachability multiplier in the score instead of deleting the rule.
reachability_override = fp_reachability_overrides(findings, prior_learnings)
score_artifact = runtime.run_stage(
"score",
lambda: build_score_artifact(
@@ -178,6 +183,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
min_score=config.score.min_score,
block_severity_reachable=config.score.block_severity_reachable,
blocking=config.score.blocking,
reachability_override=reachability_override,
),
)
score_path = run.root / "score.json"
+4
View File
@@ -5,6 +5,8 @@ from .store import (
PatternRule,
RulesetError,
load_ruleset,
read_rule_ledger,
write_rule_ledger,
write_ruleset,
)
@@ -13,5 +15,7 @@ __all__ = [
"PatternRule",
"RulesetError",
"load_ruleset",
"read_rule_ledger",
"write_rule_ledger",
"write_ruleset",
]
+11
View File
@@ -81,6 +81,17 @@ def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None:
path.write_text("\n\n".join(blocks) + "\n")
def read_rule_ledger(path: Path) -> dict[str, dict[str, object]]:
"""Read the loop-owned ``rule_policy.json`` overlay (status/floors per rule_id)."""
return _load_ledger(path)
def write_rule_ledger(path: Path, entries: dict[str, dict[str, object]]) -> None:
"""Write the loop-owned ``rule_policy.json`` overlay (used by the evolve loop)."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n")
def _rule_from_dict(item: dict[str, object], path: Path) -> PatternRule:
try:
rule_id = str(item["rule_id"])
+19 -6
View File
@@ -29,15 +29,26 @@ class ScoreArtifact:
return asdict(self)
def finding_penalty(finding: StaticFinding, rule_cwe: str | None, policy: Mapping[str, CwePolicy]) -> float:
def finding_penalty(
finding: StaticFinding,
rule_cwe: str | None,
policy: Mapping[str, CwePolicy],
reachability_override: Mapping[str, float] | None = None,
) -> float:
"""Penalty contributed by one finding: severity weight × reachability multiplier.
Dynamic-only or unmapped CWEs contribute zero (report-only, never scored).
Dynamic-only or unmapped CWEs contribute zero (report-only, never scored). A
confirmed false positive can supply a lowered effective reachability multiplier
via ``reachability_override`` (keyed by ``finding_id``) instead of deleting the rule.
"""
pol = policy.get(rule_cwe) if rule_cwe else None
if pol is None or not pol.static:
return 0.0
return SEV_WEIGHT[pol.severity] * REACH_MULT.get(finding.reachability_status, REACH_MULT["unknown"])
if reachability_override and finding.finding_id in reachability_override:
multiplier = reachability_override[finding.finding_id]
else:
multiplier = REACH_MULT.get(finding.reachability_status, REACH_MULT["unknown"])
return SEV_WEIGHT[pol.severity] * multiplier
def project_score(
@@ -45,13 +56,14 @@ def project_score(
rule_cwe_by_id: Mapping[str, str],
policy: Mapping[str, CwePolicy],
k: float = 60.0,
reachability_override: Mapping[str, float] | None = None,
) -> int:
"""Compute the 0-100 project score (shadow findings excluded by the caller).
Exponential decay of total penalty: no penalty → 100; one severity-5 reachable
finding → ~43.
"""
total = sum(finding_penalty(finding, rule_cwe_by_id.get(finding.finding_id), policy) for finding in findings)
total = sum(finding_penalty(finding, rule_cwe_by_id.get(finding.finding_id), policy, reachability_override) for finding in findings)
return round(100 * math.exp(-total / k))
@@ -92,8 +104,9 @@ def build_score_artifact(
min_score: int = 80,
block_severity_reachable: int = 5,
blocking: bool = False,
reachability_override: Mapping[str, float] | None = None,
) -> ScoreArtifact:
score = project_score(findings, rule_cwe_by_id, policy, k=k)
score = project_score(findings, rule_cwe_by_id, policy, k=k, reachability_override=reachability_override)
penalty_total = 0.0
max_severity = 0
by_category: dict[str, int] = {}
@@ -109,7 +122,7 @@ def build_score_artifact(
if pol.dynamic:
out_of_scope_dynamic_only += 1
continue
penalty_total += finding_penalty(finding, cwe, policy)
penalty_total += finding_penalty(finding, cwe, policy, reachability_override)
max_severity = max(max_severity, pol.severity)
by_category[pol.flaw_category] = by_category.get(pol.flaw_category, 0) + 1
return ScoreArtifact(