static_embeddings 0.1.1 → 0.1.2

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.
data/README.md CHANGED
@@ -1,16 +1,11 @@
1
1
  # static_embeddings
2
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.
3
+ A Ruby runtime for converted Model2Vec / potion static embedding models. A model
4
+ is converted once into a local `.semb` file and loaded through a small C
5
+ extension. No ONNX Runtime, no Rust or Python at runtime, no network access, one
6
+ mmap-able file, binary float32 output.
5
7
 
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.
8
+ The current production target is `minishlab/potion-retrieval-32M`.
14
9
 
15
10
  ```ruby
16
11
  require "static_embeddings"
@@ -24,99 +19,57 @@ batch = model.embed_batch([
24
19
  "postgres pipeline mode in Ruby",
25
20
  "local static embeddings without ONNX Runtime"
26
21
  ])
27
-
28
- batch.bytesize == 2 * model.dim * 4
29
22
  ```
30
23
 
31
- ## Runtime contract
32
-
33
- The runtime does not load arbitrary HuggingFace models. It loads only `.semb` files produced by this repository's converter.
24
+ New to the gem? Start with `GET_STARTED.md`. Design rationale is in
25
+ `docs/ARCHITECTURE.md`.
34
26
 
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.
27
+ ## Runtime contract
43
28
 
44
- The first supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter must reject unsupported tokenizer features instead of approximating them.
29
+ The runtime loads only `.semb` files produced by this repository's converter,
30
+ never arbitrary HuggingFace models. A `.semb` file carries validated metadata, a
31
+ BERT WordPiece tokenizer profile, an mmap-ready vocabulary lookup, float32
32
+ embedding rows, and provenance plus a checksum.
45
33
 
46
- ## Current model workflow
34
+ The only supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter
35
+ rejects unsupported tokenizer features rather than approximating them.
47
36
 
48
- The expected workflow for `potion-retrieval-32M` is explicit.
37
+ ## Converting a model
49
38
 
50
39
  ```bash
51
40
  git lfs install
52
41
  git clone https://huggingface.co/minishlab/potion-retrieval-32M
53
42
 
54
43
  bundle exec rake compile
55
-
56
44
  bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
57
45
  --id potion-retrieval-32m
58
46
  ```
59
47
 
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:
48
+ The result lands in `~/.cache/static_embeddings/models/potion-retrieval-32m.semb`.
49
+ Inspect or verify it with the `inspect` and `verify` subcommands, then load it:
77
50
 
78
51
  ```ruby
79
52
  model = StaticEmbeddings.load_model("potion-retrieval-32m")
53
+ model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL")) # explicit path
80
54
  ```
81
55
 
82
- Or load the file directly:
56
+ Convert during image build or deploy preparation; production should only ever
57
+ see `.semb` files.
83
58
 
84
- ```ruby
85
- model = StaticEmbeddings.load(
86
- File.expand_path("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
87
- )
88
- ```
59
+ ### Verify once, not on every boot
89
60
 
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:
61
+ `load` does not check the SHA-256 by default: hashing a 135 MB file at boot
62
+ would undo the point of mmapping it lazily. `verify` streams the file, so it
63
+ costs one chunk of memory rather than a copy of the model. Structural validation always runs,
64
+ so a malformed container is rejected, but a bit flip inside the matrix is not
65
+ detected and silently changes vectors. Verify where the artifact enters your
66
+ system:
96
67
 
97
68
  ```ruby
98
69
  StaticEmbeddings.load(path, verify: true) # image build / CI
99
70
  StaticEmbeddings.load(path) # hot path, artifact already trusted
100
71
  ```
101
72
 
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
73
  ## API
121
74
 
122
75
  ```ruby
@@ -133,6 +86,7 @@ model.model_id
133
86
 
134
87
  vector_blob = model.embed("postgres pipeline mode in Ruby") # f32 by default
135
88
  small_blob = model.embed("postgres pipeline mode in Ruby", format: :f16) # half-size storage
89
+ bounded = model.embed(huge_text, validate_encoding: :prefix) # see "Large inputs"
136
90
  array = model.embed_array("postgres pipeline mode in Ruby")
137
91
 
138
92
  batch_blob = model.embed_batch(texts)
@@ -145,8 +99,7 @@ stats[:token_count]
145
99
  stats[:unk_count]
146
100
  stats[:truncated]
147
101
 
148
- ids = model.tokenize("postgres pipeline mode in Ruby")
149
-
102
+ ids = model.tokenize("postgres pipeline mode in Ruby")
150
103
  vector_blob = model.embed_token_ids(ids)
151
104
  stats = model.embed_token_ids_with_stats(ids)
152
105
 
@@ -154,218 +107,138 @@ model.cosine_top_k(query_blob, matrix_blob, 10)
154
107
  model.dot_top_k(query_blob, matrix_blob, 10)
155
108
 
156
109
  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
110
  StaticEmbeddings.pack(rows, format: :f16) # Array(s) of Float -> blob
160
111
  StaticEmbeddings.unpack(blob, model.dim) # blob -> Array of Array(Float)
161
112
  ```
162
113
 
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.
114
+ Output is a binary `String` of little-endian float32 values in row-major order.
115
+ `embed_array` and `embed_batch_arrays` decode that into Ruby `Float` objects and
116
+ exist for debugging and application code, not for the hot path.
194
117
 
195
- ### Similarity helpers
118
+ `embed_token_ids` pools ids you supply, applying the same `max_tokens`
119
+ truncation as `embed`, so `embed_token_ids(model.tokenize(text))` equals
120
+ `embed(text)`. Pass `max_tokens: false` to pool every id. It is for reusing a
121
+ cached tokenization and for benchmarking pooling in isolation; it is not a
122
+ faster path for ordinary text.
196
123
 
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`.
124
+ ### `format: :f16`
200
125
 
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.
126
+ `format:` selects the returned storage encoding only. The model always computes
127
+ in float32. For a 512-dimensional model one vector goes from 2048 to 1024 bytes.
128
+ It is accepted by `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
129
+ `embed_token_ids_with_stats`, `pack`, `unpack`, and the top-k helpers.
205
130
 
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
- ```
131
+ Choose `f16` for the RAM and storage it saves, not for speed. It halves the
132
+ bytes a top-k scan streams, but every row still has to be decoded before
133
+ scoring, and which effect wins is a property of the machine this repository's
134
+ own samples show `f16` winning on x86-64 F16C and losing on M1 Pro. See
135
+ `docs/PERFORMANCE.md` before assuming either.
230
136
 
231
- `model.cosine_top_k` / `model.dot_top_k` fill `dim:` in from the model and are
232
- the recommended form.
137
+ `StaticEmbeddings.simd_backend` reports the live kernel: `"neon-fp16"`,
138
+ `"f16c"`, or `"lut"` for the lookup-table fallback, which is several-fold
139
+ slower. Check it before drawing any conclusion from an `f16` benchmark.
233
140
 
234
- #### The matrix must be frozen
141
+ ### Similarity helpers
235
142
 
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:
143
+ `cosine_top_k` divides by both norms and returns similarity in `[-1, 1]`. It
144
+ raises `ArgumentError` on a zero-norm query and scores zero-norm rows as `0.0`.
145
+ `dot_top_k` returns the raw dot product; use it when your vectors are already
146
+ unit length, including output from a model whose `normalized?` is `true`.
147
+
148
+ Both skip rows scoring `NaN` rather than admitting them to the result, so a
149
+ corrupt row is never reported as if it were merely empty.
150
+
151
+ Three contracts, each of which raises rather than guessing:
152
+
153
+ - **`dim:` is required on the module-level form.** A blob is bare bytes with no
154
+ dimension and no format tag, so an f32 query against an f16 matrix would
155
+ otherwise divide out to a plausible row count and return nonsense.
156
+ `model.cosine_top_k` and `model.dot_top_k` fill `dim:` in and are the
157
+ recommended form.
158
+ - **A matrix at or above 1 MiB must be frozen.** It is scanned with the GVL
159
+ released so other threads keep running, and a frozen `String` cannot be
160
+ mutated mid-scan. `allow_unfrozen: true` scans while holding the GVL: correct,
161
+ but it blocks every other thread for the duration.
162
+ - **A large `format: :f32` matrix must be 4-byte aligned.** Anything from
163
+ `pack`, `embed_batch` or `File.binread` is; a `byteslice` at an odd offset may
164
+ not be.
241
165
 
242
166
  ```ruby
243
167
  MATRIX = model.embed_batch(corpus).freeze
244
-
245
168
  model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL released
246
169
  ```
247
170
 
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
171
+ ## Large inputs
257
172
 
258
- `potion-retrieval-32M` is an English retrieval model. Russian text can produce a high `[UNK]` ratio while still returning a valid vector.
173
+ When truncation is active the runtime does not copy the whole string into C
174
+ memory. It copies a leading slice sized from `max_tokens`, cut on a word
175
+ boundary, and grows the budget only if that slice did not reach `max_tokens`.
176
+ The result is identical to tokenizing the whole document, because WordPiece
177
+ segmentation is word-local, but the bytes the tokenizer reads stop growing with
178
+ input size: with `max_tokens: 512` the window is a few kilobytes whether the
179
+ document is 10 KB or 3 MB.
259
180
 
260
- Check unknown-token pressure before trusting a corpus:
181
+ That bounds the tokenizer, not the whole call. `embed` also has to establish
182
+ that the Ruby `String` is valid UTF-8, and when Ruby has not computed the
183
+ string's coderange yet that scan is O(total bytes) and happens before any
184
+ prefix is chosen:
261
185
 
262
186
  ```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
187
+ text = File.read(path) # coderange unknown
188
+ model.embed(text) # full UTF-8 scan, then a few KB of tokenizing
189
+ model.embed(text) # coderange cached: prefix window only
267
190
  ```
268
191
 
269
- Use `tokenize` when vector quality looks wrong:
192
+ Three ways out, in order of preference. Reuse the `String`, since Ruby caches
193
+ the coderange after the first scan. Force the scan once outside the hot path
194
+ with `text.valid_encoding?`. Or ask for validation to be bounded the same way
195
+ tokenizing is:
270
196
 
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
197
+ ```ruby
198
+ model.embed(text, validate_encoding: :prefix)
311
199
  ```
312
200
 
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
- ```
201
+ `:prefix` skips the up-front scan and lets the tokenizer validate the bytes it
202
+ actually reads; malformed UTF-8 inside the window still raises. On a 3 MB
203
+ document that turned 442 µs into 78 µs, against 71 µs for the same string with
204
+ its coderange already cached.
333
205
 
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.
206
+ The trade is real and worth stating: under `:prefix`, invalid bytes *past* the
207
+ truncation window are never looked at and no longer raise. `:full` is the
208
+ default and keeps the whole-string guarantee. A coderange Ruby has already
209
+ computed is honoured either way, so a `String` already known to be broken
210
+ raises in both modes, and one already known to be valid costs nothing in
211
+ either. The option is accepted by `embed`, `embed_batch`, `embed_with_stats`
212
+ and `tokenize`.
340
213
 
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.
214
+ Text with no legal cut anywhere in the scan window one enormous word, a run of
215
+ combining marks — falls back to copying the whole input. With
216
+ `max_tokens: false` there is nothing to truncate, so the whole input is copied.
217
+ `docs/ARCHITECTURE.md` explains what makes a cut legal.
343
218
 
344
219
  `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.
220
+ `String`, so peak memory for one call is about twice the returned blob. Chunk
221
+ very large corpora.
347
222
 
348
223
  ## Concurrency
349
224
 
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.
225
+ `embed_batch` snapshots its input, then releases the GVL for the C work, so one
226
+ request can compute embeddings while other Puma threads keep serving. Because
227
+ the snapshot is taken up front, mutating the Array afterwards does not corrupt
228
+ anything but is ignored.
358
229
 
359
230
  `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.
231
+ number of threads can search one shared corpus at once.
232
+
233
+ The runtime does not expose internal parallelism through `threads:`. For offline
234
+ indexing, split work at the job level and run multiple Ruby workers.
362
235
 
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.
236
+ With a `Fiber::Scheduler` installed and the current fiber non-blocking, work
237
+ above 2 KB is handed to a real thread the fiber joins, so the scheduler keeps
238
+ running other fibers. That is one OS thread per call: under high concurrency,
239
+ prefer batching over many small `embed` calls.
367
240
 
368
- For Puma with `preload_app!`, load and warm the model before workers fork:
241
+ For Puma with `preload_app!`, load and warm before workers fork:
369
242
 
370
243
  ```ruby
371
244
  preload_app!
@@ -376,52 +249,70 @@ before_fork do
376
249
  end
377
250
  ```
378
251
 
379
- ## Docker / production
252
+ ## Language safety
380
253
 
381
- Convert models during image build or deploy preparation. Production runtime should only see `.semb` files.
254
+ `potion-retrieval-32M` is an English retrieval model. Russian text can produce a
255
+ high `[UNK]` ratio while still returning a valid vector, which is the most
256
+ likely way to end up with a silently bad index. Check the ratio before trusting
257
+ a corpus:
382
258
 
383
- ```dockerfile
384
- RUN bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
385
- --id potion-retrieval-32m
259
+ ```ruby
260
+ stats = model.embed_with_stats(text)
261
+ ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
262
+
263
+ warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
386
264
  ```
387
265
 
388
- Then set the path explicitly:
266
+ Out-of-vocabulary text is also slower, because every word falls through to
267
+ subword splitting instead of hitting the vocabulary directly.
389
268
 
390
- ```bash
391
- export EMBEDDING_MODEL=/root/.cache/static_embeddings/models/potion-retrieval-32m.semb
392
- ```
269
+ ## Correctness contract
393
270
 
394
- ```ruby
395
- model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
396
- ```
271
+ The reference implementation is `model2vec.StaticModel`. The converter records
272
+ its decisions in the `.semb` file: truncation at 512 tokens by default, applied
273
+ after tokenization and before pooling; `[UNK]` tokens dropped; a zero vector for
274
+ input with no usable tokens; L2 normalization when the source model requires it.
397
275
 
398
- ## Development
276
+ Token ids must match the reference exactly. Vectors are compared with tolerance,
277
+ because floating-point addition order is not bit-stable across implementations:
399
278
 
400
- ```bash
401
- bundle install
402
- bundle exec rake clean compile
403
- bundle exec rake test
279
+ ```text
280
+ cosine >= 1 - 1e-6
281
+ max_abs_diff < 1e-5
404
282
  ```
405
283
 
406
- Build the synthetic fixture model:
284
+ `docs/MODEL_AUDIT.md` records the parity result, digests and edge-case
285
+ behaviour for every trusted conversion, and the command to reproduce it. A new
286
+ conversion is not trusted until it has its own record.
287
+
288
+ ## Development
407
289
 
408
290
  ```bash
409
- ruby tools/make_fixture_model.rb
291
+ bundle install
292
+ bundle exec rake # compile + fixtures + test
293
+ bundle exec rake demo_model # tiny synthetic model for smoke tests
410
294
  ```
411
295
 
412
- Run the benchmark:
296
+ `StaticEmbeddings.load_builtin` loads that demo model. It only works inside a
297
+ checkout, it is not shipped in the published gem, and
298
+ `StaticEmbeddings.builtin_available?` returns `false` when it is missing. It is
299
+ a test fixture and API demo, not a retrieval quality baseline.
413
300
 
414
301
  ```bash
415
- ruby tools/benchmark.rb
302
+ ruby tools/benchmark.rb # normalised performance budget
303
+ bundle exec rake cancellation_timing # timing-sensitive, excluded from rake test
304
+ samples/run_all.sh # native hot-path probes; see samples/README.md
416
305
  ```
417
306
 
418
- Run the C memory smoke binary locally after building a fixture:
307
+ The C memory smoke binary runs without Ruby:
419
308
 
420
309
  ```bash
421
310
  ruby tools/make_fixture_model.rb
422
311
  ruby -Ilib -e 'require "static_embeddings"; StaticEmbeddings.convert("test/fixtures/tiny-wordpiece", output_path: "tmp/test-tiny.semb", model_id: "fixture/tiny-wordpiece")'
423
312
  cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
424
313
  tools/memory_smoke.c \
314
+ ext/static_embeddings/se_f16.c \
315
+ ext/static_embeddings/se_topk.c \
425
316
  ext/static_embeddings/se_format.c \
426
317
  ext/static_embeddings/se_unicode.c \
427
318
  ext/static_embeddings/se_tokenizer.c \
@@ -430,9 +321,9 @@ cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
430
321
  ./tmp/memory_smoke tmp/test-tiny.semb
431
322
  ```
432
323
 
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.
324
+ The runtime is intentionally narrow. If a model needs unsupported tokenizer
325
+ behaviour, fix the converter whitelist and audit it; do not make the C runtime
326
+ guess.
436
327
 
437
328
  ## License
438
329