static_embeddings 0.1.5 → 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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +17 -2
  4. data/docs/ARCHITECTURE.md +14 -5
  5. data/docs/MODEL_AUDIT.md +110 -0
  6. data/lib/models/demo.semb +0 -0
  7. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  8. data/lib/static_embeddings/canonical.rb +50 -0
  9. data/lib/static_embeddings/cli.rb +88 -63
  10. data/lib/static_embeddings/codec.rb +45 -0
  11. data/lib/static_embeddings/conversion.rb +58 -0
  12. data/lib/static_embeddings/errors.rb +2 -2
  13. data/lib/static_embeddings/format/constants.rb +109 -0
  14. data/lib/static_embeddings/format/hash_table.rb +69 -0
  15. data/lib/static_embeddings/format/trie.rb +78 -0
  16. data/lib/static_embeddings/format/verifier.rb +41 -0
  17. data/lib/static_embeddings/format/writer.rb +131 -0
  18. data/lib/static_embeddings/format.rb +3 -338
  19. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  20. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  21. data/lib/static_embeddings/importers/support.rb +111 -0
  22. data/lib/static_embeddings/importers.rb +50 -0
  23. data/lib/static_embeddings/model.rb +35 -20
  24. data/lib/static_embeddings/paths.rb +8 -12
  25. data/lib/static_embeddings/provenance.rb +58 -0
  26. data/lib/static_embeddings/reference.rb +50 -40
  27. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  28. data/lib/static_embeddings/version.rb +1 -1
  29. data/lib/static_embeddings.rb +29 -55
  30. data/static_embeddings.gemspec +2 -2
  31. data/tools/check_model2vec_parity.rb +5 -1
  32. data/tools/check_st_parity.rb +125 -0
  33. data/tools/eval_retrieval.rb +58 -0
  34. metadata +24 -6
  35. 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: 550531f7573a8d05e8d5a3a1ae0fa301c01d77b238573ae11834797e8ac44a0f
4
+ data.tar.gz: 57f487c729296e3a858604dc64aa146aa6dd6e372a45961d90a61564e3c1b117
5
5
  SHA512:
6
- metadata.gz: cec0a3959cb3f093c7bb2b06213b4c7e983986466a115eb79e0b045a5b52fdda9797be41365db41a74d85bb52c384c147d68d3d84c503fef10c122bba7b72333
7
- data.tar.gz: e99039606af3de0106e1ac26efaa78a06f09acbd9c2faaae9b4ca17339bdeb4830ba922bef84dc141317898556d09b1ec41cf1ebfebcce5e141d877c3fc34598
6
+ metadata.gz: 52162c96d458dffaaec8498cd712678b4727b29ddaa66eadf7834893113a6bd05f636cc47a2f84d969f76eb4651fe57664212fdb4ac2925449827b9d3a69a9bf
7
+ data.tar.gz: 115a0b0f3ff9d82cea099fc0ee24385034d8bcc66ca4ff9337f2213608ee4d611215b6d3400fe39aecf562cf201f5f5044eab7f9863fbdc813362c0af0eb7739
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
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
+
3
53
  ## 0.1.5
4
54
 
5
55
  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.
Binary file
@@ -0,0 +1,191 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/format/constants"
3
+
4
+ module StaticEmbeddings
5
+ module BertWordPiece
6
+ TOKENIZER_PROFILE = "BERT_WORDPIECE_V1"
7
+ ALLOWED_NORMALIZER_KEYS = %w[type clean_text handle_chinese_chars strip_accents lowercase].freeze
8
+ STANDARD_SPECIAL_TOKENS = {
9
+ "[PAD]" => Format::ADDED_PAD,
10
+ "[UNK]" => Format::ADDED_UNK,
11
+ "[CLS]" => Format::ADDED_CLS,
12
+ "[SEP]" => Format::ADDED_SEP,
13
+ "[MASK]" => Format::ADDED_MASK
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def compile(tokenizer, tokenizer_config = {})
19
+ profile = audit(tokenizer, tokenizer_config)
20
+ tokens = vocabulary(tokenizer)
21
+ validate_added_token_ids(profile.fetch(:added_tokens), tokens)
22
+ [profile.freeze, tokens]
23
+ end
24
+
25
+ def runtime_meta(profile, tokens)
26
+ prefix = profile.fetch(:continuing_subword_prefix)
27
+ {
28
+ tokenizer_type: Format::TOKENIZER_BERT_WORDPIECE_V1,
29
+ tokenizer_profile: TOKENIZER_PROFILE,
30
+ do_lower_case: profile.fetch(:lowercase),
31
+ strip_accents: profile.fetch(:strip_accents),
32
+ handle_chinese_chars: profile.fetch(:handle_chinese_chars),
33
+ clean_text: profile.fetch(:clean_text),
34
+ added_token_mask: profile.fetch(:added_token_mask),
35
+ max_input_chars_per_word: profile.fetch(:max_input_chars_per_word),
36
+ max_token_chars: max_token_chars(tokens, prefix),
37
+ subword_prefix: prefix,
38
+ pad_id: token_id(tokens, "[PAD]", 0),
39
+ unk_id: token_id(tokens, profile.fetch(:unk_token)),
40
+ cls_id: token_id(tokens, "[CLS]", 0),
41
+ sep_id: token_id(tokens, "[SEP]", 0),
42
+ mask_id: token_id(tokens, "[MASK]", 0)
43
+ }.freeze
44
+ end
45
+
46
+ def audit(tokenizer, tokenizer_config)
47
+ unsupported!("tokenizer.json is not an object") unless tokenizer.is_a?(Hash)
48
+ unsupported!("tokenizer_config.json is not an object") unless tokenizer_config.is_a?(Hash)
49
+ model = tokenizer["model"] || unsupported!("tokenizer.json has no model section")
50
+ normalizer = tokenizer["normalizer"] || unsupported!("tokenizer has no normalizer")
51
+ pre_tokenizer = tokenizer["pre_tokenizer"]
52
+ unsupported!("tokenizer model section is not an object") unless model.is_a?(Hash)
53
+ unsupported!("tokenizer normalizer is not an object") unless normalizer.is_a?(Hash)
54
+
55
+ audit_model(model)
56
+ audit_normalizer(normalizer)
57
+ audit_pre_tokenizer(pre_tokenizer)
58
+ added_tokens = audit_added_tokens(tokenizer["added_tokens"] || [])
59
+ lowercase = flag(normalizer, "lowercase", tokenizer_config["do_lower_case"], true)
60
+ clean_text = flag(normalizer, "clean_text", nil, true)
61
+ max_input_chars = Integer(model.fetch("max_input_chars_per_word", 100))
62
+ unsupported!("max_input_chars_per_word must be positive") unless max_input_chars.positive?
63
+ unsupported!("clean_text=false is not supported by the runtime") unless clean_text
64
+
65
+ {
66
+ lowercase: lowercase,
67
+ strip_accents: strip_accents(normalizer, lowercase),
68
+ clean_text: clean_text,
69
+ handle_chinese_chars: flag(normalizer, "handle_chinese_chars",
70
+ tokenizer_config["tokenize_chinese_chars"], true),
71
+ continuing_subword_prefix: model.fetch("continuing_subword_prefix", "##"),
72
+ unk_token: model.fetch("unk_token", "[UNK]"),
73
+ max_input_chars_per_word: max_input_chars,
74
+ tokenizer_class: tokenizer_config["tokenizer_class"],
75
+ added_tokens: added_tokens,
76
+ added_token_mask: added_tokens.reduce(0) do |mask, token|
77
+ mask | STANDARD_SPECIAL_TOKENS.fetch(token.fetch("content"))
78
+ end
79
+ }
80
+ end
81
+
82
+ def audit_model(model)
83
+ unsupported!("tokenizer model.type is #{model['type'].inspect}, expected WordPiece") unless model["type"] == "WordPiece"
84
+
85
+ prefix = model.fetch("continuing_subword_prefix", "##")
86
+ unsupported!("continuing_subword_prefix #{prefix.inspect} is not supported") unless prefix == "##"
87
+
88
+ unk_token = model.fetch("unk_token", "[UNK]")
89
+ unsupported!("unk_token #{unk_token.inspect} is not supported") unless unk_token == "[UNK]"
90
+ end
91
+
92
+ def audit_normalizer(normalizer)
93
+ unsupported!("normalizer type #{normalizer['type'].inspect} is not BertNormalizer") unless normalizer["type"] == "BertNormalizer"
94
+
95
+ unknown = normalizer.keys - ALLOWED_NORMALIZER_KEYS
96
+ unsupported!("normalizer has unsupported keys #{unknown.inspect}") unless unknown.empty?
97
+ end
98
+
99
+ def audit_pre_tokenizer(pre_tokenizer)
100
+ return if pre_tokenizer.is_a?(Hash) && pre_tokenizer["type"] == "BertPreTokenizer"
101
+
102
+ unsupported!("pre_tokenizer type #{pre_tokenizer && pre_tokenizer['type'].inspect} is not BertPreTokenizer")
103
+ end
104
+
105
+ def audit_added_tokens(tokens)
106
+ unsupported!("added_tokens is not an array") unless tokens.is_a?(Array)
107
+ unsupported!("added_tokens contains a non-object entry") unless tokens.all? { |token| token.is_a?(Hash) }
108
+ bad = tokens.reject { |token| standard_special?(token) }
109
+ unsupported!("tokenizer declares non-standard added_tokens #{bad.map { |token| token['content'] }.inspect}") unless bad.empty?
110
+
111
+ tokens.each do |token|
112
+ content = token["content"].to_s
113
+ unsupported!("added token #{content.inspect} contains whitespace") if content.match?(/\s/)
114
+ unsupported!("added token #{content.inspect} uses lstrip/rstrip/single_word") if token["lstrip"] || token["rstrip"] || token["single_word"]
115
+ unsupported!("added token #{content.inspect} must use normalized=false") unless token["normalized"] == false
116
+ end
117
+
118
+ duplicate = tokens.group_by { |token| token["content"] }.find { |_, rows| rows.length > 1 }
119
+ unsupported!("duplicate added token #{duplicate.first.inspect}") if duplicate
120
+ tokens.freeze
121
+ end
122
+
123
+ def vocabulary(tokenizer)
124
+ vocab = tokenizer.dig("model", "vocab") || unsupported!("tokenizer.json has no model.vocab")
125
+ unsupported!("tokenizer model.vocab is not an object") unless vocab.is_a?(Hash)
126
+ tokens = Array.new(vocab.length)
127
+ vocab.each { |token, id| assign_vocab(tokens, token, id) }
128
+ missing = tokens.index(nil)
129
+ invalid!("vocab has a hole at id #{missing}") if missing
130
+ tokens
131
+ end
132
+
133
+ def assign_vocab(tokens, token, id)
134
+ unsupported!("vocab id #{id.inspect} is not an integer") unless id.is_a?(Integer)
135
+ invalid!("vocab id #{id} for #{token.inspect} is outside 0...#{tokens.length}") unless id.between?(0, tokens.length - 1)
136
+ invalid!("duplicate vocab id #{id}") unless tokens[id].nil?
137
+ tokens[id] = token
138
+ end
139
+
140
+ def validate_added_token_ids(added_tokens, tokens)
141
+ added_tokens.each do |token|
142
+ content = token.fetch("content")
143
+ id = token["id"]
144
+ unsupported!("added token #{content.inspect} has non-integer id #{id.inspect}") unless id.is_a?(Integer)
145
+ valid = id.between?(0, tokens.length - 1) && tokens[id] == content
146
+ unsupported!("added token #{content.inspect} id #{id} does not match model.vocab") unless valid
147
+ end
148
+ end
149
+
150
+ def flag(hash, key, fallback, default)
151
+ return !!hash[key] if hash.key?(key) && !hash[key].nil?
152
+ return !!fallback unless fallback.nil?
153
+
154
+ default
155
+ end
156
+
157
+ def strip_accents(normalizer, lowercase)
158
+ value = normalizer["strip_accents"]
159
+ normalizer.key?("strip_accents") && !value.nil? ? !!value : lowercase
160
+ end
161
+
162
+ def standard_special?(token)
163
+ token["special"] && STANDARD_SPECIAL_TOKENS.key?(token["content"])
164
+ end
165
+
166
+ def max_token_chars(tokens, prefix)
167
+ tokens.reduce(1) do |maximum, token|
168
+ body = token.start_with?(prefix) ? token[prefix.length..] : token
169
+ [maximum, body.each_char.count].max
170
+ end
171
+ end
172
+
173
+ def token_id(tokens, token, fallback = nil)
174
+ id = tokens.index(token)
175
+ return id unless id.nil?
176
+ return fallback unless fallback.nil?
177
+
178
+ invalid!("vocabulary has no #{token.inspect}")
179
+ end
180
+
181
+ def unsupported!(message)
182
+ raise UnsupportedModelError,
183
+ "#{message}. Supported tokenizer profile: #{TOKENIZER_PROFILE}; " \
184
+ "other tokenizer behaviour requires a separate runtime capability."
185
+ end
186
+
187
+ def invalid!(message)
188
+ raise ConversionError, message
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,50 @@
1
+ module StaticEmbeddings
2
+ module Canonical
3
+ Dimensions = Struct.new(:native, :output, :trained, keyword_init: true)
4
+ Runtime = Struct.new(:normalization, :unk_policy, :empty_policy, :max_tokens, :add_special_tokens,
5
+ keyword_init: true)
6
+ Source = Struct.new(:family, :model, :revision, :oracle, :files_sha256, :tokenizer_class,
7
+ :config_seq_length, keyword_init: true)
8
+ Model = Struct.new(:tokens, :matrix, :dimensions, :runtime, :tokenizer, :source, keyword_init: true)
9
+
10
+ module_function
11
+
12
+ def dimensions(native:, output:, trained: nil)
13
+ Dimensions.new(native: native, output: output, trained: trained&.freeze).freeze
14
+ end
15
+
16
+ def runtime(normalization:, unk_policy:, empty_policy:, max_tokens:, add_special_tokens: false)
17
+ Runtime.new(
18
+ normalization: normalization,
19
+ unk_policy: unk_policy,
20
+ empty_policy: empty_policy,
21
+ max_tokens: max_tokens,
22
+ add_special_tokens: add_special_tokens
23
+ ).freeze
24
+ end
25
+
26
+ def source(family:, model:, oracle:, files_sha256:, revision: nil, tokenizer_class: nil,
27
+ config_seq_length: nil)
28
+ Source.new(
29
+ family: family,
30
+ model: model,
31
+ revision: revision,
32
+ oracle: oracle,
33
+ files_sha256: files_sha256.freeze,
34
+ tokenizer_class: tokenizer_class,
35
+ config_seq_length: config_seq_length
36
+ ).freeze
37
+ end
38
+
39
+ def model(tokens:, matrix:, dimensions:, runtime:, tokenizer:, source:)
40
+ Model.new(
41
+ tokens: tokens.freeze,
42
+ matrix: matrix,
43
+ dimensions: dimensions,
44
+ runtime: runtime,
45
+ tokenizer: tokenizer.freeze,
46
+ source: source
47
+ ).freeze
48
+ end
49
+ end
50
+ end