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