gigatoken 0.3.0-aarch64-linux → 0.5.0-aarch64-linux
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 +4 -4
- data/README.md +13 -5
- data/lib/gigatoken/3.3/gigatoken_rb.so +0 -0
- data/lib/gigatoken/3.4/gigatoken_rb.so +0 -0
- data/lib/gigatoken/4.0/gigatoken_rb.so +0 -0
- data/lib/gigatoken/cli/bench.rb +10 -4
- data/lib/gigatoken/cli/support.rb +40 -8
- data/lib/gigatoken/cli/validate.rb +2 -1
- data/lib/gigatoken/encodings/PROVENANCE.md +113 -1
- data/lib/gigatoken/encodings/muse_spark.json.gz +0 -0
- data/lib/gigatoken/encodings/qwen35.json.gz +0 -0
- data/lib/gigatoken/encodings/qwen38.json.gz +0 -0
- data/lib/gigatoken/encodings.rb +30 -10
- data/lib/gigatoken/hub.rb +232 -37
- data/lib/gigatoken/packed_result.rb +6 -2
- data/lib/gigatoken/tokenizer.rb +84 -15
- data/lib/gigatoken/version.rb +1 -1
- data/lib/gigatoken.rb +17 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a3b5aeb985dcdb597d00404f93cfab092b74261cfbde1f63aa3dc436d3c3465
|
|
4
|
+
data.tar.gz: a25819637d27224914a3a93bbf031c587427c23abad2614b5262f439e807791b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 907435605fdd23721af74c44d1ba47507cef21b6ae661e6784977e10cdfc0bd8c9a9b65b2e338655c93bc88af43ba9fa0ee8a2521969fdabe03524ccdb62952a
|
|
7
|
+
data.tar.gz: 2b38c06006819f1aa313cb1bf2bfdb5f7187ff6b959323b0f7a3d5bbcf0215e61bc496fc098afe9d0d6508e90342298306db63c18eaccd1ecaf3c7aae61d3375
|
data/README.md
CHANGED
|
@@ -57,13 +57,13 @@ Gigatoken::Tokenizer.from_tiktoken("cl100k_base.tiktoken", pretokenizer: "gpt4",
|
|
|
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::
|
|
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::ModelError` naming the valid ones, and `Tokenizer.load` on a `.tiktoken` path with no `pretokenizer:` raises rather than silently picking one.
|
|
61
61
|
|
|
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::
|
|
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::InputError` on invalid UTF-8 instead of guessing.
|
|
63
63
|
|
|
64
|
-
### Packaged
|
|
64
|
+
### Packaged encodings
|
|
65
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
|
|
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) — as are `qwen35`, `qwen38` and `muse_spark`, below, so all seven 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
67
|
|
|
68
68
|
```ruby
|
|
69
69
|
Gigatoken::Tokenizer.from_encoding("cl100k_base")
|
|
@@ -71,7 +71,9 @@ Gigatoken::Tokenizer.load("cl100k_base") # same result — packaged nam
|
|
|
71
71
|
# checked before the Hub-repo-id shape
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
`
|
|
74
|
+
`qwen35`, `qwen38` and `muse_spark` are whole HuggingFace `tokenizer.json` files, gzipped (`*.json.gz`, pinned to a repo revision in `PROVENANCE.md`) and loaded through `from_json`, so their special tokens come from the file. `qwen35` is the Qwen 3.5 and 3.6 tokenizer; `qwen38` is that file plus seven audio/TTS special tokens (`<|audio_start|>`, `<|audio_end|>`, `<tts_pad>`, `<tts_text_bos>`, `<tts_text_eod>`, `<tts_text_bos_single>`, `<|audio_pad|>`), so ordinary text encodes the same under both; `muse_spark` is `meta-models/Muse-Glimmer-30B`'s tokenizer, because Meta publishes no standalone Muse Spark one. Their output matches HuggingFace `tokenizers` with `add_special_tokens: false`: gigatoken applies no post-processor, so `encode` never prepends `<|begin_of_text|>` for `muse_spark`. Unlike the tiktoken entries, a registry entry for one of these is just `{json_file:}`, and each load reads, gunzips and parses its file (about 0.4 s).
|
|
75
|
+
|
|
76
|
+
`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::ModelError` explaining that, rather than `load` falling through to the Hub for a name that happens to look like a legacy repo id.
|
|
75
77
|
|
|
76
78
|
`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
79
|
|
|
@@ -117,6 +119,12 @@ packed.token_count # => total tokens
|
|
|
117
119
|
packed[3] # => document 3's ids as an Array, on demand
|
|
118
120
|
```
|
|
119
121
|
|
|
122
|
+
### Errors
|
|
123
|
+
|
|
124
|
+
Everything the library raises is a `Gigatoken::Error`, never a raw Rust panic — and, under it, one of three: `Gigatoken::ModelError` when a tokenizer can't be loaded (bad or hostile JSON, a missing file or directory, an unknown or unpackable encoding name, a malformed `.tiktoken`), `Gigatoken::InputError` when a document or an id can't be taken (a String that won't transcode, invalid UTF-8 on the SentencePiece path, an id outside the vocabulary in `decode`), and `Gigatoken::HubError` for everything `Gigatoken::Hub` raises (HTTP status, transport, timeout, the repo-id and header checks). `rescue Gigatoken::Error` catches all three.
|
|
125
|
+
|
|
126
|
+
`encode` and `encode_batch` honour the String's encoding tag: UTF-8, US-ASCII and binary go through byte-wise, and a real non-UTF-8 encoding (ISO-8859-1, UTF-16LE, …) is transcoded first, so it gives the same ids as the same text read as UTF-8. See [the reference](docs/reference/tokenizer.md#input-encodings).
|
|
127
|
+
|
|
120
128
|
### Async
|
|
121
129
|
|
|
122
130
|
`encode_batch` and `encode_files` release the GVL for the whole encode; the parallelism runs on the engine's rayon pool, not Ruby threads. Under `Async`, give the fiber scheduler a worker pool (`ASYNC_SCHEDULER_WORKER_POOL=true`) and the calling fiber yields to the reactor too. Design notes: [docs/how-to/run-under-async.md](docs/how-to/run-under-async.md).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/gigatoken/cli/bench.rb
CHANGED
|
@@ -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:
|
|
8
|
-
# byte-size parsing, document splitting, and CPU
|
|
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
|
-
|
|
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
|
|
27
|
-
#
|
|
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
|
-
|
|
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|
|
|
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
|
|
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).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/gigatoken/encodings.rb
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Gigatoken
|
|
4
|
-
# The
|
|
5
|
-
# .tiktoken file doesn't carry: its pretokenizer scheme and
|
|
6
|
-
# table (see Tokenizer.from_tiktoken and lib/gigatoken/
|
|
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
|
-
|
|
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:}
|
|
91
|
-
# packaged encoding name, or
|
|
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
|
data/lib/gigatoken/hub.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "async"
|
|
4
4
|
require "async/http"
|
|
5
|
+
require "async/http/proxy"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
require "pathname"
|
|
7
8
|
|
|
@@ -32,6 +33,39 @@ module Gigatoken
|
|
|
32
33
|
MAX_REDIRECTS = 10
|
|
33
34
|
private_constant :MAX_REDIRECTS
|
|
34
35
|
|
|
36
|
+
# Connect/read timeout in seconds, and the bound on the request phase —
|
|
37
|
+
# huggingface_hub's HF_HUB_ETAG_TIMEOUT / HF_HUB_DOWNLOAD_TIMEOUT
|
|
38
|
+
# default.
|
|
39
|
+
DEFAULT_TIMEOUT = 10
|
|
40
|
+
private_constant :DEFAULT_TIMEOUT
|
|
41
|
+
|
|
42
|
+
# A "." or ".." path segment: the traversal that must never reach a URL
|
|
43
|
+
# or the cache, wherever it comes from.
|
|
44
|
+
DOT_SEGMENT = /\A\.\.?\z/
|
|
45
|
+
private_constant :DOT_SEGMENT
|
|
46
|
+
|
|
47
|
+
# Everything outside RFC 3986's unreserved set, which is what
|
|
48
|
+
# huggingface_hub's `quote` percent-encodes.
|
|
49
|
+
RESERVED = /[^A-Za-z0-9\-._~]/
|
|
50
|
+
private_constant :RESERVED
|
|
51
|
+
|
|
52
|
+
# How a request, or the body read that follows it, fails underneath
|
|
53
|
+
# async-http: a connect/read timeout, a refused or reset connection, a
|
|
54
|
+
# proxy refusing the CONNECT tunnel, DNS resolution, a body cut short, a
|
|
55
|
+
# malformed endpoint, TLS. All of them reach the caller as
|
|
56
|
+
# Gigatoken::HubError.
|
|
57
|
+
TRANSPORT_ERRORS = [
|
|
58
|
+
Async::TimeoutError,
|
|
59
|
+
Async::HTTP::Proxy::ConnectFailure,
|
|
60
|
+
IOError,
|
|
61
|
+
SocketError,
|
|
62
|
+
SystemCallError,
|
|
63
|
+
URI::InvalidURIError,
|
|
64
|
+
Protocol::HTTP::Error,
|
|
65
|
+
OpenSSL::SSL::SSLError
|
|
66
|
+
].freeze
|
|
67
|
+
private_constant :TRANSPORT_ERRORS
|
|
68
|
+
|
|
35
69
|
class << self
|
|
36
70
|
# Whether `name` is shaped like a HuggingFace Hub repo id: `org/name`,
|
|
37
71
|
# or a bare legacy repo name like `gpt2`. At most one slash, and not
|
|
@@ -105,15 +139,44 @@ module Gigatoken
|
|
|
105
139
|
env("HF_ENDPOINT") || DEFAULT_ENDPOINT
|
|
106
140
|
end
|
|
107
141
|
|
|
142
|
+
# Whether `value` is usable as a URL path and a cache path component:
|
|
143
|
+
# not empty, not absolute, no NUL byte, no "." or ".." segment.
|
|
144
|
+
# huggingface_hub rejects the same traversals in validate_repo_id.
|
|
145
|
+
def safe_component?(value)
|
|
146
|
+
!value.empty? && !value.include?("\0") && !value.start_with?("/") &&
|
|
147
|
+
value.split("/", -1).none? { |segment| segment.match?(DOT_SEGMENT) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The proxy URL the environment names for a `scheme` request to
|
|
151
|
+
# `hostname`, or nil: http_proxy/https_proxy, lowercase spelling
|
|
152
|
+
# first, suppressed by a matching no_proxy entry — what requests does,
|
|
153
|
+
# and so what huggingface_hub inherits.
|
|
154
|
+
def proxy_url(scheme, hostname)
|
|
155
|
+
url = env("#{scheme}_proxy") || env("#{scheme.upcase}_PROXY")
|
|
156
|
+
url unless url.nil? || no_proxy?(hostname)
|
|
157
|
+
end
|
|
158
|
+
|
|
108
159
|
private
|
|
109
160
|
|
|
161
|
+
# no_proxy is a comma-separated list of host suffixes, or "*" for
|
|
162
|
+
# everything.
|
|
163
|
+
def no_proxy?(hostname)
|
|
164
|
+
host = hostname.downcase
|
|
165
|
+
(env("no_proxy") || env("NO_PROXY")).to_s.split(",").any? do |entry|
|
|
166
|
+
entry = entry.strip.downcase.delete_prefix(".")
|
|
167
|
+
next true if entry == "*"
|
|
168
|
+
|
|
169
|
+
!entry.empty? && (host == entry || host.end_with?(".#{entry}"))
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
110
173
|
def env(key)
|
|
111
174
|
value = ENV[key]
|
|
112
175
|
value unless value.nil? || value.empty?
|
|
113
176
|
end
|
|
114
177
|
|
|
115
178
|
def word_part?(part, first_alnum:)
|
|
116
|
-
return false if part.nil? || part.empty?
|
|
179
|
+
return false if part.nil? || part.empty? || part.match?(DOT_SEGMENT)
|
|
117
180
|
|
|
118
181
|
first_ok = first_alnum ? part[0].match?(/[A-Za-z0-9]/) : word_char?(part[0])
|
|
119
182
|
first_ok && part[1..].chars.all? { |c| word_char?(c) }
|
|
@@ -133,14 +196,26 @@ module Gigatoken
|
|
|
133
196
|
# for pointing at a local server in tests (dependency injection, not a
|
|
134
197
|
# mock); defaults to Hub.default_endpoint (HF_ENDPOINT, then
|
|
135
198
|
# huggingface.co).
|
|
136
|
-
|
|
199
|
+
# @parameter timeout [Numeric] connect/read timeout in seconds, applied
|
|
200
|
+
# to every request and to the request phase as a whole.
|
|
201
|
+
def initialize(endpoint: self.class.default_endpoint, timeout: DEFAULT_TIMEOUT)
|
|
137
202
|
@endpoint = endpoint.chomp("/")
|
|
138
|
-
@
|
|
203
|
+
@timeout = timeout
|
|
139
204
|
end
|
|
140
205
|
|
|
141
206
|
# Path of `filename` from Hub repo `repo_id` at `revision`, served from
|
|
142
|
-
# the standard HF cache, downloading into it first when absent.
|
|
207
|
+
# the standard HF cache, downloading into it first when absent. The
|
|
208
|
+
# three caller-supplied components are checked before any request or
|
|
209
|
+
# filesystem access: one of them carrying `..` would otherwise read and
|
|
210
|
+
# overwrite files outside the cache.
|
|
143
211
|
def hub_file(repo_id, filename = "tokenizer.json", revision: "main")
|
|
212
|
+
{"repo id" => repo_id, "filename" => filename, "revision" => revision}.each do |what, value|
|
|
213
|
+
next if self.class.safe_component?(value)
|
|
214
|
+
|
|
215
|
+
raise HubError, "#{what} #{value.inspect}: must not be empty or absolute, " \
|
|
216
|
+
"or contain a NUL byte or a \".\" or \"..\" path segment"
|
|
217
|
+
end
|
|
218
|
+
|
|
144
219
|
self.class.cached_file(repo_id, filename, revision) ||
|
|
145
220
|
Sync { fetch(repo_id, filename, revision) }
|
|
146
221
|
end
|
|
@@ -152,31 +227,127 @@ module Gigatoken
|
|
|
152
227
|
# recording the branch ref so later lookups (ours and
|
|
153
228
|
# huggingface_hub's) resolve it.
|
|
154
229
|
def fetch(repo_id, filename, revision)
|
|
155
|
-
|
|
230
|
+
clients = []
|
|
231
|
+
url = resolve_url(repo_id, filename, revision)
|
|
156
232
|
token = self.class.hf_token
|
|
157
|
-
|
|
233
|
+
headers = auth_headers(token)
|
|
234
|
+
response = get(url, headers, clients)
|
|
158
235
|
# Unlisted headers parse as a Header::Generic (an Array of values);
|
|
159
236
|
# x-repo-commit is always a single value, so flatten it to a String.
|
|
160
237
|
commit = response.headers["x-repo-commit"]&.to_s
|
|
161
238
|
|
|
162
|
-
# Redirects are followed by hand: resolve/
|
|
163
|
-
#
|
|
164
|
-
#
|
|
239
|
+
# Redirects are followed by hand: a resolve/ URL answers an LFS file
|
|
240
|
+
# with a redirect to a CDN, a renamed repo with one to its new name,
|
|
241
|
+
# and both headers below need a rule of their own across the hop.
|
|
165
242
|
hops = 0
|
|
166
243
|
while (300...400).cover?(response.status)
|
|
167
|
-
|
|
168
|
-
hops += 1
|
|
169
|
-
raise Error, "#{url}: too many redirects" if hops > MAX_REDIRECTS
|
|
170
|
-
|
|
171
|
-
location = response.headers["location"] ||
|
|
172
|
-
raise(Error, "#{url}: redirect with no Location header")
|
|
244
|
+
location = response.headers["location"]
|
|
173
245
|
response.close
|
|
174
|
-
url
|
|
175
|
-
|
|
246
|
+
raise HubError, "#{url}: redirect with no Location header" unless location
|
|
247
|
+
raise HubError, "#{url}: too many redirects" if (hops += 1) > MAX_REDIRECTS
|
|
248
|
+
|
|
249
|
+
target = absolutize(location, url)
|
|
250
|
+
# A renamed repo answers with a same-origin redirect that still needs
|
|
251
|
+
# the token; the LFS CDN elsewhere authenticates by signed URL and
|
|
252
|
+
# must never see it — huggingface_hub drops the header on the same
|
|
253
|
+
# rule.
|
|
254
|
+
headers = auth_headers(nil) unless same_origin?(target, url)
|
|
255
|
+
url = target
|
|
256
|
+
response = get(url, headers, clients)
|
|
257
|
+
# An LFS file carries x-repo-commit on the resolve/ hop; a renamed
|
|
258
|
+
# repo carries it only on the hop that finally answers 200.
|
|
259
|
+
commit ||= response.headers["x-repo-commit"]&.to_s
|
|
176
260
|
end
|
|
177
|
-
ensure_ok!(url, response
|
|
261
|
+
ensure_ok!(url, response, !!token)
|
|
262
|
+
ensure_commit!(url, commit)
|
|
263
|
+
|
|
264
|
+
write_to_cache(repo_id, filename, revision, commit, response)
|
|
265
|
+
rescue *TRANSPORT_ERRORS => e
|
|
266
|
+
raise HubError, "#{url}: #{e.message} (#{e.class})"
|
|
267
|
+
ensure
|
|
268
|
+
close_all(response, clients)
|
|
269
|
+
end
|
|
178
270
|
|
|
179
|
-
|
|
271
|
+
# Release everything the fetch holds, in the one place every exit passes
|
|
272
|
+
# through. Order matters: an unread body keeps its connection checked
|
|
273
|
+
# out, and Async::HTTP::Client#close waits for its pool to drain, so the
|
|
274
|
+
# response goes first and the clients innermost-first — a tunnel client's
|
|
275
|
+
# connection is held by the proxy client opened under it. Bounded, and
|
|
276
|
+
# deaf to the ways a broken connection fails to close: a connection some
|
|
277
|
+
# failure left mid-stream would otherwise stall the drain, and with it
|
|
278
|
+
# the Sync this all runs inside, for good. A leaked socket is worth less
|
|
279
|
+
# than the caller's result or exception.
|
|
280
|
+
def close_all(response, clients)
|
|
281
|
+
Async::Task.current.with_timeout(@timeout) do
|
|
282
|
+
response&.close
|
|
283
|
+
clients.reverse_each(&:close)
|
|
284
|
+
end
|
|
285
|
+
rescue *TRANSPORT_ERRORS
|
|
286
|
+
nil
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# `endpoint/repo/resolve/revision/filename`, percent-encoded the way
|
|
290
|
+
# huggingface_hub's `quote` does it: `revision` whole (`safe=""`), so
|
|
291
|
+
# `refs/pr/1` travels as `refs%2Fpr%2F1`, while the repo id and the
|
|
292
|
+
# filename keep their slashes.
|
|
293
|
+
def resolve_url(repo_id, filename, revision)
|
|
294
|
+
"#{@endpoint}/#{escape_path(repo_id)}/resolve/#{escape(revision)}/#{escape_path(filename)}"
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def escape_path(value)
|
|
298
|
+
value.split("/", -1).map { |segment| escape(segment) }.join("/")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def escape(value)
|
|
302
|
+
value.gsub(RESERVED) { |char| char.bytes.map { |byte| format("%%%02X", byte) }.join }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# GET `url`, through the proxy the environment names for it when there
|
|
306
|
+
# is one: an http request travels to the proxy with the absolute URI in
|
|
307
|
+
# its request line, an https one through a CONNECT tunnel — how requests,
|
|
308
|
+
# and so huggingface_hub, routes them. The clients stay open (the body
|
|
309
|
+
# is still to be streamed); #fetch closes them.
|
|
310
|
+
def get(url, headers, clients)
|
|
311
|
+
endpoint = endpoint_for(url)
|
|
312
|
+
proxy = self.class.proxy_url(endpoint.scheme, endpoint.hostname)
|
|
313
|
+
proxy &&= endpoint_for(proxy)
|
|
314
|
+
|
|
315
|
+
client =
|
|
316
|
+
if proxy.nil?
|
|
317
|
+
open_client(endpoint, clients)
|
|
318
|
+
elsif endpoint.secure?
|
|
319
|
+
# https tunnels through the proxy with CONNECT, then speaks TLS to
|
|
320
|
+
# the origin as if it had connected to it directly.
|
|
321
|
+
open_client(open_client(proxy, clients).proxied_endpoint(endpoint), clients)
|
|
322
|
+
else
|
|
323
|
+
open_client(proxy, clients)
|
|
324
|
+
end
|
|
325
|
+
# An http proxy is addressed with the absolute URI in the request line;
|
|
326
|
+
# a direct or tunnelled request carries just the path.
|
|
327
|
+
target = (proxy && !endpoint.secure?) ? url : endpoint.path
|
|
328
|
+
|
|
329
|
+
request = Protocol::HTTP::Request["GET", target, headers, scheme: endpoint.scheme, authority: endpoint.authority]
|
|
330
|
+
# A CONNECT tunnel's socket carries no timeout of its own, so the
|
|
331
|
+
# request phase is bounded here whichever way it was routed; the body
|
|
332
|
+
# then streams under the endpoint's own connect/read timeout.
|
|
333
|
+
Async::Task.current.with_timeout(@timeout) { client.call(request) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A malformed URL comes back as URI::InvalidURIError, one that cannot be
|
|
337
|
+
# routed (no scheme or host) as ArgumentError; both mean the same thing
|
|
338
|
+
# to the caller, and neither is worth a backtrace.
|
|
339
|
+
def endpoint_for(url)
|
|
340
|
+
Async::HTTP::Endpoint.parse(url, timeout: @timeout)
|
|
341
|
+
rescue ArgumentError => e
|
|
342
|
+
raise HubError, "#{url}: #{e.message} (#{e.class})"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A client for one hop, remembered in `clients` so #fetch can close it
|
|
346
|
+
# once the body is written. retries: 1 — a client built for this hop has
|
|
347
|
+
# no stale pooled connection for a retry to rescue, and async-http's
|
|
348
|
+
# default of 3 would pay @timeout three times over.
|
|
349
|
+
def open_client(endpoint, clients)
|
|
350
|
+
Async::HTTP::Client.new(endpoint, retries: 1).tap { |client| clients << client }
|
|
180
351
|
end
|
|
181
352
|
|
|
182
353
|
def auth_headers(token)
|
|
@@ -199,51 +370,75 @@ module Gigatoken
|
|
|
199
370
|
rescue
|
|
200
371
|
FileUtils.rm_f(tmp)
|
|
201
372
|
raise
|
|
202
|
-
ensure
|
|
203
|
-
response.close
|
|
204
373
|
end
|
|
205
374
|
File.rename(tmp, target)
|
|
206
375
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
376
|
+
# A nested revision like refs/pr/1 is a nested ref file, so its parent
|
|
377
|
+
# has to exist — huggingface_hub mkdir -p's the same path.
|
|
378
|
+
if revision != commit
|
|
379
|
+
ref_path = repo_dir.join("refs", revision)
|
|
380
|
+
FileUtils.mkdir_p(ref_path.dirname)
|
|
381
|
+
File.write(ref_path, commit)
|
|
211
382
|
end
|
|
212
383
|
|
|
213
384
|
target
|
|
214
385
|
end
|
|
215
386
|
|
|
216
|
-
|
|
387
|
+
# Raise on a non-success status. The unread body is left to #close_all,
|
|
388
|
+
# which every exit from #fetch passes through: it keeps the HTTP/1.x
|
|
389
|
+
# connection — and the Sync around it — alive until it is closed, so the
|
|
390
|
+
# exception would otherwise never reach the caller.
|
|
391
|
+
def ensure_ok!(url, response, had_token)
|
|
392
|
+
status = response.status
|
|
217
393
|
return if (200...400).cover?(status)
|
|
218
394
|
|
|
219
395
|
case status
|
|
220
396
|
when 404
|
|
221
|
-
raise
|
|
397
|
+
raise HubError, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
|
|
222
398
|
when 401, 403
|
|
223
399
|
token_note = had_token ? "the request used the discovered token" : "no token was found"
|
|
224
|
-
raise
|
|
400
|
+
raise HubError,
|
|
225
401
|
"#{url}: HTTP #{status} — the repo may be private or gated (#{token_note}; set HF_TOKEN or run " \
|
|
226
402
|
"`hf auth login`, and accept the repo's terms on huggingface.co if it is gated)"
|
|
227
403
|
else
|
|
228
|
-
raise
|
|
404
|
+
raise HubError, "#{url}: HTTP #{status}"
|
|
229
405
|
end
|
|
230
406
|
end
|
|
231
407
|
|
|
408
|
+
# The snapshot directory is named by a server-controlled header, so only
|
|
409
|
+
# a real commit hash may become one: anything else would write the body
|
|
410
|
+
# wherever the header points. A missing header also means the endpoint
|
|
411
|
+
# isn't a Hub, whose snapshot would never be found again.
|
|
412
|
+
def ensure_commit!(url, commit)
|
|
413
|
+
return if self.class.commit_hash?(commit.to_s)
|
|
414
|
+
|
|
415
|
+
raise HubError, "#{url}: response is missing a usable x-repo-commit header (#{commit.inspect}) — it does not " \
|
|
416
|
+
"seem to be served by a HuggingFace Hub endpoint; if HF_ENDPOINT is set, check that it points to a " \
|
|
417
|
+
"Hub-compatible endpoint, and otherwise check your firewall and proxy settings"
|
|
418
|
+
end
|
|
419
|
+
|
|
232
420
|
# A redirect Location resolved against the request URL: absolute URLs
|
|
233
421
|
# pass through, host-relative (`/x/y`) and path-relative ones join the
|
|
234
422
|
# base.
|
|
235
423
|
def absolutize(location, base)
|
|
236
424
|
return location if location.include?("://")
|
|
237
425
|
|
|
238
|
-
|
|
239
|
-
|
|
426
|
+
base_origin = origin(base)
|
|
427
|
+
return "#{base_origin}#{location}" if location.start_with?("/")
|
|
240
428
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
429
|
+
dir_end = base.rindex("/") || base.length
|
|
430
|
+
"#{base[0...[dir_end, base_origin.length].max]}/#{location}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Scheme and authority of a URL — everything before its path. Two URLs
|
|
434
|
+
# sharing one may pass the Authorization header between them.
|
|
435
|
+
def origin(url)
|
|
436
|
+
scheme_end = url.index("://")
|
|
437
|
+
url[0...(url.index("/", scheme_end ? scheme_end + 3 : 0) || url.length)]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def same_origin?(url, other)
|
|
441
|
+
origin(url).casecmp?(origin(other))
|
|
247
442
|
end
|
|
248
443
|
end
|
|
249
444
|
end
|
|
@@ -29,8 +29,12 @@ module Gigatoken
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Array of token ids for document `i`, materialized on demand; negative
|
|
32
|
-
# indices count from the end
|
|
33
|
-
|
|
32
|
+
# indices count from the end, out-of-range ones give nil, and a
|
|
33
|
+
# non-Integer index is a TypeError, all like Array.
|
|
34
|
+
def [](index)
|
|
35
|
+
i = Integer.try_convert(index)
|
|
36
|
+
raise TypeError, "no implicit conversion of #{index.class} into Integer" if i.nil?
|
|
37
|
+
|
|
34
38
|
i += size if i.negative?
|
|
35
39
|
return unless i >= 0 && i < size
|
|
36
40
|
|
data/lib/gigatoken/tokenizer.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "zlib"
|
|
4
5
|
|
|
5
6
|
module Gigatoken
|
|
6
7
|
# A tokenizer: encode, batch encode, decode, and vocabulary introspection
|
|
@@ -10,18 +11,35 @@ module Gigatoken
|
|
|
10
11
|
FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
|
|
11
12
|
private_constant :FILE_SOURCE_CLASSES
|
|
12
13
|
|
|
14
|
+
# Encodings whose bytes reach the native call untouched. UTF-8 is checked
|
|
15
|
+
# by identity first — it is the hot path, see #encode — and these are the
|
|
16
|
+
# rest: US-ASCII already is UTF-8 bytes, and ASCII-8BIT is deliberately
|
|
17
|
+
# raw bytes. Anything else — ISO-8859-1, Windows-1252, UTF-16LE — is a
|
|
18
|
+
# real encoding whose bytes are not the text's UTF-8 bytes, so encoding it
|
|
19
|
+
# raw would give different ids than the same text read as UTF-8. (Invalid
|
|
20
|
+
# bytes in a UTF-8-tagged String stay raw: a documented difference from
|
|
21
|
+
# tiktoken, which rejects them.)
|
|
22
|
+
BYTEWISE_ENCODINGS = [Encoding::US_ASCII, Encoding::BINARY].freeze
|
|
23
|
+
private_constant :BYTEWISE_ENCODINGS
|
|
24
|
+
|
|
13
25
|
# Load from in-memory tokenizer.json contents (String or bytes). Backed
|
|
14
26
|
# by a BPETokenizer or a SentencePieceTokenizer, per the model's
|
|
15
|
-
# byte_fallback flag.
|
|
27
|
+
# byte_fallback flag. The document is parsed twice, deliberately: the Ruby
|
|
28
|
+
# parse runs first because JSON.parse's depth limit is what keeps hostile
|
|
29
|
+
# nesting away from the native parser (see parse_json), and it is also
|
|
30
|
+
# where the special-token table is read from.
|
|
16
31
|
def self.from_json(data)
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
json = String.try_convert(data) or raise TypeError, "no implicit conversion of #{data.class} into String"
|
|
33
|
+
parsed = parse_json(json)
|
|
34
|
+
new(Native.load_hf_json(json), special_tokens: special_tokens_from(parsed))
|
|
19
35
|
end
|
|
20
36
|
|
|
21
37
|
# Load from a tokenizer.json path, or a directory containing one.
|
|
22
38
|
def self.from_file(path)
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
file = File.directory?(path) ? File.join(path, "tokenizer.json") : path.to_s
|
|
40
|
+
raise ModelError, "#{file.inspect}: no such file" unless File.file?(file)
|
|
41
|
+
|
|
42
|
+
from_json(File.binread(file))
|
|
25
43
|
end
|
|
26
44
|
|
|
27
45
|
# Load from a .tiktoken mergeable-ranks file. The file carries neither a
|
|
@@ -35,16 +53,18 @@ module Gigatoken
|
|
|
35
53
|
new(native, special_tokens: special_tokens)
|
|
36
54
|
end
|
|
37
55
|
|
|
38
|
-
# Load one of the
|
|
39
|
-
#
|
|
40
|
-
#
|
|
56
|
+
# Load one of the encodings gigatoken vendors a file for, by name — see
|
|
57
|
+
# Gigatoken::Encodings::NAMES — entirely from the vendored files: no
|
|
58
|
+
# network, no writable cache. A tiktoken entry loads its ranks; a
|
|
59
|
+
# tokenizer.json entry is gunzipped and loaded by from_json.
|
|
41
60
|
def self.from_encoding(name)
|
|
42
61
|
encoding = Encodings[name]
|
|
62
|
+
return from_json(Zlib.gunzip(File.binread(encoding[:json_file]))) if encoding&.key?(:json_file)
|
|
43
63
|
return from_tiktoken(encoding[:rank_file], pretokenizer: encoding[:pretokenizer], special_tokens: encoding[:special_tokens]) if encoding
|
|
44
64
|
|
|
45
65
|
reason = Encodings.unpackable_reason(name)
|
|
46
66
|
detail = reason ? " — #{reason}" : ""
|
|
47
|
-
raise
|
|
67
|
+
raise ModelError, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
|
|
48
68
|
end
|
|
49
69
|
|
|
50
70
|
# Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
|
|
@@ -71,7 +91,7 @@ module Gigatoken
|
|
|
71
91
|
source = source.to_s
|
|
72
92
|
if source.end_with?(".tiktoken")
|
|
73
93
|
unless pretokenizer
|
|
74
|
-
raise
|
|
94
|
+
raise ModelError, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
|
|
75
95
|
"pass pretokenizer: (one of #{Native.pretokenizer_names.join(", ")})"
|
|
76
96
|
end
|
|
77
97
|
return from_tiktoken(source, pretokenizer: pretokenizer, special_tokens: special_tokens)
|
|
@@ -80,25 +100,44 @@ module Gigatoken
|
|
|
80
100
|
return from_encoding(source) if Encodings::NAMES.include?(source) || Encodings.unpackable_reason(source)
|
|
81
101
|
return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
|
|
82
102
|
|
|
83
|
-
raise
|
|
103
|
+
raise ModelError, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
|
|
84
104
|
end
|
|
85
105
|
|
|
86
106
|
# `data` is UTF-8 JSON whatever its encoding tag says (File.binread tags
|
|
87
107
|
# binary; a US-ASCII default_external tags that), so retag rather than let
|
|
88
108
|
# JSON.parse transcode from the tag; a UTF-8-tagged String needs no copy.
|
|
89
|
-
|
|
109
|
+
# JSON.parse's default max_nesting of 100 is the guard: the native parser
|
|
110
|
+
# is recursive with no depth limit, and deep enough nesting overflows its
|
|
111
|
+
# stack rather than raising.
|
|
112
|
+
def self.parse_json(data)
|
|
90
113
|
data = data.dup.force_encoding(Encoding::UTF_8) unless data.encoding == Encoding::UTF_8
|
|
91
|
-
|
|
114
|
+
JSON.parse(data)
|
|
115
|
+
rescue JSON::ParserError => e
|
|
116
|
+
raise ModelError, "failed to parse tokenizer JSON: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
private_class_method :parse_json
|
|
119
|
+
|
|
120
|
+
def self.special_tokens_from(parsed)
|
|
121
|
+
added = parsed["added_tokens"] || []
|
|
92
122
|
added.each_with_object({}) { |t, h| h[t["content"]] = t["id"] if t["special"] }
|
|
93
123
|
end
|
|
94
|
-
private_class_method :
|
|
124
|
+
private_class_method :special_tokens_from
|
|
95
125
|
|
|
126
|
+
# The table is frozen so `#special_tokens` can neither alias the
|
|
127
|
+
# deep-frozen registry's Hash nor a `from_tiktoken` caller's; an
|
|
128
|
+
# already-frozen one is safe to share as-is.
|
|
96
129
|
def initialize(native, special_tokens: {})
|
|
97
130
|
@native = native
|
|
98
|
-
@special_tokens = special_tokens
|
|
131
|
+
@special_tokens = special_tokens.frozen? ? special_tokens : special_tokens.dup.freeze
|
|
99
132
|
end
|
|
100
133
|
|
|
134
|
+
# A String already tagged UTF-8 — every ordinary document — reaches the
|
|
135
|
+
# native call over one inline identity check. A helper frame here is
|
|
136
|
+
# measurable against the encode itself at document sizes this small, so
|
|
137
|
+
# the check is spelled out rather than delegated to #transcode?; anything
|
|
138
|
+
# else takes the slow path in #utf8.
|
|
101
139
|
def encode(text)
|
|
140
|
+
text = utf8(text) unless text.is_a?(String) && text.encoding == Encoding::UTF_8
|
|
102
141
|
@native.encode(text)
|
|
103
142
|
end
|
|
104
143
|
|
|
@@ -107,6 +146,7 @@ module Gigatoken
|
|
|
107
146
|
# token ids plus per-document lengths), avoiding the per-token Ruby
|
|
108
147
|
# array materialization the ragged shape costs.
|
|
109
148
|
def encode_batch(texts, packed: false)
|
|
149
|
+
texts = utf8_batch(texts)
|
|
110
150
|
if packed
|
|
111
151
|
PackedResult.new(*@native.encode_batch_packed(texts))
|
|
112
152
|
else
|
|
@@ -154,5 +194,34 @@ module Gigatoken
|
|
|
154
194
|
end
|
|
155
195
|
|
|
156
196
|
attr_reader :special_tokens
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
# UTF-8 first and by identity, so the batch walk below costs the same one
|
|
201
|
+
# comparison per document that #encode costs; the list is only consulted
|
|
202
|
+
# for the encodings that aren't it.
|
|
203
|
+
def transcode?(text)
|
|
204
|
+
text.is_a?(String) && text.encoding != Encoding::UTF_8 && !BYTEWISE_ENCODINGS.include?(text.encoding)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A transcode that can't be done — a dummy encoding like UTF-7, bytes the
|
|
208
|
+
# tag doesn't allow, a character the target can't hold — is a fact about
|
|
209
|
+
# the document, not about the tokenizer, and reaches the caller as one.
|
|
210
|
+
def utf8(text)
|
|
211
|
+
transcode?(text) ? text.encode(Encoding::UTF_8) : text
|
|
212
|
+
rescue EncodingError => e
|
|
213
|
+
raise InputError, e.message
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The same rule per document, without copying the Array when — as in
|
|
217
|
+
# every UTF-8 batch — there is nothing to transcode. Elements are only
|
|
218
|
+
# type-checked, never coerced: a `to_str` object is left for the native
|
|
219
|
+
# side to convert under its own snapshot of the input.
|
|
220
|
+
def utf8_batch(texts)
|
|
221
|
+
array = Array.try_convert(texts)
|
|
222
|
+
return texts if array.nil? || array.none? { |text| transcode?(text) }
|
|
223
|
+
|
|
224
|
+
array.map { |text| utf8(text) }
|
|
225
|
+
end
|
|
157
226
|
end
|
|
158
227
|
end
|
data/lib/gigatoken/version.rb
CHANGED
data/lib/gigatoken.rb
CHANGED
|
@@ -4,9 +4,25 @@ require_relative "gigatoken/version"
|
|
|
4
4
|
|
|
5
5
|
module Gigatoken
|
|
6
6
|
# Raised for tokenizer load and encode failures surfaced from the native
|
|
7
|
-
# extension — never a raw Rust panic across the Ruby boundary.
|
|
7
|
+
# extension — never a raw Rust panic across the Ruby boundary. The base of
|
|
8
|
+
# the three below: rescue this to catch everything gigatoken raises.
|
|
9
|
+
# Anything narrower than the three has no class of its own — CLI usage
|
|
10
|
+
# errors and the odd leftover are this one directly.
|
|
8
11
|
class Error < StandardError; end
|
|
9
12
|
|
|
13
|
+
# Everything Gigatoken::Hub raises: HTTP status, transport, timeout, repo-id
|
|
14
|
+
# / revision / filename / x-repo-commit validation.
|
|
15
|
+
class HubError < Error; end
|
|
16
|
+
|
|
17
|
+
# A document the tokenizer cannot take: an untranscodable or invalid-byte
|
|
18
|
+
# String, invalid UTF-8 on the SentencePiece path, an id outside the
|
|
19
|
+
# vocabulary in #decode.
|
|
20
|
+
class InputError < Error; end
|
|
21
|
+
|
|
22
|
+
# A tokenizer that cannot be loaded: bad or hostile JSON, a missing file or
|
|
23
|
+
# directory, an unknown or unpackable encoding name, a malformed .tiktoken.
|
|
24
|
+
class ModelError < Error; end
|
|
25
|
+
|
|
10
26
|
class << self
|
|
11
27
|
# The process-global encode-cache budget in bytes per worker (a parallel
|
|
12
28
|
# batch encode may use up to workers x budget), applied to tokenizers of
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gigatoken
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: async
|
|
@@ -75,7 +75,10 @@ files:
|
|
|
75
75
|
- lib/gigatoken/encodings.rb
|
|
76
76
|
- lib/gigatoken/encodings/PROVENANCE.md
|
|
77
77
|
- lib/gigatoken/encodings/cl100k_base.tiktoken
|
|
78
|
+
- lib/gigatoken/encodings/muse_spark.json.gz
|
|
78
79
|
- lib/gigatoken/encodings/o200k_base.tiktoken
|
|
80
|
+
- lib/gigatoken/encodings/qwen35.json.gz
|
|
81
|
+
- lib/gigatoken/encodings/qwen38.json.gz
|
|
79
82
|
- lib/gigatoken/encodings/r50k_base.tiktoken
|
|
80
83
|
- lib/gigatoken/hub.rb
|
|
81
84
|
- lib/gigatoken/packed_result.rb
|