core/evals/logic_cnf_compiler.py
Shay bc316f2128 feat(adr-0249): P3 structural formula→CNF converter (deduction leg)
Closes the deduction leg: propositional formula strings (as emitted by
meaning_graph.to_deductive_logic, or any logic_canonical-parseable syntax) →
corridor CNF PropositionalProblem + query Clause, consumable by
propositional_entails.

Structural, not truth-table (spike §4.1): reuses the production ROBDD parser
(generate.logic_canonical — never re-implemented) for the AST, then the standard
sound rewrite — eliminate iff/implies, NNF, distribute OR over AND — with
constant folding, tautological-clause elimination, and a clause budget. The
converter never enumerates assignments and never decides entailment; that stays
the corridor's job.

Soundness proved against the ROBDD oracle: for a 14-formula panel, the compiled
CNF rendered back to a formula has the same canonicalize() identity as the
source. End-to-end entailment through propositional_entails agrees with the
ROBDD gold (evaluate_entailment) wrong=0 on consistent premises; ex-falso
handled per the corridor's own contract (entailed + satisfiable_premises=False,
where the ROBDD path returns REFUSED for inconsistent premises).

Fail-closed: conjunctive/constant queries, formulas reducing to false, and CNF
budget refuse with typed CnfCompileError; >5 atoms surfaces the corridor's
HamiltonianCompileError(atom_count_out_of_range); out-of-regime propagates
LogicRegimeError. Off-serving (A-04), import-guard pinned.

32/32 pins green.

[Verification]: uv run python -m pytest tests/test_adr_0249_logic_cnf_compiler.py -q
2026-07-18 12:40:31 -07:00

221 lines
8.2 KiB
Python

"""evals.logic_cnf_compiler — structural formula → CNF PropositionalProblem (ADR-0249 P3).
Closes the deduction leg of the reader→Hamiltonian compiler: turns propositional
formula strings (as emitted by ``generate.meaning_graph.to_deductive_logic``, or
any ``logic_canonical``-parseable syntax) into the corridor's CNF
``PropositionalProblem`` plus a query ``Clause``, consumable by
``propositional_entails``.
Structural, not truth-table (spike §4.1): the formula is parsed with the
production ROBDD parser (``generate.logic_canonical`` — reused, never
re-implemented), then rewritten to CNF by the standard sound transform —
eliminate iff/implies, push negations to the literals (NNF), distribute OR over
AND — with constant folding, tautological-clause elimination, and a clause
budget. Equivalence to the source is provable against the ROBDD oracle
(``canonicalize``) and is pinned in tests. The converter never enumerates
assignments and never decides satisfiability or entailment — that is the
corridor's job; this only compiles the constraint shape.
Off-serving (A-04): imports the serving-side logic parser for its grammar only,
lives in the eval quarantine, and is never imported by ``chat/runtime.py``.
"""
from __future__ import annotations
from collections.abc import Sequence
from core.physics.cognitive_lifecycle import Clause, PropositionalProblem
from generate.logic_canonical import (
_Parser,
_reject_out_of_regime_text,
_reject_out_of_regime_tokens,
_tokenize,
)
__all__ = [
"CnfCompileError",
"formula_to_clauses",
"query_to_clause",
"compile_entailment",
"clauses_to_formula",
]
# CNF as a frozenset of clauses; a clause is a frozenset of ``(atom, polarity)``
# literals. Two sentinels: TRUE = no clauses (empty conjunction); FALSE = one
# empty clause (an unsatisfiable disjunction).
_TRUE: frozenset = frozenset()
_FALSE: frozenset = frozenset({frozenset()})
_CLAUSE_BUDGET = 512
class CnfCompileError(ValueError):
"""Typed, fail-closed refusal for CNF compilation (spike §4.3).
Parse/regime failures propagate as ``logic_canonical.LogicError`` /
``LogicRegimeError``; atom-count and structural violations surface as the
corridor's ``HamiltonianCompileError`` when the ``PropositionalProblem`` is
built. This type covers the CNF-specific refusals only.
"""
def __init__(self, reason: str, **disclosure: object) -> None:
self.reason = reason
self.disclosure = disclosure
detail = ", ".join(f"{k}={v!r}" for k, v in disclosure.items())
super().__init__(f"{reason}({detail})" if detail else reason)
def _parse(formula: str):
"""Mirror ``canonicalize``'s refusal-first front-matter, returning the AST."""
_reject_out_of_regime_text(formula)
tokens = _tokenize(formula)
_reject_out_of_regime_tokens(tokens)
return _Parser(tokens).parse()
def _eliminate(ast: tuple) -> tuple:
"""Remove ``iff`` and ``implies`` in favour of ``and``/``or``/``not``."""
kind = ast[0]
if kind == "iff":
a, b = _eliminate(ast[1]), _eliminate(ast[2])
return ("and", ("or", ("not", a), b), ("or", ("not", b), a))
if kind == "implies":
return ("or", ("not", _eliminate(ast[1])), _eliminate(ast[2]))
if kind in ("and", "or"):
return (kind, _eliminate(ast[1]), _eliminate(ast[2]))
if kind == "not":
return ("not", _eliminate(ast[1]))
return ast # atom / const
def _nnf(ast: tuple) -> tuple:
"""Push negations inward until they sit only on atoms (De Morgan)."""
kind = ast[0]
if kind == "not":
inner = ast[1]
ik = inner[0]
if ik == "not":
return _nnf(inner[1])
if ik == "and":
return ("or", _nnf(("not", inner[1])), _nnf(("not", inner[2])))
if ik == "or":
return ("and", _nnf(("not", inner[1])), _nnf(("not", inner[2])))
if ik == "const":
return ("const", not inner[1])
if ik == "atom":
return ("not", inner)
raise CnfCompileError("unparseable_negation", node=ik)
if kind in ("and", "or"):
return (kind, _nnf(ast[1]), _nnf(ast[2]))
return ast # atom / const
def _is_tautological(clause: frozenset) -> bool:
return any((atom, True) in clause and (atom, False) in clause for atom, _ in clause)
def _to_cnf(ast: tuple) -> frozenset:
"""NNF AST → frozenset of clauses (constant-folded, tautologies dropped)."""
kind = ast[0]
if kind == "atom":
return frozenset({frozenset({(ast[1], True)})})
if kind == "not": # NNF guarantees the operand is an atom
return frozenset({frozenset({(ast[1][1], False)})})
if kind == "const":
return _TRUE if ast[1] else _FALSE
left, right = _to_cnf(ast[1]), _to_cnf(ast[2])
if kind == "and":
if left == _FALSE or right == _FALSE:
return _FALSE
merged = left | right
if len(merged) > _CLAUSE_BUDGET:
raise CnfCompileError("cnf_budget_exceeded", clauses=len(merged))
return frozenset(merged)
if kind == "or":
if left == _TRUE or right == _TRUE:
return _TRUE
if left == _FALSE:
return right
if right == _FALSE:
return left
out: set[frozenset] = set()
for clause_a in left:
for clause_b in right:
combined = clause_a | clause_b
if _is_tautological(combined):
continue # always-true clause drops from the conjunction
out.add(combined)
if len(out) > _CLAUSE_BUDGET:
raise CnfCompileError("cnf_budget_exceeded", clauses=len(out))
return frozenset(out) if out else _TRUE
raise CnfCompileError("unparseable_node", node=kind)
def _sorted_clause(clause: frozenset) -> Clause:
return tuple(sorted(clause))
def _cnf_of(formula: str) -> frozenset:
return _to_cnf(_nnf(_eliminate(_parse(formula))))
def formula_to_clauses(formula: str) -> tuple[Clause, ...]:
"""Structural CNF of ``formula`` as a deterministic tuple of clauses.
A tautological formula yields ``()`` (no constraints); a formula that
reduces to ``false`` is refused (it cannot be a corridor clause set).
"""
cnf = _cnf_of(formula)
if cnf == _FALSE:
raise CnfCompileError("formula_reduces_to_false", formula=formula)
if cnf == _TRUE:
return ()
return tuple(sorted((_sorted_clause(c) for c in cnf)))
def query_to_clause(query: str) -> Clause:
"""A query must reduce to a single clause (a disjunction of literals).
That is exactly the shape ``propositional_entails`` negates into unit
clauses. Conjunctive or constant queries are refused.
"""
cnf = _cnf_of(query)
if cnf in (_TRUE, _FALSE):
raise CnfCompileError("query_reduces_to_constant", query=query, value=(cnf == _TRUE))
if len(cnf) != 1:
raise CnfCompileError("query_not_single_clause", query=query, clause_count=len(cnf))
return _sorted_clause(next(iter(cnf)))
def compile_entailment(
premises: Sequence[str], query: str
) -> tuple[PropositionalProblem, Clause]:
"""Compile ``premises ⊨ query`` into a corridor ``(PropositionalProblem, Clause)``.
Run the result through ``propositional_entails`` to decide it. Raises
``HamiltonianCompileError`` (``atom_count_out_of_range``) when the shared
atom set exceeds the corridor's ≤5-atom envelope — the honest boundary.
"""
premise_clauses: tuple[Clause, ...] = tuple(
clause for formula in premises for clause in formula_to_clauses(formula)
)
query_clause = query_to_clause(query)
atoms = sorted(
{atom for clause in premise_clauses for atom, _ in clause}
| {atom for atom, _ in query_clause}
)
problem = PropositionalProblem(atoms=tuple(atoms), clauses=premise_clauses)
return problem, query_clause
def clauses_to_formula(clauses: Sequence[Clause]) -> str:
"""Render CNF clauses to a ``logic_canonical``-parseable string (``&``/``|``/``~``).
Round-trips with ``formula_to_clauses`` under the ROBDD identity; ``()``
(a tautology) renders to ``true``.
"""
if not clauses:
return "true"
rendered = [
"(" + " | ".join(atom if pol else f"~{atom}" for atom, pol in clause) + ")"
for clause in clauses
]
return " & ".join(rendered)