arborist/aborist/qa/warrant.py
russell@unturf.com f419d76292
qa(warrant): #000003 land — anchor-class generalization (D6 → ✓)
Three new question-shape classes dispatched through warrant_check
alongside the existing relation + date anchors:

(1) Entity-list shape — `name X`, `list X`, `who are the members
    of X`. List-aware extractor `extract_entity_list_anchors`
    (multi-word phrases ∪ solo-cap individual names) so comma-
    separated entities each contribute. ANY-match semantics:
    demote-don't-reject when an extra entity from training-prior
    appears alongside grounded ones.

(2) Count shape — `how many X`, `how much X`. Digit ↔ word
    equivalence (claim says "six", span says "6", or vice versa)
    with ordinal collapse (`sixth → 6`). Year-shaped digits
    filter out (those belong to the existing date anchor class).
    ALL-match semantics: every count token in the claim must
    appear in some cited span as digit or word.

(3) Why-cause shape — `why X`. Cause-anchor pool widens to
    ≥5-char lowercase common nouns (post a generic stopword set
    that filters quantifier-adjective fillers like "various",
    "factors", "situation") PLUS proper-noun anchors from the
    existing extractor. Gated on why-shape only: lowercase
    common-noun extraction has higher false-positive risk
    elsewhere.

Per-class policy gate (proposed `claim_lattice_warrant_classes`
dict) deferred per the five-step algorithm step 2: single
`warrant_check_enabled: bool` is the minimum viable gate; per-
class flags earn their slot when bench evidence shows over-firing
on a specific class.

17 new warrant tests (detector + extractor + integration).
Marker test in test_directives.py flipped from "absent" to
"present" assertion: test_d6_warrant_generalization_landed.

Full suite: 709 passed (was 692, +17).

Directive D6 status flipped to ✓ in seven-point-program.md.
Ticket #000003 closed.
2026-05-01 19:16:06 -04:00

624 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, []