core/generate/proof_chain/exist.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

546 lines
23 KiB
Python

"""Existential-argument reader — Band v6-EX (ADR-0261).
Completes the all / no / some square. Reads deductive arguments containing
existential ("some") sentences — "All wolves are mammals. Some wolves are
tame. Therefore some mammals are tame." — by extending Band v5-VP's
per-individual propositional lowering with TWO new domain elements:
- a **witness** per existential PREMISE (Skolem constant): "some C1 are C2"
⇒ ``mem(w,C1) & mem(w,C2)`` for a fresh ``w`` that nothing else names;
- one **arbitrary individual** ``k``, minted iff the CONCLUSION is
existential: an element about which no fact is asserted and at which every
universal is still instantiated.
An existential conclusion lowers to the disjunction of its conjunction over
EVERY domain element (named individuals, witnesses, and ``k``). The same
verified ROBDD engine decides. Both atom families of v5-VP carry over
unchanged, so existentials compose with membership facts, is-a universals,
verb facts, and verb universals in one shared atom space.
Why this is sound (ADR-0261 §3), in the two directions that matter:
* **ENTAILED.** Let *M* be any first-order model of the argument's premises.
Every witness is interpretable in *M* (its existential premise asserts a
satisfier), universal instantiation is truth-preserving at every element,
and ``k`` may be interpreted as ANY element (domains are non-empty), so
every lowered premise formula holds in *M*. If the lowered premises
propositionally entail the query's disjunction, some domain element
satisfies the conjunction in *M* — which is exactly ∃. Skolem constants
never appear in the original argument, so this is the standard
entailment-preserving Skolemization.
* **REFUTED.** The conjunction dual is entailed only if it is entailed at
``k`` — and ``k`` is arbitrary (no premise mentions it), so what holds of
``k`` holds of every element: the universal ∀x¬(…) that refuting ∃ requires
is genuinely derived, never assumed by domain closure. This is the ONE
place a naive witness-only lowering would be unsound: without ``k``,
"Rex is a wolf. Rex is not tame. Therefore some wolves are tame." would
read as REFUTED (the only wolf in the lowering is not tame) instead of the
honest UNKNOWN. ``k`` is what keeps the domain open.
Completeness within the fragment lifts the same way as v3-MEM/v5-VP: a
propositional countermodel becomes a first-order countermodel whose domain is
exactly the named individuals, witnesses, and ``k``.
**No existential import.** "All C are P. Therefore some C are P." is UNKNOWN,
not ENTAILED — a universal is vacuously true over an empty class, so the
subaltern moods (Darapti, Felapton, Bamalip) come out UNKNOWN here. That is
the modern first-order reading and the conservative one; the categorical band
(v1b) already reads the square the same way (its own arena carries the
"existential-import overreach" case as INVALID).
Sentence shapes added over v5-VP, all with a ``some`` lead:
``some <C1> are|is [not] [a|an] <C2>`` (I-form and O-form) and
``some <C> <verb> [<object>]`` / ``some <C> do not <verb> [<object>]``. The
plural ``do not`` is read HERE (unlike v5-VP, which refuses it) because with
an existential subject there is no scope ambiguity: "some C do not V" is
∃x(C(x) ∧ ¬V(x)), full stop. ``all C do not V`` remains refused — that one is
genuinely ambiguous. Every other sentence shape delegates VERBATIM to v3-MEM
and v5-VP's own parsers, refusals included.
Tried strictly AFTER Bands v1 / v1b / v2-EN / v3-MEM / v4-CM / v5-VP (the
last fallback tier) — pure widening; every previously-served argument is
served byte-identically. Every earlier band refuses a ``some``-led sentence
(``quantifier_out_of_band`` / ``categorical_shape_out_of_band``), and the
categorical band v1b, which does read some-syllogisms, is tried BEFORE this
one and keeps them. Refusal-first, closed reason vocabulary:
``empty``, ``question_sentence``, ``no_conclusion``,
``multiple_conclusions``, ``conclusion_not_last``, ``no_premises``,
``empty_side``, ``quantifier_out_of_band``, ``bare_plural_out_of_band``,
``definite_description_out_of_band``, ``relative_clause_out_of_band``,
``universal_conclusion_out_of_band``, ``mixed_structure_out_of_band``,
``internal_negation_unread``, ``sentence_shape_out_of_band``,
``tense_out_of_band``, ``partitive_out_of_band``, ``structural_leak``,
``too_many_premises``, ``too_many_atoms``.
"""
from __future__ import annotations
from dataclasses import dataclass
# The v2-EN sentence splitter/tokenizer, v3-MEM's copula parsers and shared
# guards, and v5-VP's verb grammar are reused VERBATIM — one normalization
# discipline across every argument band; deliberate private-name imports
# inside the proof_chain package, same precedent as member/cond_member/verb.
from generate.proof_chain.english import _SENTENCE_RE, _tokenize
from generate.proof_chain.member import (
_A_LEADS,
_COPULA_FORMS,
_E_LEAD,
_QUANTIFIER_TOKENS,
_SENTENTIAL_NOT,
_Singular,
_Universal,
_check_run,
_forms_link,
_parse_singular,
_parse_universal,
)
from generate.proof_chain.member import _Reject as _MemberReject
from generate.proof_chain.shape import (
EN_EXIST_CHAIN,
EN_EXIST_NEGATIVE,
EN_EXIST_UNIVERSAL,
EN_EXIST_WITNESS,
)
from generate.proof_chain.verb import (
_AUXILIARIES,
_CONNECTIVES,
_VerbFact,
_VerbUniversal,
_check_term_token,
_check_verb_token,
_parse_fact,
_parse_verb_universal,
_verb_tokens_link,
)
from generate.proof_chain.verb import _Reject as _VerbReject
#: The one existential lead this band reads. Every other quantifier token
#: keeps v3-MEM's ``quantifier_out_of_band`` refusal.
_SOME = "some"
#: Partitive marker — "some of the wolves" is a different construction
#: (quantification over a contextually-fixed set, not over a class), so it
#: refuses rather than being read as an opaque class run.
_PARTITIVE = "of"
#: Honesty caps — beyond these the argument is refused, never truncated.
#: ``MAX_ATOMS`` now also bounds an existential conclusion's disjunction,
#: which mints up to two atoms per domain element, so the cap binds exactly
#: where the lowering multiplies.
MAX_PREMISE_SENTENCES = 16
MAX_ATOMS = 24
#: Display names for the two synthetic domain elements. Presentation only —
#: atom identity is the domain INDEX, so these can never collide with a name
#: the user actually wrote.
_WITNESS_DISPLAY = "witness {n}"
_ARBITRARY_DISPLAY = "any individual"
@dataclass(frozen=True, slots=True)
class ExistArgument:
"""A successfully-read existential argument, engine-ready.
Same shape as ``VerbArgument``: ``premise_formulas``/``query_formula`` are
propositional formula strings over minted atom ids; ``atoms[i]`` displays
the fact behind ``a{i}`` (``individual : class`` for membership atoms,
``individual : verb [object]`` for verb atoms, where ``individual`` may be
a witness or the arbitrary element).
"""
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 ExistRefusal:
"""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 = ExistRefusal(reason, detail)
@dataclass(frozen=True, slots=True)
class _ExistCopula:
"""``some <C1> are|is [not] [a|an] <C2>`` — an I-form (or O-form when
``negated``) over a fresh witness at lowering time."""
subject: tuple[str, ...]
predicate: tuple[str, ...]
negated: bool
@dataclass(frozen=True, slots=True)
class _ExistVerb:
"""``some <C> [do not] <verb> [<object>]`` — the verb-predicate I/O-form
over a fresh witness at lowering time."""
subject: tuple[str, ...]
verb: str
obj: str | None
negated: bool
_Existential = _ExistCopula | _ExistVerb
_Record = _Singular | _Universal | _VerbFact | _VerbUniversal | _ExistCopula | _ExistVerb
def _check_exist_subject(toks: tuple[str, ...], detail: str) -> None:
"""Guard an existential subject run: the shared opaque-run guard plus the
partitive exclusion."""
if _PARTITIVE in toks:
raise _Reject("partitive_out_of_band", detail)
_check_run(toks, detail)
def _parse_exist_copula(toks: list[str], detail: str) -> _ExistCopula:
"""``some <C1> are|is [not] [a|an] <C2>``.
Deliberately NOT v3-MEM's ``_parse_universal``: that parser refuses a
predicate-leading ``not`` because "all C are not P" is scope-ambiguous
(¬∀ vs ∀¬). "Some C are not P" has no such ambiguity — it is the O-form,
∃x(C(x) ∧ ¬P(x)) — so the negation is read here.
"""
rest = toks[1:]
cop_idx = next((i for i, t in enumerate(rest) if t in ("is", "are")), None)
if cop_idx is None:
raise _Reject("sentence_shape_out_of_band", detail)
subject = rest[:cop_idx]
if subject and subject[0] == "the": # "some of the …" is caught below
subject = subject[1:]
predicate = rest[cop_idx + 1 :]
negated = False
if predicate and predicate[0] == "not":
negated = True
predicate = predicate[1:]
if predicate and predicate[0] in ("a", "an"):
predicate = predicate[1:]
elif predicate and predicate[0] == "the":
raise _Reject("definite_description_out_of_band", detail)
_check_exist_subject(tuple(subject), detail)
_check_run(tuple(predicate), detail)
return _ExistCopula(tuple(subject), tuple(predicate), negated)
def _parse_exist_verb(toks: list[str], detail: str) -> _ExistVerb:
"""``some <C> <verb> [<obj>]`` / ``some <C> do not <verb> [<obj>]`` — no
copula form. Single-token class, verb, and object, exactly as v5-VP's
verb universals."""
rest = toks[1:]
if rest and rest[0] == "the":
rest = rest[1:]
if _PARTITIVE in rest:
raise _Reject("partitive_out_of_band", detail)
negated = False
if len(rest) >= 3 and rest[1] in _AUXILIARIES and rest[2] == "not":
if rest[1] == "did":
raise _Reject("tense_out_of_band", detail)
if rest[1] == "does":
# "some wolves does not run" — agreement this band does not read.
raise _Reject("sentence_shape_out_of_band", detail)
negated = True
rest = [rest[0], *rest[3:]]
if any(t == "not" or t.endswith("n't") for t in rest):
raise _Reject("internal_negation_unread", detail)
if any(t == "did" for t in rest):
raise _Reject("tense_out_of_band", detail)
if len(rest) < 2 or len(rest) > 3:
raise _Reject("sentence_shape_out_of_band", detail)
cls, verb = rest[0], rest[1]
obj = rest[2] if len(rest) == 3 else None
_check_term_token(cls, detail)
_check_verb_token(verb, detail)
if obj is not None:
_check_term_token(obj, detail)
return _ExistVerb((cls,), verb, obj, negated)
def _parse_existential(toks: list[str], detail: str) -> _Existential:
"""A ``some``-led sentence: a copula form routes to the I/O-form copula
parser, else the verb grammar."""
if any(t in _COPULA_FORMS for t in toks):
if any(t in ("was", "were") for t in toks):
raise _Reject("tense_out_of_band", detail)
return _parse_exist_copula(toks, detail)
return _parse_exist_verb(toks, detail)
def _parse_sentence(toks: list[str], detail: str) -> _Record:
if any(t in _CONNECTIVES for t in toks):
raise _Reject("mixed_structure_out_of_band", detail)
head = toks[0]
if head == _SOME:
return _parse_existential(toks, detail)
if head in _A_LEADS or head == _E_LEAD:
if any(t in ("is", "are") for t in toks):
return _parse_universal(toks, detail) # is-a universal (v3-MEM's)
return _parse_verb_universal(toks, detail) # verb universal (v5-VP's)
if head in _QUANTIFIER_TOKENS:
raise _Reject("quantifier_out_of_band", detail)
return _parse_fact(toks, detail) # singular membership / verb fact (v5-VP's)
def read_exist_argument(text: str) -> ExistArgument | ExistRefusal:
"""Read *text* as an existential argument, or refuse (typed).
Deterministic and pure: same text → same ``ExistArgument``. The conclusion
is the final sentence, the sole "therefore"-led one, and may be a singular
fact (membership or verb) or an existential; universal conclusions stay
out of band. Lowering is two-pass exactly as v3-MEM/v5-VP's — sentences
parse first, THEN the domain (named individuals, one witness per
existential premise, and the arbitrary element when the conclusion is
existential) is fixed, THEN atoms mint and universals instantiate at every
domain element — with premise formulas emitted in sentence order.
"""
if not text or not text.strip():
return ExistRefusal("empty")
sentences = [
(m.group(1), m.group(2)) for m in _SENTENCE_RE.finditer(text) if m.group(1).strip()
]
if not sentences:
return ExistRefusal("empty")
premises: list[_Record] = []
premise_texts: list[str] = []
conclusion: _Singular | _VerbFact | _ExistCopula | _ExistVerb | 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 conclusion 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)
if any(t in _CONNECTIVES for t in rest):
raise _Reject("mixed_structure_out_of_band", body)
if rest[0] in _A_LEADS or rest[0] == _E_LEAD:
raise _Reject("universal_conclusion_out_of_band", body)
if rest[0] == _SOME:
conclusion = _parse_existential(rest, body)
elif rest[0] in _QUANTIFIER_TOKENS:
raise _Reject("quantifier_out_of_band", body)
else:
conclusion = _parse_fact(rest, body)
query_text = " ".join(rest)
continue
if len(premises) >= MAX_PREMISE_SENTENCES:
raise _Reject("too_many_premises", body)
premises.append(_parse_sentence(toks, body))
premise_texts.append(" ".join(toks))
if conclusion is None or query_text is None:
return ExistRefusal("no_conclusion")
if not premises:
return ExistRefusal("no_premises")
records: list[_Record] = [*premises, conclusion]
# Every argument this band DECIDES contains at least one existential
# construct — a purely universal/singular argument is v3-MEM/v5-VP
# territory (and only ever reaches here when those bands refused it,
# in which case the delegated parsers above raised the same typed
# refusal already; this is a defensive closure of that invariant).
if not any(isinstance(r, (_ExistCopula, _ExistVerb)) for r in records):
raise _Reject("sentence_shape_out_of_band", "no existential read")
# --- domain (ADR-0261 §2) -------------------------------------------
# Display name per domain index; named individuals first (first-
# appearance order), then one witness per existential premise, then
# the arbitrary element iff the conclusion is existential.
domain: list[str] = []
named_index: dict[tuple[str, ...], int] = {}
for rec in records:
if isinstance(rec, (_Singular, _VerbFact)) and rec.ind not in named_index:
named_index[rec.ind] = len(domain)
domain.append(" ".join(rec.ind))
witness_index: dict[int, int] = {}
for pos, rec in enumerate(premises):
if isinstance(rec, (_ExistCopula, _ExistVerb)):
witness_index[pos] = len(domain)
domain.append(_WITNESS_DISPLAY.format(n=len(witness_index)))
conclusion_is_existential = isinstance(conclusion, (_ExistCopula, _ExistVerb))
if conclusion_is_existential:
domain.append(_ARBITRARY_DISPLAY)
# Class groups — the SAME closed noun-morphology relation as v3-MEM.
cls_group_of: dict[tuple[str, ...], int] = {}
cls_reps: list[tuple[str, ...]] = []
for rec in records:
forms: list[tuple[str, ...]] = []
if isinstance(rec, _Singular):
forms = [rec.cls]
elif isinstance(rec, (_Universal, _ExistCopula)):
forms = [rec.subject, rec.predicate]
elif isinstance(rec, (_VerbUniversal, _ExistVerb)):
forms = [rec.subject]
for form in forms:
if form in cls_group_of:
continue
for gi, rep in enumerate(cls_reps):
if _forms_link(form, rep):
cls_group_of[form] = gi
break
else:
cls_group_of[form] = len(cls_reps)
cls_reps.append(form)
# Verb groups — v5-VP's closed agreement relation, unchanged.
verb_group_of: dict[str, int] = {}
verb_reps: list[str] = []
for rec in records:
if not isinstance(rec, (_VerbFact, _VerbUniversal, _ExistVerb)):
continue
form = rec.verb
if form in verb_group_of:
continue
for gi, rep in enumerate(verb_reps):
if _verb_tokens_link(form, rep):
verb_group_of[form] = gi
break
else:
verb_group_of[form] = len(verb_reps)
verb_reps.append(form)
mint: dict[tuple, str] = {}
atom_display: list[str] = []
def _mint(key: tuple, display: str, detail: str) -> str:
if key not in mint:
if len(atom_display) >= MAX_ATOMS:
raise _Reject("too_many_atoms", detail)
mint[key] = f"a{len(atom_display)}"
atom_display.append(display)
return mint[key]
def mem_atom(ind_idx: int, group: int, detail: str) -> str:
return _mint(
("mem", ind_idx, group),
f"{domain[ind_idx]} : {' '.join(cls_reps[group])}",
detail,
)
def verb_atom(ind_idx: int, group: int, obj: str | None, detail: str) -> str:
shown = verb_reps[group] if obj is None else f"{verb_reps[group]} {obj}"
return _mint(("verb", ind_idx, group, obj), f"{domain[ind_idx]} : {shown}", detail)
def literal(atom: str, negated: bool) -> str:
return f"~({atom})" if negated else atom
def conjunction(ind_idx: int, rec: _Existential, detail: str) -> str:
"""``mem(i, subject) & [~]<predicate>(i)`` for one domain element —
the body of both an existential premise and an existential query
disjunct."""
subject = mem_atom(ind_idx, cls_group_of[rec.subject], detail)
if isinstance(rec, _ExistCopula):
predicate = mem_atom(ind_idx, cls_group_of[rec.predicate], detail)
else:
predicate = verb_atom(ind_idx, verb_group_of[rec.verb], rec.obj, detail)
return f"({subject}) & ({literal(predicate, rec.negated)})"
premise_formulas: list[str] = []
for pos, (rec, body_text) in enumerate(zip(premises, premise_texts)):
if isinstance(rec, _Singular):
atom = mem_atom(named_index[rec.ind], cls_group_of[rec.cls], body_text)
premise_formulas.append(literal(atom, rec.negated))
elif isinstance(rec, _VerbFact):
atom = verb_atom(
named_index[rec.ind], verb_group_of[rec.verb], rec.obj, body_text
)
premise_formulas.append(literal(atom, rec.negated))
elif isinstance(rec, (_ExistCopula, _ExistVerb)):
premise_formulas.append(conjunction(witness_index[pos], rec, body_text))
elif isinstance(rec, _Universal):
for ind_idx in range(len(domain)):
antecedent = mem_atom(ind_idx, cls_group_of[rec.subject], body_text)
consequent = mem_atom(ind_idx, cls_group_of[rec.predicate], body_text)
premise_formulas.append(
f"({antecedent}) -> ({literal(consequent, rec.negative)})"
)
else: # _VerbUniversal
for ind_idx in range(len(domain)):
antecedent = mem_atom(ind_idx, cls_group_of[rec.subject], body_text)
consequent = verb_atom(
ind_idx, verb_group_of[rec.verb], rec.obj, body_text
)
premise_formulas.append(
f"({antecedent}) -> ({literal(consequent, rec.negative)})"
)
if isinstance(conclusion, (_ExistCopula, _ExistVerb)):
query_formula = " | ".join(
f"({conjunction(ind_idx, conclusion, query_text)})"
for ind_idx in range(len(domain))
)
elif isinstance(conclusion, _Singular):
query_formula = literal(
mem_atom(
named_index[conclusion.ind], cls_group_of[conclusion.cls], query_text
),
conclusion.negated,
)
else:
query_formula = literal(
verb_atom(
named_index[conclusion.ind],
verb_group_of[conclusion.verb],
conclusion.obj,
query_text,
),
conclusion.negated,
)
except (_Reject, _MemberReject, _VerbReject) as rej:
# ``_parse_singular`` / ``_parse_universal`` are v3-MEM's own functions
# and ``_parse_fact`` / ``_parse_verb_universal`` are v5-VP's, all
# reused verbatim — their failures raise THEIR OWN ``_Reject`` classes
# (same shape, different identity), so all three are caught here and
# normalized to THIS band's refusal type (the v4-CM lesson, extended).
return ExistRefusal(rej.refusal.reason, rej.refusal.detail)
universals = [r for r in premises if isinstance(r, (_Universal, _VerbUniversal))]
if any(u.negative for u in universals):
band = EN_EXIST_NEGATIVE
elif len(universals) >= 2:
band = EN_EXIST_CHAIN
elif len(universals) == 1:
band = EN_EXIST_UNIVERSAL
else:
band = EN_EXIST_WITNESS
return ExistArgument(
premise_formulas=tuple(premise_formulas),
query_formula=query_formula,
premise_texts=tuple(premise_texts),
query_text=query_text,
band=band,
atoms=tuple(atom_display),
)
__all__ = [
"MAX_ATOMS",
"MAX_PREMISE_SENTENCES",
"ExistArgument",
"ExistRefusal",
"read_exist_argument",
]