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:
parent
7deee7d91a
commit
439319f960
4 changed files with 215 additions and 19 deletions
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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)?
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue