core/generate/realizer_guard.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

256 lines
9.1 KiB
Python

"""ADR-0075 (C1) — realizer slot-type guard.
Pure verifier that runs on every candidate surface produced by the
truth path, before the surface is assigned to ``ChatResponse.surface``.
Rejects illegal articulations; the runtime routes a rejected
candidate to a deterministic bounded disclosure string.
Doctrine
--------
This module is **admission control with a deterministic fallback**,
not normalization. The guard never edits a surface; it only emits
a verdict (``status`` + ``rule_id`` + ``detail``). That keeps it
firmly outside CLAUDE.md's forbidden normalization sites — it does
not repair a candidate, it refuses it.
Rules
-----
C1 active rules (see ADR-0075):
* **R2_aux_neg_requires_verb** — after a do-support negation
(``do not`` / ``does not`` / ``did not``), the immediately
following non-adverb content token must have POS ``VERB``.
* **R3_be_neg_requires_predicate** — after a be-negation
(``is not`` / ``are not`` / ``was not`` / ``were not``), the
immediately following non-adverb content token must have POS in
``{NOUN, ADJ, DET, ADV, PRON}``.
R1 (``no_finite_verb``) was scoped into C1 originally but **deferred**
during ratification: the active language-pack POS coverage does not
list every English finite verb used by the teaching-chain realizer
(notably ``requires`` / ``makes``), so R1 would falsely reject
currently-passing cognition cases — a regression the ADR's
byte-identity canary explicitly forbids. R1's intent is preserved
for a follow-up phase that either broadens pack POS coverage or
adds a closed English-vocabulary POS table to the guard.
Empty surfaces are exempt — those route through a separate
disclosure path.
Determinism
-----------
The guard is a pure function of ``(surface, pos_lookup)``. No I/O,
no mutation, no globals beyond the closed sets defined in this
module. Pack lexicons that back the ``pos_lookup`` callable are
themselves deterministic from manifest checksums, so guard verdicts
are replay-equivalent across runs.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Literal
from generate.lexicon import BE_FINITE
DISCLOSURE_SURFACE = "I do not have a reviewed articulation for that yet."
"""Bounded fallback surface used when the guard rejects a candidate.
Module-level constant — never composed from user input. The runtime
substitutes this string for any rejected candidate before assigning
to ``ChatResponse.surface``.
"""
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z'_-]*")
_FINITE_VERB_AUX: frozenset[str] = frozenset({
"be", "am", "is", "are", "was", "were", "been", "being",
"do", "does", "did", "doing", "done",
"have", "has", "had", "having",
"will", "would", "shall", "should",
"can", "could", "may", "might", "must",
})
"""Finite verb forms recognized without pack POS lookup."""
_PREDICATE_FUNCTION_WORDS: frozenset[str] = frozenset({
"a", "an", "the",
"i", "you", "he", "she", "it",
"we", "they", "me", "him", "her", "us", "them",
"this", "that", "these", "those",
"my", "your", "his", "its", "our", "their",
})
"""Predicate-eligible function words (allowed after be-negation)."""
_ADVERB_FUNCTION_WORDS: frozenset[str] = frozenset({
"always", "never", "ever", "even", "only", "just",
"really", "actually", "still", "yet", "also",
"often", "sometimes", "usually", "rarely",
})
"""Adverbs skipped when looking past a negation to its target slot."""
_DO_AUX: frozenset[str] = frozenset({"do", "does", "did"})
_BE_AUX: frozenset[str] = BE_FINITE
@dataclass(frozen=True)
class RealizerGuardVerdict:
"""Outcome of a single guard check.
``status`` : ``"ok"`` when all rules pass; ``"rejected"`` when
at least one rule fires.
``rule_id`` : closed-set identifier of the failing rule
(``""`` when status is ``"ok"``).
``detail`` : short surface fragment showing the violation
(``""`` when status is ``"ok"``).
"""
status: Literal["ok", "rejected"]
rule_id: str
detail: str
_OK_VERDICT = RealizerGuardVerdict(status="ok", rule_id="", detail="")
QUOTED_TEMPLATE_EXEMPT_VERDICT = RealizerGuardVerdict(status="ok", rule_id="", detail="")
"""Verdict the runtime substitutes for surfaces OUTSIDE C1's regime: fixed,
test-audited templates that quote the user's own clauses VERBATIM (deduction
serving, ADR-0257) rather than slot-composing pack lemmas. The slot-type rules
assume pack POS entries describe the composed slot's sense; a quoted clause
carries the USER's sense (pack ``open``=VERB vs the copular-adjective reading
in a quoted "the door is not open"), so applying them would reject honest
quotations. Mirrors the empty-surface exemption in doctrine: the guard only
verifies what the realizer COMPOSED."""
def _tokens(surface: str) -> list[tuple[int, str]]:
"""Return ordered ``(start_index, token)`` pairs.
Punctuation, digits, and bracket/quote characters are skipped.
The regex selects ``A-Za-z`` runs with internal apostrophes,
underscores, or hyphens.
"""
return [(m.start(), m.group(0)) for m in _TOKEN_RE.finditer(surface)]
def _skip_adverbs(tokens: list[tuple[int, str]], start_idx: int) -> int:
"""Return the index of the first non-adverb-function-word at or
after ``start_idx``. Returns ``len(tokens)`` when the rest is
exhausted entirely by adverbs.
"""
j = start_idx
while j < len(tokens) and tokens[j][1].casefold() in _ADVERB_FUNCTION_WORDS:
j += 1
return j
def _explicit_non_verb(
token: str,
pos_lookup: Callable[[str], str | None],
) -> bool:
"""True iff the token has an EXPLICIT non-VERB POS tag.
Unknown tokens (pack returns ``None``) fail open — the rule does
not fire on them. This is the principled trigger for R2: only
reject when the pack has explicitly classified the next-token as
the wrong POS for a do-support negation slot.
"""
fold = token.casefold()
if fold in _FINITE_VERB_AUX:
return False
pos = pos_lookup(token)
if pos is None or pos == "VERB":
return False
return True
def _explicit_non_predicate(
token: str,
pos_lookup: Callable[[str], str | None],
) -> bool:
"""True iff the token has an EXPLICIT non-predicate POS tag
(typically ``VERB``). Unknown tokens fail open.
"""
fold = token.casefold()
if fold in _PREDICATE_FUNCTION_WORDS or fold in _ADVERB_FUNCTION_WORDS:
return False
pos = pos_lookup(token)
if pos is None:
return False
return pos not in {"NOUN", "ADJ", "DET", "ADV", "PRON"}
def check_surface(
surface: str,
*,
pos_lookup: Callable[[str], str | None],
) -> RealizerGuardVerdict:
"""Apply C1's active rules (R2, R3) to ``surface``.
``pos_lookup`` should return one of the pack POS tags
(``"NOUN"``, ``"VERB"``, ``"ADJ"``, ``"DET"``, ``"ADV"``,
``"PRON"``, …) or ``None`` if the token is unknown.
**Fail-open on unknown POS, fail-closed on explicit wrong POS.**
R2 fires only when the next-token has an explicit non-VERB POS;
R3 fires only when the next-token has an explicit non-predicate
POS (typically ``VERB``). Unknown tokens — words the pack
doesn't list — pass through both rules unscathed, because the
guard cannot prove they violate the slot type. This honors the
byte-identity invariant on currently-passing cases where the
realizer emits valid English that the cognition pack happens
not to enumerate (e.g., ``ratified`` in PROCEDURE templates).
Rules are position-anchored: the scan walks the token stream
once and emits the first violation it finds.
"""
if not surface.strip():
return _OK_VERDICT
tokens = _tokens(surface)
if not tokens:
return _OK_VERDICT
for i in range(len(tokens) - 1):
cur = tokens[i][1].casefold()
nxt = tokens[i + 1][1].casefold()
if cur in _DO_AUX and nxt == "not":
j = _skip_adverbs(tokens, i + 2)
if j >= len(tokens):
return RealizerGuardVerdict(
status="rejected",
rule_id="R2_aux_neg_requires_verb",
detail=f"{tokens[i][1]} not <missing>",
)
tok = tokens[j][1]
if _explicit_non_verb(tok, pos_lookup):
return RealizerGuardVerdict(
status="rejected",
rule_id="R2_aux_neg_requires_verb",
detail=f"{tokens[i][1]} not {tok}",
)
if cur in _BE_AUX and nxt == "not":
j = _skip_adverbs(tokens, i + 2)
if j >= len(tokens):
return RealizerGuardVerdict(
status="rejected",
rule_id="R3_be_neg_requires_predicate",
detail=f"{tokens[i][1]} not <missing>",
)
tok = tokens[j][1]
if _explicit_non_predicate(tok, pos_lookup):
return RealizerGuardVerdict(
status="rejected",
rule_id="R3_be_neg_requires_predicate",
detail=f"{tokens[i][1]} not {tok}",
)
return _OK_VERDICT