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