"""Post-LLM faithfulness check: did the answer ground its claims in context?

Three layered strategies, tried in order. The first one that finds evidence
classifies the answer. `verifier_method` on the result records which path
fired so the audit chain stays diagnostic.

    1. quote     model wrapped claims in double quotes per system prompt.
                 Strongest signal — explicit, verbatim, model-asserted.
    2. span      no quotes, but bullet/sentence-level lines from the answer
                 appear verbatim in context. Catches models that quote
                 inline without "..." marks.
    3. entity    no quotes and no span match, but multi-word proper-noun
                 phrases from the answer appear verbatim in context.
                 Catches the Wikipedia-infobox-to-prose case: the model
                 paraphrases structure so spans diverge, but every named
                 entity is intact and grounded.

Each strategy classifies into v9.8's audit-mode trichotomy (RAG-adapted
vocabulary; substrate calls UNGROUNDED "VISUAL"):

    STRICT      every evidence unit (>=1) verifies verbatim against context
    HYBRID      some verify, others do not  (mixed source / emergent)
    UNGROUNDED  no evidence, or none verify (purely emergent)

`unverified_quotes` (kept under that name for schema continuity) collects
spans the model produced that don't appear in any source — the
corpus-growth signal mined by `aborist emergent`.

Hard rule (CLAUDE.md "soft hash vs hard hash"): every check is a lexical
substring test under norm-v1 + lowercase canonicalization. No embeddings,
no semantic similarity, no fuzzy alignment. The contract is "this token
sequence either is or isn't in the context."

Wikitext context is run through ``aborist.wikitext.to_base`` before the
substring test. The corpus stores raw wikitext (so the link graph is
recoverable from any page), but the LLM produces clean prose. Without
the strip, every wikilink-carrying source paragraph compares as
"different surface form" and the verifier wrongly reports UNGROUNDED on
genuine source-grounded quotes. With the strip, paraphrases of *markup*
(``[[Cloud]]`` vs ``Cloud``) verify, while paraphrases of *prose* still
flag honestly. mwparserfromhell is an optional dep; if absent, the
strip is a no-op and verification falls back to today's behavior.
"""

from __future__ import annotations

import re
import unicodedata

from aborist.qa.warrant import warrant_check

# Deferred import: aborist.qa.inspect imports aborist.compress &
# aborist.store at module load. The verifier doesn't need either
# until deflection actually runs, so defer to call-site to keep
# import order clean if anything else imports verify.py.
try:
    from aborist.wikitext import to_base as _wikitext_to_base
except ImportError:  # pragma: no cover
    _wikitext_to_base = None


# Locate every double-quote character (ASCII or curly). Sequential
# pairing in extract_quotes() turns these into intentional (open, close)
# pairs: 1st & 2nd char, 3rd & 4th, etc. Pure-regex pairing fails on
# adjacent quote pairs like `"title" prose "quote"` — the regex captures
# `prose` as a "quoted span" because every `"` looks like both an opener
# and a closer to it.
_QUOTE_CHAR_RE = re.compile(r'["“”]')

# Bullet markers at line start: -, *, +, •, 1., 2), etc.
_BULLET_RE = re.compile(r'^\s*(?:[-*+•]|\d+[.)])\s+')

# Sentence boundary: punctuation + whitespace + capital letter.
_SENT_RE = re.compile(r'(?<=[.!?])\s+(?=[A-Z])')

# Multi-word capitalized phrase. Two or more whitespace-separated tokens,
# each starting with a capital letter (allowing initials like "A.", hyphens
# like "Carrie-Anne", and trailing lowercase like "Smith"). Matches "Keanu
# Reeves", "Thomas A. Anderson", "Carrie-Anne Moss", "Agent Smith". Skips
# single capitalized words to avoid sentence-starter false positives.
_PROPER_NOUN_RE = re.compile(
    # Token gap is non-newline whitespace so a phrase never crosses a
    # paragraph break. Caught a real case where "Joe Pantoliano\n\nThe
    # sources" matched as one phrase via \s+.
    r"\b[A-Z][A-Za-z'’\-]*(?:[ \t]+(?:[A-Z]\.|[A-Z][A-Za-z'’\-]+))+\b"
)

MIN_QUOTE_CHARS = 8
MIN_SPAN_CHARS = 12

# Trailing parenthetical the model often appends to verbatim source
# prose, defeating substring match even when the prose itself IS in
# the corpus. Examples we strip:
#
#     "...lightning-based attacks. (Source: https://...)"
#     "...invented in 1976. (citing Wikipedia)"
#     "...protagonist of the game. (see Pikachu_(character))"
#     "...released in 1997 (https://en.wikipedia.org/...)"
#
# Conservative regex: only strips a SINGLE trailing parenthetical at
# end-of-string after optional whitespace, and only when the contents
# either start with a recognized citation cue OR contain a URL. Refuses
# to strip parentheticals that look like genuine prose (e.g.
# "Pikachu (a Pokémon species)").
_TRAILING_CITATION_RE = re.compile(
    r"""
    \s*\(\s*
    (?:                                           # one of:
        (?:source|src|citing|see|ref|reference|from)  # citation cue word
        \s*:?\s*[^()]*                            #   optional content
      |                                           # OR
        https?://\S+\s*[^()]*                     # URL-led parenthetical
    )
    \s*\)\s*$
    """,
    re.IGNORECASE | re.VERBOSE,
)


def _strip_trailing_citation(text: str) -> str:
    """Drop a trailing `(Source: ...)`-like parenthetical, if any.

    Idempotent: applies once. The model rarely chains citations, so we
    don't loop. Returns the input unchanged if no match.
    """
    return _TRAILING_CITATION_RE.sub("", text).rstrip()


# Paraphrase strategy thresholds. The 4th verifier method runs only
# when quote/span/entity have all failed to classify; it accepts a
# span as "paraphrase-verified" when token coverage in the source
# context crosses ``DEFAULT_PARAPHRASE_COVERAGE`` and the span has at
# least ``DEFAULT_PARAPHRASE_MIN_TOKENS`` content tokens (>4 chars).
#
# Soft-signal note: token overlap is a heuristic, NOT byte-equivalence.
# The hard chain still records `verifier_method = 'paraphrase'` so an
# auditor can distinguish lexical-verbatim from paraphrase-overlap.
# A span verified via paraphrase contributes to `n_verified` but the
# audit_mode trichotomy stays unchanged: STRICT requires all units
# (quote/span/entity/paraphrase) to verify; HYBRID = some verify some
# don't; UNGROUNDED = none.
DEFAULT_PARAPHRASE_COVERAGE = 0.85
DEFAULT_PARAPHRASE_MIN_TOKENS = 4

# Entity-path policies. Entity-existence in source is circumstantial, not
# claim-level proof — the model could correctly name entities while making
# claims around them that came from training. These four policies span the
# trade-off: max-trust to no-trust.
#
#   strict     all entities verify → STRICT (legacy behavior; overclaims)
#   hybrid     any entity verifies → HYBRID (honest cap; safe default)
#   drop       skip entity path entirely → UNGROUNDED (most conservative)
#   proximity  STRICT only if N verified entities cluster within W chars
#              of each other in context (e.g. an infobox cast list).
#              Otherwise demotes to HYBRID/UNGROUNDED based on partial match.
ENTITY_POLICIES = ("strict", "hybrid", "drop", "proximity")
DEFAULT_ENTITY_POLICY = "proximity"

# Proximity tuning. N entities within W chars of each other.
DEFAULT_PROXIMITY_N = 3
DEFAULT_PROXIMITY_WINDOW = 300

# Framing phrases that aren't real claims even when they're in source. We
# strip these from the answer before span/entity extraction so they don't
# become noise in unverified_quotes.
_FRAMING_PREFIXES = (
    "based on the provided sources",
    "based on the provided source",
    "based on the source",
    "based on the document",
    "the sources do not",
    "the source does not",
    "the document does not",
    "according to the sources",
    "according to the source",
)


def _normalize(s: str) -> str:
    """norm-v1 + lowercase. Same canonicalization as chunk leaf hashing,
    plus case-folding so the verifier doesn't fail on capitalization drift
    between source prose and the model's quoted span."""
    s = unicodedata.normalize("NFC", s)
    s = " ".join(s.split())
    return s.lower()


def _is_framing(span: str) -> bool:
    n = _normalize(span)
    return any(n.startswith(p) for p in _FRAMING_PREFIXES)


def extract_quotes(answer_text: str) -> list[str]:
    """Pull double-quoted spans of length >= MIN_QUOTE_CHARS from `answer_text`.

    Sequential pairing: locate every double-quote character, then pair
    them as (1st, 2nd), (3rd, 4th), .... Each pair brackets one quoted
    span; text between consecutive pairs is the model's own framing
    prose (not captured). This is the correct model for adjacent quote
    pairs like `"title" prose "quote"` — naive regex matching paired
    the close of "title" with the open of "quote" and captured `prose`
    as a phantom quote, dragging classifications down to HYBRID
    incorrectly.
    """
    positions = [m.start() for m in _QUOTE_CHAR_RE.finditer(answer_text)]
    quotes: list[str] = []
    for i in range(0, len(positions) - 1, 2):
        span = answer_text[positions[i] + 1:positions[i + 1]]
        # Strip model-appended citations BEFORE the length gate so a
        # `verbatim... (Source: https://...)` span that's >MIN long with
        # the citation but short without it doesn't bypass the test.
        span = _strip_trailing_citation(span)
        if len(span) >= MIN_QUOTE_CHARS:
            quotes.append(span)
    return quotes


def extract_claim_spans(answer_text: str) -> list[str]:
    """Strip bullet markers, split into sentences, drop framing prefixes.

    Returns each non-empty span of length >= MIN_SPAN_CHARS. These are the
    "claim units" the model wrote — each one we'll substring-test against
    context.
    """
    spans: list[str] = []
    for line in answer_text.splitlines():
        stripped = _BULLET_RE.sub("", line).strip()
        if not stripped:
            continue
        for sent in _SENT_RE.split(stripped):
            sent = sent.strip().rstrip(".:,;")
            # Drop model-appended `(Source: ...)` parentheticals so a
            # verbatim sentence doesn't fail substring match because of
            # an inserted citation.
            sent = _strip_trailing_citation(sent)
            if len(sent) < MIN_SPAN_CHARS:
                continue
            if _is_framing(sent):
                continue
            spans.append(sent)
    return spans


def extract_proper_nouns(answer_text: str) -> list[str]:
    """Pull multi-word capitalized phrases. Deduplicated, order preserved.

    Multi-word only — single capitalized words at sentence start are too
    noisy ("Based", "Now", "However"). Multi-word phrases like
    "Keanu Reeves" or "Thomas A. Anderson" are reliable proper-noun
    candidates and substring-test cleanly against source prose or
    structured wikitext.
    """
    seen: set[str] = set()
    out: list[str] = []
    for m in _PROPER_NOUN_RE.findall(answer_text):
        if m in seen:
            continue
        seen.add(m)
        out.append(m)
    return out


def _classify(verified: list[str], unverified: list[str]) -> str:
    if verified and not unverified:
        return "STRICT"
    if verified:
        return "HYBRID"
    return "UNGROUNDED"


def _has_entity_cluster(
    verified_entities: list[str],
    norm_ctx: str,
    n: int,
    window: int,
) -> bool:
    """Do at least `n` distinct verified entities appear within `window`
    chars of each other in `norm_ctx`? Crude proxy for "the source has a
    section about these entities" (cast list, infobox, roster) vs
    "the source incidentally mentions them in scattered prose."
    """
    if len(verified_entities) < n:
        return False
    positions: list[int] = []
    for e in verified_entities:
        idx = norm_ctx.find(_normalize(e))
        if idx >= 0:
            positions.append(idx)
    if len(positions) < n:
        return False
    positions.sort()
    for i in range(len(positions) - n + 1):
        if positions[i + n - 1] - positions[i] <= window:
            return True
    return False


def _check_each(items: list[str], norm_ctx: str) -> tuple[list[str], list[str]]:
    verified: list[str] = []
    unverified: list[str] = []
    for it in items:
        if _normalize(it) in norm_ctx:
            verified.append(it)
        else:
            unverified.append(it)
    return verified, unverified


def _token_coverage(
    span: str,
    norm_ctx: str,
    *,
    min_token_len: int = 4,
) -> tuple[float, int]:
    """Fraction of meaningful tokens from `span` that appear anywhere
    in `norm_ctx`. Returns ``(coverage_fraction, content_token_count)``.

    Two filters narrow the token set to topical content:

    1. ``len(t) >= min_token_len`` (default 4). Excludes ``the / a / an
       / of / to / is / in / on / at`` etc. — short function words.
    2. Stopword exclusion. Common English filler 4+ chars long
       (``from``, ``with``, ``have``, ``this``, ``that``, ``which``,
       ``where``, ``their``, ``would``, ``could``, etc.) match almost
       any English text & inflate coverage scores for cases where the
       *topical* content is missing. Filtering them tightens the
       signal: a span that paraphrases stylistic choice (replaces
       ``with`` → ``from``) still scores 1.0 if topical tokens match;
       a span where topical tokens are missing scores lower because
       the denominator dropped.

    The 0.85 paraphrase-coverage threshold is calibrated for THIS
    cleaner signal — lowering the threshold would promote fabrications
    (Q1 Batman case: "wealthy/businessman/resides" missing, model-
    invented). Tightening the denominator instead keeps the threshold
    stable and the discrimination crisp.

    Strip from numerator AND denominator so a span composed entirely
    of stopwords (e.g. ``"have been there"``) returns coverage 0.0
    rather than dividing by zero.
    """
    nspan = _normalize(span)
    # Strip per-token leading/trailing punctuation BEFORE the length
    # gate and the stopword filter so `batman,` and `wayne.` line up
    # with bare `batman`/`wayne` in context. We don't apply this in
    # _normalize() because the substring path needs punctuation-
    # preserving canonicalization (a span ending in `."` is a
    # different surface from one ending without).
    tokens: list[str] = []
    for raw in nspan.split():
        t = raw.strip(_TOKEN_PUNCT_STRIP)
        if len(t) < min_token_len:
            continue
        if t in _ENGLISH_STOPWORDS:
            continue
        tokens.append(t)
    if not tokens:
        return (0.0, 0)
    present = sum(1 for t in tokens if t in norm_ctx)
    return (present / len(tokens), len(tokens))


# Per-token punctuation stripped before the coverage check. Tokens like
# `wayne,` and `bruce!` line up with bare `wayne` / `bruce` in context.
# We don't strip apostrophes (``'``) so possessives stay distinct:
# ``batman's`` is a different content token from ``batman``.
_TOKEN_PUNCT_STRIP = ".,;:!?\"()[]{}"


# Common English stopwords of length >= 4 chars. Hand-curated rather
# than imported from NLTK to keep aborist dependency-light and the
# behavior pinned to a known set. Tokens are normalized form
# (lowercase, NFC). Includes auxiliaries, prepositions, pronouns,
# wh-words, conjunctions, and high-frequency adverbs/quantifiers that
# carry little topical signal.
_ENGLISH_STOPWORDS = frozenset({
    # auxiliaries / be-forms (>= 4)
    "have", "been", "being", "were", "will", "would", "could", "should",
    "might", "must", "shall",
    # prepositions / particles (>= 4)
    "from", "with", "into", "onto", "upon", "over", "under", "after",
    "before", "between", "through", "across", "above", "below", "behind",
    "beside", "beyond", "during", "without", "within", "until", "since",
    "about", "around", "along", "among",
    # demonstratives / pronouns (>= 4)
    "this", "that", "these", "those", "their", "them", "they", "there",
    "here", "your", "yours", "ours", "mine",
    # wh-words (>= 4)
    "what", "when", "where", "which", "while", "whom", "whose",
    "whoever", "whatever", "wherever", "whenever",
    # conjunctions (>= 4)
    "because", "although", "however", "therefore", "though", "unless",
    # adverbs / quantifiers (>= 4)
    "also", "very", "much", "many", "more", "most", "less", "some",
    "such", "even", "ever", "just", "only", "than", "then", "still",
    "again", "always", "never", "often", "rather", "really", "quite",
    "very", "well", "back", "next",
    # auxiliaries / discourse (>= 4)
    "also", "both", "each", "every", "into", "like",
})


def _is_prose_span(span: str, *, min_lowercase_content: int = 2) -> bool:
    """Heuristic: does this span look like prose (eligible for paraphrase
    matching) vs. a list of proper nouns (better matched by entity)?

    Counts tokens in the ORIGINAL (pre-normalize) span whose first
    letter is lowercase and whose length is >= 4 chars. A pure list
    like ``"Keanu Reeves, Laurence Fishburne"`` has zero such tokens —
    those go through the entity path. A real sentence like ``"Pikachu
    is a species of Pokémon creatures..."`` has multiple ("species",
    "creatures", "from", etc.) and qualifies for paraphrase.

    Threshold ``min_lowercase_content=2`` lets through "the cast..."
    (1 lowercase content token: "cast") only if more lowercase prose
    is present. Lists with a leading "The" don't sneak through.
    """
    n_lower = 0
    for tok in span.split():
        # Strip leading/trailing punctuation for the case check (so
        # "cast:" still counts as a lowercase token).
        core = tok.strip(".,;:!?\"'()[]{}")
        if len(core) < 4:
            continue
        if core[0].islower():
            n_lower += 1
            if n_lower >= min_lowercase_content:
                return True
    return False


def _build_claim_statuses(
    *,
    verified: list[str] | None = None,
    paraphrase: list[str] | None = None,
    unverified: list[str] | None = None,
    method: str,
) -> list[dict]:
    """Per-evidence-unit status list. Three labels drawn from the toy-
    Hermes taxonomy:

        VERIFIED_QUOTE        unit substring-matched in normalized context
                              (any of quote/span/entity strategies)
        SUPPORTED_PARAPHRASE  unit cleared the paraphrase token-coverage
                              threshold (>=85% topical tokens present)
        UNSUPPORTED           unit didn't match anything

    Soft-signal labels (QUOTE_INTEGRITY_FAILED, SOURCE_MISMATCH,
    FALSIFIED) live in the sidecar / falsification machinery, not on
    the binary verifier output — see the
    ``feedback_verifier_no_diagnostics`` discipline. Order preserved so
    callers can map back to the model's original answer ordering.
    """
    out: list[dict] = []
    for t in verified or []:
        out.append({"text": t, "status": "VERIFIED_QUOTE", "method": method})
    for t in paraphrase or []:
        out.append(
            {
                "text": t,
                "status": "SUPPORTED_PARAPHRASE",
                "method": "paraphrase",
            }
        )
    for t in unverified or []:
        out.append({"text": t, "status": "UNSUPPORTED", "method": method})
    return out


def _check_each_with_paraphrase(
    items: list[str],
    norm_ctx: str,
    *,
    paraphrase_coverage: float = DEFAULT_PARAPHRASE_COVERAGE,
    paraphrase_min_tokens: int = DEFAULT_PARAPHRASE_MIN_TOKENS,
) -> tuple[list[str], list[str], list[str]]:
    """Three-bucket variant of ``_check_each``.

    For each item:
        - if its normalized form is a substring of ``norm_ctx``: STRICT
          (verbatim verified)
        - else if its meaningful-token coverage in ``norm_ctx`` is
          ``>= paraphrase_coverage`` and it has at least
          ``paraphrase_min_tokens`` content tokens: PARAPHRASE
          (token-overlap verified)
        - else: UNVERIFIED

    Returns ``(strict_verified, paraphrase_verified, unverified)``.
    Order within each bucket preserves input order.

    Soft-signal note: paraphrase verification is heuristic. Callers
    that need byte-equivalence (proof export to other peers, audit
    chain claims) must use ``_check_each`` directly.
    """
    strict: list[str] = []
    paraphrase: list[str] = []
    unverified: list[str] = []
    for it in items:
        if _normalize(it) in norm_ctx:
            strict.append(it)
            continue
        # Paraphrase only fires for prose-shaped spans. Lists of proper
        # nouns (e.g. "Keanu Reeves, Laurence Fishburne") fall through
        # to the entity strategy where proximity policy can disambiguate
        # tight clusters from scattered mentions.
        if not _is_prose_span(it):
            unverified.append(it)
            continue
        cov, n_tok = _token_coverage(it, norm_ctx)
        if n_tok >= paraphrase_min_tokens and cov >= paraphrase_coverage:
            paraphrase.append(it)
        else:
            unverified.append(it)
    return strict, paraphrase, unverified


def verify_quotes(
    answer_text: str,
    context: str,
    *,
    entity_policy: str = DEFAULT_ENTITY_POLICY,
    proximity_n: int = DEFAULT_PROXIMITY_N,
    proximity_window: int = DEFAULT_PROXIMITY_WINDOW,
) -> dict:
    """Classify an answer's grounding against its retrieved context.

    Tries quote → span → entity verification in sequence. The first
    strategy that finds evidence classifies the answer; later strategies
    don't run.

    `entity_policy` controls how the entity path classifies — see
    ENTITY_POLICIES. The quote and span paths are unaffected; they are
    explicit-claim evidence and always classify per the trichotomy.

    Returns:
        {
          "n_quotes":          int,    # evidence units extracted (any path)
          "n_verified":        int,    # of those, how many appear verbatim
          "audit_mode":        str,    # STRICT | HYBRID | UNGROUNDED
          "unverified_quotes": [str],  # spans we couldn't ground in context
          "verifier_method":   str,    # 'quote' | 'span' | 'entity' | 'none'
        }
    """
    if entity_policy not in ENTITY_POLICIES:
        raise ValueError(
            f"entity_policy must be one of {ENTITY_POLICIES}, got {entity_policy!r}"
        )

    # Wikitext markup → plain prose. Identity if mwparserfromhell isn't
    # installed (extras: pip install 'aborist[wikitext]').
    if _wikitext_to_base is not None:
        context = _wikitext_to_base(context)

    norm_ctx = _normalize(context)

    # Strategy 1: explicit double-quoted spans.
    quotes = extract_quotes(answer_text)
    if quotes:
        verified, unverified = _check_each(quotes, norm_ctx)
        return {
            "n_quotes": len(quotes),
            "n_verified": len(verified),
            "audit_mode": _classify(verified, unverified),
            "unverified_quotes": unverified,
            "verifier_method": "quote",
            "claim_statuses": _build_claim_statuses(
                verified=verified, unverified=unverified, method="quote"
            ),
        }

    # Strategy 2: bullet/sentence spans. Try verbatim substring first;
    # then fall back to paraphrase (token-coverage) for items that didn't
    # substring-match. Spans verified via paraphrase contribute to
    # n_verified — the verifier_method label flips to "paraphrase" when
    # any soft-verified items are present so an auditor can tell.
    spans = extract_claim_spans(answer_text)
    if spans:
        strict, paraphrase, unverified = _check_each_with_paraphrase(
            spans, norm_ctx
        )
        verified = strict + paraphrase
        if verified:
            method = "paraphrase" if paraphrase else "span"
            return {
                "n_quotes": len(spans),
                "n_verified": len(verified),
                "audit_mode": _classify(verified, unverified),
                "unverified_quotes": unverified,
                "verifier_method": method,
                "claim_statuses": _build_claim_statuses(
                    verified=strict,
                    paraphrase=paraphrase,
                    unverified=unverified,
                    method="span",
                ),
            }

    # Strategy 3: multi-word proper nouns (entity grounding) — gated by
    # `entity_policy`. Entity-existence is weaker proof than quote or span;
    # the operator chooses how much weight to give it.
    if entity_policy == "drop":
        # Skip entity path entirely. Falls through to UNGROUNDED/none.
        pass
    else:
        entities = extract_proper_nouns(answer_text)
        if entities:
            verified, unverified = _check_each(entities, norm_ctx)
            if verified:
                if entity_policy == "strict":
                    # Legacy behavior: all match → STRICT. Overclaims.
                    mode = _classify(verified, unverified)
                elif entity_policy == "hybrid":
                    # Cap at HYBRID. Honest middle: evidence exists, but
                    # entity-existence ≠ claim-existence.
                    mode = "HYBRID"
                else:  # proximity
                    cluster = _has_entity_cluster(
                        verified, norm_ctx, proximity_n, proximity_window
                    )
                    if cluster and not unverified:
                        mode = "STRICT"
                    elif cluster:
                        mode = "HYBRID"
                    elif verified:
                        mode = "HYBRID"
                    else:
                        mode = "UNGROUNDED"
                return {
                    "n_quotes": len(entities),
                    "n_verified": len(verified),
                    "audit_mode": mode,
                    "unverified_quotes": unverified,
                    "verifier_method": "entity",
                    "claim_statuses": _build_claim_statuses(
                        verified=verified,
                        unverified=unverified,
                        method="entity",
                    ),
                }

    # Nothing extracted, or nothing verified. Truly emergent.
    return {
        "n_quotes": 0,
        "n_verified": 0,
        "audit_mode": "UNGROUNDED",
        "unverified_quotes": [],
        "verifier_method": "none",
        "claim_statuses": [],
    }


# ---------------------------------------------------------------------------
# Claim-lattice-pointer verifier — G0 / CTI Clause Lattice Intelligence.
#
# Companion to ``verify_quotes`` for ``policy["answer_mode"] ==
# "claim_lattice_pointer"``. The model emits weak natural-language
# pointer clauses (``Claim text. [E12]``); the runtime parses them
# into structured claim nodes and runs deterministic checks against
# the runtime-built evidence map.
#
# Why pointer-line, not JSON: small instruction-tuned models follow
# citation-style prose far more reliably than free-form JSON. JSON
# discipline failures generate spurious one-shot SCHEMA_INVALID
# verdicts even when the model knew the right answer; pointer-line
# stays inside the model's prose-generation distribution.
#
# Why decimal pointer ids (E1, E2, …) not hex (E1f8e4c2a):
# - One BPE token per id in standard tokenizers (8-hex tokenizes to
#   4–6 tokens of out-of-distribution noise that nudges the model
#   toward DSL/code mode).
# - Citation style (footnotes, references) is heavily represented in
#   training; random hex is not.
# - The runtime maps pointer ids back to content-addressed evidence
#   ids (``evidence_map_by_pointer_id``) for cache, run-DAG, audit.
#
# Hard-soft boundary: only deterministic checks. No entailment, no
# completeness, no predicate compatibility. Those stay sidecar.
# ---------------------------------------------------------------------------

ANSWER_MODES = ("quote", "claim_lattice_pointer", "claim_lattice")
DEFAULT_ANSWER_MODE = "quote"

# JSON-mode pre-parser. 8B and small-context models drift on JSON
# discipline (markdown fences, prose preamble, smart quotes, trailing
# commas). Larger reasoning models (Qwen 3.6 reasoner, Claude, GPT-4)
# emit valid JSON natively; the pre-parser is the defensive belt that
# keeps the JSON path survivable across the inference-quality spectrum.
# Lenient on syntax, strict on semantics: parsed JSON still has to
# pass the schema check & the same hard verifier rules as pointer mode.
_JSON_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*\n(.*?)\n\s*```\s*$", re.DOTALL)
_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")


def _repair_truncated_json(text: str) -> tuple[str, list[str]]:
    """Best-effort completion of truncated JSON.

    Walks ``text`` once tracking string state and bracket/brace stack.
    At end-of-input, if the parse is unbalanced (stuck mid-string,
    open ``[`` / ``{`` without matching close) or has a dangling
    structural artifact (trailing comma, partial key), repair so the
    result is parseable. Returns ``(repaired, fixups)``.

    Targets the truncation pattern observed on Hermes-3-8B JSON-mode
    output for broad-descriptive questions: the model writes one
    long claim text and runs out of ``max_tokens`` mid-sentence,
    yielding e.g.::

        {"claims":[{"text":"The Apollo program was the United
        States spaceflight effort which landed... <CUT>

    No closing ``"``, no closing ``}``, no closing ``]``, no closing
    outer ``}``. The lenient parse path can't recover any structure.
    Self-healing closes the open string, balances the stack, and
    drops trailing commas / partial keys so the partial content is
    preserved as a single claim with whatever fields survived.

    Fixups recorded:
      - ``close_string`` — appended ``"`` to close an open string
      - ``drop_partial_key`` — dropped a key without value
        (``,"key":`` or ``,"key`` or ``"key":``)
      - ``strip_trailing_comma`` — removed a comma immediately
        before stack close (separate from the regex pass which
        only handles structurally-correct trailing commas)
      - ``close_brace`` / ``close_bracket`` — appended ``}`` / ``]``
        per open frame on the stack

    Conservative: never inserts content (no key names, no values,
    no commas), only closes / drops. Worst case the repair is a
    no-op or makes parsing fail in a different way; never silently
    fabricates data.
    """
    if not text or not text.strip():
        return text, []

    fixups: list[str] = []
    stack: list[str] = []
    in_string = False
    escaped = False

    for ch in text:
        if escaped:
            escaped = False
            continue
        if in_string:
            if ch == "\\":
                escaped = True
            elif ch == '"':
                in_string = False
        else:
            if ch == '"':
                in_string = True
            elif ch == "{" or ch == "[":
                stack.append(ch)
            elif ch == "}" or ch == "]":
                if stack:
                    stack.pop()

    if not stack and not in_string:
        return text, fixups

    repaired = text

    if in_string:
        repaired += '"'
        fixups.append("close_string")

    # Drop dangling structural fragments after the last legitimate
    # value, walking back from end-of-string. Order matters: handle
    # partial-key (`,"foo":` or `,"foo"` or `"foo":`) before
    # trailing-comma so we don't strip the wrong comma.
    while True:
        rstripped = repaired.rstrip()
        if not rstripped:
            break
        # Partial key: `..., "key": ` (colon at end after trim).
        m = _PARTIAL_KEY_COLON_RE.search(rstripped)
        if m and m.end() == len(rstripped):
            repaired = rstripped[: m.start()]
            if "drop_partial_key" not in fixups:
                fixups.append("drop_partial_key")
            continue
        # Partial key: `..., "key"` (key without colon).
        m = _PARTIAL_KEY_NO_COLON_RE.search(rstripped)
        if m and m.end() == len(rstripped):
            repaired = rstripped[: m.start()]
            if "drop_partial_key" not in fixups:
                fixups.append("drop_partial_key")
            continue
        # Trailing comma — strip when it would otherwise stick before close.
        if rstripped.endswith(","):
            repaired = rstripped[:-1]
            if "strip_trailing_comma" not in fixups:
                fixups.append("strip_trailing_comma")
            continue
        break

    # Close stack in reverse, mapping `{` → `}`, `[` → `]`.
    while stack:
        opener = stack.pop()
        if opener == "{":
            repaired += "}"
            fixups.append("close_brace")
        else:
            repaired += "]"
            fixups.append("close_bracket")

    return repaired, fixups


# Partial-key patterns for _repair_truncated_json. ``,"foo":`` or
# ``"foo":`` at end-of-string after rstrip = a key with no value.
_PARTIAL_KEY_COLON_RE = re.compile(r',?\s*"[^"]*"\s*:\s*$')
# ``,"foo"`` or ``"foo"`` with no following colon — also dangling.
_PARTIAL_KEY_NO_COLON_RE = re.compile(r',\s*"[^"]*"\s*$')


def _lenient_json_parse(raw: str) -> tuple[object, list[str]]:
    """Parse ``raw`` as JSON, defensively peeling common model drift.

    Returns ``(parsed_obj, fixups_applied)`` — fixups list is empty
    when strict parse succeeded, otherwise names what we had to peel
    (``"fence"``, ``"prose_trim"``, ``"curly_quotes"``,
    ``"trailing_comma"``) or repair (``"close_string"``,
    ``"close_brace"``, ``"close_bracket"``, ``"drop_partial_key"``,
    ``"strip_trailing_comma"``).
    Raises ``json.JSONDecodeError`` if the lenient pass also fails.

    The fixups list lands in the verify payload so an agent can
    observe model drift across runs & decide whether the inference
    path is JSON-clean enough to keep using.
    """
    import json as _json
    fixups: list[str] = []
    try:
        return _json.loads(raw), fixups
    except _json.JSONDecodeError:
        pass

    text = raw

    # 1. Strip markdown fence wrappers (```json\n...\n``` or ```\n...\n```).
    m = _JSON_FENCE_RE.match(text)
    if m:
        text = m.group(1)
        fixups.append("fence")

    # 2. Trim leading prose to first `{` or `[`; trailing prose past last
    # matching `}`/`]`. Preserves the JSON object even when the model
    # writes "Here is the JSON: {...}\n\nLet me know if you need more."
    first_brace = min(
        (text.find(c) for c in "{[" if text.find(c) >= 0),
        default=-1,
    )
    last_brace = max(text.rfind("}"), text.rfind("]"))
    if first_brace > 0 or (last_brace >= 0 and last_brace < len(text) - 1):
        if first_brace >= 0 and last_brace >= first_brace:
            text = text[first_brace : last_brace + 1]
            fixups.append("prose_trim")

    # 3. Normalize curly quotes — model-emitted “…” / ‘…’ become "…" / '…'.
    if any(c in text for c in "“”‘’"):
        text = (
            text.replace("“", '"').replace("”", '"')
                .replace("‘", "'").replace("’", "'")
        )
        fixups.append("curly_quotes")

    # 4. Fix trailing commas before `}` or `]`. Conservative: only
    # comma immediately followed by whitespace + close bracket.
    if "," in text:
        new_text = _TRAILING_COMMA_RE.sub(r"\1", text)
        if new_text != text:
            text = new_text
            fixups.append("trailing_comma")

    # 5. Try strict parse before invoking the truncation repair —
    # peeling alone may have made it valid.
    try:
        return _json.loads(text), fixups
    except _json.JSONDecodeError:
        pass

    # 6. Self-heal truncated JSON: close open strings, drop dangling
    # partial keys / trailing commas, balance the bracket stack. The
    # model ran out of max_tokens mid-output; close what we can and
    # parse the partial structure rather than failing the whole run.
    repaired, repair_fixups = _repair_truncated_json(text)
    if repair_fixups:
        text = repaired
        fixups.extend(repair_fixups)

    return _json.loads(text), fixups

# Default allowed source roles for claim_lattice_pointer mode. Roles
# outside this set get classified as SOURCE_ROLE_BLOCKED. Mirrors the
# role classifications in aborist/qa/query.py:_classify_source_role;
# "noisy_background_source" and "sequel_background_source" are
# deliberately excluded by default.
DEFAULT_ALLOWED_SOURCE_ROLES = (
    "primary_answer_source",
    "secondary_context_source",
    "background_source",
    "unclassified",
)


def _has_manual_quote(text: str) -> bool:
    """Strict no-quote rule: ANY double-quote character in claim text
    is a violation.

    The premise of claim-lattice-pointer mode is that models do not type
    quote text — period. Even a 3-char quoted span (``"hi"``) is a
    model-asserted verbatim citation that the runtime didn't authorize.
    Catching every quote keeps the discipline honest: the model is
    forbidden, not just length-discouraged.

    Covers ASCII (``"``) and curly quotes (``“`` / ``”``) — same set
    ``_QUOTE_CHAR_RE`` recognizes for the legacy quote verifier.
    """
    return any(ch in text for ch in ('"', '“', '”'))


DEFAULT_MIN_CITATION_COVERAGE = 0.30


def _claim_textually_overlaps_evidence(
    claim_text: str,
    evidence_span: str,
    *,
    min_coverage: float = DEFAULT_MIN_CITATION_COVERAGE,
) -> bool:
    """Return True if claim's content-token coverage in ``evidence_span``
    meets ``min_coverage`` (case-insensitive substring match).

    Hard 6th check on a (claim, pointer) pair. Catches the lazy-anchor
    failure where the model cites an evidence pointer whose text has
    insufficient overlap with the claim's actual subject — e.g. claim
    "Yale University in New Haven and the University of Connecticut..."
    cited to a highway-data span containing only the token
    ``connecticut`` (1/10 = 10% coverage; below the 30% default
    threshold → CITATION_MISMATCH).

    Pre-2026-04-30 this function required only ≥1 shared content token,
    which let through lazy-anchored claims whose only overlap was a
    common topical word. Coverage-based threshold scales with claim
    length: short claims (1-3 content tokens) need 1 match (same as
    the old behavior), longer claims need a proportional share.

    Lexical only, no NER, no embeddings; stays inside the soft/hard
    boundary.

    A pure-stopword claim (no content tokens after the spotlight token
    extractor's filter) returns True vacuously — there's nothing
    topical to check, and the verifier's other hard checks already
    own that case (claim_text_non_empty, no_manual_quotes, etc.).
    """
    from aborist.qa.evidence import _content_tokens

    tokens = _content_tokens(claim_text)
    if not tokens:
        return True
    span_lower = evidence_span.lower()
    matched = sum(1 for t in tokens if t in span_lower)
    coverage = matched / len(tokens)
    # Floor: a single shared content token always counts when the claim
    # is itself short (≤3 content tokens) so single-fact narrow claims
    # like "Steve Jobs co-founded Apple" don't fail on a coverage
    # technicality. The threshold bites on prose-shaped multi-token
    # claims where 1/10 token overlap is the lazy-anchor signature.
    if matched >= 1 and len(tokens) <= 3:
        return True
    return coverage >= min_coverage


DEFAULT_MAX_POINTERS_PER_CLAIM = 2
DEFAULT_MIN_CLAIM_CONTENT_TOKENS = 2
DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD = 0.5
DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS = 3
# Claim-count ceiling. Bench evidence (2026-04-30 york-england run):
# pre-atomic-claim-rule, JSON mode emitted 26-59 claim-pointer pairs
# of which only 2-4 verified — the model treats "tell me all there
# is to know about X" as a license to spam encyclopedic claims from
# training. Atomic-claim prompt rule (commit b5925c8) reduced this
# to ~10 well-formed claims, but defence-in-depth: any answer with
# more than this many claims is structurally suspect regardless of
# how each claim verifies. Default 12 chosen to comfortably admit
# entity-list questions ("dinosaurs in jurassic park" → 5; "simpsons
# family + pets" → 5-7) while catching the runaway shape.
DEFAULT_MAX_CLAIMS_PER_ANSWER = 12


# Title-relevance check (Rule 8). Cited evidence's source title must
# share at least one stemmed content token with the claim text.
# Catches the retrieval-driven hallucination class fox surfaced
# 2026-05-02 on "explain spin glass modeling & tensors?": claim
# tokens {spin, glass, modeling, tensor, ...} cited to a chunk from
# the *Quantum chromodynamics* article whose title tokens are
# {quantum, chromodynamics} — zero overlap. Token-coverage check
# inside the chunk passed accidentally on shared physics vocabulary;
# the SOURCE was never about the claim's subject.
def _claim_title_overlap(claim_text: str, source_title: str | None) -> bool:
    """Return True iff the source title shares ≥1 stemmed content
    token with the claim text. Vacuous-pass when either side has no
    extractable tokens (defensive — prevents the rule from firing
    on degenerate inputs)."""
    if not source_title or not claim_text:
        return True
    from aborist.qa.evidence import _content_tokens as _ct

    claim_tokens = _ct(claim_text)
    title_tokens = _ct((source_title or "").replace("_", " "))
    if not claim_tokens or not title_tokens:
        return True

    # Reuse the retrieval-side stem helper so possessive / plural
    # collapse the same way ('movies' vs 'movie', 'simpsons' vs
    # 'simpson'). Defined in qa/query.py to avoid an import cycle:
    # inline a minimal copy here instead.
    def _stem(t: str) -> str:
        if len(t) > 4 and t.endswith("s") and not t.endswith("ss"):
            return t[:-1]
        return t

    claim_stems = {_stem(t) for t in claim_tokens}
    title_stems = {_stem(t) for t in title_tokens}
    return bool(claim_stems & title_stems)


def verify_claim_lattice(
    answer_text: str,
    evidence_map,
    *,
    allowed_source_roles: tuple[str, ...] = DEFAULT_ALLOWED_SOURCE_ROLES,
    max_pointers_per_claim: int = DEFAULT_MAX_POINTERS_PER_CLAIM,
    min_citation_coverage: float = DEFAULT_MIN_CITATION_COVERAGE,
    min_claim_content_tokens: int = DEFAULT_MIN_CLAIM_CONTENT_TOKENS,
    lazy_anchor_demote_threshold: float = DEFAULT_LAZY_ANCHOR_DEMOTE_THRESHOLD,
    lazy_anchor_demote_min_pairs: int = DEFAULT_LAZY_ANCHOR_DEMOTE_MIN_PAIRS,
    max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER,
    question: str | None = None,
    warrant_check_enabled: bool = True,
    deflection_check_enabled: bool = True,
) -> dict:
    """Deterministic verifier for ``answer_mode="claim_lattice_pointer"``.

    The model wrote pointer-line prose (``Claim text. [E12]``); the
    parser pulled (claim_text, [pointer_ids]) pairs from each non-empty
    line. This verifier maps each pointer id back to its
    content-addressed evidence object and runs six hard checks:

    1. Parser succeeded — ``parse_status == "PARSED"`` (line had a
       bracket tag). NO_EVIDENCE_POINTER claims (prose without tag)
       count toward the denominator and downgrade the verdict.
    2. Pointer id resolves to an entry in the runtime-built evidence
       map. No model-invented ids.
    3. Resolved entry's ``source_role`` is in ``allowed_source_roles``.
    4. Claim text non-empty after tag strip.
    5. Claim's content tokens textually overlap the cited evidence
       span at coverage ≥ ``min_citation_coverage`` (per-pair, lexical
       only — see ``_claim_textually_overlaps_evidence``). Catches the
       magnet-chunk lazy-anchor where the model cites an evidence
       pointer whose text contains few claim-content tokens.
    6. Pointer count per claim does not exceed ``max_pointers_per_claim``
       (default 2 — matches the prompt's "1 or 2 pointers per claim"
       rule). When exceeded, the claim is TRIMMED to the first N
       pointers and verification proceeds normally; a
       ``POINTER_OVERFLOW_TRIMMED`` violation is recorded so STRICT is
       no longer reachable (audit_mode caps at HYBRID for the run).
       Trim-and-verify (vs hard fail) protects correct claims that
       were over-cited (e.g. "Leonardo painted the Mona Lisa.
       [E2,...,E14]") while keeping the over-citation pattern
       surfaced. The dropped pointers count toward ``n_quotes`` so
       the denominator reflects what the model emitted.

    Removed 2026-04-30: the strict no-double-quote rule. The model
    routinely paraphrases source prose but copies named-quoted phrases
    verbatim (e.g. ``"Constitution State"`` from a Connecticut span).
    Hard-rejecting claims that contained any ``"`` char was rejecting
    factually correct, source-grounded claims for cosmetic punctuation.
    The coverage threshold (Rule 5) and pointer cap (Rule 6) carry the
    weight of catching synthetic-quote / mega-claim failures the old
    rule was meant to catch. ``_has_manual_quote`` is still defined and
    used by ``verify_claim_lattice_json``.

    Returns a verdict in the same shape as ``verify_quotes`` + extras:

        n_quotes              total claim-pointer pairs (denominator)
        n_verified            pairs where pointer resolved AND
                              source_role allowed AND coverage met
                              AND claim text non-empty
        audit_mode            STRICT / HYBRID / UNGROUNDED
        unverified_quotes     claim texts that didn't reach
                              EVIDENCE_LINKED — kept under that name
                              for schema continuity with verify_quotes
        verifier_method       "claim_lattice"
        claim_statuses        per-claim {text, evidence_ids,
                              pointer_ids, status, reasons[]}; status ∈
                              {EVIDENCE_LINKED, EVIDENCE_LINKED_PARTIAL,
                               UNKNOWN_EVIDENCE_ID,
                               SOURCE_ROLE_BLOCKED,
                               CITATION_MISMATCH,
                               NO_EVIDENCE_POINTER, SCHEMA_INVALID}
        violations            structured violation records for the
                              run-DAG / sidecar
        rendered_text         human-readable prose with literal spans
                              interpolated; what the runner persists
                              as ``answer_text``
        evidence_id_pairs     per-claim list of resolved
                              content-addressed evidence_ids (run-stable
                              form). Used to thread the parsed lattice
                              into the run-DAG.
    """
    from aborist.qa.evidence import (
        evidence_map_by_pointer_id as _by_pointer,
        render_claim_lattice as _render,
    )
    from aborist.qa.parse_claims import parse_pointer_claims

    by_pointer = _by_pointer(evidence_map)
    violations: list[dict] = []
    claim_statuses: list[dict] = []
    # Three-bucket rendering. ``unverified`` holds claims whose
    # status reached neither EVIDENCE_LINKED nor EVIDENCE_LINKED_PARTIAL
    # — i.e. fully failed (no pointer verified). ``partially_verified``
    # holds EVIDENCE_LINKED_PARTIAL claims (some pointers ok, some
    # failed). The renderer shows them as their own section so a
    # claim never appears in BOTH a verified bullet and the unverified
    # footer; that previously happened for partial-status claims and
    # read as "is it grounded or not?". Per-pointer detail lives in
    # ``claim_statuses`` for audit.
    unverified: list[str] = []
    partially_verified: list[str] = []

    raw_claims = parse_pointer_claims(answer_text or "")

    # Claim-count ceiling — see DEFAULT_MAX_CLAIMS_PER_ANSWER. Records
    # the violation but doesn't truncate; the per-claim loop below
    # still verifies every claim so the operator sees full evidence
    # of the runaway. Demotes verdict via the violation list.
    if len(raw_claims) > max_claims_per_answer:
        violations.append({
            "kind": "TOO_MANY_CLAIMS",
            "n_claims": len(raw_claims),
            "max": max_claims_per_answer,
        })

    n_pairs = 0
    n_pairs_verified = 0
    # Renderer claims: pointer-id form so the human display still shows
    # the short tags the model used.
    valid_claims: list[dict] = []
    # Evidence-id pairs: content-addressed form for the run-DAG &
    # cache. Per-claim list so the parsed_claim_lattice node hashes the
    # run-stable handle, not the run-dependent pointer-id.
    evidence_id_pairs: list[list[str]] = []

    for idx, c in enumerate(raw_claims):
        claim_text = c.claim_text
        pointer_ids = c.pointer_ids
        parse_status = c.parse_status

        if parse_status == "NO_EVIDENCE_POINTER":
            violations.append({
                "kind": "NO_EVIDENCE_POINTER",
                "claim_idx": idx,
                "claim_text": claim_text,
            })
            claim_statuses.append({
                "claim_idx": idx,
                "text": claim_text,
                "pointer_ids": [],
                "evidence_ids": [],
                "status": "NO_EVIDENCE_POINTER",
                "reasons": ["no [E\\d+] tag on line"],
            })
            unverified.append(claim_text)
            evidence_id_pairs.append([])
            # NO_EVIDENCE_POINTER counts as one denominator pair so the
            # verdict reflects the failure rate.
            n_pairs += 1
            continue

        if not claim_text:
            violations.append({
                "kind": "SCHEMA_INVALID",
                "claim_idx": idx,
                "reason": "tag with no claim text",
            })
            claim_statuses.append({
                "claim_idx": idx,
                "text": "",
                "pointer_ids": pointer_ids,
                "evidence_ids": [],
                "status": "SCHEMA_INVALID",
                "reasons": ["tag with no claim text"],
            })
            n_pairs += len(pointer_ids)
            evidence_id_pairs.append([])
            continue

        # Bare-name claim guard. A claim like "Tyrannosaurus rex. [E15]"
        # has 1 content token; the citation passes any span that mentions
        # T-rex anywhere, even when E15 is a video-game-behavior chunk
        # rather than a film-context one. Forcing a sentence-shape claim
        # ("Tyrannosaurus rex appeared in the first JP film") raises the
        # token-coverage bar so an off-topic chunk can no longer satisfy
        # the citation. Folds into governance_policy_hash via
        # ``claim_lattice_min_claim_content_tokens``.
        from aborist.qa.evidence import _content_tokens as _ct
        claim_content_tokens = _ct(claim_text)
        if len(claim_content_tokens) < min_claim_content_tokens:
            violations.append({
                "kind": "SCHEMA_INVALID",
                "claim_idx": idx,
                "reason": (
                    f"bare-name claim ({len(claim_content_tokens)} content "
                    f"tokens < {min_claim_content_tokens}); write a sentence"
                ),
            })
            claim_statuses.append({
                "claim_idx": idx,
                "text": claim_text,
                "pointer_ids": pointer_ids,
                "evidence_ids": [],
                "status": "SCHEMA_INVALID",
                "reasons": ["bare-name claim — write a sentence with predicate"],
            })
            n_pairs += len(pointer_ids)
            evidence_id_pairs.append([])
            unverified.append(claim_text)
            continue

        # Pointer-count cap (Rule 9). Catches the encyclopedic-mega-
        # claim where the model produces one line citing every
        # pointer at once. Counts every pointer toward the denominator
        # so the failure is loud in n_quotes. 2026-04-30: trim-and-
        # verify rather than hard-fail. A correct claim cited with too
        # many pointers ("Leonardo da Vinci painted the Mona Lisa.
        # [E2,...,E14]") deserves to count if its first N pointers
        # actually verify; the violation still blocks STRICT (audit_mode
        # caps at HYBRID) so the over-citation pattern stays surfaced.
        # Hard SCHEMA_INVALID would have nuked correct answers for a
        # cosmetic over-cite. The dropped pointers count toward
        # n_pairs so the denominator reflects what the model emitted.
        pointer_overflow_trimmed = False
        if len(pointer_ids) > max_pointers_per_claim:
            dropped = pointer_ids[max_pointers_per_claim:]
            n_pairs += len(dropped)
            pointer_ids = pointer_ids[:max_pointers_per_claim]
            pointer_overflow_trimmed = True
            violations.append({
                "kind": "POINTER_OVERFLOW_TRIMMED",
                "claim_idx": idx,
                "kept": list(pointer_ids),
                "dropped": dropped,
                "max_pointers_per_claim": max_pointers_per_claim,
            })

        per_id_results: list[dict] = []
        resolved_evidence_ids: list[str] = []
        for pid in pointer_ids:
            n_pairs += 1
            obj = by_pointer.get(pid)
            if obj is None:
                violations.append({
                    "kind": "UNKNOWN_EVIDENCE_ID",
                    "claim_idx": idx,
                    "pointer_id": pid,
                })
                per_id_results.append({
                    "pid": pid, "ok": False, "kind": "UNKNOWN_EVIDENCE_ID",
                })
                continue
            if obj.source_role not in allowed_source_roles:
                violations.append({
                    "kind": "SOURCE_ROLE_BLOCKED",
                    "claim_idx": idx,
                    "pointer_id": pid,
                    "evidence_id": obj.evidence_id,
                    "source_role": obj.source_role,
                })
                per_id_results.append({
                    "pid": pid, "ok": False, "kind": "SOURCE_ROLE_BLOCKED",
                })
                continue
            if not _claim_textually_overlaps_evidence(
                claim_text, obj.span, min_coverage=min_citation_coverage
            ):
                # Cited evidence span has zero textual overlap with any
                # content token from the claim. Strongest lazy-anchor
                # signal promoted to a hard fail — the model cited a
                # magnet chunk that doesn't textually support its claim.
                violations.append({
                    "kind": "CITATION_MISMATCH",
                    "claim_idx": idx,
                    "pointer_id": pid,
                    "evidence_id": obj.evidence_id,
                })
                per_id_results.append({
                    "pid": pid, "ok": False, "kind": "CITATION_MISMATCH",
                })
                continue
            per_id_results.append({"pid": pid, "ok": True, "evidence_id": obj.evidence_id})
            resolved_evidence_ids.append(obj.evidence_id)
            n_pairs_verified += 1

        ok_pids = [r["pid"] for r in per_id_results if r["ok"]]
        bad_kinds = sorted({r["kind"] for r in per_id_results if not r["ok"]})

        if ok_pids and not bad_kinds:
            status = "EVIDENCE_LINKED"
        elif ok_pids:
            status = "EVIDENCE_LINKED_PARTIAL"
        elif "CITATION_MISMATCH" in bad_kinds:
            status = "CITATION_MISMATCH"
        elif "UNKNOWN_EVIDENCE_ID" in bad_kinds:
            status = "UNKNOWN_EVIDENCE_ID"
        elif "SOURCE_ROLE_BLOCKED" in bad_kinds:
            status = "SOURCE_ROLE_BLOCKED"
        else:
            status = "SCHEMA_INVALID"

        claim_statuses.append({
            "claim_idx": idx,
            "text": claim_text,
            "pointer_ids": pointer_ids,
            "evidence_ids": resolved_evidence_ids,
            "status": status,
            "reasons": bad_kinds,
        })
        evidence_id_pairs.append(resolved_evidence_ids)
        if status in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
            valid_claims.append({"text": claim_text, "pointer_ids": ok_pids})
        if status == "EVIDENCE_LINKED_PARTIAL":
            partially_verified.append(claim_text)
        elif status != "EVIDENCE_LINKED":
            unverified.append(claim_text)

    rendered_text = _render(valid_claims, by_pointer) if valid_claims else ""

    # Aggregate. STRICT requires ≥1 verified pair AND zero violations of
    # any kind (schema, unknown pointer, blocked role, manual quote,
    # missing pointer). HYBRID = some pairs verified, some failed.
    # UNGROUNDED = no verified pairs (no parseable claims, or every
    # claim failed at least one check).
    if n_pairs_verified > 0 and not violations:
        audit_mode = "STRICT"
    elif n_pairs_verified > 0:
        audit_mode = "HYBRID"
    else:
        audit_mode = "UNGROUNDED"

    # Anchor-smell sidecar (render-layer only — never persisted as a
    # v9.8 field). Counts how many distinct pointer_ids the model used
    # across the verified-or-partial claims. ``lazy_anchor_ratio`` is
    # the max share any single pointer claimed: 1.0 = every claim cites
    # the same pointer (Hermes-3-8B's lazy-anchor habit on the JP-
    # dinosaurs benchmark), 1/N = every claim cites a unique pointer.
    # The distribution is recoverable from ``claim_statuses`` which
    # IS persisted in run_dag_blob; we surface the derived numbers in
    # the verdict for the human renderer, but they never thread back
    # into ``build_run_dag``'s verify_payload, so ``run_dag_root``
    # stays clean.
    pointer_distribution: dict[str, int] = {}
    for cs in claim_statuses:
        if cs["status"] not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
            continue
        for pid in cs.get("pointer_ids") or []:
            pointer_distribution[pid] = pointer_distribution.get(pid, 0) + 1
    total_pointers = sum(pointer_distribution.values())
    lazy_anchor_ratio = (
        max(pointer_distribution.values()) / total_pointers
        if total_pointers
        else 0.0
    )

    # Smell → demote. Pre-2026-04-30 the lazy-anchor signal was advisory
    # only; the verdict could still be STRICT while every claim cited
    # the same magnet chunk. Now: when ratio ≥ threshold AND total pairs
    # ≥ floor, cap audit_mode at HYBRID. STRICT becomes unreachable for
    # answers where one pointer carries every claim — that pattern is
    # almost never honest verbatim grounding. UNGROUNDED is left alone
    # (a verdict with zero verified pairs has no smell to flag).
    lazy_anchor_demoted = False
    if (
        audit_mode == "STRICT"
        and total_pointers >= lazy_anchor_demote_min_pairs
        and lazy_anchor_ratio >= lazy_anchor_demote_threshold
    ):
        audit_mode = "HYBRID"
        lazy_anchor_demoted = True
        violations.append({
            "kind": "LAZY_ANCHOR_DEMOTE",
            "ratio": round(lazy_anchor_ratio, 3),
            "min_pairs": lazy_anchor_demote_min_pairs,
            "threshold": lazy_anchor_demote_threshold,
        })

    # Warrant-lite — relation-question hard check (Ticket H from
    # feedback-3, 2026-05-01). Claim-cited spans must contain at
    # least one of the claim's named answer entities (proper-noun
    # phrases). Catches the Homer-Simpson lazy-anchor case fox
    # surfaced — claim asserts "Mr. Burns" but cited span is
    # Castellaneta voice-actor prose. See aborist/qa/warrant.py
    # for the lexical algorithm and rationale (deterministic,
    # not NLI). Fires only when the question shape suggests a
    # relation lookup AND the lookup is enabled by policy
    # (warrant_check_enabled). Per-claim WARRANT_MISSING violations
    # cap audit_mode at HYBRID via the same demote pattern as
    # lazy_anchor_demoted.
    warrant_missing_claims: list[int] = []
    if warrant_check_enabled:
        for cs in claim_statuses:
            if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
                continue
            cited_eids = cs.get("evidence_ids") or []
            cited_spans = [
                obj.span
                for eid in cited_eids
                for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
                if obj is not None
            ]
            ok, missing = warrant_check(
                cs.get("text") or "", cited_spans, question=question
            )
            if not ok:
                warrant_missing_claims.append(cs.get("claim_idx"))
                violations.append({
                    "kind": "WARRANT_MISSING",
                    "claim_idx": cs.get("claim_idx"),
                    "missing_anchors": missing,
                    "rationale": (
                        "claim asserts an answer entity or specific date "
                        "not present in any cited span — pointer-linked "
                        "but warrant missing"
                    ),
                })
    if warrant_missing_claims and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    # Rule 8 — Title-relevance check. For each claim that resolved,
    # at least one cited evidence's source title must share a
    # stemmed content token with the claim. Catches the
    # retrieval-driven hallucination class (2026-05-02 spin-glass
    # case): claim about spin glass cited to a chunk from
    # *Quantum chromodynamics* — token-coverage check passed on
    # incidental physics vocabulary, but the SOURCE was never about
    # the claim's subject.
    title_mismatch_claims: list[int] = []
    for cs in claim_statuses:
        if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
            continue
        cited_eids = cs.get("evidence_ids") or []
        cited_titles = [
            obj.title
            for eid in cited_eids
            for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
            if obj is not None
        ]
        if not cited_titles:
            continue
        any_overlap = any(
            _claim_title_overlap(cs.get("text") or "", t)
            for t in cited_titles
        )
        if not any_overlap:
            title_mismatch_claims.append(cs.get("claim_idx"))
            violations.append({
                "kind": "TITLE_MISMATCH",
                "claim_idx": cs.get("claim_idx"),
                "claim_text": (cs.get("text") or "")[:200],
                "cited_titles": cited_titles,
                "rationale": (
                    "no cited source's title shares a content token "
                    "with the claim — pointer-linked but the cited "
                    "document is structurally unrelated to the claim"
                ),
            })
    if title_mismatch_claims and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    # Deflection check (soft demote, promoted from sidecar 2026-05-02).
    # When the question's subject anchor is missing from the answer,
    # the model deflected — answered an adjacent grounded question
    # instead of the user's specific one. Caught the live cases:
    #   "who burns the amazon river?" → answered about Amazon
    #     Rainforest deforestation, "river" never in answer
    #   "what culture burns the amazon rain forest?" → answered
    #     "what causes burning", "culture" never in answer
    # Both passed every other check but the user's question wasn't
    # structurally answered. DEFLECTION_DETECTED downgrades
    # EVIDENCE-WARRANTED → ANCHOR-WARRANTED via the soft-demote
    # path. Render-layer ladder picks this up automatically.
    deflection_detected = False
    if deflection_check_enabled and question and rendered_text:
        # Deferred import to avoid pulling aborist.compress + aborist.store
        # at verify.py module-load time when callers may not need them.
        from aborist.qa.inspect import diagnose_deflection
        signal = diagnose_deflection(question, rendered_text)
        if signal.get("kind") == "deflection":
            deflection_detected = True
            violations.append({
                "kind": "DEFLECTION_DETECTED",
                "subject_anchor": signal.get("subject_anchor"),
                "overlap_ratio": signal.get("overlap_ratio"),
                "rationale": (
                    "answer's content tokens omit the question's "
                    "subject anchor — model answered an adjacent "
                    "grounded question rather than the user's "
                    "specific one"
                ),
            })
    if deflection_detected and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    return {
        "n_quotes": n_pairs,
        "n_verified": n_pairs_verified,
        "audit_mode": audit_mode,
        "unverified_quotes": unverified,
        "partially_verified_quotes": partially_verified,
        "verifier_method": "claim_lattice",
        "claim_statuses": claim_statuses,
        "violations": violations,
        "rendered_text": rendered_text,
        "evidence_id_pairs": evidence_id_pairs,
        "pointer_id_distribution": pointer_distribution,
        "lazy_anchor_ratio": lazy_anchor_ratio,
        "lazy_anchor_demoted": lazy_anchor_demoted,
        "warrant_missing_claim_idxs": warrant_missing_claims,
        "title_mismatch_claim_idxs": title_mismatch_claims,
        "deflection_detected": deflection_detected,
    }


def evidence_map_by_evidence_id_local(evidence_map, eid: str):
    """Local helper — returns the EvidenceObject whose ``evidence_id``
    matches ``eid``, or None. Avoids the import-cycle risk of pulling
    `evidence_map_by_evidence_id` into this module's hot path; the
    O(N) walk is fine since evidence maps are <30 entries.
    """
    for obj in evidence_map or []:
        if obj.evidence_id == eid:
            return obj
    return None


# ---------------------------------------------------------------------------
# JSON variant — `answer_mode="claim_lattice"`. Same lattice semantics as
# the pointer variant, but the model emits a structured JSON object
# {"claims":[{"text":str,"evidence_ids":[str,...]}]} with content-
# addressed evidence_ids directly. Pairs naturally with grammar-
# constrained inference (vLLM guided_json, Claude/GPT-4 native JSON
# mode, Qwen 3.6 reasoner) where schema-conformance is generation-time-
# enforced. The lenient pre-parser above keeps the path survivable on
# inference paths without grammar guidance.
# ---------------------------------------------------------------------------


CLAIM_LATTICE_JSON_SCHEMA = {
    "type": "object",
    "properties": {
        "claims": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "text": {"type": "string"},
                    "evidence_ids": {
                        "type": "array",
                        "items": {"type": "string"},
                    },
                },
                "required": ["text", "evidence_ids"],
                "additionalProperties": False,
            },
        },
    },
    "required": ["claims"],
    "additionalProperties": False,
}


def verify_claim_lattice_json(
    answer_json_text: str,
    evidence_map,
    *,
    allowed_source_roles: tuple[str, ...] = DEFAULT_ALLOWED_SOURCE_ROLES,
    max_evidence_per_claim: int = DEFAULT_MAX_POINTERS_PER_CLAIM,
    min_citation_coverage: float = DEFAULT_MIN_CITATION_COVERAGE,
    max_claims_per_answer: int = DEFAULT_MAX_CLAIMS_PER_ANSWER,
    question: str | None = None,
    warrant_check_enabled: bool = True,
    deflection_check_enabled: bool = True,
) -> dict:
    """Deterministic verifier for ``answer_mode="claim_lattice"`` (JSON).

    Parses the model's JSON output (lenient pre-parser handles markdown
    fences / preamble / curly quotes / trailing commas), validates the
    schema, then runs the same hard checks as ``verify_claim_lattice``
    but reading ``evidence_ids`` from the JSON claim objects.

    2026-04-30: switched from content-addressed evidence_ids
    (``Eed1b6e396``) to pointer_ids (``E1``, ``E2``, …) in the prompt
    & JSON output. Hermes-3-8B was fabricating plausible content-
    addressed IDs (``E1b6e396``-style near-misses) on cross-document
    relationship questions; the verifier correctly rejected them as
    UNKNOWN_EVIDENCE_ID but the answer text was often factually
    correct, leaving us with honest UNGROUNDED on right answers.
    Pointer IDs are short, enumerable, and fabrication-obvious. The
    runtime still resolves each pointer_id to its content-addressed
    evidence_id internally and stores that in ``evidence_id_pairs``
    (cache/run-DAG continuity); only the prompt-facing surface
    changes.

    1. JSON parses (lenient). Failure → SCHEMA_INVALID, UNGROUNDED.
    2. Top-level is ``{"claims": [...]}``.
    3. Each claim is ``{"text": str, "evidence_ids": [str, ...]}``.
    4. Each evidence_id resolves in the runtime-built evidence map
       (no model-invented IDs).
    5. Resolved entry's ``source_role`` is in ``allowed_source_roles``.
    6. Claim text contains no double-quote characters anywhere.
    7. Claim text non-empty.
    8. Claim's content tokens textually overlap the cited evidence span.
    9. ``len(evidence_ids) <= max_evidence_per_claim``.

    Returns a verdict in the same shape as ``verify_claim_lattice`` plus
    a ``json_fixups`` field naming any drift the lenient parser had to
    peel (``"fence"`` / ``"prose_trim"`` / ``"curly_quotes"`` /
    ``"trailing_comma"``). Empty list = strict JSON parse on first try.
    """
    from aborist.qa.evidence import (
        evidence_map_by_pointer_id as _by_pointer,
        render_claim_lattice as _render,
    )

    by_pointer = _by_pointer(evidence_map)
    violations: list[dict] = []
    claim_statuses: list[dict] = []
    unverified: list[str] = []
    json_fixups: list[str] = []

    parsed = None
    try:
        parsed, json_fixups = _lenient_json_parse(answer_json_text or "")
    except Exception as exc:
        violations.append({
            "kind": "SCHEMA_INVALID",
            "reason": f"json parse: {str(exc)[:200]}",
        })
    if parsed is not None and not isinstance(parsed, dict):
        violations.append({
            "kind": "SCHEMA_INVALID",
            "reason": f"top-level not object (got {type(parsed).__name__})",
        })
        parsed = None
    raw_claims = (parsed or {}).get("claims") if parsed is not None else None
    if parsed is not None and not isinstance(raw_claims, list):
        violations.append({
            "kind": "SCHEMA_INVALID",
            "reason": "missing or non-list 'claims'",
        })
        raw_claims = None

    # Claim-count ceiling — same defense-in-depth signal as the
    # pointer verifier. A "tell me all there is to know" prompt
    # shape can spam encyclopedic claims; cap demotes the verdict
    # so the runaway is operator-visible regardless of per-claim
    # verification success.
    if isinstance(raw_claims, list) and len(raw_claims) > max_claims_per_answer:
        violations.append({
            "kind": "TOO_MANY_CLAIMS",
            "n_claims": len(raw_claims),
            "max": max_claims_per_answer,
        })

    n_pairs = 0
    n_pairs_verified = 0
    valid_claims: list[dict] = []
    evidence_id_pairs: list[list[str]] = []

    for idx, c in enumerate(raw_claims or []):
        if not isinstance(c, dict):
            violations.append({
                "kind": "SCHEMA_INVALID",
                "claim_idx": idx,
                "reason": f"claim not object (got {type(c).__name__})",
            })
            claim_statuses.append({
                "text": "", "evidence_ids": [],
                "status": "SCHEMA_INVALID", "reasons": ["not_object"],
            })
            continue
        claim_text = c.get("text") or ""
        eids = c.get("evidence_ids") or []
        if not isinstance(claim_text, str) or not isinstance(eids, list):
            violations.append({
                "kind": "SCHEMA_INVALID", "claim_idx": idx,
                "reason": "claim shape: text=str, evidence_ids=list[str]",
            })
            claim_statuses.append({
                "text": str(claim_text)[:200], "evidence_ids": [],
                "status": "SCHEMA_INVALID", "reasons": ["bad_field_types"],
            })
            continue

        # Manual-quote prohibition (same rule as pointer mode).
        if _has_manual_quote(claim_text):
            violations.append({
                "kind": "MANUAL_QUOTE_VIOLATION", "claim_idx": idx,
                "claim_text": claim_text[:200],
            })
            unverified.append(claim_text)
            claim_statuses.append({
                "text": claim_text, "evidence_ids": eids,
                "status": "MANUAL_QUOTE_VIOLATION",
                "reasons": ["double_quote_in_text"],
            })
            n_pairs += max(1, len(eids))
            continue

        if not claim_text.strip():
            violations.append({
                "kind": "SCHEMA_INVALID", "claim_idx": idx,
                "reason": "empty claim text",
            })
            claim_statuses.append({
                "text": "", "evidence_ids": eids,
                "status": "SCHEMA_INVALID", "reasons": ["empty_text"],
            })
            continue

        if len(eids) > max_evidence_per_claim:
            violations.append({
                "kind": "TOO_MANY_EVIDENCE_IDS", "claim_idx": idx,
                "claim_text": claim_text[:200],
                "n_ids": len(eids), "max": max_evidence_per_claim,
            })

        # Per-id resolution + checks. ``eids`` are pointer_ids
        # (E1, E2, …) emitted by the model; we resolve each to its
        # EvidenceObject and capture the content-addressed
        # ``evidence_id`` for the cache/run-DAG handle. Pointer-style
        # IDs make fabrication obvious — if only E1-E10 were shown,
        # an emitted "E27" reads as a hallucination at the schema
        # check, not as a near-miss content-addressed string.
        per_id_results = []
        verified_pointer_ids: list[str] = []
        verified_evidence_ids: list[str] = []
        for eid in eids:
            if not isinstance(eid, str):
                per_id_results.append({"eid": str(eid), "ok": False, "kind": "SCHEMA_INVALID"})
                continue
            obj = by_pointer.get(eid)
            if obj is None:
                per_id_results.append({"eid": eid, "ok": False, "kind": "UNKNOWN_EVIDENCE_ID"})
                violations.append({
                    "kind": "UNKNOWN_EVIDENCE_ID",
                    "claim_idx": idx, "evidence_id": eid,
                })
                continue
            if obj.source_role not in allowed_source_roles:
                per_id_results.append({"eid": eid, "ok": False, "kind": "SOURCE_ROLE_BLOCKED"})
                violations.append({
                    "kind": "SOURCE_ROLE_BLOCKED",
                    "claim_idx": idx, "evidence_id": obj.evidence_id,
                    "pointer_id": eid,
                    "source_role": obj.source_role,
                })
                continue
            if not _claim_textually_overlaps_evidence(
                claim_text, obj.span, min_coverage=min_citation_coverage
            ):
                per_id_results.append({"eid": eid, "ok": False, "kind": "CITATION_MISMATCH"})
                violations.append({
                    "kind": "CITATION_MISMATCH",
                    "claim_idx": idx, "evidence_id": obj.evidence_id,
                    "pointer_id": eid,
                    "claim_text": claim_text[:200],
                })
                continue
            per_id_results.append({"eid": eid, "ok": True})
            verified_pointer_ids.append(eid)
            verified_evidence_ids.append(obj.evidence_id)

        n_pairs += max(1, len(eids))
        n_pairs_verified += len(verified_pointer_ids)

        if not eids:
            claim_statuses.append({
                "text": claim_text, "evidence_ids": [],
                "status": "NO_EVIDENCE_POINTER",
                "reasons": ["no_evidence_ids"],
            })
            unverified.append(claim_text)
            n_pairs += 1
            continue

        if len(verified_pointer_ids) == len(eids):
            status = "EVIDENCE_LINKED"
        elif verified_pointer_ids:
            status = "EVIDENCE_LINKED_PARTIAL"
        else:
            # Pick the worst per-id reason for the claim status.
            kinds = [r["kind"] for r in per_id_results if not r["ok"]]
            status = kinds[0] if kinds else "UNKNOWN_EVIDENCE_ID"
            unverified.append(claim_text)

        # claim_statuses records BOTH ids: pointer (what model wrote)
        # and content-addressed (run-stable handle). Keeps the audit
        # trail legible at both layers.
        claim_statuses.append({
            "text": claim_text,
            "pointer_ids": list(eids),
            "evidence_ids": list(verified_evidence_ids),
            "status": status,
            "reasons": [r["kind"] for r in per_id_results if not r["ok"]],
        })
        if verified_pointer_ids:
            # Renderer takes the pointer-id form (model's view) and the
            # by_pointer index; cache/run-DAG get the content-addressed
            # evidence_ids (run-stable form).
            valid_claims.append({
                "text": claim_text,
                "pointer_ids": verified_pointer_ids,
            })
            evidence_id_pairs.append(list(verified_evidence_ids))

    rendered_text = _render(valid_claims, by_pointer) if valid_claims else ""

    if n_pairs_verified > 0 and not violations:
        audit_mode = "STRICT"
    elif n_pairs_verified > 0:
        audit_mode = "HYBRID"
    else:
        audit_mode = "UNGROUNDED"

    # Warrant-lite — same relation-question hard check as the pointer
    # variant. See verify_claim_lattice for the rationale (Ticket H,
    # 2026-05-01). Identical demote-to-HYBRID semantics; the JSON
    # variant carries the same WARRANT_MISSING violations & the same
    # warrant_missing_claim_idxs field on the verdict.
    warrant_missing_claims: list[int] = []
    if warrant_check_enabled:
        for cs in claim_statuses:
            if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
                continue
            cited_eids = cs.get("evidence_ids") or []
            cited_spans = [
                obj.span
                for eid in cited_eids
                for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
                if obj is not None
            ]
            ok, missing = warrant_check(
                cs.get("text") or "", cited_spans, question=question
            )
            if not ok:
                warrant_missing_claims.append(cs.get("claim_idx"))
                violations.append({
                    "kind": "WARRANT_MISSING",
                    "claim_idx": cs.get("claim_idx"),
                    "missing_anchors": missing,
                    "rationale": (
                        "claim asserts an answer entity or specific date "
                        "not present in any cited span — pointer-linked "
                        "but warrant missing"
                    ),
                })
    if warrant_missing_claims and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    # Rule 8 — Title-relevance check (mirrors the pointer variant).
    # See verify_claim_lattice for rationale (2026-05-02 spin-glass
    # case). Demote-to-HYBRID semantics; JSON variant emits the same
    # TITLE_MISMATCH violation kind & title_mismatch_claim_idxs field.
    title_mismatch_claims: list[int] = []
    for cs in claim_statuses:
        if cs.get("status") not in ("EVIDENCE_LINKED", "EVIDENCE_LINKED_PARTIAL"):
            continue
        cited_eids = cs.get("evidence_ids") or []
        cited_titles = [
            obj.title
            for eid in cited_eids
            for obj in [evidence_map_by_evidence_id_local(evidence_map, eid)]
            if obj is not None
        ]
        if not cited_titles:
            continue
        any_overlap = any(
            _claim_title_overlap(cs.get("text") or "", t)
            for t in cited_titles
        )
        if not any_overlap:
            title_mismatch_claims.append(cs.get("claim_idx"))
            violations.append({
                "kind": "TITLE_MISMATCH",
                "claim_idx": cs.get("claim_idx"),
                "claim_text": (cs.get("text") or "")[:200],
                "cited_titles": cited_titles,
                "rationale": (
                    "no cited source's title shares a content token "
                    "with the claim — pointer-linked but the cited "
                    "document is structurally unrelated to the claim"
                ),
            })
    if title_mismatch_claims and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    # Deflection check (parallel to pointer-variant promotion).
    deflection_detected = False
    if deflection_check_enabled and question and rendered_text:
        # Deferred import to avoid pulling aborist.compress + aborist.store
        # at verify.py module-load time when callers may not need them.
        from aborist.qa.inspect import diagnose_deflection
        signal = diagnose_deflection(question, rendered_text)
        if signal.get("kind") == "deflection":
            deflection_detected = True
            violations.append({
                "kind": "DEFLECTION_DETECTED",
                "subject_anchor": signal.get("subject_anchor"),
                "overlap_ratio": signal.get("overlap_ratio"),
                "rationale": (
                    "answer's content tokens omit the question's "
                    "subject anchor — model answered an adjacent "
                    "grounded question rather than the user's "
                    "specific one"
                ),
            })
    if deflection_detected and audit_mode == "STRICT":
        audit_mode = "HYBRID"

    # Same `verifier_method` as the pointer variant ("claim_lattice")
    # so the providence_cache CHECK constraint accepts both. The mode
    # is disambiguated downstream via `answer_mode` on the run-DAG &
    # via the JSON-only `json_fixups` field on this verdict.
    return {
        "n_quotes": n_pairs,
        "n_verified": n_pairs_verified,
        "audit_mode": audit_mode,
        "unverified_quotes": unverified,
        "verifier_method": "claim_lattice",
        "claim_statuses": claim_statuses,
        "violations": violations,
        "rendered_text": rendered_text,
        "evidence_id_pairs": evidence_id_pairs,
        "json_fixups": json_fixups,
        "warrant_missing_claim_idxs": warrant_missing_claims,
        "title_mismatch_claim_idxs": title_mismatch_claims,
    }
