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