core/generate/proof_chain/member.py
Shay b938ad617f feat(deduction-serve): Band v3-MEM — member-chain band, Socrates syllogism decided (ADR-0258)
Per-individual propositional lowering over minted (individual, class)
atoms: singular membership/predicate facts + all/every/each/no universals,
instantiated at every named individual, decided by the same verified ROBDD
engine. Closed-table number linking (irregular/invariant priority + three
regular suffix rules) is the one semantic identification — attested-pair
linking only, under-link over over-link. Composer tier strictly after
v1/v1b/v2-EN: monotone widening, v1 lane byte-identical.

- 4 new shape-bands (en_member_single/chain/negative/atomic) each earned
  SERVE at the arena: 13 bands x 720 = 9,360 wrong=0; ledger re-sealed.
- New hand-authored lane evals/deduction_serve/v2_member (26/26); ds-en-0022
  promoted declined->entailed (the designed acceptance case); report
  re-pinned; contract.md refreshed to the 4-band cascade.
- Typed refusals: quantifier pronouns, bare plurals, definite descriptions,
  relative clauses, tense, mixed connectives, universal conclusions.
- Flag posture unchanged (deduction_serving_enabled default off).

[Verification]: deductive 127 / smoke 180 / warmed_session 10; arena
all_bands_serve_licensed wrong_is_zero; lanes v1 28/28, v2_en 26/26,
v2_member 26/26.
2026-07-23 19:21:41 -07:00

430 lines
18 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.

"""Member-argument reader — Band v3-MEM (ADR-0258).
Reads the classic instantiation syllogism — "Socrates is a man. All men are
mortal. Therefore Socrates is mortal." — by **per-individual propositional
lowering**: one opaque minted atom (``a0``, ``a1``, …) per *(individual,
class)* pair, with every universal premise instantiated at every named
individual (``A(C1,C2)`` ⇒ ``mem(i,C1) -> mem(i,C2)``; ``E(C1,C2)`` ⇒
``mem(i,C1) -> ~mem(i,C2)``). The same verified ROBDD engine decides.
Why this is sound (ADR-0258 §3): universal instantiation at named individuals
is truth-preserving in every first-order model, so ENTAILED / REFUTED /
inconsistent verdicts over the lowered premises hold in every model of the
original argument — and both survive co-reference of distinct names, since
that only ADDS premises and entailment is monotone. For this closed fragment
(singular literals + A/E universals, singular conclusion, Boolean reading)
the lowering is also COMPLETE: a propositional countermodel lifts to a
first-order countermodel whose domain is exactly the named individuals, so
UNKNOWN means genuinely not forced within the disclosed reading. The UNKNOWN
surface still scopes itself (names read as distinct individuals, class words
at face value), and every sentence shape announcing structure this band
cannot read refuses out of band, typed.
Number linking — the ONE semantic identification this band performs: a
universal's class word is usually plural ("all men") while the singular fact
uses the singular ("a man"). Two attested class forms are identified iff
related token-wise by a CLOSED relation: the irregular/invariant table below
(consulted first — table membership makes it the sole authority for that
token, killing over-link hazards like specie/species and new/news), else the
three regular suffix rules (``+s``, ``+es`` after sibilants, ``y↔ies``).
Anything the relation cannot relate stays distinct — under-linking costs
coverage, never soundness.
This reader is used ONLY by the deduction serving path, strictly AFTER Bands
v1 / v1b / v2-EN — pure widening; every previously-served argument is served
byte-identically. 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``,
``structural_leak``, ``too_many_premises``, ``too_many_atoms``.
"""
from __future__ import annotations
from dataclasses import dataclass
# The v2-EN sentence splitter and tokenizer are reused VERBATIM (one
# normalization discipline, no drift between the English-argument bands) —
# a deliberate private-name import inside the proof_chain package.
from generate.proof_chain.english import _SENTENCE_RE, _tokenize
from generate.proof_chain.shape import (
EN_MEMBER_ATOMIC,
EN_MEMBER_CHAIN,
EN_MEMBER_NEGATIVE,
EN_MEMBER_SINGLE,
)
#: Universal-affirmative sentence leads (A-form) and the E-form lead.
_A_LEADS = frozenset({"all", "every", "each"})
_E_LEAD = "no"
#: Quantifier tokens (determiners AND pronouns) this band cannot lower —
#: existential/plurality readings refuse rather than misread "everyone" or
#: "some men" as an individual. A/E leads are dispatched before this check.
_QUANTIFIER_TOKENS = frozenset({
"all", "every", "each", "no", "some", "none", "any", "most", "few",
"many", "several", "both", "either", "neither",
"everyone", "everybody", "everything", "someone", "somebody",
"something", "anyone", "anybody", "anything", "nobody", "nothing",
})
#: Connective structure this band does not compose (a future band fuses the
#: v2-EN connective grammar with these sentence readings — ADR-0258 §6.1).
_CONNECTIVES = frozenset({"if", "then", "or", "and", "either"})
#: Negation-bearing tokens with no normalized reading here (only the copular
#: ``is not`` slot and the sentential prefix are normalized).
_NEGATION_BEARING = frozenset({
"not", "never", "cannot", "nor", "neither", "nothing", "none", "no",
})
#: Relative-clause markers — internal structure a name/class run must not hide.
_RELATIVE_MARKERS = frozenset({"that", "which", "who", "whom", "whose"})
#: All copular forms recognized for DISPATCH; only ``is``/``are`` are in-band
#: (tense is a deliberate scope-out — ADR-0258 §6.3).
_COPULA_FORMS = ("is", "are", "was", "were")
#: The "it is not the case that <singular>" sentential-negation prefix.
_SENTENTIAL_NOT = ("it", "is", "not", "the", "case", "that")
#: Honesty caps — beyond these the argument is refused, never truncated.
#: ``MAX_ATOMS`` counts minted (individual, class) pairs, so instantiation
#: fan-out (individuals × classes) is capped exactly where it multiplies.
MAX_PREMISE_SENTENCES = 16
MAX_ATOMS = 24
#: Closed irregular/invariant number table (plural → singular; an INVARIANT
#: form maps to itself). Consulted BEFORE the regular suffix rules: if either
#: token of a candidate pair appears anywhere in this table, the table is the
#: only authority for that pair — which is what keeps ``species``/``specie``
#: and ``news``/``new`` unlinked. CORE's first morphology table; promoted to
#: a ratified pack when the tri-language siblings land (ADR-0258 §5).
_IRREGULAR_PLURALS: dict[str, str] = {
"men": "man", "women": "woman", "people": "person",
"children": "child", "mice": "mouse", "geese": "goose",
"feet": "foot", "teeth": "tooth", "oxen": "ox",
"wolves": "wolf", "knives": "knife", "lives": "life",
"leaves": "leaf", "halves": "half", "elves": "elf",
"loaves": "loaf", "thieves": "thief", "cacti": "cactus",
"fungi": "fungus", "dice": "die",
# invariants (plural == singular)
"sheep": "sheep", "fish": "fish", "deer": "deer",
"species": "species", "series": "series", "means": "means",
"offspring": "offspring", "aircraft": "aircraft", "news": "news",
}
_TABLE_TOKENS = frozenset(_IRREGULAR_PLURALS) | frozenset(_IRREGULAR_PLURALS.values())
_SIBILANT_ENDINGS = ("s", "x", "z", "ch", "sh")
@dataclass(frozen=True, slots=True)
class MemberArgument:
"""A successfully-read member argument, engine-ready.
``premise_formulas``/``query_formula`` are propositional formula strings
over minted atom ids; ``atoms[i]`` displays the *(individual, class)*
pair behind ``a{i}``. ``premise_texts``/``query_text`` are the normalized
original sentences — the exact reading the surface restates 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 MemberRefusal:
"""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 = MemberRefusal(reason, detail)
@dataclass(frozen=True, slots=True)
class _Singular:
"""``<NAME> is [not] [a|an] <CLASS>`` — a literal about one individual."""
ind: tuple[str, ...]
cls: tuple[str, ...]
negated: bool
@dataclass(frozen=True, slots=True)
class _Universal:
"""``all|every|each|no <C1> are|is [a|an] <C2>`` — instantiated per
individual at lowering time. ``negative`` marks the E-form (``no``)."""
subject: tuple[str, ...]
predicate: tuple[str, ...]
negative: bool
def _tokens_link(a: str, b: str) -> bool:
"""True iff single tokens *a* and *b* are number-forms of one class word
under the closed relation (irregular/invariant table first, then the
three regular suffix rules)."""
if a == b:
return True
if a in _TABLE_TOKENS or b in _TABLE_TOKENS:
return _IRREGULAR_PLURALS.get(a) == b or _IRREGULAR_PLURALS.get(b) == a
short, long_ = (a, b) if len(a) <= len(b) else (b, a)
if long_ == short + "s" and not short.endswith(_SIBILANT_ENDINGS):
return True
if long_ == short + "es" and short.endswith(_SIBILANT_ENDINGS):
return True
if short.endswith("y") and long_ == short[:-1] + "ies":
return True
return False
def _forms_link(a: tuple[str, ...], b: tuple[str, ...]) -> bool:
"""Multi-token class forms link token-wise (head-noun pluralization —
"guard dogs""guard dog"); differing lengths never link."""
return len(a) == len(b) and all(_tokens_link(x, y) for x, y in zip(a, b))
def _check_run(toks: tuple[str, ...], detail: str) -> None:
"""Guard an opaque name/class token run: structure it must not hide
refuses out of band, typed. Order matters — quantifier pronouns
(``nobody``) are quantifier refusals, not negation refusals."""
if not toks:
raise _Reject("empty_side", detail)
for tok in toks:
if tok == "therefore":
raise _Reject("structural_leak", detail)
if tok in _RELATIVE_MARKERS:
raise _Reject("relative_clause_out_of_band", detail)
if tok in _QUANTIFIER_TOKENS:
raise _Reject("quantifier_out_of_band", detail)
if tok in _NEGATION_BEARING or tok.endswith("n't"):
raise _Reject("internal_negation_unread", detail)
if tok in _COPULA_FORMS:
raise _Reject("sentence_shape_out_of_band", detail)
def _parse_universal(toks: list[str], detail: str) -> _Universal:
negative = toks[0] == _E_LEAD
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": # "all the dogs" — determiner, dropped
subject = subject[1:]
predicate = rest[cop_idx + 1 :]
if predicate and predicate[0] == "not":
# "All men are not mortal" — genuinely scope-ambiguous English
# (¬∀ vs ∀¬); refuse rather than guess. E-form ("no …") is the
# unambiguous spelling this band reads.
raise _Reject("internal_negation_unread", detail)
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_run(tuple(subject), detail)
_check_run(tuple(predicate), detail)
return _Universal(tuple(subject), tuple(predicate), negative)
def _parse_singular(toks: list[str], detail: str) -> _Singular:
if tuple(toks[: len(_SENTENTIAL_NOT)]) == _SENTENTIAL_NOT:
inner = _parse_singular(toks[len(_SENTENTIAL_NOT) :], detail)
return _Singular(inner.ind, inner.cls, negated=not inner.negated)
cop_idx = next((i for i, t in enumerate(toks) if t in _COPULA_FORMS), None)
if cop_idx is None or toks[cop_idx] in ("was", "were"):
raise _Reject("sentence_shape_out_of_band", detail)
if toks[cop_idx] == "are":
# "Dogs are loyal" — a bare-plural generic is an implicit universal
# this band refuses to guess (ADR-0258 §3).
raise _Reject("bare_plural_out_of_band", detail)
name = tuple(toks[:cop_idx])
rhs = toks[cop_idx + 1 :]
negated = False
if rhs and rhs[0] == "not":
negated = True
rhs = rhs[1:]
if rhs and rhs[0] in ("a", "an"):
rhs = rhs[1:]
elif rhs and rhs[0] == "the":
# "Socrates is the philosopher" — identity via definite description,
# not class membership (ADR-0258 §6.4).
raise _Reject("definite_description_out_of_band", detail)
_check_run(name, detail)
_check_run(tuple(rhs), detail)
return _Singular(name, tuple(rhs), negated)
def _parse_sentence(toks: list[str], detail: str) -> _Singular | _Universal:
if any(t in _CONNECTIVES for t in toks):
raise _Reject("mixed_structure_out_of_band", detail)
head = toks[0]
if head in _A_LEADS or head == _E_LEAD:
return _parse_universal(toks, detail)
if head in _QUANTIFIER_TOKENS:
raise _Reject("quantifier_out_of_band", detail)
return _parse_singular(toks, detail)
def read_member_argument(text: str) -> MemberArgument | MemberRefusal:
"""Read *text* as a member argument, or refuse (typed).
Deterministic and pure: same text → same ``MemberArgument``. The
conclusion is the final sentence, the sole "therefore"-led one, and must
be singular; every earlier sentence is a premise. Lowering is two-pass —
sentences parse first, then universals instantiate at every named
individual (so a universal may precede the individuals it binds) — with
premise formulas emitted in sentence order and atom ids minted in
emission order.
"""
if not text or not text.strip():
return MemberRefusal("empty")
sentences = [
(m.group(1), m.group(2)) for m in _SENTENCE_RE.finditer(text) if m.group(1).strip()
]
if not sentences:
return MemberRefusal("empty")
premises: list[_Singular | _Universal] = []
premise_texts: list[str] = []
conclusion: _Singular | 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)
parsed = _parse_sentence(rest, body)
if not isinstance(parsed, _Singular):
raise _Reject("universal_conclusion_out_of_band", body)
conclusion = parsed
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 MemberRefusal("no_conclusion")
if not premises:
return MemberRefusal("no_premises")
# --- lowering (ADR-0258 §2) ------------------------------------------
records: list[_Singular | _Universal] = [*premises, conclusion]
# Named individuals, first-appearance order.
individuals: list[tuple[str, ...]] = []
for rec in records:
if isinstance(rec, _Singular) and rec.ind not in individuals:
individuals.append(rec.ind)
# Class groups: each attested form joins the FIRST group whose
# representative (its first-attested form) it links to — greedy,
# deterministic, closed.
group_of: dict[tuple[str, ...], int] = {}
reps: list[tuple[str, ...]] = []
for rec in records:
forms = (
[rec.cls] if isinstance(rec, _Singular) else [rec.subject, rec.predicate]
)
for form in forms:
if form in group_of:
continue
for gi, rep in enumerate(reps):
if _forms_link(form, rep):
group_of[form] = gi
break
else:
group_of[form] = len(reps)
reps.append(form)
mint: dict[tuple[int, int], str] = {}
atom_display: list[str] = []
def atom_for(ind_idx: int, group: int, detail: str) -> str:
key = (ind_idx, group)
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(
f"{' '.join(individuals[ind_idx])} : {' '.join(reps[group])}"
)
return mint[key]
premise_formulas: list[str] = []
for rec, body_text in zip(premises, premise_texts):
if isinstance(rec, _Singular):
atom = atom_for(individuals.index(rec.ind), group_of[rec.cls], body_text)
premise_formulas.append(f"~({atom})" if rec.negated else atom)
continue
for ind_idx in range(len(individuals)):
antecedent = atom_for(ind_idx, group_of[rec.subject], body_text)
consequent = atom_for(ind_idx, group_of[rec.predicate], body_text)
premise_formulas.append(
f"({antecedent}) -> (~({consequent}))"
if rec.negative
else f"({antecedent}) -> ({consequent})"
)
query_atom = atom_for(
individuals.index(conclusion.ind), group_of[conclusion.cls], query_text
)
query_formula = f"~({query_atom})" if conclusion.negated else query_atom
except _Reject as rej:
return rej.refusal
n_universal = sum(1 for r in premises if isinstance(r, _Universal))
n_negative = sum(
1 for r in premises if isinstance(r, _Universal) and r.negative
)
if n_negative:
band = EN_MEMBER_NEGATIVE
elif n_universal >= 2:
band = EN_MEMBER_CHAIN
elif n_universal == 1:
band = EN_MEMBER_SINGLE
else:
band = EN_MEMBER_ATOMIC
return MemberArgument(
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",
"MemberArgument",
"MemberRefusal",
"read_member_argument",
]