qa: question_hash strips articles + verifier coverage drops stopwords

Two related changes that tighten the dedup + grounding signals
without lowering quality bars.

1. question_hash drops standalone English articles (the/a/an).

   Fox 2026-04-29: `who is the batman?` and `who is batman?`
   produced different cache records; same question, different hash.
   Articles are filler at the question-equivalence layer. Add a 4th
   canonicalization step to question_hash: after lowercase + trailing
   punctuation strip, split on whitespace & drop tokens equal to
   "the" / "a" / "an", then rejoin.

   Equivalence class now includes:
       "who is X"      ┐
       "who is the X"  │
       "who is a X"    │ -> same question_hash
       "who is an X"   │
       "Who Is X?"    ┘  (CJK question mark)

   "thesis" stays untouched — exact-match standalone tokens only,
   not substring. Conservative on i18n: ASCII English articles only;
   "el / la / le / der / die / das" etc. await demand.

2. _token_coverage filters stopwords and per-token punctuation.

   Fox asked: should we lower the 0.85 paraphrase threshold? Honest
   answer: no — that would promote the Q1 Batman fabrication
   ("wealthy/businessman/resides" missing from corpus) to STRICT.
   Tighten the signal instead so 0.85 means more.

   - Per-token punctuation strip (.,;:!?\"()[]{}) so `wayne,` lines
     up with bare `wayne` in context. Apostrophes deliberately stay
     so `batman's` is distinct from `batman`.
   - English stopword filter on length-≥4 fillers (from / with /
     have / been / would / which / where / their / etc., curated set
     in _ENGLISH_STOPWORDS). These match almost any English text &
     inflate coverage scores when topical content is missing.

   Net effect on Q1 Batman case: missing tokens are
   `wealthy/businessman/resides` — all topical. Coverage stays well
   below 0.85, span stays UNGROUNDED. Net effect on a stylistic
   paraphrase (model wrote `from` instead of `with`): coverage
   computed only over topical tokens, both copies match → 1.0 →
   correctly promoted.

Tests: 8 new in tests/test_keys.py (article-strip equivalence
classes, substring preservation, distinct-topic non-collapse) +
2 in tests/test_verify.py (stopword filter doesn't inflate; 0.85
threshold still rejects fabrication). 335 passed, 1 skipped.

Note for fox: the underlying retrieval issue surfaced in Q1/Q2 is
independent — Batman main article IS in shard 002.db but FTS5 +
title rerank ranked List_of_Batman_comics higher. Different fix,
separate commit.
This commit is contained in:
russell@unturf.com 2026-04-29 14:00:44 -04:00
parent 7deee7d91a
commit 439319f960
No known key found for this signature in database
4 changed files with 215 additions and 19 deletions

View file

@ -36,33 +36,47 @@ def _canonical_json(obj) -> str:
def question_hash(question: str) -> str:
"""SHA-256 of canonicalized + lowercased + punctuation-stripped question text.
"""SHA-256 of canonicalized + lowercased + punctuation-stripped +
article-stripped question text.
Three-step canonicalization, in order:
Four-step canonicalization, in order:
1. ``canonicalize()`` — NFC, whitespace collapse, strip ends.
2. ``.lower()`` — case-fold so "Who is X" == "who is X".
3. ``rstrip(_QUESTION_TRAILING_STRIP)`` — trailing punctuation
carries no semantic difference for retrieval. ``"who is X?"``
and ``"who is X"`` map to the same cache_key, so the
providence_cache hit returns the same answer for either form.
and ``"who is X"`` map to the same cache_key.
4. drop standalone articles (``the``, ``a``, ``an``). ``"who is the
batman"`` and ``"who is batman"`` ask the same question — fox
caught this on 2026-04-29 when the two forms produced different
cache records. Articles are filtered as exact-match standalone
tokens (split on whitespace), so ``"thesis"`` stays untouched.
Step 3 is question-specific — chunk leaf hashes stay verbatim
Steps 3-4 are question-specific — chunk leaf hashes stay verbatim
because they go through ``canonicalize()`` directly, not through
this function. Bumping ``_QUESTION_TRAILING_STRIP`` orphans prior
cache records whose canonical question ended in newly-stripped
punctuation; they live as history but won't be re-hit on lookup.
this function. Bumping ``_QUESTION_TRAILING_STRIP`` or
``_QUESTION_ARTICLE_STRIP`` orphans prior cache records whose
canonical question contained newly-stripped tokens; they live as
history but won't be re-hit on lookup.
What's IN the strip set: ``.?!,;:`` (ASCII), ``?!。、`` (CJK
full-width), ``…`` (ellipsis). Pairs like ``"`` ``'`` ``)`` ``]``
``}`` are NOT in the set: trailing-strip on one side of a pair
breaks balance (``who said "I think therefore I am"?`` → strip
``?`` → ``who said "I think therefore I am"`` is balanced; further
stripping ``"`` would yield an unbalanced surface). Apostrophes
aren't in the set either — ``X's`` is a different question from
Equivalence class examples::
"who is X" |
"who is X?" |
"Who Is X." | -> same question_hash
"who is the X" |
"who is a X" |
"who is an X?" | (CJK question mark)
What's IN the trailing-strip set: ``.?!,;:`` (ASCII), ``?!。、``
(CJK full-width), ``…`` (ellipsis). Pairs like ``"`` ``'`` ``)``
``]`` ``}`` are NOT — naive one-sided stripping breaks balance.
Apostrophes aren't either — ``X's`` is a different question from
``X``.
"""
canon = canonicalize(question).lower().rstrip(_QUESTION_TRAILING_STRIP)
tokens = [t for t in canon.split() if t not in _QUESTION_ARTICLE_STRIP]
canon = " ".join(tokens)
return _sha256(canon)
@ -78,6 +92,19 @@ def question_hash(question: str) -> str:
# Other: … U+2026 horizontal ellipsis
_QUESTION_TRAILING_STRIP = ".?!,;:?!。、…"
# English articles stripped as standalone tokens after lowercasing. The
# question equivalence class treats "the foo" and "foo" as the same
# question — fox's 2026-04-29 catch: `who is the batman` & `who is
# batman` produced different cache records under earlier rules. Tokens
# are matched as EXACT lowercase strings, so substrings like "thesis"
# (contains "the") stay untouched.
#
# Conservative on purpose: only ASCII English articles. "El", "la",
# "los", "le", "les", "der", "die", "das" etc. are not stripped today.
# Adding them when needed flows through the same equivalence-class
# expansion the trailing-punctuation set went through.
_QUESTION_ARTICLE_STRIP = frozenset({"the", "a", "an"})
def model_profile_hash(
model_id: str, revision: str = "", quantization: str = ""

View file

@ -309,18 +309,91 @@ def _token_coverage(
"""Fraction of meaningful tokens from `span` that appear anywhere
in `norm_ctx`. Returns ``(coverage_fraction, content_token_count)``.
Tokens shorter than ``min_token_len`` chars are skipped — they're
function words and short connectors that match almost any English
text and don't carry topical signal.
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)
tokens = [t for t in nspan.split() if len(t) >= min_token_len]
# 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)?

View file

@ -129,6 +129,60 @@ def test_question_hash_preserves_trailing_close_brace():
assert question_hash("who is X {a}") != question_hash("who is X {a")
# Article stripping — fox's 2026-04-29 catch: "the batman" and "batman"
# produced different cache records. Articles are filler at the question-
# equivalence layer; strip them so cache lookups dedupe.
def test_question_hash_strips_leading_article_the():
"""`who is the batman` and `who is batman` ask the same question."""
assert question_hash("who is the batman") == question_hash("who is batman")
def test_question_hash_strips_leading_article_a():
assert question_hash("what is a hot dog") == question_hash("what is hot dog")
def test_question_hash_strips_leading_article_an():
assert question_hash("what is an apple") == question_hash("what is apple")
def test_question_hash_strips_articles_anywhere_in_question():
"""Articles in the middle of the sentence also strip."""
assert (
question_hash("does the cat sit on a mat")
== question_hash("does cat sit on mat")
)
def test_question_hash_does_not_strip_article_substrings():
"""`thesis` contains `the` but is one token; substring matching
would corrupt it. Tokens are matched as exact lowercase strings."""
assert (
question_hash("explain my thesis")
!= question_hash("explain my sis")
)
# And `thesis` survives the strip:
assert "explain my thesis" in question_hash.__doc__ or True # docstring sanity
# The actual canonical form has `thesis`:
h_thesis = question_hash("thesis")
h_other = question_hash("the sis")
# "the sis" → "sis" after article strip; "thesis" stays "thesis"
assert h_thesis != h_other
def test_question_hash_handles_question_with_only_articles_and_topic():
"""Edge: very short question. `the X` and `X` and `a X` collapse."""
assert question_hash("the batman") == question_hash("batman")
assert question_hash("a batman") == question_hash("batman")
assert question_hash("an batman") == question_hash("batman")
def test_question_hash_distinct_topics_still_distinct():
"""Article strip doesn't accidentally collapse different topics."""
assert question_hash("the batman") != question_hash("the superman")
assert question_hash("a batman") != question_hash("a superman")
def test_question_hash_distinct_for_different_text():
"""Sanity: two genuinely different questions hash to different values."""
assert question_hash("who is X") != question_hash("who is Y")

View file

@ -434,6 +434,48 @@ def test_paraphrase_method_label_set_when_any_paraphrase_used():
assert v["audit_mode"] == "STRICT"
def test_paraphrase_stopword_filter_does_not_inflate_coverage():
"""Stopwords (`from`, `with`, `which`, etc.) match almost any English
text. Excluding them from token-coverage tightens the signal: a span
where the topical content is missing scores LOWER, not higher.
The fox 2026-04-29 Batman case: span 'Batman is the alias of Bruce
Wayne, a wealthy businessman who resides in Gotham City' has
'wealthy/businessman/resides' missing from the corpus. With a clean
stopword filter, those topical misses dominate the score and the
span correctly stays UNGROUNDED rather than scraping over a lowered
threshold."""
context = (
"Batman is the alias of Bruce Wayne. Batman lives in Gotham City."
)
answer_with_extra_stopwords = (
"Batman, who is the alias of Bruce Wayne, lives in Gotham City."
)
# Topical tokens (batman, alias, bruce, wayne, lives, gotham, city)
# all present in source. Stopwords ('which', 'who', etc.) are
# filtered. Coverage on filtered set = 1.0 → paraphrase verifies.
v = verify_quotes(answer_with_extra_stopwords, context)
assert v["audit_mode"] == "STRICT"
def test_paraphrase_rejects_when_topical_tokens_missing():
"""Confirms that lowering the bar wouldn't be a quick win — Q1's
Batman case (missing 'wealthy/businessman/resides') stays
UNGROUNDED because the topical content isn't in source. Stopword
filter doesn't rescue it."""
context = "Batman is Bruce Wayne. Batman fights crime in Gotham."
answer = (
"Batman is the alias of Bruce Wayne, a wealthy businessman who "
"resides in Gotham City."
)
# 'wealthy', 'businessman', 'resides', 'alias' all absent from
# context. Topical content missing — coverage drops below 0.85.
v = verify_quotes(answer, context)
# Either UNGROUNDED (substring fail) or HYBRID (some entities) —
# the key constraint is NOT STRICT (would mean fabrication promoted).
assert v["audit_mode"] != "STRICT"
def test_paraphrase_strategy_keeps_quote_strategy_strict():
"""Quote strategy stays verbatim-only — quotes ARE quotes, paraphrase-
in-quotes is the model's mistake. Paraphrase fallback applies to span