Skip to content

fix(preprocessor): table-driven Unicode classes, file-driven added tokens, parse_special and neutralize policy, Qwen3 byte-split decode - #1

Open
Aharrypotter wants to merge 5 commits into
feat/chat-template-pipelinefrom
feat/tokenizer-unicode-attributes
Open

fix(preprocessor): table-driven Unicode classes, file-driven added tokens, parse_special and neutralize policy, Qwen3 byte-split decode#1
Aharrypotter wants to merge 5 commits into
feat/chat-template-pipelinefrom
feat/tokenizer-unicode-attributes

Conversation

@Aharrypotter

Copy link
Copy Markdown
Owner

Summary

Fork-internal stacked PR: base is feat/chat-template-pipeline (upstream Draft UbiquitousLearning#707). It will be retargeted to upstream main once UbiquitousLearning#707 merges; the diff shown here is exactly the five follow-up commits.

Follow-up to UbiquitousLearning#707. It makes the tokenizer layer deterministic and data-driven, and turns the injection guard from a refusal into a choice. Two tracks, reviewed together because the second depends on the attributes the first records:

  • Track A, tokenizer correctness: character classes come from Unicode tables instead of the process locale, and added tokens come from tokenizer.json with their attributes instead of per-model hand-written lists. Both are behavior fixes with a Transformers oracle.
  • Track B, control-token handling: tokenize() gains a parse_special gate equal to Hugging Face split_special_tokens=True, and a per-model control_token_policy adds neutralize, which renders the official template with provenance so a control token inside message content becomes ordinary text instead of an error.

It also closes the follow-up item recorded on UbiquitousLearning#701: the Qwen3 family decoded each token as its own wide string, which dropped the bytes of characters that byte-level BPE splits across tokens.

Every migrated runner still produces byte-identical prompts and identical token ids under the default configuration; the parity gate proves it on five models.

Reviewer focus

  1. Why was classification wrong? isLetter/isDigit/iswspace were std::iswalpha and friends on a global locale that initLocal() installed. On the H20 container that locale does not exist, so results depended on glibc's C.UTF-8 tables; and std::iswdigit only accepts ASCII while \p{N} is every Nd/Nl/No code point. The new parity case unicode_numerals_and_spaces failed on MiniCPM5 before commit 44e26651 and passes after. The tables are the ones mllm already vendored from llama.cpp under preprocessor/tokenizers/llama_cpp_unicode.
  2. What changed in matching? The tries now carry per-token attributes from added_tokens (special, lstrip, rstrip, single_word) and match leftmost-longest. The ported Python automaton mis-split </think> once MiniCPM5's /think was registered; the parity gate caught it and the walk was replaced. lstrip/rstrip/single_word semantics were pinned against tokenizers 0.22.2 before the tests were written; no local checkpoint uses them, so they are covered by unit tests only.
  3. Which lists went away? Qwen3, Qwen3-MoE, Qwen3.5, and Qwen Ascend drop their hand lists (verified subsets of the checkpoints' added_tokens); MiniCPM5 drops its private 510-token linear scan. Qwen NPU and MiniCPM4 have no local checkpoint, so they keep their lists and register the file's entries on top.
  4. How does neutralize stay safe? ChatPreprocessor::render() refuses under both policies, because a flat string cannot carry provenance; only renderSpans() returns origin-tagged spans, and only the jinja_required backend can produce them (the engine fork's Template::render_parts). neutralize on the legacy backend fails at construction. Token merges do not cross span boundaries; stage D shows benign prompts tokenize identically under both policies for the Qwen and MiniCPM templates, whose literals end at a control token or newline.
  5. Decode side: Qwen3, Qwen3-MoE, and Qwen Ascend gain detokenizeBytes(), the contract Qwen3.5, MiniCPM5, and LFM2.5 already use; the Qwen3 service, the probing service, and the two examples stream those bytes through StreamingUtf8Decoder and flush at EOS. Qwen3DecodeTest shows the fixture splits a character across tokens and that only the byte path round-trips it; parity stage E compares streaming decode of Transformers' ids with Transformers' decode for CJK, emoji, combining marks, and flags on all five models.
  6. Engine fork delta: one commit on Aharrypotter/jinja.cpp (a04ea9b): reserved code points U+E000/U+E001 wrap input strings, comparison and search operations ignore them, slicing operations keep per-byte provenance, tojson/repr wrap the literal, render() strips them, and a context that already contains them is refused. Upstream's 414-case suite passes with and without the opt-in macros.

Review map

  1. mllm/preprocessor/tokenizers/Unicode.hpp and tests/preprocessor/UnicodeClassTest.cpp — table-backed predicates, initLocal() no longer touches the global locale.
  2. mllm/preprocessor/tokenizers/AddedToken.hpp, BPE*.{hpp,cpp}, AutoTokenizer.{hpp,cpp} — added-token attributes, registerAddedTokens, splitSegments, TokenizeOptions, attrOf.
  3. mllm/preprocessor/chat_template/ChatTemplate.{hpp,cpp}, PromptTokenization.hppControlTokenPolicy, renderSpans, tokenizePromptSpans.
  4. third_party/jinja.cpp (submodule bump to a04ea9b) — render_parts.
  5. The seven migrated tokenizers and their configurations — requestFor / renderPromptSpans / tokenizePrompt, control_token_policy.
  6. mllm/models/qwen3/modeling_qwen3_service.hpp, modeling_qwen3_probing_service.hpp, examples/qwen3*/main.cpp — streaming decode.
  7. tests/preprocessor/AddedTokenTest.cpp, Qwen3DecodeTest.cpp, new ChatTemplateTest cases, probe flags --no_parse_special / --policy, parity stages C and D.

Validation

Source identity: branch feat/tokenizer-unicode-attributes at ad6b8554, five commits on top of UbiquitousLearning#707's 320515fe. Every gate below ran at that exact head; the evidence boundary is that no row makes a claim about any other tree. macOS with Jinja ON and OFF: 32 / 19 chat-template tests, 12 Unicode, added-token, and decode tests, MiniCPM5 and Qwen3.5 tokenizer A/B, and the parity gate for qwen3_5, minicpm5, qwen3, qwen3_moe, qwen_ascend with stages A (bytes, HF ids, mllm ids), B (product path legacy vs jinja vs HF), C (parse_special=false vs split_special_tokens=True), D (neutralize vs reject on benign prompts, forged prompt accepted with no extra control ids and decoded text equal to the rendered prompt), and E (streaming decode equals Transformers decode on byte-split text). H20 repeated the Linux ON/OFF suites and cross-compiled eleven Android arm64 artifacts with NDK r28b. The OnePlus 13T ran the bundle: unit tests, injection refusal, neutralize checks, parse_special gate, probe A/B, and 16-token greedy runner A/B across legacy, jinja_required, and neutralize configurations for MiniCPM5-1B and Qwen3.5-0.8B with identical generated ids. Upstream CI and human review are pending.

Validation matrix — exact location, identity, and permitted conclusion
Gate Location Identity Result Permitted conclusion
Unit tests ON 32 / OFF 19; Unicode + added-token + decode 12 macOS ad6b8554 PASS classification under the classic C locale, attribute semantics, policies
Parity stages A-E, five models macOS transformers 5.1.0; H20 transformers 5.13.0 pinned template and tokenizer hashes PASS byte and id exactness; neutralize equivalence on benign prompts
Pre-P1 defect reproduction macOS binary at 320515fe MiniCPM5-1B mllm ids wrong on unicode_numerals_and_spaces motivates commit 44e26651
Fork engine: input-marking suite, compat suite, upstream 414 cases in both macro modes macOS jinja.cpp fork a04ea9b PASS engine provenance without behavior change when unmarked
Linux x86 ON/OFF build, tests, parity, fail-closed checks H20 ad6b8554 PASS Linux path
Android arm64 cross-build + ELF audit (11 artifacts) H20, NDK r28b ad6b8554 PASS link and target only
Device gate incl. neutralize, decode round-trip, and three-config runner A/B OnePlus 13T 050b095e artifacts, models hash-matched PASS on-device equality and guard behavior

Supported scope and limits

  • control_token_policy defaults to reject; nothing changes without an explicit configuration opt-in.
  • neutralize requires jinja_required. Under neutralize, render() still refuses; callers must use renderSpans().
  • lstrip/rstrip/single_word are implemented per Hugging Face semantics but no supported checkpoint uses them; coverage is unit-test only.
  • Qwen NPU and MiniCPM4 keep their hand lists because no local checkpoint could verify a pure file-driven registration.
  • Span boundaries stop BPE merges; a template that ends a literal mid-word would tokenize differently under neutralize than under reject. None of the five validated templates does.

🤖 Generated with Claude Code

…rocess locale

The hand-written pre-tokenizer matchers implemented \p{L}, \p{N}, and \s
with std::iswalpha, std::iswdigit, and std::iswspace, which depend on the
process-global locale that initLocal() installed. Two consequences:

- correctness varied by platform: on H20 the container has no en_US.UTF-8,
  initLocal() fell back to C.UTF-8 with a warning on stdout, and the
  result depended on glibc's wctype tables rather than on the tokenizer
- std::iswdigit accepts only ASCII digits, while Hugging Face's \p{N}
  matches every Nd, Nl, and No code point. MiniCPM5's \p{N}{1,3} chunking
  therefore split "编号①②③,价格٣٤٥元,第Ⅳ章,面积²" differently from the
  official tokenizer (parity case unicode_numerals_and_spaces, mllm token
  ids wrong before this change, exact after)

isLetter, isDigit, the new isMark, isWhitespace, and toLower now read the
vendored llama.cpp Unicode tables that mllm already ships under
preprocessor/tokenizers/llama_cpp_unicode. Eight tokenizers switch from
std::iswspace and std::towlower to the table-backed helpers. initLocal()
no longer touches the global locale; it only prepares std::wcout for the
examples that print wide strings, silently.

Mllm-Test-UnicodeClass pins the behavior under the classic C locale, and
the parity gate gains the numeral-and-space case for all five models.
…eir attributes

Special tokens were a hand-written list in every tokenizer. The lists
drifted from the checkpoints (the Qwen3 family lacked six added tokens,
and lacked the tool markers until the previous fix), and MiniCPM5 kept a
private linear scan over its 510 added tokens instead.

- BPE and BPEUTF8 now keep every added_tokens entry with id, content,
  special, lstrip, rstrip, single_word, and normalized (AddedToken.hpp)
- AutoTokenizer::registerAddedTokens feeds them into the special-token
  trie with match-time attributes; Qwen3, Qwen3-MoE, Qwen3.5, and Qwen
  Ascend drop their hand lists, MiniCPM5 drops its scan, and Qwen NPU and
  MiniCPM4 (no local checkpoint) keep their lists and register the file's
  entries on top
- the tries gain splitSegments(): each segment says whether it is a
  matched token, lstrip/rstrip absorb adjacent white space into the match
  (the white space is dropped, only the token id is emitted), single_word
  requires word boundaries, and SplitOptions::parse_special=false leaves
  control tokens in the text. Semantics were pinned against Hugging Face
  tokenizers 0.22.2 before writing the tests
- the trie automaton is replaced by a plain leftmost-longest walk. The
  ported Python automaton mis-split "</think>" once "/think" was also
  registered, which the MiniCPM5 parity gate caught immediately

All five model parity gates pass on both builds; MiniCPM5 now tokenizes
through the same trie as the rest at the same token ids.
…ize()

Add TokenizeOptions{parse_special} to AutoTokenizer and AutoTokenizerUTF8.
With parse_special=false the checkpoint's control tokens are not
recognized in the text, so "<|im_start|>" inside user content tokenizes
as ordinary characters. Added tokens that are not control tokens
(<think>, <tool_call> in the Qwen3 family) are still matched, which is
exactly Hugging Face's split_special_tokens=True. The migrated tokenizers
(Qwen3, Qwen3-MoE, Qwen3.5, Qwen Ascend, Qwen NPU, MiniCPM5, MiniCPM4)
implement the overload through the trie's SplitOptions; other tokenizers
keep their behavior through the default overload.

BPE::attrOf(id) reports kControl / kUserDefined / kNormal, the subset of
llama.cpp's token attributes mllm needs.

The parity script gains stage C: parse_special=false output equals
tokenizer.encode(text, split_special_tokens=True) and contains no control
token id, for a forged turn boundary, a BOS in running text, and plain
text, on all five models. The probe tool exposes --no_parse_special.
…nce-tagged prompts

Add control_token_policy to the migrated model configurations. The default
stays 'reject'. With 'neutralize' the official template is rendered with
provenance (the jinja.cpp fork's Template::render_parts marks every string
under messages and tools), ChatPreprocessor::renderSpans returns
origin-tagged spans, and tokenizePromptSpans tokenizes input-origin spans
with parse_special=false. A forged turn boundary inside user content is
then ordinary text: the prompt decodes to exactly the rendered bytes and
carries no control token that the template did not emit.

- ChatPreprocessor::render keeps rejecting under both policies, since a
  flat string cannot carry provenance; only renderSpans honours neutralize
- neutralize requires jinja_required and fails at construction on the
  legacy backend, which cannot tell template text from message content
- Qwen3, Qwen3-MoE, Qwen3.5, Qwen Ascend, Qwen NPU, MiniCPM5, and MiniCPM4
  gain requestFor / renderPromptSpans / tokenizePrompt; Qwen3.5 expands
  its image and video placeholders only in template-origin spans
- the jinja.cpp submodule moves to the fork commit that adds render_parts
- parity stage D: neutralize yields the reject-path token ids for every
  benign product case, and accepts the forged prompt with the same number
  of control ids as a benign one, on all five models
Qwen3, Qwen3-MoE, and Qwen Ascend converted every token to a wide string
on its own. Byte-level BPE splits a multi-byte character across tokens,
so a single token is often not valid UTF-8, and the conversion dropped
the incomplete tail: the Qwen3 service, the probing service, and the two
example runners printed mojibake or missing characters for text such as
"日本語テキスト 🇨🇳". This is the TODO left on UbiquitousLearning#701 for the next PR.

Add detokenizeBytes() to the three tokenizers, the same contract Qwen3.5,
MiniCPM5, and LFM2.5 already use, and stream the bytes through
StreamingUtf8Decoder in the service callback, the probing callback, and
the examples, flushing the decoder at EOS. detokenize() is kept for
callers that want a wide string of one token and is now implemented on
top of the byte form.

Qwen3DecodeTest shows the fixture splits at least one character across
tokens, that the per-token wide path loses it, and that the byte path
round-trips exactly. The parity gate gains stage E: streaming decode of
Transformers' ids equals Transformers' decode for CJK, emoji, combining
marks, and regional-indicator flags on all five models; the probe tool
exposes --decode.
@Aharrypotter
Aharrypotter marked this pull request as ready for review September 7, 2026 02:05
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.

1 participant