static_embeddings 0.1.5 → 1.5.7

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +17 -2
  4. data/docs/ARCHITECTURE.md +14 -5
  5. data/docs/MODEL_AUDIT.md +110 -0
  6. data/ext/static_embeddings/se_alloc_stats.c +5 -6
  7. data/ext/static_embeddings/se_f16.c +1 -1
  8. data/ext/static_embeddings/se_tokenizer.c +6 -0
  9. data/ext/static_embeddings/se_topk.c +1 -2
  10. data/ext/static_embeddings/static_embeddings.c +60 -10
  11. data/lib/models/demo.semb +0 -0
  12. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  13. data/lib/static_embeddings/canonical.rb +50 -0
  14. data/lib/static_embeddings/cli.rb +88 -63
  15. data/lib/static_embeddings/codec.rb +45 -0
  16. data/lib/static_embeddings/conversion.rb +58 -0
  17. data/lib/static_embeddings/errors.rb +2 -2
  18. data/lib/static_embeddings/format/constants.rb +109 -0
  19. data/lib/static_embeddings/format/hash_table.rb +69 -0
  20. data/lib/static_embeddings/format/trie.rb +78 -0
  21. data/lib/static_embeddings/format/verifier.rb +41 -0
  22. data/lib/static_embeddings/format/writer.rb +131 -0
  23. data/lib/static_embeddings/format.rb +3 -338
  24. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  25. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  26. data/lib/static_embeddings/importers/support.rb +111 -0
  27. data/lib/static_embeddings/importers.rb +50 -0
  28. data/lib/static_embeddings/model.rb +35 -20
  29. data/lib/static_embeddings/paths.rb +8 -12
  30. data/lib/static_embeddings/provenance.rb +58 -0
  31. data/lib/static_embeddings/reference.rb +50 -40
  32. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  33. data/lib/static_embeddings/version.rb +1 -1
  34. data/lib/static_embeddings.rb +29 -55
  35. data/static_embeddings.gemspec +2 -2
  36. data/tools/check_model2vec_parity.rb +5 -1
  37. data/tools/check_st_parity.rb +125 -0
  38. data/tools/eval_retrieval.rb +58 -0
  39. metadata +24 -6
  40. data/lib/static_embeddings/converter.rb +0 -328
@@ -4,39 +4,41 @@ module StaticEmbeddings
4
4
  class Model
5
5
  attr_reader :path
6
6
 
7
- def provenance
8
- raw = provenance_json
9
- return {} if raw.nil?
7
+ if method_defined?(:max_tokens) && !method_defined?(:max_tokens_limit)
8
+ alias_method :max_tokens_limit, :max_tokens
10
9
 
11
- JSON.parse(raw)
12
- rescue JSON::ParserError, EncodingError => e
13
- raise InvalidModelError, "invalid provenance JSON: #{e.message}"
10
+ def max_tokens
11
+ limit = max_tokens_limit
12
+ limit.zero? ? false : limit
13
+ end
14
+ end
15
+
16
+ def provenance
17
+ @provenance ||= parse_provenance.freeze
14
18
  end
15
19
 
16
20
  def model_id
17
21
  provenance["source_model_id"]
18
22
  end
19
23
 
20
- def embed_array(text, **opts)
21
- format = opts.key?(:format) ? opts[:format] : :f32
22
- StaticEmbeddings.unpack(embed(text, **opts), dim, format: format).first
24
+ def embed_array(text, **options)
25
+ format = options.fetch(:format, :f32)
26
+ StaticEmbeddings.unpack(embed(text, **options), dim, format: format).first
23
27
  end
24
28
 
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)
29
+ def embed_batch_arrays(texts, **options)
30
+ format = options.fetch(:format, :f32)
31
+ StaticEmbeddings.unpack(embed_batch(texts, **options), dim, format: format)
28
32
  end
29
33
 
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
+ def cosine_top_k(query_blob, matrix_blob, k, **options)
35
+ reject_runtime_dim!(options)
36
+ StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
34
37
  end
35
38
 
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))
39
+ def dot_top_k(query_blob, matrix_blob, k, **options)
40
+ reject_runtime_dim!(options)
41
+ StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
40
42
  end
41
43
 
42
44
  def to_s
@@ -44,5 +46,18 @@ module StaticEmbeddings
44
46
  end
45
47
 
46
48
  alias inspect to_s
49
+
50
+ private
51
+
52
+ def parse_provenance
53
+ raw = provenance_json
54
+ raw.nil? ? {} : JSON.parse(raw)
55
+ rescue JSON::ParserError, EncodingError => e
56
+ raise InvalidModelError, "invalid provenance JSON: #{e.message}"
57
+ end
58
+
59
+ def reject_runtime_dim!(options)
60
+ raise InvalidOptionError, "dim: is set by the model" if options.key?(:dim)
61
+ end
47
62
  end
48
63
  end
@@ -12,29 +12,25 @@ module StaticEmbeddings
12
12
 
13
13
  def model_path(model_id, env = ENV)
14
14
  id = model_id.to_s
15
- raise ArgumentError, "model_id must not be empty" if id.empty?
16
- raise ArgumentError, "model_id contains a NUL byte" if id.include?("\0")
15
+ raise InvalidOptionError, "model_id must not be empty" if id.empty?
16
+ raise InvalidOptionError, "model_id contains a NUL byte" if id.include?("\0")
17
17
 
18
18
  normalized = id.tr("\\", "/")
19
19
  parts = normalized.split("/", -1)
20
- if normalized.start_with?("/") ||
21
- parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
22
- raise ArgumentError, "model_id must be a relative slash-separated identifier"
23
- end
20
+ invalid = normalized.start_with?("/") ||
21
+ parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
22
+ raise InvalidOptionError, "model_id must be a relative slash-separated identifier" if invalid
24
23
 
25
24
  base = File.expand_path(File.join(cache_dir(env), "models"))
26
25
  path = File.expand_path(File.join(base, "#{normalized}.semb"))
27
26
  prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
28
- unless path.start_with?(prefix)
29
- raise ArgumentError, "model_id escapes the model cache"
30
- end
27
+ raise InvalidOptionError, "model_id escapes the model cache" unless path.start_with?(prefix)
28
+
31
29
  path
32
30
  end
33
31
 
34
32
  def builtin_path(name)
35
- file = BUILTIN_MODELS.fetch(name) do
36
- raise ModelNotFound, "unknown builtin model #{name.inspect}"
37
- end
33
+ file = BUILTIN_MODELS.fetch(name) { raise ModelNotFound, "unknown builtin model #{name.inspect}" }
38
34
  File.join(BUILTIN_DIR, file)
39
35
  end
40
36
 
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "static_embeddings/format/constants"
3
+ require "static_embeddings/bert_wordpiece"
4
+
5
+ module StaticEmbeddings
6
+ module Provenance
7
+ MODEL2VEC_VERSION = "0.9.0"
8
+ TOKENIZERS_VERSION = "0.23.1"
9
+ UNICODE_CATEGORIES_VERSION = "0.1.1"
10
+ NOTES = "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
11
+
12
+ module_function
13
+
14
+ def json(model, unicode_source:)
15
+ JSON.generate(payload(model, unicode_source: unicode_source).sort.to_h)
16
+ end
17
+
18
+ def payload(model, unicode_source:)
19
+ dimensions = model.dimensions
20
+ runtime = model.runtime
21
+ source = model.source
22
+ tokenizer = model.tokenizer
23
+
24
+ data = {
25
+ "add_special_tokens" => runtime.add_special_tokens,
26
+ "config_seq_length" => source.config_seq_length,
27
+ "converter_version" => StaticEmbeddings::VERSION,
28
+ "dim" => dimensions.output,
29
+ "format_version" => Format::VERSION,
30
+ "native_dim" => dimensions.native,
31
+ "normalize" => runtime.normalization == Format::NORMALIZATION_L2,
32
+ "notes" => NOTES,
33
+ "oracle" => source.oracle,
34
+ "output_dim" => dimensions.output,
35
+ "reference_impl" => source.oracle,
36
+ "reference_max_tokens" => runtime.max_tokens,
37
+ "reference_tokenizers_version" => TOKENIZERS_VERSION,
38
+ "source_family" => source.family,
39
+ "source_files_sha256" => source.files_sha256,
40
+ "source_model_id" => source.model,
41
+ "source_revision" => source.revision,
42
+ "tokenizer_class" => source.tokenizer_class,
43
+ "tokenizer_profile" => tokenizer.fetch(:tokenizer_profile, BertWordPiece::TOKENIZER_PROFILE),
44
+ "unicode_source" => unicode_source,
45
+ "unk_policy" => runtime.unk_policy == Format::UNK_DROP ? "drop" : "include",
46
+ "vocab_size" => model.tokens.length
47
+ }
48
+ data["trained_mrl_dims"] = dimensions.trained if dimensions.trained
49
+ add_model2vec_versions(data) if source.family == "model2vec"
50
+ data
51
+ end
52
+
53
+ def add_model2vec_versions(data)
54
+ data["reference_model2vec_version"] = MODEL2VEC_VERSION
55
+ data["reference_unicode_categories_version"] = UNICODE_CATEGORIES_VERSION
56
+ end
57
+ end
58
+ end
@@ -1,6 +1,5 @@
1
- require "json"
2
- require "static_embeddings/converter"
3
- require "static_embeddings/safetensors"
1
+ require "static_embeddings/importers"
2
+ require "static_embeddings/format/constants"
4
3
 
5
4
  module StaticEmbeddings
6
5
  class Reference
@@ -26,49 +25,52 @@ module StaticEmbeddings
26
25
  @dim = meta.fetch(:dim)
27
26
  end
28
27
 
29
- def self.from_source_dir(dir, max_tokens: Converter::REFERENCE_MAX_TOKENS)
30
- tokenizer = JSON.parse(File.binread(File.join(dir, "tokenizer.json")))
31
- config_path = File.join(dir, "config.json")
32
- config = File.file?(config_path) ? JSON.parse(File.binread(config_path)) : {}
33
- tokens = tokens_from(tokenizer)
34
- tensor = Safetensors.read(File.join(dir, "model.safetensors"))[:tensors].values.first
35
- rows, dim = tensor[:shape]
36
- floats = Safetensors.f32_bytes(tensor).unpack("e*")
37
- normalizer = tokenizer["normalizer"] || {}
28
+ def self.from_source_dir(dir, max_tokens: nil, dimensions: nil)
29
+ from_canonical(Importers.import(dir, max_tokens: max_tokens, dimensions: dimensions))
30
+ end
38
31
 
39
- new(tokens: tokens,
40
- matrix: Array.new(rows) { |i| floats[i * dim, dim] },
41
- meta: meta_from(tokenizer, config, normalizer, dim, max_tokens))
32
+ def self.from_canonical(model)
33
+ dim = model.dimensions.output
34
+ floats = matrix_bytes(model.matrix).unpack("e*")
35
+ rows = model.tokens.length
36
+ new(
37
+ tokens: model.tokens,
38
+ matrix: Array.new(rows) { |index| floats[index * dim, dim] },
39
+ meta: meta_from_canonical(model)
40
+ )
42
41
  end
43
42
 
44
- def self.tokens_from(tokenizer)
45
- vocab = tokenizer.dig("model", "vocab")
46
- vocab.each_with_object(Array.new(vocab.length)) { |(token, id), tokens| tokens[id] = token }
43
+ def self.matrix_bytes(matrix)
44
+ return matrix unless matrix.respond_to?(:each_chunk)
45
+
46
+ matrix.each_chunk.each_with_object(Format.binary_string) { |chunk, packed| packed << chunk }
47
47
  end
48
48
 
49
- def self.meta_from(tokenizer, config, normalizer, dim, max_tokens)
50
- lowercase = normalizer.fetch("lowercase", true)
49
+ def self.meta_from_canonical(model)
50
+ tokenizer = model.tokenizer
51
+ runtime = model.runtime
51
52
  {
52
- dim: dim,
53
- lowercase: lowercase,
54
- strip_accents: normalizer["strip_accents"].nil? ? lowercase : normalizer["strip_accents"],
55
- clean_text: normalizer.fetch("clean_text", true),
56
- handle_chinese_chars: normalizer.fetch("handle_chinese_chars", true),
57
- max_input_chars_per_word: tokenizer.dig("model", "max_input_chars_per_word") || 100,
58
- unk_token: tokenizer.dig("model", "unk_token") || "[UNK]",
59
- normalize: config.key?("normalize") ? config["normalize"] : true,
60
- max_tokens: max_tokens,
61
- added_tokens: supported_added_tokens(tokenizer)
53
+ dim: model.dimensions.output,
54
+ lowercase: tokenizer.fetch(:do_lower_case),
55
+ strip_accents: tokenizer.fetch(:strip_accents),
56
+ clean_text: tokenizer.fetch(:clean_text),
57
+ handle_chinese_chars: tokenizer.fetch(:handle_chinese_chars),
58
+ max_input_chars_per_word: tokenizer.fetch(:max_input_chars_per_word),
59
+ unk_token: model.tokens.fetch(tokenizer.fetch(:unk_id)),
60
+ normalize: runtime.normalization == Format::NORMALIZATION_L2,
61
+ max_tokens: runtime.max_tokens,
62
+ unk_policy: runtime.unk_policy,
63
+ added_tokens: added_tokens(model)
62
64
  }
63
65
  end
64
66
 
65
- def self.supported_added_tokens(tokenizer)
66
- allowed = Converter::STANDARD_SPECIAL_TOKENS
67
- (tokenizer["added_tokens"] || []).each_with_object({}) do |token, out|
68
- content = token["content"]
69
- next unless token["special"] && allowed.key?(content) && token["normalized"] == false
67
+ def self.added_tokens(model)
68
+ mask = model.tokenizer.fetch(:added_token_mask)
69
+ BertWordPiece::STANDARD_SPECIAL_TOKENS.each_with_object({}) do |(content, bit), tokens|
70
+ next if (mask & bit).zero?
70
71
 
71
- out[content] = Integer(token.fetch("id"))
72
+ id = model.tokens.index(content)
73
+ tokens[content] = id unless id.nil?
72
74
  end
73
75
  end
74
76
 
@@ -91,14 +93,22 @@ module StaticEmbeddings
91
93
  end
92
94
 
93
95
  def embed(text, max_tokens: @meta[:max_tokens])
94
- used = tokenize(text, max_tokens: false).reject { |id| id == unk_id }
95
- used = used.first(max_tokens) if max_tokens && max_tokens.positive? && used.length > max_tokens
96
- return Array.new(@dim, 0.0) if used.empty?
96
+ ids = apply_unk_policy(tokenize(text, max_tokens: false))
97
+ ids = ids.first(max_tokens) if max_tokens && max_tokens.positive? && ids.length > max_tokens
98
+ return Array.new(@dim, 0.0) if ids.empty?
97
99
 
98
- vector = pooled(used)
100
+ vector = pooled(ids)
99
101
  @meta[:normalize] ? l2_normalize(vector) : vector
100
102
  end
101
103
 
104
+ def apply_unk_policy(ids)
105
+ case @meta.fetch(:unk_policy)
106
+ when Format::UNK_DROP then ids.reject { |id| id == unk_id }
107
+ when Format::UNK_INCLUDE then ids
108
+ else raise InvalidModelError, "unknown unk_policy #{@meta[:unk_policy].inspect}"
109
+ end
110
+ end
111
+
102
112
  private
103
113
 
104
114
  def tokenize_with_added_tokens(text)
@@ -0,0 +1,59 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/format/constants"
3
+
4
+ module StaticEmbeddings
5
+ class RowPrefixPayload
6
+ FLOAT_BYTES = 4
7
+
8
+ attr_reader :bytesize
9
+
10
+ def initialize(inner, native_dim:, output_dim:)
11
+ raise InvalidOptionError, "native_dim must be positive" unless native_dim.positive?
12
+ unless output_dim.between?(1, native_dim)
13
+ raise InvalidOptionError, "output_dim must be between 1 and #{native_dim}"
14
+ end
15
+
16
+ @inner = inner
17
+ @source_row_bytes = native_dim * FLOAT_BYTES
18
+ @output_row_bytes = output_dim * FLOAT_BYTES
19
+ unless (inner.bytesize % @source_row_bytes).zero?
20
+ raise ConversionError, "embedding payload is not a whole number of #{native_dim}-d rows"
21
+ end
22
+ @bytesize = (inner.bytesize / @source_row_bytes) * @output_row_bytes
23
+ end
24
+
25
+ def each_chunk
26
+ return enum_for(__method__) unless block_given?
27
+
28
+ remainder = Format.binary_string
29
+ @inner.each_chunk do |chunk|
30
+ data = remainder.empty? ? chunk : remainder + chunk
31
+ complete_rows, remainder = split_complete_rows(data)
32
+ yield slice_rows(complete_rows) unless complete_rows.empty?
33
+ end
34
+
35
+ raise ConversionError, "truncated embedding matrix while slicing dimensions" unless remainder.empty?
36
+ end
37
+
38
+ private
39
+
40
+ def split_complete_rows(data)
41
+ bytes = (data.bytesize / @source_row_bytes) * @source_row_bytes
42
+ complete = data.byteslice(0, bytes) || Format.binary_string
43
+ remainder = data.byteslice(bytes, data.bytesize - bytes) || Format.binary_string
44
+ remainder.force_encoding(Encoding::BINARY)
45
+ [complete, remainder]
46
+ end
47
+
48
+ def slice_rows(data)
49
+ rows = data.bytesize / @source_row_bytes
50
+ out = Format.binary_string(rows * @output_row_bytes)
51
+ offset = 0
52
+ rows.times do
53
+ out << data.byteslice(offset, @output_row_bytes)
54
+ offset += @source_row_bytes
55
+ end
56
+ out
57
+ end
58
+ end
59
+ end
@@ -1,3 +1,3 @@
1
1
  module StaticEmbeddings
2
- VERSION = "0.1.5"
2
+ VERSION = "1.5.7"
3
3
  end
@@ -1,23 +1,20 @@
1
- require "json"
2
1
  require "static_embeddings/version"
3
2
 
4
3
  begin
5
4
  require "static_embeddings/static_embeddings"
6
5
  rescue LoadError
7
6
  ext_dir = File.expand_path("static_embeddings", __dir__)
8
- so_path = %w[.so .bundle].lazy.map { |ext| File.join(ext_dir, "static_embeddings#{ext}") }
9
- .find { |path| File.file?(path) }
10
- unless so_path
11
- raise LoadError, "Could not find the compiled StaticEmbeddings extension. " \
12
- "Run: bundle exec rake compile"
7
+ extension = %w[.so .bundle].lazy.map { |suffix| File.join(ext_dir, "static_embeddings#{suffix}") }
8
+ .find { |path| File.file?(path) }
9
+ unless extension
10
+ raise LoadError, "Could not find the compiled StaticEmbeddings extension. Run: bundle exec rake compile"
13
11
  end
14
-
15
- require so_path
12
+ require extension
16
13
  end
17
14
 
18
15
  require "static_embeddings/errors"
19
16
  require "static_embeddings/paths"
20
- require "static_embeddings/format"
17
+ require "static_embeddings/format/constants"
21
18
  require "static_embeddings/model"
22
19
 
23
20
  module StaticEmbeddings
@@ -25,11 +22,7 @@ module StaticEmbeddings
25
22
  def load(path, verify: false)
26
23
  expanded = File.expand_path(path.to_s)
27
24
  raise ModelNotFound, "no model at #{expanded}" unless File.file?(expanded)
28
-
29
- if verify
30
- result = Format.verify(expanded)
31
- raise InvalidModelError, "checksum mismatch for #{expanded}" unless result[:ok]
32
- end
25
+ raise InvalidModelError, "checksum mismatch for #{expanded}" if verify && !self.verify(expanded)[:ok]
33
26
 
34
27
  Model.new(expanded)
35
28
  end
@@ -54,63 +47,44 @@ module StaticEmbeddings
54
47
  path = model_path(model_id)
55
48
  unless File.file?(path)
56
49
  raise ModelNotFound,
57
- "model #{model_id.inspect} is not installed. " \
58
- "Convert it first: static_embeddings convert <hf-dir> --id #{model_id}"
50
+ "model #{model_id.inspect} is not installed. Convert it first: " \
51
+ "static_embeddings convert <hf-dir> --id #{model_id}"
59
52
  end
60
53
  load(path, verify: verify)
61
54
  end
62
55
 
63
- def convert(source_dir, output_path:, model_id: nil, max_tokens: nil)
64
- require "static_embeddings/converter"
65
- max_tokens = Converter::REFERENCE_MAX_TOKENS if max_tokens.nil?
66
- converter = Converter.new(source_dir)
67
- converter.convert(output_path: output_path, model_id: model_id, max_tokens: max_tokens)
68
- converter.report
56
+ def convert(source_dir, output_path:, model_id: nil, max_tokens: nil, dimensions: nil,
57
+ source_revision: nil, trained_mrl_dims: nil)
58
+ require "static_embeddings/conversion"
59
+ Conversion.call(
60
+ source_dir,
61
+ output_path: output_path,
62
+ model_id: model_id,
63
+ max_tokens: max_tokens,
64
+ dimensions: dimensions,
65
+ source_revision: source_revision,
66
+ trained_mrl_dims: trained_mrl_dims
67
+ )
69
68
  end
70
69
 
71
70
  def verify(path)
72
- Format.verify(File.expand_path(path.to_s))
71
+ require "static_embeddings/format/verifier"
72
+ Format::Verifier.call(File.expand_path(path.to_s))
73
73
  end
74
74
 
75
75
  def unpack(blob, dim, format: :f32)
76
- raise ArgumentError, "dim must be positive" unless dim.to_i.positive?
77
-
78
- floats =
79
- case normalize_format(format)
80
- when :f32
81
- raise ArgumentError, "f32 blob byte size must be a multiple of 4" unless (blob.bytesize % 4).zero?
82
-
83
- blob.unpack("e*")
84
- when :f16
85
- raise ArgumentError, "f16 blob byte size must be a multiple of 2" unless (blob.bytesize % 2).zero?
86
-
87
- decode_f16(blob)
88
- end
89
-
90
- raise ArgumentError, "blob is not a multiple of dim" unless (floats.length % dim).zero?
91
-
92
- floats.each_slice(dim).to_a
76
+ require "static_embeddings/codec"
77
+ Codec.unpack(blob, dim, format: format)
93
78
  end
94
79
 
95
80
  def pack(rows, format: :f32)
96
- flat = rows.first.is_a?(Array) ? rows.flatten(1) : rows
97
-
98
- case normalize_format(format)
99
- when :f32 then flat.map(&:to_f).pack("e*")
100
- when :f16 then encode_f16(flat.map(&:to_f))
101
- end
81
+ require "static_embeddings/codec"
82
+ Codec.pack(rows, format: format)
102
83
  end
103
84
 
104
85
  def normalize_format(format)
105
- case format&.to_sym
106
- when nil, :f32, :float32
107
- :f32
108
- when :f16, :float16
109
- :f16
110
- else
111
- raise ArgumentError, "unsupported embedding format #{format.inspect} (expected :f32 or :f16)"
112
- end
86
+ require "static_embeddings/codec"
87
+ Codec.normalize_format(format)
113
88
  end
114
-
115
89
  end
116
90
  end
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
7
7
  spec.email = ["romnhajdarov@gmail.com"]
8
8
 
9
9
  spec.summary = "Fast local text embeddings for Ruby — no ONNX, no Rust, no network"
10
- spec.description = "A small C-extension runtime for Model2Vec-style static embedding " \
11
- "models. Models are converted offline into a flat mmap-able .semb " \
10
+ spec.description = "A small C-extension runtime for Model2Vec and Sentence Transformers static " \
11
+ "WordPiece embedding models. Models are converted offline into a flat mmap-able .semb " \
12
12
  "file; at runtime the gem tokenizes (BERT WordPiece), looks up rows " \
13
13
  "and mean-pools them. Releases the GVL on large native work, rejects internal " \
14
14
  "thread fan-out, and links nothing but libc."
@@ -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})"