2026-10-30 08:38:58 +01:00
|
|
|
"""Development runner: per-record provenance, stage funnel and judgment transitions.
|
|
|
|
|
|
2026-10-30 08:53:05 +01:00
|
|
|
Tasks 12.2 and 12.3 of the access-control-prover spec (Reqs 5.1, 5.3, 5.4, 5.6,
|
|
|
|
|
6.1, 6.3, 7.1, 7.6, 9.7, 11.8). Every development record carries the bundle
|
|
|
|
|
digests (analyzer revision, fact set, acquisition record, configuration) plus the
|
|
|
|
|
population and per-change input digests, a stage funnel from entry recognition
|
|
|
|
|
through resolution to the three-way judgment counts, and the transition label
|
|
|
|
|
between compared judgments. The runner gates refuse to summarise an instrument
|
|
|
|
|
failure (zero-byte read, stale anchor) or a change over the hard runtime budget.
|
2026-10-30 08:38:58 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
from dataclasses import asdict, dataclass
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from openultrasast.hunt.prover import ProverResult
|
|
|
|
|
|
|
|
|
|
from benchmarks.valid.run_manifests import AnalyzerBundle
|
|
|
|
|
from openultrasast.hunt.producers import Context
|
|
|
|
|
from openultrasast.hunt.prover import prove_change
|
|
|
|
|
|
|
|
|
|
_STAGE_KEYS = ("entry_recognition", "resolution", "judgment")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class DevelopmentRecord:
|
|
|
|
|
"""One analyzed change with full provenance and stage accounting."""
|
|
|
|
|
|
|
|
|
|
change_id: str
|
|
|
|
|
analyzer_revision: str
|
|
|
|
|
fact_set_digest: str
|
|
|
|
|
acquisition_record_digest: str
|
|
|
|
|
configuration_digest: str
|
|
|
|
|
population_digest: str
|
|
|
|
|
input_digest: str
|
|
|
|
|
stage_funnel: dict[str, dict[str, Any]]
|
|
|
|
|
judgment_transition: str
|
|
|
|
|
seconds: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hex12(payload: str) -> str:
|
|
|
|
|
return hashlib.sha256(payload.encode()).hexdigest()[:12]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _population_digest(changes: tuple[dict[str, Any], ...]) -> str:
|
|
|
|
|
return _hex12("\n".join(sorted(str(change.get("id", "")) for change in changes)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _input_digest(change: dict[str, Any]) -> str:
|
|
|
|
|
return _hex12(json.dumps(change, sort_keys=True, default=str))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stage_funnel(result: ProverResult) -> dict[str, dict[str, Any]]:
|
|
|
|
|
"""Summarise every analysis stage of one prover result."""
|
|
|
|
|
coverage = result.coverage
|
|
|
|
|
counts = coverage.pair_judgments
|
|
|
|
|
return {
|
|
|
|
|
"entry_recognition": {
|
|
|
|
|
"entry_points": coverage.entry_points,
|
|
|
|
|
"frameworks_recognised": dict(coverage.frameworks_recognised),
|
|
|
|
|
},
|
|
|
|
|
"resolution": dict(coverage.resolution_counts),
|
|
|
|
|
"judgment": {
|
|
|
|
|
"unguarded": counts.unguarded,
|
|
|
|
|
"discharged": counts.discharged,
|
|
|
|
|
"indeterminate": counts.indeterminate,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def judgment_transition(compared_a: str, compared_b: str) -> str:
|
|
|
|
|
"""Label the transition between two compared per-change judgments."""
|
|
|
|
|
if compared_a == compared_b:
|
|
|
|
|
return "same"
|
|
|
|
|
return f"{compared_a}->{compared_b}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_development(
|
|
|
|
|
bundle: AnalyzerBundle,
|
|
|
|
|
repo_root: Path,
|
|
|
|
|
changes: tuple[dict[str, Any], ...],
|
|
|
|
|
out_dir: Path,
|
|
|
|
|
) -> tuple[DevelopmentRecord, ...]:
|
|
|
|
|
"""Run the strict judgment lane over every change and persist one record each."""
|
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
population = _population_digest(changes)
|
|
|
|
|
records: list[DevelopmentRecord] = []
|
|
|
|
|
for change in changes:
|
|
|
|
|
context = Context(root=repo_root, change=change)
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
result = prove_change(repo_root, change, context, judgment_mode=True)
|
|
|
|
|
seconds = time.perf_counter() - started
|
|
|
|
|
funnel = stage_funnel(result)
|
|
|
|
|
transition = judgment_transition("same", "same")
|
|
|
|
|
record = DevelopmentRecord(
|
|
|
|
|
change_id=str(change.get("id", "")),
|
|
|
|
|
analyzer_revision=bundle.analyzer_revision,
|
|
|
|
|
fact_set_digest=bundle.fact_set_digest,
|
|
|
|
|
acquisition_record_digest=bundle.acquisition_record_digest,
|
|
|
|
|
configuration_digest=bundle.configuration_digest,
|
|
|
|
|
population_digest=population,
|
|
|
|
|
input_digest=_input_digest(change),
|
|
|
|
|
stage_funnel=funnel,
|
|
|
|
|
judgment_transition=transition,
|
|
|
|
|
seconds=seconds,
|
|
|
|
|
)
|
|
|
|
|
records.append(record)
|
|
|
|
|
(out_dir / f"{record.change_id}.json").write_text(
|
|
|
|
|
json.dumps(asdict(record), indent=2, sort_keys=True) + "\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
return tuple(records)
|
|
|
|
|
|
|
|
|
|
|
2026-10-30 08:53:05 +01:00
|
|
|
class GateFailure(RuntimeError):
|
|
|
|
|
"""A measured change exceeded the hard runtime budget (Req 6.1)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class InstrumentFailure(RuntimeError):
|
|
|
|
|
"""An instrument failure (zero-byte read, stale anchor) refuses the summary (Req 5.6)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_INSTRUMENT_FAILURE_MARKERS = ("zero-byte", "stale_anchor")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def enforce_gates(records: tuple[DevelopmentRecord, ...], *, budget_seconds: float = 120.0) -> None:
|
|
|
|
|
"""Fail when any measured change exceeds the budget; never let an overrun pass (Req 6.1)."""
|
|
|
|
|
for record in records:
|
|
|
|
|
if record.seconds > budget_seconds:
|
|
|
|
|
raise GateFailure(f"change {record.change_id} took {record.seconds:.1f}s over the {budget_seconds:.1f}s budget")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def deadline_record(record: DevelopmentRecord, *, budget_seconds: float = 120.0) -> dict[str, object]:
|
|
|
|
|
"""Stamp the stage-aware deadline view of one record (Req 6.3)."""
|
|
|
|
|
return {
|
|
|
|
|
"deadline_seconds": budget_seconds,
|
|
|
|
|
"exceeded": record.seconds > budget_seconds,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _instrument_failure(record: DevelopmentRecord) -> str | None:
|
|
|
|
|
for value in _funnel_strings(record.stage_funnel):
|
|
|
|
|
for marker in _INSTRUMENT_FAILURE_MARKERS:
|
|
|
|
|
if marker in value:
|
|
|
|
|
return value
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _funnel_strings(funnel: dict[str, dict[str, Any]]) -> list[str]:
|
|
|
|
|
strings: list[str] = []
|
|
|
|
|
for stage in funnel.values():
|
|
|
|
|
for value in stage.values():
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
strings.append(value)
|
|
|
|
|
return strings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def summarize_development(records: tuple[DevelopmentRecord, ...], *, budget_seconds: float = 120.0) -> dict[str, object]:
|
|
|
|
|
"""Summarise a development run; an overrun or instrument failure refuses the summary."""
|
|
|
|
|
enforce_gates(records, budget_seconds=budget_seconds)
|
|
|
|
|
for record in records:
|
|
|
|
|
failure = _instrument_failure(record)
|
|
|
|
|
if failure is not None:
|
|
|
|
|
raise InstrumentFailure(f"change {record.change_id} instrument failure: {failure}")
|
|
|
|
|
judgment_counts = {"unguarded": 0, "discharged": 0, "indeterminate": 0}
|
|
|
|
|
for record in records:
|
|
|
|
|
judgment = record.stage_funnel.get("judgment", {})
|
|
|
|
|
for kind in judgment_counts:
|
|
|
|
|
judgment_counts[kind] += int(judgment.get(kind, 0))
|
|
|
|
|
seconds = [record.seconds for record in records]
|
|
|
|
|
return {
|
|
|
|
|
"total_changes": len(records),
|
|
|
|
|
"judgment_counts": judgment_counts,
|
|
|
|
|
"total_seconds": sum(seconds),
|
|
|
|
|
"max_seconds": max(seconds, default=0.0),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
|
|
"DevelopmentRecord",
|
|
|
|
|
"GateFailure",
|
|
|
|
|
"InstrumentFailure",
|
|
|
|
|
"deadline_record",
|
|
|
|
|
"enforce_gates",
|
|
|
|
|
"judgment_transition",
|
|
|
|
|
"run_development",
|
|
|
|
|
"stage_funnel",
|
|
|
|
|
"summarize_development",
|
|
|
|
|
]
|