"""ADR-0201 — standalone tests for the propositional canonicalizer keystone. Exercised in isolation, with no binding-graph wiring and no inference rules — the same way :mod:`generate.math_symbolic_equivalence` is tested standalone. The point is to prove the keystone holds ALONE before anything depends on it: equivalent formulas collapse to one canonical key, non-equivalent ones don't, the form is byte-deterministic, and out-of-regime / oversized inputs refuse rather than guess. """ from __future__ import annotations import pytest from generate.logic_canonical import ( DEFAULT_MAX_NODES, OUT_OF_DECIDABLE_REGIME, LogicBudgetError, LogicError, LogicRegimeError, canonicalize, ) from generate.logic_equivalence import Verdict, check_equivalence def _key(formula: str) -> str: return canonicalize(formula).canonical_key # --------------------------------------------------------------------------- # Canonicity: logically-equivalent formulas produce IDENTICAL keys. # Each pair would FAIL if the diagram were not reduced/canonical. # --------------------------------------------------------------------------- EQUIVALENT_PAIRS = [ ("P & Q", "Q & P"), # ∧ commutativity ("P | Q", "Q | P"), # ∨ commutativity ("~~P", "P"), # double negation ("P -> Q", "~P | Q"), # implication rewrite ("~(P & Q)", "~P | ~Q"), # De Morgan ("~(P | Q)", "~P & ~Q"), # De Morgan ("P <-> Q", "(P -> Q) & (Q -> P)"), # iff definition ("P & (Q | R)", "(P & Q) | (P & R)"), # distributivity ("P & P", "P"), # idempotence ("P", "P & (Q | ~Q)"), # irrelevant variable reduces out ("P | (P & Q)", "P"), # absorption ] @pytest.mark.parametrize("a,b", EQUIVALENT_PAIRS) def test_equivalent_formulas_share_canonical_key(a: str, b: str) -> None: assert _key(a) == _key(b) assert check_equivalence(a, b).verdict is Verdict.EQUIVALENT # --------------------------------------------------------------------------- # Discrimination: non-equivalent formulas produce DISTINCT keys. # These guard against a degenerate canonicalizer that collapses everything. # --------------------------------------------------------------------------- NON_EQUIVALENT_PAIRS = [ ("P & Q", "P | Q"), ("P", "Q"), # distinct atoms must not collide ("P -> Q", "Q -> P"), # implication is not symmetric ("P", "~P"), ("P & Q", "P"), ] @pytest.mark.parametrize("a,b", NON_EQUIVALENT_PAIRS) def test_non_equivalent_formulas_have_distinct_keys(a: str, b: str) -> None: assert _key(a) != _key(b) assert check_equivalence(a, b).verdict is Verdict.NOT_EQUIVALENT # --------------------------------------------------------------------------- # Terminals: tautologies and contradictions collapse to fixed keys. # --------------------------------------------------------------------------- def test_tautologies_collapse_to_true_terminal() -> None: for taut in ("P | ~P", "true", "P -> P", "(P -> Q) | (Q -> P)"): c = canonicalize(taut) assert c.is_tautology, taut assert c.canonical_key == "T" assert c.atoms == () # no variable survives a constant def test_contradictions_collapse_to_false_terminal() -> None: for contra in ("P & ~P", "false", "P <-> ~P"): c = canonicalize(contra) assert c.is_contradiction, contra assert c.canonical_key == "F" assert c.atoms == () def test_distinct_tautologies_are_the_same_truth_value() -> None: # All tautologies are the constant-true function regardless of atoms. assert _key("P | ~P") == _key("Q | ~Q") == _key("true") # --------------------------------------------------------------------------- # Surviving atoms: irrelevant variables are dropped from the support. # --------------------------------------------------------------------------- def test_irrelevant_variable_is_dropped_from_support() -> None: c = canonicalize("P & (Q | ~Q)") assert c.atoms == ("P",) # Q is logically irrelevant assert c.canonical_key == canonicalize("P").canonical_key def test_substring_atoms_do_not_alias() -> None: # Regression guard: atom 'a' must not be confused with atom 'ba'. assert canonicalize("a & ba").atoms == ("a", "ba") assert _key("a") != _key("ba") # --------------------------------------------------------------------------- # Determinism: same formula -> byte-identical key (the trace-hash discipline). # --------------------------------------------------------------------------- def test_canonical_key_is_byte_deterministic() -> None: formula = "(P -> Q) & (R | ~S)" assert canonicalize(formula).canonical_key == canonicalize(formula).canonical_key def test_operator_spellings_are_equivalent() -> None: assert _key("P and Q") == _key("P & Q") == _key("P ∧ Q") == _key("P && Q") assert _key("P or Q") == _key("P | Q") == _key("P ∨ Q") assert _key("not P") == _key("~P") == _key("¬P") == _key("!P") assert _key("P implies Q") == _key("P -> Q") == _key("P → Q") assert _key("P iff Q") == _key("P <-> Q") == _key("P ↔ Q") # --------------------------------------------------------------------------- # Refusal: out-of-grammar input and budget blowup REFUSE (wrong=0 discipline). # --------------------------------------------------------------------------- @pytest.mark.parametrize("bad", ["", "P &", "P Q", "(P", "P)", "P @ Q", "& P"]) def test_malformed_formula_refuses(bad: str) -> None: with pytest.raises(LogicError): canonicalize(bad) v = check_equivalence(bad, "P") assert v.verdict is Verdict.REFUSED assert v.canonical_a is None and v.canonical_b is None def test_budget_exceeded_refuses_rather_than_churns() -> None: # A wide XOR-chain is the classic ROBDD blowup case; a tiny budget must # trigger a typed refusal, not an unbounded build. formula = " <-> ".join(f"v{i}" for i in range(40)) with pytest.raises(LogicBudgetError): canonicalize(formula, max_nodes=8) v = check_equivalence(formula, "true", max_nodes=8) assert v.verdict is Verdict.REFUSED assert "budget" in v.reason.lower() def test_budget_error_is_a_logic_error_subclass() -> None: # Callers that refuse on LogicError must also refuse on budget-exceeded. assert issubclass(LogicBudgetError, LogicError) def test_bounded_formula_stays_within_default_budget() -> None: # A realistic proof-step proposition canonicalizes well within budget. c = canonicalize("(P -> Q) & (Q -> R) & P", max_nodes=DEFAULT_MAX_NODES) assert c.canonical_key # non-empty, did not refuse # --------------------------------------------------------------------------- # Out-of-regime: quantified / predicate input REFUSES with the typed reason # (ADR-0201.1). The boundary is enforced by DESIGN, before the generic grammar # error — not by the tokenizer incidentally choking on '.' or a predicate '('. # --------------------------------------------------------------------------- OUT_OF_REGIME_INPUTS = [ "forall x. rains(x) -> wet(x)", # universal (the PC-OOR-001 corpus case) "exists x. wet(x)", # existential (PC-OOR-002) "∀ x rains", # quantifier symbol "∃ y wet", # quantifier symbol "rains(x)", # predicate application, no quantifier word "P & wet(y)", # predicate application mid-formula "FORALL z holds", # case-insensitive keyword ] @pytest.mark.parametrize("text", OUT_OF_REGIME_INPUTS) def test_out_of_regime_refuses_with_typed_reason(text: str) -> None: with pytest.raises(LogicRegimeError) as exc: canonicalize(text) assert OUT_OF_DECIDABLE_REGIME in str(exc.value) # And the equivalence path surfaces the typed reason, not a generic one. v = check_equivalence(text, "P") assert v.verdict is Verdict.REFUSED assert OUT_OF_DECIDABLE_REGIME in v.reason def test_regime_error_is_a_logic_error_subclass() -> None: # Callers refusing on LogicError still refuse on out-of-regime. assert issubclass(LogicRegimeError, LogicError) def test_genuine_grammar_errors_are_not_misreported_as_out_of_regime() -> None: # The detector is principled: malformed *propositional* input is a plain # LogicError (grammar), NOT a regime refusal. Guards against over-firing. for bad in ["P &", "P @ Q", "P -> -> Q", "(P", "P)"]: with pytest.raises(LogicError) as exc: canonicalize(bad) assert not isinstance(exc.value, LogicRegimeError), bad assert OUT_OF_DECIDABLE_REGIME not in str(exc.value), bad def test_keyword_operators_before_paren_are_not_predicate_application() -> None: # `not (P)` is valid: NOT is a keyword operator, not an ATOM, so the # ATOM-then-LPAREN predicate rule must not fire. assert canonicalize("not (P)").canonical_key == canonicalize("~P").canonical_key assert canonicalize("not(P)").canonical_key == canonicalize("~P").canonical_key