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
+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