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