Repository navigation
feat: support string exclusions in JSON schema structural tags - #901
izhuhaoran wants to merge 1 commit into
Conversation
Signed-off-by: zhuhaoran <zhuhaoran.zhr@alibaba-inc.com>
a781a05 to
13d3432
Compare
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.
|
@izhuhaoran Thanks for your contributions! I'll split the pr for further review. |
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>
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>
@Seven-Streams Thanks for helping split this up! Let me know if any changes are needed on my side. |
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>
…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>
…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>
…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>
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>
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>
|
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 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 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 boundsFrom 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)
|
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 repeatedindex="1"are preserved:Immediately after
[answer redacted], the firstresponseargument 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.excludesto reserve substrings within string values and property names, including nested JSON: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 withresponseas 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=Falseoption 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_xmlandminimax_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:
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 andaccept_tokenresults agree.