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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/README.md +67 -25
- data/Rakefile +1 -1
- data/docs/ARCHITECTURE.md +54 -34
- data/docs/LIMITATIONS.md +12 -6
- data/docs/MODEL_AUDIT.md +195 -52
- data/ext/static_embeddings/se_embed.c +2 -1
- data/ext/static_embeddings/se_format.c +41 -4
- data/ext/static_embeddings/se_internal.h +17 -5
- data/ext/static_embeddings/se_tokenizer.c +155 -18
- data/ext/static_embeddings/se_unicode.c +1 -1
- data/ext/static_embeddings/static_embeddings.c +32 -2
- data/lib/models/demo.semb +0 -0
- data/lib/static_embeddings/bert_wordpiece.rb +191 -0
- data/lib/static_embeddings/canonical.rb +50 -0
- data/lib/static_embeddings/cli.rb +88 -62
- data/lib/static_embeddings/codec.rb +45 -0
- data/lib/static_embeddings/conversion.rb +58 -0
- data/lib/static_embeddings/errors.rb +2 -2
- data/lib/static_embeddings/format/constants.rb +109 -0
- data/lib/static_embeddings/format/hash_table.rb +69 -0
- data/lib/static_embeddings/format/trie.rb +78 -0
- data/lib/static_embeddings/format/verifier.rb +41 -0
- data/lib/static_embeddings/format/writer.rb +131 -0
- data/lib/static_embeddings/format.rb +3 -300
- data/lib/static_embeddings/importers/model2vec.rb +52 -0
- data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
- data/lib/static_embeddings/importers/support.rb +111 -0
- data/lib/static_embeddings/importers.rb +50 -0
- data/lib/static_embeddings/model.rb +35 -20
- data/lib/static_embeddings/paths.rb +17 -4
- data/lib/static_embeddings/provenance.rb +58 -0
- data/lib/static_embeddings/reference.rb +90 -33
- data/lib/static_embeddings/row_prefix_payload.rb +59 -0
- data/lib/static_embeddings/safetensors.rb +178 -34
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings.rb +29 -57
- data/static_embeddings.gemspec +2 -2
- data/tools/check_model2vec_parity.rb +89 -54
- data/tools/check_st_parity.rb +125 -0
- data/tools/eval_retrieval.rb +58 -0
- metadata +24 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
32
|
-
raise ConversionError, "safetensors file is shorter than its length prefix" if
|
|
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 =
|
|
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 >
|
|
146
|
+
raise ConversionError, "safetensors header runs past end of file" if body_offset > file_size
|
|
41
147
|
|
|
42
|
-
raw_header =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/static_embeddings.rb
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
unless
|
|
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
|
-
"
|
|
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:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
data/static_embeddings.gemspec
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
ref_ids = row["token_ids"]
|
|
50
|
-
|
|
70
|
+
label = row.fetch("label", i.to_s)
|
|
51
71
|
problems = []
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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 "
|
|
94
|
-
puts "
|
|
95
|
-
puts "
|
|
96
|
-
puts "
|
|
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
|
-
|
|
100
|
-
|
|
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"
|