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).
307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""Tests for the grammar round-trip lane.
|
|
|
|
The load-bearing tests here are the *mutation* tests. This lane exists because
|
|
``evals/deterministic_fluency`` reports 1.00 on all six predicates while
|
|
accepting ``"banana does the."`` — a pin that cannot fail is worthless. So
|
|
every guarantee this lane makes is paired with a test proving the guarantee can
|
|
be broken.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from evals.grammar_roundtrip import runner as rt_runner
|
|
from evals.grammar_roundtrip.corpora import (
|
|
negative_surface_cases,
|
|
positive_graph_cases,
|
|
positive_surface_cases,
|
|
)
|
|
from evals.grammar_roundtrip.projection import (
|
|
CanonicalProposition,
|
|
compare,
|
|
from_meaning_graph,
|
|
from_proposition_graph,
|
|
quantifier_predicate,
|
|
)
|
|
from evals.grammar_roundtrip.runner import run_lane
|
|
from generate.graph_planner import (
|
|
ArticulationStep,
|
|
ArticulationTarget,
|
|
GraphNode,
|
|
PropositionGraph,
|
|
RhetoricalMove,
|
|
)
|
|
from generate.intent import IntentTag
|
|
from generate.meaning_graph.model import (
|
|
Entity,
|
|
MeaningGraph,
|
|
MeaningSpan,
|
|
Relation,
|
|
)
|
|
from generate.meaning_graph.reader import Comprehension, Refusal, comprehend
|
|
|
|
|
|
# The three strings that pass every content predicate of
|
|
# evals/deterministic_fluency. This lane's rejection of them is the concrete,
|
|
# recorded improvement over that lane.
|
|
_FLUENCY_LANE_FALSE_POSITIVES = (
|
|
"banana does the.",
|
|
"wet ground rains the is.",
|
|
"is is is is.",
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def report():
|
|
return run_lane()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The negative corpus: the guarantee, and proof it can fail
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_negative_corpus_is_fully_rejected(report):
|
|
"""Word salad must never be comprehended into propositions."""
|
|
assert report.metrics["reject_rate"] == 1.0
|
|
assert report.metrics["negative_cases"] >= 16
|
|
|
|
|
|
def test_the_fluency_lanes_false_positives_are_rejected():
|
|
"""The exact strings deterministic_fluency passes must be refused here."""
|
|
for surface in _FLUENCY_LANE_FALSE_POSITIVES:
|
|
result = comprehend(surface, source_id="t")
|
|
if isinstance(result, Refusal):
|
|
continue
|
|
assert not from_meaning_graph(result.meaning_graph), (
|
|
f"{surface!r} was comprehended into propositions; the negative "
|
|
"corpus guarantee is broken"
|
|
)
|
|
|
|
|
|
def test_reject_rate_goes_red_when_the_reader_accepts_everything(monkeypatch):
|
|
"""MUTATION: an accept-everything reader must drive reject_rate to 0.
|
|
|
|
Without this test ``reject_rate == 1.0`` would be unfalsifiable — exactly
|
|
the defect that makes the existing fluency lane decoration.
|
|
"""
|
|
span = MeaningSpan(source_id="t", start=0, end=1, text="x")
|
|
graph = MeaningGraph(
|
|
entities=(
|
|
Entity(entity_id="a", name="a", span=span),
|
|
Entity(entity_id="b", name="b", span=span),
|
|
),
|
|
relations=(Relation(predicate="subset", arguments=("a", "b"), span=span),),
|
|
)
|
|
monkeypatch.setattr(
|
|
rt_runner, "comprehend", lambda text, source_id="input": Comprehension(meaning_graph=graph)
|
|
)
|
|
mutated = run_lane()
|
|
assert mutated.metrics["reject_rate"] == 0.0, (
|
|
"an accept-everything reader still scored a perfect reject_rate — the "
|
|
"negative corpus is not actually gating"
|
|
)
|
|
|
|
|
|
def test_shuffled_negatives_reuse_positive_vocabulary():
|
|
"""Shuffles must be lexically identical to a positive, order destroyed.
|
|
|
|
A lane that rejects only hand-authored salad could be checking vocabulary
|
|
rather than grammar. The shuffles remove that escape.
|
|
"""
|
|
positives = {
|
|
frozenset(c.surface.rstrip(".").lower().split()) for c in positive_surface_cases()
|
|
}
|
|
shuffles = [c for c in negative_surface_cases() if c.case_id.startswith("neg-shuf-")]
|
|
assert shuffles, "no shuffled negatives were generated"
|
|
for case in shuffles:
|
|
tokens = frozenset(case.surface.rstrip(".").lower().split())
|
|
assert tokens in positives, (
|
|
f"{case.case_id} does not reuse a positive case's exact vocabulary"
|
|
)
|
|
|
|
|
|
def test_shuffles_are_byte_stable_across_calls():
|
|
"""The negative corpus must be reproducible — no PRNG, no clock."""
|
|
first = tuple(c.surface for c in negative_surface_cases())
|
|
second = tuple(c.surface for c in negative_surface_cases())
|
|
assert first == second
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Baselines — these pin DEFECTS and must be revised upward when fixed
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_g_roundtrip_baseline_is_zero(report):
|
|
"""BASELINE PIN (a defect, not a goal): CORE reads 0% of what it writes.
|
|
|
|
Measured on main @ 9696443a. When the grammar is unified this must be
|
|
revised **upward**; it must never be revised downward to accommodate a
|
|
regression.
|
|
"""
|
|
assert report.metrics["graph_cases"] == 280
|
|
assert report.metrics["g_write_rate"] == 1.0
|
|
assert report.metrics["g_read_rate"] == 0.0
|
|
assert report.metrics["g_exact_rate"] == 0.0
|
|
|
|
|
|
def test_s_roundtrip_closes_for_every_renderable_surface(report):
|
|
"""Phase 2A pinned this at **0.0** — nothing CORE read rendered back to its
|
|
input, because the serving renderer interpolated singularized entity ids
|
|
into plural templates ("all dog are animal").
|
|
|
|
Phase 2B re-inflects at render time, and every surface the renderer can
|
|
express now returns its input exactly. The two rates are equal on purpose:
|
|
``s_surface_match_rate == s_renderable_rate`` says the *only* remaining
|
|
round-trip losses are surfaces the categorical renderer cannot express at
|
|
all, which is a coverage gap in a later phase, not a grammar defect.
|
|
|
|
Asserting equality rather than a literal keeps this honest if the corpus
|
|
grows: adding an unrenderable positive lowers both numbers together, while
|
|
reintroducing the plural defect lowers only the match rate.
|
|
"""
|
|
assert report.metrics["s_read_rate"] == 1.0
|
|
assert report.metrics["s_surface_match_rate"] == report.metrics["s_renderable_rate"]
|
|
assert report.metrics["s_surface_match_rate"] > 0.0
|
|
|
|
|
|
def test_the_categorical_render_defect_is_fixed_concretely():
|
|
"""The 2A defect stated as an example rather than a rate, now inverted.
|
|
|
|
Both sides matter: the graph still carries the SINGULAR canonical id
|
|
(``dog``), so the fix is in the renderer's inflection and not in the
|
|
comprehension it was built to preserve.
|
|
"""
|
|
result = comprehend("All dogs are animals.", source_id="t")
|
|
assert isinstance(result, Comprehension)
|
|
props = sorted(from_meaning_graph(result.meaning_graph))
|
|
assert props == [CanonicalProposition("subset", "dog", "animal", False)]
|
|
rendered = rt_runner._render_categorical("subset", "dog", "animal")
|
|
assert rendered == "all dogs are animals"
|
|
|
|
|
|
def test_irregular_plurals_survive_the_full_round_trip():
|
|
"""The reader and renderer now share one number table, so an irregular
|
|
plural returns as itself. Before 2B this produced ``all wolve are
|
|
mammals`` — the reader's bare ``-s`` strip leaking a corrupted id straight
|
|
into served text."""
|
|
cases = (
|
|
("All wolves are mammals.", "wolf", "all wolves are mammals"),
|
|
("All children are mammals.", "child", "all children are mammals"),
|
|
("All men are mammals.", "man", "all men are mammals"),
|
|
("All knives are tools.", "knife", "all knives are tools"),
|
|
)
|
|
for surface, singular, expected_clause in cases:
|
|
result = comprehend(surface, source_id="t")
|
|
assert isinstance(result, Comprehension), surface
|
|
props = sorted(from_meaning_graph(result.meaning_graph))
|
|
assert props[0].subject == singular, f"{surface} -> {props}"
|
|
rendered = rt_runner._render_categorical("subset", singular, props[0].obj)
|
|
assert rendered == expected_clause
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The projection itself
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_quantifier_map_matches_reader():
|
|
"""The lane's local quantifier map must track the reader's.
|
|
|
|
The copy is deliberate — the lane must not import the thing it measures —
|
|
so this test is what keeps the yardstick honest if the reader changes.
|
|
"""
|
|
from generate.meaning_graph.reader import _QUANTIFIER_PREDICATE as reader_map
|
|
|
|
for quantifier, predicate in reader_map.items():
|
|
assert quantifier_predicate(quantifier) == predicate
|
|
assert quantifier_predicate(None) is None
|
|
assert quantifier_predicate("nonsense") is None
|
|
|
|
|
|
def test_proposition_graph_projection_folds_quantifier():
|
|
"""A writer graph's ``quantifier`` slot folds into the reading predicate."""
|
|
node = GraphNode(
|
|
node_id="n1", subject="dog", predicate="is_a", obj="animal",
|
|
source_intent=IntentTag.UNKNOWN,
|
|
)
|
|
step = ArticulationStep(
|
|
node_id="n1", move=RhetoricalMove.ASSERT, predicate="is_a",
|
|
subject="dog", quantifier="all",
|
|
)
|
|
projected = from_proposition_graph(
|
|
PropositionGraph(nodes=(node,)),
|
|
ArticulationTarget(steps=(step,), source_intent=IntentTag.UNKNOWN),
|
|
)
|
|
assert projected == frozenset({CanonicalProposition("subset", "dog", "animal", False)})
|
|
|
|
|
|
def test_projection_drops_non_binary_relations():
|
|
"""Arity != 2 is dropped — the writing side cannot express it."""
|
|
span = MeaningSpan(source_id="t", start=0, end=1, text="x")
|
|
graph = MeaningGraph(
|
|
entities=(
|
|
Entity(entity_id="a", name="a", span=span),
|
|
Entity(entity_id="b", name="b", span=span),
|
|
Entity(entity_id="c", name="c", span=span),
|
|
),
|
|
relations=(
|
|
Relation(predicate="between", arguments=("a", "b", "c"), span=span),
|
|
Relation(predicate="subset", arguments=("a", "b"), span=span),
|
|
),
|
|
)
|
|
assert from_meaning_graph(graph) == frozenset(
|
|
{CanonicalProposition("subset", "a", "b", False)}
|
|
)
|
|
|
|
|
|
def test_compare_decomposes_argument_and_predicate_agreement():
|
|
"""A predicate name only counts when it agrees on the SAME arguments."""
|
|
expected = frozenset({CanonicalProposition("subset", "dog", "animal")})
|
|
# right arguments, wrong predicate name
|
|
args_only = frozenset({CanonicalProposition("member", "dog", "animal")})
|
|
agreement = compare(expected, args_only)
|
|
assert agreement.args_match == 1
|
|
assert agreement.predicates_match == 0
|
|
assert agreement.exact_match == 0
|
|
|
|
# right predicate name, wrong arguments — must NOT count
|
|
pred_elsewhere = frozenset({CanonicalProposition("subset", "cat", "plant")})
|
|
agreement = compare(expected, pred_elsewhere)
|
|
assert agreement.args_match == 0
|
|
assert agreement.predicates_match == 0
|
|
|
|
|
|
def test_compare_exact_agreement():
|
|
prop = CanonicalProposition("subset", "dog", "animal")
|
|
agreement = compare(frozenset({prop}), frozenset({prop}))
|
|
assert agreement.exact_rate == 1.0
|
|
assert agreement.args_rate == 1.0
|
|
assert agreement.predicates_rate == 1.0
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Corpus integrity
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_positive_surfaces_are_all_inside_the_reader_envelope():
|
|
"""Every positive surface must actually be comprehended.
|
|
|
|
A refused positive would silently measure the reader's coverage instead of
|
|
the round-trip, and would make s_surface_match_rate uninterpretable.
|
|
"""
|
|
for case in positive_surface_cases():
|
|
result = comprehend(case.surface, source_id="t")
|
|
assert isinstance(result, Comprehension), (
|
|
f"{case.case_id} ({case.surface!r}) is refused; it does not belong "
|
|
"in the positive corpus"
|
|
)
|
|
|
|
|
|
def test_graph_corpus_is_harvested_from_committed_case_files():
|
|
cases = positive_graph_cases()
|
|
assert len(cases) == 280
|
|
assert all(c.nodes for c in cases)
|