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![ort::ep::CPU::default().build()]
105 } else {
106 crate::core::ort_execution_providers::execution_providers()
107 };
108 if !deterministic
111 && let Some(msg) = crate::core::ort_execution_providers::gpu_fallback_warning()
112 {
113 tracing::warn!("{msg}");
114 }
115 let num_cpus = if deterministic {
116 1
117 } else {
118 std::thread::available_parallelism().map_or(4, |n| n.get().max(1))
119 };
120 tracing::info!(
121 model = %config.name,
122 model_path = %model_path.display(),
123 deterministic,
124 num_cpus,
125 execution_providers = eps.len(),
126 "Loading ONNX embedding model"
127 );
128 crate::core::ort_environment::ensure_ort_env(&eps)?;
129 tracing::debug!("ONNX Runtime environment ready; creating embedding session builder");
130 let mut session = ort::session::Session::builder()
131 .map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
132 .with_intra_threads(num_cpus)
133 .map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
134 .with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
135 .map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
136 .commit_from_file(&model_path)
137 .map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
138 tracing::debug!("ONNX embedding session loaded; inspecting graph signature");
139
140 let input_names: Vec<String> = session
141 .inputs()
142 .iter()
143 .map(|i| i.name().to_string())
144 .collect();
145
146 if input_names.len() < 2 {
147 anyhow::bail!(
148 "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
149 input_names.len()
150 );
151 }
152
153 let graph_inputs = if is_embedding_bag_signature(&input_names) {
157 GraphInputs::EmbeddingBag {
158 input_ids: input_names[0].clone(),
159 offsets: input_names[1].clone(),
160 }
161 } else {
162 let token_type_ids = if config.needs_token_type_ids {
163 if input_names.len() < 3 {
164 anyhow::bail!(
165 "Model {} requires token_type_ids but only has {} inputs",
166 config.name,
167 input_names.len()
168 );
169 }
170 Some(input_names[2].clone())
171 } else if input_names.len() >= 3 {
172 Some(input_names[2].clone())
173 } else {
174 None
175 };
176 GraphInputs::Transformer {
177 input_ids: input_names[0].clone(),
178 attention_mask: input_names[1].clone(),
179 token_type_ids,
180 }
181 };
182
183 let output_name = session
184 .outputs()
185 .first()
186 .map(|o| o.name().to_string())
187 .ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
188
189 let dimensions = detect_dimensions(
190 &mut session,
191 &tokenizer,
192 &graph_inputs,
193 &output_name,
194 config.max_seq_len,
195 )
196 .unwrap_or(config.dimensions);
197 tracing::debug!(dimensions, "ONNX embedding dimensions resolved");
198
199 tracing::info!(
200 "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
201 config.name,
202 dimensions,
203 config.max_seq_len,
204 match graph_inputs {
205 GraphInputs::Transformer { .. } => "transformer",
206 GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
207 },
208 );
209
210 Ok(Self {
211 session: Mutex::new(session),
212 tokenizer,
213 dimensions,
214 max_seq_len: config.max_seq_len,
215 model_id,
216 model_config: config,
217 graph_inputs,
218 output_name,
219 })
220 }
221
222 #[cfg(not(feature = "embeddings"))]
223 pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
224 anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
225 }
226
227 pub fn load_default() -> anyhow::Result<Self> {
229 Self::load(&Self::model_directory())
230 }
231
232 pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
234 let prefixed;
235 let input_text = if let Some(prefix) = &self.model_config.document_prefix {
236 prefixed = format!("{prefix}{text}");
237 &prefixed
238 } else {
239 text
240 };
241 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
242 self.run_inference(&input)
243 }
244
245 pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
248 let prefixed;
249 let input_text = if let Some(prefix) = &self.model_config.query_prefix {
250 prefixed = format!("{prefix}{query}");
251 &prefixed
252 } else {
253 query
254 };
255 let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
256 self.run_inference(&input)
257 }
258
259 pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
265 if texts.is_empty() {
266 return Ok(Vec::new());
267 }
268
269 let prefixed: Vec<String> = texts
270 .iter()
271 .map(|t| {
272 if let Some(prefix) = &self.model_config.document_prefix {
273 format!("{prefix}{t}")
274 } else {
275 t.to_string()
276 }
277 })
278 .collect();
279 let prefixed_refs: Vec<&str> = prefixed.iter().map(std::string::String::as_str).collect();
280
281 let tokenized: Vec<TokenizedInput> = prefixed_refs
283 .par_iter()
284 .map(|t| tokenize(&self.tokenizer, t, self.max_seq_len))
285 .collect();
286
287 let batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
294 .ok()
295 .and_then(|v| v.parse().ok())
296 .filter(|&v| v >= 1)
297 .unwrap_or_else(|| {
298 #[cfg(any(feature = "embeddings", feature = "neural"))]
299 if crate::core::ort_execution_providers::gpu_active() {
300 return 256;
301 }
302 64
303 });
304
305 let total = tokenized.len();
306 let total_batches = total.div_ceil(batch_size);
307 let mut results = Vec::with_capacity(total);
308 let start = std::time::Instant::now();
309 for (batch_idx, chunk) in tokenized.chunks(batch_size).enumerate() {
310 if crate::core::interrupt::is_cancelled()
316 || crate::core::memory_guard::abort_requested()
317 {
318 anyhow::bail!(
319 "embedding cancelled after {done}/{total_batches} batches ({embedded}/{total} chunks)",
320 done = batch_idx,
321 embedded = results.len(),
322 );
323 }
324 let batch_start = std::time::Instant::now();
325 let batch_out = self.run_inference_batch(chunk)?;
326 results.extend(batch_out);
327 tracing::info!(
328 batch = batch_idx + 1,
329 total_batches,
330 embedded = results.len(),
331 total,
332 batch_ms = batch_start.elapsed().as_millis(),
333 elapsed_ms = start.elapsed().as_millis(),
334 "embedding progress"
335 );
336 }
337 Ok(results)
338 }
339
340 pub fn dimensions(&self) -> usize {
341 self.dimensions
342 }
343
344 pub fn model_id(&self) -> &EmbeddingModel {
345 &self.model_id
346 }
347
348 pub fn model_name(&self) -> &str {
349 &self.model_config.name
350 }
351
352 pub fn model_directory() -> PathBuf {
354 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
355 return PathBuf::from(dir);
356 }
357 if let Ok(d) = crate::core::paths::cache_dir() {
358 return d.join("models");
359 }
360 PathBuf::from("models")
361 }
362
363 pub fn is_available() -> bool {
365 let base_dir = Self::model_directory();
366 let selected = model_registry::resolve_model();
367 let config = selected.config();
368 let model_dir = base_dir.join(selected.storage_dir_name());
369 model_dir.join("model.onnx").exists()
370 && model_dir.join(config.vocab_file.filename()).exists()
371 }
372
373 pub fn ensure_downloaded() -> anyhow::Result<()> {
384 let base_dir = Self::model_directory();
385 let selected = model_registry::resolve_model();
386 let config = selected.config();
387 let model_dir = base_dir.join(selected.storage_dir_name());
388 download::ensure_model(&model_dir, &config)?;
389 Ok(())
390 }
391
392 #[cfg(feature = "embeddings")]
393 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
394 let seq_len = input.input_ids.len();
395
396 let mut embedding = match &self.graph_inputs {
397 GraphInputs::Transformer {
398 input_ids,
399 attention_mask,
400 token_type_ids,
401 } => {
402 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
403 let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
404 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
405 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
406 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
407 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
408
409 let hidden = if let Some(type_id) = token_type_ids {
410 let type_vec: Vec<i64> =
411 input.token_type_ids.iter().map(|&x| x as i64).collect();
412 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
413 let type_tensor = ort::value::Tensor::from_array(type_array)?;
414 let mut _guard = self
415 .session
416 .lock()
417 .unwrap_or_else(std::sync::PoisonError::into_inner);
418 let outputs = _guard.run(ort::inputs![
419 input_ids.as_str() => ids_tensor,
420 attention_mask.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
428 .session
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 let outputs = _guard.run(ort::inputs![
432 input_ids.as_str() => ids_tensor,
433 attention_mask.as_str() => mask_tensor,
434 ])?;
435 let (_, data) =
436 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
437 data.to_vec()
438 };
439 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
440 }
441 GraphInputs::EmbeddingBag { input_ids, offsets } => {
442 if seq_len == 0 {
443 return Ok(vec![0.0; self.dimensions]);
444 }
445 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
446 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
447 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
448 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
449 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
450 let mut _guard = self
451 .session
452 .lock()
453 .unwrap_or_else(std::sync::PoisonError::into_inner);
454 let outputs = _guard.run(ort::inputs![
455 input_ids.as_str() => ids_tensor,
456 offsets.as_str() => offsets_tensor,
457 ])?;
458 let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
459 data.to_vec()
460 }
461 };
462
463 pooling::normalize_l2(&mut embedding);
464 Ok(embedding)
465 }
466
467 #[cfg(feature = "embeddings")]
476 fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
477 if inputs.is_empty() {
478 return Ok(Vec::new());
479 }
480
481 match &self.graph_inputs {
482 GraphInputs::Transformer {
483 input_ids: input_id,
484 attention_mask: mask_id,
485 token_type_ids,
486 } => {
487 let batch = inputs.len();
488 let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
489
490 let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
491 let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
492 let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
493 let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
494
495 for inp in inputs {
496 let seq_len = inp.input_ids.len();
497 ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
498 ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
499
500 mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
501 mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
502
503 type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
504 type_data.resize(type_data.len() + (max_len - seq_len), 0);
505
506 per_seq_masks.push(inp.attention_mask.as_slice());
507 }
508
509 let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
510 let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
511 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
512 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
513
514 let hidden = if let Some(type_id) = token_type_ids {
515 let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
516 let type_tensor = ort::value::Tensor::from_array(type_array)?;
517 let mut _guard = self
518 .session
519 .lock()
520 .unwrap_or_else(std::sync::PoisonError::into_inner);
521 let outputs = _guard.run(ort::inputs![
522 input_id.as_str() => ids_tensor,
523 mask_id.as_str() => mask_tensor,
524 type_id.as_str() => type_tensor,
525 ])?;
526 let (_, data) =
527 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
528 data.to_vec()
529 } else {
530 let mut _guard = self
531 .session
532 .lock()
533 .unwrap_or_else(std::sync::PoisonError::into_inner);
534 let outputs = _guard.run(ort::inputs![
535 input_id.as_str() => ids_tensor,
536 mask_id.as_str() => mask_tensor,
537 ])?;
538 let (_, data) =
539 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
540 data.to_vec()
541 };
542
543 let mut results =
544 pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
545 for emb in &mut results {
546 pooling::normalize_l2(emb);
547 }
548 Ok(results)
549 }
550 GraphInputs::EmbeddingBag { input_ids, offsets } => {
551 let batch = inputs.len();
552 let mut flat_ids: Vec<i64> = Vec::new();
553 let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
554 let mut last_offset = 0i64;
555
556 for inp in inputs {
557 adjusted_offsets.push(last_offset);
558 if !inp.input_ids.is_empty() {
559 flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
560 last_offset = flat_ids.len() as i64;
561 }
562 }
563
564 if flat_ids.is_empty() {
565 return Ok(vec![vec![0.0; self.dimensions]; batch]);
566 }
567
568 let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
569 let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
570 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
571 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
572
573 let mut _guard = self
574 .session
575 .lock()
576 .unwrap_or_else(std::sync::PoisonError::into_inner);
577 let outputs = _guard.run(ort::inputs![
578 input_ids.as_str() => ids_tensor,
579 offsets.as_str() => offsets_tensor,
580 ])?;
581 let (_, out_data) =
582 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
583 let out = out_data.to_vec();
584
585 let mut results: Vec<Vec<f32>> = out
586 .chunks_exact(self.dimensions)
587 .map(<[f32]>::to_vec)
588 .collect();
589 while results.len() < batch {
590 results.push(vec![0.0; self.dimensions]);
591 }
592 for emb in &mut results {
593 pooling::normalize_l2(emb);
594 }
595 Ok(results)
596 }
597 }
598 }
599
600 #[cfg(not(feature = "embeddings"))]
601 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
602 anyhow::bail!("Embeddings feature not enabled")
603 }
604
605 #[cfg(not(feature = "embeddings"))]
606 fn run_inference_batch(&self, _inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
607 anyhow::bail!("Embeddings feature not enabled")
608 }
609}
610
611fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
613 match &config.vocab_file {
614 VocabSource::VocabTxt(filename) => {
615 let path = model_dir.join(filename);
616 let tok = WordPieceTokenizer::from_file(&path)?;
617 Ok(TokenizerKind::WordPiece(tok))
618 }
619 VocabSource::TokenizerJson(filename) => {
620 let path = model_dir.join(filename);
621 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
622 anyhow::anyhow!(
623 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
624 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
625 config.name
626 )
627 })?;
628 Ok(TokenizerKind::HfTokenizer(tok))
629 }
630 }
631}
632
633fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
635 match tokenizer {
636 TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
637 TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
638 }
639}
640
641#[cfg(feature = "embeddings")]
643fn detect_dimensions(
644 session: &mut ort::session::Session,
645 tokenizer: &TokenizerKind,
646 graph_inputs: &GraphInputs,
647 output_name: &str,
648 max_seq_len: usize,
649) -> Option<usize> {
650 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
651 let seq_len = dummy.input_ids.len();
652 if seq_len == 0 {
653 return None;
654 }
655
656 let outputs = match graph_inputs {
657 GraphInputs::Transformer {
658 input_ids,
659 attention_mask,
660 token_type_ids,
661 } => {
662 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
663 let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
664 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
665 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
666 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
667 let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
668
669 if let Some(type_id) = token_type_ids {
670 let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
671 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
672 let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
673 session
674 .run(ort::inputs![
675 input_ids.as_str() => ids_tensor,
676 attention_mask.as_str() => mask_tensor,
677 type_id.as_str() => type_tensor,
678 ])
679 .ok()?
680 } else {
681 session
682 .run(ort::inputs![
683 input_ids.as_str() => ids_tensor,
684 attention_mask.as_str() => mask_tensor,
685 ])
686 .ok()?
687 }
688 }
689 GraphInputs::EmbeddingBag { input_ids, offsets } => {
690 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
691 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
692 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
693 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
694 let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
695 session
696 .run(ort::inputs![
697 input_ids.as_str() => ids_tensor,
698 offsets.as_str() => offsets_tensor,
699 ])
700 .ok()?
701 }
702 };
703
704 let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
705
706 match graph_inputs {
707 GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
709 GraphInputs::EmbeddingBag { .. } => {
713 if shape.len() == 2 {
714 shape.last().copied().map(|s| s as usize)
715 } else {
716 None
717 }
718 }
719 }
720}
721
722pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
728 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
729 crate::core::embedding_quant::dot_f32(a, b)
730}
731
732pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
734 debug_assert_eq!(a.len(), b.len());
735 use crate::core::embedding_quant::dot_f32;
736 let dot = dot_f32(a, b);
737 let norm_a = dot_f32(a, a).sqrt();
738 let norm_b = dot_f32(b, b).sqrt();
739 if norm_a == 0.0 || norm_b == 0.0 {
740 return 0.0;
741 }
742 dot / (norm_a * norm_b)
743}
744
745#[cfg(feature = "embeddings")]
746static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
747 std::sync::OnceLock::new();
748
749#[cfg(feature = "embeddings")]
754pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
755 shared_engine_result().ok()
756}
757
758#[cfg(feature = "embeddings")]
761pub fn shared_engine_result() -> anyhow::Result<&'static EmbeddingEngine> {
762 SHARED_ENGINE
763 .get_or_init(EmbeddingEngine::load_default)
764 .as_ref()
765 .map_err(|e| anyhow::anyhow!("{e}"))
766}
767
768#[cfg(feature = "embeddings")]
771pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
772 SHARED_ENGINE.get()?.as_ref().ok()
773}
774
775#[cfg(feature = "embeddings")]
779fn deterministic_inference() -> bool {
780 if let Ok(v) = std::env::var("LEAN_CTX_EMBEDDING_DETERMINISTIC") {
781 return matches!(
782 v.trim().to_lowercase().as_str(),
783 "1" | "true" | "yes" | "on"
784 );
785 }
786 crate::core::config::Config::load()
787 .embedding
788 .deterministic
789 .unwrap_or(false)
790}
791
792#[cfg(feature = "embeddings")]
810pub fn background_load_allowed() -> bool {
811 if cfg!(test) {
815 return false;
816 }
817 !current_exe_is_test_artifact()
818}
819
820#[cfg(feature = "embeddings")]
822fn current_exe_is_test_artifact() -> bool {
823 std::env::current_exe()
824 .ok()
825 .as_deref()
826 .is_some_and(exe_path_is_test_artifact)
827}
828
829#[cfg(feature = "embeddings")]
835fn exe_path_is_test_artifact(path: &Path) -> bool {
836 path.parent()
837 .and_then(Path::file_name)
838 .and_then(|name| name.to_str())
839 .is_some_and(|name| name == "deps")
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn cosine_similarity_identical() {
848 let a = vec![1.0, 0.0, 0.0];
849 let b = vec![1.0, 0.0, 0.0];
850 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
851 }
852
853 #[test]
854 fn cosine_similarity_orthogonal() {
855 let a = vec![1.0, 0.0, 0.0];
856 let b = vec![0.0, 1.0, 0.0];
857 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
858 }
859
860 #[test]
861 fn cosine_similarity_opposite() {
862 let a = vec![1.0, 0.0, 0.0];
863 let b = vec![-1.0, 0.0, 0.0];
864 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
865 }
866
867 #[test]
868 fn cosine_similarity_raw_unnormalized() {
869 let a = vec![3.0, 4.0];
870 let b = vec![3.0, 4.0];
871 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
872 }
873
874 #[test]
875 fn cosine_similarity_raw_zero_vector() {
876 let a = vec![0.0, 0.0];
877 let b = vec![1.0, 2.0];
878 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
879 }
880
881 #[test]
882 fn model_directory_env_override_and_availability() {
883 let _lock = crate::core::data_dir::test_env_lock();
886 let unique = "/tmp/lean_ctx_test_embed_42xyz";
887 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
888 let dir = EmbeddingEngine::model_directory();
889 assert_eq!(dir.to_string_lossy(), unique);
890 assert!(!EmbeddingEngine::is_available());
891 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
892 }
893
894 #[test]
901 fn ensure_downloaded_is_offline_noop_when_model_present() {
902 let _lock = crate::core::data_dir::test_env_lock();
905 let tmp = std::env::temp_dir().join(format!("lc-embed-545-{}", std::process::id()));
906 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", tmp.to_str().unwrap());
907
908 let selected = model_registry::resolve_model();
909 let config = selected.config();
910 let model_dir = tmp.join(selected.storage_dir_name());
911 std::fs::create_dir_all(&model_dir).unwrap();
912 std::fs::write(model_dir.join("model.onnx"), b"onnx").unwrap();
913 std::fs::write(model_dir.join(config.vocab_file.filename()), b"vocab").unwrap();
914
915 assert!(EmbeddingEngine::is_available());
916 assert!(EmbeddingEngine::ensure_downloaded().is_ok());
917
918 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
919 std::fs::remove_dir_all(&tmp).ok();
920 }
921
922 #[test]
926 #[cfg(feature = "embeddings")]
927 fn exe_path_test_artifact_detection() {
928 assert!(exe_path_is_test_artifact(Path::new(
930 "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
931 )));
932 assert!(exe_path_is_test_artifact(Path::new(
933 "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
934 )));
935 assert!(!exe_path_is_test_artifact(Path::new(
937 "/usr/local/bin/lean-ctx"
938 )));
939 assert!(!exe_path_is_test_artifact(Path::new(
940 "/Users/x/.local/bin/lean-ctx"
941 )));
942 assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
944 assert!(!exe_path_is_test_artifact(Path::new(
947 "/repo/rust/target/debug/lean-ctx"
948 )));
949 }
950
951 #[test]
955 #[cfg(feature = "embeddings")]
956 fn background_load_disallowed_in_tests() {
957 assert!(!background_load_allowed());
958 }
959
960 #[test]
964 #[cfg(feature = "embeddings")]
965 fn embedding_bag_signature_detection() {
966 assert!(is_embedding_bag_signature(&[
968 "input_ids".to_string(),
969 "offsets".to_string()
970 ]));
971 assert!(!is_embedding_bag_signature(&[
973 "input_ids".to_string(),
974 "attention_mask".to_string()
975 ]));
976 assert!(!is_embedding_bag_signature(&[
977 "input_ids".to_string(),
978 "attention_mask".to_string(),
979 "token_type_ids".to_string()
980 ]));
981 assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
983 assert!(!is_embedding_bag_signature(&[
984 "input_ids".to_string(),
985 "offsets".to_string(),
986 "extra".to_string()
987 ]));
988 }
989
990 #[test]
1002 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
1003 #[cfg(feature = "embeddings")]
1004 fn custom_hf_model_end_to_end() {
1005 let model = model_registry::EmbeddingModel::from_str_name(
1006 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
1007 )
1008 .expect("valid hf: spec");
1009
1010 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
1011 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
1012
1013 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
1014 assert_eq!(
1015 engine.model_name(),
1016 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
1017 );
1018
1019 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1020 assert_eq!(v.len(), 384);
1021 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1022 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1023
1024 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
1026 let lock: std::collections::BTreeMap<String, String> =
1027 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
1028 assert!(lock.contains_key("model.onnx"));
1029 assert!(lock.contains_key("tokenizer.json"));
1030
1031 let a = engine.embed("read a file from disk").unwrap();
1033 let b = engine.embed("load file contents from filesystem").unwrap();
1034 let c = engine.embed("the weather in Zurich is sunny").unwrap();
1035 assert!(
1036 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
1037 "related texts must be closer"
1038 );
1039 }
1040
1041 #[test]
1046 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
1047 #[cfg(feature = "embeddings")]
1048 fn model2vec_potion_end_to_end() {
1049 let model =
1050 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
1051 .expect("valid hf: spec");
1052
1053 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
1054 let engine =
1055 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
1056
1057 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
1060
1061 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1062 assert_eq!(code_vec.len(), 256);
1063 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1064 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1065
1066 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
1068 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
1069
1070 let read_vec = engine.embed("read a file from disk").unwrap();
1072 let load_vec = engine.embed("load file contents from filesystem").unwrap();
1073 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
1074 assert!(
1075 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
1076 "related texts must be closer"
1077 );
1078
1079 let empty_vec = engine.embed("").unwrap();
1081 assert_eq!(empty_vec.len(), 256);
1082 }
1083}