static_embeddings 0.1.1

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.
@@ -0,0 +1,289 @@
1
+ require "digest"
2
+
3
+ module StaticEmbeddings
4
+ module Format
5
+ MAGIC = "SEMBv1\0\0"
6
+ VERSION = 2
7
+ HEADER_SIZE = 320
8
+ ALIGNMENT = 64
9
+
10
+ TOKENIZER_BERT_WORDPIECE_V1 = 1
11
+ DTYPE_F32 = 1
12
+ POOLING_MEAN = 1
13
+ NORMALIZATION_NONE = 0
14
+ NORMALIZATION_L2 = 1
15
+ TRUNCATE_IDS_BEFORE_POOLING = 1
16
+ UNK_INCLUDE = 0
17
+ UNK_DROP = 1
18
+ EMPTY_ZERO_VECTOR = 0
19
+ EMPTY_RAISE = 1
20
+
21
+ SLOT_EMPTY = 0xFFFFFFFF
22
+ HASH_SEED = 2_166_136_261
23
+ LOAD_FACTOR = 0.70
24
+
25
+ MAX_TOKEN_CHARS_OFFSET = 124
26
+ CHECKSUM_OFFSET = 240
27
+ CHECKSUM_SIZE = 32
28
+ MAX_PROBE_OFFSET = 304
29
+
30
+ SECTION_FIELDS = {
31
+ vocab_strings: 128,
32
+ vocab_hash: 144,
33
+ embeddings: 160,
34
+ norm_tables: 176,
35
+ provenance: 192,
36
+ root_trie: 208,
37
+ continuation_trie: 224
38
+ }.freeze
39
+
40
+ HEADER_U32 = {
41
+ 8 => VERSION,
42
+ 12 => HEADER_SIZE,
43
+ 16 => 1,
44
+ 28 => TOKENIZER_BERT_WORDPIECE_V1,
45
+ 32 => DTYPE_F32,
46
+ 36 => POOLING_MEAN,
47
+ 48 => TRUNCATE_IDS_BEFORE_POOLING,
48
+ 108 => HASH_SEED
49
+ }.freeze
50
+
51
+ META_U32 = {
52
+ 20 => :dim,
53
+ 40 => :normalization_type,
54
+ 44 => :max_tokens_default,
55
+ 56 => :unk_policy,
56
+ 60 => :empty_policy,
57
+ 80 => :max_input_chars_per_word,
58
+ 84 => :pad_id,
59
+ 88 => :unk_id,
60
+ 92 => :cls_id,
61
+ 96 => :sep_id,
62
+ 100 => :mask_id
63
+ }.freeze
64
+
65
+ META_BOOL = {
66
+ 52 => :add_special_tokens,
67
+ 64 => :do_lower_case,
68
+ 68 => :strip_accents,
69
+ 72 => :handle_chinese_chars,
70
+ 76 => :clean_text
71
+ }.freeze
72
+
73
+ module_function
74
+
75
+ def hash_bytes(str, seed = HASH_SEED)
76
+ str.each_byte.reduce(seed) { |h, b| ((h ^ b) * 16_777_619) & 0xFFFFFFFF }
77
+ end
78
+
79
+ def next_power_of_two(n)
80
+ 1 << (n - 1).bit_length
81
+ end
82
+
83
+ def build_hash_table(tokens)
84
+ size = next_power_of_two((tokens.length / LOAD_FACTOR).ceil + 1)
85
+ slots = Array.new(size)
86
+ strings = binary_string
87
+ max_probe = 0
88
+
89
+ tokens.each_with_index do |token, id|
90
+ bytes = token.b
91
+ offset = strings.bytesize
92
+ strings << bytes
93
+ probe = insert_slot!(slots, tokens, size, bytes, hash_bytes(bytes), offset, id)
94
+ max_probe = probe if probe > max_probe
95
+ end
96
+
97
+ [size, strings, pack_slots(slots), max_probe]
98
+ end
99
+
100
+ def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
101
+ hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
102
+ root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
103
+ sections, body = build_body(
104
+ vocab_strings: strings,
105
+ vocab_hash: hash_blob,
106
+ embeddings: matrix,
107
+ norm_tables: norm_tables,
108
+ provenance: provenance,
109
+ root_trie: root_trie,
110
+ continuation_trie: continuation_trie
111
+ )
112
+
113
+ header = build_header(meta, tokens.length, hash_size, max_probe, sections)
114
+ file = header << body
115
+ digest = Digest::SHA256.digest(file)
116
+ file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
117
+
118
+ File.binwrite(path, file)
119
+ { bytes: file.bytesize, sha256: digest.unpack1("H*"), hash_table_size: hash_size }
120
+ end
121
+
122
+ def verify(path)
123
+ data = File.binread(path)
124
+ raise InvalidModelError, "file too small" if data.bytesize < HEADER_SIZE
125
+ raise InvalidModelError, "bad magic" unless data.byteslice(0, 8) == MAGIC.b
126
+
127
+ stored = data.byteslice(CHECKSUM_OFFSET, CHECKSUM_SIZE)
128
+ actual = checksum_for_verify(data)
129
+
130
+ { ok: stored == actual, expected: actual.unpack1("H*"), stored: stored.unpack1("H*") }
131
+ end
132
+
133
+ def binary_string(capacity = nil)
134
+ str = capacity ? String.new(capacity: capacity) : +""
135
+ str.force_encoding(Encoding::BINARY)
136
+ end
137
+
138
+ def insert_slot!(slots, tokens, size, bytes, hash, offset, id)
139
+ pos = hash & (size - 1)
140
+ probe = 1
141
+ loop do
142
+ slot = slots[pos]
143
+ unless slot
144
+ slots[pos] = [hash, offset, bytes.bytesize, id]
145
+ return probe
146
+ end
147
+
148
+ if slot[0] == hash && slot[2] == bytes.bytesize && tokens.fetch(slot[3]).b == bytes
149
+ raise ArgumentError, "duplicate token in vocabulary: #{tokens.fetch(slot[3]).inspect}"
150
+ end
151
+
152
+ pos = (pos + 1) & (size - 1)
153
+ probe += 1
154
+ end
155
+ end
156
+
157
+ def pack_slots(slots)
158
+ empty = [0, 0, 0, SLOT_EMPTY].pack("V4")
159
+ packed = binary_string(slots.length * 16)
160
+ slots.each { |slot| packed << (slot ? slot.pack("V4") : empty) }
161
+ packed
162
+ end
163
+
164
+ def build_wordpiece_tries(tokens, prefix)
165
+ root = TrieBuilder.new
166
+ continuation = TrieBuilder.new
167
+ prefix_bytes = prefix.b
168
+
169
+ tokens.each_with_index do |token, id|
170
+ bytes = token.b
171
+ if !prefix_bytes.empty? && bytes.start_with?(prefix_bytes) && bytes.bytesize > prefix_bytes.bytesize
172
+ continuation.insert(bytes.byteslice(prefix_bytes.bytesize, bytes.bytesize - prefix_bytes.bytesize), id)
173
+ else
174
+ root.insert(bytes, id)
175
+ end
176
+ end
177
+
178
+ [root.pack, continuation.pack]
179
+ end
180
+
181
+ class TrieBuilder
182
+ Node = Struct.new(:terminal, :children, keyword_init: true)
183
+
184
+ def initialize
185
+ @nodes = [Node.new(terminal: SLOT_EMPTY, children: {})]
186
+ end
187
+
188
+ def insert(bytes, id)
189
+ return if bytes.empty?
190
+
191
+ node_index = 0
192
+ bytes.each_byte do |byte|
193
+ node = @nodes[node_index]
194
+ child = node.children[byte]
195
+ unless child
196
+ child = @nodes.length
197
+ node.children[byte] = child
198
+ @nodes << Node.new(terminal: SLOT_EMPTY, children: {})
199
+ end
200
+ node_index = child
201
+ end
202
+
203
+ node = @nodes[node_index]
204
+ raise ArgumentError, "duplicate trie key for token id #{id}" unless node.terminal == SLOT_EMPTY
205
+
206
+ node.terminal = id
207
+ end
208
+
209
+ def pack
210
+ edges = []
211
+ node_records = @nodes.map do |node|
212
+ start = edges.length
213
+ node.children.sort_by { |byte, _| byte }.each do |byte, child|
214
+ edges << [byte, child]
215
+ end
216
+ [start, node.children.length, node.terminal, 0]
217
+ end
218
+
219
+ packed = Format.binary_string(16 + node_records.length * 16 + edges.length * 8)
220
+ packed << [node_records.length, edges.length, 0, 0].pack("V4")
221
+ node_records.each { |record| packed << record.pack("V4") }
222
+ edges.each { |edge| packed << edge.pack("V2") }
223
+ packed
224
+ end
225
+ end
226
+
227
+ def build_body(payloads)
228
+ body = binary_string
229
+ sections = {}
230
+ payloads.each do |name, payload|
231
+ align_body!(body)
232
+ sections[name] = [HEADER_SIZE + body.bytesize, payload.bytesize]
233
+ body << payload
234
+ end
235
+ [sections, body]
236
+ end
237
+
238
+ def align_body!(body)
239
+ padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
240
+ body << "\0".b * padding if padding.positive?
241
+ end
242
+
243
+ def build_header(meta, vocab_size, hash_size, max_probe, sections)
244
+ header = "\0".b * HEADER_SIZE
245
+ header[0, 8] = MAGIC.b
246
+
247
+ HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
248
+ META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
249
+ META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
250
+
251
+ put_u32(header, 24, vocab_size)
252
+ put_u32(header, 104, hash_size)
253
+ put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
254
+ put_u32(header, MAX_PROBE_OFFSET, max_probe)
255
+ put_prefix(header, meta.fetch(:subword_prefix))
256
+ SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
257
+
258
+ header
259
+ end
260
+
261
+ def put_prefix(header, prefix)
262
+ bytes = prefix.b
263
+ raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
264
+
265
+ put_u32(header, 112, bytes.bytesize)
266
+ header[116, 8] = bytes.ljust(8, "\0")
267
+ end
268
+
269
+ def put_section(header, offset, section)
270
+ off, size = section
271
+ put_u64(header, offset, off)
272
+ put_u64(header, offset + 8, size)
273
+ end
274
+
275
+ def put_u32(buffer, offset, value)
276
+ buffer[offset, 4] = [value].pack("V")
277
+ end
278
+
279
+ def put_u64(buffer, offset, value)
280
+ buffer[offset, 8] = [value & 0xFFFFFFFF, value >> 32].pack("V2")
281
+ end
282
+
283
+ def checksum_for_verify(data)
284
+ zeroed = data.dup
285
+ zeroed[CHECKSUM_OFFSET, CHECKSUM_SIZE] = "\0".b * CHECKSUM_SIZE
286
+ Digest::SHA256.digest(zeroed)
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,48 @@
1
+ require "json"
2
+
3
+ module StaticEmbeddings
4
+ class Model
5
+ attr_reader :path
6
+
7
+ def provenance
8
+ raw = provenance_json
9
+ return {} if raw.nil?
10
+
11
+ JSON.parse(raw)
12
+ rescue JSON::ParserError, EncodingError => e
13
+ raise InvalidModelError, "invalid provenance JSON: #{e.message}"
14
+ end
15
+
16
+ def model_id
17
+ provenance["source_model_id"]
18
+ end
19
+
20
+ def embed_array(text, **opts)
21
+ format = opts.key?(:format) ? opts[:format] : :f32
22
+ StaticEmbeddings.unpack(embed(text, **opts), dim, format: format).first
23
+ end
24
+
25
+ def embed_batch_arrays(texts, **opts)
26
+ format = opts.key?(:format) ? opts[:format] : :f32
27
+ StaticEmbeddings.unpack(embed_batch(texts, **opts), dim, format: format)
28
+ end
29
+
30
+ def cosine_top_k(query_blob, matrix_blob, k, **opts)
31
+ raise ArgumentError, "dim: is set by the model" if opts.key?(:dim)
32
+
33
+ StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
34
+ end
35
+
36
+ def dot_top_k(query_blob, matrix_blob, k, **opts)
37
+ raise ArgumentError, "dim: is set by the model" if opts.key?(:dim)
38
+
39
+ StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
40
+ end
41
+
42
+ def to_s
43
+ "#<StaticEmbeddings::Model #{model_id || path} dim=#{dim} vocab=#{vocab_size}>"
44
+ end
45
+
46
+ alias inspect to_s
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ module StaticEmbeddings
2
+ module Paths
3
+ BUILTIN_DIR = File.expand_path("../models", __dir__)
4
+ BUILTIN_MODELS = { demo: "demo.semb" }.freeze
5
+
6
+ module_function
7
+
8
+ def cache_dir(env = ENV)
9
+ env["STATIC_EMBEDDINGS_CACHE"] ||
10
+ File.join(env["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "static_embeddings")
11
+ end
12
+
13
+ def model_path(model_id, env = ENV)
14
+ File.join(cache_dir(env), "models", "#{model_id}.semb")
15
+ end
16
+
17
+ def builtin_path(name)
18
+ file = BUILTIN_MODELS.fetch(name) do
19
+ raise ModelNotFound, "unknown builtin model #{name.inspect}"
20
+ end
21
+ File.join(BUILTIN_DIR, file)
22
+ end
23
+
24
+ def builtin_available?(name = :demo)
25
+ file = BUILTIN_MODELS[name]
26
+ !file.nil? && File.file?(File.join(BUILTIN_DIR, file))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,191 @@
1
+ module StaticEmbeddings
2
+ class Reference
3
+ CJK_RANGES = [
4
+ 0x4E00..0x9FFF, 0x3400..0x4DBF, 0x20000..0x2A6DF, 0x2A700..0x2B73F,
5
+ 0x2B740..0x2B81F, 0x2B820..0x2CEAF, 0xF900..0xFAFF, 0x2F800..0x2FA1F
6
+ ].freeze
7
+
8
+ ASCII_PUNCT = [33..47, 58..64, 91..96, 123..126].freeze
9
+ ASCII_SPACES = [" ", "\t", "\n", "\r"].freeze
10
+ RE_PUNCT = /\A\p{P}\z/
11
+ RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co}|\p{Cs})\z/
12
+ RE_MN = /\A\p{Mn}\z/
13
+ RE_WHITESPACE = /\A(?:\p{Zs}|[\u0085\u2028\u2029])\z/
14
+
15
+ attr_reader :meta
16
+
17
+ def initialize(tokens:, matrix:, meta:)
18
+ @tokens = tokens
19
+ @vocab = tokens.each_with_index.to_h
20
+ @matrix = matrix
21
+ @meta = meta
22
+ @dim = meta.fetch(:dim)
23
+ end
24
+
25
+ def self.from_source_dir(dir, max_tokens: Converter::REFERENCE_MAX_TOKENS)
26
+ tokenizer = JSON.parse(File.binread(File.join(dir, "tokenizer.json")))
27
+ config_path = File.join(dir, "config.json")
28
+ config = File.file?(config_path) ? JSON.parse(File.binread(config_path)) : {}
29
+ tokens = tokens_from(tokenizer)
30
+ tensor = Safetensors.read(File.join(dir, "model.safetensors"))[:tensors].values.first
31
+ rows, dim = tensor[:shape]
32
+ floats = tensor[:bytes].unpack("e*")
33
+ normalizer = tokenizer["normalizer"] || {}
34
+
35
+ new(tokens: tokens,
36
+ matrix: Array.new(rows) { |i| floats[i * dim, dim] },
37
+ meta: meta_from(tokenizer, config, normalizer, dim, max_tokens))
38
+ end
39
+
40
+ def self.tokens_from(tokenizer)
41
+ vocab = tokenizer.dig("model", "vocab")
42
+ vocab.each_with_object(Array.new(vocab.length)) { |(token, id), tokens| tokens[id] = token }
43
+ end
44
+
45
+ def self.meta_from(tokenizer, config, normalizer, dim, max_tokens)
46
+ lowercase = normalizer.fetch("lowercase", true)
47
+ {
48
+ dim: dim,
49
+ lowercase: lowercase,
50
+ strip_accents: normalizer["strip_accents"].nil? ? lowercase : normalizer["strip_accents"],
51
+ clean_text: normalizer.fetch("clean_text", true),
52
+ handle_chinese_chars: normalizer.fetch("handle_chinese_chars", true),
53
+ max_input_chars_per_word: tokenizer.dig("model", "max_input_chars_per_word") || 100,
54
+ unk_token: tokenizer.dig("model", "unk_token") || "[UNK]",
55
+ normalize: config.key?("normalize") ? config["normalize"] : true,
56
+ max_tokens: max_tokens
57
+ }
58
+ end
59
+
60
+ def normalize_text(text)
61
+ chars = text.chars
62
+ chars = clean_chars(chars) if @meta[:clean_text]
63
+ chars = split_chinese(chars) if @meta[:handle_chinese_chars]
64
+ chars = strip_accents(chars) if @meta[:strip_accents]
65
+ chars = lowercase(chars) if @meta[:lowercase]
66
+ chars.join
67
+ end
68
+
69
+ def pre_tokenize(normalized)
70
+ normalized.split(" ").flat_map { |word| split_punctuation(word) }
71
+ end
72
+
73
+ def tokenize(text, max_tokens: @meta[:max_tokens])
74
+ ids = []
75
+ pre_tokenize(normalize_text(text)).each { |word| ids.concat(wordpiece(word)) }
76
+ max_tokens && max_tokens.positive? && ids.length > max_tokens ? ids.first(max_tokens) : ids
77
+ end
78
+
79
+ def embed(text, max_tokens: @meta[:max_tokens])
80
+ used = tokenize(text, max_tokens: max_tokens).reject { |id| id == unk_id }
81
+ return Array.new(@dim, 0.0) if used.empty?
82
+
83
+ vector = pooled(used)
84
+ @meta[:normalize] ? l2_normalize(vector) : vector
85
+ end
86
+
87
+ private
88
+
89
+ def clean_chars(chars)
90
+ chars.filter_map do |ch|
91
+ cp = ch.ord
92
+ next nil if cp.zero? || cp == 0xFFFD || control?(ch)
93
+
94
+ whitespace?(ch) ? " " : ch
95
+ end
96
+ end
97
+
98
+ def split_chinese(chars)
99
+ chars.flat_map { |ch| cjk?(ch.ord) ? [" ", ch, " "] : [ch] }
100
+ end
101
+
102
+ def strip_accents(chars)
103
+ chars.flat_map { |ch| ch.unicode_normalize(:nfd).chars }.reject { |ch| RE_MN.match?(ch) }
104
+ end
105
+
106
+ def lowercase(chars)
107
+ chars.flat_map { |ch| ch.downcase.chars }
108
+ end
109
+
110
+ def split_punctuation(word)
111
+ out = []
112
+ current = +""
113
+ word.each_char do |ch|
114
+ if punctuation?(ch)
115
+ out << current unless current.empty?
116
+ out << ch
117
+ current = +""
118
+ else
119
+ current << ch
120
+ end
121
+ end
122
+ out << current unless current.empty?
123
+ out
124
+ end
125
+
126
+ def wordpiece(word)
127
+ chars = word.chars
128
+ return [unk_id] if chars.length > @meta[:max_input_chars_per_word]
129
+
130
+ ids = []
131
+ start = 0
132
+ while start < chars.length
133
+ found, finish = longest_piece(chars, start)
134
+ return [unk_id] if found.nil?
135
+
136
+ ids << found
137
+ start = finish
138
+ end
139
+ ids
140
+ end
141
+
142
+ def longest_piece(chars, start)
143
+ finish = chars.length
144
+ while start < finish
145
+ piece = chars[start...finish].join
146
+ piece = "###{piece}" if start.positive?
147
+ id = @vocab[piece]
148
+ return [id, finish] unless id.nil?
149
+
150
+ finish -= 1
151
+ end
152
+ [nil, nil]
153
+ end
154
+
155
+ def pooled(ids)
156
+ acc = Array.new(@dim, 0.0)
157
+ ids.each do |id|
158
+ row = @matrix[id]
159
+ @dim.times { |i| acc[i] += row[i] }
160
+ end
161
+ scale = 1.0 / ids.length
162
+ acc.map { |v| v * scale }
163
+ end
164
+
165
+ def l2_normalize(vec)
166
+ norm = Math.sqrt(vec.sum { |v| v * v })
167
+ norm.positive? ? vec.map { |v| v / norm } : vec
168
+ end
169
+
170
+ def unk_id
171
+ @vocab.fetch(@meta[:unk_token])
172
+ end
173
+
174
+ def cjk?(codepoint)
175
+ CJK_RANGES.any? { |range| range.cover?(codepoint) }
176
+ end
177
+
178
+ def punctuation?(char)
179
+ cp = char.ord
180
+ ASCII_PUNCT.any? { |range| range.cover?(cp) } || RE_PUNCT.match?(char)
181
+ end
182
+
183
+ def control?(char)
184
+ !ASCII_SPACES.include?(char) && RE_CONTROL.match?(char)
185
+ end
186
+
187
+ def whitespace?(char)
188
+ ASCII_SPACES.include?(char) || RE_WHITESPACE.match?(char)
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,87 @@
1
+ require "json"
2
+
3
+ module StaticEmbeddings
4
+ module Safetensors
5
+ MAX_HEADER_BYTES = 100 * 1024 * 1024
6
+ SUPPORTED_DTYPES = { "F32" => 4 }.freeze
7
+
8
+ module_function
9
+
10
+ def read(path)
11
+ data = File.binread(path)
12
+ header, body_offset = parse_header(data)
13
+ body_size = data.bytesize - body_offset
14
+
15
+ tensors = header.each_with_object({}) do |(name, spec), acc|
16
+ next if name == "__metadata__"
17
+
18
+ acc[name] = tensor_from(data, body_offset, body_size, name, spec)
19
+ end
20
+
21
+ { metadata: header["__metadata__"] || {}, tensors: tensors }
22
+ end
23
+
24
+ def write(path, name, shape, floats)
25
+ body = floats.pack("e*")
26
+ header = JSON.generate(name => { "dtype" => "F32", "shape" => shape, "data_offsets" => [0, body.bytesize] })
27
+ padded = header << (" " * ((8 - (header.bytesize % 8)) % 8))
28
+ File.binwrite(path, [padded.bytesize].pack("Q<") << padded << body)
29
+ end
30
+
31
+ def parse_header(data)
32
+ raise ConversionError, "safetensors file is shorter than its length prefix" if data.bytesize < 8
33
+
34
+ header_len = data.byteslice(0, 8).unpack1("Q<")
35
+ unless header_len.positive? && header_len <= MAX_HEADER_BYTES
36
+ raise ConversionError, "implausible safetensors header length #{header_len}"
37
+ end
38
+
39
+ body_offset = 8 + header_len
40
+ raise ConversionError, "safetensors header runs past end of file" if body_offset > data.bytesize
41
+
42
+ raw_header = data.byteslice(8, header_len)
43
+ raise ConversionError, "safetensors header is not a JSON object" unless raw_header.lstrip.start_with?("{")
44
+
45
+ [JSON.parse(raw_header), body_offset]
46
+ end
47
+
48
+ def tensor_from(data, body_offset, body_size, name, spec)
49
+ dtype = spec["dtype"]
50
+ shape = spec["shape"]
51
+ begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
52
+ expected = checked_tensor_bytes(name, dtype, shape)
53
+ actual = end_off - begin_off
54
+
55
+ if actual != expected
56
+ raise ConversionError,
57
+ "tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
58
+ end
59
+
60
+ { dtype: dtype, shape: shape, bytes: data.byteslice(body_offset + begin_off, actual) }
61
+ end
62
+
63
+ def checked_offsets(name, offsets, body_size)
64
+ unless offsets.is_a?(Array) && offsets.length == 2
65
+ raise ConversionError, "tensor #{name}: malformed data_offsets"
66
+ end
67
+
68
+ begin_off, end_off = offsets
69
+ unless begin_off.is_a?(Integer) && end_off.is_a?(Integer) && begin_off >= 0 &&
70
+ end_off >= begin_off && end_off <= body_size
71
+ raise ConversionError, "tensor #{name}: data_offsets out of bounds"
72
+ end
73
+
74
+ [begin_off, end_off]
75
+ end
76
+
77
+ def checked_tensor_bytes(name, dtype, shape)
78
+ element_size = SUPPORTED_DTYPES[dtype]
79
+ raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (F32 only)" unless element_size
80
+ unless shape.is_a?(Array) && shape.all? { |dim| dim.is_a?(Integer) && dim >= 0 }
81
+ raise ConversionError, "tensor #{name}: malformed shape #{shape.inspect}"
82
+ end
83
+
84
+ shape.reduce(1, :*) * element_size
85
+ end
86
+ end
87
+ end