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,546 @@
1
+ use icu::properties::props::{EnumeratedProperty, GeneralCategory, GeneralCategoryGroup, WhiteSpace};
2
+ use icu::properties::CodePointSetData;
3
+
4
+ #[inline]
5
+ pub(crate) fn get_general_category(c: char) -> GeneralCategory {
6
+ GeneralCategory::for_char(c)
7
+ }
8
+
9
+ #[inline]
10
+ pub(crate) fn is_gc_letter(gc: GeneralCategory) -> bool {
11
+ GeneralCategoryGroup::Letter.contains(gc)
12
+ }
13
+
14
+ #[inline]
15
+ pub(crate) fn is_gc_number(gc: GeneralCategory) -> bool {
16
+ GeneralCategoryGroup::Number.contains(gc)
17
+ }
18
+
19
+ /// Unicode White_Space property — matches the same characters as `\s` in regex.
20
+ /// This includes GeneralCategory::Separator (Zs/Zl/Zp) PLUS control characters
21
+ /// like U+0009 (TAB), U+000A (LF), U+000D (CR), U+0085 (NEL), etc.
22
+ #[inline]
23
+ pub(crate) fn is_whitespace(c: char) -> bool {
24
+ // The set is a static compiled-data lookup, but cache the borrowed handle
25
+ // to avoid repeated constructor overhead.
26
+ static WS: std::sync::LazyLock<icu::properties::CodePointSetDataBorrowed<'static>> =
27
+ std::sync::LazyLock::new(CodePointSetData::new::<WhiteSpace>);
28
+ WS.contains(c)
29
+ }
30
+
31
+ #[inline]
32
+ pub(crate) fn is_letter(c: char) -> bool {
33
+ is_gc_letter(get_general_category(c))
34
+ }
35
+
36
+ #[inline]
37
+ pub(crate) fn is_number(c: char) -> bool {
38
+ is_gc_number(get_general_category(c))
39
+ }
40
+
41
+ #[inline]
42
+ pub(crate) fn is_other_complete(c: char) -> bool {
43
+ if c.is_ascii() {
44
+ return !c.is_ascii_alphanumeric() && !c.is_ascii_whitespace();
45
+ }
46
+ let gc = get_general_category(c);
47
+ !is_gc_letter(gc) && !is_gc_number(gc) && !is_whitespace(c)
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Packed codepoint → class table (hot-path classification)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /// Character class as used by the pretokenization regexes: `\p{L}`, `\p{N}`,
55
+ /// `\s` (White_Space), and everything else.
56
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57
+ #[repr(u8)]
58
+ pub(crate) enum CharClass {
59
+ Letter = 0,
60
+ Number = 1,
61
+ Whitespace = 2,
62
+ Other = 3,
63
+ }
64
+
65
+ /// 2-bit class per codepoint, 4 codepoints per byte (~272 KiB total).
66
+ /// A single L1 load replaces the ICU GeneralCategory trie walk plus the
67
+ /// White_Space set binary search that the `is_*` predicates above pay per
68
+ /// call. Only the cache lines for scripts actually present in the input
69
+ /// stay resident.
70
+ static CLASS_TABLE: std::sync::LazyLock<Box<[u8]>> =
71
+ std::sync::LazyLock::new(build_class_table);
72
+
73
+ fn build_class_table() -> Box<[u8]> {
74
+ use icu::properties::CodePointMapData;
75
+ const N: usize = 0x110000;
76
+ let mut classes = vec![CharClass::Other as u8; N];
77
+ let gc = CodePointMapData::<GeneralCategory>::new();
78
+ for (group, class) in [
79
+ (GeneralCategoryGroup::Letter, CharClass::Letter),
80
+ (GeneralCategoryGroup::Number, CharClass::Number),
81
+ ] {
82
+ for range in gc.iter_ranges_for_group(group) {
83
+ classes[*range.start() as usize..=*range.end() as usize].fill(class as u8);
84
+ }
85
+ }
86
+ // White_Space is disjoint from GC Letter/Number, so fill order is moot.
87
+ for range in CodePointSetData::new::<WhiteSpace>().iter_ranges() {
88
+ classes[*range.start() as usize..=*range.end() as usize].fill(CharClass::Whitespace as u8);
89
+ }
90
+ classes
91
+ .as_chunks::<4>().0.iter()
92
+ .map(|c| c[0] | (c[1] << 2) | (c[2] << 4) | (c[3] << 6))
93
+ .collect()
94
+ }
95
+
96
+ /// Pre-resolved handle to the packed class table. The static is a
97
+ /// `LazyLock<Box<[u8]>>`, so every bare [`class_of`] call pays the
98
+ /// lazy-init state check plus a dependent load of the Box pointer before
99
+ /// the table load itself; per-char classify loops resolve the handle once
100
+ /// and index the slice directly.
101
+ #[derive(Clone, Copy)]
102
+ pub(crate) struct ClassTable(&'static [u8]);
103
+
104
+ impl ClassTable {
105
+ #[inline]
106
+ pub(crate) fn get() -> Self {
107
+ Self(&CLASS_TABLE)
108
+ }
109
+
110
+ /// [`class_of`] without the per-call static resolution. `cp` must be
111
+ /// a valid scalar value (guaranteed when decoded from valid UTF-8).
112
+ #[inline(always)]
113
+ pub(crate) fn class_of(self, cp: u32) -> CharClass {
114
+ debug_assert!(cp < 0x110000);
115
+ // SAFETY: `self.0` is CLASS_TABLE (the only constructor), sized
116
+ // 0x110000 / 4; cp >> 2 is in range for any scalar value.
117
+ let byte = unsafe { *self.0.get_unchecked((cp >> 2) as usize) };
118
+ match (byte >> ((cp & 3) << 1)) & 3 {
119
+ 0 => CharClass::Letter,
120
+ 1 => CharClass::Number,
121
+ 2 => CharClass::Whitespace,
122
+ _ => CharClass::Other,
123
+ }
124
+ }
125
+ }
126
+
127
+ /// Classify a codepoint with one table load. `cp` must be a valid scalar
128
+ /// value (guaranteed when decoded from valid UTF-8).
129
+ #[inline(always)]
130
+ pub(crate) fn class_of(cp: u32) -> CharClass {
131
+ ClassTable::get().class_of(cp)
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // DeepSeek character classes (finer split of `Other`)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /// Character class as used by the DeepSeek V3 main regex, which additionally
139
+ /// distinguishes `\p{M}` (joins letter runs) and `\p{P}`/`\p{S}` (punctuation
140
+ /// runs) from the remaining `Other` codepoints (controls, format chars,
141
+ /// unassigned), which the regex leaves unmatched.
142
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
143
+ #[repr(u8)]
144
+ pub(crate) enum DsCharClass {
145
+ Letter = 0,
146
+ Number = 1,
147
+ Whitespace = 2,
148
+ Mark = 3,
149
+ PunctSym = 4,
150
+ Other = 5,
151
+ }
152
+
153
+ /// Four-class view for schemes whose regex joins `\p{M}` into letter
154
+ /// runs and excludes it from punctuation runs (Qwen3.5's
155
+ /// `[\p{L}\p{M}]+` / `[^\s\p{L}\p{M}\p{N}]+`): marks classify as
156
+ /// letters, everything else as in [`class_of`].
157
+ #[inline(always)]
158
+ pub(crate) fn class_of_marks_join(cp: u32) -> CharClass {
159
+ DsClassTable::get().class_of_marks_join(cp)
160
+ }
161
+
162
+ /// 4-bit class per codepoint, 2 codepoints per byte (~544 KiB total).
163
+ static DS_CLASS_TABLE: std::sync::LazyLock<Box<[u8]>> =
164
+ std::sync::LazyLock::new(build_ds_class_table);
165
+
166
+ fn build_ds_class_table() -> Box<[u8]> {
167
+ use icu::properties::CodePointMapData;
168
+ const N: usize = 0x110000;
169
+ let mut classes = vec![DsCharClass::Other as u8; N];
170
+ let gc = CodePointMapData::<GeneralCategory>::new();
171
+ for (group, class) in [
172
+ (GeneralCategoryGroup::Letter, DsCharClass::Letter),
173
+ (GeneralCategoryGroup::Number, DsCharClass::Number),
174
+ (GeneralCategoryGroup::Mark, DsCharClass::Mark),
175
+ (GeneralCategoryGroup::Punctuation, DsCharClass::PunctSym),
176
+ (GeneralCategoryGroup::Symbol, DsCharClass::PunctSym),
177
+ ] {
178
+ for range in gc.iter_ranges_for_group(group) {
179
+ classes[*range.start() as usize..=*range.end() as usize].fill(class as u8);
180
+ }
181
+ }
182
+ // White_Space is disjoint from the groups above except Zs/Zl/Zp (which
183
+ // are in none of them), so fill order is moot.
184
+ for range in CodePointSetData::new::<WhiteSpace>().iter_ranges() {
185
+ classes[*range.start() as usize..=*range.end() as usize]
186
+ .fill(DsCharClass::Whitespace as u8);
187
+ }
188
+ classes
189
+ .as_chunks::<2>().0.iter()
190
+ .map(|c| c[0] | (c[1] << 4))
191
+ .collect()
192
+ }
193
+
194
+ /// Pre-resolved handle to the packed DeepSeek class table — same
195
+ /// LazyLock-hoist rationale as [`ClassTable`].
196
+ #[derive(Clone, Copy)]
197
+ pub(crate) struct DsClassTable(&'static [u8]);
198
+
199
+ impl DsClassTable {
200
+ #[inline]
201
+ pub(crate) fn get() -> Self {
202
+ Self(&DS_CLASS_TABLE)
203
+ }
204
+
205
+ /// [`ds_class_of`] without the per-call static resolution. `cp` must
206
+ /// be a valid scalar value (guaranteed when decoded from valid UTF-8).
207
+ #[inline(always)]
208
+ pub(crate) fn ds_class_of(self, cp: u32) -> DsCharClass {
209
+ debug_assert!(cp < 0x110000);
210
+ // SAFETY: `self.0` is DS_CLASS_TABLE (the only constructor), sized
211
+ // 0x110000 / 2; cp >> 1 is in range for any scalar value.
212
+ let byte = unsafe { *self.0.get_unchecked((cp >> 1) as usize) };
213
+ match (byte >> ((cp & 1) << 2)) & 0xF {
214
+ 0 => DsCharClass::Letter,
215
+ 1 => DsCharClass::Number,
216
+ 2 => DsCharClass::Whitespace,
217
+ 3 => DsCharClass::Mark,
218
+ 4 => DsCharClass::PunctSym,
219
+ _ => DsCharClass::Other,
220
+ }
221
+ }
222
+
223
+ /// [`class_of_marks_join`] without the per-call static resolution.
224
+ #[inline(always)]
225
+ pub(crate) fn class_of_marks_join(self, cp: u32) -> CharClass {
226
+ match self.ds_class_of(cp) {
227
+ DsCharClass::Letter | DsCharClass::Mark => CharClass::Letter,
228
+ DsCharClass::Number => CharClass::Number,
229
+ DsCharClass::Whitespace => CharClass::Whitespace,
230
+ DsCharClass::PunctSym | DsCharClass::Other => CharClass::Other,
231
+ }
232
+ }
233
+ }
234
+
235
+ /// Classify a codepoint for the DeepSeek scheme with one table load. `cp`
236
+ /// must be a valid scalar value (guaranteed when decoded from valid UTF-8).
237
+ #[inline(always)]
238
+ pub(crate) fn ds_class_of(cp: u32) -> DsCharClass {
239
+ DsClassTable::get().ds_class_of(cp)
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // o200k character classes (case-aware split of Letter)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /// Character class as used by the o200k regex family (gpt-oss, Nemotron-3),
247
+ /// whose letter runs are case-structured:
248
+ /// `[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}]*[\p{Ll}\p{Lm}\p{Lo}\p{M}]+` etc.
249
+ /// `Upper` is Lu|Lt (the strict-uppercase classes that appear only in the
250
+ /// first bracket), `Lower` is Ll (only in the second), and `Caseless` is
251
+ /// Lm|Lo (in both). Marks (`\p{M}`) are their own class: they join letter
252
+ /// runs like `Caseless` but, being outside `\p{L}`, also continue
253
+ /// `[^\s\p{L}\p{N}]+` punctuation runs.
254
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
255
+ #[repr(u8)]
256
+ pub(crate) enum O200kCharClass {
257
+ Upper = 0,
258
+ Lower = 1,
259
+ Caseless = 2,
260
+ Mark = 3,
261
+ Number = 4,
262
+ Whitespace = 5,
263
+ Other = 6,
264
+ }
265
+
266
+ /// 4-bit class per codepoint, 2 codepoints per byte (~544 KiB total).
267
+ static O200K_CLASS_TABLE: std::sync::LazyLock<Box<[u8]>> =
268
+ std::sync::LazyLock::new(build_o200k_class_table);
269
+
270
+ fn build_o200k_class_table() -> Box<[u8]> {
271
+ pack_nibbles(&o200k_classes_unpacked())
272
+ }
273
+
274
+ /// One `O200kCharClass` byte per codepoint (the unpacked form both the
275
+ /// o200k and Kimi table builders start from).
276
+ fn o200k_classes_unpacked() -> Vec<u8> {
277
+ use icu::properties::CodePointMapData;
278
+ const N: usize = 0x110000;
279
+ let mut classes = vec![O200kCharClass::Other as u8; N];
280
+ let gc = CodePointMapData::<GeneralCategory>::new();
281
+ for (category, class) in [
282
+ (GeneralCategory::UppercaseLetter, O200kCharClass::Upper),
283
+ (GeneralCategory::TitlecaseLetter, O200kCharClass::Upper),
284
+ (GeneralCategory::LowercaseLetter, O200kCharClass::Lower),
285
+ (GeneralCategory::ModifierLetter, O200kCharClass::Caseless),
286
+ (GeneralCategory::OtherLetter, O200kCharClass::Caseless),
287
+ ] {
288
+ for range in gc.iter_ranges_for_value(category) {
289
+ classes[*range.start() as usize..=*range.end() as usize].fill(class as u8);
290
+ }
291
+ }
292
+ for (group, class) in [
293
+ (GeneralCategoryGroup::Mark, O200kCharClass::Mark),
294
+ (GeneralCategoryGroup::Number, O200kCharClass::Number),
295
+ ] {
296
+ for range in gc.iter_ranges_for_group(group) {
297
+ classes[*range.start() as usize..=*range.end() as usize].fill(class as u8);
298
+ }
299
+ }
300
+ // White_Space is disjoint from Letter/Mark/Number, so fill order is moot.
301
+ for range in CodePointSetData::new::<WhiteSpace>().iter_ranges() {
302
+ classes[*range.start() as usize..=*range.end() as usize]
303
+ .fill(O200kCharClass::Whitespace as u8);
304
+ }
305
+ classes
306
+ }
307
+
308
+ /// Pack one-byte-per-codepoint classes into 4-bit nibbles, 2 per byte.
309
+ fn pack_nibbles(classes: &[u8]) -> Box<[u8]> {
310
+ classes
311
+ .as_chunks::<2>().0.iter()
312
+ .map(|c| c[0] | (c[1] << 4))
313
+ .collect()
314
+ }
315
+
316
+ /// Classify a codepoint for the o200k scheme family with one table load.
317
+ /// `cp` must be a valid scalar value (guaranteed when decoded from valid
318
+ /// UTF-8).
319
+ #[inline(always)]
320
+ pub(crate) fn o200k_class_of(cp: u32) -> O200kCharClass {
321
+ debug_assert!(cp < 0x110000);
322
+ let byte = unsafe { *O200K_CLASS_TABLE.get_unchecked((cp >> 1) as usize) };
323
+ match (byte >> ((cp & 1) << 2)) & 0xF {
324
+ 0 => O200kCharClass::Upper,
325
+ 1 => O200kCharClass::Lower,
326
+ 2 => O200kCharClass::Caseless,
327
+ 3 => O200kCharClass::Mark,
328
+ 4 => O200kCharClass::Number,
329
+ 5 => O200kCharClass::Whitespace,
330
+ _ => O200kCharClass::Other,
331
+ }
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Kimi character classes (o200k classes with Script=Han split out)
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /// Character class for the Kimi (moonshotai K2 family) regex: the o200k
339
+ /// classes with `\p{Han}` split out. The pattern gives Han runs their own
340
+ /// leading alternative (`[\p{Han}]+`) and excludes Han from both letter
341
+ /// brackets (`[...&&[^\p{Han}]]`), but the general-category rules are
342
+ /// otherwise Han-blind: `\p{N}{1,3}` still counts a Han numeral and
343
+ /// `[^\s\p{L}\p{N}]+` still spans a Han symbol mid-run. Each Han variant
344
+ /// therefore records which base class the char behaves as outside a Han
345
+ /// run ([`Self::base`]).
346
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
347
+ #[repr(u8)]
348
+ pub(crate) enum KimiCharClass {
349
+ Upper = 0,
350
+ Lower = 1,
351
+ Caseless = 2,
352
+ Mark = 3,
353
+ Number = 4,
354
+ Whitespace = 5,
355
+ Other = 6,
356
+ /// Han letters (Lo, plus Lm like U+3005 々): the bulk of `\p{Han}`.
357
+ /// Never letter-run members; a maximal run of Han-class chars starting
358
+ /// a token is one `[\p{Han}]+` token.
359
+ Han = 7,
360
+ /// Han numerals (Nl: U+3007 〇, Suzhou numerals): `\p{N}` mid-number,
361
+ /// Han-run members otherwise.
362
+ HanNumber = 8,
363
+ /// Han symbols and marks (So: Kangxi radicals; Mc: U+16FF0/1 reading
364
+ /// marks, which `&&[^\p{Han}]` evicts from the letter brackets):
365
+ /// punct-run members mid-run, Han-run members at a token start.
366
+ HanOther = 9,
367
+ }
368
+
369
+ impl KimiCharClass {
370
+ /// The o200k class the char behaves as in non-Han-run contexts.
371
+ #[inline(always)]
372
+ pub(crate) fn base(self) -> O200kCharClass {
373
+ match self {
374
+ KimiCharClass::Upper => O200kCharClass::Upper,
375
+ KimiCharClass::Lower => O200kCharClass::Lower,
376
+ KimiCharClass::Caseless | KimiCharClass::Han => O200kCharClass::Caseless,
377
+ KimiCharClass::Mark => O200kCharClass::Mark,
378
+ KimiCharClass::Number | KimiCharClass::HanNumber => O200kCharClass::Number,
379
+ KimiCharClass::Whitespace => O200kCharClass::Whitespace,
380
+ KimiCharClass::Other | KimiCharClass::HanOther => O200kCharClass::Other,
381
+ }
382
+ }
383
+
384
+ /// Is the char in `\p{Han}` (a `[\p{Han}]+` run member)?
385
+ #[inline(always)]
386
+ pub(crate) fn is_han(self) -> bool {
387
+ self as u8 >= KimiCharClass::Han as u8
388
+ }
389
+ }
390
+
391
+ /// 4-bit class per codepoint, 2 codepoints per byte (~544 KiB total).
392
+ static KIMI_CLASS_TABLE: std::sync::LazyLock<Box<[u8]>> =
393
+ std::sync::LazyLock::new(build_kimi_class_table);
394
+
395
+ fn build_kimi_class_table() -> Box<[u8]> {
396
+ use icu::properties::CodePointMapData;
397
+ use icu::properties::props::Script;
398
+ let mut classes = o200k_classes_unpacked();
399
+ let script = CodePointMapData::<Script>::new();
400
+ for range in script.iter_ranges_for_value(Script::Han) {
401
+ for cp in *range.start()..=*range.end() {
402
+ let slot = &mut classes[cp as usize];
403
+ *slot = match *slot {
404
+ c if c == O200kCharClass::Number as u8 => KimiCharClass::HanNumber as u8,
405
+ // Marks land in HanOther: `&&[^\p{Han}]` evicts them from
406
+ // the letter brackets, leaving only their punct-run role.
407
+ c if c == O200kCharClass::Other as u8 || c == O200kCharClass::Mark as u8 => {
408
+ KimiCharClass::HanOther as u8
409
+ }
410
+ // Letters (Lo/Lm); no Han char is Lu/Lt/Ll/Whitespace.
411
+ _ => KimiCharClass::Han as u8,
412
+ };
413
+ }
414
+ }
415
+ pack_nibbles(&classes)
416
+ }
417
+
418
+ /// Classify a codepoint for the Kimi scheme with one table load. `cp` must
419
+ /// be a valid scalar value (guaranteed when decoded from valid UTF-8).
420
+ #[inline(always)]
421
+ pub(crate) fn kimi_class_of(cp: u32) -> KimiCharClass {
422
+ debug_assert!(cp < 0x110000);
423
+ let byte = unsafe { *KIMI_CLASS_TABLE.get_unchecked((cp >> 1) as usize) };
424
+ match (byte >> ((cp & 1) << 2)) & 0xF {
425
+ 0 => KimiCharClass::Upper,
426
+ 1 => KimiCharClass::Lower,
427
+ 2 => KimiCharClass::Caseless,
428
+ 3 => KimiCharClass::Mark,
429
+ 4 => KimiCharClass::Number,
430
+ 5 => KimiCharClass::Whitespace,
431
+ 6 => KimiCharClass::Other,
432
+ 7 => KimiCharClass::Han,
433
+ 8 => KimiCharClass::HanNumber,
434
+ _ => KimiCharClass::HanOther,
435
+ }
436
+ }
437
+
438
+ /// The CJK ranges isolated by the DeepSeek pretokenizer's second Split:
439
+ /// `[\u{4E00}-\u{9FA5}\u{3040}-\u{309F}\u{30A0}-\u{30FF}]` (CJK unified
440
+ /// ideographs, hiragana, katakana — the two kana blocks are contiguous).
441
+ #[inline(always)]
442
+ pub(crate) fn is_deepseek_cjk(cp: u32) -> bool {
443
+ (0x4E00..=0x9FA5).contains(&cp) || (0x3040..=0x30FF).contains(&cp)
444
+ }
445
+
446
+ #[cfg(test)]
447
+ mod tests {
448
+ use super::*;
449
+
450
+ /// The packed table must agree with the ICU predicates for every scalar.
451
+ #[test]
452
+ fn class_table_matches_icu() {
453
+ for cp in 0..=char::MAX as u32 {
454
+ let Some(c) = char::from_u32(cp) else { continue };
455
+ let expected = if is_letter(c) {
456
+ CharClass::Letter
457
+ } else if is_number(c) {
458
+ CharClass::Number
459
+ } else if is_whitespace(c) {
460
+ CharClass::Whitespace
461
+ } else {
462
+ CharClass::Other
463
+ };
464
+ assert_eq!(class_of(cp), expected, "mismatch at U+{cp:04X}");
465
+ }
466
+ }
467
+
468
+ /// The o200k table must agree with ICU for every scalar.
469
+ #[test]
470
+ fn o200k_class_table_matches_icu() {
471
+ for cp in 0..=char::MAX as u32 {
472
+ let Some(c) = char::from_u32(cp) else { continue };
473
+ let gc = get_general_category(c);
474
+ let expected = if matches!(
475
+ gc,
476
+ GeneralCategory::UppercaseLetter | GeneralCategory::TitlecaseLetter
477
+ ) {
478
+ O200kCharClass::Upper
479
+ } else if gc == GeneralCategory::LowercaseLetter {
480
+ O200kCharClass::Lower
481
+ } else if is_gc_letter(gc) {
482
+ O200kCharClass::Caseless
483
+ } else if GeneralCategoryGroup::Mark.contains(gc) {
484
+ O200kCharClass::Mark
485
+ } else if is_gc_number(gc) {
486
+ O200kCharClass::Number
487
+ } else if is_whitespace(c) {
488
+ O200kCharClass::Whitespace
489
+ } else {
490
+ O200kCharClass::Other
491
+ };
492
+ assert_eq!(o200k_class_of(cp), expected, "mismatch at U+{cp:04X}");
493
+ }
494
+ }
495
+
496
+ /// The Kimi table must refine the o200k table: identical off `\p{Han}`,
497
+ /// and on it a Han variant whose base is the o200k class.
498
+ #[test]
499
+ fn kimi_class_table_refines_o200k() {
500
+ use icu::properties::CodePointMapData;
501
+ use icu::properties::props::Script;
502
+ let script = CodePointMapData::<Script>::new();
503
+ for cp in 0..=char::MAX as u32 {
504
+ let Some(c) = char::from_u32(cp) else { continue };
505
+ let k = kimi_class_of(cp);
506
+ // Han marks base as Other (evicted from the letter brackets, so
507
+ // only their punct-run role remains); all else keeps its class.
508
+ let expected_base = match o200k_class_of(cp) {
509
+ O200kCharClass::Mark if k.is_han() => O200kCharClass::Other,
510
+ c => c,
511
+ };
512
+ assert_eq!(k.base(), expected_base, "base mismatch at U+{cp:04X}");
513
+ assert_eq!(
514
+ k.is_han(),
515
+ script.get(c) == Script::Han,
516
+ "Han mismatch at U+{cp:04X}"
517
+ );
518
+ }
519
+ }
520
+
521
+ /// The DeepSeek table must agree with ICU for every scalar, and refine
522
+ /// `class_of` (identical on Letter/Number/Whitespace).
523
+ #[test]
524
+ fn ds_class_table_matches_icu() {
525
+ for cp in 0..=char::MAX as u32 {
526
+ let Some(c) = char::from_u32(cp) else { continue };
527
+ let gc = get_general_category(c);
528
+ let expected = if is_gc_letter(gc) {
529
+ DsCharClass::Letter
530
+ } else if is_gc_number(gc) {
531
+ DsCharClass::Number
532
+ } else if is_whitespace(c) {
533
+ DsCharClass::Whitespace
534
+ } else if GeneralCategoryGroup::Mark.contains(gc) {
535
+ DsCharClass::Mark
536
+ } else if GeneralCategoryGroup::Punctuation.contains(gc)
537
+ || GeneralCategoryGroup::Symbol.contains(gc)
538
+ {
539
+ DsCharClass::PunctSym
540
+ } else {
541
+ DsCharClass::Other
542
+ };
543
+ assert_eq!(ds_class_of(cp), expected, "mismatch at U+{cp:04X}");
544
+ }
545
+ }
546
+ }
data/src/test_hub.rs ADDED
@@ -0,0 +1,28 @@
1
+ //! Test-only lookup of HuggingFace-cached tokenizer files.
2
+ //!
3
+ //! Thin wrappers over `load_tokenizer::hub`'s pure-filesystem cache
4
+ //! resolution. Nothing is ever downloaded: tests skip when a file is absent,
5
+ //! except GPT-2, which falls back to the committed copy in tests/fixtures so
6
+ //! the core suite passes on a machine with no HF cache at all.
7
+
8
+ use std::path::PathBuf;
9
+
10
+ /// `filename` from a model repo's `main` snapshot in the local HF cache,
11
+ /// or None when the repo, ref, or file is not cached.
12
+ pub(crate) fn cached_hub_file(repo_id: &str, filename: &str) -> Option<PathBuf> {
13
+ crate::load_tokenizer::hub::cached_hub_file(repo_id, filename, "main")
14
+ }
15
+
16
+ /// A model repo's tokenizer.json from the local HF cache.
17
+ pub(crate) fn hf_tokenizer_json(repo_id: &str) -> Option<PathBuf> {
18
+ cached_hub_file(repo_id, "tokenizer.json")
19
+ }
20
+
21
+ /// GPT-2's tokenizer.json: the HF cache copy when present, else the
22
+ /// committed fixture (same vocab/merges/config; the fixture is a verbatim
23
+ /// copy of the openai-community/gpt2 file).
24
+ pub(crate) fn gpt2_tokenizer_json() -> PathBuf {
25
+ hf_tokenizer_json("openai-community/gpt2").unwrap_or_else(|| {
26
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gpt2_tokenizer.json")
27
+ })
28
+ }
data/src/token.rs ADDED
@@ -0,0 +1,42 @@
1
+ use std::fmt::Formatter;
2
+
3
+ /// Supports at most a vocab size of 2^32
4
+ #[derive(Copy, Clone, Hash, PartialEq, Eq, Ord, PartialOrd)]
5
+ #[repr(transparent)]
6
+ pub struct TokenId(pub u32);
7
+
8
+ impl From<u32> for TokenId {
9
+ fn from(value: u32) -> Self {
10
+ TokenId(value)
11
+ }
12
+ }
13
+
14
+ impl From<usize> for TokenId {
15
+ fn from(value: usize) -> Self {
16
+ TokenId(value as u32)
17
+ }
18
+ }
19
+
20
+ impl From<i32> for TokenId {
21
+ fn from(value: i32) -> Self {
22
+ TokenId(value as u32)
23
+ }
24
+ }
25
+
26
+ impl From<TokenId> for u32 {
27
+ fn from(val: TokenId) -> Self {
28
+ val.0
29
+ }
30
+ }
31
+
32
+ impl From<TokenId> for usize {
33
+ fn from(val: TokenId) -> Self {
34
+ val.0 as usize
35
+ }
36
+ }
37
+
38
+ impl std::fmt::Debug for TokenId {
39
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
40
+ f.write_fmt(format_args!("<{}>", self.0))
41
+ }
42
+ }