static_embeddings 0.1.1 → 0.1.3

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