diff --git a/contexts/design/flow/paper.md b/contexts/design/flow/paper.md index 3f28289..5abf9ef 100644 --- a/contexts/design/flow/paper.md +++ b/contexts/design/flow/paper.md @@ -93,7 +93,7 @@ Code accepts the draft only when: - every chunk index exists; - every cited page is present in that chunk's source spans; -- every supplied quote occurs verbatim in the cited chunk; +- every supplied quote occurs verbatim in the cited chunk, compared under collapsed whitespace so a quote that renders the chunk's hard-wrap newline as a space still counts (words and order must still match); - citation count meets `min_summary_citations`; - distinct cited-page count meets `min_summary_pages`. diff --git a/contexts/design/knowledge/paper.md b/contexts/design/knowledge/paper.md index 7bc7643..eaf1c9a 100644 --- a/contexts/design/knowledge/paper.md +++ b/contexts/design/knowledge/paper.md @@ -75,7 +75,7 @@ Changing any producer field creates a distinct artifact ID. Multiple chunk sets ## Citation and Lineage Integrity -A `PaperCitation` identifies the exact chunk set, chunk, page, and optional verbatim quote. `PaperSemanticResult` rejects citations to missing chunks, pages outside the cited chunk spans, or quotes absent from chunk text. +A `PaperCitation` identifies the exact chunk set, chunk, page, and optional verbatim quote. `PaperSemanticResult` rejects citations to missing chunks, pages outside the cited chunk spans, or quotes absent from chunk text. Quote presence is checked through `quote_matches_chunk_text`, which compares under collapsed whitespace so a faithful quote that renders a chunk's hard-wrap newline as a space still resolves; only quotes whose words or order differ are rejected. `PaperGlobalSummary.derived_from` contains `ArtifactLocator` values. At least one locator must point to its producer's exact input chunk set, with the same source revision and no member ID. The library stores this relationship explicitly so lineage can be checked independently from the summary JSON. diff --git a/quantmind/flows/_paper_summary.py b/quantmind/flows/_paper_summary.py index 4bda58c..0239f1a 100644 --- a/quantmind/flows/_paper_summary.py +++ b/quantmind/flows/_paper_summary.py @@ -26,7 +26,11 @@ from quantmind.configs import PaperSemanticCfg from quantmind.flows._runner import run_with_observability -from quantmind.knowledge import PaperChunkSet, PaperSourceRevision +from quantmind.knowledge import ( + PaperChunkSet, + PaperSourceRevision, + quote_matches_chunk_text, +) _ORCHESTRATION_VERSION = "map-reduce-v1" @@ -204,7 +208,9 @@ def _validate_research_draft( pages = {span.page_number for span in chunk.source_spans} if citation.page_number not in pages: raise ValueError("research finding cites a page outside its chunk") - if finding.quote is not None and finding.quote not in chunk.text: + if finding.quote is not None and not quote_matches_chunk_text( + finding.quote, chunk.text + ): raise ValueError( "research finding quote is not present in its chunk" ) diff --git a/quantmind/flows/paper/__init__.py b/quantmind/flows/paper/__init__.py index f45c618..2381e39 100644 --- a/quantmind/flows/paper/__init__.py +++ b/quantmind/flows/paper/__init__.py @@ -28,8 +28,10 @@ - structure: 17 nodes (13 leaves), root ``"Attention Is All You Need"``. - semantic: 15 pages, 33 chunks. The cited-summary step asserts every research - quote verbatim against its chunk; the sampled models paraphrased, so that step - raised ``ValueError`` and produced no summary line on this run. + quote against its chunk through ``quote_matches_chunk_text``, which compares + under collapsed whitespace so a quote that renders a chunk's hard-wrap newline + as a space still counts as verbatim; only quotes whose words or order differ + are rejected. ``build`` fetches and parses **per call**: the flow binds no source, no library, persists nothing, and retrieves nothing. Persistence (``library``) and retrieval diff --git a/quantmind/knowledge/__init__.py b/quantmind/knowledge/__init__.py index 7bb0002..d00b12a 100644 --- a/quantmind/knowledge/__init__.py +++ b/quantmind/knowledge/__init__.py @@ -60,6 +60,7 @@ PaperStructureTreeDraft, PaperSummaryProducer, ResolvedPaperArtifact, + quote_matches_chunk_text, ) from quantmind.knowledge.thesis import Thesis @@ -111,4 +112,5 @@ "PaperSummaryProducer", "ResolvedPaperArtifact", "Thesis", + "quote_matches_chunk_text", ] diff --git a/quantmind/knowledge/paper.py b/quantmind/knowledge/paper.py index 00fbcd4..03704bc 100644 --- a/quantmind/knowledge/paper.py +++ b/quantmind/knowledge/paper.py @@ -50,6 +50,28 @@ def _text_hash(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() +def quote_matches_chunk_text(quote: str, chunk_text: str) -> bool: + """Return whether ``quote`` is a verbatim substring of ``chunk_text``. + + PDF text extraction keeps the source's hard-wrap newlines inside + ``chunk_text``, so a word pair split across a printed line keeps the line + break between the two words. A faithful model quote reproduces the same + words with that break rendered as an ordinary space. Comparing under + collapsed whitespace treats such a quote as verbatim while still rejecting + any quote whose words, order, or content differ. This is the single rule + for "the quote is present in its chunk" shared by every citation check. + + Args: + quote: Candidate verbatim quote proposed by a model. + chunk_text: The chunk's extracted source text. + + Returns: + True when ``quote`` occurs in ``chunk_text`` ignoring only differences + in runs of whitespace. + """ + return " ".join(quote.split()) in " ".join(chunk_text.split()) + + def _paper_source_id(content_hash: str) -> UUID: return uuid5(NAMESPACE_URL, f"quantmind:paper-source:{content_hash}") @@ -1147,7 +1169,9 @@ def from_draft( raise PaperCitationValidationError( "paper summary citation page is not owned by its chunk" ) - if draft.quote is not None and draft.quote not in chunk.text: + if draft.quote is not None and not quote_matches_chunk_text( + draft.quote, chunk.text + ): raise PaperCitationValidationError( "paper summary citation quote is not present in its chunk" ) @@ -1233,7 +1257,9 @@ def _validate_cross_artifact_links(self) -> "PaperSemanticResult": pages = {span.page_number for span in chunk.source_spans} if citation.page_number not in pages: raise ValueError("paper summary citation page is not in chunk") - if citation.quote and citation.quote not in chunk.text: + if citation.quote and not quote_matches_chunk_text( + citation.quote, chunk.text + ): raise ValueError("paper summary citation quote is not in chunk") return self diff --git a/tests/flows/test_paper.py b/tests/flows/test_paper.py index 9c74562..600bfde 100644 --- a/tests/flows/test_paper.py +++ b/tests/flows/test_paper.py @@ -354,6 +354,53 @@ def test_research_finding_outside_its_group_is_rejected(self) -> None: draft, ) + def test_research_finding_quote_tolerates_line_breaks(self) -> None: + # chunk 0 text: "The Transformer removes recurrence and convolution." + result = build_paper_result() + draft = PaperResearchDraft( + scope_summary="reviewed the first chunk", + findings=( + PaperResearchFindingDraft( + kind="contribution", + claim="the model removes recurrence and convolution", + citation=PaperResearchCitationDraft( + chunk_index=0, + page_number=1, + ), + quote="The Transformer removes recurrence\nand convolution.", + ), + ), + ) + # A faithful quote whose only difference is a line break must pass. + _validate_research_draft( + result.chunk_set, + _ChunkGroup(start=0, count=1), + draft, + ) + + def test_research_finding_quote_absent_from_chunk_is_rejected(self) -> None: + result = build_paper_result() + draft = PaperResearchDraft( + scope_summary="reviewed the first chunk", + findings=( + PaperResearchFindingDraft( + kind="contribution", + claim="a claim with a fabricated quote", + citation=PaperResearchCitationDraft( + chunk_index=0, + page_number=1, + ), + quote="words that never appear in the chunk", + ), + ), + ) + with self.assertRaisesRegex(ValueError, "quote is not present"): + _validate_research_draft( + result.chunk_set, + _ChunkGroup(start=0, count=1), + draft, + ) + def test_worker_and_reducer_output_is_capped(self) -> None: capped = _summary_model_settings( PaperSemanticCfg( diff --git a/tests/knowledge/test_paper.py b/tests/knowledge/test_paper.py index 2d4233b..fadca74 100644 --- a/tests/knowledge/test_paper.py +++ b/tests/knowledge/test_paper.py @@ -7,9 +7,14 @@ from quantmind.knowledge import ( PaperCitation, + PaperCitationDraft, + PaperCitationValidationError, + PaperGlobalSummary, PaperSemanticResult, PaperSourceRevision, PaperSourceSpan, + PaperSummaryProducer, + quote_matches_chunk_text, ) from quantmind.knowledge.paper import ( _paper_chunk_id, @@ -168,5 +173,69 @@ def test_result_rejects_chunk_spans_outside_source_manifest(self) -> None: ) +class QuoteVerbatimTests(unittest.TestCase): + """The verbatim-quote rule tolerates only whitespace differences.""" + + def test_helper_treats_line_break_as_space(self) -> None: + chunk_text = "for statistical\nmachine translation." + self.assertTrue( + quote_matches_chunk_text( + "for statistical machine translation.", chunk_text + ) + ) + + def test_helper_rejects_changed_words_and_order(self) -> None: + chunk_text = "The Transformer removes recurrence and convolution." + self.assertFalse( + quote_matches_chunk_text("removes recursion", chunk_text) + ) + self.assertFalse( + quote_matches_chunk_text("convolution and recurrence", chunk_text) + ) + + def _summary_with_quote(self, quote: str | None) -> PaperGlobalSummary: + result = build_paper_result() + chunk_set = result.chunk_set + producer = PaperSummaryProducer( + model="fake-summary", + prompt_version="test-v1", + input_chunk_set_id=chunk_set.id, + instructions_hash="0" * 64, + max_output_tokens=512, + research_group_size=8, + ) + return PaperGlobalSummary.from_draft( + chunk_set, + producer=producer, + summary="A cited summary of the paper's contribution.", + citations=( + PaperCitationDraft( + chunk_index=0, + page_number=chunk_set.chunks[0].source_spans[0].page_number, + quote=quote, + ), + ), + min_citations=1, + min_pages=1, + ) + + def test_from_draft_accepts_whitespace_variant_quote(self) -> None: + # chunk 0 text: "The Transformer removes recurrence and convolution." + summary = self._summary_with_quote( + "The Transformer removes recurrence\nand convolution." + ) + self.assertEqual(len(summary.citations), 1) + self.assertEqual( + summary.citations[0].quote, + "The Transformer removes recurrence\nand convolution.", + ) + + def test_from_draft_rejects_non_matching_quote(self) -> None: + with self.assertRaisesRegex( + PaperCitationValidationError, "quote is not present" + ): + self._summary_with_quote("a quote that is simply not in the chunk") + + if __name__ == "__main__": unittest.main()