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,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.4
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,284 +0,0 @@
1
- require "json"
2
- require "digest"
3
-
4
- module StaticEmbeddings
5
- class Converter
6
- REFERENCE_IMPL = "model2vec.StaticModel"
7
- REFERENCE_MAX_TOKENS = 512
8
-
9
- ALLOWED_NORMALIZER_KEYS = %w[type clean_text handle_chinese_chars strip_accents lowercase].freeze
10
- STANDARD_SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"].freeze
11
- SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
12
- TOKENIZER_PROFILE = "BERT_WORDPIECE_V1"
13
-
14
- attr_reader :source_dir, :report
15
-
16
- def initialize(source_dir)
17
- @source_dir = source_dir
18
- @report = {}
19
- end
20
-
21
- def convert(output_path:, model_id: nil, max_tokens: REFERENCE_MAX_TOKENS)
22
- source = load_source
23
- profile = audit_tokenizer(source[:tokenizer], source[:tokenizer_config])
24
- tokens = extract_vocab(source[:tokenizer])
25
- matrix, dim = extract_matrix(tokens.length)
26
- meta = runtime_meta(source[:config], profile, tokens, max_tokens)
27
-
28
- result = write_model(output_path, meta, tokens, matrix,
29
- provenance_for(model_id, meta, profile, source[:config], tokens.length, dim))
30
- @report = result.merge(vocab_size: tokens.length, dim: dim, provenance: JSON.parse(result[:provenance]))
31
- end
32
-
33
- private
34
-
35
- def load_source
36
- {
37
- tokenizer: load_json("tokenizer.json"),
38
- config: load_json("config.json", optional: true) || {},
39
- tokenizer_config: load_json("tokenizer_config.json", optional: true) || {}
40
- }
41
- end
42
-
43
- def load_json(name, optional: false)
44
- path = File.join(source_dir, name)
45
- return nil if optional && !File.file?(path)
46
- raise ConversionError, "missing #{name} in #{source_dir}" unless File.file?(path)
47
-
48
- JSON.parse(File.binread(path))
49
- end
50
-
51
- def reject!(reason)
52
- raise UnsupportedModelError,
53
- "#{reason}. This converter only accepts the #{TOKENIZER_PROFILE} profile; " \
54
- "supporting this model would require implementing that behaviour in the runtime first."
55
- end
56
-
57
- def audit_tokenizer(tokenizer, tokenizer_config)
58
- model = tokenizer["model"] or reject!("tokenizer.json has no model section")
59
- normalizer = tokenizer["normalizer"] or reject!("tokenizer has no normalizer")
60
- pre_tokenizer = tokenizer["pre_tokenizer"]
61
-
62
- assert_wordpiece!(model)
63
- assert_normalizer!(normalizer)
64
- assert_pre_tokenizer!(pre_tokenizer)
65
- audit_added_tokens(tokenizer)
66
-
67
- profile = profile_from(model, normalizer, tokenizer_config)
68
- reject!("clean_text=false is not supported by the runtime") unless profile[:clean_text]
69
- profile
70
- end
71
-
72
- def assert_wordpiece!(model)
73
- reject!("tokenizer model.type is #{model['type'].inspect}, expected WordPiece") unless model["type"] == "WordPiece"
74
-
75
- prefix = model.fetch("continuing_subword_prefix", "##")
76
- reject!("continuing_subword_prefix #{prefix.inspect} is not supported") unless prefix == "##"
77
-
78
- unk = model.fetch("unk_token", "[UNK]")
79
- reject!("unk_token #{unk.inspect} is not supported") unless unk == "[UNK]"
80
- end
81
-
82
- def assert_normalizer!(normalizer)
83
- reject!("normalizer type #{normalizer['type'].inspect} is not BertNormalizer") unless normalizer["type"] == "BertNormalizer"
84
-
85
- unknown = normalizer.keys - ALLOWED_NORMALIZER_KEYS
86
- reject!("normalizer has unsupported keys #{unknown.inspect}") unless unknown.empty?
87
- end
88
-
89
- def assert_pre_tokenizer!(pre_tokenizer)
90
- return if pre_tokenizer && pre_tokenizer["type"] == "BertPreTokenizer"
91
-
92
- reject!("pre_tokenizer type #{pre_tokenizer && pre_tokenizer['type'].inspect} is not BertPreTokenizer")
93
- end
94
-
95
- def profile_from(model, normalizer, tokenizer_config)
96
- lowercase = flag_value(normalizer, "lowercase", tokenizer_config["do_lower_case"], true)
97
- {
98
- lowercase: lowercase,
99
- strip_accents: strip_accents?(normalizer, lowercase),
100
- clean_text: flag_value(normalizer, "clean_text", nil, true),
101
- handle_chinese_chars: flag_value(normalizer, "handle_chinese_chars",
102
- tokenizer_config["tokenize_chinese_chars"], true),
103
- continuing_subword_prefix: model.fetch("continuing_subword_prefix", "##"),
104
- unk_token: model.fetch("unk_token", "[UNK]"),
105
- max_input_chars_per_word: model.fetch("max_input_chars_per_word", 100).to_i,
106
- tokenizer_class: tokenizer_config["tokenizer_class"]
107
- }
108
- end
109
-
110
- def flag_value(hash, key, fallback, default)
111
- return !!hash[key] if hash.key?(key) && !hash[key].nil?
112
- return !!fallback unless fallback.nil?
113
-
114
- default
115
- end
116
-
117
- def strip_accents?(normalizer, lowercase)
118
- return !!normalizer["strip_accents"] if normalizer.key?("strip_accents") && !normalizer["strip_accents"].nil?
119
-
120
- lowercase
121
- end
122
-
123
- def audit_added_tokens(tokenizer)
124
- added = tokenizer["added_tokens"] || []
125
- bad_content = added.reject { |token| standard_special?(token) }
126
- reject!("tokenizer declares non-standard added_tokens #{bad_content.map { |t| t['content'] }.inspect}") unless bad_content.empty?
127
-
128
- whitespace = added.find { |token| token["content"].to_s.match?(/\s/) }
129
- reject!("added token #{whitespace['content'].inspect} contains whitespace") if whitespace
130
-
131
- flagged = added.find { |token| token["lstrip"] || token["rstrip"] || token["single_word"] }
132
- reject!("added token #{flagged['content'].inspect} uses lstrip/rstrip/single_word") if flagged
133
- end
134
-
135
- def standard_special?(token)
136
- token["special"] && STANDARD_SPECIAL_TOKENS.include?(token["content"])
137
- end
138
-
139
- def extract_vocab(tokenizer)
140
- vocab = tokenizer.dig("model", "vocab") or reject!("tokenizer.json has no model.vocab")
141
- vocab.each_with_object(Array.new(vocab.length)) do |(token, id), tokens|
142
- validate_vocab_id!(token, id, vocab.length, tokens)
143
- tokens[id] = token
144
- end.tap do |tokens|
145
- missing = tokens.index(nil)
146
- raise ConversionError, "vocab has a hole at id #{missing}" if missing
147
- end
148
- end
149
-
150
- def validate_vocab_id!(token, id, size, tokens)
151
- reject!("vocab id #{id.inspect} is not an integer") unless id.is_a?(Integer)
152
- unless id.between?(0, size - 1)
153
- raise ConversionError, "vocab id #{id} for #{token.inspect} is outside 0...#{size}"
154
- end
155
- raise ConversionError, "duplicate vocab id #{id}" unless tokens[id].nil?
156
- end
157
-
158
- def extract_matrix(vocab_size)
159
- path = File.join(source_dir, "model.safetensors")
160
- raise ConversionError, "missing model.safetensors in #{source_dir}" unless File.file?(path)
161
-
162
- name, tensor = sole_matrix_tensor(Safetensors.read(path)[:tensors])
163
- rows, dim = tensor[:shape]
164
- if rows != vocab_size
165
- raise ConversionError,
166
- "embedding matrix #{name} has #{rows} rows but the tokenizer has " \
167
- "#{vocab_size} tokens — refusing to guess the mapping"
168
- end
169
-
170
- [tensor[:bytes], dim]
171
- end
172
-
173
- def sole_matrix_tensor(tensors)
174
- matrices = tensors.select { |_, tensor| tensor[:shape].length == 2 }
175
- raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
176
- return matrices.first if matrices.length == 1
177
-
178
- raise ConversionError,
179
- "model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); " \
180
- "a static embedding model must have exactly one"
181
- end
182
-
183
- def runtime_meta(config, profile, tokens, max_tokens)
184
- base_meta(config, max_tokens)
185
- .merge(tokenizer_meta(profile, tokens))
186
- .merge(token_ids(tokens, profile))
187
- end
188
-
189
- def base_meta(config, max_tokens)
190
- {
191
- dim: nil,
192
- normalization_type: normalization_from(config),
193
- max_tokens_default: max_tokens.to_i,
194
- add_special_tokens: false,
195
- unk_policy: Format::UNK_DROP,
196
- empty_policy: Format::EMPTY_ZERO_VECTOR
197
- }
198
- end
199
-
200
- def tokenizer_meta(profile, tokens)
201
- {
202
- do_lower_case: profile[:lowercase],
203
- strip_accents: profile[:strip_accents],
204
- handle_chinese_chars: profile[:handle_chinese_chars],
205
- clean_text: profile[:clean_text],
206
- max_input_chars_per_word: profile[:max_input_chars_per_word],
207
- max_token_chars: max_token_chars(tokens, profile[:continuing_subword_prefix]),
208
- subword_prefix: profile[:continuing_subword_prefix]
209
- }
210
- end
211
-
212
- def max_token_chars(tokens, prefix)
213
- tokens.map do |token|
214
- body = token.start_with?(prefix) ? token[prefix.length..] : token
215
- body.each_char.count
216
- end.max || 1
217
- end
218
-
219
- def token_ids(tokens, profile)
220
- {
221
- pad_id: token_id(tokens, "[PAD]", 0),
222
- unk_id: token_id(tokens, profile[:unk_token]),
223
- cls_id: token_id(tokens, "[CLS]", 0),
224
- sep_id: token_id(tokens, "[SEP]", 0),
225
- mask_id: token_id(tokens, "[MASK]", 0)
226
- }
227
- end
228
-
229
- def normalization_from(config)
230
- config.key?("normalize") && !config["normalize"] ? Format::NORMALIZATION_NONE : Format::NORMALIZATION_L2
231
- end
232
-
233
- def token_id(tokens, token, fallback = nil)
234
- id = tokens.index(token)
235
- return id unless id.nil?
236
- return fallback unless fallback.nil?
237
-
238
- raise ConversionError, "vocabulary has no #{token.inspect}"
239
- end
240
-
241
- def source_digests
242
- SOURCE_FILES.each_with_object({}) do |name, acc|
243
- path = File.join(source_dir, name)
244
- acc[name] = Digest::SHA256.hexdigest(File.binread(path)) if File.file?(path)
245
- end
246
- end
247
-
248
- def provenance_for(model_id, meta, profile, config, vocab_size, dim)
249
- ordered_json(
250
- "format_version" => Format::VERSION,
251
- "converter_version" => StaticEmbeddings::VERSION,
252
- "source_model_id" => model_id || File.basename(File.expand_path(source_dir)),
253
- "source_files_sha256" => source_digests,
254
- "reference_impl" => REFERENCE_IMPL,
255
- "reference_max_tokens" => meta[:max_tokens_default],
256
- "unicode_source" => UnicodeTables.source_stamp,
257
- "tokenizer_profile" => TOKENIZER_PROFILE,
258
- "tokenizer_class" => profile[:tokenizer_class],
259
- "vocab_size" => vocab_size,
260
- "dim" => dim,
261
- "normalize" => meta[:normalization_type] == Format::NORMALIZATION_L2,
262
- "config_seq_length" => config["seq_length"],
263
- "notes" => "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
264
- )
265
- end
266
-
267
- def ordered_json(hash)
268
- JSON.generate(hash.sort_by { |key, _| key }.to_h)
269
- end
270
-
271
- def write_model(output_path, meta, tokens, matrix, provenance)
272
- dim = matrix.bytesize / (tokens.length * 4)
273
- result = Format.write(
274
- path: output_path,
275
- meta: meta.merge(dim: dim),
276
- tokens: tokens,
277
- matrix: matrix,
278
- norm_tables: UnicodeTables.packed,
279
- provenance: provenance
280
- )
281
- result.merge(provenance: provenance)
282
- end
283
- end
284
- end