Pure evidence substrate for proof-carrying coherence promotion. No promotion, no vault import, no status transition, no teaching-path change; ADR-0218 stays Proposed. - generate/proof_chain/certificate.py: frozen PromotionCertificate (version/claim_form/premise ids+forms+statuses/entailment_trace/ engine_pin/decision/reason, canonical_json with sort_keys+compact separators); build_certificate runs evaluate_entailment_with_trace over entry_id-sorted premises; verify_certificate rebuilds from the embedded forms and accepts iff byte-identical — tampered forms/claim/ trace/decision/reason/ordering/version all fail replay, structural breakage fails closed as malformed_certificate. - promotion_positive is necessary-not-sufficient: entailed over a non-empty all-coherent recorded premise set; REFUTED/UNKNOWN/REFUSED verify as themselves but are never positive; zero-premise tautology certificates fail-closed non-positive in v1. - engine_pin is recorded provenance; pin tampering is caught only via caller-supplied expected_engine_pin (P3 must pass the lane SHA) — limit pinned by dedicated tests. - entail.py: docstring-only fix removing the incorrect ADR-0206 attribution (numbering collision); now cites ADR-0218. Zero behavior change. generate_claims.py:74 still carries the label (serving-frozen surface, flagged for follow-up). - No xfail markers retire: every PR-A obligation binds to the P3 promoter, which must not exist before ratification. The certificate- shaped halves of O1/O7 are proven for real in tests/test_proof_chain_certificate.py (27 tests). ADR phasing bullet and obligations docstring record the reconciliation. Validation: certificate 27 passed; obligations 3 passed + 6 xfailed; architectural invariants 61 passed (INV-21/INV-29 green, allowlists unchanged); entail/builder/rules 61 passed; smoke files 95 passed.
388 lines
17 KiB
Python
388 lines
17 KiB
Python
"""ADR-0218 PR B — PromotionCertificate + replay verifier (pure substrate).
|
||
|
||
PROMOTION IS STILL NOT LIVE. These tests prove the *certificate* substrate:
|
||
build → freeze → replay-verify, fail-closed on every tamper. The promoter
|
||
obligations (O1–O5, O7) remain strict-xfail in
|
||
tests/test_proof_carrying_promotion_obligations.py — every one of them binds
|
||
to `teaching.proof_promotion`, which must not exist before ADR-0218 is
|
||
ratified (P3). What PR B retires is not a marker but a gap: the
|
||
certificate-shaped halves of O1/O7 (replay re-verification, byte-stable
|
||
determinism) are proven here for real instead of being asserted inside
|
||
promoter xfails.
|
||
|
||
Tamper tests use `dataclasses.replace` deliberately: the certificate's
|
||
authority comes from replay recomputation, never from trust in the frozen
|
||
object — a forged field must fail `verify_certificate`, not be unrepresentable.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import dataclasses
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from generate.proof_chain import (
|
||
CERTIFICATE_VERSION,
|
||
ENGINE_PIN_MISMATCH,
|
||
INCONSISTENT_PREMISES,
|
||
MALFORMED_CERTIFICATE,
|
||
OUT_OF_REGIME_OR_MALFORMED,
|
||
PREMISE_STATUS_VOCAB,
|
||
REPLAY_MATCH,
|
||
REPLAY_MISMATCH,
|
||
TAUTOLOGICAL_IMPLICATION,
|
||
TAUTOLOGICAL_REFUTATION,
|
||
UNDETERMINED,
|
||
VERIFICATION_REASONS,
|
||
Entailment,
|
||
PremiseRecord,
|
||
PromotionCertificate,
|
||
build_certificate,
|
||
evaluate_entailment_with_trace,
|
||
verify_certificate,
|
||
)
|
||
import generate.proof_chain.certificate as certificate_module
|
||
|
||
# Opaque to the module under test; the real deductive-lane SHA is supplied by
|
||
# the P3 promoter from scripts/verify_lane_shas.py, never read from disk here.
|
||
_PIN = "deductive_logic_v1@pinned-for-test"
|
||
|
||
_CERTIFICATE_SOURCE = Path(certificate_module.__file__).read_text(encoding="utf-8")
|
||
|
||
|
||
def _coherent(*pairs: tuple[int, str]) -> tuple[PremiseRecord, ...]:
|
||
return tuple(
|
||
PremiseRecord(entry_id=entry_id, form=form, status="coherent")
|
||
for entry_id, form in pairs
|
||
)
|
||
|
||
|
||
def _build(claim: str, premises: tuple[PremiseRecord, ...]) -> PromotionCertificate:
|
||
return build_certificate(claim_form=claim, premises=premises, engine_pin=_PIN)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. The positive path: entailed certificate verifies
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_entailed_certificate_verifies() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
assert cert.certificate_version == CERTIFICATE_VERSION
|
||
assert cert.decision == Entailment.ENTAILED.value
|
||
assert cert.reason == TAUTOLOGICAL_IMPLICATION
|
||
assert cert.promotion_positive is True
|
||
|
||
verdict = verify_certificate(cert)
|
||
assert verdict.verified is True
|
||
assert verdict.reason == REPLAY_MATCH
|
||
|
||
|
||
def test_embedded_trace_matches_independent_recomputation() -> None:
|
||
"""The O1 substrate half, proven for real: recomputing from the embedded
|
||
forms reproduces the embedded EntailmentTrace exactly (ADR-0218 §D3.4)."""
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
recomputed = evaluate_entailment_with_trace(("p", "p -> q"), "q")
|
||
assert cert.entailment_trace == recomputed.as_dict()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2–4. Tampering fails replay
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_tampered_premise_form_fails_verification() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
tampered = dataclasses.replace(cert, premise_forms=("p", "p -> r"))
|
||
verdict = verify_certificate(tampered)
|
||
assert verdict.verified is False
|
||
assert verdict.reason == REPLAY_MISMATCH
|
||
|
||
|
||
def test_tampered_claim_form_fails_verification() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
tampered = dataclasses.replace(cert, claim_form="r")
|
||
verdict = verify_certificate(tampered)
|
||
assert verdict.verified is False
|
||
assert verdict.reason == REPLAY_MISMATCH
|
||
|
||
|
||
def test_tampered_certificate_version_fails_verification() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
tampered = dataclasses.replace(cert, certificate_version=CERTIFICATE_VERSION + 1)
|
||
verdict = verify_certificate(tampered)
|
||
assert verdict.verified is False
|
||
assert verdict.reason == REPLAY_MISMATCH
|
||
|
||
|
||
def test_swapped_entailment_trace_fails_verification() -> None:
|
||
"""Strongest form: graft a *valid, also-ENTAILED* trace from a different
|
||
proof — same decision and reason, different evidence keys. Replay must
|
||
reject the certificate on the evidence, not the verdict label."""
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
other = _build("b", _coherent((1, "a"), (2, "a -> b")))
|
||
assert other.decision == Entailment.ENTAILED.value
|
||
grafted = dataclasses.replace(cert, entailment_trace=other.entailment_trace)
|
||
verdict = verify_certificate(grafted)
|
||
assert verdict.verified is False
|
||
assert verdict.reason == REPLAY_MISMATCH
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Non sequitur: an unentailed claim cannot be certified entailed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_non_sequitur_does_not_verify_as_entailed() -> None:
|
||
cert = _build("q", _coherent((1, "p"))) # consistent with {p}, not entailed
|
||
assert cert.decision == Entailment.UNKNOWN.value
|
||
assert cert.reason == UNDETERMINED
|
||
assert cert.promotion_positive is False
|
||
# It verifies as what it honestly is...
|
||
assert verify_certificate(cert).verified is True
|
||
|
||
# ...and forging the decision (or its reason) fails replay.
|
||
forged = dataclasses.replace(cert, decision=Entailment.ENTAILED.value)
|
||
assert verify_certificate(forged).verified is False
|
||
forged_reason = dataclasses.replace(cert, reason=TAUTOLOGICAL_IMPLICATION)
|
||
assert verify_certificate(forged_reason).verified is False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6–7. Refusal-first: inconsistency and malformed input never vacuously entail
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_inconsistent_premises_refuse_not_vacuously_entail() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "~p")))
|
||
assert cert.decision == Entailment.REFUSED.value
|
||
assert cert.reason == INCONSISTENT_PREMISES
|
||
assert cert.decision != Entailment.ENTAILED.value
|
||
assert cert.promotion_positive is False
|
||
verdict = verify_certificate(cert)
|
||
assert verdict.verified is True # the refusal itself replays
|
||
assert verdict.reason == REPLAY_MATCH
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"claim, premises",
|
||
[
|
||
("q", _coherent((1, "p -> ("))), # malformed premise
|
||
("q -> (", _coherent((1, "p"))), # malformed claim
|
||
("q", _coherent((1, "forall x. p(x)"))), # out-of-regime (quantified)
|
||
],
|
||
)
|
||
def test_malformed_or_out_of_regime_refuses(
|
||
claim: str, premises: tuple[PremiseRecord, ...]
|
||
) -> None:
|
||
cert = _build(claim, premises)
|
||
assert cert.decision == Entailment.REFUSED.value
|
||
assert cert.reason == OUT_OF_REGIME_OR_MALFORMED
|
||
assert cert.promotion_positive is False
|
||
assert verify_certificate(cert).verified is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8–9. Determinism: canonical premise order, byte-stable canonical_json
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_premise_order_is_canonical_and_deterministic() -> None:
|
||
forward = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
reversed_input = _build("q", _coherent((2, "p -> q"), (1, "p")))
|
||
assert forward.canonical_json() == reversed_input.canonical_json()
|
||
assert forward.premise_entry_ids == (1, 2)
|
||
assert forward.premise_forms == ("p", "p -> q")
|
||
|
||
|
||
def test_canonical_json_is_byte_stable_across_double_construction() -> None:
|
||
first = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
second = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
assert first.canonical_json() == second.canonical_json()
|
||
# Canonical form: sorted keys, compact separators — re-serializing the
|
||
# parsed payload under the same policy must be a fixed point.
|
||
payload = first.canonical_json()
|
||
assert payload == json.dumps(
|
||
json.loads(payload), sort_keys=True, separators=(",", ":")
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 10. Only ENTAILED (non-empty, all-coherent) is promotion-positive
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@pytest.mark.parametrize(
|
||
"claim, premises, expected_decision, expected_reason",
|
||
[
|
||
("q", _coherent((1, "p"), (2, "p -> ~q")),
|
||
Entailment.REFUTED.value, TAUTOLOGICAL_REFUTATION),
|
||
("q", _coherent((1, "p")),
|
||
Entailment.UNKNOWN.value, UNDETERMINED),
|
||
("q", _coherent((1, "p"), (2, "~p")),
|
||
Entailment.REFUSED.value, INCONSISTENT_PREMISES),
|
||
("q", _coherent((1, "p -> (")),
|
||
Entailment.REFUSED.value, OUT_OF_REGIME_OR_MALFORMED),
|
||
],
|
||
)
|
||
def test_non_entailed_outcomes_are_never_promotion_positive(
|
||
claim: str,
|
||
premises: tuple[PremiseRecord, ...],
|
||
expected_decision: str,
|
||
expected_reason: str,
|
||
) -> None:
|
||
cert = _build(claim, premises)
|
||
assert cert.decision == expected_decision
|
||
assert cert.reason == expected_reason
|
||
assert cert.promotion_positive is False
|
||
# Each outcome verifies as itself — refusals and unknowns are replayable
|
||
# evidence too, just never promotion-positive.
|
||
assert verify_certificate(cert).verified is True
|
||
|
||
|
||
def test_entailed_over_non_coherent_recorded_status_is_not_positive() -> None:
|
||
"""Valid entailment over a speculative premise: the certificate is honest
|
||
evidence (verifies) but never promotion-positive — the recorded status
|
||
gate is part of positivity, before P3's fresh-read gate even runs."""
|
||
premises = (
|
||
PremiseRecord(entry_id=1, form="p", status="coherent"),
|
||
PremiseRecord(entry_id=2, form="p -> q", status="speculative"),
|
||
)
|
||
cert = _build("q", premises)
|
||
assert cert.decision == Entailment.ENTAILED.value
|
||
assert cert.promotion_positive is False
|
||
assert verify_certificate(cert).verified is True
|
||
|
||
|
||
def test_zero_premise_tautology_is_entailed_but_not_positive() -> None:
|
||
"""A tautology is entailed by the empty set — sound, but fail-closed out
|
||
of v1 promotion scope (module docstring; ratification question)."""
|
||
cert = _build("q | ~q", ())
|
||
assert cert.decision == Entailment.ENTAILED.value
|
||
assert cert.promotion_positive is False
|
||
assert verify_certificate(cert).verified is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Engine pin: recorded provenance, checkable only against a supplied pin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_engine_pin_is_checked_when_expected_pin_is_supplied() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
assert verify_certificate(cert, expected_engine_pin=_PIN).verified is True
|
||
mismatch = verify_certificate(cert, expected_engine_pin="other-pin")
|
||
assert mismatch.verified is False
|
||
assert mismatch.reason == ENGINE_PIN_MISMATCH
|
||
|
||
|
||
def test_engine_pin_tamper_passes_pure_replay_documented_wrinkle() -> None:
|
||
"""HONEST LIMIT, pinned on purpose: the pin is carried verbatim through
|
||
the rebuild, so pure replay cannot detect pin tampering — the module has
|
||
no filesystem and nothing true to compare against. P3 MUST pass
|
||
expected_engine_pin (the deductive-lane SHA in force) to close this."""
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
tampered = dataclasses.replace(cert, engine_pin="forged-pin")
|
||
assert verify_certificate(tampered).verified is True
|
||
checked = verify_certificate(tampered, expected_engine_pin=_PIN)
|
||
assert checked.verified is False
|
||
assert checked.reason == ENGINE_PIN_MISMATCH
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Structural fail-closed: contract violations raise / verify malformed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_structural_contract_violations_raise() -> None:
|
||
with pytest.raises(ValueError, match="duplicate premise entry_ids"):
|
||
_build("q", _coherent((1, "p"), (1, "p -> q")))
|
||
with pytest.raises(ValueError, match="closed vocabulary"):
|
||
PremiseRecord(entry_id=1, form="p", status="definitely_legit")
|
||
with pytest.raises(ValueError, match="entry_id must be an int"):
|
||
PremiseRecord(entry_id=True, form="p", status="coherent")
|
||
with pytest.raises(ValueError, match="premise form must be a str"):
|
||
PremiseRecord(entry_id=1, form=None, status="coherent") # type: ignore[arg-type]
|
||
with pytest.raises(ValueError, match="engine_pin"):
|
||
build_certificate(
|
||
claim_form="q", premises=_coherent((1, "p")), engine_pin=" "
|
||
)
|
||
with pytest.raises(ValueError, match="claim_form"):
|
||
build_certificate(
|
||
claim_form=None, # type: ignore[arg-type]
|
||
premises=_coherent((1, "p")),
|
||
engine_pin=_PIN,
|
||
)
|
||
|
||
|
||
def test_structurally_broken_certificates_verify_malformed() -> None:
|
||
cert = _build("q", _coherent((1, "p"), (2, "p -> q")))
|
||
# Field-length mismatch (zip strict in the rebuild).
|
||
short = dataclasses.replace(cert, premise_forms=("p",))
|
||
assert verify_certificate(short).reason == MALFORMED_CERTIFICATE
|
||
# Tampered status outside the closed vocabulary.
|
||
bad_status = dataclasses.replace(cert, premise_statuses=("coherent", "trusted"))
|
||
assert verify_certificate(bad_status).reason == MALFORMED_CERTIFICATE
|
||
# Non-serializable trace payload.
|
||
junk = dataclasses.replace(cert, entailment_trace={"outcome": object()})
|
||
assert verify_certificate(junk).reason == MALFORMED_CERTIFICATE
|
||
for broken in (short, bad_status, junk):
|
||
assert verify_certificate(broken).verified is False
|
||
|
||
|
||
def test_verification_reasons_are_a_closed_vocabulary() -> None:
|
||
assert VERIFICATION_REASONS == frozenset(
|
||
{REPLAY_MATCH, REPLAY_MISMATCH, MALFORMED_CERTIFICATE, ENGINE_PIN_MISMATCH}
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 11–12. Boundary hygiene: pure imports, no status-transition surface
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def test_certificate_module_imports_are_pure() -> None:
|
||
"""No vault / teaching / session / runtime-shell import, no I/O or
|
||
nondeterminism module — the certificate is evidence, not an actor."""
|
||
tree = ast.parse(_CERTIFICATE_SOURCE)
|
||
roots: set[str] = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
roots.update(alias.name.split(".")[0] for alias in node.names)
|
||
elif isinstance(node, ast.ImportFrom):
|
||
assert node.level == 0, "use absolute imports in certificate.py"
|
||
assert node.module is not None
|
||
roots.add(node.module.split(".")[0])
|
||
allowed = {"__future__", "json", "dataclasses", "typing", "generate"}
|
||
assert roots <= allowed, f"impure imports in certificate.py: {roots - allowed}"
|
||
|
||
forbidden_calls = {"eval", "exec", "open", "__import__", "compile"}
|
||
offenders = [
|
||
node.func.id
|
||
for node in ast.walk(tree)
|
||
if isinstance(node, ast.Call)
|
||
and isinstance(node.func, ast.Name)
|
||
and node.func.id in forbidden_calls
|
||
]
|
||
assert offenders == [], f"forbidden calls in certificate.py: {offenders}"
|
||
|
||
|
||
def test_certificate_module_has_no_status_transition_surface() -> None:
|
||
"""INV-29's own detector, applied directly: zero epistemic-status
|
||
transition writes in certificate.py (the tree-wide INV-29 scan covers it
|
||
too; this pins the file explicitly so a future edit fails loudly here)."""
|
||
from tests.test_architectural_invariants import _status_transition_writes
|
||
|
||
tree = ast.parse(_CERTIFICATE_SOURCE)
|
||
assert _status_transition_writes(tree) == 0
|
||
status_literals = [
|
||
node
|
||
for node in ast.walk(tree)
|
||
if isinstance(node, ast.Constant) and node.value == "epistemic_status"
|
||
]
|
||
assert status_literals == [], (
|
||
"certificate.py must not handle the epistemic_status key at all — "
|
||
"status strings arrive pre-read in PremiseRecord.status"
|
||
)
|
||
|
||
|
||
def test_status_vocab_matches_teaching_enum() -> None:
|
||
"""The local closed vocab mirrors teaching.epistemic.EpistemicStatus
|
||
values (generate/ must not import teaching/; this test owns the sync)."""
|
||
from teaching.epistemic import EpistemicStatus
|
||
|
||
assert PREMISE_STATUS_VOCAB == frozenset(s.value for s in EpistemicStatus)
|