Spike: does the LLM endpoint honour constrained decoding (json_schema / grammar)? #281
Closed
opened 2026-08-05 19:46:56 +00:00 by claude-bot
·
4 comments
No Branch/Tag specified
main
feat/1328-fill-it-in
feat/1328-fill-frontend
feat/1328-fill-backend
feat/9-foundry-push
fix/9-foundry-live
feat/9-foundry-push-frontend
renovate/celery-5.x
feat/5-foundry-connect
feat/9-foundry-push-backend
feat/9-foundry-push-module
feat/5-foundry-frontend
feat/5-foundry-backend
feat/5-foundry-module
release/v4.8.0-rc1
docs/d341-convert-draft
feat/958-update-to-version
feat/958-convert-sheet
feat/958-pathbuilder-import-frontend
fix/1360-heading-order
feat/958-pathbuilder-import-backend
feat/11-weekly-digest
feat/1341-auto-update
docs/d332-superseded
chore/1345-names-cleanup
feat/1342-mentioned-pages
feat/1328-recipes
fix/1346-pgdg-key
perf/1247-shared-recording-stream
chore/132-gitleaks-allowlist
release/v4.7.1
spike/foundry-module
release/v4.7.1-rc1
docs/decisions-d332-d337
fix/bot-certifi
chore/release-4.7.0
chore/release-4.7.0-rc4
chore/deps-4.7.0
chore/release-4.7.0-rc3
fix/1280-quote-must-say-it
fix/1257-names-row-squeeze
chore/release-4.7.0-rc2
fix/1245-pg-upgrade-count
chore/release-4.7.0-rc1
feat/1257-names-in-prep
feat/1257-be
feat/1257-fe
feat/1257-rating-fixes
design/1328-fill-it-in
feat/1257-name-splits
fix/1326-redis-503
feat/1280-fact-suggestions
feat/1280-be
feat/1280-fe
design/1280-fact-suggestions
feat/1279-infobox
feat/1279-be
feat/1279-fe
feat/1321-shelf-copies
design/1279-infobox
feat/1243-table-find
feat/1257-who-rated
feat/1243-prep-drawer
feat/1243-sheet-pickers
design/1243-browse-in-place
feat/1315-hotwords-default
feat/1242-system-filters
feat/1257-more-name-styles
feat/506-leg3-hotwords
design/1242-system-filters
fix/1302-packs-polish
feat/1241-rules-library
design/1241-rules-library
feat/1245-postgres-18
integ/4.6.2
fix/1263-map-ui-2
fix/1263-map-server
fix/1263-map-ui-1
fix/1261-editor-link-menu
fix/1229-axe-4.14-name-mismatch
renovate/axllent-mailpit-1.x
renovate/eslint-monorepo
renovate/authlib-1.x
chore/release-4.6.0
chore/release-4.6.0-rc2
fix/460-rc2-screens
fix/460-rc2-server
fix/release-prerelease-exit
chore/release-4.6.0-rc1
feat/125-campaign-templates
feat/126-calendar
feat/1181-pictures-and-files
feat/1190-event-people
integ/4.6.0
feat/1191-line-of-the-night
feat/1200-starter-packs
chore/bot-multidict-and-playwright-group
fix/1202-this-week-answered-count
fix/122-124-leftovers
fix/1178-get-routes-that-write
fix/1203-journeys-check-names
design/125-campaign-templates
feat/126-calendar-engine
design/126-in-world-calendar
fix/1189-cited-stamp-in-range
fix/1189-v4-looser-check
fix/1189-richer-beats
fix/1113-export-bind-lists
fix/1154-release-notes-by-file
chore/release-4.5.0
chore/release-4.5.0-rc12
fix/1135-wiki-markup-as-text
fix/demo-usage-cost-in-window
chore/release-4.5.0-rc11
fix/1121-sheet-consistency
fix/1121-sheet-5e-terms-and-card-hp
copy/1122-busiest-pages
fix/1121-relevant-choices
fix/1121-wiki-create-npc-save-sheet-links
fix/1029-menu-stays-on-screen
docs/prototype-transcript-bin-wording
fix/1023-editor-save-row-back-scroll
fix/1020-search-exact-first
fix/1020-table-prep
fix/1020-shell-wordmark-footer-bell
fix/1020-wiki-article-party
fix/872-admin-kept-until-phone-tables
feat/l13-milestone-level-waiting
fix/1000-1001-bell-older-setup-context
fix/999-pronouns-in-prompts
fix/l19-other-class-feats
fix/1010-transcript-window-ordinary-confirm
docs/d254-d269-decisions
fix/702-member-search
fix/680-transcript-bin
fix/993-unidentified-prompts
fix/841-redis-timeouts
fix/990-binned-not-exported
fix/830-export-prep-shelf
fix/deps-audit-2026-10-01
fix/877-title-reads
fix/798-audio-received-at
build/lane11-17-j7
build/lane11-16-levelup
build/lane11-14-seams
build/lane11-14-seed
build/lane11-15-start
build/lane11-13-old-block-out
fix/980-review-r3
build/lane11-12-block
build/lane11-8-packs
fix/demo-seed-canonical-names
build/lane11-11-hot-half
docs/l11-decisions
build/lane11-7-convert
build/lane11-10-editing
fix/npm-audit-brace-expansion
build/lane11-9-sheet-page
fix/bot-pyjwt
build/lane11-6-content
fix/lane11-rank-names
build/lane11-2b-language
build/lane11-4-js-evaluator
build/lane11-3-server
build/lane11-5a-pf2e
build/lane11-5b-5e
feat/design-admin
build/lane11-2-evaluator
build/lane11-1-sheet-pieces
build/lane11-0-drawing
fix/943-quest-objectives-fk
fix/prepsheet-shelf-flake
fix/945-leftovers
build/demo-gm-copy
fix/901-email-landings
build/lane10-8-journeys
build/demo-seed-lanes-8-10
fix/887-gm-own-confirmation
build/lane9-5-j1-admin
fix/918-audience-click
fix/useasync-overtaken
ci/939-critical-path
test/937-backend-suite-speed
chore/claude-orchestration
fix/924-xp-gm-only
fix/session-stale-poll
docs/decisions-d239-d242
fix/930-pypdf
build/lane8-journeys
build/925-quest-editor
fix/894-private-page-pictures
build/lane8-p4-drafts
build/lane8-p7-map
build/lane8-p6-handouts-rules
fix/wiki-fixture-names
build/lane10-d227-test-usage
fix/rsvp-horizon-test-tuesday
fix/media-volume-owner
build/lane8-p3-editor
fix/912-unidentified-rename
build/lane8-p5-party-quests
build/lane8-handout-takeback
build/lane10-pickers
build/lane8-p2-article
build/lane8-6-map
build/lane10-7b-email-pages
build/lane8-5-handouts
build/lane10-7a-test-the-bot-pages
build/lane8-p1-pages
build/lane8-4-quests
build/lane8-2b-feeds
build/lane8-3-parties
build/lane8-2a-links
build/lane10-5-commands
build/lane9-6-seed
build/lane10-4b-reactions
build/lane9-4d-pages
build/lane9-4c-pages
build/lane10-4a-posts
build/lane10-3-email
fix/891-web-fonts
build/lane9-4b-pages
build/lane10-6-test-the-bot
build/lane10-2-notify-out
build/lane8-1-wiki-text
build/lane9-4a-frame
design/lane10-drawings
fix/j6-budget
fix/j5-phone-flake
fix/869-test-leaks
fix/843-record-start-followups
build/lane7-all
build/lane6-all
build/lane9-b2-server
chore/changelog-fragments
docs/decisions-d218
build/lane9-a-owner-overview
fix/rule-card-level-label
fix/834-retention-semantics
fix/o3c-record-without-announcement
docs/decisions-d179
docs/decisions-d131-d170
fix/851-commit-before-response
build/lane6-j3-seed
build/lane9-b1-d67-identity
fix/841-blocking-loop
build/lane9-c-backups-audit
fix/vote-race-and-start-commit
fix/recording-announcement
fix/proposal-read-leak
fix/last-admin-guard
build/lane6-p4c-planning
build/lane6-p4b-margin
build/lane6-p4a-sheet
build/lane5-j4-seed
build/lane6-base
build/lane7-pages-talk
build/lane5-pages
build/lane7-pages-safety
build/lane7-p3-safety
build/lane7-p2-notify
build/lane6-p3-planning
build/lane6-p2-history
build/lane6-p1b-items
build/lane7-p1-threads
build/lane5-p6-faces
build/lane6-p1a-sheet
build/lane5-p5-find
build/lane5-p4a-board
build/lane5-p4b-scenes
build/lane5-p3-live
build/lane5-base
build/lane5-p2-cards
docs/lane5-decisions
build/review-r2-wide
fix/review-r1
build/lane5-p1-scenes
build/demo-seed
build/lane4-journeys-j5
build/lane4-page-review-b
build/lane4-page-review-a
build/lane4-page-read
build/lane4-journeys
build/lane4-page-sess2
build/lane4-page-sess1
build/lane4-notes
build/lane4-discord-on-approval
build/lane4-scheduling
build/lane4-versions
build/760-session-number
build/lane4-decisions
build/797-erase-no-regenerate
build/test-race-audit
build/lane3-completions
build/794-dark-only
build/796-titles-scroll
build/791-retention-anchor
build/a11y-sweep
build/shell-followups
build/retention-preview
build/753-backup-readback
docs/d91-d93
build/lane3-j1
build/ci-harness-paths-selftest
fix/useauth-test-race
build/ci-harness-paths
build/777-ai-settings-read
build/lane3-settings-c
build/lane3-settings-b
build/lane3-j6
build/lane3-sessionform
build/lane3-settings-a
build/lane3-members
build/lane3-thisweek
build/lane3-story
chore/87-align-python-3.12
v4.8.0-rc1
v4.7.1
v4.7.1-rc1
v4.7.0
v4.7.0-rc4
v4.7.0-rc3
v4.7.0-rc2
v4.7.0-rc1
v4.6.2
v4.6.2-rc4
v4.6.2-rc3
v4.6.2-rc2
v4.6.2-rc1
v4.6.1
v4.6.1-rc1
v4.6.0
v4.6.0-rc2
v4.6.0-rc1
v4.5.3
v4.5.3-rc1
v4.5.2
v4.5.2-rc1
v4.5.1
v4.5.1-rc2
v4.5.1-rc1
v4.5.0
v4.5.0-rc12
v4.5.0-rc11
v4.5.0-rc10
v4.5.0-rc9
v4.5.0-rc8
v4.5.0-rc7
v4.5.0-rc6
v4.5.0-rc5
v4.5.0-rc4
v4.5.0-rc3
v4.5.0-rc2
v4.5.0-rc1
v4.4.1
v4.4.0
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.2
v4.1.1
v4.1.0
v4.0.1
v4.0.0
v3.11.4
v3.11.3
v3.11.2
v3.11.1
v3.11.0
v3.10.0
v3.9.1
v3.9.0
v3.8.0
v3.7.0
v3.6.1
v3.6.0
v3.5.0
v3.4.1
v3.4.0
v3.3.0
v3.2.0
v3.1.0
v3.0.0
v2.1.0
v2.0.0
v1.1.0
v1.0.0
v0.12.0
v0.11.0
v0.10.0
v0.9.1
v0.3.3
v0.9.0
v0.8.0
v0.3.2
v0.3.1
v0.3.0
v0.7.0
v0.6.0
v0.5.0
v0.4.0
v0.2.1
v0.2.0
v0.1.1
v0.1.0
Labels
Clear labels
backend
Issue related to the backend
bot
Issue related to the bot
bug
Something is not working
duplicate
This issue or pull request already exists
enhancement
New feature
frontend
Issue related to the frontend
help wanted
Need some help
invalid
Something is wrong
question
More information is needed
recording
Issue related to the recording system
security
Security hardening / vulnerability work
wontfix
This won't be fixed
No labels
backend
bot
bug
duplicate
enhancement
frontend
help wanted
invalid
question
recording
security
wontfix
Milestone
Clear milestone
No items
No milestone
Projects
Clear projects
No items
No project
Assignees
Clear assignees
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Quest-Board#281
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Follow-up to #279. That issue shipped a tolerant parser — a safety net. This is the spike for the actual cure.
The problem it addresses
generate_structured_textsendsresponse_format: {"type":"json_object"}(webapp/backend/app/services/llm_service.py:174-183), and the endpoint accepts it without enforcing it. Measured against the live llama.cpp router (build b9029 fronting Qwen3.5-9B-UD-Q8_K_XL,--ctx-size 131072 --parallel 2): 6 of 9 prod-shaped calls returnedfinish_reason=stopwith structurally invalid JSON on a ~33k-token prompt. Compact single-line output correlated perfectly with malformation; pretty-printed output always parsed.v3.11.1 recovers from that and logs it. It does not stop it happening.
Questions to answer
response_format: {"type":"json_schema", …}get honoured by this router build? If yes, the whole failure class disappears for the llama.cpp path.grammarparameter work wherejson_schemadoesn't?Measure against real telemetry, not the 9-sample estimate
v3.11.1 added WARNING lines that make this countable for the first time:
Both log response size only, never content. Pull a real rate from the worker logs before and after any change rather than re-running a hand-rolled sample.
Portability constraint
generate_structured_textdispatches to four providers — Anthropic, OpenAI, Ollama, llama.cpp (llm_service.py:82-113). Agrammarparameter is llama.cpp-specific andjson_schemasupport varies. Whatever lands must degrade cleanly on providers that ignore it, which means the tolerant parser from #279 stays regardless — this would demote it from primary defence to safety net, not replace it.Do not regress
Keep
chat_template_kwargs: {"enable_thinking": False}underjson_mode(llm_service.py:183). Removing it whilemax_tokens=2048is set yieldsfinish_reason=lengthwith empty content — the entire budget goes to reasoning tokens. This was measured, and the existing comment documents it.Definition of done
A short written finding: which of
json_schema/grammarthe endpoint actually honours, whether the router is implicated, and a recommendation. Code only if the answer is favourable.Labels: backend
Picking this up. Early findings against the live router (
10.3.0.28:8090,qwen3.5,system_fingerprint b9029-2bacb1eb7— the same build the issue measured).Q1 — is
json_schemahonoured? Yes, and it is genuinely constraining.A schema whose field names no model would invent unprompted:
json_objectjson_schema['headcount_parity', 'nested', 'zzq_designation']That rules out "the model happened to guess the right shape" — the output tracked the schema, including the enum and the nested object. Worth noting the
json_objectcontrol produced malformed JSON on a tiny prompt, so the failure is not purely a large-context effect.Q2 — does an explicit
grammarparameter work? Yes. A hand-written GBNF root rule was obeyed exactly, emitting compact output matching the grammar. So both mechanisms are available;json_schemais the portable one and needs no GBNF authoring.Q3 — router or model? Moot, and that is the good outcome. The router is llama-swap fronting
llama-serveron127.0.0.1:40675(--ctx-size 131072 --parallel 2,Qwen3.5-9B-UD-Q8_K_XL). Since constrained decoding is honoured through the router, there is no router bug to chase — the direct-to-llama-server comparison was only needed ifjson_schemahad failed.Q4 — prompt-size sensitivity: sweep in progress (5k / 15k / 33k tokens, n=6 per cell,
json_objectvsjson_schema, production-shaped lore-proposal schema). Will post the table.On measuring from real telemetry: not currently possible. The prod worker restarted at 23:18 UTC and no lore work has run since, so
structurally invalidandnot parseableare both 0 — the log window has no qualifying calls rather than a clean bill of health. The numbers below are controlled probes, not observed production rate. Re-grepping after a few real sessions is still the right way to confirm any fix.Findings
All probes against
10.3.0.28:8090,qwen3.5,b9029-2bacb1eb7, production-shaped lore-proposal schema,max_tokens=2048unless stated.Q1 / Q2 — both mechanisms are honoured
json_schemagenuinely constrains decoding, proven with a schema whose field names no model would invent (zzq_designation,headcount_parityenum, nestedk7): output conformed exactly, while thejson_objectcontrol on the same prompt returned invalid JSON. An explicit GBNFgrammarwas also obeyed exactly.json_schemais the portable choice and needs no GBNF authoring.Q3 — the router is not implicated
llama-swap fronting
llama-serveron127.0.0.1:40675. Constrained decoding works through the router, so the direct-to-llama-server comparison is unnecessary — it was only needed hadjson_schemafailed.Q4 — prompt size: no reproducible effect
Valid JSON of the expected shape, raw
json.loads, n=6 per cell:json_objectjson_schemaTotals:
json_object24/26,json_schema26/26.The headline caveat: I could not reproduce the 6/9 rate
This issue records 6 of 9 prod-shaped calls returning
finish_reason=stopwith invalid JSON. I measured 2 of 26 (~8%) in the same class — an order of magnitude lower — and adding transcript entropy made it better, not worse (8/8 clean).Both failures I did see were the trivial variant:
{"proposals":[]}plus a stray closing brace, whichrepair_json_objectrecovers cleanly. So on my data the endpoint's misbehaviour is real but mild.I can't explain the gap from here. Candidates: real prompts carry approved-lore context I didn't synthesise; the original sample may have hit a different server state; or n=9 on a ~10% base rate is simply noisy. Treat the 2/3 figure as unconfirmed rather than as a measured production rate — which makes re-grepping the worker logs after a few real sessions the necessary next step, not an optional one.
What I did find:
max_tokenstruncation, andjson_schemadoes not fix itMy completions ran 300–1,200 tokens against a 2,048 cap — never near it. Forcing the cap to 256 on the same prompt:
json_objectfinish_reason=length→ invalid JSONjson_schemafinish_reason=length→ invalid JSONConstrained decoding guarantees the output follows the grammar; it cannot stop generation being cut off mid-structure. Both modes fail identically here.
Worse, this failure is currently silent.
repair_json_object"recovers" each truncated response into 2–3 proposals — dropping everything past the cut — and_structured_llamacppnever inspectsfinish_reasonwhen content is non-empty (llm_service.py:196-212; it only logs it on the empty-content path). A caller receiving 3 proposals cannot tell that from 3-of-12. This is precisely the semantic-incompleteness hazardrepair_json_object's own docstring warns about, reached through a path nothing currently detects.Recommendation
json_schema— proven, free, and it eliminates a class the tolerant parser cannot: wrong shape. Thejson_objectcontrol inventedtavern_name/patron_countfor a schema asking forname/patrons; a schema also enforces the 13-valueentry_typeenum and required fields. Add an optionaljson_schema: dict | Nonetogenerate_structured_textand let each adapter use it if it can — llama.cpp and OpenAI viaresponse_format, Ollama viaformat: <schema>, Anthropic falling back to today's{prefill. Degrades cleanly, per the portability constraint.finish_reason=length— likely the higher-value fix, and independent of everything above. Silently returning a repaired partial set is worse than raising. This wants its own issue.chat_template_kwargs.enable_thinking=Falsewas kept in every probe; no regression observed.Spike result: the endpoint honours constrained decoding — but not through the shape we were sending
Run against the live llama.cpp endpoint (
10.3.0.28:8090,qwen3.5) from the prod worker container. Read-only.The #339 claim was false as shipped. It said that on llama.cpp the schema becomes a decoding grammar so a small local model cannot emit malformed JSON. In production, every
json_schema=call was doing nothing at all.The probe
One adversarial prompt, fighting the schema on three axes at once — asks for prose instead of JSON, asks for a value outside an
enum, and asks for an extra field againstadditionalProperties: false. Only a real grammar can hold all three. Plus an impossible schema (dangling$ref), which distinguishes honoured from silently ignored: a server that compiles the schema must error on one it cannot compile.{"mood": "angry"}— enum honoured, no extra field{"mood": "angry"}{"type":"json_object"}Why
/propson this endpoint reports{"role":"router","max_instances":4,...}. It is not a vanillallama-server— it is a router in front of llama.cpp that implements the OpenAIresponse_format.json_schema.schemacontract, not llama.cpp's native flat one.llama.cpp's README documents the flat form, and #339 took it on that authority. That is right for a vanilla
llama-serverand wrong for what a self-hoster is often actually running. Since we cannot ask which is in front, both spellings are now sent — each server reads the key it knows and ignores the other. Both were verified to enforce, and both to 400 on an impossible schema.The uncomfortable part
Plain
{"type": "json_object"}is not enforced on this endpoint either. It returned prose for the same prompt. The tolerant repair parser (repair_json_object) has been carrying structured output on this deployment the whole time, which is exactly why nobody noticed — the failure was absorbed one layer down.That reframes the v4.0.0 premise a little. "The accuracy floor must not depend on model size" was resting partly on a guarantee that was not actually in force. With the corrected shape it now is, and the adversarial probe is the evidence.
And the process failure
The unit test pinning this shape passed the entire time. It asserted the flat form because that is what the code did, and both came from the same README paragraph. A unit test on a wire format can only ever confirm the belief that produced it.
The replacement names the endpoint the shape was verified against instead of citing documentation, and pins that the two spellings cannot drift apart. The general lesson, worth carrying into #349's eval harness: a contract with an external service needs at least one probe against the real thing, or the test suite is measuring our own assumptions back to us.
Not covered
format: <schema>— unverified, no endpoint to hand. Same class of risk; worth a probe before relying on it.llama-server— not reachable here, so whether it accepts the flat form was not confirmed directly. Sending both makes that moot.Fixed in
3b25451.Verified against the definition of done before closing.
This was a spike, and its deliverable was a finding: both
json_schemaandgrammarare honoured by the live llama.cpp router; the router was not implicated; no reproducible prompt-size effect; andmax_tokenstruncation defeats both modes identically (spun out separately).The finding was acted on — the code landed under #339 — and then the spike caught its own follow-up regression: the shape #339 shipped, taken from llama.cpp's README, was silently ignored by the production router, which implements the OpenAI contract. Verified adversarially with an impossible-schema probe (200 vs 400) and fixed in
3b25451.Current state sends both spellings simultaneously (
llm_service.py:418-447), pinned bytest_llamacpp_sends_the_schema_under_both_spellingsandtest_the_two_llamacpp_spellings_carry_the_same_schema(tests/test_llm_transport.py:232-284). Real callers wired atbeat_service.py:455andaudio_service.py:2567.Closing. Part of a full acceptance-criteria pass across the v4.0.0 milestone.