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,178 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "fileutils"
4
+
5
+ module StaticEmbeddings
6
+ class CLI
7
+ COMMANDS = {
8
+ "convert" => :convert,
9
+ "verify" => :verify,
10
+ "inspect" => :inspect_model,
11
+ "tokenize" => :tokenize,
12
+ "embed" => :embed,
13
+ "cache-path" => :cache_path,
14
+ "help" => :usage,
15
+ "-h" => :usage,
16
+ "--help" => :usage,
17
+ nil => :usage
18
+ }.freeze
19
+
20
+ HELP = <<~TEXT
21
+ static_embeddings <command> [options]
22
+
23
+ convert SOURCE_DIR Convert a HuggingFace/Model2Vec directory to .semb
24
+ --out PATH Output file (default: <cache>/models/<id>.semb)
25
+ --id ID Model id recorded in provenance
26
+ --max-tokens N Truncation limit baked into the file (default 512)
27
+
28
+ verify PATH Recompute the SHA-256 embedded in the header
29
+ inspect PATH Print header fields and provenance
30
+ tokenize PATH TEXT Print token ids
31
+ embed PATH TEXT Print the vector, token count and UNK ratio
32
+ cache-path Print the model cache directory
33
+ TEXT
34
+
35
+ def self.run(argv)
36
+ new.run(argv)
37
+ end
38
+
39
+ def run(argv)
40
+ method = COMMANDS[argv.shift]
41
+ return unknown unless method
42
+
43
+ send(method, argv)
44
+ rescue StaticEmbeddings::Error => e
45
+ warn "#{e.class.name.split('::').last}: #{e.message}"
46
+ 1
47
+ end
48
+
49
+ private
50
+
51
+ def usage(*)
52
+ puts HELP
53
+ 0
54
+ end
55
+
56
+ def unknown
57
+ warn "unknown command"
58
+ usage
59
+ 1
60
+ end
61
+
62
+ def cache_path(*)
63
+ puts StaticEmbeddings.cache_dir
64
+ 0
65
+ end
66
+
67
+ def convert(argv)
68
+ options = parse_convert_options(argv)
69
+ source = required_arg(argv, "usage: static_embeddings convert SOURCE_DIR [--out PATH]")
70
+ model_id = options[:id] || File.basename(File.expand_path(source))
71
+ out = options[:out] || StaticEmbeddings.model_path(model_id)
72
+ FileUtils.mkdir_p(File.dirname(out))
73
+
74
+ report = StaticEmbeddings.convert(source, output_path: out, model_id: model_id,
75
+ max_tokens: options[:max_tokens])
76
+ puts conversion_report(out, report, options[:max_tokens])
77
+ 0
78
+ end
79
+
80
+ def parse_convert_options(argv)
81
+ options = { max_tokens: Converter::REFERENCE_MAX_TOKENS }
82
+ OptionParser.new do |parser|
83
+ parser.on("--out PATH") { |value| options[:out] = value }
84
+ parser.on("--id ID") { |value| options[:id] = value }
85
+ parser.on("--max-tokens N", Integer) { |value| options[:max_tokens] = value }
86
+ end.parse!(argv)
87
+ options
88
+ end
89
+
90
+ def conversion_report(path, report, max_tokens)
91
+ [
92
+ "wrote #{path}",
93
+ " vocab #{report[:vocab_size]}",
94
+ " dim #{report[:dim]}",
95
+ " bytes #{report[:bytes]}",
96
+ " sha256 #{report[:sha256]}",
97
+ " max_tokens #{max_tokens}",
98
+ "",
99
+ "Record this conversion in docs/MODEL_AUDIT.md before trusting the vectors."
100
+ ].join("\n")
101
+ end
102
+
103
+ def verify(argv)
104
+ result = StaticEmbeddings.verify(required_arg(argv, "usage: static_embeddings verify PATH"))
105
+ return puts("ok #{result[:expected]}") || 0 if result[:ok]
106
+
107
+ warn "CHECKSUM MISMATCH"
108
+ warn " stored #{result[:stored]}"
109
+ warn " computed #{result[:expected]}"
110
+ 1
111
+ end
112
+
113
+ def inspect_model(argv)
114
+ model = StaticEmbeddings.load(required_arg(argv, "usage: static_embeddings inspect PATH"))
115
+ puts JSON.pretty_generate(model_summary(model))
116
+ 0
117
+ end
118
+
119
+ def model_summary(model)
120
+ {
121
+ "path" => model.path,
122
+ "dim" => model.dim,
123
+ "vocab_size" => model.vocab_size,
124
+ "max_tokens" => model.max_tokens,
125
+ "normalized" => model.normalized?,
126
+ "lowercase" => model.lowercase?,
127
+ "unk_id" => model.unk_id,
128
+ "mapped_bytes" => model.mapped_bytes,
129
+ "provenance" => model.provenance
130
+ }
131
+ end
132
+
133
+ def tokenize(argv)
134
+ model, text = model_and_text(argv, "usage: static_embeddings tokenize PATH TEXT")
135
+ ids = model.tokenize(text)
136
+ puts JSON.generate("ids" => ids, "count" => ids.length, "unk" => ids.count(model.unk_id))
137
+ 0
138
+ end
139
+
140
+ def embed(argv)
141
+ model, text = model_and_text(argv, "usage: static_embeddings embed PATH TEXT")
142
+ stats = model.embed_with_stats(text)
143
+ warn_high_unk(stats) if high_unk?(stats)
144
+ puts JSON.generate(stats_payload(model, stats))
145
+ 0
146
+ end
147
+
148
+ def stats_payload(model, stats)
149
+ {
150
+ "token_count" => stats[:token_count],
151
+ "unk_count" => stats[:unk_count],
152
+ "truncated" => stats[:truncated],
153
+ "vector" => StaticEmbeddings.unpack(stats[:vector], model.dim).first.map { |v| v.round(6) }
154
+ }
155
+ end
156
+
157
+ def high_unk?(stats)
158
+ stats[:token_count].positive? && stats[:unk_count].to_f / stats[:token_count] > 0.3
159
+ end
160
+
161
+ def warn_high_unk(stats)
162
+ ratio = stats[:unk_count].to_f / stats[:token_count]
163
+ warn "warning: #{(ratio * 100).round}% of tokens are [UNK] — wrong model for this language?"
164
+ end
165
+
166
+ def model_and_text(argv, usage)
167
+ path = argv.shift
168
+ text = argv.join(" ")
169
+ abort usage if path.nil? || text.empty?
170
+
171
+ [StaticEmbeddings.load(path), text]
172
+ end
173
+
174
+ def required_arg(argv, usage)
175
+ argv.shift || abort(usage)
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,284 @@
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
@@ -0,0 +1,6 @@
1
+ module StaticEmbeddings
2
+
3
+ class ConversionError < Error; end
4
+
5
+ class ModelNotFound < Error; end
6
+ end