gigatoken 0.2.2-aarch64-linux → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cc04817cf7e36afff107fbe7e5fd71d856c733d4afd57c5000ebd977006c6d4
4
- data.tar.gz: 17ece4ff18d0d63d1d3d6d1443ee037c6bb96310e2b6abe1fa1e4bb6fbf0b421
3
+ metadata.gz: b71bf2d5bc4c68c369507b7582cd539284e4106a79bc366c8f88016fd3e01475
4
+ data.tar.gz: 1a1137fd492cce3db3aebfbed54c95049b091cbf3ceeefa17d28a624b64b4765
5
5
  SHA512:
6
- metadata.gz: cbeeda4b3288656179f675beb7a7dfcb998ac068be31e794c11fb3e3c80879a6f1492bcb656ca8d03b1e6084bb2b312cb68efabdd889611c7fe47c3f5558e925
7
- data.tar.gz: 99aa1445f17e51eb6aa746d12d18357c3e86522925487f2c60074a833f873232ff6d38e47d8c73f320afa5337a9d19b232e1b81029d70851d36dc5d6425e563e
6
+ metadata.gz: bd326b7fa3b0b7c5b13570362700caf963e95630b08882bb227d7da7a4ac342db73e132f5ea286de861e39a0a30b3d06e7b92885d22d80cb489be4a343693931
7
+ data.tar.gz: 37ee8f34abf50eccb99b15db9f0fce8fcece3a3086b12a342cf06dbcde552a5506a3586ee448248bc947134926c1b7964607411c55a9c84ab1c0f91885eca797
data/README.md CHANGED
@@ -15,7 +15,7 @@ Zero-copy Ruby bindings for [marcelroed/gigatoken](https://github.com/marcelroed
15
15
 
16
16
  Mac Studio M4 Max, OpenWebText, GPT-2 tokenizer; every library produces the same tokenization, gigatoken just does it faster. The Python row includes a fix for [marcelroed/gigatoken#38](https://github.com/marcelroed/gigatoken/issues/38) — a hidden memcpy in `shrink_to_fit()` we found while chasing the last of the Ruby–Python gap and sent upstream (one-line fix; without it the wheel lands around 7.4 GB/s).
17
17
 
18
- **340x faster** than the fastest existing Ruby gem (tiktoken_ruby) and **1,050x faster** than the tokenizers gem. Full methodology & exact counts: [docs/rb/benchmarks.md](docs/rb/benchmarks.md).
18
+ **340x faster** than the fastest existing Ruby gem (tiktoken_ruby) and **1,050x faster** than the tokenizers gem. Full methodology & exact counts: [docs/explanation/benchmarks.md](docs/explanation/benchmarks.md).
19
19
 
20
20
  ## Install
21
21
 
@@ -57,9 +57,9 @@ 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::Error` naming the valid ones, and `Tokenizer.load` on a `.tiktoken` path with no `pretokenizer:` raises rather than silently picking one.
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::Error` on invalid UTF-8 instead of guessing.
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
64
  ### Packaged tiktoken encodings
65
65
 
@@ -71,7 +71,7 @@ Gigatoken::Tokenizer.load("cl100k_base") # same result — packaged nam
71
71
  # checked before the Hub-repo-id shape
72
72
  ```
73
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.
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::ModelError` explaining that, rather than `load` falling through to the Hub for a name that happens to look like a legacy repo id.
75
75
 
76
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
77
 
@@ -117,9 +117,15 @@ packed.token_count # => total tokens
117
117
  packed[3] # => document 3's ids as an Array, on demand
118
118
  ```
119
119
 
120
+ ### Errors
121
+
122
+ 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.
123
+
124
+ `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).
125
+
120
126
  ### Async
121
127
 
122
- `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/rb/async.md](docs/rb/async.md).
128
+ `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).
123
129
 
124
130
  ## CLI
125
131
 
@@ -138,6 +144,16 @@ gigatoken bench lib/gigatoken/encodings/cl100k_base.tiktoken README.md --pretoke
138
144
 
139
145
  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
146
 
147
+ ## Documentation
148
+
149
+ In-depth docs live under [`docs/`](docs/README.md), organized by
150
+ [Diátaxis](https://diataxis.fr/):
151
+
152
+ - **Tutorial:** [Getting started](docs/tutorials/getting-started.md)
153
+ - **How-to:** [Load a tokenizer](docs/how-to/load-a-tokenizer.md), [Tokenize files](docs/how-to/tokenize-files.md), [Packed results](docs/how-to/use-packed-results.md), [Cache budget](docs/how-to/tune-the-cache-budget.md), [Async](docs/how-to/run-under-async.md), [Measure](docs/how-to/measure-time-and-allocations.md)
154
+ - **Reference:** [`Tokenizer`](docs/reference/tokenizer.md), [`PackedResult`](docs/reference/packed-result.md), [File sources](docs/reference/file-sources.md), [Encodings and settings](docs/reference/encodings-and-settings.md), [CLI](docs/reference/cli.md)
155
+ - **Explanation:** [Benchmarks](docs/explanation/benchmarks.md), [Allocations](docs/explanation/allocations.md), [Async design](docs/explanation/async-design.md)
156
+
141
157
  ## Development
142
158
 
143
159
  ```bash
@@ -145,6 +161,7 @@ bundle install
145
161
  bundle exec rake compile # native extension (Rust nightly, via rust-toolchain.toml)
146
162
  bundle exec rspec
147
163
  bundle exec standardrb
164
+ ruby -Ilib bench/operations.rb # every operation: i/s, objects and malloc per call
148
165
  ```
149
166
 
150
167
  The Ruby layer is fiber-first throughout — no `Thread`, no `Mutex`; all parallelism lives in the core's rayon pool. CI runs ubuntu + macos × Ruby 3.3/3.4/4.0, and `release.yml` cross-builds the precompiled native gems (arm64-darwin, x86_64-linux, aarch64-linux).
Binary file
Binary file
Binary file
@@ -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
@@ -40,6 +40,10 @@ module Gigatoken
40
40
  HARMONY_RESERVED_TAIL = (200013..201087).to_h { |id| ["<|reserved_#{id}|>", id] }.freeze
41
41
  private_constant :HARMONY_RESERVED_TAIL
42
42
 
43
+ # Deep-frozen: entries, their `special_tokens` tables and the `rank_file`
44
+ # paths. `Tokenizer#special_tokens` hands the registry's own Hash back to
45
+ # callers, so anything less lets one caller's poke rewrite what every
46
+ # later `from_encoding` in the process loads.
43
47
  REGISTRY = {
44
48
  "r50k_base" => {
45
49
  rank_file: File.join(DATA_DIR, "r50k_base.tiktoken"),
@@ -67,7 +71,7 @@ module Gigatoken
67
71
  pretokenizer: "o200k",
68
72
  special_tokens: HARMONY_HEAD_TOKENS.merge(HARMONY_RESERVED_TAIL).freeze
69
73
  }
70
- }.freeze
74
+ }.each_value { |encoding| encoding.each_value(&:freeze).freeze }.freeze
71
75
  private_constant :REGISTRY
72
76
 
73
77
  # The packaged encoding names — the single source error messages naming
@@ -88,15 +92,16 @@ module Gigatoken
88
92
 
89
93
  class << self
90
94
  # The {rank_file:, pretokenizer:, special_tokens:} registered for a
91
- # packaged encoding name, or nil.
95
+ # packaged encoding name, or nil. Names are Strings or Symbols, as
96
+ # Tokenizer.load accepts both.
92
97
  def [](name)
93
- REGISTRY[name]
98
+ REGISTRY[name.to_s]
94
99
  end
95
100
 
96
101
  # Why `name` can't be packaged, or nil when there's no reason on
97
102
  # record (it's either packaged, or simply not one gigatoken knows of).
98
103
  def unpackable_reason(name)
99
- UNPACKABLE_REASONS[name]
104
+ UNPACKABLE_REASONS[name.to_s]
100
105
  end
101
106
  end
102
107
  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
@@ -99,15 +133,50 @@ module Gigatoken
99
133
  revision.match?(/\A[0-9a-f]{40}\z/)
100
134
  end
101
135
 
136
+ # The Hub endpoint, resolved like huggingface_hub does it: HF_ENDPOINT,
137
+ # then https://huggingface.co.
138
+ def default_endpoint
139
+ env("HF_ENDPOINT") || DEFAULT_ENDPOINT
140
+ end
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
+
102
159
  private
103
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
+
104
173
  def env(key)
105
174
  value = ENV[key]
106
175
  value unless value.nil? || value.empty?
107
176
  end
108
177
 
109
178
  def word_part?(part, first_alnum:)
110
- return false if part.nil? || part.empty?
179
+ return false if part.nil? || part.empty? || part.match?(DOT_SEGMENT)
111
180
 
112
181
  first_ok = first_alnum ? part[0].match?(/[A-Za-z0-9]/) : word_char?(part[0])
113
182
  first_ok && part[1..].chars.all? { |c| word_char?(c) }
@@ -125,15 +194,28 @@ module Gigatoken
125
194
 
126
195
  # @parameter endpoint [String] the Hub endpoint to fetch from — override
127
196
  # for pointing at a local server in tests (dependency injection, not a
128
- # mock).
129
- def initialize(endpoint: DEFAULT_ENDPOINT)
197
+ # mock); defaults to Hub.default_endpoint (HF_ENDPOINT, then
198
+ # huggingface.co).
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)
130
202
  @endpoint = endpoint.chomp("/")
131
- @internet = Async::HTTP::Internet.new
203
+ @timeout = timeout
132
204
  end
133
205
 
134
206
  # Path of `filename` from Hub repo `repo_id` at `revision`, served from
135
- # 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.
136
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
+
137
219
  self.class.cached_file(repo_id, filename, revision) ||
138
220
  Sync { fetch(repo_id, filename, revision) }
139
221
  end
@@ -145,31 +227,127 @@ module Gigatoken
145
227
  # recording the branch ref so later lookups (ours and
146
228
  # huggingface_hub's) resolve it.
147
229
  def fetch(repo_id, filename, revision)
148
- url = "#{@endpoint}/#{repo_id}/resolve/#{revision}/#{filename}"
230
+ clients = []
231
+ url = resolve_url(repo_id, filename, revision)
149
232
  token = self.class.hf_token
150
- response = @internet.get(url, auth_headers(token))
233
+ headers = auth_headers(token)
234
+ response = get(url, headers, clients)
151
235
  # Unlisted headers parse as a Header::Generic (an Array of values);
152
236
  # x-repo-commit is always a single value, so flatten it to a String.
153
237
  commit = response.headers["x-repo-commit"]&.to_s
154
238
 
155
- # Redirects are followed by hand: resolve/ URLs answer with the
156
- # x-repo-commit header and a redirect to a CDN for LFS files, and the
157
- # Authorization header must not travel to the other host.
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.
158
242
  hops = 0
159
243
  while (300...400).cover?(response.status)
160
- ensure_ok!(url, response.status, !!token)
161
- hops += 1
162
- raise Error, "#{url}: too many redirects" if hops > MAX_REDIRECTS
163
-
164
- location = response.headers["location"] ||
165
- raise(Error, "#{url}: redirect with no Location header")
244
+ location = response.headers["location"]
166
245
  response.close
167
- url = absolutize(location, url)
168
- response = @internet.get(url, {"user-agent" => "gigatoken"})
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
260
+ end
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
270
+
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)
169
284
  end
170
- ensure_ok!(url, response.status, !!token)
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
171
296
 
172
- write_to_cache(repo_id, filename, revision, commit || revision, response)
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 }
173
351
  end
174
352
 
175
353
  def auth_headers(token)
@@ -192,51 +370,75 @@ module Gigatoken
192
370
  rescue
193
371
  FileUtils.rm_f(tmp)
194
372
  raise
195
- ensure
196
- response.close
197
373
  end
198
374
  File.rename(tmp, target)
199
375
 
200
- if !self.class.commit_hash?(revision) && revision != commit
201
- refs_dir = repo_dir.join("refs")
202
- FileUtils.mkdir_p(refs_dir)
203
- File.write(refs_dir.join(revision), commit)
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)
204
382
  end
205
383
 
206
384
  target
207
385
  end
208
386
 
209
- def ensure_ok!(url, status, had_token)
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
210
393
  return if (200...400).cover?(status)
211
394
 
212
395
  case status
213
396
  when 404
214
- raise Error, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
397
+ raise HubError, "#{url}: HTTP 404 — no such repo with that file, and no such local file either"
215
398
  when 401, 403
216
399
  token_note = had_token ? "the request used the discovered token" : "no token was found"
217
- raise Error,
400
+ raise HubError,
218
401
  "#{url}: HTTP #{status} — the repo may be private or gated (#{token_note}; set HF_TOKEN or run " \
219
402
  "`hf auth login`, and accept the repo's terms on huggingface.co if it is gated)"
220
403
  else
221
- raise Error, "#{url}: HTTP #{status}"
404
+ raise HubError, "#{url}: HTTP #{status}"
222
405
  end
223
406
  end
224
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
+
225
420
  # A redirect Location resolved against the request URL: absolute URLs
226
421
  # pass through, host-relative (`/x/y`) and path-relative ones join the
227
422
  # base.
228
423
  def absolutize(location, base)
229
424
  return location if location.include?("://")
230
425
 
231
- origin_end = base.index("://") ? base.index("://") + 3 : 0
232
- origin_end = base.index("/", origin_end) || base.length
426
+ base_origin = origin(base)
427
+ return "#{base_origin}#{location}" if location.start_with?("/")
233
428
 
234
- if location.start_with?("/")
235
- "#{base[0...origin_end]}#{location}"
236
- else
237
- dir_end = base.rindex("/") || base.length
238
- "#{base[0...[dir_end, origin_end].max]}/#{location}"
239
- end
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))
240
442
  end
241
443
  end
242
444
  end
@@ -28,9 +28,17 @@ module Gigatoken
28
28
  lens.sum
29
29
  end
30
30
 
31
- # Array of token ids for document `i`, materialized on demand.
32
- def [](i)
33
- buffer.get_values(Array.new(lens[i], :u32), @offsets[i] * 4)
31
+ # Array of token ids for document `i`, materialized on demand; negative
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
+
38
+ i += size if i.negative?
39
+ return unless i >= 0 && i < size
40
+
41
+ buffer.values(:u32, @offsets[i] * 4, lens[i])
34
42
  end
35
43
 
36
44
  def each
@@ -42,7 +50,7 @@ module Gigatoken
42
50
  # A ragged Array of Arrays, one per document — the same shape
43
51
  # `encode_batch`/`encode_files` return with `packed: false`.
44
52
  def to_a
45
- each.to_a
53
+ Array.new(size) { |i| self[i] }
46
54
  end
47
55
  end
48
56
  end
@@ -10,18 +10,35 @@ module Gigatoken
10
10
  FILE_SOURCE_CLASSES = [Native::TextFileSource, Native::JsonlFileSource, Native::ParquetFileSource].freeze
11
11
  private_constant :FILE_SOURCE_CLASSES
12
12
 
13
+ # Encodings whose bytes reach the native call untouched. UTF-8 is checked
14
+ # by identity first — it is the hot path, see #encode — and these are the
15
+ # rest: US-ASCII already is UTF-8 bytes, and ASCII-8BIT is deliberately
16
+ # raw bytes. Anything else — ISO-8859-1, Windows-1252, UTF-16LE — is a
17
+ # real encoding whose bytes are not the text's UTF-8 bytes, so encoding it
18
+ # raw would give different ids than the same text read as UTF-8. (Invalid
19
+ # bytes in a UTF-8-tagged String stay raw: a documented difference from
20
+ # tiktoken, which rejects them.)
21
+ BYTEWISE_ENCODINGS = [Encoding::US_ASCII, Encoding::BINARY].freeze
22
+ private_constant :BYTEWISE_ENCODINGS
23
+
13
24
  # Load from in-memory tokenizer.json contents (String or bytes). Backed
14
25
  # by a BPETokenizer or a SentencePieceTokenizer, per the model's
15
- # byte_fallback flag.
26
+ # byte_fallback flag. The document is parsed twice, deliberately: the Ruby
27
+ # parse runs first because JSON.parse's depth limit is what keeps hostile
28
+ # nesting away from the native parser (see parse_json), and it is also
29
+ # where the special-token table is read from.
16
30
  def self.from_json(data)
17
- native = Native.load_hf_json(data)
18
- new(native, special_tokens: special_tokens_from_json(data))
31
+ json = String.try_convert(data) or raise TypeError, "no implicit conversion of #{data.class} into String"
32
+ parsed = parse_json(json)
33
+ new(Native.load_hf_json(json), special_tokens: special_tokens_from(parsed))
19
34
  end
20
35
 
21
36
  # Load from a tokenizer.json path, or a directory containing one.
22
37
  def self.from_file(path)
23
- path = File.join(path, "tokenizer.json") if File.directory?(path)
24
- from_json(File.binread(path))
38
+ file = File.directory?(path) ? File.join(path, "tokenizer.json") : path.to_s
39
+ raise ModelError, "#{file.inspect}: no such file" unless File.file?(file)
40
+
41
+ from_json(File.binread(file))
25
42
  end
26
43
 
27
44
  # Load from a .tiktoken mergeable-ranks file. The file carries neither a
@@ -44,13 +61,13 @@ module Gigatoken
44
61
 
45
62
  reason = Encodings.unpackable_reason(name)
46
63
  detail = reason ? " — #{reason}" : ""
47
- raise Error, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
64
+ raise ModelError, "#{name.inspect}: not a packaged encoding#{detail} (packaged encodings: #{Encodings::NAMES.join(", ")})"
48
65
  end
49
66
 
50
67
  # Load tokenizer.json from HuggingFace Hub repo `repo_id` at `revision`
51
68
  # (downloaded directly; huggingface_hub is not required).
52
- def self.from_hub(repo_id, revision: "main", hub: Hub.new)
53
- from_file(hub.hub_file(repo_id, "tokenizer.json", revision: revision))
69
+ def self.from_hub(repo_id, revision: "main", hub: nil)
70
+ from_file((hub || Hub.new).hub_file(repo_id, "tokenizer.json", revision: revision))
54
71
  end
55
72
 
56
73
  # Load from any of the supported source shapes: an existing file or
@@ -67,11 +84,11 @@ module Gigatoken
67
84
  # here too, raising the same explanation from_encoding gives rather than
68
85
  # reaching the Hub — but only those; an unrecognized bare name like
69
86
  # "gpt2" still dispatches to the Hub.
70
- def self.load(source, pretokenizer: nil, special_tokens: {}, revision: "main", hub: Hub.new)
87
+ def self.load(source, pretokenizer: nil, special_tokens: {}, revision: "main", hub: nil)
71
88
  source = source.to_s
72
89
  if source.end_with?(".tiktoken")
73
90
  unless pretokenizer
74
- raise Error, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
91
+ raise ModelError, "#{source.inspect}: a .tiktoken file carries no pretokenizer scheme of its own — " \
75
92
  "pass pretokenizer: (one of #{Native.pretokenizer_names.join(", ")})"
76
93
  end
77
94
  return from_tiktoken(source, pretokenizer: pretokenizer, special_tokens: special_tokens)
@@ -80,21 +97,44 @@ module Gigatoken
80
97
  return from_encoding(source) if Encodings::NAMES.include?(source) || Encodings.unpackable_reason(source)
81
98
  return from_hub(source, revision: revision, hub: hub) if Hub.looks_like_repo_id?(source)
82
99
 
83
- raise Error, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
100
+ raise ModelError, "#{source.inspect}: no such file or directory, not a .tiktoken path, and doesn't look like a HuggingFace Hub repo id"
101
+ end
102
+
103
+ # `data` is UTF-8 JSON whatever its encoding tag says (File.binread tags
104
+ # binary; a US-ASCII default_external tags that), so retag rather than let
105
+ # JSON.parse transcode from the tag; a UTF-8-tagged String needs no copy.
106
+ # JSON.parse's default max_nesting of 100 is the guard: the native parser
107
+ # is recursive with no depth limit, and deep enough nesting overflows its
108
+ # stack rather than raising.
109
+ def self.parse_json(data)
110
+ data = data.dup.force_encoding(Encoding::UTF_8) unless data.encoding == Encoding::UTF_8
111
+ JSON.parse(data)
112
+ rescue JSON::ParserError => e
113
+ raise ModelError, "failed to parse tokenizer JSON: #{e.message}"
84
114
  end
115
+ private_class_method :parse_json
85
116
 
86
- def self.special_tokens_from_json(data)
87
- added = JSON.parse(data.dup.force_encoding(Encoding::UTF_8))["added_tokens"] || []
117
+ def self.special_tokens_from(parsed)
118
+ added = parsed["added_tokens"] || []
88
119
  added.each_with_object({}) { |t, h| h[t["content"]] = t["id"] if t["special"] }
89
120
  end
90
- private_class_method :special_tokens_from_json
121
+ private_class_method :special_tokens_from
91
122
 
123
+ # The table is frozen so `#special_tokens` can neither alias the
124
+ # deep-frozen registry's Hash nor a `from_tiktoken` caller's; an
125
+ # already-frozen one is safe to share as-is.
92
126
  def initialize(native, special_tokens: {})
93
127
  @native = native
94
- @special_tokens = special_tokens
128
+ @special_tokens = special_tokens.frozen? ? special_tokens : special_tokens.dup.freeze
95
129
  end
96
130
 
131
+ # A String already tagged UTF-8 — every ordinary document — reaches the
132
+ # native call over one inline identity check. A helper frame here is
133
+ # measurable against the encode itself at document sizes this small, so
134
+ # the check is spelled out rather than delegated to #transcode?; anything
135
+ # else takes the slow path in #utf8.
97
136
  def encode(text)
137
+ text = utf8(text) unless text.is_a?(String) && text.encoding == Encoding::UTF_8
98
138
  @native.encode(text)
99
139
  end
100
140
 
@@ -103,6 +143,7 @@ module Gigatoken
103
143
  # token ids plus per-document lengths), avoiding the per-token Ruby
104
144
  # array materialization the ragged shape costs.
105
145
  def encode_batch(texts, packed: false)
146
+ texts = utf8_batch(texts)
106
147
  if packed
107
148
  PackedResult.new(*@native.encode_batch_packed(texts))
108
149
  else
@@ -150,5 +191,34 @@ module Gigatoken
150
191
  end
151
192
 
152
193
  attr_reader :special_tokens
194
+
195
+ private
196
+
197
+ # UTF-8 first and by identity, so the batch walk below costs the same one
198
+ # comparison per document that #encode costs; the list is only consulted
199
+ # for the encodings that aren't it.
200
+ def transcode?(text)
201
+ text.is_a?(String) && text.encoding != Encoding::UTF_8 && !BYTEWISE_ENCODINGS.include?(text.encoding)
202
+ end
203
+
204
+ # A transcode that can't be done — a dummy encoding like UTF-7, bytes the
205
+ # tag doesn't allow, a character the target can't hold — is a fact about
206
+ # the document, not about the tokenizer, and reaches the caller as one.
207
+ def utf8(text)
208
+ transcode?(text) ? text.encode(Encoding::UTF_8) : text
209
+ rescue EncodingError => e
210
+ raise InputError, e.message
211
+ end
212
+
213
+ # The same rule per document, without copying the Array when — as in
214
+ # every UTF-8 batch — there is nothing to transcode. Elements are only
215
+ # type-checked, never coerced: a `to_str` object is left for the native
216
+ # side to convert under its own snapshot of the input.
217
+ def utf8_batch(texts)
218
+ array = Array.try_convert(texts)
219
+ return texts if array.nil? || array.none? { |text| transcode?(text) }
220
+
221
+ array.map { |text| utf8(text) }
222
+ end
153
223
  end
154
224
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gigatoken
4
- VERSION = "0.2.2"
4
+ VERSION = "0.4.0"
5
5
  end
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
data/rust-toolchain.toml CHANGED
@@ -1,5 +1,5 @@
1
1
  [toolchain]
2
- channel = "nightly"
2
+ channel = "nightly-2026-09-18"
3
3
  # Cross triples for the precompiled-gem builds (ruby-gem.yml). rustup's
4
4
  # toolchain-file auto-install is the only thing that provisions nightly
5
5
  # inside the rb-sys-dock containers, so the targets must be declared here —
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.2.2
4
+ version: 0.4.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-09-08 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async