static_embeddings 0.1.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +67 -25
  4. data/Rakefile +1 -1
  5. data/docs/ARCHITECTURE.md +54 -34
  6. data/docs/LIMITATIONS.md +12 -6
  7. data/docs/MODEL_AUDIT.md +195 -52
  8. data/ext/static_embeddings/se_embed.c +2 -1
  9. data/ext/static_embeddings/se_format.c +41 -4
  10. data/ext/static_embeddings/se_internal.h +17 -5
  11. data/ext/static_embeddings/se_tokenizer.c +155 -18
  12. data/ext/static_embeddings/se_unicode.c +1 -1
  13. data/ext/static_embeddings/static_embeddings.c +32 -2
  14. data/lib/models/demo.semb +0 -0
  15. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  16. data/lib/static_embeddings/canonical.rb +50 -0
  17. data/lib/static_embeddings/cli.rb +88 -62
  18. data/lib/static_embeddings/codec.rb +45 -0
  19. data/lib/static_embeddings/conversion.rb +58 -0
  20. data/lib/static_embeddings/errors.rb +2 -2
  21. data/lib/static_embeddings/format/constants.rb +109 -0
  22. data/lib/static_embeddings/format/hash_table.rb +69 -0
  23. data/lib/static_embeddings/format/trie.rb +78 -0
  24. data/lib/static_embeddings/format/verifier.rb +41 -0
  25. data/lib/static_embeddings/format/writer.rb +131 -0
  26. data/lib/static_embeddings/format.rb +3 -300
  27. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  28. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  29. data/lib/static_embeddings/importers/support.rb +111 -0
  30. data/lib/static_embeddings/importers.rb +50 -0
  31. data/lib/static_embeddings/model.rb +35 -20
  32. data/lib/static_embeddings/paths.rb +17 -4
  33. data/lib/static_embeddings/provenance.rb +58 -0
  34. data/lib/static_embeddings/reference.rb +90 -33
  35. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  36. data/lib/static_embeddings/safetensors.rb +178 -34
  37. data/lib/static_embeddings/version.rb +1 -1
  38. data/lib/static_embeddings.rb +29 -57
  39. data/static_embeddings.gemspec +2 -2
  40. data/tools/check_model2vec_parity.rb +89 -54
  41. data/tools/check_st_parity.rb +125 -0
  42. data/tools/eval_retrieval.rb +58 -0
  43. metadata +24 -6
  44. data/lib/static_embeddings/converter.rb +0 -284
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f72b45c2d6fa5310e3b6d5a9b0a86ab0430fc4af71f6811f9aa02655de6272a8
4
- data.tar.gz: 51f0bb96a3b0adc5f2799e1df425cf1747ca521d92ae90afba59e0014f1d4106
3
+ metadata.gz: 550531f7573a8d05e8d5a3a1ae0fa301c01d77b238573ae11834797e8ac44a0f
4
+ data.tar.gz: 57f487c729296e3a858604dc64aa146aa6dd6e372a45961d90a61564e3c1b117
5
5
  SHA512:
6
- metadata.gz: 53630b742cd2723656b5d826a82f57042dcaab1e3259758aa6a71eac20ba387a23b3fb3905f0f36f3ce641b122229f366c9af1f029205b44cd82110f538a49cb
7
- data.tar.gz: 199cd93e9898b7457d5e19b9cf1857b4d78ffc46777ca26778b094fa70ee02559dc90733e4e815eba74297ef9c066940792cef09e022801ef48c704e868fa8db
6
+ metadata.gz: 52162c96d458dffaaec8498cd712678b4727b29ddaa66eadf7834893113a6bd05f636cc47a2f84d969f76eb4651fe57664212fdb4ac2925449827b9d3a69a9bf
7
+ data.tar.gz: 115a0b0f3ff9d82cea099fc0ee24385034d8bcc66ca4ff9337f2213608ee4d611215b6d3400fe39aecf562cf201f5f5044eab7f9863fbdc813362c0af0eb7739
data/CHANGELOG.md CHANGED
@@ -1,5 +1,123 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.6
4
+
5
+ Multi-source WordPiece release. The native `.semb` v3 runtime is unchanged; new
6
+ source families are normalized offline into the same audited
7
+ `BERT_WORDPIECE_V1 -> lookup -> mean -> NONE/L2` contract. Model identity is
8
+ chosen at `load_model`, not per `embed` call.
9
+
10
+ ### Added
11
+
12
+ - **Model2Vec and Sentence Transformers importers.** A Model2Vec directory and a
13
+ Sentence Transformers `StaticEmbedding` directory (`modules.json` + exactly
14
+ one module path) compile to the same canonical data. The ST importer is
15
+ fail-closed and confines the module path to the source root with `realpath`.
16
+ - **Source-faithful policies.** Model2Vec uses `UNK_DROP`, normalization from its
17
+ config and a 512-token default. Sentence Transformers `StaticEmbedding` uses
18
+ `UNK_INCLUDE`, `NORMALIZATION_NONE`, unlimited tokens and
19
+ `add_special_tokens=false`.
20
+ - **`--dimensions N`.** Matryoshka prefix slicing happens while streaming the
21
+ embedding matrix; the runtime still loads an ordinary fixed-dimension `.semb`.
22
+ - **`--max-tokens unlimited`.** Convert-time `0` remains an alias; runtime
23
+ `max_tokens: 0` is still rejected and `false` means unlimited.
24
+ - **Russian retrieval fixture.** `tools/eval_retrieval.rb` and the checked-in
25
+ Russian FAQ corpus provide a small domain sanity check for the multilingual
26
+ WordPiece model; it is not presented as a general benchmark.
27
+
28
+ ### Changed
29
+
30
+ - **Offline Ruby conversion was simplified.** Stateful `Converter` and the
31
+ behavior-heavy `CanonicalModel` were replaced by import functions, immutable
32
+ canonical data, pure provenance/meta transforms and a streaming format writer.
33
+ Writer/hash/trie/verifier code is split from runtime format constants, so plain
34
+ `require "static_embeddings"` does not load conversion code.
35
+ - The Ruby `Reference` twin follows canonical UNK/normalization/max-token
36
+ policies instead of re-deriving Model2Vec defaults.
37
+ - Model2Vec `normalize` now defaults to `false` when the key is absent, matching
38
+ the pinned upstream behavior. Unknown source families are rejected rather than
39
+ guessed from `tokenizer.json + model.safetensors`.
40
+ - Header integer writes validate their unsigned range instead of silently
41
+ wrapping oversized Ruby integers.
42
+ - `Model#provenance` is parsed once and cached.
43
+
44
+ Local WordPiece conversions requiring **no C change**: `potion-base-8M`,
45
+ `potion-science-32M`, `static-retrieval-mrl-en-v1` at 1024/512 and
46
+ `static-similarity-mrl-multilingual-v1` at 512/256. The retrieval model has a
47
+ recorded `SentenceTransformer.encode` oracle (436/436). Records are in
48
+ `docs/MODEL_AUDIT.md`.
49
+
50
+ SentencePiece/Unigram is intentionally **not** part of 1.5.6; it remains a
51
+ future runtime capability rather than shipping an unaudited v4 path.
52
+
53
+ ## 0.1.5
54
+
55
+ Correctness/parity release. The native runtime architecture is unchanged, but
56
+ the tokenizer/pooling contract is now pinned explicitly to `model2vec 0.9.0` +
57
+ `tokenizers 0.23.1`, and CI contains an independent upstream boundary corpus
58
+ instead of treating the Ruby `Reference` twin as proof of compatibility.
59
+
60
+ ### Fixed
61
+
62
+ - **`[UNK]` is filtered before the embedding token cap.** `max_tokens: 512` now
63
+ means the first 512 usable (non-UNK, for `UNK_DROP`) ids, matching
64
+ `StaticModel` pooling semantics. Long OOV-heavy text can therefore scan past
65
+ 512 raw ids instead of silently averaging fewer rows. `embed_with_stats` and
66
+ `embed_token_ids_with_stats` expose `pooled_count` alongside raw
67
+ `token_count` / `unk_count`.
68
+ - **The supported Hugging Face AddedVocabulary subset is implemented.** The five
69
+ standard BERT special tokens (`[PAD]`, `[UNK]`, `[CLS]`, `[SEP]`, `[MASK]`)
70
+ are extracted literally before normalization when the source tokenizer marks
71
+ them `special: true, normalized: false`. Converter and runtime now share that
72
+ contract instead of merely whitelisting the metadata.
73
+ - **BertNormalizer behavior matches the pinned Rust tokenizer at the known edges.**
74
+ The CJK Extension E range starts at `U+2B920` as in `tokenizers 0.23.1`, not
75
+ the Python `transformers` boundary `U+2B820`. The boundary corpus also locks
76
+ the actual `unicode_categories` 0.1.1 behavior for unassigned (`Cn`)
77
+ codepoints: despite a misleading comment in upstream `bert.rs`, that crate's
78
+ `is_other()` implementation checks Cc/Cf/Co, not Cn.
79
+ - **Vocabulary hash lookup is bounded by the validated `max_probe`,** and model
80
+ loading rejects hash tables above the converter's 0.70 load factor.
81
+ - **`load_model` model ids cannot escape the cache** through absolute paths,
82
+ `..`, empty path components, backslash traversal or NUL bytes.
83
+
84
+ ### Changed
85
+
86
+ - **`.semb` format v3.** The header records the supported added-token mask and
87
+ the usable-id truncation policy. v2 files must be reconverted; this avoids a
88
+ runtime silently applying 0.1.5 semantics to a file whose tokenizer contract
89
+ was recorded by an older converter.
90
+ - **Model2Vec's character pre-truncation heuristic is intentionally not copied.**
91
+ `max_tokens` means actual usable tokenizer ids. The external oracle records
92
+ rows where `StaticModel`'s `max_length * median_token_length` character cut
93
+ changes ids and reports them as an explicit semantic deviation rather than a
94
+ false parity failure/pass.
95
+ - **The converter reads safetensors incrementally and accepts F32, F16 and BF16
96
+ embedding tensors.** `.semb` rows remain float32. `Format.write` also streams
97
+ sections and computes the checksum incrementally instead of assembling a
98
+ second whole-file Ruby string.
99
+ - **Plain `require "static_embeddings"` loads runtime code only.** Converter,
100
+ safetensors, Unicode-table generation and the Ruby `Reference` are loaded only
101
+ by offline tooling/tests. The `static_embeddings convert` CLI explicitly loads
102
+ the converter before reading converter-specific defaults, so lazy loading does
103
+ not break the documented executable path.
104
+ - **TLS token-id scratch keeps a bounded high-water mark.** It still reserves only
105
+ 512 ids initially, but capacities grown by OOV-heavy usable-token truncation are
106
+ retained up to 8192 ids between calls. This removes grow/trim realloc churn on
107
+ realistic truncated inputs without pinning arbitrarily large per-thread buffers.
108
+
109
+ ### Verification
110
+
111
+ - Added a deterministic **434-row boundary corpus** covering control/unassigned
112
+ boundaries, every supported CJK range edge ±1, punctuation classes, whitespace, combining
113
+ marks, AddedVocabulary, 511/512/513 boundaries, OOV ratios and long-prefix
114
+ cases.
115
+ - CI now installs pinned `model2vec==0.9.0` / `tokenizers==0.23.1`, generates an
116
+ external oracle, and separately checks raw HF ids, usable ids, embedding
117
+ parity where the contracts are identical, and intentional character-precut
118
+ deviations. The existing 50k native-vs-Ruby differential fuzz remains as a
119
+ separate implementation-consistency layer.
120
+
3
121
  ## 0.1.4 (unreleased)
4
122
 
5
123
  Hot-path work in the C runtime. Token ids, the pooling contract and f16
data/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # static_embeddings
2
2
 
3
- A Ruby runtime for converted Model2Vec / potion static embedding models. A model
3
+ A Ruby runtime for converted Model2Vec and Sentence Transformers static embedding models. A model
4
4
  is converted once into a local `.semb` file and loaded through a small C
5
5
  extension. No ONNX Runtime, no Rust or Python at runtime, no network access, one
6
6
  mmap-able file, binary float32 output.
7
7
 
8
- The current production target is `minishlab/potion-retrieval-32M`.
8
+ The reference production target remains `minishlab/potion-retrieval-32M`; 1.5.6 also supports audited WordPiece `StaticEmbedding` sources.
9
9
 
10
10
  ```ruby
11
11
  require "static_embeddings"
@@ -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
 
@@ -46,6 +49,21 @@ bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
46
49
  --id potion-retrieval-32m
47
50
  ```
48
51
 
52
+ Sentence Transformers static models (`modules.json` + one `StaticEmbedding`
53
+ module) use the same command. Matryoshka prefix-slice is a convert-time flag,
54
+ not a runtime `dim:`:
55
+
56
+ ```bash
57
+ bundle exec ruby -Ilib exe/static_embeddings convert ./static-retrieval-mrl-en-v1 \
58
+ --id static-retrieval-mrl-en-v1-512 \
59
+ --dimensions 512 \
60
+ --trained-mrl-dims 1024,512,256,128,64,32
61
+ ```
62
+
63
+ `--max-tokens unlimited` bakes no token cap (Sentence Transformers default).
64
+ `--max-tokens 0` is a convert-time alias; `embed(max_tokens: 0)` is still
65
+ rejected. Query and documents must use the same loaded `.semb`.
66
+
49
67
  The result lands in `~/.cache/static_embeddings/models/potion-retrieval-32m.semb`.
50
68
  Inspect or verify it with the `inspect` and `verify` subcommands, then load it:
51
69
 
@@ -57,6 +75,9 @@ model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL")) # explicit path
57
75
  Convert during image build or deploy preparation; production should only ever
58
76
  see `.semb` files.
59
77
 
78
+ The offline converter accepts F32, F16 and BF16 safetensors embedding matrices;
79
+ all three are converted to float32 rows in `.semb`.
80
+
60
81
  ### Verify once, not on every boot
61
82
 
62
83
  `load` does not check the SHA-256 by default: hashing a 135 MB file at boot
@@ -98,6 +119,7 @@ stats = model.embed_with_stats("postgres pipeline mode in Ruby")
98
119
  stats[:vector]
99
120
  stats[:token_count]
100
121
  stats[:unk_count]
122
+ stats[:pooled_count] # rows actually eligible for pooling
101
123
  stats[:truncated]
102
124
 
103
125
  ids = model.tokenize("postgres pipeline mode in Ruby")
@@ -116,11 +138,19 @@ Output is a binary `String` of little-endian float32 values in row-major order.
116
138
  `embed_array` and `embed_batch_arrays` decode that into Ruby `Float` objects and
117
139
  exist for debugging and application code, not for the hot path.
118
140
 
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.
141
+ `tokenize` exposes raw WordPiece ids, including `[UNK]`, and its own
142
+ `max_tokens` cap is therefore a raw-id cap. Embedding has a different, upstream
143
+ pooling rule: for `UNK_DROP`, `[UNK]` is removed first and `max_tokens` is then
144
+ applied to usable ids. To cache tokenization without changing the embedding,
145
+ cache it unbounded:
146
+
147
+ ```ruby
148
+ ids = model.tokenize(text, max_tokens: false)
149
+ model.embed_token_ids(ids) == model.embed(text)
150
+ ```
151
+
152
+ `embed_token_ids` applies that same usable-id cap. This distinction matters only
153
+ when `[UNK]` appears around the truncation boundary.
124
154
 
125
155
  ### `format: :f16`
126
156
 
@@ -175,13 +205,13 @@ model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL release
175
205
 
176
206
  ## Large inputs
177
207
 
178
- When truncation is active the runtime does not copy the whole string into C
179
- memory. It copies a leading slice sized from `max_tokens`, cut on a word
180
- boundary, and grows the budget only if that slice did not reach `max_tokens`.
181
- The result is identical to tokenizing the whole document, because WordPiece
182
- segmentation is word-local, but the bytes the tokenizer reads stop growing with
183
- input size: with `max_tokens: 512` the window is a few kilobytes whether the
184
- document is 10 KB or 3 MB.
208
+ When truncation is active the runtime starts with a leading slice sized from
209
+ `max_tokens`, cut on a word boundary, and grows the budget until it has reached
210
+ `max_tokens` **usable** ids. For ordinary in-vocabulary text this keeps the
211
+ tokenized prefix to a few kilobytes even when the document is megabytes long.
212
+ For OOV-heavy input it may scan farther — or the whole string because `[UNK]`
213
+ no longer consumes the usable-token budget. That is required for the corrected
214
+ UNK-before-truncate contract.
185
215
 
186
216
  That bounds the tokenizer, not the whole call. `embed` also has to establish
187
217
  that the Ruby `String` is valid UTF-8, and when Ruby has not computed the
@@ -273,22 +303,34 @@ subword splitting instead of hitting the vocabulary directly.
273
303
 
274
304
  ## Correctness contract
275
305
 
276
- The reference implementation is `model2vec.StaticModel`. The converter records
277
- its decisions in the `.semb` file: truncation at 512 tokens by default, applied
278
- after tokenization and before pooling; `[UNK]` tokens dropped; a zero vector for
279
- input with no usable tokens; L2 normalization when the source model requires it.
306
+ 0.1.5 separates contracts that 0.1.4 accidentally mixed together:
307
+
308
+ - `tokenize` matches the pinned Hugging Face `tokenizers 0.23.1` raw
309
+ `BertNormalizer + BertPreTokenizer + WordPiece` ids, including the supported
310
+ `normalized: false` standard added tokens and `[UNK]`.
311
+ - Embedding drops `[UNK]` when the model records `UNK_DROP`, then applies
312
+ `max_tokens` to the remaining usable ids, mean-pools, and performs source-model
313
+ L2 normalization.
314
+ - The gem intentionally does **not** reproduce Model2Vec 0.9.0's character
315
+ pre-cut (`max_length * median_token_length`). Here `max_tokens` means actual
316
+ usable tokenizer ids. The oracle reports rows affected by this difference
317
+ separately instead of calling them parity.
280
318
 
281
- Token ids must match the reference exactly. Vectors are compared with tolerance,
282
- because floating-point addition order is not bit-stable across implementations:
319
+ Where the execution contracts are the same, vectors are compared to
320
+ `model2vec.StaticModel` with:
283
321
 
284
322
  ```text
285
323
  cosine >= 1 - 1e-6
286
324
  max_abs_diff < 1e-5
287
325
  ```
288
326
 
289
- `docs/MODEL_AUDIT.md` records the parity result, digests and edge-case
290
- behaviour for every trusted conversion, and the command to reproduce it. A new
291
- conversion is not trusted until it has its own record.
327
+ The committed boundary generator currently produces 434 systematic rows. CI
328
+ generates their oracle from pinned Python packages both on the deterministic
329
+ fixture and on the pinned `potion-retrieval-32M` snapshot. The Ruby `Reference`
330
+ fuzz is kept as a separate C-vs-Ruby implementation check and is not treated as
331
+ proof of upstream compatibility. `docs/MODEL_AUDIT.md` records per-model audits;
332
+ a corpus pass means exactly that corpus passed, not an exhaustive proof of all
333
+ Unicode/tokenizer behavior.
292
334
 
293
335
  ## Development
294
336
 
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
@@ -8,10 +8,10 @@ HuggingFace / Model2Vec files offline, once, on your machine
8
8
  model.safetensors
9
9
  |
10
10
  v
11
- Converter (pure Ruby, strict) <--- all parsing, all validation, all
12
- | Unicode table generation happens here
11
+ import -> canonical data -> writer <--- pure Ruby, offline, strict
12
+ | all source parsing/validation here
13
13
  v
14
- model.semb <--- flat, versioned, mmap-able
14
+ model.semb <--- flat, versioned, mmap-able
15
15
  |
16
16
  v
17
17
  C runtime <--- mmap + bounds checks + tokenize +
@@ -24,6 +24,15 @@ Everything expensive, fragile or security-sensitive about reading third-party
24
24
  model files happens once, offline, in a language where it is easy to get
25
25
  right. What remains in C is a bounds-checked mmap and three loops.
26
26
 
27
+ Only the offline import layer knows HuggingFace layouts. Model2Vec and Sentence
28
+ Transformers `StaticEmbedding` sources are reduced to immutable canonical data:
29
+ tokens, streamed matrix, tokenizer metadata, runtime policies and provenance.
30
+ Pure transforms then build the v3 header/provenance and the existing WordPiece
31
+ writer emits the artifact. A new WordPiece + mean-pooling source therefore does
32
+ not require a C change. New tokenizer families remain explicit future
33
+ capabilities and require a separately audited format/runtime change rather than
34
+ source-name special cases.
35
+
27
36
  The C side is `se_format.c` (mmap and validation), `se_tokenizer.c`,
28
37
  `se_embed.c`, `se_unicode.c`, `se_f16.c` (half-precision codec and kernels),
29
38
  `se_topk.c`, `se_alloc_stats.c` (optional allocation counters), and
@@ -33,7 +42,7 @@ in `se_internal.h`: the header fields are decoded little-endian explicitly, but
33
42
  the mmapped structures and the float matrix are read in native order, so a
34
43
  big-endian host is refused at load rather than silently misread.
35
44
 
36
- ## `.semb` v2
45
+ ## `.semb` v3
37
46
 
38
47
  Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
39
48
 
@@ -69,6 +78,7 @@ Little-endian throughout. 320-byte header, then sections aligned to 64 bytes.
69
78
  | 208–239 | 32 | two (offset, size) u64 pairs: `root_trie`, `continuation_trie` |
70
79
  | 240 | 32 | sha256 of the file with these 32 bytes zeroed |
71
80
  | 304 | 4 | max_probe observed in the vocabulary hash |
81
+ | 308 | 4 | supported added-token bit mask |
72
82
 
73
83
  Sections, in fixed order: `vocab_strings`, `vocab_hash`, `embeddings`,
74
84
  `norm_tables`, `provenance`, `root_trie`, `continuation_trie`.
@@ -79,11 +89,12 @@ already has a slot for I8; there is no dead I8 path in C.
79
89
  ### Why the semantics live in the header
80
90
 
81
91
  `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.
92
+ are properties of the *model*, not options the caller guesses. The embedding contract caps the first 512 usable token ids by default;
93
+ a model card's `model_max_length` of 1,000,000 is a different number entirely, and
94
+ confusing them silently changes every vector for long chunks. Model2Vec 0.9.0
95
+ also applies a character pre-cut before tokenization; this runtime intentionally
96
+ does not copy that heuristic. Anything that can silently change output is a
97
+ recorded field or an explicitly documented compatibility deviation.
87
98
 
88
99
  ## Vocabulary lookup and WordPiece trie
89
100
 
@@ -93,7 +104,7 @@ load factor ≤ 0.70, linear probing, FNV-1a with a fixed seed. The hash table i
93
104
  kept for validation, direct lookup and debugging.
94
105
 
95
106
  The hot WordPiece path no longer performs repeated hash lookups for every
96
- `end--` candidate. Format v2 also stores two converter-built, mmap-readable
107
+ `end--` candidate. The format also stores two converter-built, mmap-readable
97
108
  tries inspired by double-array trie libraries such as libdatrie:
98
109
 
99
110
  - `root_trie` for tokens that can start a word;
@@ -135,8 +146,8 @@ Rejected alternatives:
135
146
 
136
147
  ## Unicode without a Unicode library
137
148
 
138
- `BertNormalizer` needs NFD, simple lowercase, and the Mn / P / C / Zs
139
- categories. Rather than vendoring utf8proc, the converter generates exactly
149
+ `BertNormalizer` needs NFD, simple lowercase, and selected Unicode
150
+ categories (Mn, punctuation, control/format/private-use, and whitespace). Rather than vendoring utf8proc, the converter generates exactly
140
151
  those tables from the Ruby VM's own Unicode data and writes them into the
141
152
  model file; the runtime binary-searches them.
142
153
 
@@ -146,15 +157,20 @@ maps `ß` to `ss`; `str::to_lowercase`, which HF uses, does not. A gem that
146
157
  picked the wrong one would produce plausible, slightly wrong vectors forever.
147
158
 
148
159
  The Ruby build that generated the tables is stamped into provenance, so a
149
- model file and its normalizer can never drift apart unnoticed.
160
+ model file and its embedded normalizer tables can never drift apart unnoticed.
161
+ That stamp is not, by itself, proof of Hugging Face parity: 0.1.5 therefore
162
+ checks a systematic boundary corpus against pinned `tokenizers 0.23.1` in CI.
163
+ One subtle example is `Cn`: `bert.rs` says `is_other()` includes unassigned
164
+ codepoints, while the `unicode_categories 0.1.1` implementation actually tests
165
+ Cc/Cf/Co. The runtime follows the executable upstream behavior, not the comment.
150
166
 
151
167
  ## The embedding kernel
152
168
 
153
169
  ```
154
- tokenize -> ids
155
- truncate to max_tokens (before pooling — the reference contract)
170
+ tokenize -> raw ids (including [UNK])
156
171
  drop [UNK] if unk_policy = DROP
157
- acc[j] += row[j] for each id, in token order
172
+ truncate usable ids to max_tokens
173
+ acc[j] += row[j] for each usable id, in token order
158
174
  divide by the number of pooled rows
159
175
  L2 normalize if the model says so
160
176
  ```
@@ -209,10 +225,11 @@ therefore serialised the process on work that the tokenizer would discard after
209
225
 
210
226
  `embed` and `embed_batch` instead copy a leading slice. The budget starts at
211
227
  `max_tokens * 16` bytes clamped to `[4096, 65536]`, and the slice is cut at a
212
- position where the tokenizer would have started a fresh word. A result is
213
- accepted only when that slice actually reached `max_tokens`; otherwise the
214
- budget doubles and the text is tokenized again. Growth is geometric, so the
215
- pathological case costs about twice the single-pass work.
228
+ position where the tokenizer would have started a fresh word. A result is accepted only when that slice actually reached `max_tokens`
229
+ usable ids; `[UNK]` does not consume the embedding budget. Otherwise the budget
230
+ doubles and the text is tokenized again. Growth is geometric. OOV-heavy input
231
+ may therefore reach the full document: deciding that fewer than `max_tokens`
232
+ usable ids exist requires reading it.
216
233
 
217
234
  Three properties make this exact rather than approximate:
218
235
 
@@ -241,10 +258,11 @@ the predicate is Unicode-aware. Text with genuinely no legal cut inside the scan
241
258
  window - one enormous word, a run of combining marks - still falls back to a
242
259
  full copy, because there is nothing else that would be correct.
243
260
 
244
- `test/prefix_chunking_test.rb` sweeps every printable ASCII separator, checks
245
- CJK, NBSP and Unicode-punctuation documents against
246
- `embed_token_ids(tokenize(text))`, and asserts that quadrupling the input does
247
- not triple the cost.
261
+ `test/prefix_chunking_test.rb` sweeps every printable ASCII separator and
262
+ checks CJK, NBSP and Unicode-punctuation documents against unbounded raw
263
+ tokenization followed by `embed_token_ids`. It asserts bounded prefix cost for
264
+ in-vocabulary text; deliberately OOV-heavy input is checked for correctness, not
265
+ flat cost.
248
266
 
249
267
  With `max_tokens: false` there is nothing to truncate and the whole input is
250
268
  copied.
@@ -299,10 +317,13 @@ interrupts a GVL-free region.
299
317
  No global mutable state exists in C. The model is immutable after load.
300
318
  Scratch buffers are reused per OS thread via `pthread_key` / `FlsAlloc`, and
301
319
  the destructor frees them when the thread exits — including the short-lived
302
- threads the fiber scheduler path creates per large call. On release the slot
303
- is trimmed back to the reserve sizes, so a single unlimited-`max_tokens`
304
- document does not pin its working set until the thread dies. A nested call on
305
- the same thread allocates a one-off heap scratch that is freed on release.
320
+ threads the fiber scheduler path creates per large call. On release the
321
+ codepoint/byte buffers are trimmed back to their reserve sizes. The token-id
322
+ buffer keeps its observed high-water mark up to 8192 ids so repeated OOV-heavy
323
+ usable-token truncation does not grow and shrink it on every call; larger
324
+ capacities are trimmed back to that bound so one unlimited-`max_tokens`
325
+ document cannot pin an arbitrary per-thread working set. A nested call on the
326
+ same thread allocates a one-off heap scratch that is freed on release.
306
327
 
307
328
  ## Where this fits in a RAG pipeline
308
329
 
@@ -321,12 +342,11 @@ chunks -> .semb vectors --/
321
342
 
322
343
  ## Open questions before v1.0
323
344
 
324
- 1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The 32M family
325
- has a larger vocabulary than the bge-base tokenizer it was distilled from,
326
- which means tokens were added somewhere. If they live in `added_tokens`
327
- rather than `model.vocab`, HF matches them with a separate trie pass over
328
- raw text that this runtime does not implement. The converter refuses such
329
- models today; whether it has to is an audit question.
345
+ 1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The runtime now
346
+ implements the exact `normalized: false` extraction semantics needed by the
347
+ five standard BERT added tokens, but still rejects arbitrary AddedVocabulary,
348
+ BPE and Unigram profiles. Type A (WordPiece + mean) models convert without a
349
+ C change; each source still needs an audit before it is treated as compatible.
330
350
  2. **Russian.** Distilling a multilingual teacher into a WordPiece vocabulary
331
351
  we control keeps the pure-C path, but skips the Tokenlearn pre-training
332
352
  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
 
@@ -76,9 +81,10 @@ the entry says whether that is a decision or just unfinished work.
76
81
  - **`load` does not check the SHA-256.** Structural validation always runs, but
77
82
  a bit flip inside the matrix is only caught by `verify: true` or
78
83
  `StaticEmbeddings.verify`.
79
- - **Parity is per model and per runtime version.** A converted model is not
80
- trusted until it has a record in `docs/MODEL_AUDIT.md`, and that record
81
- 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.
82
88
 
83
89
  ## Known open questions
84
90