lean_ctx/core/embeddings/
tokenizer.rs1use std::collections::HashMap;
13use std::path::Path;
14
15pub struct WordPieceTokenizer {
16 vocab: HashMap<String, i32>,
17 cls_id: i32,
18 sep_id: i32,
19 pad_id: i32,
20 unk_id: i32,
21 max_word_chars: usize,
22}
23
24#[derive(Debug, Clone)]
25pub struct TokenizedInput {
26 pub input_ids: Vec<i32>,
27 pub attention_mask: Vec<i32>,
28 pub token_type_ids: Vec<i32>,
29}
30
31impl TokenizedInput {
32 pub fn pad_to(&mut self, target_len: usize, pad_id: i32) {
34 while self.input_ids.len() < target_len {
35 self.input_ids.push(pad_id);
36 self.attention_mask.push(0);
37 self.token_type_ids.push(0);
38 }
39 }
40}
41
42impl WordPieceTokenizer {
43 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
45 let content = std::fs::read_to_string(path)
46 .map_err(|e| anyhow::anyhow!("Failed to read vocab file {}: {}", path.display(), e))?;
47 Self::from_vocab_str(&content)
48 }
49
50 pub fn from_vocab_str(vocab_str: &str) -> anyhow::Result<Self> {
52 Self::build(vocab_str, true)
53 }
54
55 fn build(vocab_str: &str, require_markers: bool) -> anyhow::Result<Self> {
60 let vocab: HashMap<String, i32> = vocab_str
61 .lines()
62 .enumerate()
63 .map(|(i, line)| (line.to_string(), i as i32))
64 .collect();
65
66 let marker = |name: &str| -> anyhow::Result<i32> {
67 match vocab.get(name) {
68 Some(id) => Ok(*id),
69 None if require_markers => anyhow::bail!("Vocabulary missing {name} token"),
70 None => Ok(-1),
71 }
72 };
73 let cls_id = marker("[CLS]")?;
74 let sep_id = marker("[SEP]")?;
75 let pad_id = *vocab
76 .get("[PAD]")
77 .ok_or_else(|| anyhow::anyhow!("Vocabulary missing [PAD] token"))?;
78 let unk_id = *vocab
79 .get("[UNK]")
80 .ok_or_else(|| anyhow::anyhow!("Vocabulary missing [UNK] token"))?;
81
82 Ok(Self {
83 vocab,
84 cls_id,
85 sep_id,
86 pad_id,
87 unk_id,
88 max_word_chars: 200,
89 })
90 }
91
92 pub fn encode(&self, text: &str, max_len: usize) -> TokenizedInput {
94 let words = self.pre_tokenize(text);
95 let mut ids = vec![self.cls_id];
96
97 for word in &words {
98 if ids.len() >= max_len - 1 {
99 break;
100 }
101 let subword_ids = self.wordpiece_encode(word);
102 for id in subword_ids {
103 if ids.len() >= max_len - 1 {
104 break;
105 }
106 ids.push(id);
107 }
108 }
109
110 ids.push(self.sep_id);
111
112 let len = ids.len();
113 TokenizedInput {
114 input_ids: ids,
115 attention_mask: vec![1; len],
116 token_type_ids: vec![0; len],
117 }
118 }
119
120 pub fn encode_plain(&self, text: &str, max_tokens: usize) -> Vec<i32> {
124 let mut ids = Vec::new();
125 for word in self.pre_tokenize(text) {
126 for id in self.wordpiece_encode(&word) {
127 if ids.len() >= max_tokens {
128 return ids;
129 }
130 if id != self.unk_id {
131 ids.push(id);
132 }
133 }
134 }
135 ids
136 }
137
138 pub fn pad_id(&self) -> i32 {
139 self.pad_id
140 }
141
142 pub fn vocab_size(&self) -> usize {
143 self.vocab.len()
144 }
145
146 fn pre_tokenize(&self, text: &str) -> Vec<String> {
150 let mut words = Vec::new();
151 let mut current = String::new();
152
153 for ch in text.chars() {
154 if ch.is_whitespace() {
155 if !current.is_empty() {
156 words.extend(self.split_identifier(¤t));
157 current.clear();
158 }
159 } else if is_bert_punctuation(ch) {
160 if !current.is_empty() {
161 words.extend(self.split_identifier(¤t));
162 current.clear();
163 }
164 words.push(ch.to_string());
165 } else {
166 current.push(ch);
167 }
168 }
169 if !current.is_empty() {
170 words.extend(self.split_identifier(¤t));
171 }
172
173 words.iter().map(|w| w.to_lowercase()).collect()
174 }
175
176 fn split_identifier(&self, word: &str) -> Vec<String> {
179 let lower = word.to_lowercase();
180 if self.vocab.contains_key(&lower) {
181 return vec![word.to_string()];
182 }
183
184 let mut parts = Vec::new();
185 let mut current = String::new();
186 let chars: Vec<char> = word.chars().collect();
187
188 for (i, &ch) in chars.iter().enumerate() {
189 if ch == '_' || ch == '-' {
190 if !current.is_empty() {
191 parts.push(current.clone());
192 current.clear();
193 }
194 } else if i > 0 && ch.is_ascii_uppercase() && chars[i - 1].is_ascii_lowercase() {
195 if !current.is_empty() {
196 parts.push(current.clone());
197 current.clear();
198 }
199 current.push(ch);
200 } else {
201 current.push(ch);
202 }
203 }
204 if !current.is_empty() {
205 parts.push(current);
206 }
207
208 if parts.is_empty() {
209 vec![word.to_string()]
210 } else {
211 parts
212 }
213 }
214
215 fn wordpiece_encode(&self, word: &str) -> Vec<i32> {
217 if word.chars().count() > self.max_word_chars {
218 return vec![self.unk_id];
219 }
220
221 let chars: Vec<char> = word.chars().collect();
222 let mut tokens = Vec::new();
223 let mut start = 0;
224
225 while start < chars.len() {
226 let mut end = chars.len();
227 let mut matched = false;
228
229 while start < end {
230 let substr: String = chars[start..end].iter().collect();
231 let candidate = if start > 0 {
232 format!("##{substr}")
233 } else {
234 substr
235 };
236
237 if let Some(&id) = self.vocab.get(&candidate) {
238 tokens.push(id);
239 matched = true;
240 start = end;
241 break;
242 }
243 end -= 1;
244 }
245
246 if !matched {
247 tokens.push(self.unk_id);
248 start += 1;
249 }
250 }
251
252 tokens
253 }
254}
255
256fn is_bert_punctuation(ch: char) -> bool {
258 if ch.is_ascii() {
259 matches!(
260 ch,
261 '!' | '"'
262 | '#'
263 | '$'
264 | '%'
265 | '&'
266 | '\''
267 | '('
268 | ')'
269 | '*'
270 | '+'
271 | ','
272 | '-'
273 | '.'
274 | '/'
275 | ':'
276 | ';'
277 | '<'
278 | '='
279 | '>'
280 | '?'
281 | '@'
282 | '['
283 | '\\'
284 | ']'
285 | '^'
286 | '_'
287 | '`'
288 | '{'
289 | '|'
290 | '}'
291 | '~'
292 )
293 } else {
294 ch.is_ascii_punctuation()
295 }
296}
297
298pub struct HfTokenizerWrapper {
304 inner: WordPieceTokenizer,
305}
306
307impl HfTokenizerWrapper {
308 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
310 let content = std::fs::read_to_string(path).map_err(|e| {
311 anyhow::anyhow!("Failed to read tokenizer.json {}: {}", path.display(), e)
312 })?;
313 Self::from_json(&content, true)
314 }
315
316 pub fn from_file_static(path: &Path) -> anyhow::Result<Self> {
319 let content = std::fs::read_to_string(path).map_err(|e| {
320 anyhow::anyhow!("Failed to read tokenizer.json {}: {}", path.display(), e)
321 })?;
322 Self::from_json(&content, false)
323 }
324
325 fn from_json(json_str: &str, require_markers: bool) -> anyhow::Result<Self> {
326 let parsed: serde_json::Value = serde_json::from_str(json_str)
327 .map_err(|e| anyhow::anyhow!("Invalid tokenizer.json: {e}"))?;
328
329 let vocab_obj = parsed
330 .get("model")
331 .and_then(|m| m.get("vocab"))
332 .and_then(|v| v.as_object())
333 .ok_or_else(|| anyhow::anyhow!("tokenizer.json missing model.vocab object"))?;
334
335 let mut vocab_lines: Vec<(String, i32)> = vocab_obj
336 .iter()
337 .filter_map(|(token, id)| id.as_i64().map(|id| (token.clone(), id as i32)))
338 .collect();
339 vocab_lines.sort_by_key(|(_, id)| *id);
340
341 for (token, _) in &mut vocab_lines {
347 let mapped: &str = match token.as_str() {
348 "<s>" => "[CLS]",
349 "</s>" => "[SEP]",
350 "<pad>" => "[PAD]",
351 "<unk>" => "[UNK]",
352 "<mask>" => "[MASK]",
354 _ => continue,
355 };
356 *token = mapped.to_string();
357 }
358
359 let vocab_str: String = vocab_lines
360 .into_iter()
361 .map(|(token, _)| token)
362 .collect::<Vec<_>>()
363 .join("\n");
364
365 let inner = WordPieceTokenizer::build(&vocab_str, require_markers)?;
366 Ok(Self { inner })
367 }
368
369 pub fn encode_plain(&self, text: &str, max_tokens: usize) -> Vec<i32> {
370 self.inner.encode_plain(text, max_tokens)
371 }
372
373 pub fn vocab_size(&self) -> usize {
374 self.inner.vocab_size()
375 }
376
377 pub fn encode(&self, text: &str, max_len: usize) -> TokenizedInput {
378 self.inner.encode(text, max_len)
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn test_vocab() -> WordPieceTokenizer {
387 let vocab = "[PAD]\n[UNK]\n[CLS]\n[SEP]\nhello\nworld\nfn\nvalidate\ntoken\n##s\n##ing\nauth\n##enticate\nuser\nhandle\nrequest\n##er\nprocess\ndata\n.\n,\n(\n)\n{";
388 WordPieceTokenizer::from_vocab_str(vocab).unwrap()
389 }
390
391 #[test]
392 fn encode_basic() {
393 let tok = test_vocab();
394 let input = tok.encode("hello world", 512);
395 assert_eq!(input.input_ids[0], tok.cls_id);
396 assert_eq!(*input.input_ids.last().unwrap(), tok.sep_id);
397 assert!(input.input_ids.len() >= 4); }
399
400 #[test]
401 fn encode_attention_mask() {
402 let tok = test_vocab();
403 let input = tok.encode("hello", 512);
404 assert!(input.attention_mask.iter().all(|&m| m == 1));
405 assert_eq!(input.attention_mask.len(), input.input_ids.len());
406 }
407
408 #[test]
409 fn encode_token_type_ids_are_zero() {
410 let tok = test_vocab();
411 let input = tok.encode("hello", 512);
412 assert!(input.token_type_ids.iter().all(|&t| t == 0));
413 }
414
415 #[test]
416 fn encode_respects_max_len() {
417 let tok = test_vocab();
418 let input = tok.encode("hello world hello world hello world", 6);
419 assert!(input.input_ids.len() <= 6);
420 assert_eq!(input.input_ids[0], tok.cls_id);
421 assert_eq!(*input.input_ids.last().unwrap(), tok.sep_id);
422 }
423
424 #[test]
425 fn wordpiece_subwords() {
426 let tok = test_vocab();
427 let ids = tok.wordpiece_encode("tokens");
429 assert_eq!(ids.len(), 2);
430 assert_eq!(ids[0], *tok.vocab.get("token").unwrap());
431 assert_eq!(ids[1], *tok.vocab.get("##s").unwrap());
432 }
433
434 #[test]
435 fn wordpiece_unknown() {
436 let tok = test_vocab();
437 let ids = tok.wordpiece_encode("xyzzyplugh");
438 assert!(ids.contains(&tok.unk_id));
439 }
440
441 #[test]
442 fn pre_tokenize_camel_case() {
443 let tok = test_vocab();
444 let words = tok.pre_tokenize("handleRequest");
445 assert!(words.contains(&"handle".to_string()));
446 assert!(words.contains(&"request".to_string()));
447 }
448
449 #[test]
450 fn pre_tokenize_snake_case() {
451 let tok = test_vocab();
452 let words = tok.pre_tokenize("validate_token");
453 assert!(words.contains(&"validate".to_string()));
454 assert!(words.contains(&"token".to_string()));
455 }
456
457 #[test]
458 fn pre_tokenize_punctuation() {
459 let tok = test_vocab();
460 let words = tok.pre_tokenize("fn(x)");
461 assert!(words.contains(&"fn".to_string()));
462 assert!(words.contains(&"(".to_string()));
463 assert!(words.contains(&")".to_string()));
464 }
465
466 #[test]
467 fn pad_to_extends() {
468 let tok = test_vocab();
469 let mut input = tok.encode("hello", 512);
470 let original_len = input.input_ids.len();
471 input.pad_to(10, tok.pad_id);
472 assert_eq!(input.input_ids.len(), 10);
473 assert_eq!(input.attention_mask[original_len], 0);
474 }
475
476 #[test]
477 fn vocab_size() {
478 let tok = test_vocab();
479 assert_eq!(tok.vocab_size(), 24);
480 }
481
482 #[test]
483 fn empty_input() {
484 let tok = test_vocab();
485 let input = tok.encode("", 512);
486 assert_eq!(input.input_ids.len(), 2); }
488
489 #[test]
490 fn bert_punctuation_detection() {
491 assert!(is_bert_punctuation('.'));
492 assert!(is_bert_punctuation('('));
493 assert!(is_bert_punctuation('{'));
494 assert!(!is_bert_punctuation('a'));
495 assert!(!is_bert_punctuation('0'));
496 }
497
498 #[test]
499 fn hf_tokenizer_remaps_bpe_special_tokens() {
500 let json = r#"{
502 "version": "1.0",
503 "model": {
504 "type": "BPE",
505 "vocab": {
506 "<s>": 0, "<pad>": 1, "</s>": 2, "<unk>": 3,
507 "hello": 4, "world": 5, "fn": 6
508 }
509 }
510 }"#;
511 let tok = HfTokenizerWrapper::from_json(json, true).unwrap();
512
513 let input = tok.encode("hello world", 512);
516 assert_eq!(
517 input.input_ids[0], 0,
518 "first token should be [CLS] (remapped from <s>)"
519 );
520 assert_eq!(
521 *input.input_ids.last().unwrap(),
522 2,
523 "last token should be [SEP] (remapped from </s>)"
524 );
525 assert_eq!(input.input_ids.len(), 4); }
527
528 #[test]
529 fn hf_tokenizer_from_json() {
530 let json = r#"{
531 "version": "1.0",
532 "model": {
533 "type": "WordPiece",
534 "vocab": {
535 "[PAD]": 0, "[UNK]": 1, "[CLS]": 2, "[SEP]": 3,
536 "hello": 4, "world": 5, "fn": 6
537 }
538 }
539 }"#;
540 let tok = HfTokenizerWrapper::from_json(json, true).unwrap();
541 let input = tok.encode("hello world", 512);
542 assert_eq!(input.input_ids[0], 2); assert_eq!(*input.input_ids.last().unwrap(), 3); assert!(input.input_ids.len() >= 4);
545 }
546
547 #[test]
548 fn hf_tokenizer_invalid_json() {
549 assert!(HfTokenizerWrapper::from_json("not json", true).is_err());
550 }
551
552 #[test]
553 fn hf_tokenizer_missing_vocab() {
554 let json = r#"{"model": {"type": "WordPiece"}}"#;
555 assert!(HfTokenizerWrapper::from_json(json, true).is_err());
556 }
557
558 #[test]
562 fn static_vocab_without_markers_encodes_plain_tokens() {
563 let json = r###"{"model": {"type": "WordPiece", "vocab": {
564 "[PAD]": 0, "[UNK]": 1, "read": 2, "file": 3, "##s": 4}}}"###;
565 assert!(HfTokenizerWrapper::from_json(json, true).is_err());
566 let tok = HfTokenizerWrapper::from_json(json, false).unwrap();
567 assert_eq!(tok.encode_plain("read files", 64), vec![2, 3, 4]);
568 assert_eq!(tok.encode_plain("read zzz", 64), vec![2], "[UNK] dropped");
569 assert_eq!(tok.encode_plain("read files", 2), vec![2, 3], "token cap");
570 {
571 let found = &tok.encode_plain("", 64);
572 assert!(found.is_empty(), "{found:?}");
573 }
574 }
575}