Skip to content

[Rust Frontend] Replay roundtrip output grammars through XGrammar - #59143

Merged
BugenZhao merged 5 commits into
mainfrom
bz/output-grammar-replay
Sep 30, 2026
Merged

BugenZhao merged 5 commits into
mainfrom
bz/output-grammar-replay

Conversation

@BugenZhao

@BugenZhao BugenZhao commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Purpose

Rust-built output grammars are currently checked only as structural-tag JSON. Nothing compiles them with the real XGrammar or verifies that the matcher accepts a real generation from token zero, which is the meaning of reasoning_ended = Some(true) since #57340.

Step 2.5 of the parser-owned output grammar rollout, following the merged #55269 and #57340. Stacked on #59148, which switches Rust MiMo to the MiMo structural-tag builder in xgrammar-structural-tag 0.4.1; only the last five commits belong to this PR. It adds test coverage only; production behavior is unchanged. The open-PR search found no other XGrammar replay harness for the Rust frontend.

  • The chat roundtrip builds the output grammar from the same initialized processor that parses the completion. It runs with --tool-strict-level parameter so every builder-backed parser emits a grammar; the strict level affects grammar inputs only, not parsing.
  • tool_call_mix runs once per tool-choice variant for every model:
    • auto, with content before the tool calls;
    • required, with tool calls only, the only shape builder grammars admit under required;
    • named get_weather, with a single call and no content.
  • Each model's grammars are snapshotted into one rust/src/chat/tests/grammar_replay/<model>.json via expect_file!. The file records the model, model vocab size, and lowered stop set once. Each variant case records the BuiltOutputGrammar, the completion text, and the generation token IDs ending with a stop token. A stale file for a parser that no longer builds a grammar fails the roundtrip.
  • grammar_replay.py is a PEP 723 uv script pinned to the xgrammar version in requirements/common.txt, with CPU torch. It builds TokenizerInfo and compiles each case the same way as backend_xgrammar.py. For from_token_zero cases it replays every token, checking both the bitmask and accept_token, and requires the stop token to terminate the matcher with override_stop_tokens. final_output_only cases are compiled only. It also requires the HF tokenizer to encode the completion to the recorded IDs, so tokenizer drift fails loudly.
  • grammar_replay.rs wraps the script as a nextest test, so the existing Rust Frontend Cargo CI step runs it. That step already installs uv.
  • BuiltOutputGrammar and GrammarCoverage derive Serialize. serde_json's raw_value is enabled for chat dev-dependencies only, to keep token ID arrays on one line.

Kimi K3 renders request-scoped control messages, such as the tool_choice=required instruction, after the whole history, as the official encoding_k3.py does. The generation prompt is then A + C + G while the closed history is A + G + B + C, so the prompt is no longer a prefix of the full render. The roundtrip now locates the completion B as the unique split of C and G. It falls back to the prefix cut when C is empty, which is the case for every other template. K3's grammar remains final_output_only here; it becomes a token-zero replay case with its native builder in Step 3.

The replay found the MiMo V2.6 grammar mismatch that #59148 fixes. MiMo renders compact <tool_call><function=...> calls, but on main the Rust MiMo parser reuses the Qwen3-Coder builder, whose tags are newline-delimited. Under required the replay rejected the call. Under auto the trigger never fired, so the grammar left the calls unconstrained. With #59148, MiMo runs all three variants, and its auto trigger and tags are compact. #58019 is the Python counterpart.

Test Plan

cd rust
cargo nextest run -p vllm-chat --test roundtrip --test grammar_replay
UPDATE_EXPECT=1 cargo nextest run -p vllm-chat --test roundtrip  # regenerate cases
cargo nextest run -p vllm-parser -p vllm-chat
cargo clippy --workspace --all-targets --all-features -- -D warnings

Negative controls: I copied the cases, mutated them, and ran the script on the copies.

Test Result

  • 12 models, 36 cases, xgrammar-structural-tag 0.4.1 output replayed with XGrammar 0.2.7, the version pinned in requirements/common.txt. XGrammar 0.2.7 ignores the new excludes field. The runner pin moves together with requirements/common.txt.
    • auto, required, and named, replayed from token zero and terminated: Qwen3, Qwen3.5, MiMo V2.6, MiniMax M2.5, DeepSeek V3.2/V4/V4.1, GLM-4.7/5.2, Hy3, and Kimi K2.5.
    • Kimi K3: auto, required, and named, final_output_only, compiled.
  • All five negative controls fail at the expected token:
    • K3 relabeled from_token_zero: rejects think after <|close|>.
    • Qwen3 without the reasoning wrapper: rejects <think> at token 0.
    • DeepSeek V4 with a stop set lacking its terminator: does not terminate.
    • Unclosed final tool call: rejects <|im_end|>.
    • Completion text drift: reports a tokenizer mismatch.
  • The named grammar replayed against the two-call required generation rejects the second call right after the first closes, for Qwen3, DeepSeek V4, and GLM-4.7.
  • A modified case and a stale case each fail the roundtrip.
  • vllm-parser + vllm-chat: 860 tests passed, including a unit test for the completion split. Workspace Clippy, cargo sort, and pre-commit passed.
  • The replay takes about 28 s with warm uv and HF caches, dominated by loading 12 tokenizers. A cold uv environment added about 50 s locally.
  • trust_remote_code=True is needed only for the Kimi K3 tokenizer. It is kept for all cases because production deployments of these models load their tokenizers with it.

AI assistance: implementation and validation assisted by Claude Code.

🤖 Generated with Claude Code

@BugenZhao BugenZhao added the ready ONLY add when PR is ready to merge/full CI is needed label Sep 29, 2026
@BugenZhao
BugenZhao marked this pull request as ready for review September 29, 2026 05:38
@BugenZhao
BugenZhao requested a review from njhill as a code owner September 29, 2026 05:38

@claude claude 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.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@BugenZhao

BugenZhao commented Sep 29, 2026 •

Copy link
Copy Markdown
Member Author

/ci run --allow-stale

@mergify

mergify Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @BugenZhao.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 29, 2026
@BugenZhao
BugenZhao force-pushed the bz/output-grammar-replay branch from 537445f to 2571c36 Compare September 29, 2026 07:26
@BugenZhao
BugenZhao changed the base branch from main to bz/rust-mimo-structural-tag September 29, 2026 07:26
@mergify mergify Bot removed the needs-rebase label Sep 29, 2026
@BugenZhao
BugenZhao added this pull request to stack #59163 September 29, 2026 07:29
@BugenZhao
BugenZhao force-pushed the bz/output-grammar-replay branch from d2f732d to e4e6f7f Compare September 29, 2026 09:51
@mergify

mergify Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @BugenZhao.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 29, 2026
@BugenZhao
BugenZhao force-pushed the bz/output-grammar-replay branch from e4e6f7f to 52c6aa3 Compare September 29, 2026 11:10
@mergify mergify Bot removed the needs-rebase label Sep 29, 2026
Base automatically changed from bz/rust-mimo-structural-tag to main September 30, 2026 01:34
@BugenZhao
BugenZhao force-pushed the bz/output-grammar-replay branch from 52c6aa3 to 58ae5aa Compare September 30, 2026 01:34
@BugenZhao

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92000 for commit 58ae5aa8bfa3.

@mergify

mergify Bot commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Hi @BugenZhao, the pre-commit checks have failed. Please run:

uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-files

Then, commit the changes and push to your branch.

For future commits, pre-commit will run automatically on changed files before each commit.

BugenZhao and others added 2 commits September 30, 2026 06:36
Export the output grammar built by the initialized roundtrip parser, with
the rendered completion as its generation, as checked-in replay cases.
A PEP 723 uv script compiles every case with the real XGrammar and
replays token-zero cases through the matcher, and a nextest wrapper runs
it in the Rust CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Run the roundtrip tool-call fixture once per tool-choice variant: `auto`
with content before the calls, and `required` with tool calls only.
Each model's variants share one replay file with model-level vocab size
and stop set.

Kimi K3 and MiMo V2.6 run `auto` only: the K3 renderer appends a
tool-choice system message after the history, and MiMo renders compact
tool calls that its Qwen3-Coder builder tags do not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
BugenZhao and others added 3 commits September 30, 2026 06:36
Add a named `get_weather` variant to the roundtrip tool-call fixture,
with a single call and no content, and replay its grammar alongside the
`auto` and `required` variants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
With the MiMo structural-tag builder, MiMo's grammar tags match the
compact `<tool_call><function=...>` calls its template renders, so run
every tool-choice variant for MiMo. Regenerate the replay files for the
`excludes` field that `xgrammar-structural-tag` 0.4.0 serializes on JSON
schema formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
…essages

Kimi K3 renders request-scoped control messages, such as the
`tool_choice=required` instruction, after the whole history. The
generation prompt is then `A + C + G` while the closed history is
`A + G + B + C`, so the prompt is no longer a prefix of the full render.
Locate the completion `B` by finding the unique split of `C` and `G`,
falling back to the prefix cut when `C` is empty.

This lets Kimi K3 run the `required` variant, and every model now runs
every tool-choice variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
@BugenZhao
BugenZhao force-pushed the bz/output-grammar-replay branch from 58ae5aa to e6ce31d Compare September 30, 2026 06:37
@BugenZhao

Copy link
Copy Markdown
Member Author

/ci run

@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #92066 for commit e6ce31dce2ec.

@BugenZhao
BugenZhao merged commit 87228cf into main Sep 30, 2026
37 checks passed
@BugenZhao
BugenZhao deleted the bz/output-grammar-replay branch September 30, 2026 07:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

k3 kimi ready ONLY add when PR is ready to merge/full CI is needed rust

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants