static_embeddings 0.1.4 → 1.5.6

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +67 -25
  4. data/Rakefile +1 -1
  5. data/docs/ARCHITECTURE.md +54 -34
  6. data/docs/LIMITATIONS.md +12 -6
  7. data/docs/MODEL_AUDIT.md +195 -52
  8. data/ext/static_embeddings/se_embed.c +2 -1
  9. data/ext/static_embeddings/se_format.c +41 -4
  10. data/ext/static_embeddings/se_internal.h +17 -5
  11. data/ext/static_embeddings/se_tokenizer.c +155 -18
  12. data/ext/static_embeddings/se_unicode.c +1 -1
  13. data/ext/static_embeddings/static_embeddings.c +32 -2
  14. data/lib/models/demo.semb +0 -0
  15. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  16. data/lib/static_embeddings/canonical.rb +50 -0
  17. data/lib/static_embeddings/cli.rb +88 -62
  18. data/lib/static_embeddings/codec.rb +45 -0
  19. data/lib/static_embeddings/conversion.rb +58 -0
  20. data/lib/static_embeddings/errors.rb +2 -2
  21. data/lib/static_embeddings/format/constants.rb +109 -0
  22. data/lib/static_embeddings/format/hash_table.rb +69 -0
  23. data/lib/static_embeddings/format/trie.rb +78 -0
  24. data/lib/static_embeddings/format/verifier.rb +41 -0
  25. data/lib/static_embeddings/format/writer.rb +131 -0
  26. data/lib/static_embeddings/format.rb +3 -300
  27. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  28. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  29. data/lib/static_embeddings/importers/support.rb +111 -0
  30. data/lib/static_embeddings/importers.rb +50 -0
  31. data/lib/static_embeddings/model.rb +35 -20
  32. data/lib/static_embeddings/paths.rb +17 -4
  33. data/lib/static_embeddings/provenance.rb +58 -0
  34. data/lib/static_embeddings/reference.rb +90 -33
  35. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  36. data/lib/static_embeddings/safetensors.rb +178 -34
  37. data/lib/static_embeddings/version.rb +1 -1
  38. data/lib/static_embeddings.rb +29 -57
  39. data/static_embeddings.gemspec +2 -2
  40. data/tools/check_model2vec_parity.rb +89 -54
  41. data/tools/check_st_parity.rb +125 -0
  42. data/tools/eval_retrieval.rb +58 -0
  43. metadata +24 -6
  44. data/lib/static_embeddings/converter.rb +0 -284
@@ -0,0 +1,131 @@
1
+ require "digest"
2
+ require "static_embeddings/format/constants"
3
+ require "static_embeddings/format/hash_table"
4
+ require "static_embeddings/format/trie"
5
+
6
+ module StaticEmbeddings
7
+ module Format
8
+ module Writer
9
+ module_function
10
+
11
+ def call(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
12
+ hash_size, strings, hash_blob, max_probe = HashTable.build(tokens)
13
+ root_trie, continuation_trie = WordPieceTrie.build(tokens, meta.fetch(:subword_prefix))
14
+ payloads = payloads(strings, hash_blob, matrix, norm_tables, provenance, root_trie, continuation_trie)
15
+ sections, file_size = layout(payloads)
16
+ header = build_header(meta, tokens.length, hash_size, max_probe, sections)
17
+ checksum = write_file(path, header, payloads, sections)
18
+
19
+ { bytes: file_size, sha256: checksum.unpack1("H*"), hash_table_size: hash_size }
20
+ end
21
+
22
+ def payloads(strings, hash_blob, matrix, norm_tables, provenance, root_trie, continuation_trie)
23
+ {
24
+ vocab_strings: strings,
25
+ vocab_hash: hash_blob,
26
+ embeddings: matrix,
27
+ norm_tables: norm_tables,
28
+ provenance: provenance,
29
+ root_trie: root_trie,
30
+ continuation_trie: continuation_trie
31
+ }
32
+ end
33
+
34
+ def layout(payloads)
35
+ offset = HEADER_SIZE
36
+ sections = payloads.each_with_object({}) do |(name, payload), out|
37
+ offset += (ALIGNMENT - (offset % ALIGNMENT)) % ALIGNMENT
38
+ out[name] = [offset, payload.bytesize]
39
+ offset += payload.bytesize
40
+ end
41
+ [sections, offset]
42
+ end
43
+
44
+ def write_file(path, header, payloads, sections)
45
+ digest = Digest::SHA256.new
46
+ File.open(path, "wb") do |io|
47
+ write_chunk(io, digest, header)
48
+ offset = HEADER_SIZE
49
+
50
+ payloads.each do |name, payload|
51
+ target = sections.fetch(name).first
52
+ padding = target - offset
53
+ write_chunk(io, digest, "\0".b * padding) if padding.positive?
54
+ write_payload(io, digest, payload)
55
+ offset = target + payload.bytesize
56
+ end
57
+ end
58
+
59
+ digest.digest.tap do |checksum|
60
+ File.open(path, "r+b") do |io|
61
+ io.seek(CHECKSUM_OFFSET, IO::SEEK_SET)
62
+ io.write(checksum)
63
+ end
64
+ end
65
+ end
66
+
67
+ def write_payload(io, digest, payload)
68
+ if payload.respond_to?(:each_chunk)
69
+ payload.each_chunk { |chunk| write_chunk(io, digest, chunk) }
70
+ else
71
+ write_chunk(io, digest, payload)
72
+ end
73
+ end
74
+
75
+ def write_chunk(io, digest, chunk)
76
+ io.write(chunk)
77
+ digest << chunk
78
+ end
79
+
80
+ def build_header(meta, vocab_size, hash_size, max_probe, sections)
81
+ header = "\0".b * HEADER_SIZE
82
+ header[0, 8] = MAGIC.b
83
+
84
+ HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
85
+ META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
86
+ META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
87
+ put_u32(header, 24, vocab_size)
88
+ put_u32(header, 104, hash_size)
89
+ put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
90
+ put_u32(header, MAX_PROBE_OFFSET, max_probe)
91
+ put_u32(header, ADDED_TOKEN_MASK_OFFSET, meta.fetch(:added_token_mask, 0))
92
+ put_prefix(header, meta.fetch(:subword_prefix))
93
+ SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
94
+ header
95
+ end
96
+
97
+ def put_prefix(header, prefix)
98
+ bytes = prefix.b
99
+ raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
100
+
101
+ put_u32(header, 112, bytes.bytesize)
102
+ header[116, 8] = bytes.ljust(8, "\0")
103
+ end
104
+
105
+ def put_section(header, offset, section)
106
+ put_u64(header, offset, section.fetch(0))
107
+ put_u64(header, offset + 8, section.fetch(1))
108
+ end
109
+
110
+ def put_u32(buffer, offset, value)
111
+ integer = Integer(value)
112
+ unless integer.between?(0, UINT32_MAX)
113
+ raise ArgumentError, "u32 value out of range: #{integer}"
114
+ end
115
+ buffer[offset, 4] = [integer].pack("V")
116
+ end
117
+
118
+ def put_u64(buffer, offset, value)
119
+ integer = Integer(value)
120
+ unless integer.between?(0, UINT64_MAX)
121
+ raise ArgumentError, "u64 value out of range: #{integer}"
122
+ end
123
+ buffer[offset, 8] = [integer & UINT32_MAX, integer >> 32].pack("V2")
124
+ end
125
+ end
126
+
127
+ def self.write(**kwargs)
128
+ Writer.call(**kwargs)
129
+ end
130
+ end
131
+ end
@@ -1,300 +1,3 @@
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
- VERIFY_CHUNK_BYTES = 1024 * 1024
74
-
75
- module_function
76
-
77
- def hash_bytes(str, seed = HASH_SEED)
78
- str.each_byte.reduce(seed) { |h, b| ((h ^ b) * 16_777_619) & 0xFFFFFFFF }
79
- end
80
-
81
- def next_power_of_two(n)
82
- 1 << (n - 1).bit_length
83
- end
84
-
85
- def build_hash_table(tokens)
86
- size = next_power_of_two((tokens.length / LOAD_FACTOR).ceil + 1)
87
- slots = Array.new(size)
88
- strings = binary_string
89
- max_probe = 0
90
-
91
- tokens.each_with_index do |token, id|
92
- bytes = token.b
93
- offset = strings.bytesize
94
- strings << bytes
95
- probe = insert_slot!(slots, tokens, size, bytes, hash_bytes(bytes), offset, id)
96
- max_probe = probe if probe > max_probe
97
- end
98
-
99
- [size, strings, pack_slots(slots), max_probe]
100
- end
101
-
102
- def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
103
- hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
104
- root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
105
- sections, body = build_body(
106
- vocab_strings: strings,
107
- vocab_hash: hash_blob,
108
- embeddings: matrix,
109
- norm_tables: norm_tables,
110
- provenance: provenance,
111
- root_trie: root_trie,
112
- continuation_trie: continuation_trie
113
- )
114
-
115
- header = build_header(meta, tokens.length, hash_size, max_probe, sections)
116
- file = header << body
117
- digest = Digest::SHA256.digest(file)
118
- file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
119
-
120
- File.binwrite(path, file)
121
- { bytes: file.bytesize, sha256: digest.unpack1("H*"), hash_table_size: hash_size }
122
- end
123
-
124
- def verify(path)
125
- raise InvalidModelError, "file too small" if File.size(path) < HEADER_SIZE
126
-
127
- File.open(path, "rb") do |io|
128
- header = io.read(HEADER_SIZE)
129
- raise InvalidModelError, "bad magic" unless header.byteslice(0, 8) == MAGIC.b
130
-
131
- stored = header.byteslice(CHECKSUM_OFFSET, CHECKSUM_SIZE)
132
- actual = streaming_checksum(io, header)
133
-
134
- { ok: stored == actual, expected: actual.unpack1("H*"), stored: stored.unpack1("H*") }
135
- end
136
- end
137
-
138
- def streaming_checksum(io, header)
139
- digest = Digest::SHA256.new
140
- zeroed = header.dup
141
- zeroed[CHECKSUM_OFFSET, CHECKSUM_SIZE] = "\0".b * CHECKSUM_SIZE
142
- digest << zeroed
143
-
144
- buffer = String.new(capacity: VERIFY_CHUNK_BYTES)
145
- digest << buffer while io.read(VERIFY_CHUNK_BYTES, buffer)
146
- digest.digest
147
- end
148
-
149
- def binary_string(capacity = nil)
150
- str = capacity ? String.new(capacity: capacity) : +""
151
- str.force_encoding(Encoding::BINARY)
152
- end
153
-
154
- def insert_slot!(slots, tokens, size, bytes, hash, offset, id)
155
- pos = hash & (size - 1)
156
- probe = 1
157
- loop do
158
- slot = slots[pos]
159
- unless slot
160
- slots[pos] = [hash, offset, bytes.bytesize, id]
161
- return probe
162
- end
163
-
164
- if slot[0] == hash && slot[2] == bytes.bytesize && tokens.fetch(slot[3]).b == bytes
165
- raise ArgumentError, "duplicate token in vocabulary: #{tokens.fetch(slot[3]).inspect}"
166
- end
167
-
168
- pos = (pos + 1) & (size - 1)
169
- probe += 1
170
- end
171
- end
172
-
173
- def pack_slots(slots)
174
- empty = [0, 0, 0, SLOT_EMPTY].pack("V4")
175
- packed = binary_string(slots.length * 16)
176
- slots.each { |slot| packed << (slot ? slot.pack("V4") : empty) }
177
- packed
178
- end
179
-
180
- def build_wordpiece_tries(tokens, prefix)
181
- root = TrieBuilder.new
182
- continuation = TrieBuilder.new
183
- prefix_bytes = prefix.b
184
-
185
- tokens.each_with_index do |token, id|
186
- bytes = token.b
187
- if !prefix_bytes.empty? && bytes.start_with?(prefix_bytes) && bytes.bytesize > prefix_bytes.bytesize
188
- continuation.insert(bytes.byteslice(prefix_bytes.bytesize, bytes.bytesize - prefix_bytes.bytesize), id)
189
- else
190
- root.insert(bytes, id)
191
- end
192
- end
193
-
194
- [root.pack, continuation.pack]
195
- end
196
-
197
- class TrieBuilder
198
- Node = Struct.new(:terminal, :children, keyword_init: true)
199
-
200
- def initialize
201
- @nodes = [Node.new(terminal: SLOT_EMPTY, children: {})]
202
- end
203
-
204
- def insert(bytes, id)
205
- return if bytes.empty?
206
-
207
- node_index = 0
208
- bytes.each_byte do |byte|
209
- node = @nodes[node_index]
210
- child = node.children[byte]
211
- unless child
212
- child = @nodes.length
213
- node.children[byte] = child
214
- @nodes << Node.new(terminal: SLOT_EMPTY, children: {})
215
- end
216
- node_index = child
217
- end
218
-
219
- node = @nodes[node_index]
220
- raise ArgumentError, "duplicate trie key for token id #{id}" unless node.terminal == SLOT_EMPTY
221
-
222
- node.terminal = id
223
- end
224
-
225
- def pack
226
- edges = []
227
- node_records = @nodes.map do |node|
228
- start = edges.length
229
- node.children.sort_by { |byte, _| byte }.each do |byte, child|
230
- edges << [byte, child]
231
- end
232
- [start, node.children.length, node.terminal, 0]
233
- end
234
-
235
- packed = Format.binary_string(16 + node_records.length * 16 + edges.length * 8)
236
- packed << [node_records.length, edges.length, 0, 0].pack("V4")
237
- node_records.each { |record| packed << record.pack("V4") }
238
- edges.each { |edge| packed << edge.pack("V2") }
239
- packed
240
- end
241
- end
242
-
243
- def build_body(payloads)
244
- body = binary_string
245
- sections = {}
246
- payloads.each do |name, payload|
247
- align_body!(body)
248
- sections[name] = [HEADER_SIZE + body.bytesize, payload.bytesize]
249
- body << payload
250
- end
251
- [sections, body]
252
- end
253
-
254
- def align_body!(body)
255
- padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
256
- body << "\0".b * padding if padding.positive?
257
- end
258
-
259
- def build_header(meta, vocab_size, hash_size, max_probe, sections)
260
- header = "\0".b * HEADER_SIZE
261
- header[0, 8] = MAGIC.b
262
-
263
- HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
264
- META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
265
- META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
266
-
267
- put_u32(header, 24, vocab_size)
268
- put_u32(header, 104, hash_size)
269
- put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
270
- put_u32(header, MAX_PROBE_OFFSET, max_probe)
271
- put_prefix(header, meta.fetch(:subword_prefix))
272
- SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
273
-
274
- header
275
- end
276
-
277
- def put_prefix(header, prefix)
278
- bytes = prefix.b
279
- raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
280
-
281
- put_u32(header, 112, bytes.bytesize)
282
- header[116, 8] = bytes.ljust(8, "\0")
283
- end
284
-
285
- def put_section(header, offset, section)
286
- off, size = section
287
- put_u64(header, offset, off)
288
- put_u64(header, offset + 8, size)
289
- end
290
-
291
- def put_u32(buffer, offset, value)
292
- buffer[offset, 4] = [value].pack("V")
293
- end
294
-
295
- def put_u64(buffer, offset, value)
296
- buffer[offset, 8] = [value & 0xFFFFFFFF, value >> 32].pack("V2")
297
- end
298
-
299
- end
300
- end
1
+ require "static_embeddings/format/constants"
2
+ require "static_embeddings/format/verifier"
3
+ require "static_embeddings/format/writer"
@@ -0,0 +1,52 @@
1
+ module StaticEmbeddings
2
+ module Importers
3
+ module Model2Vec
4
+ SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
5
+ DEFAULT_MAX_TOKENS = 512
6
+ FAMILY = "model2vec"
7
+ ORACLE = "model2vec.StaticModel"
8
+
9
+ module_function
10
+
11
+ def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
12
+ source_revision: nil, trained_mrl_dims: nil)
13
+ tokenizer = Support.load_object(File.join(root, "tokenizer.json"))
14
+ config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
15
+ tokenizer_config = Support.load_object(File.join(root, "tokenizer_config.json"), optional: true) || {}
16
+ profile, tokens = BertWordPiece.compile(tokenizer, tokenizer_config)
17
+ payload, native_dim = Support.extract_matrix(File.join(root, "model.safetensors"), tokens.length)
18
+ output_dim = Support.resolve_dimensions(dimensions, native_dim)
19
+
20
+ Canonical.model(
21
+ tokens: tokens,
22
+ matrix: Support.slice_matrix(payload, native_dim, output_dim),
23
+ dimensions: Canonical.dimensions(
24
+ native: native_dim,
25
+ output: output_dim,
26
+ trained: Support.normalize_mrl_dims(trained_mrl_dims, native_dim: native_dim)
27
+ ),
28
+ runtime: Canonical.runtime(
29
+ normalization: normalization(config),
30
+ unk_policy: Format::UNK_DROP,
31
+ empty_policy: Format::EMPTY_ZERO_VECTOR,
32
+ max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
33
+ ),
34
+ tokenizer: BertWordPiece.runtime_meta(profile, tokens),
35
+ source: Canonical.source(
36
+ family: FAMILY,
37
+ model: model_id || File.basename(root),
38
+ revision: source_revision,
39
+ oracle: ORACLE,
40
+ files_sha256: Support.digest_files(SOURCE_FILES.map { |name| File.join(root, name) }),
41
+ tokenizer_class: profile[:tokenizer_class],
42
+ config_seq_length: config["seq_length"]
43
+ )
44
+ )
45
+ end
46
+
47
+ def normalization(config)
48
+ config.fetch("normalize", false) ? Format::NORMALIZATION_L2 : Format::NORMALIZATION_NONE
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,103 @@
1
+ require "pathname"
2
+
3
+ module StaticEmbeddings
4
+ module Importers
5
+ module SentenceTransformersStatic
6
+ TYPE = "sentence_transformers.models.StaticEmbedding"
7
+ FAMILY = "sentence_transformers_static"
8
+ ORACLE = "sentence_transformers.SentenceTransformer.encode"
9
+ DEFAULT_MAX_TOKENS = 0
10
+
11
+ module_function
12
+
13
+ def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
14
+ source_revision: nil, trained_mrl_dims: nil)
15
+ module_dir = module_directory(root)
16
+ tokenizer = Support.load_object(File.join(module_dir, "tokenizer.json"))
17
+ tokenizer_config = Support.load_object(File.join(module_dir, "tokenizer_config.json"), optional: true) || {}
18
+ config = Support.load_object(File.join(module_dir, "config.json"), optional: true) || {}
19
+ profile, tokens = BertWordPiece.compile(tokenizer, tokenizer_config)
20
+ payload, native_dim = Support.extract_matrix(File.join(module_dir, "model.safetensors"), tokens.length)
21
+ output_dim = Support.resolve_dimensions(dimensions, native_dim)
22
+
23
+ Canonical.model(
24
+ tokens: tokens,
25
+ matrix: Support.slice_matrix(payload, native_dim, output_dim),
26
+ dimensions: Canonical.dimensions(
27
+ native: native_dim,
28
+ output: output_dim,
29
+ trained: Support.normalize_mrl_dims(trained_mrl_dims, native_dim: native_dim)
30
+ ),
31
+ runtime: Canonical.runtime(
32
+ normalization: Format::NORMALIZATION_NONE,
33
+ unk_policy: Format::UNK_INCLUDE,
34
+ empty_policy: Format::EMPTY_ZERO_VECTOR,
35
+ max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
36
+ ),
37
+ tokenizer: BertWordPiece.runtime_meta(profile, tokens),
38
+ source: Canonical.source(
39
+ family: FAMILY,
40
+ model: model_id || File.basename(root),
41
+ revision: source_revision,
42
+ oracle: ORACLE,
43
+ files_sha256: source_digests(root, module_dir),
44
+ tokenizer_class: profile[:tokenizer_class],
45
+ config_seq_length: config["seq_length"]
46
+ )
47
+ )
48
+ end
49
+
50
+ def module_directory(root)
51
+ spec = module_spec(root)
52
+ contained_directory(root, spec.fetch("path"))
53
+ end
54
+
55
+ def module_spec(root)
56
+ modules = Support.load_json(File.join(root, "modules.json"))
57
+ reject_source("modules.json is not an array") unless modules.is_a?(Array)
58
+ reject_source("modules.json declares #{modules.length} modules, expected exactly one StaticEmbedding") unless modules.length == 1
59
+
60
+ spec = modules.first
61
+ reject_source("modules.json[0] is not an object") unless spec.is_a?(Hash)
62
+ reject_source("modules.json[0].type is #{spec['type'].inspect}, expected #{TYPE}") unless spec["type"] == TYPE
63
+ reject_source("modules.json[0] has no path") unless spec.key?("path")
64
+ reject_source("modules.json[0].path is not a string") unless spec["path"].is_a?(String)
65
+ spec
66
+ end
67
+
68
+ def contained_directory(root, relative)
69
+ relative = relative.to_s
70
+ reject_source("module path contains a NUL byte") if relative.include?("\0")
71
+ reject_source("module path #{relative.inspect} is absolute") if Pathname.new(relative).absolute?
72
+
73
+ root_real = File.realpath(root)
74
+ candidate = relative.empty? ? root : File.expand_path(relative, root)
75
+ reject_source("module path #{relative.inspect} is not a directory") unless File.directory?(candidate)
76
+
77
+ real = File.realpath(candidate)
78
+ prefix = root_real.end_with?(File::SEPARATOR) ? root_real : "#{root_real}#{File::SEPARATOR}"
79
+ reject_source("module path #{relative.inspect} escapes the source directory") unless real == root_real || real.start_with?(prefix)
80
+ real
81
+ rescue Errno::ENOENT
82
+ reject_source("module path #{relative.inspect} does not exist")
83
+ end
84
+
85
+ def source_digests(root, module_dir)
86
+ paths = [
87
+ File.join(root, "modules.json"),
88
+ File.join(module_dir, "tokenizer.json"),
89
+ File.join(module_dir, "tokenizer_config.json"),
90
+ File.join(module_dir, "config.json"),
91
+ File.join(module_dir, "model.safetensors")
92
+ ]
93
+ Support.digest_files(paths, root: root)
94
+ end
95
+
96
+ def reject_source(message)
97
+ raise UnsupportedModelError,
98
+ "#{message}. Sentence Transformers conversion accepts exactly one #{TYPE} module " \
99
+ "using #{BertWordPiece::TOKENIZER_PROFILE}."
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,111 @@
1
+ require "digest"
2
+ require "json"
3
+ require "static_embeddings/errors"
4
+ require "static_embeddings/format/constants"
5
+ require "static_embeddings/safetensors"
6
+ require "static_embeddings/row_prefix_payload"
7
+
8
+ module StaticEmbeddings
9
+ module Importers
10
+ module Support
11
+ module_function
12
+
13
+ def load_json(path, optional: false)
14
+ return nil if optional && !File.file?(path)
15
+ raise InvalidSourceError, "missing #{File.basename(path)} in #{File.dirname(path)}" unless File.file?(path)
16
+
17
+ JSON.parse(File.binread(path))
18
+ rescue JSON::ParserError => e
19
+ raise InvalidSourceError, "invalid JSON in #{path}: #{e.message}"
20
+ end
21
+
22
+ def load_object(path, optional: false)
23
+ value = load_json(path, optional: optional)
24
+ return nil if value.nil?
25
+ raise InvalidSourceError, "#{path} must contain a JSON object" unless value.is_a?(Hash)
26
+
27
+ value
28
+ end
29
+
30
+ def digest_files(paths, root: nil)
31
+ paths.each_with_object({}) do |path, digests|
32
+ next unless File.file?(path)
33
+
34
+ digests[digest_key(path, root)] = Digest::SHA256.file(path).hexdigest
35
+ end
36
+ end
37
+
38
+ def extract_matrix(path, vocab_size)
39
+ raise InvalidSourceError, "missing model.safetensors in #{File.dirname(path)}" unless File.file?(path)
40
+
41
+ name, tensor = sole_matrix_tensor(Safetensors.describe(path).fetch(:tensors))
42
+ rows, dim = tensor.fetch(:shape)
43
+ unless rows == vocab_size
44
+ raise ConversionError,
45
+ "embedding matrix #{name} has #{rows} rows but the tokenizer has #{vocab_size} tokens"
46
+ end
47
+ unless dim.positive? && dim <= Format::UINT32_MAX
48
+ raise ConversionError, "embedding matrix #{name} has unsupported dimension #{dim}"
49
+ end
50
+
51
+ [Safetensors.f32_payload(path, tensor), dim]
52
+ end
53
+
54
+ def resolve_max_tokens(requested, default)
55
+ return default if requested.nil?
56
+ return 0 if requested == false || requested == :unlimited || requested == 0
57
+
58
+ value = Integer(requested)
59
+ unless value.between?(1, Format::UINT32_MAX)
60
+ raise InvalidOptionError, "max_tokens must be 1..#{Format::UINT32_MAX}, false, or :unlimited"
61
+ end
62
+ value
63
+ end
64
+
65
+ def resolve_dimensions(requested, native_dim)
66
+ return native_dim if requested.nil?
67
+
68
+ value = Integer(requested)
69
+ unless value.between?(1, native_dim)
70
+ raise InvalidOptionError, "dimensions must be between 1 and native_dim #{native_dim}, got #{value}"
71
+ end
72
+ value
73
+ end
74
+
75
+ def normalize_mrl_dims(value, native_dim:)
76
+ return nil if value.nil?
77
+
78
+ dims = value.is_a?(Array) ? value : value.to_s.split(",").map(&:strip)
79
+ dims = dims.map { |item| Integer(item) }
80
+ if dims.empty? || dims.any? { |dim| !dim.between?(1, native_dim) }
81
+ raise InvalidOptionError, "trained_mrl_dims must contain dimensions between 1 and #{native_dim}"
82
+ end
83
+ dims.uniq.freeze
84
+ end
85
+
86
+ def slice_matrix(payload, native_dim, output_dim)
87
+ return payload if output_dim == native_dim
88
+
89
+ RowPrefixPayload.new(payload, native_dim: native_dim, output_dim: output_dim)
90
+ end
91
+
92
+ def sole_matrix_tensor(tensors)
93
+ matrices = tensors.select { |_, tensor| tensor.fetch(:shape).length == 2 }
94
+ raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
95
+ return matrices.first if matrices.length == 1
96
+
97
+ raise ConversionError,
98
+ "model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); expected exactly one"
99
+ end
100
+
101
+ def digest_key(path, root)
102
+ return File.basename(path) unless root
103
+
104
+ root_real = File.realpath(root)
105
+ real = File.realpath(path)
106
+ prefix = root_real.end_with?(File::SEPARATOR) ? root_real : "#{root_real}#{File::SEPARATOR}"
107
+ real.start_with?(prefix) ? real.delete_prefix(prefix) : File.basename(real)
108
+ end
109
+ end
110
+ end
111
+ end