static_embeddings 0.1.3 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ec20ad222235414c2e6f081a2cea5f932a7e3004cb78dcca5552c20208bd6a8
4
- data.tar.gz: 80553b44c62b46625562accee74ac9bfe689400f05258e551dc9603105f64f7a
3
+ metadata.gz: fb1d3530fba4fad467251429797fdcaae527b5c6c18413e8847002e0c3f3b5e4
4
+ data.tar.gz: f02bb4c129449803a57b08bf9b7186af08a6d21edee1e68d18178b248b855e0b
5
5
  SHA512:
6
- metadata.gz: 7fc51c583e8d0f05e5e6b7181767450e4fc93601180a638444cb715be0321d993454003d93bd9bd9f7154e5d678b57dc0fdeba7d5f86c81154f0b61886898101
7
- data.tar.gz: 1739214fe214cbb86a81e643878a371547b36365d408f2e81e7e95c6c0f0b66c2e214c839f41744347c5015c82832f5a34410e585eb504dab4fe2d3a106ff459
6
+ metadata.gz: cec0a3959cb3f093c7bb2b06213b4c7e983986466a115eb79e0b045a5b52fdda9797be41365db41a74d85bb52c384c147d68d3d84c503fef10c122bba7b72333
7
+ data.tar.gz: e99039606af3de0106e1ac26efaa78a06f09acbd9c2faaae9b4ca17339bdeb4830ba922bef84dc141317898556d09b1ec41cf1ebfebcce5e141d877c3fc34598
data/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ Correctness/parity release. The native runtime architecture is unchanged, but
6
+ the tokenizer/pooling contract is now pinned explicitly to `model2vec 0.9.0` +
7
+ `tokenizers 0.23.1`, and CI contains an independent upstream boundary corpus
8
+ instead of treating the Ruby `Reference` twin as proof of compatibility.
9
+
10
+ ### Fixed
11
+
12
+ - **`[UNK]` is filtered before the embedding token cap.** `max_tokens: 512` now
13
+ means the first 512 usable (non-UNK, for `UNK_DROP`) ids, matching
14
+ `StaticModel` pooling semantics. Long OOV-heavy text can therefore scan past
15
+ 512 raw ids instead of silently averaging fewer rows. `embed_with_stats` and
16
+ `embed_token_ids_with_stats` expose `pooled_count` alongside raw
17
+ `token_count` / `unk_count`.
18
+ - **The supported Hugging Face AddedVocabulary subset is implemented.** The five
19
+ standard BERT special tokens (`[PAD]`, `[UNK]`, `[CLS]`, `[SEP]`, `[MASK]`)
20
+ are extracted literally before normalization when the source tokenizer marks
21
+ them `special: true, normalized: false`. Converter and runtime now share that
22
+ contract instead of merely whitelisting the metadata.
23
+ - **BertNormalizer behavior matches the pinned Rust tokenizer at the known edges.**
24
+ The CJK Extension E range starts at `U+2B920` as in `tokenizers 0.23.1`, not
25
+ the Python `transformers` boundary `U+2B820`. The boundary corpus also locks
26
+ the actual `unicode_categories` 0.1.1 behavior for unassigned (`Cn`)
27
+ codepoints: despite a misleading comment in upstream `bert.rs`, that crate's
28
+ `is_other()` implementation checks Cc/Cf/Co, not Cn.
29
+ - **Vocabulary hash lookup is bounded by the validated `max_probe`,** and model
30
+ loading rejects hash tables above the converter's 0.70 load factor.
31
+ - **`load_model` model ids cannot escape the cache** through absolute paths,
32
+ `..`, empty path components, backslash traversal or NUL bytes.
33
+
34
+ ### Changed
35
+
36
+ - **`.semb` format v3.** The header records the supported added-token mask and
37
+ the usable-id truncation policy. v2 files must be reconverted; this avoids a
38
+ runtime silently applying 0.1.5 semantics to a file whose tokenizer contract
39
+ was recorded by an older converter.
40
+ - **Model2Vec's character pre-truncation heuristic is intentionally not copied.**
41
+ `max_tokens` means actual usable tokenizer ids. The external oracle records
42
+ rows where `StaticModel`'s `max_length * median_token_length` character cut
43
+ changes ids and reports them as an explicit semantic deviation rather than a
44
+ false parity failure/pass.
45
+ - **The converter reads safetensors incrementally and accepts F32, F16 and BF16
46
+ embedding tensors.** `.semb` rows remain float32. `Format.write` also streams
47
+ sections and computes the checksum incrementally instead of assembling a
48
+ second whole-file Ruby string.
49
+ - **Plain `require "static_embeddings"` loads runtime code only.** Converter,
50
+ safetensors, Unicode-table generation and the Ruby `Reference` are loaded only
51
+ by offline tooling/tests. The `static_embeddings convert` CLI explicitly loads
52
+ the converter before reading converter-specific defaults, so lazy loading does
53
+ not break the documented executable path.
54
+ - **TLS token-id scratch keeps a bounded high-water mark.** It still reserves only
55
+ 512 ids initially, but capacities grown by OOV-heavy usable-token truncation are
56
+ retained up to 8192 ids between calls. This removes grow/trim realloc churn on
57
+ realistic truncated inputs without pinning arbitrarily large per-thread buffers.
58
+
59
+ ### Verification
60
+
61
+ - Added a deterministic **434-row boundary corpus** covering control/unassigned
62
+ boundaries, every supported CJK range edge ±1, punctuation classes, whitespace, combining
63
+ marks, AddedVocabulary, 511/512/513 boundaries, OOV ratios and long-prefix
64
+ cases.
65
+ - CI now installs pinned `model2vec==0.9.0` / `tokenizers==0.23.1`, generates an
66
+ external oracle, and separately checks raw HF ids, usable ids, embedding
67
+ parity where the contracts are identical, and intentional character-precut
68
+ deviations. The existing 50k native-vs-Ruby differential fuzz remains as a
69
+ separate implementation-consistency layer.
70
+
71
+ ## 0.1.4 (unreleased)
72
+
73
+ Hot-path work in the C runtime. Token ids, the pooling contract and f16
74
+ rounding are unchanged: the ASCII parity sweep and the differential fuzz
75
+ digest against `StaticEmbeddings::Reference` still match. The
76
+ `model2vec.StaticModel` oracle was re-run for 0.1.4 against the same
77
+ `potion-retrieval-32m` snapshot and `.semb` as 0.1.3: 31/31 id rows, 31/31
78
+ vectors, `min_cosine=0.9999999999989528`, `max_abs_all=2.980232238769531e-07`
79
+ — the same printed numbers as 0.1.3. L2 still accumulates sum-of-squares in
80
+ double; 0.1.4 does that with SIMD pairwise adds.
81
+
82
+ Measured on an M1 Pro, `potion-retrieval-32m`, `samples/run_all.sh`
83
+ `DURATION=25`, production build (no alloc-stats). The 0.1.3 comparison run
84
+ had `STATIC_EMBEDDINGS_ALLOC_STATS=1`, which the docs cost at about 3% on
85
+ `embed` and 13% on `tokenize`; differences smaller than that, or inside the
86
+ usual 5–10% laptop spread, are not claimed.
87
+
88
+ ### Changed
89
+
90
+ - **L2 normalisation is a SIMD double reduction plus one scale into `out`.**
91
+ It used to `scale_copy` with `1.0` and then walk the vector twice in scalar
92
+ double. On this model `dim=512` and short in-vocabulary English, that L2
93
+ slot was about 19% of an ASCII `embed_batch` sample tree. The fused path
94
+ has a NEON/`__aarch64__` kernel and an SSE2 kernel; the scalar tail is
95
+ unchanged. ASCII batch went 307k → 338k texts/s on the M1 Pro (+10%). The
96
+ SSE2 path is compiled, not timed.
97
+
98
+ - **The AArch64 f16 top-k kernel decodes 16 halves per iteration, not 8.**
99
+ The x86 F16C kernel is untouched. Same 50 000-row, `k=10`, ASCII matrix:
100
+
101
+ ```text
102
+ f32 f16
103
+ x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster (0.1.2, F16C)
104
+ M1 Pro, neon-fp16 2.36 ms 1.81 ms f16 1.3x faster (0.1.4)
105
+ ```
106
+
107
+ The M1 Pro row used to read 2.29 / 2.88 ms, f16 1.3x *slower*. `docs/PERFORMANCE.md`
108
+ and the README follow the new numbers. On the lookup-table fallback f16 is
109
+ still usually slower than f32.
110
+
111
+ - **Scratch buffers are reused per OS thread, and freed when the thread
112
+ exits.** `embed`, `tokenize`, `embed_batch` and `embed_token_ids` used to
113
+ `malloc`/`free` a scratch set on every call. A first cut of 0.1.4 used
114
+ `__thread` storage with no destructor, which leaked the heap buffers inside
115
+ the slot — bounded for a Puma pool, unbounded on the fiber-scheduler path
116
+ that `rb_thread_create`s one OS thread per large call. The slot is now a
117
+ `pthread_key` / `FlsAlloc` value whose destructor frees it. On release the
118
+ slot is trimmed back to the reserve sizes, so one `max_tokens: false`
119
+ document does not pin megabytes on that thread until process exit.
120
+ `tokenize` and the small `embed` path release the slot through `rb_ensure`,
121
+ so `rb_ary_push` raising cannot leave `in_use` stuck. `memory_smoke` calls
122
+ acquire/release so ASan/valgrind actually see this code.
123
+
124
+ - **ASCII WordPiece copies an all-ASCII word as bytes** instead of
125
+ encoding UTF-8 through the codepoint buffer, then hashes and splits on the
126
+ trie with identity offsets. Output is the same ids. On the OOV tokenize
127
+ probes this is inside the run-to-run floor.
128
+
129
+ - **Latin-1 (`U+0080..U+00FF`) lower/NFD/Mn/P/C/Zs tables are built at
130
+ load.** Codepoints below 256 skip the binary search into the mmap'd maps.
131
+ ASCII (`< 0x80`) still uses the existing 128-entry class table. Not visible
132
+ as texts/s on the English batch corpus.
133
+
134
+ - **Vocabulary compare is an inline unaligned equality test** instead of
135
+ libc `memcmp`. Trie child lookup special-cases one- and two-edge nodes
136
+ before the linear/binary search. Same ids; no measured throughput claim.
137
+
138
+ Two things were measured and not shipped. Unrolling `add_row` to 32 floats
139
+ and stretching the prefetch distance to 8 cost about 13% on
140
+ `random_pooling_hot_path` — that loop is already memory-bound on random 2 KiB
141
+ row gathers. Walking the ASCII trie without `cps2` (`start += matched_len`)
142
+ cost about 38% on the OOV tokenize probes.
143
+
144
+ ### Added
145
+
146
+ - **Batch samples print `mean_tokens_per_text`, `unk_ratio` and
147
+ `tokens_per_sec`.** 307k vs 103k texts/s is not readable without token
148
+ density: on this corpus ASCII is 31 tokens/text and 0 unk, hashes 73 and 0,
149
+ unicode 68 and 4.4% unk.
150
+
151
+ - **`test/scratch_tls_test.rb`.** When the instrumented allocator is loaded:
152
+ scratch bytes plateau across eight generations of helper threads, and a
153
+ large `max_tokens: false` embed must not leave the calling thread's slot
154
+ pinned. The `alloc_stats` CI job is what actually runs it.
155
+
3
156
  ## 0.1.3 (unreleased)
4
157
 
5
158
  Hardening and tooling, mostly borrowed from the sibling `tg_geometry` gem after
data/README.md CHANGED
@@ -32,8 +32,11 @@ never arbitrary HuggingFace models. A `.semb` file carries validated metadata, a
32
32
  BERT WordPiece tokenizer profile, an mmap-ready vocabulary lookup, float32
33
33
  embedding rows, and provenance plus a checksum.
34
34
 
35
- The only supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter
36
- rejects unsupported tokenizer features rather than approximating them.
35
+ The only supported tokenizer profile is `BERT_WORDPIECE_V1`, pinned for
36
+ compatibility to `tokenizers 0.23.1`. The converter rejects unsupported
37
+ tokenizer features rather than approximating them. `.semb` format v3 also
38
+ records which of the five standard BERT added tokens are active. Files produced
39
+ by 0.1.4 and earlier use format v2 and must be reconverted.
37
40
 
38
41
  ## Converting a model
39
42
 
@@ -57,6 +60,9 @@ model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL")) # explicit path
57
60
  Convert during image build or deploy preparation; production should only ever
58
61
  see `.semb` files.
59
62
 
63
+ The offline converter accepts F32, F16 and BF16 safetensors embedding matrices;
64
+ all three are converted to float32 rows in `.semb`.
65
+
60
66
  ### Verify once, not on every boot
61
67
 
62
68
  `load` does not check the SHA-256 by default: hashing a 135 MB file at boot
@@ -98,6 +104,7 @@ stats = model.embed_with_stats("postgres pipeline mode in Ruby")
98
104
  stats[:vector]
99
105
  stats[:token_count]
100
106
  stats[:unk_count]
107
+ stats[:pooled_count] # rows actually eligible for pooling
101
108
  stats[:truncated]
102
109
 
103
110
  ids = model.tokenize("postgres pipeline mode in Ruby")
@@ -116,11 +123,19 @@ Output is a binary `String` of little-endian float32 values in row-major order.
116
123
  `embed_array` and `embed_batch_arrays` decode that into Ruby `Float` objects and
117
124
  exist for debugging and application code, not for the hot path.
118
125
 
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.
126
+ `tokenize` exposes raw WordPiece ids, including `[UNK]`, and its own
127
+ `max_tokens` cap is therefore a raw-id cap. Embedding has a different, upstream
128
+ pooling rule: for `UNK_DROP`, `[UNK]` is removed first and `max_tokens` is then
129
+ applied to usable ids. To cache tokenization without changing the embedding,
130
+ cache it unbounded:
131
+
132
+ ```ruby
133
+ ids = model.tokenize(text, max_tokens: false)
134
+ model.embed_token_ids(ids) == model.embed(text)
135
+ ```
136
+
137
+ `embed_token_ids` applies that same usable-id cap. This distinction matters only
138
+ when `[UNK]` appears around the truncation boundary.
124
139
 
125
140
  ### `format: :f16`
126
141
 
@@ -132,10 +147,11 @@ in float32. For a 512-dimensional model one vector goes from 2048 to 1024 bytes.
132
147
  It is accepted by `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
133
148
  `embed_token_ids_with_stats`, `pack`, `unpack`, and the top-k helpers.
134
149
 
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
150
+ Choose `f16` for the RAM and storage it saves; speed is a property of the
151
+ machine and of the decode kernel. It halves the bytes a top-k scan streams, but
152
+ every row still has to be decoded before scoring — this repository's own
153
+ samples show `f16` winning on x86-64 F16C and, as of 0.1.4, on M1 Pro neon-fp16
154
+ as well. On the lookup-table fallback it usually loses. See
139
155
  `docs/PERFORMANCE.md` before assuming either.
140
156
 
141
157
  `StaticEmbeddings.simd_backend` reports the live kernel: `"neon-fp16"`,
@@ -174,13 +190,13 @@ model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL release
174
190
 
175
191
  ## Large inputs
176
192
 
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.
193
+ When truncation is active the runtime starts with a leading slice sized from
194
+ `max_tokens`, cut on a word boundary, and grows the budget until it has reached
195
+ `max_tokens` **usable** ids. For ordinary in-vocabulary text this keeps the
196
+ tokenized prefix to a few kilobytes even when the document is megabytes long.
197
+ For OOV-heavy input it may scan farther — or the whole string because `[UNK]`
198
+ no longer consumes the usable-token budget. That is required for the corrected
199
+ UNK-before-truncate contract.
184
200
 
185
201
  That bounds the tokenizer, not the whole call. `embed` also has to establish
186
202
  that the Ruby `String` is valid UTF-8, and when Ruby has not computed the
@@ -272,22 +288,34 @@ subword splitting instead of hitting the vocabulary directly.
272
288
 
273
289
  ## Correctness contract
274
290
 
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.
291
+ 0.1.5 separates contracts that 0.1.4 accidentally mixed together:
292
+
293
+ - `tokenize` matches the pinned Hugging Face `tokenizers 0.23.1` raw
294
+ `BertNormalizer + BertPreTokenizer + WordPiece` ids, including the supported
295
+ `normalized: false` standard added tokens and `[UNK]`.
296
+ - Embedding drops `[UNK]` when the model records `UNK_DROP`, then applies
297
+ `max_tokens` to the remaining usable ids, mean-pools, and performs source-model
298
+ L2 normalization.
299
+ - The gem intentionally does **not** reproduce Model2Vec 0.9.0's character
300
+ pre-cut (`max_length * median_token_length`). Here `max_tokens` means actual
301
+ usable tokenizer ids. The oracle reports rows affected by this difference
302
+ separately instead of calling them parity.
279
303
 
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:
304
+ Where the execution contracts are the same, vectors are compared to
305
+ `model2vec.StaticModel` with:
282
306
 
283
307
  ```text
284
308
  cosine >= 1 - 1e-6
285
309
  max_abs_diff < 1e-5
286
310
  ```
287
311
 
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.
312
+ The committed boundary generator currently produces 434 systematic rows. CI
313
+ generates their oracle from pinned Python packages both on the deterministic
314
+ fixture and on the pinned `potion-retrieval-32M` snapshot. The Ruby `Reference`
315
+ fuzz is kept as a separate C-vs-Ruby implementation check and is not treated as
316
+ proof of upstream compatibility. `docs/MODEL_AUDIT.md` records per-model audits;
317
+ a corpus pass means exactly that corpus passed, not an exhaustive proof of all
318
+ Unicode/tokenizer behavior.
291
319
 
292
320
  ## Development
293
321
 
@@ -323,7 +351,7 @@ cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
323
351
  ext/static_embeddings/se_unicode.c \
324
352
  ext/static_embeddings/se_tokenizer.c \
325
353
  ext/static_embeddings/se_embed.c \
326
- -lm -o tmp/memory_smoke
354
+ -lm -pthread -o tmp/memory_smoke
327
355
  ./tmp/memory_smoke tmp/test-tiny.semb
328
356
  ```
329
357
 
data/Rakefile CHANGED
@@ -44,7 +44,7 @@ task parity: :compile do
44
44
  Generate it first (needs Python and the model2vec package):
45
45
 
46
46
  python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
47
- pip install -U model2vec numpy
47
+ pip install "model2vec==0.9.0" "tokenizers==0.23.1" numpy
48
48
  python tools/model2vec_oracle.py /path/to/source-model --out #{oracle}
49
49
  MSG
50
50
  end
data/docs/ARCHITECTURE.md CHANGED
@@ -33,7 +33,7 @@ in `se_internal.h`: the header fields are decoded little-endian explicitly, but
33
33
  the mmapped structures and the float matrix are read in native order, so a
34
34
  big-endian host is refused at load rather than silently misread.
35
35
 
36
- ## `.semb` v2
36
+ ## `.semb` v3
37
37
 
38
38
  Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
39
39
 
@@ -69,6 +69,7 @@ Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
69
69
  | 208–239 | 32 | two (offset, size) u64 pairs: `root_trie`, `continuation_trie` |
70
70
  | 240 | 32 | sha256 of the file with these 32 bytes zeroed |
71
71
  | 304 | 4 | max_probe observed in the vocabulary hash |
72
+ | 308 | 4 | supported added-token bit mask |
72
73
 
73
74
  Sections, in fixed order: `vocab_strings`, `vocab_hash`, `embeddings`,
74
75
  `norm_tables`, `provenance`, `root_trie`, `continuation_trie`.
@@ -79,11 +80,12 @@ already has a slot for I8; there is no dead I8 path in C.
79
80
  ### Why the semantics live in the header
80
81
 
81
82
  `max_tokens_default`, `unk_policy`, `empty_policy` and the normalizer flags
82
- are properties of the *model*, not options the caller guesses. The reference
83
- implementation truncates at 512 tokens; a model card's `model_max_length` of
84
- 1,000,000 is a different number entirely, and confusing them silently changes
85
- every vector for long chunks. Anything that can silently change output is a
86
- recorded field.
83
+ are properties of the *model*, not options the caller guesses. The embedding contract caps the first 512 usable token ids by default;
84
+ a model card's `model_max_length` of 1,000,000 is a different number entirely, and
85
+ confusing them silently changes every vector for long chunks. Model2Vec 0.9.0
86
+ also applies a character pre-cut before tokenization; this runtime intentionally
87
+ does not copy that heuristic. Anything that can silently change output is a
88
+ recorded field or an explicitly documented compatibility deviation.
87
89
 
88
90
  ## Vocabulary lookup and WordPiece trie
89
91
 
@@ -93,7 +95,7 @@ load factor ≤ 0.70, linear probing, FNV-1a with a fixed seed. The hash table i
93
95
  kept for validation, direct lookup and debugging.
94
96
 
95
97
  The hot WordPiece path no longer performs repeated hash lookups for every
96
- `end--` candidate. Format v2 also stores two converter-built, mmap-readable
98
+ `end--` candidate. The format also stores two converter-built, mmap-readable
97
99
  tries inspired by double-array trie libraries such as libdatrie:
98
100
 
99
101
  - `root_trie` for tokens that can start a word;
@@ -135,8 +137,8 @@ Rejected alternatives:
135
137
 
136
138
  ## Unicode without a Unicode library
137
139
 
138
- `BertNormalizer` needs NFD, simple lowercase, and the Mn / P / C / Zs
139
- categories. Rather than vendoring utf8proc, the converter generates exactly
140
+ `BertNormalizer` needs NFD, simple lowercase, and selected Unicode
141
+ categories (Mn, punctuation, control/format/private-use, and whitespace). Rather than vendoring utf8proc, the converter generates exactly
140
142
  those tables from the Ruby VM's own Unicode data and writes them into the
141
143
  model file; the runtime binary-searches them.
142
144
 
@@ -146,15 +148,20 @@ maps `ß` to `ss`; `str::to_lowercase`, which HF uses, does not. A gem that
146
148
  picked the wrong one would produce plausible, slightly wrong vectors forever.
147
149
 
148
150
  The Ruby build that generated the tables is stamped into provenance, so a
149
- model file and its normalizer can never drift apart unnoticed.
151
+ model file and its embedded normalizer tables can never drift apart unnoticed.
152
+ That stamp is not, by itself, proof of Hugging Face parity: 0.1.5 therefore
153
+ checks a systematic boundary corpus against pinned `tokenizers 0.23.1` in CI.
154
+ One subtle example is `Cn`: `bert.rs` says `is_other()` includes unassigned
155
+ codepoints, while the `unicode_categories 0.1.1` implementation actually tests
156
+ Cc/Cf/Co. The runtime follows the executable upstream behavior, not the comment.
150
157
 
151
158
  ## The embedding kernel
152
159
 
153
160
  ```
154
- tokenize -> ids
155
- truncate to max_tokens (before pooling — the reference contract)
161
+ tokenize -> raw ids (including [UNK])
156
162
  drop [UNK] if unk_policy = DROP
157
- acc[j] += row[j] for each id, in token order
163
+ truncate usable ids to max_tokens
164
+ acc[j] += row[j] for each usable id, in token order
158
165
  divide by the number of pooled rows
159
166
  L2 normalize if the model says so
160
167
  ```
@@ -174,10 +181,13 @@ storage/transport choice, not a different model. `embed_array` and
174
181
 
175
182
  The f32 compute path uses small explicit SIMD kernels where they keep the format
176
183
  simple: row accumulation and scaling use NEON/SSE when the compiler target
177
- exposes them, with scalar fallback everywhere else. `dot_top_k`/`cosine_top_k`
184
+ exposes them, L2 sum-of-squares uses SIMD double (NEON on AArch64, SSE2 on
185
+ x86) so the inverse stays a `1/sqrt` of a double accumulation, and there is a
186
+ scalar fallback everywhere else. `dot_top_k`/`cosine_top_k`
178
187
  share the SIMD dot kernel for `format: :f32`; `cosine_top_k` additionally
179
188
  accumulates each row's sum of squares so it can divide by the true norms.
180
- `format: :f16` top-k decodes half components on the fly. What is still
189
+ `format: :f16` top-k decodes half components on the fly (16-wide on AArch64
190
+ NEON-FP16, 16-wide on x86 F16C). What is still
181
191
  deliberately absent is a dependency on BLAS: pooling is gathering random
182
192
  embedding rows, not a dense matrix multiply.
183
193
 
@@ -206,10 +216,11 @@ therefore serialised the process on work that the tokenizer would discard after
206
216
 
207
217
  `embed` and `embed_batch` instead copy a leading slice. The budget starts at
208
218
  `max_tokens * 16` bytes clamped to `[4096, 65536]`, and the slice is cut at a
209
- position where the tokenizer would have started a fresh word. A result is
210
- accepted only when that slice actually reached `max_tokens`; otherwise the
211
- budget doubles and the text is tokenized again. Growth is geometric, so the
212
- pathological case costs about twice the single-pass work.
219
+ position where the tokenizer would have started a fresh word. A result is accepted only when that slice actually reached `max_tokens`
220
+ usable ids; `[UNK]` does not consume the embedding budget. Otherwise the budget
221
+ doubles and the text is tokenized again. Growth is geometric. OOV-heavy input
222
+ may therefore reach the full document: deciding that fewer than `max_tokens`
223
+ usable ids exist requires reading it.
213
224
 
214
225
  Three properties make this exact rather than approximate:
215
226
 
@@ -238,10 +249,11 @@ the predicate is Unicode-aware. Text with genuinely no legal cut inside the scan
238
249
  window - one enormous word, a run of combining marks - still falls back to a
239
250
  full copy, because there is nothing else that would be correct.
240
251
 
241
- `test/prefix_chunking_test.rb` sweeps every printable ASCII separator, checks
242
- CJK, NBSP and Unicode-punctuation documents against
243
- `embed_token_ids(tokenize(text))`, and asserts that quadrupling the input does
244
- not triple the cost.
252
+ `test/prefix_chunking_test.rb` sweeps every printable ASCII separator and
253
+ checks CJK, NBSP and Unicode-punctuation documents against unbounded raw
254
+ tokenization followed by `embed_token_ids`. It asserts bounded prefix cost for
255
+ in-vocabulary text; deliberately OOV-heavy input is checked for correctness, not
256
+ flat cost.
245
257
 
246
258
  With `max_tokens: false` there is nothing to truncate and the whole input is
247
259
  copied.
@@ -293,8 +305,16 @@ cadence in bytes so a long ASCII run is no less interruptible. The pooling loop
293
305
  checks every 256 rows, and the top-k scan every 1024 rows. `unblock_cancel` sets that flag when Ruby
294
306
  interrupts a GVL-free region.
295
307
 
296
- No global mutable state exists in C. The model is immutable after load and
297
- scratch buffers are per call.
308
+ No global mutable state exists in C. The model is immutable after load.
309
+ Scratch buffers are reused per OS thread via `pthread_key` / `FlsAlloc`, and
310
+ the destructor frees them when the thread exits — including the short-lived
311
+ threads the fiber scheduler path creates per large call. On release the
312
+ codepoint/byte buffers are trimmed back to their reserve sizes. The token-id
313
+ buffer keeps its observed high-water mark up to 8192 ids so repeated OOV-heavy
314
+ usable-token truncation does not grow and shrink it on every call; larger
315
+ capacities are trimmed back to that bound so one unlimited-`max_tokens`
316
+ document cannot pin an arbitrary per-thread working set. A nested call on the
317
+ same thread allocates a one-off heap scratch that is freed on release.
298
318
 
299
319
  ## Where this fits in a RAG pipeline
300
320
 
@@ -313,12 +333,11 @@ chunks -> .semb vectors --/
313
333
 
314
334
  ## Open questions before v1.0
315
335
 
316
- 1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The 32M family
317
- has a larger vocabulary than the bge-base tokenizer it was distilled from,
318
- which means tokens were added somewhere. If they live in `added_tokens`
319
- rather than `model.vocab`, HF matches them with a separate trie pass over
320
- raw text that this runtime does not implement. The converter refuses such
321
- models today; whether it has to is an audit question.
336
+ 1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The runtime now
337
+ implements the exact `normalized: false` extraction semantics needed by the
338
+ five standard BERT added tokens, but still rejects arbitrary AddedVocabulary,
339
+ BPE and Unigram profiles. Each source model still needs an audit before it is
340
+ treated as compatible.
322
341
  2. **Russian.** Distilling a multilingual teacher into a WordPiece vocabulary
323
342
  we control keeps the pure-C path, but skips the Tokenlearn pre-training
324
343
  that gives the published potion models their quality. That gap has to be
data/docs/LIMITATIONS.md CHANGED
@@ -13,9 +13,10 @@ the entry says whether that is a decision or just unfinished work.
13
13
  - **Only `.semb` files from this repository's converter.** Loading a
14
14
  HuggingFace directory at runtime is not supported and will not be. The
15
15
  converter is the only thing that reads third-party files, and it runs offline.
16
- - **Only the `BERT_WORDPIECE_V1` tokenizer profile.** SentencePiece, BPE, Unigram
17
- and byte-level tokenizers are rejected at conversion time rather than
18
- approximated.
16
+ - **Only the pinned `BERT_WORDPIECE_V1` tokenizer profile.** SentencePiece, BPE,
17
+ Unigram and byte-level tokenizers are rejected. AddedVocabulary support is
18
+ deliberately limited to the five standard BERT special tokens with
19
+ `special: true`, `normalized: false` and no strip/single-word behavior.
19
20
  - **No model training, distillation or fine-tuning.** Produce the model with
20
21
  upstream `model2vec`, then convert it.
21
22
  - **No transformer inference.** Static embeddings have no attention and no
@@ -38,6 +39,10 @@ the entry says whether that is a decision or just unfinished work.
38
39
  - **Big-endian hosts are refused at load.** The header is decoded
39
40
  little-endian explicitly, but the mmapped structures and the float matrix are
40
41
  read in native order.
42
+ - **Do not truncate/overwrite a mapped `.semb` in place.** On POSIX, shrinking
43
+ the inode underneath an existing mmap can raise `SIGBUS` in a worker. Deploy a
44
+ new file and atomically rename it over the path (`write .tmp` → `fsync` →
45
+ `rename`); existing workers keep their old inode mapping safely.
41
46
 
42
47
  ## Search
43
48
 
@@ -57,8 +62,10 @@ the entry says whether that is a decision or just unfinished work.
57
62
  ## Storage formats
58
63
 
59
64
  - **`f32` and `f16` only.** No int8, no binary quantisation.
60
- - **`f16` is a storage trade-off, not a speed feature.** See
61
- `docs/PERFORMANCE.md`; it wins on some CPUs and loses on others.
65
+ - **`f16` is a storage encoding, not a different model.** Top-k over an f16
66
+ matrix can be faster or slower than f32 depending on the decode kernel; see
67
+ `docs/PERFORMANCE.md`. `embed_batch(format: :f16)` still encodes the blob
68
+ with the scalar converter.
62
69
  - **`f16` rounding is half-up, not ties-to-even.** Blobs written by this gem can
63
70
  differ from NumPy or PyTorch by one ULP on exact halfway values.
64
71
 
@@ -74,9 +81,10 @@ the entry says whether that is a decision or just unfinished work.
74
81
  - **`load` does not check the SHA-256.** Structural validation always runs, but
75
82
  a bit flip inside the matrix is only caught by `verify: true` or
76
83
  `StaticEmbeddings.verify`.
77
- - **Parity is per model and per runtime version.** A converted model is not
78
- trusted until it has a record in `docs/MODEL_AUDIT.md`, and that record
79
- expires when the tokenizer changes.
84
+ - **Parity is per model, runtime, pinned upstream versions and corpus.** A
85
+ recorded pass means those rows passed; it is not an exhaustive proof of
86
+ tokenizer equivalence. The independent upstream CI and the Ruby implementation
87
+ fuzz intentionally test different things.
80
88
 
81
89
  ## Known open questions
82
90