"""Warrant-lite — anchor-class hard check on claim-cited evidence.

Closes two lazy-anchor failure classes fox surfaced in May 2026:

(1) Relation lazy-anchor (2026-05-01, "who is homer simpson's boss?"):

  Claim: "Homer Simpson's boss is Mr. Burns."
  Cited span: voice-actor / Castellaneta bio prose
  → Pointer resolves, source role allowed, coverage passes
  → BUT span contains no "Mr. Burns"
  → Warrant-lite catches it via proper-noun anchor check

(2) Date lazy-anchor (2026-05-01, "what date did back to the future
come out?"):

  Claim: "Back to the Future was released in theaters on July 3, 1985."
  Cited span: trilogy/SNES/pinball prose with no 1985 anywhere
  → Pointer resolves, source role allowed, coverage passes
  → BUT span does not contain the year the claim asserts
  → Warrant-lite catches it via date anchor check

The principle generalizes: pointer verification ≠ warrant verification.
A pointer resolves to a span; a warrant requires the span to actually
contain the claim's load-bearing tokens — the named ANSWER ENTITY for
relation questions, the asserted YEAR for any claim that names a
specific year, and (forward-looking) any unambiguous lexical anchor
the claim asserts.

Two anchor classes today:

- proper-noun anchors: gated on relation-question shape (regex
  detector). Required: at least one anchor extracted from the claim
  must appear in some cited span. Catches the relation lazy-anchor
  class.
- date anchors: gated on the CLAIM containing a 4-digit year (any
  question shape). Required: every year asserted in the claim must
  appear in some cited span. Years are unambiguous; if the claim says
  "1985" and no span has "1985", the warrant fails. Catches the date
  lazy-anchor class.

Both classes compose: a claim with both a proper-noun anchor and a
date anchor must satisfy both.

Hard or soft? Per CLAUDE.md "Soft hash vs hard hash":
substring/lexical = hard. Output is binary: warrant present or not.
Violations enter the proof path through `violations[]` exactly the
way SOURCE_ROLE_BLOCKED and CITATION_MISMATCH do — but
WARRANT_MISSING caps audit_mode at HYBRID rather than rejecting the
pointer outright. The lexical pointer checks still pass; the
user/auditor sees both "pointer-linked" AND "warrant missing" so
the failure mode is legible.

What is intentionally NOT here: typed-contract frameworks (per-
question-type rule libraries like "release_date_lookup with
required predicate cues"). The general anchor-class primitive
catches the back-to-the-future failure deterministically without a
per-type rule book. Typed contracts earn their slot only when bench
evidence shows the general primitive misses cases — see
docs/naming-deferral.md for the discipline.
"""

from __future__ import annotations

import re

# Heuristic relation-question detector. Fires when the question
# shape fits a "X's Y" / "who Z's W?" / "who owns Y?" / "who founded Z?"
# pattern — the cases fox names in feedback-3. Conservative: false
# negatives are fine (warrant just doesn't run); false positives are
# the real cost (warrant fires on questions where it shouldn't).
# Relation-noun aliases — used both in "who is X's <noun>?" and
# "<noun> of X" shapes. Possessive-less variant ("who is supermans
# girlfriend?") needs a separate pattern path since `\S+` alone won't
# bind to a relation noun without an explicit alias list.
_RELATION_NOUNS = (
    "boss|employer|supervisor|manager|owner|founder|director|creator|"
    "inventor|author|composer|painter|spouse|wife|husband|mother|father|"
    "parent|son|daughter|sister|brother|nephew|niece|aunt|uncle|cousin|"
    "girlfriend|boyfriend|partner|fiancee|fiance|friend|enemy|rival|"
    "successor|predecessor|mentor|teacher|student|coach|captain"
)
_RELATION_VERBS = (
    "owns|founded|directed|created|invented|wrote|painted|composed|"
    "discovered|killed|married|defeated|coached|hired|fired|sang|"
    "produced|designed|built|sculpted|filmed"
)
_RELATION_PATTERNS = (
    # "who is X's Y?" — apostrophe-s possessive (multi-word or single)
    re.compile(r"\bwho\s+is\s+\S+(?:\s+\S+)*\s*'s\s+\w+", re.IGNORECASE),
    # "who is supermans girlfriend?" — possessive without apostrophe,
    # gated on relation-noun alias list so generic nouns don't trip it
    re.compile(
        r"\bwho\s+is\s+\S+(?:\s+\S+)*\s+(?:" + _RELATION_NOUNS + r")\b",
        re.IGNORECASE,
    ),
    # "who founded microsoft?" — relation-verb
    re.compile(r"\bwho\s+(?:" + _RELATION_VERBS + r")\b", re.IGNORECASE),
    # "what is X's Y?" — possessive on a what-question
    re.compile(r"\bwhat\s+is\s+\S+(?:\s+\S+)*\s*'s\s+\w+", re.IGNORECASE),
    # "<relation-noun> of X" — bare relation-of phrase
    re.compile(r"\b(?:" + _RELATION_NOUNS + r")\s+of\b", re.IGNORECASE),
)


def is_relation_question(question: str) -> bool:
    """Return True iff the question shape suggests a relation lookup.

    Conservative — defaults to False on any question that doesn't
    match a known relation-shape regex. False negatives leave the
    warrant check inactive (claim verifies under the existing six
    hard checks alone); false positives risk over-firing on
    non-relation questions (worth tuning if the bench shows it).
    """
    if not question:
        return False
    for pat in _RELATION_PATTERNS:
        if pat.search(question):
            return True
    return False


# Multi-word proper noun (Title-Case sequences) — captures "Lois
# Lane", "Mr. Burns", "New England", "Homer Simpson's". The first-leg
# alternation lets a token end in `\.` ONLY when followed by another
# title-cased token (so "Mr." in "Mr. Burns" matches but "Burns." at
# sentence end does not slurp the trailing period). Trailing `'s` /
# stray punctuation are stripped in post-processing.
_PROPER_NOUN_RE = re.compile(
    r"\b(?:[A-Z][A-Za-z'’\-]*\.(?=[ \t]+[A-Z])|[A-Z][A-Za-z'’\-]+)"
    r"(?:[ \t]+(?:[A-Z][A-Za-z'’\-]*\.(?=[ \t]+[A-Z])|[A-Z][A-Za-z'’\-]+))+"
)

# Solo-capitalized tokens (e.g. "Apple", "Burns"). Less reliable
# than multi-word phrases since sentence-start words look like
# proper nouns too — used only when no multi-word phrase is
# available.
_SOLO_CAP_TOKEN_RE = re.compile(r"\b[A-Z][A-Za-z'’\-]{2,}\b")


def _strip_anchor_tail(anchor: str) -> str:
    """Strip possessive `'s` / `’s` and stray trailing punctuation."""
    a = anchor.rstrip(".,;:!?")
    for tail in ("'s", "’s", "'", "’"):
        if a.endswith(tail):
            a = a[: -len(tail)]
            break
    return a.strip()


def extract_answer_anchors(claim_text: str) -> list[str]:
    """Pull candidate answer-entity anchors from a claim's text.

    Strategy:
    1. Multi-word proper-noun phrases (`"Mr. Burns"`, `"Lois Lane"`,
       `"Nineteen Eighty-Four"`) — these are reliable named
       entities even at sentence start.
    2. If none found, fall back to solo capitalized tokens that
       are not the FIRST word of the claim (skipping
       sentence-starter false positives like the leading
       "Connecticut" in "Connecticut is a state...").

    Each anchor is post-stripped of possessive `'s` and stray
    sentence-end punctuation so downstream substring tests find
    base forms in cited spans.

    Returns a list of anchor strings; empty list if no anchor
    detectable. Lowercase consumers should `.lower()` per call.
    """
    if not claim_text:
        return []
    raw = _PROPER_NOUN_RE.findall(claim_text)
    if raw:
        cleaned = []
        for a in raw:
            stripped = _strip_anchor_tail(a)
            if stripped:
                cleaned.append(stripped)
        return cleaned
    # Solo-cap fallback. Skip the first token (sentence starter).
    tokens = _SOLO_CAP_TOKEN_RE.findall(claim_text)
    if len(tokens) <= 1:
        return []
    return [_strip_anchor_tail(t) for t in tokens[1:] if _strip_anchor_tail(t)]


# Year pattern — 4-digit years between 1500 and 2199. Avoids matching
# random 4-digit numbers (elevations, model IDs, ZIP codes, room
# numbers) while covering historical dates and near-future ones.
_YEAR_RE = re.compile(r"\b(?:1[5-9]\d\d|2[01]\d\d)\b")

# Month-name pattern — full English month names. Captures "July" in
# "July 3, 1985". Three-letter abbreviations ("Jul") are intentionally
# excluded for the first cut — too many false positives ("Mar" inside
# "Mara", "May" as a verb, etc.). If bench shows the gap, add as a
# whole-word-bounded alternation.
_MONTH_RE = re.compile(
    r"\b(?:January|February|March|April|May|June|"
    r"July|August|September|October|November|December)\b",
    re.IGNORECASE,
)


def extract_date_anchors(claim_text: str) -> list[str]:
    """Pull date anchors from a claim's text.

    Returns the union of:

    - **Year strings** (4-digit years 1500-2199). Avoids matching
      random 4-digit numbers like elevations or ZIP codes.
    - **Month names** (January-December, full names only).

    Both classes are required as conditions: when a claim asserts
    "July 3, 1985", the cited span must contain BOTH "1985" and
    "July" — checked independently as substrings. Catches the
    back-to-the-future class where a span happens to contain "1985"
    in unrelated narrative context (e.g. "back to the real 1985"
    referring to the film's diegetic time period) but never names
    the actual release month. Falls short on ISO date formats
    (``1985-07-03`` lacks the literal "July") but real-world Wikipedia
    prose uses month names; if bench shows the ISO gap, layer in a
    month-number alternative.

    Returns unique anchor strings in claim order; empty list if the
    claim has no year and no month name. Year strings are
    case-insensitive by definition; month names are returned in
    their claim-text form (consumers should ``.lower()`` before
    substring matching).
    """
    if not claim_text:
        return []
    seen: set[str] = set()
    out: list[str] = []
    for y in _YEAR_RE.findall(claim_text):
        if y not in seen:
            seen.add(y)
            out.append(y)
    for m in _MONTH_RE.findall(claim_text):
        key = m.lower()
        if key not in seen:
            seen.add(key)
            out.append(m)
    return out


# ---------------------------------------------------------------------------
# Entity-list shape (#000003)
# ---------------------------------------------------------------------------

# Question-shape detector for entity-list questions: "name X",
# "list X", "who are the members of X", "what are the X". Conservative
# regex — matches at the start of the question string only.
_ENTITY_LIST_PATTERNS = (
    re.compile(r"^\s*name\s+", re.IGNORECASE),
    re.compile(r"^\s*list\s+", re.IGNORECASE),
    re.compile(r"^\s*who\s+are\s+(?:the\s+)?members\s+of\b", re.IGNORECASE),
    re.compile(r"^\s*what\s+are\s+(?:the\s+)?(?:members?|names?)\s+of\b", re.IGNORECASE),
)


def _question_is_entity_list_shape(question: str) -> bool:
    """Return True iff the question shape suggests an entity-list lookup.

    Triggers on `name X`, `list X`, `who are the members of X`, etc.
    Conservative — false negatives leave the warrant vacuous;
    false positives would over-fire the entity-list anchor check on
    questions that don't enumerate entities.
    """
    if not question:
        return False
    for pat in _ENTITY_LIST_PATTERNS:
        if pat.search(question):
            return True
    return False


def extract_entity_list_anchors(claim_text: str) -> list[str]:
    """Pull entity-list anchors — individual capitalized tokens AND
    multi-word proper-noun phrases, both contributing to the pool.

    Differs from ``extract_answer_anchors`` (which prefers multi-word
    phrases when present and falls back to solo-cap only when none
    found): for entity-list questions the answer typically enumerates
    individual names separated by commas (``Homer, Marge, Bart, Lisa,
    Maggie``), and we want EACH name as an anchor candidate, not
    just the framing multi-word phrase.

    Strategy: union of (multi-word phrases) ∪ (solo-cap tokens
    skipping the first token to avoid sentence-starter false
    positives). Same ``_strip_anchor_tail`` post-processing; same
    case-insensitive substring semantics downstream.
    """
    if not claim_text:
        return []
    seen: set[str] = set()
    out: list[str] = []
    # Multi-word phrases (e.g. "The Simpsons", "Homer Simpson").
    for raw in _PROPER_NOUN_RE.findall(claim_text):
        stripped = _strip_anchor_tail(raw)
        if stripped and stripped.lower() not in seen:
            seen.add(stripped.lower())
            out.append(stripped)
    # Solo-cap tokens — every Title-Case token after the first.
    # The first token is sentence-starter ("The Simpsons family
    # consists of Homer..." → skip "The" as sentence opener but keep
    # "Simpsons", "Homer", "Marge", etc.).
    tokens = _SOLO_CAP_TOKEN_RE.findall(claim_text)
    for t in tokens[1:]:
        stripped = _strip_anchor_tail(t)
        if stripped and stripped.lower() not in seen:
            seen.add(stripped.lower())
            out.append(stripped)
    return out


# ---------------------------------------------------------------------------
# Count shape (#000003)
# ---------------------------------------------------------------------------

# Question-shape detector for count questions: "how many X", "how much X".
_COUNT_SHAPE_RE = re.compile(r"^\s*how\s+(?:many|much)\b", re.IGNORECASE)


def _question_is_count_shape(question: str) -> bool:
    if not question:
        return False
    return bool(_COUNT_SHAPE_RE.search(question))


# Word-form ↔ digit-form count equivalents. Bidirectional: a claim
# saying "six" matches a span containing "6" and vice versa. The
# table covers small counts (0-20) and decade words; large counts
# (hundreds, thousands) tend to appear in body prose verbatim.
_COUNT_WORD_TO_DIGIT = {
    "zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
    "five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
    "ten": "10", "eleven": "11", "twelve": "12", "thirteen": "13",
    "fourteen": "14", "fifteen": "15", "sixteen": "16", "seventeen": "17",
    "eighteen": "18", "nineteen": "19", "twenty": "20",
    "thirty": "30", "forty": "40", "fifty": "50", "sixty": "60",
    "seventy": "70", "eighty": "80", "ninety": "90",
    "hundred": "100", "thousand": "1000", "million": "1000000",
    "billion": "1000000000",
    # Ordinals collapse to their cardinal form (eighth → eight → 8).
    "first": "1", "second": "2", "third": "3", "fourth": "4",
    "fifth": "5", "sixth": "6", "seventh": "7", "eighth": "8",
    "ninth": "9", "tenth": "10", "eleventh": "11", "twelfth": "12",
}
_COUNT_DIGIT_TO_WORDS: dict[str, list[str]] = {}
for _w, _d in _COUNT_WORD_TO_DIGIT.items():
    _COUNT_DIGIT_TO_WORDS.setdefault(_d, []).append(_w)

# Digit-only count tokens in the claim. Standalone integers, no
# year-shaped tokens (those go through extract_date_anchors). Bound
# 1-9999; larger integers tend to be year-adjacent or measurement
# values that this primitive doesn't try to anchor (Boltzmann-class
# numerics get a sidecar in a future iteration).
_COUNT_DIGIT_RE = re.compile(r"\b(\d{1,4})\b")
_COUNT_WORD_RE = re.compile(
    r"\b(" + "|".join(re.escape(w) for w in _COUNT_WORD_TO_DIGIT) + r")\b",
    re.IGNORECASE,
)


def extract_count_anchors(claim_text: str) -> list[str]:
    """Pull count anchors from a claim's text.

    Each anchor returned is the *form as the claim wrote it* (digit
    or word). The downstream check accepts either form in the cited
    span (so a claim saying "six" passes a span containing "6", and
    vice versa). Year-shaped digit tokens (1500-2199) get filtered
    out since they belong to ``extract_date_anchors``.

    Returns unique anchors in claim order; empty list when the
    claim has no count token.
    """
    if not claim_text:
        return []
    seen: set[str] = set()
    out: list[str] = []
    for raw_digit in _COUNT_DIGIT_RE.findall(claim_text):
        # Skip year-shaped digits — those are date anchors, not count.
        if _YEAR_RE.fullmatch(raw_digit):
            continue
        if raw_digit not in seen:
            seen.add(raw_digit)
            out.append(raw_digit)
    for raw_word in _COUNT_WORD_RE.findall(claim_text):
        key = raw_word.lower()
        if key not in seen:
            seen.add(key)
            out.append(raw_word)
    return out


def _count_anchor_present(anchor: str, joined_lower: str) -> bool:
    """Match a count anchor against the joined cited spans, accepting
    either the digit form or any word form for the same value.

    Examples:
      anchor='six' + span containing '6'    → True
      anchor='12'  + span containing 'twelve' → True
      anchor='six' + span containing 'sixth' → True (ordinal collapses)
    """
    a_lower = anchor.lower()
    if a_lower in joined_lower:
        return True
    # Word form → digit form.
    digit = _COUNT_WORD_TO_DIGIT.get(a_lower)
    if digit and digit in joined_lower:
        return True
    # Digit form → all word forms for that digit (cardinal + ordinal).
    if a_lower in _COUNT_DIGIT_TO_WORDS:
        for w in _COUNT_DIGIT_TO_WORDS[a_lower]:
            if w in joined_lower:
                return True
    return False


# ---------------------------------------------------------------------------
# Why-cause shape (#000003)
# ---------------------------------------------------------------------------

# Question-shape detector for why-cause questions.
_WHY_SHAPE_RE = re.compile(r"^\s*why\b", re.IGNORECASE)


def _question_is_why_shape(question: str) -> bool:
    if not question:
        return False
    return bool(_WHY_SHAPE_RE.search(question))


# Stopwords for cause-anchor extraction. The why-shape extractor pulls
# ≥5-char tokens from the claim; this list filters generic vocabulary
# that isn't load-bearing (the cause is rarely "because", "however").
_CAUSE_STOPWORDS = frozenset({
    "about", "above", "across", "after", "again", "against", "almost",
    "alone", "along", "already", "although", "always", "another", "anyone",
    "anything", "around", "because", "before", "behind", "being", "below",
    "between", "beyond", "during", "either", "every", "everyone",
    "everything", "first", "found", "further", "however", "instead",
    "itself", "later", "least", "maybe", "myself", "neither", "never",
    "nothing", "otherwise", "perhaps", "really", "second", "several",
    "should", "since", "someone", "something", "somewhere", "still",
    "their", "themselves", "there", "these", "thing", "things", "third",
    "those", "though", "through", "throughout", "together", "toward",
    "under", "until", "upon", "usually", "very", "where", "whether",
    "which", "while", "whose", "without", "would", "actually", "become",
    "called", "during", "later", "named", "result", "results", "happen",
    "happened", "happens", "happening",
    # Quantifier-adjective fillers — "various factors", "certain
    # things", "other reasons" carry no causal load.
    "various", "certain", "other", "different", "particular", "specific",
    "general", "common", "ordinary", "typical", "normal", "regular",
    "simple", "complex", "complicated", "factors", "factor", "reason",
    "reasons", "issues", "issue", "matter", "matters", "situation",
    "situations", "case", "cases", "place", "places", "point", "points",
    "level", "levels", "kind", "kinds", "sort", "sorts", "type", "types",
})

# Lower-case alpha tokens of length ≥ 5 (the cause class accepts
# common-noun anchors like "iceberg", "asteroid", "propaganda" which
# the proper-noun extractor misses when they're sentence-internal
# lowercase).
_CAUSE_TOKEN_RE = re.compile(r"\b[a-z][a-z'\-]{4,}\b")


def extract_cause_anchors(claim_text: str) -> list[str]:
    """Pull cause-noun candidates from a claim's text.

    Strategy: collect ≥5-char lowercase content tokens (post-stopword)
    PLUS the proper-noun anchors from ``extract_answer_anchors``. The
    union widens the anchor pool so a why-cause claim like "The
    Titanic struck an iceberg" lands ``iceberg`` (lowercase
    common-noun) AND ``Titanic`` (Title-Case proper-noun) as eligible
    anchors.

    Gated by the caller: this extractor only fires when the question
    is why-shape (``_question_is_why_shape``). Lowering the bar for
    common-noun extraction outside why-shape questions raises
    false-positive risk too far for the lexical-only discipline.

    Returns unique lowercase strings (cause-noun candidates) plus
    proper-noun anchors in their original case.
    """
    if not claim_text:
        return []
    seen: set[str] = set()
    out: list[str] = []
    # Proper-noun anchors first — preserve original case ("Titanic"
    # not "titanic"). The lowercase pass skips any token whose
    # case-folded form is already seen, so we don't duplicate.
    for pn in extract_answer_anchors(claim_text):
        key = pn.lower()
        if key not in seen:
            seen.add(key)
            out.append(pn)
    # Lowercase common-noun candidates.
    for raw in _CAUSE_TOKEN_RE.findall(claim_text.lower()):
        if raw in _CAUSE_STOPWORDS:
            continue
        if raw not in seen:
            seen.add(raw)
            out.append(raw)
    return out


def warrant_check(
    claim_text: str,
    cited_spans: list[str],
    *,
    question: str | None = None,
) -> tuple[bool, list[str]]:
    """Hard lexical warrant check — anchor classes composed.

    Two anchor classes:

    1. **Proper-noun anchors** (relation lazy-anchor class). Gated on
       relation-question shape via ``is_relation_question(question)``;
       requires ``question`` kwarg. AT LEAST ONE extracted anchor must
       appear as a substring in at least one cited span
       (case-insensitive). The any-match semantics is lenient — the
       answer entity OR the subject matching is enough lexical signal
       that the cited span is contextually right. Vacuous-passes when
       ``question`` is None/non-relation, or when the claim has no
       proper-noun anchor.

    2. **Date anchors** (date lazy-anchor class). Always-on, gated on
       the CLAIM containing a 4-digit year. Requires ALL years
       asserted in the claim to appear in at least one cited span.
       Years are unambiguous; if the claim says "1985" and no cited
       span has "1985", the warrant fails — no fuzzy matching, no
       semantic equivalence. Vacuous-passes when the claim has no
       year.

    Returns ``(warrant_ok, missing_anchors)``:
      - ``warrant_ok=True`` when both anchor classes pass (or
        vacuous-pass).
      - ``warrant_ok=False`` and ``missing_anchors=[...]`` listing
        every anchor (date or proper-noun) the claim asserted but
        no cited span supports. The auditor sees what specifically
        was missing — date strings, entity names, or both.

    Composition: when a claim has both classes (e.g. relation-shape
    question with a year-asserting claim), both must pass. The
    failure list accumulates from both checks.
    """
    proper_anchors: list[str] = []
    if question and is_relation_question(question):
        proper_anchors = extract_answer_anchors(claim_text)

    # Entity-list shape: list-aware extractor (multi-word phrases +
    # individual solo-cap tokens), ANY-match semantics. A claim
    # listing entities passes if at least one named entity appears
    # in some cited span — the demote-don't-reject pattern: an extra
    # entity from training-prior is acceptable as long as the cited
    # evidence anchors at least one.
    entity_list_anchors: list[str] = []
    if question and _question_is_entity_list_shape(question):
        entity_list_anchors = extract_entity_list_anchors(claim_text)

    date_anchors = extract_date_anchors(claim_text)

    # Count shape: gated on question. ALL-match semantics — every
    # count token in the claim must have an equivalent (digit ↔ word)
    # in some cited span. Year-shaped digits already filter out via
    # extract_count_anchors.
    count_anchors: list[str] = []
    if question and _question_is_count_shape(question):
        count_anchors = extract_count_anchors(claim_text)

    # Why-cause shape: gated on question. ANY-match semantics —
    # cause-noun pool widens to lowercase common nouns ≥5 chars plus
    # proper nouns. At least one cause anchor must appear in some
    # cited span. Conservative gate: only fires on why-shape questions
    # where the false-positive risk on lowercase common-noun
    # extraction is bounded.
    cause_anchors: list[str] = []
    if question and _question_is_why_shape(question):
        cause_anchors = extract_cause_anchors(claim_text)

    if (not proper_anchors and not date_anchors and not entity_list_anchors
            and not count_anchors and not cause_anchors):
        return True, []

    joined_lower = " ".join(s.lower() for s in cited_spans)
    failures: list[str] = []

    # Date anchors: every component (year + month name if present)
    # must appear in some cited span (case-insensitive substring).
    # Year strings are case-trivial; month names round-trip via
    # `.lower()`.
    for d in date_anchors:
        if d.lower() not in joined_lower:
            failures.append(d)

    # Count anchors: every count must appear in word OR digit form.
    for c in count_anchors:
        if not _count_anchor_present(c, joined_lower):
            failures.append(c)

    # Proper-noun anchors (relation): at least one match suffices.
    if proper_anchors:
        any_found = any(a.lower() in joined_lower for a in proper_anchors)
        if not any_found:
            failures.extend(proper_anchors)

    # Entity-list anchors: at least one match suffices (demote-don't-
    # reject; an extra entity from training-prior is OK as long as
    # the cited evidence anchors at least one named entity).
    if entity_list_anchors:
        any_found = any(a.lower() in joined_lower for a in entity_list_anchors)
        if not any_found:
            failures.extend(entity_list_anchors)

    # Cause anchors (why-shape): at least one match suffices.
    if cause_anchors:
        any_found = any(a.lower() in joined_lower for a in cause_anchors)
        if not any_found:
            failures.extend(cause_anchors)

    if failures:
        return False, failures
    return True, []
