disarm as a tokenizer preprocessing front-end¶
English-centric subword tokenizers over-fragment non-Latin scripts: the same
sentence costs far more tokens in Hindi or Thai than in English, which raises
inference cost and latency and produces uneven quality across languages. A fast,
deterministic, dependency-free normalizer in front of the tokenizer is a cheap
lever on that problem — and that is exactly the class of transform disarm
already ships (ml_normalize, transliterate, normalization).
This page positions disarm for that tokenizer-efficiency use case and is honest about where it helps and where it does not. For the guardrail-matching and RAG-ingestion recipes, see Using disarm in LLM pipelines; this page is the deterministic-preprocessing / token-fertility companion to it (both build on the LLM pre-processing survey). Every snippet is executed and asserted in CI.
Why token fertility matters¶
"Fertility" is the average number of subword tokens per word (or per character). Recent work shows it is driven by design choices — script, normalization, romanization — not intrinsic difficulty, and that those choices materially affect cost, fairness, and cross-lingual transfer:
- Jung et al. 2025, "Happiness is Sharing a Vocabulary" (arXiv:2510.10827) — romanization beats other input representations in 7 of 8 NER/NLI settings; longer subword tokens shared with pretrained languages drive the gains.
- Limisiewicz et al. 2024, MYTE (arXiv:2403.10691) — encoding choices yield shorter, fairer sequences across 99 languages.
- Shani et al. 2026, "The Roots of Performance Disparity in Multilingual LMs" (arXiv:2601.07220) — multilingual gaps stem largely from tokenization/normalization design, and shrink when those are normalized.
Two deterministic levers¶
disarm offers two complementary front-end transforms. Both are O(1) PHF lookups with ASCII-or-script-stable output and no runtime dependencies.
Normalize, keep the script. ml_normalize applies NFKC, folds emoji to
words, strips accents and case, and collapses whitespace — without romanizing, so
the script is preserved:
from disarm import ml_normalize
assert ml_normalize("CAFÉ") == "cafe"
assert ml_normalize("Привет") == "привет" # stays Cyrillic, normalized
assert ml_normalize("Café — RÉSUMÉ 🎉") == "cafe — resume party popper"
Only the emoji is named. The em dash is punctuation, not an emoji, and stays an em dash — see Emoji, and only emoji below.
use disarm::api;
// ml_normalize(text, lang, emoji_style, fold_case) — lang=None preserves the script
assert_eq!(api::ml_normalize("CAFÉ", None, "cldr", true).unwrap(), "cafe");
assert_eq!(api::ml_normalize("Привет", None, "cldr", true).unwrap(), "привет"); // stays Cyrillic, normalized
assert_eq!(api::ml_normalize("Café — RÉSUMÉ 🎉", None, "cldr", true).unwrap(), "cafe — resume party popper");
// fold_case=false keeps capitals for a cased model (#559).
assert_eq!(api::ml_normalize("CAFÉ", None, "cldr", false).unwrap(), "CAFE");
Case folding is on by default — turn it off for a cased model
ml_normalize folds case, which suits the uncased tokenizers most pipelines use.
In front of a cased model it is a measurable loss, and one an uncased
evaluation harness cannot see: the fold happens before the model, so the harness
scores text that has already lost the signal. Pass fold_case=False to drop that
one step and keep every other stage:
assert ml_normalize("José Martínez") == "jose martinez"
assert ml_normalize("José Martínez", fold_case=False) == "Jose Martinez"
Accents still go — strip_accents is a separate step. If diacritics must survive
too, use normalize_confusables instead and skip the bundle; see
what each entry point costs you.
Romanize to ASCII. transliterate (and the rag_ingest preset) map
non-Latin scripts to a shared Latin representation, which tends to tokenize into
fewer, pretrained-shared subwords. The transliterate lever is available in
every binding; the rag_ingest preset is a Python pipeline:
from disarm import transliterate, get_pipeline
assert transliterate("नमस्ते") == "namaste"
assert transliterate("Привет, мир") == "Privet, mir"
assert get_pipeline("rag_ingest")("Привет, мир!") == "Privet, mir!"
use disarm::api;
assert_eq!(api::transliterate("नमस्ते"), "namaste");
assert_eq!(api::transliterate("Привет, мир"), "Privet, mir");
require "disarm"
Disarm.transliterate("नमस्ते") # => "namaste"
Disarm.transliterate("Привет, мир") # => "Privet, mir"
import { transliterate } from 'disarm'
transliterate('नमस्ते') // => 'namaste'
transliterate('Привет, мир') // => 'Privet, mir'
Pick the lever by path: romanize for an index/matching path; keep the script when the text goes to a multilingual model that reads it natively (see when NOT to use disarm).
"Keeps the script" is not "keeps the writing system" (#754)¶
The page opens on Hindi and Thai, and ml_normalize does keep text in its own script —
it does not romanize. But it strips every combining mark, and in an abugida the
vowel signs are combining marks. So the script survives and the words do not:
from disarm import ml_normalize
ml_normalize("हिन्दी") # 'हनद' — Devanagari, still Devanagari, no longer a word
ml_normalize("मराठी") # 'मरठ'
ml_normalize("မြန်မာ") # 'မနမ' — Myanmar
ml_normalize("বাংলা") # 'বল' — Bengali
ml_normalize("ภาษาไทย") # 'ภาษาไทย' — Thai is unaffected
ml_normalize("Привет") # 'привет' — Cyrillic is unaffected
Measured over assigned code points in each block, the share ml_normalize deletes
outright:
| script | deleted | of assigned |
|---|---|---|
| Myanmar | 58 | 160 |
| Devanagari | 34 | 128 |
| Sinhala | 21 | 91 |
| Bengali | 20 | 96 |
| Tamil | 14 | 72 |
| Thai | 16 | 87 |
Thai is the exception in practice and worth understanding, because it is half of this
page's opening sentence: Thai vowels are written as separate code points that
strip_accents does not classify as marks to remove, so ordinary Thai survives. The
Indic abugidas do not.
If your corpus is Devanagari, Bengali, Tamil, Sinhala or Myanmar, this is the wrong
lever. Use transliterate — which is the other lever on this page and is designed to
lose the script deliberately rather than to lose the vowels accidentally — or apply NFKC
and case folding yourself without strip_accents. ml_normalize is the right tool for
Latin, Cyrillic, Greek and CJK corpora.
Emoji, and only emoji¶
ml_normalize names a character when it carries the Unicode Emoji or
Extended_Pictographic property. Nothing else. The CLDR annotation data disarm reads
also names 326 code points that are not emoji by either property — the curly
apostrophes and quotes, the dashes, the currency signs, the math operators, the CJK
brackets — and naming those inserts words into ordinary prose:
| input | named as (before 0.15.0) | now |
|---|---|---|
’ U+2019 |
right apostrophe |
’ |
— U+2014 |
em dash |
— |
€ U+20AC |
euro |
€ |
• U+2022 |
bullet |
• |
⁄ U+2044 |
fraction slash |
⁄ |
🎉 U+1F389 |
party popper |
party popper |
The measurable difference on body text is the count. A 30-word English sentence
carrying nothing but typographic punctuation came back as 47 words: film’s became
film right apostrophe s, one token to four with the possessive gone. That is the
spurious-token-insertion mechanism adversarial defense
disqualifies unidecode for, and it applied to disarm's own tokenizer front-end
until #757.
demojize is unchanged: called directly it still names every row, because
demojize("I ❤ €5") → "I red heart euro 5" is what that function is for. The
change is about which table wins inside a preset.
To keep every emoji as-is instead, pass emoji="none".
Measuring fertility¶
The metric is tokens-per-word (or per-character) before vs after the transform, across scripts and tokenizers. disarm has no tokenizer dependency, so a measurement wires in whichever tokenizer you target:
# Sketch (requires the external tokenizer; not run in CI):
import tiktoken
from disarm import transliterate
enc = tiktoken.get_encoding("o200k_base") # GPT-4o
text = "नमस्ते दुनिया"
before = len(enc.encode(text))
after = len(enc.encode(transliterate(text))) # romanized → fewer subwords
A reproducible token-fertility benchmark across several non-Latin scripts and multiple tokenizers (with a results table) is tracked as a follow-up to this positioning work; it is intentionally out of CI because it pulls in large, license-gated tokenizers and datasets.
Honest caveats¶
Neither lever is safe on source code (#745). Both end in collapse_whitespace, which
folds LF to a space by design — right for a prompt, wrong for a file. If the text you are
about to normalize is code, use the code_context profile instead; see
Code is not prose.
This matters for the case that looks least like it: an AI coding assistant's context is
source code, and the pages recommending disarm for untrusted context are the pages its
authors read.
Romanization is not a free win, and fertility is not the whole story:
- Compatibility-tier romanization is lossy. For CJK it is context-free and
phonetic, so it does not recover the intended reading —
東京タワー("Tokyo Tower") romanizes via pinyin, not Japanese:
=== "Python"
```python
from disarm import transliterate
assert transliterate("東京タワー") == "dong jing tawa-"
```
=== "Rust"
```rust
use disarm::api;
assert_eq!(api::transliterate("東京タワー"), "dong jing tawa-");
```
=== "Ruby"
```ruby
require "disarm"
Disarm.transliterate("東京タワー") # => "dong jing tawa-"
```
=== "Node"
```ts
import { transliterate } from 'disarm'
transliterate('東京タワー') // => 'dong jing tawa-'
```
Use romanization for matching/indexing where this is acceptable, not where the reading must be preserved. See Limitations. - Fertility alone is a poor quality proxy — Asgari et al. 2025, MorphBPE (arXiv:2502.00894). Fewer tokens is not automatically better; pair it with a downstream-quality check. - Romanization must be high quality or it strips query nuance — Chari et al. 2025, "Lost in Transliteration" (arXiv:2505.08411). - Byte-level / tokenizer-free models (MYTE-style) reduce the need for this front-end entirely; it is a lever for subword tokenizers, not a universal one.
Downstream-quality numbers (CER/WER and abjad indicators) are tracked by the quality-benchmark capstone, #173.
See also¶
- Using disarm in LLM pipelines — guardrail and RAG recipes, and which path to choose.
- Research: Transliteration for LLM Pre-Processing and #172 — the survey and positioning issue.
- Precompiled Pipelines, Limitations.