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,891 @@
1
+ //! Shared mask-scanner boundary algebra for the cl100k regex family:
2
+ //! cl100k, olmo3, qwen2, and qwen3.5. Their patterns share the shape
3
+ //!
4
+ //! `'(?i:contractions)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3} or \p{N}|
5
+ //! ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+`
6
+ //!
7
+ //! and differ only in the digit-group size (`digits3`), end-of-input
8
+ //! whitespace behavior (always in the scalar tail, never in a batch), and
9
+ //! which unicode classes join runs (`\p{M}`: punct-class for
10
+ //! cl100k/olmo3/qwen2, letter-class for qwen3.5 — expressed by the
11
+ //! codepoint classifier each scheme passes in).
12
+ //!
13
+ //! Boundary rules, derived in `pretokenizer_optimization_log.md` step 16:
14
+ //! - A letter starts a token unless it continues a letter run, follows
15
+ //! space/tab-class whitespace (which always sits at a boundary before a
16
+ //! non-ws char and absorbs one following letter run via the
17
+ //! `[^\r\n\p{L}\p{N}]?` prefix), or follows a punct char that is itself
18
+ //! at a boundary — i.e. whose own predecessor is neither punct nor a
19
+ //! space (a two-chars-back test, made char-aware for multi-byte chars).
20
+ //! - Digits split every 1 or 3 chars from each run start and never absorb
21
+ //! a preceding space.
22
+ //! - A punct char starts a token unless it continues a punct run or
23
+ //! follows a space (` ?[^\s\p{L}\p{N}]+`).
24
+ //! - Newlines directly after a punct run are absorbed (`[\r\n]*`).
25
+ //! - A whitespace run containing newlines emits one token through its LAST
26
+ //! newline (`\s*[\r\n]+` / `\s*[\r\n]`), then the r50k-style tail rules;
27
+ //! NL-free runs split before their last char when followed by non-ws
28
+ //! (`\s+(?!\S)`). A run touching the batch end resolves in-batch when
29
+ //! the char at byte 64 is non-ws (the run demonstrably ends at the
30
+ //! edge — ~16% of OWT batches end in a single space, and deferring
31
+ //! them was the family's dominant cost, log step 19); a run actually
32
+ //! crossing the edge is deferred to the scalar path (its "last
33
+ //! newline" may lie in a later batch).
34
+
35
+ use super::is_ascii_ws;
36
+ use super::mask::{self, AsciiMasks};
37
+ use crate::pretokenize::unicode::CharClass;
38
+
39
+ /// Smear `seed` upward (toward higher bits) through contiguous set bits of
40
+ /// `within`, in log steps.
41
+ #[inline(always)]
42
+ fn smear_up(seed: u64, within: u64) -> u64 {
43
+ let mut a = seed;
44
+ let mut m = within;
45
+ let mut sh = 1u32;
46
+ while sh < 64 {
47
+ a |= (a << sh) & m;
48
+ m &= m << sh;
49
+ sh <<= 1;
50
+ }
51
+ a
52
+ }
53
+
54
+ /// Boundary carries from the two chars before the batch: P1 ends at
55
+ /// `scan - 1`, P2 is the one before it (the two-chars-back absorb test).
56
+ #[derive(Clone, Copy, Default)]
57
+ struct Carries {
58
+ /// P1 is a letter / space (0x20) / non-newline non-space ws / punct /
59
+ /// any ws / digit.
60
+ pl: u64,
61
+ ps: u64,
62
+ pwt: u64,
63
+ po: u64,
64
+ pws: u64,
65
+ pd: u64,
66
+ /// P2 is punct-or-space, for a char lead at bit 0 (P1 entirely before
67
+ /// the batch).
68
+ c2_os: u64,
69
+ /// Same test positioned at the first lead AFTER a P1 that straddles
70
+ /// into the batch (P1's own prev is then P2).
71
+ b2b_in: u64,
72
+ }
73
+
74
+ /// Pure-ASCII carries (hot, branchless). Requires `scan > 0` and
75
+ /// `bytes[scan-1] < 0x80` (and `bytes[scan-2] < 0x80` when present).
76
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
77
+ #[inline(always)]
78
+ fn ascii_carries(bytes: &[u8], scan: usize) -> Carries {
79
+ let b = bytes[scan - 1];
80
+ let bit = |c: bool| u64::from(c);
81
+ let (l, d, w) = (super::is_letter(b), super::is_digit(b), is_ascii_ws(b));
82
+ let n = b == b'\r' || b == b'\n';
83
+ let c2_os = if scan >= 2 {
84
+ let b2 = bytes[scan - 2];
85
+ bit(b2 == b' '
86
+ || (!super::is_letter(b2) && !super::is_digit(b2) && !is_ascii_ws(b2)))
87
+ } else {
88
+ 0
89
+ };
90
+ Carries {
91
+ pl: bit(l),
92
+ ps: bit(b == b' '),
93
+ pwt: bit(w && !n && b != b' '),
94
+ po: bit(!l && !d && !w),
95
+ pws: bit(w),
96
+ pd: bit(d),
97
+ c2_os,
98
+ b2b_in: 0,
99
+ }
100
+ }
101
+
102
+ /// `(usable, bad)` for `bytes[scan..scan+64]` under the cl100k-family
103
+ /// rules. `digits3`: `\p{N}{1,3}` (cl100k/olmo3) vs `\p{N}` (qwen2/3.5).
104
+ /// `class`: the scheme's codepoint classifier — `unicode::class_of` for
105
+ /// cl100k/olmo3/qwen2 (marks are punct-class), or
106
+ /// `unicode::class_of_marks_join` for qwen3.5 (`\p{M}` joins letter
107
+ /// runs).
108
+ ///
109
+ /// Structured like the r50k scanner (log step 17): NEON classifies the
110
+ /// ASCII classes with 5 movemasks (letter, digit, space, whitespace,
111
+ /// newline; `wt` is derived in bit algebra, apostrophe and non-ASCII sit
112
+ /// behind horizontal any-tests) and the pure-ASCII boundary algebra stays
113
+ /// inline; batches with any non-ASCII byte in or just before them take
114
+ /// [`family_extended_masks`], `#[inline(never)]` so the hot path's
115
+ /// register allocation stays clean.
116
+ #[cfg(target_arch = "aarch64")]
117
+ #[inline]
118
+ pub(crate) fn batch_masks(
119
+ bytes: &[u8],
120
+ scan: usize,
121
+ digits3: bool,
122
+ class: impl Fn(u32) -> CharClass + Copy,
123
+ ) -> (u64, u64) {
124
+ use std::arch::aarch64::*;
125
+ let len = bytes.len();
126
+ if scan + 70 > len {
127
+ // Not enough lookahead for the batch-edge char classification
128
+ // (up to a 4-byte char starting at scan + 66); scalar batch.
129
+ return (0, u64::MAX);
130
+ }
131
+ unsafe {
132
+ let p = bytes.as_ptr().add(scan);
133
+ let zero = vdupq_n_u8(0);
134
+ let mut lv = [zero; 4];
135
+ let mut dv = [zero; 4];
136
+ let mut sv = [zero; 4];
137
+ let mut wsv = [zero; 4];
138
+ let mut nv = [zero; 4];
139
+ let mut hiv = [zero; 4];
140
+ let mut apv = [zero; 4];
141
+ for i in 0..4 {
142
+ let v = vld1q_u8(p.add(16 * i));
143
+ let lowered = vorrq_u8(v, vdupq_n_u8(0x20));
144
+ lv[i] = vcleq_u8(vsubq_u8(lowered, vdupq_n_u8(b'a')), vdupq_n_u8(25));
145
+ dv[i] = vcleq_u8(vsubq_u8(v, vdupq_n_u8(b'0')), vdupq_n_u8(9));
146
+ sv[i] = vceqq_u8(v, vdupq_n_u8(b' '));
147
+ wsv[i] = vorrq_u8(
148
+ sv[i],
149
+ vcleq_u8(vsubq_u8(v, vdupq_n_u8(9)), vdupq_n_u8(4)),
150
+ );
151
+ nv[i] = vorrq_u8(
152
+ vceqq_u8(v, vdupq_n_u8(b'\r')),
153
+ vceqq_u8(v, vdupq_n_u8(b'\n')),
154
+ );
155
+ hiv[i] = vcltzq_s8(vreinterpretq_s8_u8(v));
156
+ apv[i] = vceqq_u8(v, vdupq_n_u8(b'\''));
157
+ }
158
+ let l64 = mask::movemask64(lv[0], lv[1], lv[2], lv[3]);
159
+ let d64 = mask::movemask64(dv[0], dv[1], dv[2], dv[3]);
160
+ let s64 = mask::movemask64(sv[0], sv[1], sv[2], sv[3]);
161
+ let wsa = mask::movemask64(wsv[0], wsv[1], wsv[2], wsv[3]);
162
+ let n64 = mask::movemask64(nv[0], nv[1], nv[2], nv[3]);
163
+
164
+ // Apostrophes only matter for the contraction fixup.
165
+ let ap_any = vorrq_u8(vorrq_u8(apv[0], apv[1]), vorrq_u8(apv[2], apv[3]));
166
+ let ap64 = if vmaxvq_u8(ap_any) != 0 {
167
+ mask::movemask64(apv[0], apv[1], apv[2], apv[3])
168
+ } else {
169
+ 0
170
+ };
171
+
172
+ let am = mask::AsciiMasks {
173
+ l: l64,
174
+ d: d64,
175
+ s: s64,
176
+ wt: wsa & !s64 & !n64,
177
+ n: n64,
178
+ hi: 0,
179
+ ap: ap64,
180
+ };
181
+
182
+ // Any non-ASCII byte in the batch — or within the two carry bytes
183
+ // before it — routes to the extended classifier.
184
+ let hi_any = vorrq_u8(vorrq_u8(hiv[0], hiv[1]), vorrq_u8(hiv[2], hiv[3]));
185
+ if vmaxvq_u8(hi_any) != 0
186
+ || (scan >= 1 && bytes[scan - 1] >= 0x80)
187
+ || (scan >= 2 && bytes[scan - 2] >= 0x80)
188
+ {
189
+ let mut am = am;
190
+ am.hi = mask::movemask64(hiv[0], hiv[1], hiv[2], hiv[3]);
191
+ return family_extended_masks(bytes, scan, digits3, class, am);
192
+ }
193
+
194
+ let cr = if scan == 0 { Carries::default() } else { ascii_carries(bytes, scan) };
195
+ family_algebra(bytes, scan, digits3, am, cr, mask::UniClasses::default())
196
+ }
197
+ }
198
+
199
+ /// x86-64 front-end for the family schemes: same contract as the NEON
200
+ /// `batch_masks` above, monomorphized on the SIMD tier (see
201
+ /// `MaskScheme::batch_masks_x86`, whose provided `batch_masks` supplies
202
+ /// the runtime-dispatched form). The classification is
203
+ /// [`mask::ascii_masks_avx512`] (one 64-byte load and one k-register
204
+ /// compare per class) or [`mask::ascii_masks_avx2`] (two 32-byte loads,
205
+ /// compare + vpmovmskb per class); the boundary algebra and the extended
206
+ /// (non-ASCII) path are the shared scalar code. `#[inline(always)]` (with
207
+ /// no `target_feature` of its own) so the body fuses into whichever
208
+ /// feature region calls it — LLVM's cost model declined to inline the
209
+ /// previous `#[target_feature]` form into the tier-monomorphized fill
210
+ /// wrappers and left a call per 64-byte batch.
211
+ ///
212
+ /// # Safety
213
+ ///
214
+ /// The selected tier must have been runtime-detected
215
+ /// ([`mask::avx512_scanner_available`] /
216
+ /// [`mask::avx2_scanner_available`]).
217
+ #[cfg(target_arch = "x86_64")]
218
+ #[inline(always)]
219
+ pub(crate) unsafe fn batch_masks_x86<const AVX512: bool>(
220
+ bytes: &[u8],
221
+ scan: usize,
222
+ digits3: bool,
223
+ class: impl Fn(u32) -> CharClass + Copy,
224
+ ) -> (u64, u64) {
225
+ let len = bytes.len();
226
+ if scan + 70 > len {
227
+ // Not enough lookahead for the batch-edge char classification
228
+ // (up to a 4-byte char starting at scan + 66); scalar batch.
229
+ return (0, u64::MAX);
230
+ }
231
+ let am = if AVX512 {
232
+ // SAFETY: the caller detected the AVX-512 tier (fn contract).
233
+ unsafe { mask::ascii_masks_avx512(bytes, scan) }
234
+ } else {
235
+ // SAFETY: the caller detected the AVX2 tier (fn contract).
236
+ unsafe { mask::ascii_masks_avx2(bytes, scan) }
237
+ };
238
+
239
+ // Any non-ASCII byte in the batch — or within the two carry bytes
240
+ // before it — routes to the extended classifier. (`am.hi` is exact
241
+ // and already computed, unlike NEON's lazily-movemasked variant.)
242
+ if am.hi != 0
243
+ || (scan >= 1 && bytes[scan - 1] >= 0x80)
244
+ || (scan >= 2 && bytes[scan - 2] >= 0x80)
245
+ {
246
+ // SAFETY: both detected tiers include the BMI1/BMI2/LZCNT/POPCNT
247
+ // bit features `family_extended_masks` re-declares (fn contract).
248
+ return unsafe { family_extended_masks(bytes, scan, digits3, class, am) };
249
+ }
250
+
251
+ let cr = if scan == 0 { Carries::default() } else { ascii_carries(bytes, scan) };
252
+ family_algebra(bytes, scan, digits3, am, cr, mask::UniClasses::default())
253
+ }
254
+
255
+ /// Slow(er) path for batches with non-ASCII in or just before them: the
256
+ /// carries walk back through multi-byte chars and classify with the
257
+ /// packed table, so a batch following unicode text gets true carries
258
+ /// instead of a bit-0 bad zone; every unicode char in the batch is
259
+ /// classified with the same table and joins the effective class masks
260
+ /// ([`mask::classify_uni_chars`]); then the shared boundary algebra
261
+ /// applies unchanged. Only number chars (their `\p{N}{1,3}` grouping is
262
+ /// char-counted, inexpressible in byte masks), whitespace straddling the
263
+ /// batch end, and stray continuation bytes stay bad zones.
264
+ ///
265
+ /// On x86_64 the `target_feature` re-declaration keeps the bit-scan
266
+ /// loops on tzcnt/lzcnt/blsr in a baseline (non-native) build — this
267
+ /// function is out-of-line, so without it the ~21% of OWT batches
268
+ /// landing here would compile against baseline x86-64 even though every
269
+ /// caller is a SIMD batch classifier. Only the bit features are enabled
270
+ /// (not the callers' vector features): both the AVX-512 and AVX2 tiers
271
+ /// call this, so it must never emit instructions beyond the AVX2 tier's
272
+ /// set. Measured neutral on the OWT mask-compute diagnostic
273
+ /// (ASCII-dominated); kept for codegen parity on non-ASCII-heavy corpora
274
+ /// where this path dominates.
275
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
276
+ #[cfg_attr(
277
+ target_arch = "x86_64",
278
+ target_feature(enable = "bmi1,bmi2,lzcnt,popcnt")
279
+ )]
280
+ #[inline(never)]
281
+ fn family_extended_masks(
282
+ bytes: &[u8],
283
+ scan: usize,
284
+ digits3: bool,
285
+ class: impl Fn(u32) -> CharClass + Copy,
286
+ am: mask::AsciiMasks,
287
+ ) -> (u64, u64) {
288
+ // A P1 straddling into the batch claims its continuation bytes with
289
+ // its class; `b2b_in` is the two-back test for the char right after
290
+ // it, whose predecessor chain starts before the batch.
291
+ let mut cl = mask::UniClasses::default();
292
+ let cr = if scan == 0 {
293
+ Carries::default()
294
+ } else if bytes[scan - 1] < 0x80 && (scan < 2 || bytes[scan - 2] < 0x80) {
295
+ ascii_carries(bytes, scan)
296
+ } else {
297
+ // A multi-byte char within two bytes of the batch start.
298
+ // SAFETY: scan > 0 on this branch, and the classifier's
299
+ // scan + 70 <= len batch guard covers pos + 3 <= len.
300
+ let (c1, j1, e1) = unsafe { mask::char_through(bytes, scan, class) };
301
+ let pb = bytes[scan - 1];
302
+ let chm = if e1 > scan { (1u64 << (e1 - scan)) - 1 } else { 0 };
303
+ cl.cont = chm;
304
+ let c2v = if j1 == 0 {
305
+ 0
306
+ } else {
307
+ // SAFETY: j1 > 0 just checked, and j1 < scan keeps the decode
308
+ // within the classifier's scan + 70 <= len batch guard.
309
+ let c2c = unsafe { mask::char_through(bytes, j1, class) }.0;
310
+ u64::from(bytes[j1 - 1] == b' ' || c2c == CharClass::Other)
311
+ };
312
+ let mut c = Carries::default();
313
+ if e1 > scan {
314
+ c.b2b_in = c2v << (e1 - scan);
315
+ } else {
316
+ c.c2_os = c2v;
317
+ }
318
+ c.pd = u64::from(c1 == CharClass::Number);
319
+ match c1 {
320
+ CharClass::Letter => {
321
+ cl.l = chm;
322
+ c.pl = 1;
323
+ }
324
+ // A digit P1 sets no letter/punct carries: `\p{N}` groups
325
+ // restart at token boundaries. A P1 entirely before the batch
326
+ // is covered by the `pd` seed (bit 0 is then an ASCII digit
327
+ // when the run continues). A digit char STRADDLING into the
328
+ // batch defeats that seed — bit 0 is its continuation byte,
329
+ // not an ASCII digit — so its claimed bytes defer via resid
330
+ // and the bad<<1 seed catches the following run. (Found by
331
+ // the o200k-family port's differential fuzz: "٢1234" with the
332
+ // ٢ split across a batch edge mis-phased `\p{N}{1,3}`.)
333
+ CharClass::Number => {
334
+ cl.n = chm;
335
+ cl.resid |= chm;
336
+ }
337
+ CharClass::Other => {
338
+ cl.o = chm;
339
+ c.po = 1;
340
+ }
341
+ CharClass::Whitespace => {
342
+ cl.ws = chm;
343
+ if e1 > scan {
344
+ // Straddling-in ws: run bookkeeping crosses the edge.
345
+ cl.resid = chm;
346
+ }
347
+ c.ps = u64::from(pb == b' ');
348
+ let nl = pb == b'\r' || pb == b'\n';
349
+ c.pwt = u64::from(pb != b' ' && !nl);
350
+ c.pws = 1;
351
+ }
352
+ }
353
+ c
354
+ };
355
+
356
+ let mut uni = if am.hi != 0 {
357
+ // SAFETY: this classifier's scan + 70 <= len batch guard is
358
+ // exactly `classify_uni_chars`' contract.
359
+ unsafe { mask::classify_uni_chars::<false, true>(bytes, scan, am.hi & !cl.cont, class) }
360
+ } else {
361
+ mask::UniClasses::default()
362
+ };
363
+ uni.l |= cl.l;
364
+ uni.n |= cl.n;
365
+ uni.o |= cl.o;
366
+ uni.ws |= cl.ws;
367
+ uni.cont |= cl.cont;
368
+ uni.resid |= cl.resid;
369
+
370
+ family_algebra(bytes, scan, digits3, am, cr, uni)
371
+ }
372
+
373
+ /// The scheme family's shared u64 boundary algebra over per-byte class
374
+ /// masks. `uni` is all-zero on the pure-ASCII path (the constant folds
375
+ /// away every unicode term); the extended path passes real class masks
376
+ /// with straddle-in claims already merged in.
377
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
378
+ #[inline(always)]
379
+ fn family_algebra(
380
+ bytes: &[u8],
381
+ scan: usize,
382
+ digits3: bool,
383
+ am: mask::AsciiMasks,
384
+ cr: Carries,
385
+ uni: mask::UniClasses,
386
+ ) -> (u64, u64) {
387
+ let Carries { pl, ps, pwt, po, pws, pd, c2_os, b2b_in } = cr;
388
+ let contm = uni.cont;
389
+ let resid = uni.resid;
390
+
391
+ // Effective per-byte classes: every byte of a classified char carries
392
+ // the char's class, so byte-adjacency == char-adjacency.
393
+ let lb = am.l | uni.l;
394
+ let sb = am.s; // the ` ?` / prefix "space" is ASCII 0x20 only
395
+ let wtb = am.wt | uni.ws;
396
+ let ob = !(am.l | am.d | am.s | am.wt | am.n | am.hi) | uni.o;
397
+ let ws_all = sb | wtb | am.n;
398
+
399
+ // --- Letters: `[^\r\n\p{L}\p{N}]?\p{L}+` -------------------------------
400
+ // B: "the char two back is punct or space" — evaluated at each char's
401
+ // lead by shifting the prev-byte test C by the PREV char's length.
402
+ let len1 = !(contm | uni.lead2 | uni.lead3 | uni.lead4);
403
+ let c_test = ((ob | sb) << 1) | po | ps; // bit 0: byte scan-1 in O|S
404
+ let b2back = ((c_test & len1) << 1)
405
+ | ((c_test & uni.lead2) << 2)
406
+ | ((c_test & uni.lead3) << 3)
407
+ | ((c_test & uni.lead4) << 4)
408
+ | c2_os // prev char entirely before the batch
409
+ | b2b_in; // prev char straddles in; its own prev is P2
410
+ let p_l = (lb << 1) | pl;
411
+ let p_s = (sb << 1) | ps;
412
+ let p_wt = (wtb << 1) | pwt;
413
+ let p_o = (ob << 1) | po;
414
+ let absorb = p_o & !b2back;
415
+ let b_letters = lb & !contm & !p_l & !p_s & !p_wt & !absorb;
416
+
417
+ // --- Digits: `\p{N}{1,3}` or `\p{N}` -----------------------------------
418
+ // The run-split hop loop only runs when a run of 2+ digits exists.
419
+ let b_digits = if digits3 && am.d & (am.d >> 1) != 0 {
420
+ mask::digit_run_splits3(am.d)
421
+ } else {
422
+ am.d
423
+ };
424
+
425
+ // --- Punct: ` ?[^\s\p{L}\p{N}]+` ----------------------------------------
426
+ let b_punct = ob & !contm & !p_o & !p_s;
427
+
428
+ // --- Whitespace ---------------------------------------------------------
429
+ // Newlines directly after a punct run are absorbed (`[\r\n]*`). The
430
+ // smear only runs on a nonzero seed (most batches have no
431
+ // punct-adjacent newline).
432
+ let abs_seed = am.n & ((ob << 1) | po);
433
+ let abs_n = if abs_seed == 0 { 0 } else { smear_up(abs_seed, am.n) };
434
+ let ws_eff = ws_all & !abs_n;
435
+
436
+ let mut bad = resid | resid << 1 | resid >> 1;
437
+
438
+ // Byte-64 lookahead: is the char at the next batch's first byte
439
+ // non-ws? Decides whether ws-like runs touching bit 63 resolve
440
+ // in-batch — the dominant deferral cause before this existed (~16% of
441
+ // OWT batches ended in a single space). Branchless for an ASCII byte
442
+ // 64 as in the r50k scanner ("is the edge ws" is a ~20% coin flip on
443
+ // natural text); only a non-ASCII byte 64 (rare) branches, for the
444
+ // table-backed check. Guarded on `bad >> 63`: a ws char straddling
445
+ // out makes byte 64 a continuation byte, not a lead (only reachable
446
+ // with a non-ASCII byte 64).
447
+ let nb64 = bytes[scan + 64]; // in bounds: scan + 70 <= len
448
+ let nn64 = if nb64 < 0x80 {
449
+ !is_ascii_ws(nb64)
450
+ } else {
451
+ // SAFETY: the same scan + 70 <= len batch guard puts the decode at
452
+ // scan + 64 in bounds (needs scan + 68 <= len).
453
+ bad >> 63 == 0 && unsafe { mask::nn_at_full(bytes, scan + 64) }
454
+ };
455
+ let nn64m = u64::from(nn64).wrapping_neg(); // all-ones when non-ws
456
+
457
+ // A punct-absorbed newline run touching the batch end: if the char at
458
+ // byte 64 is ws, the token may continue (another newline), and even
459
+ // when it doesn't, the next batch cannot tell the absorbed `\n` before
460
+ // its bit 0 from a ws-run `\n` — defer to the scalar path. If byte 64
461
+ // is non-ws, the punct token ends exactly at the batch edge.
462
+ if abs_n >> 63 != 0 && !nn64 {
463
+ bad |= 1u64 << 63;
464
+ }
465
+
466
+ // A ws run touching the batch end resolves in-batch when byte 64's
467
+ // char is non-ws (the run's last newline and its `(?!\S)` split are
468
+ // then all visible; `nn64m` feeds the lookahead bits below).
469
+ // Otherwise it defers: its last newline (and the `\s+$`-style
470
+ // end-of-input rules) may lie beyond this batch.
471
+ let nonws = !ws_eff;
472
+ if ws_eff >> 63 != 0 && !nn64 {
473
+ if nonws == 0 {
474
+ return (0, u64::MAX); // whole batch one ws run
475
+ }
476
+ let h = 63 - nonws.leading_zeros(); // highest non-ws bit (< 63)
477
+ bad |= u64::MAX << (h + 1);
478
+ }
479
+
480
+ // A digit run crossing the batch END needs no deferral: its in-batch
481
+ // `\p{N}{1,3}` splits are phased from the run's in-batch start (a
482
+ // continuation from before the batch is the `pd` case below), and
483
+ // they are token starts no matter how far the run continues — the
484
+ // NEXT batch defers its own leading run via its `pd` seed and the
485
+ // scalar path resumes from the last in-batch split.
486
+
487
+ // A digit run whose grouping phase did not start inside this batch is
488
+ // deferred too: `digit_run_splits3` phases each run from its first
489
+ // in-batch digit, which is wrong when the run continues from before
490
+ // the batch (`pd`: the walker stays on the 64-byte grid across scalar
491
+ // overruns, so a batch can begin mid-run) or follows a bad zone that
492
+ // may hold digit-class chars (e.g. Arabic-Indic digits, kept out of
493
+ // the mask because `\p{N}{1,3}` counts chars, not bytes — a latent
494
+ // bug that predates the table classifier, caught when Arabic-Indic
495
+ // digits joined the fuzz corpus).
496
+ if digits3 {
497
+ let seed = (am.d & (bad << 1)) | (am.d & pd);
498
+ if seed != 0 {
499
+ bad |= smear_up(seed, am.d);
500
+ }
501
+ }
502
+
503
+ // Base rule (correct for NL-free runs; NL runs are overridden below):
504
+ // run start, or split before the last char when followed by non-ws.
505
+ let ws_leads1 = (am.s | am.wt | am.n) & ws_eff;
506
+ let ws_leads = (ws_leads1 | uni.w2 | uni.w3) & !abs_n;
507
+ let p_ws = (ws_eff << 1) | pws; // prev byte ws (any kind)
508
+ // Last-char `(?!\S)` split: in-batch via shifted nonws; the run
509
+ // touching bit 63 uses the byte-64 lookahead (`nn64m`). A 2-byte ws
510
+ // led at 62 or 3-byte ws led at 61 ends at the edge too.
511
+ let edge_last = (ws_leads1 & (1 << 63)) | (uni.w2 & (1 << 62)) | (uni.w3 & (1 << 61));
512
+ let split_ok = (ws_leads1 & (nonws >> 1))
513
+ | (uni.w2 & (nonws >> 2))
514
+ | (uni.w3 & (nonws >> 3))
515
+ | (edge_last & nn64m);
516
+ let mut b_ws = ws_leads & (!p_ws | split_ok);
517
+
518
+ // Override every run that contains a (non-absorbed) newline: one token
519
+ // through the run's last newline, then r50k-style tail rules. (A
520
+ // branchless formulation via a downward smear — add the tail-start
521
+ // bit after each run's last newline, clear the split bit sitting on
522
+ // it — measured 0.95x: the smear's serial chain runs every batch
523
+ // while this loop is skipped or predicted on most.)
524
+ let mut runs_n = am.n & ws_eff & !bad;
525
+ while runs_n != 0 {
526
+ let f = runs_n.trailing_zeros();
527
+ let below_gap = nonws & ((1u64 << f) - 1);
528
+ let a = if below_gap == 0 { 0 } else { 64 - below_gap.leading_zeros() };
529
+ // First non-ws above f, or 64 for a run ending exactly at the
530
+ // batch edge (only reachable when `nn64`).
531
+ let e = (nonws & (u64::MAX << f)).trailing_zeros();
532
+ let run_mask = (u64::MAX << a) & !u64::MAX.unbounded_shl(e);
533
+ b_ws &= !run_mask;
534
+ // Run start. Bit-0-leading runs with prev-byte ws cannot contain a
535
+ // newline (scalar resumes only after `\s*[\r\n]+` tokens), so `a`
536
+ // is always a true run start here.
537
+ b_ws |= 1u64 << a;
538
+ let q = 63 - (am.n & run_mask).leading_zeros(); // last NL in run
539
+ if (q + 1) < e {
540
+ // Tail after the last newline: starts a token, and its last
541
+ // char splits off before the following non-ws char.
542
+ b_ws |= 1u64 << (q + 1);
543
+ let tail = run_mask & (u64::MAX << (q + 1));
544
+ let tail_leads = ws_leads & tail;
545
+ b_ws |= 1u64 << (63 - tail_leads.leading_zeros());
546
+ }
547
+ runs_n &= !run_mask;
548
+ }
549
+
550
+ let mut boundary = b_letters | b_digits | b_punct | b_ws;
551
+
552
+ // --- Contractions: `'(?i:[sdmt]|ll|ve|re)` ------------------------------
553
+ // ('ſ — U+017F — is non-ASCII, so it already sits in a bad zone.)
554
+ let mut cand = am.ap & boundary & !bad;
555
+ while cand != 0 {
556
+ let i = cand.trailing_zeros() as usize;
557
+ cand &= cand - 1;
558
+ if i >= 61 {
559
+ bad |= u64::MAX << i;
560
+ break;
561
+ }
562
+ let b1 = bytes[scan + i + 1];
563
+ if b1 >= 0x80 {
564
+ // `(?i:'s)` also matches 'ſ (U+017F folds to s). With the
565
+ // table now classifying ſ as a letter instead of leaving it
566
+ // in a bad zone, an apostrophe before ANY non-ASCII char
567
+ // must defer to the scalar path explicitly.
568
+ bad |= 0b111u64 << i;
569
+ continue;
570
+ }
571
+ let k = match b1 | 0x20 {
572
+ b's' | b'd' | b'm' | b't' => 2,
573
+ b'l' if bytes[scan + i + 2] | 0x20 == b'l' => 3,
574
+ b'v' if bytes[scan + i + 2] | 0x20 == b'e' => 3,
575
+ b'r' if bytes[scan + i + 2] | 0x20 == b'e' => 3,
576
+ _ => 0,
577
+ };
578
+ if k != 0 {
579
+ boundary &= !(1u64 << (i + 1));
580
+ boundary |= 1u64 << (i + k);
581
+ }
582
+ }
583
+
584
+ (boundary & !bad, bad)
585
+ }
586
+
587
+ #[cfg(test)]
588
+ mod tests {
589
+ use crate::pretokenize::fast::cl100k::Cl100kScheme;
590
+ use crate::pretokenize::fast::mask::{MaskScheme, MaskState};
591
+ use crate::pretokenize::fast::olmo3::Olmo3Scheme;
592
+ use crate::pretokenize::fast::qwen2::Qwen2Scheme;
593
+ use crate::pretokenize::fast::qwen3_5::Qwen35Scheme;
594
+
595
+ fn scalar_tokens<S: MaskScheme>(bytes: &[u8]) -> Vec<Vec<u8>> {
596
+ let mut pos = 0;
597
+ let mut out = vec![];
598
+ while pos < bytes.len() {
599
+ let e = S::advance(bytes, pos);
600
+ out.push(bytes[pos..e].to_vec());
601
+ pos = e;
602
+ }
603
+ out
604
+ }
605
+
606
+ fn mask_tokens<S: MaskScheme>(bytes: &[u8]) -> Vec<Vec<u8>> {
607
+ let mut st = MaskState::new(0);
608
+ let mut out = vec![];
609
+ while let Some((s, e)) = st.next_span::<S>(bytes) {
610
+ out.push(bytes[s..e].to_vec());
611
+ }
612
+ out
613
+ }
614
+
615
+ #[track_caller]
616
+ fn check_one<S: MaskScheme>(buf: &[u8], scheme: &str) {
617
+ let a = scalar_tokens::<S>(buf);
618
+ let b = mask_tokens::<S>(buf);
619
+ if a != b {
620
+ let i = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
621
+ panic!(
622
+ "{scheme} diverged at token {i} on {:?}\n scalar: {:?}\n mask: {:?}",
623
+ String::from_utf8_lossy(buf),
624
+ a.get(i).map(|t| String::from_utf8_lossy(t).into_owned()),
625
+ b.get(i).map(|t| String::from_utf8_lossy(t).into_owned()),
626
+ );
627
+ }
628
+ }
629
+
630
+ fn check_all(buf: &[u8]) {
631
+ check_one::<Olmo3Scheme>(buf, "olmo3");
632
+ check_one::<Cl100kScheme>(buf, "cl100k");
633
+ check_one::<Qwen2Scheme>(buf, "qwen2");
634
+ check_one::<Qwen35Scheme>(buf, "qwen3_5");
635
+ }
636
+
637
+ /// Crafted cases, padded so they cross the batch (not scalar-tail) path.
638
+ #[test]
639
+ fn family_mask_matches_scalar_padded_cases() {
640
+ let pad = "The quick brown fox jumps over the lazy dog again and again. ";
641
+ let cases = [
642
+ "January 24, 2015 and 12345678 numbers 1 22 333 4444",
643
+ "don't DON'T they'Ll 'sound 'lx x'y '' ' \u{2019}s",
644
+ "!hello !!hello ?!x a-b ... !!!\n\nnext",
645
+ "tabs\tand\nnewlines\r\n mixed \n runs \n\n\n deep",
646
+ "hi!\n\ndef hi !!\n\nabc \"quoted\" (paren)",
647
+ "caf\u{e9} r\u{e9}sum\u{e9} \u{201c}word\u{201d} \u{2014}dash\u{2013} \u{00a0}nbsp",
648
+ "\u{2003}em \u{2009}thin\u{2028}ls x\u{e9}\u{e9}y",
649
+ "price: $5.99! 100,000.00 3.14159 2nd 3rd 4th",
650
+ "a\u{2028}b a\u{2028}\n \n\n\t x \n\n ",
651
+ "mixed 1\u{662}3x \u{661}\u{662}\u{663} arabic",
652
+ ];
653
+ for case in cases {
654
+ for lead in [0usize, 1, 37, 63, 64, 65] {
655
+ let mut buf = pad.as_bytes().repeat(4)[..pad.len() * 2 + lead].to_vec();
656
+ buf.extend_from_slice(case.as_bytes());
657
+ buf.extend_from_slice(pad.as_bytes());
658
+ buf.extend_from_slice(case.as_bytes());
659
+ check_all(&buf);
660
+ }
661
+ }
662
+ }
663
+
664
+ /// A multi-byte digit char straddling a batch edge, followed by
665
+ /// ASCII digits: the `\p{N}{1,3}` phase starts at the straddling
666
+ /// char, which the `pd` seed alone cannot see (bit 0 is a
667
+ /// continuation byte). Regression for the resid fix in
668
+ /// `family_extended_masks`; lead 127 was the failing alignment.
669
+ #[test]
670
+ fn family_straddling_digit_char_phase() {
671
+ for lead in 100..200usize {
672
+ let mut buf = vec![b'a'; lead];
673
+ buf.extend_from_slice("\u{662}1234".as_bytes());
674
+ buf.extend_from_slice(&vec![b'a'; 262 - 6 - lead][..]);
675
+ check_all(&buf);
676
+ }
677
+ }
678
+
679
+ /// Differential fuzz across all four family schemes.
680
+ #[test]
681
+ fn family_mask_matches_scalar_fuzz() {
682
+ let pieces: &[&str] = &[
683
+ "a", "B", "z", "9", "0", " ", " ", "\n", "\t", "\r\n", "\r", "'", "'s", "'LL",
684
+ "!", ".", ",", "(", "é", "ß", "日", "🎉", "\u{00A0}", "\u{2003}", "word", "12",
685
+ "1234", "’", "“", "”", "–", "—", "…", "\u{2009}", "\u{200B}", "\u{2028}",
686
+ "\u{202F}", "×", "÷", "«", "µ", "café", "éé", "naïve", "Α", "а", "\n\n", "!x",
687
+ "\tx", " x", "?!", "\u{301}", "ſ", "'ſ", "'\u{301}", "\u{661}\u{662}",
688
+ "\u{FF11}", "क", "\u{940}", "\u{1D54F}", "€", "™", "…\u{2028}",
689
+ ];
690
+ let mut state = 0x243F6A8885A308D3u64;
691
+ let mut rng = move || {
692
+ state ^= state << 13;
693
+ state ^= state >> 7;
694
+ state ^= state << 17;
695
+ state
696
+ };
697
+ for round in 0..3000 {
698
+ let target = 80 + (round % 400);
699
+ let mut buf = Vec::new();
700
+ while buf.len() < target {
701
+ buf.extend_from_slice(pieces[(rng() % pieces.len() as u64) as usize].as_bytes());
702
+ }
703
+ check_all(&buf);
704
+ }
705
+ }
706
+ }
707
+
708
+ #[cfg(test)]
709
+ mod owt_tests {
710
+ use super::tests_support::*;
711
+
712
+ /// Diagnostic: per-batch mask-compute cost, r50k vs cl100k, same
713
+ /// input, walker excluded. Local instantiations (inline fns), so
714
+ /// relative timing is honest despite test builds skipping fat LTO.
715
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
716
+ #[test]
717
+ #[ignore]
718
+ fn family_vs_r50k_mask_compute_cost() {
719
+ use crate::pretokenize::fast::cl100k::Cl100kScheme;
720
+ use crate::pretokenize::fast::mask::MaskScheme;
721
+ use crate::pretokenize::fast::r50k::R50kScheme;
722
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
723
+ use std::io::Read;
724
+ let f = std::fs::File::open(&path).unwrap();
725
+ let mut input = Vec::new();
726
+ f.take(1_000_000_000).read_to_end(&mut input).unwrap();
727
+ while !input.is_empty() && std::str::from_utf8(&input).is_err() {
728
+ input.pop();
729
+ }
730
+ let mb = input.len() as f64 / 1e6;
731
+ fn drive<S: MaskScheme>(input: &[u8]) -> u64 {
732
+ let mut acc = 0u64;
733
+ let mut scan = 0usize;
734
+ while scan + 70 <= input.len() {
735
+ let (u, b) = S::batch_masks(input, scan);
736
+ acc = acc.wrapping_add(u ^ b);
737
+ scan += 64;
738
+ }
739
+ acc
740
+ }
741
+ std::hint::black_box(drive::<R50kScheme>(&input));
742
+ std::hint::black_box(drive::<Cl100kScheme>(&input));
743
+ let (mut best_r, mut best_c) = (f64::INFINITY, f64::INFINITY);
744
+ for round in 0..5 {
745
+ let t = std::time::Instant::now();
746
+ std::hint::black_box(drive::<R50kScheme>(&input));
747
+ let dr = t.elapsed().as_secs_f64();
748
+ let t = std::time::Instant::now();
749
+ std::hint::black_box(drive::<Cl100kScheme>(&input));
750
+ let dc = t.elapsed().as_secs_f64();
751
+ best_r = best_r.min(dr);
752
+ best_c = best_c.min(dc);
753
+ eprintln!("round {round}: r50k {:.0} MB/s | cl100k {:.0} MB/s", mb / dr, mb / dc);
754
+ }
755
+ eprintln!(
756
+ "best: r50k {:.0} MB/s ({:.2} cy/B) | cl100k {:.0} MB/s ({:.2} cy/B) | ratio {:.3}x",
757
+ mb / best_r,
758
+ 4.5e9 * best_r / (mb * 1e6),
759
+ mb / best_c,
760
+ 4.5e9 * best_c / (mb * 1e6),
761
+ best_c / best_r,
762
+ );
763
+ }
764
+
765
+ /// Diagnostic census: why do cl100k batches go dirty on OWT?
766
+ /// (2026-07-07, 1 GB: 1.36% dirty — 0.20% ap/abs-edge cases behind a
767
+ /// ws@63, 0.40% ws runs truly crossing the edge, 0.05% digit `pd`
768
+ /// defers, 0.10% unicode resid, 0.61% other [mostly contraction
769
+ /// spills and `pd` continuations]; was 18.62% before the byte-64
770
+ /// edge resolution.)
771
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
772
+ #[test]
773
+ #[ignore]
774
+ fn family_deferral_census() {
775
+ use crate::pretokenize::fast::mask::MaskScheme;
776
+ use crate::pretokenize::fast::cl100k::Cl100kScheme;
777
+ use crate::pretokenize::fast::{is_ascii_ws, is_digit};
778
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
779
+ use std::io::Read;
780
+ let f = std::fs::File::open(&path).unwrap();
781
+ let mut input = Vec::new();
782
+ f.take(1_000_000_000).read_to_end(&mut input).unwrap();
783
+ while !input.is_empty() && std::str::from_utf8(&input).is_err() {
784
+ input.pop();
785
+ }
786
+ let (mut batches, mut dirty, mut gap_bytes) = (0usize, 0usize, 0u64);
787
+ // categories (a dirty batch may hit several; count first that applies)
788
+ let (mut ws63_next_nonws, mut ws63_next_ws, mut digit63, mut hi_any, mut other) =
789
+ (0usize, 0usize, 0usize, 0usize, 0usize);
790
+ let mut scan = 0usize;
791
+ while scan + 70 <= input.len() {
792
+ let (usable, bad) = Cl100kScheme::batch_masks(&input, scan);
793
+ batches += 1;
794
+ if bad != 0 {
795
+ dirty += 1;
796
+ let first_bad = bad.trailing_zeros();
797
+ let rest = usable & (u64::MAX << first_bad);
798
+ let resume = if rest != 0 { rest.trailing_zeros() } else { 64 };
799
+ gap_bytes += u64::from(resume - first_bad);
800
+ let b63 = input[scan + 63];
801
+ let b64 = input[scan + 64];
802
+ let ws63 = is_ascii_ws(b63);
803
+ if ws63 && !is_ascii_ws(b64) && b64 < 0x80 {
804
+ ws63_next_nonws += 1;
805
+ } else if ws63 {
806
+ ws63_next_ws += 1;
807
+ } else if is_digit(b63) {
808
+ digit63 += 1;
809
+ } else if input[scan..scan + 64].iter().any(|&b| b >= 0x80) {
810
+ hi_any += 1;
811
+ } else {
812
+ other += 1;
813
+ }
814
+ }
815
+ scan += 64;
816
+ }
817
+ let pct = |n: usize| 100.0 * n as f64 / batches as f64;
818
+ eprintln!("batches {batches}, dirty {dirty} ({:.2}%)", pct(dirty));
819
+ eprintln!(" ws@63, byte64 non-ws ASCII: {ws63_next_nonws} ({:.2}%)", pct(ws63_next_nonws));
820
+ eprintln!(" ws@63, byte64 ws/hi: {ws63_next_ws} ({:.2}%)", pct(ws63_next_ws));
821
+ eprintln!(" digit@63: {digit63} ({:.2}%)", pct(digit63));
822
+ eprintln!(" hi in batch: {hi_any} ({:.2}%)", pct(hi_any));
823
+ eprintln!(" other: {other} ({:.2}%)", pct(other));
824
+ eprintln!("first-gap bytes: {:.2}%", 100.0 * gap_bytes as f64 / input.len() as f64);
825
+ }
826
+
827
+ /// Full-OWT (~12 GB) mask-vs-scalar differential for all four family
828
+ /// schemes. Streams token-by-token; ~4 min total.
829
+ #[test]
830
+ #[ignore = "reads the full ~12 GB OWT file"]
831
+ fn family_mask_matches_scalar_owt_full() {
832
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
833
+ let input = std::fs::read(&path).expect("Could not read ~/data/owt_train.txt");
834
+ eprintln!("loaded {} bytes", input.len());
835
+ check_streaming_all(&input);
836
+ }
837
+
838
+ /// 100 MB variant for quicker iteration.
839
+ #[test]
840
+ #[ignore]
841
+ fn family_mask_matches_scalar_owt() {
842
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
843
+ use std::io::Read;
844
+ let f = std::fs::File::open(&path).unwrap();
845
+ let mut input = Vec::new();
846
+ f.take(100_000_000).read_to_end(&mut input).unwrap();
847
+ while !input.is_empty() && std::str::from_utf8(&input).is_err() {
848
+ input.pop();
849
+ }
850
+ check_streaming_all(&input);
851
+ }
852
+ }
853
+
854
+ #[cfg(test)]
855
+ mod tests_support {
856
+ use crate::pretokenize::fast::cl100k::Cl100kScheme;
857
+ use crate::pretokenize::fast::mask::{MaskScheme, MaskState};
858
+ use crate::pretokenize::fast::olmo3::Olmo3Scheme;
859
+ use crate::pretokenize::fast::qwen2::Qwen2Scheme;
860
+ use crate::pretokenize::fast::qwen3_5::Qwen35Scheme;
861
+
862
+ fn check_streaming<S: MaskScheme>(bytes: &[u8], scheme: &str) {
863
+ let mut st = MaskState::new(0);
864
+ let mut pos = 0usize;
865
+ let mut idx = 0usize;
866
+ while pos < bytes.len() {
867
+ let scalar_end = S::advance(bytes, pos);
868
+ match st.next_span::<S>(bytes) {
869
+ Some((s, e)) => assert!(
870
+ s == pos && e == scalar_end,
871
+ "{scheme} diverged at token {idx} (byte {pos}): scalar {pos}..{scalar_end} \
872
+ mask {s}..{e}: {:?} vs {:?}",
873
+ String::from_utf8_lossy(&bytes[pos..scalar_end]),
874
+ String::from_utf8_lossy(&bytes[s..e]),
875
+ ),
876
+ None => panic!("{scheme} ended early at token {idx} (byte {pos})"),
877
+ }
878
+ pos = scalar_end;
879
+ idx += 1;
880
+ }
881
+ assert!(st.next_span::<S>(bytes).is_none(), "{scheme} produced extra tokens");
882
+ eprintln!("{scheme}: all {idx} tokens match");
883
+ }
884
+
885
+ pub(super) fn check_streaming_all(bytes: &[u8]) {
886
+ check_streaming::<Olmo3Scheme>(bytes, "olmo3");
887
+ check_streaming::<Cl100kScheme>(bytes, "cl100k");
888
+ check_streaming::<Qwen2Scheme>(bytes, "qwen2");
889
+ check_streaming::<Qwen35Scheme>(bytes, "qwen3_5");
890
+ }
891
+ }