1pub mod download;
14pub mod model_registry;
15pub mod pooling;
16pub mod tokenizer;
17
18use std::path::{Path, PathBuf};
19
20use model_registry::{EmbeddingModel, ModelConfig, VocabSource};
21use tokenizer::{TokenizedInput, WordPieceTokenizer};
22
23#[cfg(feature = "embeddings")]
24use rayon::prelude::*;
25#[cfg(feature = "embeddings")]
26use std::sync::Mutex;
27
28pub struct EmbeddingEngine {
29 tokenizer: TokenizerKind,
30 dimensions: usize,
31 max_seq_len: usize,
32 model_id: EmbeddingModel,
33 model_config: ModelConfig,
34 #[cfg(feature = "embeddings")]
35 session: Mutex<ort::session::Session>,
36 #[cfg(feature = "embeddings")]
37 graph_inputs: GraphInputs,
38 #[cfg(feature = "embeddings")]
39 output_name: String,
40}
41
42enum TokenizerKind {
44 WordPiece(WordPieceTokenizer),
45 HfTokenizer(tokenizer::HfTokenizerWrapper),
46}
47
48#[cfg(feature = "embeddings")]
55enum GraphInputs {
56 Transformer {
57 input_ids: String,
58 attention_mask: String,
59 token_type_ids: Option<String>,
60 },
61 EmbeddingBag {
62 input_ids: String,
63 offsets: String,
64 },
65}
66
67#[cfg(feature = "embeddings")]
71fn is_embedding_bag_signature(input_names: &[String]) -> bool {
72 input_names.len() == 2 && input_names[1] == "offsets"
73}
74
75impl EmbeddingEngine {
76 #[cfg(feature = "embeddings")]
79 pub fn load(model_dir: &Path) -> anyhow::Result<Self> {
80 let selected = model_registry::resolve_model();
81 Self::load_model(model_dir, selected)
82 }
83
84 #[cfg(feature = "embeddings")]
86 pub fn load_model(base_dir: &Path, model_id: EmbeddingModel) -> anyhow::Result<Self> {
87 let config = model_id.config();
88 let model_dir = base_dir.join(model_id.storage_dir_name());
89
90 download::ensure_model(&model_dir, &config)?;
91
92 let tokenizer = load_tokenizer(&model_dir, &config)?;
93 let model_path = model_dir.join("model.onnx");
94
95 let eps = crate::core::ort_execution_providers::gpu_execution_providers();
96 let num_cpus = std::thread::available_parallelism().map_or(4, |n| n.get().max(1));
97 crate::core::ort_environment::ensure_ort_env(&eps)?;
98 let mut session = ort::session::Session::builder()
99 .map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
100 .with_intra_threads(num_cpus)
101 .map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
102 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
103 .map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
104 .commit_from_file(&model_path)
105 .map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
106
107 let input_names: Vec<String> = session
108 .inputs()
109 .iter()
110 .map(|i| i.name().to_string())
111 .collect();
112
113 if input_names.len() < 2 {
114 anyhow::bail!(
115 "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
116 input_names.len()
117 );
118 }
119
120 let graph_inputs = if is_embedding_bag_signature(&input_names) {
124 GraphInputs::EmbeddingBag {
125 input_ids: input_names[0].clone(),
126 offsets: input_names[1].clone(),
127 }
128 } else {
129 let token_type_ids = if config.needs_token_type_ids {
130 if input_names.len() < 3 {
131 anyhow::bail!(
132 "Model {} requires token_type_ids but only has {} inputs",
133 config.name,
134 input_names.len()
135 );
136 }
137 Some(input_names[2].clone())
138 } else if input_names.len() >= 3 {
139 Some(input_names[2].clone())
140 } else {
141 None
142 };
143 GraphInputs::Transformer {
144 input_ids: input_names[0].clone(),
145 attention_mask: input_names[1].clone(),
146 token_type_ids,
147 }
148 };
149
150 let output_name = session
151 .outputs()
152 .first()
153 .map(|o| o.name().to_string())
154 .ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
155
156 let dimensions = detect_dimensions(
157 &mut session,
158 &tokenizer,
159 &graph_inputs,
160 &output_name,
161 config.max_seq_len,
162 )
163 .unwrap_or(config.dimensions);
164
165 tracing::info!(
166 "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
167 config.name,
168 dimensions,
169 config.max_seq_len,
170 match graph_inputs {
171 GraphInputs::Transformer { .. } => "transformer",
172 GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
173 },
174 );
175
176 Ok(Self {
177 session: Mutex::new(session),
178 tokenizer,
179 dimensions,
180 max_seq_len: config.max_seq_len,
181 model_id,
182 model_config: config,
183 graph_inputs,
184 output_name,
185 })
186 }
187
188 #[cfg(not(feature = "embeddings"))]
189 pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
190 anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
191 }
192
193 pub fn load_default() -> anyhow::Result<Self> {
195 Self::load(&Self::model_directory())
196 }
197
198 pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
200 let prefixed;
201 let input_text = if let Some(prefix) = &self.model_config.document_prefix {
202 prefixed = format!("{prefix}{text}");
203 &prefixed
204 } else {
205 text
206 };
207 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
208 self.run_inference(&input)
209 }
210
211 pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
214 let prefixed;
215 let input_text = if let Some(prefix) = &self.model_config.query_prefix {
216 prefixed = format!("{prefix}{query}");
217 &prefixed
218 } else {
219 query
220 };
221 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
222 self.run_inference(&input)
223 }
224
225 pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
231 if texts.is_empty() {
232 return Ok(Vec::new());
233 }
234
235 let prefixed: Vec<String> = texts
236 .iter()
237 .map(|t| {
238 if let Some(prefix) = &self.model_config.document_prefix {
239 format!("{prefix}{t}")
240 } else {
241 t.to_string()
242 }
243 })
244 .collect();
245 let prefixed_refs: Vec<&str> = prefixed.iter().map(std::string::String::as_str).collect();
246
247 let tokenized: Vec<TokenizedInput> = prefixed_refs
249 .par_iter()
250 .map(|t| tokenize(&self.tokenizer, t, self.max_seq_len))
251 .collect();
252
253 let batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
256 .ok()
257 .and_then(|v| v.parse().ok())
258 .filter(|&v| v >= 1)
259 .unwrap_or(64);
260 let mut results = Vec::with_capacity(texts.len());
261 for chunk in tokenized.chunks(batch_size) {
262 let batch_out = self.run_inference_batch(chunk)?;
263 results.extend(batch_out);
264 }
265 Ok(results)
266 }
267
268 pub fn dimensions(&self) -> usize {
269 self.dimensions
270 }
271
272 pub fn model_id(&self) -> &EmbeddingModel {
273 &self.model_id
274 }
275
276 pub fn model_name(&self) -> &str {
277 &self.model_config.name
278 }
279
280 pub fn model_directory() -> PathBuf {
282 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
283 return PathBuf::from(dir);
284 }
285 if let Ok(d) = crate::core::paths::cache_dir() {
286 return d.join("models");
287 }
288 PathBuf::from("models")
289 }
290
291 pub fn is_available() -> bool {
293 let base_dir = Self::model_directory();
294 let selected = model_registry::resolve_model();
295 let config = selected.config();
296 let model_dir = base_dir.join(selected.storage_dir_name());
297 model_dir.join("model.onnx").exists()
298 && model_dir.join(config.vocab_file.filename()).exists()
299 }
300
301 #[cfg(feature = "embeddings")]
302 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
303 let seq_len = input.input_ids.len();
304
305 let mut embedding = match &self.graph_inputs {
306 GraphInputs::Transformer {
307 input_ids,
308 attention_mask,
309 token_type_ids,
310 } => {
311 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
312 let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
313 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
314 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
315 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
316 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
317
318 let hidden = if let Some(type_id) = token_type_ids {
319 let type_vec: Vec<i64> =
320 input.token_type_ids.iter().map(|&x| x as i64).collect();
321 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
322 let type_tensor = ort::value::Tensor::from_array(type_array)?;
323 let mut _guard = self.session.lock().unwrap();
324 let outputs = _guard.run(ort::inputs![
325 input_ids.as_str() => ids_tensor,
326 attention_mask.as_str() => mask_tensor,
327 type_id.as_str() => type_tensor,
328 ])?;
329 let (_, data) =
330 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
331 data.to_vec()
332 } else {
333 let mut _guard = self.session.lock().unwrap();
334 let outputs = _guard.run(ort::inputs![
335 input_ids.as_str() => ids_tensor,
336 attention_mask.as_str() => mask_tensor,
337 ])?;
338 let (_, data) =
339 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
340 data.to_vec()
341 };
342 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
343 }
344 GraphInputs::EmbeddingBag { input_ids, offsets } => {
345 if seq_len == 0 {
346 return Ok(vec![0.0; self.dimensions]);
347 }
348 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
349 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
350 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
351 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
352 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
353 let mut _guard = self.session.lock().unwrap();
354 let outputs = _guard.run(ort::inputs![
355 input_ids.as_str() => ids_tensor,
356 offsets.as_str() => offsets_tensor,
357 ])?;
358 let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
359 data.to_vec()
360 }
361 };
362
363 pooling::normalize_l2(&mut embedding);
364 Ok(embedding)
365 }
366
367 #[cfg(feature = "embeddings")]
376 fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
377 if inputs.is_empty() {
378 return Ok(Vec::new());
379 }
380
381 match &self.graph_inputs {
382 GraphInputs::Transformer {
383 input_ids: input_id,
384 attention_mask: mask_id,
385 token_type_ids,
386 } => {
387 let batch = inputs.len();
388 let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
389
390 let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
391 let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
392 let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
393 let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
394
395 for inp in inputs {
396 let seq_len = inp.input_ids.len();
397 ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
398 ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
399
400 mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
401 mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
402
403 type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
404 type_data.resize(type_data.len() + (max_len - seq_len), 0);
405
406 per_seq_masks.push(inp.attention_mask.as_slice());
407 }
408
409 let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
410 let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
411 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
412 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
413
414 let hidden = if let Some(type_id) = token_type_ids {
415 let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
416 let type_tensor = ort::value::Tensor::from_array(type_array)?;
417 let mut _guard = self.session.lock().unwrap();
418 let outputs = _guard.run(ort::inputs![
419 input_id.as_str() => ids_tensor,
420 mask_id.as_str() => mask_tensor,
421 type_id.as_str() => type_tensor,
422 ])?;
423 let (_, data) =
424 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
425 data.to_vec()
426 } else {
427 let mut _guard = self.session.lock().unwrap();
428 let outputs = _guard.run(ort::inputs![
429 input_id.as_str() => ids_tensor,
430 mask_id.as_str() => mask_tensor,
431 ])?;
432 let (_, data) =
433 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
434 data.to_vec()
435 };
436
437 let mut results =
438 pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
439 for emb in &mut results {
440 pooling::normalize_l2(emb);
441 }
442 Ok(results)
443 }
444 GraphInputs::EmbeddingBag { input_ids, offsets } => {
445 let batch = inputs.len();
446 let mut flat_ids: Vec<i64> = Vec::new();
447 let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
448 let mut last_offset = 0i64;
449
450 for inp in inputs {
451 adjusted_offsets.push(last_offset);
452 if !inp.input_ids.is_empty() {
453 flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
454 last_offset = flat_ids.len() as i64;
455 }
456 }
457
458 if flat_ids.is_empty() {
459 return Ok(vec![vec![0.0; self.dimensions]; batch]);
460 }
461
462 let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
463 let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
464 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
465 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
466
467 let mut _guard = self.session.lock().unwrap();
468 let outputs = _guard.run(ort::inputs![
469 input_ids.as_str() => ids_tensor,
470 offsets.as_str() => offsets_tensor,
471 ])?;
472 let (_, out_data) =
473 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
474 let out = out_data.to_vec();
475
476 let mut results: Vec<Vec<f32>> = out
477 .chunks_exact(self.dimensions)
478 .map(<[f32]>::to_vec)
479 .collect();
480 while results.len() < batch {
481 results.push(vec![0.0; self.dimensions]);
482 }
483 for emb in &mut results {
484 pooling::normalize_l2(emb);
485 }
486 Ok(results)
487 }
488 }
489 }
490
491 #[cfg(not(feature = "embeddings"))]
492 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
493 anyhow::bail!("Embeddings feature not enabled")
494 }
495}
496
497fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
499 match &config.vocab_file {
500 VocabSource::VocabTxt(filename) => {
501 let path = model_dir.join(filename);
502 let tok = WordPieceTokenizer::from_file(&path)?;
503 Ok(TokenizerKind::WordPiece(tok))
504 }
505 VocabSource::TokenizerJson(filename) => {
506 let path = model_dir.join(filename);
507 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
508 anyhow::anyhow!(
509 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
510 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
511 config.name
512 )
513 })?;
514 Ok(TokenizerKind::HfTokenizer(tok))
515 }
516 }
517}
518
519fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
521 match tokenizer {
522 TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
523 TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
524 }
525}
526
527#[cfg(feature = "embeddings")]
529fn detect_dimensions(
530 session: &mut ort::session::Session,
531 tokenizer: &TokenizerKind,
532 graph_inputs: &GraphInputs,
533 output_name: &str,
534 max_seq_len: usize,
535) -> Option<usize> {
536 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
537 let seq_len = dummy.input_ids.len();
538 if seq_len == 0 {
539 return None;
540 }
541
542 let outputs = match graph_inputs {
543 GraphInputs::Transformer {
544 input_ids,
545 attention_mask,
546 token_type_ids,
547 } => {
548 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
549 let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
550 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
551 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
552 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
553 let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
554
555 if let Some(type_id) = token_type_ids {
556 let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
557 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
558 let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
559 session
560 .run(ort::inputs![
561 input_ids.as_str() => ids_tensor,
562 attention_mask.as_str() => mask_tensor,
563 type_id.as_str() => type_tensor,
564 ])
565 .ok()?
566 } else {
567 session
568 .run(ort::inputs![
569 input_ids.as_str() => ids_tensor,
570 attention_mask.as_str() => mask_tensor,
571 ])
572 .ok()?
573 }
574 }
575 GraphInputs::EmbeddingBag { input_ids, offsets } => {
576 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
577 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
578 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
579 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
580 let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
581 session
582 .run(ort::inputs![
583 input_ids.as_str() => ids_tensor,
584 offsets.as_str() => offsets_tensor,
585 ])
586 .ok()?
587 }
588 };
589
590 let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
591
592 match graph_inputs {
593 GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
595 GraphInputs::EmbeddingBag { .. } => {
599 if shape.len() == 2 {
600 shape.last().copied().map(|s| s as usize)
601 } else {
602 None
603 }
604 }
605 }
606}
607
608pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
614 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
615 crate::core::embedding_quant::dot_f32(a, b)
616}
617
618pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
620 debug_assert_eq!(a.len(), b.len());
621 use crate::core::embedding_quant::dot_f32;
622 let dot = dot_f32(a, b);
623 let norm_a = dot_f32(a, a).sqrt();
624 let norm_b = dot_f32(b, b).sqrt();
625 if norm_a == 0.0 || norm_b == 0.0 {
626 return 0.0;
627 }
628 dot / (norm_a * norm_b)
629}
630
631#[cfg(feature = "embeddings")]
632static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
633 std::sync::OnceLock::new();
634
635#[cfg(feature = "embeddings")]
640pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
641 SHARED_ENGINE
642 .get_or_init(EmbeddingEngine::load_default)
643 .as_ref()
644 .ok()
645}
646
647#[cfg(feature = "embeddings")]
650pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
651 SHARED_ENGINE.get()?.as_ref().ok()
652}
653
654#[cfg(feature = "embeddings")]
672pub fn background_load_allowed() -> bool {
673 if cfg!(test) {
677 return false;
678 }
679 !current_exe_is_test_artifact()
680}
681
682#[cfg(feature = "embeddings")]
684fn current_exe_is_test_artifact() -> bool {
685 std::env::current_exe()
686 .ok()
687 .as_deref()
688 .is_some_and(exe_path_is_test_artifact)
689}
690
691#[cfg(feature = "embeddings")]
697fn exe_path_is_test_artifact(path: &Path) -> bool {
698 path.parent()
699 .and_then(Path::file_name)
700 .and_then(|name| name.to_str())
701 .is_some_and(|name| name == "deps")
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 #[test]
709 fn cosine_similarity_identical() {
710 let a = vec![1.0, 0.0, 0.0];
711 let b = vec![1.0, 0.0, 0.0];
712 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
713 }
714
715 #[test]
716 fn cosine_similarity_orthogonal() {
717 let a = vec![1.0, 0.0, 0.0];
718 let b = vec![0.0, 1.0, 0.0];
719 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
720 }
721
722 #[test]
723 fn cosine_similarity_opposite() {
724 let a = vec![1.0, 0.0, 0.0];
725 let b = vec![-1.0, 0.0, 0.0];
726 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
727 }
728
729 #[test]
730 fn cosine_similarity_raw_unnormalized() {
731 let a = vec![3.0, 4.0];
732 let b = vec![3.0, 4.0];
733 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
734 }
735
736 #[test]
737 fn cosine_similarity_raw_zero_vector() {
738 let a = vec![0.0, 0.0];
739 let b = vec![1.0, 2.0];
740 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
741 }
742
743 #[test]
744 fn model_directory_env_override_and_availability() {
745 let unique = "/tmp/lean_ctx_test_embed_42xyz";
746 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
747 let dir = EmbeddingEngine::model_directory();
748 assert_eq!(dir.to_string_lossy(), unique);
749 assert!(!EmbeddingEngine::is_available());
750 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
751 }
752
753 #[test]
757 #[cfg(feature = "embeddings")]
758 fn exe_path_test_artifact_detection() {
759 assert!(exe_path_is_test_artifact(Path::new(
761 "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
762 )));
763 assert!(exe_path_is_test_artifact(Path::new(
764 "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
765 )));
766 assert!(!exe_path_is_test_artifact(Path::new(
768 "/usr/local/bin/lean-ctx"
769 )));
770 assert!(!exe_path_is_test_artifact(Path::new(
771 "/Users/x/.local/bin/lean-ctx"
772 )));
773 assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
775 assert!(!exe_path_is_test_artifact(Path::new(
778 "/repo/rust/target/debug/lean-ctx"
779 )));
780 }
781
782 #[test]
786 #[cfg(feature = "embeddings")]
787 fn background_load_disallowed_in_tests() {
788 assert!(!background_load_allowed());
789 }
790
791 #[test]
795 #[cfg(feature = "embeddings")]
796 fn embedding_bag_signature_detection() {
797 assert!(is_embedding_bag_signature(&[
799 "input_ids".to_string(),
800 "offsets".to_string()
801 ]));
802 assert!(!is_embedding_bag_signature(&[
804 "input_ids".to_string(),
805 "attention_mask".to_string()
806 ]));
807 assert!(!is_embedding_bag_signature(&[
808 "input_ids".to_string(),
809 "attention_mask".to_string(),
810 "token_type_ids".to_string()
811 ]));
812 assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
814 assert!(!is_embedding_bag_signature(&[
815 "input_ids".to_string(),
816 "offsets".to_string(),
817 "extra".to_string()
818 ]));
819 }
820
821 #[test]
833 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
834 #[cfg(feature = "embeddings")]
835 fn custom_hf_model_end_to_end() {
836 let model = model_registry::EmbeddingModel::from_str_name(
837 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
838 )
839 .expect("valid hf: spec");
840
841 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
842 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
843
844 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
845 assert_eq!(
846 engine.model_name(),
847 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
848 );
849
850 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
851 assert_eq!(v.len(), 384);
852 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
853 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
854
855 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
857 let lock: std::collections::BTreeMap<String, String> =
858 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
859 assert!(lock.contains_key("model.onnx"));
860 assert!(lock.contains_key("tokenizer.json"));
861
862 let a = engine.embed("read a file from disk").unwrap();
864 let b = engine.embed("load file contents from filesystem").unwrap();
865 let c = engine.embed("the weather in Zurich is sunny").unwrap();
866 assert!(
867 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
868 "related texts must be closer"
869 );
870 }
871
872 #[test]
877 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
878 #[cfg(feature = "embeddings")]
879 fn model2vec_potion_end_to_end() {
880 let model =
881 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
882 .expect("valid hf: spec");
883
884 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
885 let engine =
886 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
887
888 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
891
892 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
893 assert_eq!(code_vec.len(), 256);
894 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
895 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
896
897 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
899 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
900
901 let read_vec = engine.embed("read a file from disk").unwrap();
903 let load_vec = engine.embed("load file contents from filesystem").unwrap();
904 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
905 assert!(
906 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
907 "related texts must be closer"
908 );
909
910 let empty_vec = engine.embed("").unwrap();
912 assert_eq!(empty_vec.len(), 256);
913 }
914}