core/evals/grammar_roundtrip/runner.py
Shay cfda71dc1c feat(evals): grammar round-trip instrument (Phase 1)
The measurement foundation for docs/plans/grammar-unification-2026-07-26.md.

WHY: evals/deterministic_fluency reports 1.00 on all six predicates and
still passes "banana does the.", "wet ground rains the is." and
"is is is is." — it checks terminal punctuation, presence of a verb-shaped
token, and two anti-shape regexes. Heuristic predicates will always have
that failure mode, because grammaticality cannot be measured without a
grammar. So this lane measures agreement between the two halves of CORE
that already encode grammar, and requires the measurement to FAIL on salad.

Two directions, reported separately because they fail for different
reasons and have different remedies:

  G-round-trip  graph -> realize_target -> surface -> comprehend -> graph
  S-round-trip  surface -> comprehend -> graph -> categorical renderer -> surface

v1 baseline on main @ 9696443a:

  graph_cases            280      surface_cases            8
  g_write_rate         1.000      s_read_rate          1.000
  g_read_rate          0.000      s_renderable_rate    0.625
  g_exact_rate         0.000      s_surface_match_rate 0.000
                                  negative_cases          16
                                  reject_rate          1.000

g_read_rate and s_surface_match_rate are pins on measured DEFECTS, not
goals; they may be revised upward only. s_surface_match_rate = 0 is the
§1.7 categorical render defect caught by construction — the lane found it
without being told to look.

g_args_rate and g_predicates_rate are deliberately separate: high argument
agreement with low predicate agreement would mean the grammars align and
only the vocabulary is split, a materially different remedy from both
being low. That distinction decides the arc's direction (plan §6).

Design notes:
- The committed cases.jsonl is the SINGLE source for authored surfaces —
  no in-module duplicate, since a second copy of a corpus is the defect
  this arc exists to remove. Negative shuffles are DERIVED at run time so
  they cannot drift from the positives.
- The shuffles are lexically identical to positives (same vocabulary, same
  length, order destroyed) so the lane cannot pass by vocabulary-checking.
- Fixed rotation, not a PRNG, so reject_rate is byte-reproducible.
- The lane keeps a local copy of the reader's quantifier map ON PURPOSE so
  it never becomes a consumer of what it measures;
  test_quantifier_map_matches_reader fails loudly if the reader changes.
- _render_categorical deliberately reaches a private serving function: a
  lane measuring a private copy would measure what users never see.

Every guarantee is paired with a mutation test. The load-bearing one is
test_reject_rate_goes_red_when_the_reader_accepts_everything: an
accept-everything reader must drive reject_rate to 0.0. Without it,
reject_rate == 1.0 would be unfalsifiable — precisely the defect that
makes the existing fluency lane decoration.

Also documents plainly what round-trip does NOT prove: it measures mutual
intelligibility, not English quality. english_fluency_ood accepts "river
flows valley" and round-trip would be happy with it. No metric here may be
cited as evidence of prose quality.

scripts/measure_grammar_seam.py reproduces every number in the plan's §1
so a reader can check them instead of trusting them.

[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 (unchanged), deductive 364 (349 + 15 new). Lane SHA pins
verified separately. No serving code touched; new files plus one suite
registration line.
2026-07-26 16:30:49 -07:00

277 lines
10 KiB
Python

"""Grammar round-trip eval lane.
Measures whether CORE can read what it writes, and write what it reads, in
both directions, with a negative corpus that must be rejected.
Why this lane exists
--------------------
``evals/deterministic_fluency`` reports 1.00 on all six of its predicates and
still passes ``"banana does the."``, ``"wet ground rains the is."`` and
``"is is is is."``. It checks terminal punctuation, the presence of a
verb-shaped token, and two anti-shape regexes; it cannot distinguish English
from word salad. Heuristic predicates will always have that failure mode,
because **grammaticality cannot be measured without a grammar**.
So this lane measures agreement between the two halves of CORE that already
encode grammar — the reader and the writer — and requires the measurement to
fail on salad. Round-trip is falsifiable without a judge, an embedding, or a
gold aesthetic.
What round-trip does NOT prove
------------------------------
Round-trip is **necessary, not sufficient** for fluency. It measures *mutual
intelligibility*: both halves agreeing about a surface. Two halves can agree
on an impoverished construction — ``english_fluency_ood`` accepts ``"river
flows valley"`` as correct — and round-trip would be perfectly happy. A high
round-trip rate therefore licenses the claim "CORE means what it says", not
"CORE writes well". The negative corpus is what stops the lane from drifting
into the first failure mode; nothing here stops the second, and no metric in
this lane should ever be cited as evidence of prose quality.
Framework contract: ``run_lane(cases, config=None) -> LaneReport``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from generate.graph_planner import (
ArticulationStep,
ArticulationTarget,
GraphNode,
PropositionGraph,
RhetoricalMove,
)
from generate.intent import IntentTag
from generate.meaning_graph.reader import Refusal, comprehend
from generate.realizer import realize_target
from evals.grammar_roundtrip.corpora import (
GraphCase,
SurfaceCase,
negative_surface_cases,
positive_graph_cases,
positive_surface_cases,
)
from evals.grammar_roundtrip.projection import (
Agreement,
compare,
from_meaning_graph,
from_proposition_graph,
)
#: Reading-side predicate -> categorical form letter, for the S-round-trip
#: re-render. Only the three quantified forms have a categorical surface;
#: ``member`` / ``less`` and friends have no ``all X are Y`` shape, and cases
#: carrying them are reported as ``no_renderer`` rather than as a match
#: failure — conflating "we cannot write this at all" with "we wrote it wrong"
#: would hide which of the two is actually blocking.
_PREDICATE_TO_FORM: dict[str, str] = {
"subset": "A",
"disjoint": "E",
"intersects": "I",
}
@dataclass(frozen=True, slots=True)
class LaneReport:
metrics: dict[str, Any] = field(default_factory=dict)
case_details: list[dict[str, Any]] = field(default_factory=list)
def _normalize_surface(surface: str) -> str:
"""Lowercase and drop terminal punctuation for surface comparison.
Case and the sentence-final period are the renderer's caller's business,
not the clause renderer's, so comparing them would report a failure the
grammar is not responsible for.
"""
return surface.strip().rstrip(".!?").strip().lower()
def _build(case: GraphCase) -> tuple[ArticulationTarget, PropositionGraph]:
nodes = tuple(
GraphNode(
node_id=str(n["node_id"]),
subject=str(n["subject"]),
predicate=str(n["predicate"]),
obj=str(n["obj"]),
source_intent=IntentTag.UNKNOWN,
)
for n in case.nodes
)
steps = tuple(
ArticulationStep(
node_id=str(n["node_id"]),
move=RhetoricalMove.ASSERT,
predicate=str(n["predicate"]),
subject=str(n["subject"]),
negated=bool(n.get("negated", False)),
quantifier=n.get("quantifier"), # type: ignore[arg-type]
tense=n.get("tense"), # type: ignore[arg-type]
aspect=n.get("aspect"), # type: ignore[arg-type]
)
for n in case.nodes
)
return (
ArticulationTarget(steps=steps, source_intent=IntentTag.UNKNOWN),
PropositionGraph(nodes=nodes),
)
def _render_categorical(predicate: str, subject: str, obj: str) -> str | None:
"""Re-render one canonical proposition through the SERVING renderer.
Deliberately reaches ``generate.proof_chain.render._categorical_clause``
even though it is private: that function is what band v1b actually serves,
and a lane that measured a private copy would measure something users
never see.
"""
from generate.proof_chain.render import _categorical_clause
form = _PREDICATE_TO_FORM.get(predicate)
if form is None:
return None
return _categorical_clause({"form": form, "subject": subject, "predicate": obj})
def _run_graph_case(case: GraphCase) -> dict[str, Any]:
"""G-round-trip: graph -> surface -> graph."""
target, graph = _build(case)
expected = from_proposition_graph(graph, target)
surface = realize_target(target, graph).surface
row: dict[str, Any] = {
"case_id": case.case_id,
"direction": "graph",
"surface": surface,
"wrote": bool(surface),
"read": False,
"refusal_reason": None,
}
if not surface:
row["agreement"] = Agreement(len(expected), 0, 0, 0, 0).as_dict()
return row
result = comprehend(surface, source_id="grammar_roundtrip")
if isinstance(result, Refusal):
row["refusal_reason"] = result.reason
row["agreement"] = Agreement(len(expected), 0, 0, 0, 0).as_dict()
return row
row["read"] = True
row["agreement"] = compare(expected, from_meaning_graph(result.meaning_graph)).as_dict()
return row
def _run_surface_case(case: SurfaceCase) -> dict[str, Any]:
"""S-round-trip: surface -> graph -> surface."""
row: dict[str, Any] = {
"case_id": case.case_id,
"direction": "surface",
"surface": case.surface,
"note": case.note,
"read": False,
"rendered": None,
"renderable": False,
"surface_match": False,
"refusal_reason": None,
}
result = comprehend(case.surface, source_id="grammar_roundtrip")
if isinstance(result, Refusal):
row["refusal_reason"] = result.reason
return row
row["read"] = True
props = sorted(from_meaning_graph(result.meaning_graph))
if not props:
row["refusal_reason"] = "empty_projection"
return row
rendered = [_render_categorical(p.predicate, p.subject, p.obj) for p in props]
if any(r is None for r in rendered):
row["rendered"] = None
return row
row["renderable"] = True
joined = "; ".join(r for r in rendered if r is not None)
row["rendered"] = joined
row["surface_match"] = _normalize_surface(joined) == _normalize_surface(case.surface)
return row
def _run_negative_case(case: SurfaceCase) -> dict[str, Any]:
"""A negative case passes when the reader REFUSES or recovers nothing."""
result = comprehend(case.surface, source_id="grammar_roundtrip")
if isinstance(result, Refusal):
return {
"case_id": case.case_id,
"direction": "negative",
"surface": case.surface,
"note": case.note,
"rejected": True,
"refusal_reason": result.reason,
}
props = from_meaning_graph(result.meaning_graph)
return {
"case_id": case.case_id,
"direction": "negative",
"surface": case.surface,
"note": case.note,
# Comprehending salad into ZERO propositions still counts as rejected:
# the reader committed to no meaning. Comprehending it into one or more
# propositions is a real failure — the lane must go red for that.
"rejected": not props,
"recovered": sorted(f"{p.predicate}({p.subject},{p.obj})" for p in props),
"refusal_reason": None,
}
def _rate(numerator: int, denominator: int) -> float:
return round(numerator / denominator, 6) if denominator else 0.0
def run_lane(cases: list[dict[str, Any]] | None = None, config: Any = None) -> LaneReport: # noqa: ARG001
"""Run the grammar round-trip lane over its own committed corpora.
``cases`` is accepted for framework-contract compatibility and ignored:
the corpora are derived from committed case files and in-module tables so
the lane's numbers are reproducible without an external split.
"""
graph_rows = [_run_graph_case(c) for c in positive_graph_cases()]
surface_rows = [_run_surface_case(c) for c in positive_surface_cases()]
negative_rows = [_run_negative_case(c) for c in negative_surface_cases()]
n_graph = len(graph_rows)
wrote = sum(1 for r in graph_rows if r["wrote"])
g_read = sum(1 for r in graph_rows if r["read"])
expected_total = sum(int(r["agreement"]["expected"]) for r in graph_rows)
args_total = sum(int(r["agreement"]["args_match"]) for r in graph_rows)
preds_total = sum(int(r["agreement"]["predicates_match"]) for r in graph_rows)
exact_total = sum(int(r["agreement"]["exact_match"]) for r in graph_rows)
n_surface = len(surface_rows)
s_read = sum(1 for r in surface_rows if r["read"])
s_renderable = sum(1 for r in surface_rows if r["renderable"])
s_match = sum(1 for r in surface_rows if r["surface_match"])
n_neg = len(negative_rows)
rejected = sum(1 for r in negative_rows if r["rejected"])
metrics: dict[str, Any] = {
# --- G-round-trip: graph -> surface -> graph ---
"graph_cases": n_graph,
"g_write_rate": _rate(wrote, n_graph),
"g_read_rate": _rate(g_read, n_graph),
"g_propositions_expected": expected_total,
"g_args_rate": _rate(args_total, expected_total),
"g_predicates_rate": _rate(preds_total, expected_total),
"g_exact_rate": _rate(exact_total, expected_total),
# --- S-round-trip: surface -> graph -> surface ---
"surface_cases": n_surface,
"s_read_rate": _rate(s_read, n_surface),
"s_renderable_rate": _rate(s_renderable, n_surface),
"s_surface_match_rate": _rate(s_match, n_surface),
# --- negative corpus: salad must be rejected ---
"negative_cases": n_neg,
"reject_rate": _rate(rejected, n_neg),
}
return LaneReport(metrics=metrics, case_details=[*graph_rows, *surface_rows, *negative_rows])
__all__ = ("LaneReport", "run_lane")