core/generate/proof_chain/english.py
Shay a83b35de07 feat(deduction-serve): Band v2-EN — natural-English argument serving, earned licenses (ADR-0257)
CORE now decides natural-English deductive arguments end-to-end:
'If it rains then the ground is wet. It rains. Therefore the ground
is wet.' -> ENTAILED, rendered over the user's own clauses. Both of
ADR-0256's documented boundary cases (multiword conditional, nested-
negation contraposition) are now decided; their lane gold updated
declined->entailed.

- generate/proof_chain/english.py (new): closed function-word grammar
  over OPAQUE clause-atoms (minted ids); if/then, [either] or, and,
  three negation forms (leading not / sentential / copular incl.
  contractions); typed refusals; honesty caps. Soundness: positive
  verdicts are substitution-closed; UNKNOWN is scoped + guarded
  (quantifier-led, is-a membership, unnormalizable negation refuse
  out of the opaque band).
- render_entailment_english: verdicts quoted back verbatim; UNKNOWN
  phrasing explicitly scoped to the opaque reading.
- chat/deduction_surface.py: v2-EN fallback tier AFTER v1/v1b —
  monotone widening, byte-identical on previously-served arguments.
- Four en_* shape-bands EARN SERVE via the ADR-0199 arena (720/band,
  wrong=0, reliability 0.99087 >= 0.99); 9-band ledger re-sealed.
- evals/deduction_serve/v2_en: 26 hand-authored real-English cases
  (content disjoint from the synthetic lexicon), wrong=0; report
  re-pinned.
- realizer guard (ADR-0075): deduction surfaces exempt — quoted
  templates are not slot-composed articulations (pack 'open'=VERB
  was rejecting an honest quoted 'the door is not open'); pinned
  cold+warm in e2e.

[Verification]: deductive 84 passed; smoke 180 passed; cognition 122
passed 1 skipped; arena 9x720 wrong=0 all SERVE; lanes v1 28/28,
v2_en 26/26.
2026-07-23 14:37:50 -07:00

337 lines
14 KiB
Python

"""English-clause argument reader — Band v2-EN (ADR-0257).
Reads a natural-English deductive argument ("If it rains then the ground is
wet. It rains. Therefore the ground is wet.") into ``(premises, query)``
propositional formula strings over MINTED OPAQUE ATOM IDS (``a0``, ``a1``, …),
one id per distinct normalized clause. The clause text itself never enters a
formula — ``generate.logic_canonical`` treats ``and``/``or``/``not``/``implies``
as keyword operators, so clause text is lexically unsafe there; ids are always
safe, which frees clause-atoms from every lexical constraint (articles,
copulas, apostrophes, digits — all fine).
Why this is sound (the load-bearing argument, ADR-0257 §3):
Propositional entailment is closed under uniform substitution of formulas
for atoms. An ``ENTAILED`` / ``REFUTED`` / inconsistent-premises verdict
computed over opaque clause-atoms therefore remains true under ANY deeper
reading of those clauses — if the user mentally refines "the ground is wet"
into richer structure, or identifies two clauses we kept distinct, every
positive verdict still holds. The ONE verdict that is not substitution-safe
is UNKNOWN ("doesn't settle"): internal clause structure we did not read
could settle it. Serving therefore (a) renders UNKNOWN with an explicitly
scoped phrasing ("reading each clause as one indivisible claim…"), and
(b) refuses clauses whose surface form ANNOUNCES internal structure this
band cannot read — quantifier-led categorical clauses, is-a membership
clauses, and unnormalized negation — so a genuine syllogism can never be
misread into a misleading "doesn't follow".
This reader is DELIBERATELY separate from ``generate.meaning_graph.reader``
(the shared 7-lane comprehension reader): argument-mode reading is only sound
because the "therefore" commit gate has already fired, so every clause is a
premise or conclusion BY POSITION. The shared reader's single-token floor and
reserved-word guard are load-bearing for its lanes' wrong=0 and are untouched.
The serving composer tries the shared reader's bands (v1/v1b) FIRST and falls
back here, so every previously-served argument is served byte-identically —
this band only widens.
Refusal-first, closed reason vocabulary (every ``EnglishRefusal.reason``):
``empty``, ``question_sentence``, ``no_conclusion``,
``multiple_conclusions``, ``conclusion_not_last``, ``no_premises``,
``empty_side``, ``nested_conditional``, ``ambiguous_and_or``,
``categorical_shape_out_of_band``, ``membership_shape_out_of_band``,
``internal_negation_unread``, ``structural_leak``,
``too_many_premises``, ``too_many_atoms``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from generate.proof_chain.shape import (
EN_ATOMIC,
EN_CONDITIONAL_CHAIN,
EN_CONDITIONAL_SINGLE,
EN_DISJUNCTIVE,
)
#: Sentence splitter — same shape as the shared reader's (body + terminator).
_SENTENCE_RE = re.compile(r"\s*([^.?!]+?)\s*([.?!])")
#: Structural function words. One may never survive into an opaque atom — a
#: leftover here means the clause has structure this grammar did not consume.
_STRUCTURAL = frozenset({"if", "then", "or", "and", "either", "therefore"})
#: Quantifier-led clause = categorical shape ("all whales are mammals") — real
#: internal structure the opaque band must NOT flatten (a valid syllogism read
#: as three opaque atoms would yield a misleading "doesn't follow"). Refused
#: here; the categorical band (v1b) is the honest home for these.
_QUANTIFIER_LEAD = frozenset({"all", "every", "each", "no", "some", "none", "any", "most"})
#: Negation-bearing tokens inside a clause that this band cannot normalize.
#: Leaving one inside an opaque atom would hide a negation from the engine
#: ("it never rains" vs "it rains" would be unrelated atoms) — refuse instead.
_NEGATION_BEARING = frozenset({"never", "cannot", "nor", "neither", "nothing", "none", "no"})
#: Copulas whose "<copula> not" form this band DOES normalize into ``~atom``.
_COPULAS = frozenset({"is", "are", "was", "were"})
#: Contraction → expanded tokens (applied before parsing; deterministic).
_CONTRACTIONS = {
"isn't": ("is", "not"),
"aren't": ("are", "not"),
"wasn't": ("was", "not"),
"weren't": ("were", "not"),
}
#: The "it is not the case that <X>" sentential-negation prefix.
_SENTENTIAL_NOT = ("it", "is", "not", "the", "case", "that")
#: Honesty caps — an argument beyond these is refused, not truncated.
MAX_PREMISE_SENTENCES = 16
MAX_ATOMS = 24
@dataclass(frozen=True, slots=True)
class EnglishArgument:
"""A successfully-read English argument, engine-ready.
``premise_formulas``/``query_formula`` are propositional formula strings
over minted atom ids; ``atoms[i]`` is the normalized clause text of ``a{i}``.
``premise_texts``/``query_text`` are the normalized original sentences —
the exact reading the surface's "Given:" line restates back to the user.
"""
premise_formulas: tuple[str, ...]
query_formula: str
premise_texts: tuple[str, ...]
query_text: str
band: str
atoms: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class EnglishRefusal:
"""A typed refusal — the argument is not honestly readable by this band."""
reason: str
detail: str = ""
class _Reject(Exception):
def __init__(self, reason: str, detail: str = "") -> None:
super().__init__(reason)
self.refusal = EnglishRefusal(reason, detail)
class _AtomTable:
"""Mints one opaque id per distinct normalized clause (first-seen order)."""
def __init__(self) -> None:
self._ids: dict[str, str] = {}
self._display: list[str] = []
def mint(self, toks: list[str], detail: str) -> str:
if not toks:
raise _Reject("empty_side", detail)
if toks[0] in _QUANTIFIER_LEAD:
raise _Reject("categorical_shape_out_of_band", detail)
for i, tok in enumerate(toks):
if tok in _STRUCTURAL:
raise _Reject("structural_leak", detail)
if tok == "not" or tok.endswith("n't") or tok in _NEGATION_BEARING:
raise _Reject("internal_negation_unread", detail)
# "<X> is|are a|an <Y>" is a membership clause — internal structure
# (an is-a fact), out of the opaque band by design.
if tok in _COPULAS and i + 1 < len(toks) and toks[i + 1] in ("a", "an"):
raise _Reject("membership_shape_out_of_band", detail)
key = " ".join(toks)
if key not in self._ids:
if len(self._display) >= MAX_ATOMS:
raise _Reject("too_many_atoms", key)
self._ids[key] = f"a{len(self._display)}"
self._display.append(key)
return self._ids[key]
@property
def atoms(self) -> tuple[str, ...]:
return tuple(self._display)
def _tokenize(body: str) -> list[str]:
"""Sentence body → normalized tokens: lowercased, comma-free, contractions
expanded. Commas are treated as plain separators (the reading is disclosed
verbatim in the surface's "Given:" line, so this is a stated normalization,
not a hidden one)."""
out: list[str] = []
for raw in body.replace(",", " ").lower().split():
out.extend(_CONTRACTIONS.get(raw, (raw,)))
return out
def _split_on(toks: list[str], sep: str) -> list[list[str]]:
parts: list[list[str]] = [[]]
for tok in toks:
if tok == sep:
parts.append([])
else:
parts[-1].append(tok)
return parts
def _parse_negatom(toks: list[str], table: _AtomTable, detail: str) -> str:
"""Negation-or-atom: sentential "it is not the case that", leading "not",
copular "<L> is|are|was|were not <R>" (normalized to ``~atom(<L> <cop> <R>)``),
else an opaque atom."""
if tuple(toks[: len(_SENTENTIAL_NOT)]) == _SENTENTIAL_NOT:
return f"~({_parse_negatom(toks[len(_SENTENTIAL_NOT):], table, detail)})"
if toks and toks[0] == "not":
return f"~({_parse_negatom(toks[1:], table, detail)})"
if "not" in toks:
i = toks.index("not")
if i >= 2 and toks[i - 1] in _COPULAS:
left, cop, right = toks[: i - 1], toks[i - 1], toks[i + 1 :]
if not left or not right:
raise _Reject("empty_side", detail)
return f"~({table.mint([*left, cop, *right], detail)})"
raise _Reject("internal_negation_unread", detail)
return table.mint(toks, detail)
def _parse_junction(toks: list[str], table: _AtomTable, detail: str) -> str:
"""Or/and over negation-atoms. "either" is consumed before a disjunction;
mixed top-level and+or refuses (English scope is genuinely ambiguous)."""
if "if" in toks or "then" in toks:
raise _Reject("nested_conditional", detail)
if toks and toks[0] == "either" and "or" in toks:
toks = toks[1:]
has_or = "or" in toks
has_and = "and" in toks
if has_or and has_and:
raise _Reject("ambiguous_and_or", detail)
if has_or or has_and:
sep, op = ("or", "|") if has_or else ("and", "&")
parts = _split_on(toks, sep)
if any(not p for p in parts):
raise _Reject("empty_side", detail)
return f" {op} ".join(f"({_parse_negatom(p, table, detail)})" for p in parts)
return _parse_negatom(toks, table, detail)
def _parse_node(toks: list[str], table: _AtomTable, detail: str) -> str:
"""One full clause: ``if <A> then <B>`` (junctions allowed on both sides,
no nested conditionals) or a junction."""
if toks and toks[0] == "if":
if "then" not in toks:
raise _Reject("nested_conditional", detail)
then_idx = toks.index("then")
antecedent = toks[1:then_idx]
consequent = toks[then_idx + 1 :]
if not antecedent or not consequent:
raise _Reject("empty_side", detail)
left = _parse_junction(antecedent, table, detail)
right = _parse_junction(consequent, table, detail)
return f"({left}) -> ({right})"
return _parse_junction(toks, table, detail)
def _parse_premise(toks: list[str], table: _AtomTable, detail: str) -> list[str]:
"""A premise sentence → one or more formulas. A top-level conjunction
("<A> and <B>.") is split into separate premises — identical to conjoining,
and it keeps premise formulas in the same connective inventory the
shape-bands classify over."""
if toks and toks[0] != "if" and "and" in toks and "or" not in toks and "if" not in toks:
parts = _split_on(toks, "and")
if any(not p for p in parts):
raise _Reject("empty_side", detail)
return [_parse_negatom(p, table, detail) for p in parts]
return [_parse_node(toks, table, detail)]
def _classify_en_band(premise_formulas: tuple[str, ...]) -> str:
"""Structural shape-band over the PREMISES (same axis discipline as
``classify_deduction_shape``, namespaced ``en_`` because the READER is
different — a band's earned license certifies reader fidelity, so a new
reader must earn its own record per shape (ADR-0256/0257)."""
if any("|" in p for p in premise_formulas):
return EN_DISJUNCTIVE
n_conditional = sum(1 for p in premise_formulas if "->" in p)
if n_conditional >= 2:
return EN_CONDITIONAL_CHAIN
if n_conditional == 1:
return EN_CONDITIONAL_SINGLE
return EN_ATOMIC
def read_english_argument(text: str) -> EnglishArgument | EnglishRefusal:
"""Read *text* as an English-clause deductive argument, or refuse (typed).
Deterministic and pure: same text → same ``EnglishArgument`` (atom ids are
minted in first-appearance order). The conclusion is the final sentence and
must be the only "therefore"-led sentence; every earlier sentence is a
premise. All structure recognition is by closed function-word grammar —
clause content is never interpreted, only quoted back.
"""
if not text or not text.strip():
return EnglishRefusal("empty")
sentences = [
(m.group(1), m.group(2)) for m in _SENTENCE_RE.finditer(text) if m.group(1).strip()
]
if not sentences:
return EnglishRefusal("empty")
table = _AtomTable()
premise_formulas: list[str] = []
premise_texts: list[str] = []
query_formula: str | None = None
query_text: str | None = None
try:
for idx, (body, terminator) in enumerate(sentences):
if terminator == "?":
raise _Reject("question_sentence", body)
toks = _tokenize(body)
if not toks:
continue
if toks[0] == "therefore":
if query_formula is not None:
raise _Reject("multiple_conclusions", body)
if idx != len(sentences) - 1:
raise _Reject("conclusion_not_last", body)
rest = toks[1:]
if not rest:
raise _Reject("empty_side", body)
query_formula = _parse_node(rest, table, body)
query_text = " ".join(rest)
continue
if len(premise_texts) >= MAX_PREMISE_SENTENCES:
raise _Reject("too_many_premises", body)
premise_formulas.extend(_parse_premise(toks, table, body))
premise_texts.append(" ".join(toks))
except _Reject as rej:
return rej.refusal
if query_formula is None or query_text is None:
return EnglishRefusal("no_conclusion")
if not premise_formulas:
return EnglishRefusal("no_premises")
formulas = tuple(premise_formulas)
return EnglishArgument(
premise_formulas=formulas,
query_formula=query_formula,
premise_texts=tuple(premise_texts),
query_text=query_text,
band=_classify_en_band(formulas),
atoms=table.atoms,
)
__all__ = [
"MAX_ATOMS",
"MAX_PREMISE_SENTENCES",
"EnglishArgument",
"EnglishRefusal",
"read_english_argument",
]