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.
624 lines
26 KiB
Python
624 lines
26 KiB
Python
"""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, []
|