core/generate/templates.py
Shay 7c2b753d1d fix(generate): inflect the head verb on every branch, not just the plural two
Phase 3 fixed two of the twelve branches of `_inflect_predicate` by hand and
pinned them with a hand-written oracle. The other ten kept handing whole
predicate phrases to single-verb functions, so the same root cause was still
live on **57 of 96** (branch x multi-word predicate) pairs, 9 of 12 branches:

    "belongs to"      --perfective-->    "has belongs toed"
    "belongs to"      --imperfective-->  "is belongs toing"
    "belongs to"      --past-->          "belongs toed"
    "is defined as"   --future-->        "will is defined a"
    "contrasts with"  --negated-->       "does not contrasts with"

Every branch now routes through `morphology.inflect_phrase_head`, which applies
a single-verb inflection to the finite verb and carries tokens 2..n through
byte-identically. Two closed tables were needed because `_base_form` is a
suffix stripper and the head of every copular predicate is a form of BE:
`base_form("is")` returned "i" and `present_participle("is")` returned "iing".

Also fixes predicate-nominal object agreement. `render_step` pluralized the
subject and never the object, so it wrote "all dogs are a mammal". The object
agrees only for a predicate nominal -- a closed set of two -- because a
prepositional object carries its own number and a productive rule would write
"all claims are grounded in evidences".

Measured
--------
    tail-mangling pairs          57/96  ->  0/96
    g_read_rate (round-trip)     0.0    ->  0.003413   first non-zero in the arc
    grammatical_coverage v1      49/49  (2 cases corrected, see below)
    english_fluency_ood          117/117 + 39/39 + 13/13   unchanged
    discourse_paragraph          12/12 + 6/6 + 5/5 + 1/1   unchanged
    zero_code_domain_acquisition 18/18 + 30/30 + 21/21     unchanged

`gram_C14_p01` and `gram_C14_p10` expected "...defined as compound", which is
not English. Corrected, and the old string moved into `reject_surfaces` so the
case now actively rejects what it used to accept. The correction is not my
judgment: the reader independently READS "all molecules are defined as
compounds" and REFUSES the singular form, and that is precisely what took
g_read_rate off zero.

Why the invariant instead of a bigger oracle
--------------------------------------------
A per-branch oracle has to be extended by hand for each new branch, and a
branch added without one is invisible -- which is how eight survived Phase 3.
The pin is structural instead: English marks tense, number and aspect on the
finite verb, so inflection must leave tokens 2..n byte-identical. It needs no
oracle and covers branches nobody has written yet.

It is necessary, not sufficient: "does not contrasts with" preserves its tail
perfectly and is still wrong. `test_do_support_puts_the_head_in_the_bare_
infinitive` is the sufficiency half.

Mutation
--------
    baseline                                                84 pass
    inflect_phrase_head applied to the whole phrase          39 FAIL
    _IRREGULAR_BASE emptied                                   2 FAIL
    _IRREGULAR_PARTICIPLE auxiliaries removed                 2 FAIL
    PREDICATIVE_NOMINAL emptied                               3 FAIL
    PREDICATIVE_NOMINAL widened to every copular predicate    2 FAIL

The last row is the one that matters: the plausible-but-wrong fix -- "pluralize
the object under a plural subject" -- is caught by the mass-noun control.

Not fixed here, deliberately: "has not the following steps" is archaic rather
than wrong, and modernizing it to do-support is a separate judgment call.

[Verification]: smoke 621, deductive 406, lane pins 11/11 unchanged
(grammatical_coverage is not a pinned lane; no pin was edited).
2026-07-27 11:17:38 -07:00

223 lines
9.6 KiB
Python

"""Deterministic surface templates for rhetorical moves.
Each template is a format string keyed by RhetoricalMove. Slots:
{subject} — primary subject from the articulation step
{predicate} — semantic predicate (e.g. "is_defined_as", "contrasts_with")
{obj} — object slot from the graph node (may be "<pending>")
Templates are intentionally simple. The goal is structural correctness,
not fluency — fluency comes in a later phase when the generation stream
consumes these as constraints rather than final output.
"""
from __future__ import annotations
from generate.lexicon import (
IRREGULAR_PLURALS,
PLURAL_QUANTIFIERS,
PREDICATE_DISPLAY,
PREDICATIVE_NOMINAL,
)
from generate.articulation_legality import (
ArticulationLegality,
validate_finite_predicate_legality,
)
from generate.graph_planner import RhetoricalMove
from generate.morphology import (
agree_plural_phrase,
base_form,
inflect_phrase_head,
is_mass_noun,
past_participle,
past_tense,
pluralize,
present_participle,
takes_bare_not,
)
# Noun pluralisation — used under quantifiers (all/some/many/few/most).
# Closes english_fluency_ood gaps.md G2 (plural agreement).
#
# Phase 2B: the rules moved to generate/morphology.py, which now owns number
# in both directions. Re-exported here because this module's public surface
# is consumed by the eval runners.
_IRREGULAR_PLURALS: dict[str, str] = IRREGULAR_PLURALS
# Quantifiers that demand plural agreement on the subject + verb.
# "the" / "a" stay singular; "every" / "each" are singular by English
# rule even though semantically universal.
_PLURAL_QUANTIFIERS: frozenset[str] = PLURAL_QUANTIFIERS
_PREDICATE_DISPLAY: dict[str, str] = PREDICATE_DISPLAY
def _humanize_predicate(predicate: str) -> str:
return _PREDICATE_DISPLAY.get(predicate, predicate.replace("_", " "))
def _drop_indefinite_article(predicate_h: str) -> str:
"""Strip a trailing ``a``/``an`` from an inflected predicate.
``is_a`` humanizes to "is a" and pluralizes to "are a"; the article cannot
survive a plural nominal ("all dogs are a mammals" is not English).
"""
head, sep, rest = predicate_h.rpartition(" ")
return head if sep and rest in ("a", "an") else predicate_h
_MOVE_TEMPLATES: dict[RhetoricalMove, str] = {
RhetoricalMove.ASSERT: "{subject} {predicate_h} {obj}",
RhetoricalMove.ELABORATE: "furthermore, {subject} {predicate_h} {obj}",
RhetoricalMove.CONTRAST: "in contrast, {subject} {predicate_h} {obj}",
RhetoricalMove.SEQUENCE: "next, {subject} {predicate_h} {obj}",
RhetoricalMove.CORRECT: "correction: {subject} {predicate_h} {obj}",
}
def _inflect_predicate(
predicate_h: str,
*,
negated: bool = False,
tense: str | None = None,
aspect: str | None = None,
plural_subject: bool = False,
) -> str:
"""Apply tense/aspect/negation to a humanized predicate.
When ``plural_subject`` is true, the conjugation uses plural
agreement (do not / have / are / bare-base verb in present) so
surfaces like "all molecules bind enzyme" come out correctly
instead of "all molecule binds enzyme" (english_fluency_ood G2).
"""
verb = predicate_h
copular = any(
predicate_h.startswith(prefix)
for prefix in ("is ", "are ", "has ", "have ", "belongs ")
)
match (aspect, tense, negated, plural_subject):
# Every branch below inflects the phrase HEAD and carries tokens 2..n
# through untouched. Phase 3 fixed only the two plural branches, so
# these eight were still handing whole phrases to single-verb
# functions: "belongs to" came back "has belongs toed" (perfective),
# "is belongs toing" (imperfective), "belongs toed" (past), and
# "is defined as" came back "will is defined a" (future).
case ("perfective", _, _, True):
return f"have {inflect_phrase_head(verb, past_participle)}"
case ("perfective", _, _, False):
return f"has {inflect_phrase_head(verb, past_participle)}"
case ("imperfective", _, _, True):
return f"are {inflect_phrase_head(verb, present_participle)}"
case ("imperfective", _, _, False):
return f"is {inflect_phrase_head(verb, present_participle)}"
case (_, "past", True, _):
# A be/have head negates in place and carries its own past tense
# ("was not defined as"); anything else takes do-support in the
# past ("did not belong to"), where the head reverts to the base.
if takes_bare_not(verb):
past = inflect_phrase_head(verb, past_tense)
p_head, sep, rest = past.partition(" ")
if plural_subject:
p_head = {"was": "were", "has": "have", "did": "did"}.get(p_head, p_head)
return f"{p_head} not{sep}{rest}" if rest else f"{p_head} not"
return f"did not {inflect_phrase_head(verb, base_form)}"
case (_, "past", False, _):
past = inflect_phrase_head(verb, past_tense)
if plural_subject:
p_head, sep, rest = past.partition(" ")
return {"was": "were"}.get(p_head, p_head) + sep + rest
return past
case (_, "future", True, _):
return f"will not {inflect_phrase_head(verb, base_form)}"
case (_, "future", False, _):
return f"will {inflect_phrase_head(verb, base_form)}"
case (_, _, True, True):
# Plural + negated. Agree the head first, then negate around it:
# a plural copula takes a bare "not" ("are not defined as"), while
# any other verb needs do-support ("do not have the following
# steps", "do not belong to"). Previously this was
# ``f"do not {base}"`` over the whole phrase, which produced
# "do not is defined a".
agreed = agree_plural_phrase(predicate_h)
head, sep, rest = agreed.partition(" ")
if head in ("are", "were"):
return f"{head} not{sep}{rest}" if rest else f"{head} not"
return f"do not {agreed}"
case (_, _, True, False) if copular:
if predicate_h.startswith("is "):
return "is not " + predicate_h[3:]
if predicate_h.startswith("are "):
return "are not " + predicate_h[4:]
if predicate_h.startswith("has "):
return "has not " + predicate_h[4:]
if predicate_h.startswith("have "):
return "have not " + predicate_h[5:]
if predicate_h.startswith("belongs "):
return "does not belong " + predicate_h[8:]
return f"is not {inflect_phrase_head(verb, base_form)}"
case (_, _, True, False):
# Do-support puts the head in the bare infinitive. This branch was
# the ninth instance of the same defect: ``base_form`` on the whole
# phrase left "contrasts with" untouched (no -s/-es/-ies suffix to
# strip from "with"), yielding "does not contrasts with".
return f"does not {inflect_phrase_head(verb, base_form)}"
case (_, _, False, True):
# Plural agreement on the whole phrase, not base_form() of it.
# This is the branch the 9-of-26 defect lived in: it returned the
# bare base and never consulted ``copular``, so "is defined as"
# came back "is defined a" instead of "are defined as".
return agree_plural_phrase(predicate_h)
case _:
return verb
def render_step(
move: RhetoricalMove,
subject: str,
predicate: str,
obj: str,
*,
negated: bool = False,
quantifier: str | None = None,
tense: str | None = None,
aspect: str | None = None,
) -> str:
"""Render a single articulation step into a surface fragment."""
template = _MOVE_TEMPLATES[move]
# Mass nouns under a quantifier stay singular ("all evidence
# supports", not "all evidences support"). Count nouns
# pluralise and the verb de-conjugates ("all molecules bind").
plural_q = quantifier is not None and quantifier.lower() in _PLURAL_QUANTIFIERS
is_mass = is_mass_noun(subject)
plural = plural_q and not is_mass
predicate_h = _humanize_predicate(predicate)
legality = validate_finite_predicate_legality(
predicate_humanized=predicate_h,
negated=negated,
)
if legality.legality is ArticulationLegality.ILLEGAL_NON_VERB_FINITE_PREDICATE:
return "I cannot realize that proposition coherently yet."
predicate_h = _inflect_predicate(
predicate_h,
negated=negated, tense=tense, aspect=aspect,
plural_subject=plural,
)
obj_display = obj if obj != "<pending>" else "..."
# A predicate nominal names the subject's category, so it agrees with the
# subject in number and sheds its indefinite article: "all dogs are
# mammals", never "all dogs are a mammal". Restricted to the closed
# PREDICATIVE_NOMINAL set — a prepositional object carries its own number
# ("all claims are grounded in evidence") and pluralizing it produces
# "evidences". The reader accepts the agreeing form and refuses the other,
# which is why this was the last writer-side blocker on G-round-trip.
if plural and predicate in PREDICATIVE_NOMINAL:
obj_display = pluralize(obj_display)
predicate_h = _drop_indefinite_article(predicate_h)
subject_form = pluralize(subject) if plural else subject
subject_display = f"{quantifier} {subject_form}" if quantifier else subject_form
return template.format(
subject=subject_display,
predicate_h=predicate_h,
obj=obj_display,
)