Skip to content

fix(llm): slice oversized PDFs through the converter (#2906) - #2907

Open
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/pdf-slicing-through-converter
Open

fix(llm): slice oversized PDFs through the converter (#2906)#2907
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/pdf-slicing-through-converter

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Fixes #2906.

This is the half #2902 called out and deliberately left undone.

The bug

expand_oversized_files slices oversized documents so the whole file still reaches the model. But it measured candidates with path.read_text() and read_slice_text sliced the same way, while llm._file_to_text routes a PDF through extract_pdf_text. Slicing a PDF would therefore have indexed the container's bytes instead of its text — so PDFs were excluded from slicing entirely, and simply lost everything past _FILE_CHAR_CAP.

One page, real text layer, FlateDecode as every real PDF is:

pdf bytes=3094   extracted chars=55690   cap=20000

before: units=1  chars reaching the LLM=20000  LOST=35690   (64% of the paper)
after:  units=3  chars reaching the LLM=55690  LOST=0

Nothing reports it — no warning, and no _partial_files marker, since that machinery tracks truncated responses rather than truncated inputs. A paper whose second half was cut is indistinguishable in the graph from one whose second half had nothing extractable in it.

A compressed PDF also hides the gap from every obvious check: 3,094 bytes on disk against 55,690 characters of text. Neither the file listing nor the corpus word count shows anything unusual.

The change

unit_source_text — one reader returning the string the prompt will carry, used by both the boundary pass and the slice reader. That is the whole fix: the offsets a FileSlice holds and the string the model is shown are now the same string by construction, rather than by two call sites happening to agree.

Plain text is still read straight off disk; only converted types go through a converter, so nothing changes for the formats that already worked.

is_splittable_text now answers the question slicing actually asks — can this be addressed by character offset? — instead of is this one of five suffixes. Images and code stay out; PDFs come in.

extract_pdf_text is imported lazily from detect, because llm imports file_slice and the reverse direction at import time would be a cycle.

The memo

Slicing asks for the same file once to measure it and then once per slice, and extraction is the expensive part — so the reader memoises on (path, size, mtime_ns). A corpus of papers is parsed once per run rather than once per slice. Keyed on size and mtime so a paper replaced mid-run is re-read rather than sliced against stale text (there is a test), and bounded because the entries are whole documents.

How it sits with the other two

If all three land, the "raw file vs converted text" confusion is gone from the input path. Happy to unify the two memos (_pdf_text_for_estimate in llm and this one) into a single shared reader afterwards — I kept them separate so each PR stands alone on v8.

Tests

tests/test_pdf_slicing.py (13 tests). The fixture builds a real one-page PDF with a genuine text layer and first asserts the precondition — small on disk, large in text — since that shape is why file size never revealed the problem.

Then: the PDF is splittable, an oversized one is sliced, the slices tile the extracted text exactly ("".join(read_slice_text(u)) == unit_source_text(p) — gap-free, no overlap), no slice exceeds the cap, a slice contains Section 0 and not %PDF/endstream (the bug in one assertion), and the tail actually reaches the prompt.

Plus the guards: a small PDF still passes through whole, plain-text slicing is unchanged, images and code are still not sliced, a corrupt PDF does not break the pass, a rewritten PDF is re-read, and repeated reads agree.

Reverting file_slice.py and keeping the tests fails 6 of 13. The unit_source_text import is deliberately guarded with a fallback so the file still collects on the pre-fix tree — otherwise the whole module would error and the count would tell you nothing about which behaviours are pinned.

Validation

Windows 11, Python 3.12, branched off b14b52e (0.9.47).

  • Full suite: 15 failed, 4738 passed -> 15 failed, 4751 passed. Identical failure set — no regressions; the +13 are the new tests.

)

_read_files caps every unit at _FILE_CHAR_CAP (20,000 characters).
expand_oversized_files exists so an oversized document is sliced and still
reaches the model whole -- but it measured candidates with path.read_text() and
read_slice_text sliced the same way, while llm._file_to_text routes a PDF
through extract_pdf_text. Slicing a PDF would therefore have indexed the
container's bytes instead of its text, so PDFs were excluded from slicing
entirely and simply lost everything past 20,000 characters.

That is the half Graphify-Labs#2900/Graphify-Labs#2902 called out and deliberately left alone. Papers are
the longest documents anyone points graphify at, and a compressed PDF gives no
hint of its text length: the fixture here is 3,094 bytes on disk and 55,690
characters of text.

  before: units=1  chars reaching the LLM=20000  LOST=35690   (64% of the paper)
  after:  units=3  chars reaching the LLM=55690  LOST=0

unit_source_text is the fix: one reader returning the string the prompt will
carry, used by BOTH the boundary pass and the slice reader, so the offsets a
FileSlice holds always index the same string the model is shown. Plain text is
still read straight off disk; only converted types go through a converter.

is_splittable_text now answers "can this be addressed by character offset",
which is the question slicing actually asks -- images and code stay out, PDFs
come in.

The reader memoises on (path, size, mtime_ns): slicing asks for the same file
once to measure it and then once per slice, and extraction is the expensive
part, so a corpus of papers is parsed once per run rather than once per slice.
Keying on size and mtime means a paper replaced mid-run is re-read rather than
sliced against stale text. Bounded, because the entries are whole documents.

extract_pdf_text is imported lazily from detect: llm imports file_slice, so the
reverse direction at import time would be a cycle.

Composes with the estimator: a PDF over the cap is now a set of FileSlices, and
_estimate_file_tokens already routes a slice through read_slice_text, so its
token estimate becomes correct here too. A PDF UNDER the cap stays a Path and is
still estimated from its bytes -- that is Graphify-Labs#2903/Graphify-Labs#2904, which is independent and
does not touch this code.
Copilot AI lite review requested due to automatic review settings August 20, 2026 16:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 1 change(s) tested, no difference found (not proven).


Graphify review — findings

Slices oversized PDFs against their extracted text instead of losing everything past the 20,000-character cap. Adds unit_source_text — a single reader (memoised on path/size/mtime) shared by expand_oversized_files and read_slice_text so slice offsets index the same string the prompt carries — and extends is_splittable_text to cover .pdf. Includes tests/test_pdf_slicing.py covering tiling, the cap, corrupt/rewritten PDFs, and unchanged plain-text/image/code behavior.

Worth a look

  • bisect_slice reads PDF bytes instead of extracted text, misaligning slice offsetsgraphify/file_slice.py:208 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • bisect_slice re-reads PDF bytes instead of extracted text, contradicting new slicing contractgraphify/file_slice.py:217 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • Converted PDF extraction exceptions escape oversized expansiongraphify/file_slice.py:178 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • PDF converter failures are not handled during expansiongraphify/file_slice.py:184 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • PDF slices are bisected using raw file bytes instead of converted textgraphify/file_slice.py:208 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 295 functions depend on the 44 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_corpus_parallel() — 26 callers, 10 callees
  • new: dispatch_command() — 2 callers, 117 callees
  • new: _extract_with_adaptive_retry() — 18 callers, 10 callees
  • new: expand_oversized_files() — 20 callers, 4 callees
  • new: _read_files() — 8 callers, 6 callees
  • new: _pack_chunks_by_tokens() — 10 callers, 3 callees
  • new: _bind_node_evidence() — 4 callers, 3 callees
  • …and 1 more — each is listed as a finding

Verification — 295 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 149 function(s) in the blast radius were not formally verified this run

Formal verification

No difference found (not proven): No behavior difference found in expand\_oversized\_files (not a proof).

The verifier ran both versions of expand\_oversized\_files on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify is\_splittable\_text.

The verifier did not have enough to check is\_splittable\_text, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify read\_slice\_text.

The verifier did not have enough to check read\_slice\_text, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `fs` is annotated `FileSlice` — outside the synthesizable primitive/collection set

· 9 more finding(s) on lines outside this diff (see the check run).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An oversized PDF loses everything past 20k characters: slicing reads the container's bytes, so PDFs are excluded from it entirely

2 participants