core/tests/test_proof_chain_rules.py
Shay cd4a925bdf refactor(proof_chain): read the #529 MP oracle fixture, not a re-transcription
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.
2026-06-03 09:37:01 -07:00

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