"""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, )