feat(hunt): registered paired ablation, repository folds, leak audit, importance table and the keep/drop rule (4.2-4.4)

ablation.py registers the manifest (table digest, arms as masks, folds, metric, sample, budget,
rule, seed, program id) before any run and refuses a run or an analysis that does not match it.
Arms run over the same changes through runner.run, paired by change; effects on recall and on
suspicions per ordinary push are reported pooled and per family with the repository-clustered
bootstrap and exact McNemar, plus the recall-against-suspicions curve at every reported
confidence (never a tuned cut). The importance table and decisions read held-out effects only;
a neutral annotation survives only when a kept one needs it. 30 fixture tests with a scripted
model. Implemented by Codex (gpt-6-astra), gated here.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-13 18:49:58 +02:00
co-authored by Claude Fable 5.1
parent bbbb0f397e
commit 6e5d060de6
3 changed files with 816 additions and 3 deletions
+335
View File
@@ -0,0 +1,335 @@
"""A registered ablation on readable git changes, with deterministic model answers."""
from __future__ import annotations
import json
import subprocess
from dataclasses import replace
import pytest
dspy = pytest.importorskip("dspy")
pd = pytest.importorskip("pandas")
pytest.importorskip("statsmodels")
pytest.importorskip("sklearn")
from dspy.utils import DummyLM # noqa: E402
from openultrasast.hunt import ablation as ab # noqa: E402
from openultrasast.hunt import program as hp # noqa: E402
from openultrasast.hunt import runner # noqa: E402
from openultrasast.hunt.features import ENGINEERED # noqa: E402
from openultrasast.hunt.table import FeatureTable # noqa: E402
@pytest.fixture
def population(tmp_path):
repos, changes, rows = {}, [], []
for number in range(3):
root = tmp_path / f"repo-{number}"
root.mkdir()
def git(*args, root=root):
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, text=True).stdout.strip()
git("init", "-q")
git("config", "user.name", "Fixture")
git("config", "user.email", "[email protected]")
(root / "app.py").write_text("def run():\n return input()\n")
git("add", "app.py")
git("commit", "-qm", "initial")
base = git("rev-parse", "HEAD")
(root / "app.py").write_text("def run():\n return eval(input())\n")
git("add", "app.py")
git("commit", "-qm", "change")
head = git("rev-parse", "HEAD")
repo = f"fixture/repo-{number}"
repos[repo] = str(root)
for item in range(2):
positive = number < 2 and item == 0
change = {
"id": f"c{number}-{item}",
"repo": repo,
"base": base,
"head": head,
"kind": "vulnerability" if positive else "ordinary",
"case_id": f"case-{number}-{item}",
"path": "app.py",
"function": "run",
"function_lines": [1, 2],
"family": "injection",
"files": [{"path": "app.py", "head_lines": [[2, 2]]}],
}
changes.append(change)
for line in (1, 2):
rows.append(
{
"change_id": change["id"],
"repo": repo,
"head": head,
"path": "app.py",
"line": line,
"kind": change["kind"],
"label": int(positive),
"feature.diff.distance": number,
"provenance.diff.ok": True,
"provenance.diff.reason": None,
"provenance.diff.version": 1,
"provenance.diff.bytes_read": 36,
}
)
table = ab.assign_folds(FeatureTable(rows), k=3, seed=13)
folds = {row["change_id"]: row["fold"] for row in table.rows}
changes = [{**change, "fold": folds[change["id"]]} for change in changes]
pool = tmp_path / "pool-fixture.toml"
pool.write_text("status = 'fixture'\n")
manifest = ab.Manifest(
table_digest=table.digest(),
arms={"full": [], "without-roles": ["roles"]},
compile_folds=[],
holdout_folds=[0, 1, 2],
metric="hunt.metric.search_outcome(tolerance=10)",
sample={"changes": [c["id"] for c in changes], "repeats": 2},
budget={"usd": 1.0, "calls": 100},
rule={"version": "keep-drop-v1", "resamples": 100, "needs": {}},
seed=13,
program_id="fixture-program",
)
return pool, repos, changes, table, manifest
def test_folds_and_audit_keep_every_row_and_column(population):
_, _, _, table, _ = population
frame = table.to_pandas()
assert frame.groupby("repo")["fold"].nunique().eq(1).all()
assert frame.groupby("change_id")["fold"].nunique().eq(1).all()
assert set(frame.fold) == {0, 1, 2}
assert ab.assign_folds(table, k=3, seed=13).digest() == table.digest()
audit = ab.leak_audit(table)
assert set(audit.column) == set(ENGINEERED)
assert audit.set_index("column").loc["diff.distance", "n"] == 12
missing = audit.set_index("column").loc["semantic.sources"]
assert pd.isna(missing.auc) and missing.n == 0 and missing.note
assert dict(zip(audit.column, audit.annotation, strict=True)) == {key: value[1] for key, value in ENGINEERED.items()}
assert len(ab.leak_audit(FeatureTable([]))) == len(ENGINEERED)
def finding(confidence=0.9, family="injection"):
return {"path": "app.py", "line": 2, "family": family, "confidence": confidence, "rationale": "eval", "cited_lines": [2]}
def model(answers):
return DummyLM([{"findings": json.dumps(answer)} for answer in answers])
def test_registration_paired_runs_statistics_and_curve(population, tmp_path):
pool, repos, changes, table, manifest = population
out = tmp_path / "ablation"
# Each arm has its own deterministic sequence, but uses the same model class and sampling.
models = {"full": model([[finding()]] * 12), "without-roles": model([[]] * 12)}
kwargs = dict(program_factory=hp.HuntProgram, lm=models, repeats=2, table=table, load_changes=lambda m, s: changes)
with pytest.raises(ValueError, match="registration"):
ab.run(manifest, pool, repos, out, **kwargs)
ab.register(manifest, out)
registration = json.loads((out / "ablation.json").read_text())
assert registration["manifest_id"] == ab.manifest_id(manifest)
with pytest.raises(ValueError, match="registration"):
ab.run(replace(manifest, seed=22), pool, repos, out, **kwargs)
ab.run(manifest, pool, repos, out, **kwargs)
for arm in manifest.arms:
for repeat in range(2):
records = [json.loads(runner.record_path(out / f"{arm}-{repeat}", c["id"]).read_text()) for c in changes]
assert {r["id"] for r in records} == {c["id"] for c in changes}
assert all(r["instrument"]["hook_bytes_read"] > 0 and r["instrument"]["head_verified"] for r in records)
assert all(("roles" in r["inputs_present"]) == (arm == "full") for r in records)
analysis = ab.analyse(out, manifest, changes)
pooled = analysis["heldout"]["comparisons"]["without-roles"]["pooled"]
assert pooled["recall"]["diff"] == -1 and pooled["recall"]["ci"] == (-1, -1)
assert pooled["recall"]["units"] == 4 and pooled["recall"]["groups"] == 2
assert pooled["suspicions_per_push"]["diff"] == -1 and pooled["suspicions_per_push"]["units"] == 8
assert pooled["mcnemar"]["b"] == 4 and pooled["mcnemar"]["c"] == 0
assert "injection" in analysis["heldout"]["comparisons"]["without-roles"]["families"]
assert analysis["heldout"]["arms"]["full"]["curve"] == [
{"confidence": 0.9, "recall": 1.0, "suspicions_per_push": 1.0, "positive_units": 4, "ordinary_units": 8}
]
assert json.loads((out / "decisions.json").read_text())["roles"]["decision"] == "keep"
# The saved registration also protects against changed folds and repeat counts.
with pytest.raises(ValueError, match="repeats"):
ab.run(manifest, pool, repos, out, **{**kwargs, "repeats": 1})
with pytest.raises(ValueError, match="digest"):
ab.run(manifest, pool, repos, out, **{**kwargs, "table": FeatureTable(table.rows[:-1])})
def test_parse_and_instrument_failures_stay_in_denominator(population, tmp_path):
pool, repos, changes, table, original = population
manifest = replace(original, sample={**original.sample, "repeats": 1})
out = tmp_path / "failed"
ab.register(manifest, out)
# ChatAdapter retries a malformed answer through JSONAdapter once.
bad = DummyLM([{"findings": "not json"}] * 2 + [{"findings": "[]"}] * 5)
ab.run(
manifest,
pool,
repos,
out,
program_factory=hp.HuntProgram,
lm={"full": bad, "without-roles": model([[]] * 6)},
repeats=1,
table=table,
load_changes=lambda m, s: changes,
)
record = json.loads(runner.record_path(out / "full-0", changes[0]["id"]).read_text())
assert record["parse_failed"] and record["instrument"]["hook_bytes_read"] > 0
# An absent record is also a miss, not a reason to shrink the paired population.
runner.record_path(out / "without-roles-0", changes[2]["id"]).unlink()
analysis = ab.analyse(out, manifest, changes)
full = analysis["heldout"]["arms"]["full"]
assert full["parse_failures"] == 1 and full["positive_units"] == 2 and full["recall"] == 0
assert analysis["heldout"]["arms"]["without-roles"]["missing_records"] == 1
assert analysis["heldout"]["comparisons"]["without-roles"]["pooled"]["recall"]["units"] == 2
def effect(diff, ci):
return {"diff": diff, "ci": ci, "units": 12, "groups": 3}
def test_importance_and_decisions_use_heldout_effects_and_dependencies(tmp_path):
neutral = {"recall": effect(0, (-0.1, 0.1)), "suspicions_per_push": effect(0, (-0.1, 0.1))}
helpful = {**neutral, "recall": effect(-0.5, (-0.8, -0.2))}
noisy = {**neutral, "suspicions_per_push": effect(-1, (-2, -0.5))}
comparisons = {
f"without-{name}": {"pooled": value}
for name, value in (("roles", helpful), ("callers", noisy), ("def_use", neutral), ("obligations", neutral))
}
analysis = {
"out_dir": str(tmp_path),
"heldout": {"comparisons": comparisons, "additions": {"with-roles": {"pooled": {**neutral, "recall": effect(0.5, (0.2, 0.8))}}}},
"compile": {"comparisons": {"without-callers": {"pooled": helpful}}},
}
table = ab.importance(analysis, annotations=["roles", "callers", "def_use", "obligations", "families"])
assert len(table) == 5
roles = table.set_index("annotation").loc["roles"]
assert roles.remove_recall == -0.5 and roles.add_recall == 0.5 and roles.remove_recall_units == 12
assert pd.isna(table.set_index("annotation").loc["families", "remove_recall"])
decisions = ab.decide(table, needs={"roles": {"def_use"}, "def_use": {"obligations"}})
assert decisions["roles"]["decision"] == "keep"
assert decisions["callers"] == {"decision": "drop", "reason": "raises suspicions without recall"}
assert decisions["def_use"]["decision"] == decisions["obligations"]["decision"] == "keep"
assert ab.decide(table, needs={})["def_use"] == {"decision": "drop", "reason": "no measurable effect"}
assert json.loads((tmp_path / "decisions.json").read_text())["def_use"]["decision"] == "drop"
@pytest.mark.parametrize("mask", [["label"], ["verification"], ["advisory"], ["provenance.source.ok"]])
def test_registration_rejects_non_feature_masks(population, tmp_path, mask):
manifest = replace(population[-1], arms={"full": [], "bad": mask})
with pytest.raises(ValueError, match="mask"):
ab.register(manifest, tmp_path / "bad")
def write_records(out, manifest, changes, answers):
"""Save runner-shaped records to isolate analysis of fixed, known outcomes."""
ab.register(manifest, out)
for arm, per_change in answers.items():
directory = out / f"{arm}-0"
directory.mkdir()
for change, findings in zip(changes, per_change, strict=True):
record = {
"id": change["id"],
"done": True,
"status": "hunt_covered",
"program_id": manifest.program_id,
"mask": manifest.arms[arm],
"input_digest": runner.input_digest(change, manifest.program_id, manifest.arms[arm]),
"push": {
"admission": {
"defects": [runner.defect_from(f, program_id=manifest.program_id, mask=tuple(manifest.arms[arm])) for f in findings]
}
},
}
runner.record_path(directory, change["id"]).write_text(json.dumps(record))
def test_curve_counts_ties_all_suspicions_and_families(population, tmp_path):
_, _, changes, _, original = population
manifest = replace(original, sample={**original.sample, "repeats": 1})
out = tmp_path / "curve"
full = [[finding(0.9)], [finding(0.9), finding(0.4, "path")], [finding(0.4)], [], [], []]
write_records(out, manifest, changes, {"full": full, "without-roles": [[]] * 6})
result = ab.analyse(out, manifest, changes)
arm = result["all"]["arms"]["full"]
assert arm["curve"] == [
{"confidence": 0.9, "recall": 0.5, "suspicions_per_push": 0.25, "positive_units": 2, "ordinary_units": 4},
{"confidence": 0.4, "recall": 1.0, "suspicions_per_push": 0.5, "positive_units": 2, "ordinary_units": 4},
]
families = result["all"]["comparisons"]["without-roles"]["families"]
assert families["injection"]["recall"]["diff"] == -1
assert families["path"]["recall"]["units"] == 0 and families["path"]["recall"]["ci"] is None
for family in ("injection", "path"):
assert families[family]["suspicions_per_push"]["diff"] == -0.25
assert families[family]["suspicions_per_push"]["units"] == 4
# A flagged failure with stale findings must be a miss in both the summary and the curve.
path = runner.record_path(out / "full-0", changes[0]["id"])
record = json.loads(path.read_text())
record["status"] = "instrument_failure"
path.write_text(json.dumps(record))
failed = ab.analyse(out, manifest, changes)["all"]["arms"]["full"]
assert failed["instrument_failures"] == 1 and failed["positive_units"] == 2 and failed["recall"] == 0.5
assert failed["curve"][0]["recall"] == 0
def test_holdout_and_baseline_addition_are_separate(population, tmp_path):
_, _, changes, _, original = population
compile_fold = changes[0]["fold"]
manifest = replace(
original,
sample={**original.sample, "repeats": 1},
compile_folds=[compile_fold],
holdout_folds=[f for f in original.holdout_folds if f != compile_fold],
arms={"full": [], "without-roles": ["roles"], "baseline": ["roles", "callers"], "with-roles": ["callers"]},
)
out = tmp_path / "split"
write_records(
out,
manifest,
changes,
{
"full": [[], [], [finding()], [], [], []],
"without-roles": [[finding()], [], [], [], [], []],
"baseline": [[]] * 6,
"with-roles": [[], [], [finding()], [], [], []],
},
)
result = ab.analyse(out, manifest, changes)
assert result["compile"]["comparisons"]["without-roles"]["pooled"]["recall"]["diff"] == 1
assert result["heldout"]["comparisons"]["without-roles"]["pooled"]["recall"]["diff"] == -1
importance = ab.importance(result, annotations=["roles"])
assert importance.iloc[0].add_recall == 1 and importance.iloc[0].remove_recall == -1
assert ab.decide(importance, needs={})["roles"]["decision"] == "keep"
assert result["annotations_removed"]["without-roles"] == ["roles"]
def test_decisions_keep_suspicion_benefits_but_not_neutral_cycles():
neutral = {"recall": effect(0, (0, 0)), "suspicions_per_push": effect(0, (0, 0))}
effects = {"roles": {**neutral, "suspicions_per_push": effect(1, (0.5, 2))}, "callers": neutral, "def_use": neutral}
table = ab.importance(
{"heldout": {"comparisons": {f"without-{name}": {"pooled": value} for name, value in effects.items()}}}, annotations=list(effects)
)
decisions = ab.decide(table, needs={"callers": {"def_use"}, "def_use": {"callers"}})
assert decisions["roles"] == {"decision": "keep", "reason": "removal raises suspicions"}
assert decisions["callers"]["decision"] == decisions["def_use"]["decision"] == "drop"
table.attrs["scope"] = "compile"
with pytest.raises(ValueError, match="held-out"):
ab.decide(table, needs={})
def test_registration_cannot_be_rewritten_or_have_its_contents_changed(population, tmp_path):
manifest = population[-1]
out = tmp_path / "registered"
ab.register(manifest, out)
assert ab.manifest_id(replace(manifest, arms=dict(reversed(list(manifest.arms.items()))))) == ab.manifest_id(manifest)
with pytest.raises(ValueError, match="registration"):
ab.register(replace(manifest, seed=99), out)
payload = json.loads((out / "ablation.json").read_text())
payload["manifest"]["seed"] = 99
(out / "ablation.json").write_text(json.dumps(payload))
with pytest.raises(ValueError, match="registration"):
ab.analyse(out, manifest, population[2])