From 7d338d29ca89af1de6c59fd74ca92bdbfa6ee079 Mon Sep 17 00:00:00 2001 From: Tarik Moussa Date: Sun, 14 Jun 2026 03:53:39 +0200 Subject: [PATCH] fix(wiki): sentence-level claim granularity + content-5-gram grounding guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 3-gram substring match with: - _last_sentence(): only the last sentence before a citation is checked (prevents a hallucinated paragraph grounding via a phrase at its end) - _content_words(): stopwords removed from both claim and chunk before n-gram comparison (prevents bypass via "the discrete conformal map") - Content-5-gram: require 5 consecutive non-stopword tokens from last sentence to appear in the chunk's content-word stream - Claims with <5 content words in last sentence → ungrounded by default Add adversarial tests: stopword-3-gram bypass → grounded=False; legitimate content-5-gram → grounded=True. Co-Authored-By: Claude Sonnet 4.6 --- codex/wiki.py | 79 ++++++++++++++++++++++++++++++-------- tests/wiki/test_compile.py | 45 ++++++++++++++++++++++ 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/codex/wiki.py b/codex/wiki.py index d6a8878..2efe15e 100644 --- a/codex/wiki.py +++ b/codex/wiki.py @@ -238,25 +238,66 @@ def _parse_claims(markdown: str) -> list[Claim]: return claims +_STOPWORDS: frozenset[str] = frozenset( + { + "the", "a", "an", "in", "on", "of", "to", "is", "are", "was", "were", + "and", "or", "but", "for", "with", "this", "that", "it", "we", "they", + "be", "as", "at", "by", "from", "has", "have", "not", "which", + } +) + +_SENTENCE_SPLIT_RE = re.compile(r"[.!?]") + + +def _last_sentence(text: str) -> str: + """Return the last non-empty sentence from *text*. + + Citations annotate the last sentence, not the whole preceding paragraph. + Splitting on ``.``, ``!``, ``?`` and taking the last non-empty element + ensures only the immediately preceding sentence is grounding-checked. + """ + parts = _SENTENCE_SPLIT_RE.split(text) + for part in reversed(parts): + stripped = part.strip() + if stripped: + return stripped + return text.strip() + + +def _content_words(text: str) -> list[str]: + """Return lowercased tokens with stopwords removed.""" + return [w for w in text.lower().split() if w not in _STOPWORDS] + + def _run_grounding_guard( claims: list[Claim], chunks: list[dict[str, Any]], ) -> list[Claim]: - """Check each claim against its cited chunk via substring match (MVP). + """Check each claim against its cited chunk via content-5-gram match. - A claim is *grounded* if at least one phrase from its text (> 4 words) - appears as a substring in a chunk attributed to the same bibkey, - OR if the claim text shares ≥ 3 consecutive words with any chunk of - that bibkey. + A claim is *grounded* if the **last sentence** before its citation + contains at least one sequence of 5 consecutive non-stopword tokens + (a "content-5-gram") that also appears as 5 consecutive non-stopword + tokens in any chunk attributed to the same bibkey. + + Matching is done in content-word space (stopwords removed from BOTH + claim and chunk), which prevents trivial bypass via common scientific + phrases like "the discrete conformal map" (only 2 content words). + + Using sentence-level granularity prevents a hallucinated paragraph from + being grounded by a single matching phrase at its end. Sets ``claim.grounded = False`` for any claim that fails this check. """ - # Build a bibkey → [content] index + # Build a bibkey → [content-word sequence] index + # We store the content-word list (joined as string for fast substring search) bib_index: dict[str, list[str]] = {} for chunk in chunks: bk = str(chunk.get("bibkey") or "") if bk: - bib_index.setdefault(bk, []).append(chunk["content"].lower()) + # Content words of the chunk (stopwords removed, lowercased) + cw = " ".join(_content_words(chunk["content"])) + bib_index.setdefault(bk, []).append(cw) for claim in claims: sources = bib_index.get(claim.bibkey) @@ -264,16 +305,22 @@ def _run_grounding_guard( claim.grounded = False continue - # Try to find any n-gram overlap (n ≥ 3 words) - words = claim.text.lower().split() + # Grounding operates on the LAST SENTENCE only + sentence = _last_sentence(claim.text) + content = _content_words(sentence) + + if len(content) < 5: + # Too short for a content-5-gram → cannot be grounded. + # A claim sentence with fewer than 5 non-stopword tokens provides + # insufficient signal and is marked ungrounded by default. + claim.grounded = False + continue + found = False - for n in range(min(len(words), 6), 2, -1): # try 6-grams down to 3-grams - for i in range(len(words) - n + 1): - phrase = " ".join(words[i : i + n]) - if any(phrase in src for src in sources): - found = True - break - if found: + for i in range(len(content) - 4): # content-5-grams + gram = " ".join(content[i : i + 5]) + if any(gram in src for src in sources): + found = True break claim.grounded = found diff --git a/tests/wiki/test_compile.py b/tests/wiki/test_compile.py index debde9d..02da74f 100644 --- a/tests/wiki/test_compile.py +++ b/tests/wiki/test_compile.py @@ -170,6 +170,51 @@ def test_grounding_guard_ungrounded_when_bibkey_missing() -> None: assert result[0].grounded is False +def test_grounding_guard_adversarial_stopword_bypass() -> None: + """Adversarial: stopword-3-gram matches are NOT sufficient for grounding. + + 'the discrete conformal map' appears in the chunk, but this is a stopword + phrase. The preceding hallucinated sentences contain NO content-5-gram + present in the chunk → grounded=False. + """ + # The adversarial text from the session-prompt review finding: + adversarial_text = ( + "The Riemann hypothesis is proven by combining Hodge theory with quantum entanglement. " + "Furthermore P=NP holds for hyperbolic tetrahedra. The discrete conformal map" + ) + claim = Claim( + text=adversarial_text, + bibkey="Springborn2008", + locator="chunk 0", + ) + result = _run_grounding_guard([claim], FIXTURE_CHUNKS) + assert result[0].grounded is False, ( + "Stopword 3-gram 'the discrete conformal map' must NOT ground a hallucinated claim" + ) + + +def test_grounding_guard_last_sentence_grounded_earlier_hallucination_ignored() -> None: + """Only the last sentence is checked for grounding. + + If the last sentence IS grounded, the earlier hallucinated sentence is + irrelevant (it has no citation → it won't be parsed as a Claim at all). + This test verifies that a claim whose text = the last sentence grounds correctly. + """ + # The last sentence of the claim text is directly grounded in the fixture chunk. + grounded_sentence = ( + "the hyperbolic volume is V = L(gamma1) + L(gamma2) + L(gamma3)" + ) + claim = Claim( + text=grounded_sentence, + bibkey="Springborn2008", + locator="chunk 16", + ) + result = _run_grounding_guard([claim], FIXTURE_CHUNKS) + assert result[0].grounded is True, ( + "Last sentence with content-5-gram present in chunk must be grounded" + ) + + # --------------------------------------------------------------------------- # _inject_cross_refs # ---------------------------------------------------------------------------