T05: the lab and its labelled mutation catalogue
lab/app.py (users, tenants, auth, resources, sharing, read/write, revoke, audit), lab/http_api.py (JSON API + browser UI, stdlib only), 20 labelled composable version-stamped mutations, ground-truth matrix. 48 tests pass. Detection against the reference scenario: MECHANICAL 0/10 flagged (correct), DEFECT 6/6, SEMANTIC 2/4 with both inert cases declared. - F-0002: M16 and M18 initially escaped detection entirely. A use case protects exactly what it asserts. Resolved by adding two claims already stated as intent in INTENT.md; the six-mutation catalogue would never have surfaced this. - test-id axis added: stable selectors survive most UI mutations, which would make H-001 trivially false. Mutations now vary on preserves_test_ids so the hypothesis is analysed split by that axis rather than rigged. - M12 (semantic deferred revoke) and M19 (defect race) are behaviourally identical and asserted as such - the discrimination problem as a test. lab/minimal.py removed; superseded by lab/app.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
This commit is contained in:
parent
4d3421ca57
commit
4ddb2f896c
22 changed files with 1091 additions and 207 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -15,7 +15,7 @@ from testdriver import (
|
|||
Claim, InadmissibleProvenance, Invariant, Oracle, Provenance, Runner,
|
||||
SemanticAction, StateObserver, SurfaceNotPermitted, Stratum, Verdict,
|
||||
)
|
||||
from lab.minimal import Denied, MinimalLab, ObservationChannel, build_baseline
|
||||
from lab.app import Denied, LabApp, ObservationChannel, build_baseline
|
||||
from scenarios.alice_bob_carol import build
|
||||
|
||||
|
||||
|
|
@ -83,7 +83,7 @@ def test_actors_cannot_be_recorded_as_judgment_collectors():
|
|||
# --- preview of the M05 authorization defect (built properly in T05) --------
|
||||
|
||||
|
||||
class RevokeIsCosmetic(MinimalLab):
|
||||
class RevokeIsCosmetic(LabApp):
|
||||
"""Revocation updates the record and the audit trail but not enforcement.
|
||||
|
||||
This is the shape of a real authorization defect: everything an operator
|
||||
|
|
@ -101,6 +101,10 @@ class RevokeIsCosmetic(MinimalLab):
|
|||
for r in self.audit
|
||||
)
|
||||
|
||||
# NOTE: this predates the mutation catalogue, where the same defect is M15.
|
||||
# Kept as a direct subclass so the kernel test does not depend on the lab
|
||||
# catalogue's wiring being correct.
|
||||
|
||||
|
||||
def test_seeded_authorization_defect_fails_the_run():
|
||||
"""The kernel must report FAIL, not adapt, when revocation does not revoke."""
|
||||
|
|
@ -124,7 +128,8 @@ def test_seeded_authorization_defect_fails_the_run():
|
|||
assert result.verdict is Verdict.FAIL
|
||||
assert result.judgment("c-bob-revoked").verdict is Verdict.FAIL
|
||||
# The claim set is untouched by the failure — there is no path to adapt it.
|
||||
assert USE_CASE.claims[2].text == "Bob cannot read R after revocation"
|
||||
by_id = {c.id: c for c in USE_CASE.claims}
|
||||
assert by_id["c-bob-revoked"].text == "Bob cannot read R after revocation"
|
||||
|
||||
|
||||
def test_defect_run_emits_an_energy_event():
|
||||
|
|
|
|||
148
tests/test_lab_ground_truth.py
Normal file
148
tests/test_lab_ground_truth.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""The lab is the measuring instrument. These tests keep it honest.
|
||||
|
||||
Nothing here tests the kernel's cleverness — the kernel has no classifier yet.
|
||||
What is established is the **ground truth** every later measurement is taken
|
||||
against: which mutations the reference scenario responds to, and how.
|
||||
|
||||
The response matrix below is an asserted fact, not a snapshot. If a change to the
|
||||
lab or the scenario moves a cell, that is a change to the measuring instrument
|
||||
and must be a deliberate, reviewed act.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from testdriver import Runner, Verdict
|
||||
from lab.mutations import BY_ID, CATALOGUE, build_lab, expected_classification
|
||||
from scenarios.alice_bob_carol import build
|
||||
|
||||
|
||||
def run_against(*mutations: str):
|
||||
world, driver, observer, asset, oracle = build(*mutations)
|
||||
return Runner(world, driver, observer, oracle).run(asset)
|
||||
|
||||
|
||||
# --- the catalogue itself -------------------------------------------------
|
||||
|
||||
|
||||
def test_catalogue_is_large_enough_to_support_a_rate():
|
||||
"""Six mutations cannot support precision or recall. Twenty can begin to."""
|
||||
assert len(CATALOGUE) >= 20
|
||||
|
||||
|
||||
def test_every_mutation_is_labelled_and_reasoned():
|
||||
for mutation in CATALOGUE:
|
||||
assert mutation.label in ("MECHANICAL", "SEMANTIC", "DEFECT")
|
||||
assert mutation.rationale.strip(), f"{mutation.id} has no rationale"
|
||||
|
||||
|
||||
def test_labels_cover_all_three_classes_with_useful_weight():
|
||||
counts = {label: 0 for label in ("MECHANICAL", "SEMANTIC", "DEFECT")}
|
||||
for mutation in CATALOGUE:
|
||||
counts[mutation.label] += 1
|
||||
assert all(count >= 4 for count in counts.values()), counts
|
||||
|
||||
|
||||
def test_every_mutation_is_reproducible_and_version_stamped():
|
||||
for mutation in CATALOGUE:
|
||||
first, _ = build_lab(mutation.id)
|
||||
second, _ = build_lab(mutation.id)
|
||||
assert first.version == second.version == f"lab-0.2.0-{mutation.id}"
|
||||
assert first.applied_mutations == (mutation.id,)
|
||||
|
||||
|
||||
def test_mutations_compose_and_record_both():
|
||||
"""Row 3 of the decision table needs a lab carrying both at once."""
|
||||
app, _ = build_lab("M01", "M15")
|
||||
assert app.version == "lab-0.2.0-M01+M15"
|
||||
assert app.applied_mutations == ("M01", "M15")
|
||||
|
||||
|
||||
def test_test_id_axis_is_represented():
|
||||
"""H-001 must be analysable split by whether stable selectors survived."""
|
||||
preserved = [m.id for m in CATALOGUE if m.preserves_test_ids]
|
||||
dropped = [m.id for m in CATALOGUE if not m.preserves_test_ids]
|
||||
assert preserved and dropped, "both sides of the test-id axis must exist"
|
||||
|
||||
|
||||
# --- the response matrix --------------------------------------------------
|
||||
|
||||
# Ground truth: what the reference scenario reports for each lab version.
|
||||
# `None` means "no assertion in this scenario covers this mutation" — recorded
|
||||
# honestly rather than papered over.
|
||||
EXPECTED_VERDICT = {
|
||||
"M01": Verdict.PASS, "M02": Verdict.PASS, "M03": Verdict.PASS,
|
||||
"M04": Verdict.PASS, "M05": Verdict.PASS, "M06": Verdict.PASS,
|
||||
"M07": Verdict.PASS, "M08": Verdict.PASS, "M09": Verdict.PASS,
|
||||
"M10": Verdict.PASS,
|
||||
"M11": Verdict.FAIL, "M12": Verdict.FAIL,
|
||||
"M13": Verdict.PASS, "M14": Verdict.PASS,
|
||||
"M15": Verdict.FAIL, "M16": Verdict.FAIL, "M17": Verdict.FAIL,
|
||||
"M18": Verdict.FAIL, "M19": Verdict.FAIL, "M20": Verdict.FAIL,
|
||||
}
|
||||
|
||||
# The two SEMANTIC mutations the reference scenario cannot see, and why.
|
||||
KNOWN_INERT = {
|
||||
"M13": "only affects grants that omit a permission; the scenario passes READ explicitly",
|
||||
"M14": "a change of intent with no change of code; nothing observable moved",
|
||||
}
|
||||
|
||||
|
||||
def test_baseline_passes():
|
||||
assert run_against().verdict is Verdict.PASS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation_id", sorted(EXPECTED_VERDICT))
|
||||
def test_response_matrix_is_stable(mutation_id):
|
||||
assert run_against(mutation_id).verdict is EXPECTED_VERDICT[mutation_id]
|
||||
|
||||
|
||||
def test_no_mechanical_mutation_changes_the_verdict():
|
||||
"""Semantics are preserved, so the use case must not notice."""
|
||||
for mutation in CATALOGUE:
|
||||
if mutation.label == "MECHANICAL":
|
||||
assert run_against(mutation.id).verdict is Verdict.PASS, mutation.id
|
||||
|
||||
|
||||
def test_every_defect_is_detected():
|
||||
"""The floor of the whole project. A defect the lab cannot surface is a
|
||||
defect no later classifier can be measured against."""
|
||||
missed = [
|
||||
m.id for m in CATALOGUE
|
||||
if m.label == "DEFECT" and run_against(m.id).verdict is Verdict.PASS
|
||||
]
|
||||
assert missed == [], f"undetected seeded defects: {missed}"
|
||||
|
||||
|
||||
def test_inert_semantic_mutations_are_declared():
|
||||
"""A mutation the scenario cannot see must be named, not silently ignored."""
|
||||
for mutation in CATALOGUE:
|
||||
if mutation.label != "SEMANTIC":
|
||||
continue
|
||||
if run_against(mutation.id).verdict is Verdict.PASS:
|
||||
assert mutation.id in KNOWN_INERT, (
|
||||
f"{mutation.id} is invisible to the reference scenario and "
|
||||
"undeclared — either cover it or record why not"
|
||||
)
|
||||
|
||||
|
||||
def test_deferred_revoke_and_revoke_race_are_behaviourally_identical():
|
||||
"""M12 (SEMANTIC) and M19 (DEFECT) must be indistinguishable from evidence.
|
||||
|
||||
This is the discrimination problem in its sharpest form, and the reason
|
||||
classification cannot be a diff over observed behaviour. Both produce the
|
||||
same failure; only intent separates them, which is why claims need
|
||||
independent provenance and why ambiguity escalates to a human.
|
||||
"""
|
||||
semantic = run_against("M12")
|
||||
defect = run_against("M19")
|
||||
failed = lambda r: sorted({j.assertion_id for j in r.judgments
|
||||
if j.verdict is not Verdict.PASS})
|
||||
assert failed(semantic) == failed(defect) == ["c-bob-revoked"]
|
||||
assert expected_classification("M12") != expected_classification("M19")
|
||||
|
||||
|
||||
def test_a_mechanical_change_shipping_with_a_defect_still_fails():
|
||||
"""Decision-table row 3: coincidence is not exoneration."""
|
||||
assert run_against("M01", "M15").verdict is Verdict.FAIL
|
||||
|
|
@ -10,8 +10,8 @@ from testdriver import Runner, Stratum, Verdict
|
|||
from scenarios.alice_bob_carol import build
|
||||
|
||||
|
||||
def run_once(variant: str = "baseline"):
|
||||
world, driver, observer, asset, oracle = build(variant)
|
||||
def run_once(*mutations: str):
|
||||
world, driver, observer, asset, oracle = build(*mutations)
|
||||
return Runner(world, driver, observer, oracle).run(asset), world
|
||||
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ def test_evidence_is_stratified_and_serializable():
|
|||
assert pack.of_stratum(Stratum.JUDGMENT)
|
||||
parsed = json.loads(pack.to_json())
|
||||
assert parsed["run_id"] == result.run_id
|
||||
assert parsed["sut_version"] == "lab-0.1.0-baseline"
|
||||
assert parsed["sut_version"] == "lab-0.2.0-baseline"
|
||||
|
||||
|
||||
def test_evidence_records_claim_provenance():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue