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,177 @@
1
+ //! Padded/truncated batch assembly, serving the drop-in compatibility APIs
2
+ //! (gigatoken.HFCompat's padding/truncation support). Kept out of lib.rs so
3
+ //! the main encode path stays easy to read: the `encode_batch_padded`
4
+ //! bindings there are one-call forwards into this module, and the options
5
+ //! travel as the Rust-defined `PadTruncate` class, so per call they cost a
6
+ //! typed downcast rather than dict-key lookups. The friendly keyword
7
+ //! signature lives on `gigatoken.Tokenizer.encode_batch_padded`.
8
+
9
+ use crate::input::file_source::DocFormat;
10
+ use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods};
11
+ use pyo3::prelude::*;
12
+
13
+ /// How `encode_batch_padded` assembles rows into a rectangular matrix.
14
+ /// A frozen pyclass: fields are validated once at construction, and bad
15
+ /// names or types fail there with a TypeError instead of at encode time.
16
+ #[pyclass(frozen, get_all, from_py_object)]
17
+ #[derive(Clone)]
18
+ pub struct PadTruncate {
19
+ pub pad_id: u32,
20
+ /// Truncation limit and/or fixed pad width, depending on the two flags.
21
+ pub max_length: Option<usize>,
22
+ /// Pad every row to exactly `max_length` instead of the longest row.
23
+ pub pad_to_max_length: bool,
24
+ /// Keep at most `max_length` ids per row, counting prefix and suffix.
25
+ pub truncate: bool,
26
+ /// Put the padding before the tokens instead of after them.
27
+ pub pad_left: bool,
28
+ /// Drop tokens from the start of a row instead of the end.
29
+ pub truncate_left: bool,
30
+ /// Special-token ids written before / after every row's tokens; they
31
+ /// count toward the truncation budget, like HF post-processors.
32
+ pub prefix: Vec<u32>,
33
+ pub suffix: Vec<u32>,
34
+ }
35
+
36
+ #[pymethods]
37
+ impl PadTruncate {
38
+ #[new]
39
+ #[allow(clippy::too_many_arguments)]
40
+ #[pyo3(signature = (pad_id, max_length=None, pad_to_max_length=false, truncate=false, pad_left=false, truncate_left=false, prefix=Vec::new(), suffix=Vec::new()))]
41
+ fn new(
42
+ pad_id: u32,
43
+ max_length: Option<usize>,
44
+ pad_to_max_length: bool,
45
+ truncate: bool,
46
+ pad_left: bool,
47
+ truncate_left: bool,
48
+ prefix: Vec<u32>,
49
+ suffix: Vec<u32>,
50
+ ) -> Self {
51
+ Self {
52
+ pad_id,
53
+ max_length,
54
+ pad_to_max_length,
55
+ truncate,
56
+ pad_left,
57
+ truncate_left,
58
+ prefix,
59
+ suffix,
60
+ }
61
+ }
62
+
63
+ fn __repr__(&self) -> String {
64
+ let py_bool = |b: bool| if b { "True" } else { "False" };
65
+ let max_length = self
66
+ .max_length
67
+ .map_or("None".to_string(), |m| m.to_string());
68
+ format!(
69
+ "PadTruncate(pad_id={}, max_length={max_length}, pad_to_max_length={}, truncate={}, \
70
+ pad_left={}, truncate_left={}, prefix={:?}, suffix={:?})",
71
+ self.pad_id,
72
+ py_bool(self.pad_to_max_length),
73
+ py_bool(self.truncate),
74
+ py_bool(self.pad_left),
75
+ py_bool(self.truncate_left),
76
+ self.prefix,
77
+ self.suffix,
78
+ )
79
+ }
80
+ }
81
+
82
+ /// The (rows x width) id matrix plus each row's real (unpadded) length —
83
+ /// everything an attention mask needs.
84
+ pub type PaddedMatrix<'py> = (Bound<'py, PyArray2<u32>>, Bound<'py, PyArray1<i64>>);
85
+
86
+ /// Shared back-end of the `encode_batch_padded` bindings: encode like
87
+ /// encode_batch, then pad/truncate into one (rows x width) uint32 matrix.
88
+ pub fn encode_batch_matrix<'py>(
89
+ py: Python<'py>,
90
+ inputs: &Bound<'py, PyAny>,
91
+ opts: PadTruncate,
92
+ parallel: bool,
93
+ encode: impl Fn(&[&[u8]], &DocFormat) -> PyResult<(Vec<u32>, Vec<i64>)> + Send + Sync,
94
+ ) -> PyResult<PaddedMatrix<'py>> {
95
+ let (flat, counts) = super::bridge::encode_batch_flat(py, inputs, encode)?;
96
+ let (data, lengths, width) = py.detach(|| pad_truncate_matrix(&flat, &counts, &opts, parallel))?;
97
+ let rows = lengths.len();
98
+ let matrix = data.into_pyarray(py).reshape([rows, width])?;
99
+ Ok((matrix, lengths.into_pyarray(py)))
100
+ }
101
+
102
+ /// Assemble a ragged token batch into one row-major (rows x width) matrix in
103
+ /// a single parallel copy pass. Each row is `prefix` + the (optionally
104
+ /// truncated) tokens + `suffix`, padded with `pad_id` to the longest row (or
105
+ /// to exactly `max_length` when `pad_to_max_length`). Returns the matrix
106
+ /// data, each row's real (unpadded) length, and the width.
107
+ fn pad_truncate_matrix(
108
+ flat: &[u32],
109
+ counts: &[i64],
110
+ opts: &PadTruncate,
111
+ parallel: bool,
112
+ ) -> PyResult<(Vec<u32>, Vec<i64>, usize)> {
113
+ use rayon::prelude::*;
114
+ let value_err = |msg: String| PyErr::new::<pyo3::exceptions::PyValueError, _>(msg);
115
+ let extra = opts.prefix.len() + opts.suffix.len();
116
+ let cap = if opts.truncate {
117
+ let max = opts
118
+ .max_length
119
+ .ok_or_else(|| value_err("truncate requires max_length".to_string()))?;
120
+ max.checked_sub(extra).ok_or_else(|| {
121
+ value_err(format!(
122
+ "max_length={max} leaves no room for the {extra} special tokens added per sequence"
123
+ ))
124
+ })?
125
+ } else {
126
+ usize::MAX
127
+ };
128
+ let mut offsets = Vec::with_capacity(counts.len());
129
+ let mut lengths = Vec::with_capacity(counts.len());
130
+ let mut longest = 0usize;
131
+ let mut pos = 0usize;
132
+ for &count in counts {
133
+ let kept = (count as usize).min(cap);
134
+ offsets.push(pos);
135
+ pos += count as usize;
136
+ lengths.push((kept + extra) as i64);
137
+ longest = longest.max(kept + extra);
138
+ }
139
+ let width = if opts.pad_to_max_length {
140
+ let max = opts
141
+ .max_length
142
+ .ok_or_else(|| value_err("pad_to_max_length requires max_length".to_string()))?;
143
+ if longest > max {
144
+ return Err(value_err(format!(
145
+ "a sequence is {longest} ids long but padding to max_length={max} was requested \
146
+ without truncation; enable truncation or raise max_length"
147
+ )));
148
+ }
149
+ max
150
+ } else {
151
+ longest
152
+ };
153
+ let mut out = vec![opts.pad_id; counts.len() * width];
154
+ if width > 0 {
155
+ let fill = |(i, row): (usize, &mut [u32])| {
156
+ let count = counts[i] as usize;
157
+ let kept = count.min(cap);
158
+ let tokens = &flat[offsets[i]..offsets[i] + count];
159
+ let tokens = if opts.truncate_left {
160
+ &tokens[count - kept..]
161
+ } else {
162
+ &tokens[..kept]
163
+ };
164
+ let len = kept + extra;
165
+ let start = if opts.pad_left { width - len } else { 0 };
166
+ row[start..start + opts.prefix.len()].copy_from_slice(&opts.prefix);
167
+ row[start + opts.prefix.len()..start + opts.prefix.len() + kept].copy_from_slice(tokens);
168
+ row[start + len - opts.suffix.len()..start + len].copy_from_slice(&opts.suffix);
169
+ };
170
+ if parallel {
171
+ out.par_chunks_mut(width).enumerate().for_each(fill);
172
+ } else {
173
+ out.chunks_mut(width).enumerate().for_each(fill);
174
+ }
175
+ }
176
+ Ok((out, lengths, width))
177
+ }
@@ -0,0 +1,53 @@
1
+ //! Python bindings exposing the pretokenizer directly (an iterator over one
2
+ //! document's pretokens and a parallel pretoken-count function) — used for
3
+ //! inspection and tests, not by the encode path.
4
+
5
+ use crate::pretokenize;
6
+ use pyo3::prelude::*;
7
+ use pyo3::types::PyBytes;
8
+
9
+ #[pyclass]
10
+ pub(crate) struct PretokenizerIter {
11
+ /// Byte offset into `bytes`; the pretokenizer is stateless beyond this, so
12
+ /// each `__next__` resumes a fresh `FastR50kPretokenizer` at this position.
13
+ pos: usize,
14
+ bytes: Py<PyBytes>,
15
+ }
16
+
17
+ #[pymethods]
18
+ impl PretokenizerIter {
19
+ fn __iter__<'py>(slf: PyRef<'py, Self>) -> PyRef<'py, PretokenizerIter> {
20
+ slf
21
+ }
22
+
23
+ fn __next__<'py>(&'py mut self, py: Python<'py>) -> Option<&'py [u8]> {
24
+ let bytes: &'py [u8] = self.bytes.as_bytes(py);
25
+ let mut iter = pretokenize::FastR50kPretokenizer::with_pos(bytes, self.pos);
26
+ let result = iter.next();
27
+ self.pos = iter.pos();
28
+ Some(result?.0)
29
+ }
30
+ }
31
+
32
+ #[pyfunction]
33
+ pub(crate) fn pretokenizer<'py>(text: Bound<'py, PyBytes>) -> PyResult<PretokenizerIter> {
34
+ Ok(PretokenizerIter {
35
+ pos: 0,
36
+ bytes: text.into(),
37
+ })
38
+ }
39
+
40
+ #[pyfunction]
41
+ #[pyo3(signature = (text, separator = None))]
42
+ pub(crate) fn pretokenized_counts<'py>(
43
+ text: Bound<'py, PyBytes>,
44
+ separator: Option<&[u8]>,
45
+ ) -> PyResult<Vec<(Bound<'py, PyBytes>, usize)>> {
46
+ let separator = separator.unwrap_or(pretokenize::DEFAULT_SEPARATOR);
47
+ let tokens_counts = pretokenize::pretokenize_par_bytes(text.as_bytes(), separator);
48
+ let tokens_counts = tokens_counts
49
+ .into_iter()
50
+ .map(|(k, v)| (PyBytes::new(text.py(), k.as_ref()), v))
51
+ .collect::<Vec<_>>();
52
+ Ok(tokens_counts)
53
+ }
@@ -0,0 +1,273 @@
1
+ //! The FileSource/BytesSource Python classes and the helpers that turn an
2
+ //! encode_files or train_bpe argument into loaded, format-tagged file
3
+ //! contents.
4
+
5
+ use crate::input::Resource;
6
+ use crate::input::file_source::{DocFormat, LoadedFile, detect_default_format, load_file};
7
+ use pyo3::prelude::*;
8
+ use pyo3::pybacked::PyBackedBytes;
9
+ use std::path::PathBuf;
10
+
11
+ /// Base class for file sources. Not directly constructible from Python —
12
+ /// use `TextFileSource` or `JsonlFileSource`, which pin down the document
13
+ /// format and its parameters. Compression (.gz/.zst) is always detected
14
+ /// from the file extension, independent of the source type.
15
+ #[pyclass(subclass, from_py_object)]
16
+ #[derive(Clone)]
17
+ pub(crate) struct FileSource {
18
+ pub(crate) paths: Vec<PathBuf>,
19
+ pub(crate) format: DocFormat,
20
+ }
21
+
22
+ #[pymethods]
23
+ impl FileSource {
24
+ fn __repr__(&self) -> String {
25
+ let n = self.paths.len();
26
+ match &self.format {
27
+ DocFormat::Jsonl { field } => {
28
+ format!("JsonlFileSource(paths=[{n} files], field={field:?})")
29
+ }
30
+ DocFormat::Text {
31
+ separator: Some(sep),
32
+ } => format!(
33
+ "TextFileSource(paths=[{n} files], separator={:?})",
34
+ String::from_utf8_lossy(sep)
35
+ ),
36
+ DocFormat::Text { separator: None } => {
37
+ format!("TextFileSource(paths=[{n} files])")
38
+ }
39
+ DocFormat::Parquet { column } => {
40
+ format!("ParquetFileSource(paths=[{n} files], column={column:?})")
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ /// A `separator` argument: bytes used as-is, or str encoded to its UTF-8
47
+ /// bytes.
48
+ #[derive(FromPyObject)]
49
+ pub(crate) enum Separator {
50
+ Bytes(Vec<u8>),
51
+ Str(String),
52
+ }
53
+
54
+ impl Separator {
55
+ fn into_bytes(self) -> Vec<u8> {
56
+ match self {
57
+ Separator::Bytes(b) => b,
58
+ Separator::Str(s) => s.into_bytes(),
59
+ }
60
+ }
61
+ }
62
+
63
+ /// Plain-text files. With `separator` (str or bytes), documents are the
64
+ /// pieces between separator occurrences (the separator itself belongs to no
65
+ /// document); without one, each file is a single document.
66
+ #[pyclass(extends = FileSource)]
67
+ pub(crate) struct TextFileSource;
68
+
69
+ #[pymethods]
70
+ impl TextFileSource {
71
+ #[new]
72
+ #[pyo3(signature = (paths, separator = None))]
73
+ fn new(paths: Vec<PathBuf>, separator: Option<Separator>) -> PyClassInitializer<Self> {
74
+ PyClassInitializer::from(FileSource {
75
+ paths,
76
+ format: DocFormat::Text {
77
+ separator: separator.map(Separator::into_bytes),
78
+ },
79
+ })
80
+ .add_subclass(Self)
81
+ }
82
+ }
83
+
84
+ /// JSON Lines files: one document per line, text taken from `field`.
85
+ #[pyclass(extends = FileSource)]
86
+ pub(crate) struct JsonlFileSource;
87
+
88
+ #[pymethods]
89
+ impl JsonlFileSource {
90
+ #[new]
91
+ #[pyo3(signature = (paths, field = "text"))]
92
+ fn new(paths: Vec<PathBuf>, field: &str) -> PyClassInitializer<Self> {
93
+ PyClassInitializer::from(FileSource {
94
+ paths,
95
+ format: DocFormat::Jsonl {
96
+ field: field.to_string(),
97
+ },
98
+ })
99
+ .add_subclass(Self)
100
+ }
101
+ }
102
+
103
+ /// Parquet files: one document per row, text taken from `column` (a string
104
+ /// or binary column; null rows become empty documents, so results stay
105
+ /// row-aligned with the table). Parquet handles compression internally, so
106
+ /// no .gz/.zst detection applies.
107
+ #[pyclass(extends = FileSource)]
108
+ pub(crate) struct ParquetFileSource;
109
+
110
+ #[pymethods]
111
+ impl ParquetFileSource {
112
+ #[new]
113
+ #[pyo3(signature = (paths, column = "text"))]
114
+ fn new(paths: Vec<PathBuf>, column: &str) -> PyClassInitializer<Self> {
115
+ PyClassInitializer::from(FileSource {
116
+ paths,
117
+ format: DocFormat::Parquet {
118
+ column: column.to_string(),
119
+ },
120
+ })
121
+ .add_subclass(Self)
122
+ }
123
+ }
124
+
125
+ /// In-memory bytes for encode_batch, the buffer analog of TextFileSource:
126
+ /// with `separator` (str or bytes), each buffer's documents are the pieces
127
+ /// between separator occurrences (the separator itself belongs to no
128
+ /// document, and empty pieces are skipped); without one, each buffer is a
129
+ /// single document. The buffers are borrowed, not copied, and split during
130
+ /// the (parallel) encode itself — handing a whole corpus over as a few
131
+ /// buffers plus a separator is much faster than pre-splitting it into
132
+ /// per-document Python objects.
133
+ #[pyclass(frozen)]
134
+ pub(crate) struct BytesSource {
135
+ pub(crate) buffers: Vec<PyBackedBytes>,
136
+ pub(crate) format: DocFormat,
137
+ }
138
+
139
+ #[pymethods]
140
+ impl BytesSource {
141
+ #[new]
142
+ #[pyo3(signature = (data, separator = None))]
143
+ fn new(data: &Bound<'_, PyAny>, separator: Option<Separator>) -> PyResult<Self> {
144
+ let buffers = if let Ok(buffer) = data.extract::<PyBackedBytes>() {
145
+ vec![buffer]
146
+ } else {
147
+ data.extract::<Vec<PyBackedBytes>>().map_err(|_| {
148
+ PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
149
+ "expected bytes or a list of bytes, got {}",
150
+ data.get_type()
151
+ ))
152
+ })?
153
+ };
154
+ Ok(Self {
155
+ buffers,
156
+ format: DocFormat::Text {
157
+ separator: separator.map(Separator::into_bytes),
158
+ },
159
+ })
160
+ }
161
+
162
+ fn __repr__(&self) -> String {
163
+ let n = self.buffers.len();
164
+ let total: usize = self.buffers.iter().map(|b| b.len()).sum();
165
+ match &self.format {
166
+ DocFormat::Text {
167
+ separator: Some(sep),
168
+ } => format!(
169
+ "BytesSource(data=[{n} buffers, {total} bytes], separator={:?})",
170
+ String::from_utf8_lossy(sep)
171
+ ),
172
+ _ => format!("BytesSource(data=[{n} buffers, {total} bytes])"),
173
+ }
174
+ }
175
+ }
176
+
177
+ /// Resolve an encode_files argument: a FileSource (TextFileSource /
178
+ /// JsonlFileSource), a single path, or a list of paths. Bare paths get a
179
+ /// default format from the first path's extension — all inputs in a batch
180
+ /// are assumed to be of the same type.
181
+ pub(crate) fn resolve_files_source(obj: &Bound<'_, PyAny>) -> PyResult<(Vec<PathBuf>, DocFormat)> {
182
+ if let Ok(fs) = obj.extract::<FileSource>() {
183
+ return Ok((fs.paths, fs.format));
184
+ }
185
+ if let Ok(path) = obj.extract::<PathBuf>() {
186
+ let format = detect_default_format(&path);
187
+ return Ok((vec![path], format));
188
+ }
189
+ if let Ok(paths) = obj.extract::<Vec<PathBuf>>() {
190
+ let format = paths
191
+ .first()
192
+ .map(|p| detect_default_format(p))
193
+ .unwrap_or(DocFormat::Text { separator: None });
194
+ return Ok((paths, format));
195
+ }
196
+ Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
197
+ "expected a TextFileSource/JsonlFileSource/ParquetFileSource, a path, or a list of paths, got {}",
198
+ obj.get_type()
199
+ )))
200
+ }
201
+
202
+ /// Shared scaffold of the encode_files pymethods: resolve the source
203
+ /// argument, load the files with the GIL released, hand their contents and
204
+ /// document format to `encode` (still detached), and return the ragged
205
+ /// result as an awkward Array. The per-backend encoding lives in
206
+ /// `batch::encode_files_docs` / `batch::sp_encode_files_docs`.
207
+ pub(crate) fn encode_files_ragged<'py>(
208
+ py: Python<'py>,
209
+ source: &Bound<'py, PyAny>,
210
+ parallel: bool,
211
+ encode: impl FnOnce(&[&[u8]], &DocFormat) -> (Vec<u32>, Vec<i64>) + Send,
212
+ ) -> PyResult<Bound<'py, PyAny>> {
213
+ let (paths, format) = resolve_files_source(source)?;
214
+ let (flat, counts) = py.detach(|| -> PyResult<_> {
215
+ // Parquet rows can't be split out of the raw file bytes, so they are
216
+ // materialized as owned documents here and encoded through the
217
+ // whole-document path (each buffer one document).
218
+ if let DocFormat::Parquet { column } = &format {
219
+ let docs = load_parquet_docs(&paths, column, parallel)?;
220
+ let bytes: Vec<&[u8]> = docs.iter().map(|d| d.as_slice()).collect();
221
+ return Ok(encode(&bytes, &DocFormat::Text { separator: None }));
222
+ }
223
+ let files = load_files(&paths, parallel)?;
224
+ let bytes: Vec<&[u8]> = files.iter().map(|f| f.as_bytes()).collect();
225
+ Ok(encode(&bytes, &format))
226
+ })?;
227
+ super::bridge::ragged_to_python(py, flat, counts)
228
+ }
229
+
230
+ /// Load `column` of every parquet file as one owned document per row, files
231
+ /// in argument order, rows in row order. Parallel across files and row
232
+ /// groups with rayon, or fully on the calling thread when `parallel` is
233
+ /// false (the sequential encode paths must never touch the rayon pool).
234
+ fn load_parquet_docs(
235
+ paths: &[PathBuf],
236
+ column: &str,
237
+ parallel: bool,
238
+ ) -> PyResult<Vec<Vec<u8>>> {
239
+ use crate::input::parquet::read_docs;
240
+ use rayon::prelude::*;
241
+ // input::parquet errors already carry the offending path.
242
+ let to_pyerr = |e: std::io::Error| PyErr::new::<pyo3::exceptions::PyIOError, _>(e.to_string());
243
+ let per_file: Vec<Vec<Vec<u8>>> = if parallel {
244
+ paths
245
+ .par_iter()
246
+ .map(|p| read_docs(p, column, true).map_err(to_pyerr))
247
+ .collect::<PyResult<_>>()?
248
+ } else {
249
+ paths
250
+ .iter()
251
+ .map(|p| read_docs(p, column, false).map_err(to_pyerr))
252
+ .collect::<PyResult<_>>()?
253
+ };
254
+ Ok(per_file.into_iter().flatten().collect())
255
+ }
256
+
257
+ /// Load all files: mmap when stored uncompressed, decompress .gz/.zst into
258
+ /// memory otherwise (parallel chunking needs random access). In parallel
259
+ /// with rayon, or serially on the calling thread when `parallel` is false
260
+ /// (the sequential encode paths must never touch the rayon pool).
261
+ pub(crate) fn load_files(paths: &[PathBuf], parallel: bool) -> PyResult<Vec<LoadedFile>> {
262
+ use rayon::prelude::*;
263
+ let load = |p: &PathBuf| {
264
+ load_file(p).map_err(|e| {
265
+ PyErr::new::<pyo3::exceptions::PyIOError, _>(format!("{}: {e}", p.display()))
266
+ })
267
+ };
268
+ if parallel {
269
+ paths.par_iter().map(load).collect()
270
+ } else {
271
+ paths.iter().map(load).collect()
272
+ }
273
+ }
@@ -0,0 +1,125 @@
1
+ //! Python binding for BPE training: the train_bpe function and the
2
+ //! conversion of its result into Python vocab/merges objects.
3
+
4
+ use super::sources::FileSource;
5
+ use crate::bpe_train;
6
+ use crate::input::file_source::FileSourceSpec;
7
+ use crate::input::{MmappedFile, Resource};
8
+ use crate::pretokenize;
9
+ use itertools::Itertools;
10
+ use pyo3::prelude::*;
11
+ use pyo3::types::{IntoPyDict, PyBytes, PyDict};
12
+ use std::path::PathBuf;
13
+
14
+ /// Vocab dict plus ordered merge pairs, as Python objects.
15
+ type PyVocabAndMerges<'py> = (
16
+ Bound<'py, PyDict>,
17
+ Vec<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)>,
18
+ );
19
+
20
+ fn bpe_result_to_python<'py>(
21
+ py: Python<'py>,
22
+ result: bpe_train::BPEResult,
23
+ ) -> PyResult<PyVocabAndMerges<'py>> {
24
+ let vocab_py = result
25
+ .vocab
26
+ .into_iter()
27
+ .map(|(k, v)| (k, PyBytes::new(py, &v)))
28
+ .sorted_by(|e1, e2| Ord::cmp(&e1.0, &e2.0))
29
+ .into_py_dict(py);
30
+ let merges_py: Vec<_> = result
31
+ .merges
32
+ .into_iter()
33
+ .map(|(k, v)| (PyBytes::new(py, &k), PyBytes::new(py, &v)))
34
+ .collect();
35
+ Ok((vocab_py?, merges_py))
36
+ }
37
+
38
+ fn parse_tie_breaking(s: &str) -> PyResult<bpe_train::TieBreaking> {
39
+ match s {
40
+ "huggingface" => Ok(bpe_train::TieBreaking::HuggingFace),
41
+ "raw_token_ids" => Ok(bpe_train::TieBreaking::RawTokenIds),
42
+ "assembled_bytes" => Ok(bpe_train::TieBreaking::AssembledBytes),
43
+ other => Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
44
+ "tie_breaking must be 'huggingface', 'raw_token_ids', or 'assembled_bytes', got {other:?}"
45
+ ))),
46
+ }
47
+ }
48
+
49
+ #[pyfunction]
50
+ #[allow(clippy::type_complexity)]
51
+ #[pyo3(signature = (in_data, vocab_size, special_tokens, tie_breaking = "huggingface", separator = None))]
52
+ pub(crate) fn train_bpe<'py>(
53
+ py: Python<'py>,
54
+ in_data: Bound<'py, PyAny>,
55
+ vocab_size: usize,
56
+ special_tokens: Vec<String>,
57
+ tie_breaking: &str,
58
+ separator: Option<&[u8]>,
59
+ ) -> PyResult<(
60
+ Bound<'py, PyDict>,
61
+ Vec<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)>,
62
+ )> {
63
+ assert!(
64
+ vocab_size <= 2_usize.pow(32),
65
+ "vocab_size must be less than 2^32"
66
+ );
67
+ let tie_breaking = parse_tie_breaking(tie_breaking)?;
68
+ let separator = separator.unwrap_or(pretokenize::DEFAULT_SEPARATOR);
69
+
70
+ // --- FileSource: multi-file parallel processing ---
71
+ if let Ok(file_source) = in_data.extract::<FileSource>() {
72
+ let spec = FileSourceSpec {
73
+ paths: file_source.paths,
74
+ format: file_source.format,
75
+ };
76
+ let counts = spec.pretokenize().map_err(|e| {
77
+ PyErr::new::<pyo3::exceptions::PyIOError, _>(format!(
78
+ "FileSource processing failed: {}",
79
+ e
80
+ ))
81
+ })?;
82
+ let result = bpe_train::train_bpe(counts, vocab_size, special_tokens, tie_breaking);
83
+ return bpe_result_to_python(py, result);
84
+ }
85
+
86
+ // --- Single bytes or file path ---
87
+ let mmap_resource;
88
+ let bytes: &[u8] = if in_data.is_instance_of::<PyBytes>() {
89
+ in_data.extract::<&[u8]>()?
90
+ } else if let Ok(path) = in_data.extract::<PathBuf>() {
91
+ if let Some(ext) = path.extension()
92
+ && ext == "parquet"
93
+ {
94
+ // A bare path takes the default column "text", matching
95
+ // detect_default_format; use ParquetFileSource to choose another
96
+ // column.
97
+ let spec = FileSourceSpec {
98
+ paths: vec![path],
99
+ format: crate::input::file_source::DocFormat::Parquet {
100
+ column: "text".to_string(),
101
+ },
102
+ };
103
+ let counts = spec
104
+ .pretokenize()
105
+ .map_err(|e| PyErr::new::<pyo3::exceptions::PyIOError, _>(e.to_string()))?;
106
+ let result = bpe_train::train_bpe(counts, vocab_size, special_tokens, tie_breaking);
107
+ return bpe_result_to_python(py, result);
108
+ }
109
+ mmap_resource = MmappedFile::open(&path).map_err(|e| {
110
+ PyErr::new::<pyo3::exceptions::PyIOError, _>(format!(
111
+ "Failed to open file {:?}: {}",
112
+ path, e
113
+ ))
114
+ })?;
115
+ mmap_resource.as_bytes()
116
+ } else {
117
+ return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(
118
+ "in_data must be bytes, a path, or a FileSource",
119
+ ));
120
+ };
121
+
122
+ let counts = pretokenize::pretokenize_par_bytes(bytes, separator);
123
+ let result = bpe_train::train_bpe(counts, vocab_size, special_tokens, tie_breaking);
124
+ bpe_result_to_python(py, result)
125
+ }