test_proof_chain_rules.py carried a 24-case inline copy of the same modus_ponens contract oracle #529 committed as tests/fixtures/proof_chain/modus_ponens_cases.json. Load the fixture instead — it is now the single source of truth. Its expected.reason is already the ADR-0205 closed set (closed_reasons == MP_REASONS), so this is a pure de-dup: the loaded cases were verified byte-for-byte against the prior inline table (24/24, 0 diffs). The pooling tests (DISAGREE-007/010/003) now pull premises from the oracle by id too, so no case is re-transcribed anywhere. Added a contract guard (24 cases; reasons subset of MP_REASONS; closed_reasons == MP_REASONS); dropped the now-unused closed-reason imports.
164 lines
6.1 KiB
Python
164 lines
6.1 KiB
Python
"""ADR-0205 — modus_ponens + disagreement rule (phase 2.3).
|
|
|
|
The 24 adversarial cases are loaded from the committed contract oracle
|
|
(``tests/fixtures/proof_chain/modus_ponens_cases.json``, added in #529) —
|
|
GPT-5.5's independent oracle, authored against the ADR-0202/0205 contract and
|
|
cross-checked 24/24 against the real rule. This module is the reproducible
|
|
cross-check: it runs ``evaluate_modus_ponens`` against the oracle's expected
|
|
``(outcome, reason)`` per case. There is no second transcription of the cases
|
|
here — the fixture is the single source of truth.
|
|
|
|
The disagreement cases are the wrong=0 guard — DISAGREE-007/010 in particular
|
|
pin the pooling semantics: pool ALL admissible MP derivations and require a
|
|
unique key. A filter-to-declared-conclusion-first rule would admit them
|
|
(admit-by-assertion); pool-first refuses. Those tests failing under filter-first
|
|
is the proof the soundness mechanism is load-bearing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from generate.proof_chain import (
|
|
CONCLUSION_DISAGREEMENT,
|
|
MP_REASONS,
|
|
Proof,
|
|
ProofNode,
|
|
UNIQUE_CANONICAL_CONCLUSION,
|
|
MPOutcome,
|
|
evaluate_modus_ponens,
|
|
evaluate_proof_conclusion,
|
|
)
|
|
|
|
_CORPUS_PATH = (
|
|
Path(__file__).resolve().parent
|
|
/ "fixtures"
|
|
/ "proof_chain"
|
|
/ "modus_ponens_cases.json"
|
|
)
|
|
|
|
|
|
def _load_corpus() -> tuple[dict, list[tuple]]:
|
|
"""Load the committed MP oracle into parametrize tuples.
|
|
|
|
Each tuple is ``(id, premises, conclusion, expected_outcome, expected_reason)``.
|
|
The corpus's ``expected.reason`` is already the ADR-0205 closed reason set
|
|
(see its ``closed_reasons``), so no label mapping is needed.
|
|
"""
|
|
data = json.loads(_CORPUS_PATH.read_text(encoding="utf-8"))
|
|
cases: list[tuple] = []
|
|
for case in data["cases"]:
|
|
sc = case["semantic_content"]
|
|
ex = case["expected"]
|
|
cases.append(
|
|
(
|
|
case["id"],
|
|
tuple(sc["premises"]),
|
|
sc["conclusion"],
|
|
ex["outcome"],
|
|
ex["reason"],
|
|
)
|
|
)
|
|
return data, cases
|
|
|
|
|
|
_CORPUS, CASES = _load_corpus()
|
|
|
|
|
|
def _inputs_for(case_id: str) -> tuple[tuple[str, ...], str]:
|
|
"""Return ``(premises, conclusion)`` for a corpus case id.
|
|
|
|
Lets the pooling tests below reference oracle cases by id instead of
|
|
re-transcribing their premises — the fixture stays the only source.
|
|
"""
|
|
for case in _CORPUS["cases"]:
|
|
if case["id"] == case_id:
|
|
sc = case["semantic_content"]
|
|
return tuple(sc["premises"]), sc["conclusion"]
|
|
raise KeyError(case_id)
|
|
|
|
|
|
def test_corpus_contract_is_the_committed_oracle() -> None:
|
|
"""Guard the fixture shape the rest of this file leans on (#529 oracle)."""
|
|
assert len(CASES) == 24, "oracle is the 24-case contract corpus"
|
|
# Every expected reason is in the ADR-0205 closed set, and the corpus's own
|
|
# declared closed set agrees with the rule's MP_REASONS.
|
|
assert {reason for *_, reason in CASES} <= set(MP_REASONS)
|
|
assert set(_CORPUS["closed_reasons"]) == set(MP_REASONS)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cid,premises,conclusion,outcome,reason",
|
|
CASES,
|
|
ids=[c[0] for c in CASES],
|
|
)
|
|
def test_corpus_case(cid, premises, conclusion, outcome, reason) -> None:
|
|
v = evaluate_modus_ponens(premises, conclusion)
|
|
assert v.outcome.value == outcome, cid
|
|
assert v.reason == reason, cid
|
|
assert v.reason in MP_REASONS
|
|
|
|
|
|
def test_admit_yields_the_canonical_conclusion_key() -> None:
|
|
from generate.logic_canonical import canonicalize
|
|
v = evaluate_modus_ponens(("P", "P -> Q"), "Q")
|
|
assert v.outcome is MPOutcome.ADMIT
|
|
assert v.conclusion_key == canonicalize("Q").canonical_key
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The pooling semantics — the wrong=0 mechanism. 007/010 MUST refuse: the premises
|
|
# admit deriving a second distinct key, so pool-first refuses. A filter-first rule
|
|
# (keep only derivations whose key == declared conclusion, then check uniqueness)
|
|
# would admit both — admit-by-assertion. These assertions fail under that mutation.
|
|
# Inputs come from the oracle (no re-transcription); the derived_keys counts are
|
|
# the extra structural property this corpus case doesn't itself pin.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pooling_refuses_unrelated_tautology_path() -> None:
|
|
# DISAGREE-007: one path yields T (R or not R), the substantive path yields Q.
|
|
premises, conclusion = _inputs_for("MP-DISAGREE-007")
|
|
v = evaluate_modus_ponens(premises, conclusion)
|
|
assert v.outcome is MPOutcome.REFUSE
|
|
assert v.reason == CONCLUSION_DISAGREEMENT
|
|
assert len(v.derived_keys) == 2 # T and key(Q) — distinct, pooled
|
|
|
|
|
|
def test_pooling_refuses_stronger_conclusion_path() -> None:
|
|
# DISAGREE-010: paths yield Q and (Q and R) — distinct keys.
|
|
premises, conclusion = _inputs_for("MP-DISAGREE-010")
|
|
v = evaluate_modus_ponens(premises, conclusion)
|
|
assert v.outcome is MPOutcome.REFUSE
|
|
assert v.reason == CONCLUSION_DISAGREEMENT
|
|
assert len(v.derived_keys) == 2
|
|
|
|
|
|
def test_equivalent_paths_collapse_not_disagree() -> None:
|
|
# DISAGREE-003: Q->R and (not Q or R) are equivalent → one key → admit.
|
|
premises, conclusion = _inputs_for("MP-DISAGREE-003")
|
|
v = evaluate_modus_ponens(premises, conclusion)
|
|
assert v.outcome is MPOutcome.ADMIT
|
|
assert len(v.derived_keys) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wiring to the ADR-0204 Proof.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_evaluate_proof_conclusion_via_builder_shape() -> None:
|
|
proof = Proof(
|
|
nodes=(
|
|
ProofNode("premise_0", "P", (), "premise"),
|
|
ProofNode("premise_1", "P -> Q", (), "premise"),
|
|
ProofNode("conclusion", "Q", ("premise_0", "premise_1"), "modus_ponens"),
|
|
),
|
|
conclusion_id="conclusion",
|
|
)
|
|
v = evaluate_proof_conclusion(proof)
|
|
assert v.outcome is MPOutcome.ADMIT
|
|
assert v.reason == UNIQUE_CANONICAL_CONCLUSION
|