Skip to content

fix(llm): estimate PDF tokens from the extracted text, not the container (#2903) - #2904

Open
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/pdf-token-estimate-uses-extracted-text
Open

fix(llm): estimate PDF tokens from the extracted text, not the container (#2903)#2904
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/pdf-token-estimate-uses-extracted-text

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Fixes #2903.

The bug

_read_files sends a PDF through _file_to_textextract_pdf_text. _estimate_file_tokens read the file with read_text and tokenised that — so it measured a compressed binary, because every real PDF Flate-compresses its content streams.

One 400-line fixture, identical text, stored two ways:

uncompressed             bytes=28470  est=5559  actual=4598  actual/est=0.83x
FlateDecode (real PDFs)  bytes= 1728  est=1334  actual=4599  actual/est=3.45x

est is _estimate_file_tokens; actual is the tokenised output of _read_files — the prompt that really gets sent. The only difference between the rows is the container, and the estimate moves by 4x.

_pack_chunks_by_tokens sizes chunks from that number, so a 3.45x undercount overfills the chunk, the request blows the context window, and _extract_with_adaptive_retry bisects — paying for the same content two, four or eight times before it fits. On a corpus of papers, which is what a PDF implies.

Nothing looks broken. No wrong output, no error the user sees; the run just costs several times what it should. The bisection machinery works exactly as designed and quietly absorbs the mis-estimate, which is why this can sit there indefinitely.

After the fix, both encodings estimate 4582 against 4598/4599 actual — 1.00x, the remaining gap being the wrapper-overhead constant.

Scope

PDFs only. Every other path still measures its own bytes, because for them read_text is what the prompt carries.

The tokenizer-absent fallback gets the same correction: it used stat().st_size, which for a PDF is the compressed size and is wrong in the same direction, so the fallback no longer disagrees with the tiktoken path about what a PDF costs.

The memo

Estimating a PDF now means extracting its text, and packing probes the same file repeatedly while deciding where a chunk ends — so _pdf_text_for_estimate memoises on (path, size, mtime_ns). A corpus of papers is parsed once per run rather than once per probe.

Keyed on size and mtime rather than path alone so a file rewritten mid-run is re-read instead of served a stale estimate (there is a test for that), and bounded so a large corpus cannot pin every paper's text in memory.

Tests

tests/test_pdf_token_estimate.py (10 tests). The fixtures build real one-page PDFs with a genuine text layer, one raw and one FlateDecode, and first assert the precondition — that both hold identical text and the compressed one really is a fraction of the size — so any estimate gap is the bug rather than the fixture.

Then: the estimate tracks the prompt it will build for both encodings, compression does not change the estimate (the tell — it used to differ by 4x purely because one was compressed), the estimate is not derived from file size, repeated probes agree, a rewritten PDF is re-estimated, a corrupt PDF estimates 0 rather than raising, non-PDF estimates are untouched, and the _FILE_CHAR_CAP truncation still bounds the result.

Reverting llm.py and keeping the tests fails 3 of 10.

One of those tests caught me while writing it: my first version grew the fixture from 400 to 800 lines to prove the memo re-reads, and it failed — both sizes are past _FILE_CHAR_CAP, so they legitimately estimate the same and the test proved nothing. It now stays under the cap on purpose, and says so.

Validation

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

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

Related

Same class as #2900/#2902: the estimate and slicing paths read the raw file while the prompt path goes through _file_to_text. That PR notes PDFs still need the converter threaded through read_slice_text; this fixes the independent half in token estimation, which costs money even where slicing never applies. The two do not overlap in code.

…ner (Graphify-Labs#2903)

_read_files sends a PDF through _file_to_text -> extract_pdf_text, but
_estimate_file_tokens read the file with read_text and tokenised THAT. A PDF's
bytes are not its text: every real PDF Flate-compresses its content streams, so
the estimate measured a compressed binary.

One 400-line fixture, identical text, stored two ways:

  uncompressed             bytes=28470  est=5559  actual=4598  actual/est=0.83x
  FlateDecode (real PDFs)  bytes= 1728  est=1334  actual=4599  actual/est=3.45x

_pack_chunks_by_tokens sizes chunks from that estimate, so a 3.45x undercount
overfills the chunk. The request then blows the model's context window and
_extract_with_adaptive_retry bisects, paying for the same content two, four or
eight times before it fits -- on a corpus of papers, which is exactly the corpus
PDFs imply. Nothing looks broken from outside; it just costs several times what
it should and takes correspondingly longer.

After: both encodings estimate 4582 against 4598/4599 actual -- 1.00x, with the
remaining gap being the wrapper overhead constant.

Scoped to PDFs. Every other path still measures its own bytes, because for them
read_text IS what the prompt carries. The tokenizer-absent branch keeps its
size-based heuristic for non-PDFs and now uses the extracted length for PDFs,
so the fallback stops being wrong in the same direction.

_pdf_text_for_estimate memoises on (path, size, mtime_ns): packing probes the
same file repeatedly while deciding where a chunk ends, and extraction is the
expensive part, so a corpus of papers is parsed once per run rather than once per
probe. Keying on size and mtime means a file rewritten mid-run is re-read rather
than served a stale estimate, and the cache is bounded so a large corpus cannot
pin every paper's text in memory.

This is the same class as Graphify-Labs#2900: the estimate/slicing paths read the raw file
while the prompt path goes through _file_to_text. That PR noted PDFs still need
the converter threaded through read_slice_text; this fixes the independent half
in token estimation, which costs money even when slicing never applies.
Copilot AI lite review requested due to automatic review settings August 20, 2026 15:24

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 1 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Fixes PDF token estimation in _estimate_file_tokens, which read a PDF's compressed bytes via read_text and undercounted by ~3.5x, causing _pack_chunks_by_tokens to overfill chunks and trigger adaptive bisection (#2903). Now estimates PDFs from their extracted text via a new _pdf_text_for_estimate helper memoised on (path, size, mtime) with a 512-entry bound. Adds tests/test_pdf_token_estimate.py covering estimate/actual parity, compression-invariance, cache freshness on rewrite, and the char cap.

Worth a look

  • PDF estimate cache stores uncapped extracted textgraphify/llm.py:1906 · 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 — 732 functions depend on the 184 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 46 callers, 14 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: extract_corpus_parallel() — 26 callers, 10 callees
  • new: dispatch_command() — 2 callers, 117 callees
  • new: _call_llm() — 11 callers, 18 callees
  • new: _call_openai_compat() — 23 callers, 8 callees
  • …and 16 more — each is listed as a finding

Verification — 732 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: 463 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_estimate\_file\_tokens.

The verifier did not have enough to check \_estimate\_file\_tokens, 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 `unit` is annotated `'Path | FileSlice'` — outside the synthesizable primitive/collection set

· 1 grounded finding(s) anchored inline below; 23 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/llm.py
return text


def _estimate_file_tokens(unit: "Path | FileSlice") -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_estimate_file_tokens()

15 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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.

PDF token estimates are computed from the compressed container, not the extracted text — chunks overfill by ~3.5x and get bisected

2 participants