core/scripts/measure_grammar_seam.py
Shay df6332fcb5 refactor(generate): one owner per linguistic fact (Phase 2A)
Collapses the duplicated closed English tables into generate/lexicon.py.
Measured on main @ 0948f7cd: the same facts were encoded up to five times
across the reading and writing paths, each copy written independently as a
successive deduction band landed.

The 2A/2B split is decided empirically per #129 — make the change, run the
11 pinned lanes, let byte-identity rule. Result: 11/11 byte-identical, so
this entire unit is 2A and nothing spilled into the authorization-gated 2B.

Collapsed to one object:
  - connectives    3 identical copies (member, verb, cond_member)
  - predicate display  2 identical 26-entry copies (semantic_templates,
                       templates)
  - be-form inventory  5 copies, not the 2 §1.3 counted. The structural
                       test found realizer_guard._BE_AUX and
                       chat/runtime._BE_FORMS, which a COPULA-shaped grep
                       could never see.

Derived rather than duplicated, so they can no longer drift:
  - STRUCTURAL = CONNECTIVES | {therefore}
  - QUANTIFIER_TOKENS = QUANTIFIER_LEAD | QUANTIFIER_NON_LEAD
  - NEGATION_BEARING_WITH_NOT = NEGATION_BEARING | {not}
  - READER_IRREGULAR_SINGULARS = IRREGULAR_SINGULARS restricted to 8 keys

§1.3 re-measured while doing this, and it overstated the disease (plan
§1.9). Almost nothing contradicts:
  - the "3 divergent plural tables" are 1 pluralizer + 2 singularizers,
    i.e. inverse directions; comparing them as copies is a category error
  - reader's 8 singulars are a STRICT SUBSET of member's 29 with the
    difference in reader's favour empty, so its values are all correct and
    only its coverage is short
  - the "3 divergent quantifier sets" are 3 distinct facts; every/each lead
    a clause but take singular nouns, so their absence from
    PLURAL_QUANTIFIERS is correct
  - english's negation set is a subset of member's, and the difference is
    principled: v2-EN normalizes "<copula> not", v3-MEM refuses it

Exactly one genuine contradiction exists in the whole inventory:
discourse_planner maps is_defined_as -> "is" where the others map it to
"is defined as". Preserved as a register choice, pinned by test.

Found while reading, pinned as current behaviour for 2B to flip:
reader._singularize does NOT refuse uncovered plurals despite a comment
claiming it does — it falls through to a bare -s strip, so news -> new and
species -> specy, exactly the corruptions member.py's table comment says
its table exists to prevent.

Two 2A exit criteria were unmeasurable as written and are replaced:
  - "Jaccard -> 1.00" cannot work. measure_grammar_seam.py scans source
    literals, so unifying a fact removes it from those files and Jaccard
    FELL, 0.083 -> 0.023 — the success direction reported by a metric
    shaped to read like failure. Replaced with an object-identity count:
    3 distinct objects behind 8 names, from 8-behind-8. Value comparison
    cannot tell a shared object from two equal copies.
  - "one table per fact" presumed §1.3's inventory was right. The report now
    separates must-be-one-object (all UNIFIED) from related-but-distinct
    (6 relations, all HOLD).

ADR-0258 §5 already decided morphology's destination is a ratified pack,
triggered by a grc/he member band. No such band exists, so the promotion is
correctly deferred and lexicon.py is a staging area, not a rival home — no
new ADR. Destination measured for whoever picks it up: packs/en/morphology
.jsonl has 9 records and zero irregular plurals, features.number has no
consumer, and _pack_morph_roots_for reads a top-level "root" key that 0 of
31 records across all four packs define, so it returns {} every call.

[Verification]: in-worktree on CPython 3.12.13, uv sync --locked —
smoke 621 unchanged; deductive 383 (364 + 19 new);
scripts/verify_lane_shas.py 11/11 byte-identical.
2026-07-26 17:45:27 -07:00

420 lines
17 KiB
Python

#!/usr/bin/env python3
"""Reproduce every measurement in the grammar-unification plan's §1.
Usage:
uv run python scripts/measure_grammar_seam.py # all sections
uv run python scripts/measure_grammar_seam.py --section tables
Each section corresponds to a numbered subsection of
``docs/plans/grammar-unification-2026-07-26.md``. The plan quotes these
numbers; this script is how a reader checks them instead of trusting them.
Read-only: imports and probes, no mutation, no writes.
"""
from __future__ import annotations
import argparse
import ast
import collections
import importlib
import json
import pathlib
import sys
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
# --- §1.3 inventory inputs ------------------------------------------------- #
_READ_PATH_FILES = (
"generate/meaning_graph/reader.py",
"generate/meaning_graph/projectors.py",
"generate/meaning_graph/relational.py",
"generate/proof_chain/english.py",
"generate/proof_chain/member.py",
"generate/proof_chain/cond_member.py",
"generate/proof_chain/verb.py",
"generate/proof_chain/exist.py",
"generate/proof_chain/categorical.py",
)
_WRITE_PATH_FILES = (
"generate/semantic_templates.py",
"generate/templates.py",
"generate/morphology.py",
"generate/realizer.py",
"generate/discourse_planner.py",
"chat/register_variation.py",
)
#: Groups of tables that encode the SAME linguistic fact and should agree.
#: Groups that are ONE fact and must therefore be ONE object.
#:
#: Corrected 2026-07-26 (plan §1.9). The original grouping asserted that the
#: three plural tables and the three quantifier sets were each one fact. They
#: are not: the plural tables are a pluralizer plus two singularizers (inverse
#: directions), and the quantifier sets are three distinct facts — "can lead a
#: clause", "is a quantifier token", "forces plural agreement". Reporting those
#: as DIVERGE was a correct answer to a wrong question, so they moved to
#: :data:`_RELATED_BUT_DISTINCT` below.
_SHOULD_AGREE = {
"connective tokens": (
("generate.proof_chain.member", "_CONNECTIVES"),
("generate.proof_chain.verb", "_CONNECTIVES"),
("generate.proof_chain.cond_member", "_CONNECTIVE_TOKENS"),
),
"predicate display": (
("generate.semantic_templates", "_PREDICATE_HUMANIZE"),
("generate.templates", "_PREDICATE_DISPLAY"),
),
"be-form inventory": (
("generate.proof_chain.english", "_COPULAS"),
("generate.realizer_guard", "_BE_AUX"),
("chat.runtime", "_BE_FORMS"),
),
}
#: Tables that are RELATED but genuinely distinct, with the relation that must
#: hold between them. Each is pinned by ``tests/test_lexicon_single_source.py``;
#: this table exists so the report states the relation instead of crying
#: divergence. ``relation`` is a predicate over the two loaded values.
_RELATED_BUT_DISTINCT = (
(
"STRUCTURAL = CONNECTIVES + therefore",
("generate.proof_chain.english", "_STRUCTURAL"),
("generate.proof_chain.member", "_CONNECTIVES"),
lambda a, b: frozenset(a) - frozenset(b) == {"therefore"},
),
(
"QUANTIFIER_TOKENS strictly contains LEAD",
("generate.proof_chain.member", "_QUANTIFIER_TOKENS"),
("generate.proof_chain.english", "_QUANTIFIER_LEAD"),
lambda a, b: frozenset(b) < frozenset(a),
),
(
"PLURAL_QUANTIFIERS is a different fact (excludes every/each)",
("generate.templates", "_PLURAL_QUANTIFIERS"),
("generate.proof_chain.english", "_QUANTIFIER_LEAD"),
lambda a, b: {"every", "each"} <= frozenset(b) and not ({"every", "each"} & frozenset(a)),
),
(
"member negation = english negation + not",
("generate.proof_chain.member", "_NEGATION_BEARING"),
("generate.proof_chain.english", "_NEGATION_BEARING"),
lambda a, b: frozenset(a) - frozenset(b) == {"not"},
),
(
"reader singulars are a subset of the full table",
("generate.meaning_graph.reader", "_IRREGULAR_PLURALS"),
("generate.proof_chain.member", "_IRREGULAR_PLURALS"),
lambda a, b: set(a.items()) < set(b.items()),
),
(
"pluralizer is the inverse direction of the singularizer",
("generate.templates", "_IRREGULAR_PLURALS"),
("generate.proof_chain.member", "_IRREGULAR_PLURALS"),
lambda a, b: all(b[p] == s for s, p in a.items() if s != p and p in b),
),
)
#: Plural-subject agreement oracle for §1.4 — hand-written English, not derived
#: from the code under test (deriving it from the code would make it agree by
#: construction and measure nothing).
_AUX_PLURAL = {"is": "are", "are": "are", "has": "have", "have": "have", "belongs": "belong"}
_BARE_BASE = {
"causes": "cause", "evidences": "evidence", "means": "mean",
"addresses": "address", "answers": "answer", "corrects": "correct",
"defines": "define", "entails": "entail", "follows": "follow",
"grounds": "ground", "implies": "imply", "orders": "order",
"precedes": "precede", "recalls": "recall", "requires": "require",
"reveals": "reveal", "supports": "support", "verifies": "verify",
"contrasts with": "contrast with",
}
def _word_tables(path: str) -> dict[str, frozenset[str]]:
"""Module-level assignments holding >=3 lowercase english-ish literals."""
source = (_REPO_ROOT / path).read_text()
out: dict[str, frozenset[str]] = {}
for node in ast.parse(source).body:
if not isinstance(node, (ast.Assign, ast.AnnAssign)):
continue
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
names = [t.id for t in targets if isinstance(t, ast.Name)]
if not names:
continue
words = {
sub.value.strip()
for sub in ast.walk(node)
if isinstance(sub, ast.Constant)
and isinstance(sub.value, str)
and sub.value.strip()
and sub.value.strip().replace("_", "").replace(" ", "").replace("-", "").isalpha()
and sub.value.strip().islower()
}
if len(words) >= 3:
out[names[0]] = frozenset(words)
return out
def section_tables() -> None:
"""§1.3 — duplication and Jaccard across the reading/writing paths."""
print("=" * 74)
print("§1.3 Linguistic knowledge duplicated across reading vs writing")
print("=" * 74)
read_tables: dict[str, frozenset[str]] = {}
write_tables: dict[str, frozenset[str]] = {}
for rel in _READ_PATH_FILES:
if (_REPO_ROOT / rel).exists():
for name, words in _word_tables(rel).items():
read_tables[f"{pathlib.Path(rel).name}::{name}"] = words
for rel in _WRITE_PATH_FILES:
if (_REPO_ROOT / rel).exists():
for name, words in _word_tables(rel).items():
write_tables[f"{pathlib.Path(rel).name}::{name}"] = words
read_words = frozenset().union(*read_tables.values()) if read_tables else frozenset()
write_words = frozenset().union(*write_tables.values()) if write_tables else frozenset()
union = read_words | write_words
shared = read_words & write_words
print(f" reading path: {len(read_tables):3d} word-tables, {len(read_words):3d} distinct words")
print(f" writing path: {len(write_tables):3d} word-tables, {len(write_words):3d} distinct words")
print(f" shared : {len(shared):3d} of {len(union)} union")
print(f" JACCARD : {len(shared) / len(union):.3f}" if union else " JACCARD: n/a")
print(
" NOTE: these counts scan SOURCE LITERALS, so after Phase 2A they\n"
" measure how much table text still lives in these files, not\n"
" how many answers exist. A unified fact leaves the file as an\n"
" import and DROPS out of the count — Jaccard falling is the\n"
" expected direction, not a regression. Read the ownership\n"
" block below for the number the 2A exit criterion actually means."
)
print("\n tables that encode the same fact (by OBJECT IDENTITY):")
owners_total = 0
views_total = 0
for label, refs in _SHOULD_AGREE.items():
loaded = []
for module_name, attr in refs:
try:
value = getattr(importlib.import_module(module_name), attr)
except (ImportError, AttributeError):
continue
keys = frozenset(value) if not isinstance(value, tuple) else frozenset(value)
loaded.append((f"{module_name.split('.')[-1]}.{attr}", keys, id(value)))
if len(loaded) < 2:
continue
# Distinct underlying OBJECTS, not distinct names. After unification a
# band attribute is the lexicon object itself, so N names backed by one
# object is one answer — which is what "unified" means. Comparing by
# value alone cannot tell a shared object from two equal copies.
distinct_objects = {oid for _, _, oid in loaded}
distinct_values = {keys for _, keys, _ in loaded}
owners_total += len(distinct_objects)
views_total += len(loaded)
if len(distinct_objects) == 1:
verdict = "UNIFIED"
elif len(distinct_values) == 1:
verdict = "EQUAL-COPY" # same content, still two objects
else:
verdict = "DIVERGE"
sizes = ", ".join(f"{n}={len(k)}" for n, k, _ in loaded)
print(
f" {verdict:10s} {label:26s} "
f"({len(distinct_objects)} owner(s) behind {len(loaded)} view(s): {sizes})"
)
if views_total:
print(
f"\n OWNERSHIP : {owners_total} distinct objects behind {views_total} names"
f" (1 owner per fact is the 2A goal)"
)
print("\n related but DISTINCT facts — the relation that must hold:")
for label, ref_a, ref_b, relation in _RELATED_BUT_DISTINCT:
try:
a = getattr(importlib.import_module(ref_a[0]), ref_a[1])
b = getattr(importlib.import_module(ref_b[0]), ref_b[1])
except (ImportError, AttributeError):
continue
try:
ok = bool(relation(a, b))
except Exception: # a broken relation is a red flag, not a crash
ok = False
print(f" {'HOLDS' if ok else 'BROKEN':10s} {label}")
def section_agreement() -> None:
"""§1.4 — plural-subject agreement over the predicate-display table."""
print("\n" + "=" * 74)
print("§1.4 Plural-subject agreement in the (eval-only) realizer")
print("=" * 74)
from generate.templates import _PREDICATE_DISPLAY, _inflect_predicate
def expected(display: str) -> str:
head = display.split(" ", 1)[0]
if head in _AUX_PLURAL:
rest = display.split(" ", 1)[1] if " " in display else ""
return (_AUX_PLURAL[head] + (" " + rest if rest else "")).strip()
return _BARE_BASE.get(display, display)
wrong = []
for key, display in sorted(_PREDICATE_DISPLAY.items()):
got, want = _inflect_predicate(display, plural_subject=True), expected(display)
if got != want:
wrong.append((key, display, got, want))
total = len(_PREDICATE_DISPLAY)
print(f" correct: {total - len(wrong)}/{total} WRONG: {len(wrong)}/{total}")
for key, display, got, want in wrong:
print(f" {key:24s} {display!r:28s} -> {got!r:26s} want {want!r}")
def section_readers() -> None:
"""§1.7 — reader-vs-reader singularization disagreement."""
print("\n" + "=" * 74)
print("§1.7 Do CORE's two readers agree on singularization?")
print("=" * 74)
from generate.meaning_graph.reader import _singularize
from generate.proof_chain.member import _IRREGULAR_PLURALS as member_table
from generate.templates import pluralize
pairs = sorted({p: s for p, s in member_table.items() if p != s}.items())
disagree = [(p, s, _singularize(p)) for p, s in pairs if _singularize(p) != s]
print(f" plurals the v3-MEM band reader knows: {len(pairs)}")
print(f" MeaningGraph reader AGREES : {len(pairs) - len(disagree)}/{len(pairs)}")
print(f" MeaningGraph reader DISAGREES : {len(disagree)}/{len(pairs)}")
silent = [(p, s, g) for p, s, g in disagree if g is not None]
refused = [(p, s) for p, s, g in disagree if g is None]
print(f"\n SILENTLY WRONG singular ({len(silent)}):")
for plural, singular, got in silent:
print(f" {plural:10s} v3-MEM={singular:10s} MeaningGraph={got!r}")
print(f"\n REFUSED by MeaningGraph reader ({len(refused)}):")
print(f" {', '.join(p for p, _ in refused)}")
bad_write = [(p, s) for p, s in pairs if pluralize(s) != p]
print(f"\n writer cannot reproduce {len(bad_write)}/{len(pairs)} of those plurals:")
for plural, singular in bad_write:
print(f" pluralize({singular!r}) -> {pluralize(singular)!r}, want {plural!r}")
def section_served() -> None:
"""§1.7 — malformed categorical surfaces reaching served output."""
print("\n" + "=" * 74)
print("§1.7 Malformed surfaces served by the ratified v1b categorical band")
print("=" * 74)
from chat.deduction_surface import deduction_grounded_surface
probes = ["dogs", "cats", "students", "wolves", "children", "men"]
print(" synthetic probes:")
for noun in probes:
text = (
f"All {noun} are mammals. All mammals are animals. "
f"Therefore all {noun} are animals."
)
print(f" in : all {noun} are mammals ...")
print(f" out: {deduction_grounded_surface(text)}")
print("\n ratified corpus cases that serve a malformed categorical clause:")
flagged = 0
served_categorical = 0
for path in sorted((_REPO_ROOT / "evals/deduction_serve").glob("*/cases.jsonl")):
for line in path.read_text().splitlines():
if not line.strip():
continue
case = json.loads(line)
surface = deduction_grounded_surface(case["text"])
if not surface:
continue
import re
matches = re.findall(r"\b(all|no|some)\s+([a-z_]+)\s+(are|is)\b", surface)
if not matches:
continue
served_categorical += 1
for quant, noun, _copula in matches:
if re.search(rf"\b{quant}\s+{noun}s?\b", case["text"], re.I) and not re.search(
rf"\b{quant}\s+{noun}\b(?!s)", case["text"], re.I
):
flagged += 1
print(f" [{case['id']}] {surface}")
break
print(f"\n {flagged} malformed of {served_categorical} serving a categorical clause")
def section_roundtrip() -> None:
"""§1.6 — the round-trip baseline and the six-reader refusal table."""
print("\n" + "=" * 74)
print("§1.6 Round-trip baseline, and every reader on writer output")
print("=" * 74)
from evals.grammar_roundtrip.runner import run_lane
metrics = run_lane().metrics
for key in (
"graph_cases", "g_write_rate", "g_read_rate", "g_args_rate",
"g_predicates_rate", "g_exact_rate", "surface_cases", "s_read_rate",
"s_renderable_rate", "s_surface_match_rate", "negative_cases", "reject_rate",
):
print(f" {key:24s} {metrics[key]}")
print("\n every reader against writer output and live served surfaces:")
from generate.meaning_graph.reader import comprehend
from generate.proof_chain.cond_member import read_cond_member_argument
from generate.proof_chain.english import read_english_argument
from generate.proof_chain.exist import read_exist_argument
from generate.proof_chain.member import read_member_argument
from generate.proof_chain.verb import read_verb_argument
readers = {
"meaning_graph": lambda t: comprehend(t, source_id="probe"),
"english": read_english_argument,
"member": read_member_argument,
"cond_member": read_cond_member_argument,
"verb": read_verb_argument,
"exist": read_exist_argument,
}
targets = (
"Molecule binds enzyme.",
"all wolves is defined a mammal",
"Knowledge is what a person knows from truth and evidence.",
)
outcomes: collections.Counter[str] = collections.Counter()
for text in targets:
print(f"\n {text!r}")
for name, fn in readers.items():
try:
result = fn(text)
reason = getattr(result, "reason", "") or ""
kind = type(result).__name__
outcomes["refused" if "Refus" in kind else "read"] += 1
print(f" {name:14s} {kind:22s} {reason}")
except Exception as exc: # noqa: BLE001 - a raising reader is a result
outcomes["raised"] += 1
print(f" {name:14s} raised {type(exc).__name__}")
print(f"\n totals: {dict(outcomes)}")
_SECTIONS = {
"tables": section_tables,
"agreement": section_agreement,
"readers": section_readers,
"served": section_served,
"roundtrip": section_roundtrip,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--section", choices=sorted(_SECTIONS), action="append",
help="run only this section (repeatable); default is all",
)
args = parser.parse_args()
for name in args.section or list(_SECTIONS):
_SECTIONS[name]()
return 0
if __name__ == "__main__":
raise SystemExit(main())