Skip to content

fix(tokenizer): encode non-special added tokens on the SentencePiece path - #2319

Merged
inureyes merged 3 commits into
mainfrom
fix/issue-1758-sp-nonspecial-added-tokens
Oct 15, 2026
Merged

inureyes merged 3 commits into
mainfrom
fix/issue-1758-sp-nonspecial-added-tokens

Conversation

@inureyes

@inureyes inureyes commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Summary

The SentencePiece fallback (tokenizer.model, no tokenizer.json) registered only "special": true added tokens for encoding, so "special": false ones decoded fine but split into 3 to 5 ordinary pieces on encode. It now matches every added_tokens_decoder entry on encode, mirroring the transformers slow tokenizer: leftmost-longest matching, then each token's rstrip / lstrip / single_word applied to its neighbours (new src/tokenizer/sp_added_vocab.rs). special keeps governing only skip_special_tokens on decode and what parse_special: false stops matching (non-special tokens are still matched there, as llama.cpp and the tokenizers backend do). With no decoder table, added_tokens.json supplies plain non-special tokens, as read_added_tokens_sorted does on the tokenizers path.

normalized is ignored (the slow tokenizer reads it only under do_lower_case). IQuest declares </s> single_word: true, so d</s>e is now text, as in transformers.

Survey

Local ~/models/mlx (184 checkpoints): only the two IQuest checkpoints take the SentencePiece path; Baichuan-M1, ERNIE-4.5-0.3B and InternLM3 ship a local tokenizer.json. Remote survey of every docs/model-catalog.tsv repo (153 with a repo id) found seven SentencePiece-path snapshots:

checkpoint non-special entries
IQuest-Coder-V1-7B-Instruct-8bit 8 (<think>, </think>, six tool tags)
IQuest-Coder-V1-40B-Loop-Instruct-4bit (not catalogued) 8, same layout
Baichuan-M1-14B-Instruct-4bit (mlx-community snapshot) 20 (<tool_call>, FIM/repo markers, <C_A>, <B_FUNC>, <function_calling>, ...)
ERNIE-4.5-VL-28B-A3B-Thinking-4bit 1015 (`<
ERNIE-4.5-0.3B-PT-4bit, internlm3-8b-instruct-4bit, TeleChat3-36B-Thinking-4bit 0

No surveyed checkpoint has an added_tokens.json entry missing from its decoder.

Verification

  • Tokenization diff, old vs new binary, on all seven tokenizers (base prompts, each non-special token embedded mid-word and space-delimited, plain and tool-calling prompts rendered through each chat template), checked against a transformers 4.57.1 slow-tokenizer reference: new matches it on every prompt; old mismatched on every prompt containing a non-special token. Prompts without one are unchanged.
  • Greedy ids (mlxcel-server /completion, temp 0, MLXCEL_SDPA_DETERMINISTIC=1, old binary self-parity checked):
    • ~/models/mlx/iquest-coder-v1-7b-instruct-8bit: old self-parity holds. Prompts without non-special tokens give identical prompt and generated ids. The tool-calling chat prompt drops from 193 to 181 tokens and still produces the get_weather call.
    • ~/models/mlx/iquest-coder-v1-40b-loop-instruct-4bit: same pattern; the tool-calling chat prompt goes from 205 to 185 tokens, same tool call.
    • Baichuan-M1 (local weights plus the snapshot's tokenizer files): old self-parity fails on CUDA even for unchanged prompt ids, so its greedy diff is not usable. Its chat template's <C_A> is non-special: the old path echoed the question and a literal <C_A>, while the new one answers.
    • ERNIE-4.5-VL: tokenizer-only (weights not on this box). The image placeholder and its framing markers are now single ids, so the image expansion finds the template's placeholder instead of splicing a second block. Not run end to end with weights.
  • New tests: sp_added_vocab_tests (splitter flags, plus an in-memory SentencePiece model covering token_to_id, encode, skip_special_tokens and parse_special: false) and iquest_tool_prompts_encode_each_tool_tag_as_one_id; the_iquest_loop_coder_tokenizer_round_trips_its_added_tokens now asserts the round trip. With the fix reverted (non-special entries dropped from the encode table), 5 of the new or inverted tests fail. Review follow-ups added splitter edge cases (single_word folds, multibyte contents, a duplicated content) and IQuest's single_word </s>. tokenizer:: (137 pass), fmt, cargo clippy --profile test-fast --features cuda --lib --tests -D warnings and the contract checks pass.

Closes #1758

@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:core mlxcel-core: MLX FFI, primitives, KV cache, layers status:review Under review labels Oct 15, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Oct 15, 2026
…path

The SentencePiece loader registered only `"special": true` entries of `added_tokens_decoder` for encoding, so a non-special added token decoded to its spelling but split into ordinary pieces on encode. On IQuest-Coder and IQuest-Coder Loop that turned every tool tag in a rendered tool-calling prompt into 3 to 5 content tokens instead of the reserved id the model was trained on.

The encode-side split now matches every added token, special or not, the way transformers' slow tokenizer does: leftmost-longest matching followed by the per-token `rstrip`, `lstrip` and `single_word` handling, ported into the new `sp_added_vocab` module. `token_to_id` resolves non-special tokens, `skip_special_tokens` still drops only special ones, and `parse_special: false` keeps matching non-special tokens as llama.cpp and the tokenizers backend do. When a checkpoint has no `added_tokens_decoder`, `added_tokens.json` supplies plain non-special tokens, as on the tokenizers path.

Validated against a transformers 4.57.1 slow-tokenizer reference on the seven catalogued SentencePiece-path tokenizers (IQuest-Coder 7B, IQuest-Coder Loop 40B, Baichuan-M1, ERNIE-4.5-VL-28B, ERNIE-4.5-0.3B, InternLM3, TeleChat3): every prompt now matches it id for id. New unit tests fail with the fix reverted; the pinned IQuest Loop test now asserts the round trip instead of the gap.

Closes #1758
Review follow-ups for #1758. The lenient SentencePiece reader and the strict tokenizers-path reader now share one `added_tokens_decoder` entry parser. `token_to_id` resolves through the same added-vocabulary table encode uses, so a content declared twice (special at a lower id, non-special at a higher one) gives the same id both ways, and the now-unused special-token map is gone. The first-byte index is a flat 256-entry table instead of a hashed map.

New tests cover the single_word right fold using the unstripped neighbour, a single_word token glued to another added token, multibyte contents, the duplicate-content case, and IQuest's single_word `</s>` (text inside `d</s>e`, a token in `x </s> y`). Module docs, CHANGELOG and supported-models wording now describe the parse_special and single_word effects precisely.

Refs #1758
@inureyes
inureyes force-pushed the fix/issue-1758-sp-nonspecial-added-tokens branch from 19d8994 to dca9ede Compare October 15, 2026 18:20
@inureyes
inureyes merged commit 774b677 into main Oct 15, 2026
@inureyes
inureyes deleted the fix/issue-1758-sp-nonspecial-added-tokens branch October 15, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(tokenizer): non-special added tokens do not encode as single ids on the SentencePiece path

1 participant