Collapses the duplicated closed English tables into generate/lexicon.py.
Measured on main @ 0948f7cd: the same facts were encoded up to five times
across the reading and writing paths, each copy written independently as a
successive deduction band landed.
The 2A/2B split is decided empirically per #129 — make the change, run the
11 pinned lanes, let byte-identity rule. Result: 11/11 byte-identical, so
this entire unit is 2A and nothing spilled into the authorization-gated 2B.
Collapsed to one object:
- connectives 3 identical copies (member, verb, cond_member)
- predicate display 2 identical 26-entry copies (semantic_templates,
templates)
- be-form inventory 5 copies, not the 2 §1.3 counted. The structural
test found realizer_guard._BE_AUX and
chat/runtime._BE_FORMS, which a COPULA-shaped grep
could never see.
Derived rather than duplicated, so they can no longer drift:
- STRUCTURAL = CONNECTIVES | {therefore}
- QUANTIFIER_TOKENS = QUANTIFIER_LEAD | QUANTIFIER_NON_LEAD
- NEGATION_BEARING_WITH_NOT = NEGATION_BEARING | {not}
- READER_IRREGULAR_SINGULARS = IRREGULAR_SINGULARS restricted to 8 keys
§1.3 re-measured while doing this, and it overstated the disease (plan
§1.9). Almost nothing contradicts:
- the "3 divergent plural tables" are 1 pluralizer + 2 singularizers,
i.e. inverse directions; comparing them as copies is a category error
- reader's 8 singulars are a STRICT SUBSET of member's 29 with the
difference in reader's favour empty, so its values are all correct and
only its coverage is short
- the "3 divergent quantifier sets" are 3 distinct facts; every/each lead
a clause but take singular nouns, so their absence from
PLURAL_QUANTIFIERS is correct
- english's negation set is a subset of member's, and the difference is
principled: v2-EN normalizes "<copula> not", v3-MEM refuses it
Exactly one genuine contradiction exists in the whole inventory:
discourse_planner maps is_defined_as -> "is" where the others map it to
"is defined as". Preserved as a register choice, pinned by test.
Found while reading, pinned as current behaviour for 2B to flip:
reader._singularize does NOT refuse uncovered plurals despite a comment
claiming it does — it falls through to a bare -s strip, so news -> new and
species -> specy, exactly the corruptions member.py's table comment says
its table exists to prevent.
Two 2A exit criteria were unmeasurable as written and are replaced:
- "Jaccard -> 1.00" cannot work. measure_grammar_seam.py scans source
literals, so unifying a fact removes it from those files and Jaccard
FELL, 0.083 -> 0.023 — the success direction reported by a metric
shaped to read like failure. Replaced with an object-identity count:
3 distinct objects behind 8 names, from 8-behind-8. Value comparison
cannot tell a shared object from two equal copies.
- "one table per fact" presumed §1.3's inventory was right. The report now
separates must-be-one-object (all UNIFIED) from related-but-distinct
(6 relations, all HOLD).
ADR-0258 §5 already decided morphology's destination is a ratified pack,
triggered by a grc/he member band. No such band exists, so the promotion is
correctly deferred and lexicon.py is a staging area, not a rival home — no
new ADR. Destination measured for whoever picks it up: packs/en/morphology
.jsonl has 9 records and zero irregular plurals, features.number has no
consumer, and _pack_morph_roots_for reads a top-level "root" key that 0 of
31 records across all four packs define, so it returns {} every call.
[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 unchanged; deductive 383 (364 + 19 new);
scripts/verify_lane_shas.py 11/11 byte-identical.
448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""Conditional-membership argument reader — Band v4-CM (ADR-0259).
|
|
|
|
Composes Band v2-EN's connective grammar (``if/then``, ``or``, ``and``,
|
|
``either`` sugar) with Band v3-MEM's singular-membership sentence reading, so
|
|
"If Socrates is a man then Socrates is mortal. Socrates is a man. Therefore
|
|
Socrates is mortal." decides — the ``mixed_structure_out_of_band`` gap
|
|
ADR-0258 §6.1 reserved as a future band.
|
|
|
|
Why this needs no new negation layer (unlike v2-EN): v2-EN's leaf minter
|
|
(``_AtomTable.mint``) is negation-BLIND, so its grammar must strip "not"
|
|
before minting. This band's leaf is ``generate.proof_chain.member``'s own
|
|
``_parse_singular`` — already negation-aware (leading "not", the sentential
|
|
"it is not the case that" prefix, and copular "is not") — so a leaf is parsed
|
|
directly, no separate negation-extraction step.
|
|
|
|
Why composing is sound (ADR-0259 §3, the load-bearing argument): every
|
|
connective built here (``&``, ``|``, ``->``) is standard propositional
|
|
structure the ROBDD engine already decides; every leaf is a v3-MEM singular
|
|
fact minted and linked by the SAME closed morphology relation ADR-0258 §3
|
|
proved sound and (for this fragment) complete. A bare universal may now
|
|
instantiate at an individual named only inside a connective's leaf — still
|
|
sound, since ADR-0258 §3's argument does not depend on WHERE a named
|
|
individual appears — and a connective's leaf atom may unify with an atom a
|
|
universal's instantiation also produced (the fusion this band is named for).
|
|
UNKNOWN is rendered by the UNCHANGED ``render_entailment_member`` (ADR-0258):
|
|
its scoping ("reading each name as one individual and each class word at
|
|
face value…") is exactly as accurate here, since every clause is read all
|
|
the way to individual+class — connectives add no new hidden structure.
|
|
|
|
Dispatch order is load-bearing: a sentence is checked for a connective token
|
|
BEFORE the universal-lead check (not after), so a connective token can never
|
|
silently leak into an opaque universal/singular name or class run.
|
|
|
|
Tried strictly AFTER Bands v1 / v1b / v2-EN / v3-MEM (a fallback tier) — 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``, ``compound_conclusion_out_of_band``,
|
|
``internal_negation_unread``, ``sentence_shape_out_of_band``,
|
|
``structural_leak``, ``nested_conditional``, ``ambiguous_and_or``,
|
|
``too_many_premises``, ``too_many_atoms``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
# The v2-EN sentence splitter, tokenizer, and generic token-run splitter are
|
|
# reused VERBATIM (one normalization discipline across every argument band —
|
|
# deliberate private-name imports inside the proof_chain package, same
|
|
# precedent as generate.proof_chain.member).
|
|
from generate.lexicon import CONNECTIVES
|
|
from generate.proof_chain.english import _SENTENCE_RE, _split_on, _tokenize
|
|
from generate.proof_chain.member import (
|
|
_A_LEADS,
|
|
_E_LEAD,
|
|
_QUANTIFIER_TOKENS,
|
|
_Singular,
|
|
_Universal,
|
|
_forms_link,
|
|
_parse_singular,
|
|
_parse_universal,
|
|
)
|
|
from generate.proof_chain.member import _Reject as _MemberReject
|
|
from generate.proof_chain.shape import (
|
|
EN_CONDMEM_CHAIN,
|
|
EN_CONDMEM_CONDITIONAL,
|
|
EN_CONDMEM_DISJUNCTIVE,
|
|
EN_CONDMEM_FUSED,
|
|
)
|
|
|
|
#: Connective structure this band's grammar composes (v2-EN's inventory,
|
|
#: minus "therefore" — that token is the commit-gate handled by the
|
|
#: outer sentence loop, never part of a clause's own structure).
|
|
_CONNECTIVE_TOKENS = CONNECTIVES
|
|
|
|
#: Honesty caps — beyond these the argument is refused, never truncated.
|
|
#: ``MAX_ATOMS`` counts minted (individual, class) pairs.
|
|
MAX_PREMISE_SENTENCES = 16
|
|
MAX_ATOMS = 24
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CondMemberArgument:
|
|
"""A successfully-read conditional-membership argument, engine-ready.
|
|
|
|
Same shape as ``MemberArgument``/``EnglishArgument``: ``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.
|
|
"""
|
|
|
|
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 CondMemberRefusal:
|
|
"""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 = CondMemberRefusal(reason, detail)
|
|
|
|
|
|
# --- the formula tree: leaves are v3-MEM singular facts, not opaque atoms ----
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Lit:
|
|
leaf: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _And:
|
|
left: "_Formula"
|
|
right: "_Formula"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Or:
|
|
left: "_Formula"
|
|
right: "_Formula"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Implies:
|
|
left: "_Formula"
|
|
right: "_Formula"
|
|
|
|
|
|
_Formula = _Lit | _And | _Or | _Implies
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Connective:
|
|
"""A parsed connective sentence: its formula tree over LOCAL leaf indices,
|
|
plus the singular facts those indices reference (leaf-parse order)."""
|
|
|
|
formula: _Formula
|
|
leaves: tuple[_Singular, ...]
|
|
|
|
|
|
def _is_connective(toks: list[str]) -> bool:
|
|
return any(t in _CONNECTIVE_TOKENS for t in toks)
|
|
|
|
|
|
def _leaf(toks: list[str], leaves: list[_Singular], detail: str) -> _Lit:
|
|
"""One membership leaf — reuses v3-MEM's own singular parser verbatim,
|
|
which is already negation-aware and already guards every name/class run
|
|
(quantifiers, relative clauses, definite descriptions, tense, bare
|
|
plurals) via ``_check_run``."""
|
|
leaves.append(_parse_singular(toks, detail))
|
|
return _Lit(len(leaves) - 1)
|
|
|
|
|
|
def _parse_junction(toks: list[str], leaves: list[_Singular], detail: str) -> _Formula:
|
|
"""Or/and over membership leaves. "either" is consumed before a
|
|
disjunction; mixed top-level and+or refuses (English scope is genuinely
|
|
ambiguous); a conditional may not nest inside a junction."""
|
|
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 = "or" if has_or else "and"
|
|
parts = _split_on(toks, sep)
|
|
if any(not p for p in parts):
|
|
raise _Reject("empty_side", detail)
|
|
combine = _Or if has_or else _And
|
|
node = _leaf(parts[0], leaves, detail)
|
|
for part in parts[1:]:
|
|
node = combine(node, _leaf(part, leaves, detail))
|
|
return node
|
|
return _leaf(toks, leaves, detail)
|
|
|
|
|
|
def _parse_node(toks: list[str], leaves: list[_Singular], detail: str) -> _Formula:
|
|
"""One full connective sentence: ``if <A> then <B>`` (junctions allowed on
|
|
both sides, no nested conditionals) or a bare 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, leaves, detail)
|
|
right = _parse_junction(consequent, leaves, detail)
|
|
return _Implies(left, right)
|
|
return _parse_junction(toks, leaves, detail)
|
|
|
|
|
|
def _parse_premise_records(
|
|
toks: list[str], detail: str
|
|
) -> list[_Singular | _Universal | _Connective]:
|
|
"""A premise sentence -> one or more records. A top-level conjunction
|
|
("<A> and <B>.", no "if"/"or" anywhere) splits into separate singular
|
|
records — identical to conjoining (v2-EN's exact discipline, ported).
|
|
Any OTHER connective-bearing sentence builds a ``_Connective``. A
|
|
connective-token check happens BEFORE the universal-lead check so a
|
|
stray connective token can never leak into a bare universal's name/class
|
|
run undetected."""
|
|
if 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_singular(p, detail) for p in parts]
|
|
if _is_connective(toks):
|
|
leaves: list[_Singular] = []
|
|
formula = _parse_node(toks, leaves, detail)
|
|
return [_Connective(formula, tuple(leaves))]
|
|
if toks[0] in _A_LEADS or toks[0] == _E_LEAD:
|
|
return [_parse_universal(toks, detail)]
|
|
if toks[0] in _QUANTIFIER_TOKENS:
|
|
raise _Reject("quantifier_out_of_band", detail)
|
|
return [_parse_singular(toks, detail)]
|
|
|
|
|
|
def _parse_conclusion(toks: list[str], detail: str) -> _Singular:
|
|
"""The conclusion stays a single singular fact (optionally negated) —
|
|
matching v3-MEM's own discipline; a universal or connective-shaped
|
|
conclusion refuses rather than attempting a compound render."""
|
|
if _is_connective(toks):
|
|
raise _Reject("compound_conclusion_out_of_band", detail)
|
|
if toks[0] in _A_LEADS or toks[0] == _E_LEAD:
|
|
raise _Reject("universal_conclusion_out_of_band", detail)
|
|
if toks[0] in _QUANTIFIER_TOKENS:
|
|
raise _Reject("quantifier_out_of_band", detail)
|
|
return _parse_singular(toks, detail)
|
|
|
|
|
|
def _contains_or(node: _Formula) -> bool:
|
|
if isinstance(node, _Lit):
|
|
return False
|
|
if isinstance(node, _Or):
|
|
return True
|
|
return _contains_or(node.left) or _contains_or(node.right)
|
|
|
|
|
|
def _count_implies(node: _Formula) -> int:
|
|
if isinstance(node, _Lit):
|
|
return 0
|
|
n = 1 if isinstance(node, _Implies) else 0
|
|
return n + _count_implies(node.left) + _count_implies(node.right)
|
|
|
|
|
|
def read_cond_member_argument(text: str) -> CondMemberArgument | CondMemberRefusal:
|
|
"""Read *text* as a conditional-membership argument, or refuse (typed).
|
|
|
|
Deterministic and pure: same text -> same ``CondMemberArgument``. The
|
|
conclusion is the final sentence, the sole "therefore"-led one, and must
|
|
be a single singular fact; every earlier sentence is a premise. Lowering
|
|
is two-pass exactly as v3-MEM's — sentences (and connective leaves) parse
|
|
first, THEN individuals/class-groups are collected from every singular
|
|
fact wherever it appears (bare, and-split, or nested inside a connective
|
|
tree), THEN atoms are minted and universals instantiate at every named
|
|
individual — with premise formulas emitted in sentence order.
|
|
"""
|
|
if not text or not text.strip():
|
|
return CondMemberRefusal("empty")
|
|
sentences = [
|
|
(m.group(1), m.group(2)) for m in _SENTENCE_RE.finditer(text) if m.group(1).strip()
|
|
]
|
|
if not sentences:
|
|
return CondMemberRefusal("empty")
|
|
|
|
premise_records: list[_Singular | _Universal | _Connective] = []
|
|
record_details: list[str] = []
|
|
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)
|
|
conclusion = _parse_conclusion(rest, body)
|
|
query_text = " ".join(rest)
|
|
continue
|
|
if len(premise_texts) >= MAX_PREMISE_SENTENCES:
|
|
raise _Reject("too_many_premises", body)
|
|
records = _parse_premise_records(toks, body)
|
|
premise_records.extend(records)
|
|
record_details.extend([body] * len(records))
|
|
premise_texts.append(" ".join(toks))
|
|
|
|
if conclusion is None or query_text is None:
|
|
return CondMemberRefusal("no_conclusion")
|
|
if not premise_records:
|
|
return CondMemberRefusal("no_premises")
|
|
|
|
# --- lowering (ADR-0259 §2): shared with v3-MEM's two-pass scheme --
|
|
all_records: list[_Singular | _Universal | _Connective] = [
|
|
*premise_records, conclusion,
|
|
]
|
|
|
|
# Named individuals, first-appearance order — scanning bare
|
|
# singulars AND every leaf nested inside a connective.
|
|
individuals: list[tuple[str, ...]] = []
|
|
|
|
def _visit_individual(ind: tuple[str, ...]) -> None:
|
|
if ind not in individuals:
|
|
individuals.append(ind)
|
|
|
|
for rec in all_records:
|
|
if isinstance(rec, _Singular):
|
|
_visit_individual(rec.ind)
|
|
elif isinstance(rec, _Connective):
|
|
for leaf in rec.leaves:
|
|
_visit_individual(leaf.ind)
|
|
|
|
# Class groups: each attested form joins the FIRST group whose
|
|
# representative it links to — greedy, deterministic, closed (the
|
|
# SAME morphology relation as v3-MEM).
|
|
group_of: dict[tuple[str, ...], int] = {}
|
|
reps: list[tuple[str, ...]] = []
|
|
|
|
def _register_form(form: tuple[str, ...]) -> None:
|
|
if form in group_of:
|
|
return
|
|
for gi, rep in enumerate(reps):
|
|
if _forms_link(form, rep):
|
|
group_of[form] = gi
|
|
return
|
|
group_of[form] = len(reps)
|
|
reps.append(form)
|
|
|
|
for rec in all_records:
|
|
if isinstance(rec, _Singular):
|
|
_register_form(rec.cls)
|
|
elif isinstance(rec, _Universal):
|
|
_register_form(rec.subject)
|
|
_register_form(rec.predicate)
|
|
elif isinstance(rec, _Connective):
|
|
for leaf in rec.leaves:
|
|
_register_form(leaf.cls)
|
|
|
|
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]
|
|
|
|
def atom_of_singular(fact: _Singular, detail: str) -> str:
|
|
atom = atom_for(individuals.index(fact.ind), group_of[fact.cls], detail)
|
|
return f"~({atom})" if fact.negated else atom
|
|
|
|
def render_formula(node: _Formula, leaves: tuple[_Singular, ...], detail: str) -> str:
|
|
if isinstance(node, _Lit):
|
|
return atom_of_singular(leaves[node.leaf], detail)
|
|
left = render_formula(node.left, leaves, detail)
|
|
right = render_formula(node.right, leaves, detail)
|
|
if isinstance(node, _And):
|
|
return f"({left}) & ({right})"
|
|
if isinstance(node, _Or):
|
|
return f"({left}) | ({right})"
|
|
return f"({left}) -> ({right})" # _Implies
|
|
|
|
premise_formulas: list[str] = []
|
|
for rec, detail in zip(premise_records, record_details):
|
|
if isinstance(rec, _Singular):
|
|
premise_formulas.append(atom_of_singular(rec, detail))
|
|
elif isinstance(rec, _Connective):
|
|
premise_formulas.append(render_formula(rec.formula, rec.leaves, detail))
|
|
else: # _Universal — instantiate at every named individual
|
|
for ind_idx in range(len(individuals)):
|
|
antecedent = atom_for(ind_idx, group_of[rec.subject], detail)
|
|
consequent = atom_for(ind_idx, group_of[rec.predicate], detail)
|
|
premise_formulas.append(
|
|
f"({antecedent}) -> (~({consequent}))"
|
|
if rec.negative
|
|
else f"({antecedent}) -> ({consequent})"
|
|
)
|
|
|
|
query_formula = atom_of_singular(conclusion, query_text)
|
|
except (_Reject, _MemberReject) as rej:
|
|
# ``_parse_singular``/``_parse_universal`` are v3-MEM's own functions,
|
|
# reused verbatim — their failures raise v3-MEM's OWN ``_Reject``
|
|
# class (same shape, different identity), so both are caught here and
|
|
# normalized to THIS band's refusal type.
|
|
return CondMemberRefusal(rej.refusal.reason, rej.refusal.detail)
|
|
|
|
connectives = [r for r in premise_records if isinstance(r, _Connective)]
|
|
has_universal = any(isinstance(r, _Universal) for r in premise_records)
|
|
if has_universal and connectives:
|
|
band = EN_CONDMEM_FUSED
|
|
elif any(_contains_or(c.formula) for c in connectives):
|
|
band = EN_CONDMEM_DISJUNCTIVE
|
|
elif sum(_count_implies(c.formula) for c in connectives) >= 2:
|
|
band = EN_CONDMEM_CHAIN
|
|
else:
|
|
band = EN_CONDMEM_CONDITIONAL
|
|
|
|
return CondMemberArgument(
|
|
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",
|
|
"CondMemberArgument",
|
|
"CondMemberRefusal",
|
|
"read_cond_member_argument",
|
|
]
|