core/evals/deductive_logic/oracle.py
Shay 48827f281a feat: sound+complete propositional entailment operator + deductive-logic lane
The first SIZEABLE, honestly-verified reasoning capability — built on CORE's
own terrain (exact, verifiable, deterministic), not GSM8K's stochastic terrain.

THE OPERATOR (generate/proof_chain/entail.py, ADR-0206):
- evaluate_entailment(premises, query) -> entailed | refuted | unknown | refused.
- The multi-hop inference operator evals/symbolic_logic/gaps.md said did not exist
  ("no operator that takes A->B, B->C and returns A->C") and ADR-0205 deferred.
- Built on the ADR-0201 ROBDD canonicalizer: premises |= Q iff (AND P) -> Q is a
  tautology. SOUND AND COMPLETE for propositional logic, not single-step.
- wrong=0 is structural: an exact tautology check refuses (LogicError) on
  malformed / out-of-decidable-regime (quantified/predicate) input, never guesses.

THE HONEST METRIC (evals/deductive_logic/):
- holdout v1 (500 cases): 500 correct / 0 WRONG, incl. 227 non-trivial deductions
  (117 entailed + 110 refuted). dev (200): 200/0.
- Gold from an INDEPENDENT truth-table oracle (oracle.py) sharing zero code with
  the engine. 8,000-case fuzz across two independent decision procedures:
  0 disagreements. This is the soundness evidence the GSM8K composer could never
  produce (it could not separate its 2 right from its 87 wrong answers).
- contract.md states the load-bearing honesty boundary: PROPOSITIONAL ONLY, and
  given-formulas (NL->logic grounding is a separate later layer, kept out of scope
  so we do not re-step on the GSM8K natural-language rake).

TESTS (17, all green): classic inference shapes (MP, multi-hop chain, modus
tollens, disjunctive/hypothetical syllogism, conjunctive rules, genuine unknown),
refusal boundary (inconsistent / quantified / predicate / malformed), and a
deterministic engine-vs-oracle fuzz cross-check.

Pure new module — does NOT touch serving. Smoke 73 passed; invariants 40 passed.
2026-06-04 07:47:01 -07:00

228 lines
7 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.

"""Independent entailment oracle — the gold for the deductive-logic lane.
This is **deliberately a second, independent decision procedure**: a self-contained
recursive-descent parser plus brute-force truth-table model enumeration. It shares
**no code** with :mod:`generate.logic_canonical` (the ROBDD) or
:mod:`generate.proof_chain.entail` (the engine under test). Two independent sound
procedures agreeing on held-out random problems is real evidence the engine is
correct; a shared-code "oracle" would only prove the engine agrees with itself.
It is intentionally simple and slow (O(2^atoms)) — correctness by obviousness, not
performance. The lane keeps atom counts small so enumeration stays cheap.
Grammar / precedence match the ROBDD's so the same formula string means the same
thing in both procedures (the comparison is about the *decision*, not parsing):
iff < implies < or < and < not (implies right-associative)
"""
from __future__ import annotations
from itertools import product
from typing import Final
# --- independent tokenizer -------------------------------------------------
_OPS: Final[tuple[tuple[str, str], ...]] = (
("<->", "IFF"), ("", "IFF"), ("", "IFF"),
("->", "IMP"), ("", "IMP"), ("", "IMP"),
("", "AND"), ("&&", "AND"), ("&", "AND"),
("", "OR"), ("||", "OR"), ("|", "OR"),
("¬", "NOT"), ("~", "NOT"), ("!", "NOT"),
("(", "LP"), (")", "RP"),
)
_KW: Final[dict[str, str]] = {
"and": "AND", "or": "OR", "not": "NOT", "implies": "IMP",
"iff": "IFF", "true": "TRUE", "false": "FALSE",
}
class OracleError(ValueError):
"""Malformed formula — the oracle refuses to guess, same posture as the engine."""
def _tokenize(text: str) -> list[tuple[str, str]]:
toks: list[tuple[str, str]] = []
i, n = 0, len(text)
while i < n:
c = text[i]
if c.isspace():
i += 1
continue
hit = False
for spell, kind in _OPS:
if text.startswith(spell, i):
toks.append((kind, spell))
i += len(spell)
hit = True
break
if hit:
continue
if c.isalpha() or c == "_":
j = i + 1
while j < n and (text[j].isalnum() or text[j] == "_"):
j += 1
word = text[i:j]
toks.append((_KW.get(word.lower(), "ATOM"), word))
i = j
continue
raise OracleError(f"unexpected character {c!r}")
return toks
# --- independent recursive-descent parser ----------------------------------
class _P:
def __init__(self, toks: list[tuple[str, str]]) -> None:
self.toks = toks
self.i = 0
def _peek(self) -> tuple[str, str] | None:
return self.toks[self.i] if self.i < len(self.toks) else None
def _eat(self) -> tuple[str, str]:
if self.i >= len(self.toks):
raise OracleError("unexpected end of formula")
t = self.toks[self.i]
self.i += 1
return t
def parse(self) -> tuple:
if not self.toks:
raise OracleError("empty formula")
ast = self._iff()
if self.i != len(self.toks):
raise OracleError("trailing tokens")
return ast
def _iff(self) -> tuple:
node = self._imp()
while (t := self._peek()) and t[0] == "IFF":
self._eat()
node = ("iff", node, self._imp())
return node
def _imp(self) -> tuple:
node = self._or()
if (t := self._peek()) and t[0] == "IMP":
self._eat()
node = ("imp", node, self._imp()) # right-assoc
return node
def _or(self) -> tuple:
node = self._and()
while (t := self._peek()) and t[0] == "OR":
self._eat()
node = ("or", node, self._and())
return node
def _and(self) -> tuple:
node = self._not()
while (t := self._peek()) and t[0] == "AND":
self._eat()
node = ("and", node, self._not())
return node
def _not(self) -> tuple:
if (t := self._peek()) and t[0] == "NOT":
self._eat()
return ("not", self._not())
return self._atom()
def _atom(self) -> tuple:
kind, lex = self._eat()
if kind == "ATOM":
return ("atom", lex)
if kind == "TRUE":
return ("const", True)
if kind == "FALSE":
return ("const", False)
if kind == "LP":
inner = self._iff()
if self._eat()[0] != "RP":
raise OracleError("expected ')'")
return inner
raise OracleError(f"unexpected token {lex!r}")
def _atoms(ast: tuple) -> set[str]:
k = ast[0]
if k == "atom":
return {ast[1]}
if k == "const":
return set()
if k == "not":
return _atoms(ast[1])
return _atoms(ast[1]) | _atoms(ast[2])
def _eval(ast: tuple, env: dict[str, bool]) -> bool:
k = ast[0]
if k == "atom":
return env[ast[1]]
if k == "const":
return ast[1]
if k == "not":
return not _eval(ast[1], env)
a = _eval(ast[1], env)
b = _eval(ast[2], env)
if k == "and":
return a and b
if k == "or":
return a or b
if k == "imp":
return (not a) or b
if k == "iff":
return a == b
raise OracleError(f"unknown node {k!r}") # pragma: no cover
def _parse(formula: str) -> tuple:
return _P(_tokenize(formula)).parse()
# --- the oracle verdict ----------------------------------------------------
# Verdict strings match generate.proof_chain.entail.Entailment values exactly so the
# runner can compare engine.outcome.value to the oracle verdict directly.
ENTAILED: Final[str] = "entailed"
REFUTED: Final[str] = "refuted"
UNKNOWN: Final[str] = "unknown"
REFUSED: Final[str] = "refused"
def oracle_entailment(premises: tuple[str, ...], query: str) -> str:
"""Brute-force entailment verdict over all truth assignments.
Enumerates every assignment of the atoms appearing anywhere in premises+query;
a *model* is an assignment satisfying every premise. Returns:
``entailed`` if query holds in all models, ``refuted`` if in none,
``unknown`` if in some-but-not-all, ``refused`` if there are no models
(inconsistent premises) or anything fails to parse."""
try:
prem_asts = [_parse(p) for p in premises]
q_ast = _parse(query)
except OracleError:
return REFUSED
atoms = set(_atoms(q_ast))
for a in prem_asts:
atoms |= _atoms(a)
ordered = sorted(atoms)
models = 0
q_true = 0
for combo in product((False, True), repeat=len(ordered)):
env = dict(zip(ordered, combo))
if all(_eval(a, env) for a in prem_asts):
models += 1
if _eval(q_ast, env):
q_true += 1
if models == 0:
return REFUSED # inconsistent premises
if q_true == models:
return ENTAILED
if q_true == 0:
return REFUTED
return UNKNOWN