core/evals/deductive_logic/grounding.py
Shay 3e2a52870d feat: Phase 2 — finite-entity grounding compiler + Phase 1.5 finding
The first comprehension->structure compiler: evals/deductive_logic/grounding.py
lowers a typed finite-entity problem (finite entities + unary predicates +
single-variable universal rules) into the propositional regime the ADR-0206
entailment operator decides, refusal-first with a closed typed reason vocabulary
(unsafe_symbol / unknown_entity / unsupported_predicate_arity / unsupported_
quantifier / malformed_case / empty_case) and collision-safe atom slugging.

finite_entity/v1/cases.jsonl: 8 cases with INDEPENDENT (oracle-derived) gold
(4 entailed, 2 unknown, 1 refuted, 1 refused) — chained rules, conjunctive
bodies, negative heads, inconsistent premises. 20 tests gate engine==oracle==gold.

This is the second diversity-panel domain (distinct comprehension, same checkable
substrate) — the universal-structure thesis validated on a different problem shape,
with the anti-overfit >=2-domain discipline now live.

Phase 1.5 finding recorded: a clean geometric/algebraic propositional decoder
agrees 716/716 with the oracle but is O(2^n) (enumeration-class), so logic is the
wrong first domain for field-as-reasoner; the wedge redirects to quantitative-
relational structure where the field is the natural non-redundant decoder.
2026-06-04 16:32:03 -07:00

201 lines
8.9 KiB
Python

"""Finite-entity grounding — compile a typed finite-entity problem to
propositional atoms (Phase 2 of the universal-structure plan; the deductive-logic
runway doc's PR-1).
This is the first **comprehension compiler**: it lowers a structured, finite-entity
problem (finite named entities, unary predicates, single-variable universal rules)
into the propositional regime the ADR-0206 entailment operator already decides —
exactly where `wrong == 0` is structural. A grounded finite-entity problem (each
``predicate(entity)`` pair → one atom) IS propositional and in scope; anything
outside the narrow v1 grammar refuses with a typed reason rather than guessing.
Lowering law (deterministic):
predicate(entity) -> ``predicate_slug__entity_slug`` (atom)
negative literal -> ``~atom``
rule body (∧ of literals) -> conjunction of lowered body literals
universal rule ∀x. B(x)->H(x)-> for each entity e: ``lower(B[e]) -> lower(H[e])``
query -> the lowered query literal
The lowered ``(premises, query)`` is decided by **two independent procedures**
(the ROBDD engine and the truth-table oracle); a case counts only when both agree
with the gold (INV-25). v1 keeps the grammar intentionally narrow — that narrowness
is the firewall, not a weakness.
Sealed: no ``chat`` import, no serving path. Deterministic.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, Final
# Closed refusal vocabulary for the grounding layer — distinct from the entailment
# operator's reasons so a malformed case never leaks into an ambiguous failure.
UNSUPPORTED_PREDICATE_ARITY: Final[str] = "unsupported_predicate_arity"
UNSUPPORTED_QUANTIFIER: Final[str] = "unsupported_quantifier"
UNSAFE_SYMBOL: Final[str] = "unsafe_symbol"
UNKNOWN_ENTITY: Final[str] = "unknown_entity"
UNKNOWN_VARIABLE: Final[str] = "unknown_variable"
MALFORMED_CASE: Final[str] = "malformed_case"
EMPTY_CASE: Final[str] = "empty_case"
GROUNDING_REASONS: Final[frozenset[str]] = frozenset({
UNSUPPORTED_PREDICATE_ARITY,
UNSUPPORTED_QUANTIFIER,
UNSAFE_SYMBOL,
UNKNOWN_ENTITY,
UNKNOWN_VARIABLE,
MALFORMED_CASE,
EMPTY_CASE,
})
_ATOM_SEPARATOR: Final[str] = "__"
# A slug component: lowercase ASCII letters/digits/single underscores, no leading
# digit, no empty, no double-underscore (would collide with the separator).
_SLUG_RE: Final[re.Pattern[str]] = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*")
class GroundingError(ValueError):
"""A finite-entity case outside the v1 grammar. Carries a typed reason."""
def __init__(self, reason: str, detail: str = "") -> None:
if reason not in GROUNDING_REASONS:
raise ValueError(f"unknown grounding reason: {reason!r}")
self.reason = reason
super().__init__(f"{reason}: {detail}" if detail else reason)
@dataclass(frozen=True, slots=True)
class GroundedProblem:
"""The lowered finite-entity problem: propositional premises + query."""
premises: tuple[str, ...]
query: str
def slug(token: Any) -> str:
"""Deterministic, collision-safe slug. Rejects (never silently repairs)
anything outside ``[a-z][a-z0-9]*(_[a-z0-9]+)*`` — including the separator,
leading digits, empties, and double underscores."""
if not isinstance(token, str):
raise GroundingError(UNSAFE_SYMBOL, f"non-string symbol {token!r}")
s = token.strip().lower()
if not s:
raise GroundingError(UNSAFE_SYMBOL, "empty symbol")
if _ATOM_SEPARATOR in s:
raise GroundingError(UNSAFE_SYMBOL, f"symbol {token!r} contains separator '{_ATOM_SEPARATOR}'")
if not _SLUG_RE.fullmatch(s):
raise GroundingError(UNSAFE_SYMBOL, f"symbol {token!r} is not a safe slug")
return s
def atom(predicate: Any, entity: Any) -> str:
"""``predicate(entity)`` → the canonical propositional atom."""
return f"{slug(predicate)}{_ATOM_SEPARATOR}{slug(entity)}"
def _literal(predicate: Any, entity: Any, polarity: Any) -> str:
if not isinstance(polarity, bool):
raise GroundingError(MALFORMED_CASE, f"polarity must be a bool, got {polarity!r}")
a = atom(predicate, entity)
return a if polarity else f"~{a}"
def _require(condition: bool, reason: str, detail: str = "") -> None:
if not condition:
raise GroundingError(reason, detail)
def _check_unary(obj: dict[str, Any], *, allow_var: bool) -> None:
"""Reject any non-unary / relational / functional shape (v1 is unary only)."""
keys = set(obj)
# A binary relation / function would carry extra argument keys.
extra = keys - {"predicate", "entity", "var", "polarity"}
_require(not extra, UNSUPPORTED_PREDICATE_ARITY, f"unexpected keys {sorted(extra)}")
has_entity = "entity" in obj
has_var = "var" in obj
if allow_var:
_require(has_entity ^ has_var, MALFORMED_CASE, "literal needs exactly one of entity/var")
else:
_require(has_entity and not has_var, UNKNOWN_VARIABLE, "free variable outside a rule")
def lower_case(case: dict[str, Any]) -> GroundedProblem:
"""Lower a finite-entity case to ``(premises, query)``. Refuse-first.
Schema (v1):
{"entities": [str, ...],
"facts": [{"predicate": str, "entity": str, "polarity": bool}, ...],
"rules": [{"if": [<unary literal with var>, ...],
"then": <unary literal with var>}, ...],
"query": {"predicate": str, "entity": str, "polarity": bool}}
"""
if not isinstance(case, dict) or not case:
raise GroundingError(EMPTY_CASE, "case is empty or not a mapping")
entities = case.get("entities")
if not (isinstance(entities, list) and entities):
raise GroundingError(EMPTY_CASE, "no entities")
entity_set = {slug(e) for e in entities} # also validates each entity slug
_require(len(entity_set) == len(entities), MALFORMED_CASE, "duplicate entities after slugging")
if "query" not in case:
raise GroundingError(MALFORMED_CASE, "no query")
premises: list[str] = []
facts = case.get("facts", []) or []
rules = case.get("rules", []) or []
_require(isinstance(facts, list), MALFORMED_CASE, "facts must be a list")
_require(isinstance(rules, list), MALFORMED_CASE, "rules must be a list")
# Facts — ground unary literals over named entities.
for fact in facts:
_require(isinstance(fact, dict), MALFORMED_CASE, f"fact is not a mapping: {fact!r}")
_check_unary(fact, allow_var=False)
_require(slug(fact["entity"]) in entity_set, UNKNOWN_ENTITY, str(fact.get("entity")))
premises.append(_literal(fact["predicate"], fact["entity"], fact.get("polarity", True)))
# Rules — single-variable universal rules, grounded by explicit entity expansion.
for rule in rules:
_require(isinstance(rule, dict), MALFORMED_CASE, f"rule is not a mapping: {rule!r}")
_require("quantifier" not in rule and "exists" not in rule,
UNSUPPORTED_QUANTIFIER, "only implicit single-var universal rules in v1")
body = rule.get("if")
head = rule.get("then")
if not (isinstance(body, list) and body):
raise GroundingError(MALFORMED_CASE, "rule body must be a non-empty list")
if not isinstance(head, dict):
raise GroundingError(MALFORMED_CASE, "rule head must be a literal")
var = _rule_variable(body, head)
for ent in entities:
body_atoms = [_literal(lit["predicate"], ent, lit.get("polarity", True)) for lit in body]
head_atom = _literal(head["predicate"], ent, head.get("polarity", True))
body_conj = " & ".join(f"({b})" for b in body_atoms)
premises.append(f"({body_conj}) -> ({head_atom})")
_ = var # validated; grounding is by explicit entity expansion, not by name
query = case["query"]
_require(isinstance(query, dict), MALFORMED_CASE, "query must be a literal")
_check_unary(query, allow_var=False)
_require(slug(query["entity"]) in entity_set, UNKNOWN_ENTITY, str(query.get("entity")))
query_lit = _literal(query["predicate"], query["entity"], query.get("polarity", True))
return GroundedProblem(premises=tuple(premises), query=query_lit)
def _rule_variable(body: list[dict[str, Any]], head: dict[str, Any]) -> str:
"""Confirm the rule is single-variable: every literal uses one shared var,
no named entities (those would make it not universal). Returns the var name."""
seen: set[str] = set()
for lit in (*body, head):
_require(isinstance(lit, dict), MALFORMED_CASE, f"rule literal not a mapping: {lit!r}")
_check_unary(lit, allow_var=True)
_require("var" in lit, MALFORMED_CASE, "rule literals must use a variable, not a named entity")
v = lit["var"]
_require(isinstance(v, str) and bool(v.strip()), MALFORMED_CASE, "empty variable")
seen.add(v)
_require(len(seen) == 1, UNSUPPORTED_QUANTIFIER, f"v1 allows one variable per rule, saw {sorted(seen)}")
return seen.pop()