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
5 changes: 3 additions & 2 deletions 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, comparing both sides with whitespace removed (extracting a multi-column PDF injects column-gap padding and line-break hyphenation into the chunk text, so a faithful quote is rarely a byte-exact substring; a paraphrase still fails);
- citation count meets `min_summary_citations`;
- distinct cited-page count meets `min_summary_pages`.

Expand Down Expand Up @@ -121,7 +121,8 @@ Bounding is delegated to the Agents SDK (per-agent `max_tokens`, structured `out
- Fetching, parsing, or missing parser assets raise their source error and produce no result.
- Empty chunk output is invalid.
- Invalid or insufficient summary citations raise `PaperCitationValidationError`.
- A research finding that cites outside its assigned group is rejected in code; a reducer timeout raises `PaperSummaryError`.
- A research finding that cites outside its assigned group, or a page its cited chunk does not own, is rejected in code; a reducer timeout raises `PaperSummaryError`.
- A research finding whose quote its chunk does not support keeps its claim, chunk, and page and loses only the quote, so one paraphrased quote in one chunk group cannot discard a whole build. Coverage still fails loudly through `min_summary_citations` and `min_summary_pages`.
- Any canonical identity, content hash, membership, lineage, or cross-artifact mismatch fails Pydantic validation.

No failure is converted into a partially valid `PaperSemanticResult`. Callers may retry with the same source and producer settings; stable IDs make successful repeated runs idempotent.
Expand Down
38 changes: 30 additions & 8 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 @@ -194,8 +198,25 @@ def _validate_research_draft(
chunk_set: PaperChunkSet,
group: _ChunkGroup,
draft: PaperResearchDraft,
) -> None:
) -> PaperResearchDraft:
"""Reject fabricated coordinates and drop quotes the chunk cannot support.

A chunk index outside the assigned group, or a page the cited chunk does
not own, is fabricated structure and raises. An unsupported quote is only
a paraphrase of real evidence: the finding keeps its claim, chunk, and
page, and the quote alone is dropped, so one loose quote in one group
cannot discard a whole paper build.

Args:
chunk_set: Chunk set the group was drawn from.
group: Chunk range this subagent was assigned.
draft: Draft returned by the subagent.

Returns:
The draft with every surviving quote supported by its chunk.
"""
allowed = set(range(group.start, group.start + group.count))
findings: list[PaperResearchFindingDraft] = []
for finding in draft.findings:
citation = finding.citation
if citation.chunk_index not in allowed:
Expand All @@ -204,10 +225,12 @@ 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:
raise ValueError(
"research finding quote is not present in its chunk"
)
if finding.quote is not None and not quote_matches_chunk_text(
finding.quote, chunk.text
):
finding = finding.model_copy(update={"quote": None})
findings.append(finding)
return draft.model_copy(update={"findings": tuple(findings)})


def _reduce_payload(
Expand Down Expand Up @@ -309,8 +332,7 @@ async def study(group: _ChunkGroup) -> PaperResearchDraft:
extra_run_hooks=[],
)
draft = PaperResearchDraft.model_validate(output)
_validate_research_draft(chunk_set, group, draft)
return draft
return _validate_research_draft(chunk_set, group, draft)

reports = await asyncio.gather(*(study(group) for group in groups))

Expand Down
10 changes: 7 additions & 3 deletions quantmind/flows/paper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,13 @@
Real run — arXiv ``1706.03762v7``, model ``gpt-5.6-luna`` (2026-07-24):

- 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.
- semantic: 15 pages, 33 chunks. The cited-summary step matches every research
quote against its chunk with whitespace removed on both sides, so a quote
copied faithfully off a multi-column page still validates even though
extraction padded it with column gaps and line-break hyphenation; a quote
the chunk cannot support is dropped and its finding keeps chunk and page.
(A byte-exact check instead failed on every sampled two-column paper, and a
single paraphrase discarded the whole build.)

``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 @@ -84,6 +84,28 @@ class PaperCitationValidationError(ValueError):
"""A generated summary did not provide valid source coverage."""


def quote_matches_chunk_text(quote: str, text: str) -> bool:
"""Return whether ``quote`` occurs in ``text`` ignoring whitespace runs.

Extracting a multi-column PDF interleaves column-gap padding and
line-break hyphenation into the chunk text, so a quote copied faithfully
off the rendered page is rarely a byte-exact substring of it: the chunk
may hold ``"from pre-"`` at one line end and ``"reconstitution tra"`` on
the next, where the model wrote ``"pre-reconstitution trading"``.
Comparing both sides with every whitespace character removed keeps a
faithful quote valid while a paraphrase or an invented sentence still
fails.

Args:
quote: Quote a model proposed for a citation.
text: Chunk text the quote must have come from.

Returns:
True when the quote occurs in the text ignoring whitespace.
"""
return "".join(quote.split()) in "".join(text.split())


@dataclass(frozen=True)
class PaperSourceFacts:
"""Code-owned source facts normalized by the flow before construction.
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
11 changes: 10 additions & 1 deletion scripts/verify_pdf_rag_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,16 @@ def _summary_has_required_coverage(summary: str) -> bool:
"multi-head attention",
"multihead attention",
),
("translation", "training efficiency", "training time"),
# The machine-translation result, however the model names it:
# a faithful summary may report the benchmark ("BLEU", "WMT")
# without ever writing the word "translation".
(
"translation",
"training efficiency",
"training time",
"bleu",
"wmt",
),
)
)
and attention_only
Expand Down
88 changes: 88 additions & 0 deletions tests/flows/test_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,33 @@ def test_unknown_chunk_page_and_quote_are_rejected(self) -> None:
cfg,
)

def test_summary_quote_tolerates_extraction_whitespace(self) -> None:
result = build_paper_result()
chunk = result.chunk_set.chunks[0]
page = min(span.page_number for span in chunk.source_spans)
respaced = "\n ".join(chunk.text.split()[:6])
draft = PaperSummaryDraft(
summary="a summary citing one respaced quote",
citations=(
PaperSummaryCitationDraft(
chunk_index=0,
page_number=page,
quote=respaced,
),
),
)

summary = _build_summary(
result.chunk_set,
draft,
PaperSemanticCfg(
min_summary_citations=1,
min_summary_pages=1,
),
)

self.assertEqual(summary.citations[0].quote, respaced)

def test_configured_citation_and_page_coverage_is_enforced(self) -> None:
result = build_paper_result()
draft = PaperSummaryDraft(
Expand Down Expand Up @@ -354,6 +381,67 @@ def test_research_finding_outside_its_group_is_rejected(self) -> None:
draft,
)

def test_research_quote_tolerates_extraction_whitespace(self) -> None:
result = build_paper_result()
chunk = result.chunk_set.chunks[0]
page = min(span.page_number for span in chunk.source_spans)
draft = PaperResearchDraft(
scope_summary="reviewed the first chunk only",
findings=(
PaperResearchFindingDraft(
kind="result",
claim="a quote respaced the way a PDF column gap does",
citation=PaperResearchCitationDraft(
chunk_index=0,
page_number=page,
),
quote="\n ".join(chunk.text.split()[:6]),
),
),
)

checked = _validate_research_draft(
result.chunk_set,
_ChunkGroup(start=0, count=1),
draft,
)

self.assertEqual(
checked.findings[0].quote,
"\n ".join(chunk.text.split()[:6]),
)

def test_research_quote_absent_from_its_chunk_is_dropped(self) -> None:
result = build_paper_result()
chunk = result.chunk_set.chunks[0]
page = min(span.page_number for span in chunk.source_spans)
draft = PaperResearchDraft(
scope_summary="reviewed the first chunk only",
findings=(
PaperResearchFindingDraft(
kind="result",
claim="a quote the chunk never contained",
citation=PaperResearchCitationDraft(
chunk_index=0,
page_number=page,
),
quote="the paper never wrote this sentence",
),
),
)

checked = _validate_research_draft(
result.chunk_set,
_ChunkGroup(start=0, count=1),
draft,
)

self.assertIsNone(checked.findings[0].quote)
self.assertEqual(
checked.findings[0].claim,
"a quote the chunk never contained",
)

def test_worker_and_reducer_output_is_capped(self) -> None:
capped = _summary_model_settings(
PaperSemanticCfg(
Expand Down
28 changes: 28 additions & 0 deletions tests/knowledge/test_paper.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
PaperSemanticResult,
PaperSourceRevision,
PaperSourceSpan,
quote_matches_chunk_text,
)
from quantmind.knowledge.paper import (
_paper_chunk_id,
Expand Down Expand Up @@ -168,5 +169,32 @@ def test_result_rejects_chunk_spans_outside_source_manifest(self) -> None:
)


class QuoteMatchingTests(unittest.TestCase):
"""A citation quote survives extraction noise but not paraphrase."""

def test_column_gaps_and_line_break_hyphenation_still_match(self) -> None:
extracted = (
"We show similar results in Fig 3 but for savings\n"
"This gives us the cost savings from pre-\n"
" reconstitution trading."
)
self.assertTrue(
quote_matches_chunk_text(
"This gives us the cost savings from pre-reconstitution "
"trading.",
extracted,
)
)

def test_paraphrase_is_still_rejected(self) -> None:
self.assertFalse(
quote_matches_chunk_text(
"The authors report savings from trading early.",
"This gives us the cost savings from pre-reconstitution "
"trading.",
)
)


if __name__ == "__main__":
unittest.main()