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
+33
View File
@@ -1,3 +1,4 @@
from openultrasast.calibration import FalsePositiveReason, fp_reachability_overrides, record_false_positive_learning
from openultrasast.findings import StaticFinding
from openultrasast.policy import CwePolicy
from openultrasast.scoring import build_score_artifact, gate, project_score
@@ -62,6 +63,38 @@ def test_hard_gate_fails_on_severity5_reachable_only() -> None:
assert ok["passed"] is True # inferred reachability does not trip the hard gate
def test_confirmed_false_positive_lowers_penalty_without_deleting_rule() -> None:
finding = StaticFinding(
finding_id="python-os-command:app.py:1",
path="app.py",
title="t",
severity="critical",
confidence="medium",
evidence_level="static_corroboration",
rationale="r",
line=1,
function_name=None,
reachability_status="reachable",
reachability_evidence=[],
reachability_conditions=[],
tags=["syscall_entry"],
ranking_priority=3.0,
)
learning = record_false_positive_learning(
finding,
reason=FalsePositiveReason.MISSING_ATTACKER_CONTROL,
evidence="input is a constant, not attacker-controlled",
)
overrides = fp_reachability_overrides([finding], [learning])
rule_cwe = {finding.finding_id: "CWE-78"}
assert overrides[finding.finding_id] < 1.0 # reachable multiplier lowered from 1.0
base = project_score([finding], rule_cwe, POLICY)
with_fp = project_score([finding], rule_cwe, POLICY, reachability_override=overrides)
assert with_fp > base # confirmed FP reduces the penalty, raising the score
assert finding.finding_id in rule_cwe # the rule still fires; it was not deleted
def test_score_gate_blocks_only_when_blocking_enabled() -> None:
finding = _finding("sql:a.py:1", "inferred-file-surface")
rule_cwe = {"sql:a.py:1": "CWE-89"}