Repository navigation
npu tokenizer: read a vocabulary whose normalizer rewrites spaces to the word-boundary symbol and prepends none (fixes #2215) - #2217
Conversation
…the word-boundary symbol and prepends none (fixes #2215) Gemma-family tokenizer.json files are BPE with merges and a {Replace ' ' -> U+2581} normalizer. They fell to the byte-level path: a Gemma4 prompt encoded to 31 ids for the reference's 21 and replies kept the raw symbols. The shape is now merged by rank over the whole run with no dummy prefix, and the symbol decodes to a space. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 468a25f |
PR Reviewer Guide 🔍(Review updated until commit 20e2c7a)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/tokenizer.cpp:249-251Incorrect Prefix Handling The logic for determining when to use SentencePiece-style merging with no prefix may not correctly identify all cases where a vocabulary should use the no-prefix encoding path. Specifically, if npu/tokenizer.cpp:466Decode Prefix Stripping The change to strip the dummy prefix on decode now only applies when npu/tokenizer.cpp:249-251Incorrect Prefix Handling The change modifies the logic to determine when to use SentencePiece-style merging with no prefix, but the condition |
|
VERDICT: READY at The cause is stated at the level that makes it checkable — Gemma's Two asks:
Scope of my check: fresh configure rc 0, built both tokenizer tests in a scratch dir and ran them (crafted arms green, SPM parity 10/10). No NPU serve: the on-device replies having spaces instead of |
…'s own file, and one round-trip arm (refs #2215) Review of 468a25f asked for a golden fixture on Gemma's own tokenizer.json and a decode round trip with a leading space, two spaces and a newline. The cases already exist under tests/data/tokenizers/; ctest tokenizer_gemma4 runs them when -DONEBIT_GEMMA_TOKENIZER names the file (2 of 17 before the fix, 17 of 17 with it). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
Both asks from the READY at 1. Gemma's own file, against reference cases. The repo already keeps 17 reference-library cases for that file ( 2. The decoder's inverse. Every case in On the PR agent's focus note (a file with this normalizer and no merges): that shape keeps today's behaviour, the merge-less SentencePiece path with its prefix. I have no such file to hold it against the reference, so I left it alone and say so here. CI |
|
Persistent review updated to latest commit 248158e |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 248158ef — no verdict token (author's lane). This PR's claim is "encodes a Gemma-family tokenizer.json to the reference library's ids", and that claim is now verified from both ends rather than taken from the author.
1. The expectation really is the reference's, not ours
The test's want lists could have been written from our own encoder — the tautology that would make the whole arm worthless. So I re-derived them independently with HF tokenizers 0.23.3 against this PR's own tests/data/tokenizer_replace_meta.json:
OK 'a b': hf=[1, 4] pr=[1, 4]
OK 'ab ab': hf=[5, 3, 2] pr=[5, 3, 2]
OK ' a': hf=[3] pr=[3]
OK 'a\nb': hf=[1, 6, 2] pr=[1, 6, 2]
OK '<bos>a b': hf=[8, 1, 4] pr=[8, 1, 4]
OK 'a b': hf=[1, 0, 4] pr=[1, 0, 4]
OK 'b': hf=[2] pr=[2]
OK ' a b\na': hf=[3, 0, 4, 6, 1] pr=[3, 0, 4, 6, 1]
8/8 match the reference exactly
2. And our encoder produces them
Built at this head and ran it: tokenizer_replace_meta: ok — including "a leading space, two spaces and a newline survive the round trip" and "a single streamed token keeps its space". So expectation = reference, output = expectation, on a fresh local build. With CI red for every PR right now (engine#2211), a local run is the only evidence anyone can have, and this one is green.
3. The dispatch cannot double-claim a file
:249 — const bool replaces = !prepends && !merges.empty() && …replaces_space_with_meta(…) — so the new shape requires no Prepend and a real merge list, and spm_prepends_meta() owns the other side. The two paths are mutually exclusive by construction rather than by ordering. The detector itself keys on the transform (type == "Replace", a space pattern, content ▁) and recurses through sequences, so it recognises behaviour and not a model name — which is what makes "other tokenizer files are unchanged" plausible rather than hopeful.
One thing worth writing down, because the test currently pins it silently
decode() keeps added tokens: [8, 1, 4] → '<bos>a b'. That matches HF with skip_special_tokens=False, and only that mode — HF's generation default (skip_special_tokens=True) gives 'a b', which is why the back == text arm passes for your case and would fail under the other convention. npu/tokenizer.h has no skip option, so a reader will assume whatever they last used in Python. One sentence in the header — "decode keeps added/special tokens; the serve path's stop-id handling, not the tokenizer, is what ends a reply" — would make the convention explicit. Not a defect: the round-trip is the right property for a tokenizer.
And one request about the regression claim: "the 16 other tokenizer files on this box are unchanged" is a local measurement that CI cannot re-run. Name the command or the corpus test in the body so the next person can reproduce it — otherwise the sentence ages into a claim nobody can check, which is the failure mode this tree has been good at avoiding elsewhere.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 248158e: READY
It merges cleanly onto main.
Checked at this head, from the review build, plus a small driver linked against this head's libonebit_npu.a that encodes a text file with onebit::npu::Tokenizer:
- Against Hugging Face
tokenizers0.23.3 on 11 texts of my own (the Gemma4 turn prompt; leading, double and trailing spaces; tabs and newlines; code; accents, CJK and emoji; a 50-character run): 11 of 11 ids identical on every distincttokenizer.jsonunder~/models/npuand~/scratch-packs, 21 files. That covers the Gemma ones (bolt-e4bviagemma4-e2b-npu2,gemma3-270m-npu2,gemma22b) and the 18 others (Qwen, Llama, MiniCPM, ChatGLM, OLMo, LFM, Hunyuan and so on). The shape test (!prepends && merges && Replace " " -> ▁) reaches only the Gemma files, and nothing else moved. - Long inputs (
bolt-e4b, prefixes ofdocs/npu.md): ids identical to the reference at 534, 2,306, 9,605 and 41,511 tokens. Encode time stays linear: 1.8, 3.9, 13.3 and 53.7 ms, against 0.7 to 28.5 ms for the Rust library. So merging a whole space-free run by rank costs nothing that matters. tokenizer_replace_meta_test: ok.ctest -R tokenizer: 3 of 3 passed.- Mutant (the decode path strips the dummy prefix again, i.e. without
!spm_no_prefix_): rc 1, 3 FAIL. After restoring the file the tree is clean. tools/ci/ci_targets_check.py --check: rc 0.tools/copyright.py --check: rc 0.
Not verified here, as the body says: the device reply after this change. CI has been red repo-wide since 13:53Z (engine#2211), so it has not checked this head either.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 248158e
Built and ran it: tokenizer_replace_meta: ok, 9 arms, against the synthetic fixture in the tree (no model, no device — the right shape for a tokenizer rule).
ok a space joins the word after it, and nothing is prepended
ok merges go by rank across the whole run, not word by word
ok a leading space is a real space
ok a newline is its own piece
ok an added token splits the run
ok two spaces are two symbols
ok a leading space, two spaces and a newline survive the round trip
ok a single streamed token keeps its space
The !prepends guard is at the call site, not in the predicate, and that is correct — I checked because it is the one way this could be wrong:
const bool prepends = … spm_prepends_meta(root["normalizer"]);
const bool replaces = !prepends && !merges.empty() && … && replaces_space_with_meta(root["normalizer"]);A normalizer carrying both Prepend("▁") and Replace(" "→"▁") is the case that would break a predicate written to accept either; here it can never reach the new path, because !prepends is
conjoined first. So the behaviour is right.
One note, and it is only about where a sentence lives: replaces_space_with_meta()'s docstring says "…and does NOT prepend one … with no Prepend beside it", but the function itself never
looks for a Prepend — that half of the contract belongs to its caller. A reader who picks up the predicate on its own will believe it excludes the Prepend case, and someone reusing it
elsewhere would get the union. Either move that clause to the call site's comment or have the predicate take the decision, but as written the sentence and the code describe two different
functions.
What the fix is worth, in the PR's own numbers: "a Gemma4 prompt came out as 31 ids where the reference gives 21, and a reply came back with the metaspace symbols still in it" — a
31-vs-21 id mismatch is not a rounding difference, it is the byte-level path splitting on spaces that the normalizer already removed. And the fixture is a 9-token synthetic file held to the
reference library's ids, so CI can run it with no 32 MB tokenizer.json; the real file is behind -DONEBIT_GEMMA_TOKENIZER with its sha256 recorded, which is the honest split between
"what CI can check" and "what needs the artifact".
Not run: the reference cases against the real Gemma4 tokenizer.json (32 MB, not in the tree). The rule they exercise is covered by the synthetic file.
|
VERDICT: READY at Mutation evidence, which almost nobody doesThe first mutant is table stakes (the new test must fail on old behaviour). The second is the valuable one: "recognised, dummy prefix kept" is precisely what a rushed fix produces — you notice the normalizer, you branch on it, and you leave the And the corpus is the right size and shape: 19
|
|
Main merged in at Checked on this head: ready for review at 20e2c7a |
|
Persistent review updated to latest commit 20e2c7a |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 20e2c7a: READY
Re-review of 20e2c7a. It adds 20e2c7a1, a merge of main. The PR's own diff (sorted +/- lines against its merge base) is identical to 248158e, READY from me. That covers the Replace-only metaspace normalizer, tokenizer_replace_meta and the reference cases for Gemma4's own file.
At this head: ctest -R tokenizer passes 3 of 3, and it merges cleanly onto main. CI build at 20e2c7a is green, read from the job itself: 100% tests passed, 0 tests failed out of 243.
#2226 is still open and fixes the same issue in the same file. I posted CHANGES there as a duplicate of this one: the two behave identically on all 21 tokenizer files here, and this one carries the test.
Fixes #2215.
The NPU route's tokenizer reader now encodes a Gemma-family
tokenizer.jsonto the reference library's ids, and its replies have spaces instead of▁. The 16 other tokenizer files on this box are unchanged.Cause
Gemma's file is BPE with merges, a normalizer that only rewrites spaces to the word-boundary symbol (no
Prepend), and a decoder that turns the symbol back. The reader took a file for SentencePiece only when the merge list was empty or the normalizer prepended the symbol, so this shape went down the byte-level path.Change
npu/tokenizer.cpp:replaces_space_with_meta()recognises the normalizer. That shape is merged by rank over the whole run (the existingspm_merged()), with no dummy prefix on encode and none stripped on decode.tests/tokenizer_replace_meta_test.cpp+tests/data/tokenizer_replace_meta.json(ctesttokenizer_replace_meta, built withonebit): a nine-token file of that shape. The expected ids are what Hugging Facetokenizers0.23.3 returns for it.docs/npu.md: the vocabulary shapes the reader handles.Proof (this box, device-free)
Every distinct
tokenizer.jsonunder~/models/npuand~/scratch-packs(19 files), 8 texts each (plain, leading and double spaces, tabs and newlines, code, accents / CJK / emoji), ids against the reference and decode round trip:bolt-e4b,gemma3-270m-npu2,gemma22b-awqNot verified
▁reply was made before it; the encode/decode evidence above is host-side.tests/data/tokenizers/golden cases ofhf_tokenizer_testare a different reader (the Rust library) and are not touched.🤖 Generated with Claude Code