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 deterministic = deterministic_inference();
101 let eps = if deterministic {
102 Vec::new()
103 } else {
104 crate::core::ort_execution_providers::gpu_execution_providers()
105 };
106 let num_cpus = if deterministic {
107 1
108 } else {
109 std::thread::available_parallelism().map_or(4, |n| n.get().max(1))
110 };
111 crate::core::ort_environment::ensure_ort_env(&eps)?;
112 let mut session = ort::session::Session::builder()
113 .map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
114 .with_intra_threads(num_cpus)
115 .map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
116 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
117 .map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
118 .commit_from_file(&model_path)
119 .map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
120
121 let input_names: Vec<String> = session
122 .inputs()
123 .iter()
124 .map(|i| i.name().to_string())
125 .collect();
126
127 if input_names.len() < 2 {
128 anyhow::bail!(
129 "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
130 input_names.len()
131 );
132 }
133
134 let graph_inputs = if is_embedding_bag_signature(&input_names) {
138 GraphInputs::EmbeddingBag {
139 input_ids: input_names[0].clone(),
140 offsets: input_names[1].clone(),
141 }
142 } else {
143 let token_type_ids = if config.needs_token_type_ids {
144 if input_names.len() < 3 {
145 anyhow::bail!(
146 "Model {} requires token_type_ids but only has {} inputs",
147 config.name,
148 input_names.len()
149 );
150 }
151 Some(input_names[2].clone())
152 } else if input_names.len() >= 3 {
153 Some(input_names[2].clone())
154 } else {
155 None
156 };
157 GraphInputs::Transformer {
158 input_ids: input_names[0].clone(),
159 attention_mask: input_names[1].clone(),
160 token_type_ids,
161 }
162 };
163
164 let output_name = session
165 .outputs()
166 .first()
167 .map(|o| o.name().to_string())
168 .ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
169
170 let dimensions = detect_dimensions(
171 &mut session,
172 &tokenizer,
173 &graph_inputs,
174 &output_name,
175 config.max_seq_len,
176 )
177 .unwrap_or(config.dimensions);
178
179 tracing::info!(
180 "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
181 config.name,
182 dimensions,
183 config.max_seq_len,
184 match graph_inputs {
185 GraphInputs::Transformer { .. } => "transformer",
186 GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
187 },
188 );
189
190 Ok(Self {
191 session: Mutex::new(session),
192 tokenizer,
193 dimensions,
194 max_seq_len: config.max_seq_len,
195 model_id,
196 model_config: config,
197 graph_inputs,
198 output_name,
199 })
200 }
201
202 #[cfg(not(feature = "embeddings"))]
203 pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
204 anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
205 }
206
207 pub fn load_default() -> anyhow::Result<Self> {
209 Self::load(&Self::model_directory())
210 }
211
212 pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
214 let prefixed;
215 let input_text = if let Some(prefix) = &self.model_config.document_prefix {
216 prefixed = format!("{prefix}{text}");
217 &prefixed
218 } else {
219 text
220 };
221 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
222 self.run_inference(&input)
223 }
224
225 pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
228 let prefixed;
229 let input_text = if let Some(prefix) = &self.model_config.query_prefix {
230 prefixed = format!("{prefix}{query}");
231 &prefixed
232 } else {
233 query
234 };
235 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
236 self.run_inference(&input)
237 }
238
239 pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
245 if texts.is_empty() {
246 return Ok(Vec::new());
247 }
248
249 let prefixed: Vec<String> = texts
250 .iter()
251 .map(|t| {
252 if let Some(prefix) = &self.model_config.document_prefix {
253 format!("{prefix}{t}")
254 } else {
255 t.to_string()
256 }
257 })
258 .collect();
259 let prefixed_refs: Vec<&str> = prefixed.iter().map(std::string::String::as_str).collect();
260
261 let tokenized: Vec<TokenizedInput> = prefixed_refs
263 .par_iter()
264 .map(|t| tokenize(&self.tokenizer, t, self.max_seq_len))
265 .collect();
266
267 let batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
270 .ok()
271 .and_then(|v| v.parse().ok())
272 .filter(|&v| v >= 1)
273 .unwrap_or(64);
274 let mut results = Vec::with_capacity(texts.len());
275 for chunk in tokenized.chunks(batch_size) {
276 let batch_out = self.run_inference_batch(chunk)?;
277 results.extend(batch_out);
278 }
279 Ok(results)
280 }
281
282 pub fn dimensions(&self) -> usize {
283 self.dimensions
284 }
285
286 pub fn model_id(&self) -> &EmbeddingModel {
287 &self.model_id
288 }
289
290 pub fn model_name(&self) -> &str {
291 &self.model_config.name
292 }
293
294 pub fn model_directory() -> PathBuf {
296 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
297 return PathBuf::from(dir);
298 }
299 if let Ok(d) = crate::core::paths::cache_dir() {
300 return d.join("models");
301 }
302 PathBuf::from("models")
303 }
304
305 pub fn is_available() -> bool {
307 let base_dir = Self::model_directory();
308 let selected = model_registry::resolve_model();
309 let config = selected.config();
310 let model_dir = base_dir.join(selected.storage_dir_name());
311 model_dir.join("model.onnx").exists()
312 && model_dir.join(config.vocab_file.filename()).exists()
313 }
314
315 pub fn ensure_downloaded() -> anyhow::Result<()> {
326 let base_dir = Self::model_directory();
327 let selected = model_registry::resolve_model();
328 let config = selected.config();
329 let model_dir = base_dir.join(selected.storage_dir_name());
330 download::ensure_model(&model_dir, &config)?;
331 Ok(())
332 }
333
334 #[cfg(feature = "embeddings")]
335 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
336 let seq_len = input.input_ids.len();
337
338 let mut embedding = match &self.graph_inputs {
339 GraphInputs::Transformer {
340 input_ids,
341 attention_mask,
342 token_type_ids,
343 } => {
344 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
345 let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
346 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
347 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
348 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
349 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
350
351 let hidden = if let Some(type_id) = token_type_ids {
352 let type_vec: Vec<i64> =
353 input.token_type_ids.iter().map(|&x| x as i64).collect();
354 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
355 let type_tensor = ort::value::Tensor::from_array(type_array)?;
356 let mut _guard = self.session.lock().unwrap();
357 let outputs = _guard.run(ort::inputs![
358 input_ids.as_str() => ids_tensor,
359 attention_mask.as_str() => mask_tensor,
360 type_id.as_str() => type_tensor,
361 ])?;
362 let (_, data) =
363 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
364 data.to_vec()
365 } else {
366 let mut _guard = self.session.lock().unwrap();
367 let outputs = _guard.run(ort::inputs![
368 input_ids.as_str() => ids_tensor,
369 attention_mask.as_str() => mask_tensor,
370 ])?;
371 let (_, data) =
372 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
373 data.to_vec()
374 };
375 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
376 }
377 GraphInputs::EmbeddingBag { input_ids, offsets } => {
378 if seq_len == 0 {
379 return Ok(vec![0.0; self.dimensions]);
380 }
381 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
382 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
383 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
384 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
385 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
386 let mut _guard = self.session.lock().unwrap();
387 let outputs = _guard.run(ort::inputs![
388 input_ids.as_str() => ids_tensor,
389 offsets.as_str() => offsets_tensor,
390 ])?;
391 let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
392 data.to_vec()
393 }
394 };
395
396 pooling::normalize_l2(&mut embedding);
397 Ok(embedding)
398 }
399
400 #[cfg(feature = "embeddings")]
409 fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
410 if inputs.is_empty() {
411 return Ok(Vec::new());
412 }
413
414 match &self.graph_inputs {
415 GraphInputs::Transformer {
416 input_ids: input_id,
417 attention_mask: mask_id,
418 token_type_ids,
419 } => {
420 let batch = inputs.len();
421 let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
422
423 let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
424 let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
425 let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
426 let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
427
428 for inp in inputs {
429 let seq_len = inp.input_ids.len();
430 ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
431 ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
432
433 mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
434 mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
435
436 type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
437 type_data.resize(type_data.len() + (max_len - seq_len), 0);
438
439 per_seq_masks.push(inp.attention_mask.as_slice());
440 }
441
442 let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
443 let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
444 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
445 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
446
447 let hidden = if let Some(type_id) = token_type_ids {
448 let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
449 let type_tensor = ort::value::Tensor::from_array(type_array)?;
450 let mut _guard = self.session.lock().unwrap();
451 let outputs = _guard.run(ort::inputs![
452 input_id.as_str() => ids_tensor,
453 mask_id.as_str() => mask_tensor,
454 type_id.as_str() => type_tensor,
455 ])?;
456 let (_, data) =
457 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
458 data.to_vec()
459 } else {
460 let mut _guard = self.session.lock().unwrap();
461 let outputs = _guard.run(ort::inputs![
462 input_id.as_str() => ids_tensor,
463 mask_id.as_str() => mask_tensor,
464 ])?;
465 let (_, data) =
466 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
467 data.to_vec()
468 };
469
470 let mut results =
471 pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
472 for emb in &mut results {
473 pooling::normalize_l2(emb);
474 }
475 Ok(results)
476 }
477 GraphInputs::EmbeddingBag { input_ids, offsets } => {
478 let batch = inputs.len();
479 let mut flat_ids: Vec<i64> = Vec::new();
480 let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
481 let mut last_offset = 0i64;
482
483 for inp in inputs {
484 adjusted_offsets.push(last_offset);
485 if !inp.input_ids.is_empty() {
486 flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
487 last_offset = flat_ids.len() as i64;
488 }
489 }
490
491 if flat_ids.is_empty() {
492 return Ok(vec![vec![0.0; self.dimensions]; batch]);
493 }
494
495 let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
496 let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
497 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
498 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
499
500 let mut _guard = self.session.lock().unwrap();
501 let outputs = _guard.run(ort::inputs![
502 input_ids.as_str() => ids_tensor,
503 offsets.as_str() => offsets_tensor,
504 ])?;
505 let (_, out_data) =
506 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
507 let out = out_data.to_vec();
508
509 let mut results: Vec<Vec<f32>> = out
510 .chunks_exact(self.dimensions)
511 .map(<[f32]>::to_vec)
512 .collect();
513 while results.len() < batch {
514 results.push(vec![0.0; self.dimensions]);
515 }
516 for emb in &mut results {
517 pooling::normalize_l2(emb);
518 }
519 Ok(results)
520 }
521 }
522 }
523
524 #[cfg(not(feature = "embeddings"))]
525 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
526 anyhow::bail!("Embeddings feature not enabled")
527 }
528}
529
530fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
532 match &config.vocab_file {
533 VocabSource::VocabTxt(filename) => {
534 let path = model_dir.join(filename);
535 let tok = WordPieceTokenizer::from_file(&path)?;
536 Ok(TokenizerKind::WordPiece(tok))
537 }
538 VocabSource::TokenizerJson(filename) => {
539 let path = model_dir.join(filename);
540 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
541 anyhow::anyhow!(
542 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
543 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
544 config.name
545 )
546 })?;
547 Ok(TokenizerKind::HfTokenizer(tok))
548 }
549 }
550}
551
552fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
554 match tokenizer {
555 TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
556 TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
557 }
558}
559
560#[cfg(feature = "embeddings")]
562fn detect_dimensions(
563 session: &mut ort::session::Session,
564 tokenizer: &TokenizerKind,
565 graph_inputs: &GraphInputs,
566 output_name: &str,
567 max_seq_len: usize,
568) -> Option<usize> {
569 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
570 let seq_len = dummy.input_ids.len();
571 if seq_len == 0 {
572 return None;
573 }
574
575 let outputs = match graph_inputs {
576 GraphInputs::Transformer {
577 input_ids,
578 attention_mask,
579 token_type_ids,
580 } => {
581 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
582 let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
583 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
584 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
585 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
586 let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
587
588 if let Some(type_id) = token_type_ids {
589 let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
590 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
591 let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
592 session
593 .run(ort::inputs![
594 input_ids.as_str() => ids_tensor,
595 attention_mask.as_str() => mask_tensor,
596 type_id.as_str() => type_tensor,
597 ])
598 .ok()?
599 } else {
600 session
601 .run(ort::inputs![
602 input_ids.as_str() => ids_tensor,
603 attention_mask.as_str() => mask_tensor,
604 ])
605 .ok()?
606 }
607 }
608 GraphInputs::EmbeddingBag { input_ids, offsets } => {
609 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
610 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
611 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
612 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
613 let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
614 session
615 .run(ort::inputs![
616 input_ids.as_str() => ids_tensor,
617 offsets.as_str() => offsets_tensor,
618 ])
619 .ok()?
620 }
621 };
622
623 let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
624
625 match graph_inputs {
626 GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
628 GraphInputs::EmbeddingBag { .. } => {
632 if shape.len() == 2 {
633 shape.last().copied().map(|s| s as usize)
634 } else {
635 None
636 }
637 }
638 }
639}
640
641pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
647 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
648 crate::core::embedding_quant::dot_f32(a, b)
649}
650
651pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
653 debug_assert_eq!(a.len(), b.len());
654 use crate::core::embedding_quant::dot_f32;
655 let dot = dot_f32(a, b);
656 let norm_a = dot_f32(a, a).sqrt();
657 let norm_b = dot_f32(b, b).sqrt();
658 if norm_a == 0.0 || norm_b == 0.0 {
659 return 0.0;
660 }
661 dot / (norm_a * norm_b)
662}
663
664#[cfg(feature = "embeddings")]
665static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
666 std::sync::OnceLock::new();
667
668#[cfg(feature = "embeddings")]
673pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
674 SHARED_ENGINE
675 .get_or_init(EmbeddingEngine::load_default)
676 .as_ref()
677 .ok()
678}
679
680#[cfg(feature = "embeddings")]
683pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
684 SHARED_ENGINE.get()?.as_ref().ok()
685}
686
687#[cfg(feature = "embeddings")]
691fn deterministic_inference() -> bool {
692 if let Ok(v) = std::env::var("LEAN_CTX_EMBEDDING_DETERMINISTIC") {
693 return matches!(
694 v.trim().to_lowercase().as_str(),
695 "1" | "true" | "yes" | "on"
696 );
697 }
698 crate::core::config::Config::load()
699 .embedding
700 .deterministic
701 .unwrap_or(false)
702}
703
704#[cfg(feature = "embeddings")]
722pub fn background_load_allowed() -> bool {
723 if cfg!(test) {
727 return false;
728 }
729 !current_exe_is_test_artifact()
730}
731
732#[cfg(feature = "embeddings")]
734fn current_exe_is_test_artifact() -> bool {
735 std::env::current_exe()
736 .ok()
737 .as_deref()
738 .is_some_and(exe_path_is_test_artifact)
739}
740
741#[cfg(feature = "embeddings")]
747fn exe_path_is_test_artifact(path: &Path) -> bool {
748 path.parent()
749 .and_then(Path::file_name)
750 .and_then(|name| name.to_str())
751 .is_some_and(|name| name == "deps")
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[test]
759 fn cosine_similarity_identical() {
760 let a = vec![1.0, 0.0, 0.0];
761 let b = vec![1.0, 0.0, 0.0];
762 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
763 }
764
765 #[test]
766 fn cosine_similarity_orthogonal() {
767 let a = vec![1.0, 0.0, 0.0];
768 let b = vec![0.0, 1.0, 0.0];
769 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
770 }
771
772 #[test]
773 fn cosine_similarity_opposite() {
774 let a = vec![1.0, 0.0, 0.0];
775 let b = vec![-1.0, 0.0, 0.0];
776 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
777 }
778
779 #[test]
780 fn cosine_similarity_raw_unnormalized() {
781 let a = vec![3.0, 4.0];
782 let b = vec![3.0, 4.0];
783 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
784 }
785
786 #[test]
787 fn cosine_similarity_raw_zero_vector() {
788 let a = vec![0.0, 0.0];
789 let b = vec![1.0, 2.0];
790 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
791 }
792
793 #[test]
794 fn model_directory_env_override_and_availability() {
795 let _lock = crate::core::data_dir::test_env_lock();
798 let unique = "/tmp/lean_ctx_test_embed_42xyz";
799 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
800 let dir = EmbeddingEngine::model_directory();
801 assert_eq!(dir.to_string_lossy(), unique);
802 assert!(!EmbeddingEngine::is_available());
803 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
804 }
805
806 #[test]
813 fn ensure_downloaded_is_offline_noop_when_model_present() {
814 let _lock = crate::core::data_dir::test_env_lock();
817 let tmp = std::env::temp_dir().join(format!("lc-embed-545-{}", std::process::id()));
818 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", tmp.to_str().unwrap());
819
820 let selected = model_registry::resolve_model();
821 let config = selected.config();
822 let model_dir = tmp.join(selected.storage_dir_name());
823 std::fs::create_dir_all(&model_dir).unwrap();
824 std::fs::write(model_dir.join("model.onnx"), b"onnx").unwrap();
825 std::fs::write(model_dir.join(config.vocab_file.filename()), b"vocab").unwrap();
826
827 assert!(EmbeddingEngine::is_available());
828 assert!(EmbeddingEngine::ensure_downloaded().is_ok());
829
830 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
831 std::fs::remove_dir_all(&tmp).ok();
832 }
833
834 #[test]
838 #[cfg(feature = "embeddings")]
839 fn exe_path_test_artifact_detection() {
840 assert!(exe_path_is_test_artifact(Path::new(
842 "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
843 )));
844 assert!(exe_path_is_test_artifact(Path::new(
845 "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
846 )));
847 assert!(!exe_path_is_test_artifact(Path::new(
849 "/usr/local/bin/lean-ctx"
850 )));
851 assert!(!exe_path_is_test_artifact(Path::new(
852 "/Users/x/.local/bin/lean-ctx"
853 )));
854 assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
856 assert!(!exe_path_is_test_artifact(Path::new(
859 "/repo/rust/target/debug/lean-ctx"
860 )));
861 }
862
863 #[test]
867 #[cfg(feature = "embeddings")]
868 fn background_load_disallowed_in_tests() {
869 assert!(!background_load_allowed());
870 }
871
872 #[test]
876 #[cfg(feature = "embeddings")]
877 fn embedding_bag_signature_detection() {
878 assert!(is_embedding_bag_signature(&[
880 "input_ids".to_string(),
881 "offsets".to_string()
882 ]));
883 assert!(!is_embedding_bag_signature(&[
885 "input_ids".to_string(),
886 "attention_mask".to_string()
887 ]));
888 assert!(!is_embedding_bag_signature(&[
889 "input_ids".to_string(),
890 "attention_mask".to_string(),
891 "token_type_ids".to_string()
892 ]));
893 assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
895 assert!(!is_embedding_bag_signature(&[
896 "input_ids".to_string(),
897 "offsets".to_string(),
898 "extra".to_string()
899 ]));
900 }
901
902 #[test]
914 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
915 #[cfg(feature = "embeddings")]
916 fn custom_hf_model_end_to_end() {
917 let model = model_registry::EmbeddingModel::from_str_name(
918 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
919 )
920 .expect("valid hf: spec");
921
922 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
923 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
924
925 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
926 assert_eq!(
927 engine.model_name(),
928 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
929 );
930
931 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
932 assert_eq!(v.len(), 384);
933 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
934 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
935
936 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
938 let lock: std::collections::BTreeMap<String, String> =
939 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
940 assert!(lock.contains_key("model.onnx"));
941 assert!(lock.contains_key("tokenizer.json"));
942
943 let a = engine.embed("read a file from disk").unwrap();
945 let b = engine.embed("load file contents from filesystem").unwrap();
946 let c = engine.embed("the weather in Zurich is sunny").unwrap();
947 assert!(
948 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
949 "related texts must be closer"
950 );
951 }
952
953 #[test]
958 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
959 #[cfg(feature = "embeddings")]
960 fn model2vec_potion_end_to_end() {
961 let model =
962 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
963 .expect("valid hf: spec");
964
965 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
966 let engine =
967 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
968
969 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
972
973 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
974 assert_eq!(code_vec.len(), 256);
975 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
976 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
977
978 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
980 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
981
982 let read_vec = engine.embed("read a file from disk").unwrap();
984 let load_vec = engine.embed("load file contents from filesystem").unwrap();
985 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
986 assert!(
987 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
988 "related texts must be closer"
989 );
990
991 let empty_vec = engine.embed("").unwrap();
993 assert_eq!(empty_vec.len(), 256);
994 }
995}