-
Notifications
You must be signed in to change notification settings - Fork 0
0014 precompiled normalizer
Status: accepted · Date: 2026-08-06
SentencePieceTokenizer named ALBERT, T5, camemBERT and XLM-R in its
documentation, and SentencePieceModelLoader refused all four. Every stock model
of those families is trained with nmt_nfkc, the spm_train default, and the
loader accepted identity alone — correctly, since normalizing differently from
the reference produces different embeddings while looking like it works.
#75 asked for that gap to be
closed, and required the premise to be measured before anything was designed.
Five real models — t5-small, albert-base-v2, camembert-base,
xlm-roberta-base, google/mt5-small:
| Finding | |
|---|---|
normalizer_spec.name |
nmt_nfkc on all five |
precompiled_charsmap |
237 539 bytes on all five, byte-identical (same SHA-256) |
| Loadable before this change | none |
The one spiece.model this library could read was the one it had trained itself.
The issue offered two routes: interpret the compiled map, or reimplement the
named rules on top of string.Normalize(NormalizationForm.FormKC) plus the NMT
adjustments.
The measurement that settled it compares the map against Python's NFKC — the same algorithm .NET exposes — over all 149 251 assigned code points, with the whitespace flags off so only the map speaks. 181 code points differ (0.121 %), in three families:
| Family | Count | Examples |
|---|---|---|
| Dropped by the map, kept by NFKC | 30 | U+0001…U+001F |
| Turned into a space by the map | 15 | U+0009, U+1680, U+200B…U+200F, U+FEFF, U+FFFD, and U+2581 ▁ itself |
| Kept by the map, changed by NFKC | 136 | U+32FF ㋿, U+A7F2…, the U+10780 block |
The first two families are the NMT adjustments — a small fixed table, exactly what Route B would hand-write. The third is the one that decides:
Those 136 code points were added to Unicode after the map was compiled.
U+32FF arrived in Unicode 12.1, U+A7F2… and U+10780… in Unicode 14. The map is
frozen at the Unicode version of the sentencepiece build that produced it;
string.Normalize follows the runtime's ICU. The gap is therefore not a table to
patch once — it grows with every Unicode release, and differs between .NET
versions and platforms for the same input and the same model file.
Byte-exact parity, which is this library's contract and the issue's acceptance criterion, is unreachable that way by construction rather than by effort.
Interpret the blob. PrecompiledNormalizer reads the darts-clone
double-array trie and the replacement strings it indexes, and applies the same
longest-match walk as sentencepiece's Normalizer.
Three consequences follow from the shape of that choice rather than from extra work:
-
Every rule is covered by one implementation.
nmt_nfkc,nfkc, their_cfvariants and any--normalization_rule_tsvcompile to the same kind of blob.tests/oracles/custom_norm.model— three hand-written rules, and anormalizer_spec.nameof merelyuser_defined— is in the corpus to keep that claim honest. -
Validation stays anchored to content. Nothing is decided from
normalizer_spec.name, which preserves the property the old|| hasCharsMapguard encoded: a file that declares one thing and carries another cannot slip through by naming itself well. -
tokenizer.jsonagrees withspiece.model. HuggingFace writes the same blob, base64-encoded, as{"type": "Precompiled"}.TokenizerJsonLoaderreads it through the same class, so the two formats no longer disagree about the same model.
What is refused is now the case that cannot be applied, not the case that was not
enumerated: a normalizer named without a map to apply, a map that will not parse,
and — unchanged — NFKC in a tokenizer.json, which asks for the runtime's
tables where the model asked for a frozen map.
Both were found by the new corpus, not by reading, and both are fixed in the same change. Neither was reachable before: a corpus of self-covered ASCII under a normalizer that did nothing cannot show either.
-
Whitespace. Preprocessing split on every Unicode space.
sentencepiecesplits on U+0020 and nothing else — underidentity,"a\tb"keeps its tab as an ordinary character that the vocabulary either covers or does not. -
Unknown runs.
sentencepieceemits one unknown piece per run of uncovered characters, not one per character: full-widthLEagainst a vocabulary that does not cover it is a single token.
-
tests/oracles/xlmr_fairseq.modelkeeps the stocknmt_nfkcmap rather than having it overwritten withidentity, which is what0013said to revisit on the day this landed. The fixture is now the stock XLM-R pipeline with the vocabulary relabelled — one transformation instead of two — and grows by 237 KB. -
docs/equivalence.mdstates parity over the models that load, and theidentity-only restriction is gone from it. - The normalization pass costs one trie walk per input byte, on text that is usually short relative to the Viterbi search that follows.
- A model whose map this interpreter cannot read is refused rather than partly normalized. Half-normalized text is the same silent failure as no normalization, with a better disguise.
- The 136 divergences will grow. That is a property of the reference, not a
defect here:
sentencepieceitself applies the frozen map, so following it is what parity means.
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels