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.
data/docs/MODEL_AUDIT.md CHANGED
@@ -1,58 +1,46 @@
1
1
  # Model Audit
2
2
 
3
- A converted model is trusted only after parity against upstream
4
- `model2vec.StaticModel` is recorded here. An audit record covers one `.semb`
5
- file **and** one runtime version: changing the tokenizer, normalizer, prefix
6
- window, pooling, output normalization or the oracle's corpus invalidates the
7
- runtime half of it even though the file bytes are untouched.
3
+ A converted model is trusted only after a recorded comparison against an
4
+ external upstream oracle. An audit record covers one source-model snapshot,
5
+ one `.semb` format/runtime contract, pinned upstream package versions, and the
6
+ specific corpus that was checked. A passing corpus is evidence for those rows;
7
+ it is **not** an exhaustive proof that every Unicode string is equivalent.
8
+
9
+ `StaticEmbeddings::Reference` remains useful for high-volume differential fuzz
10
+ of the C implementation, but it is an implementation twin and is not counted as
11
+ independent upstream evidence.
8
12
 
9
13
  ## potion-retrieval-32m
10
14
 
11
- | runtime | parity | note |
15
+ | runtime | external result | note |
12
16
  |---|---|---|
13
- | 0.1.1 | `parity OK`, recorded below | superseded |
14
- | 0.1.2 | **not re-run** | superseded before release |
15
- | 0.1.3 | `parity OK`, recorded below | current |
17
+ | 0.1.1 | 31-row corpus passed | superseded |
18
+ | 0.1.2 | not re-run | superseded before release |
19
+ | 0.1.3 | 31-row corpus passed | superseded |
20
+ | 0.1.4 | 31-row corpus passed | historical result below; later review found uncovered boundary cases |
21
+ | 0.1.5 | **434-row corpus passed** | format v3; CI `potion_audit` record below |
16
22
 
17
- 0.1.2 changed `is_control()`, which changes token ids for any input containing
18
- `U+007F`. `StaticEmbeddings::Reference` cannot settle whether the new behaviour
19
- matches HuggingFace, because it is an implementation twin of the C runtime
20
- written in this repository. The 0.1.3 release candidate was checked against a
21
- fresh upstream `model2vec.StaticModel` oracle that includes DEL, control
22
- characters, Unicode, OOV, long-word and truncation rows.
23
+ ### Historical 0.1.4 record
23
24
 
24
25
  Source model:
25
26
 
26
27
  - Hugging Face repository: `minishlab/potion-retrieval-32M`
27
28
  - Hugging Face snapshot: `6fc8051fab2a1e0ee76689cf08c853792ac285e7`
28
29
  - Oracle implementation: `model2vec.StaticModel.from_pretrained`
29
- - Python package: `model2vec 0.9.0`
30
- - Oracle file: `tmp/model2vec_oracle.json`
30
+ - Python package: `model2vec 0.9.0` (`tokenizers 0.23.1`, `numpy 2.5.2`)
31
31
  - Oracle rows in this recorded run: `31`
32
32
  - Oracle dimension: `512`
33
33
  - Oracle max length: `512`
34
- - Runtime at time of this record: `static_embeddings 0.1.3`
34
+ - Runtime at time of this record: `static_embeddings 0.1.4`
35
35
 
36
36
  Converted `.semb`:
37
37
 
38
- - Path: `$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb`
39
38
  - Format version: `2`
40
39
  - Header size: `320`
41
40
  - Bytes: `135411608`
42
41
  - SHA256: `79e087863d2bab825779fd7de3574e5625542ef6a5ecad33fe681ea16d4b3ab0`
43
42
 
44
- Parity command:
45
-
46
- ```bash
47
- python tools/model2vec_oracle.py minishlab/potion-retrieval-32M \
48
- --out tmp/model2vec_oracle.json
49
-
50
- bundle exec rake parity \
51
- MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
52
- ORACLE=tmp/model2vec_oracle.json
53
- ```
54
-
55
- Parity result:
43
+ Recorded result (2026-08-31):
56
44
 
57
45
  ```text
58
46
  rows=31
@@ -64,17 +52,70 @@ vector_failures=[]
64
52
  parity OK
65
53
  ```
66
54
 
67
- Decision:
55
+ The last line means **31/31 rows in that historical oracle passed**. It must not
56
+ be read as a global tokenizer-equivalence claim. The corpus did not cover, for
57
+ example, the corrected UNK-before-truncation ordering, standard AddedVocabulary
58
+ literals, or the Rust/Python CJK Extension E boundary.
59
+
60
+ ## 0.1.5 record
61
+
62
+ 0.1.5 separates three contracts that the old oracle mixed together:
63
+
64
+ 1. raw Hugging Face `tokenizers 0.23.1` ids, including `[UNK]`;
65
+ 2. this runtime's usable-id embedding contract (`[UNK]` drop, then token cap);
66
+ 3. `model2vec.StaticModel` vectors where its character pre-cut does not change
67
+ the usable token sequence.
68
+
69
+ Rows changed solely by Model2Vec's `max_length * median_token_length` character
70
+ pre-cut are reported as intentional deviations rather than hidden inside a pass.
71
+ A passing corpus is evidence for those 434 rows, not an exhaustive proof of
72
+ every Unicode string.
73
+
74
+ Recorded from GitHub Actions (`potion_audit`, Python 3.12.14, pinned
75
+ `model2vec==0.9.0` / `tokenizers==0.23.1` / `numpy==2.5.2`):
76
+
77
+ Source model:
78
+
79
+ - Hugging Face repository: `minishlab/potion-retrieval-32M`
80
+ - Hugging Face snapshot: `6fc8051fab2a1e0ee76689cf08c853792ac285e7`
81
+ - Oracle implementation: `model2vec.StaticModel.from_pretrained`
82
+ - Oracle rows: `434` from `tools/parity_cases.py`
83
+ - Oracle dimension: `512`
84
+ - Oracle max length: `512`
85
+ - Runtime at time of this record: `static_embeddings 0.1.5`
86
+
87
+ Converted `.semb` (CI artifact; Unicode tables stamped from the Ubuntu Ruby that
88
+ converted it, so the SHA is not expected to match a macOS local convert of the
89
+ same snapshot):
90
+
91
+ - Format version: `3`
92
+ - Bytes: `135411800`
93
+ - SHA256: `747231b5afbcb3b16bf2b04538f81d3a96be88a798982214b7cf01eddbdcf4eb`
94
+ - `dim=512` `vocab=63091`
95
+
96
+ Recorded result:
68
97
 
69
- - Tokenization parity: pass
70
- - Vector parity: pass
71
- - Empty input behavior: pass
72
- - Whitespace behavior: pass
73
- - Unicode normalization behavior: pass
74
- - Unknown-word behavior: pass
75
- - Long input / truncation behavior: pass
76
- - DEL / control-character behavior: pass
98
+ ```text
99
+ rows=434
100
+ vectors_checked=432
101
+ intentional_character_pretruncate_deviations=2
102
+ min_cosine=0.9999999999999989
103
+ max_abs_all=1.4901161193847656e-08
104
+ raw_token_id_failures=[]
105
+ usable_token_id_failures=[]
106
+ embed_invariant_failures=[]
107
+ vector_failures=[]
108
+ corpus parity OK (434/434); intentional Model2Vec character pre-truncation deviations are reported separately
109
+ ```
77
110
 
78
- Accepted for runtime and benchmark use **under 0.1.3**. The `.semb` file is
79
- unchanged and its SHA256 still matches; the runtime half of the record was
80
- refreshed after the DEL fix.
111
+ The two intentional deviations are `long:sparse-whitespace` and
112
+ `long:unknown-prefix`. Vectors were not required to match Model2Vec on those
113
+ rows. The remaining 432 rows were inside `cosine >= 1 - 1e-6` and
114
+ `max_abs <= 1e-5`.
115
+
116
+ The preceding CI job `upstream_parity` ran the same 434-row corpus against the
117
+ synthetic `tiny-wordpiece` fixture: `434/434`, all four failure lists empty,
118
+ `vectors_checked=429`, `intentional_character_pretruncate_deviations=5`,
119
+ `min_cosine=0.9999999999999988`, `max_abs_all=5.960464477539063e-08`. That job
120
+ proves the fixture loads in `StaticModel.from_pretrained` and that the checker
121
+ contracts hold; it is not a potion audit.
data/docs/PERFORMANCE.md CHANGED
@@ -22,33 +22,42 @@ ruby tools/benchmark.rb [model.semb]
22
22
 
23
23
  ## Where the time actually goes
24
24
 
25
- Tokenization dominates; pooling is a handful of row reads and adds. That is why
26
- the tokenizer is the file to profile, and why SIMD in the pooling loop is not
27
- where the wins are that loop is memory-bound on random row lookups and the
28
- compiler already vectorises it.
29
-
30
- Out-of-vocabulary text is the main cliff. In-vocabulary words hit the
31
- vocabulary hash directly; everything else falls through to trie-driven subword
32
- splitting, which on a synthetic all-OOV corpus measured several times the
33
- per-text cost. Non-English input on an English model pays this on top of the
34
- `[UNK]` quality problem.
25
+ Which file to profile depends on the model and the text. On a short-dimension
26
+ demo model, or on synthetic all-OOV input, tokenization dominates. On
27
+ `potion-retrieval-32m` (`dim=512`) with in-vocabulary English, pooling is the
28
+ larger share: each token is a random 2 KiB row add, and before 0.1.4 the scalar
29
+ L2 pass showed up as about 19% of an ASCII `embed_batch` sample tree. SIMD in
30
+ that L2 slot was a real win; another unroll of the add loop was not it is
31
+ memory-bound on those gathers, and the compiler already vectorises it.
32
+
33
+ Out-of-vocabulary text is the remaining tokenizer cliff. In-vocabulary words
34
+ hit the vocabulary hash directly; everything else falls through to trie-driven
35
+ subword splitting, which on a synthetic all-OOV corpus measured several times
36
+ the per-text cost. Non-English input on an English model pays this on top of
37
+ the `[UNK]` quality problem. Quote `mean_tokens_per_text` and `unk_ratio` from
38
+ the batch samples before comparing texts/s across modes: hashes are slower
39
+ than ASCII on this corpus mostly because they emit ~73 tokens/text against
40
+ ~31, not only because they miss the hash.
35
41
 
36
42
  ## `f16` is a storage trade-off
37
43
 
38
- `format: :f16` halves the bytes a top-k scan streams and doubles the decode
39
- work. Which one wins is a property of the machine, and this repository's own
40
- sample runs disagree with each other — same corpus, same `k`, native decode
41
- kernel in both cases:
44
+ `format: :f16` halves the bytes a top-k scan streams and costs a decode per
45
+ row. Which one wins is a property of the machine and of the decode kernel, and
46
+ this repository's own sample runs disagree with each other — same corpus, same
47
+ `k`, native decode kernel in both cases:
42
48
 
43
49
  ```text
44
50
  f32 f16
45
- x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster
46
- M1 Pro, neon-fp16 2.29 ms 2.88 ms f16 1.3x slower
51
+ x86-64, f16c 3.00 ms 1.65 ms f16 1.8x faster (F16C kernel, unchanged)
52
+ M1 Pro, neon-fp16 2.36 ms 1.81 ms f16 1.3x faster (0.1.4, 16-wide NEON)
47
53
  ```
48
54
 
49
- Neither ratio transfers. Confirm `StaticEmbeddings.simd_backend`, then measure
50
- on the hardware you will run on. On the lookup-table fallback `f16` is usually
51
- slower than `f32`.
55
+ The M1 Pro row in 0.1.3 was 2.29 / 2.88 ms, f16 1.3x *slower*, with an 8-wide
56
+ decode. Neither ratio transfers. Confirm `StaticEmbeddings.simd_backend`, then
57
+ measure on the hardware you will run on. On the lookup-table fallback `f16` is
58
+ usually slower than `f32`. `embed_batch(format: :f16)` still encodes the
59
+ returned blob with the scalar half converter, so a batch that is not a top-k
60
+ scan can still be slower than `f32` on the same machine.
52
61
 
53
62
  ## C allocation counters
54
63
 
@@ -8,6 +8,7 @@
8
8
  #define SE_HAVE_NEON 1
9
9
  #elif defined(__SSE__)
10
10
  #include <xmmintrin.h>
11
+ #include <emmintrin.h>
11
12
  #define SE_HAVE_SSE 1
12
13
  #endif
13
14
 
@@ -71,15 +72,49 @@ static void scale_copy(float *out, const float *acc, uint32_t dim, float inv) {
71
72
  out[j] = acc[j] * inv;
72
73
  }
73
74
 
74
- void se_l2_normalize(float *vec, uint32_t dim) {
75
+ static double sum_sq_d(const float *vec, uint32_t dim) {
76
+ uint32_t j = 0;
75
77
  double sum = 0.0;
76
- for (uint32_t j = 0; j < dim; j++)
78
+ #if defined(SE_HAVE_NEON) && defined(__aarch64__)
79
+ float64x2_t s0 = vdupq_n_f64(0.0);
80
+ float64x2_t s1 = vdupq_n_f64(0.0);
81
+ for (; j + 7 < dim; j += 8) {
82
+ float32x4_t a = vld1q_f32(vec + j);
83
+ float32x4_t b = vld1q_f32(vec + j + 4);
84
+ float64x2_t a0 = vcvt_f64_f32(vget_low_f32(a));
85
+ float64x2_t a1 = vcvt_f64_f32(vget_high_f32(a));
86
+ float64x2_t b0 = vcvt_f64_f32(vget_low_f32(b));
87
+ float64x2_t b1 = vcvt_f64_f32(vget_high_f32(b));
88
+ s0 = vaddq_f64(s0, vmulq_f64(a0, a0));
89
+ s1 = vaddq_f64(s1, vmulq_f64(a1, a1));
90
+ s0 = vaddq_f64(s0, vmulq_f64(b0, b0));
91
+ s1 = vaddq_f64(s1, vmulq_f64(b1, b1));
92
+ }
93
+ sum = vaddvq_f64(vaddq_f64(s0, s1));
94
+ #elif defined(SE_HAVE_SSE)
95
+ __m128d s0 = _mm_setzero_pd();
96
+ __m128d s1 = _mm_setzero_pd();
97
+ for (; j + 3 < dim; j += 4) {
98
+ __m128 v = _mm_loadu_ps(vec + j);
99
+ __m128d lo = _mm_cvtps_pd(v);
100
+ __m128d hi = _mm_cvtps_pd(_mm_movehl_ps(v, v));
101
+ s0 = _mm_add_pd(s0, _mm_mul_pd(lo, lo));
102
+ s1 = _mm_add_pd(s1, _mm_mul_pd(hi, hi));
103
+ }
104
+ double tmp[2];
105
+ _mm_storeu_pd(tmp, _mm_add_pd(s0, s1));
106
+ sum = tmp[0] + tmp[1];
107
+ #endif
108
+ for (; j < dim; j++)
77
109
  sum += (double)vec[j] * (double)vec[j];
110
+ return sum;
111
+ }
112
+
113
+ void se_l2_normalize(float *vec, uint32_t dim) {
114
+ double sum = sum_sq_d(vec, dim);
78
115
  if (sum <= 0.0)
79
116
  return;
80
- float inv = (float)(1.0 / sqrt(sum));
81
- for (uint32_t j = 0; j < dim; j++)
82
- vec[j] *= inv;
117
+ scale_copy(vec, vec, dim, (float)(1.0 / sqrt(sum)));
83
118
  }
84
119
 
85
120
  static se_status_t embed_ids_core(const se_model_t *model, se_scratch_t *sc, const uint32_t *ids,
@@ -128,8 +163,12 @@ static se_status_t embed_ids_core(const se_model_t *model, se_scratch_t *sc, con
128
163
  }
129
164
 
130
165
  if (model->meta.normalization_type == SE_NORMALIZATION_L2) {
131
- scale_copy(out, acc, dim, 1.0f);
132
- se_l2_normalize(out, dim);
166
+ double sum = sum_sq_d(acc, dim);
167
+ if (sum <= 0.0) {
168
+ memset(out, 0, (size_t)dim * sizeof(float));
169
+ return SE_OK;
170
+ }
171
+ scale_copy(out, acc, dim, (float)(1.0 / sqrt(sum)));
133
172
  } else {
134
173
  const float inv = 1.0f / (float)used;
135
174
  scale_copy(out, acc, dim, inv);
@@ -158,7 +197,8 @@ se_status_t se_embed_ids(const se_model_t *model, se_scratch_t *sc, const uint32
158
197
  se_status_t se_embed_one(const se_model_t *model, se_scratch_t *sc, const uint8_t *input,
159
198
  size_t input_len, uint32_t max_tokens, float *out, se_token_stats_t *stats,
160
199
  se_error_t *err, volatile sig_atomic_t *cancelled) {
161
- se_status_t rc = se_tokenize(model, sc, input, input_len, max_tokens, stats, err, cancelled);
200
+ se_status_t rc = se_tokenize(model, sc, input, input_len, max_tokens, SE_TOKEN_LIMIT_USABLE,
201
+ stats, err, cancelled);
162
202
  if (rc != SE_OK)
163
203
  return rc;
164
204
  return embed_ids_core(model, sc, sc->ids, stats->token_count, out, err, cancelled);
@@ -221,17 +221,31 @@ static float dot_product_f16_neon(const float *q, const uint8_t *row, size_t dim
221
221
  size_t j = 0;
222
222
  float32x4_t a0 = vdupq_n_f32(0.0f);
223
223
  float32x4_t a1 = vdupq_n_f32(0.0f);
224
+ float32x4_t a2 = vdupq_n_f32(0.0f);
225
+ float32x4_t a3 = vdupq_n_f32(0.0f);
226
+ for (; j + 15 < dim; j += 16) {
227
+ float16x8_t h0 =
228
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + j * 2)));
229
+ float16x8_t h1 =
230
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + (j + 8) * 2)));
231
+ float32x4_t r0 = vcvt_f32_f16(vget_low_f16(h0));
232
+ float32x4_t r1 = vcvt_f32_f16(vget_high_f16(h0));
233
+ float32x4_t r2 = vcvt_f32_f16(vget_low_f16(h1));
234
+ float32x4_t r3 = vcvt_f32_f16(vget_high_f16(h1));
235
+ a0 = vmlaq_f32(a0, vld1q_f32(q + j), r0);
236
+ a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), r1);
237
+ a2 = vmlaq_f32(a2, vld1q_f32(q + j + 8), r2);
238
+ a3 = vmlaq_f32(a3, vld1q_f32(q + j + 12), r3);
239
+ }
224
240
  for (; j + 7 < dim; j += 8) {
225
241
  float16x4_t h0 =
226
242
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + j * 2)));
227
243
  float16x4_t h1 =
228
244
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + (j + 4) * 2)));
229
- float32x4_t r0 = vcvt_f32_f16(h0);
230
- float32x4_t r1 = vcvt_f32_f16(h1);
231
- a0 = vmlaq_f32(a0, vld1q_f32(q + j), r0);
232
- a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), r1);
245
+ a0 = vmlaq_f32(a0, vld1q_f32(q + j), vcvt_f32_f16(h0));
246
+ a1 = vmlaq_f32(a1, vld1q_f32(q + j + 4), vcvt_f32_f16(h1));
233
247
  }
234
- float32x4_t sumv = vaddq_f32(a0, a1);
248
+ float32x4_t sumv = vaddq_f32(vaddq_f32(a0, a1), vaddq_f32(a2, a3));
235
249
  float dot = vaddvq_f32(sumv);
236
250
  for (; j < dim; j++)
237
251
  dot += q[j] * se_f16_bits_to_float(read_u16le(row + j * 2));
@@ -243,8 +257,30 @@ static float dot_and_row_sq_f16_neon(const float *q, const uint8_t *row, size_t
243
257
  size_t j = 0;
244
258
  float32x4_t d0 = vdupq_n_f32(0.0f);
245
259
  float32x4_t d1 = vdupq_n_f32(0.0f);
260
+ float32x4_t d2 = vdupq_n_f32(0.0f);
261
+ float32x4_t d3 = vdupq_n_f32(0.0f);
246
262
  float32x4_t s0 = vdupq_n_f32(0.0f);
247
263
  float32x4_t s1 = vdupq_n_f32(0.0f);
264
+ float32x4_t s2 = vdupq_n_f32(0.0f);
265
+ float32x4_t s3 = vdupq_n_f32(0.0f);
266
+ for (; j + 15 < dim; j += 16) {
267
+ float16x8_t h0 =
268
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + j * 2)));
269
+ float16x8_t h1 =
270
+ vreinterpretq_f16_u16(vld1q_u16((const uint16_t *)(const void *)(row + (j + 8) * 2)));
271
+ float32x4_t r0 = vcvt_f32_f16(vget_low_f16(h0));
272
+ float32x4_t r1 = vcvt_f32_f16(vget_high_f16(h0));
273
+ float32x4_t r2 = vcvt_f32_f16(vget_low_f16(h1));
274
+ float32x4_t r3 = vcvt_f32_f16(vget_high_f16(h1));
275
+ d0 = vmlaq_f32(d0, vld1q_f32(q + j), r0);
276
+ d1 = vmlaq_f32(d1, vld1q_f32(q + j + 4), r1);
277
+ d2 = vmlaq_f32(d2, vld1q_f32(q + j + 8), r2);
278
+ d3 = vmlaq_f32(d3, vld1q_f32(q + j + 12), r3);
279
+ s0 = vmlaq_f32(s0, r0, r0);
280
+ s1 = vmlaq_f32(s1, r1, r1);
281
+ s2 = vmlaq_f32(s2, r2, r2);
282
+ s3 = vmlaq_f32(s3, r3, r3);
283
+ }
248
284
  for (; j + 7 < dim; j += 8) {
249
285
  float16x4_t h0 =
250
286
  vreinterpret_f16_u16(vld1_u16((const uint16_t *)(const void *)(row + j * 2)));
@@ -257,8 +293,8 @@ static float dot_and_row_sq_f16_neon(const float *q, const uint8_t *row, size_t
257
293
  s0 = vmlaq_f32(s0, r0, r0);
258
294
  s1 = vmlaq_f32(s1, r1, r1);
259
295
  }
260
- float dot = vaddvq_f32(vaddq_f32(d0, d1));
261
- float row_sq = vaddvq_f32(vaddq_f32(s0, s1));
296
+ float dot = vaddvq_f32(vaddq_f32(vaddq_f32(d0, d1), vaddq_f32(d2, d3)));
297
+ float row_sq = vaddvq_f32(vaddq_f32(vaddq_f32(s0, s1), vaddq_f32(s2, s3)));
262
298
  for (; j < dim; j++) {
263
299
  float r = se_f16_bits_to_float(read_u16le(row + j * 2));
264
300
  dot += q[j] * r;
@@ -61,6 +61,34 @@ static uint32_t hash_bytes_continue(uint32_t h, const uint8_t *data, size_t len)
61
61
  return h;
62
62
  }
63
63
 
64
+ static int se_memeq(const uint8_t *a, const uint8_t *b, size_t n) {
65
+ while (n >= 8) {
66
+ uint64_t ua, ub;
67
+ memcpy(&ua, a, 8);
68
+ memcpy(&ub, b, 8);
69
+ if (ua != ub)
70
+ return 0;
71
+ a += 8;
72
+ b += 8;
73
+ n -= 8;
74
+ }
75
+ if (n >= 4) {
76
+ uint32_t ua, ub;
77
+ memcpy(&ua, a, 4);
78
+ memcpy(&ub, b, 4);
79
+ if (ua != ub)
80
+ return 0;
81
+ a += 4;
82
+ b += 4;
83
+ n -= 4;
84
+ }
85
+ while (n--) {
86
+ if (*a++ != *b++)
87
+ return 0;
88
+ }
89
+ return 1;
90
+ }
91
+
64
92
  int se_vocab_lookup_piece(const se_model_t *model, const uint8_t *prefix, size_t prefix_len,
65
93
  const uint8_t *bytes, size_t len, uint32_t *id_out) {
66
94
  if (prefix_len > UINT32_MAX || len > UINT32_MAX || prefix_len > UINT32_MAX - len)
@@ -74,14 +102,14 @@ int se_vocab_lookup_piece(const se_model_t *model, const uint8_t *prefix, size_t
74
102
  uint32_t pos = h & mask;
75
103
  uint32_t total_len = (uint32_t)(prefix_len + len);
76
104
 
77
- for (uint32_t probe = 0; probe <= mask; probe++) {
105
+ for (uint32_t probe = 0; probe < model->meta.max_probe; probe++) {
78
106
  const se_vocab_slot_t *slot = &model->vocab_hash[pos];
79
107
  if (slot->token_id == SE_SLOT_EMPTY)
80
108
  return 0;
81
109
  if (slot->hash == h && slot->str_len == total_len) {
82
- const char *token = model->vocab_strings + slot->str_off;
83
- if ((prefix_len == 0 || memcmp(token, prefix, prefix_len) == 0) &&
84
- (len == 0 || memcmp(token + prefix_len, bytes, len) == 0)) {
110
+ const uint8_t *token = (const uint8_t *)(model->vocab_strings + slot->str_off);
111
+ if ((prefix_len == 0 || se_memeq(token, prefix, prefix_len)) &&
112
+ (len == 0 || se_memeq(token + prefix_len, bytes, len))) {
85
113
  *id_out = slot->token_id;
86
114
  return 1;
87
115
  }
@@ -103,6 +131,17 @@ static inline uint32_t trie_find_child(const se_trie_t *trie, const se_trie_node
103
131
  if (count == 0)
104
132
  return SE_SLOT_EMPTY;
105
133
 
134
+ if (count == 1)
135
+ return trie->edges[start].byte == byte ? trie->edges[start].child : SE_SLOT_EMPTY;
136
+
137
+ if (count == 2) {
138
+ if (trie->edges[start].byte == byte)
139
+ return trie->edges[start].child;
140
+ if (trie->edges[start + 1u].byte == byte)
141
+ return trie->edges[start + 1u].child;
142
+ return SE_SLOT_EMPTY;
143
+ }
144
+
106
145
  if (count <= 8) {
107
146
  for (uint32_t i = 0; i < count; i++) {
108
147
  const se_trie_edge_t *edge = &trie->edges[start + i];
@@ -342,6 +381,37 @@ static se_status_t validate_norm_ranges(const se_range_t *ranges, uint32_t count
342
381
  return SE_OK;
343
382
  }
344
383
 
384
+ static void fill_map256(const se_map_entry_t **out, const se_map_entry_t *entries, uint32_t count) {
385
+ memset(out, 0, 256 * sizeof(*out));
386
+ for (uint32_t i = 0; i < count; i++) {
387
+ if (entries[i].cp < 256)
388
+ out[entries[i].cp] = &entries[i];
389
+ }
390
+ }
391
+
392
+ static void fill_range256(uint8_t *out, const se_range_t *ranges, uint32_t count) {
393
+ memset(out, 0, 256);
394
+ for (uint32_t i = 0; i < count; i++) {
395
+ uint32_t lo = ranges[i].lo;
396
+ uint32_t hi = ranges[i].hi;
397
+ if (lo > 255)
398
+ continue;
399
+ if (hi > 255)
400
+ hi = 255;
401
+ for (uint32_t cp = lo; cp <= hi; cp++)
402
+ out[cp] = 1;
403
+ }
404
+ }
405
+
406
+ static void prepare_norm_fast_tables(se_model_t *model) {
407
+ fill_map256(model->norm.lower256, model->norm.lower, model->norm.lower_count);
408
+ fill_map256(model->norm.nfd256, model->norm.nfd, model->norm.nfd_count);
409
+ fill_range256(model->norm.mn256, model->norm.mn, model->norm.mn_count);
410
+ fill_range256(model->norm.punct256, model->norm.punct, model->norm.punct_count);
411
+ fill_range256(model->norm.control256, model->norm.control, model->norm.control_count);
412
+ fill_range256(model->norm.whitespace256, model->norm.whitespace, model->norm.whitespace_count);
413
+ }
414
+
345
415
  static se_status_t parse_norm_tables(se_model_t *model, const uint8_t *base, struct section sec,
346
416
  se_error_t *err) {
347
417
  memset(&model->norm, 0, sizeof(model->norm));
@@ -417,8 +487,13 @@ static se_status_t parse_norm_tables(se_model_t *model, const uint8_t *base, str
417
487
  rc = validate_norm_ranges(model->norm.control, model->norm.control_count, "control", err);
418
488
  if (rc != SE_OK)
419
489
  return rc;
420
- return validate_norm_ranges(model->norm.whitespace, model->norm.whitespace_count, "whitespace",
421
- err);
490
+ rc = validate_norm_ranges(model->norm.whitespace, model->norm.whitespace_count, "whitespace",
491
+ err);
492
+ if (rc != SE_OK)
493
+ return rc;
494
+
495
+ prepare_norm_fast_tables(model);
496
+ return SE_OK;
422
497
  }
423
498
 
424
499
  static se_status_t validate_vocab_hash(const se_model_t *model, struct section vocab_strings,
@@ -481,6 +556,31 @@ static se_status_t validate_vocab_hash(const se_model_t *model, struct section v
481
556
  se_error_set(err, SE_ERR_INVALID_FORMAT,
482
557
  "[UNK] is not reachable through the vocabulary hash");
483
558
  rc = SE_ERR_INVALID_FORMAT;
559
+ goto done;
560
+ }
561
+
562
+ static const struct {
563
+ uint32_t bit;
564
+ const char *text;
565
+ uint32_t len;
566
+ } added[] = {
567
+ {SE_ADDED_PAD, "[PAD]", 5u}, {SE_ADDED_UNK, "[UNK]", 5u}, {SE_ADDED_CLS, "[CLS]", 5u},
568
+ {SE_ADDED_SEP, "[SEP]", 5u}, {SE_ADDED_MASK, "[MASK]", 6u},
569
+ };
570
+ const uint32_t ids[] = {m->pad_id, m->unk_id, m->cls_id, m->sep_id, m->mask_id};
571
+
572
+ for (size_t i = 0; i < SE_ARRAY_LEN(added); i++) {
573
+ if ((m->added_token_mask & added[i].bit) == 0)
574
+ continue;
575
+ uint32_t got = 0;
576
+ if (ids[i] >= m->vocab_size ||
577
+ !se_vocab_lookup(model, (const uint8_t *)added[i].text, added[i].len, &got) ||
578
+ got != ids[i]) {
579
+ se_error_set(err, SE_ERR_INVALID_FORMAT,
580
+ "added token %s is not reachable at its recorded id", added[i].text);
581
+ rc = SE_ERR_INVALID_FORMAT;
582
+ goto done;
583
+ }
484
584
  }
485
585
 
486
586
  done:
@@ -571,6 +671,7 @@ static se_status_t validate(se_model_t *model, se_error_t *err) {
571
671
  m->subword_prefix_len = read_u32(base, SE_OFF_SUBWORD_PREFIX_LEN);
572
672
  m->max_token_chars = read_u32(base, SE_OFF_MAX_TOKEN_CHARS);
573
673
  m->max_probe = read_u32(base, SE_OFF_MAX_PROBE);
674
+ m->added_token_mask = read_u32(base, SE_OFF_ADDED_TOKEN_MASK);
574
675
  memcpy(m->subword_prefix, base + SE_OFF_SUBWORD_PREFIX, 8);
575
676
 
576
677
  if (m->tokenizer_type != SE_TOKENIZER_BERT_WORDPIECE_V1) {
@@ -594,7 +695,7 @@ static se_status_t validate(se_model_t *model, se_error_t *err) {
594
695
  m->normalization_type);
595
696
  return SE_ERR_INVALID_FORMAT;
596
697
  }
597
- if (m->truncation_policy != SE_TRUNCATE_IDS_BEFORE_POOLING) {
698
+ if (m->truncation_policy != SE_TRUNCATE_USABLE_IDS_BEFORE_POOLING) {
598
699
  se_error_set(err, SE_ERR_INVALID_FORMAT, "truncation policy %u is not supported",
599
700
  m->truncation_policy);
600
701
  return SE_ERR_INVALID_FORMAT;
@@ -607,6 +708,11 @@ static se_status_t validate(se_model_t *model, se_error_t *err) {
607
708
  se_error_set(err, SE_ERR_INVALID_FORMAT, "unknown UNK policy %u", m->unk_policy);
608
709
  return SE_ERR_INVALID_FORMAT;
609
710
  }
711
+ if ((m->added_token_mask & ~SE_ADDED_TOKEN_MASK_ALL) != 0) {
712
+ se_error_set(err, SE_ERR_INVALID_FORMAT, "unknown added-token mask bits 0x%x",
713
+ m->added_token_mask & ~SE_ADDED_TOKEN_MASK_ALL);
714
+ return SE_ERR_INVALID_FORMAT;
715
+ }
610
716
  if (m->empty_policy != SE_EMPTY_ZERO_VECTOR && m->empty_policy != SE_EMPTY_RAISE) {
611
717
  se_error_set(err, SE_ERR_INVALID_FORMAT, "unknown empty-input policy %u", m->empty_policy);
612
718
  return SE_ERR_INVALID_FORMAT;
@@ -629,8 +735,14 @@ static se_status_t validate(se_model_t *model, se_error_t *err) {
629
735
  m->hash_table_size);
630
736
  return SE_ERR_INVALID_FORMAT;
631
737
  }
632
- if (m->hash_table_size < m->vocab_size) {
633
- se_error_set(err, SE_ERR_INVALID_FORMAT, "hash table smaller than vocabulary");
738
+ if (m->hash_table_size <= m->vocab_size ||
739
+ (uint64_t)m->vocab_size * 100u > (uint64_t)m->hash_table_size * 70u) {
740
+ se_error_set(err, SE_ERR_INVALID_FORMAT,
741
+ "hash table load factor exceeds the supported 0.70 maximum");
742
+ return SE_ERR_INVALID_FORMAT;
743
+ }
744
+ if (m->max_probe == 0 || m->max_probe > m->hash_table_size) {
745
+ se_error_set(err, SE_ERR_INVALID_FORMAT, "invalid recorded max probe %u", m->max_probe);
634
746
  return SE_ERR_INVALID_FORMAT;
635
747
  }
636
748
  if (m->subword_prefix_len > 8) {