fix(tokenizers): add qwen2 + qwen35 pre-tokenizer pipelines; complete the #373/#387 pre-tokenizer work - #410
Conversation
…, #387) `qwen2` and `qwen35` were absent from `TiktokenPreTokenizer`, so since the loud gate landed (8abf228, #373) every Qwen2/Qwen3 GGUF threw at load, and before that they silently ran with NO pre-tokenization at all — BPE merges crossing digit boundaries the model was trained to respect (#237 measured tokenizer drift alone at +3.46% PPL). Both expressions are the ORIGINAL tokenizer.json patterns that llama.cpp quotes verbatim above its own copies in `src/llama-vocab.cpp` (`LLAMA_VOCAB_PRE_TYPE_QWEN2` / `_QWEN35`). llama.cpp spells the contractions out as `'[sS]|'[tT]|…` only because std::regex has no `(?i:…)` group; .NET supports the original form directly. - qwen2 = the llama3 expression with a bare `\p{N}` (one digit per segment) instead of `\p{N}{1,3}`. Aliases routed here by llama.cpp: `deepseek-r1-qwen`, `kormo`, `f2llmv2`, `megrez`, plus `stablelm2`/`hunyuan`/`solar-open`, which share the same regex block by case fall-through. - qwen35 = qwen2 with combining marks folded into the letter run (`[\p{L}\p{M}]+`, and `\p{M}` excluded from the punctuation class). - Also adds `minerva-7b`, llama.cpp's actual spelling of the value the table already listed as `minerva`. Tests: - Discriminating unit tests: both Qwen pipelines split every digit where gpt2 and llama3 group runs; and a decomposed "e" + U+0301 stays one segment under qwen35 but splits under qwen2/llama3/gpt2 — the only assertion that separates qwen35 from qwen2. - Real-GGUF token-id parity against llama.cpp b10434 for both `pre` values (Qwen2.5-0.5B-Instruct Q8_0 and Ternary-Bonsai-27B Q2_0), fixtures resolved through KnownTestFixtures; no weights enter the repo. - Negative control for #373: the pre-fix "no pre-tokenization" path is reconstructed explicitly and shown to load quietly and mis-tokenize. docs/TOKENIZERS.md now tabulates every supported `pre` value, including `tekken` (#387), which the table had not picked up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
There was a problem hiding this comment.
Pull request overview
Adds missing Qwen2/Qwen3 (qwen2) and Qwen3.5 (qwen35) tiktoken pre-tokenizer regex pipelines to align GGUF tokenization with llama.cpp, completing the remaining “loud gate + pre-tokenizer coverage” work for the tokenizer.ggml.pre table.
Changes:
- Add
qwen2andqwen35regex pipelines (plus routing aliases) toTiktokenPreTokenizer. - Add discriminating unit tests for Qwen routing/behavior and integration parity tests against llama.cpp token IDs on real GGUF fixtures.
- Document supported
tokenizer.ggml.prevalues per pipeline indocs/TOKENIZERS.md, and add fixture resolvers for the new integration tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Tokenizers/PreTokenizerPolicyTests.cs | Adds discriminating unit tests for Qwen2/Qwen35 segmentation differences and a negative control for the old silent no-pre-tokenization path. |
| tests/DotLLM.Tests.Integration/Tokenizers/QwenPreTokenizerParityTests.cs | Adds real-GGUF integration tests asserting token-id parity with llama.cpp for qwen2 and qwen35. |
| tests/DotLLM.Tests.Integration/Fixtures/KnownTestFixtures.cs | Adds named fixture resolvers for the Qwen2.5 and Bonsai GGUFs used by parity tests. |
| src/DotLLM.Tokenizers/Bpe/TiktokenPreTokenizer.cs | Implements Qwen2 and Qwen3.5 pre-tokenizer regex pipelines and adds routing for relevant aliases. |
| docs/TOKENIZERS.md | Updates documentation to tabulate supported tokenizer.ggml.pre values per pipeline, including Qwen and tekken. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <summary> | ||
| /// Ternary-Bonsai-27B, Q2_0 (~7.2 GB) — <c>prism-ml/Ternary-Bonsai-27B-gguf</c>. | ||
| /// Carries <c>tokenizer.ggml.pre = qwen35</c> (#397). | ||
| /// </summary> | ||
| public static FixtureLocation TernaryBonsai27B_Q2_0 => TestFixtureResolver.ResolveFile( | ||
| "DOTLLM_BONSAI_PQ2_0_GGUF", | ||
| "prism-ml", | ||
| "Ternary-Bonsai-27B-gguf", | ||
| "Ternary-Bonsai-27B-Q2_0.gguf"); | ||
|
|
||
| /// <summary>Human-readable name for <see cref="TernaryBonsai27B_Q2_0"/> skip messages.</summary> | ||
| public const string TernaryBonsai27BDescription = "Ternary-Bonsai-27B Q2_0 GGUF (tokenizer.ggml.pre = qwen35)"; |
| FixtureLocation loc = KnownTestFixtures.TernaryBonsai27B_Q2_0; | ||
| Skip.If(!loc.Found, loc.SkipMessage(KnownTestFixtures.TernaryBonsai27BDescription)); |
CI triage: the Integration Tests failure is #392, not this PR
All three failures are That is exactly #392 — "Three RealHfSafetensorsEndToEndTests methods use Nothing in this PR touches safetensors loading; the diff is Consequence worth acting on#392 makes the Integration Tests job red on every PR, which destroys its value as a merge gate — a genuinely broken integration test would be indistinguishable from this noise. That is the same "silent skip / false signal" failure class this repo keeps finding in its verification apparatus, and it is a two-line fix. Prioritising it. This PR is otherwise green and |
…397) Review found the integration parity fixture could not detect a wrong pipeline. Measured on the two real vocabularies, the original fixture was byte-identical under qwen2, qwen35, llama3 AND tekken — only a fall-back to gpt2 was caught, and only via the trailing "\n\tdone". So Qwen35Gguf_TokenIds_MatchLlamaCpp passed unchanged if qwen35 were routed to qwen2, which is precisely the bug the test exists to catch. Two vocabulary properties caused it, and the amended fixture defeats both: - Neither vocab contains any ASCII multi-digit token, so `\p{N}` vs `\p{N}{1,3}` is unobservable in ids however many ASCII digits are used. Fullwidth U+FF11 U+FF10 are the only multi-digit tokens in either vocab and now separate qwen2/qwen35 from llama3. - Latin decomposed marks never merge with letter bytes here (café, naïve, Zürich all identical under qwen2/qwen35). A Thai cluster (U+0E17 U+0E35 U+0E48) does merge, and now separates qwen35's `[\p{L}\p{M}]+` from qwen2's `\p{L}+`. Parity and discrimination are asserted as two separate properties: - Expected ids REGENERATED from llama.cpp b10434 for the amended fixture — still llama.cpp's output, never dotLLM's, so this stays a parity test and does not become a self-consistency test. - New AssertDiscriminates re-tokenizes the same real vocabulary under a forced `pre` and requires every wrong pipeline (gpt2, llama3, starcoder, gpt-4o and the other Qwen) to produce a different id stream, with a failure message explaining how the discriminators were chosen. Also adds the qwen35-vs-tekken discrimination at unit level (Qwen35_KeepsCaseBoundariesInsideTheLetterRun_UnlikeTekken): the two agree on both real vocabularies because neither contains a case-crossing merge, so the assertion is only meaningful on a vocabulary built to have one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
Closes #397
Closes #373
Closes #387
What was already on
dev, and what this addsTwo thirds of the umbrella work had already landed on
devand simply never closed their issues (the merged PR targeteddev, not the default branch):pixtral8abf2281(already ondev)tekkencc0ee1e0(already ondev)qwen2/qwen35So #387's claim that Nemotron-Nano-9B-v2 was "unloadable since #373's loud gate" was a branch-local truth, not a false report. Nothing existing was rewritten; this builds on top of both commits and completes their remaining acceptance criteria (the docs table, and #373's explicit negative control).
The fix
qwen2andqwen35were missing fromTiktokenPreTokenizer. Since the loud gate landed, every Qwen2/Qwen3 GGUF throws at load; before it, they ran with no pre-tokenization at all, so BPE merges crossed the digit boundaries the model was trained to respect. #237 measured tokenizer drift alone at +3.46% PPL, which is why this gates any quality measurement on Qwen3.8-27B or the Bonsai family.Both expressions are the original
tokenizer.jsonpatterns that llama.cpp quotes verbatim in the comments above its own copies (src/llama-vocab.cpp,LLAMA_VOCAB_PRE_TYPE_QWEN2/_QWEN35, fetched frommaster— the local checkout predatesqwen35). llama.cpp spells the contractions out as'[sS]|'[tT]|…only becausestd::regexhas no(?i:…)group; .NET supports the original form directly, matching what the existingLlama3Pipelinealready does.\p{N}(one digit per segment) instead of\p{N}{1,3}.Aliases llama.cpp routes to
LLAMA_VOCAB_PRE_TYPE_QWEN2:deepseek-r1-qwen,kormo,f2llmv2,megrez.Also mapped:
stablelm2,hunyuan,solar-open— distinct enum values that share the sameregex_exprsblock bycasefall-through, exactly as the file already does for the StarCoder eight.[\p{L}\p{M}]+) and\p{M}excluded from the punctuation class.minerva-7b— llama.cpp's actual spelling of the value the table listed only asminerva.minervais kept for compatibility.What I verified
Discriminating unit tests (
PreTokenizerPolicyTests, synthetic 256-byte vocab + one merge, so a single merge fires or is blocked purely by segment placement):QwenPipelines_SplitEveryDigit_UnlikeGpt2AndLlama3—"34"with a3 4merge is 2 tokens under every Qwen alias, 1 under gpt2 (?\p{N}+) and 1 under llama3 (\p{N}{1,3}). Fails if either is mis-routed.Qwen35_KeepsCombiningMarksInTheLetterRun_UnlikeQwen2Gpt2AndLlama3— the load-bearing one, since the digit test cannot separate qwen35 from qwen2. A decomposede+ U+0301 is one segment under qwen35 (2 tokens via the byte-levele+0xCCmerge) and two under qwen2/llama3/gpt2 (3 tokens). Literals are built from code points so an editor NFC-composing the file cannot defeat it.NegativeControl_TheOldSilentNoPreTokenizationPath_LoadsQuietlyAndMisTokenizes— Unknown tokenizer.ggml.pre silently disables pre-tokenization entirely (e.g. pixtral) #373's explicit negative control. The pre-fix behaviour (unknownpre→nullregex → no pre-tokenization) is reconstructed viaCreateTiktokenWithRegex(..., preRegex: null)and shown to load without complaint and produce 1 token where qwen2 gives 2, 3 where llama3 gives 4.Real-GGUF token-id parity against llama.cpp — both
prevalues covered, neither skipped(
QwenPreTokenizerParityTests, Integration; fixtures resolved throughKnownTestFixtures, no weights in the repo):preqwen2Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q8_0.ggufqwen35prism-ml/Ternary-Bonsai-27B-gguf/Ternary-Bonsai-27B-Q2_0.ggufReference ids from the official
ggml-org/llama.cpprelease b10434 Windows CPU build:llama-tokenize -m <gguf> -f <fixture> --ids --no-bos.Caveat, stated plainly: upstream llama.cpp cannot open the Bonsai Q2_0 tensors (non-upstream quantization type 42). Its qwen35 ids were therefore produced from a tensor-less copy of that file's header + KV section — identical vocabulary, merges and
tokenizer.ggml.pre,tensor_countpatched to 0, no tensor data. That is a faithful vocab source, but it is not a full-model load; the scratch file is not checked in and the test's XML doc records how to regenerate it. Everything else about the parity run is a normal end-to-endGgufFile.Open→GgufBpeTokenizerFactory.Load→Encodeon the real GGUF.Test runs (Release,
--no-build):PreTokenizerPolicyTests18 passed / 0 failed;QwenPreTokenizerParityTests2 passed / 0 skipped. (FullDotLLM.Tests.Unit.Tokenizersnamespace was green at 369 passed / 12 pre-existing HF-fixture skips before the two tests added in review.)Discrimination — added after review, proved by mutation
Parity and discrimination are two different properties; the first revision only had the first. The suite now asserts both.
Amended fixture (reference ids regenerated from llama.cpp b10434, not from dotLLM — this stays a parity test):
"It's 10 ที่ 2026: cafe\u0301 1234.56 — nai\u0308ve/OK\n\tdone"Two measured vocabulary properties made the original fixture blind, and the two added characters defeat each:
\p{N}vs\p{N}{1,3}invisible → qwen2/qwen35 ≡ llama310(U+FF11 U+FF10) — the only multi-digit tokens in either vocab[\p{L}\p{M}]+vs\p{L}+invisible → qwen35 ≡ qwen2café,naïve,Zürichall identical)ที่(U+0E17 U+0E35 U+0E48) — base + vowel sign + tone mark, which does mergeNew
AssertDiscriminatesre-tokenizes the same real vocabulary and merge table under a forcedtokenizer.ggml.preand requires every wrong pipeline —gpt2,llama3,starcoder,gpt-4o, and the other Qwen — to produce a different id stream. Its failure message explains how the discriminators were chosen, so a future fixture edit that reintroduces blindness fails loudly instead of silently.Mutation-tested, not assumed. With the two discriminators removed (i.e. the original fixture) and the parity asserts suppressed so property 2 is what reports, the new assertion fires:
Restored to the amended fixture: 2 passed, 0 skipped. So the assertion is load-bearing rather than vacuously true.
qwen35vstekken(review MEDIUM). These agree exactly on both real vocabularies even with the amended fixture, because neither contains a merge crossing an upper/lower-case boundary — the only place their expressions differ. Asserting it at integration level would be vacuous, so per the review's own guidance it is proved at unit level instead, on a vocabulary built to contain such a merge:Qwen35_KeepsCaseBoundariesInsideTheLetterRun_UnlikeTekken—"aB"with ana Bmerge is 1 token under qwen35's[\p{L}\p{M}]+and 2 under tekken's case-split pair.tekkenis explicitly excluded from the integrationWrongPipelineslist with a comment saying why.Route taken: the strong one — amended fixture with regenerated llama.cpp reference ids, not the fall-back of freezing the old parity test and bolting on separate unit tests. The b10434 release binary was already fetched during the first pass, so no llama.cpp build was needed. (The
qwen35reference still comes from the tensor-less header+KV copy of the Bonsai GGUF, since upstream cannot parse quant type 42 — unchanged, and noted above.)Note on
stablelm2(review LOW, not fixed by design). It matches qwen2 at regex level, but llama.cpp gives itclean_spaces = truewhere qwen2/hunyuan/solar-open getfalse. dotLLM models noclean_spacesconcept at all, so there is no divergence today; it is a pre-existing decode-side gap, not something this PR introduces.Audit —
prevalues llama.cpp knows that dotLLM still does notRequested by #397. All of these currently hit the loud gate. The first group is nearly free — each maps onto a pipeline this file already has:
gpt-2,phi-2,mpt,olmo,jais,trillion,granite-docling,jina-es,jina-de,jina-v2-es,jina-v2-de,gigachat,a.x-4.0,mellum,modern-bertdbrx,smaug-bpe,glm4,chatglm-bpekanana2,talkie,minimax-m2The rest need new expressions:
deepseek-v3,youtu,falcon,jais-2,poro-chat,bloom,gpt3-finnish,viking,chameleon,superbpe,seed-coder,hunyuan-dense,joyai-llm,kimi-k2,grok-2,afmoe,laguna,exaone4,exaone-moe,bailingmoe,bailingmoe2,llada-moe,sarvam-moe,tiny_aya,cohere2moe,granite-embed-multi-97m,granite-embed-multi-311m,minicpm5,jina-v1-en,jina-v2-code,roberta-bpe,whitespace. (gemma4is handled by its own model path, not this table.)I did not add these — out of scope for #397, and each wants its own verification rather than a bulk paste.
Docs
docs/TOKENIZERS.mdnow tabulates every supportedprevalue per pipeline, includingtekken, which #387 shipped but never documented. The unknown-value policy and theDOTLLM_ALLOW_UNKNOWN_PRETOKENIZER=1escape hatch were already documented by #373 and are unchanged.