core/chat/deduction_surface.py
Shay e84c0e8428 feat(deduction-serve): Band v6-EX — existential witnesses, decided (ADR-0261)
Completes the all/no/some square. `some` is read for the first time:
"All mammals are vertebrates. Some whales are mammals. Therefore some
whales are vertebrates." is decided entailed; "All unicorns are horned.
Therefore some unicorns are horned." is decided unsettled, with the
no-existential-import reading disclosed in the surface.

Mechanism (generate/proof_chain/exist.py): v5-VP's per-individual
lowering over a domain widened by one Skolem witness per existential
premise and — the load-bearing part — one ARBITRARY element per
existential conclusion, at which every universal still instantiates and
about which no premise asserts anything. Refuting an existential means
deriving a universal, and the arbitrary element is what makes that
derivation genuine instead of an assumption of domain closure: without
it "Rex is a wolf. Rex is not tame. Therefore some wolves are tame."
reads as REFUTED from a domain of one wolf. With it, UNKNOWN.

Four `en_exist_*` bands earned SERVE at 720x wrong=0 on the first arena
seal (25-band ledger); the 32-case hand-authored v2_exist split passed
32/32 first run; full lane 166/166 wrong=0 across six splits.

Also fixes a wrong-answer path this work uncovered in Band v1b
(ADR-0261 §5.1): `to_syllogism` FILTERED premises it could not express
out of the projection and answered from the remainder, so "Aristotle is
a philosopher. All philosophers are scholars. Therefore some scholars
are philosophers." lost its only witness and was served "that doesn't
follow". It now refuses — matching its propositional sibling
`to_deductive_logic`, which always has — and the argument falls through
to the bands that can hold a singular fact. The categorical band
re-earned SERVE 720/720 after the change; both examples are pinned as
regression cases.

Promotion: `ds-mem-0020` declined -> unknown (ADR-0258's existential
scope-out, now read; an anonymous witness never transfers to a named
individual, so UNKNOWN is its honest verdict, not entailed).

[Verification]: core test --suite deductive 232 passed; reader tests
33/33; register+surface tests 52 passed; arena 25 bands x 720 wrong=0
(all SERVE); lane 166/166 wrong=0; lane SHAs 9/10 match (public_demo
drift is pre-existing on the base commit and unrelated — evidence in
the Tier-S brief).
2026-07-24 14:14:37 -07:00

298 lines
16 KiB
Python

"""chat/deduction_surface.py — DEDUCTION composer (deduction-serve arc).
Wires the verified propositional-entailment engine (``generate.proof_chain``,
716/716 wrong=0 on ``evals/deductive_logic``) into serving. When a prompt
reads as a propositional argument — "P1. P2. ... Therefore C." — comprehend
the text into a ``MeaningGraph``, project into ``(premises, query)`` formula
strings, and decide with the sound+complete ROBDD entailment engine.
Deterministic templates only (``generate.proof_chain.render``) — no LLM, no
synthesis, matching every other composer in this package.
Bands (docs/research/deduction-serve-arc-phase0-baseline-2026-07-23.md):
- Band v1 — propositional arguments with single-token atoms.
- Band v1b (Phase 4) — categorical/syllogism arguments ("All M are P. All S are
M. Therefore all S are P."), projected by ``to_syllogism`` and decided by
``generate.proof_chain.categorical`` (a propositional-lowering decider that
rides the same verified ROBDD engine). ``evals.syllogism.oracle`` must never
be imported here: it is the sealed independence oracle the comprehension lane
scores against, not a serving decider — importing it would collapse INV-25
(independent gold).
- Band v2-EN (ADR-0257) — natural-English propositional arguments over OPAQUE
clause-atoms ("If it rains then the ground is wet. It rains. Therefore the
ground is wet."), read by ``generate.proof_chain.english`` and decided by the
same engine. Tried strictly AFTER v1/v1b (a fallback tier), so every argument
those bands serve is served byte-identically; this band only widens coverage.
- Band v3-MEM (ADR-0258) — singular-membership arguments with universal
premises ("Socrates is a man. All men are mortal. Therefore Socrates is
mortal."), read by ``generate.proof_chain.member`` via per-individual
propositional lowering and decided by the same engine. Tried strictly AFTER
v2-EN (which guards these shapes out) — again a pure widening tier.
- Band v4-CM (ADR-0259) — conditional-membership fusion arguments ("If
Socrates is a man then Socrates is mortal. Socrates is a man. Therefore
Socrates is mortal."), composing v2-EN's connective grammar with v3-MEM's
singular-membership sentence reading over one shared per-individual atom
space, read by ``generate.proof_chain.cond_member``. Tried strictly AFTER
v3-MEM (which guards these shapes out via ``mixed_structure_out_of_band``)
— again a pure widening tier.
- Band v5-VP (ADR-0260) — verb-predicate arguments ("All philosophers teach.
Socrates is a philosopher. Therefore Socrates teaches."), read by
``generate.proof_chain.verb`` via per-individual lowering with a second
(individual, verb-group, object) atom family alongside v3-MEM's membership
atoms, decided by the same engine. Tried strictly AFTER v4-CM (the earlier
bands refuse quantifier-led verb universals, is-a + verb mixes, and
``does not`` negation) — again a pure widening tier.
- Band v6-EX (ADR-0261) — existential arguments ("All wolves are mammals. Some
wolves are tame. Therefore some mammals are tame."), read by
``generate.proof_chain.exist``: v5-VP's lowering over a domain widened with
one Skolem witness per existential premise and one ARBITRARY element per
existential conclusion (what keeps the domain open, so an existential is
refuted only by a genuine universal). Tried LAST, strictly after v5-VP — the
earlier fallback bands refuse every ``some``-led sentence, and the
categorical band v1b, which reads some-syllogisms in its own synthetic
regime, is tried well before this one and keeps them. A pure widening tier.
Fail-closed (INV-34): once ``looks_like_deductive_argument`` fires, every path
below returns a committed, honest surface — reader refusal, out-of-band shape,
and every decision outcome — never a silent fall-through to a different composer.
"""
from __future__ import annotations
import re
from typing import Callable
from chat.deduction_serve_license import deduction_serve_license
from core.reliability_gate import LicenseDecision
from generate.meaning_graph.projectors import to_deductive_logic, to_syllogism
from generate.meaning_graph.reader import Comprehension, comprehend
from generate.proof_chain.categorical import CategoricalError, decide_syllogism
from generate.proof_chain.cond_member import CondMemberArgument, read_cond_member_argument
from generate.proof_chain.english import EnglishArgument, read_english_argument
from generate.proof_chain.entail import evaluate_entailment_with_trace
from generate.proof_chain.exist import ExistArgument, read_exist_argument
from generate.proof_chain.member import MemberArgument, read_member_argument
from generate.proof_chain.render import (
render_entailment,
render_entailment_english,
render_entailment_exist,
render_entailment_member,
render_entailment_verb,
render_syllogism,
)
from generate.proof_chain.verb import VerbArgument, read_verb_argument
from generate.proof_chain.shape import CATEGORICAL, classify_deduction_shape
#: An argument's conclusion clause starts a sentence with "therefore" — the
#: same shape ``generate.meaning_graph.reader`` recognizes per-clause
#: (``toks[0] == "therefore"``). Matching at this commit-gate with the same
#: sentence-initial discipline avoids drift between "looks like an argument"
#: and "is an argument" — the reader remains the sole decider of the latter.
_ARGUMENT_CONCLUSION_RE = re.compile(r"(?:^|[.!?]\s+)therefore\b", re.IGNORECASE)
def looks_like_deductive_argument(text: str) -> bool:
"""True iff *text* has a sentence-initial "therefore" conclusion clause.
A cheap, deterministic COMMIT gate — not a decision. A match only
signals "attempt deduction serving"; the reader and projector below
remain the sole authority on whether the argument is actually
well-formed and in-band.
"""
return bool(_ARGUMENT_CONCLUSION_RE.search(text))
_READER_REFUSAL_SURFACE = (
"That reads as an argument, but I can't parse it precisely enough to "
"decide it yet ({reason})."
)
_OUT_OF_BAND_SURFACE = (
"That reads as an argument, but its form is outside what I can decide yet "
"(I handle plain propositional and categorical 'all/no/some' arguments)."
)
#: Disclosed hedge prepended when a decided argument's shape-band has NOT earned
#: the SERVE license (ADR-0256). The ROBDD engine is sound — the answer is not
#: guessed — but an unearned band means the FULL pipeline (crucially the reader)
#: has no demonstrated track record on this shape, so committing it as verified
#: would overstate the pipeline's earned trust. The answer is served, disclosed.
_UNVERIFIED_SHAPE_DISCLOSURE = (
"(reasoned, but I haven't yet earned a verified track record on arguments "
"of this shape) "
)
_LicenseLookup = Callable[[str], LicenseDecision | None]
def deduction_grounded_surface(
text: str, *, license_lookup: _LicenseLookup = deduction_serve_license,
) -> str | None:
"""Return a deterministic DEDUCTION-tier surface, or ``None``.
Returns ``None`` only when *text* is not argument-shaped at all
(``looks_like_deductive_argument`` is False) — the caller then falls
through to the pre-existing dispatch, byte-identical to before this
composer existed. Once argument-shaped, every branch below commits to
an honest surface; see the module docstring's fail-closed contract.
Earned-license gate (ADR-0256): a decided argument's rendered surface is
served AUTHORITATIVELY only when its propositional shape-band holds a
genuine ``Action.SERVE`` license on the committed, SHA-sealed reliability
ledger (``chat/deduction_serve_license``). A band that has not earned it —
including the forward-looking case of a future shape absent from the ledger,
or any deployment whose ledger is stripped — still gets the sound answer,
but DISCLOSED (hedged), never presented as a verified capability. This is
what makes deduction serving an *earned* capability, not merely a flagged
one. ``license_lookup`` is injectable for testing; it defaults to the real
ratified-ledger reader.
"""
if not looks_like_deductive_argument(text):
return None
comp = comprehend(text)
if not isinstance(comp, Comprehension):
# Band v2-EN fallback (ADR-0257): the shared reader could not read the
# argument (e.g. multi-word English clauses), but the English-clause
# argument reader may — a strict WIDENING: every argument the shared
# reader serves is still served by the branches below, byte-identical.
english = _english_band_surface(text, license_lookup)
if english is not None:
return english
member = _member_band_surface(text, license_lookup)
if member is not None:
return member
cond_member = _cond_member_band_surface(text, license_lookup)
if cond_member is not None:
return cond_member
verb = _verb_band_surface(text, license_lookup)
if verb is not None:
return verb
exist = _exist_band_surface(text, license_lookup)
if exist is not None:
return exist
return _READER_REFUSAL_SURFACE.format(reason=comp.reason)
# Band v1 — propositional argument.
projected = to_deductive_logic(comp)
if projected is not None:
premises, query = projected
trace = evaluate_entailment_with_trace(premises, query)
surface = render_entailment(trace, premises, query)
return _license_gate(surface, classify_deduction_shape(premises, query), license_lookup)
# Band v1b — categorical / syllogism argument. Decided by the propositional-
# lowering categorical decider (rides the same verified ROBDD engine); a
# malformed categorical shape declines rather than guessing.
syllogism = to_syllogism(comp)
if syllogism is not None:
structure, s_query = syllogism
try:
trace = decide_syllogism(structure, s_query)
except CategoricalError:
return _OUT_OF_BAND_SURFACE
surface = render_syllogism(trace, structure, s_query)
return _license_gate(surface, CATEGORICAL, license_lookup)
# Band v2-EN fallback — comprehended, but projectable by neither v1 shape.
english = _english_band_surface(text, license_lookup)
if english is not None:
return english
# Band v3-MEM fallback — the membership/universal shapes v2-EN guards out.
member = _member_band_surface(text, license_lookup)
if member is not None:
return member
# Band v4-CM fallback — the conditional-membership shapes v3-MEM guards out.
cond_member = _cond_member_band_surface(text, license_lookup)
if cond_member is not None:
return cond_member
# Band v5-VP fallback — the verb-predicate shapes every earlier band guards out.
verb = _verb_band_surface(text, license_lookup)
if verb is not None:
return verb
# Band v6-EX fallback — the existential shapes every earlier band guards out.
exist = _exist_band_surface(text, license_lookup)
if exist is not None:
return exist
return _OUT_OF_BAND_SURFACE
def _english_band_surface(text: str, license_lookup: _LicenseLookup) -> str | None:
"""Band v2-EN (ADR-0257): read *text* as an English-clause argument over
opaque atoms and decide it with the same verified ROBDD engine; ``None``
when the English reader refuses (the caller keeps its pre-existing honest
surface, so this band only ever widens what is decided, never narrows)."""
arg = read_english_argument(text)
if not isinstance(arg, EnglishArgument):
return None
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
surface = render_entailment_english(trace, arg.premise_texts, arg.query_text)
return _license_gate(surface, arg.band, license_lookup)
def _member_band_surface(text: str, license_lookup: _LicenseLookup) -> str | None:
"""Band v3-MEM (ADR-0258): read *text* as a singular-membership argument,
lower per-individual, and decide with the same verified ROBDD engine;
``None`` when the member reader refuses (the caller keeps its pre-existing
honest surface — this band only ever widens what is decided)."""
arg = read_member_argument(text)
if not isinstance(arg, MemberArgument):
return None
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
surface = render_entailment_member(trace, arg.premise_texts, arg.query_text)
return _license_gate(surface, arg.band, license_lookup)
def _cond_member_band_surface(text: str, license_lookup: _LicenseLookup) -> str | None:
"""Band v4-CM (ADR-0259): read *text* as a conditional-membership argument
(v2-EN connectives over v3-MEM singular-membership leaves), and decide
with the same verified ROBDD engine; ``None`` when the reader refuses (the
caller keeps its pre-existing honest surface — this band only ever widens
what is decided). Reuses ``render_entailment_member`` unchanged: its
UNKNOWN scoping is exactly as accurate here (every clause is read all the
way to individual+class; connectives add no new hidden structure)."""
arg = read_cond_member_argument(text)
if not isinstance(arg, CondMemberArgument):
return None
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
surface = render_entailment_member(trace, arg.premise_texts, arg.query_text)
return _license_gate(surface, arg.band, license_lookup)
def _verb_band_surface(text: str, license_lookup: _LicenseLookup) -> str | None:
"""Band v5-VP (ADR-0260): read *text* as a verb-predicate argument
(membership atoms + verb atoms over one per-individual space), and decide
with the same verified ROBDD engine; ``None`` when the reader refuses (the
caller keeps its pre-existing honest surface — this band only ever widens
what is decided). Rendered by ``render_entailment_verb``, whose UNKNOWN
scoping extends the member phrasing to the verb reading."""
arg = read_verb_argument(text)
if not isinstance(arg, VerbArgument):
return None
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
surface = render_entailment_verb(trace, arg.premise_texts, arg.query_text)
return _license_gate(surface, arg.band, license_lookup)
def _exist_band_surface(text: str, license_lookup: _LicenseLookup) -> str | None:
"""Band v6-EX (ADR-0261): read *text* as an existential argument (witness
and arbitrary-element domain widening over v5-VP's two atom families), and
decide with the same verified ROBDD engine; ``None`` when the reader
refuses (the caller keeps its pre-existing honest surface — this band only
ever widens what is decided). Rendered by ``render_entailment_exist``,
whose UNKNOWN scoping discloses the no-existential-import reading."""
arg = read_exist_argument(text)
if not isinstance(arg, ExistArgument):
return None
trace = evaluate_entailment_with_trace(arg.premise_formulas, arg.query_formula)
surface = render_entailment_exist(trace, arg.premise_texts, arg.query_text)
return _license_gate(surface, arg.band, license_lookup)
def _license_gate(surface: str, band: str, license_lookup: _LicenseLookup) -> str:
"""Serve *surface* authoritatively iff *band* holds a SERVE license; else
serve the same sound answer DISCLOSED (hedged). The one place the earned-
license posture is applied, shared by the propositional and categorical
bands (ADR-0256)."""
decision = license_lookup(band)
if decision is not None and decision.licensed:
return surface # earned SERVE — authoritative
return _UNVERIFIED_SHAPE_DISCLOSURE + surface # sound, but disclosed
__all__ = ["deduction_grounded_surface", "looks_like_deductive_argument"]