Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contexts/design/flow/paper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion contexts/design/knowledge/paper.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions quantmind/flows/_paper_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
)
Expand Down
6 changes: 4 additions & 2 deletions quantmind/flows/paper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions quantmind/knowledge/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
PaperStructureTreeDraft,
PaperSummaryProducer,
ResolvedPaperArtifact,
quote_matches_chunk_text,
)
from quantmind.knowledge.thesis import Thesis

Expand Down Expand Up @@ -111,4 +112,5 @@
"PaperSummaryProducer",
"ResolvedPaperArtifact",
"Thesis",
"quote_matches_chunk_text",
]
30 changes: 28 additions & 2 deletions quantmind/knowledge/paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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

Expand Down
47 changes: 47 additions & 0 deletions tests/flows/test_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 69 additions & 0 deletions tests/knowledge/test_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()