gigatoken 0.3.0 → 0.5.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.
@@ -20,30 +20,36 @@ module Gigatoken
20
20
  option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
21
21
 
22
22
  def call(tokenizer:, files:, doc_separator: nil, limit_bytes: "none", parallel: true, packed: false, pretokenizer: nil, **)
23
+ Support.check_usage!(files, doc_separator)
23
24
  limit = Support.parse_size(limit_bytes)
24
25
  out.puts "#{label("cpu")}: #{Support.cpu_info}"
25
26
 
26
27
  gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
27
28
 
29
+ # Only the batch path materializes the documents; the native paths
30
+ # leave this nil and count their bytes off the clock below.
31
+ docs = nil
28
32
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
33
  if packed
30
34
  encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: parallel, packed: true)
31
- n_bytes = files.sum { |file| File.size(file) }
32
35
  n_tokens = encoded.token_count
33
36
  elsif parallel
34
37
  docs = Support.subset_docs(Support.split_docs(files, doc_separator), limit)
35
38
  encoded = gt_tokenizer.encode_batch(docs)
36
- n_bytes = docs.sum(&:bytesize)
37
39
  n_tokens = encoded.sum(&:length)
38
40
  else
39
41
  encoded = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator), parallel: false)
40
- n_bytes = files.sum { |file| File.size(file) }
41
42
  n_tokens = encoded.sum(&:length)
42
43
  end
43
44
  seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
44
45
 
46
+ # The throughput is over the bytes encoded, which for a compressed
47
+ # file are its decompressed bytes. Counting those means reading the
48
+ # file, so the native paths count off the clock; the batch path
49
+ # already holds the documents it encoded.
50
+ n_bytes = docs ? docs.sum(&:bytesize) : Support.input_bytesize(files)
45
51
  out.puts report("gigatoken", seconds, n_bytes, n_tokens)
46
- rescue Gigatoken::Error => e
52
+ rescue Gigatoken::Error, SystemCallError => e
47
53
  err.puts "error: #{e.message}"
48
54
  exit(1)
49
55
  end
@@ -4,16 +4,29 @@ require "etc"
4
4
 
5
5
  module Gigatoken
6
6
  module CLI
7
- # Helpers shared by the bench and validate commands: tokenizer loading,
8
- # byte-size parsing, document splitting, and CPU identification.
7
+ # Helpers shared by the bench and validate commands: argument checking,
8
+ # tokenizer loading, byte-size parsing, document splitting, and CPU
9
+ # identification.
9
10
  module Support
10
11
  SIZE_UNITS = {"" => 1, "k" => 10**3, "m" => 10**6, "g" => 10**9, "t" => 10**12}.freeze
11
12
  private_constant :SIZE_UNITS
12
13
 
13
- SIZE_PATTERN = /\A\s*(\d+(?:\.\d+)?)\s*([kmgt]?)i?b?\s*\z/i
14
+ # "KiB" and friends are binary, as everywhere else that spells the i.
15
+ BINARY_SIZE_UNITS = {"" => 1, "k" => 2**10, "m" => 2**20, "g" => 2**30, "t" => 2**40}.freeze
16
+ private_constant :BINARY_SIZE_UNITS
17
+
18
+ SIZE_PATTERN = /\A\s*(\d+(?:\.\d+)?)\s*([kmgt]?)(i?)b?\s*\z/i
14
19
  private_constant :SIZE_PATTERN
15
20
 
16
21
  class << self
22
+ # The argument shapes dry-cli itself lets through: FILES declared
23
+ # `required: true` still arrives empty, and an empty separator would
24
+ # split every byte into its own document.
25
+ def check_usage!(files, separator)
26
+ raise Gigatoken::Error, "FILES is required: name at least one file to encode" if files.empty?
27
+ raise Gigatoken::Error, "--doc-separator cannot be empty" if separator == ""
28
+ end
29
+
17
30
  # Load TOKENIZER: a tokenizer.json path/directory, a packaged
18
31
  # tiktoken encoding name, a HuggingFace repo id, or a .tiktoken file
19
32
  # — see Gigatoken::Tokenizer.load. `pretokenizer:` is forwarded
@@ -23,15 +36,17 @@ module Gigatoken
23
36
  Gigatoken::Tokenizer.load(spec, pretokenizer: pretokenizer)
24
37
  end
25
38
 
26
- # Parse a decimal byte size like "100MB", "2.5GB", or "1000000";
27
- # "none"/"unlimited" means no limit.
39
+ # Parse a byte size like "100MB", "2.5GB", "64KiB" or "1000000" —
40
+ # decimal units unless the prefix spells the i, which makes it
41
+ # binary; "none"/"unlimited" means no limit.
28
42
  def parse_size(text)
29
43
  return nil if ["none", "unlimited"].include?(text.strip.downcase)
30
44
 
31
45
  match = SIZE_PATTERN.match(text)
32
46
  raise Gigatoken::Error, "cannot parse size #{text.inspect}; expected something like 100MB (or 'none')" unless match
33
47
 
34
- (match[1].to_f * SIZE_UNITS.fetch(match[2].downcase)).to_i
48
+ units = match[3].empty? ? SIZE_UNITS : BINARY_SIZE_UNITS
49
+ (match[1].to_f * units.fetch(match[2].downcase)).to_i
35
50
  end
36
51
 
37
52
  # A Native::TextFileSource for FILES, split on `separator` when
@@ -42,15 +57,23 @@ module Gigatoken
42
57
 
43
58
  # Whole files as raw bytes, one document per file, or (with a
44
59
  # separator) the separator-split pieces of each file in order, empty
45
- # documents skipped.
60
+ # documents skipped. Compressed files are decompressed first, as the
61
+ # native file sources do, so both paths split the same bytes.
46
62
  def split_docs(files, separator)
47
- raws = files.map { |file| File.binread(file.to_s) }
63
+ raws = files.map { |file| read_decompressed(file.to_s) }
48
64
  return raws if separator.nil?
49
65
 
50
66
  sep = separator.b
51
67
  raws.flat_map { |raw| raw.split(sep).reject(&:empty?) }
52
68
  end
53
69
 
70
+ # The bytes the tokenizer actually sees for FILES — a compressed
71
+ # file's decompressed size, not its size on disk — so throughput is
72
+ # reported over the input that was tokenized.
73
+ def input_bytesize(files)
74
+ files.sum { |file| read_decompressed(file.to_s).bytesize }
75
+ end
76
+
54
77
  # The prefix of `docs` totalling at most `limit_bytes`, byte-
55
78
  # truncating the final document to fill the budget. Unlike a
56
79
  # text-comparison tool, gigatoken encodes raw bytes and does not
@@ -90,6 +113,15 @@ module Gigatoken
90
113
 
91
114
  private
92
115
 
116
+ # One file's bytes, decompressed. `Native.read_input` is the core's
117
+ # own decoder — the one the native file sources load through — so
118
+ # `.gz`, `.zst`/`.zstd` and plain files are detected and read here
119
+ # exactly as they are on the other side of `validate`, and an
120
+ # unreadable file raises Gigatoken::Error naming it.
121
+ def read_decompressed(path)
122
+ Gigatoken::Native.read_input(path)
123
+ end
124
+
93
125
  def darwin_cpu_info
94
126
  name = sysctl("machdep.cpu.brand_string")
95
127
  [name, sysctl_int("hw.physicalcpu"), sysctl_int("hw.packages")]
@@ -20,6 +20,7 @@ module Gigatoken
20
20
  option :pretokenizer, desc: "pretokenizer scheme, required when TOKENIZER is a .tiktoken file (one of #{Native.pretokenizer_names.join(", ")}); ignored otherwise"
21
21
 
22
22
  def call(tokenizer:, files:, doc_separator: nil, pretokenizer: nil, **)
23
+ Support.check_usage!(files, doc_separator)
23
24
  gt_tokenizer = Support.load_tokenizer(tokenizer, pretokenizer: pretokenizer)
24
25
 
25
26
  via_files = gt_tokenizer.encode_files(Support.text_file_source(files, doc_separator))
@@ -38,7 +39,7 @@ module Gigatoken
38
39
  end
39
40
 
40
41
  out.puts "validation OK: #{via_files.length} documents match"
41
- rescue Gigatoken::Error => e
42
+ rescue Gigatoken::Error, SystemCallError => e
42
43
  err.puts "error: #{e.message}"
43
44
  exit(1)
44
45
  end
@@ -1,4 +1,4 @@
1
- # Vendored tiktoken encodings — provenance
1
+ # Vendored encodings — provenance
2
2
 
3
3
  The `.tiktoken` files in this directory are OpenAI's published BPE mergeable-rank
4
4
  tables, vendored verbatim so `gigatoken` can resolve these encodings by name with
@@ -9,6 +9,10 @@ carries **mergeable ranks only** — the pretokenizer split regex and the specia
9
9
  tokens belong to the encoding's *definition*, not to the file, and live in code
10
10
  (see the table below).
11
11
 
12
+ The three `.json.gz` files are HuggingFace `tokenizer.json` files, not tiktoken
13
+ tables; they are covered in [their own section](#huggingface-tokenizerjson-files)
14
+ at the end.
15
+
12
16
  They live under `lib/` rather than a top-level `data/` directory because this
13
17
  repo's `.gitignore` ignores `/data/` ("downloaded test data"); these are shipped
14
18
  gem payload, not test fixtures.
@@ -108,3 +112,111 @@ The `tiktoken` project and its published encoding files are MIT licensed,
108
112
  Copyright (c) 2022 OpenAI, Shantanu Jain. See
109
113
  <https://github.com/openai/tiktoken/blob/main/LICENSE>. The files are vendored
110
114
  here unmodified.
115
+
116
+ ## HuggingFace `tokenizer.json` files
117
+
118
+ `qwen35.json.gz`, `qwen38.json.gz` and `muse_spark.json.gz` are HuggingFace
119
+ `tokenizer.json` files, vendored byte-for-byte and gzipped. Unlike a
120
+ `.tiktoken` table, a `tokenizer.json` carries the encoding's whole definition
121
+ — vocabulary, merges, normalizer, pretokenizer, added tokens — so the registry
122
+ records only the file, and `Tokenizer.from_encoding` loads it with
123
+ `Tokenizer.from_json`, reading the special tokens from the file's
124
+ `added_tokens` (those marked `"special": true`) exactly as `from_json` does.
125
+
126
+ ### Files
127
+
128
+ | File | Decompressed bytes | sha256 (decompressed) |
129
+ |------|-------------------:|-----------------------|
130
+ | `qwen35.json.gz` | 12,807,982 | `5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42` |
131
+ | `qwen38.json.gz` | 12,809,320 | `0997f410c57a1f4e53b09e4be8f4a172d90edd9564368fb0847030937229b9f3` |
132
+ | `muse_spark.json.gz` | 28,129,897 | `c9dbee66967b58f31a7c27f723c3760da3526ccd0427578e8905b0abb0031c4d` |
133
+
134
+ ### Source
135
+
136
+ Retrieved **2026-10-07** over HTTPS from the Hub's `resolve` endpoint, each at
137
+ a pinned commit:
138
+
139
+ | Encoding | Repo | Revision | URL |
140
+ |---|---|---|---|
141
+ | `qwen35` | `Qwen/Qwen3.5-27B` | `fc05daec18b0a78c049392ed2e771dde82bdf654` | <https://huggingface.co/Qwen/Qwen3.5-27B/resolve/fc05daec18b0a78c049392ed2e771dde82bdf654/tokenizer.json> |
142
+ | `qwen38` | `Qwen/Qwen3.8-27B` | `1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0` | <https://huggingface.co/Qwen/Qwen3.8-27B/resolve/1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0/tokenizer.json> |
143
+ | `muse_spark` | `meta-models/Muse-Glimmer-30B` | `a4e59da52a7bc87ae7251dd5545c0dd437c44b68` | <https://huggingface.co/meta-models/Muse-Glimmer-30B/resolve/a4e59da52a7bc87ae7251dd5545c0dd437c44b68/tokenizer.json> |
144
+
145
+ **`qwen35` is shared across releases.** The Hub's tree API
146
+ (`https://huggingface.co/api/models/<repo>?blobs=true`) reports the identical
147
+ LFS sha256 `5f9e4d49…` for `tokenizer.json` in `Qwen/Qwen3.5-{0.8B, 2B, 4B, 9B,
148
+ 27B, 35B-A3B, 122B-A10B, 397B-A17B}` and `Qwen/Qwen3.6-{27B, 35B-A3B}`
149
+ (queried 2026-10-07), so one file serves Qwen 3.5 and 3.6.
150
+ `Qwen/Qwen3.6-Plus` is gated and was not checked.
151
+
152
+ **`qwen38` ships its own file**, identical (`0997f410…`) across
153
+ `Qwen/Qwen3.8-{27B, Flash-Next, 2.4T-A95B}` and their `-FP8` twins. It differs
154
+ from `qwen35` only in seven more `"special": true` added tokens, ids
155
+ 248070–248076: `<|audio_start|>`, `<|audio_end|>`, `<tts_pad>`,
156
+ `<tts_text_bos>`, `<tts_text_eod>`, `<tts_text_bos_single>`, `<|audio_pad|>`.
157
+ The vocabulary, merges, normalizer, pretokenizer, decoder and post-processor
158
+ are identical, so ordinary text encodes the same under both; only those seven
159
+ literals, `vocab_size` and `special_tokens` differ. It is vendored as a second
160
+ file rather than derived from the first because the engine reads added tokens
161
+ from the JSON, and a JSON-backed tokenizer has no way to add specials after
162
+ the fact. `Qwen3.8-27B` is the repo cited; `Flash-Next` and `2.4T-A95B` carry an
163
+ "other" licence on the *model*, but their tokenizer file is byte-identical to
164
+ the Apache-2.0 repo's.
165
+
166
+ **`muse_spark` is the Muse-Glimmer-30B file.** Meta publishes no standalone
167
+ Muse Spark tokenizer (a Hub search over `meta-models` finds none, and Meta's
168
+ model-API docs at <https://dev.meta.ai/docs/muse-glimmer> point at none). The
169
+ Muse-Glimmer-30B model card states "Tokenizer: 200,000 BPE tokens + 2,048
170
+ special tokens" (vocabulary 202,048), trained from Muse Spark's outputs, and
171
+ this exact repo and revision is the one the reference application pins. As of
172
+ 2026-10-07 that revision is also `main`.
173
+
174
+ ### Authenticity
175
+
176
+ Verified three independent ways at retrieval time:
177
+
178
+ 1. **Transport** — HTTPS directly from `huggingface.co`, at the pinned
179
+ commit above.
180
+ 2. **Publisher checksum** — each file's sha256 above matches the LFS sha256 the
181
+ Hub's tree API reports for `tokenizer.json` at that revision, and so does
182
+ its size. Two independent channels agree on the bytes.
183
+ 3. **Behavioral, against the reference implementation** — loaded through
184
+ `Tokenizer.from_encoding`, every token id matched HuggingFace
185
+ [`tokenizers`](https://rubygems.org/gems/tokenizers) 0.7.0 loading the same
186
+ decompressed file with `add_special_tokens: false`, across this repo's own
187
+ source and docs (`spec/gigatoken/differential_spec.rb`). Resulting
188
+ `vocab_size`: 248070 / 248077 / 202048; `special_tokens.size`: 14 / 21 / 2048.
189
+
190
+ ### Compression
191
+
192
+ Each file was compressed with `gzip -9 -n` (no name or timestamp in the gzip
193
+ header, so the bytes are reproducible), and `gunzip -c` of it reproduces the
194
+ published file byte-for-byte — the sha256s above are of the decompressed
195
+ bytes. Compressed sizes: 3,514,616 (`qwen35`), 3,514,699 (`qwen38`), 4,475,666
196
+ (`muse_spark`).
197
+
198
+ ### What gigatoken does not take from the file
199
+
200
+ - **The post-processor.** gigatoken applies none. `muse_spark`'s is a
201
+ `TemplateProcessing` that prepends `<|begin_of_text|>`; `Tokenizer#encode`
202
+ does not, matching `tokenizers`' `add_special_tokens: false`. The Qwen files'
203
+ is a `ByteLevel` post-processor, a no-op on ids.
204
+ - **Non-special added tokens in `special_tokens`.** Qwen's 12 non-special
205
+ added tokens (`<tool_call>`, `<think>`, the `<|fim_*|>` tokens,
206
+ `<|repo_name|>`, `<|file_sep|>`, `<tool_response>` and so on) are matched
207
+ atomically by the engine, like the special ones, but only `"special": true`
208
+ tokens appear in `Tokenizer#special_tokens` — 14 for `qwen35`, 21 for
209
+ `qwen38`.
210
+ - **`tokenizer_config.json`.** Not vendored; gigatoken reads only
211
+ `tokenizer.json`.
212
+
213
+ ### Licence
214
+
215
+ All three repos are Apache-2.0, each with its own `LICENSE` at the pinned
216
+ revision:
217
+
218
+ - <https://huggingface.co/Qwen/Qwen3.5-27B/blob/fc05daec18b0a78c049392ed2e771dde82bdf654/LICENSE>
219
+ - <https://huggingface.co/Qwen/Qwen3.8-27B/blob/1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0/LICENSE>
220
+ - <https://huggingface.co/meta-models/Muse-Glimmer-30B/blob/a4e59da52a7bc87ae7251dd5545c0dd437c44b68/LICENSE>
221
+
222
+ The files are vendored here unmodified (gzipped).
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gigatoken
4
- # The tiktoken encodings gigatoken vendors ranks for, and the pieces a
5
- # .tiktoken file doesn't carry: its pretokenizer scheme and special-token
6
- # table (see Tokenizer.from_tiktoken and lib/gigatoken/encodings/
7
- # PROVENANCE.md, the source of truth this is transcribed from).
4
+ # The encodings gigatoken vendors a file for. For the tiktoken ones, the
5
+ # pieces a .tiktoken file doesn't carry: its pretokenizer scheme and
6
+ # special-token table (see Tokenizer.from_tiktoken and lib/gigatoken/
7
+ # encodings/PROVENANCE.md, the source of truth this is transcribed from).
8
+ # The HuggingFace ones are whole tokenizer.json files, which carry theirs.
8
9
  module Encodings
9
10
  DATA_DIR = File.expand_path("encodings", __dir__)
10
11
  private_constant :DATA_DIR
@@ -40,6 +41,10 @@ module Gigatoken
40
41
  HARMONY_RESERVED_TAIL = (200013..201087).to_h { |id| ["<|reserved_#{id}|>", id] }.freeze
41
42
  private_constant :HARMONY_RESERVED_TAIL
42
43
 
44
+ # Deep-frozen: entries, their `special_tokens` tables and the `rank_file` /
45
+ # `json_file` paths. `Tokenizer#special_tokens` hands the registry's own
46
+ # Hash back to callers, so anything less lets one caller's poke rewrite
47
+ # what every later `from_encoding` in the process loads.
43
48
  REGISTRY = {
44
49
  "r50k_base" => {
45
50
  rank_file: File.join(DATA_DIR, "r50k_base.tiktoken"),
@@ -66,8 +71,22 @@ module Gigatoken
66
71
  rank_file: File.join(DATA_DIR, "o200k_base.tiktoken"),
67
72
  pretokenizer: "o200k",
68
73
  special_tokens: HARMONY_HEAD_TOKENS.merge(HARMONY_RESERVED_TAIL).freeze
69
- }
70
- }.freeze
74
+ },
75
+ # The three below are gzipped HuggingFace tokenizer.json files, vendored
76
+ # verbatim (see PROVENANCE.md for repos, revisions and hashes). Nothing
77
+ # else is registered: the pretokenizer and the special tokens are the
78
+ # file's own, read at load as Tokenizer.from_json reads them.
79
+ #
80
+ # Qwen 3.5 and 3.6 share this one file.
81
+ "qwen35" => {json_file: File.join(DATA_DIR, "qwen35.json.gz")},
82
+ # The qwen35 file plus seven audio/TTS special tokens — vendored
83
+ # separately because a JSON-backed tokenizer takes its added tokens
84
+ # from the file.
85
+ "qwen38" => {json_file: File.join(DATA_DIR, "qwen38.json.gz")},
86
+ # meta-models/Muse-Glimmer-30B's file: Meta publishes no standalone
87
+ # Muse Spark tokenizer.
88
+ "muse_spark" => {json_file: File.join(DATA_DIR, "muse_spark.json.gz")}
89
+ }.each_value { |encoding| encoding.each_value(&:freeze).freeze }.freeze
71
90
  private_constant :REGISTRY
72
91
 
73
92
  # The packaged encoding names — the single source error messages naming
@@ -87,16 +106,17 @@ module Gigatoken
87
106
  private_constant :UNPACKABLE_REASONS
88
107
 
89
108
  class << self
90
- # The {rank_file:, pretokenizer:, special_tokens:} registered for a
91
- # packaged encoding name, or nil.
109
+ # The {rank_file:, pretokenizer:, special_tokens:} (tiktoken) or
110
+ # {json_file:} (HuggingFace) registered for a packaged encoding name, or
111
+ # nil. Names are Strings or Symbols, as Tokenizer.load accepts both.
92
112
  def [](name)
93
- REGISTRY[name]
113
+ REGISTRY[name.to_s]
94
114
  end
95
115
 
96
116
  # Why `name` can't be packaged, or nil when there's no reason on
97
117
  # record (it's either packaged, or simply not one gigatoken knows of).
98
118
  def unpackable_reason(name)
99
- UNPACKABLE_REASONS[name]
119
+ UNPACKABLE_REASONS[name.to_s]
100
120
  end
101
121
  end
102
122
  end