core/generate/logic_canonical.py
Shay 64f3da18a7 feat: modus_ponens + disagreement rule — proof_chain wrong=0 mechanism (ADR-0205)
Phase 2.3: the first inference rule + the wrong=0 mechanism for proofs.

- generate/proof_chain/rules.py: evaluate_modus_ponens / evaluate_proof_conclusion.
  Proof-layer dispatch (Option B) over proposition FORMULAS via the canonicalizer;
  never touches check_admissibility/_resolve_dep_units (proofs have no units).
  Disagreement rule = the select_self_verified twin: pool ALL admissible single-step
  MP derivations, require a unique canonical key == declared conclusion. Pooling (not
  filter-to-declared-first) is the soundness mechanism.
- generate/logic_canonical.py: parse_top_implication (+ _unparse) — recovers an
  implication's syntactic antecedent/consequent (the ROBDD form doesn't preserve it).
- Closed typed-reason set; the corpus's finer labels consolidate (6 disagreement
  refuse-labels -> conclusion_disagreement; 4 antecedent-flavor labels ->
  unestablished_antecedent — same redundancy, same mechanism-makes-one-distinction
  principle).
- Honesty boundary (exact scope): guarantees a unique conclusion among SINGLE-STEP MP
  over the premises, NOT "uniquely entailed" by all strategies.

Cross-check: all 24 GPT-5.5 adversarial corpus cases agree on OUTCOME against the real
rule (no rule bug / no corpus outcome-misread); reasons consolidate as above.
Mutation: filter-to-declared-first makes DISAGREE-007/010 wrongly admit -> pooling
tests fail (pooling load-bearing).

Drive-by fix (cleanup-as-you-find): merged ADR-0204 ProofNode.__post_init__ was
dedented to module level -> all ProofNode validation was silently DEAD (smoke skips
the dedicated test file; the smoke != full-suite hazard). Re-indented; validation
restored.

Additive (math lane untouched). Full binding-graph surface green; smoke 67.
2026-06-02 20:56:57 -07:00

590 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ADR-0201 — Propositional canonicalizer (the ``proof_chain`` keystone).
Boolean-logic twin of :mod:`generate.math_symbolic_normalizer` /
:mod:`generate.math_symbolic_equivalence`. Where the algebra side normalizes an
expression to a canonical *polynomial string* and compares by byte-equality, this
module canonicalizes a propositional formula to a **Reduced Ordered Binary Decision
Diagram (ROBDD)** under a fixed (sorted) variable ordering and emits a canonical
*string* serialization of the reduced diagram.
Why ROBDD, not CNF/DNF: for a fixed variable ordering the ROBDD is canonical —
two formulas are logically equivalent **iff** their reduced diagrams are
isomorphic. CNF/DNF are merely normal (standardized shape), not canonical, and
have no poly-time equivalence-preserving transform. The reduced diagram collapses
logically-irrelevant variables, so ``P`` and ``P ∧ (Q ¬Q)`` produce the same key.
``wrong == 0`` discipline (mirrors the sibling): the canonicalizer **refuses**
rather than guesses. Out-of-grammar input raises :class:`LogicError`; a diagram
that would exceed the node budget raises :class:`LogicBudgetError` (a subclass, so
callers catching :class:`LogicError` refuse on both) rather than churning. There is
no approximate path — an answer is either the exact canonical form or a refusal.
Honesty boundary: this is **propositional** logic only (finite Boolean variables —
decidable, canonical). It does NOT canonicalize quantifiers/predicate logic and
must not be used to claim ``wrong == 0`` for first-order reasoning.
Hand-rolled (no external BDD library) to stay in CORE's idiom: deterministic by
construction, fully inspectable, zero opaque dependencies — the same posture as the
hand-rolled symbolic normalizer.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Final
# ---------------------------------------------------------------------------
# Errors (twin of math_symbolic_normalizer.SymbolicError)
# ---------------------------------------------------------------------------
class LogicError(ValueError):
"""Raised when a formula cannot be canonicalized. Refusal-first; never
coerces a malformed or out-of-regime input into a guess."""
class LogicBudgetError(LogicError):
"""Raised when the ROBDD would exceed the node budget (the exponential-blowup
guard). A subclass of :class:`LogicError` so callers that refuse on
``LogicError`` refuse on budget-exceeded too — the proof-domain analog of the
math gate refusing rather than churning."""
class LogicRegimeError(LogicError):
"""Raised when the input is outside the decidable **propositional** regime —
quantified or predicate logic (ADR-0201.1; the typed refusal ADR-0202 §3
names). A subclass of :class:`LogicError` so callers that refuse on
``LogicError`` refuse here too, but it carries the typed
:data:`OUT_OF_DECIDABLE_REGIME` reason so the regime boundary is
distinguishable from a generic malformed-formula grammar error.
Crucially, the boundary is enforced **by design** (see
:func:`_reject_out_of_regime_text` / :func:`_reject_out_of_regime_tokens`),
not by the tokenizer incidentally choking on an out-of-grammar character —
the latter is the by-luck-not-by-design refusal the ``wrong == 0``
discipline rejects."""
# ---------------------------------------------------------------------------
# Public defaults
# ---------------------------------------------------------------------------
DEFAULT_MAX_NODES: Final[int] = 100_000
"""Default cap on reduced-diagram nodes. Bounded proof-step propositions relate a
handful of atoms; this is generous for that regime and refuses on adversarial
blowup rather than hanging."""
# Terminal node ids. 0 = constant false, 1 = constant true. Non-terminal ids >= 2.
_FALSE: Final[int] = 0
_TRUE: Final[int] = 1
# ---------------------------------------------------------------------------
# Tokenizer
# ---------------------------------------------------------------------------
# Multi-character / unicode operator spellings, longest first so the scanner is
# unambiguous. Each maps to a canonical token kind.
_OPERATOR_SPELLINGS: Final[tuple[tuple[str, str], ...]] = (
("<->", "IFF"),
("", "IFF"),
("", "IFF"),
("->", "IMPLIES"),
("", "IMPLIES"),
("", "IMPLIES"),
("", "AND"),
("&&", "AND"),
("&", "AND"),
("", "OR"),
("||", "OR"),
("|", "OR"),
("¬", "NOT"),
("~", "NOT"),
("!", "NOT"),
("(", "LPAREN"),
(")", "RPAREN"),
)
# Keyword operators / constants (matched on word boundaries, case-insensitive).
_KEYWORDS: Final[dict[str, str]] = {
"and": "AND",
"or": "OR",
"not": "NOT",
"implies": "IMPLIES",
"iff": "IFF",
"true": "TRUE",
"false": "FALSE",
}
def _is_ident_start(ch: str) -> bool:
return ch.isalpha() or ch == "_"
def _is_ident_char(ch: str) -> bool:
return ch.isalnum() or ch == "_"
def _tokenize(text: str) -> list[tuple[str, str]]:
"""Scan ``text`` into ``(kind, lexeme)`` tokens. Raises :class:`LogicError`
on any character that is not part of the propositional grammar."""
tokens: list[tuple[str, str]] = []
pos = 0
n = len(text)
while pos < n:
ch = text[pos]
if ch.isspace():
pos += 1
continue
# Symbolic operators (longest spelling first).
matched = False
for spelling, kind in _OPERATOR_SPELLINGS:
if text.startswith(spelling, pos):
tokens.append((kind, spelling))
pos += len(spelling)
matched = True
break
if matched:
continue
# Identifiers / keywords.
if _is_ident_start(ch):
start = pos
pos += 1
while pos < n and _is_ident_char(text[pos]):
pos += 1
word = text[start:pos]
kind = _KEYWORDS.get(word.lower())
if kind is not None:
tokens.append((kind, word))
else:
tokens.append(("ATOM", word))
continue
raise LogicError(f"unexpected character {ch!r} at position {pos}")
return tokens
# ---------------------------------------------------------------------------
# Parser (recursive descent — twin of math_symbolic_normalizer._Parser)
#
# Precedence, lowest to highest: IFF < IMPLIES < OR < AND < NOT < atom/paren.
# IMPLIES is right-associative; the rest left-associative (associativity is
# semantically irrelevant under ROBDD but a fixed parse keeps errors crisp).
#
# The AST is a nested tuple, e.g. ('and', ('atom','P'), ('not',('atom','Q'))).
# ---------------------------------------------------------------------------
_Ast = tuple
class _Parser:
def __init__(self, tokens: list[tuple[str, str]]) -> None:
self._tokens = tokens
self._pos = 0
def _peek(self) -> tuple[str, str] | None:
return None if self._pos >= len(self._tokens) else self._tokens[self._pos]
def _consume(self) -> tuple[str, str]:
if self._pos >= len(self._tokens):
raise LogicError("unexpected end of formula")
tok = self._tokens[self._pos]
self._pos += 1
return tok
def parse(self) -> _Ast:
if not self._tokens:
raise LogicError("empty formula")
ast = self._iff()
if self._pos != len(self._tokens):
raise LogicError(f"unexpected trailing token {self._tokens[self._pos]!r}")
return ast
def _iff(self) -> _Ast:
node = self._implies()
while (tok := self._peek()) is not None and tok[0] == "IFF":
self._consume()
node = ("iff", node, self._implies())
return node
def _implies(self) -> _Ast:
node = self._or()
if (tok := self._peek()) is not None and tok[0] == "IMPLIES":
self._consume()
# right-associative: recurse into _implies for the RHS
node = ("implies", node, self._implies())
return node
def _or(self) -> _Ast:
node = self._and()
while (tok := self._peek()) is not None and tok[0] == "OR":
self._consume()
node = ("or", node, self._and())
return node
def _and(self) -> _Ast:
node = self._not()
while (tok := self._peek()) is not None and tok[0] == "AND":
self._consume()
node = ("and", node, self._not())
return node
def _not(self) -> _Ast:
tok = self._peek()
if tok is not None and tok[0] == "NOT":
self._consume()
return ("not", self._not())
return self._atom()
def _atom(self) -> _Ast:
tok = self._consume()
kind, lexeme = tok
if kind == "ATOM":
return ("atom", lexeme)
if kind == "TRUE":
return ("const", True)
if kind == "FALSE":
return ("const", False)
if kind == "LPAREN":
inner = self._iff()
close = self._consume()
if close[0] != "RPAREN":
raise LogicError(f"expected ')'; got {close[1]!r}")
return inner
raise LogicError(f"unexpected token {lexeme!r}")
def _collect_atoms(ast: _Ast) -> set[str]:
kind = ast[0]
if kind == "atom":
return {ast[1]}
if kind == "const":
return set()
if kind == "not":
return _collect_atoms(ast[1])
# binary
return _collect_atoms(ast[1]) | _collect_atoms(ast[2])
_BINARY_OPS: Final[dict[str, str]] = {"and": "&", "or": "|", "implies": "->", "iff": "<->"}
def _unparse(ast: _Ast) -> str:
"""Render an AST back to a fully-parenthesized formula string. Used to hand a
sub-formula (e.g. an implication's antecedent/consequent) back to
:func:`canonicalize`. Parenthesized everywhere so re-parsing is unambiguous."""
kind = ast[0]
if kind == "atom":
return ast[1]
if kind == "const":
return "true" if ast[1] else "false"
if kind == "not":
return f"(~{_unparse(ast[1])})"
return f"({_unparse(ast[1])} {_BINARY_OPS[kind]} {_unparse(ast[2])})"
def parse_top_implication(formula: str) -> tuple[str, str] | None:
"""If ``formula``'s top-level connective is ``->``, return
``(antecedent, consequent)`` as formula strings; else ``None``.
Modus ponens needs the syntactic antecedent/consequent of an implication
premise — the ROBDD form does not preserve which side is which (``P->Q`` and
``~P|Q`` share one diagram), so this works at the parse layer. Raises
:class:`LogicError` / :class:`LogicRegimeError` on malformed / out-of-regime
input, consistent with :func:`canonicalize`."""
_reject_out_of_regime_text(formula)
tokens = _tokenize(formula)
_reject_out_of_regime_tokens(tokens)
ast = _Parser(tokens).parse()
if ast[0] == "implies":
return _unparse(ast[1]), _unparse(ast[2])
return None
# ---------------------------------------------------------------------------
# ROBDD manager (hand-rolled, minimal: mk + apply + negate + unique table)
# ---------------------------------------------------------------------------
class _Bdd:
"""A single-formula ROBDD builder. Variables are addressed by index into a
fixed (sorted) ordering; ``var_count`` is the terminal sentinel level."""
__slots__ = ("var_count", "max_nodes", "_nodes", "_unique", "_and_c", "_or_c", "_neg_c")
def __init__(self, var_count: int, max_nodes: int) -> None:
self.var_count = var_count
self.max_nodes = max_nodes
# node id -> (var_index, low_id, high_id); ids 0/1 are terminals (absent here).
self._nodes: list[tuple[int, int, int]] = []
self._unique: dict[tuple[int, int, int], int] = {}
self._and_c: dict[tuple[int, int], int] = {}
self._or_c: dict[tuple[int, int], int] = {}
self._neg_c: dict[int, int] = {}
def _var(self, node: int) -> int:
# Terminals sit "below" every variable: use var_count as +inf sentinel.
if node <= _TRUE:
return self.var_count
return self._nodes[node - 2][0]
def _low(self, node: int) -> int:
return self._nodes[node - 2][1]
def _high(self, node: int) -> int:
return self._nodes[node - 2][2]
def mk(self, var: int, low: int, high: int) -> int:
"""Make-or-reuse a node, applying the two reduction rules. This is the
only node-creation site, so the budget is enforced here."""
if low == high:
return low # redundant-node rule
key = (var, low, high)
existing = self._unique.get(key)
if existing is not None:
return existing # shared-subgraph rule (hash-cons)
if len(self._nodes) >= self.max_nodes:
raise LogicBudgetError(
f"ROBDD exceeded node budget ({self.max_nodes}); refusing rather "
f"than churn"
)
node_id = len(self._nodes) + 2
self._nodes.append(key)
self._unique[key] = node_id
return node_id
def var_node(self, var: int) -> int:
"""The diagram for a bare variable: if var then true else false."""
return self.mk(var, _FALSE, _TRUE)
def negate(self, f: int) -> int:
if f == _FALSE:
return _TRUE
if f == _TRUE:
return _FALSE
cached = self._neg_c.get(f)
if cached is not None:
return cached
result = self.mk(self._var(f), self.negate(self._low(f)), self.negate(self._high(f)))
self._neg_c[f] = result
return result
def conj(self, f: int, g: int) -> int:
if f == _FALSE or g == _FALSE:
return _FALSE
if f == _TRUE:
return g
if g == _TRUE:
return f
if f == g:
return f
key = (f, g) if f <= g else (g, f) # commutative -> canonical cache key
cached = self._and_c.get(key)
if cached is not None:
return cached
result = self._apply(self.conj, f, g)
self._and_c[key] = result
return result
def disj(self, f: int, g: int) -> int:
if f == _TRUE or g == _TRUE:
return _TRUE
if f == _FALSE:
return g
if g == _FALSE:
return f
if f == g:
return f
key = (f, g) if f <= g else (g, f)
cached = self._or_c.get(key)
if cached is not None:
return cached
result = self._apply(self.disj, f, g)
self._or_c[key] = result
return result
def _apply(self, op, f: int, g: int) -> int:
"""Shannon expansion on the top variable of ``f``/``g`` (Bryant apply)."""
v = min(self._var(f), self._var(g))
f0, f1 = self._cofactor(f, v)
g0, g1 = self._cofactor(g, v)
return self.mk(v, op(f0, g0), op(f1, g1))
def _cofactor(self, f: int, v: int) -> tuple[int, int]:
if self._var(f) == v:
return self._low(f), self._high(f)
return f, f # v does not occur at the top of f
def compile(self, ast: _Ast, index_of: dict[str, int]) -> int:
kind = ast[0]
if kind == "atom":
return self.var_node(index_of[ast[1]])
if kind == "const":
return _TRUE if ast[1] else _FALSE
if kind == "not":
return self.negate(self.compile(ast[1], index_of))
left = self.compile(ast[1], index_of)
right = self.compile(ast[2], index_of)
if kind == "and":
return self.conj(left, right)
if kind == "or":
return self.disj(left, right)
if kind == "implies":
return self.disj(self.negate(left), right)
if kind == "iff":
# (a -> b) ∧ (b -> a)
return self.conj(self.disj(self.negate(left), right),
self.disj(self.negate(right), left))
raise LogicError(f"unknown AST node {kind!r}") # pragma: no cover
def serialize(self, root: int, names: tuple[str, ...]) -> str:
"""Canonical, construction-order-independent serialization of the reduced
diagram reachable from ``root``. Post-order DFS (low subtree fully before
high subtree) assigns canonical indices; nodes reference variable *names*
so diagrams over different atoms never collide, while terminals collapse
(every tautology -> 'T', every contradiction -> 'F'). Because the diagram
is reduced and the ordering fixed, isomorphic diagrams emit identical
strings."""
if root == _TRUE:
return "T"
if root == _FALSE:
return "F"
order: dict[int, int] = {}
lines: list[str] = []
def ref(node: int) -> str:
if node == _TRUE:
return "T"
if node == _FALSE:
return "F"
return f"@{order[node]}"
def visit(node: int) -> None:
if node in order or node <= _TRUE:
return
visit(self._low(node))
visit(self._high(node))
idx = len(order)
order[node] = idx
lines.append(
f"{idx}:{names[self._var(node)]}?{ref(self._high(node))}:{ref(self._low(node))}"
)
visit(root)
return ";".join(lines)
def support(self, root: int) -> set[int]:
"""The set of variable indices that occur in the reduced diagram —
i.e. the atoms that survive reduction (irrelevant ones are absent)."""
seen: set[int] = set()
out: set[int] = set()
def visit(node: int) -> None:
if node <= _TRUE or node in seen:
return
seen.add(node)
out.add(self._var(node))
visit(self._low(node))
visit(self._high(node))
visit(root)
return out
# ---------------------------------------------------------------------------
# Public API (twin of math_symbolic_equivalence)
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class CanonicalProposition:
"""The canonical form of a propositional formula.
``canonical_key`` is the byte-equality discriminator — two formulas are
logically equivalent iff their keys are equal. ``atoms`` are the variables
that *survive* reduction (logically-irrelevant ones are dropped), so it can be
a strict subset of the atoms written in the input."""
canonical_key: str
atoms: tuple[str, ...]
is_tautology: bool
is_contradiction: bool
# ---------------------------------------------------------------------------
# Out-of-regime detection (ADR-0201.1)
#
# Propositional logic is the only regime with a canonical form + decidable
# equivalence. Quantified / predicate input must REFUSE with the typed
# `out_of_decidable_regime` reason (ADR-0202 §3) — by DESIGN, recognized as
# out-of-regime, not by accident of the tokenizer choking on an out-of-grammar
# character. These checks run BEFORE the generic grammar error so the regime
# boundary is principled, typed, and inspectable.
# ---------------------------------------------------------------------------
OUT_OF_DECIDABLE_REGIME: Final[str] = "out_of_decidable_regime"
# Quantifier markers: ASCII keywords (word-boundary, case-insensitive) and the
# logic symbols ∀ / ∃. Their presence means first-order/predicate reasoning,
# which has no ROBDD canonical form and is undecidable in general. `forall` and
# `exists` are therefore reserved — not usable as atom ids.
_QUANTIFIER_WORD_RE: Final[re.Pattern[str]] = re.compile(r"\b(forall|exists)\b", re.IGNORECASE)
_QUANTIFIER_SYMBOLS: Final[frozenset[str]] = frozenset({"", ""}) # ∀ ∃
def _reject_out_of_regime_text(formula: str) -> None:
"""Refuse raw input that carries a quantifier marker. Runs before tokenizing
so quantifier symbols (∀/∃) and the ``forall x. …`` / ``exists x. …`` shape
refuse with the typed regime reason rather than a generic 'unexpected
character' grammar error from the trailing ``.``/predicate syntax."""
for sym in sorted(_QUANTIFIER_SYMBOLS):
if sym in formula:
raise LogicRegimeError(f"{OUT_OF_DECIDABLE_REGIME}: quantifier symbol {sym!r}")
match = _QUANTIFIER_WORD_RE.search(formula)
if match is not None:
raise LogicRegimeError(f"{OUT_OF_DECIDABLE_REGIME}: quantifier {match.group(0)!r}")
def _reject_out_of_regime_tokens(tokens: list[tuple[str, str]]) -> None:
"""Refuse predicate-application shape — an atom immediately applied to an
argument list, e.g. ``rains(x)``. In the propositional grammar an atom is
never followed by ``(`` (grouping only follows an operator or opens an
expression), so ``ATOM (`` is a predicate, not a well-formed propositional
formula. Runs before the parser's generic trailing-token error so the regime
boundary is the reason that surfaces. (Keyword operators such as ``not`` are
NOT ``ATOM`` tokens, so ``not (P)`` is unaffected.)"""
for (kind, lexeme), (next_kind, _next_lexeme) in zip(tokens, tokens[1:]):
if kind == "ATOM" and next_kind == "LPAREN":
raise LogicRegimeError(
f"{OUT_OF_DECIDABLE_REGIME}: predicate application {lexeme!r}(…)"
)
def canonicalize(formula: str, *, max_nodes: int = DEFAULT_MAX_NODES) -> CanonicalProposition:
"""Canonicalize ``formula`` to its ROBDD identity under the sorted-atom
ordering. Refusal-first:
* :class:`LogicRegimeError` (``out_of_decidable_regime``) if the input is
quantified / predicate logic — checked *before* grammar, so the regime
boundary is principled, not an incidental tokenizer failure;
* :class:`LogicError` on out-of-grammar (malformed propositional) input;
* :class:`LogicBudgetError` if the diagram exceeds ``max_nodes``.
"""
_reject_out_of_regime_text(formula)
tokens = _tokenize(formula)
_reject_out_of_regime_tokens(tokens)
ast = _Parser(tokens).parse()
declared = tuple(sorted(_collect_atoms(ast))) # fixed variable ordering
index_of = {name: i for i, name in enumerate(declared)}
bdd = _Bdd(var_count=len(declared), max_nodes=max_nodes)
root = bdd.compile(ast, index_of)
key = bdd.serialize(root, declared)
# Atoms that actually occur in the reduced diagram (irrelevant ones dropped).
support_idx = bdd.support(root)
surviving = tuple(name for i, name in enumerate(declared) if i in support_idx)
return CanonicalProposition(
canonical_key=key,
atoms=surviving,
is_tautology=(root == _TRUE),
is_contradiction=(root == _FALSE),
)