Skip to content

fix: preserve Unicode character class ranges in FSMs - #904

Closed
Seven-Streams wants to merge 1 commit into
mlc-ai:mainfrom
Seven-Streams:fix/unicode-character-class-fsm
Closed

Seven-Streams wants to merge 1 commit into
mlc-ai:mainfrom
Seven-Streams:fix/unicode-character-class-fsm

Conversation

@Seven-Streams

Copy link
Copy Markdown
Member

Character-class FSMs truncated negated Unicode endpoints to bytes and used noncanonical UTF-8 boundaries when a range crossed encoding widths. For example, [^\u0000-\U0010FFFF] incorrectly accepted non-ASCII characters. Compute complements in codepoint space, split ranges by UTF-8 width, and correct the two-continuation-byte lower bound in the three-byte upper-tail path.

This extracts the Unicode fixes from #901 by @izhuhaoran onto main at 1de4247. Production changes are limited to cpp/grammar_functor.cc and cpp/fsm_builder.cc; the other five changed files are regression tests and affected snapshots.

Validation

  • 74 C++ tests and 2,758 Python tests passed, including the Hugging Face JSON token-mask and cross-grammar rule-cache tests.
  • All 11 added behavioral regression cases fail on the unmodified baseline and pass with this fix.
  • Audited all 1,425 token-mask positions behind the updated expectations: only token bytes C0, C1, F5, F6, and F7 became rejected; no tokens became newly allowed.
  • Repository-wide pre-commit and Ruff checks passed.

Performance

Same GCC 13.3 Release builds with -O3 and LTO on a Ryzen Threadripper 3970X; Llama-3.2-1B-Instruct tokenizer, 8 compiler threads, compiler cache disabled, CPU affinity 0–7. Three paired rounds in base/fixed, fixed/base, base/fixed order. Each process runs one warmup and three measured iterations per case; numbers below are medians of nine samples. Tokenization and compiler construction are outside compilation timing. Mask latency measures fill_next_token_bitmask only.

Case Compile ms: base → fixed Change Compiled MiB: base → fixed Mask µs/token: base → fixed Change
builtin_json 17.113 → 18.064 +5.6% 0.5759 → 0.5822 10.780 → 10.957 +1.6%
nested_json_schema 15.514 → 16.463 +6.1% 0.1824 → 0.1840 19.713 → 20.239 +2.7%
bounded_json_1024 846.985 → 928.032 +9.6% 6.9207 → 7.3255 431.348 → 441.031 +2.2%
bounded_kimi_xml_1024 877.777 → 937.249 +6.8% 6.1391 → 6.4506 12526.197 → 14104.382 +12.6%
negated_character_class 9.428 → 8.525 -9.6% 0.0182 → 0.0198 2.590 → 2.646 +2.2%

The bounded cases each use one required string with maxLength 1024 and mixed ASCII/Chinese/emoji content. The Kimi XML case uses the upstream behavior without string exclusions. Each mask sample replays the output 30 times (3 for bounded Kimi XML); every replay is validated. Memory is CompiledGrammar.memory_size_bytes, not process RSS.

The stricter UTF-8 automata require additional states. The measured bounded-string compilation, memory, and Kimi mask overheads are retained as a correctness tradeoff and are disclosed above; this PR does not claim a performance improvement. These measurements cover the listed workloads on one host.

Extract the Unicode range fixes from PR mlc-ai#901 by izhuhaoran. Complement negated classes in codepoint space, split UTF-8 widths, and fix the three-byte upper-tail bound. Update FSM snapshots and tokenizer masks for the newly rejected invalid leading bytes.
Copilot AI lite review requested due to automatic review settings September 18, 2026 15:59

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.

🟡 Changes recommended

The regex FSM path still admits overlong UTF-8 sequences because it bypasses the new codepoint-width splitting.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR corrects Unicode character-class FSM construction by preserving codepoint ranges, enforcing canonical UTF-8 boundaries, and fixing three-byte upper-tail handling.

Changes:

  • Reworked positive and negated character-class range generation.
  • Corrected UTF-8 range boundary construction.
  • Updated regression tests and serialized FSM/token-mask snapshots.
File summaries
File Description
cpp/grammar_functor.cc Codepoint-based character-class handling
cpp/fsm_builder.cc UTF-8 boundary correction
tests/cpp/test_fsm_builder.cc FSM snapshot updates
tests/python/test_grammar_fsm_builder.py Unicode regression coverage
tests/python/test_grammar_matcher_json.py Token-mask snapshot updates
tests/python/test_grammar_matcher_json_schema.py Cache test snapshot updates
tests/python/test_serialization.py Serialization snapshot updates
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cpp/fsm_builder.cc
int tmp_state_max = fsm.AddState();
fsm.AddEdge(from, tmp_state_max, byte_max[2], byte_max[2]);
AddSameLengthCharacterRange(fsm, tmp_state_max, to, 0x0080, (max & 0x00FFFF));
AddSameLengthCharacterRange(fsm, tmp_state_max, to, 0x008080, (max & 0x00FFFF));
@Seven-Streams
Seven-Streams marked this pull request as draft September 18, 2026 16:03
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>
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.

2 participants