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,396 @@
1
+ //! Python<->Rust bridging shared by the encode/decode bindings: document
2
+ //! and token-id extraction from Python objects, vocab/merges conversion the
3
+ //! other way, and the shared front-ends that resolve an encode_batch input
4
+ //! and hand the ragged result back to Python.
5
+
6
+ use crate::input::file_source::DocFormat;
7
+ use crate::token::TokenId;
8
+ use numpy::{IntoPyArray, PyArray1, PyArrayMethods, PyReadonlyArray1};
9
+ use pyo3::prelude::*;
10
+ use pyo3::pybacked::{PyBackedBytes, PyBackedStr};
11
+ use pyo3::types::{IntoPyDict, PyBytes, PyDict, PyList};
12
+ use std::path::PathBuf;
13
+
14
+ /// A document to encode: str (UTF-8 text) or bytes. Both variants borrow
15
+ /// the Python object's buffer without copying and are usable with the GIL
16
+ /// released. Paths are deliberately not accepted here — encoding from files
17
+ /// goes through `encode_files`, which mmaps and chunks them.
18
+ pub(crate) enum EncodeInput {
19
+ Text(PyBackedStr),
20
+ Bytes(PyBackedBytes),
21
+ }
22
+
23
+ impl EncodeInput {
24
+ pub(crate) fn as_bytes(&self) -> &[u8] {
25
+ match self {
26
+ EncodeInput::Text(s) => s.as_bytes(),
27
+ EncodeInput::Bytes(b) => b,
28
+ }
29
+ }
30
+ }
31
+
32
+ /// Extract one document, pointing path-holders at encode_files.
33
+ pub(crate) fn extract_doc(obj: &Bound<'_, PyAny>) -> PyResult<EncodeInput> {
34
+ if let Ok(s) = obj.extract::<PyBackedStr>() {
35
+ return Ok(EncodeInput::Text(s));
36
+ }
37
+ if let Ok(b) = obj.extract::<PyBackedBytes>() {
38
+ return Ok(EncodeInput::Bytes(b));
39
+ }
40
+ let hint = if obj.extract::<PathBuf>().is_ok() {
41
+ "; to encode files, use encode_files"
42
+ } else {
43
+ ""
44
+ };
45
+ Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
46
+ "expected str or bytes, got {}{hint}",
47
+ obj.get_type()
48
+ )))
49
+ }
50
+
51
+ /// decode() input resolved to a `TokenId` slice. A numpy uint32 array is
52
+ /// borrowed in place (`TokenId` is repr(transparent) over u32); other
53
+ /// integer dtypes are converted in one checked pass; anything else falls
54
+ /// back to generic per-element sequence extraction.
55
+ pub(crate) enum TokenIds<'py> {
56
+ Borrowed(PyReadonlyArray1<'py, u32>),
57
+ Owned(Vec<TokenId>),
58
+ }
59
+
60
+ impl TokenIds<'_> {
61
+ pub(crate) fn as_slice(&self) -> PyResult<&[TokenId]> {
62
+ match self {
63
+ TokenIds::Borrowed(arr) => {
64
+ let ids: &[u32] = arr.as_slice()?;
65
+ // SAFETY: TokenId is #[repr(transparent)] over u32.
66
+ Ok(unsafe { std::slice::from_raw_parts(ids.as_ptr().cast(), ids.len()) })
67
+ }
68
+ TokenIds::Owned(ids) => Ok(ids),
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Convert a non-u32 integer numpy array in one pass, rejecting values that
74
+ /// do not fit a token ID.
75
+ fn cast_token_ids<T>(arr: PyReadonlyArray1<'_, T>) -> PyResult<Vec<TokenId>>
76
+ where
77
+ T: numpy::Element + Copy + TryInto<u32> + std::fmt::Display,
78
+ {
79
+ arr.as_array()
80
+ .iter()
81
+ .map(|&t| {
82
+ t.try_into().map(TokenId).map_err(|_| {
83
+ PyErr::new::<pyo3::exceptions::PyOverflowError, _>(format!(
84
+ "token id {t} does not fit in uint32"
85
+ ))
86
+ })
87
+ })
88
+ .collect()
89
+ }
90
+
91
+ /// Extract decode() input: a numpy integer array or any sequence of ints.
92
+ pub(crate) fn extract_token_ids<'py>(tokens: &Bound<'py, PyAny>) -> PyResult<TokenIds<'py>> {
93
+ if let Ok(arr) = tokens.cast::<PyArray1<u32>>() {
94
+ let arr = arr.readonly();
95
+ return Ok(if arr.as_slice().is_ok() {
96
+ TokenIds::Borrowed(arr)
97
+ } else {
98
+ // Non-contiguous (e.g. a strided view): gather instead.
99
+ TokenIds::Owned(arr.as_array().iter().map(|&t| t.into()).collect())
100
+ });
101
+ }
102
+ // Other integer dtypes (e.g. int64 model outputs) get a single
103
+ // vectorized pass instead of per-element Python iteration.
104
+ if let Ok(arr) = tokens.cast::<PyArray1<i64>>() {
105
+ return Ok(TokenIds::Owned(cast_token_ids(arr.readonly())?));
106
+ }
107
+ if let Ok(arr) = tokens.cast::<PyArray1<i32>>() {
108
+ return Ok(TokenIds::Owned(cast_token_ids(arr.readonly())?));
109
+ }
110
+ if let Ok(arr) = tokens.cast::<PyArray1<u64>>() {
111
+ return Ok(TokenIds::Owned(cast_token_ids(arr.readonly())?));
112
+ }
113
+ Ok(TokenIds::Owned(
114
+ tokens
115
+ .extract::<Vec<u32>>()?
116
+ .into_iter()
117
+ .map(Into::into)
118
+ .collect(),
119
+ ))
120
+ }
121
+
122
+ /// Build a `vocab` getter's dict from `(id, bytes)` entries.
123
+ pub(crate) fn vocab_to_pydict<'py, 'a>(
124
+ py: Python<'py>,
125
+ entries: impl Iterator<Item = (u32, &'a [u8])>,
126
+ ) -> PyResult<Bound<'py, PyDict>> {
127
+ entries
128
+ .map(|(id, bytes)| (id, PyBytes::new(py, bytes)))
129
+ .into_py_dict(py)
130
+ }
131
+
132
+ /// Build a `merges` getter's list from `(left, right)` byte pairs.
133
+ pub(crate) fn merges_to_pylist<'py>(
134
+ py: Python<'py>,
135
+ entries: Vec<(&[u8], &[u8])>,
136
+ ) -> Vec<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> {
137
+ entries
138
+ .into_iter()
139
+ .map(|(a, b)| (PyBytes::new(py, a), PyBytes::new(py, b)))
140
+ .collect()
141
+ }
142
+
143
+ /// Flat uint8 content array and per-document byte counts.
144
+ type FlatDocs<'py> = (
145
+ Bound<'py, numpy::PyArray1<u8>>,
146
+ Bound<'py, numpy::PyArray1<i64>>,
147
+ );
148
+
149
+ /// If `inputs` is an awkward Array of strings or bytestrings, pull out its
150
+ /// flat uint8 content and per-document counts directly — no per-document
151
+ /// Python objects are materialized. Returns None when `inputs` is not an
152
+ /// awkward Array (or awkward is not importable).
153
+ fn extract_awkward_docs<'py>(inputs: &Bound<'py, PyAny>) -> PyResult<Option<FlatDocs<'py>>> {
154
+ let py = inputs.py();
155
+ let Ok(ak) = py.import("awkward") else {
156
+ return Ok(None);
157
+ };
158
+ if !inputs.is_instance(&ak.getattr("Array")?)? {
159
+ return Ok(None);
160
+ }
161
+ let type_err = |_| {
162
+ PyErr::new::<pyo3::exceptions::PyTypeError, _>(
163
+ "awkward input must be an array of strings or bytestrings",
164
+ )
165
+ };
166
+ // Stripping the string/bytestring parameters turns the array into plain
167
+ // lists of uint8, whose flattened content and row lengths are views of
168
+ // the existing buffers.
169
+ let raw = ak
170
+ .call_method1("without_parameters", (inputs,))
171
+ .map_err(type_err)?;
172
+ let flat = ak.call_method1("flatten", (&raw,)).map_err(type_err)?;
173
+ let content = ak
174
+ .call_method1("to_numpy", (flat,))?
175
+ .cast_into::<PyArray1<u8>>()
176
+ .map_err(|e| type_err(e.into()))?;
177
+ let counts = ak
178
+ .call_method1("to_numpy", (ak.call_method1("num", (&raw,))?,))?
179
+ .cast_into::<PyArray1<i64>>()
180
+ .map_err(|e| type_err(e.into()))?;
181
+ Ok(Some((content, counts)))
182
+ }
183
+
184
+ /// Hand a ragged token batch to Python as an `awkward.Array`: one flat
185
+ /// contents array plus per-document counts — two allocations total instead
186
+ /// of one numpy array per document. Falls back to a list of zero-copy numpy
187
+ /// views when awkward is not importable.
188
+ pub(crate) fn ragged_to_python<'py>(
189
+ py: Python<'py>,
190
+ flat: Vec<u32>,
191
+ counts: Vec<i64>,
192
+ ) -> PyResult<Bound<'py, PyAny>> {
193
+ let n_rows = counts.len();
194
+ let content = flat.into_pyarray(py);
195
+ let counts = counts.into_pyarray(py);
196
+ match py.import("awkward") {
197
+ Ok(ak) => ak.call_method1("unflatten", (content, counts)),
198
+ Err(_) => {
199
+ if n_rows == 0 {
200
+ return Ok(pyo3::types::PyList::empty(py).into_any());
201
+ }
202
+ let np = py.import("numpy")?;
203
+ let bounds = np.call_method1("cumsum", (&counts,))?;
204
+ let split_at = bounds.get_item(pyo3::types::PySlice::new(py, 0, -1, 1))?;
205
+ np.call_method1("split", (content, split_at))
206
+ }
207
+ }
208
+ }
209
+
210
+ /// Format for every non-BytesSource encode_batch input: each byte region
211
+ /// handed to `encode` is exactly one document.
212
+ const WHOLE_DOCS: DocFormat = DocFormat::Text { separator: None };
213
+
214
+ /// Shared front-end of encode_batch: extract the documents (a BytesSource,
215
+ /// a list of str, a list of bytes, or an awkward Array of strings — whose
216
+ /// flat buffer is used directly, with no per-document Python objects) and
217
+ /// run `encode` on the resolved byte slices with the GIL released,
218
+ /// returning the ragged result as one flat id buffer plus per-document row
219
+ /// lengths. `encode`'s `DocFormat` argument says how its byte regions split
220
+ /// into documents: one document per region for everything except a
221
+ /// BytesSource, whose buffers carry its separator format and are split
222
+ /// during the encode itself. Bytes-shaped inputs are trusted to be valid
223
+ /// UTF-8 by the consumers that care (the SentencePiece backend) — nothing
224
+ /// is validated here or downstream.
225
+ pub(crate) fn encode_batch_flat<'py>(
226
+ py: Python<'py>,
227
+ inputs: &Bound<'py, PyAny>,
228
+ encode: impl Fn(&[&[u8]], &DocFormat) -> PyResult<(Vec<u32>, Vec<i64>)> + Send + Sync,
229
+ ) -> PyResult<(Vec<u32>, Vec<i64>)> {
230
+ // BytesSource: hand the borrowed buffers over as byte regions together
231
+ // with the source's separator format.
232
+ if let Ok(source) = inputs.cast::<super::sources::BytesSource>() {
233
+ let source = source.get();
234
+ return py.detach(|| {
235
+ let regions: Vec<&[u8]> = source.buffers.iter().map(|b| &**b).collect();
236
+ encode(&regions, &source.format)
237
+ });
238
+ }
239
+
240
+ // Awkward input: encode straight from the flat content buffer.
241
+ if let Some((content, in_counts)) = extract_awkward_docs(inputs)? {
242
+ let content = content.readonly();
243
+ let bytes: &[u8] = content.as_slice()?;
244
+ let in_counts = in_counts.readonly();
245
+ let in_counts: &[i64] = in_counts.as_slice()?;
246
+ return py.detach(|| -> PyResult<_> {
247
+ let mut docs = Vec::with_capacity(in_counts.len());
248
+ let mut pos = 0usize;
249
+ for &n in in_counts {
250
+ docs.push(&bytes[pos..pos + n as usize]);
251
+ pos += n as usize;
252
+ }
253
+ encode(&docs, &WHOLE_DOCS)
254
+ });
255
+ }
256
+
257
+ let inputs: Vec<Bound<'py, PyAny>> = inputs.extract().map_err(|_| {
258
+ PyErr::new::<pyo3::exceptions::PyTypeError, _>(
259
+ "expected a list of str, a list of bytes, a BytesSource, or an awkward Array of strings",
260
+ )
261
+ })?;
262
+ if inputs.is_empty() {
263
+ return Ok((vec![], vec![]));
264
+ }
265
+ let mut docs = Vec::with_capacity(inputs.len());
266
+ docs.push(extract_doc(&inputs[0])?);
267
+ for obj in &inputs[1..] {
268
+ let doc = extract_doc(obj)?;
269
+ if std::mem::discriminant(&doc) != std::mem::discriminant(&docs[0]) {
270
+ return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
271
+ "all documents in a batch must be of the same type \
272
+ (a list of str or a list of bytes)",
273
+ ));
274
+ }
275
+ docs.push(doc);
276
+ }
277
+ py.detach(|| {
278
+ let slices: Vec<&[u8]> = docs.iter().map(|d| d.as_bytes()).collect();
279
+ encode(&slices, &WHOLE_DOCS)
280
+ })
281
+ }
282
+
283
+ /// encode_batch: `encode_batch_flat`, handed to Python as an awkward.Array.
284
+ pub(crate) fn encode_batch_ragged<'py>(
285
+ py: Python<'py>,
286
+ inputs: &Bound<'py, PyAny>,
287
+ encode: impl Fn(&[&[u8]], &DocFormat) -> PyResult<(Vec<u32>, Vec<i64>)> + Send + Sync,
288
+ ) -> PyResult<Bound<'py, PyAny>> {
289
+ let (flat, counts) = encode_batch_flat(py, inputs, encode)?;
290
+ ragged_to_python(py, flat, counts)
291
+ }
292
+
293
+ /// How `_encode_batch_list_compat` assembles each row, plus its fused
294
+ /// forbidden-specials scan — the compat wrappers' per-call options, bundled
295
+ /// like `PadTruncate` so the entrypoint stays one argument wide. A frozen
296
+ /// pyclass: fields are validated once at construction, and each call costs
297
+ /// a typed downcast rather than a keyword list. Underscore-named on the
298
+ /// Python side: an implementation detail of the compat layer.
299
+ #[pyclass(frozen, name = "_WrapTruncate")]
300
+ pub(crate) struct WrapTruncate {
301
+ /// Token ids written before / after every row's tokens.
302
+ prefix: Vec<u32>,
303
+ suffix: Vec<u32>,
304
+ /// Keep at most this many encoded ids per row, not counting
305
+ /// prefix/suffix (the caller has already budgeted for those).
306
+ max_tokens: Option<usize>,
307
+ /// Drop ids from the start of a row instead of the end.
308
+ truncate_left: bool,
309
+ /// Scan every document for these patterns before encoding and raise
310
+ /// SpecialTokenFound on any hit (the tiktoken-compat specials check).
311
+ forbid: Option<Py<crate::bindings::matcher::SubstringMatcher>>,
312
+ }
313
+
314
+ #[pymethods]
315
+ impl WrapTruncate {
316
+ #[new]
317
+ #[pyo3(signature = (*, prefix = Vec::new(), suffix = Vec::new(), max_tokens = None, truncate_left = false, forbid = None))]
318
+ fn new(
319
+ prefix: Vec<u32>,
320
+ suffix: Vec<u32>,
321
+ max_tokens: Option<usize>,
322
+ truncate_left: bool,
323
+ forbid: Option<Py<crate::bindings::matcher::SubstringMatcher>>,
324
+ ) -> Self {
325
+ Self {
326
+ prefix,
327
+ suffix,
328
+ max_tokens,
329
+ truncate_left,
330
+ forbid,
331
+ }
332
+ }
333
+ }
334
+
335
+ /// encode_batch assembled into plain Python lists in Rust — one list of ints
336
+ /// per document, built directly from the flat ragged buffers — for callers
337
+ /// that need lists, which would otherwise convert the awkward result one
338
+ /// Python object at a time. `options` carries the compat wrappers' row
339
+ /// assembly: optional truncation to `max_tokens` ids (from the left when
340
+ /// `truncate_left`), wrapping in `prefix`/`suffix`, and the fused `forbid`
341
+ /// scan — every document is first checked for the matcher's patterns (in
342
+ /// parallel over documents when `parallel` is set, still with the GIL
343
+ /// released) and SpecialTokenFound is raised on any hit. `None` means plain
344
+ /// rows.
345
+ pub(crate) fn encode_batch_pylist<'py>(
346
+ py: Python<'py>,
347
+ inputs: &Bound<'py, PyAny>,
348
+ options: Option<&WrapTruncate>,
349
+ parallel: bool,
350
+ encode: impl Fn(&[&[u8]], &DocFormat) -> PyResult<(Vec<u32>, Vec<i64>)> + Send + Sync,
351
+ ) -> PyResult<Bound<'py, PyList>> {
352
+ static PLAIN: WrapTruncate = WrapTruncate {
353
+ prefix: Vec::new(),
354
+ suffix: Vec::new(),
355
+ max_tokens: None,
356
+ truncate_left: false,
357
+ forbid: None,
358
+ };
359
+ let opts = options.unwrap_or(&PLAIN);
360
+ let (prefix, suffix) = (opts.prefix.as_slice(), opts.suffix.as_slice());
361
+ let truncate_left = opts.truncate_left;
362
+ let (flat, counts) = encode_batch_flat(py, inputs, |docs, format| {
363
+ if let Some(matcher) = &opts.forbid {
364
+ matcher.get().scan_docs(docs, parallel)?;
365
+ }
366
+ encode(docs, format)
367
+ })?;
368
+ let cap = opts.max_tokens.unwrap_or(usize::MAX);
369
+ let wrap = !prefix.is_empty() || !suffix.is_empty();
370
+ let mut row_buf: Vec<u32> = Vec::new();
371
+ let mut rows = Vec::with_capacity(counts.len());
372
+ let mut pos = 0usize;
373
+ for &n in &counts {
374
+ let n = n as usize;
375
+ let tokens = &flat[pos..pos + n];
376
+ pos += n;
377
+ let kept = n.min(cap);
378
+ let tokens = if truncate_left {
379
+ &tokens[n - kept..]
380
+ } else {
381
+ &tokens[..kept]
382
+ };
383
+ if wrap {
384
+ row_buf.clear();
385
+ row_buf.extend_from_slice(prefix);
386
+ row_buf.extend_from_slice(tokens);
387
+ row_buf.extend_from_slice(suffix);
388
+ rows.push(PyList::new(py, &row_buf)?);
389
+ } else {
390
+ // Nothing to wrap (the tiktoken path): build the row straight
391
+ // from the flat slice, skipping the row_buf copy.
392
+ rows.push(PyList::new(py, tokens)?);
393
+ }
394
+ }
395
+ PyList::new(py, rows)
396
+ }
@@ -0,0 +1,42 @@
1
+ //! pyo3 forwards for `load_tokenizer::hub` — the HuggingFace Hub
2
+ //! cache-or-download used by `gigatoken._load.hub`.
3
+
4
+ use crate::load_tokenizer::hub;
5
+ use pyo3::exceptions::{PyFileNotFoundError, PyPermissionError, PyValueError};
6
+ use pyo3::prelude::*;
7
+ use std::path::PathBuf;
8
+
9
+ /// Path of `filename` from Hub repo `repo_id` at `revision`, served from
10
+ /// the standard HF cache, downloading into it first when absent.
11
+ #[pyfunction]
12
+ #[pyo3(signature = (repo_id, filename = "tokenizer.json", *, repo_type = "model", revision = "main"))]
13
+ pub fn hub_file(
14
+ py: Python<'_>,
15
+ repo_id: &str,
16
+ filename: &str,
17
+ repo_type: &str,
18
+ revision: &str,
19
+ ) -> PyResult<PathBuf> {
20
+ let repo_type: hub::RepoType =
21
+ repo_type.parse().map_err(|err: eyre::Report| PyValueError::new_err(err.to_string()))?;
22
+ py.detach(|| hub::hub_file_in(repo_type, repo_id, filename, revision)).map_err(|err| {
23
+ // Definite HTTP causes surface as the matching Python exception.
24
+ match err.downcast_ref::<hub::FetchError>() {
25
+ Some(fetch @ hub::FetchError::NotFound { .. }) => PyFileNotFoundError::new_err(fetch.to_string()),
26
+ Some(fetch @ hub::FetchError::Unauthorized { .. }) => PyPermissionError::new_err(fetch.to_string()),
27
+ None => err.into(),
28
+ }
29
+ })
30
+ }
31
+
32
+ /// Whether `name` is shaped like a HuggingFace Hub repo id.
33
+ #[pyfunction]
34
+ pub fn looks_like_repo_id(name: &str) -> bool {
35
+ hub::looks_like_repo_id(name)
36
+ }
37
+
38
+ /// The discovered HuggingFace access token, or None.
39
+ #[pyfunction]
40
+ pub fn get_hf_token() -> Option<String> {
41
+ hub::get_hf_token()
42
+ }
@@ -0,0 +1,114 @@
1
+ //! A prebuilt multi-substring matcher exposed to Python, used by the
2
+ //! tiktoken compat layer to scan documents for special tokens. The scan runs fused into
3
+ //! the encode_batch_list call (`scan_docs`), in parallel over documents
4
+ //! with the GIL released, instead of one Python-level substring search per
5
+ //! token per document.
6
+
7
+ use pyo3::prelude::*;
8
+ use pyo3::pybacked::PyBackedStr;
9
+
10
+ pyo3::create_exception!(
11
+ gigatoken_rs,
12
+ SpecialTokenFound,
13
+ pyo3::exceptions::PyException,
14
+ "A forbidden pattern was found while encoding; args[0] holds the sorted \
15
+ indices of every matched pattern, for the caller to raise its own error."
16
+ );
17
+
18
+ /// Compiled multi-pattern substring matcher. Build once from a list of
19
+ /// patterns, then query which patterns occur in a text — plain substring
20
+ /// containment, exactly like `pattern in text` for each pattern.
21
+ /// Underscore-named on the Python side: an implementation detail of the
22
+ /// compat layer, not part of the public API.
23
+ #[pyclass(frozen, name = "_SubstringMatcher")]
24
+ pub(crate) struct SubstringMatcher {
25
+ automaton: aho_corasick::AhoCorasick,
26
+ n_patterns: usize,
27
+ }
28
+
29
+ impl SubstringMatcher {
30
+ /// Which patterns occur in `haystack`, as a bitvec over pattern indices.
31
+ /// Overlapping search keeps containment semantics exact when one
32
+ /// pattern is a substring of another (which the default Standard match
33
+ /// kind supports).
34
+ fn scan(&self, haystack: &[u8]) -> Vec<bool> {
35
+ let mut seen = vec![false; self.n_patterns];
36
+ let mut found = 0;
37
+ for m in self.automaton.find_overlapping_iter(haystack) {
38
+ let idx = m.pattern().as_usize();
39
+ if !seen[idx] {
40
+ seen[idx] = true;
41
+ found += 1;
42
+ if found == self.n_patterns {
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ seen
48
+ }
49
+
50
+ /// Scan every document — in parallel with rayon when `parallel` is set;
51
+ /// serially otherwise (forked workers must never touch the global pool).
52
+ /// Ok(()) when no pattern occurs anywhere; SpecialTokenFound carrying
53
+ /// the sorted indices of every matched pattern otherwise. Call with the
54
+ /// GIL released.
55
+ pub(crate) fn scan_docs(&self, docs: &[&[u8]], parallel: bool) -> PyResult<()> {
56
+ use rayon::prelude::*;
57
+ let merge = |mut a: Vec<bool>, b: Vec<bool>| {
58
+ a.iter_mut().zip(&b).for_each(|(x, y)| *x |= y);
59
+ a
60
+ };
61
+ let seen = if parallel {
62
+ docs.par_iter()
63
+ .map(|d| self.scan(d))
64
+ .reduce(|| vec![false; self.n_patterns], merge)
65
+ } else {
66
+ docs.iter()
67
+ .map(|d| self.scan(d))
68
+ .fold(vec![false; self.n_patterns], merge)
69
+ };
70
+ let hits: Vec<usize> = seen
71
+ .iter()
72
+ .enumerate()
73
+ .filter_map(|(i, &s)| s.then_some(i))
74
+ .collect();
75
+ if hits.is_empty() {
76
+ Ok(())
77
+ } else {
78
+ Err(SpecialTokenFound::new_err(hits))
79
+ }
80
+ }
81
+ }
82
+
83
+ #[pymethods]
84
+ impl SubstringMatcher {
85
+ #[new]
86
+ fn new(patterns: Vec<String>) -> PyResult<Self> {
87
+ let n_patterns = patterns.len();
88
+ let automaton = aho_corasick::AhoCorasick::new(&patterns).map_err(|e| {
89
+ PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
90
+ "failed to build matcher: {e}"
91
+ ))
92
+ })?;
93
+ Ok(Self {
94
+ automaton,
95
+ n_patterns,
96
+ })
97
+ }
98
+
99
+ /// Sorted indices of the patterns that occur in `text`. Runs with the
100
+ /// GIL released.
101
+ fn present(&self, py: Python<'_>, text: PyBackedStr) -> Vec<usize> {
102
+ py.detach(|| {
103
+ self.scan(text.as_bytes())
104
+ .iter()
105
+ .enumerate()
106
+ .filter_map(|(i, &s)| s.then_some(i))
107
+ .collect()
108
+ })
109
+ }
110
+
111
+ fn __len__(&self) -> usize {
112
+ self.n_patterns
113
+ }
114
+ }
@@ -0,0 +1,14 @@
1
+ //! pyo3 glue for everything except the two tokenizer classes, which stay in
2
+ //! lib.rs so the main path of the API remains front and center. One
3
+ //! submodule per feature: Python<->Rust bridging shared by the bindings
4
+ //! (bridge), the FileSource/BytesSource classes (sources), BPE training (train), padded
5
+ //! compat-API encoding (padding), the compat layers' special-token scanner
6
+ //! (matcher), and the pretokenizer helpers (pretokenize).
7
+
8
+ pub(crate) mod bridge;
9
+ pub(crate) mod hub;
10
+ pub(crate) mod matcher;
11
+ pub(crate) mod padding;
12
+ pub(crate) mod pretokenize;
13
+ pub(crate) mod sources;
14
+ pub(crate) mod train;