Summary
normalizers.Precompiled does not reproduce SentencePiece's normalization. For multi-codepoint graphemes under 6 bytes it silently deletes codepoints, producing text that SentencePiece never would.
Two independent defects compound:
spm_precompiled::transform() picks the shortest prefix match, not the longest. SentencePiece uses leftmost-longest matching.
- The match is not required to cover the input chunk, and the remainder is discarded. The caller replaces the entire grapheme with the mapping of a proper prefix.
The user-visible effect: NFD-form text loses its diacritics. For example, NFD Vietnamese Tiếng Việt rất đẹp normalizes to Tiêng Viẹt rât đẹp — tone marks vanish, changing meaning. This is silent; there is no error or warning.
Reproduction
Self-contained — no model download needed. Trains a 2-rule charsmap where one rule is a proper prefix of the other.
import tempfile, os
import sentencepiece as spm
from sentencepiece import sentencepiece_model_pb2
from tokenizers import normalizers
d = tempfile.mkdtemp()
# Two rules; the first key is a proper prefix of the second.
# U+00AA -> U+0061
# U+00AA U+0300 -> U+00E0
tsv = os.path.join(d, "rules.tsv")
with open(tsv, "w", encoding="utf-8") as f:
f.write("00AA\t0061\n")
f.write("00AA 0300\t00E0\n")
corpus = os.path.join(d, "corpus.txt")
with open(corpus, "w", encoding="utf-8") as f:
for _ in range(200):
f.write("hello world abcdefghijklmnopqrstuvwxyz\n")
f.write("ª̀ ª test\n")
spm.SentencePieceTrainer.train(
input=corpus, model_prefix=os.path.join(d, "m"), vocab_size=32,
normalization_rule_tsv=tsv, character_coverage=1.0,
add_dummy_prefix=False, remove_extra_whitespaces=False,
)
proto = sentencepiece_model_pb2.ModelProto()
proto.ParseFromString(open(os.path.join(d, "m.model"), "rb").read())
sp = spm.SentencePieceProcessor(model_proto=proto.SerializeToString())
hf = normalizers.Precompiled(proto.normalizer_spec.precompiled_charsmap)
U = lambda s: " ".join(f"U+{ord(c):04X}" for c in s)
for s in ["ª", "ª̀"]:
a, b = sp.normalize(s), hf.normalize_str(s)
print(f"{U(s):18} SP={U(a):10} HF={U(b):10} {'' if a == b else '<-- MISMATCH'}")
Output:
U+00AA SP=U+0061 HF=U+0061
U+00AA U+0300 SP=U+00E0 HF=U+0061 <-- MISMATCH
U+0300 is dropped. Note the single-codepoint case is fine — the bug needs a multi-codepoint grapheme.
Root cause
1. transform() returns the shortest match
spm_precompiled 0.1.4 (the version tokenizers v0.22.2 resolves to, per bindings/python/Cargo.lock:1066-1067), src/lib.rs:175-191. Current master is unchanged in this logic — it differs only by a base64 API migration, which shifts these to lib.rs:178-194:
pub fn transform(&self, chunk: &str) -> Option<&str> {
let results = self.trie.common_prefix_search(chunk.as_bytes());
if results.is_empty() {
None
} else {
let index = results[0] as usize; // <-- results[0]
common_prefix_search (lib.rs:108-129; master: lib.rs:111-132) walks the key bytes and pushes a value at every prefix that has a leaf, so the vector is ordered by increasing prefix length. results[0] is therefore the shortest match. SentencePiece takes the longest (sentencepiece/src/normalizer.cc, NormalizePrefix).
common_prefix_search also never checks that the key was fully consumed, and it returns only values — not the match lengths — so a caller cannot tell how many bytes a match covered.
2. The caller replaces the whole grapheme
tokenizers/src/normalizers/precompiled.rs:45-53:
normalized.get().graphemes(true).for_each(|grapheme| {
if grapheme.len() < 6 {
if let Some(norm) = self.transform(grapheme) {
modified = true;
replace(&mut transformations, grapheme, norm); // whole grapheme replaced
return; // remainder dropped
}
}
...
Walking the repro through: the grapheme is 4 bytes, so it enters the < 6 fast path. transform matches the 2-byte prefix U+00AA, returns "a", and the whole 4-byte grapheme is replaced by "a". U+0300 is gone.
The >= 6 byte path is not affected the same way — it goes per-character and the else branch (transformations.push((c, 0))) preserves unmatched characters. So the "fast path" is the one that loses data:
U+00AA U+0300 4 bytes fast path -> U+0061 2 codepoints -> 1
U+00AA U+0302 U+0301 6 bytes per-char path -> U+0061 U+0302 U+0301 3 -> 3
U+FF76 U+FF9E 6 bytes per-char path -> U+30AB U+3099 2 -> 2
I realize the < 6 heuristic is deliberate and load-bearing — the comment at precompiled.rs:36-44 is explicit about that, and about it seeming broken. This report is narrower than "remove the heuristic": the data loss comes from combining the whole-chunk replacement with a shortest-prefix lookup.
Proposed fix
Replace the grapheme-segmented loop with SentencePiece's own algorithm: longest-prefix match, then advance by the number of bytes consumed.
This needs a small addition to spm_precompiled, because match lengths are currently not exposed:
// spm_precompiled: additive, non-breaking
impl Precompiled {
/// Longest prefix match. Returns (bytes_consumed, replacement).
pub fn transform_prefix(&self, chunk: &str) -> Option<(usize, &str)> {
// same trie walk as common_prefix_search, but tracking byte position
// and keeping the LAST match instead of the first
}
}
and then in tokenizers/src/normalizers/precompiled.rs:
let mut rest = normalized.get();
while !rest.is_empty() {
if let Some((len, norm)) = self.transform_prefix(rest) {
replace(&mut transformations, &rest[..len], norm);
rest = &rest[len..];
} else {
let c = rest.chars().next().unwrap();
transformations.push((c, 0));
rest = &rest[c.len_utf8()..];
}
}
This removes the need for grapheme segmentation and the < 6 fence entirely, and mirrors normalizer.cc directly.
Scope: roughly 20 lines added in spm_precompiled and 20 lines changed in precompiled.rs. Existing transform() can stay for backwards compatibility.
Summary
normalizers.Precompileddoes not reproduce SentencePiece's normalization. For multi-codepoint graphemes under 6 bytes it silently deletes codepoints, producing text that SentencePiece never would.Two independent defects compound:
spm_precompiled::transform()picks the shortest prefix match, not the longest. SentencePiece uses leftmost-longest matching.The user-visible effect: NFD-form text loses its diacritics. For example, NFD Vietnamese
Tiếng Việt rất đẹpnormalizes toTiêng Viẹt rât đẹp— tone marks vanish, changing meaning. This is silent; there is no error or warning.Reproduction
Self-contained — no model download needed. Trains a 2-rule charsmap where one rule is a proper prefix of the other.
Output:
U+0300is dropped. Note the single-codepoint case is fine — the bug needs a multi-codepoint grapheme.Root cause
1.
transform()returns the shortest matchspm_precompiled0.1.4 (the versiontokenizersv0.22.2 resolves to, perbindings/python/Cargo.lock:1066-1067),src/lib.rs:175-191. Currentmasteris unchanged in this logic — it differs only by abase64API migration, which shifts these tolib.rs:178-194:common_prefix_search(lib.rs:108-129;master:lib.rs:111-132) walks the key bytes and pushes a value at every prefix that has a leaf, so the vector is ordered by increasing prefix length.results[0]is therefore the shortest match. SentencePiece takes the longest (sentencepiece/src/normalizer.cc,NormalizePrefix).common_prefix_searchalso never checks that the key was fully consumed, and it returns only values — not the match lengths — so a caller cannot tell how many bytes a match covered.2. The caller replaces the whole grapheme
tokenizers/src/normalizers/precompiled.rs:45-53:Walking the repro through: the grapheme is 4 bytes, so it enters the
< 6fast path.transformmatches the 2-byte prefixU+00AA, returns"a", and the whole 4-byte grapheme is replaced by"a".U+0300is gone.The
>= 6byte path is not affected the same way — it goes per-character and theelsebranch (transformations.push((c, 0))) preserves unmatched characters. So the "fast path" is the one that loses data:I realize the
< 6heuristic is deliberate and load-bearing — the comment atprecompiled.rs:36-44is explicit about that, and about it seeming broken. This report is narrower than "remove the heuristic": the data loss comes from combining the whole-chunk replacement with a shortest-prefix lookup.Proposed fix
Replace the grapheme-segmented loop with SentencePiece's own algorithm: longest-prefix match, then advance by the number of bytes consumed.
This needs a small addition to
spm_precompiled, because match lengths are currently not exposed:and then in
tokenizers/src/normalizers/precompiled.rs:This removes the need for grapheme segmentation and the
< 6fence entirely, and mirrorsnormalizer.ccdirectly.Scope: roughly 20 lines added in
spm_precompiledand 20 lines changed inprecompiled.rs. Existingtransform()can stay for backwards compatibility.