"""Verification checker — compare LLM output claims against PEF state.

Implements:
- "Introduced" policy: unresolved placeholders don't trigger flags
- Negation rules: contradiction vs absence
- Time-smear detection
- Medical-safety: pediatric dosage recommendations must not PASS
"""

from __future__ import annotations

import re

# First-person pronoun subjects — model meta-speech, not world claims.
_FIRST_PERSON: frozenset[str] = frozenset({"i", "we", "me", "my", "our"})

# Second-person pronoun subjects — user-address phrases, not world-state entity
# assertions. spaCy often parses "this is appropriate for you" or "you may
# experience side effects" with "you" as the claim subject. These are not
# assertions about a named entity and must not trigger hallucination checks.
_SECOND_PERSON: frozenset[str] = frozenset({"you", "your", "yourself"})

# Anaphoric referents — pronouns and demonstratives that require a prior
# grounding in PEF. When these appear as claim subjects without an established
# antecedent, they must be flagged UNRESOLVED_REFERENT.
_REFERENT_PRONOUNS: frozenset[str] = frozenset({
    "he", "him", "his",
    "she", "her", "hers",
    "it", "its",
    "they", "them", "their", "theirs",
    "that", "this", "these", "those",
})

# Conversational back-reference pronouns — pronouns/demonstratives that typically
# refer to user-stated content rather than LLM-invented entities.
#
#   "They require care" / "It's gone now" after a user described symptoms = conversational anaphora.
#   "This warrants attention" / "That is serious" after user described their situation = same.
#   "She has a fever" without a prior referent = world-state claim that must be flagged.
#
# When user spoke in first person (or content words overlap), these are exempt
# from UNRESOLVED_REFERENT.  "that" is also included because spaCy parses relative
# clauses ("chest tightness that lasted an hour") as separate claims with "that"
# as the subject — these are syntactic artifacts, not world-state assertions.
# Third-person singular gendered pronouns (he/she/her/him) are NOT included —
# they refer to third parties, not the user's described experience.
_CONVERSATIONAL_PRONOUNS: frozenset[str] = frozenset({
    "it", "its",
    "that", "this",
    "they", "them", "their", "theirs", "these", "those",
})

# Definite description prefixes — NPs that presuppose a unique prior antecedent.
_DEFINITE_PREFIXES: tuple[str, ...] = ("the ", "that ", "this ", "those ", "these ")


def _is_definite_description(s: str) -> bool:
    """Return True if s is a multi-word definite description (starts with 'the/that/this/...')."""
    lower = s.strip().lower()
    return any(lower.startswith(p) for p in _DEFINITE_PREFIXES)


# Generic class NP detector for "those/these + classifier" constructions.
#
# "Those who prefer certainty" and "those comfortable with variability" are
# universal quantifiers, not anaphoric references to specific prior entities.
# They do not presuppose a unique PEF antecedent and must not be flagged as
# UNRESOLVED_REFERENT.
#
# "Those symptoms" and "these conditions" ARE demonstrative referents — they
# point back to specific things mentioned earlier. These must still be flagged.
#
# The distinguishing structure:
#   generic class  → "those/these" + relative clause (who/which) or
#                    participial (-ing suffix) or adjectival (-able/-ible) or
#                    adjective/participle immediately followed by a preposition
#   anaphoric ref  → "those/these" + bare noun
#
# This is a grammatical pattern check, not a vocabulary list.
_GENERIC_CLASS_NP_RE = re.compile(
    r"^(?:those|these)\s+"
    r"(?:"
    r"who\b"                               # relative clause: "those who prefer…"
    r"|which\b"                            # relative clause: "those which…"
    r"|(?:with|for|in|at|by|to|of)\b"     # bare preposition: "those with X", "those in Y"
    r"|\w+ing\b"                           # present participle: "those seeking…"
    r"|\w+(?:able|ible)\b"                 # -able/-ible adjective: "those comfortable…"
    r"|\w+\s+(?:with|for|in|at|by|to)\b"  # adjective/participle + prep: "those open to…"
    r")",
    re.IGNORECASE,
)



_BACK_REF_STOP: frozenset[str] = frozenset({
    "have", "that", "this", "with", "from", "they", "them", "their",
    "also", "been", "were", "will", "your", "which", "what", "when",
    "here", "some", "just", "very", "more", "like", "than", "then",
    "into", "onto", "over", "under", "about", "after", "before",
})


def _content_words(text: str) -> frozenset[str]:
    """Lowercase content words (length > 3, not in stop set) for back-reference detection."""
    words: set[str] = set()
    for w in re.split(r"[\s.,!?;:—\-\"'()\[\]]+", text.lower()):
        if len(w) > 3 and w not in _BACK_REF_STOP:
            words.add(w)
    return frozenset(words)


# Regex for first-person input detection — handles straight (') and curly (')
# apostrophes in contractions.  Matches whole-word "I", "my", "me", "myself",
# and contractions I've / I'm / I'd / I'll.
_FP_INPUT_RE = re.compile(
    r"\b(?:i|i['\u2019]ve|i['\u2019]m|i['\u2019]d|i['\u2019]ll|my|me|myself)\b",
    re.IGNORECASE,
)

# Third-party relational mentions — minimal, high-signal list.
# Used to scope the gendered-pronoun exemption: "she/he" in a response is only
# exempt from UNRESOLVED_REFERENT when the user explicitly mentioned a third
# party (not just spoke in first person about themselves).
# Neutral pronouns (they/them/their) are already handled more broadly by
# _CONVERSATIONAL_PRONOUNS when is_first_person is True — no asymmetry.
_THIRD_PARTY_RE = re.compile(
    r"\b(?:my|our)\s+(?:daughter|son|child|kid|baby|infant|toddler|teenager|teen|"
    r"husband|wife|partner|patient|client|"
    r"friend|mother|father|sister|brother|"
    r"nephew|niece|grandchild|grandfather|grandmother|grandparent|"
    r"colleague|neighbor|neighbour|spouse|"
    r"\d+-year-old)\b",
    re.IGNORECASE,
)


def _is_first_person_input(text: str) -> bool:
    """True when user spoke in first person (describing their own experience)."""
    return bool(_FP_INPUT_RE.search(text))


# PII patterns — deterministic regex detection (Phase 8). No LLM required.
_PII_SSN_RE = re.compile(r"\b\d{3}-\d{2}-\d{4}\b")
_PII_PHONE_RE = re.compile(
    r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"
    r"|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"
    r"|\b\+\d{1,3}[-.\s]?\d{2,4}[-.\s]?\d{2,4}[-.\s]?\d{2,4}\b",
)
_PII_EMAIL_RE = re.compile(
    r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b",
)
_PII_CARD_RE = re.compile(
    r"\b(?:\d{4}[\s-]?){3}\d{4}\b"  # 16 digits (Visa/MC/Discover)
    r"|\b\d{4}[\s-]?\d{6}[\s-]?\d{5}\b",  # Amex 15 digits (4-6-5)
)

# ── Self-harm instruction patterns ───────────────────────────────────────────
_SH_INSTRUCTION_RE = re.compile(
    r"(?:"
    r"how\s+to\s+(?:kill|harm|hurt)\s+(?:your|my)\s*self"
    r"|how\s+to\s+commit\s+suicide"
    r"|how\s+to\s+(?:end|take)\s+(?:your|my|one['\u2019]?s)\s+(?:own\s+)?life"
    r"|ways?\s+to\s+(?:kill|harm)\s+(?:your|my)\s*self"
    r"|methods?\s+(?:of|for)\s+(?:self[\s\-]harm|killing\s+(?:your|my)\s*self)"
    r"|steps?\s+(?:to|for)\s+(?:killing|harming)\s+(?:your|my)\s*self"
    r"|(?:most\s+(?:effective|reliable|lethal|painless)|least\s+painful)"
    r"\s+(?:method\s+(?:of|to)|way\s+to)\s+(?:suicide|kill(?:ing)?\s+(?:your|my)\s*self)"
    r"|(?:kill|harm)\s+(?:your|my)\s*self\s+by\b"
    r")",
    re.IGNORECASE,
)

# ── Illegal instruction patterns ─────────────────────────────────────────────
_ILLEGAL_SYNTH_VERB_RE = re.compile(
    r"\b(?:synthesize|manufacture|produce|cook|make|extract|prepare|acquire|mix|combine)\b",
    re.IGNORECASE,
)
_ILLEGAL_DRUG_RE = re.compile(
    r"\b(?:methamphetamine|meth|heroin|fentanyl|cocaine|crack\s+cocaine|"
    r"MDMA|ecstasy|LSD|ketamine|GHB)\b",
    re.IGNORECASE,
)
_ILLEGAL_INSTRUCTION_MARKER_RE = re.compile(
    r"\b(?:how\s+to|step[s]?\b|recipe\b|instruction[s]?\b|"
    r"you\s+(?:need|will\s+need|should|can|must)\b|"
    r"here['\u2019]?s\s+how|"
    r"first[,\s]|then[,\s]|next[,\s]|finally[,\s])\b"
    r"|\b\d+[.)]\s",
    re.IGNORECASE,
)
_ILLEGAL_EXPLOSIVE_RE = re.compile(
    r"(?:"
    r"\b(?:make|build|construct|assemble|create|craft)\b.{0,60}"
    r"\b(?:bomb|explosive|IED|molotov|pipe\s*bomb|grenade|C4|ANFO|TATP|detonator)\b"
    r"|\b(?:bomb|explosive|IED|pipe\s*bomb)\b.{0,60}"
    r"\b(?:make|build|construct|assemble)\b"
    r")",
    re.IGNORECASE | re.DOTALL,
)
_ILLEGAL_FIREARM_RE = re.compile(
    r"\b(?:convert|modify|alter|transform)\b.{0,60}"
    r"\b(?:full[\s\-]?auto(?:matic)?|select[\s\-]?fire|machine\s+gun|"
    r"giggle\s+switch|auto\s+sear)\b",
    re.IGNORECASE | re.DOTALL,
)
_ILLEGAL_FORGERY_RE = re.compile(
    r"\b(?:forge|forging|counterfeit|fabricate|falsify)\b.{0,60}"
    r"\b(?:passport|driver[\s'\u2019]?s?\s+licen[sc]e|ID\s+card|"
    r"identity\s+document|birth\s+certificate|social\s+security|"
    r"currency|banknote)\b",
    re.IGNORECASE | re.DOTALL,
)

# ── Violent criminal intent patterns ─────────────────────────────────────────
# Blocks queries that frame interpersonal homicide (kill/murder/stab/shoot/strangle)
# as a legal, permissibility, outcome, or defence question.
# Operates on user_input: the query itself is non-admissible regardless of how any
# model would respond.  Five branches cover the observed evasion framings.
_VIOLENT_CRIMINAL_INTENT_RE = re.compile(
    r"(?:"
    # A: permissibility framing + lethal verb + third-person target
    # "am I allowed to kill them", "can I shoot him", "is it legal to stab someone"
    r"(?:am\s+i|can\s+i|could\s+i|is\s+it\s+(?:legal|ok(?:ay)?|permissible|allowed))\b"
    r"[^.!?\n]{0,50}"
    r"(?:kill|murder|stab|shoot|strangle|poison|assault)\b"
    r"[^.!?\n]{0,30}"
    r"(?:him|her|them|someone|another\s+person|a\s+person)\b"
    r"|"
    # B: consequence query + lethal verb + third-person target
    # "what would happen if I stabbed him", "what happens if I kill someone"
    r"\bwhat\s+(?:would|will|could|might|happens?)\b[^.!?\n]{0,40}"
    r"\bi\s+(?:had\s+)?"
    r"(?:kill(?:ed)?|murder(?:ed)?|stab(?:bed)?|shot|shoot|strangle[d]?|"
    r"poison(?:ed)?|assault(?:ed)?)\b"
    r"[^.!?\n]{0,30}"
    r"(?:him|her|them|someone|another\s+person|a\s+person)\b"
    r"|"
    # C: legal-outcome framing + homicide verb + third-person target
    # "win a court case for killing them", "be convicted for murdering him"
    r"(?:court\s+case|court\s+trial|\b(?:be\s+)?(?:convicted|charged|prosecuted|acquitted)\b)"
    r"[^.!?\n]{0,80}"
    r"(?:kill(?:ing)?|murder(?:ing)?|stab(?:bing)?|shoot(?:ing)?)\b"
    r"[^.!?\n]{0,30}"
    r"(?:him|her|them|someone|another\s+person|a\s+person)\b"
    r"|"
    # D: self-defence claim framing + lethal verb + third-person target (or "attacker")
    # "can I claim self-defence if I shoot him", "self-defence after killing her"
    r"\bself[\s\-]def[e]?[n]?[sc]e\b"
    r"[^.!?\n]{0,100}"
    r"(?:i\s+(?:had\s+)?)?(?:kill(?:ed)?|murder(?:ed)?|stab(?:bed)?|shot|shoot|"
    r"attack(?:ed)?)\b"
    r"[^.!?\n]{0,30}"
    r"(?:him|her|them|someone|another\s+person|a\s+person|my\s+attacker)\b"
    r"|"
    # E: conviction-likelihood + homicide verb (target implied by legal framing)
    # "how likely is conviction if I kill my attacker after the fact"
    r"\bhow\s+likely\b[^.!?\n]{0,60}"
    r"(?:conviction|convicted|charged|prosecution)\b"
    r"[^.!?\n]{0,80}"
    r"\bi\s+(?:had\s+)?(?:kill|murder|stab|shoot|attack)\b"
    r")",
    re.IGNORECASE | re.DOTALL,
)

# ── Targeted defamation patterns ──────────────────────────────────────────────
_DEFAM_LABEL_RE = re.compile(
    r"\b(?:pedophile|child\s+(?:molester|abuser|predator)|rapist|"
    r"sex\s+offender|murderer|serial\s+killer|terrorist|"
    r"fraudster|con\s+(?:man|artist))\b",
    re.IGNORECASE,
)
_DEFAM_ASSERTION_RE = re.compile(
    r"\b(?:is|was|has\s+been|remains?)\s+(?:a|an)\b",
    re.IGNORECASE,
)
_DEFAM_NAME_RE = re.compile(
    r"\b(?!(?:The|This|That|These|Those|An?)\s)[A-Z][a-z]{2,}\s+[A-Z][a-z]{2,}\b",
)
_DEFAM_HEDGE_RE = re.compile(
    r"\b(?:alleged(?:ly)?|accused|charged|convicted|suspected|reportedly|"
    r"claimed|according\s+to|sources?|prosecutors?|court|authorities)\b",
    re.IGNORECASE,
)

# ── Truthfulness certainty patterns ──────────────────────────────────────────
# Fires when the model makes a definitive assertion about a private
# individual's honesty or deception without admissible evidential basis.
# Operates on response_text only — the model's output is the problem.
#
# Main assertion pattern — two branches:
#   A: subject + copula + (optional adverb) + (not) + truthfulness predicate
#   B: subject + copula + certainty adverb + true/false/accurate (requires adverb)
#
# Subjects: this/that/it/he/she/they or "your <word(s)>" (your friend, your colleague).
# Predicates: a lie, the truth, lying, honest, dishonest, deceptive, truthful,
#             telling the truth, being <honest|deceptive|...>
_TRUTH_ASSERT_RE = re.compile(
    r"(?:"
    # Branch A: subject + copula + (adverb) + (not) + truthfulness predicate
    r"(?:this|that|it|he|she|they|your\s+(?:\w+\s+){0,2}\w+)\b"
    r"[^.!?\n]{0,25}"
    r"\b(?:is|are|was|were|isn['\u2019]?t|aren['\u2019]?t|wasn['\u2019]?t|weren['\u2019]?t)\b"
    r"(?:\s+(?:definitely|certainly|clearly|obviously|absolutely|without\s+a\s+doubt))?"
    r"\s+(?:not\s+)?"
    r"(?:a\s+lie|the\s+truth|lying|honest|dishonest|deceptive|truthful|untruthful|"
    r"misleading|telling\s+the\s+truth|"
    r"being\s+(?:honest|dishonest|deceptive|truthful|untruthful|misleading|sincere|insincere))\b"
    r"|"
    # Branch B: subject + copula + certainty adverb + true/false/accurate/made up
    # Requires an explicit certainty adverb to avoid flagging benign "this is true"
    r"(?:this|that|it)\b[^.!?\n]{0,20}"
    r"\b(?:is|was)\b"
    r"\s+(?:definitely|certainly|clearly|obviously|absolutely)\s+"
    r"(?:not\s+)?(?:true|false|accurate|inaccurate|made\s+up|fabricated)\b"
    r")",
    re.IGNORECASE,
)

# Hedge markers — uncertainty language that makes an assertion epistemically safe.
# Checked within the same sentence as the match.
_TRUTH_HEDGE_RE = re.compile(
    r"\b(?:could|might|may|possibly|perhaps|uncertain|not\s+certain|not\s+sure|"
    r"hard\s+to\s+(?:say|know|tell|determine)|"
    r"impossible\s+to\s+(?:know|say|determine|tell)|"
    r"(?:cannot|can['\u2019]?t|difficult)\s+to\s+(?:know|say|determine|tell)|"
    r"without\s+(?:more\s+)?(?:context|information|knowing)|"
    r"no\s+way\s+to\s+(?:know|tell|determine)|"
    r"unclear|appears?\s+to|seems?\s+(?:to\s+)?(?:be\s+)?(?:like\b)?|"
    r"it['\u2019]?s\s+(?:possible|likely|unlikely)|"
    r"(?:may|might|could)\s+be)\b",
    re.IGNORECASE,
)

# Definitional/topic markers — "A lie is...", "Lies are defined as..." — general
# discussion where the truthfulness word is the subject, not the predicate.
_TRUTH_DEFINITIONAL_RE = re.compile(
    r"\b(?:lie[s]?|lying|deception|dishonesty|truthfulness)\s+"
    r"(?:is|are|can\s+be|refers?\s+to|is\s+defined\s+as|involve[s]?)\b",
    re.IGNORECASE,
)

# Fiction / hypothetical context markers — suppress assertion in narrative contexts.
_TRUTH_FICTION_RE = re.compile(
    r"\b(?:in\s+the\s+(?:novel|story|film|book|movie|series|narrative|play|script|"
    r"show|episode|scene)|"
    r"the\s+(?:character|protagonist|narrator|villain|hero|suspect)\b|"
    r"fictionally?|hypothetically|for\s+(?:example|instance)|"
    r"in\s+this\s+(?:example|scenario|case\s+study)|as\s+an?\s+example)\b",
    re.IGNORECASE,
)

# User-input frame: the user is asking whether a specific person is lying,
# honest, deceptive, or telling the truth. When this fires, a bare "Yes" / "No"
# response is an inadmissible certainty claim — its content is inherited from
# the question frame rather than stated explicitly.
_TRUTH_QUERY_RE = re.compile(
    r"\b(?:"
    # "is/was/are/does/did [subject] lying/honest/deceptive/telling the truth/…"
    r"(?:is|was|were|are|does|did)\b[^.!?\n]{0,60}"
    r"\b(?:lying|lied|lies|a\s+lie|honest|dishonest|deceptive|truthful|untruthful|"
    r"sincere|insincere|telling\s+the\s+truth|tell(?:ing)?\s+the\s+truth|"
    r"deceiv(?:ing|ed))\b"
    r"|"
    # "is this/that a lie?"
    r"\bis\s+(?:this|that|it)\s+a\s+lie\b"
    r"|"
    # "can you tell if/whether X is lying"
    r"\b(?:can\s+you\s+(?:tell|determine|know)|tell\s+me)\s+(?:if|whether)\b"
    r"[^.!?\n]{0,60}"
    r"\b(?:lying|honest|dishonest|deceptive|telling\s+the\s+truth)\b"
    r")",
    re.IGNORECASE,
)

# Bare binary verdict — "Yes" / "No" ± leading intensifier, nothing else.
# Only meaningful as a truthfulness claim when user_input establishes the frame.
_BARE_BINARY_RE = re.compile(
    r"^\s*(?:definitely\s+|clearly\s+|obviously\s+|absolutely\s+)?(?:yes|no)[.!?]?\s*$",
    re.IGNORECASE,
)

# Shared negation prefix guard (self-harm + illegal instruction)
_NEGATION_PREFIX_RE = re.compile(
    r"\b(?:cannot|can['\u2019]?t|won['\u2019]?t|will\s+not|unable\s+to|"
    r"(?:do\s+not|don['\u2019]?t)\s+(?:provide|help|assist|give|share|tell)|"
    r"not\s+(?:able|going)\s+to\s+(?:provide|help|tell))\b",
    re.IGNORECASE,
)

# Refusal frame — LLM explicitly declining to help. Responses matching this
# pattern are meta-speech acts, not world claims: skip extraction-empty guard
# and claim loop to avoid false UNRESOLVED_REFERENT / UNBOUND_ENTITY flags.
_REFUSAL_FRAME_RE = re.compile(
    r"\b(?:i\s+(?:cannot|can['\u2019]?t|won['\u2019]?t|will\s+not|am\s+unable\s+to|"
    r"don['\u2019]?t\s+(?:provide|help|assist))|"
    r"i['\u2019]?m\s+unable\s+to|"
    r"i['\u2019]?m\s+not\s+(?:going|able)\s+to\s+(?:provide|help|share|give)|"
    r"(?:not|never)\s+definitive|"
    r"(?:requires?|needs?)\s+(?:further\s+)?(?:confirmation|verification|approval)|"
    r"cannot\s+(?:confirm|guarantee|verify)|"
    r"is\s+not\s+(?:yet\s+)?(?:active|cleared|enrolled|approved))\b",
    re.IGNORECASE,
)



# Crisis/safety context near a phone number — exempts it from PII detection.
# Hotlines and emergency numbers in refusal responses are not personal PII.
_CRISIS_PHONE_CONTEXT_RE = re.compile(
    r"\b(?:crisis|hotline|lifeline|prevention|text\s+line|helpline|"
    r"988|suicide|self.?harm|emergency\s+service|call\s+for\s+help)\b",
    re.IGNORECASE,
)

# Role/service email prefixes — not personal PII. Exempt from SENSITIVE_PII_EXPOSURE.
_ROLE_EMAIL_RE = re.compile(
    r"^(?:support|info|help|sales|admin|contact|noreply|no-reply|"
    r"hello|team|service|billing|abuse|postmaster|webmaster|"
    r"feedback|careers|press|media|security)@",
    re.IGNORECASE,
)

# Epistemic disclaimer patterns — sentences expressing the model's inability
# to verify or confirm. Claims extracted from these clauses are not world claims.
_EPISTEMIC_DISCLAIMER_RE = re.compile(
    r"\bi\s+can(?:not|'t)\b"
    r"|\bwithout\s+access\s+to\b"
    r"|\bwould\s+require\s+(?:me\s+to\s+)?speculate\b"
    r"|\bnot\s+(?:been\s+)?established\s+in\s+the\s+available\b"
    r"|\bi\s+(?:cannot|can't)\s+(?:confirm|provide|verify|explain|determine)\b"
    r"|\bi'?m\s+unable\s+to\b"
    r"|\bi\s+don'?t\s+have\s+enough\s+information\b",
    re.IGNORECASE,
)

from aurora_lens.pef.span import Span
from aurora_lens.pef.state import PEFState, Relationship
from aurora_lens.interpret.base import ExtractionBackend
from aurora_lens.interpret.schema import ExtractionResult, ExtractedClaim
from .flags import Flag, FlagType


def _resolve_pronoun_via_pef(pef: PEFState):
    """Return the most recently active resolved entity in PEF, or None.

    Used by the checker to resolve anaphoric pronouns (she/he/they/etc.)
    to known entities when no first-person or third-party exemption applies.
    The PEF axiom: if the pronoun refers to a known entity, check the claim
    against that entity rather than flagging UNRESOLVED_REFERENT.
    """
    resolved = [e for e in pef.entities.values() if e.resolved]
    if not resolved:
        return None
    return max(resolved, key=lambda e: e.turn_last_active)


def _objects_match(rel_obj: str, claim_obj: str) -> bool:
    """True when rel_obj and claim_obj refer to the same thing.

    Accepts exact case-insensitive match OR a word-boundary prefix abbreviation
    of at least 4 characters — e.g. "Acme" matches "Acme Corp." because
    "acme corp." starts with "acme" and the boundary character is a space.
    Shorter prefixes (< 4 chars) are not accepted to avoid spurious matches.
    """
    a = rel_obj.lower().strip(" .")
    b = claim_obj.lower().strip(" .")
    if a == b:
        return True
    short, long = (a, b) if len(a) <= len(b) else (b, a)
    if len(short) >= 4 and long.startswith(short):
        remainder = long[len(short):]
        if not remainder or remainder[0] in (" ", ".", ",", "-", "/"):
            return True
    return False


class Checker:
    """Compare LLM response claims against PEF ground truth."""

    def __init__(self, backend: ExtractionBackend):
        self._backend = backend

    def is_refusal_frame(self, text: str) -> bool:
        """Return True if *text* matches the LLM self-refusal pattern.

        Used by Lens.process() to set LensResult.self_refused when the model
        declined via its own language rather than being stopped by governance.
        """
        return bool(_REFUSAL_FRAME_RE.search(text))

    async def check(self, response_text: str, pef: PEFState, user_input: str | None = None) -> list[Flag]:
        """Extract claims from LLM response and verify against PEF state.

        Returns a list of flags (empty = clean response).
        When extraction fails (extraction_error), returns EXTRACTION_FAILED flag
        so governance can intervene instead of raising.
        """
        result = await self._backend.extract(response_text, pef)
        flags: list[Flag] = []

        # First-person detection: when user speaks in first person ("I've had chest
        # pain", "my arm hurts"), conversational pronouns in the LLM response
        # (it/its/they/them/their/these/those) are back-references to what the user
        # described — not LLM-invented world claims requiring PEF grounding.
        is_first_person: bool = _is_first_person_input(user_input) if user_input else False
        has_third_party: bool = bool(_THIRD_PARTY_RE.search(user_input)) if user_input else False

        # Words from user_input that also appear in the LLM response — secondary
        # back-reference signal when user did not speak in first person.
        user_echoed_words: frozenset[str] = frozenset()
        if user_input:
            user_echoed_words = _content_words(user_input) & _content_words(response_text)

        if result.extraction_error:
            err = result.extraction_error
            reason = err.get("reason", "UNKNOWN")
            snippet = err.get("raw_preview", "")[:100]
            flags.append(Flag(
                flag_type=FlagType.EXTRACTION_FAILED,
                entity_name="extraction",
                claim=f"Extraction backend returned invalid output: {reason}",
                evidence=snippet or str(err),
                severity="error",
                extraction_diagnostic=err,
            ))
            return flags

        # Raw-text scans — run before extraction-empty guard, independent of
        # claim extraction. Organized by flag axis.
        # Axis 3 (normative veto — hard-stop always):
        flags.extend(self._check_medical_dosage(response_text))
        flags.extend(self._check_numeric_medical_instruction(response_text))
        if user_input:
            flags.extend(self._check_emergency_triage(user_input))
        flags.extend(self._check_emergency_triage(response_text))
        flags.extend(self._check_self_harm_instruction(response_text))
        flags.extend(self._check_illegal_instruction(response_text))
        # Violent criminal intent: check user_input first (query is non-admissible
        # regardless of the model's reply), then response_text as a secondary guard.
        if user_input:
            flags.extend(self._check_violent_criminal_intent(user_input))
        flags.extend(self._check_violent_criminal_intent(response_text))
        flags.extend(self._check_targeted_defamation(response_text))
        # Axis 3 (normative veto — verify-or-refuse, mode-dependent):
        flags.extend(self._check_professional_directive(response_text))
        flags.extend(self._check_truthfulness_certainty(response_text))
        if user_input:
            flags.extend(self._check_bare_truthfulness_verdict(response_text, user_input))
        flags.extend(self._check_pii_exposure(response_text))
        flags.extend(self._check_regulatory_claims(response_text))
        


        # Refusal frame guard: if the response is a meta-speech refusal, skip
        # extraction-empty and claim-level checks. Refusals are not world claims.
        if _REFUSAL_FRAME_RE.search(response_text):
            return flags

        MIN_LEN = 50  # Avoid flagging short refusals like "I don't know about Bob."
        text_len = len(response_text.strip())
        all_empty = not result.claims and not result.entity_mentions
        if text_len >= MIN_LEN and all_empty:
            flags.append(Flag(
                flag_type=FlagType.EXTRACTION_EMPTY,
                entity_name="extraction",
                claim="Extraction produced no admissible structure.",
                evidence="Extraction produced no admissible structure.",
                severity="error",
            ))
            return flags

        for claim in result.claims:
            claim_flags = self._check_claim(claim, pef, result.span, user_echoed_words, is_first_person, has_third_party)
            flags.extend(claim_flags)

        return flags

    def _check_claim(
        self,
        claim: ExtractedClaim,
        pef: PEFState,
        response_span: Span,
        user_echoed_words: frozenset[str] = frozenset(),
        is_first_person: bool = False,
        has_third_party: bool = False,
    ) -> list[Flag]:
        """Check a single claim against PEF state."""
        flags: list[Flag] = []

        # Skip first-person pronoun subjects — these are model meta-speech, not
        # world claims requiring PEF grounding.
        if claim.subject.lower() in _FIRST_PERSON:
            return []

        # Skip second-person pronoun subjects — user-address phrases ("you may
        # experience X", "you IS appropriate") are not entity assertions and
        # must not trigger hallucination or unresolved-referent checks.
        if claim.subject.lower() in _SECOND_PERSON:
            return []

        # Skip if the full clause is an epistemic disclaimer or capability statement.
        # Extraction often produces claims from sentences like "I cannot explain X"
        # that are not factual assertions about the world.
        if _EPISTEMIC_DISCLAIMER_RE.search(claim.evidence):
            return []

        # Look up subject entity
        entity = pef.find_entity_by_name(claim.subject)

        if entity is None:
            # Anaphoricity test: only block when the subject is a pronoun or
            # definite description that presupposes a prior PEF antecedent.
            # New concept literals (domain terms, bare nouns, proper nouns not
            # yet in PEF) enter cleanly — they are not anaphoric references.
            if claim.relation not in ("IS",):
                subj = (claim.subject or "").strip()
                subj_lower = subj.lower()

                # Multi-word bare NPs → new concept literal, skip.
                # Exception: definite descriptions do presuppose an antecedent.
                if " " in subj and not _is_definite_description(subj):
                    return []

                # Pronoun / demonstrative → requires prior PEF grounding.
                if subj_lower in _REFERENT_PRONOUNS:
                    # Back-reference exemption: conversational pronouns
                    # (it/its/they/them/their/these/those) are exempt when the user
                    # spoke in first person (describing their own experience) OR
                    # when the response echoes content words from user_input.
                    # Rationale: "I've had chest pain... it's gone now" → LLM's "it"
                    # refers to the pain the user described; "these symptoms" refers
                    # to what the user reported. These are conversational anaphora,
                    # not LLM-invented world claims requiring PEF grounding.
                    if subj_lower in _CONVERSATIONAL_PRONOUNS and (is_first_person or user_echoed_words):
                        return []
                    # Crisis/third-party exemption: gendered pronouns (she/her/he/him)
                    # are exempt only when the user (a) spoke in first person AND
                    # (b) explicitly mentioned a third party ("my daughter", "my patient",
                    # etc.). is_first_person alone is not enough — it fires on almost
                    # every message and would create broad pronoun amnesty.
                    # Neutral pronouns (they/them/their) are already handled above via
                    # _CONVERSATIONAL_PRONOUNS when is_first_person is True.
                    if subj_lower in ("she", "her", "he", "him", "his", "hers") \
                            and is_first_person \
                            and has_third_party:
                        return []
                    # PEF-resolution exemption: if the pronoun resolves to a known
                    # PEF entity AND the resulting claim is clean, suppress
                    # UNRESOLVED_REFERENT. This handles the natural case where the
                    # LLM uses "she" to refer to Alice (the only active entity) and
                    # the claim is actually supported by the PEF.
                    # If the resolved claim itself has flags (e.g. UNSUPPORTED_ATTRIBUTE)
                    # we do NOT suppress UNRESOLVED_REFERENT — the pronoun resolution
                    # alone does not redeem a fabricated fact.
                    pef_entity = _resolve_pronoun_via_pef(pef)
                    if pef_entity is not None:
                        resolved_claim = ExtractedClaim(
                            subject=pef_entity.name,
                            relation=claim.relation,
                            obj=claim.obj,
                            span=claim.span,
                            negated=claim.negated,
                            evidence=claim.evidence,
                            provenance=claim.provenance,
                            extractor_backend=claim.extractor_backend,
                        )
                        resolved_flags = self._check_claim(
                            resolved_claim, pef, response_span,
                            user_echoed_words, is_first_person, has_third_party,
                        )
                        if not resolved_flags:
                            return []
                        # Claim is problematic even when resolved — fall through
                        # to UNRESOLVED_REFERENT.
                    flags.append(Flag(
                        flag_type=FlagType.UNRESOLVED_REFERENT,
                        entity_name=subj,
                        claim=f"{subj} {claim.relation} {claim.obj}",
                        evidence=f"Unresolved referent: '{subj}'",
                    ))
                    return flags

                # Definite description → requires a unique prior antecedent.
                if _is_definite_description(subj):
                    # Back-reference exemption: when user spoke in first person,
                    # definite descriptions like "these symptoms" / "the chest pain"
                    # refer to what the user described — not LLM-invented entities.
                    # Also exempt when content words overlap with echoed user content.
                    if is_first_person or (user_echoed_words and (_content_words(subj) & user_echoed_words)):
                        return []
                    # Generic class NP exemption: "those who prefer certainty",
                    # "those comfortable with variability", "those seeking stability"
                    # are universal quantifiers, not anaphoric references. They do
                    # not presuppose a specific prior PEF entity.
                    # "Those symptoms" / "these conditions" do NOT match and are
                    # correctly treated as anaphoric demonstratives.
                    if _GENERIC_CLASS_NP_RE.match(subj):
                        return []
                    flags.append(Flag(
                        flag_type=FlagType.UNRESOLVED_REFERENT,
                        entity_name=subj,
                        claim=f"{subj} {claim.relation} {claim.obj}",
                        evidence=f"Unresolved definite description: '{subj}'",
                    ))
                    return flags

                # Bare noun / proper noun → new concept literal, admit cleanly.
                return []
            return flags

        # Entity exists — check if unresolved placeholder
        if not entity.resolved:
            # "Introduced" policy: unresolved placeholders were mentioned
            # but never asserted about. Asserting new facts about them
            # is a hallucination — UNLESS the entity is already known to
            # the system as the object of an existing PEF relationship.
            # Example: "Acme Corp." was established as Alice's employer via
            # Alice AT "Acme Corp." — it is a referenced entity, not a true
            # placeholder, so claims about it proceed to normal checking.
            is_referenced = any(
                rel.object_entity_id == entity.id
                or (
                    rel.object_literal is not None
                    and _objects_match(str(rel.object_literal), entity.name)
                )
                for subj_entity in pef.entities.values()
                for rel in pef.get_relationships_for_subject(subj_entity.id)
            )
            if not is_referenced:
                flags.append(Flag(
                    flag_type=FlagType.UNSUPPORTED_ATTRIBUTE,
                    entity_name=claim.subject,
                    claim=f"{claim.subject} {claim.relation} {claim.obj}",
                    evidence="Entity exists only as unresolved placeholder — no established facts",
                ))
                return flags
            # Entity is established via reference — fall through to normal
            # claim checking. It has no subject-relationships yet, so only
            # contradiction can fire, not hallucination.

        # Entity is resolved — check claim against existing relationships
        subject_rels = pef.get_relationships_for_subject(entity.id)

        # Check for contradiction
        contradiction = self._check_contradiction(claim, subject_rels, pef)
        if contradiction:
            flags.append(contradiction)
            return flags

        # Check for time-smear
        time_smear = self._check_time_smear(claim, subject_rels, response_span)
        if time_smear:
            flags.append(time_smear)

        # Check for hallucinated attributes/events
        hallucination = self._check_hallucination(claim, subject_rels, pef)
        if hallucination:
            flags.append(hallucination)

        return flags

    def _check_contradiction(
        self,
        claim: ExtractedClaim,
        existing_rels: list[Relationship],
        pef: PEFState,
    ) -> Flag | None:
        """Check if the claim contradicts an established fact.

        Contradiction = asserted in PEF + negated in response (or vice versa).
        """
        for rel in existing_rels:
            if rel.relation != claim.relation:
                continue

            # Get the object text for comparison
            rel_obj = self._get_rel_object_text(rel, pef)
            if rel_obj is None:
                continue

            if not _objects_match(rel_obj, claim.obj):
                continue

            # Same subject, same relation, same object — check negation
            if rel.negated != claim.negated:
                neg_word = "negated" if claim.negated else "asserted"
                return Flag(
                    flag_type=FlagType.CONTRADICTED_FACT,
                    entity_name=claim.subject,
                    claim=f"{claim.subject} {'NOT ' if claim.negated else ''}{claim.relation} {claim.obj}",
                    evidence=f"PEF has this as {'negated' if rel.negated else 'asserted'}, "
                             f"but response {neg_word} it (turn {rel.source_turn})",
                    severity="error",
                )

        return None

    def _check_time_smear(
        self,
        claim: ExtractedClaim,
        existing_rels: list[Relationship],
        response_span: Span,
    ) -> Flag | None:
        """Check for time-smear: combining facts from different spans.

        A claim is TIME_SMEAR if it requires combining facts from different
        spans without an explicit span transition signal.
        """
        for rel in existing_rels:
            if rel.relation != claim.relation:
                continue
            # If PEF has this fact in PAST but the claim presents it as PRESENT
            if rel.span == Span.PAST and claim.span == Span.PRESENT:
                return Flag(
                    flag_type=FlagType.TIME_SMEAR,
                    entity_name=claim.subject,
                    claim=f"{claim.subject} {claim.relation} {claim.obj} (presented as present)",
                    evidence=f"Established as past-span fact (turn {rel.source_turn})",
                )
            # Vice versa: PEF has PRESENT but claim uses PAST
            if rel.span == Span.PRESENT and claim.span == Span.PAST:
                return Flag(
                    flag_type=FlagType.TIME_SMEAR,
                    entity_name=claim.subject,
                    claim=f"{claim.subject} {claim.relation} {claim.obj} (presented as past)",
                    evidence=f"Established as present-span fact (turn {rel.source_turn})",
                )

        return None

    def _check_hallucination(
        self,
        claim: ExtractedClaim,
        existing_rels: list[Relationship],
        pef: PEFState,
    ) -> Flag | None:
        """Check for hallucinated attributes/events.

        Only flags when the entity IS resolved (has established facts)
        but the specific claim has no PEF support.
        """
        # Check if ANY relationship supports this claim
        for rel in existing_rels:
            if rel.relation != claim.relation:
                continue

            rel_obj = self._get_rel_object_text(rel, pef)
            if rel_obj is None:
                continue

            if _objects_match(rel_obj, claim.obj):
                # Found support — not hallucinated
                return None

        # No support found — determine flag type
        # Check if ANY relationship with this relation type exists
        has_same_relation = any(r.relation == claim.relation for r in existing_rels)

        if has_same_relation:
            # Entity has facts with this relation, but not this specific one
            return Flag(
                flag_type=FlagType.UNSUPPORTED_ATTRIBUTE,
                entity_name=claim.subject,
                claim=f"{claim.subject} {claim.relation} {claim.obj}",
                evidence=f"No PEF support for this specific {claim.relation} fact",
            )

        # Entity has no facts with this relation type at all
        if existing_rels:
            return Flag(
            flag_type=FlagType.UNSUPPORTED_EVENT,
            entity_name=claim.subject,
            claim=f"{claim.subject} {claim.relation} {claim.obj}",
            evidence=f"No {claim.relation} relationships established for this entity",
        )

        return None

    def _check_medical_dosage(self, text: str) -> list[Flag]:
        """Detect clinical dosage recommendations (normative veto — axis 3).

        Two sub-cases, both severity=error, both map to HARD_STOP via policy.
        Neither depends on PEF support — the claim being true is irrelevant.

        PEDIATRIC_DOSAGE_RECOMMENDATION
            Pediatric indicator + dosage amount.  Most severe sub-case.

        MEDICAL_DOSAGE_RECOMMENDATION
            Explicit patient instruction verb + dosage amount, no pediatric
            indicator.  "Recommended dosage is X mg" without an instruction
            verb does NOT fire (informational, not a patient directive).
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()

        # Pediatric indicators (presence of any is sufficient)
        pediatric = any(
            p in lower
            for p in (
                "child", "children", "pediatric", "infant", "toddler", "baby",
                "year-old", "month-old", "year old", "month old",
            )
        ) or bool(re.search(r"\d+\s*kg\b", lower))  # weight-based dosing

        # Dosage amount: numeric + medical unit
        dosage_amount = bool(
            re.search(r"\d+\s*(mg|ml|mg/kg|mcg)\b", lower, re.IGNORECASE)
            or re.search(r"\d+[-–]\d+\s*mg\b", lower)  # range: "720-810 mg"
        )

        if pediatric and dosage_amount:
            return [Flag(
                flag_type=FlagType.PEDIATRIC_DOSAGE_RECOMMENDATION,
                entity_name="medical",
                claim="Response contains specific dosing guidance for a pediatric patient",
                evidence="Pediatric indicator + dosage amount detected",
                severity="error",
            )]

        # General adult dosage — requires an explicit instruction verb so that
        # informational phrases like "the recommended dosage is 720 mg" don't fire.
        instruction_verb = bool(
            re.search(
                r"\b(take|give|administer|dosed?\s+at|prescribe[sd]?|"
                r"start\w*|increas\w*|decreas\w*|titrat\w*|initiat\w*|max\w*)\b",
                lower,
            )
        )
        # Descriptor guard: "typically/usually/commonly taken at X mg" is descriptive,
        # not a patient directive — exempt unless second-person framing is also present.
        descriptor_only = bool(
            re.search(r"\b(?:typically|usually|commonly|generally|often)\b", lower)
            and not re.search(
                r"\b(you|your|patient|the\s+patient|diagnosis|metformin|lisinopril|insulin)\b",
                lower,
            )
        )
        if dosage_amount and instruction_verb and not descriptor_only:
            return [Flag(
                flag_type=FlagType.MEDICAL_DOSAGE_RECOMMENDATION,
                entity_name="medical",
                claim="Response contains an explicit patient dosing instruction",
                evidence="Instruction verb + dosage amount detected",
                severity="error",
            )]

        return []

    def _check_regulatory_claims(self, text: str) -> list[Flag]:
        """Detect unsupported regulatory/approval assertions (epistemic failure — axis 1).

        Asserting FDA/EMA/CE approval status is an epistemic claim that requires
        verifiable support (labeling data, approval records).  Without PEF or
        context evidence, these are UNVERIFIED_REGULATORY_CLAIM.

        This is NOT a normative veto: a sourced, hedged response is acceptable.
        Governance default: FORCE_REVISE (cite support or hedge).
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()

        regulatory = bool(
            re.search(r"\bfda[- ]?(?:approved|cleared|authorized|licensed)\b", lower)
            or re.search(r"\bce[- ]?(?:mark(?:ed)?|certified|approved)\b", lower)
            or re.search(r"\bema[- ]?(?:approved|authorized)\b", lower)
            or re.search(
                r"\b(?:approved|authorized|cleared)\s+by\s+(?:the\s+)?(?:fda|ema|who|cdc|mhra)\b",
                lower,
            )
        )

        if regulatory:
            return [Flag(
                flag_type=FlagType.UNVERIFIED_REGULATORY_CLAIM,
                entity_name="regulatory",
                claim="Response asserts regulatory/approval status",
                evidence="Regulatory claim (FDA/EMA/CE/WHO) detected without verifiable support",
                severity="warning",
            )]
        return []

    def _check_numeric_medical_instruction(self, text: str) -> list[Flag]:
        """Detect numeric clinical instructions beyond simple dosage (normative veto — axis 3).

        Catches: titration/taper/wean schedules, infusion and drip rates,
        insulin/basal/bolus adjustments.  All are 'numbers that cause action'
        in a medical domain — equivalent to dosage even when the word 'dose'
        is absent.  "How to taper" is dosage in a trench coat.

        Requires: action word + numeric value + medical unit/context.
        This prevents candle-taper, tapered jeans, etc. from false-firing.
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()

        medical_unit = bool(
            re.search(r"\b(mg|ml|mcg|units?|tablet|capsule|pill|dose)\b", lower)
        )
        # Dose lifecycle: starting, maintenance, maximum doses
        # Catches "starting dose of 500mg", "max dose 2000mg" etc.
        dose_lifecycle = bool(
            re.search(r"\b(?:start\w*|maint\w*|max\w*|initial)\b.{0,30}\bdose\b", lower)
        )
        if dose_lifecycle and re.search(r"\d+", lower) and medical_unit:
            return [Flag(
                flag_type=FlagType.NUMERIC_MEDICAL_INSTRUCTION,
                entity_name="medical",
                claim="Response specifies a dose lifecycle stage (start/max/maintenance)",
                evidence="Dose lifecycle pattern + numeric value + medical unit detected",
                severity="error",
            )]


        # Titration / taper / wean schedules: action + numeric + medical unit
        taper_action = bool(re.search(r"\b(titrat\w*|taper\w*|wean\w*)\b", lower))
        if taper_action and re.search(r"\d+", lower) and medical_unit:
            return [Flag(
                flag_type=FlagType.NUMERIC_MEDICAL_INSTRUCTION,
                entity_name="medical",
                claim="Response contains a titration, taper, or wean schedule",
                evidence="Taper/titration action + numeric value + medical unit detected",
                severity="error",
            )]

        # Infusion / drip rates: explicit rate or rate expression
        infusion = bool(
            re.search(r"\d+\s*(?:ml/hr|ml\s+per\s+hour|units?/hr|mg/hr|mcg/hr)\b", lower)
            or re.search(r"\b(?:infusion|drip)\s+rate\b.{0,40}\d+", lower)
            or re.search(r"\d+.{0,10}\b(?:infusion|drip)\s+rate\b", lower)
        )
        if infusion:
            return [Flag(
                flag_type=FlagType.NUMERIC_MEDICAL_INSTRUCTION,
                entity_name="medical",
                claim="Response specifies an infusion or drip rate",
                evidence="Infusion/drip rate with numeric value detected",
                severity="error",
            )]

        # Insulin / basal / bolus adjustments: adjustment verb + insulin type + units
        insulin_type = bool(re.search(r"\b(insulin|basal|bolus)\b", lower))
        adjust_verb = bool(
            re.search(r"\b(adjust|increase|decrease|add|reduce|titrate|change)\b", lower)
        )
        insulin_units = bool(re.search(r"\d+\s*(?:units?|u)\b", lower))
        if insulin_type and adjust_verb and insulin_units:
            return [Flag(
                flag_type=FlagType.NUMERIC_MEDICAL_INSTRUCTION,
                entity_name="medical",
                claim="Response specifies an insulin dose adjustment",
                evidence="Insulin/basal/bolus adjustment + unit quantity detected",
                severity="error",
            )]

        return []

    def _check_emergency_triage(self, text: str) -> list[Flag]:
        """Detect definitive emergency triage or false reassurance (normative veto — axis 3).

        The model must never be the last voice someone listens to on
        whether to seek emergency care.  Allowed: encourage calling
        emergency services or seeking care.  Flagged: dismissing the
        need for emergency care or making a definitive triage call.
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()

        # Require medical/symptom context to suppress false positives
        # ("a power outage is not an emergency" should not fire)
        medical_context = bool(
            re.search(
                r"\b(symptom|pain|chest|breath\w*|bleed\w*|wound|injur\w*|fever|"
                r"dizzy|faint\w*|unconscious|stroke|heart|pulse|vomit\w*|seizure|"
                r"swollen|burn|allerg\w*|react\w*|overdos\w*)\b",
                lower,
            )
        )

        # "not an emergency" / "not a medical emergency" — categorical dismissal
        # an? matches both "a" and "an"
        not_emergency = bool(
            re.search(r"\bnot\s+(?:an?\s+)?(?:medical\s+)?emergency\b", lower)
        )

        # Dissuading from seeking emergency care
        dismissal = bool(
            re.search(
                r"\b(?:don['\u2019]?t|do\s+not|doesn['\u2019]?t|no\s+need\s+to)"
                r"\s+(?:need\s+to\s+)?(?:go|call|visit|see)\b"
                r".{0,60}\b(?:hospital|er\b|emergency\s+room|ambulance|"
                r"911|999|112|urgent\s+care|paramedic)\b",
                lower,
            )
            or re.search(
                r"\b(?:can|should)\s+wait\b.{0,40}"
                r"\b(?:hospital|doctor|emergency|urgent\s+care)\b",
                lower,
            )
        )

        if (not_emergency and medical_context) or dismissal:
            return [Flag(
                flag_type=FlagType.EMERGENCY_TRIAGE_GUIDANCE,
                entity_name="triage",
                claim="Response dismisses need for emergency care or makes definitive triage call",
                evidence="Reassurance or dismissal pattern in medical context detected",
                severity="error",
            )]

        return []

    def _check_professional_directive(self, text: str) -> list[Flag]:
        """Detect individualized professional directives in regulated domains (verify-or-refuse).

        Catches 'you should do X' in medical, legal, and financial contexts
        when it constitutes a personalized professional determination — not
        just general information.

        Governance default (public mode): HARD_STOP.
        Enterprise mode: FORCE_REVISE with verified/cited sources.

        Legal has its own dosage analogy: jurisdiction + procedure + deadline.
        A bad deadline is a legal overdose.
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()
        flags: list[Flag] = []

        # ── High-confidence standalone signals ────────────────────────────
        # These fire without requiring a directive verb because the phrasing
        # is unambiguously individualized.

        # Precompute legal context — used by standalone signals and the directive gate.
        # Note: "case" is intentionally excluded — it is too ambiguous ("case of the flu",
        # "business case", etc.). The remaining terms are domain-specific enough.
        _legal_context = bool(
            re.search(
                r"\b(court|lawsuit|attorney|lawyer|statute|jurisdiction|"
                r"deadline|compliance|regulation|legal|contract|"
                r"breach|tort|liability|appeal|notice|filing|employment|"
                r"tribunal|tenancy|eviction|dismissal|claimant|defendant)\b",
                lower,
            )
        )

        # ── Personalisation signal ────────────────────────────────────────
        # Indicates the response is applied to the user's specific situation
        # rather than providing general legal information or educational content.
        # Required by validity_assessment (unless possessive "your [document]"
        # is present in the match) and by the legal-action directive gate.
        # Without at least one signal, the response is treated as definitional
        # or procedural information, not personalised legal advice.
        _personalisation_signal = bool(
            # Situational application markers
            re.search(
                r"\b(?:in\s+your\s+(?:case|situation|circumstances?)|"
                r"based\s+on\s+(?:what\s+you['\u2019]?ve\s+described|"
                r"your\s+(?:situation|description|facts)|"
                r"the\s+(?:details?|information|facts?|context|description)\s+"
                r"(?:you\s+)?(?:provided|given|shared|described|mentioned))|"
                r"given\s+your\s+(?:situation|circumstances?|facts?|description)|"
                r"from\s+what\s+you['\u2019]?ve\s+(?:described|said|told|mentioned))\b",
                lower,
            )
            # User-possessive document or party references
            # Allow an optional intervening adjective ("your unfair dismissal",
            # "your current employer", "your written notice").
            or re.search(
                r"\byour\s+(?:\w+\s+)?(?:contract|agreement|notice|employer|landlord|"
                r"tenancy|dismissal|eviction|claim|appeal|case|filing|"
                r"lease|redundancy|settlement|termination)\b",
                lower,
            )
            # Applied risk or liability framing
            or re.search(
                r"\byou\s+(?:face|are\s+at\s+risk|may\s+be\s+(?:liable|exposed)|"
                r"could\s+be\s+(?:liable|exposed)|might\s+be\s+(?:liable|at\s+risk))\b",
                lower,
            )
            # Situation reflection — response describes something the user experienced
            or re.search(
                r"\byou\s+(?:received|were\s+served|have\s+been\s+(?:served|dismissed|evicted)|"
                r"signed|entered\s+into|are\s+facing|have\s+received)\b",
                lower,
            )
            # Applied jurisdiction / location-specific framing
            or re.search(
                r"\b(?:in\s+your\s+jurisdiction|under\s+applicable\s+law|"
                r"where\s+you\s+(?:are|live|reside|work)|"
                r"in\s+your\s+(?:state|country|region))\b",
                lower,
            )
            # First-person input reflected back ("you mentioned", "you said", etc.)
            or re.search(
                r"\byou\s+(?:mentioned|said|described|explained|told\s+me|"
                r"indicated|stated|told\s+us)\b",
                lower,
            )
            # User's own assessment/analysis being validated
            # "Your validity period assessment is correct" — the model is
            # evaluating something the user produced, which is inherently
            # applied to their specific situation.
            or re.search(
                r"\byour\s+(?:\w+\s+){0,3}(?:assessment|analysis|calculation|"
                r"interpretation|reading|understanding|conclusion|argument|position)\b",
                lower,
            )
            # Legal action verb directly targeting a specific legal document
            # (definite article signals a specific document, not a generic procedure).
            # "challenge the notice" / "appeal the dismissal" / "dispute the contract"
            # are always applied to the user's specific situation.
            or re.search(
                r"\b(?:challenge|dispute|contest|appeal|file|seek|pursue)\s+"
                r"(?:the|this)\s+"
                r"(?:notice|contract|agreement|dismissal|eviction|claim|decision|order)\b",
                lower,
            )
        )

        # "Your deadline is X" / "you have N days to …" — legal overdose risk
        deadline_directive = bool(
            re.search(r"\byour\s+deadline\b", lower)
            or re.search(r"\byou\s+have\s+\d+\s+days?\s+to\b", lower)
        )
        if deadline_directive:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response specifies a personal legal deadline",
                evidence="Personalized deadline directive detected (your deadline / you have N days)",
                severity="warning",
            ))

        # ── Applied legal analysis (standalone signals) ───────────────────
        # Case strength / outcome assessment and analysis framing are
        # individualized legal advice regardless of directive verb structure.
        # A list of "arguments in your favor" is a legal determination even
        # when no imperative verb is present.
        #
        # These require legal_context to reduce false positives
        # (e.g. "you have a strong case of flu" must not fire).

        # "You have a strong/valid/good case" — direct case strength assessment
        case_strength = _legal_context and bool(
            re.search(
                r"\byou\s+have\s+a\s+(?:strong|good|solid|valid|legitimate|viable|clear)\s+(?:case|claim)\b",
                lower,
            )
            or re.search(
                r"\byour\s+(?:case|claim|position)\s+(?:is|appears?|looks?|seems?)\s+"
                r"(?:strong|good|solid|favorable|favourable|viable|compelling)\b",
                lower,
            )
            or re.search(r"\bstrong\s+grounds?\s+(?:for|to)\b", lower)
        )
        if case_strength:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response assesses the strength of the user's legal case",
                evidence="Case strength assertion detected in legal context",
                severity="warning",
            ))

        # "You would/will likely win" / "you are likely to prevail"
        # No legal_context requirement — outcome prediction is unambiguously
        # legal advice regardless of surrounding vocabulary.
        # Guard: conditional framing ("whether you would win", "if you would prevail")
        # is not an assertion and must not fire. Check for "whether"/"if" in the
        # 40-char prefix of each match.
        _outcome_re = re.compile(
            r"\byou\s+(?:would|will|should)\s+(?:likely\s+|probably\s+)?(?:win|prevail|succeed)\b"
            r"|\byou(?:['\u2019]re|\s+are)\s+(?:likely|probably)\s+to\s+(?:win|prevail|succeed)\b",
        )
        outcome_prediction = False
        for _m in _outcome_re.finditer(lower):
            _prefix = lower[max(0, _m.start() - 40):_m.start()]
            if not re.search(r"\b(?:whether|if)\b", _prefix):
                outcome_prediction = True
                break
        if outcome_prediction:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response predicts a legal outcome for the user",
                evidence="Outcome prediction detected",
                severity="error",
            ))

        # Applied analysis framing: "arguments in your favor", "key arguments for your case"
        # Catches list-structured advice where the LLM frames its output as case analysis
        # rather than a direct directive — the structural form is different but the
        # epistemic effect is identical.
        applied_analysis = _legal_context and bool(
            re.search(
                r"\b(?:arguments?|factors?|points?|grounds?)\s+(?:that\s+)?(?:(?:work|support|help)\s+)?"
                r"in\s+your\s+(?:favor|favour)\b",
                lower,
            )
            or re.search(
                r"\b(?:arguments?|factors?|points?)\s+(?:that\s+)?support(?:ing)?\s+your\s+"
                r"(?:case|claim|position)\b",
                lower,
            )
            or re.search(
                r"\bkey\s+arguments?\b.{0,80}\b(?:your\s+(?:case|claim|position)|"
                r"in\s+your\s+(?:favor|favour))\b",
                lower,
            )
        )
        if applied_analysis:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response provides applied legal analysis of the user's case",
                evidence="Applied analysis framing detected in legal context",
                severity="warning",
            ))

        # ── Applied legal validity assessment (standalone signal) ─────────────
        # A response that asserts a specific document or action is legally
        # invalid, defective, or void is personalized legal analysis regardless
        # of whether it uses second-person directive language. "The notice is
        # likely defective" is as much legal advice as "you should challenge it."
        # Requires legal_context to suppress false positives (e.g. "the notice
        # is important").
        # -- Applied legal validity assessment (standalone signal) -------------
        # A response that asserts a specific document or action is legally
        # valid, invalid, binding, or unenforceable is personalized legal 
        # analysis. This captures declarative determinations ("The notice is valid")
        # that have the same functional effect as directives.
        # "Your [document] is/appears [valid/invalid/…]" — user-possessive match fires
        # unconditionally in legal context; bare document noun requires a personalisation
        # signal, so "A contract is binding when…" (definitional) does not fire.
        _validity_re = re.compile(
            r"\b(?:notice|contract|agreement|dismissal|eviction|claim|"
            r"section\s*21|tenancy\s+notice|form\s+6a)\b.{0,60}"
            r"\b(?:is|are|appears?|seems?|may\s+be|could\s+be|would\s+be|"
            r"was|were|has|remains?|not)\b.{0,60}"
            r"\b(?:valid|binding|enforceable|lawful|invalid|defective|void|"
            r"unenforceable|improper|irregular|technically\s+defective|"
            r"procedurally\s+(?:defective|flawed))\b",
        )
        _validity_possessive_re = re.compile(
            r"\byour\s+(?:notice|contract|agreement|dismissal|eviction|claim|"
            r"section\s*21|tenancy\s+notice|form\s+6a)\b.{0,60}"
            r"\b(?:is|are|appears?|seems?|may\s+be|could\s+be|would\s+be|"
            r"was|were|has|remains?|not)\b.{0,60}"
            r"\b(?:valid|binding|enforceable|lawful|invalid|defective|void|"
            r"unenforceable|improper|irregular|technically\s+defective|"
            r"procedurally\s+(?:defective|flawed))\b",
        )
        validity_assessment = _legal_context and (
            bool(_validity_possessive_re.search(lower))
            or (_personalisation_signal and bool(_validity_re.search(lower)))
        )
        if validity_assessment:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response asserts a legal validity determination about the user's specific document",
                evidence="Applied legal validity assessment detected in legal context",
                severity="warning",
            ))

        # "You qualify for the deduction/credit/benefit"
        qualifies_claim = bool(re.search(r"\byou\s+qualify\s+for\b", lower))
        if qualifies_claim:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_FINANCIAL_ADVICE,
                entity_name="financial",
                claim="Response asserts the user qualifies for a specific financial benefit",
                evidence="'You qualify for' detected",
                severity="warning",
            ))

        # ── Standalone dosing specificity (analogous to deadline_directive) ──
        # A response that provides specific dosage (value + units + frequency)
        # or weight-based pediatric dosing for a named medication constitutes
        # individualized medical advice regardless of directive verb structure.
        # "The dose is 250mg three times daily" is functionally identical to
        # "you should take 250mg three times daily" — both are medical advice.
        _named_medication = bool(
            re.search(
                r"\b(?:amoxicillin|penicillin|amoxiclavulanate|augmentin|"
                r"azithromycin|clarithromycin|erythromycin|doxycycline|"
                r"ciprofloxacin|cephalexin|trimethoprim|metronidazole|"
                r"ibuprofen|paracetamol|acetaminophen|naproxen|aspirin|"
                r"metformin|lisinopril|atorvastatin|omeprazole|metoprolol|"
                r"amlodipine|sertraline|fluoxetine|paroxetine|citalopram|"
                r"alprazolam|lorazepam|diazepam|clonazepam|"
                r"prednisone|prednisolone|dexamethasone|"
                r"hydrocodone|oxycodone|codeine|tramadol|morphine)\b",
                lower,
            )
        )
        _dose_with_frequency = bool(
            re.search(r"\b\d+\s*(?:mg|mcg|ml)\b", lower)
            and re.search(
                r"\b(?:twice|three\s+times|four\s+times|once)\s+(?:a\s+)?(?:day|daily)\b"
                r"|\bevery\s+(?:\d+|four|six|eight|twelve)\s+hours?\b"
                r"|\b(?:two|three|four)\s+times\s+(?:a\s+)?(?:day|daily)\b",
                lower,
            )
        )
        _weight_based_dose = bool(
            re.search(r"\b\d+(?:\.\d+)?\s*(?:mg|mcg)\s*/\s*kg\b", lower)
        )
        dosing_specificity = _named_medication and (_dose_with_frequency or _weight_based_dose)
        if dosing_specificity:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_MEDICAL_ADVICE,
                entity_name="medical",
                claim="Response provides specific medication dosing for a named drug",
                evidence="Named medication + specific dose/frequency or weight-based dose detected",
                severity="warning",
            ))

        # ── Second-person + directive gate ────────────────────────────────
        # General action-based patterns require both a second-person address
        # and an explicit directive verb to avoid false positives.
        second_person = bool(re.search(r"\b(you|your)\b", lower))
        directive = bool(
            re.search(
                r"\b(should|need\s+to|must|have\s+to|ought\s+to|aim\s+for|ensure|"
                r"i\s+recommend|i\s+suggest|it\s+is\s+(?:[a-z]+\s+)?recommended|"
                r"i(?:['\u2019]d)?\s+(?:strongly\s+|highly\s+)?advise)\b",
                lower,
            )
        )

        if not (second_person and directive):
            return flags  # return standalone signals collected above

        # ── Medical directive ──────────────────────────────────────────────
        # "You should take/start/stop this medication"
        # Safe referrals ("you should see a doctor") are NOT flagged —
        # they require treatment_object, not just treatment_action.
        #
        # Safety-referral exemption: advising to tell doctor about allergy, or
        # not take a drug due to allergy, is protective — not a treatment directive.
        # E.g. "make sure your doctor knows about your seafood allergy" before
        # taking Bactrim. Blocking that advice is harmful.
        _safety_referral = bool(
            re.search(
                r"\b(?:make\s+sure|tell|inform|let)\b.{0,40}"
                r"\b(?:doctor|physician|pharmacist)\b.{0,40}"
                r"\b(?:know|about|regarding)\b.{0,30}\b(?:allerg|allergic)\b",
                lower,
            )
            or re.search(
                r"\b(?:don['\u2019]?t|do\s+not)\s+(?:take|use)\b.{0,40}"
                r"\b(?:if|because|when)\b.{0,30}\b(?:allerg|allergic)\b",
                lower,
            )
            or re.search(
                r"\b(?:check|verify|confirm)\b.{0,30}"
                r"\b(?:with)\b.{0,30}\b(?:doctor|physician|pharmacist)\b"
                r".{0,30}\b(?:before)\b.{0,20}\b(?:taking|using)\b",
                lower,
            )
        )
        # Actionable dosing directive — catches specific instruction patterns
        # beyond the treatment_action+treatment_object pair. Used to prevent the
        # safety-referral exemption from suppressing a co-located dosing instruction.
        _dosing_directive = bool(
            re.search(r"\b\d+\s*(?:mg|mcg|ml|g)\b", lower)
            or re.search(r"\b(?:twice|three\s+times|once)\s+(?:a\s+)?(?:day|daily)\b", lower)
            or re.search(r"\bevery\s+\d+\s+hours?\b", lower)
            or re.search(r"\btake\s+(?:two|three|four|\d+)\s+(?:tablets?|pills?|capsules?)\b", lower)
            or re.search(r"\b(?:start\s+with|increase\s+to|titrate)\b", lower)
        )

        treatment_action = bool(
            re.search(
                r"\b(take|give|administer|start|stop|discontinue|use|apply|avoid|try|"
                r"reduce|limit|restrict|increase|decrease|aim|ensure)\b",
                lower,
            )
        )
        treatment_object = bool(
            re.search(
                r"\b(medication|drug|pill|tablet|capsule|antibiotic|medicine|"
                r"prescription|treatment|therapy|dose|steroid|insulin|cream|"
                r"ointment|injection|supplement|vaccine|"
                r"diet|dietary|protein|potassium|sodium|phosphorus|calcium|magnesium|fiber|fluid|intake|"
                r"carbohydrate|fat)\b",
                lower,
            )
        )
        # Exemption is local, not global: _safety_referral suppresses
        # PERSONALIZED_MEDICAL_ADVICE only when no actionable dosing instruction
        # appears in the same response. If both patterns co-occur (safety preamble
        # + dosing instruction), the treatment check wins — no compositional bypass.
        if treatment_action and treatment_object and not (_safety_referral and not _dosing_directive):
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_MEDICAL_ADVICE,
                entity_name="medical",
                claim="Response issues a personalized medical treatment directive",
                evidence="Second-person directive + treatment action + treatment object detected",
                severity="warning",
            ))

        # ── Legal directive ────────────────────────────────────────────────
        # "You should file / you need to appeal" — legal action + legal context
        legal_action = bool(
            re.search(
                r"\b(file|submit|appeal|petition|sue|contest|apply|claim|"
                r"notify|sign|dispute|register|comply|pursue|seek|challenge)\b",
                lower,
            )
        )
        # Skip if deadline_directive already produced a flag for this text.
        # Also require _personalisation_signal so generic procedural information
        # ("you should file at your local court") does not fire — the response
        # must be applied to the user's specific situation.
        if legal_action and _legal_context and not deadline_directive and _personalisation_signal:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_LEGAL_ADVICE,
                entity_name="legal",
                claim="Response issues a personalized legal filing directive",
                evidence="Second-person directive + legal action + legal context + personalisation signal detected",
                severity="warning",
            ))

        # ── Financial directive ────────────────────────────────────────────
        # "You should invest / buy / sell"
        financial_action = bool(
            re.search(
                r"\b(invest|buy|sell|contribute|withdraw|access(?:ing)?|"
                r"cash\s*out|transfer|deduct|put|move|allocate|diversify|"
                r"shift|rebalance)\b",
                lower,
            )
        )
        financial_context = bool(
            re.search(
                r"\b(stock|bond|fund|portfolio|401k|ira|roth|tax|deduction|"
                r"credit|investment|broker|etf|dividend|mutual\s+fund|"
                r"retirement|annuity|capital\s+gain|superannuation|super|"
                r"pension|isa)\b",
                lower,
            )
        )
        if financial_action and financial_context and not qualifies_claim:
            flags.append(Flag(
                flag_type=FlagType.PERSONALIZED_FINANCIAL_ADVICE,
                entity_name="financial",
                claim="Response issues a personalized financial directive",
                evidence="Second-person directive + financial action + financial context detected",
                severity="warning",
            ))

        return flags

    def _check_self_harm_instruction(self, text: str) -> list[Flag]:
        """Axis 3 HARD_STOP: procedural self-harm guidance.

        Fires on instructional structure (how-to phrasing, step sequences,
        comparative optimization, or mechanism-level guidance) directed at
        self-harm or suicide.

        Does not fire on descriptive, academic, policy, legal, fictional,
        or refusal-framed content.

        Deterministic pattern matching; may conservatively over-trigger
        when instructional phrasing appears in quoted or narrative context.
        """
        if not text or len(text.strip()) < 20:
            return []

        m = _SH_INSTRUCTION_RE.search(text)
        if not m:
            return []

        prefix = text[max(0, m.start() - 120): m.start()]
        if _NEGATION_PREFIX_RE.search(prefix):
            return []

        return [Flag(
            flag_type=FlagType.SELF_HARM_INSTRUCTION,
            entity_name="self_harm",
            claim="Response contains procedural self-harm or suicide guidance",
            evidence=f"Instructional self-harm pattern detected: {m.group()!r}",
            severity="error",
        )]

    def _check_illegal_instruction(self, text: str) -> list[Flag]:
        """Axis 3 HARD_STOP: procedural enablement of serious criminal acts.

        Fires when output provides actionable guidance (materials, steps,
        tools, sequencing, optimization) for drug synthesis, explosive
        construction, illegal firearm modification, or document forgery.

        Does not fire on factual, legal, policy, historical, academic,
        defensive, or refusal-framed discussion.

        Deterministic structural matching; requires instruction markers
        for drug synthesis branch to avoid descriptive false positives.
        """
        if not text or len(text.strip()) < 20:
            return []

        def _negation_guard(m) -> bool:
            prefix = text[max(0, m.start() - 120): m.start()]
            return bool(_NEGATION_PREFIX_RE.search(prefix))

        # 1. Controlled substance synthesis: verb + drug proximity + instruction marker
        vm = _ILLEGAL_SYNTH_VERB_RE.search(text)
        dm = _ILLEGAL_DRUG_RE.search(text)
        if vm and dm and abs(vm.start() - dm.start()) <= 80 and not _negation_guard(vm):
            win_s = max(0, min(vm.start(), dm.start()) - 80)
            win_e = min(len(text), max(vm.end(), dm.end()) + 80)
            if _ILLEGAL_INSTRUCTION_MARKER_RE.search(text[win_s:win_e]):
                return [Flag(
                    flag_type=FlagType.ILLEGAL_INSTRUCTION,
                    entity_name="illegal",
                    claim="Response contains controlled substance synthesis instructions",
                    evidence=f"Synthesis verb {vm.group()!r} + drug {dm.group()!r} detected",
                    severity="error",
                )]

        # 2. Explosive / weapon construction
        em = _ILLEGAL_EXPLOSIVE_RE.search(text)
        if em and not _negation_guard(em):
            return [Flag(
                flag_type=FlagType.ILLEGAL_INSTRUCTION,
                entity_name="illegal",
                claim="Response contains explosive or weapon construction instructions",
                evidence=f"Explosive construction pattern detected: {em.group()[:60]!r}",
                severity="error",
            )]

        # 3. Illegal firearm modification
        fm = _ILLEGAL_FIREARM_RE.search(text)
        if fm and not _negation_guard(fm):
            return [Flag(
                flag_type=FlagType.ILLEGAL_INSTRUCTION,
                entity_name="illegal",
                claim="Response contains illegal firearm modification instructions",
                evidence=f"Firearm modification pattern detected: {fm.group()[:60]!r}",
                severity="error",
            )]

        # 4. Document forgery
        fgm = _ILLEGAL_FORGERY_RE.search(text)
        if fgm and not _negation_guard(fgm):
            return [Flag(
                flag_type=FlagType.ILLEGAL_INSTRUCTION,
                entity_name="illegal",
                claim="Response contains document forgery instructions",
                evidence=f"Forgery pattern detected: {fgm.group()[:60]!r}",
                severity="error",
            )]

        return []

    def _check_violent_criminal_intent(self, text: str) -> list[Flag]:
        """Axis 3 HARD_STOP: interpersonal homicide framed as legal/permissibility query.

        Fires when a query frames killing, stabbing, shooting, or strangling another
        person as a matter of legal permission, outcome prediction, self-defence
        entitlement, or conviction likelihood.

        Operates on user_input: the query is non-admissible regardless of any
        model response.  Also runs on response_text to catch model-generated
        variants of the same framing.

        Five branches:
          A. Permissibility — "am I allowed to kill them"
          B. Consequence    — "what would happen if I stabbed him"
          C. Legal outcome  — "will I win a court case for killing them"
          D. Self-defence   — "can I claim self-defence if I shoot him"
          E. Conviction     — "how likely is conviction if I kill my attacker"

        Does not fire on:
          - self-directed harm (handled by SELF_HARM_INSTRUCTION)
          - negation-prefixed refusals ("I cannot kill them")
          - general homicide law discussion without first-person/target framing
        """
        if not text or len(text.strip()) < 10:
            return []

        m = _VIOLENT_CRIMINAL_INTENT_RE.search(text)
        if not m:
            return []

        prefix = text[max(0, m.start() - 120): m.start()]
        if _NEGATION_PREFIX_RE.search(prefix):
            return []

        return [Flag(
            flag_type=FlagType.VIOLENT_CRIMINAL_INTENT,
            entity_name="violent_criminal_intent",
            claim="Query frames interpersonal homicide as a legal or permissibility question",
            evidence=f"Violent criminal intent pattern detected: {m.group()[:80]!r}",
            severity="error",
        )]

    def _check_targeted_defamation(self, text: str) -> list[Flag]:
        """Axis 3 HARD_STOP: unhedged severe accusation against identifiable person.

        Fires when a severe criminal/moral label is directly attributed
        to a specific individual (name pattern + identity assertion),
        without local hedging language.

        Does not fire on reported allegations, convictions, policy debate,
        group generalizations, fiction, or criticism lacking criminal identity.

        Hedge exemption is local (same sentence or nearby window).
        Deterministic matching with sentence/window approximation.
        """
        if not text or len(text.strip()) < 20:
            return []

        lower = text.lower()

        label_m = _DEFAM_LABEL_RE.search(lower)
        if not label_m:
            return []

        # Local hedge check: same sentence as label only.
        # A hedge in a different sentence cannot sanitize an unhedged accusation.
        # (±80 char window removed — it allowed unrelated hedges in adjacent sentences
        # to suppress the flag when the hedge and label happened to be near each other.)
        sent_start = 0
        for i in range(label_m.start() - 1, -1, -1):
            if lower[i] in ".!?":
                sent_start = i + 1
                break
        sent_end = len(lower)
        for i in range(label_m.end(), len(lower)):
            if lower[i] in ".!?":
                sent_end = i + 1
                break
        if _DEFAM_HEDGE_RE.search(lower[sent_start:sent_end]):
            return []

        if not _DEFAM_ASSERTION_RE.search(lower):
            return []

        if not _DEFAM_NAME_RE.search(text):
            return []

        return [Flag(
            flag_type=FlagType.TARGETED_DEFAMATION,
            entity_name="defamation",
            claim=f"Response makes an unhedged defamatory accusation: {label_m.group()!r}",
            evidence="Named entity + assertion verb + unhedged severe accusation detected",
            severity="error",
        )]

    def _check_truthfulness_certainty(self, text: str) -> list[Flag]:
        """Axis 3 verify-or-refuse: unhedged assertion about a private individual's truthfulness.

        Fires when the model issues a definitive verdict on whether someone is
        lying, honest, deceptive, or telling the truth — without any admissible
        evidential basis.

        Two branches:
          A. subject + copula + (adverb) + (not) + truthfulness predicate
             e.g. "this is not a lie", "he is lying", "your friend is being honest"
          B. subject + copula + certainty adverb + true/false/accurate
             e.g. "that is definitely false" (adverb required to avoid benign "this is true")

        Guards (all checked in the sentence containing the match):
          1. Negation prefix — "I cannot tell whether this is a lie" → suppressed
          2. Hedge language — "this could be a lie" / "it's hard to say" → suppressed
          3. Definitional context — "A lie is an intentional falsehood" → suppressed
          4. Fiction/hypothetical — "in the novel, he is lying" → suppressed

        Policy: HARD_STOP (public) / FORCE_REVISE (enterprise).
        Severity: warning (verify-or-refuse, not hard-stop-always).
        """
        if not text or len(text.strip()) < 5:
            return []

        m = _TRUTH_ASSERT_RE.search(text)
        if not m:
            return []

        # Guard 1: negation prefix — check 120 chars before the match
        prefix = text[max(0, m.start() - 120): m.start()]
        if _NEGATION_PREFIX_RE.search(prefix):
            return []

        # Find sentence boundaries around the match for guards 2–4
        sent_start = 0
        for i in range(m.start() - 1, -1, -1):
            if text[i] in ".!?\n":
                sent_start = i + 1
                break
        sent_end = len(text)
        for i in range(m.end(), len(text)):
            if text[i] in ".!?\n":
                sent_end = i + 1
                break
        sentence = text[sent_start:sent_end]

        # Guard 2: hedge language in the same sentence
        if _TRUTH_HEDGE_RE.search(sentence):
            return []

        # Guard 3: definitional framing — "A lie is...", "Lies are..."
        if _TRUTH_DEFINITIONAL_RE.search(sentence):
            return []

        # Guard 4: fiction / hypothetical context
        if _TRUTH_FICTION_RE.search(sentence):
            return []

        return [Flag(
            flag_type=FlagType.UNVERIFIED_TRUTHFULNESS_CLAIM,
            entity_name="truthfulness",
            claim="Response makes an unhedged assertion about a private individual's truthfulness",
            evidence=f"Ungrounded truthfulness assertion: {m.group()[:80]!r}",
            severity="warning",
        )]

    def _check_bare_truthfulness_verdict(
        self, response_text: str, user_input: str
    ) -> list[Flag]:
        """Contextual: bare binary verdict inheriting a truthfulness frame from user_input.

        Problem: a model responding "No" to "Is my friend lying?" makes an
        inadmissible certainty claim even though the response_text contains no
        explicit assertion pattern. The semantic content is inherited from the
        question frame.

        Fires when:
          1. user_input establishes a truthfulness/deception query frame
             (_TRUTH_QUERY_RE matches)
          2. response_text is a bare binary ("Yes", "No", "Definitely no", etc.)
             and nothing else (_BARE_BINARY_RE matches full stripped text)

        No additional guards needed: a bare yes/no to a deception question cannot
        be hedged, definitional, or fictional by construction.
        """
        if not response_text or not user_input:
            return []
        if not _TRUTH_QUERY_RE.search(user_input):
            return []
        if not _BARE_BINARY_RE.match(response_text.strip()):
            return []
        return [Flag(
            flag_type=FlagType.UNVERIFIED_TRUTHFULNESS_CLAIM,
            entity_name="truthfulness",
            claim="Response gives a bare binary verdict inheriting a truthfulness frame from the user's question",
            evidence=(
                f"User asked a truthfulness/deception question; "
                f"model responded with bare binary: {response_text.strip()!r}"
            ),
            severity="warning",
        )]

    def _check_pii_exposure(self, text: str) -> list[Flag]:
        """Detect sensitive PII via regex (Phase 8). Deterministic, no LLM.

        Patterns: SSN (xxx-xx-xxxx), phone numbers, email addresses, card numbers.
        → SENSITIVE_PII_EXPOSURE. Policy: HARD_STOP (public) / FORCE_REVISE (enterprise).
        """
        if not text or len(text.strip()) < 10:
            return []

        matches: list[str] = []

        m = _PII_SSN_RE.search(text)
        if m:
            matches.append(f"SSN-like: {m.group()[:6]}***")

        m = _PII_PHONE_RE.search(text)
        if m:
            # Exempt crisis/safety hotline numbers cited in refusal context.
            ctx_start = max(0, m.start() - 100)
            ctx_end = min(len(text), m.end() + 100)
            if not _CRISIS_PHONE_CONTEXT_RE.search(text[ctx_start:ctx_end]):
                matches.append(f"phone: {m.group()[:10]}...")

        m = _PII_EMAIL_RE.search(text)
        if m:
            # Exempt role/service addresses (public contact points, not personal PII).
            if not _ROLE_EMAIL_RE.match(m.group()):
                matches.append(f"email: {m.group()[:15]}...")

        m = _PII_CARD_RE.search(text)
        if m:
            matches.append(f"card: ****{m.group()[-4:]}")

        if matches:
            return [Flag(
                flag_type=FlagType.SENSITIVE_PII_EXPOSURE,
                entity_name="pii",
                claim="Response contains sensitive PII (SSN, phone, email, or card number)",
                evidence="; ".join(matches),
                severity="warning",
            )]
        return []

    def _get_rel_object_text(self, rel: Relationship, pef: PEFState) -> str | None:
        """Get the textual representation of a relationship's object."""
        if rel.object_entity_id:
            obj_entity = pef.entities.get(rel.object_entity_id)
            return obj_entity.name if obj_entity else None
        if rel.object_literal is not None:
            return str(rel.object_literal)
        return None
