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,514 @@
1
+ use std::collections::HashMap;
2
+ use std::path::{Path, PathBuf};
3
+
4
+ use rayon::prelude::*;
5
+ use rustc_hash::FxBuildHasher;
6
+
7
+ use crate::input::decompress;
8
+ use crate::input::jsonl::{JsonLinesReader, JsonLinesSlice};
9
+ use crate::input::MmappedFile;
10
+ use crate::input::Resource;
11
+ use crate::pretokenize::{pretokenize_as_iter, pretokenize_par_bytes};
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // File format detection: compression and content format are independent
15
+ // ---------------------------------------------------------------------------
16
+
17
+ #[derive(Debug, Clone, Copy)]
18
+ pub(crate) enum Compression {
19
+ None,
20
+ Gzip,
21
+ Zstd,
22
+ }
23
+
24
+ #[derive(Debug, Clone, Copy)]
25
+ pub enum ContentFormat {
26
+ PlainText,
27
+ Jsonl,
28
+ Parquet,
29
+ }
30
+
31
+ /// Strip compression extension and detect compression type.
32
+ /// Returns (stem without compression ext, compression).
33
+ fn detect_compression(name: &str) -> (&str, Compression) {
34
+ if let Some(stem) = name.strip_suffix(".zst").or_else(|| name.strip_suffix(".zstd")) {
35
+ (stem, Compression::Zstd)
36
+ } else if let Some(stem) = name.strip_suffix(".gz") {
37
+ (stem, Compression::Gzip)
38
+ } else {
39
+ (name, Compression::None)
40
+ }
41
+ }
42
+
43
+ /// Detect content format from the (uncompressed) filename stem.
44
+ fn detect_content_format(stem: &str) -> ContentFormat {
45
+ if stem.ends_with(".jsonl") {
46
+ ContentFormat::Jsonl
47
+ } else if stem.ends_with(".parquet") {
48
+ ContentFormat::Parquet
49
+ } else {
50
+ ContentFormat::PlainText
51
+ }
52
+ }
53
+
54
+ fn detect_format(path: &Path) -> (ContentFormat, Compression) {
55
+ let name = path
56
+ .file_name()
57
+ .and_then(|n| n.to_str())
58
+ .unwrap_or("");
59
+ let (stem, compression) = detect_compression(name);
60
+ let content = detect_content_format(stem);
61
+ (content, compression)
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // DocFormat: how a file's bytes split into documents
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /// How to split a file's bytes into documents. Carried by the Python
69
+ /// `TextFileSource` / `JsonlFileSource` classes; compression is orthogonal
70
+ /// and always detected from the file extension.
71
+ #[derive(Debug, Clone)]
72
+ pub enum DocFormat {
73
+ /// Plain text. With a separator, documents are the pieces between
74
+ /// occurrences; without one, the whole file is a single document.
75
+ Text { separator: Option<Vec<u8>> },
76
+ /// JSON Lines: one document per line, text taken from `field`.
77
+ Jsonl { field: String },
78
+ /// Parquet: one document per row, text taken from `column` (a string or
79
+ /// binary column; null rows become empty documents). Unlike the byte-
80
+ /// stream formats, parquet files are materialized into owned documents
81
+ /// up front (see `input::parquet`) and never reach the byte-region
82
+ /// chunking machinery.
83
+ Parquet { column: String },
84
+ }
85
+
86
+ impl DocFormat {
87
+ /// Separator to split text documents on. Empty means "whole input is one
88
+ /// document", matching `DocumentIter`/`SeparatorReader` semantics.
89
+ fn separator(&self) -> &[u8] {
90
+ match self {
91
+ DocFormat::Text { separator } => separator.as_deref().unwrap_or(b""),
92
+ DocFormat::Jsonl { .. } | DocFormat::Parquet { .. } => b"",
93
+ }
94
+ }
95
+ }
96
+
97
+ /// Default format for a bare path: JSONL (field "text") if the uncompressed
98
+ /// name ends in .jsonl, parquet (column "text") if it ends in .parquet,
99
+ /// otherwise plain text with the whole file as one document.
100
+ pub fn detect_default_format(path: &Path) -> DocFormat {
101
+ match detect_format(path).0 {
102
+ ContentFormat::Jsonl => DocFormat::Jsonl {
103
+ field: "text".to_string(),
104
+ },
105
+ ContentFormat::Parquet => DocFormat::Parquet {
106
+ column: "text".to_string(),
107
+ },
108
+ ContentFormat::PlainText => DocFormat::Text { separator: None },
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Loading and chunking files for parallel encoding
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /// A file's full contents: mmapped when stored uncompressed, otherwise
117
+ /// decompressed into memory (parallel chunking needs random access).
118
+ pub enum LoadedFile {
119
+ Mmapped(MmappedFile),
120
+ Owned(Vec<u8>),
121
+ }
122
+
123
+ impl LoadedFile {
124
+ pub fn as_bytes(&self) -> &[u8] {
125
+ match self {
126
+ LoadedFile::Mmapped(m) => m.as_bytes(),
127
+ LoadedFile::Owned(v) => v,
128
+ }
129
+ }
130
+ }
131
+
132
+ /// Open a file for encoding: mmap if uncompressed, else decompress fully.
133
+ pub fn load_file(path: &Path) -> Result<LoadedFile, std::io::Error> {
134
+ use std::io::Read;
135
+ let (_, compression) = detect_format(path);
136
+ Ok(match compression {
137
+ Compression::None => LoadedFile::Mmapped(MmappedFile::open(path)?),
138
+ Compression::Gzip => {
139
+ let mut buf = Vec::new();
140
+ decompress::open_gzip(path)?.read_to_end(&mut buf)?;
141
+ LoadedFile::Owned(buf)
142
+ }
143
+ Compression::Zstd => {
144
+ let mut buf = Vec::new();
145
+ decompress::open_zstd(path)?.read_to_end(&mut buf)?;
146
+ LoadedFile::Owned(buf)
147
+ }
148
+ })
149
+ }
150
+
151
+ /// Cut `bytes` into ranges of roughly `target` bytes, each ending on a
152
+ /// document boundary so no document spans two chunks. A single range is
153
+ /// returned when the input is smaller than `target` or has no boundaries
154
+ /// (plain text without a separator is one document, which cannot be split
155
+ /// without changing tokenization).
156
+ pub fn chunk_ranges(
157
+ bytes: &[u8],
158
+ format: &DocFormat,
159
+ target: usize,
160
+ ) -> Vec<std::ops::Range<usize>> {
161
+ let len = bytes.len();
162
+ // `next_boundary(probe)` finds the first document boundary at or after
163
+ // `probe` and returns (chunk_end, next_chunk_start).
164
+ let cut = |next_boundary: &dyn Fn(usize) -> Option<(usize, usize)>| {
165
+ let mut out = Vec::new();
166
+ let mut start = 0;
167
+ while start < len {
168
+ let probe = start + target;
169
+ match (probe < len).then(|| next_boundary(probe)).flatten() {
170
+ Some((end, next_start)) => {
171
+ out.push(start..end);
172
+ start = next_start;
173
+ }
174
+ None => {
175
+ out.push(start..len);
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ if out.is_empty() {
181
+ out.push(0..0); // empty file: one empty chunk, so files stay 1:1
182
+ }
183
+ out
184
+ };
185
+ match format {
186
+ DocFormat::Jsonl { .. } => cut(&|probe| {
187
+ memchr::memchr(b'\n', &bytes[probe..]).map(|off| (probe + off + 1, probe + off + 1))
188
+ }),
189
+ DocFormat::Text { separator: Some(sep) } if !sep.is_empty() => {
190
+ let finder = memchr::memmem::Finder::new(sep);
191
+ cut(&|probe| {
192
+ finder
193
+ .find(&bytes[probe..])
194
+ .map(|off| (probe + off, probe + off + sep.len()))
195
+ })
196
+ }
197
+ // One chunk spanning the whole file (a single Range element, not 0..len values).
198
+ DocFormat::Text { .. } => std::iter::once(0..len).collect(),
199
+ // Parquet documents are materialized before any byte-region chunking
200
+ // (encode_files_ragged and pretokenize_file branch on the format
201
+ // first), so parquet bytes must never arrive here.
202
+ DocFormat::Parquet { .. } => {
203
+ unreachable!("parquet files are materialized into documents before chunking")
204
+ }
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Per-file processing
210
+ // ---------------------------------------------------------------------------
211
+
212
+ fn pretokenize_plain_text_bytes(
213
+ bytes: &[u8],
214
+ separator: &[u8],
215
+ ) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
216
+ let borrowed_counts = pretokenize_par_bytes(bytes, separator);
217
+ borrowed_counts
218
+ .into_iter()
219
+ .map(|(k, v)| (k.as_ref().to_vec(), v))
220
+ .collect()
221
+ }
222
+
223
+ /// Parallel JSONL pretokenization on a memory-mapped byte slice.
224
+ /// Splits at newline boundaries into N chunks, each chunk processes its
225
+ /// JSONL lines independently.
226
+ fn pretokenize_jsonl_par(
227
+ bytes: &[u8],
228
+ field: &str,
229
+ ) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
230
+ let n_threads = rayon::current_num_threads();
231
+ if bytes.is_empty() {
232
+ return HashMap::default();
233
+ }
234
+
235
+ let boundaries = jsonl_chunk_boundaries(bytes, n_threads);
236
+
237
+ boundaries
238
+ .par_windows(2)
239
+ .map(|w| {
240
+ let chunk = &bytes[w[0]..w[1]];
241
+ let mut counts: HashMap<Vec<u8>, usize, FxBuildHasher> = HashMap::default();
242
+ for doc in JsonLinesSlice::new(chunk, field) {
243
+ for pretoken in pretokenize_as_iter(doc.as_ref()) {
244
+ *counts.entry(pretoken.as_ref().to_vec()).or_default() += 1;
245
+ }
246
+ }
247
+ counts
248
+ })
249
+ .reduce(HashMap::default, |mut acc, counts| {
250
+ if acc.is_empty() {
251
+ return counts;
252
+ }
253
+ for (k, v) in counts {
254
+ *acc.entry(k).or_default() += v;
255
+ }
256
+ acc
257
+ })
258
+ }
259
+
260
+ /// Pretokenize documents from a streaming reader.
261
+ /// For JSONL: each line is a document (field extracted from JSON).
262
+ /// For plain text: documents are split on `separator`.
263
+ /// Never buffers the entire decompressed file.
264
+ fn pretokenize_streaming(
265
+ reader: impl std::io::BufRead,
266
+ format: &DocFormat,
267
+ ) -> HashMap<Vec<u8>, usize, FxBuildHasher> {
268
+ let mut counts: HashMap<Vec<u8>, usize, FxBuildHasher> = HashMap::default();
269
+ let mut count_pretokens = |doc: &[u8]| {
270
+ for pretoken in pretokenize_as_iter(doc) {
271
+ *counts.entry(pretoken.as_ref().to_vec()).or_default() += 1;
272
+ }
273
+ };
274
+
275
+ match format {
276
+ DocFormat::Jsonl { field } => {
277
+ for doc in JsonLinesReader::new(reader, field) {
278
+ count_pretokens(doc.as_ref());
279
+ }
280
+ }
281
+ DocFormat::Text { .. } => {
282
+ for doc in SeparatorReader::new(reader, format.separator()) {
283
+ count_pretokens(&doc);
284
+ }
285
+ }
286
+ // pretokenize_file handles parquet before any streaming/compression
287
+ // path (parquet compresses internally).
288
+ DocFormat::Parquet { .. } => {
289
+ unreachable!("parquet files are pretokenized by input::parquet, not streamed")
290
+ }
291
+ }
292
+ counts
293
+ }
294
+
295
+ fn pretokenize_file(
296
+ path: &Path,
297
+ format: &DocFormat,
298
+ compression: Compression,
299
+ ) -> Result<HashMap<Vec<u8>, usize, FxBuildHasher>, std::io::Error> {
300
+ eprintln!("Processing {:?} ({:?}, {:?})", path, format, compression);
301
+
302
+ // Parquet handles its own compression and parallelism (per row group);
303
+ // it never goes through the mmap/streaming byte paths below.
304
+ if let DocFormat::Parquet { column } = format {
305
+ return crate::input::parquet::pretokenize_par(path, column);
306
+ }
307
+
308
+ // Uncompressed files: memory-map for parallel processing
309
+ if matches!(compression, Compression::None) {
310
+ let resource = MmappedFile::open(path)?;
311
+ return Ok(match format {
312
+ DocFormat::Text { .. } => {
313
+ pretokenize_plain_text_bytes(resource.as_bytes(), format.separator())
314
+ }
315
+ DocFormat::Jsonl { field } => pretokenize_jsonl_par(resource.as_bytes(), field),
316
+ DocFormat::Parquet { .. } => unreachable!("handled above"),
317
+ });
318
+ }
319
+
320
+ // Compressed files: stream from reader (never fully in memory)
321
+ Ok(match compression {
322
+ Compression::None => unreachable!(),
323
+ Compression::Gzip => pretokenize_streaming(decompress::open_gzip(path)?, format),
324
+ Compression::Zstd => pretokenize_streaming(decompress::open_zstd(path)?, format),
325
+ })
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // SeparatorReader: streaming document splitter for plain text
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /// Reads from a `BufRead` and yields documents split on a byte separator.
333
+ /// Each `next()` returns one document (the bytes between two separators).
334
+ /// Buffers only the current document, not the entire input.
335
+ struct SeparatorReader<R> {
336
+ reader: R,
337
+ separator: Vec<u8>,
338
+ buf: Vec<u8>,
339
+ finished: bool,
340
+ }
341
+
342
+ impl<R: std::io::BufRead> SeparatorReader<R> {
343
+ fn new(reader: R, separator: &[u8]) -> Self {
344
+ Self {
345
+ reader,
346
+ separator: separator.to_vec(),
347
+ buf: Vec::with_capacity(4096),
348
+ finished: false,
349
+ }
350
+ }
351
+ }
352
+
353
+ impl<R: std::io::BufRead> Iterator for SeparatorReader<R> {
354
+ type Item = Vec<u8>;
355
+
356
+ fn next(&mut self) -> Option<Vec<u8>> {
357
+ if self.finished {
358
+ return None;
359
+ }
360
+
361
+ // Empty separator: yield all remaining bytes as one document
362
+ if self.separator.is_empty() {
363
+ self.finished = true;
364
+ let mut all = Vec::new();
365
+ self.reader.read_to_end(&mut all).ok()?;
366
+ return if all.is_empty() { None } else { Some(all) };
367
+ }
368
+
369
+ let finder = memchr::memmem::Finder::new(&self.separator);
370
+ self.buf.clear();
371
+
372
+ loop {
373
+ let available = match self.reader.fill_buf() {
374
+ Ok([]) => {
375
+ // EOF
376
+ self.finished = true;
377
+ return if self.buf.is_empty() {
378
+ None
379
+ } else {
380
+ Some(std::mem::take(&mut self.buf))
381
+ };
382
+ }
383
+ Ok(buf) => buf,
384
+ Err(_) => {
385
+ self.finished = true;
386
+ return None;
387
+ }
388
+ };
389
+
390
+ // Search for separator in: [tail of buf where separator could span] + available
391
+ // We need to handle the case where the separator spans the boundary between
392
+ // self.buf and the new `available` data.
393
+ let overlap = self.separator.len().saturating_sub(1).min(self.buf.len());
394
+ let search_start = self.buf.len() - overlap;
395
+
396
+ // Append available to buf, then search from search_start
397
+ self.buf.extend_from_slice(available);
398
+ let consumed = available.len();
399
+ self.reader.consume(consumed);
400
+
401
+ if let Some(pos) = finder.find(&self.buf[search_start..]) {
402
+ let sep_start = search_start + pos;
403
+ let doc = self.buf[..sep_start].to_vec();
404
+ // Keep everything after the separator for the next call
405
+ let remainder_start = sep_start + self.separator.len();
406
+ let remainder = self.buf[remainder_start..].to_vec();
407
+ self.buf = remainder;
408
+
409
+ // Skip empty documents
410
+ if doc.is_empty() {
411
+ continue;
412
+ }
413
+ return Some(doc);
414
+ }
415
+ // No separator found yet — continue reading
416
+ }
417
+ }
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // FileSourceSpec — multi-file parallel pretokenization
422
+ // ---------------------------------------------------------------------------
423
+
424
+ pub struct FileSourceSpec {
425
+ pub paths: Vec<PathBuf>,
426
+ pub format: DocFormat,
427
+ }
428
+
429
+ /// Newline-aligned chunk boundaries for parallel JSONL processing.
430
+ fn jsonl_chunk_boundaries(bytes: &[u8], n_chunks: usize) -> Vec<usize> {
431
+ let mut boundaries = Vec::with_capacity(n_chunks + 1);
432
+ boundaries.push(0usize);
433
+ let chunk_size = bytes.len() / n_chunks;
434
+ for i in 1..n_chunks {
435
+ let target = i * chunk_size;
436
+ match memchr::memchr(b'\n', &bytes[target..]) {
437
+ Some(offset) => boundaries.push(target + offset + 1),
438
+ None => break,
439
+ }
440
+ }
441
+ boundaries.push(bytes.len());
442
+ boundaries.dedup();
443
+ boundaries
444
+ }
445
+
446
+ impl FileSourceSpec {
447
+ pub fn pretokenize(&self) -> Result<HashMap<Vec<u8>, usize, FxBuildHasher>, std::io::Error> {
448
+ eprintln!(
449
+ "FileSource: processing {} files across {} threads",
450
+ self.paths.len(),
451
+ rayon::current_num_threads()
452
+ );
453
+
454
+ self.paths
455
+ .par_iter()
456
+ .map(|path| {
457
+ let (_, compression) = detect_format(path);
458
+ pretokenize_file(path, &self.format, compression)
459
+ })
460
+ .try_reduce(HashMap::default, |mut acc, counts| {
461
+ if acc.is_empty() {
462
+ return Ok(counts);
463
+ }
464
+ for (k, v) in counts {
465
+ *acc.entry(k).or_default() += v;
466
+ }
467
+ Ok(acc)
468
+ })
469
+ }
470
+ }
471
+
472
+ #[cfg(test)]
473
+ mod tests {
474
+ use super::*;
475
+
476
+ #[test]
477
+ fn test_detect_compression() {
478
+ assert!(matches!(detect_compression("data.jsonl.zst"), (_, Compression::Zstd)));
479
+ assert!(matches!(detect_compression("data.jsonl.zstd"), (_, Compression::Zstd)));
480
+ assert!(matches!(detect_compression("data.txt.gz"), (_, Compression::Gzip)));
481
+ assert!(matches!(detect_compression("data.jsonl"), (_, Compression::None)));
482
+ assert!(matches!(detect_compression("data.txt"), (_, Compression::None)));
483
+ }
484
+
485
+ #[test]
486
+ fn test_detect_compression_strips_ext() {
487
+ assert_eq!(detect_compression("data.jsonl.zst").0, "data.jsonl");
488
+ assert_eq!(detect_compression("data.jsonl.zstd").0, "data.jsonl");
489
+ assert_eq!(detect_compression("data.txt.gz").0, "data.txt");
490
+ assert_eq!(detect_compression("data.jsonl.gz").0, "data.jsonl");
491
+ assert_eq!(detect_compression("data.txt").0, "data.txt");
492
+ }
493
+
494
+ #[test]
495
+ fn test_detect_format_combinations() {
496
+ // jsonl + compression
497
+ assert!(matches!(detect_format(Path::new("data.jsonl.zst")), (ContentFormat::Jsonl, Compression::Zstd)));
498
+ assert!(matches!(detect_format(Path::new("data.jsonl.zstd")), (ContentFormat::Jsonl, Compression::Zstd)));
499
+ assert!(matches!(detect_format(Path::new("data.jsonl.gz")), (ContentFormat::Jsonl, Compression::Gzip)));
500
+ assert!(matches!(detect_format(Path::new("data.jsonl")), (ContentFormat::Jsonl, Compression::None)));
501
+
502
+ // txt + compression
503
+ assert!(matches!(detect_format(Path::new("data.txt.zst")), (ContentFormat::PlainText, Compression::Zstd)));
504
+ assert!(matches!(detect_format(Path::new("data.txt.gz")), (ContentFormat::PlainText, Compression::Gzip)));
505
+ assert!(matches!(detect_format(Path::new("data.txt")), (ContentFormat::PlainText, Compression::None)));
506
+
507
+ // bare compression extension → plain text (unknown inner format)
508
+ assert!(matches!(detect_format(Path::new("data.zst")), (ContentFormat::PlainText, Compression::Zstd)));
509
+ assert!(matches!(detect_format(Path::new("data.gz")), (ContentFormat::PlainText, Compression::Gzip)));
510
+
511
+ // unknown → plain text, no compression
512
+ assert!(matches!(detect_format(Path::new("data.csv")), (ContentFormat::PlainText, Compression::None)));
513
+ }
514
+ }
@@ -0,0 +1,94 @@
1
+ use std::io::BufRead;
2
+
3
+ use crate::input::Document;
4
+ use sonic_rs::JsonValueTrait;
5
+
6
+ /// Zero-copy JSONL iterator over a byte slice (e.g. from mmap).
7
+ /// Used for parallel chunking of uncompressed JSONL files.
8
+ pub struct JsonLinesSlice<'a> {
9
+ slice: &'a [u8],
10
+ position: usize,
11
+ field: &'a str,
12
+ }
13
+
14
+ impl<'a> JsonLinesSlice<'a> {
15
+ pub fn new(slice: &'a [u8], field: &'a str) -> Self {
16
+ Self {
17
+ slice,
18
+ position: 0,
19
+ field,
20
+ }
21
+ }
22
+ }
23
+
24
+ impl<'a> Iterator for JsonLinesSlice<'a> {
25
+ type Item = Document<'static>; // Owned: JSON parsing requires extraction
26
+
27
+ fn next(&mut self) -> Option<Self::Item> {
28
+ loop {
29
+ // Skip newlines
30
+ while self.position < self.slice.len() && self.slice[self.position] == b'\n' {
31
+ self.position += 1;
32
+ }
33
+ if self.position >= self.slice.len() {
34
+ return None;
35
+ }
36
+
37
+ let line_end = memchr::memchr(b'\n', &self.slice[self.position..])
38
+ .map(|i| self.position + i)
39
+ .unwrap_or(self.slice.len());
40
+ let line = &self.slice[self.position..line_end];
41
+ self.position = line_end + 1;
42
+
43
+ if line.is_empty() {
44
+ continue;
45
+ }
46
+
47
+ let value = sonic_rs::get_from_slice(line, &[self.field]).ok()?;
48
+ let text = value.as_str()?;
49
+ return Some(Document::from(text.as_bytes().to_vec()));
50
+ }
51
+ }
52
+ }
53
+
54
+ /// Streaming JSONL iterator over a `BufRead` source.
55
+ /// Reads one line at a time — never buffers the entire file.
56
+ pub(crate) struct JsonLinesReader<R> {
57
+ reader: R,
58
+ field: String,
59
+ line_buf: Vec<u8>,
60
+ }
61
+
62
+ impl<R: BufRead> JsonLinesReader<R> {
63
+ pub(crate) fn new(reader: R, field: &str) -> Self {
64
+ Self {
65
+ reader,
66
+ field: field.to_string(),
67
+ line_buf: Vec::with_capacity(4096),
68
+ }
69
+ }
70
+ }
71
+
72
+ impl<R: BufRead> Iterator for JsonLinesReader<R> {
73
+ type Item = Document<'static>;
74
+
75
+ fn next(&mut self) -> Option<Self::Item> {
76
+ loop {
77
+ self.line_buf.clear();
78
+ let bytes_read = self.reader.read_until(b'\n', &mut self.line_buf).ok()?;
79
+ if bytes_read == 0 {
80
+ return None; // EOF
81
+ }
82
+
83
+ let line = self.line_buf.as_slice();
84
+ // Skip empty lines
85
+ if line.iter().all(|&b| b == b'\n' || b == b'\r') {
86
+ continue;
87
+ }
88
+
89
+ let value = sonic_rs::get_from_slice(line, &[self.field.as_str()]).ok()?;
90
+ let text = value.as_str()?;
91
+ return Some(Document::from(text.as_bytes().to_vec()));
92
+ }
93
+ }
94
+ }