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,1485 @@
1
+ use crate::bpe::bpe_merge_symbols_ranked;
2
+ use crate::pretokenize::pack_pretoken_key;
3
+ use crate::token::TokenId;
4
+ use rustc_hash::FxBuildHasher;
5
+ use std::borrow::Cow;
6
+ use std::collections::HashMap;
7
+ use std::sync::Arc;
8
+
9
+ /// SentencePiece uses U+2581 (▁) as a space marker.
10
+ const SENTENCEPIECE_SPACE: char = '\u{2581}';
11
+ const SENTENCEPIECE_SPACE_STR: &str = "\u{2581}";
12
+ const SP_MARK: [u8; 3] = [0xE2, 0x96, 0x81]; // UTF-8 bytes of ▁
13
+
14
+ /// How text divides into independently-encodable (and cacheable) word units.
15
+ /// Computed once at load from the pre-tokenizer config and the vocab.
16
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
17
+ pub(crate) enum WordSplit {
18
+ /// A unit starts at each ▁ that follows a non-▁ char: units look like
19
+ /// `▁▁▁word`. Valid when no vocab piece contains a ▁ after a non-▁ char,
20
+ /// so no merge can cross a unit boundary and per-unit BPE equals the
21
+ /// global merge.
22
+ SpaceRuns,
23
+ /// Metaspace `split=true`: a unit starts at every ▁ (HF splits there, so
24
+ /// this is exact regardless of the vocab).
25
+ EveryMark,
26
+ /// The vocab has boundary-crossing pieces; merge whole chunks, uncached.
27
+ None,
28
+ }
29
+
30
+ /// How the raw fast path prepends the dummy-prefix ▁ to a chunk.
31
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
32
+ pub(crate) enum RawPrepend {
33
+ Never,
34
+ /// `Prepend` normalizer: unconditional (Llama 2).
35
+ Unguarded,
36
+ /// Metaspace `always`: skipped when the chunk already starts with a mark.
37
+ GuardedAlways,
38
+ /// Metaspace `first`: like `GuardedAlways`, first chunk of the input only.
39
+ GuardedFirst,
40
+ }
41
+
42
+ /// One step of a tokenizer.json `normalizer` sequence, applied in order to
43
+ /// each text chunk between added tokens (HF normalizes those independently).
44
+ pub enum NormOp {
45
+ /// `Prepend`: unconditional prefix, e.g. Llama 2's "▁". HF's Prepend
46
+ /// leaves empty chunks empty.
47
+ Prepend(String),
48
+ /// `Replace` with a literal `String` pattern.
49
+ Replace { pattern: String, content: String },
50
+ /// `Replace` with the `" {2,}"` regex (transformers' SpmConverter emits it
51
+ /// for `remove_extra_whitespaces`): each run of 2+ ASCII spaces becomes
52
+ /// `content`.
53
+ CollapseSpaces { content: String },
54
+ /// `Strip` Unicode whitespace.
55
+ Strip { left: bool, right: bool },
56
+ /// `Precompiled` charsmap (sentencepiece's nmt_nfkc and friends).
57
+ Precompiled(PrecompiledCharsmap),
58
+ }
59
+
60
+ /// A precompiled charsmap plus lookup tables that let ASCII-dominant text
61
+ /// skip the per-grapheme trie walk (which runs at ~50 MB/s).
62
+ pub struct PrecompiledCharsmap {
63
+ pre: spm_precompiled::Precompiled,
64
+ /// `transform` of each single ASCII char; `None` = pass through.
65
+ ascii_map: [Option<Box<str>>; 128],
66
+ /// The "\r\n" grapheme's mapping (`None` = pass through).
67
+ crlf: Option<Box<str>>,
68
+ /// True when no printable ASCII char is remapped, so the SIMD clean-run
69
+ /// scan only has to stop on control bytes and non-ASCII.
70
+ fast_scan: bool,
71
+ }
72
+
73
+ impl PrecompiledCharsmap {
74
+ pub(crate) fn new(pre: spm_precompiled::Precompiled) -> Self {
75
+ let mut ascii_map: [Option<Box<str>>; 128] = std::array::from_fn(|_| None);
76
+ let mut fast_scan = true;
77
+ let mut buf = [0u8; 4];
78
+ for b in 0..128u8 {
79
+ let ch = b as char;
80
+ let s = ch.encode_utf8(&mut buf);
81
+ if let Some(norm) = pre.transform(s)
82
+ && norm != s
83
+ {
84
+ if (0x20..0x7F).contains(&b) {
85
+ // A remapped printable would make clean runs unsound.
86
+ fast_scan = false;
87
+ }
88
+ ascii_map[b as usize] = Some(norm.into());
89
+ }
90
+ }
91
+ let crlf = pre.transform("\r\n").map(Into::into);
92
+ PrecompiledCharsmap {
93
+ pre,
94
+ ascii_map,
95
+ crlf,
96
+ fast_scan,
97
+ }
98
+ }
99
+
100
+ /// Exactly `spm_precompiled::Precompiled::normalize_string`, but ASCII
101
+ /// runs bypass the grapheme walk: printable ASCII chars are standalone
102
+ /// grapheme clusters (only CR×LF joins, and only non-ASCII extends a
103
+ /// cluster), and control chars break unconditionally on both sides, so
104
+ /// the walk is only needed for spans around non-ASCII bytes — including
105
+ /// one ASCII margin byte on each side, which non-ASCII prepend/combining
106
+ /// characters can absorb into their cluster.
107
+ pub(crate) fn normalize_into(&self, input: &str, out: &mut String) {
108
+ use std::simd::prelude::*;
109
+
110
+ if !self.fast_scan {
111
+ out.push_str(&self.pre.normalize_string(input));
112
+ return;
113
+ }
114
+ let bytes = input.as_bytes();
115
+ let mut i = 0usize;
116
+ while i < bytes.len() {
117
+ // SIMD hop to the next attention byte (non-ASCII, control, DEL).
118
+ let mut j = i;
119
+ 'scan: {
120
+ while j + 16 <= bytes.len() {
121
+ let v = u8x16::from_slice(&bytes[j..]);
122
+ let attention = v.simd_ge(u8x16::splat(0x7F)) | v.simd_lt(u8x16::splat(0x20));
123
+ let m = attention.to_bitmask();
124
+ if m != 0 {
125
+ j += m.trailing_zeros() as usize;
126
+ break 'scan;
127
+ }
128
+ j += 16;
129
+ }
130
+ while j < bytes.len() && (0x20..0x7F).contains(&bytes[j]) {
131
+ j += 1;
132
+ }
133
+ }
134
+
135
+ if j >= bytes.len() {
136
+ out.push_str(&input[i..]);
137
+ return;
138
+ }
139
+ let b = bytes[j];
140
+ if b < 0x80 {
141
+ // Control or DEL: a standalone grapheme except CR before LF.
142
+ out.push_str(&input[i..j]);
143
+ if b == b'\r' && bytes.get(j + 1) == Some(&b'\n') {
144
+ match &self.crlf {
145
+ Some(norm) => out.push_str(norm),
146
+ None => out.push_str("\r\n"),
147
+ }
148
+ i = j + 2;
149
+ } else {
150
+ match &self.ascii_map[b as usize] {
151
+ Some(norm) => out.push_str(norm),
152
+ None => out.push(b as char),
153
+ }
154
+ i = j + 1;
155
+ }
156
+ continue;
157
+ }
158
+ // Non-ASCII span: pull in one preceding printable-ASCII byte (a
159
+ // combining mark would extend its cluster), then extend until a
160
+ // printable-ASCII byte whose successor is also ASCII (or a
161
+ // control, which always breaks).
162
+ let span_start = if j > i { j - 1 } else { j };
163
+ out.push_str(&input[i..span_start]);
164
+ let mut k = j;
165
+ let span_end = loop {
166
+ if k >= bytes.len() {
167
+ break bytes.len();
168
+ }
169
+ let c = bytes[k];
170
+ if c < 0x20 || c == 0x7F {
171
+ break k; // control: hard break before it
172
+ }
173
+ if c < 0x80 && bytes.get(k + 1).is_none_or(|&n| n < 0x80) {
174
+ break k + 1; // ASCII with ASCII successor: safe cut after
175
+ }
176
+ k += 1;
177
+ };
178
+ out.push_str(&self.pre.normalize_string(&input[span_start..span_end]));
179
+ i = span_end;
180
+ }
181
+ }
182
+ }
183
+
184
+ /// Position of a section (the text between added-token matches) within its
185
+ /// document, which decides whether the raw fast path ▁-prefixes the
186
+ /// section's first unit.
187
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
188
+ enum SectionPos {
189
+ /// The very start of the document (Metaspace `first` prepends only here).
190
+ First,
191
+ /// After an added-token match; per-section prepends still apply.
192
+ Middle,
193
+ /// Continues a section begun in an earlier fragment of a split document
194
+ /// (see [`SentencePieceBPE::safe_fragment_ranges`]): never prefixed —
195
+ /// the section's prefix, if any, was emitted with its first unit.
196
+ Continuation,
197
+ }
198
+
199
+ /// When the Metaspace pre-tokenizer prepends ▁ to a chunk.
200
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
201
+ pub enum PrependScheme {
202
+ Never,
203
+ Always,
204
+ /// Only the chunk at the very start of the input; chunks after an added
205
+ /// token don't count.
206
+ First,
207
+ }
208
+
209
+ /// tokenizer.json `Metaspace` pre-tokenizer: replaces spaces with ▁, then
210
+ /// prepends ▁ per `prepend` (unless the chunk already starts with ▁), and —
211
+ /// with `split` — keeps BPE merges from crossing ▁ word boundaries.
212
+ pub struct Metaspace {
213
+ pub prepend: PrependScheme,
214
+ pub split: bool,
215
+ }
216
+
217
+ /// An added token, matched atomically in text before encoding.
218
+ pub struct AddedTokenSpec {
219
+ /// What to find in the text. For `normalized` tokens this is the content
220
+ /// after running it through the normalizer ops (HF matches those against
221
+ /// normalized text).
222
+ pub content: String,
223
+ pub id: TokenId,
224
+ /// Consume whitespace immediately before the match.
225
+ pub lstrip: bool,
226
+ /// Consume whitespace immediately after the match.
227
+ pub rstrip: bool,
228
+ }
229
+
230
+ /// A tokenizer that mirrors SentencePiece BPE with `byte_fallback`.
231
+ ///
232
+ /// This struct holds the immutable model data (vocab, merges, added tokens,
233
+ /// normalizer configuration). For encoding, create an [`Encoder`] which
234
+ /// processes text through the normalize → character init → BPE merge pipeline.
235
+ pub struct SentencePieceBPE {
236
+ /// Merges with explicit rank, keyed by [`crate::bpe::ranked_merge_key`]:
237
+ /// `key(a, b) → (merged, rank)`.
238
+ pub(crate) merges: HashMap<u64, (TokenId, u32), FxBuildHasher>,
239
+ pub(crate) vocab: Vec<Arc<[u8]>>,
240
+ /// Maps byte sequences → token IDs (for character lookup).
241
+ pub(crate) vocab_inv: HashMap<Arc<[u8]>, TokenId, FxBuildHasher>,
242
+ /// Token ID for each byte value (0x00–0xFF) via `<0xHH>` fallback tokens.
243
+ /// `None` when the vocab lacks that byte's token (e.g. Gemma has literal
244
+ /// `\t` pieces instead of `<0x09>`); such bytes can then only appear in
245
+ /// text as chars the vocab covers, so the fallback is never consulted.
246
+ pub(crate) byte_fallback_ids: [Option<TokenId>; 256],
247
+ /// Added tokens with `normalized: false`, matched in the raw input.
248
+ pub(crate) added_tokens: Vec<AddedTokenSpec>,
249
+ /// Added tokens with `normalized: true`, matched (with pre-normalized
250
+ /// content) against normalizer output, before the Metaspace step.
251
+ pub(crate) norm_added_tokens: Vec<AddedTokenSpec>,
252
+ /// The tokenizer.json normalizer sequence, applied per chunk in order.
253
+ pub(crate) norm_ops: Vec<NormOp>,
254
+ /// The Metaspace pre-tokenizer, if the tokenizer.json has one. `None`
255
+ /// (e.g. Llama 2) means spaces are already handled by `norm_ops` and
256
+ /// merges may cross word boundaries.
257
+ pub(crate) metaspace: Option<Metaspace>,
258
+ /// Unit-splitting mode; see [`WordSplit`]. Set by `finalize_speed_paths`.
259
+ pub(crate) word_split: WordSplit,
260
+ /// `Some` when the whole normalizer pipeline reduces to
261
+ /// optional-▁-prepend + space→▁, so encoding can split raw text directly
262
+ /// without materializing a normalized string. Set by
263
+ /// `finalize_speed_paths`.
264
+ pub(crate) raw_prepend: Option<RawPrepend>,
265
+ /// Initial symbol(s) for the ▁ marker (its vocab piece, or its UTF-8
266
+ /// bytes through byte fallback). Set by `finalize_speed_paths`.
267
+ pub(crate) space_init: Vec<TokenId>,
268
+ /// Initial symbol for each ASCII char: its single-char vocab piece or its
269
+ /// byte-fallback token, skipping the per-char `vocab_inv` probe on the
270
+ /// merge path. `None` = the byte has no token at all. Set by
271
+ /// `finalize_speed_paths`.
272
+ pub(crate) ascii_init: [Option<TokenId>; 128],
273
+ /// Leftmost-longest automaton over `added_tokens` contents (pattern index
274
+ /// == `added_tokens` index), like the byte-level path's added matcher —
275
+ /// repeated `str::find` per token costs ~8% of encode on long inputs.
276
+ /// Set by `finalize_speed_paths`.
277
+ pub(crate) added_matcher: Option<aho_corasick::AhoCorasick>,
278
+ /// Frequent ASCII punctuation to split units *before* (0 = unused slot):
279
+ /// units like "▁word," otherwise explode the distinct-unit space (and
280
+ /// the pretoken cache) with word×punctuation combinations. Set by
281
+ /// `finalize_speed_paths`.
282
+ pub(crate) split_bytes: [u8; NUM_SPLIT_BYTES],
283
+ /// Indexed by the split byte, a bitset over the previous byte for when
284
+ /// the split is safe — exactly the predecessors that never appear
285
+ /// immediately before that byte inside any vocab piece, so no merge can
286
+ /// span the boundary. Empty (all zeros) for non-split bytes.
287
+ pub(crate) split_safe: Vec<[u64; 4]>,
288
+ /// Decompositions of the vocab pieces that can cross a `▁▁▁word` unit
289
+ /// boundary, as `(pre, post)` around one interior ▁ — e.g. gemma's
290
+ /// `>▁</` yields `(">", "</")`. Under `SpaceRuns` the scanner skips a
291
+ /// split exactly where such a piece occurs spanning the candidate
292
+ /// boundary (a merge result is always a contiguous vocab piece, so
293
+ /// every other boundary is provably safe). Set by
294
+ /// `finalize_speed_paths`; empty under `EveryMark`/`None`.
295
+ pub(crate) cross_pieces: Vec<(Box<[u8]>, Box<[u8]>)>,
296
+ /// Bitset over the byte just before a candidate boundary (the last byte
297
+ /// of some `cross_pieces` pre) for a one-load rejection in the scanner.
298
+ pub(crate) cross_prev: [u64; 4],
299
+ }
300
+
301
+ /// How many distinct punctuation bytes the unit splitter checks for.
302
+ pub(crate) const NUM_SPLIT_BYTES: usize = 8;
303
+
304
+ impl std::fmt::Debug for SentencePieceBPE {
305
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306
+ write!(
307
+ f,
308
+ "SentencePieceBPE {{ vocab_size: {}, merges_count: {} }}",
309
+ self.vocab.len(),
310
+ self.merges.len(),
311
+ )
312
+ }
313
+ }
314
+
315
+ impl SentencePieceBPE {
316
+ /// Apply the normalizer ops and Metaspace replacement/prepend to one text
317
+ /// chunk. `first_chunk` is true only for the chunk at the very start of
318
+ /// the input (the Metaspace "first" prepend scheme needs it).
319
+ pub fn normalize<'a>(&self, input: &'a str, first_chunk: bool) -> Cow<'a, str> {
320
+ self.apply_metaspace(self.apply_norm_ops(input), first_chunk)
321
+ }
322
+
323
+ /// The tokenizer.json normalizer sequence only (no Metaspace step).
324
+ pub(crate) fn apply_norm_ops<'a>(&self, input: &'a str) -> Cow<'a, str> {
325
+ let mut s: Cow<'a, str> = Cow::Borrowed(input);
326
+ for op in &self.norm_ops {
327
+ match op {
328
+ NormOp::Prepend(prefix) => {
329
+ if !s.is_empty() {
330
+ let mut out = String::with_capacity(prefix.len() + s.len());
331
+ out.push_str(prefix);
332
+ out.push_str(&s);
333
+ s = Cow::Owned(out);
334
+ }
335
+ }
336
+ NormOp::Replace { pattern, content } => {
337
+ if s.contains(pattern.as_str()) {
338
+ let replaced = s.replace(pattern.as_str(), content);
339
+ s = Cow::Owned(replaced);
340
+ }
341
+ }
342
+ NormOp::CollapseSpaces { content } => {
343
+ if s.contains(" ") {
344
+ s = Cow::Owned(collapse_space_runs(&s, content));
345
+ }
346
+ }
347
+ NormOp::Strip { left, right } => {
348
+ let trimmed = match (left, right) {
349
+ (true, true) => s.trim(),
350
+ (true, false) => s.trim_start(),
351
+ (false, true) => s.trim_end(),
352
+ (false, false) => &s,
353
+ };
354
+ if trimmed.len() != s.len() {
355
+ s = Cow::Owned(trimmed.to_string());
356
+ }
357
+ }
358
+ NormOp::Precompiled(charsmap) => {
359
+ let mut out = String::with_capacity(s.len() + 16);
360
+ charsmap.normalize_into(&s, &mut out);
361
+ s = Cow::Owned(out);
362
+ }
363
+ }
364
+ }
365
+ s
366
+ }
367
+
368
+ /// The Metaspace pre-tokenizer's space replacement and ▁ prepend.
369
+ pub(crate) fn apply_metaspace<'a>(
370
+ &self,
371
+ input: Cow<'a, str>,
372
+ first_chunk: bool,
373
+ ) -> Cow<'a, str> {
374
+ let mut s = input;
375
+ if let Some(ms) = &self.metaspace {
376
+ if s.contains(' ') {
377
+ let replaced: String = s
378
+ .chars()
379
+ .map(|c| if c == ' ' { SENTENCEPIECE_SPACE } else { c })
380
+ .collect();
381
+ s = Cow::Owned(replaced);
382
+ }
383
+ let prepend = match ms.prepend {
384
+ PrependScheme::Always => true,
385
+ PrependScheme::First => first_chunk,
386
+ PrependScheme::Never => false,
387
+ };
388
+ if prepend && !s.is_empty() && !s.starts_with(SENTENCEPIECE_SPACE) {
389
+ let mut out = String::with_capacity(SENTENCEPIECE_SPACE_STR.len() + s.len());
390
+ out.push(SENTENCEPIECE_SPACE);
391
+ out.push_str(&s);
392
+ s = Cow::Owned(out);
393
+ }
394
+ }
395
+ s
396
+ }
397
+
398
+ /// Whether encoding puts a ▁ in front of ordinary text — decode then
399
+ /// strips the resulting leading space, like HF's decoder does.
400
+ fn prepends_space(&self) -> bool {
401
+ self.metaspace
402
+ .as_ref()
403
+ .is_some_and(|ms| ms.prepend != PrependScheme::Never)
404
+ || self
405
+ .norm_ops
406
+ .iter()
407
+ .any(|op| matches!(op, NormOp::Prepend(_)))
408
+ }
409
+
410
+ /// Compute the encode fast-path configuration (`word_split`,
411
+ /// `raw_prepend`, `space_init`) from the assembled model. Must be called
412
+ /// once by the loader after all other fields are final.
413
+ pub(crate) fn finalize_speed_paths(&mut self) {
414
+ self.space_init = match self.vocab_inv.get(SP_MARK.as_slice()) {
415
+ Some(&id) => vec![id],
416
+ None => SP_MARK
417
+ .iter()
418
+ .filter_map(|&b| self.byte_fallback_ids[b as usize])
419
+ .collect(),
420
+ };
421
+
422
+ for b in 0u8..128 {
423
+ self.ascii_init[b as usize] = self
424
+ .vocab_inv
425
+ .get([b].as_slice())
426
+ .copied()
427
+ .or(self.byte_fallback_ids[b as usize]);
428
+ }
429
+
430
+ self.added_matcher = (!self.added_tokens.is_empty()).then(|| {
431
+ aho_corasick::AhoCorasick::builder()
432
+ .match_kind(aho_corasick::MatchKind::LeftmostLongest)
433
+ // The scan visits every input byte; a DFA's one lookup per
434
+ // byte beats the contiguous NFA noticeably when the added
435
+ // vocabulary is large (gemma-3 carries 6.4k added tokens,
436
+ // and the scan was ~23% of its encode profile). Prefix
437
+ // sharing (<unusedNNNN>…) keeps the table small.
438
+ .kind(Some(aho_corasick::AhoCorasickKind::DFA))
439
+ .build(self.added_tokens.iter().map(|t| t.content.as_bytes()))
440
+ .expect("added-token automaton")
441
+ });
442
+
443
+ self.cross_pieces = Vec::new();
444
+ self.cross_prev = [0u64; 4];
445
+ self.word_split = if self.metaspace.as_ref().is_some_and(|ms| ms.split) {
446
+ WordSplit::EveryMark
447
+ } else if let Some(cross) = self.unit_crossing_pieces() {
448
+ for (pre, _) in &cross {
449
+ let b = *pre.last().expect("interior ▁ always has a predecessor");
450
+ self.cross_prev[(b >> 6) as usize] |= 1 << (b & 63);
451
+ }
452
+ self.cross_pieces = cross;
453
+ WordSplit::SpaceRuns
454
+ } else {
455
+ WordSplit::None
456
+ };
457
+
458
+ self.raw_prepend = self.compute_raw_prepend();
459
+
460
+ // Interior byte adjacency across all vocab pieces: splitting a unit
461
+ // between bytes (x, y) is safe exactly when no piece contains x
462
+ // immediately before y (a merge's result is always a vocab piece, so
463
+ // no merge can then span the boundary; initial symbols are single
464
+ // chars and cannot either).
465
+ self.split_safe = vec![[0u64; 4]; 256];
466
+ if self.word_split != WordSplit::None {
467
+ let mut adjacent = vec![[0u64; 4]; 256]; // adjacent[x] bitset over y
468
+ for piece in &self.vocab {
469
+ for pair in piece.windows(2) {
470
+ adjacent[pair[0] as usize][(pair[1] >> 6) as usize] |= 1 << (pair[1] & 63);
471
+ }
472
+ }
473
+ // The most frequent English punctuation, best value per SIMD
474
+ // compare in the scanner.
475
+ for (slot, &b) in [b'.', b',', b'"', b')', b';', b':', b'!', b'?']
476
+ .iter()
477
+ .enumerate()
478
+ {
479
+ let mut safe = [u64::MAX; 4];
480
+ for x in 0..256usize {
481
+ if adjacent[x][(b >> 6) as usize] & (1 << (b & 63)) != 0 {
482
+ safe[x >> 6] &= !(1 << (x & 63));
483
+ }
484
+ }
485
+ // Only worth a compare if splitting is ever allowed. A byte
486
+ // whose split is never safe keeps slot 0 (never matches).
487
+ if safe != [0u64; 4] {
488
+ self.split_bytes[slot] = b;
489
+ self.split_safe[b as usize] = safe;
490
+ }
491
+ }
492
+ }
493
+ }
494
+
495
+ /// The vocab pieces that can cross a `▁▁▁word`-shaped unit boundary — a
496
+ /// unit starts at each ▁ following a non-▁ char, so a piece crosses
497
+ /// exactly when it contains such an interior ▁ — decomposed as
498
+ /// `(pre, post)` around that ▁. `Some(vec![])` means units are
499
+ /// unconditionally safe (per-unit BPE equals global BPE); a non-empty
500
+ /// list means safe with the scanner's occurrence guard; `None` means a
501
+ /// crossing piece is too complex for the guard's byte-compare (a ▁ or a
502
+ /// raw space inside `pre`/`post`, whose raw-mode text form varies) or
503
+ /// there are too many, so whole-chunk merging must stay.
504
+ fn unit_crossing_pieces(&self) -> Option<Vec<(Box<[u8]>, Box<[u8]>)>> {
505
+ // Beyond this the per-boundary guard stops being "a handful of
506
+ // memcmps on a rare prev byte" and whole-chunk merging is safer.
507
+ const MAX_CROSS_PIECES: usize = 32;
508
+ let mut out: Vec<(Box<[u8]>, Box<[u8]>)> = Vec::new();
509
+ for piece in &self.vocab {
510
+ let Ok(s) = std::str::from_utf8(piece) else {
511
+ // Byte-fallback pieces are single bytes; they can't hold a ▁.
512
+ continue;
513
+ };
514
+ let mut prev_is_mark = true; // leading ▁s are fine
515
+ for (pos, c) in s.char_indices() {
516
+ if c == SENTENCEPIECE_SPACE {
517
+ if !prev_is_mark {
518
+ // Interior ▁: the piece spans from `pre`'s unit into
519
+ // the one starting at this mark.
520
+ let (pre, rest) = s.split_at(pos);
521
+ let post = &rest[SP_MARK.len()..];
522
+ if pre.contains(SENTENCEPIECE_SPACE)
523
+ || post.contains(SENTENCEPIECE_SPACE)
524
+ || pre.contains(' ')
525
+ || post.contains(' ')
526
+ || out.len() == MAX_CROSS_PIECES
527
+ {
528
+ return None;
529
+ }
530
+ out.push((pre.as_bytes().into(), post.as_bytes().into()));
531
+ }
532
+ prev_is_mark = true;
533
+ } else {
534
+ prev_is_mark = false;
535
+ }
536
+ }
537
+ }
538
+ Some(out)
539
+ }
540
+
541
+ /// Does some crossing vocab piece occur spanning the candidate unit
542
+ /// boundary at `mark_pos` (mark of `width` bytes)? Only such an
543
+ /// occurrence can let a merge cross the boundary; everywhere else the
544
+ /// split is exact.
545
+ #[inline(always)]
546
+ fn piece_spans_boundary(&self, bytes: &[u8], mark_pos: usize, width: usize) -> bool {
547
+ let prev = bytes[mark_pos - 1];
548
+ if self.cross_prev[(prev >> 6) as usize] & (1 << (prev & 63)) == 0 {
549
+ return false;
550
+ }
551
+ let after = &bytes[mark_pos + width..];
552
+ self.cross_pieces
553
+ .iter()
554
+ .any(|(pre, post)| bytes[..mark_pos].ends_with(pre) && after.starts_with(post))
555
+ }
556
+
557
+ /// Whether an oversized document can be split into fragments that encode
558
+ /// independently with token-identical output (see
559
+ /// [`Self::safe_fragment_ranges`]). Requires the raw fast path: its
560
+ /// per-unit encoding is what makes a provable unit boundary a safe cut.
561
+ /// The materialized-normalizer path applies whole-section ops (Strip
562
+ /// trims section edges, charsmaps span graphemes, Prepend re-fires per
563
+ /// section), so no interior cut has a local safety argument there — and
564
+ /// `WordSplit::None` merges whole chunks, so nothing can be split at all.
565
+ pub(crate) fn supports_fragment_split(&self) -> bool {
566
+ self.raw_prepend.is_some()
567
+ }
568
+
569
+ /// Split raw text into ranges of roughly `target` bytes at cuts the raw
570
+ /// scanner provably treats as unit boundaries, so encoding the first
571
+ /// range with `encode_raw_cb` and each later one with
572
+ /// `encode_raw_fragment_cb` and concatenating the token streams is
573
+ /// identical to encoding `text` in one call. Only valid when
574
+ /// [`Self::supports_fragment_split`] holds.
575
+ ///
576
+ /// A cut at `p` is safe when the serial scan is guaranteed to close a
577
+ /// unit exactly at `p` and every decision it makes is local to one side:
578
+ ///
579
+ /// - `p` starts a mark — a raw space, or a complete ▁ (0xE2 always
580
+ /// leads a 3-byte char in valid UTF-8, so neither test can misread a
581
+ /// continuation byte) — and, under `SpaceRuns`, does not extend a
582
+ /// mark run (the char before it is not a mark) and no crossing vocab
583
+ /// piece occurrence spans it (`piece_spans_boundary`). The scanner's
584
+ /// remaining decisions cannot reach across `p`: a crossing piece's
585
+ /// `pre`/`post` contain no space or ▁ (see `unit_crossing_pieces`),
586
+ /// so no occurrence of one can span the mark at `p`; mark-run state
587
+ /// resets at every non-mark char; and the punctuation split test
588
+ /// reads one preceding byte.
589
+ /// - No added-token occurrence blocks `p` (see
590
+ /// `added_token_cut_blocks`): leftmost-longest matching carries no
591
+ /// state across an occurrence-free point, so restarting it at `p`
592
+ /// reproduces the single-pass matches, and lstrip/rstrip whitespace
593
+ /// trimming stays within one fragment.
594
+ ///
595
+ /// Where no safe cut exists the scan keeps probing forward, so a range
596
+ /// can exceed `target` (in the worst case it is the rest of the
597
+ /// document — output stays exact, only parallelism degrades).
598
+ pub(crate) fn safe_fragment_ranges(
599
+ &self,
600
+ text: &str,
601
+ target: usize,
602
+ ) -> Vec<std::ops::Range<usize>> {
603
+ debug_assert!(self.supports_fragment_split());
604
+ let bytes = text.as_bytes();
605
+ let len = bytes.len();
606
+ if len == 0 {
607
+ // Zero ranges would drop the document's output row.
608
+ return vec![0..0];
609
+ }
610
+ let blocks = self.added_token_cut_blocks(text);
611
+ let mut bi = 0usize; // cursor into `blocks`; probes are monotone
612
+ let every_mark = self.word_split == WordSplit::EveryMark;
613
+ let target = target.max(1);
614
+ let mut out = Vec::new();
615
+ let mut start = 0usize;
616
+ 'chunks: while start < len {
617
+ let mut probe = start + target;
618
+ while probe < len {
619
+ let Some(width) = mark_width(bytes, probe, true) else {
620
+ probe += 1;
621
+ continue;
622
+ };
623
+ let extends_run = !every_mark
624
+ && (bytes[probe - 1] == b' '
625
+ || (probe >= 3 && bytes[probe - 3..probe] == SP_MARK));
626
+ if extends_run || self.piece_spans_boundary(bytes, probe, width) {
627
+ probe += 1;
628
+ continue;
629
+ }
630
+ while bi < blocks.len() && blocks[bi].end <= probe {
631
+ bi += 1;
632
+ }
633
+ if bi < blocks.len() && blocks[bi].start <= probe {
634
+ // Inside a blocked interval: hop past it (the merged
635
+ // intervals are disjoint and sorted).
636
+ probe = blocks[bi].end;
637
+ continue;
638
+ }
639
+ out.push(start..probe);
640
+ start = probe;
641
+ continue 'chunks;
642
+ }
643
+ out.push(start..len);
644
+ break;
645
+ }
646
+ out
647
+ }
648
+
649
+ /// Sorted, disjoint byte intervals in which no fragment cut may land,
650
+ /// derived from added-token occurrences. Every occurrence is collected,
651
+ /// overlapping ones included — a superset of the matches an encode
652
+ /// selects, which is what makes the blocking conservative and exact:
653
+ ///
654
+ /// - inside an occurrence `[s, e)`: the cut halves would BPE-encode as
655
+ /// plain text (and truncation could change leftmost-longest choices);
656
+ /// - at `e` itself, under `Unguarded` prepend only: the fragment's
657
+ /// continuation section would go un-prefixed where the serial encode
658
+ /// ▁-prefixes the post-match section it opens at `e`. (Guarded
659
+ /// schemes skip the prefix on the cut's own mark either way, and both
660
+ /// sides then scan the identical section from `e`.);
661
+ /// - `lstrip`: back through the whitespace run before `s`, whose
662
+ /// trimming would otherwise reach into the previous fragment;
663
+ /// - `rstrip`: forward through the whitespace run after `e`, which the
664
+ /// serial match consumes.
665
+ ///
666
+ /// A cut only ever lands on a mark start (a space or ▁'s 0xE2 lead), so
667
+ /// an occurrence's interior can hold one only when its content carries
668
+ /// such a byte. A token without one, without lstrip/rstrip, on a
669
+ /// non-`Unguarded` model therefore cannot block anything and is skipped
670
+ /// without a scan — gemma-3 carries 6.4k added tokens, and one pass per
671
+ /// token over a multi-hundred-MB document is seconds, not milliseconds.
672
+ ///
673
+ /// The surviving tokens' scans are independent and run on the rayon
674
+ /// pool: gemma keeps 30 ▁-run tokens, and their ~9 GB of memmem over a
675
+ /// 290 MB document was ~35% of the whole parallel encode when run
676
+ /// serially. Only the parallel encode entry points fragment documents,
677
+ /// so touching rayon here is safe (the fork-safe `_serial` paths never
678
+ /// reach this).
679
+ fn added_token_cut_blocks(&self, text: &str) -> Vec<std::ops::Range<usize>> {
680
+ use rayon::prelude::*;
681
+ let bytes = text.as_bytes();
682
+ let unguarded = self.raw_prepend == Some(RawPrepend::Unguarded);
683
+ let mut blocks: Vec<std::ops::Range<usize>> = self
684
+ .added_tokens
685
+ .par_iter()
686
+ .filter(|spec| {
687
+ let content = spec.content.as_bytes();
688
+ let interior = content.contains(&b' ') || content.contains(&0xE2);
689
+ !content.is_empty() && (interior || unguarded || spec.lstrip || spec.rstrip)
690
+ })
691
+ .flat_map_iter(|spec| {
692
+ let content = spec.content.as_bytes();
693
+ let finder = memchr::memmem::Finder::new(content);
694
+ let mut out = Vec::new();
695
+ let mut from = 0usize;
696
+ while let Some(off) = finder.find(&bytes[from..]) {
697
+ let s = from + off;
698
+ let e = s + content.len();
699
+ // A valid-UTF-8 needle only matches at char boundaries
700
+ // (its first byte is a lead byte, and a full match ends
701
+ // a complete char), so slicing `text` at `s`/`e` cannot
702
+ // panic.
703
+ let lo = if spec.lstrip {
704
+ text[..s].trim_end().len()
705
+ } else {
706
+ s + 1
707
+ };
708
+ let hi = if spec.rstrip {
709
+ e + (text[e..].len() - text[e..].trim_start().len())
710
+ } else if unguarded {
711
+ e
712
+ } else {
713
+ e - 1 // only the interior (s, e) blocks; `e` is safe
714
+ };
715
+ if lo <= hi {
716
+ out.push(lo..hi + 1);
717
+ }
718
+ from = s + 1; // step one byte: overlapping occurrences too
719
+ }
720
+ out
721
+ })
722
+ .collect();
723
+ blocks.sort_unstable_by_key(|r| r.start);
724
+ blocks.dedup_by(|next, prev| {
725
+ if next.start <= prev.end {
726
+ prev.end = prev.end.max(next.end);
727
+ true
728
+ } else {
729
+ false
730
+ }
731
+ });
732
+ blocks
733
+ }
734
+
735
+ /// Raw-fast-path eligibility: the normalizer sequence must reduce to
736
+ /// optional ▁ prepend + literal space→▁, with no normalized added tokens
737
+ /// (those are matched against materialized normalizer output).
738
+ fn compute_raw_prepend(&self) -> Option<RawPrepend> {
739
+ if self.word_split == WordSplit::None || !self.norm_added_tokens.is_empty() {
740
+ return None;
741
+ }
742
+ let is_space_replace = |op: &NormOp| {
743
+ matches!(op, NormOp::Replace { pattern, content }
744
+ if pattern == " " && content == SENTENCEPIECE_SPACE_STR)
745
+ };
746
+ let from_metaspace = match self.metaspace.as_ref().map(|ms| ms.prepend) {
747
+ None | Some(PrependScheme::Never) => RawPrepend::Never,
748
+ Some(PrependScheme::Always) => RawPrepend::GuardedAlways,
749
+ Some(PrependScheme::First) => RawPrepend::GuardedFirst,
750
+ };
751
+ match self.norm_ops.as_slice() {
752
+ [NormOp::Prepend(p), op] if p == SENTENCEPIECE_SPACE_STR && is_space_replace(op) => {
753
+ // Under EveryMark the unguarded prepend would fuse a "▁" that
754
+ // HF splits off the first unit; take the materialized path.
755
+ (self.word_split != WordSplit::EveryMark).then_some(RawPrepend::Unguarded)
756
+ }
757
+ [op] if is_space_replace(op) => Some(from_metaspace),
758
+ [] if self.metaspace.is_some() => Some(from_metaspace),
759
+ _ => None,
760
+ }
761
+ }
762
+
763
+ /// Create an encoder for this model.
764
+ pub fn encoder(&self) -> Encoder<'_> {
765
+ Encoder {
766
+ model: self,
767
+ state: EncodeState::new(),
768
+ }
769
+ }
770
+
771
+ /// Size of the vocabulary: one greater than the largest token ID,
772
+ /// including added tokens (IDs with no assigned content count too).
773
+ pub fn vocab_size(&self) -> usize {
774
+ self.vocab.len()
775
+ }
776
+
777
+ /// Vocabulary entries as `(id, bytes)` pairs in ID order, including
778
+ /// added tokens and skipping IDs with no assigned content.
779
+ pub fn vocab_entries(&self) -> impl Iterator<Item = (u32, &[u8])> {
780
+ super::vocab_entries(&self.vocab)
781
+ }
782
+
783
+ /// Merge rules as `(left, right)` byte pairs in rank order.
784
+ pub fn merge_entries(&self) -> Vec<(&[u8], &[u8])> {
785
+ let mut ranked: Vec<_> = self.merges.iter().collect();
786
+ ranked.sort_unstable_by_key(|&(_, &(_, rank))| rank);
787
+ ranked
788
+ .into_iter()
789
+ .map(|(&key, _)| {
790
+ (
791
+ self.vocab[(key >> 32) as usize].as_ref(),
792
+ self.vocab[(key & u32::MAX as u64) as usize].as_ref(),
793
+ )
794
+ })
795
+ .collect()
796
+ }
797
+
798
+ /// Convenience: encode a single text (creates a temporary encoder).
799
+ pub fn encode_raw(&self, input: &str) -> Vec<TokenId> {
800
+ self.encoder().encode_raw(input)
801
+ }
802
+
803
+ /// Decode token IDs back to a UTF-8 string.
804
+ pub fn decode(&self, tokens: &[TokenId]) -> Vec<u8> {
805
+ let mut raw = Vec::new();
806
+ for &t in tokens {
807
+ let idx: usize = t.into();
808
+ if idx < self.vocab.len() {
809
+ raw.extend_from_slice(&self.vocab[idx]);
810
+ }
811
+ }
812
+ let text = String::from_utf8_lossy(&raw);
813
+ let mut out: Vec<u8> = text.replace(SENTENCEPIECE_SPACE, " ").into_bytes();
814
+ if self.prepends_space() && out.first() == Some(&b' ') {
815
+ out.remove(0);
816
+ }
817
+ out
818
+ }
819
+ }
820
+
821
+ /// Replace each run of 2 or more ASCII spaces with `content`, like HF's
822
+ /// `Replace(Regex(" {2,}"), content)`.
823
+ fn collapse_space_runs(input: &str, content: &str) -> String {
824
+ let bytes = input.as_bytes();
825
+ let mut out = String::with_capacity(input.len());
826
+ let mut i = 0;
827
+ while i < bytes.len() {
828
+ if bytes[i] == b' ' {
829
+ let mut j = i + 1;
830
+ while j < bytes.len() && bytes[j] == b' ' {
831
+ j += 1;
832
+ }
833
+ if j - i >= 2 {
834
+ out.push_str(content);
835
+ } else {
836
+ out.push(' ');
837
+ }
838
+ i = j;
839
+ } else {
840
+ let start = i;
841
+ while i < bytes.len() && bytes[i] != b' ' {
842
+ i += 1;
843
+ }
844
+ out.push_str(&input[start..i]);
845
+ }
846
+ }
847
+ out
848
+ }
849
+
850
+ /// Per-thread mutable encoding context: the pretoken cache plus scratch
851
+ /// buffers, mirroring the byte-level BPE path's memoization. Units repeat
852
+ /// heavily in natural text, so the ranked merge only runs on cache misses.
853
+ /// log2 of the direct-mapped front-cache size (entries). 2^20 keeps the
854
+ /// Zipf-hot working set resident despite eviction churn from tail units.
855
+ const FRONT_BITS: u32 = 20;
856
+
857
+ /// Index of `key` in the front cache: multiplicative hash, top bits.
858
+ #[inline(always)]
859
+ fn front_index(key: u128) -> usize {
860
+ let folded = (key as u64) ^ ((key >> 64) as u64);
861
+ let h = folded.wrapping_mul(0x9E37_79B9_7F4A_7C15);
862
+ (h >> (64 - FRONT_BITS)) as usize
863
+ }
864
+
865
+ pub struct EncodeState {
866
+ /// Append-only arena of encoded token IDs; cache entries are
867
+ /// `(offset, len)` slices into it.
868
+ arena: Vec<TokenId>,
869
+ /// Direct-mapped front cache in front of `short`, split
870
+ /// structure-of-arrays so a probe touches only the 16 MB key array
871
+ /// (values load on hit). Zipf-hot units resolve here with one compare
872
+ /// instead of a SwissTable probe into a couple-hundred-MB map. Key 0 =
873
+ /// empty (packed keys always carry a nonzero length tag).
874
+ front_keys: Vec<u128>,
875
+ front_vals: Vec<(u32, u32)>,
876
+ /// Cache for units of ≤ 15 key bytes (the overwhelming majority), keyed
877
+ /// by the same packed `u128` scheme as the byte-level path.
878
+ short: HashMap<u128, (u32, u32), FxBuildHasher>,
879
+ /// Fallback cache for longer units.
880
+ long: HashMap<Box<[u8]>, (u32, u32), FxBuildHasher>,
881
+ /// Scratch for the merge loop.
882
+ symbols: Vec<TokenId>,
883
+ /// Scratch for composing keys with a virtual leading space.
884
+ key_buf: Vec<u8>,
885
+ }
886
+
887
+ impl EncodeState {
888
+ pub fn new() -> Self {
889
+ EncodeState {
890
+ arena: Vec::new(),
891
+ front_keys: vec![0u128; 1 << FRONT_BITS],
892
+ front_vals: vec![(0u32, 0u32); 1 << FRONT_BITS],
893
+ short: HashMap::with_hasher(FxBuildHasher),
894
+ long: HashMap::with_hasher(FxBuildHasher),
895
+ symbols: Vec::new(),
896
+ key_buf: Vec::new(),
897
+ }
898
+ }
899
+
900
+ /// Number of cached units (for diagnostics).
901
+ pub fn cache_size(&self) -> usize {
902
+ self.short.len() + self.long.len()
903
+ }
904
+ }
905
+
906
+ impl Default for EncodeState {
907
+ fn default() -> Self {
908
+ Self::new()
909
+ }
910
+ }
911
+
912
+ /// An encoder that holds a reference to the model plus its own cache and
913
+ /// scratch. Create one per thread for parallel encoding.
914
+ pub struct Encoder<'a> {
915
+ model: &'a SentencePieceBPE,
916
+ state: EncodeState,
917
+ }
918
+
919
+ /// Leftmost match of any spec's content in `text`; ties go to the longest
920
+ /// content, like HF's aho-corasick LeftmostLongest added-token matching.
921
+ fn find_added_token<'s>(
922
+ specs: &'s [AddedTokenSpec],
923
+ text: &str,
924
+ ) -> Option<(usize, &'s AddedTokenSpec)> {
925
+ let mut best: Option<(usize, &AddedTokenSpec)> = None;
926
+ for spec in specs {
927
+ if let Some(pos) = text.find(spec.content.as_str()) {
928
+ let better = match best {
929
+ None => true,
930
+ Some((best_pos, best_spec)) => {
931
+ pos < best_pos
932
+ || (pos == best_pos && spec.content.len() > best_spec.content.len())
933
+ }
934
+ };
935
+ if better {
936
+ best = Some((pos, spec));
937
+ }
938
+ }
939
+ }
940
+ best
941
+ }
942
+
943
+ impl<'a> Encoder<'a> {
944
+ /// Encode raw (un-normalized) text with added-token splitting.
945
+ pub fn encode_raw(&mut self, input: &str) -> Vec<TokenId> {
946
+ let mut result = Vec::new();
947
+ self.model
948
+ .encode_raw_with(&mut self.state, input, &mut result);
949
+ result
950
+ }
951
+
952
+ /// Like [`Self::encode_raw`], emitting token runs through `f`.
953
+ pub fn encode_raw_cb<F: FnMut(&[TokenId])>(&mut self, input: &str, f: &mut F) {
954
+ self.model.encode_raw_cb(&mut self.state, input, f);
955
+ }
956
+
957
+ /// Like [`Self::encode_raw_cb`] for one fragment of a document split at
958
+ /// scanner-safe boundaries (see
959
+ /// [`SentencePieceBPE::safe_fragment_ranges`]). A non-first fragment's
960
+ /// leading section continues one begun in an earlier fragment, so it is
961
+ /// never ▁-prefixed.
962
+ pub fn encode_raw_fragment_cb<F: FnMut(&[TokenId])>(
963
+ &mut self,
964
+ input: &str,
965
+ first: bool,
966
+ f: &mut F,
967
+ ) {
968
+ let pos = if first {
969
+ SectionPos::First
970
+ } else {
971
+ SectionPos::Continuation
972
+ };
973
+ self.model.encode_raw_from(&mut self.state, input, pos, f);
974
+ }
975
+ }
976
+
977
+ /// Is the byte at `pos` the start of a unit mark? In raw mode both a space
978
+ /// and a literal ▁ count; in normalized mode only ▁ does. Returns the mark's
979
+ /// byte width.
980
+ #[inline(always)]
981
+ fn mark_width(bytes: &[u8], pos: usize, raw: bool) -> Option<usize> {
982
+ match bytes[pos] {
983
+ b' ' if raw => Some(1),
984
+ 0xE2 if bytes.len() - pos >= 3 && bytes[pos + 1] == 0x96 && bytes[pos + 2] == 0x81 => {
985
+ Some(3)
986
+ }
987
+ _ => None,
988
+ }
989
+ }
990
+
991
+ impl SentencePieceBPE {
992
+ /// Encode raw (un-normalized) text into a token buffer. See
993
+ /// [`Self::encode_raw_cb`].
994
+ pub fn encode_raw_with(&self, state: &mut EncodeState, input: &str, out: &mut Vec<TokenId>) {
995
+ self.encode_raw_cb(state, input, &mut |tokens: &[TokenId]| {
996
+ out.extend_from_slice(tokens)
997
+ });
998
+ }
999
+
1000
+ /// Encode raw (un-normalized) text with added-token splitting: first the
1001
+ /// raw-matched (`normalized: false`) tokens, then — per remaining section —
1002
+ /// the normalizer ops, the normalized-matched tokens, and Metaspace + BPE.
1003
+ /// Emits token runs through `f`, like the byte-level path's
1004
+ /// `memoized_encode`.
1005
+ pub fn encode_raw_cb<F: FnMut(&[TokenId])>(
1006
+ &self,
1007
+ state: &mut EncodeState,
1008
+ input: &str,
1009
+ f: &mut F,
1010
+ ) {
1011
+ self.encode_raw_from(state, input, SectionPos::First, f);
1012
+ }
1013
+
1014
+ /// [`Self::encode_raw_cb`] with the position of the input's leading
1015
+ /// section given explicitly: `First` for a whole document,
1016
+ /// `Continuation` for a non-first fragment of one (see
1017
+ /// [`Self::safe_fragment_ranges`]). Sections after an added-token match
1018
+ /// are always `Middle`.
1019
+ fn encode_raw_from<F: FnMut(&[TokenId])>(
1020
+ &self,
1021
+ state: &mut EncodeState,
1022
+ input: &str,
1023
+ start: SectionPos,
1024
+ f: &mut F,
1025
+ ) {
1026
+ let Some(matcher) = &self.added_matcher else {
1027
+ self.encode_section_cb(state, input, start, f);
1028
+ return;
1029
+ };
1030
+
1031
+ // Iterate leftmost-longest added-token matches; the text between
1032
+ // consecutive matches forms the chunks. lstrip/rstrip consume the
1033
+ // whitespace adjacent to a match.
1034
+ let mut chunk_start = 0usize;
1035
+ let mut pos = start;
1036
+ for m in matcher.find_iter(input.as_bytes()) {
1037
+ let spec = &self.added_tokens[m.pattern().as_usize()];
1038
+ if m.start() < chunk_start {
1039
+ // Swallowed by the previous match's rstrip.
1040
+ continue;
1041
+ }
1042
+ let mut chunk = &input[chunk_start..m.start()];
1043
+ if spec.lstrip {
1044
+ chunk = chunk.trim_end();
1045
+ }
1046
+ if !chunk.is_empty() {
1047
+ self.encode_section_cb(state, chunk, pos, f);
1048
+ }
1049
+ f(&[spec.id]);
1050
+ chunk_start = m.end();
1051
+ if spec.rstrip {
1052
+ chunk_start += input[chunk_start..].len() - input[chunk_start..].trim_start().len();
1053
+ }
1054
+ pos = SectionPos::Middle;
1055
+ }
1056
+ let chunk = &input[chunk_start..];
1057
+ if !chunk.is_empty() {
1058
+ self.encode_section_cb(state, chunk, pos, f);
1059
+ }
1060
+ }
1061
+
1062
+ /// Encode one raw section: the raw fast path when eligible, otherwise
1063
+ /// normalizer ops → normalized added-token splitting → Metaspace → BPE.
1064
+ /// HF does not re-normalize the parts around a normalized added-token
1065
+ /// match.
1066
+ fn encode_section_cb<F: FnMut(&[TokenId])>(
1067
+ &self,
1068
+ state: &mut EncodeState,
1069
+ text: &str,
1070
+ pos: SectionPos,
1071
+ f: &mut F,
1072
+ ) {
1073
+ if let Some(prepend) = self.raw_prepend {
1074
+ self.encode_chunk_raw(state, text, pos, prepend, f);
1075
+ return;
1076
+ }
1077
+
1078
+ // Fragment splitting is gated on the raw fast path
1079
+ // (`supports_fragment_split`), so a continuation never reaches the
1080
+ // materialized-normalizer path below — its whole-section ops
1081
+ // (Strip, per-section Prepend, ...) have no continuation form.
1082
+ debug_assert!(pos != SectionPos::Continuation);
1083
+ let first_chunk = pos == SectionPos::First;
1084
+ let normed = self.apply_norm_ops(text);
1085
+
1086
+ if self.norm_added_tokens.is_empty() {
1087
+ let final_text = self.apply_metaspace(normed, first_chunk);
1088
+ self.encode_normalized_cb(state, &final_text, f);
1089
+ return;
1090
+ }
1091
+
1092
+ let mut remaining: &str = &normed;
1093
+ let mut first = first_chunk;
1094
+ while !remaining.is_empty() {
1095
+ match find_added_token(&self.norm_added_tokens, remaining) {
1096
+ Some((pos, spec)) => {
1097
+ let part = if spec.lstrip {
1098
+ remaining[..pos].trim_end()
1099
+ } else {
1100
+ &remaining[..pos]
1101
+ };
1102
+ if !part.is_empty() {
1103
+ let final_text = self.apply_metaspace(Cow::Borrowed(part), first);
1104
+ self.encode_normalized_cb(state, &final_text, f);
1105
+ }
1106
+ f(&[spec.id]);
1107
+ let mut rest = &remaining[pos + spec.content.len()..];
1108
+ if spec.rstrip {
1109
+ rest = rest.trim_start();
1110
+ }
1111
+ remaining = rest;
1112
+ first = false;
1113
+ }
1114
+ None => {
1115
+ let final_text = self.apply_metaspace(Cow::Borrowed(remaining), first);
1116
+ self.encode_normalized_cb(state, &final_text, f);
1117
+ break;
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+
1123
+ /// Raw fast path: split un-normalized text into units directly, mapping
1124
+ /// spaces to ▁ on the fly. The dummy-prefix ▁ becomes a virtual leading
1125
+ /// space on the first unit, which keys and encodes identically.
1126
+ fn encode_chunk_raw<F: FnMut(&[TokenId])>(
1127
+ &self,
1128
+ state: &mut EncodeState,
1129
+ chunk: &str,
1130
+ pos: SectionPos,
1131
+ prepend: RawPrepend,
1132
+ f: &mut F,
1133
+ ) {
1134
+ if chunk.is_empty() {
1135
+ return;
1136
+ }
1137
+ let bytes = chunk.as_bytes();
1138
+ let starts_with_mark = mark_width(bytes, 0, true).is_some();
1139
+ let virtual_prefix = match prepend {
1140
+ // A continuation resumes a section mid-way: its prefix, if any,
1141
+ // was emitted with the section's first unit in an earlier
1142
+ // fragment.
1143
+ _ if pos == SectionPos::Continuation => false,
1144
+ RawPrepend::Unguarded => true,
1145
+ RawPrepend::GuardedAlways => !starts_with_mark,
1146
+ RawPrepend::GuardedFirst => pos == SectionPos::First && !starts_with_mark,
1147
+ RawPrepend::Never => false,
1148
+ };
1149
+ self.encode_units(state, bytes, virtual_prefix, true, f);
1150
+ }
1151
+
1152
+ /// Encode already-normalized text: unit split (per `word_split`) with the
1153
+ /// pretoken cache, or a whole-chunk merge when units aren't safe.
1154
+ pub fn encode_normalized_cb<F: FnMut(&[TokenId])>(
1155
+ &self,
1156
+ state: &mut EncodeState,
1157
+ input: &str,
1158
+ f: &mut F,
1159
+ ) {
1160
+ match self.word_split {
1161
+ WordSplit::None => self.bpe_chunk(state, input, f),
1162
+ _ => self.encode_units(state, input.as_bytes(), false, false, f),
1163
+ }
1164
+ }
1165
+
1166
+ /// Split a chunk into word units and encode each through the cache.
1167
+ ///
1168
+ /// `raw` selects raw-mode marks (space or ▁) vs normalized-mode marks
1169
+ /// (▁ only); `virtual_prefix` logically prepends one space to the first
1170
+ /// unit (the raw fast path's dummy prefix).
1171
+ fn encode_units<F: FnMut(&[TokenId])>(
1172
+ &self,
1173
+ state: &mut EncodeState,
1174
+ bytes: &[u8],
1175
+ virtual_prefix: bool,
1176
+ raw: bool,
1177
+ f: &mut F,
1178
+ ) {
1179
+ if raw {
1180
+ self.encode_units_impl::<true, F>(state, bytes, virtual_prefix, f);
1181
+ } else {
1182
+ self.encode_units_impl::<false, F>(state, bytes, virtual_prefix, f);
1183
+ }
1184
+ }
1185
+
1186
+ /// Walk 32-byte SIMD blocks whose mark-candidate bitmask is drained bit
1187
+ /// by bit, closing a unit at each qualifying mark. Candidates are dense
1188
+ /// in natural text (~1 per 5 bytes), so the block mask is kept in a
1189
+ /// register and the per-candidate hot path is a couple of bit ops.
1190
+ fn encode_units_impl<const RAW: bool, F: FnMut(&[TokenId])>(
1191
+ &self,
1192
+ state: &mut EncodeState,
1193
+ bytes: &[u8],
1194
+ virtual_prefix: bool,
1195
+ f: &mut F,
1196
+ ) {
1197
+ use std::simd::prelude::*;
1198
+
1199
+ let every_mark = self.word_split == WordSplit::EveryMark;
1200
+ let mut unit_start = 0usize;
1201
+ let mut last_mark_end = usize::MAX;
1202
+ let mut first_unit = true;
1203
+
1204
+ let splats = self.split_bytes.map(u8x16::splat);
1205
+
1206
+ let mut block = 0usize;
1207
+ while block < bytes.len() {
1208
+ let mut mask: u32;
1209
+ if block + 32 <= bytes.len() {
1210
+ let lo = u8x16::from_slice(&bytes[block..]);
1211
+ let hi = u8x16::from_slice(&bytes[block + 16..]);
1212
+ let mut m_lo = lo.simd_eq(u8x16::splat(0xE2));
1213
+ let mut m_hi = hi.simd_eq(u8x16::splat(0xE2));
1214
+ if RAW {
1215
+ m_lo |= lo.simd_eq(u8x16::splat(b' '));
1216
+ m_hi |= hi.simd_eq(u8x16::splat(b' '));
1217
+ }
1218
+ // Split-punct candidates (unused slots are 0x00 splats; NUL
1219
+ // bytes then take the punct path and split_safe[0] is empty).
1220
+ for splat in splats {
1221
+ m_lo |= lo.simd_eq(splat);
1222
+ m_hi |= hi.simd_eq(splat);
1223
+ }
1224
+ mask = (m_lo.to_bitmask() as u32) | ((m_hi.to_bitmask() as u32) << 16);
1225
+ } else {
1226
+ mask = 0;
1227
+ for (i, &b) in bytes[block..].iter().enumerate() {
1228
+ if b == 0xE2 || (RAW && b == b' ') || self.split_bytes.contains(&b) {
1229
+ mask |= 1 << i;
1230
+ }
1231
+ }
1232
+ }
1233
+
1234
+ while mask != 0 {
1235
+ let mark_pos = block + mask.trailing_zeros() as usize;
1236
+ mask &= mask - 1;
1237
+ let byte = bytes[mark_pos];
1238
+ if (RAW && byte == b' ') || byte == 0xE2 {
1239
+ // A candidate is only a mark if it's a space (raw mode)
1240
+ // or a full ▁ (0xE2 also starts other three-byte chars;
1241
+ // its continuation bytes are never candidates).
1242
+ let Some(width) = mark_width(bytes, mark_pos, RAW) else {
1243
+ continue;
1244
+ };
1245
+ // SpaceRuns: only a mark that doesn't extend a run
1246
+ // starts a unit — and not where a crossing vocab piece
1247
+ // spans the boundary (`cross_prev` is all-zero when
1248
+ // there are none, so the guard is one load + test).
1249
+ let boundary = every_mark || mark_pos != last_mark_end;
1250
+ if boundary
1251
+ && mark_pos != unit_start
1252
+ && !self.piece_spans_boundary(bytes, mark_pos, width)
1253
+ {
1254
+ self.encode_unit(
1255
+ state,
1256
+ &bytes[unit_start..mark_pos],
1257
+ virtual_prefix && first_unit,
1258
+ RAW,
1259
+ f,
1260
+ );
1261
+ first_unit = false;
1262
+ unit_start = mark_pos;
1263
+ }
1264
+ last_mark_end = mark_pos + width;
1265
+ } else if mark_pos != unit_start {
1266
+ // Split punctuation: a unit boundary only after a
1267
+ // vocab-verified safe predecessor. A raw space acts as ▁,
1268
+ // so check its final UTF-8 byte.
1269
+ let mut prev = bytes[mark_pos - 1];
1270
+ if RAW && prev == b' ' {
1271
+ prev = SP_MARK[2];
1272
+ }
1273
+ let safe = &self.split_safe[byte as usize];
1274
+ if safe[(prev >> 6) as usize] & (1 << (prev & 63)) != 0 {
1275
+ self.encode_unit(
1276
+ state,
1277
+ &bytes[unit_start..mark_pos],
1278
+ virtual_prefix && first_unit,
1279
+ RAW,
1280
+ f,
1281
+ );
1282
+ first_unit = false;
1283
+ unit_start = mark_pos;
1284
+ }
1285
+ }
1286
+ }
1287
+ block += 32;
1288
+ }
1289
+ // `unit_start` only ever advances to a mark position < len, so a
1290
+ // non-empty chunk always has a final unit.
1291
+ if unit_start < bytes.len() {
1292
+ self.encode_unit(
1293
+ state,
1294
+ &bytes[unit_start..],
1295
+ virtual_prefix && first_unit,
1296
+ RAW,
1297
+ f,
1298
+ );
1299
+ }
1300
+ }
1301
+
1302
+ /// Encode one unit through the pretoken cache; run the ranked merge only
1303
+ /// on a miss. The cache key is the unit's bytes (raw or normalized —
1304
+ /// byte-equal keys always encode identically), with a `b' '` prefix for
1305
+ /// the virtual leading space.
1306
+ #[inline]
1307
+ fn encode_unit<F: FnMut(&[TokenId])>(
1308
+ &self,
1309
+ state: &mut EncodeState,
1310
+ unit: &[u8],
1311
+ virtual_prefix: bool,
1312
+ raw: bool,
1313
+ f: &mut F,
1314
+ ) {
1315
+ let packed = if virtual_prefix {
1316
+ state.key_buf.clear();
1317
+ state.key_buf.push(b' ');
1318
+ state.key_buf.extend_from_slice(unit);
1319
+ pack_pretoken_key(&state.key_buf)
1320
+ } else {
1321
+ pack_pretoken_key(unit)
1322
+ };
1323
+ // Front cache first: one L1/L2 compare resolves Zipf-hot units.
1324
+ let mut front_idx = 0;
1325
+ if let Some(key) = packed {
1326
+ front_idx = front_index(key);
1327
+ // SAFETY: the front arrays have 2^FRONT_BITS entries and
1328
+ // `front_index` returns FRONT_BITS bits.
1329
+ if unsafe { *state.front_keys.get_unchecked(front_idx) } == key {
1330
+ let (offset, len) = unsafe { *state.front_vals.get_unchecked(front_idx) };
1331
+ let start = offset as usize;
1332
+ // SAFETY: entries are recorded right after appending `len`
1333
+ // tokens at `offset`; `arena` never shrinks.
1334
+ f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
1335
+ return;
1336
+ }
1337
+ }
1338
+ let cached = match packed {
1339
+ Some(key) => state.short.get(&key).copied(),
1340
+ None if virtual_prefix => state.long.get(state.key_buf.as_slice()).copied(),
1341
+ None => state.long.get(unit).copied(),
1342
+ };
1343
+ if let Some((offset, len)) = cached {
1344
+ if let Some(key) = packed {
1345
+ state.front_keys[front_idx] = key;
1346
+ state.front_vals[front_idx] = (offset, len);
1347
+ }
1348
+ let start = offset as usize;
1349
+ // SAFETY: every cached (offset, len) was recorded right after
1350
+ // appending those `len` tokens at `offset`, and `arena` never
1351
+ // shrinks, so the range is always in bounds.
1352
+ f(unsafe { state.arena.get_unchecked(start..start + len as usize) });
1353
+ return;
1354
+ }
1355
+
1356
+ // Miss: character init → ranked merge, then record in the arena.
1357
+ state.symbols.clear();
1358
+ if virtual_prefix {
1359
+ state.symbols.extend_from_slice(&self.space_init);
1360
+ }
1361
+ // SAFETY: units start at mark starts and end at mark starts or the
1362
+ // chunk end, all of which are char boundaries of the original &str.
1363
+ let unit_str = unsafe { std::str::from_utf8_unchecked(unit) };
1364
+ self.init_symbols(unit_str, raw, &mut state.symbols);
1365
+ bpe_merge_symbols_ranked(&self.merges, &mut state.symbols);
1366
+
1367
+ let offset = state.arena.len() as u32;
1368
+ let len = state.symbols.len() as u32;
1369
+ state.arena.extend_from_slice(&state.symbols);
1370
+ match packed {
1371
+ Some(key) => {
1372
+ state.short.insert(key, (offset, len));
1373
+ state.front_keys[front_idx] = key;
1374
+ state.front_vals[front_idx] = (offset, len);
1375
+ }
1376
+ None => {
1377
+ let key: Box<[u8]> = if virtual_prefix {
1378
+ state.key_buf.as_slice().into()
1379
+ } else {
1380
+ unit.into()
1381
+ };
1382
+ state.long.insert(key, (offset, len));
1383
+ }
1384
+ }
1385
+ f(&state.symbols);
1386
+ }
1387
+
1388
+ /// Whole-chunk merge without caching (vocabs with boundary-crossing
1389
+ /// pieces).
1390
+ fn bpe_chunk<F: FnMut(&[TokenId])>(&self, state: &mut EncodeState, chunk: &str, f: &mut F) {
1391
+ state.symbols.clear();
1392
+ self.init_symbols(chunk, false, &mut state.symbols);
1393
+ bpe_merge_symbols_ranked(&self.merges, &mut state.symbols);
1394
+ f(&state.symbols);
1395
+ }
1396
+
1397
+ /// Character init: each char's vocab piece, or its UTF-8 bytes through
1398
+ /// byte fallback. In raw mode spaces initialize as the ▁ marker.
1399
+ #[inline]
1400
+ fn init_symbols(&self, text: &str, raw: bool, symbols: &mut Vec<TokenId>) {
1401
+ for ch in text.chars() {
1402
+ if raw && ch == ' ' {
1403
+ symbols.extend_from_slice(&self.space_init);
1404
+ continue;
1405
+ }
1406
+ if (ch as u32) < 128 {
1407
+ if let Some(id) = self.ascii_init[ch as usize] {
1408
+ symbols.push(id);
1409
+ }
1410
+ continue;
1411
+ }
1412
+ let mut buf = [0u8; 4];
1413
+ let ch_bytes = ch.encode_utf8(&mut buf).as_bytes();
1414
+ if let Some(&id) = self.vocab_inv.get(ch_bytes) {
1415
+ symbols.push(id);
1416
+ } else {
1417
+ for &b in ch_bytes {
1418
+ if let Some(id) = self.byte_fallback_ids[b as usize] {
1419
+ symbols.push(id);
1420
+ }
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ #[cfg(test)]
1428
+ mod tests {
1429
+ use super::*;
1430
+
1431
+ #[test]
1432
+ fn test_precompiled_fast_scan_matches_grapheme_walk() {
1433
+ // The SIMD clean-run scan must reproduce normalize_string exactly,
1434
+ // including CRLF pairs, control chars, combining marks that extend an
1435
+ // ASCII cluster, and non-ASCII spans with ASCII margins.
1436
+ let path = concat!(
1437
+ env!("CARGO_MANIFEST_DIR"),
1438
+ "/data/fineweb_4096_bpe_tokenizer.json"
1439
+ );
1440
+ if !std::path::Path::new(path).exists() {
1441
+ eprintln!("Skipping: {path} not found");
1442
+ return;
1443
+ }
1444
+ let tok = crate::load_tokenizer::hf::load_hf_sentencepiece(path).unwrap();
1445
+ let charsmap = tok
1446
+ .norm_ops
1447
+ .iter()
1448
+ .find_map(|op| match op {
1449
+ NormOp::Precompiled(c) => Some(c),
1450
+ _ => None,
1451
+ })
1452
+ .expect("fineweb model has a precompiled charsmap");
1453
+ let cases = [
1454
+ "plain ascii text",
1455
+ "tabs\tand\r\nnewlines\rmixed\x07controls\x00",
1456
+ "combining: a\u{301} e\u{308} at end x\u{300}",
1457
+ "filigature ½ ㎒ Ⅷ fullwidth",
1458
+ "mixed日本語and ascii, ünïcode wörds",
1459
+ "\u{200b}zero width start",
1460
+ "trailing non-ascii é",
1461
+ "é",
1462
+ "",
1463
+ "\r",
1464
+ "\r\n",
1465
+ "a\r\nb",
1466
+ ];
1467
+ for case in cases {
1468
+ let mut fast = String::new();
1469
+ charsmap.normalize_into(case, &mut fast);
1470
+ assert_eq!(
1471
+ fast,
1472
+ charsmap.pre.normalize_string(case),
1473
+ "fast path diverged for {case:?}"
1474
+ );
1475
+ }
1476
+ }
1477
+
1478
+ #[test]
1479
+ fn test_collapse_space_runs() {
1480
+ assert_eq!(collapse_space_runs("a b c d ", "▁"), "a▁b▁c d▁");
1481
+ assert_eq!(collapse_space_runs("a \t b", "▁"), "a \t▁b");
1482
+ assert_eq!(collapse_space_runs(" ", "▁"), "▁");
1483
+ assert_eq!(collapse_space_runs("no runs", "▁"), "no runs");
1484
+ }
1485
+ }