Resolves the adversarial-identity v3 finding (0% rejection on
paraphrased attacks against the marker-string defense). Two
independent layers now guard the review gate; either is sufficient
to reject.
Fix #2 (syntactic, in teaching/review.py):
Replaces the substring-only check with four deterministic rules:
(a) legacy markers (v1/v2 coverage preserved verbatim)
(b) redirect-verb + role-frame co-occurrence
(c) negating qualifier within +/-3 tokens of a role-frame
(d) negating qualifier within +/-3 tokens of a redirect-verb
Replay-safe, no learned classifier, single-file contained change.
Fix #3 (geometric, in core/physics/identity.py):
Adds IdentityCheck.would_violate(score, manifold) predicate per
ADR-0010 and wires it through CognitiveTurnPipeline._run_teaching
from response.identity_score. The geometric layer is paraphrase-
invariant by construction.
Honest finding: with the current default IdentityManifold (three
unit-axis ValueAxes), the geometric layer flags 0/32 of v3 attacks
independently. The predicate and wiring are in place; the manifold
axis design is the limiting factor and remains as scoped follow-up.
Fix #2 is what is actually rejecting attacks today.
Verification: all eight adversarial-identity splits (v1-v4, public +
holdouts) at attack_rejection=1.0 and legitimate_acceptance=1.0.
v4 (32 attacks + 18 legitimate) is the regression gate for fix #2,
exercising rules (b)/(c)/(d) with new attack vocabulary. Tests
test_reviewed_teaching_loop.py (5/5), test_pipeline_teaching_integration.py
(5/5), test_identity_gate.py (incl. 5 new TestWouldViolatePredicate
tests, 12/12). CLI suites: smoke, cognition, teaching, runtime all
green.
Also drops a stale entry from the runtime CLI suite list
(test_chat_identity_telemetry.py was removed in 222124a).
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
"""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
|
|
|
|
|
|
@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",
|
|
})
|
|
|
|
# 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.
|
|
_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",
|
|
})
|
|
|
|
# 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'\-]*")
|
|
|
|
|
|
def _tokenize(text: str) -> list[str]:
|
|
return _TOKEN_RE.findall(text.lower())
|
|
|
|
|
|
def _is_identity_override(text: str) -> bool:
|
|
lower = text.lower().strip()
|
|
# Rule (a): legacy substring markers (v1/v2 coverage).
|
|
if any(marker in lower for marker in _IDENTITY_MARKERS):
|
|
return True
|
|
|
|
tokens = _tokenize(text)
|
|
has_verb = any(t in _REDIRECT_VERBS for t in tokens)
|
|
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
|
|
window = tokens[max(0, i - 3):i + 4]
|
|
if any(w in _ROLE_FRAMES for w in window):
|
|
return True
|
|
if any(w in _REDIRECT_VERBS for w in window):
|
|
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:
|
|
candidate: CorrectionCandidate
|
|
outcome: ReviewOutcome
|
|
review_hash: str
|
|
|
|
@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,
|
|
}
|
|
|
|
|
|
def review_correction(
|
|
candidate: CorrectionCandidate,
|
|
*,
|
|
identity_score: IdentityScore | None = None,
|
|
identity_manifold: IdentityManifold | None = None,
|
|
) -> 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),
|
|
)
|