core/tests/test_verb_argument_reader.py
Shay d181ae30f9 feat(deduction-serve): Band v5-VP — verb-predicate arguments, decided (ADR-0260)
Generalization arc Phase 1.1 (Tier F, docs/plans/generalization-arc-2026-07-24.md):
"All philosophers teach. Socrates is a philosopher. Therefore Socrates
teaches." now reads and decides — the ADR-0258 §6.3 verb-predicate
scope-out, and the reading gate for Phase 2's subject serving.

generate/proof_chain/verb.py extends v3-MEM's per-individual lowering with
a second atom family in one shared space: membership atoms (v3-MEM's own
parsers reused verbatim) + verb atoms (individual, verb-lemma-group,
object-term). Verb universals instantiate as mem(i,C) -> [~]verb(i), so a
copula-minted membership fact discharges a verb rule. The ONE new semantic
identification is closed 3sg agreement (verb-specific irregular table +
the three regular suffix rules — deliberately NOT the noun table, which
would misroute "lives"/"leaves"). Scope-tight segmentation: single-token
name/class/verb/object shapes only; everything longer refuses typed
(tense_out_of_band joins the closed reason vocabulary). Rendered by
render_entailment_verb (member surface, UNKNOWN scoping extended to the
verb reading). Rides deduction_serving_enabled (default off); pure
widening — every previously-served argument byte-identical, no existing
lane case changed outcome.

Earned: 4 en_verb_* bands x 720 arena wrong=0 (first seal), ledger resealed
at 21 bands; corpus soundness asserted against the independent truth-table
oracle over each template's intended lowering (15,120 cases, INV-25).
Lane: v2_verb 28 hand-authored real cases 28/28 first run; full lane
134/134 wrong=0 across five splits. Surgical single-line pin update +
CLAIMS regen.

Stacked on feat/generalization-phase0 (merge that first — shared
verify_lane_shas.py / CLAIMS.md / generate_claims.py lane-ADR mapping).

[Verification]: verb reader tests 34/34; deduction battery 86/86
(surface +5, e2e +1, lane +1) in-worktree; arena seal + oracle
cross-check green; smoke + warmed_session via pre-push gate.
2026-07-24 12:51:36 -07:00

250 lines
9.5 KiB
Python

"""Band v5-VP (ADR-0260) — verb-predicate argument reader contract.
The reader extends v3-MEM's per-individual propositional lowering with a
second atom family — one atom per *(individual, verb-lemma-group,
object-term)* — in one shared atom space, so a verb universal's
instantiation can be discharged by a membership fact minted from a copula
sentence. It is pure and deterministic. Soundness rides on the ROBDD engine;
these tests pin that the reader hands it the RIGHT problem — in particular
that third-person-singular agreement links a universal's base verb form to a
singular fact's 3sg form via the closed verb relation (NOT the noun table).
"""
from __future__ import annotations
import pytest
from generate.proof_chain.member import MemberRefusal, read_member_argument
from generate.proof_chain.shape import (
EN_VERB_CHAIN,
EN_VERB_FACT,
EN_VERB_NEGATIVE,
EN_VERB_UNIVERSAL,
)
from generate.proof_chain.verb import (
MAX_ATOMS,
MAX_PREMISE_SENTENCES,
VerbArgument,
VerbRefusal,
read_verb_argument,
)
def _read(text: str) -> VerbArgument:
arg = read_verb_argument(text)
assert isinstance(arg, VerbArgument), arg
return arg
def _refusal(text: str) -> VerbRefusal:
ref = read_verb_argument(text)
assert isinstance(ref, VerbRefusal), ref
return ref
# --- the gap this band fills is real -----------------------------------------
def test_the_gap_this_band_fills_is_real() -> None:
"""The exact ADR-0258 §6.3 verb-predicate scope-out: v3-MEM refuses verb
sentences (no copula ⇒ ``sentence_shape_out_of_band``); this band decides
them. Both facts hold at once — v3-MEM is unchanged, this is a new tier."""
text = "All philosophers teach. Socrates is a philosopher. Therefore Socrates teaches."
assert isinstance(read_member_argument(text), MemberRefusal)
assert isinstance(read_verb_argument(text), VerbArgument)
# --- the flagship reading -----------------------------------------------------
def test_flagship_universal_verb_instantiation() -> None:
arg = _read(
"All philosophers teach. Socrates is a philosopher. Therefore Socrates teaches."
)
assert arg.band == EN_VERB_UNIVERSAL
# Universal instantiates at the one named individual: mem -> verb; the
# membership fact anchors it; the query is the instantiated verb atom.
assert arg.premise_formulas == ("(a0) -> (a1)", "a0")
assert arg.query_formula == "a1"
assert arg.atoms == ("socrates : philosophers", "socrates : teach")
def test_agreement_links_base_and_third_person_forms() -> None:
# +s rule: live/lives must land in ONE verb group (and must NOT be routed
# through the noun table, where "lives" is the plural of "life").
arg = _read("All men live. Socrates is a man. Therefore Socrates lives.")
assert arg.query_formula == "a1" # same atom as the instantiated consequent
assert "socrates : live" in arg.atoms
@pytest.mark.parametrize(
"universal_form, conclusion_form",
[
("teach", "teaches"), # +es after sibilant
("study", "studies"), # y -> ies
("go", "goes"), # irregular table
("bark", "barks"), # +s
],
)
def test_agreement_rules_each_link(universal_form: str, conclusion_form: str) -> None:
arg = _read(
f"All wolves {universal_form}. Rex is a wolf. Therefore Rex {conclusion_form}."
)
assert arg.band == EN_VERB_UNIVERSAL
assert arg.premise_formulas == ("(a0) -> (a1)", "a0")
assert arg.query_formula == "a1"
def test_transitive_object_is_part_of_the_atom_key() -> None:
arg = _read(
"All poets write verses. Homer is a poet. Therefore Homer writes verses."
)
assert arg.query_formula == "a1"
assert "homer : write verses" in arg.atoms
# A different object is a DIFFERENT atom — arity/object at face value.
arg2 = _read(
"All poets write verses. Homer is a poet. Therefore Homer writes epics."
)
assert arg2.query_formula == "a2"
def test_negative_universal_instantiates_negated() -> None:
arg = _read("Miriam is a nun. No nuns marry. Therefore Miriam does not marry.")
assert arg.band == EN_VERB_NEGATIVE
assert arg.premise_formulas == ("a0", "(a0) -> (~(a1))")
assert arg.query_formula == "~(a1)"
def test_membership_chain_discharges_verb_universal() -> None:
arg = _read(
"Felix is a tortoise. All tortoises are reptiles. All reptiles crawl. "
"Therefore Felix crawls."
)
assert arg.band == EN_VERB_CHAIN
assert arg.premise_formulas == ("a0", "(a0) -> (a1)", "(a1) -> (a2)")
assert arg.query_formula == "a2"
def test_sentential_not_wraps_a_verb_fact() -> None:
arg = _read(
"Boris does not smoke. Therefore it is not the case that Boris smokes."
)
assert arg.band == EN_VERB_FACT
assert arg.premise_formulas == ("~(a0)",)
assert arg.query_formula == "~(a0)"
def test_universal_does_not_leak_across_individuals() -> None:
arg = _read(
"All monks chant. Basil is a monk. Therefore Gregory chants."
)
# The universal instantiates at BOTH named individuals; the query is
# Gregory's verb atom, whose membership antecedent nothing anchors.
assert arg.premise_formulas == ("(a0) -> (a1)", "(a2) -> (a3)", "a0")
assert arg.query_formula == "a3"
assert arg.atoms[2] == "gregory : monks"
def test_verb_and_class_words_never_cross_families() -> None:
# "teacher" (class) and "teaches" (verb) must stay distinct atoms even
# though the noun rules could relate the strings.
arg = _read("Socrates teaches. Therefore Socrates is a teacher.")
assert arg.premise_formulas == ("a0",)
assert arg.query_formula == "a1"
# --- band classification ------------------------------------------------------
@pytest.mark.parametrize(
"text, band",
[
("All poets write. Homer is a poet. Therefore Homer writes.",
EN_VERB_UNIVERSAL),
("Homer is a poet. All poets are artists. All artists dream. Therefore Homer dreams.",
EN_VERB_CHAIN),
("Homer is a poet. No poets whistle. Therefore Homer does not whistle.",
EN_VERB_NEGATIVE),
("Homer writes. Homer is a poet. Therefore Homer writes.",
EN_VERB_FACT),
],
)
def test_band_classification(text: str, band: str) -> None:
assert _read(text).band == band
# --- typed refusals -----------------------------------------------------------
@pytest.mark.parametrize(
"text, reason",
[
("Caesar did not surrender. Therefore Caesar surrenders.",
"tense_out_of_band"),
("The old sailor whittles. Therefore Socrates naps.",
"sentence_shape_out_of_band"),
("Nero fiddles in Rome. Therefore Nero fiddles.",
"sentence_shape_out_of_band"),
("Hannah gives Samuel bread. Therefore Hannah gives Samuel bread.",
"sentence_shape_out_of_band"),
# "do not" after a multi-token subject: the negation is real but the
# grammar cannot normalize it (the exact ``<name> does not`` shape is
# the only one read), so the honest reason is the unread negation.
("The twins do not quarrel. Therefore the twins quarrel.",
"internal_negation_unread"),
("If Noah builds then Noah rests. Noah builds. Therefore Noah rests.",
"mixed_structure_out_of_band"),
("Someone sneezes. Therefore Ora sneezes.",
"quantifier_out_of_band"),
("All men do not teach. Socrates is a man. Therefore Socrates teaches.",
"internal_negation_unread"),
("Jonah preaches. Therefore all prophets preach.",
"universal_conclusion_out_of_band"),
("Socrates teaches. Do philosophers teach?",
"question_sentence"),
# Delegated copula parses keep v3-MEM's own typed refusals.
("Dogs are loyal. Rex barks. Therefore Rex barks.",
"bare_plural_out_of_band"),
("Socrates is the philosopher. Socrates teaches. Therefore Socrates teaches.",
"definite_description_out_of_band"),
("Socrates is a man who teaches. Therefore Socrates teaches.",
"relative_clause_out_of_band"),
# A pure-copula argument is never decided by this band (defensive
# closure — such texts belong to v3-MEM/v4-CM).
("Socrates is a man. Therefore Socrates is a man.",
"sentence_shape_out_of_band"),
],
)
def test_typed_refusals(text: str, reason: str) -> None:
assert _refusal(text).reason == reason
# --- honesty caps -------------------------------------------------------------
def test_premise_cap_refuses_not_truncates() -> None:
body = " ".join(f"Zed{i} naps." for i in range(MAX_PREMISE_SENTENCES + 1))
ref = _refusal(body + " Therefore Zed0 naps.")
assert ref.reason == "too_many_premises"
def test_atom_cap_refuses_not_truncates() -> None:
# Each premise mints one verb atom; 16 premises fit the premise cap, but
# a universal instantiated at every individual overflows MAX_ATOMS.
names = [f"Kip{i}" for i in range(12)]
body = " ".join(f"{n} naps." for n in names)
body += " " + " ".join(f"{n} is a squirrel." for n in names[:4])
text = body + " All squirrels forage. Therefore Kip0 forages."
ref = _refusal(text)
assert ref.reason in ("too_many_atoms", "too_many_premises")
# --- determinism --------------------------------------------------------------
def test_reading_is_deterministic() -> None:
text = (
"All poets write. Homer is a poet. Therefore Homer writes."
)
assert read_verb_argument(text) == read_verb_argument(text)
assert MAX_ATOMS == 24 and MAX_PREMISE_SENTENCES == 16