feat(access-control-prover): 12.3 instrument and hard runtime gates

This commit is contained in:
Marius Ciepluch
2026-10-30 08:53:05 +01:00
parent eb30d0386c
commit 282a085f77
3 changed files with 178 additions and 7 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
- _Requirements: 5.1, 5.3, 5.4, 7.1, 7.6, 9.7, 11.8_ - _Requirements: 5.1, 5.3, 5.4, 7.1, 7.6, 9.7, 11.8_
- _Boundary: Development Runner_ - _Boundary: Development Runner_
- _Depends: 10.6, 12.1_ - _Depends: 10.6, 12.1_
- [ ] 12.3 Enforce instrument and hard runtime gates - [x] 12.3 Enforce instrument and hard runtime gates
- Preserve zero-byte and stale-anchor refusal and add stage-aware deadline records. - Preserve zero-byte and stale-anchor refusal and add stage-aware deadline records.
- Fail the run if any measured change exceeds 120 seconds; never summarize the overrun as a passing result. - Fail the run if any measured change exceeds 120 seconds; never summarize the overrun as a passing result.
- Done when zero-byte, stale-anchor, deadline and 121-second fixtures all refuse the summary. - Done when zero-byte, stale-anchor, deadline and 121-second fixtures all refuse the summary.
+82 -6
View File
@@ -1,10 +1,12 @@
"""Development runner: per-record provenance, stage funnel and judgment transitions. """Development runner: per-record provenance, stage funnel and judgment transitions.
Task 12.2 of the access-control-prover spec (Reqs 5.1, 5.3, 5.4, 7.1, 7.6, 9.7, 11.8). Tasks 12.2 and 12.3 of the access-control-prover spec (Reqs 5.1, 5.3, 5.4, 5.6,
Every development record carries the bundle digests (analyzer revision, fact set, 6.1, 6.3, 7.1, 7.6, 9.7, 11.8). Every development record carries the bundle
acquisition record, configuration) plus the population and per-change input digests, digests (analyzer revision, fact set, acquisition record, configuration) plus the
a stage funnel from entry recognition through resolution to the three-way judgment population and per-change input digests, a stage funnel from entry recognition
counts, and the transition label between compared judgments. 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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -116,4 +118,78 @@ def run_development(
return tuple(records) return tuple(records)
__all__ = ["DevelopmentRecord", "judgment_transition", "run_development", "stage_funnel"] 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",
]
+95
View File
@@ -0,0 +1,95 @@
"""Task 12.3 -- runner instrument and hard runtime gates (Reqs 5.6, 6.1, 6.3).
Gate fixtures refuse the summary: zero-byte and stale-anchor records raise
InstrumentFailure, a change over the 120-second budget raises GateFailure, and
the deadline record stamps budget + exceeded flag per record.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from benchmarks.valid import development_runner # noqa: E402
from benchmarks.valid.development_runner import ( # noqa: E402
DevelopmentRecord,
InstrumentFailure,
deadline_record,
enforce_gates,
summarize_development,
)
GateFailure = development_runner.GateFailure
def _record(
*,
seconds: float = 1.0,
change_id: str = "change-1",
instrument_reason: str | None = None,
) -> DevelopmentRecord:
judgment: dict[str, object] = {"unguarded": 0, "discharged": 0, "indeterminate": 1}
funnel: dict[str, dict[str, object]] = {"judgment": judgment}
if instrument_reason is not None:
funnel["instrument"] = {"reason": instrument_reason}
return DevelopmentRecord(
change_id=change_id,
analyzer_revision="0" * 12,
fact_set_digest="1" * 12,
acquisition_record_digest="2" * 12,
configuration_digest="3" * 12,
population_digest="4" * 12,
input_digest="5" * 12,
stage_funnel=funnel,
judgment_transition="same",
seconds=seconds,
)
def test_enforce_gates_passes_clean_records() -> None:
enforce_gates((_record(seconds=1.0), _record(seconds=119.9, change_id="change-2")))
def test_enforce_gates_fails_a_change_over_the_budget() -> None:
with pytest.raises(GateFailure) as caught:
enforce_gates((_record(seconds=0.5), _record(seconds=121.0, change_id="slow-change")))
assert "slow-change" in str(caught.value)
def test_budget_is_overridable_for_fixtures() -> None:
with pytest.raises(GateFailure):
enforce_gates((_record(seconds=6.0),), budget_seconds=5.0)
def test_deadline_record_stamps_budget_and_exceeded() -> None:
kept = deadline_record(_record(seconds=1.0), budget_seconds=120.0)
assert kept == {"deadline_seconds": 120.0, "exceeded": False}
blown = deadline_record(_record(seconds=121.0), budget_seconds=120.0)
assert blown == {"deadline_seconds": 120.0, "exceeded": True}
def test_summary_refuses_an_overrun_never_reports_it_passing() -> None:
with pytest.raises(GateFailure):
summarize_development((_record(seconds=121.0),))
def test_summary_reports_clean_records() -> None:
summary = summarize_development((_record(seconds=1.0), _record(seconds=2.0, change_id="change-2")))
assert summary["total_changes"] == 2
assert summary["max_seconds"] == 2.0
def test_zero_byte_record_refuses_the_summary() -> None:
with pytest.raises(InstrumentFailure) as caught:
summarize_development((_record(instrument_reason="zero-byte read"),))
assert "zero-byte" in str(caught.value)
def test_stale_anchor_record_refuses_the_summary() -> None:
with pytest.raises(InstrumentFailure) as caught:
summarize_development((_record(instrument_reason="stale_anchor"),))
assert "stale_anchor" in str(caught.value)