mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-19 04:59:28 +00:00
feat(constrained-detector): the ceiling, measured offline across every slice
Group 1 complete. Tasks 1.2 and 1.3: `candidates.ceiling`, `ousast learning
candidates`, and the stop-or-go reading. No model is called anywhere in this
group, so the condition that could have ended the feature was reachable for
free.
Verdict: go.
agent-vfc (deciding) 89.7% ceiling against 44.4% round-zero detection
vfc (deciding) 84.7%, baseline still running, non-gating
vfc-js (deciding) 52.9% -- see below
github (deciding) 100.0%
vibe-py (development)100.0% against 83.3%
The margin is largest on agent-vfc, which is the slice the tool is actually
for.
The measurement forced a shape correction before the detector existed.
config_secrets first read 63.6% with a bind-and-literal shape, because a
permissive default is as often an argument to a call -- cors({origin: '*'}),
app.use(session({secure: false})) -- as it is a binding. Adding call sites to
that family's shape took it to 100% and recovered four agent-vfc rows.
Carry-forward, and not this feature's to fix: vfc-js sits at 52.9% because
eight of seventeen rows label a function the JavaScript CST names `<anon>` --
`module.exports.publish = function () {}` and its relatives. That is a
function-naming gap, it caps the existing hunter on that slice exactly as
much, and it is the same class as the `_declarator_start` defects found in
learning-harness task 4.1.
The ruleset arm of `ceiling` is kept so the 12.1% that rejected it stays a
measurement rather than a remembered number.
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
8d771901bf
commit
78b4f33a8e
@@ -33,7 +33,10 @@ FAMILY_SHAPE: dict[str, tuple[CandidateKind, ...]] = {
|
||||
"memory": ("call",),
|
||||
"prototype": ("call", "bind"),
|
||||
"access_control": ("operation",),
|
||||
"config_secrets": ("bind", "config"),
|
||||
# A permissive default is as often an argument to a call — `cors({origin: "*"})`, `app.use(session({secure:
|
||||
# false}))` — as it is a binding. Measured: with bindings alone the shape missed `createApplication` and
|
||||
# `getAllowedHosts`, whose whole bug is inside a call argument.
|
||||
"config_secrets": ("call", "bind", "config"),
|
||||
"unknown": ("call", "bind"),
|
||||
}
|
||||
|
||||
@@ -232,22 +235,113 @@ def _language_of(path: str) -> str:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateReport:
|
||||
"""The ceiling: what a candidate-driven detector could find before any model is asked anything."""
|
||||
"""The ceiling: what a candidate-driven detector could find before any model is asked anything.
|
||||
|
||||
This is the number that decides whether the design continues. A generator that cannot put a candidate inside
|
||||
the labeled function can never find that pair's bug however good the judgement is, so the ceiling is published
|
||||
as the limit on recall rather than discovered later as a disappointment (Req 1.3)."""
|
||||
|
||||
generator: str = "ir"
|
||||
per_family: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
per_slice: dict[str, dict[str, object]] = field(default_factory=dict)
|
||||
gaps: tuple[tuple[str, str, str], ...] = () # (slice, pair, reason)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"generator": self.generator,
|
||||
"per_family": {name: dict(block) for name, block in sorted(self.per_family.items())},
|
||||
"per_slice": {name: dict(block) for name, block in sorted(self.per_slice.items())},
|
||||
"gaps": [list(row) for row in self.gaps],
|
||||
}
|
||||
|
||||
|
||||
def ceiling(cases: Sequence[object], *, taxonomy: object, generator: str = "ir") -> CandidateReport:
|
||||
"""Per family and per slice, the fraction of labeled functions a generator can put a candidate inside.
|
||||
|
||||
``generator`` is ``ir`` or ``ruleset``. The ruleset arm exists so the comparison that rejected it — 12.1%
|
||||
against 96.6% on the web slices — stays a measurement rather than a remembered number."""
|
||||
import tempfile
|
||||
|
||||
from ..pairs import _materialize_side, _overlay_scan, _quick_scan
|
||||
from .rounds import _family_of
|
||||
from .scoring import _spans
|
||||
|
||||
families: dict[str, dict[str, int]] = {}
|
||||
slices: dict[str, dict[str, int]] = {}
|
||||
gaps: list[tuple[str, str, str]] = []
|
||||
sites: dict[str, list[int]] = {}
|
||||
for case in cases:
|
||||
family = _family_of(case, taxonomy) # type: ignore[arg-type]
|
||||
slice_name = str(getattr(case, "slice", "") or "")
|
||||
region_path = str(getattr(case, "relpath", "") or "")
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-ceiling-") as scratch:
|
||||
root = _materialize_side(Path(scratch) / "vuln", case, side="vuln") # type: ignore[arg-type]
|
||||
if generator == "ruleset":
|
||||
ranges = _overlay_scan(root).ranges
|
||||
spans = _spans(case, ranges)
|
||||
found = [
|
||||
finding
|
||||
for finding in _quick_scan(root)
|
||||
if finding.line is not None and any(path == finding.path and start <= finding.line <= end for path, start, end in spans)
|
||||
]
|
||||
count, reason = len(found), ("" if found else "no_candidate")
|
||||
else:
|
||||
labeled = [str(getattr(row, "function", "") or "") for row in getattr(case, "expected", ()) or ()]
|
||||
result = enumerate_candidates(
|
||||
root,
|
||||
_Region(region_path, next((name for name in labeled if name), None)),
|
||||
family,
|
||||
taxonomy=taxonomy,
|
||||
limit=10**6, # the ceiling is about coverage, never about the batch bound
|
||||
)
|
||||
count, reason = len(result.candidates), result.reason
|
||||
for bucket, key in ((families, family), (slices, slice_name)):
|
||||
block = bucket.setdefault(key, {"pairs": 0, "with_candidate": 0})
|
||||
block["pairs"] += 1
|
||||
block["with_candidate"] += 1 if count else 0
|
||||
if count:
|
||||
sites.setdefault(family, []).append(count)
|
||||
else:
|
||||
gaps.append((slice_name, str(getattr(case, "name", "")), reason or "no_candidate"))
|
||||
per_family: dict[str, dict[str, object]] = {}
|
||||
per_slice: dict[str, dict[str, object]] = {}
|
||||
for bucket, out in ((families, per_family), (slices, per_slice)):
|
||||
for key, block in bucket.items():
|
||||
pairs = block["pairs"]
|
||||
row: dict[str, object] = {
|
||||
"pairs": pairs,
|
||||
"with_candidate": block["with_candidate"],
|
||||
"ceiling": (block["with_candidate"] / pairs) if pairs else 0.0,
|
||||
}
|
||||
if bucket is families:
|
||||
row["sites"] = _distribution(sites.get(key, []))
|
||||
out[key] = row
|
||||
return CandidateReport(generator=generator, per_family=per_family, per_slice=per_slice, gaps=tuple(sorted(gaps)))
|
||||
|
||||
|
||||
def _distribution(counts: list[int]) -> dict[str, int]:
|
||||
if not counts:
|
||||
return {"min": 0, "median": 0, "p90": 0, "max": 0}
|
||||
counts = sorted(counts)
|
||||
return {
|
||||
"min": counts[0],
|
||||
"median": counts[len(counts) // 2],
|
||||
"p90": counts[min(int(len(counts) * 0.9), len(counts) - 1)],
|
||||
"max": counts[-1],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Region:
|
||||
"""The two fields `enumerate_candidates` reads, so the ceiling does not import the detector module."""
|
||||
|
||||
path: str
|
||||
function: str | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FAMILY_SHAPE",
|
||||
"ceiling",
|
||||
"MAX_CANDIDATES_PER_CALL",
|
||||
"Candidate",
|
||||
"CandidateKind",
|
||||
|
||||
Reference in New Issue
Block a user