Skip to content

feat: support string exclusions in JSON schema structural tags - #912

Merged
Seven-Streams merged 6 commits into
mlc-ai:mainfrom
Seven-Streams:split901/4-json-schema-excludes
Sep 21, 2026
Merged

Seven-Streams merged 6 commits into
mlc-ai:mainfrom
Seven-Streams:split901/4-json-schema-excludes

Conversation

@Seven-Streams

@Seven-Streams Seven-Streams commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

feat: support string exclusions in JSON schema structural tags

Extracted from #901 by @izhuhaoran, fourth piece after #906, #909 and #910. The branch keeps the original commit and its revert at the bottom for attribution; the net diff against main is the feat: support string exclusions in JSON schema structural tags commit plus feat: drop string length constraints when exclusions are present, a refactor of the exclusion helpers, and feat: apply exclusions only to strings without pattern or format. The Kimi-K3 builtin wiring that turns this on by default is the last piece and comes separately.

Motivation

Kimi-K3 tool arguments are raw text. The model can emit control markers such as <|close|>response<|sep|> inside an argument that is still open, then start another tools/call block. The schema grammar accepts all of that as string content, while the tool-call parser reads it as structure. There is no way today to tell the converter that certain substrings must never appear inside a string value.

API

JSONSchemaFormat gets an optional excludes: list[str] (C++, Python, TypeScript, docs):

JSONSchemaFormat(json_schema=..., style="kimi_k3_xml", excludes=["<|open|>", "<|close|>", "<|sep|>"])
  • Forbids each substring in string values and property names, including nested JSON strings. It applies to the strings without pattern or format: a pattern or format is the schema's own contract for the string and is matched as is. Filtering those would intersect an arbitrary regex with the exclusion automaton, which reproduces the length blowup below for any pattern with a wide repetition (^[\s\S]{0,256}$ gives 4592 rules).
  • minLength/maxLength are dropped when exclusions are set, with a warning naming the string and the bounds. Bounding a filtered string's length would multiply the grammar by the bound: one rule per position and exclusion state, about 18 rules per character for the three Kimi-K3 markers, which is 18 s and 111 MiB for maxLength: 256 and 110 s for ten maxLength: 128 properties. Without the bounds those schemas compile in 96 ms and 71 ms, as the unbounded strings do.
  • Matches the emitted text before JSON/XML unescaping, may span tokens, and never spans separate strings. JSON delimiter quotes, numbers, punctuation and XML parameter wrappers are outside the scope, so <|close|> can still begin a valid <|close|>argument<|sep|> terminator.
  • const/enum alternatives and fixed property names that contain an excluded substring are dropped; a required one makes the schema unsatisfiable rather than silently allowed.
  • An omitted or empty list changes nothing: every exclusion code path is behind !excludes_.empty().
  • Errors instead of silent bypass: XML styles reject exclusions that start or end with formatting whitespace, and cohere_xml and minimax_m3_xml reject non-empty exclusions.

Implementation

ExcludingString builds the FSM of the converter's own string body (the JSON string content with escapes, or the XML identifier), intersects it with the complement automaton of the excluded substrings (Intersect with the budgets from #906), prunes states that can no longer reach an accepting state so no dead-end prefix is ever offered in a mask, and re-emits the result as EBNF rules with codepoint transitions. UTF-8 byte paths are collapsed back to codepoint classes and never turned into a second transition for an ASCII character, which relies on the canonical encodings from #909. The closing JSON quote is appended after the intersection because it is syntax, not content. Property names go through the same intersection for additionalProperties keys and through a substring check for fixed keys; keys constrained by patternProperties/propertyNames patterns are matched as is like patterned values. A length-bounded string with exclusions uses the shared string rule (json) or the raw TagDispatch body (XML styles) instead of an intersected length bound.

Validation

  • tests/python/test_json_schema_excludes.py: 91 cases covering every style, nested JSON, property names and literals, Unicode and overlapping exclusions, emitted-text semantics, patterned and formatted strings left unfiltered, dropped length bounds and their warning, whitespace-boundary and unsupported-style errors, compiler cache isolation, and token-mask replays that reject the original Kimi-K3 bad output at response while accepting the repaired output and a valid argument end.
  • xgrammar_test 82/82. Full tests/python with HF_TOKEN: 4947 passed, 3 skipped; the two CUDA kernel tests that JIT-build a torch extension fail identically on main in this environment (CUDA 11.1 toolkit).
  • Rebased onto main after Bugfix: DeepSeek XML parameter string attribute binding #908: the DeepSeek key exclusion check now applies to both deepseek_xml and deepseek_v4_1_xml, which share the raw-key path.

izhuhaoran and others added 3 commits September 20, 2026 19:26
Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
This reverts commit d62c120.

The change is re-applied in separate commits so that the FSM state-limit
fix, the Unicode character class fix, the regular-grammar flattener, the
excludes feature, and the Kimi-K3 wiring can be reviewed independently.
Kimi-K3 can emit structural control markers inside a tool argument's
string value. The schema grammar accepts them as text while the
tool-call parser treats them as structure: after a raw string argument,
the model can emit <|close|>response<|sep|> and open another tools/call
block, and the grammar swallows all of it as part of the first string
until a later <|close|>argument<|sep|>.

Add optional JSONSchemaFormat.excludes to reserve substrings within
string values and property names, including nested JSON:

    JSONSchemaFormat(
        json_schema={...},
        style="kimi_k3_xml",
        excludes=["<|open|>", "<|close|>", "<|sep|>"],
    )

This is a structural-tag format option, not a JSON Schema keyword. It
matches emitted text before JSON/XML unescaping, including matches
split across tokens. It does not filter token IDs or join separate
strings. An omitted or empty list applies no substring filter.

Existing string constraints (pattern, format, minLength/maxLength, the
default string body, property-name tries) stay in effect: each one is
lowered to an FSM, intersected with the exclusion automaton, pruned of
paths that can never complete, and written back as codepoint character
classes. Conflicting const/enum alternatives and fixed keys are removed.
JSON delimiter quotes and XML wrappers are outside the exclusion scope,
so <|close|> can still begin a valid <|close|>argument<|sep|>
terminator but cannot continue with "response" as string content.

Non-empty exclusions currently reject cohere_xml and minimax_m3_xml;
XML styles also reject exclusions starting or ending with formatting
whitespace. Compilation fails explicitly if constraints cannot be
lowered safely or exceed construction budgets.

The Kimi-K3 builtin is wired to this option in a follow-up commit.

Extracted from mlc-ai#901.
Intersecting an unrolled minLength/maxLength bound with the exclusion
automaton emits one rule per position and automaton state (about 18 rules
per character for three markers; 18 s and 111 MiB for maxLength 256). With
excludes, a string keeps only the exclusions: the JSON string rule, or the
raw TagDispatch body for the XML styles. A warning names the string and the
dropped bounds. pattern and format are unchanged.
@Seven-Streams
Seven-Streams force-pushed the split901/4-json-schema-excludes branch from c96b791 to 0fcc895 Compare September 21, 2026 16:22
- Unsatisfiable() replaces the eight copies of the reject-everything class.
- TryRegexFSM holds the regex frontend choice once, for RegexExpression
  and ExcludingString.
- RegexString picks the filtered or plain regex string, so GenerateString
  has one call per constraint instead of an excludes fork.
- The JSON string body regex is one constant; IsAllowedJSONLiteral parses
  and checks literals for const and enum; the XML string generator handles
  the dropped length bound in one place; the warning prints both bounds.

Printed grammars are unchanged for every style with and without excludes.
A pattern or format is the schema's own contract for the string and is
matched as is. Filtering it intersected an arbitrary regex with the
exclusion automaton, which reproduces the length blowup for any pattern
with a wide repetition (^[\s\S]{0,256}$ gives 4592 rules) and needed the
CFG-expansion fallback. ExcludingString now only handles the converter's
own string bodies: the JSON string content and the XML identifier.
@Seven-Streams
Seven-Streams force-pushed the split901/4-json-schema-excludes branch from 0fcc895 to 26ada27 Compare September 21, 2026 17:08
@Seven-Streams
Seven-Streams marked this pull request as ready for review September 21, 2026 18:26
Copilot AI lite review requested due to automatic review settings September 21, 2026 18:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Seven-Streams
Seven-Streams merged commit b2b860b into mlc-ai:main Sep 21, 2026
51 checks passed
Seven-Streams added a commit to Seven-Streams/xgrammar that referenced this pull request Sep 21, 2026
…ing rules

Per mlc-ai#912, excludes apply to the string values without pattern/format and
to property names; minLength/maxLength are dropped with a warning and
const/enum alternatives containing an excluded substring are removed.
Say so in the structural tag docs, the JSONSchemaFormat docstring and the
Kimi-K3 builtin docstrings, which still claimed every string value.
Seven-Streams added a commit that referenced this pull request Sep 21, 2026
Extracted from #901 by @izhuhaoran, last piece after #906, #909, #910
and #912. The branch keeps the original commit and its revert at the
bottom for attribution; the net diff against `main` is the
`feat(kimi_k3): exclude control markers from tool-argument strings`
commit plus a docs commit that states the exclusion rules of #912 as a
list.

## What changes

The Kimi-K3 builtin now passes `excludes=["<|open|>", "<|cloase|>",
"<|sep|>"]` to the `JSONSchemaFormat` of every tool-argument schema, in
`auto`, `required` and forced tool modes. A control marker inside a raw
string argument is rejected at the mask instead of being swallowed as
text, which is the original #901 bad case: the model emitted
`<|close|>response<|sep|>` inside an open `response` argument and
started another tools/call block. The existing
`exclude_special_tokens=False` option disables this together with the
free-text exclusions; the free-text reasoning/response scope is
unchanged.

Per #912, the exclusions apply to argument strings without
`pattern`/`format` and to property names, nested JSON strings included;
`minLength`/`maxLength` on such strings are dropped with a warning.
Argument and call wrappers are outside the scope, so
`<|close|>argument<|sep|>` still terminates a value.

`web/src/structural_tag.ts` gains the `kimi_k3_xml` member of the
`JSONSchemaFormat.style` union, missed when the style was introduced in
#806.

## Validation

- `tests/python/test_builtin_structural_tag.py`: the instance that
accepted a complete `<|close|>response<|sep|>` inside a string argument
now expects rejection (`malformed-argument-end-in-string`);
`test_kimi_k3_exclude_special_tokens` checks the three exclusions on
every tool-argument schema across the three tool-choice modes and the
opt-out; `test_kimi_k3_tool_argument_exclusions` covers raw and nested
JSON string values with and without the option, including complete valid
argument and call endings.
- `test_builtin_structural_tag`, `test_json_schema_excludes`,
`test_structural_tag_converter`: 1733 passed. The C++ tree is identical
to `main`. Full `tests/python` with `HF_TOKEN`: 4946 passed, 3 skipped;
the only failures are the CUDA kernel tests that JIT-build a torch
extension, which fail identically on `main` in this environment (CUDA
11.1 toolkit).

---------

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Co-authored-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Seven-Streams added a commit that referenced this pull request Sep 24, 2026
)

## Summary

- Add a `"gemma"` JSON-schema style that emits Gemma 4's tool-call
argument spelling: unquoted object keys and strings delimited by the
single `<|"|>` token with no escape sequences, at every nesting level.
- Re-enable the `gemma_4` builtin structural tag (auto / required /
forced tool choice, `<|channel>thought` reasoning prefix,
`parallel_tool_calls`), which has been unregistered with a "we will
support it later" TODO since #609.
- Validate against the released Gemma-4 chat templates in the alignment
test, plus token-level matcher tests.

Closes #587. Continues #684 by @iwanhae, which was closed unmerged after
the converter was rewritten to build grammar trees directly (#745) and
the branch fell into conflict; the original implementation and
authorship are preserved through `Co-authored-by`.

## Why

Engines currently fall back to unconstrained decoding for Gemma 4 tool
calls because the existing converters cannot express `<|"|>`-delimited
strings. This blocks `tool_choice="required"` and
JSON-schema-constrained arguments for Gemma 4 in SGLang and vLLM
(vllm-project/vllm#53363, sgl-project/sglang users on #684).

## What changed since #684

Ported to the current `int32_t`-based converter API and reworked around
`JSONSchemaFormat.excludes` (#912):

- **The delimiter is registered as an exclusion.**
`GemmaToolCallingConverter` appends `<|"|>` to `excludes_`, so:
- `pattern` / `format` / `minLength`-`maxLength` string bodies are
intersected with "does not contain `<|"|>`" through `ExcludingString`. A
permissive pattern such as `.*` can no longer close the string early —
this was the documented limitation of #684. (Built-in format regexes the
FSM engine cannot build keep the CFG expansion, as in the JSON
converter.)
- `const` / `enum` literals that would have to spell the delimiter
become unsatisfiable instead of emitting a string that parses
differently.
- Declared property names are excluded from `additionalProperties` keys
(the original PR fell back to the plain identifier pattern).
- Caller-provided `JSONSchemaFormat.excludes` work for `style="gemma"`
too.
- **Two small virtual hooks on `JSONSchemaConverter`**:
`CreatePatternKeyRule` (replaces the local lambda in `GenerateObject`)
and `CreatePropertyNamesKeyRule`. Formats with unquoted keys override
them to emit bare key bodies for `patternProperties` / `propertyNames`;
the JSON and XML converters keep their behaviour unchanged.
- **Length-bounded strings** are built as `(.|\n){m,n}` intersected with
the exclusion, so they count codepoints and allow lone `<` / delimiter
prefixes without hand-written lookahead rules.
- The alignment test renders the vendored Gemma-4 templates with
`jinja2` under the `GEMMA:` model-id prefix (no HF token or
Gemma-4-capable `transformers` needed), reusing the tokenizer
prompt-diff path; a token-gated drift guard checks the vendored
templates against `google/gemma-4-E2B-it` / `google/gemma-4-31B-it`.
- Docs (`structural_tag.md`, `tool_calling_and_reasoning.md`), the
TypeScript `JSONSchemaFormat.style` union, and the style error message
are updated.

## Test plan

- [x] `tests/python/test_function_calling_converter.py -k gemma` (59
cases: strings, scalars, nesting, `any`, enum/const incl. large-int
literal preservation, `additionalProperties`, `patternProperties`,
`propertyNames`, length bounds incl. multi-byte and newline, `.*`
pattern cannot emit the delimiter, `format: date`, `excludes`)
- [x] `tests/python/test_builtin_structural_tag.py` (612 passed incl.
the shared `get_*_structural_tag` matrix, a single-token `<|"|>` /
`<|tool_call>` tokenizer walk, and the auto-dispatch regression)
- [x] `tests/python/test_builtin_structural_tag_alignment.py -k gemma`
(34 passed: e2b + 31b templates × thinking on/off × tool scenarios, plus
the drift guard against the official templates with a HF token)
- [x] Full `test_function_calling_converter.py`,
`test_structural_tag_converter.py`, `test_json_schema_excludes.py`,
`test_grammar_matcher_json_schema.py`, `test_json_schema_converter.py`:
2933 passed (`-m "not hf_token_required"`)
- [x] `pre-commit run` (black, isort, clang-format 19.1.7, taplo) and
`ruff check` pass

## Known limitations

- A `patternProperties` / `propertyNames` key pattern whose language
contains `:` cannot round-trip, because Gemma keys are unquoted (same as
#684).
- Numbers nested inside `const` objects/arrays are re-serialized through
picojson; top-level scalar literals keep the schema's own text.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: yuchuan <yuchuan.7streams@gmail.com>
Co-authored-by: iwanhae <iwanhae@gmail.com>
Co-authored-by: wanhae.lee <wanhae.lee@linecorp.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: yuchuan <yuchuan.7streams@gmail.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.

3 participants