Skip to main content

lean_ctx/core/embeddings/
mod.rs

1//! Embedding engine for semantic code search.
2//!
3//! Provides dense vector embeddings for code chunks using a local ONNX model.
4//! Supports multiple models via `EmbeddingModel` registry — selected via
5//! `LEAN_CTX_EMBEDDING_MODEL` env var (default: all-MiniLM-L6-v2).
6//!
7//! Feature-gated under `embeddings` — falls back gracefully to BM25-only
8//! search when the feature or model is not available.
9//!
10//! Architecture:
11//!   Tokenizer → ONNX Model (ort) → Mean Pooling → L2 Normalize → `Vec<f32>`
12
13pub mod download;
14pub mod model_registry;
15pub mod pooling;
16pub mod tokenizer;
17
18use std::path::{Path, PathBuf};
19
20use model_registry::{EmbeddingModel, ModelConfig, VocabSource};
21use tokenizer::{TokenizedInput, WordPieceTokenizer};
22
23#[cfg(feature = "embeddings")]
24use rayon::prelude::*;
25#[cfg(feature = "embeddings")]
26use std::sync::Mutex;
27
28pub struct EmbeddingEngine {
29    tokenizer: TokenizerKind,
30    dimensions: usize,
31    max_seq_len: usize,
32    model_id: EmbeddingModel,
33    model_config: ModelConfig,
34    #[cfg(feature = "embeddings")]
35    session: Mutex<ort::session::Session>,
36    #[cfg(feature = "embeddings")]
37    graph_inputs: GraphInputs,
38    #[cfg(feature = "embeddings")]
39    output_name: String,
40}
41
42/// Abstraction over different tokenizer backends.
43enum TokenizerKind {
44    WordPiece(WordPieceTokenizer),
45    HfTokenizer(tokenizer::HfTokenizerWrapper),
46}
47
48/// The two ONNX graph topologies we can drive (GL #452).
49///
50/// Transformers take `[1, seq]` id/mask tensors and emit per-token hidden
51/// states `[1, seq, dim]` that we mean-pool. model2vec exports are
52/// EmbeddingBag graphs: flat `input_ids: [n_tokens]` plus `offsets: [batch]`,
53/// already pooled to `[batch, dim]` — ~500x faster, no attention pass.
54#[cfg(feature = "embeddings")]
55enum GraphInputs {
56    Transformer {
57        input_ids: String,
58        attention_mask: String,
59        token_type_ids: Option<String>,
60    },
61    EmbeddingBag {
62        input_ids: String,
63        offsets: String,
64    },
65}
66
67/// Classify the graph topology from its input names (pure, unit-testable).
68/// The model2vec signature is exactly two inputs whose second is `offsets`;
69/// everything else is treated as a transformer.
70#[cfg(feature = "embeddings")]
71fn is_embedding_bag_signature(input_names: &[String]) -> bool {
72    input_names.len() == 2 && input_names[1] == "offsets"
73}
74
75impl EmbeddingEngine {
76    /// Load embedding model and vocabulary from a directory.
77    /// Downloads model automatically from HuggingFace if not present.
78    #[cfg(feature = "embeddings")]
79    pub fn load(model_dir: &Path) -> anyhow::Result<Self> {
80        let selected = model_registry::resolve_model();
81        Self::load_model(model_dir, selected)
82    }
83
84    /// Load a specific embedding model from a directory.
85    #[cfg(feature = "embeddings")]
86    pub fn load_model(base_dir: &Path, model_id: EmbeddingModel) -> anyhow::Result<Self> {
87        let config = model_id.config();
88        let model_dir = base_dir.join(model_id.storage_dir_name());
89
90        download::ensure_model(&model_dir, &config)?;
91
92        let tokenizer = load_tokenizer(&model_dir, &config)?;
93        let model_path = model_dir.join("model.onnx");
94
95        let eps = crate::core::ort_execution_providers::gpu_execution_providers();
96        let num_cpus = std::thread::available_parallelism().map_or(4, |n| n.get().max(1));
97        crate::core::ort_environment::ensure_ort_env(&eps)?;
98        let mut session = ort::session::Session::builder()
99            .map_err(|e| anyhow::anyhow!("ORT builder: {e}"))?
100            .with_intra_threads(num_cpus)
101            .map_err(|e| anyhow::anyhow!("ORT intra threads: {e}"))?
102            .with_optimization_level(ort::session::builder::GraphOptimizationLevel::All)
103            .map_err(|e| anyhow::anyhow!("ORT optimization: {e}"))?
104            .commit_from_file(&model_path)
105            .map_err(|e| anyhow::anyhow!("ORT load model: {e}"))?;
106
107        let input_names: Vec<String> = session
108            .inputs()
109            .iter()
110            .map(|i| i.name().to_string())
111            .collect();
112
113        if input_names.len() < 2 {
114            anyhow::bail!(
115                "Expected model with at least 2 inputs (input_ids, attention_mask), got {}",
116                input_names.len()
117            );
118        }
119
120        // Topology detection (GL #452): model2vec EmbeddingBag graphs expose
121        // exactly (input_ids, offsets) — structurally incompatible with the
122        // transformer path, so they get their own input adapter.
123        let graph_inputs = if is_embedding_bag_signature(&input_names) {
124            GraphInputs::EmbeddingBag {
125                input_ids: input_names[0].clone(),
126                offsets: input_names[1].clone(),
127            }
128        } else {
129            let token_type_ids = if config.needs_token_type_ids {
130                if input_names.len() < 3 {
131                    anyhow::bail!(
132                        "Model {} requires token_type_ids but only has {} inputs",
133                        config.name,
134                        input_names.len()
135                    );
136                }
137                Some(input_names[2].clone())
138            } else if input_names.len() >= 3 {
139                Some(input_names[2].clone())
140            } else {
141                None
142            };
143            GraphInputs::Transformer {
144                input_ids: input_names[0].clone(),
145                attention_mask: input_names[1].clone(),
146                token_type_ids,
147            }
148        };
149
150        let output_name = session
151            .outputs()
152            .first()
153            .map(|o| o.name().to_string())
154            .ok_or_else(|| anyhow::anyhow!("Model has no named outputs"))?;
155
156        let dimensions = detect_dimensions(
157            &mut session,
158            &tokenizer,
159            &graph_inputs,
160            &output_name,
161            config.max_seq_len,
162        )
163        .unwrap_or(config.dimensions);
164
165        tracing::info!(
166            "Embedding engine loaded: model={}, {}d, max_seq_len={}, topology={}",
167            config.name,
168            dimensions,
169            config.max_seq_len,
170            match graph_inputs {
171                GraphInputs::Transformer { .. } => "transformer",
172                GraphInputs::EmbeddingBag { .. } => "embedding-bag (model2vec)",
173            },
174        );
175
176        Ok(Self {
177            session: Mutex::new(session),
178            tokenizer,
179            dimensions,
180            max_seq_len: config.max_seq_len,
181            model_id,
182            model_config: config,
183            graph_inputs,
184            output_name,
185        })
186    }
187
188    #[cfg(not(feature = "embeddings"))]
189    pub fn load(_model_dir: &Path) -> anyhow::Result<Self> {
190        anyhow::bail!("Embeddings feature not enabled. Compile with --features embeddings")
191    }
192
193    /// Load from default model directory (~/.lean-ctx/models/).
194    pub fn load_default() -> anyhow::Result<Self> {
195        Self::load(&Self::model_directory())
196    }
197
198    /// Generate an embedding vector for a single text (document/code).
199    pub fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
200        let prefixed;
201        let input_text = if let Some(prefix) = &self.model_config.document_prefix {
202            prefixed = format!("{prefix}{text}");
203            &prefixed
204        } else {
205            text
206        };
207        let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
208        self.run_inference(&input)
209    }
210
211    /// Generate an embedding vector for a query string.
212    /// Applies query-specific prefix if the model requires one.
213    pub fn embed_query(&self, query: &str) -> anyhow::Result<Vec<f32>> {
214        let prefixed;
215        let input_text = if let Some(prefix) = &self.model_config.query_prefix {
216            prefixed = format!("{prefix}{query}");
217            &prefixed
218        } else {
219            query
220        };
221        let input = tokenize(&self.tokenizer, input_text, self.max_seq_len);
222        self.run_inference(&input)
223    }
224
225    /// Generate embedding vectors for multiple texts using true batched ONNX
226    /// inference. Sends a single `[batch, max_seq_len]` tensor through the model
227    /// instead of `batch` separate calls — up to 50× faster on CPU for typical
228    /// batch sizes (64–128) by leveraging matrix-matrix instead of matrix-vector
229    /// operations inside the transformer.
230    pub fn embed_batch(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
231        if texts.is_empty() {
232            return Ok(Vec::new());
233        }
234
235        let prefixed: Vec<String> = texts
236            .iter()
237            .map(|t| {
238                if let Some(prefix) = &self.model_config.document_prefix {
239                    format!("{prefix}{t}")
240                } else {
241                    t.to_string()
242                }
243            })
244            .collect();
245        let prefixed_refs: Vec<&str> = prefixed.iter().map(std::string::String::as_str).collect();
246
247        // Tokenize all texts upfront (parallel — wordpiece tokenization is CPU-bound)
248        let tokenized: Vec<TokenizedInput> = prefixed_refs
249            .par_iter()
250            .map(|t| tokenize(&self.tokenizer, t, self.max_seq_len))
251            .collect();
252
253        // Process in mini-batches to cap peak memory
254        // Override via LEAN_CTX_EMBEDDING_BATCH_SIZE env var (e.g. "128").
255        let batch_size: usize = std::env::var("LEAN_CTX_EMBEDDING_BATCH_SIZE")
256            .ok()
257            .and_then(|v| v.parse().ok())
258            .filter(|&v| v >= 1)
259            .unwrap_or(64);
260        let mut results = Vec::with_capacity(texts.len());
261        for chunk in tokenized.chunks(batch_size) {
262            let batch_out = self.run_inference_batch(chunk)?;
263            results.extend(batch_out);
264        }
265        Ok(results)
266    }
267
268    pub fn dimensions(&self) -> usize {
269        self.dimensions
270    }
271
272    pub fn model_id(&self) -> &EmbeddingModel {
273        &self.model_id
274    }
275
276    pub fn model_name(&self) -> &str {
277        &self.model_config.name
278    }
279
280    /// Resolve the model directory (respects LEAN_CTX_MODELS_DIR env).
281    pub fn model_directory() -> PathBuf {
282        if let Ok(dir) = std::env::var("LEAN_CTX_MODELS_DIR") {
283            return PathBuf::from(dir);
284        }
285        if let Ok(d) = crate::core::paths::cache_dir() {
286            return d.join("models");
287        }
288        PathBuf::from("models")
289    }
290
291    /// Check if the model files are present and loadable.
292    pub fn is_available() -> bool {
293        let base_dir = Self::model_directory();
294        let selected = model_registry::resolve_model();
295        let config = selected.config();
296        let model_dir = base_dir.join(selected.storage_dir_name());
297        model_dir.join("model.onnx").exists()
298            && model_dir.join(config.vocab_file.filename()).exists()
299    }
300
301    #[cfg(feature = "embeddings")]
302    fn run_inference(&self, input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
303        let seq_len = input.input_ids.len();
304
305        let mut embedding = match &self.graph_inputs {
306            GraphInputs::Transformer {
307                input_ids,
308                attention_mask,
309                token_type_ids,
310            } => {
311                let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
312                let mask_vec: Vec<i64> = input.attention_mask.iter().map(|&x| x as i64).collect();
313                let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec)?;
314                let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec)?;
315                let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
316                let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
317
318                let hidden = if let Some(type_id) = token_type_ids {
319                    let type_vec: Vec<i64> =
320                        input.token_type_ids.iter().map(|&x| x as i64).collect();
321                    let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec)?;
322                    let type_tensor = ort::value::Tensor::from_array(type_array)?;
323                    let mut _guard = self.session.lock().unwrap();
324                    let outputs = _guard.run(ort::inputs![
325                        input_ids.as_str() => ids_tensor,
326                        attention_mask.as_str() => mask_tensor,
327                        type_id.as_str() => type_tensor,
328                    ])?;
329                    let (_, data) =
330                        outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
331                    data.to_vec()
332                } else {
333                    let mut _guard = self.session.lock().unwrap();
334                    let outputs = _guard.run(ort::inputs![
335                        input_ids.as_str() => ids_tensor,
336                        attention_mask.as_str() => mask_tensor,
337                    ])?;
338                    let (_, data) =
339                        outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
340                    data.to_vec()
341                };
342                pooling::mean_pool(&hidden, &input.attention_mask, seq_len, self.dimensions)
343            }
344            GraphInputs::EmbeddingBag { input_ids, offsets } => {
345                if seq_len == 0 {
346                    return Ok(vec![0.0; self.dimensions]);
347                }
348                let ids_vec: Vec<i64> = input.input_ids.iter().map(|&x| x as i64).collect();
349                let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec)?;
350                let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64])?;
351                let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
352                let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
353                let mut _guard = self.session.lock().unwrap();
354                let outputs = _guard.run(ort::inputs![
355                    input_ids.as_str() => ids_tensor,
356                    offsets.as_str() => offsets_tensor,
357                ])?;
358                let (_, data) = outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
359                data.to_vec()
360            }
361        };
362
363        pooling::normalize_l2(&mut embedding);
364        Ok(embedding)
365    }
366
367    /// Run batched inference over multiple tokenized inputs.
368    ///
369    /// For the Transformer topology: pads all inputs to `max_seq_len` of the
370    /// batch, creates a `[batch, max_seq_len]` tensor, runs ONNX once, then
371    /// mean-pools and L2-normalizes each sequence individually.
372    ///
373    /// For the EmbeddingBag topology: concatenates tokens with per-row offsets,
374    /// runs ONNX once, and L2-normalizes each output row.
375    #[cfg(feature = "embeddings")]
376    fn run_inference_batch(&self, inputs: &[TokenizedInput]) -> anyhow::Result<Vec<Vec<f32>>> {
377        if inputs.is_empty() {
378            return Ok(Vec::new());
379        }
380
381        match &self.graph_inputs {
382            GraphInputs::Transformer {
383                input_ids: input_id,
384                attention_mask: mask_id,
385                token_type_ids,
386            } => {
387                let batch = inputs.len();
388                let max_len = inputs.iter().map(|i| i.input_ids.len()).max().unwrap_or(0);
389
390                let mut ids_data: Vec<i64> = Vec::with_capacity(batch * max_len);
391                let mut mask_data: Vec<i64> = Vec::with_capacity(batch * max_len);
392                let mut type_data: Vec<i64> = Vec::with_capacity(batch * max_len);
393                let mut per_seq_masks: Vec<&[i32]> = Vec::with_capacity(batch);
394
395                for inp in inputs {
396                    let seq_len = inp.input_ids.len();
397                    ids_data.extend(inp.input_ids.iter().map(|&x| x as i64));
398                    ids_data.resize(ids_data.len() + (max_len - seq_len), 0);
399
400                    mask_data.extend(inp.attention_mask.iter().map(|&x| x as i64));
401                    mask_data.resize(mask_data.len() + (max_len - seq_len), 0);
402
403                    type_data.extend(inp.token_type_ids.iter().map(|&x| x as i64));
404                    type_data.resize(type_data.len() + (max_len - seq_len), 0);
405
406                    per_seq_masks.push(inp.attention_mask.as_slice());
407                }
408
409                let ids_array = ndarray::Array2::from_shape_vec((batch, max_len), ids_data)?;
410                let mask_array = ndarray::Array2::from_shape_vec((batch, max_len), mask_data)?;
411                let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
412                let mask_tensor = ort::value::Tensor::from_array(mask_array)?;
413
414                let hidden = if let Some(type_id) = token_type_ids {
415                    let type_array = ndarray::Array2::from_shape_vec((batch, max_len), type_data)?;
416                    let type_tensor = ort::value::Tensor::from_array(type_array)?;
417                    let mut _guard = self.session.lock().unwrap();
418                    let outputs = _guard.run(ort::inputs![
419                        input_id.as_str() => ids_tensor,
420                        mask_id.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.session.lock().unwrap();
428                    let outputs = _guard.run(ort::inputs![
429                        input_id.as_str() => ids_tensor,
430                        mask_id.as_str() => mask_tensor,
431                    ])?;
432                    let (_, data) =
433                        outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
434                    data.to_vec()
435                };
436
437                let mut results =
438                    pooling::mean_pool_batch(&hidden, &per_seq_masks, max_len, self.dimensions);
439                for emb in &mut results {
440                    pooling::normalize_l2(emb);
441                }
442                Ok(results)
443            }
444            GraphInputs::EmbeddingBag { input_ids, offsets } => {
445                let batch = inputs.len();
446                let mut flat_ids: Vec<i64> = Vec::new();
447                let mut adjusted_offsets: Vec<i64> = Vec::with_capacity(batch);
448                let mut last_offset = 0i64;
449
450                for inp in inputs {
451                    adjusted_offsets.push(last_offset);
452                    if !inp.input_ids.is_empty() {
453                        flat_ids.extend(inp.input_ids.iter().map(|&x| x as i64));
454                        last_offset = flat_ids.len() as i64;
455                    }
456                }
457
458                if flat_ids.is_empty() {
459                    return Ok(vec![vec![0.0; self.dimensions]; batch]);
460                }
461
462                let ids_array = ndarray::Array1::from_shape_vec(flat_ids.len(), flat_ids)?;
463                let offsets_array = ndarray::Array1::from_shape_vec(batch, adjusted_offsets)?;
464                let ids_tensor = ort::value::Tensor::from_array(ids_array)?;
465                let offsets_tensor = ort::value::Tensor::from_array(offsets_array)?;
466
467                let mut _guard = self.session.lock().unwrap();
468                let outputs = _guard.run(ort::inputs![
469                    input_ids.as_str() => ids_tensor,
470                    offsets.as_str() => offsets_tensor,
471                ])?;
472                let (_, out_data) =
473                    outputs[self.output_name.as_str()].try_extract_tensor::<f32>()?;
474                let out = out_data.to_vec();
475
476                let mut results: Vec<Vec<f32>> = out
477                    .chunks_exact(self.dimensions)
478                    .map(<[f32]>::to_vec)
479                    .collect();
480                while results.len() < batch {
481                    results.push(vec![0.0; self.dimensions]);
482                }
483                for emb in &mut results {
484                    pooling::normalize_l2(emb);
485                }
486                Ok(results)
487            }
488        }
489    }
490
491    #[cfg(not(feature = "embeddings"))]
492    fn run_inference(&self, _input: &TokenizedInput) -> anyhow::Result<Vec<f32>> {
493        anyhow::bail!("Embeddings feature not enabled")
494    }
495}
496
497/// Load the appropriate tokenizer for the model config.
498fn load_tokenizer(model_dir: &Path, config: &ModelConfig) -> anyhow::Result<TokenizerKind> {
499    match &config.vocab_file {
500        VocabSource::VocabTxt(filename) => {
501            let path = model_dir.join(filename);
502            let tok = WordPieceTokenizer::from_file(&path)?;
503            Ok(TokenizerKind::WordPiece(tok))
504        }
505        VocabSource::TokenizerJson(filename) => {
506            let path = model_dir.join(filename);
507            let tok = tokenizer::HfTokenizerWrapper::from_file(&path).map_err(|e| {
508                anyhow::anyhow!(
509                    "Failed to load tokenizer.json for {}: {e}. Custom models must ship a \
510                     HuggingFace tokenizer.json with a supported model type (WordPiece/BPE).",
511                    config.name
512                )
513            })?;
514            Ok(TokenizerKind::HfTokenizer(tok))
515        }
516    }
517}
518
519/// Tokenize text using whatever tokenizer backend is loaded.
520fn tokenize(tokenizer: &TokenizerKind, text: &str, max_len: usize) -> TokenizedInput {
521    match tokenizer {
522        TokenizerKind::WordPiece(wp) => wp.encode(text, max_len),
523        TokenizerKind::HfTokenizer(hf) => hf.encode(text, max_len),
524    }
525}
526
527/// Detect embedding dimensions by running a dummy inference.
528#[cfg(feature = "embeddings")]
529fn detect_dimensions(
530    session: &mut ort::session::Session,
531    tokenizer: &TokenizerKind,
532    graph_inputs: &GraphInputs,
533    output_name: &str,
534    max_seq_len: usize,
535) -> Option<usize> {
536    let dummy = tokenize(tokenizer, "test", max_seq_len.min(8));
537    let seq_len = dummy.input_ids.len();
538    if seq_len == 0 {
539        return None;
540    }
541
542    let outputs = match graph_inputs {
543        GraphInputs::Transformer {
544            input_ids,
545            attention_mask,
546            token_type_ids,
547        } => {
548            let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
549            let mask_vec: Vec<i64> = dummy.attention_mask.iter().map(|&x| x as i64).collect();
550            let ids_array = ndarray::Array2::from_shape_vec((1, seq_len), ids_vec).ok()?;
551            let mask_array = ndarray::Array2::from_shape_vec((1, seq_len), mask_vec).ok()?;
552            let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
553            let mask_tensor = ort::value::Tensor::from_array(mask_array).ok()?;
554
555            if let Some(type_id) = token_type_ids {
556                let type_vec: Vec<i64> = dummy.token_type_ids.iter().map(|&x| x as i64).collect();
557                let type_array = ndarray::Array2::from_shape_vec((1, seq_len), type_vec).ok()?;
558                let type_tensor = ort::value::Tensor::from_array(type_array).ok()?;
559                session
560                    .run(ort::inputs![
561                        input_ids.as_str() => ids_tensor,
562                        attention_mask.as_str() => mask_tensor,
563                        type_id.as_str() => type_tensor,
564                    ])
565                    .ok()?
566            } else {
567                session
568                    .run(ort::inputs![
569                        input_ids.as_str() => ids_tensor,
570                        attention_mask.as_str() => mask_tensor,
571                    ])
572                    .ok()?
573            }
574        }
575        GraphInputs::EmbeddingBag { input_ids, offsets } => {
576            let ids_vec: Vec<i64> = dummy.input_ids.iter().map(|&x| x as i64).collect();
577            let ids_array = ndarray::Array1::from_shape_vec(seq_len, ids_vec).ok()?;
578            let offsets_array = ndarray::Array1::from_shape_vec(1, vec![0i64]).ok()?;
579            let ids_tensor = ort::value::Tensor::from_array(ids_array).ok()?;
580            let offsets_tensor = ort::value::Tensor::from_array(offsets_array).ok()?;
581            session
582                .run(ort::inputs![
583                    input_ids.as_str() => ids_tensor,
584                    offsets.as_str() => offsets_tensor,
585                ])
586                .ok()?
587        }
588    };
589
590    let (shape, _) = outputs[output_name].try_extract_tensor::<f32>().ok()?;
591
592    match graph_inputs {
593        // Shape is [batch=1, seq_len, dim].
594        GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
595        // Already pooled: [batch=1, dim] — the last axis IS the dim, but be
596        // explicit about the rank so a surprising graph fails loudly into
597        // the config fallback instead of mis-probing.
598        GraphInputs::EmbeddingBag { .. } => {
599            if shape.len() == 2 {
600                shape.last().copied().map(|s| s as usize)
601            } else {
602                None
603            }
604        }
605    }
606}
607
608/// Compute cosine similarity between two L2-normalized vectors.
609/// Both vectors must be pre-normalized for correct results.
610///
611/// Uses the chunked, autovectorizable dot product from [`crate::core::embedding_quant`]
612/// (turbovec-derived) so every semantic-search hot path gets SIMD throughput.
613pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
614    debug_assert_eq!(a.len(), b.len(), "vectors must have equal dimensions");
615    crate::core::embedding_quant::dot_f32(a, b)
616}
617
618/// Compute cosine similarity without requiring pre-normalization.
619pub fn cosine_similarity_raw(a: &[f32], b: &[f32]) -> f32 {
620    debug_assert_eq!(a.len(), b.len());
621    use crate::core::embedding_quant::dot_f32;
622    let dot = dot_f32(a, b);
623    let norm_a = dot_f32(a, a).sqrt();
624    let norm_b = dot_f32(b, b).sqrt();
625    if norm_a == 0.0 || norm_b == 0.0 {
626        return 0.0;
627    }
628    dot / (norm_a * norm_b)
629}
630
631#[cfg(feature = "embeddings")]
632static SHARED_ENGINE: std::sync::OnceLock<anyhow::Result<EmbeddingEngine>> =
633    std::sync::OnceLock::new();
634
635/// Global singleton embedding engine. Loaded once, shared across all consumers.
636/// Returns None if the embeddings feature is disabled or the model fails to load.
637/// NOTE: This function BLOCKS on first call while loading the ONNX model.
638/// For non-blocking access, use `try_shared_engine()` instead.
639#[cfg(feature = "embeddings")]
640pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
641    SHARED_ENGINE
642        .get_or_init(EmbeddingEngine::load_default)
643        .as_ref()
644        .ok()
645}
646
647/// Non-blocking variant: returns the engine ONLY if already loaded.
648/// Never triggers model loading or download. Safe to call on hot paths.
649#[cfg(feature = "embeddings")]
650pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
651    SHARED_ENGINE.get()?.as_ref().ok()
652}
653
654/// Whether this process may load the ONNX model on a **detached background
655/// thread** (#519).
656///
657/// ONNX Runtime registers its op schemas in global C++ static state while a
658/// model is loading. If a detached loader thread is still mid-load when the
659/// process returns from `main`, it races `libonnxruntime`'s static-destructor
660/// teardown — a use-after-free SIGSEGV inside `onnx::OpSchema` on an ORT worker
661/// thread. The shipped `lean-ctx` daemon/MCP server is long-lived, so its
662/// warmup always finishes well before exit; short-lived processes (`cargo test`/
663/// bench/doctest binaries, build-time generators) can exit mid-load, so we
664/// refuse the background spawn for them. Their semantic features simply stay
665/// cold — blocking, on-thread loads still work and always complete before exit,
666/// which is race-free.
667///
668/// Note: blocking [`shared_engine`] loads are intentionally NOT gated — they
669/// finish on the caller's thread before the process exits, so no worker is ever
670/// active during teardown.
671#[cfg(feature = "embeddings")]
672pub fn background_load_allowed() -> bool {
673    // Unit tests compile with cfg(test): a cheap, unambiguous short-circuit.
674    // Integration/bench/doctest binaries link the lib in its normal config
675    // (cfg(test) is false), so they are caught by the executable-path probe.
676    if cfg!(test) {
677        return false;
678    }
679    !current_exe_is_test_artifact()
680}
681
682/// `true` when the running executable is a Cargo test/bench/doctest artifact.
683#[cfg(feature = "embeddings")]
684fn current_exe_is_test_artifact() -> bool {
685    std::env::current_exe()
686        .ok()
687        .as_deref()
688        .is_some_and(exe_path_is_test_artifact)
689}
690
691/// Pure predicate (unit-testable): Cargo places test, bench and doctest binaries
692/// directly inside `…/target/<profile>/deps/`. The shipped binary lives in a
693/// package `bin` directory (`/usr/bin`, `~/.local/bin`, `…/Homebrew/bin`, …) and
694/// is never a direct child of a `deps/` directory, so the parent-dir name is an
695/// install-location-independent signal (survives renames of the binary). (#519)
696#[cfg(feature = "embeddings")]
697fn exe_path_is_test_artifact(path: &Path) -> bool {
698    path.parent()
699        .and_then(Path::file_name)
700        .and_then(|name| name.to_str())
701        .is_some_and(|name| name == "deps")
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn cosine_similarity_identical() {
710        let a = vec![1.0, 0.0, 0.0];
711        let b = vec![1.0, 0.0, 0.0];
712        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 1e-6);
713    }
714
715    #[test]
716    fn cosine_similarity_orthogonal() {
717        let a = vec![1.0, 0.0, 0.0];
718        let b = vec![0.0, 1.0, 0.0];
719        assert!(cosine_similarity(&a, &b).abs() < 1e-6);
720    }
721
722    #[test]
723    fn cosine_similarity_opposite() {
724        let a = vec![1.0, 0.0, 0.0];
725        let b = vec![-1.0, 0.0, 0.0];
726        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
727    }
728
729    #[test]
730    fn cosine_similarity_raw_unnormalized() {
731        let a = vec![3.0, 4.0];
732        let b = vec![3.0, 4.0];
733        assert!((cosine_similarity_raw(&a, &b) - 1.0).abs() < 1e-6);
734    }
735
736    #[test]
737    fn cosine_similarity_raw_zero_vector() {
738        let a = vec![0.0, 0.0];
739        let b = vec![1.0, 2.0];
740        assert_eq!(cosine_similarity_raw(&a, &b), 0.0);
741    }
742
743    #[test]
744    fn model_directory_env_override_and_availability() {
745        let unique = "/tmp/lean_ctx_test_embed_42xyz";
746        crate::test_env::set_var("LEAN_CTX_MODELS_DIR", unique);
747        let dir = EmbeddingEngine::model_directory();
748        assert_eq!(dir.to_string_lossy(), unique);
749        assert!(!EmbeddingEngine::is_available());
750        crate::test_env::remove_var("LEAN_CTX_MODELS_DIR");
751    }
752
753    /// #519: Cargo test/bench/doctest binaries live under `…/deps/`; the shipped
754    /// binary lives in a `bin` directory. The parent-dir probe must distinguish
755    /// them so background ORT loads are refused only in short-lived processes.
756    #[test]
757    #[cfg(feature = "embeddings")]
758    fn exe_path_test_artifact_detection() {
759        // Cargo test/bench/doctest artifacts: parent dir is `deps`.
760        assert!(exe_path_is_test_artifact(Path::new(
761            "/repo/rust/target/debug/deps/conformance_suite-0a1b2c3d4e5f6789"
762        )));
763        assert!(exe_path_is_test_artifact(Path::new(
764            "/repo/rust/target/release/deps/lean_ctx-deadbeefcafef00d"
765        )));
766        // Shipped/installed binary: never a direct child of `deps`.
767        assert!(!exe_path_is_test_artifact(Path::new(
768            "/usr/local/bin/lean-ctx"
769        )));
770        assert!(!exe_path_is_test_artifact(Path::new(
771            "/Users/x/.local/bin/lean-ctx"
772        )));
773        // A renamed shipped binary is still allowed (rename-independent signal).
774        assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
775        // The plain target dir build (e.g. `target/debug/lean-ctx`) is not under
776        // `deps/` — treated as a product binary, not a test artifact.
777        assert!(!exe_path_is_test_artifact(Path::new(
778            "/repo/rust/target/debug/lean-ctx"
779        )));
780    }
781
782    /// In the unit-test binary `background_load_allowed` must be false via the
783    /// `cfg!(test)` short-circuit — no detached ORT load may ever be spawned
784    /// from a test process (the #519 teardown race).
785    #[test]
786    #[cfg(feature = "embeddings")]
787    fn background_load_disallowed_in_tests() {
788        assert!(!background_load_allowed());
789    }
790
791    /// GL #452: the EmbeddingBag detection is purely name-based — exactly two
792    /// inputs with the second named `offsets`. Everything else (classic 2-/
793    /// 3-input transformers, unnamed graphs) must stay on the transformer path.
794    #[test]
795    #[cfg(feature = "embeddings")]
796    fn embedding_bag_signature_detection() {
797        // model2vec / potion export.
798        assert!(is_embedding_bag_signature(&[
799            "input_ids".to_string(),
800            "offsets".to_string()
801        ]));
802        // Transformers: mask second, optional token types third.
803        assert!(!is_embedding_bag_signature(&[
804            "input_ids".to_string(),
805            "attention_mask".to_string()
806        ]));
807        assert!(!is_embedding_bag_signature(&[
808            "input_ids".to_string(),
809            "attention_mask".to_string(),
810            "token_type_ids".to_string()
811        ]));
812        // Wrong arity never flips the topology.
813        assert!(!is_embedding_bag_signature(&["input_ids".to_string()]));
814        assert!(!is_embedding_bag_signature(&[
815            "input_ids".to_string(),
816            "offsets".to_string(),
817            "extra".to_string()
818        ]));
819    }
820
821    // NOTE: `try_shared_engine_returns_none_when_not_initialized` lives in
822    // `tests/embeddings_shared_engine.rs` (own process). SHARED_ENGINE is a
823    // process-wide OnceLock: in the unit-test suite any sibling test that
824    // legitimately loads the engine (or #551 background activation) would
825    // initialize it first and make the assertion order-dependent/flaky.
826
827    /// Live proof for GL #397: loads a real HuggingFace repo through the
828    /// `hf:org/repo@rev` scheme (download → SHA-256 lockfile → tokenizer.json →
829    /// ONNX inference → dimension probe). Ignored by default (network + ~91MB);
830    /// run explicitly:
831    /// `cargo test --lib --features embeddings -- --ignored custom_hf_model_end_to_end`
832    #[test]
833    #[ignore = "downloads a real model from HuggingFace (~91MB)"]
834    #[cfg(feature = "embeddings")]
835    fn custom_hf_model_end_to_end() {
836        let model = model_registry::EmbeddingModel::from_str_name(
837            "hf:sentence-transformers/all-MiniLM-L6-v2@main",
838        )
839        .expect("valid hf: spec");
840
841        let base = std::env::temp_dir().join("lean_ctx_test_custom_hf_e2e");
842        let engine = EmbeddingEngine::load_model(&base, model.clone()).expect("load custom model");
843
844        assert_eq!(engine.dimensions(), 384, "probed dims from ONNX graph");
845        assert_eq!(
846            engine.model_name(),
847            "hf:sentence-transformers/all-MiniLM-L6-v2@main"
848        );
849
850        let v = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
851        assert_eq!(v.len(), 384);
852        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
853        assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
854
855        // Lockfile must exist and pin both artifacts.
856        let lock_path = base.join(model.storage_dir_name()).join("model.lock.json");
857        let lock: std::collections::BTreeMap<String, String> =
858            serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
859        assert!(lock.contains_key("model.onnx"));
860        assert!(lock.contains_key("tokenizer.json"));
861
862        // Semantic sanity: similar code closer than unrelated text.
863        let a = engine.embed("read a file from disk").unwrap();
864        let b = engine.embed("load file contents from filesystem").unwrap();
865        let c = engine.embed("the weather in Zurich is sunny").unwrap();
866        assert!(
867            cosine_similarity(&a, &b) > cosine_similarity(&a, &c),
868            "related texts must be closer"
869        );
870    }
871
872    /// Live proof for GL #452: a model2vec EmbeddingBag graph end-to-end
873    /// through the same `hf:` scheme (potion-base-8M, ~30MB). Ignored by
874    /// default (network); run explicitly:
875    /// `cargo test --lib --features embeddings -- --ignored model2vec_potion_end_to_end`
876    #[test]
877    #[ignore = "downloads a real model from HuggingFace (~30MB)"]
878    #[cfg(feature = "embeddings")]
879    fn model2vec_potion_end_to_end() {
880        let model =
881            model_registry::EmbeddingModel::from_str_name("hf:minishlab/potion-base-8M@main")
882                .expect("valid hf: spec");
883
884        let base = std::env::temp_dir().join("lean_ctx_test_model2vec_e2e");
885        let engine =
886            EmbeddingEngine::load_model(&base, model.clone()).expect("load model2vec model");
887
888        // potion-base-8M is 256d; the probe must read it off the rank-2
889        // output, not assume a [1, seq, dim] transformer shape.
890        assert_eq!(engine.dimensions(), 256, "probed dims from EmbeddingBag");
891
892        let code_vec = engine.embed("fn main() { println!(\"hello\"); }").unwrap();
893        assert_eq!(code_vec.len(), 256);
894        let norm: f32 = code_vec.iter().map(|x| x * x).sum::<f32>().sqrt();
895        assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
896
897        // Distinct inputs must not collapse to one vector.
898        let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
899        assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
900
901        // Semantic sanity survives the static-embedding quality trade-off.
902        let read_vec = engine.embed("read a file from disk").unwrap();
903        let load_vec = engine.embed("load file contents from filesystem").unwrap();
904        let weather_vec = engine.embed("the weather in Zurich is sunny").unwrap();
905        assert!(
906            cosine_similarity(&read_vec, &load_vec) > cosine_similarity(&read_vec, &weather_vec),
907            "related texts must be closer"
908        );
909
910        // Empty input: zero vector, no inference panic.
911        let empty_vec = engine.embed("").unwrap();
912        assert_eq!(empty_vec.len(), 256);
913    }
914}