mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 20:19:29 +00:00
feat(learning-harness): aggregation, noise handling and honest denominators
Task 2.4. Per family, and per slice when asked: outcome counts, recall, silence, Youden, a directional-bias index and recall at a fixed false-positive ceiling. Rows that cannot be scored are listed by reason and leave every denominator, and a family with nothing scorable reports no rate rather than a perfect one. Two numbers exist because Youden cannot tell apart a detector that flags everything from one that says everything is safe: the directional-bias index separates them, and recall at a fixed ceiling is forfeited entirely once a family leaks more than the budget allows. Change against a baseline is not a difference of means. Flips are counted per pair in both directions and a two-sided exact sign test decides whether the change is bigger than the noise, so one flip in ten reads as noise and six reads as a real regression. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
241af31a9a
commit
95ffd901e4
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"feature_name": "learning-harness",
|
"feature_name": "learning-harness",
|
||||||
"created_at": "2026-09-06T06:32:41Z",
|
"created_at": "2026-09-06T06:32:41Z",
|
||||||
"updated_at": "2026-09-06T06:57:33Z",
|
"updated_at": "2026-09-06T07:00:52Z",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"phase": "tasks-generated",
|
"phase": "implementation",
|
||||||
"approvals": {
|
"approvals": {
|
||||||
"requirements": {
|
"requirements": {
|
||||||
"generated": true,
|
"generated": true,
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"generated": true,
|
"generated": true,
|
||||||
"approved": false
|
"approved": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ready_for_implementation": false,
|
"ready_for_implementation": true,
|
||||||
"depends_on": [
|
"depends_on": [
|
||||||
"pair-corpus-honesty",
|
"pair-corpus-honesty",
|
||||||
"corpus-seeded-mechanisms",
|
"corpus-seeded-mechanisms",
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
"tree-sitter-overlay-extra",
|
"tree-sitter-overlay-extra",
|
||||||
"harnessx-self-improving-rulesets"
|
"harnessx-self-improving-rulesets"
|
||||||
],
|
],
|
||||||
"approval_note": "2026-09-06 requirements approved by the maintainer in session ('approve the requirements and run the design phase'); 2026-09-06 design approved by the maintainer in session ('approve the design and generate the tasks')"
|
"approval_note": "2026-09-06 requirements approved by the maintainer in session ('approve the requirements and run the design phase'); 2026-09-06 design approved by the maintainer in session ('approve the design and generate the tasks'); 2026-09-06 tasks approved by the maintainer in session ('approve the tasks and go on with the implementation')"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ outcome is the majority of the runs, and the runs that disagree with it are the
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from ..findings import StaticFinding
|
from ..findings import StaticFinding
|
||||||
@@ -34,6 +35,7 @@ class PairFamilyScore:
|
|||||||
family: str
|
family: str
|
||||||
runs: tuple[FamilyOutcome, ...]
|
runs: tuple[FamilyOutcome, ...]
|
||||||
outcome: FamilyOutcome
|
outcome: FamilyOutcome
|
||||||
|
slice: str = ""
|
||||||
other_findings_vuln: int = 0 # right place, another family: not a detection, never a leak
|
other_findings_vuln: int = 0 # right place, another family: not a detection, never a leak
|
||||||
other_findings_fixed: int = 0
|
other_findings_fixed: int = 0
|
||||||
fabricated_vuln: int = 0 # a family id outside the taxonomy: noise, and worth naming
|
fabricated_vuln: int = 0 # a family id outside the taxonomy: noise, and worth naming
|
||||||
@@ -54,9 +56,10 @@ def score_pair_family(
|
|||||||
) -> PairFamilyScore:
|
) -> PairFamilyScore:
|
||||||
"""Score one pair for one family over K runs of the detector on each side."""
|
"""Score one pair for one family over K runs of the detector on each side."""
|
||||||
name = str(getattr(case, "name", "?"))
|
name = str(getattr(case, "name", "?"))
|
||||||
|
slice_name = str(getattr(case, "slice", "") or "")
|
||||||
reason = _unscorable(case, ranges)
|
reason = _unscorable(case, ranges)
|
||||||
if reason is not None:
|
if reason is not None:
|
||||||
return PairFamilyScore(pair=name, family=family, runs=(), outcome="unscorable", unscorable_reason=reason)
|
return PairFamilyScore(pair=name, family=family, slice=slice_name, runs=(), outcome="unscorable", unscorable_reason=reason)
|
||||||
spans = _spans(case, ranges)
|
spans = _spans(case, ranges)
|
||||||
outcomes: list[FamilyOutcome] = []
|
outcomes: list[FamilyOutcome] = []
|
||||||
other_vuln = other_fixed = fabricated_vuln = fabricated_fixed = 0
|
other_vuln = other_fixed = fabricated_vuln = fabricated_fixed = 0
|
||||||
@@ -73,11 +76,12 @@ def score_pair_family(
|
|||||||
rung = _best_rung(rung, hits_vuln)
|
rung = _best_rung(rung, hits_vuln)
|
||||||
outcomes.append(_outcome(bool(hits_vuln), bool(hits_fixed)))
|
outcomes.append(_outcome(bool(hits_vuln), bool(hits_fixed)))
|
||||||
if not outcomes:
|
if not outcomes:
|
||||||
return PairFamilyScore(pair=name, family=family, runs=(), outcome="unscorable", unscorable_reason="no_runs")
|
return PairFamilyScore(pair=name, family=family, slice=slice_name, runs=(), outcome="unscorable", unscorable_reason="no_runs")
|
||||||
majority = Counter(outcomes).most_common(1)[0][0]
|
majority = Counter(outcomes).most_common(1)[0][0]
|
||||||
return PairFamilyScore(
|
return PairFamilyScore(
|
||||||
pair=name,
|
pair=name,
|
||||||
family=family,
|
family=family,
|
||||||
|
slice=slice_name,
|
||||||
runs=tuple(outcomes),
|
runs=tuple(outcomes),
|
||||||
outcome=majority,
|
outcome=majority,
|
||||||
other_findings_vuln=other_vuln,
|
other_findings_vuln=other_vuln,
|
||||||
@@ -158,4 +162,123 @@ def _best_rung(current: Rung, hits: Sequence[StaticFinding]) -> Rung:
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["FamilyOutcome", "FunctionRanges", "PairFamilyScore", "Rung", "score_pair_family"]
|
@dataclass(frozen=True)
|
||||||
|
class FamilyMetrics:
|
||||||
|
"""One family's numbers, with the denominator they were computed over and why rows fell out of it."""
|
||||||
|
|
||||||
|
family: str
|
||||||
|
taxonomy_version: str
|
||||||
|
scorable: int
|
||||||
|
unscorable: dict[str, int] = field(default_factory=dict)
|
||||||
|
outcomes: dict[str, int] = field(default_factory=dict)
|
||||||
|
recall: float = 0.0 # flagged the vulnerable side
|
||||||
|
silence: float = 0.0 # said nothing on the fixed side
|
||||||
|
youden: float = 0.0
|
||||||
|
directional_bias: float = 0.0 # positive: flags both sides; negative: silent on both. Youden cannot tell them apart.
|
||||||
|
leak_rate: float = 0.0
|
||||||
|
fixed_fpr_recall: float = 0.0 # recall, forfeited entirely when the leak rate exceeds the ceiling
|
||||||
|
negative_flips: int = 0 # pairs that were correct in the baseline and are not now
|
||||||
|
positive_flips: int = 0
|
||||||
|
p_value: float | None = None # two-sided sign test over the discordant pairs
|
||||||
|
reliable_change: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"family": self.family,
|
||||||
|
"taxonomy_version": self.taxonomy_version,
|
||||||
|
"scorable": self.scorable,
|
||||||
|
"unscorable": dict(sorted(self.unscorable.items())),
|
||||||
|
"outcomes": dict(sorted(self.outcomes.items())),
|
||||||
|
"recall": self.recall,
|
||||||
|
"silence": self.silence,
|
||||||
|
"youden": self.youden,
|
||||||
|
"directional_bias": self.directional_bias,
|
||||||
|
"leak_rate": self.leak_rate,
|
||||||
|
"fixed_fpr_recall": self.fixed_fpr_recall,
|
||||||
|
"negative_flips": self.negative_flips,
|
||||||
|
"positive_flips": self.positive_flips,
|
||||||
|
"p_value": self.p_value,
|
||||||
|
"reliable_change": self.reliable_change,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate(
|
||||||
|
scores: Sequence[PairFamilyScore],
|
||||||
|
*,
|
||||||
|
taxonomy: FamilyTaxonomy,
|
||||||
|
baseline: Sequence[PairFamilyScore] | None = None,
|
||||||
|
fpr_ceiling: float = 0.1,
|
||||||
|
per_slice: bool = False,
|
||||||
|
alpha: float = 0.05,
|
||||||
|
) -> dict[str, FamilyMetrics]:
|
||||||
|
"""Group scores into per-family numbers. Unscorable rows are listed by reason and leave every denominator."""
|
||||||
|
grouped: dict[str, list[PairFamilyScore]] = {}
|
||||||
|
for score in scores:
|
||||||
|
grouped.setdefault(f"{score.slice}/{score.family}" if per_slice else score.family, []).append(score)
|
||||||
|
was_correct = {(item.slice if per_slice else "", item.family, item.pair): item.outcome for item in baseline or ()}
|
||||||
|
out: dict[str, FamilyMetrics] = {}
|
||||||
|
for key, group in sorted(grouped.items()):
|
||||||
|
scorable = [item for item in group if item.outcome != "unscorable"]
|
||||||
|
reasons: dict[str, int] = {}
|
||||||
|
for item in group:
|
||||||
|
if item.outcome == "unscorable":
|
||||||
|
reason = item.unscorable_reason or "unknown"
|
||||||
|
reasons[reason] = reasons.get(reason, 0) + 1
|
||||||
|
counts = Counter(item.outcome for item in scorable)
|
||||||
|
total = len(scorable)
|
||||||
|
detected = counts["pair_correct"] + counts["both_flagged"]
|
||||||
|
silent = counts["pair_correct"] + counts["both_silent"]
|
||||||
|
leaks = counts["both_flagged"] + counts["reversed"]
|
||||||
|
recall = detected / total if total else 0.0
|
||||||
|
silence = silent / total if total else 0.0
|
||||||
|
leak_rate = leaks / total if total else 0.0
|
||||||
|
negative = positive = 0
|
||||||
|
for item in scorable:
|
||||||
|
before = was_correct.get((item.slice if per_slice else "", item.family, item.pair))
|
||||||
|
if before is None or before == "unscorable":
|
||||||
|
continue
|
||||||
|
if before == "pair_correct" and item.outcome != "pair_correct":
|
||||||
|
negative += 1
|
||||||
|
elif before != "pair_correct" and item.outcome == "pair_correct":
|
||||||
|
positive += 1
|
||||||
|
p_value = sign_test(positive, negative) if baseline is not None else None
|
||||||
|
out[key] = FamilyMetrics(
|
||||||
|
family=group[0].family,
|
||||||
|
taxonomy_version=taxonomy.version,
|
||||||
|
scorable=total,
|
||||||
|
unscorable=reasons,
|
||||||
|
outcomes={name: count for name, count in sorted(counts.items())},
|
||||||
|
recall=recall,
|
||||||
|
silence=silence,
|
||||||
|
youden=(recall + silence - 1.0) if total else 0.0,
|
||||||
|
directional_bias=((counts["both_flagged"] - counts["both_silent"]) / total) if total else 0.0,
|
||||||
|
leak_rate=leak_rate,
|
||||||
|
fixed_fpr_recall=recall if leak_rate <= fpr_ceiling else 0.0,
|
||||||
|
negative_flips=negative,
|
||||||
|
positive_flips=positive,
|
||||||
|
p_value=p_value,
|
||||||
|
reliable_change=bool(p_value is not None and p_value < alpha and positive != negative),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sign_test(better: int, worse: int) -> float:
|
||||||
|
"""Two-sided exact sign test over the pairs that changed. 1.0 when nothing changed, so noise never reads as progress."""
|
||||||
|
total = better + worse
|
||||||
|
if total == 0:
|
||||||
|
return 1.0
|
||||||
|
smaller = min(better, worse)
|
||||||
|
tail = sum(math.comb(total, index) for index in range(smaller + 1)) / float(2**total)
|
||||||
|
return float(min(1.0, 2.0 * tail))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FamilyMetrics",
|
||||||
|
"FamilyOutcome",
|
||||||
|
"FunctionRanges",
|
||||||
|
"PairFamilyScore",
|
||||||
|
"Rung",
|
||||||
|
"aggregate",
|
||||||
|
"score_pair_family",
|
||||||
|
"sign_test",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""learning-harness task 2.4: aggregation, noise handling and honest denominators (offline)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from openultrasast.learning.families import load_families
|
||||||
|
from openultrasast.learning.scoring import FamilyOutcome, PairFamilyScore
|
||||||
|
|
||||||
|
|
||||||
|
def _score(
|
||||||
|
pair: str, outcome: FamilyOutcome, *, family: str = "access_control", slice_name: str = "vibe-py", **extra: object
|
||||||
|
) -> PairFamilyScore:
|
||||||
|
runs = () if outcome == "unscorable" else (outcome,)
|
||||||
|
return PairFamilyScore(pair=pair, family=family, slice=slice_name, runs=runs, outcome=outcome, **extra) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rates_use_only_the_scorable_rows_and_the_reasons_are_kept() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
taxonomy = load_families()
|
||||||
|
scores = [
|
||||||
|
_score("a", "pair_correct"),
|
||||||
|
_score("b", "both_silent"),
|
||||||
|
_score("c", "unscorable", unscorable_reason="identical_twin"),
|
||||||
|
_score("d", "unscorable", unscorable_reason="identical_twin"),
|
||||||
|
_score("e", "unscorable", unscorable_reason="unsupported_language"),
|
||||||
|
]
|
||||||
|
metrics = aggregate(scores, taxonomy=taxonomy)["access_control"]
|
||||||
|
assert metrics.scorable == 2 and metrics.unscorable == {"identical_twin": 2, "unsupported_language": 1}
|
||||||
|
assert metrics.recall == 0.5 and metrics.silence == 1.0 and metrics.youden == 0.5
|
||||||
|
assert metrics.outcomes == {"pair_correct": 1, "both_silent": 1}
|
||||||
|
assert metrics.taxonomy_version == taxonomy.version
|
||||||
|
json.dumps(metrics.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_family_with_nothing_scorable_reports_no_rate_rather_than_a_perfect_one() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
metrics = aggregate([_score("a", "unscorable", unscorable_reason="identical_twin")], taxonomy=load_families())["access_control"]
|
||||||
|
assert metrics.scorable == 0 and metrics.recall == 0.0 and metrics.silence == 0.0 and metrics.youden == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_directional_bias_separates_flagging_everything_from_saying_everything_is_safe() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
taxonomy = load_families()
|
||||||
|
loud = aggregate([_score("a", "both_flagged"), _score("b", "both_flagged")], taxonomy=taxonomy)["access_control"]
|
||||||
|
quiet = aggregate([_score("a", "both_silent"), _score("b", "both_silent")], taxonomy=taxonomy)["access_control"]
|
||||||
|
assert loud.directional_bias == 1.0 and quiet.directional_bias == -1.0
|
||||||
|
assert loud.youden == quiet.youden == 0.0 # the same Youden, opposite failures: the index is what tells them apart
|
||||||
|
|
||||||
|
|
||||||
|
def test_recall_at_a_fixed_false_positive_ceiling_is_forfeited_when_the_family_leaks() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
taxonomy = load_families()
|
||||||
|
clean = [_score(str(index), "pair_correct") for index in range(9)] + [_score("x", "both_silent")]
|
||||||
|
assert aggregate(clean, taxonomy=taxonomy, fpr_ceiling=0.1)["access_control"].fixed_fpr_recall == 0.9
|
||||||
|
leaky = [_score(str(index), "pair_correct") for index in range(8)] + [_score("y", "both_flagged"), _score("z", "reversed")]
|
||||||
|
metrics = aggregate(leaky, taxonomy=taxonomy, fpr_ceiling=0.1)["access_control"]
|
||||||
|
assert metrics.recall == 0.9 and metrics.fixed_fpr_recall == 0.0 # a 20% leak rate buys no credit at a 10% ceiling
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_flips_and_the_reliable_change_test_compare_against_a_baseline() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
taxonomy = load_families()
|
||||||
|
baseline = [_score(str(index), "pair_correct") for index in range(10)]
|
||||||
|
same = aggregate(baseline, taxonomy=taxonomy, baseline=baseline)["access_control"]
|
||||||
|
assert same.negative_flips == 0 and same.reliable_change is False
|
||||||
|
broke_one = [_score("0", "both_silent")] + [_score(str(index), "pair_correct") for index in range(1, 10)]
|
||||||
|
one = aggregate(broke_one, taxonomy=taxonomy, baseline=baseline)["access_control"]
|
||||||
|
assert one.negative_flips == 1 and one.reliable_change is False # one flip in ten is inside the noise
|
||||||
|
broke_six = [_score(str(index), "both_silent") for index in range(6)] + [_score(str(index), "pair_correct") for index in range(6, 10)]
|
||||||
|
six = aggregate(broke_six, taxonomy=taxonomy, baseline=baseline)["access_control"]
|
||||||
|
assert six.negative_flips == 6 and six.reliable_change is True
|
||||||
|
assert six.p_value is not None and six.p_value < 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_are_reported_per_slice_when_asked() -> None:
|
||||||
|
from openultrasast.learning.scoring import aggregate
|
||||||
|
|
||||||
|
taxonomy = load_families()
|
||||||
|
scores = [_score("a", "pair_correct", slice_name="vibe-py"), _score("b", "both_silent", slice_name="agent-vfc")]
|
||||||
|
overall = aggregate(scores, taxonomy=taxonomy)
|
||||||
|
assert set(overall) == {"access_control"} and overall["access_control"].scorable == 2
|
||||||
|
per_slice = aggregate(scores, taxonomy=taxonomy, per_slice=True)
|
||||||
|
assert set(per_slice) == {"vibe-py/access_control", "agent-vfc/access_control"}
|
||||||
|
assert per_slice["vibe-py/access_control"].recall == 1.0 and per_slice["agent-vfc/access_control"].recall == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_sign_test_is_two_sided_and_symmetric() -> None:
|
||||||
|
from openultrasast.learning.scoring import sign_test
|
||||||
|
|
||||||
|
assert sign_test(0, 0) == 1.0
|
||||||
|
assert sign_test(6, 0) == sign_test(0, 6) < 0.05
|
||||||
|
assert sign_test(1, 0) > 0.05 and sign_test(3, 3) == 1.0
|
||||||
Reference in New Issue
Block a user