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
@@ -0,0 +1,111 @@
1
+ require "digest"
2
+ require "json"
3
+ require "static_embeddings/errors"
4
+ require "static_embeddings/format/constants"
5
+ require "static_embeddings/safetensors"
6
+ require "static_embeddings/row_prefix_payload"
7
+
8
+ module StaticEmbeddings
9
+ module Importers
10
+ module Support
11
+ module_function
12
+
13
+ def load_json(path, optional: false)
14
+ return nil if optional && !File.file?(path)
15
+ raise InvalidSourceError, "missing #{File.basename(path)} in #{File.dirname(path)}" unless File.file?(path)
16
+
17
+ JSON.parse(File.binread(path))
18
+ rescue JSON::ParserError => e
19
+ raise InvalidSourceError, "invalid JSON in #{path}: #{e.message}"
20
+ end
21
+
22
+ def load_object(path, optional: false)
23
+ value = load_json(path, optional: optional)
24
+ return nil if value.nil?
25
+ raise InvalidSourceError, "#{path} must contain a JSON object" unless value.is_a?(Hash)
26
+
27
+ value
28
+ end
29
+
30
+ def digest_files(paths, root: nil)
31
+ paths.each_with_object({}) do |path, digests|
32
+ next unless File.file?(path)
33
+
34
+ digests[digest_key(path, root)] = Digest::SHA256.file(path).hexdigest
35
+ end
36
+ end
37
+
38
+ def extract_matrix(path, vocab_size)
39
+ raise InvalidSourceError, "missing model.safetensors in #{File.dirname(path)}" unless File.file?(path)
40
+
41
+ name, tensor = sole_matrix_tensor(Safetensors.describe(path).fetch(:tensors))
42
+ rows, dim = tensor.fetch(:shape)
43
+ unless rows == vocab_size
44
+ raise ConversionError,
45
+ "embedding matrix #{name} has #{rows} rows but the tokenizer has #{vocab_size} tokens"
46
+ end
47
+ unless dim.positive? && dim <= Format::UINT32_MAX
48
+ raise ConversionError, "embedding matrix #{name} has unsupported dimension #{dim}"
49
+ end
50
+
51
+ [Safetensors.f32_payload(path, tensor), dim]
52
+ end
53
+
54
+ def resolve_max_tokens(requested, default)
55
+ return default if requested.nil?
56
+ return 0 if requested == false || requested == :unlimited || requested == 0
57
+
58
+ value = Integer(requested)
59
+ unless value.between?(1, Format::UINT32_MAX)
60
+ raise InvalidOptionError, "max_tokens must be 1..#{Format::UINT32_MAX}, false, or :unlimited"
61
+ end
62
+ value
63
+ end
64
+
65
+ def resolve_dimensions(requested, native_dim)
66
+ return native_dim if requested.nil?
67
+
68
+ value = Integer(requested)
69
+ unless value.between?(1, native_dim)
70
+ raise InvalidOptionError, "dimensions must be between 1 and native_dim #{native_dim}, got #{value}"
71
+ end
72
+ value
73
+ end
74
+
75
+ def normalize_mrl_dims(value, native_dim:)
76
+ return nil if value.nil?
77
+
78
+ dims = value.is_a?(Array) ? value : value.to_s.split(",").map(&:strip)
79
+ dims = dims.map { |item| Integer(item) }
80
+ if dims.empty? || dims.any? { |dim| !dim.between?(1, native_dim) }
81
+ raise InvalidOptionError, "trained_mrl_dims must contain dimensions between 1 and #{native_dim}"
82
+ end
83
+ dims.uniq.freeze
84
+ end
85
+
86
+ def slice_matrix(payload, native_dim, output_dim)
87
+ return payload if output_dim == native_dim
88
+
89
+ RowPrefixPayload.new(payload, native_dim: native_dim, output_dim: output_dim)
90
+ end
91
+
92
+ def sole_matrix_tensor(tensors)
93
+ matrices = tensors.select { |_, tensor| tensor.fetch(:shape).length == 2 }
94
+ raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
95
+ return matrices.first if matrices.length == 1
96
+
97
+ raise ConversionError,
98
+ "model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); expected exactly one"
99
+ end
100
+
101
+ def digest_key(path, root)
102
+ return File.basename(path) unless root
103
+
104
+ root_real = File.realpath(root)
105
+ real = File.realpath(path)
106
+ prefix = root_real.end_with?(File::SEPARATOR) ? root_real : "#{root_real}#{File::SEPARATOR}"
107
+ real.start_with?(prefix) ? real.delete_prefix(prefix) : File.basename(real)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,50 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/bert_wordpiece"
3
+ require "static_embeddings/canonical"
4
+ require "static_embeddings/importers/support"
5
+ require "static_embeddings/importers/model2vec"
6
+ require "static_embeddings/importers/sentence_transformers_static"
7
+
8
+ module StaticEmbeddings
9
+ module Importers
10
+ module_function
11
+
12
+ def import(source_dir, **options)
13
+ root = source_root(source_dir)
14
+ case detect(root)
15
+ when :model2vec then Model2Vec.call(root, **options)
16
+ when :sentence_transformers_static then SentenceTransformersStatic.call(root, **options)
17
+ end
18
+ end
19
+
20
+ def detect(root)
21
+ config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
22
+ tokenizer = File.join(root, "tokenizer.json")
23
+ weights = File.join(root, "model.safetensors")
24
+ modules = File.join(root, "modules.json")
25
+
26
+ return :model2vec if model2vec_config?(config) && File.file?(tokenizer) && File.file?(weights)
27
+ return :sentence_transformers_static if File.file?(modules)
28
+
29
+ if File.file?(tokenizer) && File.file?(weights)
30
+ raise InvalidSourceError,
31
+ "cannot prove source family in #{root}: config.json is not Model2Vec and modules.json is absent"
32
+ end
33
+
34
+ raise InvalidSourceError,
35
+ "cannot detect a static embedding source in #{root}: need a Model2Vec config with " \
36
+ "tokenizer.json + model.safetensors, or modules.json for Sentence Transformers StaticEmbedding"
37
+ end
38
+
39
+ def source_root(source_dir)
40
+ root = File.expand_path(source_dir.to_s)
41
+ raise InvalidSourceError, "source directory #{root} does not exist" unless File.directory?(root)
42
+
43
+ root
44
+ end
45
+
46
+ def model2vec_config?(config)
47
+ config["model_type"].to_s == "model2vec" || Array(config["architectures"]).include?("StaticModel")
48
+ end
49
+ end
50
+ end
@@ -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.6"
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."