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,541 @@
1
+ //! Fast pretokenizer for the Qwen3.5 regex — on aarch64 (NEON) and
2
+ //! x86_64 with AVX-512 (runtime-detected) a mask scanner
3
+ //! via the shared `cl100k_family::batch_masks` boundary algebra with the
4
+ //! mark-folding classifier (`unicode::class_of_marks_join`), so marks
5
+ //! join letter runs in-mask exactly as in the scalar `advance_pos`:
6
+ //! `(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+`
7
+ //!
8
+ //! Differences from the Qwen2/Qwen3 scheme:
9
+ //! - `\p{M}` joins letter runs (`[\p{L}\p{M}]+` instead of `\p{L}+`), so a
10
+ //! combining mark extends a word and a bare mark run is a word of its own
11
+ //! - `\p{M}` is excluded from the punctuation run (`[^\s\p{L}\p{M}\p{N}]+`),
12
+ //! so a mark after punctuation terminates the run
13
+ //!
14
+ //! The optional one-char prefix `[^\r\n\p{L}\p{N}]?` is unchanged; a mark is
15
+ //! a valid prefix, but since marks are also in the run class the match span
16
+ //! is the same either way.
17
+
18
+ #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))]
19
+ #[cfg(target_arch = "aarch64")]
20
+ use super::cl100k_family::batch_masks;
21
+ #[cfg(target_arch = "x86_64")]
22
+ use super::cl100k_family::batch_masks_x86;
23
+ use super::mask::{MaskScheme, MaskState};
24
+ use super::{decode_cp, is_ascii_ws, is_digit, is_letter, scan_newlines, swar_scan_letters};
25
+ use crate::pretokenize::unicode::{self, DsCharClass, ds_class_of};
26
+ use crate::pretokenize::Pretoken;
27
+
28
+ pub(crate) struct Qwen35Scheme;
29
+
30
+ impl MaskScheme for Qwen35Scheme {
31
+ #[inline(always)]
32
+ fn advance(bytes: &[u8], pos: usize) -> usize {
33
+ advance_pos(bytes, pos)
34
+ }
35
+
36
+ #[cfg(target_arch = "aarch64")]
37
+ #[inline(always)]
38
+ fn batch_masks(bytes: &[u8], scan: usize) -> (u64, u64) {
39
+ // Class-table LazyLock resolved once per batch; the extended
40
+ // path's per-char classify is then a bare slice index.
41
+ let ct = unicode::DsClassTable::get();
42
+ batch_masks(bytes, scan, false, move |cp| ct.class_of_marks_join(cp))
43
+ }
44
+
45
+ #[cfg(target_arch = "x86_64")]
46
+ #[inline(always)]
47
+ unsafe fn batch_masks_x86<const AVX512: bool>(bytes: &[u8], scan: usize) -> (u64, u64) {
48
+ // Class-table LazyLock resolved once per batch; the extended
49
+ // path's per-char classify is then a bare slice index.
50
+ let ct = unicode::DsClassTable::get();
51
+ // SAFETY: the caller detected the tier (trait contract).
52
+ unsafe { batch_masks_x86::<AVX512>(bytes, scan, false, move |cp| ct.class_of_marks_join(cp)) }
53
+ }
54
+ }
55
+
56
+ /// With SIMD support (aarch64 NEON, or x86_64 AVX-512 detected at runtime),
57
+ /// iteration runs the shared cl100k-family mask scanner (see
58
+ /// `cl100k_family::batch_masks`); elsewhere every token takes the scalar
59
+ /// `advance_pos`.
60
+ pub struct FastQwen35Pretokenizer<'a> {
61
+ bytes: &'a [u8],
62
+ state: MaskState,
63
+ }
64
+
65
+ impl<'a> FastQwen35Pretokenizer<'a> {
66
+ #[inline]
67
+ pub fn new(bytes: &'a [u8]) -> Self {
68
+ Self::with_pos(bytes, 0)
69
+ }
70
+
71
+ /// Resume iteration at a byte offset previously returned by [`Self::pos`].
72
+ #[inline]
73
+ pub fn with_pos(bytes: &'a [u8], pos: usize) -> Self {
74
+ Self { bytes, state: MaskState::new(pos) }
75
+ }
76
+
77
+ /// Current position as a byte offset into the input.
78
+ #[inline]
79
+ pub fn pos(&self) -> usize {
80
+ self.state.pos
81
+ }
82
+ }
83
+
84
+ impl<'a> Iterator for FastQwen35Pretokenizer<'a> {
85
+ type Item = Pretoken<'a>;
86
+
87
+ #[inline]
88
+ fn next(&mut self) -> Option<Pretoken<'a>> {
89
+ let (start, end) = self.state.next_span::<Qwen35Scheme>(self.bytes)?;
90
+ Some(Pretoken(&self.bytes[start..end]))
91
+ }
92
+ }
93
+
94
+ super::impl_mask_pretoken_spans!(FastQwen35Pretokenizer, Qwen35Scheme);
95
+
96
+ /// If the char at `pos` is `\p{L}` or `\p{M}`, return the offset just past it.
97
+ #[inline(always)]
98
+ fn lm_end_at(bytes: &[u8], pos: usize) -> Option<usize> {
99
+ let &b = bytes.get(pos)?;
100
+ if is_letter(b) {
101
+ return Some(pos + 1);
102
+ }
103
+ if b >= 0x80 {
104
+ let (cp, l) = unsafe { decode_cp(bytes, pos) };
105
+ if matches!(ds_class_of(cp), DsCharClass::Letter | DsCharClass::Mark) {
106
+ return Some(pos + l);
107
+ }
108
+ }
109
+ None
110
+ }
111
+
112
+ /// `[\p{L}\p{M}]+` from `pos`.
113
+ #[inline(always)]
114
+ fn scan_lm_from(bytes: &[u8], pos: usize) -> usize {
115
+ let len = bytes.len();
116
+ let mut p = pos;
117
+ loop {
118
+ p = swar_scan_letters(bytes, p);
119
+ if p < len && unsafe { *bytes.get_unchecked(p) } >= 0x80 {
120
+ let (cp, l) = unsafe { decode_cp(bytes, p) };
121
+ if matches!(ds_class_of(cp), DsCharClass::Letter | DsCharClass::Mark) {
122
+ p += l;
123
+ continue;
124
+ }
125
+ }
126
+ return p;
127
+ }
128
+ }
129
+
130
+ /// `[^\s\p{L}\p{M}\p{N}]+` from `pos` (punctuation, symbols, controls —
131
+ /// everything except letters, marks, numbers, and whitespace).
132
+ #[inline(always)]
133
+ fn scan_other_from(bytes: &[u8], pos: usize) -> usize {
134
+ let len = bytes.len();
135
+ let mut p = pos;
136
+ loop {
137
+ while p < len {
138
+ let b = unsafe { *bytes.get_unchecked(p) };
139
+ if b >= 0x80 {
140
+ break;
141
+ }
142
+ if is_letter(b) || is_digit(b) || is_ascii_ws(b) {
143
+ return p;
144
+ }
145
+ p += 1;
146
+ }
147
+ if p < len {
148
+ let (cp, l) = unsafe { decode_cp(bytes, p) };
149
+ if matches!(ds_class_of(cp), DsCharClass::PunctSym | DsCharClass::Other) {
150
+ p += l;
151
+ continue;
152
+ }
153
+ }
154
+ return p;
155
+ }
156
+ }
157
+
158
+ /// Whitespace-led token starting at `start`, i.e. the alternatives
159
+ /// `\s*[\r\n]+` | `\s+(?!\S)` | `\s+`, in that priority.
160
+ /// Precondition: the letter-prefix (`[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+`) and
161
+ /// space+punct (` ?[^\s\p{L}\p{M}\p{N}]+...`) alternatives were ruled out.
162
+ #[inline(always)]
163
+ fn ws_token_end(bytes: &[u8], start: usize) -> usize {
164
+ let len = bytes.len();
165
+ let mut p = start;
166
+ let mut last_nl_end = 0usize; // 0 = run contains no \r\n
167
+ let mut last_char_start = start;
168
+ while p < len {
169
+ let b = unsafe { *bytes.get_unchecked(p) };
170
+ if b == b'\r' || b == b'\n' {
171
+ last_char_start = p;
172
+ p += 1;
173
+ last_nl_end = p;
174
+ } else if is_ascii_ws(b) {
175
+ last_char_start = p;
176
+ p += 1;
177
+ } else if b >= 0x80 {
178
+ let (cp, l) = unsafe { decode_cp(bytes, p) };
179
+ if ds_class_of(cp) == DsCharClass::Whitespace {
180
+ last_char_start = p;
181
+ p += l;
182
+ } else {
183
+ break;
184
+ }
185
+ } else {
186
+ break;
187
+ }
188
+ }
189
+ if last_nl_end != 0 {
190
+ return last_nl_end; // `\s*[\r\n]+`: through the last newline, even at EOS
191
+ }
192
+ if p >= len {
193
+ return p; // `\s+(?!\S)`: lookahead succeeds at EOS
194
+ }
195
+ if last_char_start > start {
196
+ return last_char_start; // `\s+(?!\S)`: all but the last ws char
197
+ }
198
+ p // `\s+`: single whitespace char before content
199
+ }
200
+
201
+ /// Advance past one token starting at `pos`. Returns the new position.
202
+ /// `pos` must be < `bytes.len()`.
203
+ #[inline(always)]
204
+ fn advance_pos(bytes: &[u8], pos: usize) -> usize {
205
+ let b0 = unsafe { *bytes.get_unchecked(pos) };
206
+
207
+ // Hot path 1: ASCII letter — `[\p{L}\p{M}]+` with empty prefix
208
+ if is_letter(b0) {
209
+ return scan_lm_from(bytes, pos + 1);
210
+ }
211
+
212
+ // Hot path 2: space prefix
213
+ if b0 == b' ' {
214
+ let Some(&b1) = bytes.get(pos + 1) else {
215
+ return pos + 1; // trailing lone space (`\s+(?!\S)` at EOS)
216
+ };
217
+ if is_letter(b1) {
218
+ return scan_lm_from(bytes, pos + 2); // " word"
219
+ }
220
+ if b1 < 0x80 {
221
+ if is_digit(b1) {
222
+ return pos + 1; // numbers never absorb the space
223
+ }
224
+ if is_ascii_ws(b1) {
225
+ return ws_token_end(bytes, pos);
226
+ }
227
+ // ` ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*`
228
+ let p = scan_other_from(bytes, pos + 2);
229
+ return scan_newlines(bytes, p);
230
+ }
231
+ let (cp, l) = unsafe { decode_cp(bytes, pos + 1) };
232
+ let p1 = pos + 1 + l;
233
+ match ds_class_of(cp) {
234
+ DsCharClass::Letter | DsCharClass::Mark => return scan_lm_from(bytes, p1),
235
+ DsCharClass::Whitespace => return ws_token_end(bytes, pos),
236
+ DsCharClass::Number => return pos + 1,
237
+ DsCharClass::PunctSym | DsCharClass::Other => {
238
+ let p = scan_other_from(bytes, p1);
239
+ return scan_newlines(bytes, p);
240
+ }
241
+ }
242
+ }
243
+
244
+ // Non-ASCII
245
+ if b0 >= 0x80 {
246
+ let (cp, l) = unsafe { decode_cp(bytes, pos) };
247
+ let p0 = pos + l;
248
+ match ds_class_of(cp) {
249
+ DsCharClass::Letter | DsCharClass::Mark => return scan_lm_from(bytes, p0),
250
+ DsCharClass::Number => return p0, // `\p{N}`: exactly one char
251
+ // Any non-letter/mark/number char except \r\n may prefix a run
252
+ class => {
253
+ if let Some(p) = lm_end_at(bytes, p0) {
254
+ return scan_lm_from(bytes, p);
255
+ }
256
+ if class == DsCharClass::Whitespace {
257
+ return ws_token_end(bytes, pos);
258
+ }
259
+ let p = scan_other_from(bytes, p0);
260
+ return scan_newlines(bytes, p);
261
+ }
262
+ }
263
+ }
264
+
265
+ // ASCII digit: `\p{N}` matches exactly one char
266
+ if is_digit(b0) {
267
+ return pos + 1;
268
+ }
269
+
270
+ // Apostrophe: case-insensitive contractions
271
+ if b0 == b'\'' {
272
+ match bytes.get(pos + 1).map(u8::to_ascii_lowercase) {
273
+ Some(b's' | b'd' | b'm' | b't') => return pos + 2,
274
+ Some(b'l') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'l') => {
275
+ return pos + 3;
276
+ }
277
+ Some(b'v') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'e') => {
278
+ return pos + 3;
279
+ }
280
+ Some(b'r') if bytes.get(pos + 2).map(u8::to_ascii_lowercase) == Some(b'e') => {
281
+ return pos + 3;
282
+ }
283
+ _ => {}
284
+ }
285
+ // U+017F LATIN SMALL LETTER LONG S case-folds to 's' under `(?i)`
286
+ if bytes.get(pos + 1) == Some(&0xC5) && bytes.get(pos + 2) == Some(&0xBF) {
287
+ return pos + 3;
288
+ }
289
+ // Not a contraction: `'` can still prefix a letter/mark run
290
+ if let Some(p) = lm_end_at(bytes, pos + 1) {
291
+ return scan_lm_from(bytes, p);
292
+ }
293
+ let p = scan_other_from(bytes, pos + 1);
294
+ return scan_newlines(bytes, p);
295
+ }
296
+
297
+ // \r and \n are excluded from the letter-run prefix
298
+ if b0 == b'\r' || b0 == b'\n' {
299
+ return ws_token_end(bytes, pos);
300
+ }
301
+
302
+ // Other ASCII whitespace (\t, \x0b, \x0c) may prefix a letter/mark run
303
+ if is_ascii_ws(b0) {
304
+ if let Some(p) = lm_end_at(bytes, pos + 1) {
305
+ return scan_lm_from(bytes, p);
306
+ }
307
+ return ws_token_end(bytes, pos);
308
+ }
309
+
310
+ // ASCII punctuation/symbol/control
311
+ if let Some(p) = lm_end_at(bytes, pos + 1) {
312
+ return scan_lm_from(bytes, p);
313
+ }
314
+ let p = scan_other_from(bytes, pos + 1);
315
+ scan_newlines(bytes, p)
316
+ }
317
+
318
+ #[cfg(test)]
319
+ mod tests {
320
+ use super::*;
321
+ use std::io::Read;
322
+
323
+ /// The Qwen3.5 pattern verbatim — it contains no possessive quantifiers,
324
+ /// so it runs directly under fancy-regex.
325
+ const QWEN35_REF_REGEX: &str =
326
+ r"(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?[\p{L}\p{M}]+|\p{N}| ?[^\s\p{L}\p{M}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+";
327
+
328
+ fn regex_tokens(s: &str) -> Vec<String> {
329
+ let re = fancy_regex::Regex::new(QWEN35_REF_REGEX).unwrap();
330
+ re.find_iter(s)
331
+ .map(|m| m.unwrap().as_str().to_string())
332
+ .collect()
333
+ }
334
+
335
+ fn fast_tokens(s: &str) -> Vec<String> {
336
+ FastQwen35Pretokenizer::new(s.as_bytes())
337
+ .map(|t| String::from_utf8_lossy(t.0).into_owned())
338
+ .collect()
339
+ }
340
+
341
+ /// Load the first `max_bytes` of ~/data/owt_train.txt, truncated to a
342
+ /// UTF-8 boundary (streamed; the full file is ~12 GB).
343
+ fn load_owt_prefix(max_bytes: usize) -> Vec<u8> {
344
+ let path = std::env::home_dir().unwrap().join("data/owt_train.txt");
345
+ let f = std::fs::File::open(&path).expect("Could not open ~/data/owt_train.txt");
346
+ let mut buf = Vec::with_capacity(max_bytes);
347
+ f.take(max_bytes as u64).read_to_end(&mut buf).unwrap();
348
+ while !buf.is_empty() && std::str::from_utf8(&buf).is_err() {
349
+ buf.pop();
350
+ }
351
+ buf
352
+ }
353
+
354
+ #[test]
355
+ fn qwen35_small_cases() {
356
+ let cases = [
357
+ "hello",
358
+ " hello",
359
+ "hello world",
360
+ " hello",
361
+ " hello",
362
+ "\thello",
363
+ "\t\thello",
364
+ "\nhello",
365
+ "\n\nhello",
366
+ "\n\n hello",
367
+ "!hello",
368
+ "!!hello",
369
+ "?!x",
370
+ "don't",
371
+ "DON'T",
372
+ "they'LL go",
373
+ "it'S he'Ll",
374
+ "we'Ve THEY'RE",
375
+ "'sound",
376
+ "'lx",
377
+ "'hello",
378
+ " 'hello",
379
+ " 's",
380
+ "x'0",
381
+ "123",
382
+ "1234",
383
+ "1234567",
384
+ " 123",
385
+ " 123",
386
+ "3rd",
387
+ "abc1234def",
388
+ "hello, world!",
389
+ "hi!\n\ndef",
390
+ "hi !!\n\ndef",
391
+ " !!!",
392
+ "a-b",
393
+ "a - b",
394
+ "...",
395
+ "hello\n",
396
+ "hello \n",
397
+ "hello \nx",
398
+ "hello\n x",
399
+ "hello \n\n ",
400
+ "x \n\n ",
401
+ "x ",
402
+ "x \t",
403
+ " \n hello",
404
+ "\r\nhello",
405
+ "a\r\n",
406
+ "a\r\n ",
407
+ "a\n \n",
408
+ "a \n \t",
409
+ "\n\n",
410
+ "\n\n\t",
411
+ " ",
412
+ " ",
413
+ "",
414
+ "café",
415
+ " café",
416
+ "\u{a0}word",
417
+ "voilà ¡hola!",
418
+ "١٢٣٤٥",
419
+ "1٢3x",
420
+ "tab\tsep\tvals",
421
+ "\x0bword",
422
+ "a\u{2028}b",
423
+ "a\u{2028}\n",
424
+ "price: $5.99!",
425
+ "'ſ",
426
+ "it'ſ fine",
427
+ "日本語のテキスト",
428
+ " 日本語",
429
+ // Mark-specific cases: `[\p{L}\p{M}]+` runs and marks excluded
430
+ // from punctuation runs.
431
+ "e\u{301}f",
432
+ "cafe\u{301} de\u{301}composed",
433
+ "\u{301}leading mark",
434
+ "\u{301}\u{301}two marks",
435
+ " \u{301}abc",
436
+ "\t\u{301}abc",
437
+ "!\u{301}",
438
+ "!\u{301}!",
439
+ "!!\u{301}x",
440
+ "1\u{301}2",
441
+ "'\u{301}s",
442
+ "देवनागरी में परीक्षण",
443
+ "अंग्रेज़ी",
444
+ "టెస్ట్ తెలుగు",
445
+ "עִבְרִית נִקּוּד",
446
+ "الْعَرَبِيَّة",
447
+ "a\u{20dd}b",
448
+ " \u{20dd}",
449
+ "\u{200b}\u{301}x",
450
+ ];
451
+ for case in cases {
452
+ assert_eq!(
453
+ fast_tokens(case),
454
+ regex_tokens(case),
455
+ "Mismatch on case {case:?}"
456
+ );
457
+ }
458
+ }
459
+
460
+ /// Random codepoint soup drawn from classes the scheme distinguishes,
461
+ /// compared against the reference regex.
462
+ #[test]
463
+ fn qwen35_matches_regex_random() {
464
+ use rand::prelude::*;
465
+ let pools: &[&[char]] = &[
466
+ &['a', 'Z', 'é', 'ß', 'Ж', 'ا', '한', '日'], // letters
467
+ &['1', '9', '٢', '½', 'Ⅷ', '๕'], // numbers
468
+ &[' ', '\t', '\n', '\r', '\u{a0}', '\u{2028}'], // whitespace
469
+ &['\u{301}', '\u{5bf}', '\u{93b}', '\u{20dd}'], // marks
470
+ &['.', ',', '!', '$', '\'', '«', '¡', '€', '☃'], // punct/symbols
471
+ &['\u{0}', '\u{ad}', '\u{200b}', '\u{e0001}'], // other (C*)
472
+ ];
473
+ let mut rng = StdRng::seed_from_u64(0x93E3_5EEC);
474
+ for round in 0..2000 {
475
+ let len = rng.random_range(1..40);
476
+ let s: String = (0..len)
477
+ .map(|_| {
478
+ let pool = pools.choose(&mut rng).unwrap();
479
+ *pool.choose(&mut rng).unwrap()
480
+ })
481
+ .collect();
482
+ assert_eq!(
483
+ fast_tokens(&s),
484
+ regex_tokens(&s),
485
+ "Mismatch on round {round}, case {s:?}"
486
+ );
487
+ }
488
+ }
489
+
490
+ #[test]
491
+ fn qwen35_matches_regex_owt() {
492
+ const SIZE: usize = 5_000_000;
493
+ let input = load_owt_prefix(SIZE);
494
+ let text = std::str::from_utf8(&input).unwrap();
495
+ eprintln!(
496
+ "Testing qwen3.5 fast pretokenizer vs regex on {:.1} MB of OWT",
497
+ input.len() as f64 / 1e6
498
+ );
499
+
500
+ let re = fancy_regex::Regex::new(QWEN35_REF_REGEX).unwrap();
501
+ let mut fast_iter = FastQwen35Pretokenizer::new(&input);
502
+ let mut re_iter = re.find_iter(text);
503
+ let mut token_idx: usize = 0;
504
+ let mut recent: Vec<(String, String)> = Vec::new();
505
+
506
+ loop {
507
+ match (fast_iter.next(), re_iter.next()) {
508
+ (Some(fast_tok), Some(re_match)) => {
509
+ let re_match = re_match.expect("regex match error");
510
+ let fast_str = String::from_utf8_lossy(fast_tok.0);
511
+ let re_str = &text[re_match.start()..re_match.end()];
512
+ recent.push((fast_str.to_string(), re_str.to_string()));
513
+ if recent.len() > 10 {
514
+ recent.remove(0);
515
+ }
516
+ assert_eq!(
517
+ fast_str, re_str,
518
+ "Mismatch at token {token_idx} (byte ~{}).\n fast: {:?}\n regex: {:?}\n recent tokens: {:?}",
519
+ re_match.start(), fast_str, re_str, recent
520
+ );
521
+ }
522
+ (None, None) => break,
523
+ (Some(fast_tok), None) => panic!(
524
+ "Fast produced extra token at index {token_idx}: {:?}\n recent: {:?}",
525
+ String::from_utf8_lossy(fast_tok.0),
526
+ recent
527
+ ),
528
+ (None, Some(re_match)) => {
529
+ let re_match = re_match.expect("regex match error");
530
+ panic!(
531
+ "Regex produced extra token at index {token_idx}: {:?}\n recent: {:?}",
532
+ &text[re_match.start()..re_match.end()],
533
+ recent
534
+ );
535
+ }
536
+ }
537
+ token_idx += 1;
538
+ }
539
+ eprintln!("All {token_idx} tokens match.");
540
+ }
541
+ }