gigatoken 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 —