mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
feat(corpus-seeded-mechanisms): leave-one-out is the corpus's own detection rate
Task 3.1 (Req 4.1-4.4). - semantic/loo.py: every scorable pair is held out once; a temporary store is seeded from the other seeded/reviewed pairs (advisory and title pairs are scored as targets, never teach; known_limit skipped); both excerpts are searched for variants and scored with the pair rules (detected inside the labeled function naming the labeled mechanism or sink; silent when the fixed side yields no hit); found_by and leaked_by name the records and the pairs that taught them; metrics per slice, profile and mechanism; score_pair_with_store is shared with the lever. - cli: `pairs --slice S --loo [--loo-out P]` writes loo.json; never a gate. - First measurement: vibe-py 35 pairs, 11 detected, 21 silent, Youden -0.086, 16 teaching pairs. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8f444051f2
commit
64ac46c24c
+34
-10
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
@@ -45,6 +46,7 @@ from .pair_gate import print_pair_metrics
|
|||||||
from .pairs import (
|
from .pairs import (
|
||||||
DEFAULT_CATALOG,
|
DEFAULT_CATALOG,
|
||||||
SLICE_NAMES,
|
SLICE_NAMES,
|
||||||
|
PairCase,
|
||||||
evaluate_catalog,
|
evaluate_catalog,
|
||||||
load_pair_catalog,
|
load_pair_catalog,
|
||||||
make_hunter_scan,
|
make_hunter_scan,
|
||||||
@@ -60,6 +62,7 @@ from .provenance import fingerprint
|
|||||||
from .provider.openrouter import OpenRouterEmbeddingClient, OpenRouterError
|
from .provider.openrouter import OpenRouterEmbeddingClient, OpenRouterError
|
||||||
from .rank import rank_targets, write_rankings
|
from .rank import rank_targets, write_rankings
|
||||||
from .regress import TRIGGERABLE, CandidateVerdict, run_regression, write_verdicts
|
from .regress import TRIGGERABLE, CandidateVerdict, run_regression, write_verdicts
|
||||||
|
from .regress.candidate import hotspot_from_finding
|
||||||
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
|
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
|
||||||
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
|
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
|
||||||
from .run import ScanRun, create_scan_run
|
from .run import ScanRun, create_scan_run
|
||||||
@@ -159,6 +162,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
pairs.add_argument("--hunter", action="store_true", help="also score the LLM tool hunter on overlay slices (needs a hunter model)")
|
pairs.add_argument("--hunter", action="store_true", help="also score the LLM tool hunter on overlay slices (needs a hunter model)")
|
||||||
pairs.add_argument("--hunter-model", default=None, help="model id for --hunter (default: [models].hunter from openultrasast.toml)")
|
pairs.add_argument("--hunter-model", default=None, help="model id for --hunter (default: [models].hunter from openultrasast.toml)")
|
||||||
pairs.add_argument("--pointers", action="store_true", help="allow network for non-vendored pointer pairs this run (nightly; CI never)")
|
pairs.add_argument("--pointers", action="store_true", help="allow network for non-vendored pointer pairs this run (nightly; CI never)")
|
||||||
|
pairs.add_argument(
|
||||||
|
"--loo", action="store_true", help="leave-one-out: score each pair against a store seeded from the other trusted pairs"
|
||||||
|
)
|
||||||
|
pairs.add_argument("--loo-out", type=Path, default=None, help="where to write loo.json (default: reports/loo.json)")
|
||||||
pairs.add_argument("--json", action="store_true", help="print the pair scoreboard as JSON")
|
pairs.add_argument("--json", action="store_true", help="print the pair scoreboard as JSON")
|
||||||
|
|
||||||
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
|
subparsers.add_parser("mcp", help="run the narrow MCP server over stdio for OpenCode integration")
|
||||||
@@ -203,6 +210,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
hunter=args.hunter,
|
hunter=args.hunter,
|
||||||
hunter_model=args.hunter_model,
|
hunter_model=args.hunter_model,
|
||||||
pointers=args.pointers,
|
pointers=args.pointers,
|
||||||
|
loo=args.loo,
|
||||||
|
loo_out=args.loo_out,
|
||||||
)
|
)
|
||||||
if args.command == "mechanisms":
|
if args.command == "mechanisms":
|
||||||
return _mechanisms_export(args.catalog, args.slice, args.store, json_out=args.json)
|
return _mechanisms_export(args.catalog, args.slice, args.store, json_out=args.json)
|
||||||
@@ -823,10 +832,14 @@ def _pairs(
|
|||||||
hunter: bool = False,
|
hunter: bool = False,
|
||||||
hunter_model: str | None = None,
|
hunter_model: str | None = None,
|
||||||
pointers: bool = False,
|
pointers: bool = False,
|
||||||
|
loo: bool = False,
|
||||||
|
loo_out: Path | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
if not catalog.exists() or not catalog.is_file():
|
if not catalog.exists() or not catalog.is_file():
|
||||||
raise SystemExit(f"pair catalog is not a file: {catalog}")
|
raise SystemExit(f"pair catalog is not a file: {catalog}")
|
||||||
cases = select_split(select_profile(select_slice(load_pair_catalog(catalog), slice_name), profile), split)
|
cases = select_split(select_profile(select_slice(load_pair_catalog(catalog), slice_name), profile), split)
|
||||||
|
if loo:
|
||||||
|
return _pairs_loo(cases, loo_out or Path("reports") / "loo.json", json_out=json_out)
|
||||||
scan = None
|
scan = None
|
||||||
if hunter:
|
if hunter:
|
||||||
config_path = Path("openultrasast.toml")
|
config_path = Path("openultrasast.toml")
|
||||||
@@ -877,16 +890,27 @@ def _search_variants(
|
|||||||
|
|
||||||
|
|
||||||
def _hotspot_from_variant(finding: StaticFinding) -> Hotspot:
|
def _hotspot_from_variant(finding: StaticFinding) -> Hotspot:
|
||||||
return Hotspot(
|
return hotspot_from_finding(finding, rationale="variant of a known mechanism (suspicion; sandbox may raise)")
|
||||||
path=finding.path,
|
|
||||||
function_name=finding.function_name,
|
|
||||||
score=0.0,
|
def _pairs_loo(cases: Sequence[PairCase], out: Path, *, json_out: bool) -> int:
|
||||||
band="low",
|
"""Req 4: the corpus's own detection rate; written as an artifact, never a gate."""
|
||||||
signals={},
|
from .semantic.loo import evaluate_loo
|
||||||
rationale="variant of a known mechanism (suspicion; sandbox may raise)",
|
|
||||||
test_hint=None,
|
result = evaluate_loo(select_vendored(cases))
|
||||||
inventory_finding_ids=(finding.finding_id,),
|
payload = result.to_dict()
|
||||||
)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
|
if json_out:
|
||||||
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
for slice_name, metrics in result.per_slice.items():
|
||||||
|
print(
|
||||||
|
f"loo {slice_name}: pairs={metrics['pairs']} detected={metrics['detected']} silent={metrics['silent']} "
|
||||||
|
f"youden={metrics['youden']:+.3f} teaching={metrics['teaching']}"
|
||||||
|
)
|
||||||
|
print(f"artifact={out}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _mechanisms_export(catalog: Path, slice_name: str, store_path: Path, *, json_out: bool) -> int:
|
def _mechanisms_export(catalog: Path, slice_name: str, store_path: Path, *, json_out: bool) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""Leave-one-out: the pair corpus's own detection rate (corpus-seeded-mechanisms, Req 4).
|
||||||
|
|
||||||
|
For every scorable pair, a temporary mechanism store is seeded from every *other* trusted pair, both excerpts are
|
||||||
|
materialized and searched for variants, and the pair is scored with the pair rules: detected when a hit lies inside the
|
||||||
|
labeled function and names the labeled mechanism (or sink), silent when the fixed side yields no hit. Only ``seeded`` and
|
||||||
|
``reviewed`` pairs teach; ``advisory`` and ``title`` pairs are scored as held-out targets but never seed. Reported, never
|
||||||
|
gated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..pairs import GATING_TIERS, PairCase, _materialize, _targets
|
||||||
|
from .facts import FactLoadError, SemanticFacts, load_facts
|
||||||
|
from .functions import named_function_ranges, spans_named
|
||||||
|
from .mechanisms import Mechanism, MechanismStore, append_from_pair, corpus_mechanisms
|
||||||
|
from .seed import Lesson, _parse_language, pair_lessons
|
||||||
|
from .variant_search import search_tree
|
||||||
|
from .variants import Shape, trailing_name
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LooOutcome:
|
||||||
|
pair: str
|
||||||
|
slice: str
|
||||||
|
provenance: str
|
||||||
|
mechanisms: tuple[str, ...] # labeled mechanism ids of the held-out pair
|
||||||
|
detected: bool
|
||||||
|
silent: bool
|
||||||
|
found_by: tuple[tuple[str, tuple[str, ...]], ...] # (mechanism record id, pairs that taught it) for detecting hits
|
||||||
|
hits_vuln: int
|
||||||
|
hits_fixed: int
|
||||||
|
teaches: bool # the pair contributed at least one shape when others were held out
|
||||||
|
leaked_by: tuple[str, ...] = () # mechanism record ids that hit the fixed side
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"pair": self.pair,
|
||||||
|
"slice": self.slice,
|
||||||
|
"provenance": self.provenance,
|
||||||
|
"mechanisms": list(self.mechanisms),
|
||||||
|
"detected": self.detected,
|
||||||
|
"silent": self.silent,
|
||||||
|
"found_by": [{"mechanism_id": mechanism_id, "pairs": list(pairs)} for mechanism_id, pairs in self.found_by],
|
||||||
|
"hits_vuln": self.hits_vuln,
|
||||||
|
"hits_fixed": self.hits_fixed,
|
||||||
|
"teaches": self.teaches,
|
||||||
|
"leaked_by": list(self.leaked_by),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LooResult:
|
||||||
|
outcomes: tuple[LooOutcome, ...]
|
||||||
|
per_slice: dict[str, dict[str, float]]
|
||||||
|
per_profile: dict[str, dict[str, float]]
|
||||||
|
per_mechanism: dict[str, dict[str, float]]
|
||||||
|
skipped: tuple[tuple[str, str], ...] = ()
|
||||||
|
teaching_pairs: int = 0
|
||||||
|
degradations: tuple[dict[str, object], ...] = field(default=())
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"per_slice": self.per_slice,
|
||||||
|
"per_profile": self.per_profile,
|
||||||
|
"per_mechanism": self.per_mechanism,
|
||||||
|
"outcomes": [outcome.to_dict() for outcome in self.outcomes],
|
||||||
|
"skipped": [{"pair": pair, "reason": reason} for pair, reason in self.skipped],
|
||||||
|
"teaching_pairs": self.teaching_pairs,
|
||||||
|
"degradations": list(self.degradations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_loo(cases: Sequence[PairCase], *, facts: SemanticFacts | None = None) -> LooResult:
|
||||||
|
loaded = facts if facts is not None else _facts()
|
||||||
|
targets: list[PairCase] = []
|
||||||
|
skipped: list[tuple[str, str]] = []
|
||||||
|
for case in cases:
|
||||||
|
if case.known_limit:
|
||||||
|
skipped.append((case.name, f"known_limit:{case.known_limit}"))
|
||||||
|
elif not case.vuln_file.is_file() or not case.fixed_file.is_file():
|
||||||
|
skipped.append((case.name, "pointer_pair_not_cached" if not case.vendored else "excerpt_missing"))
|
||||||
|
else:
|
||||||
|
targets.append(case)
|
||||||
|
lessons = {case.name: pair_lessons(case, loaded) for case in targets if case.review_tier in GATING_TIERS}
|
||||||
|
outcomes = [_hold_out(case, targets, lessons, loaded) for case in targets]
|
||||||
|
return LooResult(
|
||||||
|
outcomes=tuple(outcomes),
|
||||||
|
per_slice=_group(outcomes, lambda item: (item.slice,)),
|
||||||
|
per_profile=_group(outcomes, lambda item: (item.provenance,)),
|
||||||
|
per_mechanism=_group(outcomes, lambda item: item.mechanisms),
|
||||||
|
skipped=tuple(skipped),
|
||||||
|
teaching_pairs=sum(1 for shapes in lessons.values() if shapes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hold_out(case: PairCase, targets: Sequence[PairCase], lessons: dict[str, list[Lesson]], facts: SemanticFacts) -> LooOutcome:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ousast-loo-") as scratch:
|
||||||
|
root = Path(scratch)
|
||||||
|
store = MechanismStore(root / "mechanisms.jsonl")
|
||||||
|
for teacher in targets:
|
||||||
|
if teacher.name == case.name:
|
||||||
|
continue
|
||||||
|
for lesson in lessons.get(teacher.name, ()):
|
||||||
|
append_from_pair(
|
||||||
|
store,
|
||||||
|
lesson.shape,
|
||||||
|
summary=lesson.summary,
|
||||||
|
cwe=lesson.cwe,
|
||||||
|
pair=teacher.name,
|
||||||
|
provenance=teacher.provenance,
|
||||||
|
tier=teacher.review_tier,
|
||||||
|
)
|
||||||
|
scored = score_pair_with_store(case, store, facts, root=root)
|
||||||
|
return LooOutcome(**{**scored.__dict__, "teaches": bool(lessons.get(case.name))})
|
||||||
|
|
||||||
|
|
||||||
|
def score_pair_with_store(case: PairCase, store: MechanismStore, facts: SemanticFacts, *, root: Path | None = None) -> LooOutcome:
|
||||||
|
"""Search both sides of one pair with a fixed store and score it with the pair rules (shared by LOO and the lever)."""
|
||||||
|
labeled_mechanisms = tuple(sorted({row.mechanism for row in case.expected if row.mechanism}))
|
||||||
|
records = {record.id: record for record in corpus_mechanisms(store.load())}
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ousast-loo-pair-") as scratch:
|
||||||
|
base = root if root is not None else Path(scratch)
|
||||||
|
vuln_root = _materialize(base / "vuln", case.vuln_file, case.relpath)
|
||||||
|
fix_root = _materialize(base / "fixed", case.fixed_file, case.relpath)
|
||||||
|
vuln_hits = search_tree(vuln_root, _targets(vuln_root), store, facts, max_mechanisms=len(records) or 1).hits
|
||||||
|
fix_hits = search_tree(fix_root, _targets(fix_root), store, facts, max_mechanisms=len(records) or 1).hits
|
||||||
|
text = case.vuln_file.read_text(errors="ignore")
|
||||||
|
ranges = named_function_ranges(case.relpath, text, _parse_language(case)) or ()
|
||||||
|
found: list[tuple[str, tuple[str, ...]]] = []
|
||||||
|
for hit in vuln_hits:
|
||||||
|
record = records.get(hit.mechanism_id)
|
||||||
|
if record is None or not _inside_label(case, hit.line, ranges):
|
||||||
|
continue
|
||||||
|
if _names_label(case, record, hit.sink_name):
|
||||||
|
found.append((record.id, tuple(record.pairs)))
|
||||||
|
return LooOutcome(
|
||||||
|
pair=case.name,
|
||||||
|
slice=case.slice,
|
||||||
|
provenance=case.provenance,
|
||||||
|
mechanisms=labeled_mechanisms,
|
||||||
|
detected=bool(found),
|
||||||
|
silent=not fix_hits,
|
||||||
|
found_by=tuple(found),
|
||||||
|
hits_vuln=len(vuln_hits),
|
||||||
|
hits_fixed=len(fix_hits),
|
||||||
|
teaches=False,
|
||||||
|
leaked_by=tuple(sorted({hit.mechanism_id for hit in fix_hits})),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inside_label(case: PairCase, line: int, ranges: Sequence[tuple[str, int, int]]) -> bool:
|
||||||
|
functions = {row.function for row in case.expected if row.function}
|
||||||
|
if not functions:
|
||||||
|
return True
|
||||||
|
return any(start <= line <= end for function in functions for start, end in spans_named(ranges, function))
|
||||||
|
|
||||||
|
|
||||||
|
def _names_label(case: PairCase, record: Mechanism, sink_name: str) -> bool:
|
||||||
|
"""The hit names the pair's labeled mechanism, or its labeled sink when the row carries one."""
|
||||||
|
shape = Shape.from_dict(record.shape or {})
|
||||||
|
for row in case.expected:
|
||||||
|
if row.mechanism and row.mechanism == shape.mechanism:
|
||||||
|
return True
|
||||||
|
if row.sink and trailing_name(row.sink) == sink_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _group(outcomes: Sequence[LooOutcome], keys: object) -> dict[str, dict[str, float]]:
|
||||||
|
buckets: dict[str, list[LooOutcome]] = {}
|
||||||
|
for outcome in outcomes:
|
||||||
|
for key in keys(outcome): # type: ignore[operator]
|
||||||
|
buckets.setdefault(str(key), []).append(outcome)
|
||||||
|
return {name: _metrics(items) for name, items in sorted(buckets.items())}
|
||||||
|
|
||||||
|
|
||||||
|
def _metrics(outcomes: Sequence[LooOutcome]) -> dict[str, float]:
|
||||||
|
pairs = len(outcomes)
|
||||||
|
detected = sum(1 for item in outcomes if item.detected)
|
||||||
|
silent = sum(1 for item in outcomes if item.silent)
|
||||||
|
recall = detected / pairs if pairs else 0.0
|
||||||
|
silence = silent / pairs if pairs else 0.0
|
||||||
|
return {
|
||||||
|
"pairs": pairs,
|
||||||
|
"detected": detected,
|
||||||
|
"silent": silent,
|
||||||
|
"recall": recall,
|
||||||
|
"silence": silence,
|
||||||
|
"youden": recall + silence - 1.0 if pairs else 0.0,
|
||||||
|
"teaching": sum(1 for item in outcomes if item.teaches),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _facts() -> SemanticFacts:
|
||||||
|
try:
|
||||||
|
return load_facts()
|
||||||
|
except FactLoadError:
|
||||||
|
return SemanticFacts(version="", sources=(), sinks=(), sanitizers=())
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""corpus-seeded-mechanisms task 3.1: leave-one-out is the corpus's own detection rate (offline)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openultrasast.benchmark import ExpectedFinding
|
||||||
|
from openultrasast.pairs import PairCase
|
||||||
|
|
||||||
|
EXEC_A = (
|
||||||
|
"import sqlite3\n\n\ndef lookup(user):\n c = sqlite3.connect('x').cursor()\n"
|
||||||
|
+ " c.execute(\"SELECT * FROM t WHERE u = '%s'\" % user)\n"
|
||||||
|
)
|
||||||
|
EXEC_A_FIX = (
|
||||||
|
"import sqlite3\n\n\ndef lookup(user):\n c = sqlite3.connect('x').cursor()\n c.execute('SELECT * FROM t WHERE u = ?', (user,))\n"
|
||||||
|
)
|
||||||
|
EXEC_B = (
|
||||||
|
"import sqlite3\n\n\ndef delete(name):\n cur = sqlite3.connect('y').cursor()\n cur.execute('DELETE FROM t WHERE n = ' + name)\n"
|
||||||
|
)
|
||||||
|
EXEC_B_FIX = (
|
||||||
|
"import sqlite3\n\n\ndef delete(name):\n cur = sqlite3.connect('y').cursor()\n"
|
||||||
|
+ " cur.execute('DELETE FROM t WHERE n = ?', (name,))\n"
|
||||||
|
)
|
||||||
|
SYS_C = "import os\n\n\ndef ping(host):\n os.system('ping ' + host)\n"
|
||||||
|
SYS_C_FIX = "import os\nimport subprocess\n\n\ndef ping(host):\n subprocess.run(['ping', host])\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _case(tmp_path: Path, name: str, vuln: str, fixed: str, *, function: str, cwe: str, provenance: str = "human") -> PairCase:
|
||||||
|
(tmp_path / f"{name}-v.py").write_text(vuln)
|
||||||
|
(tmp_path / f"{name}-f.py").write_text(fixed)
|
||||||
|
return PairCase(
|
||||||
|
name=name,
|
||||||
|
slice="toy",
|
||||||
|
language="python",
|
||||||
|
origin="test",
|
||||||
|
vuln_file=tmp_path / f"{name}-v.py",
|
||||||
|
fixed_file=tmp_path / f"{name}-f.py",
|
||||||
|
relpath="app.py",
|
||||||
|
expected=(
|
||||||
|
ExpectedFinding(
|
||||||
|
cwe=cwe, vulnerability_class="x", path="app.py", evidence="", function=function, mechanism="source_reaches_sink"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
min_recall=1.0,
|
||||||
|
fix_policy="silent",
|
||||||
|
provenance=provenance,
|
||||||
|
review_tier="seeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _toy(tmp_path: Path) -> list[PairCase]:
|
||||||
|
return [
|
||||||
|
_case(tmp_path, "a", EXEC_A, EXEC_A_FIX, function="lookup", cwe="CWE-89"),
|
||||||
|
_case(tmp_path, "b", EXEC_B, EXEC_B_FIX, function="delete", cwe="CWE-89", provenance="agent"),
|
||||||
|
_case(tmp_path, "c", SYS_C, SYS_C_FIX, function="ping", cwe="CWE-78"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_leave_one_out_reports_which_pair_taught_which(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.loo import evaluate_loo
|
||||||
|
|
||||||
|
result = evaluate_loo(_toy(tmp_path))
|
||||||
|
by_name = {outcome.pair: outcome for outcome in result.outcomes}
|
||||||
|
assert set(by_name) == {"a", "b", "c"}
|
||||||
|
assert by_name["a"].detected and by_name["a"].silent and by_name["a"].found_by # taught by b's execute shape
|
||||||
|
assert by_name["b"].detected and by_name["b"].silent and any("a" in pairs for _, pairs in by_name["b"].found_by)
|
||||||
|
assert not by_name["c"].detected and by_name["c"].found_by == () # nobody else teaches os.system: c only tests
|
||||||
|
slice_metrics = result.per_slice["toy"]
|
||||||
|
assert slice_metrics["pairs"] == 3 and slice_metrics["detected"] == 2 and slice_metrics["silent"] == 3
|
||||||
|
assert abs(slice_metrics["recall"] - 2 / 3) < 1e-9 and slice_metrics["silence"] == 1.0 and abs(slice_metrics["youden"] - 2 / 3) < 1e-9
|
||||||
|
assert result.per_profile["agent"]["pairs"] == 1 and result.per_profile["human"]["pairs"] == 2
|
||||||
|
assert "source_reaches_sink" in result.per_mechanism
|
||||||
|
payload = result.to_dict()
|
||||||
|
assert set(payload) >= {"per_slice", "per_profile", "per_mechanism", "outcomes"}
|
||||||
|
json.dumps(payload) # artifact-ready
|
||||||
|
|
||||||
|
|
||||||
|
def test_leave_one_out_seeds_only_trusted_pairs_and_skips_known_limit(tmp_path: Path) -> None:
|
||||||
|
from openultrasast.semantic.loo import evaluate_loo
|
||||||
|
|
||||||
|
cases = _toy(tmp_path)
|
||||||
|
cases[1] = PairCase(**{**cases[1].__dict__, "review_tier": "advisory"}) # b may be tested, never teaches
|
||||||
|
cases[2] = PairCase(**{**cases[2].__dict__, "known_limit": "engine"})
|
||||||
|
result = evaluate_loo(cases)
|
||||||
|
by_name = {outcome.pair: outcome for outcome in result.outcomes}
|
||||||
|
assert set(by_name) == {"a", "b"} # known_limit excluded entirely
|
||||||
|
assert not by_name["a"].detected # b is advisory: it cannot teach a
|
||||||
|
assert by_name["b"].detected # a (seeded) teaches b, which is scored as a held-out target
|
||||||
|
assert result.skipped == (("c", "known_limit:engine"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_pairs_loo_writes_the_artifact_offline(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import io
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
|
||||||
|
from openultrasast.cli import main
|
||||||
|
|
||||||
|
monkeypatch.chdir(Path.cwd())
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
assert main(["pairs", "--slice", "vibe-py", "--loo", "--json", "--loo-out", str(tmp_path / "loo.json")]) == 0
|
||||||
|
payload = json.loads(buf.getvalue())
|
||||||
|
assert payload["per_slice"]["vibe-py"]["pairs"] == 35 and "found_by" in payload["outcomes"][0]
|
||||||
|
assert json.loads((tmp_path / "loo.json").read_text()) == payload
|
||||||
Reference in New Issue
Block a user