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
@@ -3,61 +3,157 @@ require "json"
3
3
  module StaticEmbeddings
4
4
  module Safetensors
5
5
  MAX_HEADER_BYTES = 100 * 1024 * 1024
6
- SUPPORTED_DTYPES = { "F32" => 4 }.freeze
6
+ SUPPORTED_DTYPES = { "F32" => 4, "F16" => 2, "BF16" => 2 }.freeze
7
+ CONVERT_CHUNK_ELEMENTS = 64 * 1024
8
+ IO_CHUNK_BYTES = 1024 * 1024
9
+
10
+ class F32Payload
11
+ attr_reader :bytesize
12
+
13
+ def initialize(path, tensor)
14
+ @path = path
15
+ @offset = tensor.fetch(:absolute_offset)
16
+ @source_bytes = tensor.fetch(:source_bytes)
17
+ @dtype = tensor.fetch(:dtype)
18
+ @bytesize = tensor.fetch(:shape).reduce(1) { |count, dim| count * dim } * 4
19
+ end
7
20
 
8
- module_function
21
+ def each_chunk
22
+ return enum_for(__method__) unless block_given?
23
+
24
+ File.open(@path, "rb") do |io|
25
+ io.seek(@offset, IO::SEEK_SET)
26
+ remaining = @source_bytes
27
+ while remaining.positive?
28
+ read_size = [remaining, IO_CHUNK_BYTES].min
29
+ # F16/BF16 elements are two bytes; never split one between chunks.
30
+ read_size -= 1 if @dtype != "F32" && read_size.odd?
31
+ read_size = remaining if read_size.zero?
32
+ chunk = io.read(read_size)
33
+ unless chunk&.bytesize == read_size
34
+ raise ConversionError, "tensor data is truncated while streaming #{@path}"
35
+ end
36
+ chunk.force_encoding(Encoding::BINARY)
37
+ yield case @dtype
38
+ when "F32" then chunk
39
+ when "F16" then Safetensors.convert_f16_to_f32(chunk)
40
+ when "BF16" then Safetensors.convert_bf16_to_f32(chunk)
41
+ else
42
+ raise ConversionError, "tensor dtype #{@dtype} is not supported"
43
+ end
44
+ remaining -= read_size
45
+ end
46
+ end
47
+ end
48
+ end
9
49
 
10
- def read(path)
11
- data = File.binread(path)
12
- header, body_offset = parse_header(data)
13
- body_size = data.bytesize - body_offset
50
+ module_function
14
51
 
15
- tensors = header.each_with_object({}) do |(name, spec), acc|
16
- next if name == "__metadata__"
52
+ def describe(path)
53
+ File.open(path, "rb") do |io|
54
+ file_size = io.stat.size
55
+ header, body_offset = read_header(io, file_size)
56
+ body_size = file_size - body_offset
57
+
58
+ tensors = header.each_with_object({}) do |(name, spec), acc|
59
+ next if name == "__metadata__"
60
+
61
+ dtype = spec["dtype"]
62
+ shape = spec["shape"]
63
+ begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
64
+ expected = checked_tensor_bytes(name, dtype, shape)
65
+ actual = end_off - begin_off
66
+ if actual != expected
67
+ raise ConversionError,
68
+ "tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
69
+ end
70
+
71
+ acc[name] = {
72
+ dtype: dtype,
73
+ shape: shape,
74
+ absolute_offset: body_offset + begin_off,
75
+ source_bytes: actual
76
+ }
77
+ end
78
+
79
+ { metadata: header["__metadata__"] || {}, tensors: tensors }
80
+ end
81
+ end
17
82
 
18
- acc[name] = tensor_from(data, body_offset, body_size, name, spec)
83
+ def read(path)
84
+ description = describe(path)
85
+ tensors = description.fetch(:tensors).transform_values do |tensor|
86
+ File.open(path, "rb") do |io|
87
+ io.seek(tensor.fetch(:absolute_offset), IO::SEEK_SET)
88
+ bytes = io.read(tensor.fetch(:source_bytes))
89
+ unless bytes&.bytesize == tensor.fetch(:source_bytes)
90
+ raise ConversionError, "tensor data is truncated while reading #{path}"
91
+ end
92
+ tensor.merge(bytes: bytes.force_encoding(Encoding::BINARY))
93
+ end
19
94
  end
95
+ { metadata: description.fetch(:metadata), tensors: tensors }
96
+ end
20
97
 
21
- { metadata: header["__metadata__"] || {}, tensors: tensors }
98
+ def f32_payload(path, tensor)
99
+ F32Payload.new(path, tensor)
22
100
  end
23
101
 
24
102
  def write(path, name, shape, floats)
25
103
  body = floats.pack("e*")
26
- header = JSON.generate(name => { "dtype" => "F32", "shape" => shape, "data_offsets" => [0, body.bytesize] })
104
+ write_raw(path, name, shape, "F32", body)
105
+ end
106
+
107
+ def write_raw(path, name, shape, dtype, body)
108
+ checked_tensor_bytes(name, dtype, shape).tap do |expected|
109
+ raise ArgumentError, "body size does not match #{dtype} shape" unless body.bytesize == expected
110
+ end
111
+
112
+ header = JSON.generate(name => { "dtype" => dtype, "shape" => shape, "data_offsets" => [0, body.bytesize] })
27
113
  padded = header << (" " * ((8 - (header.bytesize % 8)) % 8))
28
- File.binwrite(path, [padded.bytesize].pack("Q<") << padded << body)
114
+ File.open(path, "wb") do |io|
115
+ io.write([padded.bytesize].pack("Q<"))
116
+ io.write(padded)
117
+ io.write(body)
118
+ end
119
+ end
120
+
121
+ def f32_bytes(tensor)
122
+ case tensor.fetch(:dtype)
123
+ when "F32"
124
+ tensor.fetch(:bytes)
125
+ when "F16"
126
+ convert_f16_to_f32(tensor.fetch(:bytes))
127
+ when "BF16"
128
+ convert_bf16_to_f32(tensor.fetch(:bytes))
129
+ else
130
+ raise ConversionError, "tensor dtype #{tensor[:dtype]} is not supported"
131
+ end
29
132
  end
30
133
 
31
- def parse_header(data)
32
- raise ConversionError, "safetensors file is shorter than its length prefix" if data.bytesize < 8
134
+ def read_header(io, file_size)
135
+ raise ConversionError, "safetensors file is shorter than its length prefix" if file_size < 8
136
+
137
+ prefix = io.read(8)
138
+ raise ConversionError, "safetensors file is shorter than its length prefix" unless prefix&.bytesize == 8
33
139
 
34
- header_len = data.byteslice(0, 8).unpack1("Q<")
140
+ header_len = prefix.unpack1("Q<")
35
141
  unless header_len.positive? && header_len <= MAX_HEADER_BYTES
36
142
  raise ConversionError, "implausible safetensors header length #{header_len}"
37
143
  end
38
144
 
39
145
  body_offset = 8 + header_len
40
- raise ConversionError, "safetensors header runs past end of file" if body_offset > data.bytesize
146
+ raise ConversionError, "safetensors header runs past end of file" if body_offset > file_size
41
147
 
42
- raw_header = data.byteslice(8, header_len)
148
+ raw_header = io.read(header_len)
149
+ unless raw_header&.bytesize == header_len
150
+ raise ConversionError, "safetensors header runs past end of file"
151
+ end
43
152
  raise ConversionError, "safetensors header is not a JSON object" unless raw_header.lstrip.start_with?("{")
44
153
 
45
154
  [JSON.parse(raw_header), body_offset]
46
- end
47
-
48
- def tensor_from(data, body_offset, body_size, name, spec)
49
- dtype = spec["dtype"]
50
- shape = spec["shape"]
51
- begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
52
- expected = checked_tensor_bytes(name, dtype, shape)
53
- actual = end_off - begin_off
54
-
55
- if actual != expected
56
- raise ConversionError,
57
- "tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
58
- end
59
-
60
- { dtype: dtype, shape: shape, bytes: data.byteslice(body_offset + begin_off, actual) }
155
+ rescue JSON::ParserError => e
156
+ raise ConversionError, "invalid safetensors JSON header: #{e.message}"
61
157
  end
62
158
 
63
159
  def checked_offsets(name, offsets, body_size)
@@ -76,12 +172,60 @@ module StaticEmbeddings
76
172
 
77
173
  def checked_tensor_bytes(name, dtype, shape)
78
174
  element_size = SUPPORTED_DTYPES[dtype]
79
- raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (F32 only)" unless element_size
175
+ unless element_size
176
+ raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (expected F32, F16, or BF16)"
177
+ end
80
178
  unless shape.is_a?(Array) && shape.all? { |dim| dim.is_a?(Integer) && dim >= 0 }
81
179
  raise ConversionError, "tensor #{name}: malformed shape #{shape.inspect}"
82
180
  end
83
181
 
84
- shape.reduce(1, :*) * element_size
182
+ shape.reduce(1) { |count, dim| count * dim } * element_size
183
+ end
184
+
185
+ def convert_f16_to_f32(bytes)
186
+ out = String.new(capacity: (bytes.bytesize / 2) * 4, encoding: Encoding::BINARY)
187
+ offset = 0
188
+ chunk_bytes = CONVERT_CHUNK_ELEMENTS * 2
189
+ while offset < bytes.bytesize
190
+ chunk = bytes.byteslice(offset, [chunk_bytes, bytes.bytesize - offset].min)
191
+ floats = if StaticEmbeddings.respond_to?(:decode_f16)
192
+ StaticEmbeddings.decode_f16(chunk)
193
+ else
194
+ chunk.unpack("v*").map { |bits| half_to_float(bits) }
195
+ end
196
+ out << floats.pack("e*")
197
+ offset += chunk.bytesize
198
+ end
199
+ out
200
+ end
201
+
202
+ def convert_bf16_to_f32(bytes)
203
+ out = String.new(capacity: (bytes.bytesize / 2) * 4, encoding: Encoding::BINARY)
204
+ offset = 0
205
+ chunk_bytes = CONVERT_CHUNK_ELEMENTS * 2
206
+ while offset < bytes.bytesize
207
+ chunk = bytes.byteslice(offset, [chunk_bytes, bytes.bytesize - offset].min)
208
+ words = chunk.unpack("v*")
209
+ out << words.map { |bits| bits << 16 }.pack("V*")
210
+ offset += chunk.bytesize
211
+ end
212
+ out
213
+ end
214
+
215
+ def half_to_float(bits)
216
+ sign = (bits >> 15) & 1
217
+ exponent = (bits >> 10) & 0x1F
218
+ fraction = bits & 0x3FF
219
+
220
+ value =
221
+ if exponent.zero?
222
+ fraction.zero? ? 0.0 : Math.ldexp(fraction.to_f, -24)
223
+ elsif exponent == 0x1F
224
+ fraction.zero? ? Float::INFINITY : Float::NAN
225
+ else
226
+ Math.ldexp(1.0 + fraction.to_f / 1024.0, exponent - 15)
227
+ end
228
+ sign.zero? ? value : -value
85
229
  end
86
230
  end
87
231
  end
@@ -1,3 +1,3 @@
1
1
  module StaticEmbeddings
2
- VERSION = "0.1.4"
2
+ VERSION = "1.5.6"
3
3
  end
@@ -1,27 +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"
21
- require "static_embeddings/unicode_tables"
22
- require "static_embeddings/safetensors"
23
- require "static_embeddings/converter"
24
- require "static_embeddings/reference"
17
+ require "static_embeddings/format/constants"
25
18
  require "static_embeddings/model"
26
19
 
27
20
  module StaticEmbeddings
@@ -29,11 +22,7 @@ module StaticEmbeddings
29
22
  def load(path, verify: false)
30
23
  expanded = File.expand_path(path.to_s)
31
24
  raise ModelNotFound, "no model at #{expanded}" unless File.file?(expanded)
32
-
33
- if verify
34
- result = Format.verify(expanded)
35
- raise InvalidModelError, "checksum mismatch for #{expanded}" unless result[:ok]
36
- end
25
+ raise InvalidModelError, "checksum mismatch for #{expanded}" if verify && !self.verify(expanded)[:ok]
37
26
 
38
27
  Model.new(expanded)
39
28
  end
@@ -58,61 +47,44 @@ module StaticEmbeddings
58
47
  path = model_path(model_id)
59
48
  unless File.file?(path)
60
49
  raise ModelNotFound,
61
- "model #{model_id.inspect} is not installed. " \
62
- "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}"
63
52
  end
64
53
  load(path, verify: verify)
65
54
  end
66
55
 
67
- def convert(source_dir, output_path:, model_id: nil, max_tokens: Converter::REFERENCE_MAX_TOKENS)
68
- converter = Converter.new(source_dir)
69
- converter.convert(output_path: output_path, model_id: model_id, max_tokens: max_tokens)
70
- 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
+ )
71
68
  end
72
69
 
73
70
  def verify(path)
74
- Format.verify(File.expand_path(path.to_s))
71
+ require "static_embeddings/format/verifier"
72
+ Format::Verifier.call(File.expand_path(path.to_s))
75
73
  end
76
74
 
77
75
  def unpack(blob, dim, format: :f32)
78
- raise ArgumentError, "dim must be positive" unless dim.to_i.positive?
79
-
80
- floats =
81
- case normalize_format(format)
82
- when :f32
83
- raise ArgumentError, "f32 blob byte size must be a multiple of 4" unless (blob.bytesize % 4).zero?
84
-
85
- blob.unpack("e*")
86
- when :f16
87
- raise ArgumentError, "f16 blob byte size must be a multiple of 2" unless (blob.bytesize % 2).zero?
88
-
89
- decode_f16(blob)
90
- end
91
-
92
- raise ArgumentError, "blob is not a multiple of dim" unless (floats.length % dim).zero?
93
-
94
- floats.each_slice(dim).to_a
76
+ require "static_embeddings/codec"
77
+ Codec.unpack(blob, dim, format: format)
95
78
  end
96
79
 
97
80
  def pack(rows, format: :f32)
98
- flat = rows.first.is_a?(Array) ? rows.flatten(1) : rows
99
-
100
- case normalize_format(format)
101
- when :f32 then flat.map(&:to_f).pack("e*")
102
- when :f16 then encode_f16(flat.map(&:to_f))
103
- end
81
+ require "static_embeddings/codec"
82
+ Codec.pack(rows, format: format)
104
83
  end
105
84
 
106
85
  def normalize_format(format)
107
- case format&.to_sym
108
- when nil, :f32, :float32
109
- :f32
110
- when :f16, :float16
111
- :f16
112
- else
113
- raise ArgumentError, "unsupported embedding format #{format.inspect} (expected :f32 or :f16)"
114
- end
86
+ require "static_embeddings/codec"
87
+ Codec.normalize_format(format)
115
88
  end
116
-
117
89
  end
118
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."
@@ -4,8 +4,7 @@ require "static_embeddings"
4
4
 
5
5
  options = {
6
6
  min_cosine: 1.0 - 1e-6,
7
- max_abs: 1e-5,
8
- ids: true
7
+ max_abs: 1e-5
9
8
  }
10
9
 
11
10
  parser = OptionParser.new do |opts|
@@ -13,7 +12,6 @@ parser = OptionParser.new do |opts|
13
12
  opts.on("--oracle PATH") { |value| options[:oracle] = value }
14
13
  opts.on("--min-cosine N", Float) { |value| options[:min_cosine] = value }
15
14
  opts.on("--max-abs N", Float) { |value| options[:max_abs] = value }
16
- opts.on("--[no-]ids") { |value| options[:ids] = value }
17
15
  end
18
16
  parser.parse!(ARGV)
19
17
  unless options[:model] && options[:oracle]
@@ -22,12 +20,18 @@ end
22
20
 
23
21
  model = StaticEmbeddings.load(options[:model], verify: true)
24
22
  payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
25
- rows = payload.is_a?(Hash) ? payload.fetch("rows") : payload
26
- oracle_max_length = payload.is_a?(Hash) ? payload["max_length"] : nil
23
+ abort "unsupported oracle schema #{payload["schema_version"].inspect}" unless payload["schema_version"] == 2
27
24
 
28
- if oracle_max_length && oracle_max_length != model.max_tokens
29
- warn "WARNING oracle max_length=#{oracle_max_length} but model.max_tokens=#{model.max_tokens}"
25
+ reference = payload.fetch("reference")
26
+ max_length = Integer(reference.fetch("max_length"))
27
+ if max_length != model.max_tokens
28
+ abort "oracle max_length=#{max_length} but model.max_tokens=#{model.max_tokens}"
30
29
  end
30
+ if reference["unk_token_id"] && Integer(reference["unk_token_id"]) != model.unk_id
31
+ abort "oracle unk_token_id=#{reference["unk_token_id"]} but model.unk_id=#{model.unk_id}"
32
+ end
33
+
34
+ rows = payload.fetch("rows")
31
35
 
32
36
  def dot(a, b)
33
37
  a.zip(b).sum { |x, y| x * y }
@@ -37,71 +41,102 @@ def norm(a)
37
41
  Math.sqrt(a.sum { |x| x * x })
38
42
  end
39
43
 
44
+ def vector_metrics(reference, got)
45
+ max_abs = reference.zip(got).map { |a, b| (a - b).abs }.max || 0.0
46
+ ref_zero = reference.all?(&:zero?)
47
+ got_zero = got.all?(&:zero?)
48
+ cosine =
49
+ if ref_zero && got_zero
50
+ 1.0
51
+ elsif ref_zero || got_zero
52
+ 0.0
53
+ else
54
+ dot(reference, got) / (norm(reference) * norm(got))
55
+ end
56
+ [cosine, max_abs]
57
+ end
58
+
59
+ raw_failures = []
60
+ usable_failures = []
61
+ vector_failures = []
62
+ invariant_failures = []
63
+ intentional_deviations = []
40
64
  min_cosine = 1.0
41
65
  max_abs_all = 0.0
42
- vector_failures = []
43
- id_failures = []
44
- id_rows_checked = 0
66
+ vectors_checked = 0
45
67
 
46
68
  rows.each_with_index do |row, i|
47
69
  text = row.fetch("text")
48
- ref = row.fetch("vector")
49
- ref_ids = row["token_ids"]
50
-
70
+ label = row.fetch("label", i.to_s)
51
71
  problems = []
52
72
 
53
- if options[:ids] && ref_ids
54
- id_rows_checked += 1
55
- got_ids = model.tokenize(text)
56
- if got_ids != ref_ids
57
- id_failures << i
58
- first = got_ids.zip(ref_ids).index { |a, b| a != b } || [got_ids.length, ref_ids.length].min
59
- problems << "ids differ at #{first} (got #{got_ids.length}, ref #{ref_ids.length})"
60
- end
73
+ expected_raw = row.fetch("hf_raw_token_ids")
74
+ got_raw = model.tokenize(text)
75
+ if got_raw != expected_raw
76
+ raw_failures << i
77
+ first = got_raw.zip(expected_raw).index { |a, b| a != b } || [got_raw.length, expected_raw.length].min
78
+ problems << "raw ids differ at #{first} (got #{got_raw.length}, ref #{expected_raw.length})"
61
79
  end
62
80
 
63
- got = model.embed(text).unpack("e*")
64
- max_abs = ref.zip(got).map { |a, b| (a - b).abs }.max || 0.0
65
- ref_zero = ref.all?(&:zero?)
66
- got_zero = got.all?(&:zero?)
67
- cosine =
68
- if ref_zero && got_zero
69
- 1.0
70
- elsif ref_zero || got_zero
71
- 0.0
72
- else
73
- dot(ref, got) / (norm(ref) * norm(got))
74
- end
81
+ full_raw = model.tokenize(text, max_tokens: false)
82
+ expected_usable = row.fetch("static_usable_token_ids")
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
88
+ if got_usable != expected_usable
89
+ usable_failures << i
90
+ first = got_usable.zip(expected_usable).index { |a, b| a != b } || [got_usable.length, expected_usable.length].min
91
+ problems << "usable ids differ at #{first} (got #{got_usable.length}, ref #{expected_usable.length})"
92
+ end
75
93
 
76
- min_cosine = [min_cosine, cosine].min
77
- max_abs_all = [max_abs_all, max_abs].max
94
+ got_vector_blob = model.embed(text)
95
+ pooled_blob = model.embed_token_ids(full_raw)
96
+ unless got_vector_blob == pooled_blob
97
+ invariant_failures << i
98
+ problems << "embed(text) != embed_token_ids(unbounded tokenize(text))"
99
+ end
78
100
 
79
- unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
80
- vector_failures << i
81
- problems << "vector out of tolerance"
101
+ model2vec_ids = row.fetch("model2vec_token_ids")
102
+ declared_deviation = row.fetch("model2vec_character_pretruncate_changes_ids")
103
+ actual_deviation = model2vec_ids != expected_usable
104
+ if actual_deviation != declared_deviation
105
+ problems << "oracle character-pretruncate flag is inconsistent"
106
+ usable_failures << i unless usable_failures.include?(i)
107
+ elsif actual_deviation
108
+ intentional_deviations << i
109
+ else
110
+ reference_vector = row.fetch("model2vec_vector")
111
+ got_vector = got_vector_blob.unpack("e*")
112
+ cosine, max_abs = vector_metrics(reference_vector, got_vector)
113
+ vectors_checked += 1
114
+ min_cosine = [min_cosine, cosine].min
115
+ max_abs_all = [max_abs_all, max_abs].max
116
+ unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
117
+ vector_failures << i
118
+ problems << format("vector out of tolerance cos=%.10f max_abs=%.8g", cosine, max_abs)
119
+ end
82
120
  end
83
121
 
84
122
  status = problems.empty? ? "ok" : "FAIL"
85
- label = text.bytesize > 64 ? "#{text[0, 32].inspect}...(#{text.bytesize}B)" : text.inspect
86
- line = format("%s idx=%02d cos=%.10f max_abs=%.8g bytes=%d text=%s",
87
- status, i, cosine, max_abs, text.bytesize, label)
88
- line += " [#{problems.join('; ')}]" unless problems.empty?
89
- puts line
123
+ suffix = actual_deviation ? " intentional-character-pretruncate-deviation" : ""
124
+ puts "#{status} idx=#{format('%03d', i)} label=#{label.inspect} bytes=#{text.bytesize}#{suffix}" +
125
+ (problems.empty? ? "" : " [#{problems.join('; ')}]")
90
126
  end
91
127
 
92
128
  puts "rows=#{rows.length}"
93
- puts "id_rows_checked=#{id_rows_checked}"
94
- puts "min_cosine=#{min_cosine}"
95
- puts "max_abs_all=#{max_abs_all}"
96
- puts "token_id_failures=#{id_failures.inspect}"
129
+ puts "vectors_checked=#{vectors_checked}"
130
+ puts "intentional_character_pretruncate_deviations=#{intentional_deviations.length}"
131
+ puts "min_cosine=#{min_cosine}" if vectors_checked.positive?
132
+ puts "max_abs_all=#{max_abs_all}" if vectors_checked.positive?
133
+ puts "raw_token_id_failures=#{raw_failures.inspect}"
134
+ puts "usable_token_id_failures=#{usable_failures.inspect}"
135
+ puts "embed_invariant_failures=#{invariant_failures.inspect}"
97
136
  puts "vector_failures=#{vector_failures.inspect}"
98
137
 
99
- if id_rows_checked.zero? && options[:ids]
100
- warn "WARNING oracle has no token_ids; regenerate it with the current tools/model2vec_oracle.py"
101
- end
102
-
103
- unless id_failures.empty? && vector_failures.empty?
104
- abort "parity failed: token ids #{id_failures.inspect}, vectors #{vector_failures.inspect}"
138
+ unless raw_failures.empty? && usable_failures.empty? && invariant_failures.empty? && vector_failures.empty?
139
+ abort "parity failed"
105
140
  end
106
141
 
107
- puts "parity OK"
142
+ puts "corpus parity OK (#{rows.length}/#{rows.length}); intentional Model2Vec character pre-truncation deviations are reported separately"