1use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12#[cfg(feature = "onnx-embeddings")]
13use std::sync::{Arc, Mutex};
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::embedding::{EmbeddingError, EmbeddingProvider, MockEmbeddingProvider};
20
21pub struct WordPieceTokenizer {
36 vocab: HashMap<String, i64>,
38 max_length: usize,
40 max_word_chars: usize,
42}
43
44const CLS_ID: i64 = 101;
46const SEP_ID: i64 = 102;
47const UNK_ID: i64 = 100;
48const PAD_ID: i64 = 0;
49
50impl WordPieceTokenizer {
51 pub fn load(vocab_path: &Path) -> Option<Self> {
55 let content = std::fs::read_to_string(vocab_path).ok()?;
56 let line_count = content.lines().count();
57 let mut vocab = HashMap::with_capacity(line_count);
58 for (id, line) in content.lines().enumerate() {
59 vocab.insert(line.to_string(), id as i64);
60 }
61 if vocab.len() < 1000 {
62 tracing::warn!(
64 "vocab.txt at {} has only {} entries, expected ~30k",
65 vocab_path.display(),
66 vocab.len()
67 );
68 return None;
69 }
70 tracing::info!(
71 "WordPiece vocab loaded: {} tokens from {}",
72 vocab.len(),
73 vocab_path.display()
74 );
75 Some(Self {
76 vocab,
77 max_length: 128,
78 max_word_chars: 100,
79 })
80 }
81
82 pub fn with_max_length(mut self, max_length: usize) -> Self {
84 self.max_length = max_length;
85 self
86 }
87
88 pub fn encode(&self, text: &str) -> Option<(Vec<i64>, Vec<i64>, Vec<i64>)> {
93 if self.vocab.is_empty() {
94 return None;
95 }
96
97 let mut token_ids: Vec<i64> = Vec::with_capacity(self.max_length);
98 token_ids.push(CLS_ID);
99
100 let words = self.pre_tokenize(text);
102
103 for word in &words {
104 if token_ids.len() >= self.max_length - 1 {
105 break; }
107 let sub_ids = self.wordpiece_split(word);
108 for id in sub_ids {
109 if token_ids.len() >= self.max_length - 1 {
110 break;
111 }
112 token_ids.push(id);
113 }
114 }
115
116 token_ids.push(SEP_ID);
117
118 let seq_len = token_ids.len();
119 let mut attention_mask = vec![1i64; seq_len];
120 let mut token_type_ids = vec![0i64; seq_len];
121
122 while token_ids.len() < self.max_length {
124 token_ids.push(PAD_ID);
125 attention_mask.push(0);
126 token_type_ids.push(0);
127 }
128
129 Some((token_ids, attention_mask, token_type_ids))
130 }
131
132 fn pre_tokenize(&self, text: &str) -> Vec<String> {
134 let lower = text.to_lowercase();
135 let mut words = Vec::new();
136 let mut current = String::new();
137
138 for ch in lower.chars() {
139 if ch.is_whitespace() {
140 if !current.is_empty() {
141 words.push(std::mem::take(&mut current));
142 }
143 } else if ch.is_ascii_punctuation() || is_cjk_char(ch) {
144 if !current.is_empty() {
146 words.push(std::mem::take(&mut current));
147 }
148 words.push(ch.to_string());
149 } else if is_accent_char(ch) {
150 continue;
152 } else if ch.is_control() {
153 continue;
154 } else {
155 current.push(ch);
156 }
157 }
158 if !current.is_empty() {
159 words.push(current);
160 }
161 words
162 }
163
164 fn wordpiece_split(&self, word: &str) -> Vec<i64> {
166 if word.len() > self.max_word_chars {
167 return vec![UNK_ID];
168 }
169
170 let chars: Vec<char> = word.chars().collect();
171 let mut ids = Vec::new();
172 let mut start = 0;
173
174 while start < chars.len() {
175 let mut end = chars.len();
176 let mut found = false;
177
178 while start < end {
179 let substr: String = if start == 0 {
180 chars[start..end].iter().collect()
181 } else {
182 format!("##{}", chars[start..end].iter().collect::<String>())
183 };
184
185 if self.vocab.contains_key(&substr) {
186 ids.push(self.vocab[&substr]);
187 found = true;
188 start = end;
189 break;
190 }
191 end -= 1;
192 }
193
194 if !found {
195 ids.push(UNK_ID);
196 start += 1;
197 }
198 }
199
200 ids
201 }
202}
203
204fn is_cjk_char(ch: char) -> bool {
206 let cp = ch as u32;
207 matches!(cp,
208 0x4E00..=0x9FFF
209 | 0x3400..=0x4DBF
210 | 0x20000..=0x2A6DF
211 | 0x2A700..=0x2B73F
212 | 0x2B740..=0x2B81F
213 | 0x2B820..=0x2CEAF
214 | 0xF900..=0xFAFF
215 | 0x2F800..=0x2FA1F
216 )
217}
218
219fn is_accent_char(ch: char) -> bool {
221 let cp = ch as u32;
222 matches!(cp, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0xFE20..=0xFE2F)
223}
224
225#[cfg_attr(not(feature = "onnx-embeddings"), allow(dead_code))]
230fn vocab_search_paths(model_path: &Path) -> Vec<PathBuf> {
231 let mut paths = Vec::new();
232
233 if let Some(parent) = model_path.parent() {
234 paths.push(parent.join("vocab.txt"));
236
237 if let Some(stem) = model_path.file_stem() {
239 paths.push(parent.join(stem).join("vocab.txt"));
240 }
241 }
242
243 let model_dir_name = "all-MiniLM-L6-v2";
245 paths.push(PathBuf::from(format!(
246 ".weftos/models/{model_dir_name}/vocab.txt"
247 )));
248 if let Ok(home) = std::env::var("HOME") {
249 paths.push(PathBuf::from(format!(
250 "{home}/.weftos/models/{model_dir_name}/vocab.txt"
251 )));
252 }
253 if let Ok(env_dir) = std::env::var("WEFTOS_VOCAB_PATH") {
254 paths.push(PathBuf::from(env_dir));
255 }
256
257 paths
258}
259
260fn simple_tokenize(text: &str, max_tokens: usize) -> Vec<String> {
266 text.to_lowercase()
267 .split_whitespace()
268 .take(max_tokens)
269 .map(|s| {
270 s.chars()
271 .filter(|c| c.is_alphanumeric())
272 .collect::<String>()
273 })
274 .filter(|s| !s.is_empty())
275 .collect()
276}
277
278fn tokens_to_embedding(tokens: &[String], dims: usize) -> Vec<f32> {
281 let mut embedding = vec![0.0f32; dims];
282
283 for (i, token) in tokens.iter().enumerate() {
284 let mut hasher = Sha256::new();
285 hasher.update(token.as_bytes());
286 hasher.update((i as u32).to_le_bytes());
287 let hash = hasher.finalize();
288
289 for (j, &byte) in hash.iter().enumerate() {
291 let dim = (j + i * 32) % dims;
292 let val = (byte as f32 / 128.0) - 1.0; embedding[dim] += val / (tokens.len() as f32).sqrt();
294 }
295 }
296
297 l2_normalize(&mut embedding);
298 embedding
299}
300
301fn l2_normalize(vec: &mut [f32]) {
303 let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
304 if norm > 0.0 {
305 vec.iter_mut().for_each(|x| *x /= norm);
306 }
307}
308
309#[cfg(test)]
311fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
312 debug_assert_eq!(a.len(), b.len());
313 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
314 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
315 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
316 if norm_a == 0.0 || norm_b == 0.0 {
317 return 0.0;
318 }
319 dot / (norm_a * norm_b)
320}
321
322pub struct OnnxEmbeddingProvider {
333 model_path: PathBuf,
335 dimensions: usize,
337 model_name: String,
339 runtime_available: bool,
341 max_tokens: usize,
343 #[allow(dead_code)]
345 fallback: MockEmbeddingProvider,
346 #[cfg(feature = "onnx-embeddings")]
349 tokenizer: Option<WordPieceTokenizer>,
350 #[cfg(feature = "onnx-embeddings")]
354 session: Option<Arc<Mutex<ort::session::Session>>>,
355}
356
357impl OnnxEmbeddingProvider {
358 pub const DEFAULT_DIMS: usize = 384;
360 pub const DEFAULT_MAX_TOKENS: usize = 128;
362 pub const MODEL_NAME: &'static str = "all-MiniLM-L6-v2";
364
365 pub fn new(model_path: impl Into<PathBuf>) -> Self {
370 let model_path = model_path.into();
371 #[cfg(feature = "onnx-embeddings")]
372 let session = Self::try_load_session(&model_path);
373 #[cfg(feature = "onnx-embeddings")]
374 let runtime_available = session.is_some();
375 #[cfg(not(feature = "onnx-embeddings"))]
376 let runtime_available = false;
377 #[cfg(feature = "onnx-embeddings")]
378 let tokenizer = Self::try_load_tokenizer(&model_path, Self::DEFAULT_MAX_TOKENS);
379
380 Self {
381 model_name: if runtime_available {
382 Self::MODEL_NAME.to_string()
383 } else {
384 format!("{}-hash-fallback", Self::MODEL_NAME)
385 },
386 model_path,
387 dimensions: Self::DEFAULT_DIMS,
388 runtime_available,
389 max_tokens: Self::DEFAULT_MAX_TOKENS,
390 fallback: MockEmbeddingProvider::new(Self::DEFAULT_DIMS),
391 #[cfg(feature = "onnx-embeddings")]
392 tokenizer,
393 #[cfg(feature = "onnx-embeddings")]
394 session,
395 }
396 }
397
398 pub fn with_config(
400 model_path: impl Into<PathBuf>,
401 dimensions: usize,
402 max_tokens: usize,
403 ) -> Self {
404 let model_path = model_path.into();
405 #[cfg(feature = "onnx-embeddings")]
406 let session = Self::try_load_session(&model_path);
407 #[cfg(feature = "onnx-embeddings")]
408 let runtime_available = session.is_some();
409 #[cfg(not(feature = "onnx-embeddings"))]
410 let runtime_available = false;
411 #[cfg(feature = "onnx-embeddings")]
412 let tokenizer = Self::try_load_tokenizer(&model_path, max_tokens);
413
414 Self {
415 model_name: if runtime_available {
416 Self::MODEL_NAME.to_string()
417 } else {
418 format!("{}-hash-fallback", Self::MODEL_NAME)
419 },
420 model_path,
421 dimensions,
422 runtime_available,
423 max_tokens,
424 fallback: MockEmbeddingProvider::new(dimensions),
425 #[cfg(feature = "onnx-embeddings")]
426 tokenizer,
427 #[cfg(feature = "onnx-embeddings")]
428 session,
429 }
430 }
431
432 #[cfg(feature = "onnx-embeddings")]
434 fn try_load_tokenizer(model_path: &Path, max_tokens: usize) -> Option<WordPieceTokenizer> {
435 for path in vocab_search_paths(model_path) {
436 if path.exists()
437 && let Some(tok) = WordPieceTokenizer::load(&path)
438 {
439 let max_len = (max_tokens + 2).min(512);
443 return Some(tok.with_max_length(max_len));
444 }
445 }
446 tracing::debug!(
447 "No vocab.txt found for WordPiece tokenizer near {}; \
448 ONNX inference will use hash-based token IDs (degraded quality)",
449 model_path.display()
450 );
451 None
452 }
453
454 #[cfg(feature = "onnx-embeddings")]
456 fn try_load_session(model_path: &PathBuf) -> Option<Arc<Mutex<ort::session::Session>>> {
457 if !model_path.exists() {
458 tracing::debug!(
459 "ONNX model not found at {}, using hash fallback",
460 model_path.display()
461 );
462 return None;
463 }
464 match ort::session::Session::builder()
465 .and_then(|mut builder| builder.commit_from_file(model_path))
466 {
467 Ok(session) => {
468 tracing::info!("ONNX session loaded from {}", model_path.display());
469 Some(Arc::new(Mutex::new(session)))
470 }
471 Err(e) => {
472 tracing::warn!("Failed to load ONNX session: {e}, using hash fallback");
473 None
474 }
475 }
476 }
477
478 pub fn is_runtime_available(&self) -> bool {
480 self.runtime_available
481 }
482
483 pub fn model_path(&self) -> &PathBuf {
485 &self.model_path
486 }
487
488 pub fn max_tokens(&self) -> usize {
490 self.max_tokens
491 }
492
493 fn hash_embed(&self, text: &str) -> Vec<f32> {
495 let tokens = simple_tokenize(text, self.max_tokens);
496 if tokens.is_empty() {
497 return vec![0.0f32; self.dimensions];
499 }
500 tokens_to_embedding(&tokens, self.dimensions)
501 }
502
503 #[cfg(feature = "onnx-embeddings")]
514 fn onnx_embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
515 let session = self
516 .session
517 .as_ref()
518 .ok_or_else(|| EmbeddingError::BackendError("ONNX session not loaded".to_string()))?;
519
520 let (input_ids, attention_mask, token_type_ids) =
522 if let Some(ref tokenizer) = self.tokenizer {
523 tokenizer.encode(text).ok_or_else(|| {
524 EmbeddingError::BackendError("WordPiece tokenizer returned None".to_string())
525 })?
526 } else {
527 static WARNED: std::sync::atomic::AtomicBool =
530 std::sync::atomic::AtomicBool::new(false);
531 if !WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
532 tracing::warn!(
533 "ONNX inference without WordPiece vocab — embeddings will not be semantic"
534 );
535 }
536 let tokens = simple_tokenize(text, self.max_tokens);
537 let seq_len = tokens.len().max(1) + 2; let mut ids = vec![CLS_ID];
540 for token in &tokens {
541 let mut hasher = Sha256::new();
542 hasher.update(token.as_bytes());
543 let hash = hasher.finalize();
544 let id = 1000
545 + (u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]) % 29000) as i64;
546 ids.push(id);
547 }
548 ids.push(SEP_ID);
549
550 let mask = vec![1i64; seq_len];
551 let types = vec![0i64; seq_len];
552 (ids, mask, types)
553 };
554
555 let seq_len = input_ids.len();
556 let shape = vec![1_i64, seq_len as i64];
557
558 let input_ids_t = ort::value::Tensor::from_array((shape.clone(), input_ids))
562 .map_err(|e| EmbeddingError::BackendError(format!("input_ids tensor: {e}")))?;
563 let attention_mask_t =
564 ort::value::Tensor::from_array((shape.clone(), attention_mask.clone()))
565 .map_err(|e| EmbeddingError::BackendError(format!("attention_mask tensor: {e}")))?;
566 let token_type_ids_t = ort::value::Tensor::from_array((shape, token_type_ids))
567 .map_err(|e| EmbeddingError::BackendError(format!("token_type_ids tensor: {e}")))?;
568
569 let inputs = ort::inputs![
570 "input_ids" => input_ids_t,
571 "attention_mask" => attention_mask_t,
572 "token_type_ids" => token_type_ids_t,
573 ];
574
575 let mut guard = session
578 .lock()
579 .map_err(|_| EmbeddingError::BackendError("ONNX session mutex poisoned".to_string()))?;
580 let outputs = guard
581 .run(inputs)
582 .map_err(|e| EmbeddingError::BackendError(format!("inference error: {e}")))?;
583
584 let output_tensor = match outputs.get("last_hidden_state") {
587 Some(v) => v,
588 None if outputs.len() > 0 => &outputs[0],
589 None => {
590 return Err(EmbeddingError::BackendError("no output tensor".to_string()));
591 }
592 };
593
594 let (out_shape, data) = output_tensor
596 .try_extract_tensor::<f32>()
597 .map_err(|e| EmbeddingError::BackendError(format!("extract error: {e}")))?;
598
599 if out_shape.len() < 2 {
600 return Err(EmbeddingError::BackendError(format!(
601 "unexpected output shape: {out_shape:?}"
602 )));
603 }
604 let hidden_dim = out_shape[out_shape.len() - 1] as usize;
605 let seq = out_shape[1] as usize;
606
607 let mut embedding = vec![0.0f32; hidden_dim];
609
610 let mut active_count: f32 = 0.0;
611 for s in 0..seq {
612 let mask_val = if s < attention_mask.len() {
613 attention_mask[s] as f32
614 } else {
615 0.0
616 };
617 if mask_val > 0.0 {
618 for d in 0..hidden_dim {
619 embedding[d] += data[s * hidden_dim + d];
620 }
621 active_count += 1.0;
622 }
623 }
624 if active_count > 0.0 {
625 for val in &mut embedding {
626 *val /= active_count;
627 }
628 }
629
630 l2_normalize(&mut embedding);
632
633 embedding.truncate(self.dimensions);
635 while embedding.len() < self.dimensions {
636 embedding.push(0.0);
637 }
638
639 Ok(embedding)
640 }
641}
642
643#[async_trait]
644impl EmbeddingProvider for OnnxEmbeddingProvider {
645 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
646 #[cfg(feature = "onnx-embeddings")]
647 if self.runtime_available {
648 return self.onnx_embed(text);
649 }
650 Ok(self.hash_embed(text))
651 }
652
653 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
654 let mut results = Vec::with_capacity(texts.len());
655 for text in texts {
656 results.push(self.embed(text).await?);
657 }
658 Ok(results)
659 }
660
661 fn dimensions(&self) -> usize {
662 self.dimensions
663 }
664
665 fn model_name(&self) -> &str {
666 &self.model_name
667 }
668}
669
670pub struct SentenceTransformerProvider {
679 base: OnnxEmbeddingProvider,
681 max_tokens: usize,
683 split_sentences: bool,
685}
686
687impl SentenceTransformerProvider {
688 pub const DEFAULT_MAX_TOKENS: usize = 512;
690
691 pub fn new(model_path: impl Into<PathBuf>) -> Self {
693 Self {
694 base: OnnxEmbeddingProvider::with_config(
695 model_path,
696 OnnxEmbeddingProvider::DEFAULT_DIMS,
697 Self::DEFAULT_MAX_TOKENS,
698 ),
699 max_tokens: Self::DEFAULT_MAX_TOKENS,
700 split_sentences: true,
701 }
702 }
703
704 pub fn with_config(
706 model_path: impl Into<PathBuf>,
707 max_tokens: usize,
708 split_sentences: bool,
709 ) -> Self {
710 Self {
711 base: OnnxEmbeddingProvider::with_config(
712 model_path,
713 OnnxEmbeddingProvider::DEFAULT_DIMS,
714 max_tokens,
715 ),
716 max_tokens,
717 split_sentences,
718 }
719 }
720
721 pub fn split_sentences(&self) -> bool {
723 self.split_sentences
724 }
725
726 pub fn max_tokens(&self) -> usize {
728 self.max_tokens
729 }
730
731 fn embed_text(&self, text: &str) -> Vec<f32> {
733 self.base.hash_embed(text)
734 }
735}
736
737pub fn preprocess_markdown(text: &str) -> String {
739 text.lines()
740 .filter(|l| !l.starts_with('#')) .filter(|l| !l.starts_with("```")) .filter(|l| !l.starts_with('|')) .filter(|l| !l.starts_with("- [")) .map(|l| l.trim())
745 .filter(|l| !l.is_empty())
746 .collect::<Vec<_>>()
747 .join(" ")
748}
749
750pub fn split_sentences(text: &str) -> Vec<&str> {
752 text.split(". ")
753 .flat_map(|s| s.split('\n'))
754 .map(|s| s.trim())
755 .filter(|s| s.len() > 10) .collect()
757}
758
759#[async_trait]
760impl EmbeddingProvider for SentenceTransformerProvider {
761 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
762 let cleaned = preprocess_markdown(text);
763
764 if !self.split_sentences {
765 return Ok(self.embed_text(&cleaned));
766 }
767
768 let sentences = split_sentences(&cleaned);
769 if sentences.is_empty() {
770 return Ok(self.embed_text(&cleaned));
772 }
773
774 let dims = self.base.dimensions;
776 let mut summed = vec![0.0f32; dims];
777 let count = sentences.len() as f32;
778
779 for sentence in &sentences {
780 let vec = self.embed_text(sentence);
781 for (i, val) in vec.iter().enumerate() {
782 summed[i] += val;
783 }
784 }
785
786 summed.iter_mut().for_each(|x| *x /= count);
788 l2_normalize(&mut summed);
789
790 Ok(summed)
791 }
792
793 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
794 let mut results = Vec::with_capacity(texts.len());
795 for text in texts {
796 results.push(self.embed(text).await?);
797 }
798 Ok(results)
799 }
800
801 fn dimensions(&self) -> usize {
802 self.base.dimensions
803 }
804
805 fn model_name(&self) -> &str {
806 "sentence-transformer"
807 }
808}
809
810#[derive(Debug, Clone, Default, Serialize, Deserialize)]
816pub struct RustCodeFeatures {
817 pub signature: Option<String>,
819 pub return_type: Option<String>,
821 pub param_types: Vec<String>,
823 pub visibility: String,
825 pub is_async: bool,
827 pub is_generic: bool,
829 pub trait_bounds: Vec<String>,
831 pub attributes: Vec<String>,
833 pub item_kind: String,
835}
836
837pub fn extract_rust_features(code: &str) -> RustCodeFeatures {
840 let mut features = RustCodeFeatures::default();
841
842 if code.contains("pub trait ") || code.contains("trait ") {
846 features.item_kind = "trait".into();
847 } else if code.contains("pub struct ") || code.contains("struct ") {
848 features.item_kind = "struct".into();
849 } else if code.contains("pub enum ") || code.contains("enum ") {
850 features.item_kind = "enum".into();
851 } else if code.contains("pub fn ") || code.contains("fn ") {
852 features.item_kind = "fn".into();
853 } else if code.contains("impl ") {
854 features.item_kind = "impl".into();
855 } else if code.contains("pub mod ") || code.contains("mod ") {
856 features.item_kind = "mod".into();
857 }
858
859 features.visibility = if code.contains("pub(crate)") {
861 "pub(crate)".into()
862 } else if code.contains("pub(super)") {
863 "pub(super)".into()
864 } else if code.contains("pub ") {
865 "pub".into()
866 } else {
867 "private".into()
868 };
869
870 features.is_async = code.contains("async fn");
872
873 features.is_generic = code.contains('<') && code.contains('>');
875
876 for line in code.lines() {
878 let trimmed = line.trim();
879 if trimmed.contains("fn ")
880 || trimmed.starts_with("pub struct ")
881 || trimmed.starts_with("struct ")
882 || trimmed.starts_with("pub enum ")
883 || trimmed.starts_with("enum ")
884 || trimmed.starts_with("pub trait ")
885 || trimmed.starts_with("trait ")
886 {
887 let sig = if let Some(brace) = trimmed.find('{') {
889 trimmed[..brace].trim()
890 } else {
891 trimmed.trim_end_matches(';').trim()
892 };
893 features.signature = Some(sig.to_string());
894 break;
895 }
896 }
897
898 if let Some(arrow) = code.find("->") {
900 let after = &code[arrow + 2..];
901 if let Some(brace) = after.find('{') {
902 features.return_type = Some(after[..brace].trim().to_string());
903 } else if let Some(semi) = after.find(';') {
904 features.return_type = Some(after[..semi].trim().to_string());
905 }
906 }
907
908 if features.item_kind == "fn"
910 && let Some(open) = code.find('(')
911 && let Some(close) = code.find(')')
912 && close > open
913 {
914 let params = &code[open + 1..close];
915 for param in params.split(',') {
916 let param = param.trim();
917 if param == "&self" || param == "&mut self" || param == "self" {
918 features.param_types.push(param.to_string());
919 } else if let Some(colon) = param.find(':') {
920 let ty = param[colon + 1..].trim().to_string();
921 if !ty.is_empty() {
922 features.param_types.push(ty);
923 }
924 }
925 }
926 }
927
928 if let Some(where_idx) = code.find("where") {
930 let after = &code[where_idx + 5..];
931 let end = after.find('{').unwrap_or(after.len());
932 let clause = &after[..end];
933 for bound in clause.split(',') {
934 let bound = bound.trim();
935 if !bound.is_empty() {
936 features.trait_bounds.push(bound.to_string());
937 }
938 }
939 }
940
941 for line in code.lines() {
943 let trimmed = line.trim();
944 if trimmed.starts_with("#[") {
945 features.attributes.push(trimmed.to_string());
946 }
947 }
948
949 features
950}
951
952pub struct AstEmbeddingProvider {
957 text_provider: OnnxEmbeddingProvider,
959 structural_dims: usize,
961 total_dims: usize,
963 structural_weight: f32,
965}
966
967impl AstEmbeddingProvider {
968 pub const DEFAULT_TOTAL_DIMS: usize = 256;
970 pub const DEFAULT_STRUCTURAL_DIMS: usize = 64;
972 pub const DEFAULT_STRUCTURAL_WEIGHT: f32 = 0.3;
974
975 pub fn new(model_path: impl Into<PathBuf>) -> Self {
977 Self {
978 text_provider: OnnxEmbeddingProvider::with_config(
979 model_path,
980 Self::DEFAULT_TOTAL_DIMS - Self::DEFAULT_STRUCTURAL_DIMS,
981 OnnxEmbeddingProvider::DEFAULT_MAX_TOKENS,
982 ),
983 structural_dims: Self::DEFAULT_STRUCTURAL_DIMS,
984 total_dims: Self::DEFAULT_TOTAL_DIMS,
985 structural_weight: Self::DEFAULT_STRUCTURAL_WEIGHT,
986 }
987 }
988
989 pub fn with_config(
991 model_path: impl Into<PathBuf>,
992 total_dims: usize,
993 structural_dims: usize,
994 structural_weight: f32,
995 ) -> Self {
996 assert!(
997 structural_dims < total_dims,
998 "structural_dims must be less than total_dims"
999 );
1000 let text_dims = total_dims - structural_dims;
1001 Self {
1002 text_provider: OnnxEmbeddingProvider::with_config(
1003 model_path,
1004 text_dims,
1005 OnnxEmbeddingProvider::DEFAULT_MAX_TOKENS,
1006 ),
1007 structural_dims,
1008 total_dims,
1009 structural_weight: structural_weight.clamp(0.0, 1.0),
1010 }
1011 }
1012
1013 pub fn total_dims(&self) -> usize {
1015 self.total_dims
1016 }
1017
1018 pub fn structural_weight(&self) -> f32 {
1020 self.structural_weight
1021 }
1022
1023 fn encode_structural(&self, features: &RustCodeFeatures) -> Vec<f32> {
1025 let dims = self.structural_dims;
1026 let mut vec = vec![0.0f32; dims];
1027
1028 let mut write_hash = |label: &str, offset: usize, slots: usize| {
1030 let mut hasher = Sha256::new();
1031 hasher.update(label.as_bytes());
1032 let hash = hasher.finalize();
1033 for (j, &byte) in hash.iter().enumerate().take(slots.min(32)) {
1034 let dim = (offset + j) % dims;
1035 vec[dim] += (byte as f32 / 128.0) - 1.0;
1036 }
1037 };
1038
1039 write_hash(&format!("kind:{}", features.item_kind), 0, 8);
1041
1042 write_hash(&format!("vis:{}", features.visibility), 8, 6);
1044
1045 if features.is_async {
1047 write_hash("async:true", 14, 4);
1048 }
1049
1050 if features.is_generic {
1052 write_hash("generic:true", 18, 4);
1053 }
1054
1055 if let Some(ref rt) = features.return_type {
1057 write_hash(&format!("ret:{rt}"), 22, 8);
1058 }
1059
1060 for (i, pt) in features.param_types.iter().enumerate() {
1062 write_hash(&format!("param{i}:{pt}"), 30 + i * 6, 6);
1063 }
1064
1065 for (i, attr) in features.attributes.iter().enumerate() {
1067 write_hash(&format!("attr{i}:{attr}"), 48 + i * 4, 4);
1068 }
1069
1070 l2_normalize(&mut vec);
1071 vec
1072 }
1073
1074 fn hybrid_embed(&self, code: &str) -> Vec<f32> {
1076 let features = extract_rust_features(code);
1077 let structural = self.encode_structural(&features);
1078 let text = self.text_provider.hash_embed(code);
1079
1080 let w_s = self.structural_weight;
1081 let w_t = 1.0 - w_s;
1082
1083 let mut combined = Vec::with_capacity(self.total_dims);
1085 for val in &structural {
1086 combined.push(val * w_s);
1087 }
1088 for val in &text {
1089 combined.push(val * w_t);
1090 }
1091
1092 combined.truncate(self.total_dims);
1094 while combined.len() < self.total_dims {
1095 combined.push(0.0);
1096 }
1097
1098 l2_normalize(&mut combined);
1099 combined
1100 }
1101}
1102
1103#[async_trait]
1104impl EmbeddingProvider for AstEmbeddingProvider {
1105 async fn embed(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
1106 Ok(self.hybrid_embed(text))
1107 }
1108
1109 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
1110 Ok(texts.iter().map(|t| self.hybrid_embed(t)).collect())
1111 }
1112
1113 fn dimensions(&self) -> usize {
1114 self.total_dims
1115 }
1116
1117 fn model_name(&self) -> &str {
1118 "ast-aware-hybrid"
1119 }
1120}
1121
1122#[cfg(test)]
1127mod tests {
1128 use super::*;
1129
1130 fn vec_magnitude(v: &[f32]) -> f32 {
1133 v.iter().map(|x| x * x).sum::<f32>().sqrt()
1134 }
1135
1136 #[test]
1141 fn onnx_construction_default() {
1142 let p = OnnxEmbeddingProvider::new("/nonexistent/model.onnx");
1143 assert_eq!(p.dimensions(), 384);
1144 assert!(!p.is_runtime_available());
1145 assert!(p.model_name().contains("fallback"));
1146 }
1147
1148 #[test]
1149 fn onnx_construction_custom() {
1150 let p = OnnxEmbeddingProvider::with_config("/tmp/model.onnx", 128, 64);
1151 assert_eq!(p.dimensions(), 128);
1152 assert_eq!(p.max_tokens(), 64);
1153 }
1154
1155 #[tokio::test]
1156 async fn onnx_embed_returns_correct_dimensions() {
1157 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1158 let vec = p.embed("hello world").await.unwrap();
1159 assert_eq!(vec.len(), 384);
1160 }
1161
1162 #[tokio::test]
1163 async fn onnx_embed_deterministic() {
1164 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1165 let v1 = p.embed("test input").await.unwrap();
1166 let v2 = p.embed("test input").await.unwrap();
1167 assert_eq!(v1, v2);
1168 }
1169
1170 #[tokio::test]
1171 async fn onnx_embed_different_inputs_differ() {
1172 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1173 let v1 = p.embed("alpha").await.unwrap();
1174 let v2 = p.embed("beta").await.unwrap();
1175 assert_ne!(v1, v2);
1176 }
1177
1178 #[tokio::test]
1179 async fn onnx_embed_l2_normalized() {
1180 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1181 let vec = p.embed("normalisation check").await.unwrap();
1182 let mag = vec_magnitude(&vec);
1183 assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1184 }
1185
1186 #[tokio::test]
1187 async fn onnx_embed_batch() {
1188 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1189 let results = p.embed_batch(&["a", "b", "c"]).await.unwrap();
1190 assert_eq!(results.len(), 3);
1191 for v in &results {
1192 assert_eq!(v.len(), 384);
1193 }
1194 }
1195
1196 #[tokio::test]
1197 async fn onnx_similar_inputs_high_cosine() {
1198 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1199 let v1 = p.embed("the quick brown fox").await.unwrap();
1200 let v2 = p.embed("the quick brown dog").await.unwrap();
1201 let sim = cosine_similarity(&v1, &v2);
1202 assert!(sim > 0.5, "similar inputs cosine = {sim}, expected > 0.5");
1203 }
1204
1205 #[tokio::test]
1206 async fn onnx_empty_input_returns_zero_vector() {
1207 let p = OnnxEmbeddingProvider::new("/tmp/model.onnx");
1208 let vec = p.embed("").await.unwrap();
1209 assert_eq!(vec.len(), 384);
1210 assert!(vec.iter().all(|x| *x == 0.0));
1211 }
1212
1213 #[test]
1218 fn sentence_construction() {
1219 let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1220 assert_eq!(p.dimensions(), 384);
1221 assert_eq!(p.max_tokens(), 512);
1222 assert!(p.split_sentences());
1223 assert_eq!(p.model_name(), "sentence-transformer");
1224 }
1225
1226 #[tokio::test]
1227 async fn sentence_embed_returns_correct_dimensions() {
1228 let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1229 let vec = p
1230 .embed("This is a test paragraph with enough words.")
1231 .await
1232 .unwrap();
1233 assert_eq!(vec.len(), 384);
1234 }
1235
1236 #[tokio::test]
1237 async fn sentence_embed_l2_normalized() {
1238 let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1239 let vec = p
1240 .embed("Testing normalisation of sentence embeddings here.")
1241 .await
1242 .unwrap();
1243 let mag = vec_magnitude(&vec);
1244 assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1245 }
1246
1247 #[tokio::test]
1248 async fn sentence_embed_batch() {
1249 let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1250 let results = p
1251 .embed_batch(&[
1252 "First paragraph with a decent amount of words in it.",
1253 "Second paragraph also has a reasonable length for testing.",
1254 ])
1255 .await
1256 .unwrap();
1257 assert_eq!(results.len(), 2);
1258 for v in &results {
1259 assert_eq!(v.len(), 384);
1260 }
1261 }
1262
1263 #[tokio::test]
1264 async fn sentence_similar_inputs_positive_cosine() {
1265 let p = SentenceTransformerProvider::new("/tmp/model.onnx");
1266 let v1 = p
1267 .embed("The kernel boots up the system and runs all the services correctly.")
1268 .await
1269 .unwrap();
1270 let v2 = p
1271 .embed("The kernel boots up the system and runs all the services properly.")
1272 .await
1273 .unwrap();
1274 let v3 = p
1275 .embed("Quantum chromodynamics explains the strong interaction between quarks.")
1276 .await
1277 .unwrap();
1278 let sim_similar = cosine_similarity(&v1, &v2);
1279 let sim_different = cosine_similarity(&v1, &v3);
1280 assert!(
1281 sim_similar > sim_different,
1282 "similar ({sim_similar}) should be closer than different ({sim_different})"
1283 );
1284 }
1285
1286 #[test]
1287 fn preprocess_markdown_strips_headers() {
1288 let md = "# Title\nSome text.\n## Subtitle\nMore text.";
1289 let result = preprocess_markdown(md);
1290 assert!(!result.contains("Title"));
1291 assert!(!result.contains("Subtitle"));
1292 assert!(result.contains("Some text."));
1293 assert!(result.contains("More text."));
1294 }
1295
1296 #[test]
1297 fn preprocess_markdown_strips_code_fences() {
1298 let md = "Before.\n```rust\nlet x = 1;\n```\nAfter.";
1299 let result = preprocess_markdown(md);
1300 assert!(!result.contains("```"));
1301 assert!(result.contains("Before."));
1303 assert!(result.contains("After."));
1304 }
1305
1306 #[test]
1307 fn preprocess_markdown_strips_tables() {
1308 let md = "Intro.\n| Col1 | Col2 |\n|------|------|\n| A | B |\nOutro.";
1309 let result = preprocess_markdown(md);
1310 assert!(!result.contains("Col1"));
1311 assert!(result.contains("Intro."));
1312 assert!(result.contains("Outro."));
1313 }
1314
1315 #[test]
1316 fn preprocess_markdown_strips_checklists() {
1317 let md = "Text here.\n- [x] Done item\n- [ ] Todo item\nMore text.";
1318 let result = preprocess_markdown(md);
1319 assert!(!result.contains("Done item"));
1320 assert!(result.contains("Text here."));
1321 }
1322
1323 #[test]
1324 fn split_sentences_basic() {
1325 let text = "First sentence here. Second sentence here. Third.";
1326 let sentences = split_sentences(text);
1327 assert_eq!(sentences.len(), 2);
1329 assert!(sentences[0].contains("First"));
1330 assert!(sentences[1].contains("Second"));
1331 }
1332
1333 #[test]
1334 fn split_sentences_newlines() {
1335 let text = "Line one is long enough.\nLine two is also long enough.";
1336 let sentences = split_sentences(text);
1337 assert_eq!(sentences.len(), 2);
1338 }
1339
1340 #[test]
1345 fn ast_construction_default() {
1346 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1347 assert_eq!(p.dimensions(), 256);
1348 assert_eq!(p.total_dims(), 256);
1349 assert!((p.structural_weight() - 0.3).abs() < 0.001);
1350 assert_eq!(p.model_name(), "ast-aware-hybrid");
1351 }
1352
1353 #[tokio::test]
1354 async fn ast_embed_returns_correct_dimensions() {
1355 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1356 let vec = p.embed("pub fn hello() -> String { }").await.unwrap();
1357 assert_eq!(vec.len(), 256);
1358 }
1359
1360 #[tokio::test]
1361 async fn ast_embed_l2_normalized() {
1362 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1363 let vec = p.embed("pub fn hello() -> String { }").await.unwrap();
1364 let mag = vec_magnitude(&vec);
1365 assert!((mag - 1.0).abs() < 0.01, "magnitude = {mag}, expected ~1.0");
1366 }
1367
1368 #[tokio::test]
1369 async fn ast_embed_batch() {
1370 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1371 let results = p
1372 .embed_batch(&["fn a() {}", "fn b() {}", "struct C {}"])
1373 .await
1374 .unwrap();
1375 assert_eq!(results.len(), 3);
1376 for v in &results {
1377 assert_eq!(v.len(), 256);
1378 }
1379 }
1380
1381 #[tokio::test]
1382 async fn ast_embed_different_inputs_differ() {
1383 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1384 let v1 = p.embed("pub fn alpha() -> u32 {}").await.unwrap();
1385 let v2 = p.embed("struct Beta { x: f64 }").await.unwrap();
1386 assert_ne!(v1, v2);
1387 }
1388
1389 #[tokio::test]
1390 async fn ast_structural_similarity_same_signature() {
1391 let p = AstEmbeddingProvider::new("/tmp/model.onnx");
1394 let v_foo = p
1395 .embed("pub async fn foo(&self, x: u32) -> Result<(), Error> {}")
1396 .await
1397 .unwrap();
1398 let v_bar = p
1399 .embed("pub async fn bar(&self, x: u32) -> Result<(), Error> {}")
1400 .await
1401 .unwrap();
1402 let v_struct = p.embed("pub struct Baz { count: usize }").await.unwrap();
1403
1404 let sim_fns = cosine_similarity(&v_foo, &v_bar);
1405 let sim_fn_struct = cosine_similarity(&v_foo, &v_struct);
1406 assert!(
1407 sim_fns > sim_fn_struct,
1408 "same-signature fns ({sim_fns}) should be more similar than fn-vs-struct ({sim_fn_struct})"
1409 );
1410 }
1411
1412 #[test]
1417 fn rust_features_pub_async_fn() {
1418 let code = r#"
1419#[test]
1420pub async fn process_batch(&self, items: Vec<Item>) -> Result<(), Error> {
1421 // body
1422}
1423"#;
1424 let f = extract_rust_features(code);
1425 assert_eq!(f.item_kind, "fn");
1426 assert_eq!(f.visibility, "pub");
1427 assert!(f.is_async);
1428 assert!(f.is_generic);
1429 assert_eq!(f.return_type.as_deref(), Some("Result<(), Error>"));
1430 assert!(f.attributes.contains(&"#[test]".to_string()));
1431 assert!(f.param_types.contains(&"&self".to_string()));
1432 assert!(f.param_types.iter().any(|p| p.contains("Vec<Item>")));
1433 }
1434
1435 #[test]
1436 fn rust_features_struct() {
1437 let code = "pub struct Config { pub name: String, pub value: u64 }";
1438 let f = extract_rust_features(code);
1439 assert_eq!(f.item_kind, "struct");
1440 assert_eq!(f.visibility, "pub");
1441 assert!(!f.is_async);
1442 assert!(!f.is_generic); assert!(f.return_type.is_none());
1444 }
1445
1446 #[test]
1447 fn rust_features_private_fn() {
1448 let code = "fn helper(x: &str) -> bool { true }";
1449 let f = extract_rust_features(code);
1450 assert_eq!(f.item_kind, "fn");
1451 assert_eq!(f.visibility, "private");
1452 assert!(!f.is_async);
1453 assert_eq!(f.return_type.as_deref(), Some("bool"));
1454 assert!(f.param_types.iter().any(|p| p.contains("&str")));
1455 }
1456
1457 #[test]
1458 fn rust_features_enum() {
1459 let code = "pub enum Status { Active, Inactive, Pending }";
1460 let f = extract_rust_features(code);
1461 assert_eq!(f.item_kind, "enum");
1462 assert_eq!(f.visibility, "pub");
1463 }
1464
1465 #[test]
1466 fn rust_features_trait() {
1467 let code = "pub trait Displayable { fn display(&self) -> String; }";
1468 let f = extract_rust_features(code);
1469 assert_eq!(f.item_kind, "trait");
1470 assert_eq!(f.visibility, "pub");
1471 }
1472
1473 #[test]
1474 fn rust_features_impl_block() {
1475 let code = "impl MyStruct { fn new() -> Self { Self {} } }";
1476 let f = extract_rust_features(code);
1477 assert_eq!(f.item_kind, "fn");
1479 }
1480
1481 #[test]
1482 fn rust_features_where_clause() {
1483 let code = "pub fn serialize<T>(val: T) -> String where T: Serialize + Debug { }";
1484 let f = extract_rust_features(code);
1485 assert!(f.is_generic);
1486 assert!(!f.trait_bounds.is_empty());
1487 assert!(f.trait_bounds.iter().any(|b| b.contains("Serialize")));
1488 }
1489
1490 #[test]
1491 fn rust_features_pub_crate() {
1492 let code = "pub(crate) fn internal_helper() {}";
1493 let f = extract_rust_features(code);
1494 assert_eq!(f.visibility, "pub(crate)");
1495 }
1496
1497 #[test]
1498 fn rust_features_multiple_attributes() {
1499 let code = "#[cfg(test)]\n#[allow(dead_code)]\nfn test_fn() {}";
1500 let f = extract_rust_features(code);
1501 assert_eq!(f.attributes.len(), 2);
1502 assert!(f.attributes.contains(&"#[cfg(test)]".to_string()));
1503 assert!(f.attributes.contains(&"#[allow(dead_code)]".to_string()));
1504 }
1505
1506 #[test]
1511 fn simple_tokenize_basic() {
1512 let tokens = simple_tokenize("Hello World! Foo-bar", 10);
1513 assert_eq!(tokens, vec!["hello", "world", "foobar"]);
1514 }
1515
1516 #[test]
1517 fn simple_tokenize_max_tokens() {
1518 let tokens = simple_tokenize("a b c d e f", 3);
1519 assert_eq!(tokens.len(), 3);
1520 }
1521
1522 #[test]
1523 fn simple_tokenize_empty() {
1524 let tokens = simple_tokenize("", 10);
1525 assert!(tokens.is_empty());
1526 }
1527
1528 #[test]
1529 fn tokens_to_embedding_deterministic() {
1530 let tokens: Vec<String> = vec!["hello".into(), "world".into()];
1531 let v1 = tokens_to_embedding(&tokens, 64);
1532 let v2 = tokens_to_embedding(&tokens, 64);
1533 assert_eq!(v1, v2);
1534 }
1535
1536 #[test]
1537 fn tokens_to_embedding_normalized() {
1538 let tokens: Vec<String> = vec!["test".into()];
1539 let v = tokens_to_embedding(&tokens, 128);
1540 let mag = vec_magnitude(&v);
1541 assert!((mag - 1.0).abs() < 0.01);
1542 }
1543
1544 fn make_test_vocab() -> PathBuf {
1551 use std::fmt::Write as FmtWrite;
1552 let mut content = String::new();
1553 for i in 0..100 {
1556 writeln!(content, "[unused{}]", i).unwrap();
1557 }
1558 writeln!(content, "[UNK]").unwrap(); writeln!(content, "[CLS]").unwrap(); writeln!(content, "[SEP]").unwrap(); writeln!(content, "[MASK]").unwrap(); for i in 104..1000 {
1563 writeln!(content, "[unused{}]", i).unwrap();
1564 }
1565 let words = [
1567 "the",
1568 "a",
1569 "is",
1570 "of",
1571 "and",
1572 "to",
1573 "in",
1574 "for",
1575 "that",
1576 "it",
1577 "hello",
1578 "world",
1579 "test",
1580 "input",
1581 "embedding",
1582 "model",
1583 "token",
1584 "##s",
1585 "##ing",
1586 "##ed",
1587 "##er",
1588 "##tion",
1589 "##ly",
1590 "##ize",
1591 ".",
1592 ",",
1593 "!",
1594 "?",
1595 "quick",
1596 "brown",
1597 "fox",
1598 "dog",
1599 "cat",
1600 "rust",
1601 "code",
1602 "function",
1603 "struct",
1604 "pub",
1605 "async",
1606 "fn",
1607 ];
1608 for w in &words {
1609 writeln!(content, "{}", w).unwrap();
1610 }
1611 for i in 0..100 {
1613 writeln!(content, "extra{}", i).unwrap();
1614 }
1615
1616 static VOCAB_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1623 let seq = VOCAB_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1624 let path = std::env::temp_dir().join(format!(
1625 "clawft_test_vocab_{}_{}.txt",
1626 std::process::id(),
1627 seq
1628 ));
1629 std::fs::write(&path, &content).expect("failed to write test vocab");
1630 path
1631 }
1632
1633 #[test]
1634 fn wordpiece_load_valid_vocab() {
1635 let f = make_test_vocab();
1636 let tok = WordPieceTokenizer::load(&f);
1637 assert!(tok.is_some(), "should load a vocab with >1000 entries");
1638 }
1639
1640 #[test]
1641 fn wordpiece_load_missing_file() {
1642 let tok = WordPieceTokenizer::load(Path::new("/nonexistent/vocab.txt"));
1643 assert!(tok.is_none());
1644 }
1645
1646 #[test]
1647 fn wordpiece_encode_produces_cls_sep() {
1648 let f = make_test_vocab();
1649 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(32);
1650 let (ids, mask, types) = tok.encode("hello world").unwrap();
1651 assert_eq!(ids.len(), 32, "should be padded to max_length");
1652 assert_eq!(ids[0], CLS_ID, "first token must be [CLS]");
1653 let sep_pos = ids.iter().position(|&x| x == SEP_ID);
1655 assert!(sep_pos.is_some(), "must contain [SEP]");
1656 let sep_pos = sep_pos.unwrap();
1657 assert!(
1658 sep_pos >= 2,
1659 "[SEP] should come after at least one content token"
1660 );
1661 assert_eq!(mask[0], 1);
1663 assert_eq!(mask[sep_pos], 1);
1664 if sep_pos + 1 < 32 {
1665 assert_eq!(mask[sep_pos + 1], 0, "padding should have mask=0");
1666 }
1667 assert!(types.iter().all(|&t| t == 0));
1669 }
1670
1671 #[test]
1672 fn wordpiece_encode_known_tokens() {
1673 let f = make_test_vocab();
1674 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1675 let (ids, _, _) = tok.encode("hello").unwrap();
1676 let content_ids: Vec<i64> = ids[1..]
1678 .iter()
1679 .take_while(|&&x| x != SEP_ID)
1680 .cloned()
1681 .collect();
1682 assert!(
1683 !content_ids.is_empty(),
1684 "should tokenize 'hello' to at least one token"
1685 );
1686 assert!(
1687 content_ids.iter().any(|&id| id != UNK_ID),
1688 "known word 'hello' should not be all [UNK]"
1689 );
1690 }
1691
1692 #[test]
1693 fn wordpiece_encode_unknown_token_uses_unk() {
1694 let f = make_test_vocab();
1695 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1696 let (ids, _, _) = tok.encode("xyzzyplugh").unwrap();
1697 let content_ids: Vec<i64> = ids[1..]
1699 .iter()
1700 .take_while(|&&x| x != SEP_ID)
1701 .cloned()
1702 .collect();
1703 assert!(
1704 content_ids.contains(&UNK_ID),
1705 "unknown word should produce [UNK] token"
1706 );
1707 }
1708
1709 #[test]
1710 fn wordpiece_encode_truncates_long_input() {
1711 let f = make_test_vocab();
1712 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(8);
1713 let long_input = "the quick brown fox hello world test input embedding model";
1715 let (ids, mask, _) = tok.encode(long_input).unwrap();
1716 assert_eq!(ids.len(), 8, "output must be exactly max_length");
1717 assert_eq!(mask.len(), 8);
1718 assert_eq!(ids[0], CLS_ID);
1719 assert!(ids.contains(&SEP_ID));
1721 }
1722
1723 #[test]
1724 fn wordpiece_encode_empty_input() {
1725 let f = make_test_vocab();
1726 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(16);
1727 let (ids, mask, _) = tok.encode("").unwrap();
1728 assert_eq!(ids[0], CLS_ID);
1729 assert_eq!(ids[1], SEP_ID);
1730 assert!(ids[2..].iter().all(|&x| x == PAD_ID));
1732 assert_eq!(mask[0], 1);
1733 assert_eq!(mask[1], 1);
1734 assert!(mask[2..].iter().all(|&x| x == 0));
1735 }
1736
1737 #[test]
1738 fn wordpiece_pre_tokenize_punctuation() {
1739 let f = make_test_vocab();
1740 let tok = WordPieceTokenizer::load(&f).unwrap();
1741 let words = tok.pre_tokenize("Hello, World!");
1742 assert!(words.contains(&",".to_string()));
1744 assert!(words.contains(&"!".to_string()));
1745 assert!(words.contains(&"hello".to_string()));
1746 assert!(words.contains(&"world".to_string()));
1747 }
1748
1749 #[test]
1750 fn wordpiece_subword_splitting() {
1751 let f = make_test_vocab();
1752 let tok = WordPieceTokenizer::load(&f).unwrap();
1753 let ids = tok.wordpiece_split("tokens");
1755 assert!(!ids.is_empty(), "should produce at least one subword token");
1757 }
1758
1759 #[test]
1760 fn wordpiece_deterministic() {
1761 let f = make_test_vocab();
1762 let tok = WordPieceTokenizer::load(&f).unwrap().with_max_length(32);
1763 let (ids1, _, _) = tok.encode("the quick brown fox").unwrap();
1764 let (ids2, _, _) = tok.encode("the quick brown fox").unwrap();
1765 assert_eq!(ids1, ids2, "encoding must be deterministic");
1766 }
1767
1768 #[test]
1769 fn vocab_search_paths_finds_sibling() {
1770 let paths = vocab_search_paths(Path::new("/models/all-MiniLM-L6-v2.onnx"));
1771 assert!(paths.iter().any(|p| p.ends_with("vocab.txt")));
1772 assert!(
1773 paths
1774 .iter()
1775 .any(|p| p.to_string_lossy().contains("all-MiniLM-L6-v2/vocab.txt")),
1776 "should check sibling directory: {:?}",
1777 paths
1778 );
1779 }
1780}