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 configured_batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
274 .ok()
275 .and_then(|v| v.parse().ok())
276 .filter(|&v| v >= 1)
277 .unwrap_or_else(|| {
278 #[cfg(any(feature = "embeddings", feature = "neural"))]
279 if crate::core::ort_execution_providers::gpu_active() {
280 return 256;
281 }
282 64
283 });
284 let estimated_bytes_per_text = self
287 .max_seq_len
288 .saturating_mul(self.dimensions.max(1))
289 .saturating_mul(std::mem::size_of::<f32>())
290 .saturating_mul(8) as u64;
291 let batch_size = crate::core::memory_guard::adaptive_batch_size(
292 1,
293 configured_batch_size,
294 estimated_bytes_per_text.max(1),
295 );
296
297 let total = texts.len();
298 let total_batches = total.div_ceil(batch_size);
299 let mut results = Vec::with_capacity(total);
300 let start = std::time::Instant::now();
301
302 for (batch_idx, text_chunk) in texts.chunks(batch_size).enumerate() {
303 if crate::core::interrupt::is_cancelled()
307 || crate::core::memory_guard::abort_requested()
308 {
309 anyhow::bail!(
310 "embedding cancelled after {done}/{total_batches} batches ({embedded}/{total} chunks)",
311 done = batch_idx,
312 embedded = results.len(),
313 );
314 }
315
316 let prefixed: Vec<String> = text_chunk
317 .iter()
318 .map(|text| {
319 if let Some(prefix) = &self.model_config.document_prefix {
320 format!("{prefix}{text}")
321 } else {
322 (*text).to_owned()
323 }
324 })
325 .collect();
326 let tokenized: Vec<TokenizedInput> = prefixed
327 .par_iter()
328 .map(|text| tokenize(&self.tokenizer, text, self.max_seq_len))
329 .collect();
330
331 let batch_start = std::time::Instant::now();
332 let batch_out = self.run_inference_batch(&tokenized)?;
333 results.extend(batch_out);
334 tracing::info!(
335 batch = batch_idx + 1,
336 total_batches,
337 embedded = results.len(),
338 total,
339 batch_ms = batch_start.elapsed().as_millis(),
340 elapsed_ms = start.elapsed().as_millis(),
341 "embedding progress"
342 );
343 }
344 Ok(results)
345 }
346
347 pub fn dimensions(&self) -> usize {
348 self.dimensions
349 }
350
351 pub fn model_id(&self) -> &EmbeddingModel {
352 &self.model_id
353 }
354
355 pub fn model_name(&self) -> &str {
356 &self.model_config.name
357 }
358
359 pub fn model_directory() -> PathBuf {
361 if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
362 return PathBuf::from(dir);
363 }
364 if let Ok(d) = crate::core::paths::cache_dir() {
365 return d.join("models");
366 }
367 PathBuf::from("models")
368 }
369
370 pub fn is_available() -> bool {
372 let base_dir = Self::model_directory();
373 let selected = model_registry::resolve_model();
374 let config = selected.config();
375 let model_dir = base_dir.join(selected.storage_dir_name());
376 model_dir.join("model.onnx").exists()
377 && model_dir.join(config.vocab_file.filename()).exists()
378 }
379
380 pub fn ensure_downloaded() -> anyhow::Result<()> {
391 let base_dir = Self::model_directory();
392 let selected = model_registry::resolve_model();
393 let config = selected.config();
394 let model_dir = base_dir.join(selected.storage_dir_name());
395 download::ensure_model(&model_dir, &config)?;
396 Ok(())
397 }
398
399 #[cfg(feature = "embeddings")]
400 fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
401 let seq_len = input.input_ids.len();
402
403 let mut embedding = match &self.graph_inputs {
404 GraphInputs::Transformer {
405 input_ids,
406 attention_mask,
407 token_type_ids,
408 } => {
409 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
410 let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
411 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
412 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
413 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
414 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
415
416 let hidden = if let Some(type_id) = token_type_ids {
417 let type_vec: Vec<i64> =
418 input.token_type_ids.iter().map(|&x| x as i64).collect();
419 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
420 let type_tensor = ort::value::Tensor::from_array(type_array)?;
421 let mut _guard = self
422 .session
423 .lock()
424 .unwrap_or_else(std::sync::PoisonError::into_inner);
425 let outputs = _guard.run(ort::inputs![
426 input_ids.as_str() => ids_tensor,
427 attention_mask.as_str() => mask_tensor,
428 type_id.as_str() => type_tensor,
429 ])?;
430 let (_, data) =
431 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
432 data.to_vec()
433 } else {
434 let mut _guard = self
435 .session
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 let outputs = _guard.run(ort::inputs![
439 input_ids.as_str() => ids_tensor,
440 attention_mask.as_str() => mask_tensor,
441 ])?;
442 let (_, data) =
443 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
444 data.to_vec()
445 };
446 pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
447 }
448 GraphInputs::EmbeddingBag { input_ids, offsets } => {
449 if seq_len == 0 {
450 return Ok(vec![0.0; self.dimensions]);
451 }
452 let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
453 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
454 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
455 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
456 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
457 let mut _guard = self
458 .session
459 .lock()
460 .unwrap_or_else(std::sync::PoisonError::into_inner);
461 let outputs = _guard.run(ort::inputs![
462 input_ids.as_str() => ids_tensor,
463 offsets.as_str() => offsets_tensor,
464 ])?;
465 let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
466 data.to_vec()
467 }
468 };
469
470 pooling::normalize_l2(&mut embedding);
471 Ok(embedding)
472 }
473
474 #[cfg(feature = "embeddings")]
483 fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
484 if inputs.is_empty() {
485 return Ok(Vec::new());
486 }
487
488 match &self.graph_inputs {
489 GraphInputs::Transformer {
490 input_ids: input_id,
491 attention_mask: mask_id,
492 token_type_ids,
493 } => {
494 let batch = inputs.len();
495 let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
496
497 let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
498 let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
499 let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
500 let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
501
502 for inp in inputs {
503 let seq_len = inp.input_ids.len();
504 ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
505 ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
506
507 mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
508 mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
509
510 type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
511 type_data.resize(type_data.len() + (max_len - seq_len), 0);
512
513 per_seq_masks.push(inp.attention_mask.as_slice());
514 }
515
516 let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
517 let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
518 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
519 let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
520
521 let hidden = if let Some(type_id) = token_type_ids {
522 let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
523 let type_tensor = ort::value::Tensor::from_array(type_array)?;
524 let mut _guard = self
525 .session
526 .lock()
527 .unwrap_or_else(std::sync::PoisonError::into_inner);
528 let outputs = _guard.run(ort::inputs![
529 input_id.as_str() => ids_tensor,
530 mask_id.as_str() => mask_tensor,
531 type_id.as_str() => type_tensor,
532 ])?;
533 let (_, data) =
534 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
535 data.to_vec()
536 } else {
537 let mut _guard = self
538 .session
539 .lock()
540 .unwrap_or_else(std::sync::PoisonError::into_inner);
541 let outputs = _guard.run(ort::inputs![
542 input_id.as_str() => ids_tensor,
543 mask_id.as_str() => mask_tensor,
544 ])?;
545 let (_, data) =
546 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
547 data.to_vec()
548 };
549
550 let mut results =
551 pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
552 for emb in &mut results {
553 pooling::normalize_l2(emb);
554 }
555 Ok(results)
556 }
557 GraphInputs::EmbeddingBag { input_ids, offsets } => {
558 let batch = inputs.len();
559 let mut flat_ids: Vec<i64> = Vec::new();
560 let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
561 let mut last_offset = 0i64;
562
563 for inp in inputs {
564 adjusted_offsets.push(last_offset);
565 if !inp.input_ids.is_empty() {
566 flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
567 last_offset = flat_ids.len() as i64;
568 }
569 }
570
571 if flat_ids.is_empty() {
572 return Ok(vec![vec![0.0; self.dimensions]; batch]);
573 }
574
575 let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
576 let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
577 let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
578 let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
579
580 let mut _guard = self
581 .session
582 .lock()
583 .unwrap_or_else(std::sync::PoisonError::into_inner);
584 let outputs = _guard.run(ort::inputs![
585 input_ids.as_str() => ids_tensor,
586 offsets.as_str() => offsets_tensor,
587 ])?;
588 let (_, out_data) =
589 outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
590 let out = out_data.to_vec();
591
592 let mut results: Vec<Vec<f32>> = out
593 .chunks_exact(self.dimensions)
594 .map(<[f32]>::to_vec)
595 .collect();
596 while results.len() < batch {
597 results.push(vec![0.0; self.dimensions]);
598 }
599 for emb in &mut results {
600 pooling::normalize_l2(emb);
601 }
602 Ok(results)
603 }
604 }
605 }
606
607 #[cfg(not(feature = "embeddings"))]
608 fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
609 anyhow::bail!("Embeddings feature not enabled")
610 }
611
612 #[cfg(not(feature = "embeddings"))]
613 fn run_inference_batch(&self, _inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
614 anyhow::bail!("Embeddings feature not enabled")
615 }
616}
617
618fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
620 match &config.vocab_file {
621 VocabSource::VocabTxt(filename) => {
622 let path = model_dir.join(filename);
623 let tok = WordPieceTokenizer::from_file(&path)?;
624 Ok(TokenizerKind::WordPiece(tok))
625 }
626 VocabSource::TokenizerJson(filename) => {
627 let path = model_dir.join(filename);
628 let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
629 anyhow::anyhow!(
630 "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
631 HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
632 config.name
633 )
634 })?;
635 Ok(TokenizerKind::HfTokenizer(tok))
636 }
637 }
638}
639
640fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
642 match tokenizer {
643 TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
644 TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
645 }
646}
647
648#[cfg(feature = "embeddings")]
650fn detect_dimensions(
651 session: &mut ort::session::Session,
652 tokenizer: &TokenizerKind,
653 graph_inputs: &GraphInputs,
654 output_name: &str,
655 max_seq_len: usize,
656) -> Option<usize> {
657 let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
658 let seq_len = dummy.input_ids.len();
659 if seq_len == 0 {
660 return None;
661 }
662
663 let outputs = match graph_inputs {
664 GraphInputs::Transformer {
665 input_ids,
666 attention_mask,
667 token_type_ids,
668 } => {
669 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
670 let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
671 let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
672 let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
673 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
674 let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
675
676 if let Some(type_id) = token_type_ids {
677 let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
678 let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
679 let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
680 session
681 .run(ort::inputs![
682 input_ids.as_str() => ids_tensor,
683 attention_mask.as_str() => mask_tensor,
684 type_id.as_str() => type_tensor,
685 ])
686 .ok()?
687 } else {
688 session
689 .run(ort::inputs![
690 input_ids.as_str() => ids_tensor,
691 attention_mask.as_str() => mask_tensor,
692 ])
693 .ok()?
694 }
695 }
696 GraphInputs::EmbeddingBag { input_ids, offsets } => {
697 let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
698 let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
699 let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
700 let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
701 let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
702 session
703 .run(ort::inputs![
704 input_ids.as_str() => ids_tensor,
705 offsets.as_str() => offsets_tensor,
706 ])
707 .ok()?
708 }
709 };
710
711 let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
712
713 match graph_inputs {
714 GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
716 GraphInputs::EmbeddingBag { .. } => {
720 if shape.len() == 2 {
721 shape.last().copied().map(|s| s as usize)
722 } else {
723 None
724 }
725 }
726 }
727}
728
729pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
735 debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
736 crate::core::embedding_quant::dot_f32(a, b)
737}
738
739pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
741 debug_assert_eq!(a.len(), b.len());
742 use crate::core::embedding_quant::dot_f32;
743 let dot = dot_f32(a, b);
744 let norm_a = dot_f32(a, a).sqrt();
745 let norm_b = dot_f32(b, b).sqrt();
746 if norm_a == 0.0 || norm_b == 0.0 {
747 return 0.0;
748 }
749 dot / (norm_a * norm_b)
750}
751
752#[cfg(feature = "embeddings")]
753static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
754 std::sync::OnceLock::new();
755
756#[cfg(feature = "embeddings")]
761pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
762 shared_engine_result().ok()
763}
764
765#[cfg(feature = "embeddings")]
768pub fn shared_engine_result() -> anyhow::Result<&'static EmbeddingEngine> {
769 SHARED_ENGINE
770 .get_or_init(EmbeddingEngine::load_default)
771 .as_ref()
772 .map_err(|e| anyhow::anyhow!("{e}"))
773}
774
775#[cfg(feature = "embeddings")]
778pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
779 SHARED_ENGINE.get()?.as_ref().ok()
780}
781
782#[cfg(feature = "embeddings")]
786fn deterministic_inference() -> bool {
787 if let Ok(v) = std::env::var("LEAN_CTX_EMBEDDING_DETERMINISTIC") {
788 return matches!(
789 v.trim().to_lowercase().as_str(),
790 "1" | "true" | "yes" | "on"
791 );
792 }
793 crate::core::config::Config::load()
794 .embedding
795 .deterministic
796 .unwrap_or(false)
797}
798
799#[cfg(feature = "embeddings")]
817pub fn background_load_allowed() -> bool {
818 if cfg!(test) {
822 return false;
823 }
824 !current_exe_is_test_artifact()
825}
826
827#[cfg(feature = "embeddings")]
829fn current_exe_is_test_artifact() -> bool {
830 std::env::current_exe()
831 .ok()
832 .as_deref()
833 .is_some_and(exe_path_is_test_artifact)
834}
835
836#[cfg(feature = "embeddings")]
842fn exe_path_is_test_artifact(path: &Path) -> bool {
843 path.parent()
844 .and_then(Path::file_name)
845 .and_then(|name| name.to_str())
846 .is_some_and(|name| name == "deps")
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 #[test]
854 fn cosine_similarity_identical() {
855 let a = vec![1.0, 0.0, 0.0];
856 let b = vec![1.0, 0.0, 0.0];
857 assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
858 }
859
860 #[test]
861 fn cosine_similarity_orthogonal() {
862 let a = vec![1.0, 0.0, 0.0];
863 let b = vec![0.0, 1.0, 0.0];
864 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
865 }
866
867 #[test]
868 fn cosine_similarity_opposite() {
869 let a = vec![1.0, 0.0, 0.0];
870 let b = vec![-1.0, 0.0, 0.0];
871 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
872 }
873
874 #[test]
875 fn cosine_similarity_raw_unnormalized() {
876 let a = vec![3.0, 4.0];
877 let b = vec![3.0, 4.0];
878 assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
879 }
880
881 #[test]
882 fn cosine_similarity_raw_zero_vector() {
883 let a = vec![0.0, 0.0];
884 let b = vec![1.0, 2.0];
885 assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
886 }
887
888 #[test]
889 fn model_directory_env_override_and_availability() {
890 let _lock = crate::core::data_dir::test_env_lock();
893 let unique = "/tmp/lean_ctx_test_embed_42xyz";
894 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
895 let dir = EmbeddingEngine::model_directory();
896 assert_eq!(dir.to_string_lossy(), unique);
897 assert!(!EmbeddingEngine::is_available());
898 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
899 }
900
901 #[test]
908 fn ensure_downloaded_is_offline_noop_when_model_present() {
909 let _lock = crate::core::data_dir::test_env_lock();
912 let tmp = std::env::temp_dir().join(format!("lc-embed-545-{}", std::process::id()));
913 crate::test_env::set_var("LEAN_CTX_MODELS_DIR", tmp.to_str().unwrap());
914
915 let selected = model_registry::resolve_model();
916 let config = selected.config();
917 let model_dir = tmp.join(selected.storage_dir_name());
918 std::fs::create_dir_all(&model_dir).unwrap();
919 std::fs::write(model_dir.join("model.onnx"), b"onnx").unwrap();
920 std::fs::write(model_dir.join(config.vocab_file.filename()), b"vocab").unwrap();
921
922 assert!(EmbeddingEngine::is_available());
923 assert!(EmbeddingEngine::ensure_downloaded().is_ok());
924
925 crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
926 std::fs::remove_dir_all(&tmp).ok();
927 }
928
929 #[test]
933 #[cfg(feature = "embeddings")]
934 fn exe_path_test_artifact_detection() {
935 assert!(exe_path_is_test_artifact(Path::new(
937 "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
938 )));
939 assert!(exe_path_is_test_artifact(Path::new(
940 "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
941 )));
942 assert!(!exe_path_is_test_artifact(Path::new(
944 "/usr/local/bin/lean-ctx"
945 )));
946 assert!(!exe_path_is_test_artifact(Path::new(
947 "/Users/x/.local/bin/lean-ctx"
948 )));
949 assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
951 assert!(!exe_path_is_test_artifact(Path::new(
954 "/repo/rust/target/debug/lean-ctx"
955 )));
956 }
957
958 #[test]
962 #[cfg(feature = "embeddings")]
963 fn background_load_disallowed_in_tests() {
964 assert!(!background_load_allowed());
965 }
966
967 #[test]
971 #[cfg(feature = "embeddings")]
972 fn embedding_bag_signature_detection() {
973 assert!(is_embedding_bag_signature(&[
975 "input_ids".to_string(),
976 "offsets".to_string()
977 ]));
978 assert!(!is_embedding_bag_signature(&[
980 "input_ids".to_string(),
981 "attention_mask".to_string()
982 ]));
983 assert!(!is_embedding_bag_signature(&[
984 "input_ids".to_string(),
985 "attention_mask".to_string(),
986 "token_type_ids".to_string()
987 ]));
988 assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
990 assert!(!is_embedding_bag_signature(&[
991 "input_ids".to_string(),
992 "offsets".to_string(),
993 "extra".to_string()
994 ]));
995 }
996
997 #[test]
1009 #[ignore = "downloads a real model from HuggingFace (~91MB)"]
1010 #[cfg(feature = "embeddings")]
1011 fn custom_hf_model_end_to_end() {
1012 let model = model_registry::EmbeddingModel::from_str_name(
1013 "hf:sentence-transformers/all-MiniLM-L6-v2@main",
1014 )
1015 .expect("valid hf: spec");
1016
1017 let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
1018 let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
1019
1020 assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
1021 assert_eq!(
1022 engine.model_name(),
1023 "hf:sentence-transformers/all-MiniLM-L6-v2@main"
1024 );
1025
1026 let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1027 assert_eq!(v.len(), 384);
1028 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1029 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1030
1031 let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
1033 let lock: std::collections::BTreeMap<String, String> =
1034 serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
1035 assert!(lock.contains_key("model.onnx"));
1036 assert!(lock.contains_key("tokenizer.json"));
1037
1038 let a = engine.embed("read a file from disk").unwrap();
1040 let b = engine.embed("load file contents from filesystem").unwrap();
1041 let c = engine.embed("the weather in Zurich is sunny").unwrap();
1042 assert!(
1043 cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
1044 "related texts must be closer"
1045 );
1046 }
1047
1048 #[test]
1053 #[ignore = "downloads a real model from HuggingFace (~30MB)"]
1054 #[cfg(feature = "embeddings")]
1055 fn model2vec_potion_end_to_end() {
1056 let model =
1057 model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
1058 .expect("valid hf: spec");
1059
1060 let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
1061 let engine =
1062 EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
1063
1064 assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
1067
1068 let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
1069 assert_eq!(code_vec.len(), 256);
1070 let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1071 assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
1072
1073 let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
1075 assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
1076
1077 let read_vec = engine.embed("read a file from disk").unwrap();
1079 let load_vec = engine.embed("load file contents from filesystem").unwrap();
1080 let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
1081 assert!(
1082 cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
1083 "related texts must be closer"
1084 );
1085
1086 let empty_vec = engine.embed("").unwrap();
1088 assert_eq!(empty_vec.len(), 256);
1089 }
1090}