learn: build_features reads v2 _a/_b scans and TypeScript repository-scan witnesses; --dry-run

Population v2 scans are named <case>--<side>_a/_b.json; the script looked only
for <case>--<side>.json and recorded those examples as engine not-run. The two
repeats combine symmetrically for both sides. Implemented with Codex.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-03 14:41:55 +02:00
co-authored by Claude Opus 5.5
parent 2836b78819
commit 463fe1503a
2 changed files with 288 additions and 17 deletions
+132 -17
View File
@@ -7,7 +7,7 @@ from:
- quick rules and deterministic roles, run here on the unit's tree: a pair side materialised from its excerpt, a - quick rules and deterministic roles, run here on the unit's tree: a pair side materialised from its excerpt, a
population or recipe pin exported from its clone or checkout (one at a time, deleted after, disk-guarded); population or recipe pin exported from its clone or checkout (one at a time, deleted after, disk-guarded);
- the engine: ``benchmarks/learn/engine_pairs.py`` records for pairs, the recorded protocol scans - the engine: ``benchmarks/learn/engine_pairs.py`` records for pairs, the recorded protocol scans
(``~/ousast-results/independent-v{1,2}/scans/<case>--<pin role>.json``) for populations; (``~/ousast-results/independent-v{1,2}/scans/<case>--<pin role>[_a|_b].json``) for populations;
- verify passes a/b and ``agree`` from the harvest Run (``harvest-verify``) and, for population v2, the recorded - verify passes a/b and ``agree`` from the harvest Run (``harvest-verify``) and, for population v2, the recorded
``validation-46`` Run; model roles from ``harvest-roles``; ``validation-46`` Run; model roles from ``harvest-roles``;
- callers and entry-point distance on a repository (``not_applicable`` on a pair excerpt: no repository exists). - callers and entry-point distance on a repository (``not_applicable`` on a pair excerpt: no repository exists).
@@ -21,6 +21,11 @@ An instrument that has not run for a unit yet (the engine job still running, a R
memory store (``OUSAST_MEMORY``, default ``~/ousast-results/plane/memory``) as kind ``features``; a rerun replaces memory store (``OUSAST_MEMORY``, default ``~/ousast-results/plane/memory``) as kind ``features``; a rerun replaces
them by id. ``--counts`` writes the counts-only coverage record (no candidate, path or repository name). them by id. ``--counts`` writes the counts-only coverage record (no candidate, path or repository name).
``--dry-run`` instead compares existing local store examples with the corrected engine features, printing
per-source transitions from not-run/none to ran (and recorded failures separately). It reads only the spent
v1/v2 manifests, saved scans and local Git objects; it writes neither the store nor ``--counts`` and does not
rebuild other instruments. No label snapshot, harvest units or scratch directory is needed in this mode.
Usage:: Usage::
python benchmarks/learn/build_features.py --labels labels-<sha>.jsonl --units <harvest plane>/units.json --counts counts.json python benchmarks/learn/build_features.py --labels labels-<sha>.jsonl --units <harvest plane>/units.json --counts counts.json
@@ -30,9 +35,11 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import tomllib
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Mapping from collections.abc import Mapping
from pathlib import Path from pathlib import Path
@@ -61,6 +68,7 @@ from openultrasast.learn.features import (
source_part, source_part,
verify_part, verify_part,
) )
from openultrasast.learn.labels import Clone
from openultrasast.learn.roles import RoleSet, from_model_roles, infer_for_checkout from openultrasast.learn.roles import RoleSet, from_model_roles, infer_for_checkout
from openultrasast.pairs import DEFAULT_CATALOG, _materialize_side, _targets, load_pair_catalog from openultrasast.pairs import DEFAULT_CATALOG, _materialize_side, _targets, load_pair_catalog
from openultrasast.plane import memory from openultrasast.plane import memory
@@ -89,6 +97,110 @@ def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
def resolve_scan(scans: Path, case: str, side: str) -> dict[str, Any] | None:
"""Read the v1 single scan, otherwise combine the v2 repeats for this side."""
single = read_json(scans / f"{case}--{side}.json")
if single is not None:
return dict(single)
repeats = [read_json(scans / f"{case}--{side}_{repeat}.json") for repeat in ("a", "b")]
available = [r for r in repeats if r is not None]
if not available:
return None
# Identical rule for vulnerable and fixed: a finding in either repeat counts;
# exact duplicates count once. engine_part takes the highest rung and minimum
# witness steps across the union. Completion is sum(completed)/sum(questions).
# A successful repeat survives a failed/missing peer, which marks degradation.
ran = [r for r in available if engine_for(r) is None]
findings = {json.dumps(f, sort_keys=True): f for r in ran for f in r.get("findings", [])}
degradations = sorted({d for r in available for d in r.get("degradations", [])})
if len(ran) != len(repeats):
degradations.append("repeat_missing_or_failed")
return {
"state": "ran" if ran else "none" if all(engine_for(r) == NONE for r in available) else "failed",
"questions": sum(int(r.get("questions") or 0) for r in available),
"completed": sum(int(r.get("completed") or 0) for r in available),
"findings": list(findings.values()),
"degradations": degradations,
"image": "+".join(sorted({str(r.get("image") or "recorded") for r in available})),
}
def recorded_engine(language: str, findings: list, result: Mapping | None, repository: bool) -> Part:
# The language list describes standalone scans. Repository scans also analyze
# TypeScript; recorded function findings take precedence over that list.
if language not in ENGINE_LANGUAGES and not findings and not (repository and language == "typescript" and result is not None):
return NONE
state = engine_for(result)
if state is not None:
return state
result = result or {}
questions, completed = int(result.get("questions") or 0), int(result.get("completed") or 0)
return engine_part(
findings, completion=completed / questions if questions else None, degraded=bool(result.get("degradations")),
vocabulary=Vocabulary.load(language), version=str(result.get("image") or "recorded"),
) # fmt: skip
class PinSourceIndex(SourceIndex):
"""Resolve findings with the same matcher as a checkout, without exporting it."""
def __init__(self, clone: Path, pin: str) -> None:
super().__init__(clone)
self.clone, self.pin = Clone(clone), pin
def lines(self, path: str) -> list[str]:
if path not in self._lines:
self._lines[path] = self.clone.git("show", f"{self.pin}:{path}").splitlines()
return self._lines[path] or []
def dry_run(store: memory.FileStore, results: Path, cache: Path) -> dict[str, Any]:
"""Count existing examples whose engine becomes available; never write rows or blobs.
Only spent v1/v2 populations are read. Counts are examples, not the two
static/plane feature records. Other sources are reported with zero changes.
"""
root = Path(__file__).resolve().parents[2]
cases = {}
for population in ("population-v1", "population-v2"):
manifest = root / "benchmarks" / "independent" / f"{population}.toml"
for case in tomllib.loads(manifest.read_text(encoding="utf-8"))["case"]:
for side in ("vulnerable", "fixed"):
cases[(population, memory.repo_key(case["repo"]), case[side], side)] = case["id"]
rows = [r.row for r in store.rows(kind="example")]
if not rows:
raise ValueError("dry run read no examples from the store")
counts: dict[str, Counter[str]] = defaultdict(Counter)
for row in rows:
count = counts[row["source"]]
count["examples"] += 1
old = row["instruments"]["engine"]
reason = "not_run" if old.get("version") == "missing:not-run" else "none" if old["state"] == "none" else None
if reason is None:
continue
key = (row["split"], row["repo"], row["pin"], row["pin_role"])
case_id = cases.get(key)
if case_id is None:
continue
scans = results / row["split"].replace("population", "independent") / "scans"
result = resolve_scan(scans, case_id, row["pin_role"])
path, _, function = row["candidate"].partition("::")
engine = {}
if engine_for(result) is None:
index = PinSourceIndex(cache / "independent" / case_id, row["pin"])
lines = index.lines(path) # fail loudly if the candidate's input cannot be read
count["source_bytes_read"] += len("\n".join(lines).encode("utf-8"))
selected = dict(result or {})
selected["findings"] = [f for f in selected.get("findings", []) if f["site"].partition(":")[0] == path]
_, engine = _signals(index, [], selected, {})
part = recorded_engine(row["language"], engine.get((path, function, row["family"]), []), result, True)
if part.state == "ran":
count[f"{reason}_to_ran"] += 1
elif result is not None and part.state == "failed":
count[f"{reason}_to_recorded_failed"] += 1
return {source: {**{"not_run_to_ran": 0, "none_to_ran": 0}, **dict(count)} for source, count in sorted(counts.items())}
def done(run: Path, entry: str) -> bool: def done(run: Path, entry: str) -> bool:
state = (read_json(run / "state.json") or {}).get("tasks", {}) state = (read_json(run / "state.json") or {}).get("tasks", {})
return state.get(entry, {}).get("status") == "done" return state.get(entry, {}).get("status") == "done"
@@ -188,17 +300,7 @@ def static_parts(root: Path, index: SourceIndex, quick: Mapping, engine: Mapping
key = (path, function, family) key = (path, function, family)
parts: dict[str, Part] = {} parts: dict[str, Part] = {}
parts["quick"] = quick_part(quick.get(key, []), rules, quick_version) if covers(QUICK_LANGUAGES, language) else NONE parts["quick"] = quick_part(quick.get(key, []), rules, quick_version) if covers(QUICK_LANGUAGES, language) else NONE
if language not in ENGINE_LANGUAGES: parts["engine"] = recorded_engine(language, engine.get(key, []), engine_result, repository)
parts["engine"] = NONE
elif engine_state is not None:
parts["engine"] = engine_state
else:
result = engine_result or {}
questions, completed = int(result.get("questions") or 0), int(result.get("completed") or 0)
parts["engine"] = engine_part(
engine.get(key, []), completion=completed / questions if questions else None, degraded=bool(result.get("degradations")),
vocabulary=Vocabulary.load(language), version=str(result.get("image") or "recorded"),
) # fmt: skip
lines = index.lines(path) lines = index.lines(path)
parts["source"] = source_part(lines, language, function) parts["source"] = source_part(lines, language, function)
parts["roles"] = roles_part(function_of(lines, path, language, function), inferred, language) parts["roles"] = roles_part(function_of(lines, path, language, function), inferred, language)
@@ -237,21 +339,34 @@ def export(clone: Path, pin: str, target: Path) -> bool:
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--labels", type=Path, required=True) parser.add_argument("--labels", type=Path)
parser.add_argument("--units", type=Path, required=True, help="units.json of the harvest plane") parser.add_argument("--units", type=Path, help="units.json of the harvest plane")
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG) parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
parser.add_argument("--cache", type=Path, default=Path.home() / ".cache" / "openultrasast") parser.add_argument("--cache", type=Path, default=Path.home() / ".cache" / "openultrasast")
parser.add_argument("--engine", type=Path, default=RESULTS / "plane" / "harvest-engine") parser.add_argument("--engine", type=Path, default=RESULTS / "plane" / "harvest-engine")
parser.add_argument("--verify-run", type=Path, default=RESULTS / "plane" / "harvest-verify") parser.add_argument("--verify-run", type=Path, default=RESULTS / "plane" / "harvest-verify")
parser.add_argument("--roles-run", type=Path, default=RESULTS / "plane" / "harvest-roles") parser.add_argument("--roles-run", type=Path, default=RESULTS / "plane" / "harvest-roles")
parser.add_argument("--v46", type=Path, default=RESULTS / "plane" / "validation-46") parser.add_argument("--v46", type=Path, default=RESULTS / "plane" / "validation-46")
parser.add_argument("--scratch", type=Path, required=True) parser.add_argument("--scratch", type=Path)
parser.add_argument("--min-free-gb", type=float, default=1.2) parser.add_argument("--min-free-gb", type=float, default=1.2)
parser.add_argument("--counts", type=Path, required=True) parser.add_argument("--counts", type=Path)
parser.add_argument("--store", help="OUSAST_MEMORY spec (default: the environment's)") parser.add_argument("--store", help="OUSAST_MEMORY spec (default: the environment's)")
parser.add_argument("--limit", type=int, default=0, help="stop after this many targets (a test)") parser.add_argument("--limit", type=int, default=0, help="stop after this many targets (a test)")
parser.add_argument("--kinds", default="pairs,pin", help="which targets: pairs, pin (population and recipe pins)") parser.add_argument("--kinds", default="pairs,pin", help="which targets: pairs, pin (population and recipe pins)")
parser.add_argument("--results-root", type=Path, default=RESULTS)
parser.add_argument("--dry-run", action="store_true", help="read local stored examples and print engine changes by source; no writes")
args = parser.parse_args() args = parser.parse_args()
if args.dry_run:
spec = args.store or os.environ.get("OUSAST_MEMORY") or ""
if spec and not spec.startswith("file://"):
parser.error("--dry-run requires a local file:// store (no network)")
store = memory.open_store(spec)
if not isinstance(store, memory.FileStore):
parser.error("--dry-run requires a local file:// store (no network)")
print(json.dumps({"dry_run": True, "by_source": dry_run(store, args.results_root, args.cache)}, sort_keys=True))
return 0
if any(getattr(args, name) is None for name in ("labels", "units", "scratch", "counts")):
parser.error("--labels, --units, --scratch and --counts are required unless --dry-run is used")
labels = [r for r in jsonl(args.labels) if r["unit"] == "pin" and r["source"] not in CONDITIONAL_SOURCES and not r.get("conditional")] labels = [r for r in jsonl(args.labels) if r["unit"] == "pin" and r["source"] not in CONDITIONAL_SOURCES and not r.get("conditional")]
units = json.loads(args.units.read_text(encoding="utf-8")) units = json.loads(args.units.read_text(encoding="utf-8"))
@@ -314,7 +429,7 @@ def main() -> int:
continue continue
split = {r["split"] for r in rows} | {unit["source"]} split = {r["split"] for r in rows} | {unit["source"]}
population = "independent-v1" if "population-v1" in split else "independent-v2" population = "independent-v1" if "population-v1" in split else "independent-v2"
engine_result = read_json(RESULTS / population / "scans" / f"{case_id}--{rows[0]['pin_role']}.json") engine_result = resolve_scan(args.results_root / population / "scans", case_id, rows[0]["pin_role"])
stems = [(args.verify_run, n) for n in names] + [(args.v46, unit["ref"])] stems = [(args.verify_run, n) for n in names] + [(args.v46, unit["ref"])]
owner = next( owner = next(
(n for n in names if f"{n}-roles" in {e for e in (read_json(args.roles_run / "state.json") or {}).get("tasks", {})}), (n for n in names if f"{n}-roles" in {e for e in (read_json(args.roles_run / "state.json") or {}).get("tasks", {})}),
+156
View File
@@ -0,0 +1,156 @@
"""Recorded scan names, repository coverage, and the feature builder's read-only audit."""
import importlib.util
import json
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from openultrasast.learn.features import SourceIndex, _signals
from openultrasast.learn.labels import LabelSourceError
from openultrasast.learn.roles import RoleSet
SPEC = importlib.util.spec_from_file_location("build_features", Path(__file__).parents[1] / "benchmarks/learn/build_features.py")
assert SPEC and SPEC.loader
builder = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(builder)
def scan(steps: int = 5) -> dict:
return {
"questions": 4,
"completed": 3,
"findings": [{"site": "a.ts:2:handler", "family": "injection", "rung": "model_entailed", "witness": f"flow ({steps} steps)"}],
"degradations": [],
}
def write_scan(root: Path, name: str, result: dict) -> None:
root.mkdir(parents=True, exist_ok=True)
(root / f"{name}.json").write_text(json.dumps(result))
@pytest.mark.parametrize("side", ["vulnerable", "fixed"])
def test_feature_scan_names_and_repeat_union(tmp_path: Path, side: str) -> None:
single = scan()
write_scan(tmp_path, f"v1--{side}", single)
assert builder.resolve_scan(tmp_path, "v1", side) == single
write_scan(tmp_path, f"v2--{side}_a", single)
other = scan(2)
other["findings"].extend(single["findings"])
other["completed"] = 1
write_scan(tmp_path, f"v2--{side}_b", other)
result = builder.resolve_scan(tmp_path, "v2", side)
part = builder.recorded_engine("typescript", result["findings"], result, True)
assert part.state == "ran"
assert part.values["eng.findings"] == 2 # duplicate from the other repeat counted once
assert part.values["eng.witness_steps_min"] == 2
assert part.values["eng.rung_max"] == "model_entailed"
assert part.values["eng.completion"] == 0.5
assert part.values["eng.degraded"] is False
# v1 is authoritative when both naming schemes are present.
write_scan(tmp_path, f"v2--{side}", single)
assert builder.resolve_scan(tmp_path, "v2", side) == single
@pytest.mark.parametrize("peer", [None, {"state": "failed", "questions": 4, "completed": 0}])
def test_feature_successful_repeat_survives_missing_or_failed_peer(tmp_path: Path, peer: dict | None) -> None:
write_scan(tmp_path, "case--fixed_b", scan())
if peer is not None:
write_scan(tmp_path, "case--fixed_a", peer)
result = builder.resolve_scan(tmp_path, "case", "fixed")
assert builder.engine_for(result) is None
assert result["findings"] == scan()["findings"]
assert "repeat_missing_or_failed" in result["degradations"]
def test_feature_missing_scan_is_not_run_and_failed_scan_is_recorded(tmp_path: Path) -> None:
result = builder.resolve_scan(tmp_path, "absent", "vulnerable")
part = builder.recorded_engine("python", [], result, True)
assert part.state == "failed" and part.version == "missing:not-run"
write_scan(tmp_path, "failed--vulnerable_a", {"questions": 0, "findings": []})
result = builder.resolve_scan(tmp_path, "failed", "vulnerable")
part = builder.recorded_engine("python", [], result, True)
assert part.state == "failed" and part.version == "recorded"
def test_feature_typescript_repository_witness_reaches_static_record(tmp_path: Path) -> None:
(tmp_path / "a.ts").write_text("function handler(req) {\n return eval(req.query);\n}\n")
index = SourceIndex(tmp_path)
result = scan()
quick, engine = _signals(index, [], result, {})
parts = builder.static_parts(
tmp_path, index, quick, engine, None, result, RoleSet(), {}, "test", "a.ts", "handler", "injection", True, None
)
assert parts["engine"].state == "ran"
assert parts["engine"].values["eng.witness_steps_min"] == 5
assert parts["engine"].values["eng.findings"] == 1
assert builder.recorded_engine("typescript", [], None, True).state == "none"
assert builder.recorded_engine("typescript", [], result, False).state == "none"
assert builder.recorded_engine("typescript", [], result, True).values["eng.findings"] == 0
def test_feature_dry_run_counts_sources_without_writing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
) -> None:
script = tmp_path / "benchmarks/learn/build_features.py"
script.parent.mkdir(parents=True)
monkeypatch.setattr(builder, "__file__", str(script))
manifests = tmp_path / "benchmarks/independent"
manifests.mkdir()
for version in (1, 2):
(manifests / f"population-v{version}.toml").write_text(
'[[case]]\nid="case"\nrepo="https://github.com/o/r"\nvulnerable="pin"\nfixed="fix"\n'
)
results = tmp_path / "results"
write_scan(results / "independent-v2/scans", "case--vulnerable_b", scan())
base = {"repo": "github.com/o/r", "pin": "pin", "pin_role": "vulnerable", "split": "population-v2",
"candidate": "a.ts::handler", "family": "injection", "language": "typescript"} # fmt: skip
rows = [
base | {"source": "population-v2", "instruments": {"engine": {"state": "failed", "version": "missing:not-run"}}},
base | {"source": "adjudications", "instruments": {"engine": {"state": "none"}}},
]
monkeypatch.setattr(builder.memory.FileStore, "rows", lambda *a, **kw: [SimpleNamespace(row=r) for r in rows])
monkeypatch.setattr(builder.PinSourceIndex, "__init__", lambda self, *a: None)
monkeypatch.setattr(builder.PinSourceIndex, "lines", lambda *a: ["function handler(req) {", "return eval(req.query);", "}"])
def no_write(*args: object, **kwargs: object) -> None:
pytest.fail("dry run attempted a store write")
for method in ("_put", "ingest_rows", "put_rows", "drop_rows", "put_blob"):
monkeypatch.setattr(builder.memory.FileStore, method, no_write)
counts = tmp_path / "counts.json"
monkeypatch.setattr(
sys,
"argv",
[
"build_features.py",
"--dry-run",
"--results-root",
str(results),
"--store",
f"file://{tmp_path / 'memory'}",
"--counts",
str(counts),
],
)
assert builder.main() == 0
report = json.loads(capsys.readouterr().out)["by_source"]
assert report["population-v2"]["not_run_to_ran"] == 1
assert report["adjudications"]["none_to_ran"] == 1
assert not counts.exists() and not (tmp_path / "memory").exists()
def test_feature_unread_pin_fails_loudly(tmp_path: Path) -> None:
with pytest.raises(LabelSourceError, match="no clone"):
builder.PinSourceIndex(tmp_path, "pin")
def test_feature_dry_run_rejects_network_store_before_open(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(sys, "argv", ["build_features.py", "--dry-run", "--store", "s3://bucket"])
monkeypatch.setattr(builder.memory, "open_store", lambda *a: pytest.fail("remote store was opened"))
with pytest.raises(SystemExit) as exc:
builder.main()
assert exc.value.code == 2