core/generate/logic_equivalence.py
Shay 69a7d8bae8 feat: principled out-of-regime detector — typed out_of_decidable_regime (ADR-0201.1)
GPT-5.5's independent corpus caught that the canonicalizer refused quantified/
predicate input only by accident (tokenizer chokes on '.'), not by design — a
by-luck-not-by-design refusal the wrong=0 discipline rejects. ADR-0202 §3 names a
typed `out_of_decidable_regime` refusal; the keystone emitted a generic grammar error.

- logic_canonical.py: LogicRegimeError(LogicError) + OUT_OF_DECIDABLE_REGIME;
  _reject_out_of_regime_text (quantifier words forall/exists + symbols ∀/∃, pre-scan)
  and _reject_out_of_regime_tokens (predicate-application ATOM-then-LPAREN), run BEFORE
  the generic grammar error. Refusal only — no predicate/FOL capability added.
- logic_equivalence.py: typed regime branch (before the generic LogicError branch).
- tests: 43 total (10 new) — OOR refuses with typed reason; equivalence path too;
  genuine grammar errors stay plain LogicError (no over-fire); `not (P)` not mistaken
  for predicate application. Mutation-verified by-design (neuter -> falls through to
  generic grammar error).
- ADR-0201.1: additive sub-ADR of 0201 (not an amendment; sub-number preserves the
  landed ADR-0203 forward refs + phase-2 plan numbering). Honesty boundary load-bearing.

Corpus now 22/22 (PC-OOR-001/002 agree on the principled reason). Full canonicalizer
suite green; smoke 67 passed. modus_ponens rule-reasons remain deferred to ADR-0205 (2.3).
2026-06-02 19:12:41 -07:00

99 lines
3 KiB
Python

"""ADR-0201 — Propositional equivalence check.
Boolean-logic twin of :mod:`generate.math_symbolic_equivalence`. Given two
propositional formulas A and B, produces an :class:`EquivalenceVerdict` of
EQUIVALENT, NOT_EQUIVALENT, or REFUSED, by canonicalizing each to its ROBDD
identity (:mod:`generate.logic_canonical`) and comparing the canonical keys by
byte-equality.
REFUSED preserves ``wrong == 0``: out-of-grammar input or a diagram that exceeds
the node budget refuses rather than emitting a verdict — the same posture as the
algebra sibling refusing on out-of-scope expressions.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Final
from generate.logic_canonical import (
DEFAULT_MAX_NODES,
OUT_OF_DECIDABLE_REGIME,
LogicBudgetError,
LogicError,
LogicRegimeError,
canonicalize,
)
class Verdict(str, Enum):
EQUIVALENT = "equivalent"
NOT_EQUIVALENT = "not_equivalent"
REFUSED = "refused"
@dataclass(frozen=True, slots=True)
class EquivalenceVerdict:
verdict: Verdict
canonical_a: str | None
canonical_b: str | None
reason: str
REFUSED_VERDICTS: Final[frozenset[Verdict]] = frozenset({Verdict.REFUSED})
"""Helper set for callers that need to gate on refusal vs decision."""
def check_equivalence(
formula_a: str,
formula_b: str,
*,
max_nodes: int = DEFAULT_MAX_NODES,
) -> EquivalenceVerdict:
"""Return whether two propositional formulas are logically equivalent.
Equivalence is decided by ROBDD canonical-key byte-equality, which is exact
for propositional logic. Refuses (rather than guesses) on malformed input or
on diagram blowup beyond ``max_nodes``.
"""
try:
canon_a = canonicalize(formula_a, max_nodes=max_nodes).canonical_key
canon_b = canonicalize(formula_b, max_nodes=max_nodes).canonical_key
except LogicBudgetError as exc:
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=None,
canonical_b=None,
reason=f"canonicalization_budget_exceeded: {exc}",
)
except LogicRegimeError as exc:
# Out of the decidable propositional regime (quantified/predicate).
# Caught before the generic LogicError branch since it is a subclass.
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=None,
canonical_b=None,
reason=f"{OUT_OF_DECIDABLE_REGIME}: {exc}",
)
except LogicError as exc:
return EquivalenceVerdict(
verdict=Verdict.REFUSED,
canonical_a=None,
canonical_b=None,
reason=f"canonicalize refused: {exc}",
)
if canon_a == canon_b:
return EquivalenceVerdict(
verdict=Verdict.EQUIVALENT,
canonical_a=canon_a,
canonical_b=canon_b,
reason="",
)
return EquivalenceVerdict(
verdict=Verdict.NOT_EQUIVALENT,
canonical_a=canon_a,
canonical_b=canon_b,
reason="",
)