feat(llm): bound generation length, not only the wait (#422) - #439
Merged
Conversation
Extraction is bimodal: a generation either finishes fast or never terminates. Every successful run in the measured sweep finished under 60s (slowest 56.2s), while 4 of 60 hit a 180s deadline and returned nothing. Raising chat_timeout converts a 3-minute loss into a 10-minute one; nothing governed how much the model may emit. - Add max_generation_tokens (default 8192), forwarded as options.num_predict at the OllamaClient.chat seam so every chat verb is bounded, not just extraction. When unset, options is omitted entirely and the request is byte-identical to before. - Add OllamaGenerationCapped(OllamaError), raised when the response reports done_reason == length. A truncated reply is unusable -- extract_json_items returns [] on mid-JSON truncation -- so raising keeps the failure loud instead of degrading to a silent zero-object result. Subclassing OllamaError means every existing handler already covers it: extraction records the source as failed exactly as a hung call does today. - Reject Ollama's num_predict sentinels (0, -1, -2) in config validation. -1 means unlimited and would silently disable the rail this change installs. - Wire both OllamaClient construction sites, pinned by an AST drift guard mirroring the existing chat_timeout one. The default is calibrated, not guessed: five extraction calls through the project's own prompt against qwen3:8b on 17KB sources produced eval_count of 4154, 1624, 962, 269 and 107, all done_reason stop. 8192 is ~2x the largest legitimate reply. It is a safety rail, not a quality-tuning knob. Closes #422
This was referenced Aug 6, 2026
jasonssdev
added a commit
that referenced
this pull request
Aug 9, 2026
`openspec/changes/` held three folders containing only `exploration.md`: `bound-generation-length` (#422), `reach-same-verdicts` (#427) and `surface-merged-body-contradictions` (#409). All three issues shipped and closed WITHOUT an SDD cycle -- #422 via PR #439, #427 via PR #438, #409 via PRs #442/#443 -- so these are abandoned exploration notes, not parked work. They are deleted rather than archived: every one of the 30+ entries under `openspec/changes/archive/` is a completed SDD cycle carrying proposal/design/tasks/specs plus an archive report, and none holds a single file. Moving exploration-only stubs there would redefine the archive from "finished cycle" to "folder I stopped using". Git history keeps the notes if they are ever wanted. `openspec/changes/` now holds only `archive/`.
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.
Closes #422.
Unblocks the last open criterion of #379, the P0 validation gate: bounded cost cannot be measured honestly while a fraction of calls never return.
The problem
Extraction is bimodal — a generation either finishes fast or never terminates. Every successful run in the measured sweep finished under 60s (slowest 56.2s); 4 of 60 hit a 180s deadline and returned nothing.
chat_timeoutgoverns how long we wait; nothing governed how much the model may emit. So raising the deadline converts a 3-minute loss into a 10-minute one.The fix
max_generation_tokens(default 8192), forwarded asoptions.num_predictat theOllamaClient.chatseam — the client seam, not per-verb, socurate,query,adjudicate,suggest-relationsandcontradictionsare bounded too. Per-verb placement would repeat the exact drift defect #405'schat_timeoutwork was written to close.When the knob is unset,
optionsis omitted entirely and the request is byte-identical to before.Why it raises instead of reporting
This is the design decision worth reading.
extract_json_itemsreturns[]on mid-JSON truncation — every fallback candidate requires a closing delimiter (parsing.py:23-40), so a truncated reply falls through toreturn []at:95. That[]is structurally identical to "the model found nothing", andExtractionReport(produced=0, retained=0)"renders no notice" per its own docstring.So a bare cap would have traded today's loud failure (600s →
OllamaUnavailable→extraction_status: failed) for a silent one (fast →[]→ nothing said). That is precisely the defect #381, #404 and #409 were each filed about.Instead,
chat()readsdone_reason— present in every response and currently discarded atollama.py:442— and raises a newOllamaGenerationCapped(OllamaError)on"length". Because it subclassesOllamaError, every existing handler already covers it:extract_concept's docstring (concept.py:542) states anyOllamaError-family exception propagates, and the CLI catchesOllamaErrorfor its Source-only fallback. No Protocol change, no stub churn, no client state, no heuristic.An alternative that widened
LLMBackend.chatfrom-> strwas rejected: the Protocol is implemented structurally by every test double.The default is calibrated, not guessed
Five extraction calls through the project's own
_build_messages/_SYSTEM_PROMPTagainst localqwen3:8bon 17 KB real prose sources:eval_countdone_reasondocs/architecture.mdstopdocs/knowledge-object-model.mdstopdocs/user-journey.mdstopdocs/architecture.md(repeat)stopdocs/testing.mdstop8192 is ~2x the largest legitimate completed reply. It is documented as a safety rail, not a quality-tuning knob — the distinction matters, and the codebase already has both kinds (
_MAX_CANDIDATE_GROUPS = 50is a rail;_MAX_OBJECTS_PER_SOURCEis a quality ceiling whose under-calibration at 5 discarded real material in 12/14 runs).The same run reproduced #422's bimodality at the generation layer:
docs/architecture.mdproduced 4154 tokens on one call and 269 on another — same document, same model, same prompt, a 15x spread.Ollama's sentinels (
0,-1,-2) are rejected in config validation.-1means "unlimited" and would silently disable the rail.Verified against a real backend
The unit tests inject
urlopen, so they prove what the client sends — not that Ollama honoursnum_predict. That gap was closed outside the suite against localqwen3:8b: a 16-token ceiling raisedOllamaGenerationCapped, an 8192 ceiling did not, and an unconfigured client did not.Review
Lineage
review-f1c74eacac07e412, risk high (14 files, 687 lines) → canonical 4-lens review. 0 BLOCKER, 0 CRITICAL. All gatesallow.Known, accepted, and recorded rather than hidden:
None. Unreachable from the CLI, whereConfig.max_generation_tokensis a non-optional int with a default. Follow-up.resolution/adjudication.pywas observed but lies outside this candidate. Follow-up.