fix(retain): bound and recover degenerate fact-extraction output - #3657
Open
kamilfurtak wants to merge 7 commits into
Open
fix(retain): bound and recover degenerate fact-extraction output#3657kamilfurtak wants to merge 7 commits into
kamilfurtak wants to merge 7 commits into
Conversation
This was referenced Aug 19, 2026
The fixed 16-fact bound was tuned against a small local model with a 4096-token
retain ceiling. Deployments with a larger ceiling would be pushed into constant
splitting, since 16 facts is a small fraction of what their budget allows.
Derive the bound from `retain_max_completion_tokens` instead, using an approximate
per-fact output cost and halving the budget so the item bound bites before the
token limit rather than at the same point. A conservative floor still applies when
no ceiling is configured.
budget 4096 -> 24 facts
budget 65536 -> 385 facts
unset -> 16 facts (floor)
The recursive split gathered sub-chunk results without return_exceptions, so a single unsplittable half discarded the facts the other half had already extracted, and the failure propagated to the whole document. Losing good data is exactly the failure mode this path exists to prevent. Collect sub-chunk results tolerantly: salvage what succeeded, log the partial failure at error level with counts, and only re-raise when the split produced nothing at all.
kamilfurtak
force-pushed
the
agent/retain-saturation-and-recovery
branch
from
August 20, 2026 12:33
aeadffd to
fbdafc8
Compare
Contributor
Author
|
Rebased onto current
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bounds and recovery for degenerate fact-extraction output. Depends on #3656 — see
below.
The failure
Against a local OpenAI-compatible backend, the same 2376-token prompt produced
16 384 output tokens with
finish_reason=lengthon five attempts and completednormally in 554 tokens on the sixth. Input size, sampling parameters and chunk
size were all ruled out: the model stochastically degenerates into repeating
structurally valid fact objects, and nothing in the schema tells the grammar to
stop. This repo already documents the same conclusion for the split floor:
1. Bound the facts array, and never accept the bound as success
The response schema gains a
maxItemsbound so a capable structured-outputbackend stops the array itself. Reaching the bound is not treated as a
result — it raises
OutputTooLongErrorand enters the existing split path, so thebound can never silently truncate a genuinely dense chunk. In the worst case a
chunk is split unnecessarily; no facts are lost.
The bound is derived from
retain_max_completion_tokensrather than fixed:An earlier revision hard-coded 16, which was tuned against a 4096-token ceiling. At
that ceiling a fact object costs roughly 85 output tokens, so a bound of 48 would
have landed exactly where the token limit already bites and achieved nothing.
Halving the budget keeps the item bound biting first. Field data behind the floor,
from 168 single-chunk extractions: median 2 facts, p95 10, max 29.
2. Fail instead of dropping, but keep what succeeded
When a chunk cannot be reduced further, the splitter previously returned an empty
result that the caller counted as a successful extraction — the operation reported
completedwith the sub-chunk's data gone. It now raises.The recursive split also gathered sub-chunk results without
return_exceptions, soone unsplittable half discarded facts the other half had already extracted. Results
are now collected tolerantly:
error level with counts
The remaining trade-off is deliberate: a partially-failed chunk still completes,
with the loss visible in logs rather than silent at the operation level.
Depends on #3656
That PR is what makes splits succeed for conversation payloads wrapped in an extra
array. This PR increases how often the split path is entered and makes the
unsplittable case fatal, so merging it alone would turn recoverable cases into hard
failures.
Tests
Verified end-to-end on a document that previously lost its content: saturation at
the bound →
OutputTooLongError→ recursive split (2747 → 1824 + 922 → …) →8/2/7/11 facts extracted, every call
finish_reason=stop, no dropped sub-chunk.Merge order
Both PRs are open at once and do not conflict in source. Merging #3656 first leaves
exactly one conflict here, in
tests/test_fact_extraction_retry.py, where bothbranches append new test functions at the end of the file — resolution is "keep
both". Happy to rebase once #3656 lands.