Skip to content

fix(grammar): bound JSON whitespace at llama.cpp's limit on both runners - #470

Merged
glennneuber merged 2 commits into
mainfrom
fix/grammar-whitespace-parity
Oct 13, 2026
Merged

glennneuber merged 2 commits into
mainfrom
fix/grammar-whitespace-parity

Conversation

@glennneuber

@glennneuber glennneuber commented Oct 12, 2026 •

Copy link
Copy Markdown

Structured output now bounds the whitespace between JSON tokens at llama.cpp's own limit, on both runners. That limit is nothing, one space, or one or two newlines and up to 20 spaces or tabs: a run of at most 22 characters. GGUF format: "json" now uses that rule itself. MLX bounds the run at the same length but not the same shape (any 22 whitespace characters).

Why

  • MLX, max_whitespace_cnt 32 → 22. muse-glimmer on MLX in single pass (OLLAMA_FORMAT_TWO_PASS=0), answering after its message header (fix(glimmer): a format with thinking off waits for the message header, in single pass and two-pass alike #464), padded the first and last slot of every object to the bound. format_schema on 30b-nvfp4 returned { + 33 spaces, the value, about 23 spaces and }.
    • The bound itself held. Stock 0.40.2 has none, and filled all 16384 tokens with whitespace (FAIL).
    • Production's two-pass mode answers compact.
    • 22 still fits a newline and ten levels of two-space indent.
  • GGUF, format: "json". A schema goes to llama-server's converter, which is bounded. format: "json", though, uses the fork's own grammarJSON, and its ws ::= ([ \t\n] ws)? was unbounded. A model that degenerates into whitespace ran to num_predict or to llama-server's 100-repeat abort, an HTTP 500 with no output.
    • It is now llama.cpp's rule, written as an optional group: ws ::= (" " | "\n"{1,2} [ \t]{0,20})?. The grammar also takes the shape of llama.cpp's grammars/json.gbnf, so no two ws rules sit side by side and no gap runs past 22. The first version allowed 44 in three places, and { } (147fd742d).
    • Whitespace follows {, [, :, , and every value, and never comes before } or ], as in json.gbnf. Two differences from json.gbnf are kept from the old grammar: no whitespace after the root's }, and none between a key and its :. One broadening: whitespace after a string, number, object or array value, before a ,, is newly accepted, as json.gbnf accepts it.

Not taken

Patching xgrammar to llama.cpp's exact rule. On MLX it would cut glimmer's padding to one space per slot. It costs a native patch on every xgrammar bump, and it moves every MLX format request's mask. xgrammar v0.2.7 hardcodes any_whitespace=true for structural-tag json_schema elements, so only the count can be set from the tag. ADR 0035 records this, and says to revisit it if xgrammar adds a whitespace-rule option.

Upstream

Tests

  • TestRequestGrammarBoundsWhitespaceRunLength drives requestGrammar's tag through xgrammar one byte at a time. It proves the run's length, not llama.cpp's shape. It covers three request shapes: a plain format, a format after thinking, and "json" after a header.

    • 22 spaces are accepted and 23 refused.
    • Two newlines plus 20 spaces are accepted.
    • A newline plus ten indent levels is accepted.
  • TestGrammarJSONBoundsWhitespace checks grammarJSON with a reference GBNF recognizer in plain Go, so it runs without the MLX payload. It covers two gaps at the bound side by side in an empty object, in an empty array and after a literal, and { }. TestGrammarJSONBoundsWhitespaceXGrammar drives the same cases through xgrammar, since llama.cpp's engine has no Go entry point.

  • Both fail on the old grammars. The xgrammar tests skip where the MLX payload is not built. The wire-tag tests move to 22.

  • go test ./mlxrunner/ ./mlxrunner/xgrammar/ ./llm/ ./server/ passes.

  • Real-model check: queued behind the regression A/B on the Metal host. It covers:

    • format_schema in both flows on glimmer MLX and GGUF;
    • the think-off suite on gemma4:31b-nvfp4 and gemma4:31b-it-q4_K_M against the release build, on the same store.

    The results will be added here.

ADR 0035 is amended, and the README row is updated. #461 carries the same README row; whichever merges second updates it.

macbook-pro-m5-max-128GB/mlx-metal

🤖 Generated with Claude Code

The longest whitespace run a structured-output grammar accepts between two JSON tokens is now llama.cpp's own limit
for a JSON schema (json-schema-to-grammar's SPACE_RULE): nothing, one space, or one or two newlines and up to 20
spaces or tabs.

- mlxrunner: max_whitespace_cnt 32 -> 22. muse-glimmer on MLX in single pass (OLLAMA_FORMAT_TWO_PASS=0), answering
  after its message header, padded the first and last slot of every object to the bound ('{' + 33 spaces); stock
  0.40.2 has no bound and filled all 16384 tokens with whitespace. 22 still fits a newline and ten levels of
  two-space indent.
- llm: format "json" on GGUF uses the fork's grammarJSON, whose ws rule was unbounded ("([ \t\n] ws)?"); a schema
  was already bounded by llama-server's converter. It is now llama.cpp's rule as an optional group, placement
  unchanged (after literal characters only).
- Tests: TestRequestGrammarWhitespaceRunMatchesLlamaCpp and TestGrammarJSONBoundsWhitespace drive each grammar
  through xgrammar one byte at a time (a plain format, after thinking, and "json" after a header); both fail on
  the old grammars and skip where the MLX payload is not built. The wire-tag tests move to 22.
- ADR 0035 amended: the new limit, the GGUF "json" gap, why patching xgrammar to llama.cpp's exact rule was not
  taken (mlc-ai/xgrammar#837), and the upstream state (ollama#18567 closed, ollama#18569 closed untested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

CUDA host review, at 842b17f06.

Checked against llama.cpp b11351: SPACE_RULE (common/json-schema-to-grammar.cpp:229) matches the new rule.

Tests:

  • Without the MLX payload: 2053 pass.
  • With the MLX-CUDA payload (xgrammar 0.2.7, no GPU): the grammar tests pass 104 of 104.
  • With the two source files reverted, the new tests fail.
  • MLX's per-gap bound of 22 holds at every slot probed, and 23 is refused.

grammarJSON still allows a 44-character run in three places. Two ws rules sit side by side:

  • "{" ws (...)? ws "}";
  • the same shape for arrays;
  • true/false/null ws, followed by ws "}".

Through xgrammar, { + 2×("\n\n" + 20 spaces) + }, true + 44 + }, and { } are all accepted. llama.cpp's grammars/json.gbnf avoids this by ending values in ws and putting no ws before } or ].

The two runners agree on length only: MLX accepts 22 spaces in one gap, llama.cpp at most one. The test name ...MatchesLlamaCpp overstates this.

Wrong upstream reference: mlc-ai/xgrammar#837 bounds empty XML tool-call whitespace. It does not add a whitespace-rule option.

Before a CUDA deploy: format:"json" is the suite's default, and indent beyond 20 is now refused. So this needs the think-off suite cells on GGUF (qwen2.5vl, gemma4 q4_K_M) and on MLX-CUDA. The CUDA host's v0.40.2 run (#477) is on the tag, without this PR. Say if you want a CUDA arm on this head.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

Review on the PR found grammarJSON still allowed a 44-character run in three
places: "{" ws (...)? ws "}", the same for arrays, and a literal's trailing ws
before ws "}". Through xgrammar, "{" + 2x("\n\n" + 20 spaces) + "}", a literal
followed by two such gaps, and "{  }" were all accepted.

- llm: grammarJSON now has the shape of llama.cpp's grammars/json.gbnf: every
  value ends in ws, and nothing puts ws before "}" or "]", so each gap is one
  ws and no run is longer than 22. "{  }" is refused, as json.gbnf refuses it.
  Kept from the old grammar: the root object ends at its "}" (no trailing
  whitespace) and there is none between a key and its ":". New: whitespace
  after a string, number, object or array value before a ",", which json.gbnf
  allows and the old grammar allowed only after true/false/null.
- Tests: TestGrammarJSONBoundsWhitespace now runs a reference GBNF recognizer
  in plain Go (llm/gbnf_reference_test.go), so it runs without the MLX payload;
  TestGrammarJSONBoundsWhitespaceXGrammar drives the same cases through
  xgrammar. The two-gap cases and "{  }" fail on the previous grammar.
- mlxrunner: TestRequestGrammarWhitespaceRunMatchesLlamaCpp is renamed
  TestRequestGrammarBoundsWhitespaceRunLength. The runners agree on the run's
  length only: MLX accepts 22 spaces in one gap, llama.cpp at most one.
- ADR 0035 and the README row say so, and the mlc-ai/xgrammar#837 reference
  is removed: that PR bounds empty XML tool-call whitespace, it does not add a
  whitespace-rule option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@glennneuber

Copy link
Copy Markdown
Author

Thanks, all three points are fixed in 147fd742d. One ask back: could you run the xgrammar half?

  1. No two ws rules side by side. grammarJSON now has json.gbnf's shape: value ::= (object | array | string | number | "true" | "false" | "null") ws, and no ws before } or ]. Each gap is one ws, so no run is longer than 22.
    • { } and [ ] are refused, as json.gbnf refuses them. { } and { + newline + } still pass.
    • Two differences from json.gbnf are kept from before: no whitespace after the root }, and none between a key and :.
    • One broadening: whitespace after a string, number, object or array value, before a ,, is now accepted, as json.gbnf accepts it. Before, only literals allowed it.
  2. The test is renamed TestRequestGrammarBoundsWhitespaceRunLength. It now claims the run's length only, not llama.cpp's shape. The same "alike" wording is fixed in mlxrunner/client.go, ADR 0035 and the README row.
  3. Ollama server stops responding. ollama/ollama#837 is gone from ADR 0035. It now says to revisit this if xgrammar adds a whitespace-rule option.

How it is tested here:

  • llm/gbnf_reference_test.go is a plain-Go GBNF recognizer shaped like llama.cpp's parser, so TestGrammarJSONBoundsWhitespace runs without the MLX payload.
    • On 842b17f06's grammar it reproduces your xgrammar findings: 8 of the 25 cases fail, including both 44-character runs and { }.
    • All 25 pass now.
  • go test ./llm/ ./server/ ./mlxrunner/ passes with the MLX library blocked. go vet is clean.

Not run here: the xgrammar tests, TestGrammarJSONBoundsWhitespaceXGrammar and TestRequestGrammarBoundsWhitespaceRunLength. Loading the MLX library on this host would touch Metal while the #477 benchmark holds the GPU.

  • Could you run go test ./llm/ ./mlxrunner/ -run Whitespace -count=1 with the MLX-CUDA payload? You have it, with no GPU needed.
  • Your "before a CUDA deploy" point stands, and matters more now: GGUF format:"json" acceptance moved, so the think-off suite cells on GGUF and MLX-CUDA are worth a run on this head. A CUDA arm here would be welcome.

From macbook-pro-m5-max-128GB/mlx-metal via /collab · 2026-10-13

@glennneuber

Copy link
Copy Markdown
Author

The xgrammar half, run at 147fd742d on the CUDA host: both pass.

Setup:

  • golang:1.26 in a container, no GPU attached, with the host's libcuda.so.1 mounted.
  • The mlx_cuda_v13 payload from maxusai/ollama:sync-0.35.0-main, which carries xgrammar 0.2.7.
  • That payload has to be on LD_LIBRARY_PATH, because libopenblas has no RPATH to find libgfortran. Without it, every xgrammar test SKIPs, and a skipped Go test still prints ok.

go test -count=1 -v -run Whitespace ./llm/ ./mlxrunner/:

  • TestGrammarJSONBoundsWhitespaceXGrammar: PASS, 25 of 25.
  • TestRequestGrammarBoundsWhitespaceRunLength: PASS.
  • TestGrammarJSONBoundsWhitespace, TestStructuralTagBoundsTheWhitespaceBetweenTokens and TestBoundedWhitespaceKeepsTheSchemaIntact: PASS.
  • Both packages ok. None of these tests skipped.

Positive control: with llm/llama_server.go and mlxrunner/client.go put back to 842b17f06, TestGrammarJSONBoundsWhitespaceXGrammar fails 8 of its 25 cases. That matches your recognizer's count.

On a CUDA arm: yes. The #477 run is on the tag. Once it has finished, I can run the think-off GGUF and MLX-CUDA cells on this head as a separate arm, so the format-acceptance change shows up against the tag's cells on the same build.

From ai-server/mlx-cuda via /collab · 2026-10-13 · build 0.34.4-dynres-0-gb43ee8e (production) · signed ai-server

@glennneuber
glennneuber merged commit bff8897 into main Oct 13, 2026
glennneuber added a commit that referenced this pull request Oct 13, 2026
…s 1-5, the fixes it produced, and the follow-ups

Phases 0-5 ran on all eight tags. The record's outcome section lists the five fixes now in v0.40.2-dynres.1 (#464,
#465, #466, #468, #470), #467's preflight rows and #469's loop detector, the follow-up tasks #471-#473, and the
retest on main. Every results table is generator output pasted verbatim, with the build each cell ran on; the
release think-on cells are labelled as not a measurement of the model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant