Skip to content

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

Open
izhuhaoran wants to merge 1 commit into
mlc-ai:mainfrom
izhuhaoran:feat/json-schema-excludes
Open

izhuhaoran wants to merge 1 commit into
mlc-ai:mainfrom
izhuhaoran:feat/json-schema-excludes

Conversation

@izhuhaoran

Copy link
Copy Markdown
Contributor

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.

This is the original detokenized badcase with prose redacted, the tool renamed to reply, and line breaks added for readability. The control-marker order, argument names, and repeated index="1" are preserved:

<|open|>think<|sep|>[reasoning redacted]<|close|>think<|sep|>
<|open|>response<|sep|>[intro redacted]<|close|>response<|sep|>
<|open|>tools<|sep|>
<|open|>call tool="reply" index="1"<|sep|>
<|open|>argument key="response" type="string"<|sep|>[answer redacted]
<|close|>response<|sep|>
<|open|>tools<|sep|>
<|open|>call tool="reply" index="1"<|sep|>
<|open|>argument key="response" type="string"<|sep|>[follow-up redacted]<|close|>argument<|sep|>
<|open|>argument key="task_progress" type="string"<|sep|>[progress redacted]<|close|>argument<|sep|>
<|close|>call<|sep|>
<|close|>tools<|sep|>
<|close|>message<|sep|>[EOS]

Immediately after [answer redacted], the first response argument is still open. The model emits <|close|>response<|sep|> where it should complete <|close|>argument<|sep|>, then starts another tools/call block before closing the original one. This is not a missing JSON quote: K3 string arguments are raw text. Without exclusions, the grammar can swallow the unexpected markers and repeated call/argument opening as part of the first string, until the later <|close|>argument<|sep|>.

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

JSONSchemaFormat(
    json_schema={
        "type": "object",
        "properties": {"message": {"type": "string"}},
        "required": ["message"],
        "additionalProperties": False,
    },
    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 remain in effect. JSON delimiter quotes and XML wrappers are outside the exclusion scope: <|close|> can still begin a valid <|close|>argument<|sep|> terminator, but cannot continue with response as string content. Tokens spanning a value's end and its enclosing suffix remain valid.

The Kimi-K3 builtin now supplies these three exclusions for every tool-argument schema in auto, required, and forced tool modes. The existing exclude_special_tokens=False option disables this filter. Free-text reasoning/response exclusions retain their current scope.

Compiler changes preserve existing string constraints and fix Unicode range/empty-language handling needed to prevent excluded branches from accepting multibyte or overlong UTF-8 input.

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

Validation:

  • JSON-schema, structural-tag, Unicode, serialization, and native regression tests; repository formatting/lint checks and TypeScript type checking.
  • K3 builtin tests cover default exclusions and opt-out, all three tool-choice modes, raw and nested JSON strings, and complete valid argument/call endings.
  • Token-mask replay rejects the original bad output at response, while accepting both the repaired output and the subsequent valid model response. A synthetic vocabulary also verifies split markers and tokens crossing valid end boundaries; mask and accept_token results agree.

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
@izhuhaoran
izhuhaoran force-pushed the feat/json-schema-excludes branch from a781a05 to 13d3432 Compare September 17, 2026 15:53
Seven-Streams added a commit to Seven-Streams/xgrammar that referenced this pull request Sep 20, 2026
ToDFA only rejected inputs that already had more than max_num_states
states and never bounded the subset construction itself, so a small NFA
could still expand into an unbounded DFA. Intersect determinized both
operands with ToDFA's default limit of 1000 states instead of the
max_result_num_states it was given, and never bounded the product
automaton either.

Check the DFA state count at every closure insertion, pass the caller's
budget through Intersect to ToDFA, and bound the product. Callers that
pass a budget now get an error instead of unbounded construction, and
intersecting automata with more than 1000 states works when the caller
allows it, which JSONSchemaFormat.excludes needs for long bounded
strings.

Extracted from mlc-ai#901.
@Seven-Streams

Copy link
Copy Markdown
Member

@izhuhaoran Thanks for your contributions! I'll split the pr for further review.

Seven-Streams added a commit that referenced this pull request Sep 20, 2026
Extracted from #901 by @izhuhaoran. This is the first piece of that PR
split into independently reviewable units. The branch keeps the original
commit and a revert of it at the bottom for attribution, so the net diff
against `main` is the last commit only (`cpp/fsm.cc`,
`tests/cpp/test_fsm.cc`).

`FSMWithStartEnd::ToDFA(max_num_states)` only rejected an input that
already had more than `max_num_states` states. The subset construction
itself was never bounded, so a small NFA could still expand into an
arbitrarily large DFA and the budget was effectively a no-op.
`Intersect(lhs, rhs, max_result_num_states)` had two related gaps: it
determinized both operands with `ToDFA()`, i.e. the default limit of
1000 states rather than the budget it was given, and it never bounded
the product automaton either.

## Changes

- `ToDFA`: check the DFA state count before every new closure is
inserted (character-range, rule-ref, repeat-ref, token and exclude-token
edges) and return an error once it reaches `max_num_states`.
- `Intersect`: pass `max_result_num_states` through to both `ToDFA`
calls, and return an error once the product automaton reaches the limit.

For callers this means a budget is now actually enforced (an error
instead of unbounded construction), and intersecting automata with more
than 1000 states works when the caller allows it. `Not` and
`MinimizeDFA` go through `ToDFA` and pick up the same bound. Nothing on
`main` outside the tests reaches these functions today; the
string-exclusion feature in #901 is the first production caller and
needs the larger budget for strings with `maxLength` of roughly 1000 or
more.

## Validation

- Three new tests in `tests/cpp/test_fsm.cc`:
- `a{1001}` ∩ `a*` succeeds with a budget of 4096 and accepts exactly
1001 `a`s; the same call with a budget of 1000 fails.
- An NFA whose determinization needs 64 subset states fails under a
budget of 16 and succeeds under 64 with the expected language.
- A 2-cycle ∩ 3-cycle product fails under a budget of 4 and succeeds
under 6.
- `xgrammar_test`: 77/77 pass, including all 15 `XGrammarFSMTest` cases.

---------

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Co-authored-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Seven-Streams added a commit to Seven-Streams/xgrammar that referenced this pull request Sep 20, 2026
Extracted from mlc-ai#901 by @izhuhaoran. This is the first piece of that PR
split into independently reviewable units. The branch keeps the original
commit and a revert of it at the bottom for attribution, so the net diff
against `main` is the last commit only (`cpp/fsm.cc`,
`tests/cpp/test_fsm.cc`).

`FSMWithStartEnd::ToDFA(max_num_states)` only rejected an input that
already had more than `max_num_states` states. The subset construction
itself was never bounded, so a small NFA could still expand into an
arbitrarily large DFA and the budget was effectively a no-op.
`Intersect(lhs, rhs, max_result_num_states)` had two related gaps: it
determinized both operands with `ToDFA()`, i.e. the default limit of
1000 states rather than the budget it was given, and it never bounded
the product automaton either.

## Changes

- `ToDFA`: check the DFA state count before every new closure is
inserted (character-range, rule-ref, repeat-ref, token and exclude-token
edges) and return an error once it reaches `max_num_states`.
- `Intersect`: pass `max_result_num_states` through to both `ToDFA`
calls, and return an error once the product automaton reaches the limit.

For callers this means a budget is now actually enforced (an error
instead of unbounded construction), and intersecting automata with more
than 1000 states works when the caller allows it. `Not` and
`MinimizeDFA` go through `ToDFA` and pick up the same bound. Nothing on
`main` outside the tests reaches these functions today; the
string-exclusion feature in mlc-ai#901 is the first production caller and
needs the larger budget for strings with `maxLength` of roughly 1000 or
more.

## Validation

- Three new tests in `tests/cpp/test_fsm.cc`:
- `a{1001}` ∩ `a*` succeeds with a budget of 4096 and accepts exactly
1001 `a`s; the same call with a budget of 1000 fails.
- An NFA whose determinization needs 64 subset states fails under a
budget of 16 and succeeds under 64 with the expected language.
- A 2-cycle ∩ 3-cycle product fails under a budget of 4 and succeeds
under 6.
- `xgrammar_test`: 77/77 pass, including all 15 `XGrammarFSMTest` cases.

---------

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Co-authored-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
@izhuhaoran

Copy link
Copy Markdown
Contributor Author

@izhuhaoran Thanks for your contributions! I'll split the pr for further review.

@Seven-Streams Thanks for helping split this up! Let me know if any changes are needed on my side.

Seven-Streams added a commit that referenced this pull request Sep 20, 2026
Extracted from #901 by @izhuhaoran, second piece after #906. The branch
keeps the original commit and its revert at the bottom for attribution;
the net diff against `main` is the `fix(fsm): preserve Unicode character
class ranges` commit. Same change as the closed #904, rebased.

Character-class FSMs mishandled Unicode in three places:

- Negated classes truncated codepoint endpoints to bytes, complemented
over an ASCII bitset, and then accepted every multi-byte sequence.
`[^\u0000-\U0010FFFF]` accepted all non-ASCII characters and non-ASCII
endpoints were dropped with a "clamping to 127" warning (#848).
- Positive ranges crossing UTF-8 widths were encoded in one packed-byte
call, which admits overlong encodings such as `C0 80`.
- The three-byte upper-tail path used lower bound `0x80` instead of
`0x8080`, so `[一-龥]` rejected every character with lead byte `E9` (`道`,
`部`, `金`, ...).

Fix: complement negated classes in codepoint space, split every range at
the UTF-8 width boundaries so only canonical encodings are accepted, and
fix the tail bound.

FSM snapshots grow because canonical UTF-8 needs more states. The
`hf_token_required` token-mask goldens change in two ways, verified by
diffing per-position masks against `main`: JSON string positions reject
exactly 5 more single-byte tokens (`C0 C1 F5 F6 F7`), and the
`[a-zа-я一-龥]+` test accepts 78 more CJK tokens that the tail bug had
rejected. Nothing else changes.

Overlaps #849, which fixes only the negated-class complement and still
admits overlong encodings. Closes #848.

Validation: 11 new regression tests that all fail on `main`;
`xgrammar_test` 77/77; full `tests/python` with `HF_TOKEN` 4764 passed.
Performance was measured in #904 on this same diff: a few percent more
compile time and mask latency on JSON, +12.6% mask latency on a
1024-char bounded Kimi XML string, from the extra states. This is a
correctness trade-off.

---------

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Signed-off-by: yuchuan <yuchuan.7streams@gmail.com>
Co-authored-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Seven-Streams added a commit that referenced this pull request Sep 20, 2026
…d repetitions (#911)

Two correctness bugs in the grammar compilation pipeline, both reachable
from ordinary EBNF, regex and JSON-schema input on `main`. They were
found by diffing the regular-grammar flattener from #901 against
`GrammarMatcher`; the flattener was right both times and the matcher
side was wrong.

## 1. `MergeEquivalentStates` merges the start state away
(over-acceptance)

The "equivalent successor" rule merges two states that have the same
single predecessor, the same incoming labels and the same end-ness. When
the start state's only incoming edge is its own self-loop, it passes
that test, and a state reached from the start state by the loop label
looks equivalent to it. But the start state is also entered without
taking any edge, so the merged automaton accepts input that never
consumed the looped class. Three-state automata escape only because of
the `NumStates() < 4` early return.

- `root ::= [a-z]* [a-z] "c" "d"` accepts `cd`; `root ::= [a-z]* [a-z]
[0-9] [0-9]` accepts `12`.
- JSON schema `{"type": "string", "pattern": "^a*acd$"}` accepts `"cd"`,
through the regex FSM builder.

Fix: skip the start state in that rule, as candidate and as sibling. The
"equivalent precursor" rule is unaffected, since states with identical
outgoing edges have the same future however they are entered.

## 2. Nested unbounded repetitions reject everything (under-acceptance)

`RepetitionRangeExpander` inlines a single-element rule into the
repetition that references it, but copied the element verbatim instead
of visiting it. For `root ::= b{1,2}`, `b ::= a{2,}` the inlined
`a{2,-1}` was therefore never expanded into recursion, an unbounded
`kRepeat` edge reached the parser, and the parser's `repeat_count >=
Upper()` check against `-1` never let it enter the repeated rule.

- Regex `(a{2,}){1,2}`, `(a{2,}){2}`, `(a{1,}){1,2}` and the EBNF form
above reject every input.

Fix: visit the inlined element (`VisitExpr`) so nested repetitions go
through the expander like any other.

## Validation

- New tests:
`XGrammarFSMTest.MergeEquivalentStatesKeepsStartStateSeparate`
(hand-built four-state NFA),
`test_character_class_star_followed_by_same_class` (EBNF and
schema-pattern paths), and
`test_bounded_repetition_of_unbounded_repetition` (four regex cases plus
the EBNF form). All fail before their fix.
- `xgrammar_test` 78/78. Full `tests/python` with `HF_TOKEN`: 4851
passed, 3 skipped, 0 failed.
- No output change for unaffected grammars: the per-rule FSMs of the
builtin JSON grammar, several JSON schemas (bounded strings, patterns,
formats, numbers), Kimi K3 and DeepSeek V4 structural tags are
byte-for-byte identical before and after, and the full serialized
compiled grammar of a 1024-char bounded string schema (23.5 MB including
the token mask cache) is identical as well. So no goldens change.
- 600 random character-class grammars and a set of nested-repetition
regexes (`((a{2,}){1,2}){1,2}`, `(a{2,}b{1,}){1,3}`, `(a{130,}){1,2}`)
agree with Python's `re`.
- Performance: A/B against the unfixed `main` build on the same host,
alternating rounds. Compile time and mask latency for the builtin JSON
grammar, a 1024-char bounded string, an email regex, a schema `pattern`,
300 bounded strings, 400 properties with `max_whitespace_cnt` and large
repetitions are all within run-to-run noise; the compiled artifacts
being identical, the fixes add only a per-state comparison in the merge
pass and one visitor dispatch per inlined element.

---------

Signed-off-by: yuchuan <yuchuan.7streams@gmail.com>
Seven-Streams added a commit that referenced this pull request Sep 20, 2026
…910)

Extracted from #901 by @izhuhaoran, third piece after #906 and #909. The
branch keeps the original commit and its revert at the bottom for
attribution; the net diff against `main` is the `feat(fsm): add
GrammarFSMBuilder::FromRegularGrammar` commit, additions only.

`Grammar::FromRegex` sends some patterns through `RegexToEBNF` and
produces a small CFG with sub-rules instead of a single FSM.
`JSONSchemaFormat.excludes` (#901) needs an FSM for every string
constraint so it can intersect it with the exclusion automaton, so it
needs a way to flatten that CFG back.

`GrammarFSMBuilder::FromRegularGrammar(grammar, max_num_states)` lowers
the regular subset `RegexToEBNF` emits without re-parsing its terminals:
byte strings and character classes go through the existing FSM builders.
Each finite rule call is inlined with private entry and exit states, so
one call cannot return into another call's continuation, and a direct
tail self-reference loops back to that call's own entry. Bounded
repetitions are unrolled, unbounded ones become a loop. Mutual or
non-tail recursion, non-byte terminals such as `Token(...)`, annotated
rules (lookahead, `max_tokens`, capture, lazy, temperature, suffix stop)
and exceeding `max_num_states` return an error instead of a wrong
automaton.

Nothing on `main` calls it yet; the excludes feature is the first
consumer.

Validation: four new tests in `tests/cpp/test_regular_grammar_fsm.cc`.
Two cover the call-return isolation (`part ::= "a" part | "b"` used
twice, and a recursive callee next to an optional exit in the caller).
One is differential: six regex patterns, including nested quantifiers,
`[é你]+`, `[^你]` and an email pattern, agree with `GrammarMatcher` on 24
samples. One checks the error cases and the state budget.
`xgrammar_test` 81/81; no Python changes.

---------

Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Signed-off-by: yuchuan <yuchuan.7streams@gmail.com>
Co-authored-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
Seven-Streams added a commit that referenced this pull request Sep 20, 2026
…913)

A grammar with a single `max_chars` rule made every
`fill_next_token_bitmask` call cost O(generated length), and slowed the
masks of rules that are never under a budget.

Measured on a 30-property JSON schema grammar where only the `response`
string carries `max_chars=256` (Llama-3-8B tokenizer, 8 threads), mask
latency by length of the already generated prefix:

| prefix | no budget: response / other string | before: response / other
string | after: response / other string |
|---|---|---|---|
| 40 chars | 140 / 133 µs | 1041 / 221 µs | 156 / 138 µs |
| 530 chars | 144 / 132 µs | 1846 / 304 µs | 156 / 139 µs |
| 2030 chars | 144 / 132 µs | 4272 / 541 µs | 156 / 139 µs |
| 8030 chars | 144 / 133 µs | 13197 / 1419 µs | 154 / 138 µs |

## Cause

When the byte-level walk rejects an uncertain token, the mask filler
retries it through the atomic token path, since a `Token`/`ExcludeToken`
edge may still accept it. That retry ran on a copy of the whole matcher
(`Impl atomic_trial(...)`), created once per state with uncertain tokens
and again for every rejected token. The copy includes every history row,
so each mask cost O(history) per rejected token, for every state of the
grammar. The JSON-schema grammar has no token edges, so all of it was
wasted.

## Change

Retry in place instead of on a copy: drop the rows of the partially
matched previous token, rebuild the single-state row, run the atomic
token path, then rebuild the row once more so enforcement performed by
the trial cannot leak into later tokens. The LCP shortcut is reset after
a retry. Skip the retry entirely when the grammar has no
`Token`/`ExcludeToken` edges (`has_token_edges_`, computed once from the
complete FSM), and use the precomputed per-token character counts
instead of recounting bytes.

The per-byte budget check (`AdvanceWithCharacterBudget`) is kept for
every state, since a token can finish an unbudgeted rule and continue
through the parent into a budgeted sibling; the scan it adds per
codepoint is negligible. The compile-time gates keyed on
`has_char_budget_rules_` (speculative masks, rule-level cache,
token-edge demotion) are unchanged and remain a follow-up.

## Validation

- Masks are unchanged: per-position bitmasks are identical between the
old and new implementation on nine grammars (budgeted
`TagDispatch(excludes=...)` inside JSON and as a raw body, `Token` edges
plus `max_chars`, `capture` plus `max_chars`, nested budgets, a rule
shared inside and outside a budget, two budgets) over Llama-3
tokenizations and a small vocabulary, with prefixes up to 3000
characters.
- Three new tests in `tests/python/test_max_chars.py`: the token-edge
retry under a budget, an unbudgeted rule keeping budget-free masks after
a long prefix, and a token that crosses from an unbudgeted rule into a
budgeted sibling (mask and `accept_token` must agree). All pass on
`main` too.
- `xgrammar_test` 82/82. Full `tests/python` with `HF_TOKEN`: 4854
passed, 3 skipped, 0 failed.
- Grammars without character budgets are unaffected: compile and mask
A/B against `main` is within noise.

Found while evaluating `TagDispatch(excludes=...)` with `max_chars` as a
cheaper representation for `JSONSchemaFormat.excludes` with `maxLength`
(#901).

---------

Signed-off-by: yuchuan <yuchuan.7streams@gmail.com>
Seven-Streams added a commit that referenced this pull request Sep 21, 2026
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):

```python
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 #908: the DeepSeek key exclusion check now
applies to both `deepseek_xml` and `deepseek_v4_1_xml`, which share the
raw-key path.

---------

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 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>
@stu-cao

stu-cao commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

We hit the same exclusion/constraint problem while validating DeepSeek V4.1 tool calls; coordinating here rather than opening a duplicate constraint-preservation PR. The downstream frontend consumer is now proposed separately in ai-dynamo/frontend-crates#531 (draft, blocked on constraint-preserving backend support).

A fresh CPU replay with public xgrammar==0.2.8 confirms that JSONSchemaFormat(style="deepseek_v4_1_xml", excludes=["</|"]) accepts a one-character string despite minLength: 2, maxLength: 3; it emits the warning about ignoring length constraints. The V4.1 guard correctly rejects malformed </|DSML| parameter> text while accepting valid structural endings, so removing the frontend guard is not the desired resolution.

Here is our downstream implementation for review/reuse: preserve the length bounds and represent large raw-string bounds compactly instead of unrolling a full DFA per permitted character count. It is based on public source 9778737 and may need adaptation to this PR's current implementation; I have not tested this patch against your current PR head. Tests include Unicode/code-point bounds and large bounds. This is a bounded/raw-string implementation, not a claim to solve every JSON escaped-string/pattern interaction.

The downstream combination (this backend implementation plus the frontend V4.1 guard/prefix fixes) passed the unchanged 816 official Kimi schema requests and 8 selected tool-choice behaviors with a 2,048-token budget on real DeepSeek V4.1. That is integration evidence for the downstream combination, not proof about this PR or an isolated A/B attribution to these two commits. The frontend draft includes an executable length-constraint merge gate which currently fails against the public backend.

Signed downstream patches: preserve bounds, then compact large raw-string bounds
From 0a7d67e425dedade86a348328e951e4ea8db6343 Mon Sep 17 00:00:00 2001
From: Stu Cao <stu@primeintellect.ai>
Date: Sun, 25 Oct 2026 13:46:04 -0700
Subject: [PATCH 1/2] fix(grammar): preserve string length bounds with
 exclusions

Signed-off-by: Stu Cao <stu@primeintellect.ai>
---
 cpp/json_schema_converter.cc              | 21 ++++----
 python/xgrammar/structural_tag.py         |  2 +-
 tests/python/test_json_schema_excludes.py | 58 ++++++++++++++++-------
 3 files changed, 53 insertions(+), 28 deletions(-)

diff --git a/cpp/json_schema_converter.cc b/cpp/json_schema_converter.cc
index 9261cdf..eeadb01 100644
--- a/cpp/json_schema_converter.cc
+++ b/cpp/json_schema_converter.cc
@@ -2667,14 +2667,17 @@ int32_t JSONSchemaConverter::GenerateString(const StringSpec& spec, const std::s
         {ByteString("\""), RegexExpression(*spec.pattern, /*json_string=*/true), ByteString("\"")}
     );
   }
-  // Check for length constraints. They are dropped when there are exclusions: intersecting the
-  // unrolled bound with the exclusion automaton emits one rule per position and automaton state
-  // (about 18 rules per character for three markers), so the string keeps only the exclusions.
-  // Exclusions apply only to the strings without pattern and format above: those constraints
-  // are the schema's own contract for the string.
+  // Enforce length and exclusions together. Each regex atom is one decoded character,
+  // including JSON escapes and a paired UTF-16 surrogate escape.
   if (spec.min_length != 0 || spec.max_length != -1) {
     if (!excludes_.empty()) {
-      WarnDroppedLengthConstraints(spec, rule_name);
+      const std::string atom =
+          R"(([^"\\\x00-\x1f]|\\(["\\/bfnrt]|u([dD][89aAbB][0-9a-fA-F]{2}\\u[dD][c-fC-F][0-9a-fA-F]{2}|[0-9a-cA-Ce-fE-F][0-9a-fA-F]{3}|[dD][0-7c-fC-F][0-9a-fA-F]{2}))))";
+      const std::string bounds = "{" + std::to_string(spec.min_length) + "," +
+                                 (spec.max_length < 0 ? "" : std::to_string(spec.max_length)) + "}";
+      return Sequence(
+          {ByteString("\""), ExcludingString(atom + bounds, rule_name, false), ByteString("\"")}
+      );
     } else {
       int32_t character =
           builder_.AddCharacterClass({{'"', '"'}, {'\\', '\\'}, {'\r', '\r'}, {'\n', '\n'}}, true);
@@ -3876,10 +3879,10 @@ int32_t XMLToolCallingConverter::GenerateString(
           spec.max_length
       );
     }
-    // With exclusions the raw string keeps only the exclusions: length constraints are dropped
-    // (see JSONSchemaConverter::GenerateString) and an unrecognized format is unconstrained.
     if (bounded) {
-      WarnDroppedLengthConstraints(spec, rule_name);
+      const std::string bounds = "{" + std::to_string(spec.min_length) + "," +
+                                 (spec.max_length < 0 ? "" : std::to_string(spec.max_length)) + "}";
+      return ExcludingString("[\\s\\S]" + bounds, rule_name, false);
     }
     return RuleRef(kXMLString);
   }
diff --git a/python/xgrammar/structural_tag.py b/python/xgrammar/structural_tag.py
index 8003d66..5efcd79 100644
--- a/python/xgrammar/structural_tag.py
+++ b/python/xgrammar/structural_tag.py
@@ -69,7 +69,7 @@ class JSONSchemaFormat(BaseModel):
     property names, nested JSON strings included. Matches the emitted text before JSON/XML
     unescaping; quotes, punctuation and XML parameter wrappers are never part of a match. A string
     with ``pattern`` or ``format`` is matched by that constraint alone and is not filtered;
-    ``minLength``/``maxLength`` are dropped with a warning and the string is filtered;
+    ``minLength``/``maxLength`` are enforced together with the exclusions;
     ``const``/``enum`` alternatives containing an excluded substring are removed. XML styles reject
     exclusions that start or end with whitespace; ``cohere_xml`` and ``minimax_m3_xml`` reject
     non-empty lists."""
diff --git a/tests/python/test_json_schema_excludes.py b/tests/python/test_json_schema_excludes.py
index 613efb9..fbfc9ed 100644
--- a/tests/python/test_json_schema_excludes.py
+++ b/tests/python/test_json_schema_excludes.py
@@ -81,27 +81,16 @@ def test_pattern_and_format_strings_are_not_filtered(style, schema, accepted, re
         {"type": "string", "minLength": 2, "maxLength": 3},
     ],
 )
-def test_excludes_drop_length_constraints(style, schema, capfd):
-    # Bounding the length of a filtered string multiplies the grammar by the bound, so the
-    # length constraints are dropped when exclusions are present; the exclusions still apply.
-    bounded = leaf_grammar(schema, style, excludes=[])
+def test_excludes_preserve_length_constraints(style, schema, capfd):
+    grammar = leaf_grammar(schema, style, excludes=["bc", "cb"])
     assert "Ignoring" not in capfd.readouterr().err
-    unbounded = leaf_grammar(schema, style, excludes=["bc", "cb"])
-    warning = capfd.readouterr().err
-    assert "Ignoring" in warning and "JSONSchemaFormat.excludes" in warning
-    assert f"minLength={schema.get('minLength', 0)}" in warning
-    assert f"maxLength={schema.get('maxLength', -1)}" in warning
-    plain = leaf_grammar({"type": "string"}, style, excludes=["bc", "cb"])
-    # Same rules as the unbounded string (up to the reference to the shared string rule), not
-    # one rule per position and exclusion state.
-    assert str(unbounded).count("::=") <= str(plain).count("::=") + 1
-    for value in ("a", "abde", "ax" * 10):
-        assert _is_grammar_accept_string(unbounded, string_instance(value, style))
-        assert _is_grammar_accept_string(bounded, string_instance(value, style)) == (
+    for value in ("", "a", "ab", "abc", "你好", "你好🦋", "abde", "ax" * 10):
+        expected = (
             schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", 10**9)
+            and "bc" not in value
+            and "cb" not in value
         )
-    for value in ("bc", "acbz", "a" * 20 + "bc"):
-        assert not _is_grammar_accept_string(unbounded, string_instance(value, style))
+        assert _is_grammar_accept_string(grammar, string_instance(value, style)) == expected
 
 
 @pytest.mark.parametrize("style", ["json", "kimi_k3_xml"])
@@ -664,3 +653,36 @@ def test_deepseek_v41_excludes_preserve_value_type_and_parameter_end():
     for body in ('true">BAD', 'false">{"text":"BAD"}', 'false">"safe"'):
         assert not _is_grammar_accept_string(grammar, prefix + body + end)
     assert not _is_grammar_accept_string(grammar, prefix + 'false">{"text":"' + end + '"}' + end)
+
+
+@pytest.mark.parametrize("style", ["deepseek_v4_1_xml", "json"])
+@pytest.mark.parametrize("minimum,maximum", [(5, None), (0, 5), (2, 5)])
+def test_v41_length_and_closing_marker_exclusion(style, minimum, maximum):
+    leaf = {"type": "string", "minLength": minimum}
+    if maximum is not None:
+        leaf["maxLength"] = maximum
+    schema = {
+        "type": "object",
+        "properties": {"value": leaf},
+        "required": ["value"],
+        "additionalProperties": False,
+    }
+    grammar = xgr.Grammar.from_structural_tag(schema_tag(schema, style, ["</|DSML|"]))
+    for value in ["a", "abcde", "abcdef", "你好🦋ab", "</|DSML|  parameter>"]:
+        wire = (
+            f'<|DSML| parameter name="value" string="true">{value}</|DSML| parameter>'
+            if style != "json"
+            else json.dumps({"value": value}, ensure_ascii=False)
+        )
+        expected = (
+            minimum <= len(value)
+            and (maximum is None or len(value) <= maximum)
+            and "</|DSML|" not in value
+        )
+        assert _is_grammar_accept_string(grammar, wire) == expected
+
+
+def test_excluded_bounded_json_counts_escaped_characters():
+    grammar = leaf_grammar({"type": "string", "minLength": 2, "maxLength": 2}, "json", ["BAD"])
+    for value in [chr(10) + chr(34), "🦋a", "你好", "a", "abc"]:
+        assert _is_grammar_accept_string(grammar, json.dumps(value)) == (len(value) == 2)
-- 
2.50.1 (Apple Git-155)


From 3aa1314fc8f0fa94dec3ff6fbceb38ae7ca5b712 Mon Sep 17 00:00:00 2001
From: Stu Cao <stu@primeintellect.ai>
Date: Sun, 25 Oct 2026 14:19:03 -0700
Subject: [PATCH 2/2] fix(grammar): encode large raw-string bounds compactly

Signed-off-by: Stu Cao <stu@primeintellect.ai>
---
 cpp/json_schema_converter.cc              | 78 +++++++++++++++++++++--
 cpp/json_schema_converter.h               |  4 +-
 tests/python/test_json_schema_excludes.py | 20 ++++++
 3 files changed, 97 insertions(+), 5 deletions(-)

diff --git a/cpp/json_schema_converter.cc b/cpp/json_schema_converter.cc
index eeadb01..8f736b8 100644
--- a/cpp/json_schema_converter.cc
+++ b/cpp/json_schema_converter.cc
@@ -2339,7 +2339,9 @@ int32_t JSONSchemaConverter::ExcludingString(
     const std::string& regex,
     const std::string& rule_name,
     bool close_json_string,
-    const std::vector<std::string>& excluded_keys
+    const std::vector<std::string>& excluded_keys,
+    int min_length,
+    int max_length
 ) {
   auto parsed = GrammarFSMBuilder::Regex(regex, false);
   XGRAMMAR_CHECK(parsed.IsOk()) << "Cannot build the FSM of " << regex << ": "
@@ -2440,6 +2442,7 @@ int32_t JSONSchemaConverter::ExcludingString(
     return it->second;
   };
 
+  std::map<std::pair<int, int>, int32_t> one_character;
   std::vector<int32_t> rules(fsm.NumStates(), -1);
   std::vector<int> pending{fsm.GetStart()};
   // Share complete fallback branches across key prefixes to reuse their token masks.
@@ -2490,6 +2493,9 @@ int32_t JSONSchemaConverter::ExcludingString(
       choices.push_back(close_json_string ? ByteString("\"") : Empty());
     }
     for (const auto& [target, codepoints] : transitions) {
+      if (min_length >= 0) {
+        one_character[{state, target}] = builder_.AddCharacterClass(codepoints);
+      }
       if (rules[target] == -1) {
         rules[target] = builder_.AddEmptyRuleWithHint(rule_name + "_exclude");
         pending.push_back(target);
@@ -2522,6 +2528,72 @@ int32_t JSONSchemaConverter::ExcludingString(
     }
     builder_.UpdateRuleBody(rules[state], Choice(choices));
   }
+  if (min_length >= 0) {
+    // Count raw Unicode codepoints without unrolling a potentially multi-million-character
+    // bound into an FSM. Nonterminals describe paths between exclusion-DFA states. Splitting
+    // exact lengths and ranges in half shares O(log(bound)) length levels.
+    XGRAMMAR_CHECK(!close_json_string);
+    using Key = std::tuple<int64_t, int, int>;
+    std::map<Key, int32_t> exact_cache, range_cache;
+    auto make_rule = [&](const std::vector<int32_t>& choices) -> int32_t {
+      if (choices.empty()) return -1;
+      return RuleRef(builder_.AddRuleWithHint(rule_name + "_bounded", Choice(choices)));
+    };
+    std::function<int32_t(int64_t, int, int)> exact = [&](int64_t n, int from, int to) -> int32_t {
+      Key key{n, from, to};
+      if (auto it = exact_cache.find(key); it != exact_cache.end()) return it->second;
+      int32_t result = -1;
+      if (n == 0) {
+        result = from == to ? Empty() : -1;
+      } else if (n == 1) {
+        auto it = one_character.find({from, to});
+        if (it != one_character.end()) result = it->second;
+      } else {
+        std::vector<int32_t> choices;
+        for (int mid : pending) {
+          auto left = exact(n / 2, from, mid);
+          if (left < 0) continue;
+          auto right = exact(n - n / 2, mid, to);
+          if (right >= 0) choices.push_back(Sequence({left, right}));
+        }
+        result = make_rule(choices);
+      }
+      exact_cache[key] = result;
+      return result;
+    };
+    std::function<int32_t(int64_t, int, int)> upto = [&](int64_t n, int from, int to) -> int32_t {
+      Key key{n, from, to};
+      if (auto it = range_cache.find(key); it != range_cache.end()) return it->second;
+      if (n == 0) return from == to ? Empty() : -1;
+      std::vector<int32_t> choices;
+      auto shorter = upto(n / 2, from, to);
+      if (shorter >= 0) choices.push_back(shorter);
+      for (int mid : pending) {
+        auto left = exact(n / 2 + 1, from, mid);
+        if (left < 0) continue;
+        auto right = upto(n - n / 2 - 1, mid, to);
+        if (right >= 0) choices.push_back(Sequence({left, right}));
+      }
+      auto result = make_rule(choices);
+      range_cache[key] = result;
+      return result;
+    };
+    std::vector<int32_t> choices;
+    for (int mid : pending) {
+      auto prefix = exact(min_length, fsm.GetStart(), mid);
+      if (prefix < 0) continue;
+      if (max_length < 0) {
+        choices.push_back(Sequence({prefix, RuleRef(rules[mid])}));
+      } else {
+        for (int end : pending) {
+          if (!fsm.IsEndState(end)) continue;
+          auto suffix = upto(static_cast<int64_t>(max_length) - min_length, mid, end);
+          if (suffix >= 0) choices.push_back(Sequence({prefix, suffix}));
+        }
+      }
+    }
+    return choices.empty() ? Unsatisfiable() : Choice(choices);
+  }
   return RuleRef(rules[fsm.GetStart()]);
 }
 
@@ -3880,9 +3952,7 @@ int32_t XMLToolCallingConverter::GenerateString(
       );
     }
     if (bounded) {
-      const std::string bounds = "{" + std::to_string(spec.min_length) + "," +
-                                 (spec.max_length < 0 ? "" : std::to_string(spec.max_length)) + "}";
-      return ExcludingString("[\\s\\S]" + bounds, rule_name, false);
+      return ExcludingString("[\\s\\S]*", rule_name, false, {}, spec.min_length, spec.max_length);
     }
     return RuleRef(kXMLString);
   }
diff --git a/cpp/json_schema_converter.h b/cpp/json_schema_converter.h
index 8ca4896..0ce55bb 100644
--- a/cpp/json_schema_converter.h
+++ b/cpp/json_schema_converter.h
@@ -431,7 +431,9 @@ class JSONSchemaConverter {
       const std::string& regex,
       const std::string& rule_name,
       bool close_json_string,
-      const std::vector<std::string>& excluded_keys = {}
+      const std::vector<std::string>& excluded_keys = {},
+      int min_length = -1,
+      int max_length = -1
   );
   bool IsAllowedString(const std::string& text) const;
   bool IsAllowedLiteral(const picojson::value& value, bool raw_string = false) const;
diff --git a/tests/python/test_json_schema_excludes.py b/tests/python/test_json_schema_excludes.py
index fbfc9ed..257ebdc 100644
--- a/tests/python/test_json_schema_excludes.py
+++ b/tests/python/test_json_schema_excludes.py
@@ -686,3 +686,23 @@ def test_excluded_bounded_json_counts_escaped_characters():
     grammar = leaf_grammar({"type": "string", "minLength": 2, "maxLength": 2}, "json", ["BAD"])
     for value in [chr(10) + chr(34), "🦋a", "你好", "a", "abc"]:
         assert _is_grammar_accept_string(grammar, json.dumps(value)) == (len(value) == 2)
+
+
+@pytest.mark.parametrize("minimum,maximum", [(0, 5_000_000), (5, 5_000_000), (0, 0), (2, 7)])
+def test_v41_compact_large_length_bound(minimum, maximum):
+    schema = {
+        "type": "object",
+        "properties": {"value": {"type": "string", "minLength": minimum, "maxLength": maximum}},
+        "required": ["value"],
+        "additionalProperties": False,
+    }
+    grammar = xgr.Grammar.from_structural_tag(schema_tag(schema, "deepseek_v4_1_xml", ["</|"]))
+    assert len(str(grammar)) < 2_000_000
+    for length in range(11):
+        value = "a" * length
+        wire = f'<|DSML| parameter name="value" string="true">{value}</|DSML| parameter>'
+        assert _is_grammar_accept_string(grammar, wire) == (minimum <= length <= maximum)
+    assert not _is_grammar_accept_string(
+        grammar,
+        '<|DSML| parameter name="value" string="true">aaaaa</|DSML parameter></|DSML| parameter>',
+    )
-- 
2.50.1 (Apple Git-155)

This branch has not been deployed

No deployments
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