static_embeddings 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a6d1a316162408c80a3de7afce3e1bc1f6e7cce0707c58df8737c86e0748183
4
+ data.tar.gz: c6e117117e09740437da333ec7203b62695b53233d5f64caa8cd15cc16f82d7c
5
+ SHA512:
6
+ metadata.gz: 274b82bff6ffbe3f650687b9e30a611c919ffeedea859d98864857d095100f0142f3ed56954b9420eddf17ab9aac1d879dcf7648539af9a852c4d4a08df150c3
7
+ data.tar.gz: e9f11243e8e73ea99ab215620fa024b7cf78ccae8c5e9b2bb1d06dd88e60862fadaa37b8d2fb56b2251740a474c5d556f262e827b35ec898b1dfeadac938fc3d
data/CHANGELOG.md ADDED
@@ -0,0 +1,136 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 (unreleased)
4
+
5
+ Safety and correctness release. Everything here was found by re-reviewing 0.1.0
6
+ against a built extension rather than against the source, and every item has a
7
+ regression test that fails on 0.1.0.
8
+
9
+ ### Fixed
10
+
11
+ - **Concurrent `top_k` on a shared matrix (blocker).** 0.1.0 wrapped the matrix
12
+ in `rb_str_locktmp` before releasing the GVL. That is an exclusive lock, so a
13
+ second thread searching the same corpus got
14
+ `RuntimeError: temporal locking already locked string` - 900 failures out of
15
+ 1200 calls with four threads over a 51 MB matrix. The lock is gone. A matrix
16
+ at or above 1 MiB must now be `frozen`, which makes mutation impossible and
17
+ lock-free concurrent scanning safe. A large unfrozen matrix raises
18
+ `ArgumentError`; `allow_unfrozen: true` scans it while holding the GVL.
19
+ - **`embed_batch` read the caller's Array after releasing the GVL (blocker).**
20
+ Elements were validated once and then re-read on every round, so another
21
+ thread replacing an element with a non-String turned a later `RSTRING_LEN`
22
+ into undefined behaviour - a reproducible `[BUG] Segmentation fault` from
23
+ ordinary Ruby. Input is now snapshotted into a private Array before any GVL
24
+ release, and the caller's Array is never read again.
25
+ - **Silent `f32`/`f16` mixing.** An `f32` query against an `f16` matrix used to
26
+ divide out to a consistent row count and return plausible garbage.
27
+ `StaticEmbeddings.cosine_top_k` / `dot_top_k` now require `dim:` and check the
28
+ query length against it. `Model#cosine_top_k` / `Model#dot_top_k` supply
29
+ `dim:` automatically and are the recommended form.
30
+ - **Prefix cutting only worked on ASCII.** The cut had to land on an ASCII
31
+ whitespace or punctuation byte, so CJK and NBSP-separated documents grew the
32
+ budget to the full length and copied the whole input under the GVL - cost
33
+ scaled linearly with input size again. `se_prefix_boundary_len` now uses the
34
+ tokenizer's own predicates: ASCII and Unicode whitespace, ASCII and Unicode
35
+ punctuation, and either side of a CJK codepoint, skipping any codepoint the
36
+ normaliser rewrites. A 24 MB CJK document went from 2.24 ms to 0.10 ms and is
37
+ byte-identical to `embed_token_ids(tokenize(text))`.
38
+ - A large unaligned matrix is rejected instead of being silently copied in full
39
+ while holding the GVL.
40
+ - **`f16` top-k was scalar and unusably slow on large corpora.** Half-precision
41
+ rows are now decoded with AArch64 `FCVTL` / x86 `VCVTPH2PS` kernels when
42
+ available, and with a lookup-table fallback otherwise. Measured on x86 with
43
+ F16C at dim 512 over 20k rows: 55 -> 978 ops/s, which is also 1.8x the `f32`
44
+ scan since it moves half the bytes. The fallback alone is 220 ops/s.
45
+ - The AArch64 kernel was gated on `__ARM_FEATURE_FP16_VECTOR_ARITHMETIC`, which
46
+ is only defined with an explicit `-mcpu`. Default builds - including
47
+ `rake compile` on Apple silicon - therefore fell back to the lookup table.
48
+ `vcvt_f32_f16` is baseline AArch64, so the gate is now just `__aarch64__`.
49
+ - The half-precision lookup table (256 KB) is populated only when the fallback
50
+ kernel is selected; native builds never touch those pages.
51
+ - `cosine_top_k` scored a row containing `NaN` as `0.0`, making a corrupt row
52
+ indistinguishable from a zero-norm one, while `dot_top_k` dropped it. Both
53
+ now drop it.
54
+ - `cold_start` reported `cold_page_cache=likely` from a ratio that cannot tell a
55
+ page-cache read from a device read - it said "cold" while faulting 132 MB in
56
+ 4.3 ms. It now reports `warmup_mapped_bytes_per_sec` and
57
+ `page_cache_state=unknown` with instructions for a real cold measurement.
58
+ - The valgrind job no longer passes `--undef-value-errors=no`. The harness is
59
+ pure C with no Ruby noise, so the flag only hid the class of bug most likely
60
+ in a parser reading an mmapped file.
61
+
62
+ ### Added
63
+
64
+ - `StaticEmbeddings.encode_f16` / `decode_f16`: the storage codec that backs
65
+ `format: :f16`, exposed directly and covered by table tests for zero, negative
66
+ zero, subnormals, the smallest normal, the largest finite half, overflow to
67
+ infinity, mantissa carry and NaN. `Ruby`'s duplicate half decoder in `unpack`
68
+ was deleted; there is now one implementation.
69
+ - `StaticEmbeddings.pack`, the inverse of `unpack`.
70
+ - `StaticEmbeddings.simd_backend`, reporting the live `f16` kernel.
71
+ - `Model#cosine_top_k`, `Model#dot_top_k`.
72
+ - `test/top_k_contract_test.rb`, `test/input_snapshot_test.rb`,
73
+ `test/f16_codec_test.rb`, and Unicode-boundary cases in
74
+ `test/prefix_chunking_test.rb`.
75
+
76
+ ### Changed
77
+
78
+ - `se_scratch_reserve` no longer takes an `input_bytes` argument it ignored.
79
+ - README documents the frozen-matrix contract, the required `dim:`, the
80
+ non-ASCII prefix fallback, `verify: true` at artifact-entry time, the
81
+ `embed_batch` peak-memory factor of two, and that `load_builtin` is a
82
+ checkout-only fixture.
83
+
84
+ ## 0.1.0
85
+
86
+ First working cut of the runtime and the offline converter.
87
+
88
+ - `.semb` v2 container: 320-byte header, 64-byte aligned sections, SHA-256
89
+ over the file with the checksum field zeroed, mandatory provenance section.
90
+ - C runtime: mmap loader with full bounds validation, read-only
91
+ open-addressing vocabulary hash table straight out of the mapping,
92
+ `BERT_WORDPIECE_V1` tokenizer, f32 mean pooling with optional L2
93
+ normalisation, binary vector output with `format: :f32` by default and compact
94
+ `format: :f16` storage for single embeddings, batches, and token-id pooling.
95
+ - Model semantics (`max_tokens`, unk policy, empty policy, normalizer flags)
96
+ are header fields, not hardcoded assumptions. Default truncation is 512
97
+ tokens, matching `model2vec.StaticModel` rather than `model_max_length`.
98
+ - Unicode tables (NFD, simple lowercase, Mn/P/C/Zs ranges) are generated by
99
+ the converter and shipped inside the model file. No utf8proc, no vendored
100
+ dependency of any kind.
101
+ - Concurrency: GVL released for batches above 2 KB, real-thread hand-off when
102
+ a `Fiber::Scheduler` is installed; internal `threads:` fan-out is intentionally rejected.
103
+ - Large inputs are not copied whole. When truncation is active `embed` and
104
+ `embed_batch` copy a leading slice sized from `max_tokens`, cut on a word
105
+ boundary, and grow it only if that slice did not reach `max_tokens`. Cost per
106
+ call stops scaling with input size (3 MB behaves like 10 KB).
107
+ - `cosine_top_k` returns real cosine similarity; `dot_top_k` returns the raw dot
108
+ product. They are separate kernels, not aliases. Both skip `NaN` rows and
109
+ accept `format: :f16` when query and matrix blobs are half-precision encoded.
110
+ - `embed_token_ids` / `embed_token_ids_with_stats` pool a caller-supplied id
111
+ array, honouring `max_tokens` truncation.
112
+ - Strict fail-closed converter: refuses non-WordPiece models, unknown
113
+ normalizer or pre-tokenizer types, non-standard `added_tokens`, and any
114
+ vocabulary/matrix size mismatch.
115
+ - CLI: `convert`, `verify`, `inspect`, `tokenize`, `embed`, `cache-path`.
116
+ - Tiny demo model is generated by `bundle exec rake demo_model` for smoke tests.
117
+ - Test suite runs offline against a synthetic source model: format fuzzing over
118
+ the whole file, tokenizer parity and fuzzing against a pure-Ruby reference,
119
+ vector tolerance, converter determinism, batch/thread determinism, GVL
120
+ release, and the large-input prefix path (including a sweep over every
121
+ printable ASCII separator).
122
+ - `tools/model2vec_oracle.py` and `tools/check_model2vec_parity.rb` compare
123
+ token ids and vectors against Python `model2vec.StaticModel`. Run via
124
+ `rake parity`.
125
+
126
+ ### Known gaps
127
+
128
+ - `format: :f16` is a storage encoding, not a new model. The runtime computes in
129
+ float32, converts the returned blob to IEEE float16, and decodes half-precision
130
+ top-k inputs on the fly. Use `format: :f32` when exact float32 parity is needed.
131
+
132
+ ### Not in this release
133
+
134
+ - int8 weights (the format has the slot, the runtime has no path)
135
+ - SIMD distance kernels
136
+ - SentencePiece / Unigram tokenizers
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Roman Haydarov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,439 @@
1
+ # static_embeddings
2
+
3
+ `static_embeddings` is a Ruby runtime for converted Model2Vec / potion static embedding models.
4
+ The current production target is `minishlab/potion-retrieval-32M` converted once into a local `.semb` file and loaded through a small C extension.
5
+
6
+ Runtime goals:
7
+
8
+ - no ONNX Runtime;
9
+ - no Rust or Python dependency at runtime;
10
+ - no runtime network access;
11
+ - no HuggingFace parsing in the hot path;
12
+ - one mmap-able model file;
13
+ - binary float32 output suitable for storing or passing to a vector index.
14
+
15
+ ```ruby
16
+ require "static_embeddings"
17
+
18
+ model = StaticEmbeddings.load_model("potion-retrieval-32m")
19
+
20
+ blob = model.embed("postgres pipeline mode in Ruby")
21
+ blob.bytesize == model.dim * 4
22
+
23
+ batch = model.embed_batch([
24
+ "postgres pipeline mode in Ruby",
25
+ "local static embeddings without ONNX Runtime"
26
+ ])
27
+
28
+ batch.bytesize == 2 * model.dim * 4
29
+ ```
30
+
31
+ ## Runtime contract
32
+
33
+ The runtime does not load arbitrary HuggingFace models. It loads only `.semb` files produced by this repository's converter.
34
+
35
+ A `.semb` file contains:
36
+
37
+ - validated model metadata;
38
+ - a BERT WordPiece tokenizer profile;
39
+ - a mmap-ready vocabulary lookup table;
40
+ - float32 embedding rows;
41
+ - provenance and checksum data;
42
+ - the Model2Vec inference decisions needed by the C runtime.
43
+
44
+ The first supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter must reject unsupported tokenizer features instead of approximating them.
45
+
46
+ ## Current model workflow
47
+
48
+ The expected workflow for `potion-retrieval-32M` is explicit.
49
+
50
+ ```bash
51
+ git lfs install
52
+ git clone https://huggingface.co/minishlab/potion-retrieval-32M
53
+
54
+ bundle exec rake compile
55
+
56
+ bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
57
+ --id potion-retrieval-32m
58
+ ```
59
+
60
+ The converted model is written to:
61
+
62
+ ```text
63
+ ~/.cache/static_embeddings/models/potion-retrieval-32m.semb
64
+ ```
65
+
66
+ Inspect and verify it:
67
+
68
+ ```bash
69
+ bundle exec ruby -Ilib exe/static_embeddings inspect \
70
+ ~/.cache/static_embeddings/models/potion-retrieval-32m.semb
71
+
72
+ bundle exec ruby -Ilib exe/static_embeddings verify \
73
+ ~/.cache/static_embeddings/models/potion-retrieval-32m.semb
74
+ ```
75
+
76
+ Load it from Ruby:
77
+
78
+ ```ruby
79
+ model = StaticEmbeddings.load_model("potion-retrieval-32m")
80
+ ```
81
+
82
+ Or load the file directly:
83
+
84
+ ```ruby
85
+ model = StaticEmbeddings.load(
86
+ File.expand_path("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
87
+ )
88
+ ```
89
+
90
+ `load` does not verify the SHA-256 by default: hashing a 135 MB file on every
91
+ boot would undo the point of mmapping it lazily. Structural validation always
92
+ runs, so a malformed container is rejected, but a bit flip inside the matrix is
93
+ not detected and silently changes the vectors. Verify once where the artifact
94
+ enters your system - during the image build, in CI, or right after conversion -
95
+ and load without verification at runtime:
96
+
97
+ ```ruby
98
+ StaticEmbeddings.load(path, verify: true) # image build / CI
99
+ StaticEmbeddings.load(path) # hot path, artifact already trusted
100
+ ```
101
+
102
+ ## Demo model
103
+
104
+ A tiny demo model can be generated for smoke tests without network access or external model files.
105
+
106
+ ```bash
107
+ bundle exec rake demo_model
108
+ ```
109
+
110
+ ```ruby
111
+ model = StaticEmbeddings.load_builtin
112
+ ```
113
+
114
+ `load_builtin` only works inside a checkout after `rake demo_model` has run. The
115
+ demo model is a build artifact, it is not shipped in the published gem, and
116
+ `StaticEmbeddings.builtin_available?` returns `false` when it is missing. Treat
117
+ it only as a test fixture and API demo. Do not use it as a retrieval quality
118
+ baseline.
119
+
120
+ ## API
121
+
122
+ ```ruby
123
+ model.path
124
+ model.dim
125
+ model.vocab_size
126
+ model.max_tokens
127
+ model.normalized?
128
+ model.lowercase?
129
+ model.unk_id
130
+ model.mapped_bytes
131
+ model.provenance
132
+ model.model_id
133
+
134
+ vector_blob = model.embed("postgres pipeline mode in Ruby") # f32 by default
135
+ small_blob = model.embed("postgres pipeline mode in Ruby", format: :f16) # half-size storage
136
+ array = model.embed_array("postgres pipeline mode in Ruby")
137
+
138
+ batch_blob = model.embed_batch(texts)
139
+ small_batch = model.embed_batch(texts, format: :f16)
140
+ arrays = model.embed_batch_arrays(texts)
141
+
142
+ stats = model.embed_with_stats("postgres pipeline mode in Ruby")
143
+ stats[:vector]
144
+ stats[:token_count]
145
+ stats[:unk_count]
146
+ stats[:truncated]
147
+
148
+ ids = model.tokenize("postgres pipeline mode in Ruby")
149
+
150
+ vector_blob = model.embed_token_ids(ids)
151
+ stats = model.embed_token_ids_with_stats(ids)
152
+
153
+ model.cosine_top_k(query_blob, matrix_blob, 10)
154
+ model.dot_top_k(query_blob, matrix_blob, 10)
155
+
156
+ StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, 10, dim: model.dim)
157
+ StaticEmbeddings.cosine_top_k(query_f16, matrix_f16, 10, dim: model.dim, format: :f16)
158
+
159
+ StaticEmbeddings.pack(rows, format: :f16) # Array(s) of Float -> blob
160
+ StaticEmbeddings.unpack(blob, model.dim) # blob -> Array of Array(Float)
161
+ ```
162
+
163
+ Default embedding output is a binary `String` containing little-endian float32 values in row-major order.
164
+ Pass `format: :f16` to `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
165
+ or `embed_token_ids_with_stats` when you want IEEE float16 storage instead. For a
166
+ 512-dimensional model this changes one vector from `512 * 4 = 2048` bytes to
167
+ `512 * 2 = 1024` bytes. The model still computes in float32; `format:` only
168
+ controls the returned storage encoding.
169
+
170
+ `embed_array` and `embed_batch_arrays` are convenience methods and allocate Ruby `Float` objects.
171
+ They accept the same `format:` option and decode the returned storage format for debugging or application code.
172
+
173
+ `f16` top-k scans decode half rows with native conversion where the CPU has it:
174
+ `FCVTL` on AArch64 (baseline, no `-mcpu` needed) and `VCVTPH2PS` on x86 when
175
+ CPUID reports F16C with OS support for AVX state. Everything else falls back to
176
+ a 65,536-entry lookup table, which is populated only when that fallback is
177
+ actually selected.
178
+
179
+ `StaticEmbeddings.simd_backend` reports which kernel is live and returns
180
+ `"neon-fp16"`, `"f16c"` or `"lut"`. Check it before drawing conclusions from an
181
+ `f16` benchmark - the difference between the native and fallback kernels is
182
+ several-fold.
183
+
184
+ With a native kernel `f16` search can be faster on memory-bound corpora, but it
185
+ is not guaranteed: half rows still have to be decoded before scoring. Treat
186
+ `f16` primarily as a storage/RAM trade-off and benchmark on the target CPU. On
187
+ the lookup-table fallback it is usually slower than `f32`.
188
+
189
+ `embed_token_ids` skips the tokenizer and pools the rows you give it. It applies
190
+ the same `max_tokens` truncation as `embed`, so `embed_token_ids(model.tokenize(text))`
191
+ equals `embed(text)`. Pass `max_tokens: false` to pool every id. It exists for
192
+ debugging, for reusing a cached tokenization, and for benchmarking the pooling
193
+ loop in isolation; it is not a faster path for ordinary text.
194
+
195
+ ### Similarity helpers
196
+
197
+ `cosine_top_k` divides by both vector norms and returns cosine similarity in
198
+ `[-1, 1]`. It raises `ArgumentError` on a zero-norm query and scores zero-norm
199
+ rows as `0.0`.
200
+
201
+ `dot_top_k` returns the raw dot product with no normalization. Use it when your
202
+ vectors are already unit length, including output from a model whose
203
+ `normalized?` is `true`, because then the two agree and the dot product is
204
+ cheaper. Use `cosine_top_k` in every other case.
205
+
206
+ Both skip rows whose score is `NaN` rather than letting them into the result.
207
+ That includes a row that itself contains `NaN`: `cosine_top_k` returns `0.0`
208
+ only for a genuinely zero-norm row, so a corrupt row is never reported as if it
209
+ were merely empty.
210
+
211
+ ```ruby
212
+ if model.normalized?
213
+ model.dot_top_k(query_blob, matrix_blob, 10)
214
+ else
215
+ model.cosine_top_k(query_blob, matrix_blob, 10)
216
+ end
217
+ ```
218
+
219
+ #### `dim:` is required on the module-level form
220
+
221
+ An embedding blob is bare bytes. It carries no dimension and no format tag, so
222
+ a query and a matrix stored in different formats can divide out to a consistent
223
+ row count and produce plausible nonsense. The module-level helpers therefore
224
+ require `dim:` and check the query against it:
225
+
226
+ ```ruby
227
+ StaticEmbeddings.cosine_top_k(query_f32, matrix_f16, 10, dim: 512, format: :f16)
228
+ # ArgumentError: query is 2048 bytes but dim: 512 with format :f16 needs 1024 bytes
229
+ ```
230
+
231
+ `model.cosine_top_k` / `model.dot_top_k` fill `dim:` in from the model and are
232
+ the recommended form.
233
+
234
+ #### The matrix must be frozen
235
+
236
+ A matrix at or above 1 MiB is scanned with the GVL released, so other Ruby
237
+ threads keep running while the scan is in flight. Nothing stops one of them from
238
+ reallocating the blob mid-scan, and copying a corpus-sized matrix on every query
239
+ would defeat the point of the API. A frozen `String` cannot be mutated at all,
240
+ so freezing is the contract:
241
+
242
+ ```ruby
243
+ MATRIX = model.embed_batch(corpus).freeze
244
+
245
+ model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL released
246
+ ```
247
+
248
+ Passing a large unfrozen matrix raises `ArgumentError`. If you cannot freeze it,
249
+ `allow_unfrozen: true` scans it while holding the GVL: correct, but it blocks
250
+ every other thread in the process for the duration of the scan.
251
+
252
+ A large matrix must also be 4-byte aligned when `format: :f32`. A blob produced
253
+ by `pack`, `embed_batch` or `File.binread` always is; a `byteslice` at an odd
254
+ offset may not be, and is rejected rather than silently copied.
255
+
256
+ ## Language safety
257
+
258
+ `potion-retrieval-32M` is an English retrieval model. Russian text can produce a high `[UNK]` ratio while still returning a valid vector.
259
+
260
+ Check unknown-token pressure before trusting a corpus:
261
+
262
+ ```ruby
263
+ stats = model.embed_with_stats(text)
264
+ ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
265
+
266
+ warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
267
+ ```
268
+
269
+ Use `tokenize` when vector quality looks wrong:
270
+
271
+ ```bash
272
+ bundle exec ruby -Ilib exe/static_embeddings tokenize \
273
+ ~/.cache/static_embeddings/models/potion-retrieval-32m.semb \
274
+ "postgres pipeline mode in Ruby"
275
+ ```
276
+
277
+ ## Correctness contract
278
+
279
+ The reference implementation is `model2vec.StaticModel`.
280
+
281
+ The converter records these decisions in the `.semb` file:
282
+
283
+ - default truncation is 512 tokens;
284
+ - truncation happens after tokenization and before pooling;
285
+ - `[UNK]` tokens are dropped;
286
+ - input with no usable tokens returns a zero vector;
287
+ - vectors are L2-normalized when the source model requires it.
288
+
289
+ `docs/MODEL_AUDIT.md` records the source model revision, file digests, tokenizer facts, added-token audit, edge-case behavior and reference parity result for every trusted converted model. The audited `potion-retrieval-32m` conversion is recorded there with `.semb` bytes, SHA256, token-id parity and vector parity. New conversions must add their own parity record before they are treated as trusted.
290
+
291
+ Run the parity check with:
292
+
293
+ ```bash
294
+ python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
295
+ pip install -U model2vec numpy
296
+ python tools/model2vec_oracle.py minishlab/potion-retrieval-32M --out tmp/model2vec_oracle.json
297
+ bundle exec rake parity \
298
+ MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
299
+ ORACLE=tmp/model2vec_oracle.json
300
+ ```
301
+
302
+ The oracle records token ids as well as vectors, and includes inputs on both sides of the large-input prefix window.
303
+
304
+ Token ids must match the reference exactly. Float vectors are checked with tolerance because floating-point addition order is not bit-stable across implementations.
305
+
306
+ Accepted vector tolerance:
307
+
308
+ ```text
309
+ cosine >= 1 - 1e-6
310
+ max_abs_diff < 1e-5
311
+ ```
312
+
313
+ ## Large inputs
314
+
315
+ Ruby strings have to be copied into C-owned memory before the GVL can be
316
+ released, and that copy is charged to the calling thread while it still holds
317
+ the GVL. When truncation is active the runtime therefore does not copy the whole
318
+ string: it copies a leading slice sized from `max_tokens`, cut immediately after
319
+ an ASCII byte that ends a word, and only accepts the result once that slice has
320
+ actually reached `max_tokens`. If it has not, the budget doubles and the text is
321
+ tokenized again.
322
+
323
+ The result is identical to tokenizing the whole document — WordPiece
324
+ segmentation is word-local, and the cut is always on both a codepoint and a word
325
+ boundary — but the cost of `embed` stops growing with input size:
326
+
327
+ ```text
328
+ 10 KB 43-47 us
329
+ 100 KB 43-47 us
330
+ 1 MB 43-47 us
331
+ 3 MB 43-47 us
332
+ ```
333
+
334
+ A cut is legal where the tokenizer would have started a fresh word: after
335
+ whitespace, after punctuation, and on either side of a CJK codepoint, using the
336
+ model's own Unicode tables rather than an ASCII-only rule. Text with no legal
337
+ cut anywhere in the scan window - one enormous word, or a run of combining marks
338
+ - falls back to copying the whole input, which is the only correct thing to do
339
+ there.
340
+
341
+ `embed_batch` applies the same strategy per text. With `max_tokens: false` there
342
+ is nothing to truncate, so the whole input is copied and processed.
343
+
344
+ `embed_batch` builds its result in C memory and then copies it into a Ruby
345
+ `String`, so peak memory for one call is about twice the size of the returned
346
+ blob. Split very large corpora into chunks if that matters.
347
+
348
+ ## Concurrency
349
+
350
+ `embed_batch` copies Ruby input into C-owned memory, then releases the GVL for large C computations. That is the intended web-runtime behaviour: one request can run a CPU-bound embedding batch while other Puma threads in the same process continue serving work.
351
+
352
+ The runtime does not expose internal parallelism through `threads:`. For offline indexing, split work at the application/job level and run multiple Ruby workers explicitly.
353
+
354
+ Do not mutate an Array while it is being embedded. `embed_batch` snapshots the
355
+ elements it was given before it releases the GVL, so a concurrent mutation
356
+ cannot corrupt memory, but the call embeds the snapshot and ignores anything
357
+ that happened afterwards.
358
+
359
+ `cosine_top_k` and `dot_top_k` scan a frozen matrix without any lock, so any
360
+ number of threads can search one shared corpus at the same time. See the
361
+ similarity helpers section for the frozen-matrix contract.
362
+
363
+ When a `Fiber::Scheduler` is installed and the current fiber is non-blocking,
364
+ work above 2 KB is handed to a real thread that the fiber joins, so the
365
+ scheduler keeps running other fibers. Note that this creates one OS thread per
366
+ call: under high concurrency prefer batching over many small `embed` calls.
367
+
368
+ For Puma with `preload_app!`, load and warm the model before workers fork:
369
+
370
+ ```ruby
371
+ preload_app!
372
+
373
+ before_fork do
374
+ MODEL = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
375
+ MODEL.warmup!
376
+ end
377
+ ```
378
+
379
+ ## Docker / production
380
+
381
+ Convert models during image build or deploy preparation. Production runtime should only see `.semb` files.
382
+
383
+ ```dockerfile
384
+ RUN bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
385
+ --id potion-retrieval-32m
386
+ ```
387
+
388
+ Then set the path explicitly:
389
+
390
+ ```bash
391
+ export EMBEDDING_MODEL=/root/.cache/static_embeddings/models/potion-retrieval-32m.semb
392
+ ```
393
+
394
+ ```ruby
395
+ model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
396
+ ```
397
+
398
+ ## Development
399
+
400
+ ```bash
401
+ bundle install
402
+ bundle exec rake clean compile
403
+ bundle exec rake test
404
+ ```
405
+
406
+ Build the synthetic fixture model:
407
+
408
+ ```bash
409
+ ruby tools/make_fixture_model.rb
410
+ ```
411
+
412
+ Run the benchmark:
413
+
414
+ ```bash
415
+ ruby tools/benchmark.rb
416
+ ```
417
+
418
+ Run the C memory smoke binary locally after building a fixture:
419
+
420
+ ```bash
421
+ ruby tools/make_fixture_model.rb
422
+ ruby -Ilib -e 'require "static_embeddings"; StaticEmbeddings.convert("test/fixtures/tiny-wordpiece", output_path: "tmp/test-tiny.semb", model_id: "fixture/tiny-wordpiece")'
423
+ cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
424
+ tools/memory_smoke.c \
425
+ ext/static_embeddings/se_format.c \
426
+ ext/static_embeddings/se_unicode.c \
427
+ ext/static_embeddings/se_tokenizer.c \
428
+ ext/static_embeddings/se_embed.c \
429
+ -lm -o tmp/memory_smoke
430
+ ./tmp/memory_smoke tmp/test-tiny.semb
431
+ ```
432
+
433
+ ## Notes
434
+
435
+ The runtime is intentionally narrow. If a model requires unsupported tokenizer behavior, fix the converter whitelist and audit first; do not make the C runtime guess.
436
+
437
+ ## License
438
+
439
+ MIT. See `LICENSE.txt`.