core/generate/proof_chain/render.py
Shay 5ba1359a79 fix(generate): CORE writes plural nouns in categorical clauses (Phase 2B)
The ratified, flag-ON v1b categorical band served "all dog are mammal" for
every noun: the A/E/I/O templates supply all/no/some + are, which demand a
plural, and the slots were filled with singular canonical entity ids. Four
of the 47 ratified corpus cases were affected, and wrong=0 never noticed
because every verdict was correct — only the prose was wrong.

Two fixes that compose into a closed round trip:

1. generate/morphology.py becomes the single owner of the number RULES
   (Phase 2A gave the TABLES one owner). Both directions live there:
   pluralize + singularize, over lexicon.IRREGULAR_PLURALS /
   IRREGULAR_SINGULARS, with mass-noun and compound-head handling.
   templates.pluralize and reader._singularize were two independent copies
   of the regular suffix rules that disagreed about which irregulars they
   knew; both now delegate.

2. render._display_noun re-inflects at render time and swaps _ for a space,
   so compound ids read "guard dogs" rather than "guards dog" or
   "guard_dog".

Result: reader gives wolves -> wolf, renderer gives wolf -> wolves. The
round trip closes.

  0 malformed of 47 (from 4)
  reader-vs-reader singularization 20/20 (from 8/20), 0 silently wrong
  s_surface_match_rate 0.0 -> 0.625, now EQUAL to s_renderable_rate, so the
    only remaining losses are surfaces the renderer cannot express at all

NEAR-MISS — widening a reader broke soundness, caught by the lane:

Routing the reader through the full 29-entry table first produced wrong=1 on
band v6-EX. ds-ex-0012 ("No fish are mammals. Therefore some fish are
mammals.") answered invalid where gold is refuted.

_singularize returning None makes the reader REFUSE, and the serving
composer tries the categorical band FIRST, falling through to more capable
bands. "fish" previously returned None (it matches no suffix rule), so v6-EX
got the case and decided it correctly. Resolving "fish" made v1b accept a
sentence it cannot decide.

Fixed by a principle, not a patch: a number-INVARIANT form is ambiguous in
number — "fish are mammals" is plural, "a fish is a mammal" is singular, and
the token cannot tell you which — so a reader that must not guess number
declines it. lexicon.INVARIANT_NUMBER is derived from the singularizer's own
key == value rows, so the rule cannot drift from the table.

  => Coverage and correctness are different axes. Fixing a corrupted VALUE is
     safe; widening ACCEPTANCE changes which band answers, and in a
     first-match composer that turns a right answer into a wrong one. Same
     family as ADR-0261 5.1 refuse-don't-drop.

Also fixed, found by testing the inverse law: the two number tables were not
mutual inverses. The pluralizer lacked cactus->cacti, fungus->fungi,
die->dice and the invariants aircraft/means/offspring, so CORE could read
"cacti" but wrote "cactuses", and would have written "aircrafts", "meanses",
"offsprings". No round trip can close across a non-invertible table.

THE LANE SHA PINS ARE BLIND TO SERVED ENGLISH:

2B changed 4 served surfaces and the pins came back 11/11 byte-identical.
The deduction_serve_v1 hashed report holds only n/counts/by_gold/
correct_by_gold/all_cases_correct/mismatch_examples — no prose at all.
Confirmed by sabotage: with _display_noun returning "SABOTAGE_" + ..., so
every clause reads "all SABOTAGE_dogs are SABOTAGE_animals",

  11 lane SHA pins                        -> 11/11 byte-identical, blind
  test_deduction_serve_lane + _license    -> 20 passed, blind
  Phase 1 grammar_roundtrip               -> 3 tests RED, caught it

This corrects #129: byte-identity of the pins is the arbiter for values and
verdicts, NOT for surface text, so the 2A/2B split is not a partition of
"changes users can see." Phase 2A's 11/11 conclusion still stands on its own
independent evidence (24/24 table-equality checks vs the pre-migration
literals), but the justification was thinner than stated.

Before Phase 1 no test in the tree would have noticed CORE's served prose
turning into word salad. That is exactly how "all dog are mammal" survived
on a ratified flag-ON band with wrong=0 intact.

Deliberately not fixed: mass-noun verb agreement ("all evidence ARE truth"
should be "is"). The copula is fixed text inside the templates, so agreeing
it changes the template shape rather than a slot, and no mass noun reaches a
categorical clause in any serve corpus. Recorded in render.py.

No lane pin edited — none moved.

[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 unchanged; deductive 403 passed (383 + 20 new/rewritten);
scripts/verify_lane_shas.py 11/11 (see blindness note above — gate on
grammar_roundtrip for surface work, not on these pins).
2026-07-26 18:42:42 -07:00

285 lines
12 KiB
Python

"""Deterministic surface rendering for a propositional ``EntailmentTrace``
(deduction-serve arc, Phase 1).
Renders the four ``Entailment`` outcomes into user-facing prose. Deterministic
templates only — no LLM, no synthesis; every visible token is either fixed
prose or a formula string ``to_deductive_logic`` produced directly from the
user's own text. This is the ONLY renderer for propositional-deduction
serving; it must not be confused with ``generate.determine.render`` (which
renders realized-structure DETERMINE answers — a different gear entirely).
"""
from __future__ import annotations
from generate.morphology import pluralize
from generate.proof_chain.entail import (
INCONSISTENT_PREMISES,
Entailment,
EntailmentTrace,
)
#: A/E/I/O categorical form → an English sentence template over (subject, predicate).
#:
#: Every template takes a PLURAL noun on both sides — ``all``/``no``/``some``
#: with ``are``. The slots are filled from canonical entity ids, which are
#: SINGULAR lowercase lemmas (``dog``, ``mammal``), so filling them raw emitted
#: ``all dog are mammal``. Phase 2B re-inflects at render time; see
#: :func:`_display_noun`.
_CATEGORICAL_PHRASE = {
"A": "all {s} are {p}",
"E": "no {s} are {p}",
"I": "some {s} are {p}",
"O": "some {s} are not {p}",
}
def render_entailment(
trace: EntailmentTrace, premises: tuple[str, ...], query: str
) -> str:
"""The user-facing surface for a propositional deduction verdict.
Every branch is a fixed template around the literal premise/query
formula strings — no fabricated content. ``trace.outcome`` selects the
template; REFUSED further branches on ``trace.reason`` since an
inconsistent-premises refusal and an out-of-regime refusal warrant
different honest phrasing.
"""
given = "; ".join(premises)
if trace.outcome is Entailment.ENTAILED:
return f"Given: {given}. Your premises entail: {query}."
if trace.outcome is Entailment.REFUTED:
return (
f"Given: {given}. Your premises entail the opposite of "
f"{query} — it cannot hold."
)
if trace.outcome is Entailment.UNKNOWN:
return (
f"Given: {given}. Your premises don't settle whether "
f"{query} — it holds in some cases and fails in others."
)
# REFUSED
if trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {given}. Those premises are inconsistent — they "
f"can't all be true, so I won't assert anything from them."
)
return f"Given: {given}. I can't evaluate {query} from that as stated."
def render_entailment_english(
trace: EntailmentTrace, premise_texts: tuple[str, ...], query_text: str
) -> str:
"""The user-facing surface for an English-clause (Band v2-EN) verdict.
Same four-outcome discipline as :func:`render_entailment`, but the visible
tokens are the user's own normalized clauses (``EnglishArgument`` texts),
not formula strings. The ONE deliberate divergence is the UNKNOWN template:
an opaque-atom "doesn't settle" is only true *of the reading* (internal
clause structure this band did not parse could settle it — ADR-0257 §3),
so the phrasing scopes the claim to that reading explicitly. ENTAILED /
REFUTED / inconsistent are substitution-closed — true under any deeper
reading — and are stated at full strength.
"""
given = "; ".join(premise_texts)
if trace.outcome is Entailment.ENTAILED:
return f"Given: {given}. Your premises entail: {query_text}."
if trace.outcome is Entailment.REFUTED:
return (
f"Given: {given}. Your premises entail the opposite of "
f"{query_text} — it cannot hold."
)
if trace.outcome is Entailment.UNKNOWN:
return (
f"Given: {given}. Reading each clause as one indivisible claim, "
f"your premises don't settle whether {query_text} — it holds in "
f"some cases and fails in others."
)
# REFUSED
if trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {given}. Those premises are inconsistent — they "
f"can't all be true, so I won't assert anything from them."
)
return f"Given: {given}. I can't evaluate {query_text} from that as stated."
def render_entailment_member(
trace: EntailmentTrace, premise_texts: tuple[str, ...], query_text: str
) -> str:
"""The user-facing surface for a member-argument (Band v3-MEM) verdict.
Same discipline as :func:`render_entailment_english`; the UNKNOWN template
scopes itself to THIS band's reading (ADR-0258 §3): distinct names are
read as distinct individuals and class words at face value — co-reference
or an unlinked class identity could settle what the lowering leaves open,
so the claim is stated of the reading, disclosed as such. ENTAILED /
REFUTED / inconsistent survive any such refinement (instantiation is
sound and entailment is monotone) and are stated at full strength.
"""
given = "; ".join(premise_texts)
if trace.outcome is Entailment.ENTAILED:
return f"Given: {given}. Your premises entail: {query_text}."
if trace.outcome is Entailment.REFUTED:
return (
f"Given: {given}. Your premises entail the opposite of "
f"{query_text} — it cannot hold."
)
if trace.outcome is Entailment.UNKNOWN:
return (
f"Given: {given}. Reading each name as one individual and each "
f"class word at face value, your premises don't settle whether "
f"{query_text} — it holds in some cases and fails in others."
)
# REFUSED
if trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {given}. Those premises are inconsistent — they "
f"can't all be true, so I won't assert anything from them."
)
return f"Given: {given}. I can't evaluate {query_text} from that as stated."
def render_entailment_verb(
trace: EntailmentTrace, premise_texts: tuple[str, ...], query_text: str
) -> str:
"""The user-facing surface for a verb-predicate (Band v5-VP) verdict.
Same discipline as :func:`render_entailment_member`; the UNKNOWN template
additionally scopes the verb reading (ADR-0260 §3): each verb phrase is
read at face value — an unlinked agreement form or an unstated relation
between a verb and a class word could settle what the lowering leaves
open, so the claim is stated of the reading, disclosed as such. ENTAILED /
REFUTED / inconsistent survive any such refinement (instantiation is
sound and entailment is monotone) and are stated at full strength.
"""
given = "; ".join(premise_texts)
if trace.outcome is Entailment.ENTAILED:
return f"Given: {given}. Your premises entail: {query_text}."
if trace.outcome is Entailment.REFUTED:
return (
f"Given: {given}. Your premises entail the opposite of "
f"{query_text} — it cannot hold."
)
if trace.outcome is Entailment.UNKNOWN:
return (
f"Given: {given}. Reading each name as one individual and each "
f"class word and verb phrase at face value, your premises don't "
f"settle whether {query_text} — it holds in some cases and fails "
f"in others."
)
# REFUSED
if trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {given}. Those premises are inconsistent — they "
f"can't all be true, so I won't assert anything from them."
)
return f"Given: {given}. I can't evaluate {query_text} from that as stated."
def render_entailment_exist(
trace: EntailmentTrace, premise_texts: tuple[str, ...], query_text: str
) -> str:
"""The user-facing surface for an existential (Band v6-EX) verdict.
Same discipline as :func:`render_entailment_verb`; the UNKNOWN template
additionally discloses the ONE reading choice existentials force
(ADR-0261 §3): "some" is read WITHOUT existential import, so a universal
says nothing about whether its class has members. That is precisely what
makes "all C are P, therefore some C are P" come out unsettled rather than
entailed, and a reader who expects the traditional square deserves to be
told which square this is. ENTAILED / REFUTED / inconsistent hold under
the classical reading too (adding existential-import premises only ADDS
premises, and entailment is monotone), so they are stated at full strength.
"""
given = "; ".join(premise_texts)
if trace.outcome is Entailment.ENTAILED:
return f"Given: {given}. Your premises entail: {query_text}."
if trace.outcome is Entailment.REFUTED:
return (
f"Given: {given}. Your premises entail the opposite of "
f"{query_text} — it cannot hold."
)
if trace.outcome is Entailment.UNKNOWN:
return (
f"Given: {given}. Reading each name as one individual, each class "
f"word and verb phrase at face value, and \"some\" as claiming a "
f"member exists (so \"all\" does not), your premises don't settle "
f"whether {query_text} — it holds in some cases and fails in others."
)
# REFUSED
if trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {given}. Those premises are inconsistent — they "
f"can't all be true, so I won't assert anything from them."
)
return f"Given: {given}. I can't evaluate {query_text} from that as stated."
def _display_noun(term: str) -> str:
"""A canonical entity id → the plural noun phrase a categorical clause needs.
Two transformations, both required for well-formed output:
1. **Number.** Ids are singular lemmas but every A/E/I/O template supplies
``all``/``no``/``some`` + ``are``, which demand a plural. Mass nouns are
left alone by :func:`generate.morphology.pluralize` ("all evidence", not
"all evidences").
2. **Word separator.** Ids join tokens with ``_`` (``guard_dog``), which is
machine syntax, not English. Compounds inflect on the head, so
pluralizing before the swap gives "guard dogs" rather than "guards dog".
Deliberately NOT done here: mass-noun verb agreement. "all evidence are
truth" should read "all evidence is truth", but the copula is fixed text
inside the templates, and making it agree changes the template shape rather
than the slot. Left as a separate, smaller defect — no mass noun currently
reaches a categorical clause in any serve corpus.
"""
if not term:
return term
return pluralize(term).replace("_", " ")
def _categorical_clause(prop: dict) -> str:
"""One categorical proposition dict → its English clause."""
template = _CATEGORICAL_PHRASE.get(prop.get("form", ""), "{s} ~ {p}")
return template.format(
s=_display_noun(prop.get("subject", "?")),
p=_display_noun(prop.get("predicate", "?")),
)
def render_syllogism(trace: EntailmentTrace, structure: dict, query: dict) -> str:
"""The user-facing surface for a categorical (syllogism) verdict.
Deterministic templates over the argument's own clauses — no synthesis. The
``EntailmentTrace`` is the propositional-lowering decision (``ENTAILED`` ⇒
valid; ``UNKNOWN``/``REFUTED`` ⇒ invalid; ``REFUSED`` ⇒ inconsistent).
"""
premises = "; ".join(
_categorical_clause(p) for p in structure.get("premises", [])
)
conclusion = _categorical_clause(query.get("conclusion", {}))
if trace.outcome is Entailment.ENTAILED:
return f"Given: {premises}. That's valid — {conclusion} follows."
if trace.outcome is Entailment.REFUSED and trace.reason == INCONSISTENT_PREMISES:
return (
f"Given: {premises}. Those premises are inconsistent — they can't all "
f"be true, so I won't assert anything from them."
)
if trace.outcome is Entailment.REFUSED:
return f"Given: {premises}. I can't evaluate {conclusion} from that as stated."
# UNKNOWN or REFUTED — the conclusion is not forced by the premises.
return (
f"Given: {premises}. That doesn't follow — {conclusion} isn't guaranteed "
f"by those premises."
)
__all__ = [
"render_entailment",
"render_entailment_english",
"render_entailment_exist",
"render_entailment_member",
"render_entailment_verb",
"render_syllogism",
]