gigatoken 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/Cargo.lock +3016 -0
  3. data/Cargo.toml +135 -0
  4. data/LICENSE +21 -0
  5. data/README.md +141 -0
  6. data/exe/gigatoken +9 -0
  7. data/ext/gigatoken/Cargo.toml +24 -0
  8. data/ext/gigatoken/extconf.rb +19 -0
  9. data/ext/gigatoken/src/error.rs +20 -0
  10. data/ext/gigatoken/src/gvl.rs +122 -0
  11. data/ext/gigatoken/src/lib.rs +50 -0
  12. data/ext/gigatoken/src/sentencepiece.rs +205 -0
  13. data/ext/gigatoken/src/sources.rs +207 -0
  14. data/ext/gigatoken/src/tokenizer.rs +571 -0
  15. data/lib/gigatoken/cli/bench.rb +64 -0
  16. data/lib/gigatoken/cli/support.rb +132 -0
  17. data/lib/gigatoken/cli/validate.rb +55 -0
  18. data/lib/gigatoken/cli.rb +18 -0
  19. data/lib/gigatoken/hub.rb +242 -0
  20. data/lib/gigatoken/packed_result.rb +48 -0
  21. data/lib/gigatoken/tokenizer.rb +117 -0
  22. data/lib/gigatoken/version.rb +5 -0
  23. data/lib/gigatoken.rb +29 -0
  24. data/rust-toolchain.toml +8 -0
  25. data/src/batch.rs +1808 -0
  26. data/src/bindings/bridge.rs +396 -0
  27. data/src/bindings/hub.rs +42 -0
  28. data/src/bindings/matcher.rs +114 -0
  29. data/src/bindings/mod.rs +14 -0
  30. data/src/bindings/padding.rs +177 -0
  31. data/src/bindings/pretokenize.rs +53 -0
  32. data/src/bindings/sources.rs +273 -0
  33. data/src/bindings/train.rs +125 -0
  34. data/src/bpe/mod.rs +1217 -0
  35. data/src/bpe/pretoken_cache.rs +495 -0
  36. data/src/bpe/sentencepiece.rs +1485 -0
  37. data/src/bpe/tiktoken.rs +2555 -0
  38. data/src/bpe_train.rs +351 -0
  39. data/src/input/decompress.rs +11 -0
  40. data/src/input/file_source.rs +514 -0
  41. data/src/input/jsonl.rs +94 -0
  42. data/src/input/mod.rs +333 -0
  43. data/src/input/parquet.rs +303 -0
  44. data/src/lib.rs +578 -0
  45. data/src/load_tokenizer/hf.rs +1036 -0
  46. data/src/load_tokenizer/hub.rs +344 -0
  47. data/src/load_tokenizer/mod.rs +3 -0
  48. data/src/load_tokenizer/tiktoken.rs +87 -0
  49. data/src/main.rs +95 -0
  50. data/src/pretokenize/fast/cl100k.rs +426 -0
  51. data/src/pretokenize/fast/cl100k_family.rs +891 -0
  52. data/src/pretokenize/fast/deepseek_v3.rs +605 -0
  53. data/src/pretokenize/fast/kimi.rs +281 -0
  54. data/src/pretokenize/fast/mask.rs +1486 -0
  55. data/src/pretokenize/fast/mod.rs +446 -0
  56. data/src/pretokenize/fast/nemotron.rs +138 -0
  57. data/src/pretokenize/fast/o200k.rs +347 -0
  58. data/src/pretokenize/fast/o200k_family.rs +1734 -0
  59. data/src/pretokenize/fast/olmo3.rs +505 -0
  60. data/src/pretokenize/fast/qwen2.rs +429 -0
  61. data/src/pretokenize/fast/qwen3_5.rs +541 -0
  62. data/src/pretokenize/fast/r50k.rs +1250 -0
  63. data/src/pretokenize/mod.rs +1079 -0
  64. data/src/pretokenize/options.rs +188 -0
  65. data/src/pretokenize/pretoken.rs +20 -0
  66. data/src/pretokenize/pretokenize_traits.rs +49 -0
  67. data/src/pretokenize/reference/avx512.rs +522 -0
  68. data/src/pretokenize/reference/combinator.rs +572 -0
  69. data/src/pretokenize/reference/mod.rs +28 -0
  70. data/src/pretokenize/reference/simd.rs +852 -0
  71. data/src/pretokenize/reference/state_machine.rs +365 -0
  72. data/src/pretokenize/unicode.rs +546 -0
  73. data/src/test_hub.rs +28 -0
  74. data/src/token.rs +42 -0
  75. metadata +161 -0
@@ -0,0 +1,2555 @@
1
+ use crate::bpe::pretoken_cache::ShortPretokenCache;
2
+ use crate::bpe::{
3
+ ByteRemapping, MergeScratch, PairRankTable, SHORT_MERGE_MAX, bpe_merge_symbols_by_rank,
4
+ bpe_merge_symbols_ranked, bpe_merge_symbols_ranked_slice, bpe_merge_symbols_short_scalar,
5
+ bpe_merge_symbols_with_scratch, simple_bpe_merge,
6
+ };
7
+ #[cfg(target_arch = "aarch64")]
8
+ use crate::bpe::bpe_merge_symbols_short_neon;
9
+ use crate::pretokenize::{
10
+ FastCl100kPretokenizer, FastDeepSeekV3Pretokenizer, FastOlmo3Pretokenizer,
11
+ FastQwen2Pretokenizer, FastQwen35Pretokenizer, FastR50kPretokenizer, PRETOKEN_CHUNK,
12
+ Pretoken, PretokenSpans, PretokenizerType, SpanBatch, pack_pretoken_key, pretoken_key_hash,
13
+ };
14
+ use crate::token::TokenId;
15
+ use eyre::Result;
16
+ use std::collections::HashMap;
17
+ use std::fmt::{Debug, Formatter};
18
+ use std::sync::Arc;
19
+
20
+ /// Byte-level BPE tokenizer (tiktoken / GPT-2 style).
21
+ ///
22
+ /// Initial symbols are individual bytes (0–255). Merge priority is
23
+ /// determined by the merged token's vocab ID (lower = first), which
24
+ /// equals the merge rank for tiktoken vocabularies.
25
+ pub struct Tokenizer {
26
+ // The model tables (merges, pair_ranks, vocab, vocab_inv) are immutable
27
+ // after construction and shared across forks behind `Arc`: parallel
28
+ // workers read the same few MB of tables instead of holding one deep
29
+ // clone each, which keeps a single copy resident per cache/cluster on
30
+ // the cold miss path and makes forking the tables O(1). The rare
31
+ // mutation (`add_special_token`) goes through `Arc::make_mut`.
32
+ pub(crate) merges: Arc<HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>>,
33
+ /// Flat pair-rank tables replacing `merges` lookups on the miss path's
34
+ /// merge loop; `None` for vocabularies whose IDs don't fit its packed
35
+ /// keys (those keep probing `merges`).
36
+ pair_ranks: Option<Arc<PairRankTable>>,
37
+ /// Explicit merge priorities for rank-mapped vocabularies (fairseq
38
+ /// heritage: RoBERTa/OPT/DeBERTa, whose vocab IDs are frequency-ordered
39
+ /// and carry no rank information). When set, `merges` is empty,
40
+ /// `pair_ranks` is `None`, and every merge runs through the ranked loops
41
+ /// with priority read from this table (`ranked_merge_key(a, b)` →
42
+ /// `(merged, rank)`). `None` for id-as-rank vocabularies (everything
43
+ /// tiktoken-style), whose fast paths are untouched.
44
+ ranked_merges: Option<Arc<RankedMerges>>,
45
+ pub(crate) vocab: Arc<Vec<Arc<[u8]>>>,
46
+ pub(crate) vocab_inv: Arc<HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>>,
47
+ pub(crate) byte_remapping: Option<ByteRemapping>,
48
+ /// Append-only arena of encoded token IDs. Cache entries for encodings
49
+ /// of 5+ tokens store `(offset, len)` slices into this vector; shorter
50
+ /// encodings (well over 99% of hit occurrences) live inline in the
51
+ /// cache entry and never touch it.
52
+ token_arena: Vec<TokenId>,
53
+ /// Pretoken cache for the common case (≤ 15 bytes, ~99.9% of
54
+ /// pretokens). The key packs the bytes into the low 15 bytes and the
55
+ /// length into the top byte of a `u128`, so lookups are a single
56
+ /// inlined 128-bit compare instead of a `memcmp` call. See
57
+ /// `pretoken_cache.rs` for why this is a custom prefetchable table
58
+ /// rather than a `HashMap`.
59
+ pretoken_cache: ShortPretokenCache,
60
+ /// Fallback cache for pretokens longer than 15 bytes.
61
+ pretoken_cache_long: HashMap<Box<[u8]>, (u32, u32), rustc_hash::FxBuildHasher>,
62
+ /// Scratch buffers reused across cache-missing pretokens so the merge loop
63
+ /// performs no per-pretoken allocations.
64
+ merge_scratch: MergeScratch,
65
+ symbol_scratch: Vec<TokenId>,
66
+ /// Pretokenization scheme used by [`Self::encode_with_added_tokens`].
67
+ pub(crate) pretokenizer_type: PretokenizerType,
68
+ /// Added tokens (special and non-special), matched atomically in the raw
69
+ /// input before pretokenization, like HuggingFace's AddedVocabulary.
70
+ added_tokens: Vec<AddedTokenDef>,
71
+ /// Leftmost-longest Aho-Corasick automaton over `added_tokens` contents
72
+ /// (pattern index == `added_tokens` index). A prebuilt automaton keeps the
73
+ /// scan fast even when an added token starts with a byte that is common in
74
+ /// text (ModernBERT has 23 space-run added tokens, so a first-byte
75
+ /// candidate scan would probe on every space). Clones share the automaton
76
+ /// via its internal `Arc`.
77
+ added_matcher: Option<aho_corasick::AhoCorasick>,
78
+ /// Apply NFC normalization to non-added-token segments before
79
+ /// pretokenization, like HuggingFace's `NFC` normalizer (e.g. Qwen2).
80
+ normalize_nfc: bool,
81
+ /// HF `ByteLevel(add_prefix_space=true)` (RoBERTa-style exports): every
82
+ /// non-empty added-token-split segment that does not already start with
83
+ /// a space gets one prepended before pretokenization.
84
+ add_prefix_space: bool,
85
+ /// HF BPE `ignore_merges`: a pretoken whose whole byte string is a
86
+ /// vocab entry encodes as that single ID without running the merge
87
+ /// loop. Matters when the vocab has whole-word entries the merges
88
+ /// would decompose differently (GLM-5.2 has ~97k such words); a plain
89
+ /// merge walk diverges from HF on those.
90
+ ignore_merges: bool,
91
+ }
92
+
93
+ /// NFC-normalize a segment if needed, using `buf` as scratch on the slow path.
94
+ ///
95
+ /// ASCII and already-normalized segments are returned as-is. Invalid UTF-8 is
96
+ /// passed through unchanged (HF only ever sees `str`, so there is no parity
97
+ /// behavior to match).
98
+ fn nfc_segment<'a>(seg: &'a [u8], buf: &'a mut String) -> &'a [u8] {
99
+ if seg.is_ascii() {
100
+ return seg;
101
+ }
102
+ let Ok(s) = std::str::from_utf8(seg) else {
103
+ return seg;
104
+ };
105
+ let nfc = icu::normalizer::ComposingNormalizer::new_nfc();
106
+ if nfc.is_normalized(s) {
107
+ return seg;
108
+ }
109
+ buf.clear();
110
+ nfc.normalize_to(s, buf)
111
+ .expect("writing to a String cannot fail");
112
+ buf.as_bytes()
113
+ }
114
+
115
+ /// Cache-value packing (shared by the short-pretoken table and decode in
116
+ /// the encode loop). `val` low byte: token count in bits 0-6 plus a
117
+ /// "spilled" flag in bit 7. Inline values (1-4 tokens; only the first ID
118
+ /// must fit 24 bits — true of every real vocab) carry tokens 1-2 in `val`
119
+ /// bits 8-31 and 32-63 and tokens 3-4 in `ext`'s two u32 lanes; spilled
120
+ /// values carry the token-arena offset in `val`'s high 32 bits and leave
121
+ /// `ext` unused.
122
+ const VAL_SPILL: u64 = 0x80;
123
+
124
+ #[inline(always)]
125
+ fn pack_val_inline(symbols: &[TokenId]) -> Option<(u64, u64)> {
126
+ match *symbols {
127
+ [a] if a.0 < (1 << 24) => Some((1 | ((a.0 as u64) << 8), 0)),
128
+ [a, b] if a.0 < (1 << 24) => {
129
+ Some((2 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32), 0))
130
+ }
131
+ [a, b, c] if a.0 < (1 << 24) => Some((
132
+ 3 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32),
133
+ c.0 as u64,
134
+ )),
135
+ [a, b, c, d] if a.0 < (1 << 24) => Some((
136
+ 4 | ((a.0 as u64) << 8) | ((b.0 as u64) << 32),
137
+ c.0 as u64 | ((d.0 as u64) << 32),
138
+ )),
139
+ _ => None,
140
+ }
141
+ }
142
+
143
+ /// View a `TokenId` slice as its underlying `u32`s (repr(transparent)),
144
+ /// so bulk emits are `extend_from_slice` memcpys instead of per-element
145
+ /// iterator writes.
146
+ #[inline(always)]
147
+ fn token_ids_as_u32s(toks: &[TokenId]) -> &[u32] {
148
+ // SAFETY: TokenId is #[repr(transparent)] over u32.
149
+ unsafe { std::slice::from_raw_parts(toks.as_ptr() as *const u32, toks.len()) }
150
+ }
151
+
152
+ /// Unpack an inline value's four token lanes (lanes past the count are
153
+ /// another key's leftovers; callers truncate by the count).
154
+ #[inline(always)]
155
+ fn unpack_val_lanes(val: u64, ext: u64) -> [u32; 4] {
156
+ [
157
+ (val >> 8) as u32 & 0xFF_FFFF,
158
+ (val >> 32) as u32,
159
+ ext as u32,
160
+ (ext >> 32) as u32,
161
+ ]
162
+ }
163
+
164
+ /// One piece of the added-token pipeline walk (see
165
+ /// [`Tokenizer::for_each_piece`]): a between-occurrences text segment to
166
+ /// pretokenize and encode (paired with its source byte offset, which only
167
+ /// the verify-heavy differential reads), or an added token's ID to emit
168
+ /// verbatim.
169
+ enum Piece<'a> {
170
+ Segment(&'a [u8], usize),
171
+ Added(TokenId),
172
+ }
173
+
174
+ /// Explicit merge-priority table for rank-mapped vocabularies:
175
+ /// `ranked_merge_key(a, b)` → `(merged, rank)`. Same shape as the
176
+ /// SentencePiece engine's merge table.
177
+ pub(crate) type RankedMerges = HashMap<u64, (TokenId, u32), rustc_hash::FxBuildHasher>;
178
+
179
+ /// One added token as configured by the loader: byte content, emitted ID, and
180
+ /// HF `AddedToken` whitespace-stripping flags (`lstrip` absorbs whitespace
181
+ /// before a match, `rstrip` absorbs whitespace after it). Content is shared
182
+ /// (`Arc`) so forks clone entries cheaply.
183
+ #[derive(Clone, Debug)]
184
+ pub struct AddedTokenDef {
185
+ pub content: Arc<[u8]>,
186
+ pub id: TokenId,
187
+ pub lstrip: bool,
188
+ pub rstrip: bool,
189
+ }
190
+
191
+ /// Byte offset after the leading Unicode whitespace of `bytes` (the set of
192
+ /// `str::trim_start`, which is what HF's `\s*` sees). Invalid UTF-8 stops the
193
+ /// scan.
194
+ fn trim_ws_start(bytes: &[u8]) -> usize {
195
+ let mut pos = 0;
196
+ while pos < bytes.len() {
197
+ let width = match bytes[pos] {
198
+ 0x00..=0x7F => 1,
199
+ 0xC0..=0xDF => 2,
200
+ 0xE0..=0xEF => 3,
201
+ 0xF0..=0xF7 => 4,
202
+ _ => break,
203
+ };
204
+ let Some(chunk) = bytes.get(pos..pos + width) else {
205
+ break;
206
+ };
207
+ match std::str::from_utf8(chunk) {
208
+ Ok(s) if s.chars().next().is_some_and(char::is_whitespace) => pos += width,
209
+ _ => break,
210
+ }
211
+ }
212
+ pos
213
+ }
214
+
215
+ /// Length of `bytes` after trimming trailing Unicode whitespace (the set of
216
+ /// `str::trim_end`). Invalid UTF-8 stops the scan.
217
+ fn trim_ws_end(bytes: &[u8]) -> usize {
218
+ let mut end = bytes.len();
219
+ while end > 0 {
220
+ // Back up over at most 3 continuation bytes to the character start.
221
+ let mut start = end - 1;
222
+ while start > 0 && (bytes[start] & 0xC0) == 0x80 && end - start < 4 {
223
+ start -= 1;
224
+ }
225
+ match std::str::from_utf8(&bytes[start..end]) {
226
+ Ok(s) if s.chars().next().is_some_and(char::is_whitespace) => end = start,
227
+ _ => break,
228
+ }
229
+ }
230
+ end
231
+ }
232
+
233
+ /// Overwrite the short-cache entry of every added-token content of 1..=15
234
+ /// bytes that resolves in `vocab_inv` with that single ID, so a matching
235
+ /// pretoken encodes as the added token rather than its merge decomposition.
236
+ ///
237
+ /// This function IS the cache-seed sync invariant: the short cache's
238
+ /// seed-level state is always "vocab seed, then these overwrites" — a pure
239
+ /// function of `(vocab, added_tokens)` — because both
240
+ /// [`Tokenizer::set_added_tokens`] (on the parent) and
241
+ /// [`Tokenizer::fork_sized`] (after a fork's fresh reseed) apply the
242
+ /// overwrites through this one body, so parent and forked workers always
243
+ /// agree on every short pretoken.
244
+ fn apply_added_token_overwrites(
245
+ added_tokens: &[AddedTokenDef],
246
+ vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
247
+ pretoken_cache: &mut ShortPretokenCache,
248
+ token_arena: &mut Vec<TokenId>,
249
+ ) {
250
+ for tok in added_tokens {
251
+ let content = &tok.content;
252
+ if !(1..=15).contains(&content.len()) {
253
+ continue;
254
+ }
255
+ let Some(&id) = vocab_inv.get(content) else {
256
+ continue;
257
+ };
258
+ let key = pack_pretoken_key(content).expect("length checked <= 15");
259
+ let h = pretoken_key_hash(key);
260
+ let (val, ext) = Tokenizer::pack_val(&[id], token_arena);
261
+ pretoken_cache.replace(key, h, val, ext);
262
+ }
263
+ }
264
+
265
+ impl Tokenizer {
266
+ pub fn new(
267
+ merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
268
+ vocab: Vec<Vec<u8>>,
269
+ byte_remapping: Option<ByteRemapping>,
270
+ ) -> Self {
271
+ let vocab = vocab.into_iter().map(Into::into).collect();
272
+ Self::from_tables(merges, None, vocab, byte_remapping)
273
+ }
274
+
275
+ /// Construct from an explicit-rank merge table (`ranked_merge_key(a, b)`
276
+ /// → `(merged, rank)`), for vocabularies whose IDs do not follow merge
277
+ /// order (see `ranked_merges`). Every merge runs through the ranked
278
+ /// loops; the id-as-rank fast paths stay off.
279
+ pub fn new_ranked(
280
+ ranked_merges: RankedMerges,
281
+ vocab: Vec<Vec<u8>>,
282
+ byte_remapping: Option<ByteRemapping>,
283
+ ) -> Self {
284
+ let vocab = vocab.into_iter().map(Into::into).collect();
285
+ Self::from_tables(HashMap::default(), Some(ranked_merges), vocab, byte_remapping)
286
+ }
287
+
288
+ /// Shared construction tail ([`Self::new`], [`Self::new_ranked`] and
289
+ /// [`Self::from_ranks`]): derive `vocab_inv` and the pair-rank table
290
+ /// from the finished merges/vocab, seed the pretoken cache, and
291
+ /// assemble the tokenizer with default pipeline settings (GPT-2
292
+ /// pretokenization, no added tokens, no NFC).
293
+ fn from_tables(
294
+ merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
295
+ ranked_merges: Option<RankedMerges>,
296
+ vocab: Vec<Arc<[u8]>>,
297
+ byte_remapping: Option<ByteRemapping>,
298
+ ) -> Self {
299
+ let vocab_inv: HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher> = vocab
300
+ .iter()
301
+ .cloned()
302
+ .zip((0..).map(TokenId::from))
303
+ .collect();
304
+ let ranked_merges = ranked_merges.map(Arc::new);
305
+ let pair_ranks = if ranked_merges.is_none() {
306
+ PairRankTable::build(&merges, byte_remapping.as_ref(), vocab.len()).map(Arc::new)
307
+ } else {
308
+ None
309
+ };
310
+ let mut token_arena = Vec::new();
311
+ let pretoken_cache = Self::seeded_pretoken_cache(
312
+ &vocab,
313
+ byte_remapping.as_ref(),
314
+ pair_ranks.as_deref(),
315
+ &merges,
316
+ ranked_merges.as_deref(),
317
+ false,
318
+ &vocab_inv,
319
+ &mut token_arena,
320
+ 0,
321
+ );
322
+ Tokenizer {
323
+ merges: Arc::new(merges),
324
+ pair_ranks,
325
+ ranked_merges,
326
+ vocab_inv: Arc::new(vocab_inv),
327
+ vocab: Arc::new(vocab),
328
+ byte_remapping,
329
+ token_arena,
330
+ pretoken_cache,
331
+ pretoken_cache_long: HashMap::with_hasher(rustc_hash::FxBuildHasher {}),
332
+ merge_scratch: MergeScratch::default(),
333
+ symbol_scratch: Vec::new(),
334
+ pretokenizer_type: PretokenizerType::GPT2,
335
+ added_tokens: Vec::new(),
336
+ added_matcher: None,
337
+ normalize_nfc: false,
338
+ add_prefix_space: false,
339
+ ignore_merges: false,
340
+ }
341
+ }
342
+
343
+ /// A short-pretoken cache pre-seeded with the BPE encoding of every
344
+ /// vocab entry of 1..=15 bytes: precomputed miss results, computed by
345
+ /// the same [`Self::merge_short`] the miss path runs, so a seeded
346
+ /// value is bit-identical to what a cold miss on those bytes would
347
+ /// have produced and cached. Any short pretoken that is a whole vocab
348
+ /// word then hits the cache outright, so the miss path never sees one.
349
+ ///
350
+ /// Without `ignore_merges`, the seed value must be the MERGE RESULT,
351
+ /// not the entry's own ID: BPE encode semantics (HF `tokenizers`
352
+ /// without `ignore_merges`, this repo's merge loop, and the pre-cache
353
+ /// baseline 0e27c71) produce a whole-word token only when the merge
354
+ /// rules can derive it, and vocabs may contain merge-UNREACHABLE
355
+ /// entries — qwen3_5 has ~200 (multi-char CJK phrases, " Jap\u{f3}n",
356
+ /// …) that must encode as their merge decomposition. Seeding
357
+ /// `bytes -> [own id]` was a measured divergence from HF (see
358
+ /// `verify_vocab_seeded_cache_matches_merge_decomposition`). For
359
+ /// merge-reachable entries — all of gpt2/olmo3/qwen2/deepseek_v3 —
360
+ /// the merge result is the single own ID, as before. Duplicate byte
361
+ /// strings encode identically (the merge sees only bytes), so the
362
+ /// insert-if-absent dedup is purely a work-skip.
363
+ ///
364
+ /// WITH `ignore_merges` the rule flips: HF emits the vocab entry's own
365
+ /// ID for any whole-pretoken vocab hit, so every seed value is
366
+ /// `[vocab_inv[bytes]]` (`vocab_inv` also resolves duplicate byte
367
+ /// strings to the one ID a lookup would find).
368
+ ///
369
+ /// `min_slots` additionally floors the table size for a worker with a
370
+ /// known workload (see [`Self::fork_sized`]); the table is built once
371
+ /// at the max of the seed requirement and that floor, so seeding never
372
+ /// grows it mid-way. Values of 5+ tokens (only possible for
373
+ /// merge-unreachable entries) spill into `token_arena` like any other
374
+ /// miss.
375
+ fn seeded_pretoken_cache(
376
+ vocab: &[Arc<[u8]>],
377
+ byte_remapping: Option<&ByteRemapping>,
378
+ pair_ranks: Option<&PairRankTable>,
379
+ merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
380
+ ranked_merges: Option<&RankedMerges>,
381
+ ignore_merges: bool,
382
+ vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
383
+ token_arena: &mut Vec<TokenId>,
384
+ min_slots: usize,
385
+ ) -> ShortPretokenCache {
386
+ let n_short = vocab
387
+ .iter()
388
+ .filter(|bytes| (1..=15).contains(&bytes.len()))
389
+ .count();
390
+ let mut cache = ShortPretokenCache::with_at_least(n_short, min_slots);
391
+ let mut buf = [TokenId(0); SHORT_MERGE_MAX];
392
+ for bytes in vocab {
393
+ if !(1..=15).contains(&bytes.len()) {
394
+ continue;
395
+ }
396
+ let key = pack_pretoken_key(bytes).expect("length checked <= 15");
397
+ let h = pretoken_key_hash(key);
398
+ // Duplicate byte strings seed the same value (see the doc
399
+ // above), so insertion order is irrelevant and the
400
+ // insert-if-absent check only skips redundant merges.
401
+ if cache.get_or_slot(key, h).is_err() {
402
+ let n = Self::seed_symbols_any(
403
+ byte_remapping,
404
+ pair_ranks,
405
+ merges,
406
+ ranked_merges,
407
+ ignore_merges,
408
+ vocab_inv,
409
+ bytes,
410
+ &mut buf,
411
+ );
412
+ let (val, ext) = Self::pack_val(&buf[..n], token_arena);
413
+ cache.insert(key, h, val, ext);
414
+ }
415
+ }
416
+ cache
417
+ }
418
+
419
+ /// Seed-level encoding of one short vocab byte string under the
420
+ /// current `ignore_merges` setting: the single `vocab_inv` ID when the
421
+ /// flag is set (HF's whole-pretoken vocab hit), the merge
422
+ /// decomposition otherwise. One body shared by
423
+ /// [`Self::seeded_pretoken_cache`] and [`Self::set_ignore_merges`] so
424
+ /// a fork's fresh reseed and the parent's in-place rewrite always
425
+ /// agree.
426
+ #[inline]
427
+ fn seed_symbols(
428
+ byte_remapping: Option<&ByteRemapping>,
429
+ pair_ranks: Option<&PairRankTable>,
430
+ merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
431
+ ignore_merges: bool,
432
+ vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
433
+ bytes: &[u8],
434
+ buf: &mut [TokenId; SHORT_MERGE_MAX],
435
+ ) -> usize {
436
+ if ignore_merges {
437
+ if let Some(&id) = vocab_inv.get(bytes) {
438
+ buf[0] = id;
439
+ return 1;
440
+ }
441
+ }
442
+ Self::merge_short(byte_remapping, pair_ranks, merges, bytes, buf)
443
+ }
444
+
445
+ /// [`Self::seed_symbols`] for either merge-table shape: dispatches to
446
+ /// the ranked variant when `ranked_merges` is set. Load-time call sites
447
+ /// (cache seeding, flag flips) go through this; the per-pretoken miss
448
+ /// path dispatches once per pretoken instead (see
449
+ /// [`Self::encode_pretoken_miss`]), keeping the id-as-rank miss
450
+ /// codegen identical to a build without ranked support.
451
+ #[inline]
452
+ fn seed_symbols_any(
453
+ byte_remapping: Option<&ByteRemapping>,
454
+ pair_ranks: Option<&PairRankTable>,
455
+ merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
456
+ ranked_merges: Option<&RankedMerges>,
457
+ ignore_merges: bool,
458
+ vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
459
+ bytes: &[u8],
460
+ buf: &mut [TokenId; SHORT_MERGE_MAX],
461
+ ) -> usize {
462
+ match ranked_merges {
463
+ Some(rm) => Self::seed_symbols_ranked(
464
+ byte_remapping,
465
+ rm,
466
+ ignore_merges,
467
+ vocab_inv,
468
+ bytes,
469
+ buf,
470
+ ),
471
+ None => Self::seed_symbols(
472
+ byte_remapping,
473
+ pair_ranks,
474
+ merges,
475
+ ignore_merges,
476
+ vocab_inv,
477
+ bytes,
478
+ buf,
479
+ ),
480
+ }
481
+ }
482
+
483
+ /// Ranked-merge-table variant of [`Self::seed_symbols`].
484
+ fn seed_symbols_ranked(
485
+ byte_remapping: Option<&ByteRemapping>,
486
+ ranked_merges: &RankedMerges,
487
+ ignore_merges: bool,
488
+ vocab_inv: &HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher>,
489
+ bytes: &[u8],
490
+ buf: &mut [TokenId; SHORT_MERGE_MAX],
491
+ ) -> usize {
492
+ if ignore_merges {
493
+ if let Some(&id) = vocab_inv.get(bytes) {
494
+ buf[0] = id;
495
+ return 1;
496
+ }
497
+ }
498
+ let n = bytes.len();
499
+ debug_assert!((1..SHORT_MERGE_MAX).contains(&n));
500
+ match byte_remapping {
501
+ Some(br) => {
502
+ for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
503
+ *dst = br.mapping[b as usize];
504
+ }
505
+ }
506
+ None => {
507
+ for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
508
+ *dst = TokenId(b as u32);
509
+ }
510
+ }
511
+ }
512
+ if n < 2 {
513
+ return n;
514
+ }
515
+ bpe_merge_symbols_ranked_slice(ranked_merges, &mut buf[..n])
516
+ }
517
+
518
+ /// BPE-encode one short pretoken (1..=15 bytes) into `buf`, returning
519
+ /// its token count: byte remapping, then the short merge loop. This is
520
+ /// exactly the computation [`Self::encode_pretoken_miss`] performs for
521
+ /// short keys — shared with [`Self::seeded_pretoken_cache`] so the
522
+ /// vocab seed can never disagree with a cold miss.
523
+ #[inline]
524
+ fn merge_short(
525
+ byte_remapping: Option<&ByteRemapping>,
526
+ pair_ranks: Option<&PairRankTable>,
527
+ merges: &HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher>,
528
+ bytes: &[u8],
529
+ buf: &mut [TokenId; SHORT_MERGE_MAX],
530
+ ) -> usize {
531
+ let n = bytes.len();
532
+ debug_assert!((1..SHORT_MERGE_MAX).contains(&n));
533
+ match byte_remapping {
534
+ Some(br) => {
535
+ for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
536
+ *dst = br.mapping[b as usize];
537
+ }
538
+ }
539
+ None => {
540
+ for (dst, &b) in buf[..n].iter_mut().zip(bytes) {
541
+ *dst = TokenId(b as u32);
542
+ }
543
+ }
544
+ }
545
+ if n < 2 {
546
+ return n;
547
+ }
548
+ match pair_ranks {
549
+ #[cfg(target_arch = "aarch64")]
550
+ Some(table) => bpe_merge_symbols_short_neon(table, buf, n),
551
+ // x86-64 stays scalar ON PURPOSE: the AVX-512/AVX2 ports of the
552
+ // min-rank scan (`bpe_merge_symbols_short_avx512/_avx2`, kept as
553
+ // tested reference) measured ~1% SLOWER on cold encode_st (Zen 5,
554
+ // gpt2, 100 MB and 1 GB OWT, interleaved min-of-5) — the x86
555
+ // horizontal reduce is a 4-step dependent chain plus a
556
+ // vector->GPR transfer on the serial merge chain, and the
557
+ // `target_feature` boundary blocks inlining, while the scalar
558
+ // scan's `rank < best` branches predict well on Zen 5. See
559
+ // profiling/x86_port_plan.md §6.
560
+ #[cfg(not(target_arch = "aarch64"))]
561
+ Some(table) => bpe_merge_symbols_short_scalar(
562
+ |a, b| table.rank(a, b),
563
+ |a, b| table.prefetch_rank(a, b),
564
+ buf,
565
+ n,
566
+ ),
567
+ None => bpe_merge_symbols_short_scalar(
568
+ |a, b| merges.get(&(a, b)).map_or(u32::MAX, |m| m.0),
569
+ |_, _| {},
570
+ buf,
571
+ n,
572
+ ),
573
+ }
574
+ }
575
+
576
+ /// Pack a cache value: inline when possible, else spilled to the arena.
577
+ #[inline(always)]
578
+ fn pack_val(symbols: &[TokenId], token_arena: &mut Vec<TokenId>) -> (u64, u64) {
579
+ pack_val_inline(symbols).unwrap_or_else(|| {
580
+ let offset = token_arena.len() as u64;
581
+ token_arena.extend_from_slice(symbols);
582
+ (VAL_SPILL | symbols.len() as u64 | (offset << 32), 0)
583
+ })
584
+ }
585
+
586
+ /// Given a list of tokens in rank order (by merge order), reconstructs the
587
+ /// merges map and returns a Tokenizer.
588
+ ///
589
+ /// This process is necessary to load some tokenizers found in tiktoken.
590
+ pub fn from_ranks(vocab: Vec<Vec<u8>>) -> Result<Self> {
591
+ let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
592
+ HashMap::with_hasher(rustc_hash::FxBuildHasher {});
593
+ let vocab = vocab
594
+ .into_iter()
595
+ .map(Into::into)
596
+ .collect::<Vec<Arc<[u8]>>>();
597
+ let vocab_inv: HashMap<Arc<[u8]>, TokenId, rustc_hash::FxBuildHasher> = vocab
598
+ .iter()
599
+ .cloned()
600
+ .zip((0..).map(TokenId::from))
601
+ .collect();
602
+
603
+ for (token_idx, token_bytes) in vocab.iter().cloned().enumerate() {
604
+ if token_bytes.len() < 2 {
605
+ continue;
606
+ }
607
+ let byte_symbols: Vec<u8> = token_bytes
608
+ .iter()
609
+ .map(|b| vocab_inv.get(std::slice::from_ref(b)).unwrap().0 as u8)
610
+ .collect();
611
+ let tokenized = simple_bpe_merge(&merges, &byte_symbols);
612
+ assert_eq!(tokenized.len(), 2);
613
+ merges.insert((tokenized[0], tokenized[1]), TokenId::from(token_idx));
614
+ }
615
+
616
+ let byte_remapping = ByteRemapping::from_byte_vocab(&vocab)?;
617
+ Ok(Self::from_tables(merges, None, vocab, byte_remapping))
618
+ }
619
+
620
+ /// Create a new tokenizer sharing the same model data but with a
621
+ /// freshly seeded cache (no encoded pretokens beyond the vocab seed).
622
+ /// Useful for per-thread encoding in parallel.
623
+ pub fn fork(&self) -> Self {
624
+ self.fork_sized(0)
625
+ }
626
+
627
+ /// [`Self::fork`] with the caches pre-sized for a worker expected to
628
+ /// encode roughly `expected_bytes` of input. On a cold parallel run a
629
+ /// default-sized worker rehashes its pretoken table through 6-7
630
+ /// doublings — random scatter writes into a fresh zeroed allocation
631
+ /// each time, on every worker at once; sizing from the input share
632
+ /// pays for the table exactly once. The estimates are capacity hints
633
+ /// only: every structure still grows past them as needed, and the
634
+ /// clamps keep tiny inputs at the default size. The short-table size
635
+ /// is a floor passed through the vocab seeding, so the seed
636
+ /// requirement and the workload estimate resolve to one table
637
+ /// construction (whichever is larger).
638
+ pub(crate) fn fork_sized(&self, expected_bytes: usize) -> Self {
639
+ // Distinct short pretokens follow Heaps' law: ~1.3M at 1 GB and
640
+ // ~5.5M at 10 GB of OWT-like text gives distinct(n) ≈ 3.45·n^0.62.
641
+ // Size for the Heaps estimate at the table's 3/4 growth load with
642
+ // 1.4x headroom (self-paced chunk handout lets a fast core encode
643
+ // more than its even share; the margin holds a >2x-oversubscribed
644
+ // worker under the growth threshold before the table would resize).
645
+ // Still a capacity hint: the table grows past it at 3/4 load on
646
+ // corpora more diverse than the OWT calibration. Clamped to 2^22
647
+ // slots (128 MB) per worker.
648
+ let distinct = 3.45 * (expected_bytes as f64).powf(0.62);
649
+ let cache_slots = ((distinct * (4.0 / 3.0) * 1.4) as usize)
650
+ .clamp(1 << 16, 1 << 22)
651
+ .next_power_of_two();
652
+ let arena_cap = (expected_bytes / 256).min(1 << 24);
653
+ let long_cap = (expected_bytes / 8192).min(1 << 20);
654
+ let mut token_arena = Vec::with_capacity(arena_cap);
655
+ let mut pretoken_cache = Self::seeded_pretoken_cache(
656
+ &self.vocab,
657
+ self.byte_remapping.as_ref(),
658
+ self.pair_ranks.as_deref(),
659
+ &self.merges,
660
+ self.ranked_merges.as_deref(),
661
+ self.ignore_merges,
662
+ &self.vocab_inv,
663
+ &mut token_arena,
664
+ cache_slots,
665
+ );
666
+ // The vocab seed above holds the plain seed encoding of every short
667
+ // byte string (merge result, or own ID under `ignore_merges`);
668
+ // re-apply the added-token `[id]` overwrites so the fork's cache
669
+ // matches the parent's seed-level state (the shared function is the
670
+ // sync invariant — see `apply_added_token_overwrites`).
671
+ apply_added_token_overwrites(
672
+ &self.added_tokens,
673
+ &self.vocab_inv,
674
+ &mut pretoken_cache,
675
+ &mut token_arena,
676
+ );
677
+ Tokenizer {
678
+ merges: Arc::clone(&self.merges),
679
+ pair_ranks: self.pair_ranks.clone(),
680
+ ranked_merges: self.ranked_merges.clone(),
681
+ vocab: Arc::clone(&self.vocab),
682
+ vocab_inv: Arc::clone(&self.vocab_inv),
683
+ byte_remapping: self.byte_remapping.clone(),
684
+ token_arena,
685
+ pretoken_cache,
686
+ pretoken_cache_long: HashMap::with_capacity_and_hasher(
687
+ long_cap,
688
+ rustc_hash::FxBuildHasher {},
689
+ ),
690
+ merge_scratch: MergeScratch::default(),
691
+ symbol_scratch: Vec::new(),
692
+ pretokenizer_type: self.pretokenizer_type,
693
+ added_tokens: self.added_tokens.clone(),
694
+ added_matcher: self.added_matcher.clone(),
695
+ normalize_nfc: self.normalize_nfc,
696
+ add_prefix_space: self.add_prefix_space,
697
+ ignore_merges: self.ignore_merges,
698
+ }
699
+ }
700
+
701
+ /// Loader-phase mutator: like every `Tokenizer` mutation, this must
702
+ /// run before any `WorkerPool` forks workers from this tokenizer —
703
+ /// already-forked workers keep the old state (see [`WorkerPool`]).
704
+ ///
705
+ /// [`WorkerPool`]: crate::batch::WorkerPool
706
+ pub fn set_pretokenizer_type(&mut self, pretokenizer_type: PretokenizerType) {
707
+ self.pretokenizer_type = pretokenizer_type;
708
+ }
709
+
710
+ pub fn pretokenizer_type(&self) -> PretokenizerType {
711
+ self.pretokenizer_type
712
+ }
713
+
714
+ /// Enable NFC normalization of non-added-token segments before
715
+ /// pretokenization (HF `normalizer: {"type": "NFC"}`).
716
+ pub fn set_normalize_nfc(&mut self, normalize_nfc: bool) {
717
+ self.normalize_nfc = normalize_nfc;
718
+ }
719
+
720
+ /// Enable HF `ByteLevel(add_prefix_space=true)` semantics (see the
721
+ /// `add_prefix_space` field).
722
+ pub fn set_add_prefix_space(&mut self, add_prefix_space: bool) {
723
+ self.add_prefix_space = add_prefix_space;
724
+ }
725
+
726
+ /// Enable HF BPE `ignore_merges` semantics: a pretoken whose whole
727
+ /// byte string is a vocab entry encodes as that single ID, skipping
728
+ /// the merge loop.
729
+ ///
730
+ /// Rewrites the vocab-seeded short-cache entries to the new flag's
731
+ /// seed values (own ID vs merge decomposition — see
732
+ /// [`Self::seed_symbols`]) and reasserts the added-token overwrites,
733
+ /// so the cache stays a pure function of
734
+ /// `(vocab, ignore_merges, added_tokens)` and matches what a fork's
735
+ /// fresh reseed produces.
736
+ ///
737
+ /// Loader-phase mutator: must run before any `WorkerPool` forks
738
+ /// workers from this tokenizer — already-forked workers keep the old
739
+ /// state (see [`WorkerPool`]).
740
+ ///
741
+ /// [`WorkerPool`]: crate::batch::WorkerPool
742
+ pub fn set_ignore_merges(&mut self, ignore_merges: bool) {
743
+ if self.ignore_merges == ignore_merges {
744
+ return;
745
+ }
746
+ self.ignore_merges = ignore_merges;
747
+ let mut buf = [TokenId(0); SHORT_MERGE_MAX];
748
+ for bytes in self.vocab.iter() {
749
+ if !(1..=15).contains(&bytes.len()) {
750
+ continue;
751
+ }
752
+ let key = pack_pretoken_key(bytes).expect("length checked <= 15");
753
+ let h = pretoken_key_hash(key);
754
+ let n = Self::seed_symbols_any(
755
+ self.byte_remapping.as_ref(),
756
+ self.pair_ranks.as_deref(),
757
+ &self.merges,
758
+ self.ranked_merges.as_deref(),
759
+ ignore_merges,
760
+ &self.vocab_inv,
761
+ bytes,
762
+ &mut buf,
763
+ );
764
+ let (val, ext) = Self::pack_val(&buf[..n], &mut self.token_arena);
765
+ self.pretoken_cache.replace(key, h, val, ext);
766
+ }
767
+ apply_added_token_overwrites(
768
+ &self.added_tokens,
769
+ &self.vocab_inv,
770
+ &mut self.pretoken_cache,
771
+ &mut self.token_arena,
772
+ );
773
+ }
774
+
775
+ /// Set the added tokens matched atomically by
776
+ /// [`Self::encode_with_added_tokens`]. Empty contents are ignored.
777
+ ///
778
+ /// Loader-phase mutator: must run before any `WorkerPool` forks
779
+ /// workers from this tokenizer — already-forked workers keep the old
780
+ /// added-token set (see [`WorkerPool`]).
781
+ ///
782
+ /// [`WorkerPool`]: crate::batch::WorkerPool
783
+ pub fn set_added_tokens(&mut self, added_tokens: Vec<AddedTokenDef>) {
784
+ let mut added_tokens: Vec<AddedTokenDef> = added_tokens
785
+ .into_iter()
786
+ .filter(|t| !t.content.is_empty())
787
+ .collect();
788
+ added_tokens.sort_by_key(|t| std::cmp::Reverse(t.content.len()));
789
+ self.added_matcher = (!added_tokens.is_empty()).then(|| {
790
+ aho_corasick::AhoCorasick::builder()
791
+ .match_kind(aho_corasick::MatchKind::LeftmostLongest)
792
+ .build(added_tokens.iter().map(|t| t.content.as_ref()))
793
+ .expect("added-token automaton construction cannot fail")
794
+ });
795
+ let outgoing = std::mem::replace(&mut self.added_tokens, added_tokens);
796
+ // Restore the plain seed value for the outgoing set first (only
797
+ // contents that resolve in `vocab_inv` were ever overwritten, so
798
+ // this replaces existing entries and never inserts), then apply
799
+ // the incoming overwrites through the shared sync-invariant body
800
+ // (see `apply_added_token_overwrites`).
801
+ for tok in &outgoing {
802
+ let content = &tok.content;
803
+ if !(1..=15).contains(&content.len()) || self.vocab_inv.get(content).is_none() {
804
+ continue;
805
+ }
806
+ let key = pack_pretoken_key(content).expect("length checked <= 15");
807
+ let h = pretoken_key_hash(key);
808
+ let mut buf = [TokenId(0); SHORT_MERGE_MAX];
809
+ let n = Self::seed_symbols_any(
810
+ self.byte_remapping.as_ref(),
811
+ self.pair_ranks.as_deref(),
812
+ &self.merges,
813
+ self.ranked_merges.as_deref(),
814
+ self.ignore_merges,
815
+ &self.vocab_inv,
816
+ content,
817
+ &mut buf,
818
+ );
819
+ let (val, ext) = Self::pack_val(&buf[..n], &mut self.token_arena);
820
+ self.pretoken_cache.replace(key, h, val, ext);
821
+ }
822
+ apply_added_token_overwrites(
823
+ &self.added_tokens,
824
+ &self.vocab_inv,
825
+ &mut self.pretoken_cache,
826
+ &mut self.token_arena,
827
+ );
828
+ }
829
+
830
+ /// Register one additional added token, extending the decode vocab when
831
+ /// its id lies outside the base ranks (mirrors the out-of-vocab
832
+ /// added-token handling in the HF loader).
833
+ ///
834
+ /// Loader-phase mutator: must run before any `WorkerPool` forks
835
+ /// workers from this tokenizer — already-forked workers keep the old
836
+ /// vocab, matcher, and cache seed (see [`WorkerPool`]).
837
+ ///
838
+ /// [`WorkerPool`]: crate::batch::WorkerPool
839
+ pub fn add_special_token(&mut self, content: Vec<u8>, id: TokenId) {
840
+ let idx = id.0 as usize;
841
+ // Loader-phase mutation of the shared model tables: `make_mut`
842
+ // copies only when a fork holds the tables too (never during
843
+ // loading, where this is called).
844
+ let vocab = Arc::make_mut(&mut self.vocab);
845
+ if idx >= vocab.len() {
846
+ vocab.resize(idx + 1, Arc::from(Vec::new().as_slice()));
847
+ }
848
+ if vocab[idx].is_empty() {
849
+ vocab[idx] = content.clone().into();
850
+ // If `content` duplicates an already-present vocab byte
851
+ // string, `vocab_inv` switches to the new ID (unconditional
852
+ // overwrite). The short-cache overwrite that keeps a matching
853
+ // pretoken resolving to `vocab_inv`'s answer happens in
854
+ // `set_added_tokens` below, which re-derives every added-token
855
+ // cache overwrite from the updated `vocab_inv` — the same
856
+ // computation a fork's reseed + re-apply performs (see
857
+ // [`Self::fork_sized`]), so parent and forked workers agree.
858
+ Arc::make_mut(&mut self.vocab_inv).insert(vocab[idx].clone(), id);
859
+ }
860
+ let mut added = self.added_tokens.clone();
861
+ added.push(AddedTokenDef { content: content.into(), id, lstrip: false, rstrip: false });
862
+ self.set_added_tokens(added);
863
+ }
864
+
865
+ /// Size of the vocabulary: one greater than the largest token ID,
866
+ /// including added tokens (IDs with no assigned content count too).
867
+ pub fn vocab_size(&self) -> usize {
868
+ self.vocab.len()
869
+ }
870
+
871
+ /// Vocabulary entries as `(id, bytes)` pairs in ID order, including
872
+ /// added tokens and skipping IDs with no assigned content.
873
+ pub fn vocab_entries(&self) -> impl Iterator<Item = (u32, &[u8])> {
874
+ super::vocab_entries(&self.vocab)
875
+ }
876
+
877
+ /// Merge rules as `(left, right)` byte pairs in merge-priority order
878
+ /// (priority equals the merged token's ID for tiktoken vocabularies;
879
+ /// rank-mapped vocabularies keep their explicit rank order).
880
+ pub fn merge_entries(&self) -> Vec<(&[u8], &[u8])> {
881
+ let mut ranked: Vec<(u32, u32, u32)> = match self.ranked_merges.as_deref() {
882
+ Some(rm) => rm
883
+ .iter()
884
+ .map(|(&key, &(_, rank))| ((key >> 32) as u32, key as u32, rank))
885
+ .collect(),
886
+ None => self.merges.iter().map(|(&(a, b), &m)| (a.0, b.0, m.0)).collect(),
887
+ };
888
+ ranked.sort_unstable_by_key(|&(.., priority)| priority);
889
+ ranked
890
+ .into_iter()
891
+ .map(|(a, b, _)| {
892
+ (
893
+ self.vocab[a as usize].as_ref(),
894
+ self.vocab[b as usize].as_ref(),
895
+ )
896
+ })
897
+ .collect()
898
+ }
899
+
900
+ /// Added-token contents paired with their `rstrip` flag, for
901
+ /// `pretokenize::safe_split_ranges`: an rstrip occurrence must not end
902
+ /// exactly at a chunk boundary, or the whitespace it would absorb lands
903
+ /// at the start of the next chunk and encodes as plain text.
904
+ pub fn added_token_split_blockers(&self) -> Vec<(&[u8], bool)> {
905
+ self.added_tokens
906
+ .iter()
907
+ .map(|t| (t.content.as_ref(), t.rstrip))
908
+ .collect()
909
+ }
910
+
911
+ /// Find the leftmost added-token occurrence at or after `from`, taking
912
+ /// the longest token when several match at the same position. Returns
913
+ /// `(start, end, index into added_tokens)`.
914
+ fn find_added_token(&self, bytes: &[u8], from: usize) -> Option<(usize, usize, usize)> {
915
+ let m = self.added_matcher.as_ref()?.find(&bytes[from..])?;
916
+ Some((from + m.start(), from + m.end(), m.pattern().as_usize()))
917
+ }
918
+
919
+ /// Shared piece walk of the added-token pipeline: split out added-token
920
+ /// occurrences and hand each piece — the (possibly NFC-normalized)
921
+ /// segment between occurrences, or the added token's ID — to `f` in
922
+ /// input order. Scheme dispatch costs one enum match per 256-pretoken
923
+ /// chunk fill (see [`PretokenizerType::pretokenize`] and
924
+ /// `FastPretokenizerDispatch::fill_spans_keyed`), which delegates to
925
+ /// the same out-of-line concrete fills a hardcoded pretokenizer uses.
926
+ fn for_each_piece(&mut self, bytes: &[u8], mut f: impl FnMut(&mut Self, Piece<'_>)) {
927
+ let normalize_nfc = self.normalize_nfc;
928
+ let mut nfc_buf = String::new();
929
+ let mut prefix_buf = Vec::new();
930
+ let mut pos = 0;
931
+ while pos < bytes.len() {
932
+ let (mut seg_end, added) = match self.find_added_token(bytes, pos) {
933
+ Some((start, end, idx)) => {
934
+ let t = &self.added_tokens[idx];
935
+ (start, Some((end, t.id, t.lstrip, t.rstrip)))
936
+ }
937
+ None => (bytes.len(), None),
938
+ };
939
+ // An lstrip added token absorbs the whitespace before it (HF's
940
+ // `\s*` on the left of the match); drop it from the segment.
941
+ if let Some((_, _, true, _)) = added {
942
+ seg_end = pos + trim_ws_end(&bytes[pos..seg_end]);
943
+ }
944
+ let mut segment = if normalize_nfc {
945
+ nfc_segment(&bytes[pos..seg_end], &mut nfc_buf)
946
+ } else {
947
+ &bytes[pos..seg_end]
948
+ };
949
+ if self.add_prefix_space && !segment.is_empty() && segment[0] != b' ' {
950
+ prefix_buf.clear();
951
+ prefix_buf.push(b' ');
952
+ prefix_buf.extend_from_slice(segment);
953
+ segment = &prefix_buf;
954
+ }
955
+ f(self, Piece::Segment(segment, pos));
956
+ match added {
957
+ Some((end, id, _, rstrip)) => {
958
+ f(self, Piece::Added(id));
959
+ // An rstrip added token absorbs the whitespace after it.
960
+ pos = if rstrip { end + trim_ws_start(&bytes[end..]) } else { end };
961
+ }
962
+ None => break,
963
+ }
964
+ }
965
+ }
966
+
967
+ /// Encode raw text: split out added-token occurrences (emitted as their
968
+ /// single token ID), pretokenize the segments between them with this
969
+ /// tokenizer's pretokenization scheme, and BPE-encode each pretoken.
970
+ /// This mirrors the full HuggingFace `tokenizers` encode pipeline.
971
+ pub fn encode_with_added_tokens(&mut self, bytes: &[u8], mut f: impl FnMut(&[TokenId])) {
972
+ let pt = self.pretokenizer_type;
973
+ self.for_each_piece(bytes, |this, piece| match piece {
974
+ Piece::Segment(segment, _) => this.memoized_encode(pt.pretokenize(segment), &mut f),
975
+ Piece::Added(id) => f(&[id]),
976
+ });
977
+ }
978
+
979
+ /// Flat variant of [`Self::encode_with_added_tokens`]: the identical
980
+ /// token stream appended to `out` as raw u32 ids, routed through
981
+ /// [`Self::memoized_encode_flat`] so segment tokens land directly in
982
+ /// the caller's buffer (the batch engine's per-chunk id buffer).
983
+ pub fn encode_with_added_tokens_flat(&mut self, bytes: &[u8], out: &mut Vec<u32>) {
984
+ let pt = self.pretokenizer_type;
985
+ self.for_each_piece(bytes, |this, piece| match piece {
986
+ Piece::Segment(segment, _) => this.memoized_encode_flat(pt.pretokenize(segment), out),
987
+ Piece::Added(id) => out.push(id.0),
988
+ });
989
+ }
990
+
991
+ /// For each pretoken in the input iterator, looks up the string in the
992
+ /// cache, and if not found, encodes it and inserts it into the cache.
993
+ /// Calls `f` with the encoded token slice for each pretoken.
994
+ ///
995
+ /// A thin wrapper over the flat probe/emit machinery (see
996
+ /// [`Self::memoized_encode_flat`], the path the batch engine and
997
+ /// benches use): each chunk's tokens land in a reused L1-resident
998
+ /// buffer with per-pretoken end offsets recorded on the side, then `f`
999
+ /// receives one slice per pretoken.
1000
+ pub fn memoized_encode<'i>(
1001
+ &mut self,
1002
+ mut pretokens: impl PretokenSpans<'i>,
1003
+ mut f: impl FnMut(&[TokenId]),
1004
+ ) {
1005
+ let mut batch = SpanBatch::new();
1006
+ let mut out: Vec<u32> = Vec::new();
1007
+ let mut ends = [0usize; PRETOKEN_CHUNK];
1008
+ loop {
1009
+ let cache = &self.pretoken_cache;
1010
+ let n = pretokens.fill_spans_keyed(&mut batch, &|h| cache.prefetch_l2(h));
1011
+ if n == 0 {
1012
+ break;
1013
+ }
1014
+ out.clear();
1015
+ self.probe_emit_chunk(&batch, n, &mut out, |i, w| ends[i] = w);
1016
+ let mut start = 0;
1017
+ for &end in &ends[..n] {
1018
+ // SAFETY: TokenId is repr(transparent) over u32, and the
1019
+ // recorded ends partition `out` (0 <= start <= end <= len).
1020
+ f(unsafe {
1021
+ std::slice::from_raw_parts(
1022
+ out.as_ptr().add(start) as *const TokenId,
1023
+ end - start,
1024
+ )
1025
+ });
1026
+ start = end;
1027
+ }
1028
+ if n < PRETOKEN_CHUNK {
1029
+ break;
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ /// Flat variant of [`Self::memoized_encode`]: the identical token
1035
+ /// stream appended to `out` as raw u32 ids (bit-compatible with
1036
+ /// `TokenId`), with no per-pretoken delivery. This is the batch
1037
+ /// engine's output shape (`batch::encode_into` fills chunk id buffers),
1038
+ /// so the emit loop writes tokens straight into the final buffer.
1039
+ ///
1040
+ /// Runs in chunks of `PRETOKEN_CHUNK` pretokens through two phases —
1041
+ /// pull spans from the pretokenizer with keys/hashes derived and probe
1042
+ /// lines prefetched into L2 on the way out (out of line, fused with
1043
+ /// the span walker — see PretokenSpans), then probe and emit. The
1044
+ /// phase split keeps the walker's state register-allocated in one
1045
+ /// tight loop and gives every probe line a chunk of latency (hundreds
1046
+ /// of cycles, enough to cover DRAM) before its probe.
1047
+ pub fn memoized_encode_flat<'i>(
1048
+ &mut self,
1049
+ mut pretokens: impl PretokenSpans<'i>,
1050
+ out: &mut Vec<u32>,
1051
+ ) {
1052
+ let mut batch = SpanBatch::new();
1053
+ loop {
1054
+ let cache = &self.pretoken_cache;
1055
+ let n = pretokens.fill_spans_keyed(&mut batch, &|h| cache.prefetch_l2(h));
1056
+ if n == 0 {
1057
+ break;
1058
+ }
1059
+ self.probe_emit_chunk(&batch, n, out, |_, _| {});
1060
+ if n < PRETOKEN_CHUNK {
1061
+ break;
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ /// Probe-and-emit for one chunk: branchless flat emit with a single
1067
+ /// rare data-dependent branch per pretoken. Every iteration stores the
1068
+ /// probed value's four token lanes unconditionally at the write cursor
1069
+ /// and advances by the token count only when the fast predicate (pair
1070
+ /// hit ∧ inline value ∧ short key, ~99% of pretokens) holds; stores
1071
+ /// past the cursor are dead — overwritten by a later iteration or
1072
+ /// truncated by the final `set_len`. Everything else — probe walks
1073
+ /// past the home pair, arena spills, long pretokens, misses — takes
1074
+ /// the `#[cold]` slow path. `record(i, cursor)` runs once per pretoken
1075
+ /// (per-pretoken slicing in [`Self::memoized_encode`]; a no-op closure
1076
+ /// in the flat variant).
1077
+ ///
1078
+ /// Slack invariant: `out.capacity() >= cursor + 4 * (iterations
1079
+ /// left)`, established by the reserve below and re-established by the
1080
+ /// slow path after any reallocation, so the two 8-byte stores are
1081
+ /// always in bounds.
1082
+ #[inline(always)]
1083
+ fn probe_emit_chunk(
1084
+ &mut self,
1085
+ batch: &SpanBatch<'_>,
1086
+ n: usize,
1087
+ out: &mut Vec<u32>,
1088
+ mut record: impl FnMut(usize, usize),
1089
+ ) {
1090
+ // One check up front so `i`- and `pf`-indexing of the batch arrays
1091
+ // below is provably in bounds (removes two per-iteration compares).
1092
+ assert!(n <= PRETOKEN_CHUNK);
1093
+ if n == 0 {
1094
+ return;
1095
+ }
1096
+ out.reserve(4 * n);
1097
+ let mut w = out.len();
1098
+ // Loop-invariant raw cursors. The slow path's `&mut self` call is
1099
+ // the only thing that can move `out`'s buffer or the cache's slot
1100
+ // array, so both are refreshed there and nowhere else; without
1101
+ // these the compiler reloaded the Vec pointer, table base, and
1102
+ // mask from the stack on every iteration.
1103
+ let mut dst = out.as_mut_ptr();
1104
+ let mut table = self.pretoken_cache.probe_view();
1105
+ // Probe-stage prefetch: promote the pair's line L2 -> L1 a fixed
1106
+ // short distance ahead (the fill phase staged it into L2; D only
1107
+ // has to cover the L2 hit latency, a handful of iterations).
1108
+ const D: usize = 16;
1109
+ const _: () = assert!(D <= crate::pretokenize::SPAN_BATCH_SLACK);
1110
+ for i in 0..D.min(n) {
1111
+ table.prefetch(batch.entries[i].meta);
1112
+ }
1113
+ for i in 0..n {
1114
+ // Unclamped prefetch distance: the batch carries D slack
1115
+ // entries past a full chunk, so `i + D` always indexes into
1116
+ // the array and no per-pretoken bounds clamp is needed — the
1117
+ // load is one fixed-offset ldr off the walking entry pointer.
1118
+ // Tail iterations prefetch stale or zero `meta`, and long
1119
+ // entries a length, not a hash — either way a masked,
1120
+ // in-bounds table line: harmless.
1121
+ table.prefetch(batch.entries[i + D].meta);
1122
+ // One 32-byte entry: key + meta land in a single cache line
1123
+ // (the parallel-array layout walked three load streams here).
1124
+ let (key, h) = (batch.entries[i].key, batch.entries[i].meta);
1125
+ let (val, ext, found) = table.probe_pair(key, h);
1126
+ // `key != 0` folds the long-pretoken route in AND guards the
1127
+ // empty-slot sentinel (probe_pair matches key 0 against empty
1128
+ // slots); on !found the lanes below are another entry's, dead
1129
+ // because the cursor does not advance.
1130
+ let fast = found & (val & VAL_SPILL == 0) & (key != 0);
1131
+ // Lanes 1-2 packed into one u64 store, lanes 3-4 are `ext`
1132
+ // verbatim (little-endian lane order, like the raw key load in
1133
+ // `pack_pretoken_key`); the two u64 writes fuse into one 16 B
1134
+ // `stp`.
1135
+ let ab = ((val >> 8) & 0x00FF_FFFF) | (val & 0xFFFF_FFFF_0000_0000);
1136
+ // SAFETY: the slack invariant leaves >= 4 u32s past `w`.
1137
+ unsafe {
1138
+ let p = dst.add(w);
1139
+ (p as *mut u64).write_unaligned(ab);
1140
+ (p.add(2) as *mut u64).write_unaligned(ext);
1141
+ }
1142
+ w += if fast { (val & 0x7F) as usize } else { 0 };
1143
+ if !fast {
1144
+ // Cold: reconstruct the span from the entry. For key == 0
1145
+ // `h` is really the span length, but the slow path never
1146
+ // reads `h` on the long route (see probe_emit_slow), so it
1147
+ // passes through unfiltered — a select here got hoisted
1148
+ // into the hot loop as a per-pretoken cset.
1149
+ // SAFETY: entry `i` was written by this chunk's fill, so
1150
+ // `ptr` points at a live span of the input's lifetime.
1151
+ let bytes = unsafe { batch.span(i) };
1152
+ w = self.probe_emit_slow(bytes, key, h, out, w);
1153
+ dst = out.as_mut_ptr();
1154
+ table = self.pretoken_cache.probe_view();
1155
+ }
1156
+ record(i, w);
1157
+ }
1158
+ // SAFETY: w <= capacity by the slack invariant, and every element
1159
+ // below `w` was written (fast advances never skip lanes; the slow
1160
+ // path appends through Vec).
1161
+ unsafe { out.set_len(w) };
1162
+ }
1163
+
1164
+ /// Everything [`Self::probe_emit_chunk`]'s fast predicate rejects.
1165
+ /// Appends this pretoken's tokens at cursor `w` and returns the new
1166
+ /// cursor, re-establishing the emit loop's slack invariant.
1167
+ ///
1168
+ /// `h` is only meaningful (and only read) when `key != 0`: the long
1169
+ /// route keys on `bytes` and passes literal zeros to the miss path.
1170
+ /// The emit loop relies on this and forwards the batch entry's `meta`
1171
+ /// (the span length when `key == 0`) without filtering it.
1172
+ #[cold]
1173
+ #[inline(never)]
1174
+ fn probe_emit_slow(
1175
+ &mut self,
1176
+ bytes: &[u8],
1177
+ key: u128,
1178
+ h: u64,
1179
+ out: &mut Vec<u32>,
1180
+ w: usize,
1181
+ ) -> usize {
1182
+ // SAFETY: elements below `w` are initialized and w <= capacity
1183
+ // (emit-loop invariant); Vec append methods need len in sync.
1184
+ unsafe { out.set_len(w) };
1185
+ if key != 0 {
1186
+ // A miss hands back the insert slot its walk found, so the
1187
+ // miss path's insert skips re-walking the (just-touched)
1188
+ // chain.
1189
+ match self.pretoken_cache.get_or_slot(key, h) {
1190
+ Ok((val, ext)) => {
1191
+ let len = (val & 0x7F) as usize;
1192
+ if val & VAL_SPILL == 0 {
1193
+ out.extend_from_slice(&unpack_val_lanes(val, ext)[..len]);
1194
+ } else {
1195
+ let start = (val >> 32) as usize;
1196
+ // SAFETY: recorded right after appending `len`
1197
+ // tokens at `start`; the arena never shrinks.
1198
+ let toks =
1199
+ unsafe { self.token_arena.get_unchecked(start..start + len) };
1200
+ out.extend_from_slice(token_ids_as_u32s(toks));
1201
+ }
1202
+ }
1203
+ Err(slot) => self.encode_pretoken_miss(bytes, key, h, slot, out),
1204
+ }
1205
+ } else {
1206
+ // Long pretokens (> 15 bytes, rare) always spill to the arena;
1207
+ // their token counts can exceed the packed-value range, so
1208
+ // they bypass it entirely.
1209
+ match self.pretoken_cache_long.get(bytes) {
1210
+ Some(&(offset, len)) => {
1211
+ let start = offset as usize;
1212
+ // SAFETY: as above.
1213
+ let toks = unsafe {
1214
+ self.token_arena.get_unchecked(start..start + len as usize)
1215
+ };
1216
+ out.extend_from_slice(token_ids_as_u32s(toks));
1217
+ }
1218
+ None => self.encode_pretoken_miss(bytes, 0, 0, 0, out),
1219
+ }
1220
+ }
1221
+ out.reserve(4 * PRETOKEN_CHUNK);
1222
+ out.len()
1223
+ }
1224
+
1225
+ /// Outlined miss path for rank-mapped vocabularies: the same cache
1226
+ /// bookkeeping as [`Self::encode_pretoken_miss`], with every merge
1227
+ /// running through the explicit-rank loops.
1228
+ #[cold]
1229
+ #[inline(never)]
1230
+ fn encode_pretoken_miss_ranked(
1231
+ &mut self,
1232
+ bytes: &[u8],
1233
+ key: u128,
1234
+ h: u64,
1235
+ slot: usize,
1236
+ out: &mut Vec<u32>,
1237
+ ) {
1238
+ let rm = self.ranked_merges.clone().expect("caller checked ranked_merges");
1239
+ if key != 0 {
1240
+ let mut buf = [TokenId(0); SHORT_MERGE_MAX];
1241
+ let n = Self::seed_symbols_ranked(
1242
+ self.byte_remapping.as_ref(),
1243
+ &rm,
1244
+ self.ignore_merges,
1245
+ &self.vocab_inv,
1246
+ bytes,
1247
+ &mut buf,
1248
+ );
1249
+ let symbols = &buf[..n];
1250
+ let (val, ext) = Self::pack_val(symbols, &mut self.token_arena);
1251
+ self.pretoken_cache.insert_at(slot, key, h, val, ext);
1252
+ out.extend_from_slice(token_ids_as_u32s(symbols));
1253
+ } else {
1254
+ // Mirrors the long-pretoken arm of `encode_pretoken_miss`,
1255
+ // including the no-whole-pretoken-shortcut rule documented
1256
+ // there.
1257
+ let symbols = &mut self.symbol_scratch;
1258
+ symbols.clear();
1259
+ if self.ignore_merges
1260
+ && let Some(&id) = self.vocab_inv.get(bytes)
1261
+ {
1262
+ symbols.push(id);
1263
+ } else {
1264
+ match self.byte_remapping.as_ref() {
1265
+ Some(br) => symbols.extend(bytes.iter().map(|&b| br.mapping[b as usize])),
1266
+ None => symbols.extend(bytes.iter().map(|&b| TokenId::from(b as u32))),
1267
+ }
1268
+ bpe_merge_symbols_ranked(&rm, symbols);
1269
+ }
1270
+ let len = symbols.len() as u32;
1271
+ let offset = self.token_arena.len() as u32;
1272
+ self.token_arena.extend_from_slice(symbols);
1273
+ self.pretoken_cache_long.insert(bytes.into(), (offset, len));
1274
+ out.extend_from_slice(token_ids_as_u32s(symbols));
1275
+ }
1276
+ }
1277
+
1278
+ /// Cache-miss path of the probe/emit loop: BPE-encode `bytes`, record
1279
+ /// it in the table `key` routes to (the short-pretoken table, or the
1280
+ /// long map when `key == 0`), and append its tokens to `out`. `slot`
1281
+ /// is the short-cache insert position reported by the failed
1282
+ /// `get_or_slot` probe (meaningful only when `key != 0`); nothing
1283
+ /// here touches the short cache before the insert, so it stays valid.
1284
+ #[inline(never)]
1285
+ fn encode_pretoken_miss(
1286
+ &mut self,
1287
+ bytes: &[u8],
1288
+ key: u128,
1289
+ h: u64,
1290
+ slot: usize,
1291
+ out: &mut Vec<u32>,
1292
+ ) {
1293
+ // Rank-mapped vocabularies take the outlined
1294
+ // ranked miss path; the branch is one perfectly-predicted test for
1295
+ // everything else, keeping this function's codegen identical to a
1296
+ // build without ranked support.
1297
+ if self.ranked_merges.is_some() {
1298
+ return self.encode_pretoken_miss_ranked(bytes, key, h, slot, out);
1299
+ }
1300
+ if key != 0 {
1301
+ // Short pretoken (≤ 15 bytes, the overwhelming majority of
1302
+ // misses): straight to byte symbols and the merge loop
1303
+ // (`merge_short`, shared with the vocab seed), in a stack
1304
+ // buffer instead of the `Vec` scratch. The cache is pre-seeded
1305
+ // with the seed encoding of every short vocab entry (see
1306
+ // `seeded_pretoken_cache`), so a miss here is never a whole
1307
+ // vocab word — but nothing depends on that: `seed_symbols`
1308
+ // computes the correct encoding for any bytes under either
1309
+ // `ignore_merges` setting.
1310
+ let mut buf = [TokenId(0); SHORT_MERGE_MAX];
1311
+ let n = Self::seed_symbols(
1312
+ self.byte_remapping.as_ref(),
1313
+ self.pair_ranks.as_deref(),
1314
+ &self.merges,
1315
+ self.ignore_merges,
1316
+ &self.vocab_inv,
1317
+ bytes,
1318
+ &mut buf,
1319
+ );
1320
+ let symbols = &buf[..n];
1321
+ let (val, ext) = Self::pack_val(symbols, &mut self.token_arena);
1322
+ self.pretoken_cache.insert_at(slot, key, h, val, ext);
1323
+ out.extend_from_slice(token_ids_as_u32s(symbols));
1324
+ } else {
1325
+ // Long pretoken (> 15 bytes, rare): remap and run the merge
1326
+ // loop. Without `ignore_merges`, deliberately NO
1327
+ // whole-pretoken reverse-vocab (`vocab_inv`) shortcut here — a
1328
+ // vocab entry is not guaranteed to be derivable from its own
1329
+ // merges (qwen3_5 has ~50 entries > 15 bytes, multi-char CJK
1330
+ // phrases, that HF `tokenizers` without `ignore_merges`
1331
+ // encodes as their merge decomposition, never as the single
1332
+ // ID), so any such shortcut diverges from HF and from the
1333
+ // pre-cache baseline. The same rule holds for short keys via
1334
+ // the seeded merge results above. Do not reintroduce it. With
1335
+ // `ignore_merges` set, the shortcut IS HF's semantics, so it
1336
+ // applies — gated on the flag.
1337
+ let symbols = &mut self.symbol_scratch;
1338
+ symbols.clear();
1339
+ if self.ignore_merges
1340
+ && let Some(&id) = self.vocab_inv.get(bytes)
1341
+ {
1342
+ symbols.push(id);
1343
+ } else {
1344
+ match self.byte_remapping.as_ref() {
1345
+ Some(br) => symbols.extend(bytes.iter().map(|&b| br.mapping[b as usize])),
1346
+ None => symbols.extend(bytes.iter().map(|&b| TokenId::from(b as u32))),
1347
+ }
1348
+ match self.pair_ranks.as_deref() {
1349
+ Some(table) => bpe_merge_symbols_by_rank(
1350
+ &|a, b| table.rank(a, b),
1351
+ symbols,
1352
+ &mut self.merge_scratch,
1353
+ ),
1354
+ None => bpe_merge_symbols_with_scratch(
1355
+ &self.merges,
1356
+ symbols,
1357
+ &mut self.merge_scratch,
1358
+ ),
1359
+ }
1360
+ }
1361
+ let len = symbols.len() as u32;
1362
+ let offset = self.token_arena.len() as u32;
1363
+ self.token_arena.extend_from_slice(symbols);
1364
+ self.pretoken_cache_long.insert(bytes.into(), (offset, len));
1365
+ out.extend_from_slice(token_ids_as_u32s(symbols));
1366
+ }
1367
+ }
1368
+
1369
+ pub fn decode(&self, v: &[TokenId]) -> impl Iterator<Item = u8> {
1370
+ v.iter()
1371
+ .flat_map(|&token| self.vocab[token.0 as usize].as_ref())
1372
+ .copied()
1373
+ }
1374
+
1375
+ /// Detailed cache stats for memory accounting (see examples/cache_memory.rs):
1376
+ /// (short_len, short_cap, long_len, long_cap, long_key_bytes, arena_len, arena_cap).
1377
+ pub fn cache_mem_stats(&self) -> (usize, usize, usize, usize, usize, usize, usize) {
1378
+ let long_key_bytes: usize = self.pretoken_cache_long.keys().map(|k| k.len()).sum();
1379
+ (
1380
+ self.pretoken_cache.len(),
1381
+ self.pretoken_cache.capacity(),
1382
+ self.pretoken_cache_long.len(),
1383
+ self.pretoken_cache_long.capacity(),
1384
+ long_key_bytes,
1385
+ self.token_arena.len(),
1386
+ self.token_arena.capacity(),
1387
+ )
1388
+ }
1389
+ }
1390
+
1391
+ impl Debug for Tokenizer {
1392
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1393
+ f.debug_struct("Tokenizer")
1394
+ .field("vocab_size", &self.vocab.len())
1395
+ .field("merges_count", &self.merges.len())
1396
+ .field("pair_ranks", &self.pair_ranks.is_some())
1397
+ .field("byte_remapping", &self.byte_remapping.is_some())
1398
+ .finish()
1399
+ }
1400
+ }
1401
+
1402
+ /// Helpers shared by the test modules below.
1403
+ #[cfg(test)]
1404
+ mod test_util {
1405
+ use super::*;
1406
+
1407
+ pub(super) fn gpt2_path() -> std::path::PathBuf {
1408
+ crate::test_hub::gpt2_tokenizer_json()
1409
+ }
1410
+
1411
+ /// Uncached reference encode of one pretoken: byte remap + plain merge
1412
+ /// loop over the merges HashMap (no pair-rank table, no cache, no
1413
+ /// short-merge kernels).
1414
+ pub(super) fn plain_encode_pretoken(tok: &Tokenizer, pretoken: &[u8], out: &mut Vec<u32>) {
1415
+ let mut symbols: Vec<TokenId> = match tok.byte_remapping.as_ref() {
1416
+ Some(br) => pretoken.iter().map(|&b| br.mapping[b as usize]).collect(),
1417
+ None => pretoken.iter().map(|&b| TokenId::from(b as u32)).collect(),
1418
+ };
1419
+ crate::bpe::bpe_merge_symbols(&tok.merges, &mut symbols);
1420
+ out.extend(symbols.iter().map(|t| t.0));
1421
+ }
1422
+
1423
+ /// Pretoken lengths through the two-phase walker path
1424
+ /// (`fill_spans_keyed`), for comparison against the Iterator path.
1425
+ pub(super) fn two_phase_lens<'a>(mut p: impl PretokenSpans<'a>) -> Vec<usize> {
1426
+ let mut batch = SpanBatch::new();
1427
+ let mut lens = Vec::new();
1428
+ loop {
1429
+ let n = p.fill_spans_keyed(&mut batch, &|_| {});
1430
+ for i in 0..n {
1431
+ lens.push(batch.entries[i].span_len());
1432
+ }
1433
+ if n < PRETOKEN_CHUNK {
1434
+ return lens;
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ /// xorshift64: deterministic, dependency-free RNG for test inputs.
1440
+ pub(super) struct XorShift64(pub u64);
1441
+
1442
+ impl XorShift64 {
1443
+ pub(super) fn next_u64(&mut self) -> u64 {
1444
+ self.0 ^= self.0 << 13;
1445
+ self.0 ^= self.0 >> 7;
1446
+ self.0 ^= self.0 << 17;
1447
+ self.0
1448
+ }
1449
+ }
1450
+ }
1451
+
1452
+ #[cfg(test)]
1453
+ mod tests {
1454
+ use super::*;
1455
+ use crate::load_tokenizer::tiktoken::load_tiktoken;
1456
+ use std::io::Read;
1457
+
1458
+ /// `add_special_token` whose content duplicates an existing vocab byte
1459
+ /// string must resolve to the added ID everywhere: `vocab_inv`, the
1460
+ /// parent's seeded cache (overwritten, not insert-if-absent), and
1461
+ /// forked workers (vocab reseed plus re-applied added-token
1462
+ /// overwrites). Regression test for the three-way disagreement where
1463
+ /// the parent kept the stale seed entry (old ID) while a fork's
1464
+ /// descending reseed picked the new ID.
1465
+ #[test]
1466
+ fn add_special_token_duplicate_content_agrees_across_forks() {
1467
+ let encode = |t: &mut Tokenizer, input: &[u8]| -> Vec<TokenId> {
1468
+ let mut out = Vec::new();
1469
+ t.memoized_encode(crate::pretokenize::pretokenize_as_iter(input), |tokens| {
1470
+ out.extend_from_slice(tokens)
1471
+ });
1472
+ out
1473
+ };
1474
+
1475
+ // Case 1: added ID above the duplicate's ID.
1476
+ let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
1477
+ HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1478
+ merges.insert((TokenId(104), TokenId(105)), TokenId(256)); // 'h' 'i' -> "hi"
1479
+ let mut vocab: Vec<Vec<u8>> = (0..=255u32).map(|b| vec![b as u8]).collect();
1480
+ vocab.push(b"hi".to_vec()); // id 256 = "hi"
1481
+ let mut tok = Tokenizer::new(merges, vocab, None);
1482
+ tok.add_special_token(b"hi".to_vec(), TokenId(1000));
1483
+ assert_eq!(tok.vocab_inv.get(b"hi".as_slice()), Some(&TokenId(1000)));
1484
+ let mut fork = tok.fork();
1485
+ assert_eq!(
1486
+ encode(&mut tok, b"hi"),
1487
+ vec![TokenId(1000)],
1488
+ "parent cache must resolve the duplicate to the added ID (vocab_inv's answer)"
1489
+ );
1490
+ assert_eq!(
1491
+ encode(&mut fork, b"hi"),
1492
+ vec![TokenId(1000)],
1493
+ "forked worker must agree with the parent"
1494
+ );
1495
+
1496
+ // Case 2 (mirror): added ID fills an empty placeholder BELOW the
1497
+ // duplicate's ID; the fork's reseed alone would pick the higher
1498
+ // ID (the merge result), diverging from vocab_inv and the parent.
1499
+ let mut merges: HashMap<(TokenId, TokenId), TokenId, rustc_hash::FxBuildHasher> =
1500
+ HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1501
+ merges.insert((TokenId(104), TokenId(105)), TokenId(257)); // 'h' 'i' -> "hi"
1502
+ let mut vocab: Vec<Vec<u8>> = (0..=255u32).map(|b| vec![b as u8]).collect();
1503
+ vocab.push(Vec::new()); // id 256: empty placeholder
1504
+ vocab.push(b"hi".to_vec()); // id 257 = "hi"
1505
+ let mut tok = Tokenizer::new(merges, vocab, None);
1506
+ tok.add_special_token(b"hi".to_vec(), TokenId(256));
1507
+ assert_eq!(tok.vocab_inv.get(b"hi".as_slice()), Some(&TokenId(256)));
1508
+ let mut fork = tok.fork();
1509
+ assert_eq!(encode(&mut tok, b"hi"), vec![TokenId(256)]);
1510
+ assert_eq!(encode(&mut fork, b"hi"), vec![TokenId(256)]);
1511
+ }
1512
+
1513
+ /// GPT-2 must take the PairRankTable fast path, with the table agreeing
1514
+ /// with the merges map, and the vocab seed must serve every short vocab
1515
+ /// word as its own ID: every base GPT-2 entry is merge-reachable, so
1516
+ /// its merge decomposition IS the single own ID, and the one
1517
+ /// unreachable entry (<|endoftext|>, an added token) gets the
1518
+ /// `set_added_tokens` `[id]` overwrite.
1519
+ #[test]
1520
+ fn gpt2_pair_rank_table_and_vocab_seed() {
1521
+ use crate::load_tokenizer::hf::load_hf_bpe;
1522
+ use crate::pretokenize::{pack_pretoken_key, pretoken_key_hash};
1523
+ let tokenizer = load_hf_bpe(super::test_util::gpt2_path()).expect("load GPT-2 tokenizer");
1524
+
1525
+ let table = tokenizer
1526
+ .pair_ranks
1527
+ .as_deref()
1528
+ .expect("GPT-2 must take the pair-rank fast path");
1529
+ for (&(a, b), &m) in tokenizer.merges.iter() {
1530
+ assert_eq!(table.rank(a, b), m.0, "pair ({}, {})", a.0, b.0);
1531
+ }
1532
+ // Dense negatives (byte × byte) and flat negatives must agree with
1533
+ // the map too.
1534
+ for a in (0..50257u32).step_by(97) {
1535
+ for b in (0..50257u32).step_by(89) {
1536
+ let expected = tokenizer
1537
+ .merges
1538
+ .get(&(TokenId(a), TokenId(b)))
1539
+ .map_or(u32::MAX, |m| m.0);
1540
+ assert_eq!(table.rank(TokenId(a), TokenId(b)), expected, "pair ({a}, {b})");
1541
+ }
1542
+ }
1543
+
1544
+ let mut seeded = 0usize;
1545
+ for (id, bytes) in tokenizer.vocab_entries() {
1546
+ if !(1..=15).contains(&bytes.len()) {
1547
+ continue;
1548
+ }
1549
+ let key = pack_pretoken_key(bytes).unwrap();
1550
+ let (val, ext) = tokenizer
1551
+ .pretoken_cache
1552
+ .get_or_slot(key, pretoken_key_hash(key))
1553
+ .expect("short vocab entry must be seeded");
1554
+ // Every real vocab ID is < 2^24, so the seed is inline: 1 token,
1555
+ // the entry's own ID (GPT-2 has no duplicate byte strings and,
1556
+ // added-token overwrites included, no entry whose cached value
1557
+ // differs from its own ID).
1558
+ assert_eq!(val, 1 | ((id as u64) << 8), "vocab entry {id}");
1559
+ assert_eq!(ext, 0, "vocab entry {id}");
1560
+ seeded += 1;
1561
+ }
1562
+ assert_eq!(tokenizer.pretoken_cache.len(), seeded);
1563
+ // A fork starts from the same seed, sharing the same table.
1564
+ let fork = tokenizer.fork();
1565
+ assert_eq!(fork.pretoken_cache.len(), seeded);
1566
+ assert!(fork.pair_ranks.is_some());
1567
+ }
1568
+
1569
+ #[test]
1570
+ fn short_pretoken_cache_serves_repeated_pretokens() {
1571
+ use crate::pretokenize::{SpanIter, pack_pretoken_key, pretoken_key_hash};
1572
+
1573
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1574
+ let vocab = (0..=u8::MAX).map(|byte| vec![byte]).collect();
1575
+ let mut tokenizer = Tokenizer::new(merges, vocab, None);
1576
+ let bytes = b"hello";
1577
+
1578
+ let mut first = Vec::new();
1579
+ tokenizer.memoized_encode(SpanIter([Pretoken(bytes)].into_iter()), |tokens| {
1580
+ first.extend(tokens.iter().map(|token| token.0));
1581
+ });
1582
+ let expected: Vec<u32> = bytes.iter().map(|&byte| byte as u32).collect();
1583
+ assert_eq!(first, expected);
1584
+
1585
+ // The 5-token encoding is too long to inline, so it spilled to the
1586
+ // arena, but the cache entry serves it either way.
1587
+ let key = pack_pretoken_key(bytes).unwrap();
1588
+ let h = pretoken_key_hash(key);
1589
+ assert!(tokenizer.pretoken_cache.get_or_slot(key, h).is_ok());
1590
+
1591
+ let mut repeated = Vec::new();
1592
+ tokenizer.memoized_encode(SpanIter([Pretoken(bytes)].into_iter()), |tokens| {
1593
+ repeated.extend(tokens.iter().map(|token| token.0));
1594
+ });
1595
+ assert_eq!(repeated, first);
1596
+ // The 256 single-byte vocab entries are pre-seeded; "hello" is the
1597
+ // one entry the encodes added.
1598
+ assert_eq!(tokenizer.pretoken_cache.len(), 257);
1599
+
1600
+ // The zero key marks empty slots in the short table, so an empty
1601
+ // pretoken (possible through the public API) must take the long-map
1602
+ // path.
1603
+ tokenizer.memoized_encode(SpanIter([Pretoken(b"")].into_iter()), |tokens| {
1604
+ assert!(tokens.is_empty());
1605
+ });
1606
+ assert!(tokenizer.pretoken_cache_long.contains_key(&b""[..]));
1607
+ }
1608
+
1609
+ /// With no added tokens configured, `encode_with_added_tokens`'s piece
1610
+ /// walk reduces to one whole-input segment — its output must equal a
1611
+ /// direct `memoized_encode` of the same scheme's pretokens.
1612
+ #[test]
1613
+ fn encode_with_added_tokens_matches_memoized_encode_all_schemes() {
1614
+ let schemes = [
1615
+ PretokenizerType::GPT2,
1616
+ PretokenizerType::GPT4,
1617
+ PretokenizerType::Qwen2,
1618
+ PretokenizerType::Qwen35,
1619
+ PretokenizerType::Olmo3,
1620
+ PretokenizerType::DeepSeekV3,
1621
+ PretokenizerType::O200k,
1622
+ PretokenizerType::Nemotron,
1623
+ PretokenizerType::Kimi,
1624
+ ];
1625
+ let input = "Hello, 世界! café 12345\r\ncan't stop".as_bytes();
1626
+
1627
+ for scheme in schemes {
1628
+ let make_tokenizer = || {
1629
+ let merges = HashMap::with_hasher(rustc_hash::FxBuildHasher {});
1630
+ let vocab = (0..=u8::MAX).map(|byte| vec![byte]).collect();
1631
+ Tokenizer::new(merges, vocab, None)
1632
+ };
1633
+
1634
+ let mut reference = make_tokenizer();
1635
+ let mut expected = Vec::new();
1636
+ reference.memoized_encode(scheme.pretokenize(input), |tokens| {
1637
+ expected.extend_from_slice(tokens);
1638
+ });
1639
+
1640
+ let mut concrete = make_tokenizer();
1641
+ concrete.set_pretokenizer_type(scheme);
1642
+ let mut actual = Vec::new();
1643
+ concrete.encode_with_added_tokens(input, |tokens| {
1644
+ actual.extend_from_slice(tokens);
1645
+ });
1646
+ assert_eq!(actual, expected, "dispatch differs for {scheme:?}");
1647
+ }
1648
+ }
1649
+
1650
+ #[test]
1651
+ fn test_merges_from_vocab() {
1652
+ use base64::prelude::*;
1653
+ let mut buf = String::new();
1654
+ let data_dir = std::env::home_dir().unwrap().join("data");
1655
+ let tiktoken_path = data_dir.join("tokenizers/r50k_base.tiktoken");
1656
+ std::fs::File::open(tiktoken_path)
1657
+ .expect("Didn't find file")
1658
+ .read_to_string(&mut buf)
1659
+ .unwrap();
1660
+ let vocab: Vec<Vec<u8>> = buf
1661
+ .lines()
1662
+ .enumerate()
1663
+ .map(|(i, line)| {
1664
+ let (base64_token, id_str) = line.split_once(' ').unwrap();
1665
+ let id = id_str.trim().parse::<u32>().unwrap();
1666
+ assert!(id == i as u32);
1667
+
1668
+ BASE64_STANDARD.decode(base64_token).unwrap()
1669
+ })
1670
+ .collect();
1671
+ for (i, token) in vocab.iter().enumerate().skip(256).take(20) {
1672
+ eprintln!("{i}: {:?}", String::from_utf8_lossy(token));
1673
+ }
1674
+ let tokenizer = Tokenizer::from_ranks(vocab).unwrap();
1675
+
1676
+ let merges_inv = tokenizer
1677
+ .merges
1678
+ .iter()
1679
+ .map(|((a, b), c)| (*c, (*a, *b)))
1680
+ .collect::<HashMap<TokenId, (TokenId, TokenId)>>();
1681
+
1682
+ let decode_token = |token_id: TokenId| -> String {
1683
+ String::from_utf8_lossy(&tokenizer.vocab[token_id.0 as usize]).into_owned()
1684
+ };
1685
+
1686
+ eprintln!("Merges:");
1687
+ for i in 256..=300 {
1688
+ let (a, b) = *merges_inv.get(&i.into()).unwrap();
1689
+ eprintln!(
1690
+ "Merge {i}: \"{}\" + \"{}\" -> \"{}\"",
1691
+ decode_token(a),
1692
+ decode_token(b),
1693
+ decode_token(i.into()),
1694
+ )
1695
+ }
1696
+ }
1697
+
1698
+ #[test]
1699
+ fn basic_tokenization() {
1700
+ let text = "This is a test string. Please tokenize it!";
1701
+ let data_dir = std::env::home_dir().unwrap().join("data");
1702
+ let tiktoken_path = data_dir.join("tokenizers/r50k_base.tiktoken");
1703
+ let mut tokenizer = load_tiktoken(tiktoken_path).expect("Failed to load tokenizer");
1704
+ let pretokenize_iter = crate::pretokenize::pretokenize_as_iter(text.as_bytes());
1705
+ let mut output = vec![];
1706
+ tokenizer.memoized_encode(pretokenize_iter, |tokens| {
1707
+ output.extend_from_slice(tokens);
1708
+ });
1709
+ assert!(tokenizer.byte_remapping.is_some());
1710
+ println!("Encoded: {:?}", output);
1711
+ let decoded = tokenizer.decode(&output).collect::<Vec<u8>>();
1712
+ println!("Decoded: {:?}", String::from_utf8_lossy(&decoded));
1713
+ }
1714
+ }
1715
+
1716
+ /// Heavy correctness differentials for the optimized encode pipeline
1717
+ /// (from the opt/verify-heavy campaign branch). The OWT-scale tests
1718
+ /// (`#[ignore]`d; each doc comment states corpus size, runtime ballpark,
1719
+ /// and the cargo command) check the CACHED paths (`memoized_encode` /
1720
+ /// `encode_with_added_tokens_flat`: packed keys, open-addressing short
1721
+ /// table, two-phase span walkers, branchless emit) against an UNCACHED
1722
+ /// reference (per-pretoken plain `bpe_merge_symbols` over the merges
1723
+ /// HashMap). Two fast tests probe `pack_pretoken_key` at every page
1724
+ /// offset and pin the vocab-seed merge-decomposition rule. Boundary fuzz
1725
+ /// and walker edge cases live in `walker_edge` below.
1726
+ #[cfg(test)]
1727
+ mod verify_heavy {
1728
+ use super::test_util::{XorShift64, gpt2_path, plain_encode_pretoken};
1729
+ use super::*;
1730
+ use crate::load_tokenizer::hf::load_hf_bpe;
1731
+ use std::io::Read;
1732
+
1733
+ fn load_owt(max_bytes: usize) -> Vec<u8> {
1734
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
1735
+ let f = std::fs::File::open(&path).expect("open ~/data/owt_train.txt");
1736
+ let mut input = Vec::new();
1737
+ f.take(max_bytes as u64).read_to_end(&mut input).unwrap();
1738
+ while !input.is_empty() && std::str::from_utf8(&input).is_err() {
1739
+ input.pop();
1740
+ }
1741
+ input
1742
+ }
1743
+
1744
+ /// Cut `input` at the first newline at or after `at` (whole input if none).
1745
+ fn cut_at_newline(input: &[u8], at: usize) -> &[u8] {
1746
+ let at = at.min(input.len());
1747
+ match memchr::memchr(b'\n', &input[at..]) {
1748
+ Some(off) => &input[..at + off + 1],
1749
+ None => input,
1750
+ }
1751
+ }
1752
+
1753
+ /// Token-for-token comparison of the full cached public path
1754
+ /// (`encode_with_added_tokens_flat`) against a per-pretoken plain-merge
1755
+ /// walk of the same piece stream. The piece walk itself
1756
+ /// (`for_each_piece`) is shared with production — its added-token split
1757
+ /// is covered independently by `join_differential`; what this checks is
1758
+ /// the cache/probe/emit machinery against uncached plain merges.
1759
+ /// Panics with byte offset, pretoken bytes, and both id streams on the
1760
+ /// first divergence.
1761
+ fn compare_cached_vs_reference(tok: &mut Tokenizer, input: &[u8], label: &str, verbose: bool) {
1762
+ let mut cached: Vec<u32> = Vec::new();
1763
+ tok.encode_with_added_tokens_flat(input, &mut cached);
1764
+
1765
+ let mut idx = 0usize;
1766
+ let mut scratch: Vec<u32> = Vec::new();
1767
+ tok.for_each_piece(input, |this, piece| match piece {
1768
+ Piece::Segment(segment, pos) => {
1769
+ let mut seg_off = 0usize;
1770
+ for pretoken in this.pretokenizer_type.pretokenize(segment) {
1771
+ scratch.clear();
1772
+ plain_encode_pretoken(this, pretoken.0, &mut scratch);
1773
+ let got = cached.get(idx..idx + scratch.len());
1774
+ if got != Some(&scratch[..]) {
1775
+ // Approximate: NFC or a prepended prefix space can
1776
+ // shift lengths vs the raw input.
1777
+ let byte_off = pos + seg_off;
1778
+ let ctx_start = byte_off.saturating_sub(40).min(input.len());
1779
+ let ctx_end = (byte_off + pretoken.0.len() + 40).min(input.len());
1780
+ panic!(
1781
+ "{label}: encode mismatch at byte offset ~{byte_off} (input len {}), token index {idx}\n \
1782
+ pretoken ({} bytes): {:?}\n expected ids: {:?}\n cached ids: {:?}\n context: {:?}",
1783
+ input.len(),
1784
+ pretoken.0.len(),
1785
+ String::from_utf8_lossy(pretoken.0),
1786
+ scratch,
1787
+ &cached[idx.min(cached.len())..(idx + scratch.len() + 4).min(cached.len())],
1788
+ String::from_utf8_lossy(&input[ctx_start..ctx_end]),
1789
+ );
1790
+ }
1791
+ idx += scratch.len();
1792
+ seg_off += pretoken.0.len();
1793
+ }
1794
+ }
1795
+ Piece::Added(id) => {
1796
+ assert_eq!(
1797
+ cached.get(idx).copied(),
1798
+ Some(id.0),
1799
+ "{label}: added-token id mismatch at token index {idx}"
1800
+ );
1801
+ idx += 1;
1802
+ }
1803
+ });
1804
+ assert_eq!(
1805
+ idx,
1806
+ cached.len(),
1807
+ "{label}: cached stream has {} extra trailing tokens",
1808
+ cached.len() - idx
1809
+ );
1810
+ if verbose {
1811
+ eprintln!(
1812
+ "{label}: all {idx} tokens match on {:.1} MB",
1813
+ input.len() as f64 / 1e6
1814
+ );
1815
+ }
1816
+ }
1817
+
1818
+ /// Independent added-token differential: join ~1 MB corpus pieces with
1819
+ /// the tokenizer's first added token and check the public encode equals
1820
+ /// concat(plain-encode(piece), sep_id, ...) — the expected stream is
1821
+ /// built WITHOUT `find_added_token`, so the Aho-Corasick split itself
1822
+ /// is under test, not just mirrored.
1823
+ fn join_differential(tok: &mut Tokenizer, corpus: &[u8], label: &str) {
1824
+ let Some((sep, sep_id)) = tok.added_tokens.first().map(|t| (t.content.to_vec(), t.id)) else {
1825
+ eprintln!("{label}: no added tokens registered; skipping join differential");
1826
+ return;
1827
+ };
1828
+ // OWT embeds document separators (e.g. <|endoftext|>) throughout;
1829
+ // mask every added-token occurrence so pieces are separator-free
1830
+ // and the expected stream can be built without find_added_token.
1831
+ let mut corpus: Vec<u8> = corpus.to_vec();
1832
+ for t in tok.added_tokens.clone() {
1833
+ let content = &t.content;
1834
+ let hits: Vec<usize> = memchr::memmem::find_iter(&corpus, &content[..]).collect();
1835
+ for pos in hits {
1836
+ corpus[pos] = b'~';
1837
+ }
1838
+ }
1839
+ let corpus = &corpus[..];
1840
+ let mut expected: Vec<u32> = Vec::new();
1841
+ let mut joined: Vec<u8> = Vec::new();
1842
+ let mut nfc_buf = String::new();
1843
+ let mut start = 0usize;
1844
+ let mut pieces = 0usize;
1845
+ while start < corpus.len() {
1846
+ let target = (start + (1 << 20)).min(corpus.len());
1847
+ let end = match memchr::memchr(b'\n', &corpus[target..]) {
1848
+ Some(off) => target + off + 1,
1849
+ None => corpus.len(),
1850
+ };
1851
+ let piece = &corpus[start..end];
1852
+ start = end;
1853
+ // Masking is single-byte, so no new occurrence can appear; but
1854
+ // keep the guard as a belt-and-braces skip.
1855
+ if tok
1856
+ .added_tokens
1857
+ .iter()
1858
+ .any(|t| memchr::memmem::find(piece, &t.content).is_some())
1859
+ {
1860
+ continue;
1861
+ }
1862
+ joined.extend_from_slice(piece);
1863
+ joined.extend_from_slice(&sep);
1864
+ let seg = if tok.normalize_nfc {
1865
+ nfc_segment(piece, &mut nfc_buf)
1866
+ } else {
1867
+ piece
1868
+ };
1869
+ for pretoken in tok.pretokenizer_type.pretokenize(seg) {
1870
+ plain_encode_pretoken(tok, pretoken.0, &mut expected);
1871
+ }
1872
+ expected.push(sep_id.0);
1873
+ pieces += 1;
1874
+ }
1875
+ let mut cached: Vec<u32> = Vec::new();
1876
+ tok.encode_with_added_tokens_flat(&joined, &mut cached);
1877
+ if cached != expected {
1878
+ let i = expected
1879
+ .iter()
1880
+ .zip(&cached)
1881
+ .position(|(a, b)| a != b)
1882
+ .unwrap_or_else(|| expected.len().min(cached.len()));
1883
+ panic!(
1884
+ "{label}: join differential diverged at token index {i} \
1885
+ (expected len {}, cached len {}):\n expected[{i}..] = {:?}\n cached[{i}..] = {:?}",
1886
+ expected.len(),
1887
+ cached.len(),
1888
+ &expected[i..(i + 8).min(expected.len())],
1889
+ &cached[i..(i + 8).min(cached.len())],
1890
+ );
1891
+ }
1892
+ assert!(pieces > 0, "{label}: join differential ran on zero pieces (vacuous)");
1893
+ eprintln!(
1894
+ "{label}: join differential ok — {pieces} pieces, {} tokens, sep {:?} id {}",
1895
+ cached.len(),
1896
+ String::from_utf8_lossy(&sep),
1897
+ sep_id.0
1898
+ );
1899
+ }
1900
+
1901
+ /// Token-for-token differential of the cached callback encode path
1902
+ /// (`memoized_encode`: packed keys, open-addressing table, inline
1903
+ /// values, prefetch pipeline) against the uncached reference
1904
+ /// (plain BPE merge per pretoken) on 50 MB of OWT. Runs in a few
1905
+ /// seconds in release mode.
1906
+ /// `cargo test --release verify_memoized_encode_matches_reference_owt_50m -- --ignored --nocapture`
1907
+ #[test]
1908
+ #[ignore = "reads 50 MB of OWT; run explicitly in release mode"]
1909
+ fn verify_memoized_encode_matches_reference_owt_50m() {
1910
+ let mut tokenizer = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
1911
+ let all = load_owt(50_000_000);
1912
+ let input = &all[..];
1913
+
1914
+ let mut cached: Vec<TokenId> = Vec::new();
1915
+ tokenizer
1916
+ .memoized_encode(crate::pretokenize::pretokenize_as_iter(input), |tokens| {
1917
+ cached.extend_from_slice(tokens)
1918
+ });
1919
+
1920
+ // Uncached reference: remap bytes and run the plain merge loop.
1921
+ let encode_reference = |pretoken: Pretoken| -> Vec<TokenId> {
1922
+ let mut symbols: Vec<TokenId> = match tokenizer.byte_remapping.as_ref() {
1923
+ Some(br) => pretoken.iter().map(|&b| br.mapping[b as usize]).collect(),
1924
+ None => pretoken.iter().map(|&b| TokenId::from(b as u32)).collect(),
1925
+ };
1926
+ crate::bpe::bpe_merge_symbols(&tokenizer.merges, &mut symbols);
1927
+ symbols
1928
+ };
1929
+ let mut idx = 0usize;
1930
+ for (pi, pretoken) in crate::pretokenize::pretokenize_as_iter(input).enumerate() {
1931
+ let reference = encode_reference(pretoken);
1932
+ assert!(
1933
+ cached[idx..(idx + reference.len()).min(cached.len())] == reference[..],
1934
+ "pretoken {pi} ({:?}) diverged: cached {:?} vs reference {:?}",
1935
+ String::from_utf8_lossy(pretoken.0),
1936
+ &cached[idx..(idx + reference.len()).min(cached.len())],
1937
+ reference,
1938
+ );
1939
+ idx += reference.len();
1940
+ }
1941
+ assert_eq!(idx, cached.len(), "cached encode produced extra tokens");
1942
+ eprintln!("all {idx} tokens match on {} MB", input.len() / 1_000_000);
1943
+ }
1944
+
1945
+ /// 1 GB of OWT through the public GPT-2 encode path
1946
+ /// (`encode_with_added_tokens_flat`) vs the uncached reference, plus a
1947
+ /// 100 MB added-token join differential. About half a minute in
1948
+ /// release mode (the uncached reference dominates).
1949
+ /// `cargo test --release verify_gpt2_public_encode_matches_reference_owt_1g -- --ignored --nocapture`
1950
+ #[test]
1951
+ #[ignore = "reads 1 GB of OWT; run explicitly in release mode"]
1952
+ fn verify_gpt2_public_encode_matches_reference_owt_1g() {
1953
+ let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
1954
+ let input = load_owt(1_000_000_000);
1955
+ assert!(input.len() > 900_000_000, "corpus too small: {}", input.len());
1956
+ compare_cached_vs_reference(&mut tok, &input, "gpt2-raw-1g", true);
1957
+ let mut tok2 = load_hf_bpe(gpt2_path()).unwrap();
1958
+ join_differential(&mut tok2, cut_at_newline(&input, 100_000_000), "gpt2-join-100m");
1959
+ }
1960
+
1961
+ /// ~200 MB of OWT through the public encode path of every non-GPT2
1962
+ /// tokenizer whose tokenizer.json loads (olmo3, qwen2, qwen3_5,
1963
+ /// deepseek_v3), vs the uncached reference; plus a 25 MB join
1964
+ /// differential each. About half a minute in release mode.
1965
+ /// `cargo test --release verify_multi_public_encode_matches_reference_owt_200m -- --ignored --nocapture`
1966
+ #[test]
1967
+ #[ignore = "reads 200 MB of OWT per tokenizer; run explicitly in release mode"]
1968
+ fn verify_multi_public_encode_matches_reference_owt_200m() {
1969
+ let input = load_owt(200_000_000);
1970
+ assert!(input.len() > 190_000_000, "corpus too small: {}", input.len());
1971
+ let mut ran = 0usize;
1972
+ // qwen3_5 is the load-bearing tokenizer here: its vocab has ~200
1973
+ // merge-unreachable entries (CJK phrases, " Jap\u{f3}n", …) that
1974
+ // the raw-ID vocab seed used to return as single tokens, diverging
1975
+ // from HF (see verify_vocab_seeded_cache_matches_merge_decomposition);
1976
+ // the seed now stores merge decompositions. Kept last so the clean
1977
+ // tokenizers report first on a regression.
1978
+ for (name, repo_id) in [
1979
+ ("olmo3", "allenai/Olmo-3-1025-7B"),
1980
+ ("qwen2", "Qwen/Qwen2-1.5B-Instruct"),
1981
+ ("deepseek_v3", "deepseek-ai/DeepSeek-V3"),
1982
+ ("qwen3_5", "Qwen/Qwen3.5-9B"),
1983
+ ] {
1984
+ let Some(path) = crate::test_hub::hf_tokenizer_json(repo_id) else {
1985
+ eprintln!("{name}: {repo_id} tokenizer.json not in the HF cache; skipping");
1986
+ continue;
1987
+ };
1988
+ let mut tok = match load_hf_bpe(&path) {
1989
+ Ok(t) => t,
1990
+ Err(e) => {
1991
+ eprintln!("{name}: failed to load ({e}); skipping");
1992
+ continue;
1993
+ }
1994
+ };
1995
+ eprintln!(
1996
+ "{name}: scheme {:?}, nfc {}, {} added tokens, vocab {}",
1997
+ tok.pretokenizer_type,
1998
+ tok.normalize_nfc,
1999
+ tok.added_tokens.len(),
2000
+ tok.vocab_size()
2001
+ );
2002
+ compare_cached_vs_reference(&mut tok, &input, name, true);
2003
+ let mut tok2 = load_hf_bpe(&path).unwrap();
2004
+ join_differential(&mut tok2, cut_at_newline(&input, 25_000_000), name);
2005
+ ran += 1;
2006
+ }
2007
+ assert!(ran >= 3, "only {ran} tokenizers loaded — expected at least olmo3/qwen2/deepseek_v3");
2008
+ }
2009
+
2010
+ /// Regression test for the vocab-seeded cache (commit d39bca2 originally
2011
+ /// seeded EVERY short vocab entry as pretoken -> [own id]): BPE encode
2012
+ /// semantics (HF `tokenizers` without `ignore_merges`, this repo's merge
2013
+ /// loop, and the pre-cache baseline 0e27c71) produce a whole-vocab-word
2014
+ /// token only when the merge rules can derive it. qwen3_5's vocab has
2015
+ /// ~200 merge-unreachable entries (mostly multi-char CJK phrases, plus
2016
+ /// e.g. " Jap\u{f3}n"); when one appears as a whole pretoken the pipeline
2017
+ /// must return the merge decomposition, exactly as if it had missed —
2018
+ /// the seed is precomputed misses (`merge_short`), never the raw ID.
2019
+ /// Ground truth verified against HF `tokenizers`: encode(" Jap\u{f3}n")
2020
+ /// on Qwen/Qwen3.5-9B's tokenizer.json gives [604, 385, 3064] ("ĠJ",
2021
+ /// "ap", "ón"); the raw-ID seed returned [209344] (" Jap\u{f3}n").
2022
+ #[test]
2023
+ fn verify_vocab_seeded_cache_matches_merge_decomposition() {
2024
+ let Some(path) = crate::test_hub::hf_tokenizer_json("Qwen/Qwen3.5-9B") else {
2025
+ eprintln!("Skipping: Qwen/Qwen3.5-9B tokenizer.json not in the HF cache");
2026
+ return;
2027
+ };
2028
+ let mut tok = load_hf_bpe(&path).expect("load qwen3_5");
2029
+ let pretoken: &[u8] = " Jap\u{f3}n".as_bytes();
2030
+
2031
+ let mut cached: Vec<u32> = Vec::new();
2032
+ tok.encode_with_added_tokens_flat(pretoken, &mut cached);
2033
+ // HF `tokenizers` ground truth for this tokenizer.json.
2034
+ assert_eq!(
2035
+ cached,
2036
+ vec![604, 385, 3064],
2037
+ "seeded cache returned the merge-unreachable whole-vocab entry"
2038
+ );
2039
+
2040
+ // Same rule on the long (> 15 byte) miss path, which used to take
2041
+ // a whole-pretoken vocab_inv shortcut: a merge-unreachable long
2042
+ // vocab entry must also encode as its decomposition. qwen3_5 id
2043
+ // 107517 is a 30-byte CJK phrase HF splits in 3.
2044
+ let long_entry = tok
2045
+ .vocab_entries()
2046
+ .find(|&(id, _)| id == 107517)
2047
+ .map(|(_, b)| b.to_vec())
2048
+ .expect("qwen3_5 vocab entry 107517");
2049
+ assert!(long_entry.len() > 15, "expected a long entry");
2050
+ let mut cached_long: Vec<u32> = Vec::new();
2051
+ tok.encode_with_added_tokens_flat(&long_entry, &mut cached_long);
2052
+ assert_ne!(
2053
+ cached_long,
2054
+ vec![107517],
2055
+ "long miss path returned the merge-unreachable whole-vocab entry"
2056
+ );
2057
+ }
2058
+
2059
+ /// `pack_pretoken_key`'s unaligned-16-byte fast path vs the naive lane
2060
+ /// copy at EVERY page offset (both branches of the page-boundary guard),
2061
+ /// all lengths 0..=15.
2062
+ #[test]
2063
+ fn verify_pack_pretoken_key_all_page_offsets() {
2064
+ use crate::pretokenize::pack_pretoken_key;
2065
+ let mut buf = vec![0u8; 12288];
2066
+ let mut rng = XorShift64(0x0123_4567_89AB_CDEF);
2067
+ for b in buf.iter_mut() {
2068
+ *b = rng.next_u64() as u8;
2069
+ if *b == 0 {
2070
+ *b = 1; // avoid zero lanes masking length-tag mistakes
2071
+ }
2072
+ }
2073
+ for start in 0..buf.len() - 16 {
2074
+ for n in 0..=15usize {
2075
+ let span = &buf[start..start + n];
2076
+ let key = pack_pretoken_key(span);
2077
+ let mut lanes = [0u8; 16];
2078
+ lanes[..n].copy_from_slice(span);
2079
+ let naive = if n == 0 {
2080
+ 0u128
2081
+ } else {
2082
+ u128::from_le_bytes(lanes) | ((n as u128) << 120)
2083
+ };
2084
+ assert_eq!(
2085
+ key,
2086
+ Some(naive),
2087
+ "pack_pretoken_key mismatch at buf offset {start} (page offset {}), len {n}",
2088
+ (buf[start..].as_ptr() as usize) & 4095
2089
+ );
2090
+ }
2091
+ }
2092
+ // Length > 15 routes to the long map.
2093
+ assert_eq!(pack_pretoken_key(&buf[..16]), None);
2094
+ }
2095
+ }
2096
+
2097
+ /// Alignment-invariance sweep: the walkers' output must not depend on the
2098
+ /// span's heap address. (The full-suite flake of the edge-length test —
2099
+ /// now `walker_edge::walker_edge_length_pretokens` — motivated this: same bytes,
2100
+ /// different run -> different tokens can only come from address-dependent
2101
+ /// framing in the SIMD batch walkers.)
2102
+ #[cfg(test)]
2103
+ mod verify_alignment {
2104
+ use super::test_util::{XorShift64, gpt2_path, two_phase_lens};
2105
+ use super::*;
2106
+ use crate::load_tokenizer::hf::load_hf_bpe;
2107
+
2108
+ #[test]
2109
+ fn verify_walker_alignment_invariance() {
2110
+ let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
2111
+ // Inputs chosen to stress batch-edge machinery: long runs of one
2112
+ // class, class flips near multiples of 64, multi-byte chars
2113
+ // straddling batch edges, contractions, digit groups.
2114
+ let mut inputs: Vec<Vec<u8>> = Vec::new();
2115
+ for n in [63usize, 64, 65, 127, 128, 129, 255, 256, 300, 4096] {
2116
+ for fill in [&b"a"[..], b"5", b" ", b"!", b"\n", "\u{e9}".as_bytes(), "\u{597d}".as_bytes()] {
2117
+ let mut v = Vec::new();
2118
+ while v.len() < n {
2119
+ v.extend_from_slice(fill);
2120
+ }
2121
+ inputs.push(v);
2122
+ }
2123
+ }
2124
+ let mut rng = XorShift64(0x9E37_79B9_7F4A_7C15);
2125
+ const PIECES: &[&str] = &[
2126
+ " the", " a", "word", "05", " ", "\n", "'s", "n't", ",", " \u{e9}t\u{e9}",
2127
+ "\u{597d}\u{597d}", " 123", "...", "\t", " I'm", "\u{2014}", "e", " ",
2128
+ ];
2129
+ for _ in 0..200 {
2130
+ let target = 80 + (rng.next_u64() % 400) as usize;
2131
+ let mut v = Vec::new();
2132
+ while v.len() < target {
2133
+ v.extend_from_slice(
2134
+ PIECES[(rng.next_u64() % PIECES.len() as u64) as usize].as_bytes(),
2135
+ );
2136
+ }
2137
+ inputs.push(v);
2138
+ }
2139
+
2140
+ for (which, input) in inputs.iter().enumerate() {
2141
+ // Copy the same bytes at every offset 0..64 of a fresh buffer;
2142
+ // walker output must be identical for all of them.
2143
+ let mut ref_lens: Option<Vec<usize>> = None;
2144
+ let mut ref_ids: Option<Vec<u32>> = None;
2145
+ for off in 0..64usize {
2146
+ let mut buf = vec![0u8; off + input.len() + 64];
2147
+ buf[off..off + input.len()].copy_from_slice(input);
2148
+ let span = &buf[off..off + input.len()];
2149
+ let lens = two_phase_lens(FastR50kPretokenizer::new(span));
2150
+ let mut ids: Vec<u32> = Vec::new();
2151
+ tok.memoized_encode_flat(FastR50kPretokenizer::new(span), &mut ids);
2152
+ match (&ref_lens, &ref_ids) {
2153
+ (None, _) => {
2154
+ ref_lens = Some(lens);
2155
+ ref_ids = Some(ids);
2156
+ }
2157
+ (Some(rl), Some(ri)) => {
2158
+ assert!(
2159
+ &lens == rl,
2160
+ "input {which}: pretoken lens differ at offset {off}\n base: {rl:?}\n off{off}: {lens:?}\n input: {:?}",
2161
+ String::from_utf8_lossy(input)
2162
+ );
2163
+ assert!(
2164
+ &ids == ri,
2165
+ "input {which}: token ids differ at offset {off} on {:?}",
2166
+ String::from_utf8_lossy(input)
2167
+ );
2168
+ }
2169
+ _ => unreachable!(),
2170
+ }
2171
+ }
2172
+ }
2173
+ eprintln!("alignment invariance: {} inputs x 64 offsets ok", inputs.len());
2174
+ }
2175
+ }
2176
+
2177
+ /// Walker edge-condition tests: truncated multi-byte UTF-8 at the buffer
2178
+ /// end (Bug A: `decode_cp` used to read up to 3 bytes past the slice and
2179
+ /// return a pretoken end past `len`) and invalid-UTF-8 garbage codepoints
2180
+ /// (Bug B: 0xF5..=0xFF leads decoded to cp > 0x10FFFF, and `class_of`'s
2181
+ /// unchecked table load then read heap memory past the class table —
2182
+ /// nondeterministic under concurrent allocation, causing the Iterator and
2183
+ /// two-phase paths to split >65 KB invalid pretokens differently).
2184
+ /// Correctness bar: every scheme partitions ARBITRARY bytes contiguously,
2185
+ /// in bounds, identically on the Iterator (`next_span`) and two-phase
2186
+ /// (`fill_spans_keyed`) paths, deterministically.
2187
+ #[cfg(test)]
2188
+ mod walker_edge {
2189
+ use super::test_util::{XorShift64, gpt2_path, plain_encode_pretoken, two_phase_lens};
2190
+ use super::*;
2191
+ use crate::load_tokenizer::hf::load_hf_bpe;
2192
+
2193
+ /// Assert a scheme's pretokens are a contiguous, non-empty, in-bounds
2194
+ /// partition of `span` (required for encode correctness; also catches
2195
+ /// walkers running past the buffer on truncated UTF-8).
2196
+ fn check_partition<'a>(
2197
+ span: &'a [u8],
2198
+ it: impl Iterator<Item = Pretoken<'a>>,
2199
+ scheme: &str,
2200
+ ) {
2201
+ let mut off = 0usize;
2202
+ for p in it {
2203
+ assert!(
2204
+ std::ptr::eq(p.0.as_ptr(), span[off..].as_ptr()),
2205
+ "{scheme}: non-contiguous pretoken at byte {off} of {:?}",
2206
+ String::from_utf8_lossy(span)
2207
+ );
2208
+ assert!(!p.0.is_empty(), "{scheme}: empty pretoken at byte {off}");
2209
+ off += p.0.len();
2210
+ assert!(
2211
+ off <= span.len(),
2212
+ "{scheme}: pretoken overruns span end ({off} > {}) on {:?}",
2213
+ span.len(),
2214
+ String::from_utf8_lossy(span)
2215
+ );
2216
+ }
2217
+ assert_eq!(
2218
+ off,
2219
+ span.len(),
2220
+ "{scheme}: pretokens cover {off} of {} bytes of {:?}",
2221
+ span.len(),
2222
+ String::from_utf8_lossy(span)
2223
+ );
2224
+ }
2225
+
2226
+ /// Partition check (Iterator path) plus cached encode (two-phase
2227
+ /// `fill_spans_keyed` path) vs the plain per-pretoken reference over
2228
+ /// the Iterator's pretokens — so the two walker paths are compared
2229
+ /// against each other on every span.
2230
+ fn check_scheme_encode<'a, P>(
2231
+ tok: &mut Tokenizer,
2232
+ span: &'a [u8],
2233
+ make: impl Fn(&'a [u8]) -> P,
2234
+ scheme: &str,
2235
+ ) where
2236
+ P: PretokenSpans<'a>,
2237
+ P: Iterator<Item = Pretoken<'a>>,
2238
+ {
2239
+ check_partition(span, make(span), scheme);
2240
+ let mut got: Vec<u32> = Vec::new();
2241
+ tok.memoized_encode_flat(make(span), &mut got);
2242
+ let mut expected: Vec<u32> = Vec::new();
2243
+ for p in make(span) {
2244
+ plain_encode_pretoken(tok, p.0, &mut expected);
2245
+ }
2246
+ assert!(
2247
+ got == expected,
2248
+ "{scheme}: cached encode mismatch on {:?} (len {}):\n cached {:?}\n expected {:?}",
2249
+ String::from_utf8_lossy(span),
2250
+ span.len(),
2251
+ got,
2252
+ expected
2253
+ );
2254
+ }
2255
+
2256
+ fn check_all_schemes(tok: &mut Tokenizer, span: &[u8]) {
2257
+ check_scheme_encode(tok, span, FastR50kPretokenizer::new, "r50k");
2258
+ check_scheme_encode(tok, span, FastCl100kPretokenizer::new, "cl100k");
2259
+ check_scheme_encode(tok, span, FastQwen2Pretokenizer::new, "qwen2");
2260
+ check_scheme_encode(tok, span, FastQwen35Pretokenizer::new, "qwen3_5");
2261
+ check_scheme_encode(tok, span, FastOlmo3Pretokenizer::new, "olmo3");
2262
+ check_scheme_encode(tok, span, FastDeepSeekV3Pretokenizer::new, "deepseek_v3");
2263
+ }
2264
+
2265
+ /// Truncated multi-byte UTF-8 at the buffer end, every shape: for each
2266
+ /// lead-byte length (2/3/4) every truncation point (1..len-1 available
2267
+ /// continuation bytes missing), plus lone continuation bytes and
2268
+ /// invalid 0xF5..=0xFF leads, behind assorted prefixes that put the
2269
+ /// truncated char after a letter run / digit run / space / whitespace
2270
+ /// run / punctuation / another unicode char. Exactly-sized heap
2271
+ /// allocations so any walker overrun is an observable OOB.
2272
+ #[test]
2273
+ fn walker_truncated_utf8_tail() {
2274
+ let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
2275
+ // Leads: 2-byte (0xC3), 3-byte (0xE2, and 0xE0 low), 4-byte (0xF0,
2276
+ // 0xF4 high), invalid leads (0xF5, 0xF8, 0xFF), continuation (0x80,
2277
+ // 0xBF), and 0xC0/0xC1 (invalid 2-byte leads).
2278
+ let leads: &[&[u8]] = &[
2279
+ b"\xc3",
2280
+ b"\xe2",
2281
+ b"\xe2\x80",
2282
+ b"\xe0",
2283
+ b"\xe0\xa0",
2284
+ b"\xf0",
2285
+ b"\xf0\x9f",
2286
+ b"\xf0\x9f\x99",
2287
+ b"\xf4",
2288
+ b"\xf4\x8f",
2289
+ b"\xf4\x8f\xbf",
2290
+ b"\xf5",
2291
+ b"\xf8\x88",
2292
+ b"\xff",
2293
+ b"\xff\xff",
2294
+ b"\xff\xff\xff",
2295
+ b"\x80",
2296
+ b"\xbf",
2297
+ b"\xc0",
2298
+ b"\xc1",
2299
+ ];
2300
+ let prefixes: &[&[u8]] = &[
2301
+ b"",
2302
+ b"a",
2303
+ b"hello",
2304
+ b"hello ",
2305
+ b"123",
2306
+ b" ",
2307
+ b" \n",
2308
+ b"!?",
2309
+ "é".as_bytes(),
2310
+ "好".as_bytes(),
2311
+ b"'s",
2312
+ b"\xff\xff\xff\xff", // complete invalid run before the tail
2313
+ ];
2314
+ for &lead in leads {
2315
+ for &prefix in prefixes {
2316
+ let mut buf = Vec::with_capacity(prefix.len() + lead.len());
2317
+ buf.extend_from_slice(prefix);
2318
+ buf.extend_from_slice(lead);
2319
+ check_all_schemes(&mut tok, &buf);
2320
+ // Public path must not panic and must match the plain
2321
+ // reference over the Iterator's pretokens.
2322
+ let mut cached: Vec<u32> = Vec::new();
2323
+ tok.encode_with_added_tokens_flat(&buf, &mut cached);
2324
+ let mut expected: Vec<u32> = Vec::new();
2325
+ for p in FastR50kPretokenizer::new(&buf) {
2326
+ plain_encode_pretoken(&tok, p.0, &mut expected);
2327
+ }
2328
+ assert!(
2329
+ cached == expected,
2330
+ "public path mismatch on {:?}: cached {:?} expected {:?}",
2331
+ String::from_utf8_lossy(&buf),
2332
+ cached,
2333
+ expected
2334
+ );
2335
+ }
2336
+ }
2337
+ }
2338
+
2339
+ /// Deterministic boundary fuzz: random spans (0-64 bytes; ASCII text,
2340
+ /// raw bytes incl. invalid UTF-8 and truncated tails, valid multi-byte
2341
+ /// UTF-8, whitespace runs) placed at the END of an exactly-sized
2342
+ /// allocation, through every scheme's walker (partition + cached-vs-
2343
+ /// plain encode) and the full public GPT-2 path. Fixed seed, no I/O
2344
+ /// beyond the tokenizer.
2345
+ #[test]
2346
+ fn walker_boundary_fuzz_memoized_vs_reference() {
2347
+ let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
2348
+ let mut rng = XorShift64(0x243F_6A88_85A3_08D3);
2349
+ const CHARS: &[&str] = &["é", "ü", "好", "日", "🙂", "ß", "—", "\u{0301}", "٣", "क"];
2350
+ let iters = if cfg!(debug_assertions) { 2_000 } else { 12_000 };
2351
+ for iter in 0..iters {
2352
+ let len = (rng.next_u64() % 65) as usize;
2353
+ let pad = (rng.next_u64() % 17) as usize;
2354
+ let mut buf: Vec<u8> = Vec::with_capacity(pad + len + 8);
2355
+ for _ in 0..pad {
2356
+ buf.push(rng.next_u64() as u8);
2357
+ }
2358
+ let span_start = buf.len();
2359
+ match rng.next_u64() % 4 {
2360
+ 0 => {
2361
+ // ASCII text: letters, digits, spaces, punct, contractions
2362
+ const POOL: &[u8] = b" aetoAETO059'.,!-\n\t\"()s d";
2363
+ while buf.len() - span_start < len {
2364
+ buf.push(POOL[(rng.next_u64() % POOL.len() as u64) as usize]);
2365
+ }
2366
+ }
2367
+ 1 => {
2368
+ // Raw bytes: full 0..=255, mostly invalid UTF-8,
2369
+ // truncated tails included.
2370
+ while buf.len() - span_start < len {
2371
+ buf.push(rng.next_u64() as u8);
2372
+ }
2373
+ }
2374
+ 2 => {
2375
+ // Valid UTF-8 mix: fill to >= len, then trim whole
2376
+ // chars back to <= len so the span stays valid UTF-8.
2377
+ while buf.len() - span_start < len {
2378
+ if rng.next_u64().is_multiple_of(2) {
2379
+ buf.push(b' ' + (rng.next_u64() % 94) as u8);
2380
+ } else {
2381
+ buf.extend_from_slice(
2382
+ CHARS[(rng.next_u64() % CHARS.len() as u64) as usize].as_bytes(),
2383
+ );
2384
+ }
2385
+ }
2386
+ while buf.len() - span_start > len
2387
+ || std::str::from_utf8(&buf[span_start..]).is_err()
2388
+ {
2389
+ buf.pop();
2390
+ }
2391
+ }
2392
+ _ => {
2393
+ // Whitespace-heavy
2394
+ const POOL: &[u8] = b" \n\n\t\r a5.";
2395
+ while buf.len() - span_start < len {
2396
+ buf.push(POOL[(rng.next_u64() % POOL.len() as u64) as usize]);
2397
+ }
2398
+ }
2399
+ }
2400
+ buf.truncate(span_start + len.min(buf.len() - span_start));
2401
+ let (head, span) = buf.split_at(span_start);
2402
+ let _ = head;
2403
+ check_all_schemes(&mut tok, span);
2404
+ // Full public path (added-token scan + NFC gate + flat emit).
2405
+ let mut cached: Vec<u32> = Vec::new();
2406
+ let mut expected: Vec<u32> = Vec::new();
2407
+ // GPT-2's only added token is <|endoftext|>, absent from these
2408
+ // spans (13-byte pattern, span <= 64 random bytes — and the
2409
+ // reference below would not model it).
2410
+ for p in FastR50kPretokenizer::new(span) {
2411
+ plain_encode_pretoken(&tok, p.0, &mut expected);
2412
+ }
2413
+ tok.encode_with_added_tokens_flat(span, &mut cached);
2414
+ assert!(
2415
+ cached == expected,
2416
+ "public path mismatch at iter {iter} on {:?}: cached {:?} expected {:?}",
2417
+ String::from_utf8_lossy(span),
2418
+ cached,
2419
+ expected
2420
+ );
2421
+ }
2422
+ eprintln!("boundary fuzz: {iters} spans x 6 schemes ok");
2423
+ }
2424
+
2425
+ /// Pretokens at the exact edge lengths of the key-packing and cache
2426
+ /// machinery (15/16 for the packed u128 key, 65535/65536 and beyond
2427
+ /// for long runs through the walkers), in letter/digit/space/punct/
2428
+ /// multi-byte/invalid fills, each in an exactly-sized allocation.
2429
+ #[test]
2430
+ fn walker_edge_length_pretokens() {
2431
+ let mut tok = load_hf_bpe(gpt2_path()).expect("load GPT-2 tokenizer");
2432
+ let lens: &[usize] = &[
2433
+ 1, 2, 7, 8, 14, 15, 16, 17, 31, 32, 63, 64, 65, 127, 128, 255, 256, 4095, 4096,
2434
+ 4097, 65_535, 65_536, 65_537, 70_003,
2435
+ ];
2436
+ let fills: &[&[u8]] = &[
2437
+ b"a",
2438
+ b"5",
2439
+ b" ",
2440
+ b"!",
2441
+ b"\n",
2442
+ "\u{e9}".as_bytes(), // é (2-byte letter)
2443
+ "\u{597d}".as_bytes(), // 好 (3-byte letter)
2444
+ b"\xff", // invalid UTF-8
2445
+ ];
2446
+ for &n in lens {
2447
+ for fill in fills {
2448
+ // Repeat fill to >= n bytes, then cut to n only for 1-byte
2449
+ // fills (multi-byte fills keep whole chars).
2450
+ let reps = n / fill.len() + usize::from(n % fill.len() != 0);
2451
+ if reps == 0 {
2452
+ continue;
2453
+ }
2454
+ let exact = if fill.len() == 1 { n } else { reps * fill.len() };
2455
+ let mut buf: Vec<u8> = Vec::with_capacity(exact);
2456
+ while buf.len() < exact {
2457
+ buf.extend_from_slice(fill);
2458
+ }
2459
+ buf.truncate(exact);
2460
+ check_all_schemes(&mut tok, &buf);
2461
+ // Space-prefixed variant hits the space-fused starts.
2462
+ let mut buf2: Vec<u8> = Vec::with_capacity(exact + 1);
2463
+ buf2.push(b' ');
2464
+ buf2.extend_from_slice(&buf);
2465
+ check_all_schemes(&mut tok, &buf2);
2466
+ // 0xFF run with a letter tail: the exact shape of the
2467
+ // >65 KB nondeterminism (the last garbage 4-byte decode
2468
+ // straddles into the letters).
2469
+ if fill == b"\xff" && exact >= 4 {
2470
+ let mut buf3 = buf.clone();
2471
+ let e = buf3.len();
2472
+ buf3[e - 3..].copy_from_slice(b"cba");
2473
+ check_all_schemes(&mut tok, &buf3);
2474
+ }
2475
+ }
2476
+ }
2477
+ eprintln!("edge-length pretokens ok");
2478
+ }
2479
+
2480
+ /// Bug B regression: the two spans that flaked (~1/25 full-suite runs)
2481
+ /// pre-fix — 65534 x 0xFF + "cba", bare and space-prefixed — walked
2482
+ /// repeatedly on both paths while background threads churn the heap.
2483
+ /// Pre-fix, `class_of` read past the class table for the garbage
2484
+ /// codepoints these 0xFF runs decode to, so the boundary near the run
2485
+ /// tail depended on whatever heap memory followed the table; the churn
2486
+ /// recreates the "concurrent test threads" condition deterministically
2487
+ /// enough that the old code fails this test within a few rounds.
2488
+ /// Post-fix every round must produce the identical partition on both
2489
+ /// paths.
2490
+ #[test]
2491
+ fn walker_ff_run_paths_agree_under_heap_churn() {
2492
+ // 16 spare bytes of capacity so the run is AddressSanitizer-clean:
2493
+ // `pack_pretoken_key`'s page-guarded 16-byte load may overread the
2494
+ // final short span within its page (by design — masked out, and
2495
+ // never crosses into an unmapped page), which ASAN's redzones
2496
+ // would otherwise flag on an exactly-sized allocation.
2497
+ // Exactly-sized allocations are covered by the other tests here.
2498
+ let mut a = Vec::with_capacity(65537 + 16);
2499
+ a.resize(65534, 0xFFu8);
2500
+ a.extend_from_slice(b"cba"); // len 65537
2501
+ let mut b = Vec::with_capacity(65538 + 16);
2502
+ b.push(b' ');
2503
+ b.extend_from_slice(&a); // len 65538
2504
+ let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2505
+ let churners: Vec<_> = (0..4)
2506
+ .map(|t| {
2507
+ let stop = stop.clone();
2508
+ std::thread::spawn(move || {
2509
+ let mut keep: Vec<Vec<u8>> = Vec::new();
2510
+ let mut i = 0usize;
2511
+ while !stop.load(std::sync::atomic::Ordering::Relaxed) {
2512
+ // Vary size and content so the pages after any
2513
+ // fresh allocation keep changing.
2514
+ let sz = 1 << (12 + (i + t) % 8); // 4 KB .. 512 KB
2515
+ keep.push(vec![(i as u8) ^ 0x5A; sz]);
2516
+ if keep.len() > 8 {
2517
+ keep.clear();
2518
+ }
2519
+ i += 1;
2520
+ }
2521
+ })
2522
+ })
2523
+ .collect();
2524
+ let iter_lens = |span: &[u8]| -> Vec<usize> {
2525
+ FastR50kPretokenizer::new(span).map(|p| p.0.len()).collect()
2526
+ };
2527
+ let mut reference: Option<[Vec<usize>; 2]> = None;
2528
+ for round in 0..40 {
2529
+ let got = [
2530
+ {
2531
+ let (i, t) = (iter_lens(&a), two_phase_lens(FastR50kPretokenizer::new(&a)));
2532
+ assert_eq!(i, t, "round {round} span A: iterator vs two-phase");
2533
+ i
2534
+ },
2535
+ {
2536
+ let (i, t) = (iter_lens(&b), two_phase_lens(FastR50kPretokenizer::new(&b)));
2537
+ assert_eq!(i, t, "round {round} span B: iterator vs two-phase");
2538
+ i
2539
+ },
2540
+ ];
2541
+ match &reference {
2542
+ None => reference = Some(got),
2543
+ Some(r) => assert_eq!(
2544
+ r,
2545
+ &got,
2546
+ "round {round}: partition changed between rounds (nondeterminism)"
2547
+ ),
2548
+ }
2549
+ }
2550
+ stop.store(true, std::sync::atomic::Ordering::Relaxed);
2551
+ for c in churners {
2552
+ c.join().unwrap();
2553
+ }
2554
+ }
2555
+ }