core/tests/test_english_argument_reader.py
Shay a83b35de07 feat(deduction-serve): Band v2-EN — natural-English argument serving, earned licenses (ADR-0257)
CORE now decides natural-English deductive arguments end-to-end:
'If it rains then the ground is wet. It rains. Therefore the ground
is wet.' -> ENTAILED, rendered over the user's own clauses. Both of
ADR-0256's documented boundary cases (multiword conditional, nested-
negation contraposition) are now decided; their lane gold updated
declined->entailed.

- generate/proof_chain/english.py (new): closed function-word grammar
  over OPAQUE clause-atoms (minted ids); if/then, [either] or, and,
  three negation forms (leading not / sentential / copular incl.
  contractions); typed refusals; honesty caps. Soundness: positive
  verdicts are substitution-closed; UNKNOWN is scoped + guarded
  (quantifier-led, is-a membership, unnormalizable negation refuse
  out of the opaque band).
- render_entailment_english: verdicts quoted back verbatim; UNKNOWN
  phrasing explicitly scoped to the opaque reading.
- chat/deduction_surface.py: v2-EN fallback tier AFTER v1/v1b —
  monotone widening, byte-identical on previously-served arguments.
- Four en_* shape-bands EARN SERVE via the ADR-0199 arena (720/band,
  wrong=0, reliability 0.99087 >= 0.99); 9-band ledger re-sealed.
- evals/deduction_serve/v2_en: 26 hand-authored real-English cases
  (content disjoint from the synthetic lexicon), wrong=0; report
  re-pinned.
- realizer guard (ADR-0075): deduction surfaces exempt — quoted
  templates are not slot-composed articulations (pack 'open'=VERB
  was rejecting an honest quoted 'the door is not open'); pinned
  cold+warm in e2e.

[Verification]: deductive 84 passed; smoke 180 passed; cognition 122
passed 1 skipped; arena 9x720 wrong=0 all SERVE; lanes v1 28/28,
v2_en 26/26.
2026-07-23 14:37:50 -07:00

206 lines
8.8 KiB
Python

"""Band v2-EN (ADR-0257) — English-clause argument reader contract.
Pins the closed function-word grammar of ``generate.proof_chain.english``:
- structure recognition (conditional / disjunction / conjunction / the three
negation forms) over OPAQUE clause-atoms, with atom identity by normalized
exact text (same clause → same atom; distinct wording → distinct atoms);
- the ``en_*`` shape-band classification the earned license keys on;
- every structural guard that keeps the substitution-soundness argument
airtight: quantifier-led categorical clauses, is-a membership clauses, and
unnormalizable negation all REFUSE out of the opaque band (typed);
- determinism (same text → same reading).
"""
from __future__ import annotations
import pytest
from generate.proof_chain.english import (
EnglishArgument,
EnglishRefusal,
read_english_argument,
)
from generate.proof_chain.shape import (
EN_ATOMIC,
EN_CONDITIONAL_CHAIN,
EN_CONDITIONAL_SINGLE,
EN_DISJUNCTIVE,
)
def _read(text: str) -> EnglishArgument:
arg = read_english_argument(text)
assert isinstance(arg, EnglishArgument), getattr(arg, "reason", arg)
return arg
def _refusal(text: str) -> EnglishRefusal:
arg = read_english_argument(text)
assert isinstance(arg, EnglishRefusal), arg
return arg
# ---------------------------------------------------------------------------
# Structure recognition
# ---------------------------------------------------------------------------
def test_modus_ponens_reads_to_implication_and_shared_atom() -> None:
arg = _read("If it rains then the ground is wet. It rains. Therefore the ground is wet.")
assert arg.premise_formulas == ("(a0) -> (a1)", "a0")
assert arg.query_formula == "a1"
assert arg.atoms == ("it rains", "the ground is wet")
assert arg.band == EN_CONDITIONAL_SINGLE
assert arg.premise_texts == ("if it rains then the ground is wet", "it rains")
assert arg.query_text == "the ground is wet"
def test_atom_identity_is_normalized_exact_text() -> None:
"""Case and commas normalize away; different wording stays distinct."""
arg = _read("The Report Is Filed. Therefore the report is filed.")
assert arg.premise_formulas == ("a0",)
assert arg.query_formula == "a0" # same atom — case-insensitive identity
distinct = _read("The report is filed. Therefore the report was filed.")
assert distinct.premise_formulas == ("a0",)
assert distinct.query_formula == "a1" # tense differs → distinct atom
def test_copular_negation_normalizes_to_engine_negation() -> None:
arg = _read(
"If the alarm is armed then the light is on. The light is not on. "
"Therefore the alarm is not armed."
)
assert arg.premise_formulas == ("(a0) -> (a1)", "~(a1)")
assert arg.query_formula == "~(a0)"
assert arg.atoms == ("the alarm is armed", "the light is on")
def test_contractions_and_sentential_negation_normalize() -> None:
arg = _read("The door isn't open. Therefore it is not the case that the door is open.")
assert arg.premise_formulas == ("~(a0)",)
assert arg.query_formula == "~(a0)"
assert arg.atoms == ("the door is open",)
def test_leading_not_negates_inside_a_conditional() -> None:
"""Nested negation in conditional slots — the formerly-out-of-band
contraposition shape."""
arg = _read("If p then q. Therefore if not q then not p.")
assert arg.premise_formulas == ("(a0) -> (a1)",)
assert arg.query_formula == "(~(a1)) -> (~(a0))"
def test_either_or_reads_to_disjunction() -> None:
arg = _read("Either the door is open or the window is open. Therefore the door is open.")
assert arg.premise_formulas == ("(a0) | (a1)",)
assert arg.band == EN_DISJUNCTIVE
def test_top_level_and_premise_splits_into_premises() -> None:
arg = _read("The oven is hot and the dough is ready. Therefore the dough is ready.")
assert arg.premise_formulas == ("a0", "a1")
assert arg.premise_texts == ("the oven is hot and the dough is ready",)
assert arg.band == EN_ATOMIC
def test_conjunction_and_disjunction_conclusions() -> None:
conj = _read("The kettle is boiling. The toast is ready. Therefore the kettle is boiling and the toast is ready.")
assert conj.query_formula == "(a0) & (a1)"
disj = _read("The train is late. Therefore the train is late or the bus is early.")
assert disj.query_formula == "(a0) | (a1)"
def test_conditional_conclusion_reads() -> None:
arg = _read(
"If it rains then the ground is wet. If the ground is wet then the match is "
"cancelled. Therefore if it rains then the match is cancelled."
)
assert arg.band == EN_CONDITIONAL_CHAIN
assert arg.query_formula == "(a0) -> (a2)"
def test_disjunctive_antecedent_inside_conditional() -> None:
arg = _read("If the pump runs or the valve opens then the tank fills. Therefore the tank fills.")
assert arg.premise_formulas == ("((a0) | (a1)) -> (a2)",)
# band keys on a disjunction appearing in the premises
assert arg.band == EN_DISJUNCTIVE
# ---------------------------------------------------------------------------
# Band classification
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,band", [
("If a is set then b is set. a is set. Therefore b is set.", EN_CONDITIONAL_SINGLE),
("If a is set then b is set. If b is set then c is set. Therefore c is set.", EN_CONDITIONAL_CHAIN),
("The cat is in or the dog is in. Therefore the cat is in.", EN_DISJUNCTIVE),
("The cat is in. Therefore the cat is in.", EN_ATOMIC),
])
def test_band_classification(text: str, band: str) -> None:
assert _read(text).band == band
# ---------------------------------------------------------------------------
# Structural guards — the soundness perimeter (typed refusals)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text,reason", [
# Quantifier-led clause = categorical structure the opaque band must not flatten.
("All whales are mammals. Therefore all whales are mammals.", "categorical_shape_out_of_band"),
("Some doors are open. Therefore some doors are open.", "categorical_shape_out_of_band"),
# is-a membership clause — reserved for a future member_chain band.
("Socrates is a man. Therefore Socrates is a man.", "membership_shape_out_of_band"),
# Negation this band cannot normalize must not hide inside an opaque atom.
("It never snows. Therefore it never snows.", "internal_negation_unread"),
("The engine doesn't start. Therefore the engine doesn't start.", "internal_negation_unread"),
("The tank does not fill. Therefore the tank does not fill.", "internal_negation_unread"),
# Genuinely ambiguous English scope refuses rather than guessing.
("The fan spins and the light is on or the door is open. Therefore the door is open.", "ambiguous_and_or"),
# Nested conditionals are out of the v2-EN grammar.
("If the door is open then if the fan spins then the light is on. Therefore the light is on.", "nested_conditional"),
# Questions are not premises.
("Is the door open? Therefore the door is open.", "question_sentence"),
# The conclusion must be the final sentence, and unique.
("Therefore the door is open. The door is open.", "conclusion_not_last"),
("The door is open. Therefore the door is open. Therefore the window is open.", "conclusion_not_last"),
# An argument needs both parts.
("Therefore the door is open.", "no_premises"),
("The door is open. The window is open.", "no_conclusion"),
# Structural emptiness.
("If then the light is on. Therefore the light is on.", "empty_side"),
])
def test_typed_refusals(text: str, reason: str) -> None:
assert _refusal(text).reason == reason
def test_atom_cap_refuses_rather_than_truncating() -> None:
# 13 disjunctive premises mint 26 distinct atoms (> MAX_ATOMS=24) while
# staying under MAX_PREMISE_SENTENCES — the atom cap is what fires.
premises = " ".join(
f"The unit{i} is live or the pack{i} is live." for i in range(13)
)
refusal = _refusal(premises + " Therefore the unit0 is live.")
assert refusal.reason == "too_many_atoms"
def test_premise_cap_refuses_rather_than_truncating() -> None:
premises = " ".join(f"The unit{i} is live." for i in range(17))
refusal = _refusal(premises + " Therefore the unit0 is live.")
assert refusal.reason == "too_many_premises"
# ---------------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------------
def test_reading_is_deterministic() -> None:
text = (
"If the pump runs then the tank fills. If the valve opens then the tank fills. "
"The pump runs or the valve opens. Therefore the tank fills."
)
assert read_english_argument(text) == read_english_argument(text)