gigatoken 0.1.1 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d36a860419519d55bf6d7f2fe8c30aa9c4bc25befe98ce810313758be8bf5380
4
- data.tar.gz: 0fda91b895be4ef02de36cb16832628203bba09de6c53955997b69611e796bce
3
+ metadata.gz: 071b3abb52387367f392e88e50d92c7413f5f6c70dae24accf6d378ad6e78fd8
4
+ data.tar.gz: 26da76745e877b6ee30821ab5fcae50aefdb6ecddcd660e9b8c7b48b4d94bd06
5
5
  SHA512:
6
- metadata.gz: 65d3987a0943a5552a8e8b8493de03df9d37ac376cfc9dfc661a6c25a38cc03cd511ed4141b98580e4bd2cff64086802ad25705f9e841fb09cae779bafce0a71
7
- data.tar.gz: 34eace98922cb0ef2cf2a4fbecc103131bdfaa26dad459254a1d458efedc28154f3b103dd3200e106f94988d2028aa66370e680c6acee284148f707a0212288b
6
+ metadata.gz: 402d1e74262ffd0d4ff8d6f6162c944747b75c567249f788fdf46d2c5e09cd001f1cd66d037156848023fb5aad62087c73def5c4d3134d2aba7a898575ce2631
7
+ data.tar.gz: c43c48249a2c7bd0d8146e20d830975dae6e3acec93b1bc42e125fdd72050da38d02d0a10068b6ad208c85bd1790a54629b3b2394c1b12762feddce834909995
data/Cargo.lock CHANGED
@@ -735,7 +735,7 @@ dependencies = [
735
735
 
736
736
  [[package]]
737
737
  name = "gigatoken"
738
- version = "0.9.0"
738
+ version = "0.10.0"
739
739
  dependencies = [
740
740
  "aho-corasick",
741
741
  "arrow-array",
@@ -771,7 +771,7 @@ dependencies = [
771
771
 
772
772
  [[package]]
773
773
  name = "gigatoken-rb"
774
- version = "0.1.1"
774
+ version = "0.2.0"
775
775
  dependencies = [
776
776
  "gigatoken",
777
777
  "magnus",
data/Cargo.toml CHANGED
@@ -3,7 +3,7 @@ members = [".", "ext/gigatoken"]
3
3
 
4
4
  [package]
5
5
  name = "gigatoken"
6
- version = "0.9.0"
6
+ version = "0.10.0"
7
7
  edition = "2024"
8
8
  license = "MIT"
9
9
 
data/README.md CHANGED
@@ -48,17 +48,50 @@ tok.vocab_size # => 50257
48
48
  tok.special_tokens # => {"<|endoftext|>" => 50256}
49
49
  ```
50
50
 
51
- `load` takes a `tokenizer.json` path, a directory holding one, a HuggingFace Hub repo id, or a `.tiktoken` mergeable-ranks file, and dispatches on shape. Hub downloads run over socketry's `async-http` — no Python anywhere. Know what you have? Skip the dispatch:
51
+ `load` takes a `tokenizer.json` path, a directory holding one, a packaged tiktoken encoding name (`r50k_base`, `cl100k_base`, `o200k_base`), a HuggingFace Hub repo id, or a `.tiktoken` mergeable-ranks file, and dispatches on shape. Hub downloads run over socketry's `async-http` — no Python anywhere. Know what you have? Skip the dispatch:
52
52
 
53
53
  ```ruby
54
54
  Gigatoken::Tokenizer.from_file("tokenizer.json")
55
55
  Gigatoken::Tokenizer.from_hub("openai-community/gpt2", revision: "main")
56
- Gigatoken::Tokenizer.from_tiktoken("vocab.tiktoken")
56
+ Gigatoken::Tokenizer.from_tiktoken("cl100k_base.tiktoken", pretokenizer: "gpt4", special_tokens: {"<|endoftext|>" => 100257})
57
57
  Gigatoken::Tokenizer.from_json(File.binread("tokenizer.json"))
58
58
  ```
59
59
 
60
+ A `.tiktoken` file holds mergeable ranks only — its pretokenization scheme and special tokens live in the code that defines the encoding, not the file — so `pretokenizer:` is a required keyword (one of `Gigatoken::Native.pretokenizer_names`: `gpt2`/`r50k`, `gpt4`/`cl100k`, `qwen2`, `qwen35`, `olmo3`, `deepseek_v3`, `o200k`, `nemotron`, `kimi`) and `special_tokens:` defaults to none. Nothing is guessed: an unknown scheme raises `Gigatoken::Error` naming the valid ones, and `Tokenizer.load` on a `.tiktoken` path with no `pretokenizer:` raises rather than silently picking one.
61
+
60
62
  SentencePiece-BPE models (Llama, Gemma, Mistral — any `tokenizer.json` with `byte_fallback: true`) load through the same entry points and pick the right backend automatically. One difference: the SentencePiece core decodes text, so it validates input and raises `Gigatoken::Error` on invalid UTF-8 instead of guessing.
61
63
 
64
+ ### Packaged tiktoken encodings
65
+
66
+ `r50k_base`, `cl100k_base`, `o200k_base`, and `o200k_harmony` are vendored directly — mergeable ranks, pretokenizer scheme, and special-token table all shipped inside the gem (`lib/gigatoken/encodings/`; see `PROVENANCE.md` there for exact source URLs and hashes) — so all four resolve by name through both entry points entirely offline: no network access, no writable cache directory. `o200k_harmony` vendors no new file at all: it reuses `o200k_base.tiktoken`'s ranks and the `o200k` scheme verbatim, differing only in its special-token table (10 named control tokens — `<|start|>`, `<|message|>`, `<|end|>`, `<|return|>`, and so on — plus 1081 reserved slots; see `PROVENANCE.md` for the exact table). It's also the one packaged encoding not checked against `tiktoken_ruby`: that gem's 0.0.17 harmony table drops `<|endofprompt|>` where `openai/tiktoken` 0.9.0 keeps it at id 200018, so the oracle is the outlier here — `spec/gigatoken/differential_spec.rb` proves harmony instead by reduction to `o200k_base` plus a pinned special-token table.
67
+
68
+ ```ruby
69
+ Gigatoken::Tokenizer.from_encoding("cl100k_base")
70
+ Gigatoken::Tokenizer.load("cl100k_base") # same result — packaged names are
71
+ # checked before the Hub-repo-id shape
72
+ ```
73
+
74
+ `p50k_base` and `p50k_edit` are deliberately not packaged: both load the same non-dense ranks (id 50256 is left free for `<|endoftext|>`), and the rank loader rejects non-dense ranks. Both entry points raise `Gigatoken::Error` explaining that, rather than `load` falling through to the Hub for a name that happens to look like a legacy repo id.
75
+
76
+ `encode` on a packaged tokenizer honours its special-token table: text containing `<|endoftext|>` (or any other literal special-token string) is tokenized as that special token, not as ordinary text. That matches [`tiktoken`](https://github.com/openai/tiktoken)'s `encode_with_special_tokens`, not its plain `encode`, which treats the same literal as ordinary text — a difference worth knowing if you're tokenizing untrusted input. To get tiktoken's non-honouring default instead, build a tokenizer from the same rank file with an empty special-token table:
77
+
78
+ ```ruby
79
+ entry = Gigatoken::Encodings["cl100k_base"]
80
+ Gigatoken::Tokenizer.from_tiktoken(entry[:rank_file], pretokenizer: entry[:pretokenizer], special_tokens: {})
81
+ ```
82
+
83
+ ### Encode-cache budget
84
+
85
+ Each tokenizer's pretoken cache is capped process-globally (512 MiB per worker by default) so long-lived processes — a Rails worker, say — don't grow it unbounded; a full cache wipes back toward its seed level and refills, which costs a bit of re-computation but never changes encode output. Tune it before building tokenizers you want the new budget to apply to:
86
+
87
+ ```ruby
88
+ Gigatoken.max_cache_bytes # => 536870912 (512 MiB)
89
+ Gigatoken.max_cache_bytes = 64 << 20 # only tokenizers built after this see the new budget
90
+ Gigatoken.max_cache_bytes = nil # unbounded
91
+
92
+ tok.cache_entries # => cached pretoken/unit count right now
93
+ ```
94
+
62
95
  ### Tokenize files without leaving Rust
63
96
 
64
97
  `encode_files` reads and tokenizes files entirely on the native side — document contents never materialize as Ruby objects. `.gz` and `.zst` decompress transparently.
@@ -97,6 +130,14 @@ gigatoken validate openai-community/gpt2 owt_train.txt --doc-separator "<|endoft
97
130
 
98
131
  `bench` reports MB/s and Mtok/s (`--packed` for the fused packed path, `--no-parallel` for the serial core). `validate` confirms native split-and-encode agrees with a Ruby-side split through `encode_batch`.
99
132
 
133
+ TOKENIZER also takes a bare `.tiktoken` file, which is where `--pretokenizer` comes in: the file carries mergeable ranks only, so the split regex has to come from the caller, same as `from_tiktoken` above. `--pretokenizer` takes one of the scheme names listed above for `pretokenizer:`:
134
+
135
+ ```bash
136
+ gigatoken bench lib/gigatoken/encodings/cl100k_base.tiktoken README.md --pretokenizer gpt4
137
+ ```
138
+
139
+ Leave it off against a `.tiktoken` TOKENIZER and both commands raise `Gigatoken::Error` naming the valid schemes instead of crashing; for every other TOKENIZER shape (`tokenizer.json`, a packaged name, a Hub repo id) `--pretokenizer` is accepted but ignored.
140
+
100
141
  ## Development
101
142
 
102
143
  ```bash
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "gigatoken-rb"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -0,0 +1,37 @@
1
+ //! The process-global cache-budget knob, read once per tokenizer
2
+ //! construction. Mirrors the pyo3 mechanism in the core crate's
3
+ //! `src/bindings/cache.rs`, which is `pub(crate)` in a different crate and
4
+ //! so cannot be called directly from here — the encode-cache semantics
5
+ //! themselves live in `gigatoken_rs::Tokenizer::set_max_cache_bytes` and
6
+ //! `gigatoken_rs::SentencePieceBPE::set_max_cache_bytes`.
7
+
8
+ use gigatoken_rs::{SentencePieceBPE, Tokenizer};
9
+ use std::sync::Mutex;
10
+
11
+ /// The budget applied to tokenizers constructed after the last
12
+ /// `set_max_cache_bytes` call; `None` = unbounded.
13
+ static MAX_CACHE_BYTES: Mutex<Option<usize>> = Mutex::new(Some(Tokenizer::DEFAULT_MAX_CACHE_BYTES));
14
+
15
+ /// Apply the global setting to a freshly constructed tokenizer (which
16
+ /// already carries the built-in default, hence the `!=` skip).
17
+ pub(crate) fn apply_max_cache_bytes(mut tokenizer: Tokenizer) -> Tokenizer {
18
+ let configured = *MAX_CACHE_BYTES.lock().unwrap();
19
+ if configured != tokenizer.max_cache_bytes() {
20
+ tokenizer.set_max_cache_bytes(configured);
21
+ }
22
+ tokenizer
23
+ }
24
+
25
+ /// SentencePiece analog of [`apply_max_cache_bytes`].
26
+ pub(crate) fn apply_max_cache_bytes_sp(mut model: SentencePieceBPE) -> SentencePieceBPE {
27
+ model.set_max_cache_bytes(*MAX_CACHE_BYTES.lock().unwrap());
28
+ model
29
+ }
30
+
31
+ pub(crate) fn set_max_cache_bytes(max_bytes: Option<usize>) {
32
+ *MAX_CACHE_BYTES.lock().unwrap() = max_bytes;
33
+ }
34
+
35
+ pub(crate) fn get_max_cache_bytes() -> Option<usize> {
36
+ *MAX_CACHE_BYTES.lock().unwrap()
37
+ }
@@ -1,4 +1,5 @@
1
1
  use gigatoken_rs::load_tokenizer::hf::{self, HfTokenizer};
2
+ use gigatoken_rs::pretokenize::PretokenizerType;
2
3
  use magnus::{Error, Module, RString, Ruby, Value, function};
3
4
 
4
5
  // XZM-WORKAROUND: macOS 26's xzm malloc zone SIGTRAPs on multi-GB Rust chunk
@@ -7,6 +8,7 @@ use magnus::{Error, Module, RString, Ruby, Value, function};
7
8
  #[global_allocator]
8
9
  static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
9
10
 
11
+ mod cache;
10
12
  mod error;
11
13
  mod gvl;
12
14
  mod sentencepiece;
@@ -23,6 +25,26 @@ fn crate_version() -> String {
23
25
  env!("CARGO_PKG_VERSION").to_string()
24
26
  }
25
27
 
28
+ /// The pretokenizer scheme names `BPETokenizer.from_tiktoken` and
29
+ /// `Tokenizer.load` accept — the single source of truth `Gigatoken::Tokenizer.load`
30
+ /// names in its "no scheme for a .tiktoken path" error, so that list can
31
+ /// never drift from the one `PretokenizerType::from_name` actually accepts.
32
+ fn pretokenizer_names() -> Vec<&'static str> {
33
+ PretokenizerType::NAMES.to_vec()
34
+ }
35
+
36
+ /// The process-global encode-cache budget in bytes per worker, applied to
37
+ /// tokenizers constructed afterward; `None` removes the bound. Mirrors
38
+ /// pyo3's `set_max_cache_bytes`/`get_max_cache_bytes` (`src/bindings/cache.rs`
39
+ /// in the core crate) over the same public core API.
40
+ fn set_max_cache_bytes(max_bytes: Option<usize>) {
41
+ cache::set_max_cache_bytes(max_bytes);
42
+ }
43
+
44
+ fn get_max_cache_bytes() -> Option<usize> {
45
+ cache::get_max_cache_bytes()
46
+ }
47
+
26
48
  /// Load a tokenizer from in-memory HuggingFace `tokenizer.json` contents.
27
49
  /// Returns a `SentencePieceTokenizer` when the model uses `byte_fallback`, a
28
50
  /// `BPETokenizer` otherwise — the same split as pyo3's `load_hf_json` and
@@ -43,6 +65,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
43
65
  let native = gigatoken.define_module("Native")?;
44
66
  native.define_module_function("crate_version", function!(crate_version, 0))?;
45
67
  native.define_module_function("load_hf_json", function!(load_hf_json, 1))?;
68
+ native.define_module_function("pretokenizer_names", function!(pretokenizer_names, 0))?;
69
+ native.define_module_function("set_max_cache_bytes", function!(set_max_cache_bytes, 1))?;
70
+ native.define_module_function("get_max_cache_bytes", function!(get_max_cache_bytes, 0))?;
46
71
  sources::init(ruby, native)?;
47
72
  tokenizer::init(ruby, native)?;
48
73
  sentencepiece::init(ruby, native)?;
@@ -42,10 +42,14 @@ pub struct SentencePieceTokenizer {
42
42
  }
43
43
 
44
44
  impl SentencePieceTokenizer {
45
+ /// Wrap a loaded model, applying the global cache budget to it and to
46
+ /// the single-document encode state — mirrors pyo3's `with_model`.
45
47
  pub(crate) fn from_tokenizer(tokenizer: SentencePieceBPE) -> Self {
48
+ let tokenizer = crate::cache::apply_max_cache_bytes_sp(tokenizer);
49
+ let state = EncodeState::with_budget(tokenizer.max_cache_bytes());
46
50
  Self {
47
51
  tokenizer: RefCell::new(tokenizer),
48
- state: RefCell::new(EncodeState::new()),
52
+ state: RefCell::new(state),
49
53
  }
50
54
  }
51
55
 
@@ -188,6 +192,12 @@ impl SentencePieceTokenizer {
188
192
  }
189
193
  Ok(result)
190
194
  }
195
+
196
+ /// Cached unit entries on the single-document `encode` path's state
197
+ /// (batch encoders are per-call); see `BPETokenizer::cache_entries`.
198
+ fn cache_entries(&self) -> usize {
199
+ self.state.borrow().cache_size()
200
+ }
191
201
  }
192
202
 
193
203
  pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
@@ -201,5 +211,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
201
211
  class.define_method("vocab_size", method!(SentencePieceTokenizer::vocab_size, 0))?;
202
212
  class.define_method("vocab", method!(SentencePieceTokenizer::vocab, 0))?;
203
213
  class.define_method("merges", method!(SentencePieceTokenizer::merges, 0))?;
214
+ class.define_method("cache_entries", method!(SentencePieceTokenizer::cache_entries, 0))?;
204
215
  Ok(())
205
216
  }
@@ -9,6 +9,7 @@ use std::os::raw::c_long;
9
9
 
10
10
  use gigatoken_rs::load_tokenizer::hf::HfTokenizer;
11
11
  use gigatoken_rs::load_tokenizer::{hf, tiktoken};
12
+ use gigatoken_rs::pretokenize::PretokenizerType;
12
13
  use gigatoken_rs::{
13
14
  GatherBuf, GatherOutcome, Tokenizer, WorkerPool, encode_docs_into, encode_docs_ragged,
14
15
  encode_files_docs, encode_files_docs_serial,
@@ -359,7 +360,7 @@ pub struct BPETokenizer {
359
360
  impl BPETokenizer {
360
361
  pub(crate) fn from_tokenizer(tokenizer: Tokenizer) -> Self {
361
362
  Self {
362
- tokenizer: RefCell::new(tokenizer),
363
+ tokenizer: RefCell::new(crate::cache::apply_max_cache_bytes(tokenizer)),
363
364
  workers: WorkerPool::new(),
364
365
  }
365
366
  }
@@ -379,8 +380,28 @@ impl BPETokenizer {
379
380
  }
380
381
  }
381
382
 
382
- fn from_tiktoken(ruby: &Ruby, path: String) -> Result<Self, Error> {
383
- match tiktoken::load_tiktoken(&path) {
383
+ /// Load from a .tiktoken rank file with the named pretokenizer scheme
384
+ /// and a {content => id} mapping of special tokens. The file carries
385
+ /// neither, so both are the caller's to supply — see
386
+ /// `Gigatoken::Tokenizer.from_tiktoken`, which knows them for the
387
+ /// encodings OpenAI publishes.
388
+ fn from_tiktoken(
389
+ ruby: &Ruby,
390
+ path: String,
391
+ pretokenizer: String,
392
+ special_tokens: HashMap<String, u32>,
393
+ ) -> Result<Self, Error> {
394
+ let scheme = PretokenizerType::from_name(&pretokenizer).ok_or_else(|| {
395
+ raise(
396
+ ruby,
397
+ format!(
398
+ "unknown pretokenizer scheme {pretokenizer:?}; expected one of {}",
399
+ PretokenizerType::NAMES.join(", ")
400
+ ),
401
+ )
402
+ })?;
403
+ let special_tokens: Vec<(String, u32)> = special_tokens.into_iter().collect();
404
+ match tiktoken::load_tiktoken(&path, scheme, special_tokens) {
384
405
  Ok(tokenizer) => Ok(Self::from_tokenizer(tokenizer)),
385
406
  Err(e) => Err(raise(ruby, e.to_string())),
386
407
  }
@@ -552,12 +573,19 @@ impl BPETokenizer {
552
573
  }
553
574
  Ok(result)
554
575
  }
576
+
577
+ /// Cached pretoken entries on this tokenizer: grows as text is encoded,
578
+ /// drops back toward vocab-seed level when a budgeted cache wipes (see
579
+ /// `Gigatoken.max_cache_bytes`).
580
+ fn cache_entries(&self) -> usize {
581
+ self.tokenizer.borrow().cache_entries()
582
+ }
555
583
  }
556
584
 
557
585
  pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
558
586
  let class: RClass = native.define_class("BPETokenizer", ruby.class_object())?;
559
587
  class.define_singleton_method("from_hf_json", function!(BPETokenizer::from_hf_json, 1))?;
560
- class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken, 1))?;
588
+ class.define_singleton_method("from_tiktoken", function!(BPETokenizer::from_tiktoken, 3))?;
561
589
  class.define_method("encode", method!(BPETokenizer::encode, 1))?;
562
590
  class.define_method("encode_batch", method!(BPETokenizer::encode_batch, 1))?;
563
591
  class.define_method("encode_batch_packed", method!(BPETokenizer::encode_batch_packed, 1))?;
@@ -567,5 +595,6 @@ pub fn init(ruby: &Ruby, native: RModule) -> Result<(), Error> {
567
595
  class.define_method("vocab_size", method!(BPETokenizer::vocab_size, 0))?;
568
596
  class.define_method("vocab", method!(BPETokenizer::vocab, 0))?;
569
597
  class.define_method("merges", method!(BPETokenizer::merges, 0))?;
598
+ class.define_method("cache_entries", method!(BPETokenizer::cache_entries, 0))?;
570
599
  Ok(())
571
600
  }
@@ -10,19 +10,20 @@ module Gigatoken
10
10
  class Bench < Dry::CLI::Command
11
11
  desc "Measure the time to encode FILES with TOKENIZER"
12
12
 
13
- argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
13
+ argument :tokenizer, required: true, desc: "tokenizer.json path or directory, packaged encoding name (e.g. \"cl100k_base\"), HuggingFace repo id, or .tiktoken file (requires --pretokenizer)"
14
14
  argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
15
15
 
16
16
  option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
17
17
  option :limit_bytes, default: "none", desc: "cap the bytes benchmarked, e.g. 100MB; 'none' for everything (parallel mode only — ignored with --no-parallel)"
18
18
  option :parallel, type: :boolean, default: true, desc: "encode on the worker pool instead of the fused serial core path"
19
19
  option :packed, type: :boolean, default: false, desc: "time the fused native file path with a packed IO::Buffer result instead of per-document Ruby arrays (ignores --limit-bytes)"
20
+ option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
20
21
 
21
- def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, **)
22
+ def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, pretokenizer: nil, **)
22
23
  limit = Support.parse_size(limit_bytes)
23
24
  out.puts "#{label("cpu")}: #{Support.cpu_info}"
24
25
 
25
- gt_tokenizer = Support.load_tokenizer(tokenizer)
26
+ gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
26
27
 
27
28
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
29
  if packed
@@ -14,10 +14,13 @@ module Gigatoken
14
14
  private_constant :SIZE_PATTERN
15
15
 
16
16
  class << self
17
- # Load TOKENIZER: a tokenizer.json path/directory, a HuggingFace
18
- # repo id, or a .tiktoken file — see Gigatoken::Tokenizer.load.
19
- def load_tokenizer(spec)
20
- Gigatoken::Tokenizer.load(spec)
17
+ # Load TOKENIZER: a tokenizer.json path/directory, a packaged
18
+ # tiktoken encoding name, a HuggingFace repo id, or a .tiktoken file
19
+ # — see Gigatoken::Tokenizer.load. `pretokenizer:` is forwarded
20
+ # as-is; it's required for a bare .tiktoken path (which carries no
21
+ # scheme of its own) and ignored for the other shapes.
22
+ def load_tokenizer(spec, pretokenizer: nil)
23
+ Gigatoken::Tokenizer.load(spec, pretokenizer: pretokenizer)
21
24
  end
22
25
 
23
26
  # Parse a decimal byte size like "100MB", "2.5GB", or "1000000";
@@ -13,13 +13,14 @@ module Gigatoken
13
13
  class Validate < Dry::CLI::Command
14
14
  desc "Check that encode_files agrees with a Ruby-side split plus encode_batch on FILES"
15
15
 
16
- argument :tokenizer, required: true, desc: "tokenizer.json path or directory, HuggingFace repo id, or .tiktoken file"
16
+ argument :tokenizer, required: true, desc: "tokenizer.json path or directory, packaged encoding name (e.g. \"cl100k_base\"), HuggingFace repo id, or .tiktoken file (requires --pretokenizer)"
17
17
  argument :files, type: :array, required: true, desc: "UTF-8 text files to encode"
18
18
 
19
19
  option :doc_separator, desc: 'document separator to split the files on, e.g. "<|endoftext|>"; whole files are single documents otherwise'
20
+ option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
20
21
 
21
- def call(tokenizer:, files:, doc_separator: nil, **)
22
- gt_tokenizer = Support.load_tokenizer(tokenizer)
22
+ def call(tokenizer:, files:, doc_separator: nil, pretokenizer: nil, **)
23
+ gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
23
24
 
24
25
  via_files = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator))
25
26
  via_batch = gt_tokenizer.encode_batch(Support.split_docs(files, doc_separator))
@@ -0,0 +1,110 @@
1
+ # Vendored tiktoken encodings — provenance
2
+
3
+ The `.tiktoken` files in this directory are OpenAI's published BPE mergeable-rank
4
+ tables, vendored verbatim so `gigatoken` can resolve these encodings by name with
5
+ no network access and no writable cache directory.
6
+
7
+ Each file is a plain text table: one `base64(token_bytes) rank` pair per line. It
8
+ carries **mergeable ranks only** — the pretokenizer split regex and the special
9
+ tokens belong to the encoding's *definition*, not to the file, and live in code
10
+ (see the table below).
11
+
12
+ They live under `lib/` rather than a top-level `data/` directory because this
13
+ repo's `.gitignore` ignores `/data/` ("downloaded test data"); these are shipped
14
+ gem payload, not test fixtures.
15
+
16
+ ## Files
17
+
18
+ | File | Bytes | sha256 |
19
+ |------|------:|--------|
20
+ | `r50k_base.tiktoken` | 835,554 | `306cd27f03c1a714eca7108e03d66b7dc042abe8c258b44c199a7ed9838dd930` |
21
+ | `cl100k_base.tiktoken` | 1,681,126 | `223921b76ee99bde995b7ff738513eef100fb51d18c93597a113bcffe865b2a7` |
22
+ | `o200k_base.tiktoken` | 3,613,922 | `446a9538cb6c348e3516120d7c08b09f57c36495e2acfffe59a5bf8b0cfb1a2d` |
23
+
24
+ ## Source
25
+
26
+ Retrieved **2026-08-10** over HTTPS from OpenAI's public encodings endpoint:
27
+
28
+ ```
29
+ https://openaipublic.blob.core.windows.net/encodings/r50k_base.tiktoken
30
+ https://openaipublic.blob.core.windows.net/encodings/cl100k_base.tiktoken
31
+ https://openaipublic.blob.core.windows.net/encodings/o200k_base.tiktoken
32
+ ```
33
+
34
+ These are the same URLs `openai/tiktoken` itself fetches from, in
35
+ [`tiktoken_ext/openai_public.py`](https://github.com/openai/tiktoken/blob/main/tiktoken_ext/openai_public.py).
36
+
37
+ ## Authenticity
38
+
39
+ Verified three independent ways at retrieval time:
40
+
41
+ 1. **Transport** — HTTPS directly from `openaipublic.blob.core.windows.net`, the
42
+ origin OpenAI publishes and `tiktoken` itself downloads from.
43
+ 2. **Publisher checksum** — each measured sha256 above matches the `expected_hash`
44
+ OpenAI publishes for that file in `openai_public.py`, fetched separately from
45
+ `github.com/openai/tiktoken`. Two independent channels agree on the bytes.
46
+ 3. **Behavioral, against a third-party implementation** — loaded through
47
+ `gigatoken` with the pretokenizer and special tokens below, every token id
48
+ matched [`tiktoken_ruby`](https://github.com/IAPark/tiktoken_ruby) 0.0.17
49
+ (which embeds its own copy of these ranks) across a corpus covering ASCII,
50
+ CJK, ZWJ emoji sequences, combining accents, whitespace runs, source code,
51
+ and URLs. Resulting `vocab_size`: 50257 / 100277 / 200019.
52
+
53
+ ## Encoding definitions
54
+
55
+ Transcribed from `openai_public.py` (each encoding's `pat_str` and
56
+ `special_tokens`), cross-checked against upstream gigatoken's own port in
57
+ `gigatoken/_load/tiktoken.py`. The scheme names are `PretokenizerType::NAMES`
58
+ values (`src/pretokenize/options.rs`).
59
+
60
+ | Encoding | Pretokenizer scheme | Special tokens |
61
+ |---|---|---|
62
+ | `r50k_base` | `gpt2` | `<\|endoftext\|>`=50256 |
63
+ | `cl100k_base` | `gpt4` | `<\|endoftext\|>`=100257, `<\|fim_prefix\|>`=100258, `<\|fim_middle\|>`=100259, `<\|fim_suffix\|>`=100260, `<\|endofprompt\|>`=100276 |
64
+ | `o200k_base` | `o200k` | `<\|endoftext\|>`=199999, `<\|endofprompt\|>`=200018 |
65
+
66
+ `p50k_base` is deliberately absent: its ranks are not dense (50256 is left free
67
+ for `<|endoftext|>`), and the rank loader rejects non-dense ranks with
68
+ `"ranks must be dense"`. `p50k_edit` loads the same `p50k_base.tiktoken` ranks
69
+ and is absent for the identical reason.
70
+
71
+ ## `o200k_harmony`
72
+
73
+ Packaged with **no new vendored file**: it reuses `o200k_base.tiktoken`'s
74
+ mergeable ranks and the `o200k` pretokenizer scheme verbatim. Confirmed
75
+ against `openai/tiktoken` 0.9.0 that `o200k_harmony()` in `openai_public.py`
76
+ calls `mergeable_ranks` with the same rank file and uses the same `pat_str` as
77
+ `o200k_base()` — only the special-token table differs.
78
+
79
+ That table (1091 entries: 10 named control tokens, 1081
80
+ `<|reserved_N|>` slots) is transcribed, not derived, from `o200k_harmony()`:
81
+ base specials `<|endoftext|>` 199999 and `<|endofprompt|>` 200018, then
82
+ `<|startoftext|>` 199998, `<|endoftext|>` 199999, `<|reserved_200000|>`
83
+ 200000, `<|reserved_200001|>` 200001, `<|return|>` 200002, `<|constrain|>`
84
+ 200003, `<|reserved_200004|>` 200004, `<|channel|>` 200005, `<|start|>`
85
+ 200006, `<|end|>` 200007, `<|message|>` 200008, `<|reserved_200009|>` 200009,
86
+ `<|reserved_200010|>` 200010, `<|reserved_200011|>` 200011, `<|call|>`
87
+ 200012, then `<|reserved_N|>` for `N` in `200013..201087`. The reserved range
88
+ is **not** contiguous from 200000 — the named control tokens sit inside
89
+ 200000..200012, leaving reserved slots only at `{200000, 200001, 200004,
90
+ 200009, 200010, 200011} ∪ [200013, 201087]`. A table built as "200000..201087
91
+ minus the named ids" invents `<|reserved_200002|>` and drops
92
+ `<|reserved_200018|>`; the transcription above avoids both.
93
+
94
+ **Not verified against `tiktoken_ruby`** (unlike the three encodings above):
95
+ `tiktoken_ruby` 0.0.17's own `o200k_harmony` table drops `<|endofprompt|>` —
96
+ it encodes the literal as six ordinary-text tokens rather than `[200018]` —
97
+ while treating `<|reserved_200018|>` as the sole literal at that id.
98
+ `openai/tiktoken` 0.9.0 keeps both `<|endofprompt|>` and
99
+ `<|reserved_200018|>`, at the same id, as Python dict construction preserves
100
+ both keys. `openai/tiktoken` is authoritative here; `tiktoken_ruby` is the
101
+ outlier. See `spec/gigatoken/differential_spec.rb` for the measurement and
102
+ the reduction-plus-pinning proof used in place of a `tiktoken_ruby`
103
+ comparison.
104
+
105
+ ## Licence
106
+
107
+ The `tiktoken` project and its published encoding files are MIT licensed,
108
+ Copyright (c) 2022 OpenAI, Shantanu Jain. See
109
+ <https://github.com/openai/tiktoken/blob/main/LICENSE>. The files are vendored
110
+ here unmodified.