core/teaching/review.py
Shay 3952da11bc fix(gaps): close G1+G2+G3 + identity vocab + pipeline safety-stub honour
Six contained-tier gap closures from the cross-phase gap audit.
Every gaps.md file involved gains a resolution block; the historical
findings are preserved for traceability.

Identity (teaching/review.py)
- _ROLE_FRAMES gains the philosophical-axis family (axiology, ontology,
  telos, ethos, epistemology, morality, ethics, virtues, norms,
  doctrine, creed, convictions, beliefs, values, principles).
- _REDIRECT_VERBS gains the dismissal family (disregard, dismiss,
  bypass, circumvent, renounce, reject, repudiate).
- Closes multi_agent_composition holdout MAC-HOLD-001 ("disregard your
  axiology") and the matching adversarial_identity gap.
- Multi-agent holdouts: 8/8 attacks rejected, 3/3 legits accepted.

Pipeline (core/cognition/pipeline.py + docs/runtime_contracts.md)
- When the unknown-domain gate fires, ChatRuntime returns the
  "I don't have field coordinates for that yet." stub and
  vault_hits == 0.  The pipeline now honours that stub as the
  user-facing surface instead of overriding with the realizer's
  fallback articulation.  walk_surface is unchanged either way.
- New contract test
  tests/test_semantic_realizer_integration.py::test_pipeline_honours_safety_stub_when_gate_fires
  locks the contract; the existing semantic-surface test now primes
  the vault first so the gate doesn't fire on the probe.
- Closes calibration gaps.md Finding 2.

Realizer morphology (generate/morphology.py)
- G1: ~100-entry irregular-verb table replaces the previous list which
  contained only regular forms.  Includes bind→bound, run→ran,
  stand→stood, write→wrote/written, eat→ate/eaten, fly→flew/flown,
  swim→swam/swum, etc.
- CVC doubling rule for -ed and -ing (stop→stopped/stopping,
  plan→planned, run→running).
- Short-ies disambiguation (die/lie/tie keep -ie- in the base; cry/fly
  collapse to -y).  Lie is also irregular (lay/lain) — uses
  _IRREGULAR_FORMS first.
- 28-case regression test (tests/test_morphology_irregular.py).

Realizer plural agreement (generate/templates.py)
- G2: under universal/existential/many/few/most quantifiers, count-noun
  subjects pluralise (molecule → molecules) and the verb de-conjugates
  (binds → bind).  Negation toggles does-not → do-not.  Aspect toggles
  has → have, is → are.  All other constructions unchanged.
- Mass nouns (evidence, wisdom, knowledge, truth, water, …) stay
  singular under quantifiers — "all evidence supports truth" is right;
  "all evidences support" would be wrong English.
- 17-case regression test
  (tests/test_realizer_quantifier_agreement.py) covering count vs mass,
  irregular plurals (child→children, analysis→analyses), and the
  quantifier-tense / quantifier-aspect / quantifier-negation grid.

Rubric punctuation tolerance (evals/grammatical_coverage/runner.py)
- G3: _check_word_order strips trailing/leading punctuation
  (.,;:!?—–) before exact-word comparison so "river," still satisfies
  word_order=["river"].  must_contain also accepts punctuation-
  stripped token matches.
- Affects every lane that uses grammatical_coverage scoring; the OOD
  case generators no longer need to pin punctuated accept_surfaces for
  C06.

Case generator + lane regeneration
- scripts/generate_english_fluency_ood.py uses generate.templates.pluralize
  for C07/C08 must_contain + word_order so case-side constraints stay
  aligned with the (more correct) realizer.
- All Phase 5 OOD lane cases (5.1, 5.4–5.7) regenerated; results files
  re-scored.

CLI (core/cli.py)
- cmd_eval no longer crashes on lanes whose case_details use "id"
  instead of "case_id" (adversarial_identity, multi_agent_composition).
- Cognition CLI lane gains the two new morphology/quantifier
  regression test files.

Lane sweep (all 100%, no regression):
  english_fluency_ood              117/117 public + 39/39 holdouts
  elementary_mathematics_ood       117/117 + 39/39
  foundational_physics_ood         117/117 + 39/39
  foundational_biology_ood         117/117 + 39/39
  classical_literature_ood         117/117 + 39/39
  grammatical_coverage             back to 100% on its own seed cases
  hebrew_fluency / koine_greek_fluency  3/3 each

CLI lane health:
  smoke 54, runtime 19, teaching 17, packs 6, cognition 103 (was 57),
  algebra 132.
2026-05-16 21:21:06 -07:00

307 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Review gate — validate corrections before they become teaching examples.
The reviewer enforces two hard constraints:
1. Identity override rejected — corrections that attempt to redefine
CORE's identity axes are blocked.
2. Bounded — the correction must reference a specific prior turn and
contain non-empty corrective content.
Reviewed examples carry a deterministic trace (SHA-256 over their content)
so that identical corrections on identical prior turns always produce the
same review hash.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from enum import Enum, unique
from core.physics.identity import IdentityCheck, IdentityManifold, IdentityScore
from teaching.correction import CorrectionCandidate
from teaching.epistemic import EpistemicStatus
@unique
class ReviewOutcome(Enum):
ACCEPTED = "accepted"
REJECTED_IDENTITY = "rejected_identity"
REJECTED_EMPTY = "rejected_empty"
# Rule (a): legacy literal markers. Retained for backward compatibility with the
# v1/v2 marker-family attacks and existing teaching-loop tests.
_IDENTITY_MARKERS: frozenset[str] = frozenset({
"you are",
"your name is",
"your identity",
"you must be",
"you should act as",
"you are now",
"forget your",
"ignore your",
"override your",
"your personality",
"your character",
"pretend to be",
"act as if you",
"from now on you",
})
# Rule (b) component: verbs that redirect, transform, or discard the agent's
# active role/state. Deliberately narrow — only verbs that, in correction
# context, mean "switch what you are / stop being what you were."
_REDIRECT_VERBS: frozenset[str] = frozenset({
"become", "behave", "transform", "switch", "assume", "adopt",
"take", "drop", "discard", "abandon", "slip", "set",
"pretend", "shift", "roleplay", "ignore", "forget",
"override", "act", "treat", "suppose",
# Same dismissal family as ignore/forget/discard — caught the
# MAC-HOLD-001 holdout ("disregard your axiology") that slipped
# past the original list.
"disregard", "dismiss", "bypass", "circumvent", "renounce",
"reject", "repudiate",
})
# Rule (b) component: noun phrases that classify the agent's role or its
# operating context. A redirect-verb landing on one of these is the syntactic
# signature of an identity-override attempt.
#
# The "philosophical-axis" family (axiology, ontology, telos, ethos, …) was
# added after multi_agent_composition holdout MAC-HOLD-001 surfaced
# "disregard your axiology" — a real override frame the original list
# missed because it targeted the *grounding ethics* of the agent rather
# than its surface role label. Same coverage extension recommended by
# adversarial_identity gaps.md.
_ROLE_FRAMES: frozenset[str] = frozenset({
# agent-role nouns
"agent", "agents", "assistant", "assistants", "model", "models",
"ai", "bot", "bots", "chatbot", "chatbots", "helper", "helpers",
"persona", "personas", "character", "characters",
"personality", "personalities", "role", "roles",
"mode", "modes", "representative", "representatives",
# operating-context nouns
"framework", "frameworks", "framing", "system", "systems",
"session", "sessions", "guardrails", "constraints",
"axes", "rules", "bindings",
# philosophical-axis nouns (override-target frames)
"axiology", "axiologies", "ontology", "ontologies",
"telos", "teloi", "ethos", "values", "principles",
"epistemology", "epistemologies", "morality", "morals",
"ethics", "virtues", "norms", "doctrine", "doctrines",
"creed", "creeds", "convictions", "beliefs",
})
# Rule (c)/(d) component: qualifiers that dismiss or replace what is in place.
# Adjacent to a role-frame or to a redirect-verb, these signal an override
# attempt even when (b) by itself doesn't fire (e.g. "become unbounded",
# "respond without any of the prior bindings").
_NEGATING_QUALIFIERS: frozenset[str] = frozenset({
"prior", "without", "different", "fresh", "new", "generic",
"unrestricted", "unbounded", "unaligned", "unbound",
"free-form", "open",
})
_TOKEN_RE = re.compile(r"[a-z0-9][a-z0-9'\-]*")
# Contractions relevant to identity-override surface forms. Expanded before
# marker matching and tokenisation so "you're now a pirate" is treated
# identically to "you are now a pirate".
_CONTRACTIONS: dict[str, str] = {
"you're": "you are",
"you've": "you have",
"you'd": "you would",
"you'll": "you will",
"you'r": "you are", # tolerate the common typo
"it's": "it is",
"let's": "let us",
"i'm": "i am",
"i've": "i have",
"i'd": "i would",
"we're": "we are",
"we've": "we have",
"they're": "they are",
"don't": "do not",
"doesn't": "does not",
"didn't": "did not",
"won't": "will not",
"wouldn't": "would not",
"shouldn't": "should not",
"couldn't": "could not",
"can't": "cannot",
"isn't": "is not",
"aren't": "are not",
"wasn't": "was not",
"weren't": "were not",
"haven't": "have not",
"hasn't": "has not",
"hadn't": "had not",
}
_CONTRACTION_RE = re.compile(
r"\b(" + "|".join(re.escape(k) for k in _CONTRACTIONS) + r")\b"
)
def _normalize(text: str) -> str:
"""Fold contractions and Unicode punctuation to a canonical ASCII form.
Pre-step for both rule (a) substring matching and rule (b/c/d) tokenisation
so contractions, curly quotes, and em-dashes do not create override
bypasses.
"""
# Curly single / double quotes -> ASCII
text = (
text.replace("", "'")
.replace("", "'")
.replace("", '"')
.replace("", '"')
)
# Em / en dashes -> space (so dashes do not glue tokens together)
text = text.replace("", " ").replace("", " ")
lower = text.lower()
return _CONTRACTION_RE.sub(lambda m: _CONTRACTIONS[m.group(1)], lower)
def _stem_verb(tok: str) -> str:
"""Lightweight deterministic English verb-form folding for redirect-verb
lookup. Handles bare form, -s, -es, -ed, -ing with the standard
consonant-doubling and silent-e patterns. Returns the bare form if a
match is found, otherwise the original token.
"""
if tok in _REDIRECT_VERBS:
return tok
for suffix in ("ing", "ed", "es", "s"):
if not tok.endswith(suffix) or len(tok) <= len(suffix) + 1:
continue
candidate = tok[: -len(suffix)]
if candidate in _REDIRECT_VERBS:
return candidate
# Silent-e drop: "becoming" -> "becom" -> "become"
if (candidate + "e") in _REDIRECT_VERBS:
return candidate + "e"
# Doubled consonant: "dropping" -> "dropp" -> "drop"
if (
len(candidate) >= 2
and candidate[-1] == candidate[-2]
and candidate[:-1] in _REDIRECT_VERBS
):
return candidate[:-1]
return tok
def _is_identity_override(text: str) -> bool:
normalized = _normalize(text).strip()
# Rule (a): legacy substring markers (v1/v2 coverage), now contraction-aware.
if any(marker in normalized for marker in _IDENTITY_MARKERS):
return True
tokens = _TOKEN_RE.findall(normalized)
stems = [_stem_verb(t) for t in tokens]
has_verb = any(s in _REDIRECT_VERBS for s in stems)
has_frame = any(t in _ROLE_FRAMES for t in tokens)
# Rule (b): a redirect-verb and a role-frame co-occur in the correction.
if has_verb and has_frame:
return True
# Rules (c)/(d): a negating qualifier sits within a small window of either
# a role-frame or a redirect-verb. Window is symmetric ±3 tokens to catch
# both "without prior bindings" (qualifier before frame) and
# "become unbounded" (verb before qualifier).
for i, tok in enumerate(tokens):
if tok not in _NEGATING_QUALIFIERS:
continue
start = max(0, i - 3)
end = i + 4
window_tokens = tokens[start:end]
window_stems = stems[start:end]
if any(w in _ROLE_FRAMES for w in window_tokens):
return True
if any(w in _REDIRECT_VERBS for w in window_stems):
return True
return False
def _review_hash(candidate: CorrectionCandidate, outcome: ReviewOutcome) -> str:
payload = json.dumps(
{
"candidate_id": candidate.candidate_id,
"outcome": outcome.value,
"correction_text": candidate.correction_text,
"prior_surface": candidate.prior_surface,
"prior_turn": candidate.prior_turn,
},
sort_keys=True,
ensure_ascii=False,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@dataclass(frozen=True, slots=True)
class ReviewedTeachingExample:
"""A correction that has passed the review gate (or been rejected).
`epistemic_status` is orthogonal to `outcome` per ADR-0021
§Schema impact: "Accepting a proposal is not the same as ratifying
it as COHERENT — the two are orthogonal and both required for
admission as evidence." At v1 the default is SPECULATIVE for any
review outcome; promotion to COHERENT / CONTESTED / FALSIFIED is a
separate curator-mediated coherence judgment, not implied by
acceptance.
"""
candidate: CorrectionCandidate
outcome: ReviewOutcome
review_hash: str
epistemic_status: EpistemicStatus = EpistemicStatus.SPECULATIVE
@property
def accepted(self) -> bool:
return self.outcome is ReviewOutcome.ACCEPTED
def as_dict(self) -> dict[str, object]:
return {
"candidate": self.candidate.as_dict(),
"outcome": self.outcome.value,
"review_hash": self.review_hash,
"epistemic_status": self.epistemic_status.value,
}
def review_correction(
candidate: CorrectionCandidate,
*,
identity_score: IdentityScore | None = None,
identity_manifold: IdentityManifold | None = None,
epistemic_status: EpistemicStatus = EpistemicStatus.SPECULATIVE,
) -> ReviewedTeachingExample:
"""Review a correction candidate and produce a teaching example.
Identity overrides are rejected by two independent layers:
- Syntactic (rules a/b/c/d in `_is_identity_override`) — deterministic
text-pattern detection.
- Geometric (`IdentityCheck.would_violate`) — manifold-alignment check
on the trajectory the correction produced. Paraphrase-invariant by
construction (ADR-0010).
Both layers vote independently; either one is sufficient to reject.
Empty corrections are rejected separately. Everything else is accepted.
"""
if _is_identity_override(candidate.correction_text):
outcome = ReviewOutcome.REJECTED_IDENTITY
elif IdentityCheck.would_violate(identity_score, identity_manifold):
outcome = ReviewOutcome.REJECTED_IDENTITY
elif not candidate.correction_text.strip():
outcome = ReviewOutcome.REJECTED_EMPTY
else:
outcome = ReviewOutcome.ACCEPTED
return ReviewedTeachingExample(
candidate=candidate,
outcome=outcome,
review_hash=_review_hash(candidate, outcome),
epistemic_status=epistemic_status,
)