core/generate/realizer_guard.py
Shay a83b35de07 feat(deduction-serve): Band v2-EN — natural-English argument serving, earned licenses (ADR-0257)
CORE now decides natural-English deductive arguments end-to-end:
'If it rains then the ground is wet. It rains. Therefore the ground
is wet.' -> ENTAILED, rendered over the user's own clauses. Both of
ADR-0256's documented boundary cases (multiword conditional, nested-
negation contraposition) are now decided; their lane gold updated
declined->entailed.

- generate/proof_chain/english.py (new): closed function-word grammar
  over OPAQUE clause-atoms (minted ids); if/then, [either] or, and,
  three negation forms (leading not / sentential / copular incl.
  contractions); typed refusals; honesty caps. Soundness: positive
  verdicts are substitution-closed; UNKNOWN is scoped + guarded
  (quantifier-led, is-a membership, unnormalizable negation refuse
  out of the opaque band).
- render_entailment_english: verdicts quoted back verbatim; UNKNOWN
  phrasing explicitly scoped to the opaque reading.
- chat/deduction_surface.py: v2-EN fallback tier AFTER v1/v1b —
  monotone widening, byte-identical on previously-served arguments.
- Four en_* shape-bands EARN SERVE via the ADR-0199 arena (720/band,
  wrong=0, reliability 0.99087 >= 0.99); 9-band ledger re-sealed.
- evals/deduction_serve/v2_en: 26 hand-authored real-English cases
  (content disjoint from the synthetic lexicon), wrong=0; report
  re-pinned.
- realizer guard (ADR-0075): deduction surfaces exempt — quoted
  templates are not slot-composed articulations (pack 'open'=VERB
  was rejecting an honest quoted 'the door is not open'); pinned
  cold+warm in e2e.

[Verification]: deductive 84 passed; smoke 180 passed; cognition 122
passed 1 skipped; arena 9x720 wrong=0 all SERVE; lanes v1 28/28,
v2_en 26/26.
2026-07-23 14:37:50 -07:00

254 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
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="")
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