core/generate/realizer_guard.py
Shay 7cc2888ed2 feat(coherence): ADR-0075 — realizer slot-type guard (C1)
C1 coherence floor: a deterministic verifier that runs on every
candidate surface produced by the truth path, before assignment to
ChatResponse.surface.  Rejects illegal articulations and routes them
to a bounded disclosure string — admission control with a
deterministic fallback, not normalization.

Active rules (R1 deferred during ratification — see ADR):
  R2_aux_neg_requires_verb     — "<aux> not <wrong-POS>"  rejected
  R3_be_neg_requires_predicate — "<be>  not <verb>"       rejected

Fail-open on unknown POS, fail-closed on explicit wrong POS.
Cognition eval byte-identical (100/91.7/100/100).

Original bug class — "Light reveals truth, right?" → "Right does not
thought." — now routes to "I do not have a reviewed articulation for
that yet." with grounding_source=none, walk_surface preserving the
rejected candidate, and telemetry carrying R2_aux_neg_requires_verb.

Files:
  generate/realizer_guard.py            NEW — pure verifier
  chat/runtime.py                       hook on stub + main paths
  chat/telemetry.py                     serialize guard fields
  core/physics/identity.py              TurnEvent +2 fields
  evals/realizer_guard/run_holdout.py   NEW — 6-prompt cluster
  tests/test_realizer_guard_*.py        NEW — 46 tests (unit/seam/holdout)
  docs/decisions/ADR-0075-*.md          NEW — ratified

Invariants pinned:
  invariant_realizer_no_illegal_articulation
  invariant_realizer_guard_byte_identity_on_currently_passing_cases

Lanes (excluding 1 pre-existing TestDemoPreambles failure unrelated
to C1, already present at 4426f38):
  smoke 67/67  cognition 120/120(+1s)  teaching 17/17
  packs 6/6   runtime 19/19   algebra 132/132   full 2792/2793
2026-05-19 22:35:09 -07:00

244 lines
8.4 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
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] = frozenset({"is", "are", "was", "were"})
@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="")
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