static_embeddings 0.1.5 → 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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +17 -2
  4. data/docs/ARCHITECTURE.md +14 -5
  5. data/docs/MODEL_AUDIT.md +110 -0
  6. data/lib/models/demo.semb +0 -0
  7. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  8. data/lib/static_embeddings/canonical.rb +50 -0
  9. data/lib/static_embeddings/cli.rb +88 -63
  10. data/lib/static_embeddings/codec.rb +45 -0
  11. data/lib/static_embeddings/conversion.rb +58 -0
  12. data/lib/static_embeddings/errors.rb +2 -2
  13. data/lib/static_embeddings/format/constants.rb +109 -0
  14. data/lib/static_embeddings/format/hash_table.rb +69 -0
  15. data/lib/static_embeddings/format/trie.rb +78 -0
  16. data/lib/static_embeddings/format/verifier.rb +41 -0
  17. data/lib/static_embeddings/format/writer.rb +131 -0
  18. data/lib/static_embeddings/format.rb +3 -338
  19. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  20. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  21. data/lib/static_embeddings/importers/support.rb +111 -0
  22. data/lib/static_embeddings/importers.rb +50 -0
  23. data/lib/static_embeddings/model.rb +35 -20
  24. data/lib/static_embeddings/paths.rb +8 -12
  25. data/lib/static_embeddings/provenance.rb +58 -0
  26. data/lib/static_embeddings/reference.rb +50 -40
  27. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  28. data/lib/static_embeddings/version.rb +1 -1
  29. data/lib/static_embeddings.rb +29 -55
  30. data/static_embeddings.gemspec +2 -2
  31. data/tools/check_model2vec_parity.rb +5 -1
  32. data/tools/check_st_parity.rb +125 -0
  33. data/tools/eval_retrieval.rb +58 -0
  34. metadata +24 -6
  35. data/lib/static_embeddings/converter.rb +0 -328
@@ -80,7 +80,11 @@ rows.each_with_index do |row, i|
80
80
 
81
81
  full_raw = model.tokenize(text, max_tokens: false)
82
82
  expected_usable = row.fetch("static_usable_token_ids")
83
- got_usable = full_raw.reject { |id| id == model.unk_id }.first(max_length)
83
+ got_usable = if reference["unk_token_id"].nil?
84
+ full_raw.first(max_length)
85
+ else
86
+ full_raw.reject { |id| id == model.unk_id }.first(max_length)
87
+ end
84
88
  if got_usable != expected_usable
85
89
  usable_failures << i
86
90
  first = got_usable.zip(expected_usable).index { |a, b| a != b } || [got_usable.length, expected_usable.length].min
@@ -0,0 +1,125 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "static_embeddings"
4
+
5
+ options = {
6
+ min_cosine: 1.0 - 1e-6,
7
+ max_abs: 1e-5
8
+ }
9
+
10
+ parser = OptionParser.new do |opts|
11
+ opts.on("--model PATH") { |value| options[:model] = value }
12
+ opts.on("--oracle PATH") { |value| options[:oracle] = value }
13
+ opts.on("--min-cosine N", Float) { |value| options[:min_cosine] = value }
14
+ opts.on("--max-abs N", Float) { |value| options[:max_abs] = value }
15
+ end
16
+ parser.parse!(ARGV)
17
+ unless options[:model] && options[:oracle]
18
+ abort "usage: ruby -Ilib tools/check_st_parity.rb --model MODEL.semb --oracle oracle.json"
19
+ end
20
+
21
+ model = StaticEmbeddings.load(options[:model], verify: true)
22
+ payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
23
+ abort "unsupported oracle schema #{payload["schema_version"].inspect}" unless payload["schema_version"] == 1
24
+
25
+ reference = payload.fetch("reference")
26
+ abort "ST oracle must use add_special_tokens=false" unless reference["add_special_tokens"] == false
27
+ abort "ST oracle must use normalize_embeddings=false" unless reference["normalize_embeddings"] == false
28
+ if model.normalized?
29
+ abort "ST source-faithful .semb must not L2-normalize; got normalized?=true"
30
+ end
31
+ unless model.max_tokens == false
32
+ abort "ST source-faithful .semb must bake unlimited tokens; got max_tokens=#{model.max_tokens.inspect}"
33
+ end
34
+ if model.provenance["unk_policy"] != "include"
35
+ abort "ST source-faithful .semb must use UNK_INCLUDE; got #{model.provenance["unk_policy"].inspect}"
36
+ end
37
+
38
+ def prefix_dims(vector, dim)
39
+ return vector if vector.length == dim
40
+ abort "oracle vector shorter than model dim (#{vector.length} < #{dim})" if vector.length < dim
41
+
42
+ vector.first(dim)
43
+ end
44
+
45
+ def dot(a, b)
46
+ a.zip(b).sum { |x, y| x * y }
47
+ end
48
+
49
+ def norm(a)
50
+ Math.sqrt(a.sum { |x| x * x })
51
+ end
52
+
53
+ def vector_metrics(reference, got)
54
+ max_abs = reference.zip(got).map { |a, b| (a - b).abs }.max || 0.0
55
+ ref_zero = reference.all?(&:zero?)
56
+ got_zero = got.all?(&:zero?)
57
+ cosine =
58
+ if ref_zero && got_zero
59
+ 1.0
60
+ elsif ref_zero || got_zero
61
+ 0.0
62
+ else
63
+ dot(reference, got) / (norm(reference) * norm(got))
64
+ end
65
+ [cosine, max_abs]
66
+ end
67
+
68
+ raw_failures = []
69
+ vector_failures = []
70
+ diag_failures = []
71
+ min_cosine = 1.0
72
+ max_abs_all = 0.0
73
+ vectors_checked = 0
74
+
75
+ payload.fetch("rows").each_with_index do |row, i|
76
+ text = row.fetch("text")
77
+ label = row.fetch("label", i.to_s)
78
+ problems = []
79
+
80
+ expected_raw = row.fetch("hf_raw_token_ids")
81
+ got_raw = model.tokenize(text, max_tokens: false)
82
+ if got_raw != expected_raw
83
+ raw_failures << i
84
+ first = got_raw.zip(expected_raw).index { |a, b| a != b } || [got_raw.length, expected_raw.length].min
85
+ problems << "raw ids differ at #{first} (got #{got_raw.length}, ref #{expected_raw.length})"
86
+ end
87
+
88
+ got_vector = model.embed_array(text)
89
+ reference_vector = prefix_dims(row.fetch("st_encode_vector"), got_vector.length)
90
+ cosine, max_abs = vector_metrics(reference_vector, got_vector)
91
+ vectors_checked += 1
92
+ min_cosine = [min_cosine, cosine].min
93
+ max_abs_all = [max_abs_all, max_abs].max
94
+ unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
95
+ vector_failures << i
96
+ problems << format("encode vector out of tolerance cos=%.10f max_abs=%.8g", cosine, max_abs)
97
+ end
98
+
99
+ if row.key?("static_embedding_vector")
100
+ diag = prefix_dims(row.fetch("static_embedding_vector"), got_vector.length)
101
+ dcos, dabs = vector_metrics(diag, got_vector)
102
+ unless dcos >= options[:min_cosine] && dabs <= options[:max_abs]
103
+ diag_failures << i
104
+ problems << format("StaticEmbedding diagnostic out of tolerance cos=%.10f max_abs=%.8g", dcos, dabs)
105
+ end
106
+ end
107
+
108
+ status = problems.empty? ? "ok" : "FAIL"
109
+ puts "#{status} idx=#{format('%03d', i)} label=#{label.inspect} bytes=#{text.bytesize}" +
110
+ (problems.empty? ? "" : " [#{problems.join('; ')}]")
111
+ end
112
+
113
+ puts "rows=#{payload.fetch("rows").length}"
114
+ puts "vectors_checked=#{vectors_checked}"
115
+ puts "min_cosine=#{min_cosine}"
116
+ puts "max_abs_all=#{max_abs_all}"
117
+ puts "raw_token_id_failures=#{raw_failures.inspect}"
118
+ puts "vector_failures=#{vector_failures.inspect}"
119
+ puts "static_embedding_failures=#{diag_failures.inspect}"
120
+
121
+ unless raw_failures.empty? && vector_failures.empty? && diag_failures.empty?
122
+ abort "ST parity failed"
123
+ end
124
+
125
+ puts "ST corpus parity OK (#{payload.fetch("rows").length}/#{payload.fetch("rows").length})"
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "static_embeddings"
4
+
5
+ options = { k: 10 }
6
+ parser = OptionParser.new do |opts|
7
+ opts.on("--model PATH") { |value| options[:model] = value }
8
+ opts.on("--dataset PATH") { |value| options[:dataset] = value }
9
+ opts.on("--k N", Integer) { |value| options[:k] = value }
10
+ end
11
+ parser.parse!(ARGV)
12
+ abort "usage: ruby -Ilib tools/eval_retrieval.rb --model MODEL.semb --dataset dataset.json" unless options[:model] && options[:dataset]
13
+
14
+ payload = JSON.parse(File.read(options[:dataset], encoding: "UTF-8"))
15
+ docs = payload.fetch("documents")
16
+ queries = payload.fetch("queries")
17
+ k = options[:k]
18
+
19
+ model = StaticEmbeddings.load(options[:model], verify: true)
20
+ matrix = model.embed_batch(docs.map { |doc| doc.fetch("text") })
21
+
22
+ def dcg(gains)
23
+ gains.each_with_index.sum { |gain, i| gain / Math.log2(i + 2) }
24
+ end
25
+
26
+ mrr = 0.0
27
+ ndcg = 0.0
28
+ hits = 0
29
+
30
+ queries.each do |query|
31
+ relevant = query.fetch("relevant").map(&:to_s)
32
+ blob = model.embed(query.fetch("text"))
33
+ ranked = model.cosine_top_k(blob, matrix, [k, docs.length].min)
34
+ ids = ranked.map { |index, _| docs[index].fetch("id").to_s }
35
+ rank = ids.index { |id| relevant.include?(id) }
36
+ mrr += rank ? 1.0 / (rank + 1) : 0.0
37
+ hits += 1 if rank && rank < k
38
+ gains = ids.map { |id| relevant.include?(id) ? 1.0 : 0.0 }
39
+ ideal = [1.0] * [relevant.length, k].min + [0.0] * [k - relevant.length, 0].max
40
+ ideal = ideal.first(gains.length)
41
+ denom = dcg(ideal)
42
+ ndcg += denom.positive? ? dcg(gains) / denom : 0.0
43
+ end
44
+
45
+ n = queries.length.to_f
46
+ result = {
47
+ "model_id" => model.model_id,
48
+ "dim" => model.dim,
49
+ "normalized" => model.normalized?,
50
+ "queries" => queries.length,
51
+ "documents" => docs.length,
52
+ "k" => k,
53
+ "mrr" => mrr / n,
54
+ "ndcg_at_k" => ndcg / n,
55
+ "hit_at_k" => hits / n
56
+ }
57
+ puts JSON.pretty_generate(result)
58
+ model.close
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: static_embeddings
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 1.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
@@ -65,10 +65,11 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '1.2'
68
- description: A small C-extension runtime for Model2Vec-style static embedding models.
69
- Models are converted offline into a flat mmap-able .semb file; at runtime the gem
70
- tokenizes (BERT WordPiece), looks up rows and mean-pools them. Releases the GVL
71
- on large native work, rejects internal thread fan-out, and links nothing but libc.
68
+ description: A small C-extension runtime for Model2Vec and Sentence Transformers static
69
+ WordPiece embedding models. Models are converted offline into a flat mmap-able .semb
70
+ file; at runtime the gem tokenizes (BERT WordPiece), looks up rows and mean-pools
71
+ them. Releases the GVL on large native work, rejects internal thread fan-out, and
72
+ links nothing but libc.
72
73
  email:
73
74
  - romnhajdarov@gmail.com
74
75
  executables:
@@ -100,14 +101,29 @@ files:
100
101
  - ext/static_embeddings/se_topk.c
101
102
  - ext/static_embeddings/se_unicode.c
102
103
  - ext/static_embeddings/static_embeddings.c
104
+ - lib/models/demo.semb
103
105
  - lib/static_embeddings.rb
106
+ - lib/static_embeddings/bert_wordpiece.rb
107
+ - lib/static_embeddings/canonical.rb
104
108
  - lib/static_embeddings/cli.rb
105
- - lib/static_embeddings/converter.rb
109
+ - lib/static_embeddings/codec.rb
110
+ - lib/static_embeddings/conversion.rb
106
111
  - lib/static_embeddings/errors.rb
107
112
  - lib/static_embeddings/format.rb
113
+ - lib/static_embeddings/format/constants.rb
114
+ - lib/static_embeddings/format/hash_table.rb
115
+ - lib/static_embeddings/format/trie.rb
116
+ - lib/static_embeddings/format/verifier.rb
117
+ - lib/static_embeddings/format/writer.rb
118
+ - lib/static_embeddings/importers.rb
119
+ - lib/static_embeddings/importers/model2vec.rb
120
+ - lib/static_embeddings/importers/sentence_transformers_static.rb
121
+ - lib/static_embeddings/importers/support.rb
108
122
  - lib/static_embeddings/model.rb
109
123
  - lib/static_embeddings/paths.rb
124
+ - lib/static_embeddings/provenance.rb
110
125
  - lib/static_embeddings/reference.rb
126
+ - lib/static_embeddings/row_prefix_payload.rb
111
127
  - lib/static_embeddings/safetensors.rb
112
128
  - lib/static_embeddings/unicode_tables.rb
113
129
  - lib/static_embeddings/version.rb
@@ -115,6 +131,8 @@ files:
115
131
  - tools/benchmark.rb
116
132
  - tools/build_demo_model.rb
117
133
  - tools/check_model2vec_parity.rb
134
+ - tools/check_st_parity.rb
135
+ - tools/eval_retrieval.rb
118
136
  - tools/make_fixture_model.rb
119
137
  homepage: https://github.com/roman-haidarov/static_embeddings
120
138
  licenses:
@@ -1,328 +0,0 @@
1
- require "json"
2
- require "digest"
3
- require "static_embeddings/format"
4
- require "static_embeddings/safetensors"
5
- require "static_embeddings/unicode_tables"
6
-
7
- module StaticEmbeddings
8
- class Converter
9
- REFERENCE_IMPL = "model2vec.StaticModel"
10
- REFERENCE_MODEL2VEC_VERSION = "0.9.0"
11
- REFERENCE_TOKENIZERS_VERSION = "0.23.1"
12
- REFERENCE_UNICODE_CATEGORIES_VERSION = "0.1.1"
13
- REFERENCE_MAX_TOKENS = 512
14
-
15
- ALLOWED_NORMALIZER_KEYS = %w[type clean_text handle_chinese_chars strip_accents lowercase].freeze
16
- STANDARD_SPECIAL_TOKENS = {
17
- "[PAD]" => Format::ADDED_PAD,
18
- "[UNK]" => Format::ADDED_UNK,
19
- "[CLS]" => Format::ADDED_CLS,
20
- "[SEP]" => Format::ADDED_SEP,
21
- "[MASK]" => Format::ADDED_MASK
22
- }.freeze
23
- SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
24
- TOKENIZER_PROFILE = "BERT_WORDPIECE_V1"
25
-
26
- attr_reader :source_dir, :report
27
-
28
- def initialize(source_dir)
29
- @source_dir = source_dir
30
- @report = {}
31
- end
32
-
33
- def convert(output_path:, model_id: nil, max_tokens: REFERENCE_MAX_TOKENS)
34
- source = load_source
35
- profile = audit_tokenizer(source[:tokenizer], source[:tokenizer_config])
36
- tokens = extract_vocab(source[:tokenizer])
37
- validate_added_token_ids!(profile[:added_tokens], tokens)
38
- matrix, dim = extract_matrix(tokens.length)
39
- meta = runtime_meta(source[:config], profile, tokens, max_tokens)
40
-
41
- result = write_model(output_path, meta, tokens, matrix,
42
- provenance_for(model_id, meta, profile, source[:config], tokens.length, dim))
43
- @report = result.merge(vocab_size: tokens.length, dim: dim, provenance: JSON.parse(result[:provenance]))
44
- end
45
-
46
- private
47
-
48
- def load_source
49
- {
50
- tokenizer: load_json("tokenizer.json"),
51
- config: load_json("config.json", optional: true) || {},
52
- tokenizer_config: load_json("tokenizer_config.json", optional: true) || {}
53
- }
54
- end
55
-
56
- def load_json(name, optional: false)
57
- path = File.join(source_dir, name)
58
- return nil if optional && !File.file?(path)
59
- raise ConversionError, "missing #{name} in #{source_dir}" unless File.file?(path)
60
-
61
- JSON.parse(File.binread(path))
62
- end
63
-
64
- def reject!(reason)
65
- raise UnsupportedModelError,
66
- "#{reason}. This converter only accepts the #{TOKENIZER_PROFILE} profile; " \
67
- "supporting this model would require implementing that behaviour in the runtime first."
68
- end
69
-
70
- def audit_tokenizer(tokenizer, tokenizer_config)
71
- model = tokenizer["model"] or reject!("tokenizer.json has no model section")
72
- normalizer = tokenizer["normalizer"] or reject!("tokenizer has no normalizer")
73
- pre_tokenizer = tokenizer["pre_tokenizer"]
74
-
75
- assert_wordpiece!(model)
76
- assert_normalizer!(normalizer)
77
- assert_pre_tokenizer!(pre_tokenizer)
78
- added_tokens = audit_added_tokens(tokenizer)
79
-
80
- profile = profile_from(model, normalizer, tokenizer_config)
81
- profile[:added_tokens] = added_tokens
82
- profile[:added_token_mask] = added_tokens.reduce(0) do |mask, token|
83
- mask | STANDARD_SPECIAL_TOKENS.fetch(token.fetch("content"))
84
- end
85
- reject!("clean_text=false is not supported by the runtime") unless profile[:clean_text]
86
- profile
87
- end
88
-
89
- def assert_wordpiece!(model)
90
- reject!("tokenizer model.type is #{model['type'].inspect}, expected WordPiece") unless model["type"] == "WordPiece"
91
-
92
- prefix = model.fetch("continuing_subword_prefix", "##")
93
- reject!("continuing_subword_prefix #{prefix.inspect} is not supported") unless prefix == "##"
94
-
95
- unk = model.fetch("unk_token", "[UNK]")
96
- reject!("unk_token #{unk.inspect} is not supported") unless unk == "[UNK]"
97
- end
98
-
99
- def assert_normalizer!(normalizer)
100
- reject!("normalizer type #{normalizer['type'].inspect} is not BertNormalizer") unless normalizer["type"] == "BertNormalizer"
101
-
102
- unknown = normalizer.keys - ALLOWED_NORMALIZER_KEYS
103
- reject!("normalizer has unsupported keys #{unknown.inspect}") unless unknown.empty?
104
- end
105
-
106
- def assert_pre_tokenizer!(pre_tokenizer)
107
- return if pre_tokenizer && pre_tokenizer["type"] == "BertPreTokenizer"
108
-
109
- reject!("pre_tokenizer type #{pre_tokenizer && pre_tokenizer['type'].inspect} is not BertPreTokenizer")
110
- end
111
-
112
- def profile_from(model, normalizer, tokenizer_config)
113
- lowercase = flag_value(normalizer, "lowercase", tokenizer_config["do_lower_case"], true)
114
- {
115
- lowercase: lowercase,
116
- strip_accents: strip_accents?(normalizer, lowercase),
117
- clean_text: flag_value(normalizer, "clean_text", nil, true),
118
- handle_chinese_chars: flag_value(normalizer, "handle_chinese_chars",
119
- tokenizer_config["tokenize_chinese_chars"], true),
120
- continuing_subword_prefix: model.fetch("continuing_subword_prefix", "##"),
121
- unk_token: model.fetch("unk_token", "[UNK]"),
122
- max_input_chars_per_word: model.fetch("max_input_chars_per_word", 100).to_i,
123
- tokenizer_class: tokenizer_config["tokenizer_class"]
124
- }
125
- end
126
-
127
- def flag_value(hash, key, fallback, default)
128
- return !!hash[key] if hash.key?(key) && !hash[key].nil?
129
- return !!fallback unless fallback.nil?
130
-
131
- default
132
- end
133
-
134
- def strip_accents?(normalizer, lowercase)
135
- return !!normalizer["strip_accents"] if normalizer.key?("strip_accents") && !normalizer["strip_accents"].nil?
136
-
137
- lowercase
138
- end
139
-
140
- def audit_added_tokens(tokenizer)
141
- added = tokenizer["added_tokens"] || []
142
- bad_content = added.reject { |token| standard_special?(token) }
143
- unless bad_content.empty?
144
- reject!("tokenizer declares non-standard added_tokens #{bad_content.map { |t| t['content'] }.inspect}")
145
- end
146
-
147
- whitespace = added.find { |token| token["content"].to_s.match?(/\s/) }
148
- reject!("added token #{whitespace['content'].inspect} contains whitespace") if whitespace
149
-
150
- flagged = added.find { |token| token["lstrip"] || token["rstrip"] || token["single_word"] }
151
- reject!("added token #{flagged['content'].inspect} uses lstrip/rstrip/single_word") if flagged
152
-
153
- normalized = added.find { |token| token["normalized"] != false }
154
- if normalized
155
- reject!("added token #{normalized['content'].inspect} must use normalized=false")
156
- end
157
-
158
- duplicate = added.group_by { |token| token["content"] }.find { |_, rows| rows.length > 1 }
159
- reject!("duplicate added token #{duplicate[0].inspect}") if duplicate
160
-
161
- added
162
- end
163
-
164
- def standard_special?(token)
165
- token["special"] && STANDARD_SPECIAL_TOKENS.key?(token["content"])
166
- end
167
-
168
- def validate_added_token_ids!(added_tokens, tokens)
169
- added_tokens.each do |token|
170
- content = token.fetch("content")
171
- id = token["id"]
172
- reject!("added token #{content.inspect} has non-integer id #{id.inspect}") unless id.is_a?(Integer)
173
- unless id.between?(0, tokens.length - 1) && tokens[id] == content
174
- reject!("added token #{content.inspect} id #{id} does not match model.vocab")
175
- end
176
- end
177
- end
178
-
179
- def extract_vocab(tokenizer)
180
- vocab = tokenizer.dig("model", "vocab") or reject!("tokenizer.json has no model.vocab")
181
- vocab.each_with_object(Array.new(vocab.length)) do |(token, id), tokens|
182
- validate_vocab_id!(token, id, vocab.length, tokens)
183
- tokens[id] = token
184
- end.tap do |tokens|
185
- missing = tokens.index(nil)
186
- raise ConversionError, "vocab has a hole at id #{missing}" if missing
187
- end
188
- end
189
-
190
- def validate_vocab_id!(token, id, size, tokens)
191
- reject!("vocab id #{id.inspect} is not an integer") unless id.is_a?(Integer)
192
- unless id.between?(0, size - 1)
193
- raise ConversionError, "vocab id #{id} for #{token.inspect} is outside 0...#{size}"
194
- end
195
- raise ConversionError, "duplicate vocab id #{id}" unless tokens[id].nil?
196
- end
197
-
198
- def extract_matrix(vocab_size)
199
- path = File.join(source_dir, "model.safetensors")
200
- raise ConversionError, "missing model.safetensors in #{source_dir}" unless File.file?(path)
201
-
202
- name, tensor = sole_matrix_tensor(Safetensors.describe(path)[:tensors])
203
- rows, dim = tensor[:shape]
204
- if rows != vocab_size
205
- raise ConversionError,
206
- "embedding matrix #{name} has #{rows} rows but the tokenizer has " \
207
- "#{vocab_size} tokens — refusing to guess the mapping"
208
- end
209
-
210
- [Safetensors.f32_payload(path, tensor), dim]
211
- end
212
-
213
- def sole_matrix_tensor(tensors)
214
- matrices = tensors.select { |_, tensor| tensor[:shape].length == 2 }
215
- raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
216
- return matrices.first if matrices.length == 1
217
-
218
- raise ConversionError,
219
- "model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); " \
220
- "a static embedding model must have exactly one"
221
- end
222
-
223
- def runtime_meta(config, profile, tokens, max_tokens)
224
- base_meta(config, max_tokens)
225
- .merge(tokenizer_meta(profile, tokens))
226
- .merge(token_ids(tokens, profile))
227
- end
228
-
229
- def base_meta(config, max_tokens)
230
- {
231
- dim: nil,
232
- normalization_type: normalization_from(config),
233
- max_tokens_default: max_tokens.to_i,
234
- add_special_tokens: false,
235
- unk_policy: Format::UNK_DROP,
236
- empty_policy: Format::EMPTY_ZERO_VECTOR
237
- }
238
- end
239
-
240
- def tokenizer_meta(profile, tokens)
241
- {
242
- do_lower_case: profile[:lowercase],
243
- strip_accents: profile[:strip_accents],
244
- handle_chinese_chars: profile[:handle_chinese_chars],
245
- clean_text: profile[:clean_text],
246
- added_token_mask: profile.fetch(:added_token_mask),
247
- max_input_chars_per_word: profile[:max_input_chars_per_word],
248
- max_token_chars: max_token_chars(tokens, profile[:continuing_subword_prefix]),
249
- subword_prefix: profile[:continuing_subword_prefix]
250
- }
251
- end
252
-
253
- def max_token_chars(tokens, prefix)
254
- tokens.map do |token|
255
- body = token.start_with?(prefix) ? token[prefix.length..] : token
256
- body.each_char.count
257
- end.max || 1
258
- end
259
-
260
- def token_ids(tokens, profile)
261
- {
262
- pad_id: token_id(tokens, "[PAD]", 0),
263
- unk_id: token_id(tokens, profile[:unk_token]),
264
- cls_id: token_id(tokens, "[CLS]", 0),
265
- sep_id: token_id(tokens, "[SEP]", 0),
266
- mask_id: token_id(tokens, "[MASK]", 0)
267
- }
268
- end
269
-
270
- def normalization_from(config)
271
- config.key?("normalize") && !config["normalize"] ? Format::NORMALIZATION_NONE : Format::NORMALIZATION_L2
272
- end
273
-
274
- def token_id(tokens, token, fallback = nil)
275
- id = tokens.index(token)
276
- return id unless id.nil?
277
- return fallback unless fallback.nil?
278
-
279
- raise ConversionError, "vocabulary has no #{token.inspect}"
280
- end
281
-
282
- def source_digests
283
- SOURCE_FILES.each_with_object({}) do |name, acc|
284
- path = File.join(source_dir, name)
285
- acc[name] = Digest::SHA256.file(path).hexdigest if File.file?(path)
286
- end
287
- end
288
-
289
- def provenance_for(model_id, meta, profile, config, vocab_size, dim)
290
- ordered_json(
291
- "format_version" => Format::VERSION,
292
- "converter_version" => StaticEmbeddings::VERSION,
293
- "source_model_id" => model_id || File.basename(File.expand_path(source_dir)),
294
- "source_files_sha256" => source_digests,
295
- "reference_impl" => REFERENCE_IMPL,
296
- "reference_model2vec_version" => REFERENCE_MODEL2VEC_VERSION,
297
- "reference_tokenizers_version" => REFERENCE_TOKENIZERS_VERSION,
298
- "reference_unicode_categories_version" => REFERENCE_UNICODE_CATEGORIES_VERSION,
299
- "reference_max_tokens" => meta[:max_tokens_default],
300
- "unicode_source" => UnicodeTables.source_stamp,
301
- "tokenizer_profile" => TOKENIZER_PROFILE,
302
- "tokenizer_class" => profile[:tokenizer_class],
303
- "vocab_size" => vocab_size,
304
- "dim" => dim,
305
- "normalize" => meta[:normalization_type] == Format::NORMALIZATION_L2,
306
- "config_seq_length" => config["seq_length"],
307
- "notes" => "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
308
- )
309
- end
310
-
311
- def ordered_json(hash)
312
- JSON.generate(hash.sort_by { |key, _| key }.to_h)
313
- end
314
-
315
- def write_model(output_path, meta, tokens, matrix, provenance)
316
- dim = matrix.bytesize / (tokens.length * 4)
317
- result = Format.write(
318
- path: output_path,
319
- meta: meta.merge(dim: dim),
320
- tokens: tokens,
321
- matrix: matrix,
322
- norm_tables: UnicodeTables.packed,
323
- provenance: provenance
324
- )
325
- result.merge(provenance: provenance)
326
- end
327
- end
328
- end