static_embeddings 0.1.5 → 1.5.7

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +17 -2
  4. data/docs/ARCHITECTURE.md +14 -5
  5. data/docs/MODEL_AUDIT.md +110 -0
  6. data/ext/static_embeddings/se_alloc_stats.c +5 -6
  7. data/ext/static_embeddings/se_f16.c +1 -1
  8. data/ext/static_embeddings/se_tokenizer.c +6 -0
  9. data/ext/static_embeddings/se_topk.c +1 -2
  10. data/ext/static_embeddings/static_embeddings.c +60 -10
  11. data/lib/models/demo.semb +0 -0
  12. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  13. data/lib/static_embeddings/canonical.rb +50 -0
  14. data/lib/static_embeddings/cli.rb +88 -63
  15. data/lib/static_embeddings/codec.rb +45 -0
  16. data/lib/static_embeddings/conversion.rb +58 -0
  17. data/lib/static_embeddings/errors.rb +2 -2
  18. data/lib/static_embeddings/format/constants.rb +109 -0
  19. data/lib/static_embeddings/format/hash_table.rb +69 -0
  20. data/lib/static_embeddings/format/trie.rb +78 -0
  21. data/lib/static_embeddings/format/verifier.rb +41 -0
  22. data/lib/static_embeddings/format/writer.rb +131 -0
  23. data/lib/static_embeddings/format.rb +3 -338
  24. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  25. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  26. data/lib/static_embeddings/importers/support.rb +111 -0
  27. data/lib/static_embeddings/importers.rb +50 -0
  28. data/lib/static_embeddings/model.rb +35 -20
  29. data/lib/static_embeddings/paths.rb +8 -12
  30. data/lib/static_embeddings/provenance.rb +58 -0
  31. data/lib/static_embeddings/reference.rb +50 -40
  32. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  33. data/lib/static_embeddings/version.rb +1 -1
  34. data/lib/static_embeddings.rb +29 -55
  35. data/static_embeddings.gemspec +2 -2
  36. data/tools/check_model2vec_parity.rb +5 -1
  37. data/tools/check_st_parity.rb +125 -0
  38. data/tools/eval_retrieval.rb +58 -0
  39. metadata +24 -6
  40. data/lib/static_embeddings/converter.rb +0 -328
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb1d3530fba4fad467251429797fdcaae527b5c6c18413e8847002e0c3f3b5e4
4
- data.tar.gz: f02bb4c129449803a57b08bf9b7186af08a6d21edee1e68d18178b248b855e0b
3
+ metadata.gz: a8e174dd93ba3b3e9ee25047fd28ea5ba980d096a4922c0b8325c366208bff65
4
+ data.tar.gz: 741e0cc35791d9a335c82a05aed18e3388232828824168ff59ed1751c281e8a8
5
5
  SHA512:
6
- metadata.gz: cec0a3959cb3f093c7bb2b06213b4c7e983986466a115eb79e0b045a5b52fdda9797be41365db41a74d85bb52c384c147d68d3d84c503fef10c122bba7b72333
7
- data.tar.gz: e99039606af3de0106e1ac26efaa78a06f09acbd9c2faaae9b4ca17339bdeb4830ba922bef84dc141317898556d09b1ec41cf1ebfebcce5e141d877c3fc34598
6
+ metadata.gz: 58462ed4c203fb657630d092e1a4aa85857e5d44106bb97feae26f12579fc4b6d88d20632e462624ad0cb93e1d0d0e735f91ee12aaec80b637d29b313e6dc53d
7
+ data.tar.gz: 80e9897969dfe6aace3d851f1a11ffb6610b628cddc35239301a676b2ce32d97d1a8a614ae6dca0526703e61d6d813af89965d0906c593392e66cdd25f7e16da
data/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.7
4
+
5
+ ### Fixed
6
+
7
+ - Model mappings are no longer closed while embedding, warmup, or token-ID
8
+ operations are using them without the GVL. `Model#close` raises
9
+ `StaticEmbeddings::Error` while such an operation is active.
10
+ - Tokenizer code-point writes now verify the capacity invariant after growth.
11
+ - Resolved Clang conversion warnings and formatting issues in the native code.
12
+
13
+ ## 1.5.6
14
+
15
+ Multi-source WordPiece release. The native `.semb` v3 runtime is unchanged; new
16
+ source families are normalized offline into the same audited
17
+ `BERT_WORDPIECE_V1 -> lookup -> mean -> NONE/L2` contract. Model identity is
18
+ chosen at `load_model`, not per `embed` call.
19
+
20
+ ### Added
21
+
22
+ - **Model2Vec and Sentence Transformers importers.** A Model2Vec directory and a
23
+ Sentence Transformers `StaticEmbedding` directory (`modules.json` + exactly
24
+ one module path) compile to the same canonical data. The ST importer is
25
+ fail-closed and confines the module path to the source root with `realpath`.
26
+ - **Source-faithful policies.** Model2Vec uses `UNK_DROP`, normalization from its
27
+ config and a 512-token default. Sentence Transformers `StaticEmbedding` uses
28
+ `UNK_INCLUDE`, `NORMALIZATION_NONE`, unlimited tokens and
29
+ `add_special_tokens=false`.
30
+ - **`--dimensions N`.** Matryoshka prefix slicing happens while streaming the
31
+ embedding matrix; the runtime still loads an ordinary fixed-dimension `.semb`.
32
+ - **`--max-tokens unlimited`.** Convert-time `0` remains an alias; runtime
33
+ `max_tokens: 0` is still rejected and `false` means unlimited.
34
+ - **Russian retrieval fixture.** `tools/eval_retrieval.rb` and the checked-in
35
+ Russian FAQ corpus provide a small domain sanity check for the multilingual
36
+ WordPiece model; it is not presented as a general benchmark.
37
+
38
+ ### Changed
39
+
40
+ - **Offline Ruby conversion was simplified.** Stateful `Converter` and the
41
+ behavior-heavy `CanonicalModel` were replaced by import functions, immutable
42
+ canonical data, pure provenance/meta transforms and a streaming format writer.
43
+ Writer/hash/trie/verifier code is split from runtime format constants, so plain
44
+ `require "static_embeddings"` does not load conversion code.
45
+ - The Ruby `Reference` twin follows canonical UNK/normalization/max-token
46
+ policies instead of re-deriving Model2Vec defaults.
47
+ - Model2Vec `normalize` now defaults to `false` when the key is absent, matching
48
+ the pinned upstream behavior. Unknown source families are rejected rather than
49
+ guessed from `tokenizer.json + model.safetensors`.
50
+ - Header integer writes validate their unsigned range instead of silently
51
+ wrapping oversized Ruby integers.
52
+ - `Model#provenance` is parsed once and cached.
53
+
54
+ Local WordPiece conversions requiring **no C change**: `potion-base-8M`,
55
+ `potion-science-32M`, `static-retrieval-mrl-en-v1` at 1024/512 and
56
+ `static-similarity-mrl-multilingual-v1` at 512/256. The retrieval model has a
57
+ recorded `SentenceTransformer.encode` oracle (436/436). Records are in
58
+ `docs/MODEL_AUDIT.md`.
59
+
60
+ SentencePiece/Unigram is intentionally **not** part of 1.5.6; it remains a
61
+ future runtime capability rather than shipping an unaudited v4 path.
62
+
3
63
  ## 0.1.5
4
64
 
5
65
  Correctness/parity release. The native runtime architecture is unchanged, but
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"
@@ -49,6 +49,21 @@ bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
49
49
  --id potion-retrieval-32m
50
50
  ```
51
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
+
52
67
  The result lands in `~/.cache/static_embeddings/models/potion-retrieval-32m.semb`.
53
68
  Inspect or verify it with the `inspect` and `verify` subcommands, then load it:
54
69
 
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
@@ -336,8 +345,8 @@ chunks -> .semb vectors --/
336
345
  1. **Which potion models actually match `BERT_WORDPIECE_V1`.** The runtime now
337
346
  implements the exact `normalized: false` extraction semantics needed by the
338
347
  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.
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.
341
350
  2. **Russian.** Distilling a multilingual teacher into a WordPiece vocabulary
342
351
  we control keeps the pure-C path, but skips the Tokenlearn pre-training
343
352
  that gives the published potion models their quality. That gap has to be
data/docs/MODEL_AUDIT.md CHANGED
@@ -119,3 +119,113 @@ synthetic `tiny-wordpiece` fixture: `434/434`, all four failure lists empty,
119
119
  `min_cosine=0.9999999999999988`, `max_abs_all=5.960464477539063e-08`. That job
120
120
  proves the fixture loads in `StaticModel.from_pretrained` and that the checker
121
121
  contracts hold; it is not a potion audit.
122
+
123
+ ## 1.5.6 Patch 1 proof
124
+
125
+ Architecture: Model2Vec / Sentence Transformers import functions → immutable canonical data →
126
+ existing WordPiece `.semb` v3 writer. C runtime unchanged.
127
+
128
+ ### potion-retrieval-32M reconvert
129
+
130
+ Official snapshot `6fc8051fab2a1e0ee76689cf08c853792ac285e7` ships a Sentence
131
+ Transformers `modules.json` (`StaticEmbedding` + `Normalize`). Detection uses
132
+ `config.json` `model_type=model2vec` first, so this stays UNK_DROP / L2 / 512.
133
+
134
+ New file `potion-retrieval-32m-reconvert.semb` vs existing
135
+ `potion-retrieval-32m-v3.semb`: vocab/hash/embeddings/norm_tables/tries
136
+ byte-identical. 10-text corpus `max_abs=0`. Provenance JSON differs (new keys).
137
+
138
+ ### static-retrieval-mrl-en-v1 vs SentenceTransformer.encode
139
+
140
+ Oracle: `tools/st_oracle.py` + `tools/check_st_parity.rb`,
141
+ `sentence-transformers 6.0.1`, `add_special_tokens=false`,
142
+ `normalize_embeddings=false`, 436 rows including `all-unk:private-use` and
143
+ `long:known-words-600`.
144
+
145
+ ```text
146
+ 1024: 436/436 min_cosine=0.999999999999999 max_abs_all=7.62939453125e-06
147
+ 512: 436/436 min_cosine=0.9999999999999989 max_abs_all=7.62939453125e-06
148
+ ```
149
+
150
+ 512 compared as prefix of the 1024-d encode vectors. Ruby Reference matched C
151
+ separately. Diagnostic `StaticEmbedding.forward` matched encode on this model.
152
+
153
+ ### UNK and max_tokens on the real models
154
+
155
+ - potion-retrieval-32M `🧬 🧬 🧬` → UNK ids dropped → zero vector; 600×`hello`
156
+ → `truncated=true pooled=512`.
157
+ - static-retrieval-mrl-en-v1-1024 same emoji string → three UNK ids pooled
158
+ (non-zero, mean of UNK rows); 600×`hello` → `truncated=false pooled=600`.
159
+ - Private-use codepoints are BertNormalizer `Co` and become empty, not UNK.
160
+
161
+ ## 1.5.6 Type A conversions
162
+
163
+ These records prove the offline import layer maps extra WordPiece sources onto
164
+ the existing runtime. They are **not** a replacement for the
165
+ potion-retrieval-32m Python oracle above. `static-retrieval-mrl-en-v1` has the
166
+ separate `SentenceTransformer.encode` oracle recorded above; the multilingual
167
+ similarity artifact was checked against the Ruby `.semb` Reference twin and the
168
+ small Russian retrieval fixture, not claimed as a general upstream retrieval
169
+ benchmark.
170
+
171
+ All conversions used `static_embeddings 1.5.6` on arm64-darwin24.
172
+
173
+ ### minishlab/potion-base-8M
174
+
175
+ - Snapshot: `bf8b056651a2c21b8d2565580b8569da283cab23`
176
+ - Layout: Model2Vec (`UNK_DROP`, L2, `max_tokens` 512)
177
+ - `.semb`: `potion-base-8m.semb` `dim=256` `vocab=29528` `33346200` bytes
178
+ SHA256 `8abd8d1f26511959e14ca26ec4dcacad1d3db9b4b66b2e67683fb2d06074f490`
179
+ - Native C matched Ruby Reference on short English/OOV/empty texts (`max_abs < 1e-5`)
180
+ - `model2vec 0.9.0` / `tokenizers 0.23.1` oracle: 434/434, vectors_checked=432,
181
+ intentional character-pretruncate deviations=2, min_cosine=0.9999999999999989,
182
+ max_abs_all=2.98e-08
183
+
184
+ ### minishlab/potion-science-32M
185
+
186
+ - Snapshot: `7366079845507de14a4330007cdfa01bb92bca52`
187
+ - Layout: Model2Vec (`UNK_DROP`, L2, `max_tokens` 512)
188
+ - `.semb`: `potion-science-32m.semb` `dim=256` `vocab=124428` `140353240` bytes
189
+ SHA256 `e968f93b89d59d9c63c6a8152495283d1138144e61ed5c08d306c21c00b7cdb1`
190
+ - Native C matched Ruby Reference on English and Russian snippets (`max_abs < 1e-5`)
191
+ - `model2vec 0.9.0` / `tokenizers 0.23.1` oracle: 434/434, vectors_checked=432,
192
+ intentional character-pretruncate deviations=2, min_cosine=0.9999999999999986,
193
+ max_abs_all=2.98e-08
194
+
195
+ ### sentence-transformers/static-retrieval-mrl-en-v1
196
+
197
+ - Snapshot: `f60985c706f192d45d218078e49e5a8b6f15283a`
198
+ - Layout: Sentence Transformers StaticEmbedding (`UNK_INCLUDE`, no L2, unlimited)
199
+ - `static-retrieval-mrl-en-v1-1024.semb` `dim=1024` `vocab=30522` `128186264` bytes
200
+ SHA256 `f316bb07348503418ed0f6d897ddbf7b44422d2077ca4860518f31f328610946`
201
+ - `static-retrieval-mrl-en-v1-512.semb` `--dimensions 512` `65677208` bytes
202
+ SHA256 `efbb43c14def793d0bbb42ef591a0951caed23a49cdd94dcf72312569d1b7cff`
203
+ - Prefix of a 1024-d vector matched the 512-d artifact on in-vocabulary English
204
+ (valid because this source does not L2-normalize)
205
+ - Native C matched Ruby Reference on short texts
206
+
207
+ ### sentence-transformers/static-similarity-mrl-multilingual-v1
208
+
209
+ - Snapshot: `b68f4122911bcffcd6e1f695f2d99cd6788972d8`
210
+ - Layout: Sentence Transformers StaticEmbedding (`UNK_INCLUDE`, no L2, unlimited)
211
+ - `static-similarity-mrl-multilingual-v1-512.semb` `--dimensions 512` `vocab=105879`
212
+ `227983824` bytes SHA256 `8d9a63d20ee23468ae2fd2584ef3f0ec75e720e68410e416157aaf2b873e110e`
213
+ - `static-similarity-mrl-multilingual-v1-256.semb` `--dimensions 256` `119563728` bytes
214
+ SHA256 `2e9e689bd3afd18ed50a77f0f8842b4b5a5be39bfad75e3fa24ad99b478de068`
215
+ - Native C matched Ruby Reference on Russian text (`max_abs 9.1e-7`)
216
+
217
+ This model is published as similarity, not retrieval. `tools/eval_retrieval.rb`
218
+ on `test/fixtures/russian_faq_eval.json` (10 queries / 10 docs, cosine@10,
219
+ arm64-darwin24, 1.5.6):
220
+
221
+ | model | dim | MRR | nDCG@10 |
222
+ |---|---:|---:|---:|
223
+ | static-similarity-mrl-multilingual-v1-256 | 256 | 0.875 | 0.906 |
224
+ | static-similarity-mrl-multilingual-v1-512 | 512 | 0.850 | 0.889 |
225
+ | static-retrieval-mrl-en-v1-512 | 512 | 0.792 | 0.842 |
226
+ | potion-retrieval-32m | 512 | 0.733 | 0.799 |
227
+ | potion-base-8m | 256 | 0.712 | 0.780 |
228
+
229
+ Hit@10 was 1.0 for every model on this tiny labeled set. That is a domain sanity
230
+ check, not a public retrieval benchmark, and it does not make the similarity
231
+ model a hard-coded Russian default.
@@ -5,9 +5,8 @@
5
5
  #include <string.h>
6
6
 
7
7
  static const char *const se_alloc_category_names[SE_ALLOC_CATEGORY_COUNT] = {
8
- "unknown", "scratch", "batch_input", "batch_output",
9
- "batch_stats", "batch_index", "token_ids", "topk_query",
10
- "topk_best", "topk_matrix_copy", "format_validate", "model_file"};
8
+ "unknown", "scratch", "batch_input", "batch_output", "batch_stats", "batch_index",
9
+ "token_ids", "topk_query", "topk_best", "topk_matrix_copy", "format_validate", "model_file"};
11
10
 
12
11
  const char *se_alloc_category_name(se_alloc_category_t category) {
13
12
  if ((unsigned)category >= SE_ALLOC_CATEGORY_COUNT)
@@ -176,7 +175,8 @@ void *se_alloc_stats_realloc(se_alloc_category_t category, void *ptr, size_t byt
176
175
  if (old_header->h.magic != SE_ALLOC_STATS_MAGIC)
177
176
  abort();
178
177
 
179
- se_alloc_category_t old_category = normalize_category((se_alloc_category_t)old_header->h.category);
178
+ se_alloc_category_t old_category =
179
+ normalize_category((se_alloc_category_t)old_header->h.category);
180
180
  size_t old_bytes = old_header->h.size;
181
181
  se_alloc_header_t *new_header = (se_alloc_header_t *)realloc(old_header, total);
182
182
  if (!new_header)
@@ -223,8 +223,7 @@ void se_alloc_stats_snapshot(se_alloc_stats_t out[SE_ALLOC_CATEGORY_COUNT]) {
223
223
  for (size_t i = 0; i < SE_ALLOC_CATEGORY_COUNT; i++) {
224
224
  out[i].current_bytes = atomic_load_size(&se_alloc_stats[i].current_bytes);
225
225
  out[i].peak_bytes = atomic_load_size(&se_alloc_stats[i].peak_bytes);
226
- out[i].total_allocated_bytes =
227
- atomic_load_size(&se_alloc_stats[i].total_allocated_bytes);
226
+ out[i].total_allocated_bytes = atomic_load_size(&se_alloc_stats[i].total_allocated_bytes);
228
227
  out[i].total_freed_bytes = atomic_load_size(&se_alloc_stats[i].total_freed_bytes);
229
228
  out[i].alloc_count = atomic_load_size(&se_alloc_stats[i].alloc_count);
230
229
  out[i].realloc_count = atomic_load_size(&se_alloc_stats[i].realloc_count);
@@ -35,7 +35,7 @@ static void write_u16le(uint8_t *dst, uint16_t v) {
35
35
  }
36
36
 
37
37
  static uint16_t read_u16le(const uint8_t *src) {
38
- return (uint16_t)src[0] | ((uint16_t)src[1] << 8);
38
+ return (uint16_t)((uint16_t)src[0] | ((uint16_t)src[1] << 8));
39
39
  }
40
40
 
41
41
  uint16_t se_float_to_f16_bits(float value) {
@@ -512,6 +512,8 @@ static int append_segment_cp(token_state_t *st, uint32_t cp) {
512
512
  st->segment_ascii = 0;
513
513
  if (!grow_u32(&st->scratch->cps, &st->scratch->cps_cap, st->segment_len + 1))
514
514
  return 0;
515
+ if (st->segment_len >= st->scratch->cps_cap)
516
+ return 0;
515
517
  st->scratch->cps[st->segment_len++] = cp;
516
518
  return 1;
517
519
  }
@@ -906,6 +908,10 @@ static se_status_t tokenize_ascii_run(token_state_t *st, const uint8_t *input, s
906
908
  return oom(st, "building token segment");
907
909
  }
908
910
  }
911
+ if (st->segment_len >= sc->cps_cap) {
912
+ *ip = i;
913
+ return oom(st, "building token segment");
914
+ }
909
915
  sc->cps[st->segment_len++] =
910
916
  (lc && b >= 'A' && b <= 'Z') ? (uint32_t)(b + 32u) : (uint32_t)b;
911
917
  i++;
@@ -73,8 +73,7 @@ float se_dot_product_f32(const float *q, const float *row, size_t dim) {
73
73
  return dot;
74
74
  }
75
75
 
76
- float se_dot_and_row_sq_f32(const float *q, const float *row, size_t dim,
77
- float *row_sq_out) {
76
+ float se_dot_and_row_sq_f32(const float *q, const float *row, size_t dim, float *row_sq_out) {
78
77
  size_t j = 0;
79
78
  #if defined(SE_HAVE_NEON)
80
79
  float32x4_t d0 = vdupq_n_f32(0.0f);
@@ -93,6 +93,7 @@ RUBY_FUNC_EXPORTED void Init_static_embeddings(void);
93
93
  typedef struct {
94
94
  se_model_t model;
95
95
  int open;
96
+ unsigned int active_ops;
96
97
  } model_wrapper_t;
97
98
 
98
99
  static void model_free(void *ptr) {
@@ -177,7 +178,7 @@ static void check_text_encoding_mode(VALUE str, long index, se_encoding_validati
177
178
  rb_enc_name(enc));
178
179
  }
179
180
 
180
- int cr = ENC_CODERANGE(str);
181
+ int cr = (int)ENC_CODERANGE(str);
181
182
  if (cr == ENC_CODERANGE_UNKNOWN) {
182
183
  if (mode == SE_VALIDATE_ENCODING_PREFIX)
183
184
  return;
@@ -216,6 +217,7 @@ typedef struct {
216
217
 
217
218
  typedef struct {
218
219
  const se_model_t *model;
220
+ model_wrapper_t *owner;
219
221
  batch_input_t input;
220
222
  uint32_t max_tokens;
221
223
  float *out;
@@ -228,6 +230,8 @@ typedef struct {
228
230
  se_error_t error;
229
231
  } batch_job_t;
230
232
 
233
+ static void run_job(batch_job_t *job, size_t total_bytes);
234
+
231
235
  static void job_fail(batch_job_t *job, se_error_t err, size_t index) {
232
236
  if (job->failed)
233
237
  return;
@@ -361,6 +365,21 @@ static VALUE fiber_worker_ensure(VALUE wrapper) {
361
365
  return Qnil;
362
366
  }
363
367
 
368
+ static VALUE run_job_body(VALUE arg) {
369
+ batch_job_t *job = (batch_job_t *)(uintptr_t)arg;
370
+ size_t total_bytes = 0;
371
+ for (size_t i = 0; i < job->input.count; i++)
372
+ total_bytes += job->input.lengths[i];
373
+ run_job(job, total_bytes);
374
+ return Qnil;
375
+ }
376
+
377
+ static VALUE run_job_ensure(VALUE arg) {
378
+ batch_job_t *job = (batch_job_t *)(uintptr_t)arg;
379
+ job->owner->active_ops--;
380
+ return Qnil;
381
+ }
382
+
364
383
  static void run_job(batch_job_t *job, size_t total_bytes) {
365
384
  VALUE scheduler = current_fiber_scheduler();
366
385
 
@@ -386,11 +405,8 @@ static void run_job(batch_job_t *job, size_t total_bytes) {
386
405
 
387
406
  static VALUE protected_run_job(VALUE arg) {
388
407
  batch_job_t *job = (batch_job_t *)(uintptr_t)arg;
389
- size_t total_bytes = 0;
390
- for (size_t i = 0; i < job->input.count; i++)
391
- total_bytes += job->input.lengths[i];
392
- run_job(job, total_bytes);
393
- return Qnil;
408
+ job->owner->active_ops++;
409
+ return rb_ensure(run_job_body, arg, run_job_ensure, arg);
394
410
  }
395
411
 
396
412
  static VALUE model_alloc(VALUE klass) {
@@ -422,6 +438,8 @@ static VALUE model_close(VALUE self) {
422
438
  model_wrapper_t *w;
423
439
  TypedData_Get_Struct(self, model_wrapper_t, &model_type, w);
424
440
  if (w->open) {
441
+ if (w->active_ops != 0)
442
+ rb_raise(eError, "model is busy with an active operation");
425
443
  se_model_close(&w->model);
426
444
  w->open = 0;
427
445
  }
@@ -474,18 +492,40 @@ typedef struct {
474
492
  size_t pages;
475
493
  } warmup_job_t;
476
494
 
495
+ typedef struct {
496
+ VALUE self;
497
+ model_wrapper_t *owner;
498
+ warmup_job_t job;
499
+ } warmup_run_t;
500
+
477
501
  static void *warmup_execute(void *arg) {
478
502
  warmup_job_t *job = (warmup_job_t *)arg;
479
503
  job->pages = se_model_warmup(job->model);
480
504
  return NULL;
481
505
  }
482
506
 
507
+ static VALUE warmup_body(VALUE arg) {
508
+ warmup_run_t *run = (warmup_run_t *)(uintptr_t)arg;
509
+ rb_thread_call_without_gvl(warmup_execute, &run->job, NULL, NULL);
510
+ RB_GC_GUARD(run->self);
511
+ return Qnil;
512
+ }
513
+
514
+ static VALUE warmup_ensure(VALUE arg) {
515
+ warmup_run_t *run = (warmup_run_t *)(uintptr_t)arg;
516
+ run->owner->active_ops--;
517
+ return Qnil;
518
+ }
519
+
483
520
  static VALUE model_warmup(VALUE self) {
484
521
  model_wrapper_t *w = get_model(self);
485
- warmup_job_t job;
486
- job.model = &w->model;
487
- job.pages = 0;
488
- rb_thread_call_without_gvl(warmup_execute, &job, NULL, NULL);
522
+ warmup_run_t run;
523
+ run.self = self;
524
+ run.owner = w;
525
+ run.job.model = &w->model;
526
+ run.job.pages = 0;
527
+ w->active_ops++;
528
+ rb_ensure(warmup_body, (VALUE)(uintptr_t)&run, warmup_ensure, (VALUE)(uintptr_t)&run);
489
529
  RB_GC_GUARD(self);
490
530
  return self;
491
531
  }
@@ -829,6 +869,7 @@ static VALUE embed_texts_internal(VALUE self, VALUE texts, int is_array, size_t
829
869
  batch_job_t job;
830
870
  memset(&job, 0, sizeof(job));
831
871
  job.model = model;
872
+ job.owner = w;
832
873
  job.max_tokens = max_tokens;
833
874
  job.out = run.out;
834
875
  job.stats = run.stats;
@@ -1116,6 +1157,7 @@ typedef struct {
1116
1157
 
1117
1158
  typedef struct {
1118
1159
  VALUE ids_value;
1160
+ model_wrapper_t *owner;
1119
1161
  const se_model_t *model;
1120
1162
  uint32_t max_tokens;
1121
1163
  uint32_t *ids;
@@ -1126,6 +1168,7 @@ typedef struct {
1126
1168
  se_token_stats_t *stats_out;
1127
1169
  se_token_stats_t stats;
1128
1170
  int truncated;
1171
+ int active;
1129
1172
  } ids_run_t;
1130
1173
 
1131
1174
  typedef struct {
@@ -1197,7 +1240,11 @@ static VALUE embed_token_ids_body(VALUE arg) {
1197
1240
  se_error_clear(&job.error);
1198
1241
 
1199
1242
  if (run->n >= SE_IDS_GVL_UNLOCK_THRESHOLD) {
1243
+ run->owner->active_ops++;
1244
+ run->active = 1;
1200
1245
  rb_thread_call_without_gvl(ids_execute, &job, ids_unblock_cancel, &job);
1246
+ run->owner->active_ops--;
1247
+ run->active = 0;
1201
1248
  rb_thread_check_ints();
1202
1249
  if (job.cancelled)
1203
1250
  rb_raise(rb_eInterrupt, "operation cancelled");
@@ -1225,6 +1272,8 @@ static VALUE embed_token_ids_body(VALUE arg) {
1225
1272
 
1226
1273
  static VALUE embed_token_ids_ensure(VALUE arg) {
1227
1274
  ids_run_t *run = (ids_run_t *)(uintptr_t)arg;
1275
+ if (run->active)
1276
+ run->owner->active_ops--;
1228
1277
  se_free(run->ids);
1229
1278
  se_free(run->out);
1230
1279
  run->ids = NULL;
@@ -1280,6 +1329,7 @@ static VALUE embed_token_ids_value(VALUE self, VALUE ids_value, VALUE max_tokens
1280
1329
  ids_run_t run;
1281
1330
  memset(&run, 0, sizeof(run));
1282
1331
  run.ids_value = ids_value;
1332
+ run.owner = w;
1283
1333
  run.model = &w->model;
1284
1334
  run.max_tokens = max_tokens;
1285
1335
  run.n = n;
Binary file