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.
@@ -80,6 +80,7 @@ static ID id_blocking_p;
80
80
  static ID id_vector;
81
81
  static ID id_token_count;
82
82
  static ID id_unk_count;
83
+ static ID id_pooled_count;
83
84
  static ID id_truncated;
84
85
  static ID id_dim;
85
86
  static ID id_allow_unfrozen;
@@ -274,10 +275,14 @@ static void batch_worker_run(batch_job_t *job, se_scratch_t *scratch) {
274
275
 
275
276
  static void *batch_execute(void *arg) {
276
277
  batch_job_t *job = (batch_job_t *)arg;
277
- se_scratch_t scratch;
278
- se_scratch_init(&scratch);
279
- batch_worker_run(job, &scratch);
280
- se_scratch_free(&scratch);
278
+ se_scratch_t *scratch = se_scratch_acquire(job->model->meta.dim);
279
+ if (!scratch) {
280
+ se_error_set(&job->error, SE_ERR_OOM, "out of memory while sizing scratch buffers");
281
+ job->failed = 1;
282
+ return NULL;
283
+ }
284
+ batch_worker_run(job, scratch);
285
+ se_scratch_release(scratch);
281
286
  return NULL;
282
287
  }
283
288
 
@@ -928,6 +933,32 @@ static VALUE embed_one_via_batch(VALUE self, VALUE text, VALUE max_tokens_opt,
928
933
  return embed_texts_internal(self, text, 0, 1, max_tokens_opt, format, validation, stats);
929
934
  }
930
935
 
936
+ typedef struct {
937
+ se_scratch_t *scratch;
938
+ const se_model_t *model;
939
+ const uint8_t *input;
940
+ size_t input_len;
941
+ uint32_t max_tokens;
942
+ float *out;
943
+ se_token_stats_t *stats;
944
+ se_error_t err;
945
+ se_status_t rc;
946
+ } embed_one_scratch_job_t;
947
+
948
+ static VALUE embed_one_scratch_body(VALUE arg) {
949
+ embed_one_scratch_job_t *job = (embed_one_scratch_job_t *)(uintptr_t)arg;
950
+ job->rc = se_embed_one(job->model, job->scratch, job->input, job->input_len, job->max_tokens,
951
+ job->out, job->stats, &job->err, NULL);
952
+ return Qnil;
953
+ }
954
+
955
+ static VALUE embed_one_scratch_ensure(VALUE arg) {
956
+ embed_one_scratch_job_t *job = (embed_one_scratch_job_t *)(uintptr_t)arg;
957
+ se_scratch_release(job->scratch);
958
+ job->scratch = NULL;
959
+ return Qnil;
960
+ }
961
+
931
962
  static VALUE embed_one_value(VALUE self, VALUE text, VALUE max_tokens_opt,
932
963
  se_vector_format_t format, se_encoding_validation_t validation,
933
964
  se_token_stats_t *stats) {
@@ -947,25 +978,28 @@ static VALUE embed_one_value(VALUE self, VALUE text, VALUE max_tokens_opt,
947
978
  rb_enc_associate(result, binary_encoding);
948
979
  float *out = (float *)RSTRING_PTR(result);
949
980
 
950
- se_scratch_t scratch;
951
- se_scratch_init(&scratch);
952
- if (!se_scratch_reserve(&scratch, dim)) {
953
- se_scratch_free(&scratch);
981
+ se_token_stats_t local_stats;
982
+ embed_one_scratch_job_t job;
983
+ memset(&job, 0, sizeof(job));
984
+ job.model = &w->model;
985
+ job.input = (const uint8_t *)RSTRING_PTR(text);
986
+ job.input_len = (size_t)RSTRING_LEN(text);
987
+ job.max_tokens = resolve_max_tokens(&w->model, max_tokens_opt);
988
+ job.out = out;
989
+ job.stats = stats ? stats : &local_stats;
990
+ se_error_clear(&job.err);
991
+
992
+ job.scratch = se_scratch_acquire(dim);
993
+ if (!job.scratch)
954
994
  rb_raise(rb_eNoMemError, "out of memory");
955
- }
956
995
 
957
- se_error_t err;
958
- se_error_clear(&err);
959
- se_token_stats_t local_stats;
960
- se_status_t rc =
961
- se_embed_one(&w->model, &scratch, (const uint8_t *)RSTRING_PTR(text),
962
- (size_t)RSTRING_LEN(text), resolve_max_tokens(&w->model, max_tokens_opt), out,
963
- stats ? stats : &local_stats, &err, NULL);
964
- se_scratch_free(&scratch);
996
+ rb_ensure(embed_one_scratch_body, (VALUE)(uintptr_t)&job, embed_one_scratch_ensure,
997
+ (VALUE)(uintptr_t)&job);
965
998
  RB_GC_GUARD(text);
999
+ RB_GC_GUARD(result);
966
1000
 
967
- if (rc != SE_OK)
968
- raise_se(&err);
1001
+ if (job.rc != SE_OK)
1002
+ raise_se(&job.err);
969
1003
 
970
1004
  return result;
971
1005
  }
@@ -997,10 +1031,46 @@ static VALUE model_embed_with_stats(int argc, VALUE *argv, VALUE self) {
997
1031
  rb_hash_aset(hash, ID2SYM(id_vector), vector);
998
1032
  rb_hash_aset(hash, ID2SYM(id_token_count), UINT2NUM(stats.token_count));
999
1033
  rb_hash_aset(hash, ID2SYM(id_unk_count), UINT2NUM(stats.unk_count));
1034
+ uint32_t pooled_count = get_model(self)->model.meta.unk_policy == SE_UNK_DROP
1035
+ ? stats.token_count - stats.unk_count
1036
+ : stats.token_count;
1037
+ rb_hash_aset(hash, ID2SYM(id_pooled_count), UINT2NUM(pooled_count));
1000
1038
  rb_hash_aset(hash, ID2SYM(id_truncated), stats.truncated ? Qtrue : Qfalse);
1001
1039
  return hash;
1002
1040
  }
1003
1041
 
1042
+ typedef struct {
1043
+ se_scratch_t *scratch;
1044
+ const se_model_t *model;
1045
+ const uint8_t *input;
1046
+ size_t input_len;
1047
+ uint32_t max_tokens;
1048
+ se_token_stats_t stats;
1049
+ se_error_t err;
1050
+ se_status_t rc;
1051
+ VALUE ids;
1052
+ } tokenize_scratch_job_t;
1053
+
1054
+ static VALUE tokenize_scratch_body(VALUE arg) {
1055
+ tokenize_scratch_job_t *job = (tokenize_scratch_job_t *)(uintptr_t)arg;
1056
+ job->rc = se_tokenize(job->model, job->scratch, job->input, job->input_len, job->max_tokens,
1057
+ SE_TOKEN_LIMIT_RAW, &job->stats, &job->err, NULL);
1058
+ if (job->rc != SE_OK)
1059
+ return Qnil;
1060
+
1061
+ job->ids = rb_ary_new_capa((long)job->stats.token_count);
1062
+ for (uint32_t i = 0; i < job->stats.token_count; i++)
1063
+ rb_ary_push(job->ids, UINT2NUM(job->scratch->ids[i]));
1064
+ return Qnil;
1065
+ }
1066
+
1067
+ static VALUE tokenize_scratch_ensure(VALUE arg) {
1068
+ tokenize_scratch_job_t *job = (tokenize_scratch_job_t *)(uintptr_t)arg;
1069
+ se_scratch_release(job->scratch);
1070
+ job->scratch = NULL;
1071
+ return Qnil;
1072
+ }
1073
+
1004
1074
  static VALUE model_tokenize(int argc, VALUE *argv, VALUE self) {
1005
1075
  VALUE text, opts;
1006
1076
  rb_scan_args(argc, argv, "1:", &text, &opts);
@@ -1011,32 +1081,27 @@ static VALUE model_tokenize(int argc, VALUE *argv, VALUE self) {
1011
1081
  check_text_encoding_mode(
1012
1082
  text, -1, resolve_encoding_validation(lookup_option(opts, id_validate_encoding)));
1013
1083
 
1014
- uint32_t max_tokens = resolve_max_tokens(&w->model, lookup_option(opts, id_max_tokens));
1015
-
1016
- se_scratch_t scratch;
1017
- se_scratch_init(&scratch);
1018
- if (!se_scratch_reserve(&scratch, w->model.meta.dim)) {
1019
- se_scratch_free(&scratch);
1084
+ tokenize_scratch_job_t job;
1085
+ memset(&job, 0, sizeof(job));
1086
+ job.model = &w->model;
1087
+ job.input = (const uint8_t *)RSTRING_PTR(text);
1088
+ job.input_len = (size_t)RSTRING_LEN(text);
1089
+ job.max_tokens = resolve_max_tokens(&w->model, lookup_option(opts, id_max_tokens));
1090
+ job.ids = Qnil;
1091
+ se_error_clear(&job.err);
1092
+
1093
+ job.scratch = se_scratch_acquire(w->model.meta.dim);
1094
+ if (!job.scratch)
1020
1095
  rb_raise(rb_eNoMemError, "out of memory");
1021
- }
1022
1096
 
1023
- se_token_stats_t stats;
1024
- se_error_t err;
1025
- se_error_clear(&err);
1026
- se_status_t rc = se_tokenize(&w->model, &scratch, (const uint8_t *)RSTRING_PTR(text),
1027
- (size_t)RSTRING_LEN(text), max_tokens, &stats, &err, NULL);
1028
- if (rc != SE_OK) {
1029
- se_scratch_free(&scratch);
1030
- raise_se(&err);
1031
- }
1097
+ rb_ensure(tokenize_scratch_body, (VALUE)(uintptr_t)&job, tokenize_scratch_ensure,
1098
+ (VALUE)(uintptr_t)&job);
1099
+ RB_GC_GUARD(text);
1032
1100
 
1033
- VALUE ids = rb_ary_new_capa((long)stats.token_count);
1034
- for (uint32_t i = 0; i < stats.token_count; i++)
1035
- rb_ary_push(ids, UINT2NUM(scratch.ids[i]));
1101
+ if (job.rc != SE_OK)
1102
+ raise_se(&job.err);
1036
1103
 
1037
- se_scratch_free(&scratch);
1038
- RB_GC_GUARD(text);
1039
- return ids;
1104
+ return job.ids;
1040
1105
  }
1041
1106
 
1042
1107
  typedef struct {
@@ -1077,17 +1142,15 @@ static VALUE num2ull_at_value(VALUE arg) {
1077
1142
 
1078
1143
  static void *ids_execute(void *arg) {
1079
1144
  ids_job_t *job = (ids_job_t *)arg;
1080
- se_scratch_t scratch;
1081
- se_scratch_init(&scratch);
1082
- if (!se_scratch_reserve(&scratch, job->model->meta.dim)) {
1145
+ se_scratch_t *scratch = se_scratch_acquire(job->model->meta.dim);
1146
+ if (!scratch) {
1083
1147
  se_error_set(&job->error, SE_ERR_OOM, "out of memory while sizing scratch buffers");
1084
- se_scratch_free(&scratch);
1085
1148
  return NULL;
1086
1149
  }
1087
1150
  se_error_clear(&job->error);
1088
- se_embed_ids(job->model, &scratch, job->ids, job->n_ids, job->out, &job->stats, &job->error,
1151
+ se_embed_ids(job->model, scratch, job->ids, job->n_ids, job->out, &job->stats, &job->error,
1089
1152
  &job->cancelled);
1090
- se_scratch_free(&scratch);
1153
+ se_scratch_release(scratch);
1091
1154
  return NULL;
1092
1155
  }
1093
1156
 
@@ -1179,7 +1242,27 @@ static VALUE embed_token_ids_value(VALUE self, VALUE ids_value, VALUE max_tokens
1179
1242
  size_t n = (size_t)n_long;
1180
1243
  int truncated = 0;
1181
1244
 
1182
- if (max_tokens != 0 && n > (size_t)max_tokens) {
1245
+ if (max_tokens != 0 && w->model.meta.unk_policy == SE_UNK_DROP) {
1246
+ size_t usable = 0;
1247
+ size_t cutoff = n;
1248
+ for (size_t i = 0; i < n; i++) {
1249
+ unsigned long long value = NUM2ULL(rb_ary_entry(ids_value, (long)i));
1250
+ if (value >= w->model.meta.vocab_size)
1251
+ rb_raise(rb_eArgError, "token id at index %zu is out of range", i);
1252
+
1253
+ if ((uint32_t)value == w->model.meta.unk_id)
1254
+ continue;
1255
+
1256
+ usable++;
1257
+ if (usable == (size_t)max_tokens) {
1258
+ cutoff = i + 1;
1259
+ } else if (usable > (size_t)max_tokens) {
1260
+ n = cutoff;
1261
+ truncated = 1;
1262
+ break;
1263
+ }
1264
+ }
1265
+ } else if (max_tokens != 0 && n > (size_t)max_tokens) {
1183
1266
  n = (size_t)max_tokens;
1184
1267
  truncated = 1;
1185
1268
  }
@@ -1239,6 +1322,10 @@ static VALUE model_embed_token_ids_with_stats(int argc, VALUE *argv, VALUE self)
1239
1322
  rb_hash_aset(hash, ID2SYM(id_vector), vector);
1240
1323
  rb_hash_aset(hash, ID2SYM(id_token_count), UINT2NUM(stats.token_count));
1241
1324
  rb_hash_aset(hash, ID2SYM(id_unk_count), UINT2NUM(stats.unk_count));
1325
+ uint32_t pooled_count = get_model(self)->model.meta.unk_policy == SE_UNK_DROP
1326
+ ? stats.token_count - stats.unk_count
1327
+ : stats.token_count;
1328
+ rb_hash_aset(hash, ID2SYM(id_pooled_count), UINT2NUM(pooled_count));
1242
1329
  rb_hash_aset(hash, ID2SYM(id_truncated), stats.truncated ? Qtrue : Qfalse);
1243
1330
  return hash;
1244
1331
  }
@@ -1549,6 +1636,7 @@ RUBY_FUNC_EXPORTED void Init_static_embeddings(void) {
1549
1636
  id_vector = rb_intern("vector");
1550
1637
  id_token_count = rb_intern("token_count");
1551
1638
  id_unk_count = rb_intern("unk_count");
1639
+ id_pooled_count = rb_intern("pooled_count");
1552
1640
  id_truncated = rb_intern("truncated");
1553
1641
  id_dim = rb_intern("dim");
1554
1642
  id_allow_unfrozen = rb_intern("allow_unfrozen");
@@ -65,6 +65,7 @@ module StaticEmbeddings
65
65
  end
66
66
 
67
67
  def convert(argv)
68
+ require "static_embeddings/converter"
68
69
  options = parse_convert_options(argv)
69
70
  source = required_arg(argv, "usage: static_embeddings convert SOURCE_DIR [--out PATH]")
70
71
  model_id = options[:id] || File.basename(File.expand_path(source))
@@ -1,13 +1,25 @@
1
1
  require "json"
2
2
  require "digest"
3
+ require "static_embeddings/format"
4
+ require "static_embeddings/safetensors"
5
+ require "static_embeddings/unicode_tables"
3
6
 
4
7
  module StaticEmbeddings
5
8
  class Converter
6
9
  REFERENCE_IMPL = "model2vec.StaticModel"
10
+ REFERENCE_MODEL2VEC_VERSION = "0.9.0"
11
+ REFERENCE_TOKENIZERS_VERSION = "0.23.1"
12
+ REFERENCE_UNICODE_CATEGORIES_VERSION = "0.1.1"
7
13
  REFERENCE_MAX_TOKENS = 512
8
14
 
9
15
  ALLOWED_NORMALIZER_KEYS = %w[type clean_text handle_chinese_chars strip_accents lowercase].freeze
10
- STANDARD_SPECIAL_TOKENS = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"].freeze
16
+ STANDARD_SPECIAL_TOKENS = {
17
+ "[PAD]" => Format::ADDED_PAD,
18
+ "[UNK]" => Format::ADDED_UNK,
19
+ "[CLS]" => Format::ADDED_CLS,
20
+ "[SEP]" => Format::ADDED_SEP,
21
+ "[MASK]" => Format::ADDED_MASK
22
+ }.freeze
11
23
  SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
12
24
  TOKENIZER_PROFILE = "BERT_WORDPIECE_V1"
13
25
 
@@ -22,6 +34,7 @@ module StaticEmbeddings
22
34
  source = load_source
23
35
  profile = audit_tokenizer(source[:tokenizer], source[:tokenizer_config])
24
36
  tokens = extract_vocab(source[:tokenizer])
37
+ validate_added_token_ids!(profile[:added_tokens], tokens)
25
38
  matrix, dim = extract_matrix(tokens.length)
26
39
  meta = runtime_meta(source[:config], profile, tokens, max_tokens)
27
40
 
@@ -62,9 +75,13 @@ module StaticEmbeddings
62
75
  assert_wordpiece!(model)
63
76
  assert_normalizer!(normalizer)
64
77
  assert_pre_tokenizer!(pre_tokenizer)
65
- audit_added_tokens(tokenizer)
78
+ added_tokens = audit_added_tokens(tokenizer)
66
79
 
67
80
  profile = profile_from(model, normalizer, tokenizer_config)
81
+ profile[:added_tokens] = added_tokens
82
+ profile[:added_token_mask] = added_tokens.reduce(0) do |mask, token|
83
+ mask | STANDARD_SPECIAL_TOKENS.fetch(token.fetch("content"))
84
+ end
68
85
  reject!("clean_text=false is not supported by the runtime") unless profile[:clean_text]
69
86
  profile
70
87
  end
@@ -123,17 +140,40 @@ module StaticEmbeddings
123
140
  def audit_added_tokens(tokenizer)
124
141
  added = tokenizer["added_tokens"] || []
125
142
  bad_content = added.reject { |token| standard_special?(token) }
126
- reject!("tokenizer declares non-standard added_tokens #{bad_content.map { |t| t['content'] }.inspect}") unless bad_content.empty?
143
+ unless bad_content.empty?
144
+ reject!("tokenizer declares non-standard added_tokens #{bad_content.map { |t| t['content'] }.inspect}")
145
+ end
127
146
 
128
147
  whitespace = added.find { |token| token["content"].to_s.match?(/\s/) }
129
148
  reject!("added token #{whitespace['content'].inspect} contains whitespace") if whitespace
130
149
 
131
150
  flagged = added.find { |token| token["lstrip"] || token["rstrip"] || token["single_word"] }
132
151
  reject!("added token #{flagged['content'].inspect} uses lstrip/rstrip/single_word") if flagged
152
+
153
+ normalized = added.find { |token| token["normalized"] != false }
154
+ if normalized
155
+ reject!("added token #{normalized['content'].inspect} must use normalized=false")
156
+ end
157
+
158
+ duplicate = added.group_by { |token| token["content"] }.find { |_, rows| rows.length > 1 }
159
+ reject!("duplicate added token #{duplicate[0].inspect}") if duplicate
160
+
161
+ added
133
162
  end
134
163
 
135
164
  def standard_special?(token)
136
- token["special"] && STANDARD_SPECIAL_TOKENS.include?(token["content"])
165
+ token["special"] && STANDARD_SPECIAL_TOKENS.key?(token["content"])
166
+ end
167
+
168
+ def validate_added_token_ids!(added_tokens, tokens)
169
+ added_tokens.each do |token|
170
+ content = token.fetch("content")
171
+ id = token["id"]
172
+ reject!("added token #{content.inspect} has non-integer id #{id.inspect}") unless id.is_a?(Integer)
173
+ unless id.between?(0, tokens.length - 1) && tokens[id] == content
174
+ reject!("added token #{content.inspect} id #{id} does not match model.vocab")
175
+ end
176
+ end
137
177
  end
138
178
 
139
179
  def extract_vocab(tokenizer)
@@ -159,7 +199,7 @@ module StaticEmbeddings
159
199
  path = File.join(source_dir, "model.safetensors")
160
200
  raise ConversionError, "missing model.safetensors in #{source_dir}" unless File.file?(path)
161
201
 
162
- name, tensor = sole_matrix_tensor(Safetensors.read(path)[:tensors])
202
+ name, tensor = sole_matrix_tensor(Safetensors.describe(path)[:tensors])
163
203
  rows, dim = tensor[:shape]
164
204
  if rows != vocab_size
165
205
  raise ConversionError,
@@ -167,7 +207,7 @@ module StaticEmbeddings
167
207
  "#{vocab_size} tokens — refusing to guess the mapping"
168
208
  end
169
209
 
170
- [tensor[:bytes], dim]
210
+ [Safetensors.f32_payload(path, tensor), dim]
171
211
  end
172
212
 
173
213
  def sole_matrix_tensor(tensors)
@@ -203,6 +243,7 @@ module StaticEmbeddings
203
243
  strip_accents: profile[:strip_accents],
204
244
  handle_chinese_chars: profile[:handle_chinese_chars],
205
245
  clean_text: profile[:clean_text],
246
+ added_token_mask: profile.fetch(:added_token_mask),
206
247
  max_input_chars_per_word: profile[:max_input_chars_per_word],
207
248
  max_token_chars: max_token_chars(tokens, profile[:continuing_subword_prefix]),
208
249
  subword_prefix: profile[:continuing_subword_prefix]
@@ -241,7 +282,7 @@ module StaticEmbeddings
241
282
  def source_digests
242
283
  SOURCE_FILES.each_with_object({}) do |name, acc|
243
284
  path = File.join(source_dir, name)
244
- acc[name] = Digest::SHA256.hexdigest(File.binread(path)) if File.file?(path)
285
+ acc[name] = Digest::SHA256.file(path).hexdigest if File.file?(path)
245
286
  end
246
287
  end
247
288
 
@@ -252,6 +293,9 @@ module StaticEmbeddings
252
293
  "source_model_id" => model_id || File.basename(File.expand_path(source_dir)),
253
294
  "source_files_sha256" => source_digests,
254
295
  "reference_impl" => REFERENCE_IMPL,
296
+ "reference_model2vec_version" => REFERENCE_MODEL2VEC_VERSION,
297
+ "reference_tokenizers_version" => REFERENCE_TOKENIZERS_VERSION,
298
+ "reference_unicode_categories_version" => REFERENCE_UNICODE_CATEGORIES_VERSION,
255
299
  "reference_max_tokens" => meta[:max_tokens_default],
256
300
  "unicode_source" => UnicodeTables.source_stamp,
257
301
  "tokenizer_profile" => TOKENIZER_PROFILE,
@@ -3,7 +3,7 @@ require "digest"
3
3
  module StaticEmbeddings
4
4
  module Format
5
5
  MAGIC = "SEMBv1\0\0"
6
- VERSION = 2
6
+ VERSION = 3
7
7
  HEADER_SIZE = 320
8
8
  ALIGNMENT = 64
9
9
 
@@ -12,7 +12,7 @@ module StaticEmbeddings
12
12
  POOLING_MEAN = 1
13
13
  NORMALIZATION_NONE = 0
14
14
  NORMALIZATION_L2 = 1
15
- TRUNCATE_IDS_BEFORE_POOLING = 1
15
+ TRUNCATE_USABLE_IDS_BEFORE_POOLING = 2
16
16
  UNK_INCLUDE = 0
17
17
  UNK_DROP = 1
18
18
  EMPTY_ZERO_VECTOR = 0
@@ -26,6 +26,14 @@ module StaticEmbeddings
26
26
  CHECKSUM_OFFSET = 240
27
27
  CHECKSUM_SIZE = 32
28
28
  MAX_PROBE_OFFSET = 304
29
+ ADDED_TOKEN_MASK_OFFSET = 308
30
+
31
+ ADDED_PAD = 1 << 0
32
+ ADDED_UNK = 1 << 1
33
+ ADDED_CLS = 1 << 2
34
+ ADDED_SEP = 1 << 3
35
+ ADDED_MASK = 1 << 4
36
+ ADDED_TOKEN_MASK_ALL = ADDED_PAD | ADDED_UNK | ADDED_CLS | ADDED_SEP | ADDED_MASK
29
37
 
30
38
  SECTION_FIELDS = {
31
39
  vocab_strings: 128,
@@ -44,7 +52,7 @@ module StaticEmbeddings
44
52
  28 => TOKENIZER_BERT_WORDPIECE_V1,
45
53
  32 => DTYPE_F32,
46
54
  36 => POOLING_MEAN,
47
- 48 => TRUNCATE_IDS_BEFORE_POOLING,
55
+ 48 => TRUNCATE_USABLE_IDS_BEFORE_POOLING,
48
56
  108 => HASH_SEED
49
57
  }.freeze
50
58
 
@@ -102,7 +110,7 @@ module StaticEmbeddings
102
110
  def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
103
111
  hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
104
112
  root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
105
- sections, body = build_body(
113
+ payloads = {
106
114
  vocab_strings: strings,
107
115
  vocab_hash: hash_blob,
108
116
  embeddings: matrix,
@@ -110,15 +118,48 @@ module StaticEmbeddings
110
118
  provenance: provenance,
111
119
  root_trie: root_trie,
112
120
  continuation_trie: continuation_trie
113
- )
114
-
121
+ }
122
+ sections, file_size = layout_sections(payloads)
115
123
  header = build_header(meta, tokens.length, hash_size, max_probe, sections)
116
- file = header << body
117
- digest = Digest::SHA256.digest(file)
118
- file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
119
124
 
120
- File.binwrite(path, file)
121
- { bytes: file.bytesize, sha256: digest.unpack1("H*"), hash_table_size: hash_size }
125
+ digest = Digest::SHA256.new
126
+ File.open(path, "wb") do |io|
127
+ io.write(header)
128
+ digest << header
129
+ offset = HEADER_SIZE
130
+
131
+ payloads.each do |name, payload|
132
+ target = sections.fetch(name).first
133
+ padding = target - offset
134
+ if padding.positive?
135
+ zeros = "\0".b * padding
136
+ io.write(zeros)
137
+ digest << zeros
138
+ end
139
+ write_payload(io, digest, payload)
140
+ offset = target + payload.bytesize
141
+ end
142
+ end
143
+
144
+ checksum = digest.digest
145
+ File.open(path, "r+b") do |io|
146
+ io.seek(CHECKSUM_OFFSET, IO::SEEK_SET)
147
+ io.write(checksum)
148
+ end
149
+
150
+ { bytes: file_size, sha256: checksum.unpack1("H*"), hash_table_size: hash_size }
151
+ end
152
+
153
+ def write_payload(io, digest, payload)
154
+ if payload.respond_to?(:each_chunk)
155
+ payload.each_chunk do |chunk|
156
+ io.write(chunk)
157
+ digest << chunk
158
+ end
159
+ else
160
+ io.write(payload)
161
+ digest << payload
162
+ end
122
163
  end
123
164
 
124
165
  def verify(path)
@@ -240,20 +281,16 @@ module StaticEmbeddings
240
281
  end
241
282
  end
242
283
 
243
- def build_body(payloads)
244
- body = binary_string
284
+ def layout_sections(payloads)
285
+ offset = HEADER_SIZE
245
286
  sections = {}
246
287
  payloads.each do |name, payload|
247
- align_body!(body)
248
- sections[name] = [HEADER_SIZE + body.bytesize, payload.bytesize]
249
- body << payload
288
+ padding = (ALIGNMENT - (offset % ALIGNMENT)) % ALIGNMENT
289
+ offset += padding
290
+ sections[name] = [offset, payload.bytesize]
291
+ offset += payload.bytesize
250
292
  end
251
- [sections, body]
252
- end
253
-
254
- def align_body!(body)
255
- padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
256
- body << "\0".b * padding if padding.positive?
293
+ [sections, offset]
257
294
  end
258
295
 
259
296
  def build_header(meta, vocab_size, hash_size, max_probe, sections)
@@ -268,6 +305,7 @@ module StaticEmbeddings
268
305
  put_u32(header, 104, hash_size)
269
306
  put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
270
307
  put_u32(header, MAX_PROBE_OFFSET, max_probe)
308
+ put_u32(header, ADDED_TOKEN_MASK_OFFSET, meta.fetch(:added_token_mask, 0))
271
309
  put_prefix(header, meta.fetch(:subword_prefix))
272
310
  SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
273
311
 
@@ -11,7 +11,24 @@ module StaticEmbeddings
11
11
  end
12
12
 
13
13
  def model_path(model_id, env = ENV)
14
- File.join(cache_dir(env), "models", "#{model_id}.semb")
14
+ id = model_id.to_s
15
+ raise ArgumentError, "model_id must not be empty" if id.empty?
16
+ raise ArgumentError, "model_id contains a NUL byte" if id.include?("\0")
17
+
18
+ normalized = id.tr("\\", "/")
19
+ parts = normalized.split("/", -1)
20
+ if normalized.start_with?("/") ||
21
+ parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
22
+ raise ArgumentError, "model_id must be a relative slash-separated identifier"
23
+ end
24
+
25
+ base = File.expand_path(File.join(cache_dir(env), "models"))
26
+ path = File.expand_path(File.join(base, "#{normalized}.semb"))
27
+ prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
28
+ unless path.start_with?(prefix)
29
+ raise ArgumentError, "model_id escapes the model cache"
30
+ end
31
+ path
15
32
  end
16
33
 
17
34
  def builtin_path(name)