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// `embed_batch` parallelises tokenization in every build (rayon is a core, non
24// optional dependency), so this import must not be feature-gated — otherwise the
25// no-embeddings build (e.g. FreeBSD, #586) loses `par_iter`.
26use 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
44/// Abstraction over different tokenizer backends.
45enum TokenizerKind {
46    WordPiece(WordPieceTokenizer),
47    HfTokenizer(tokenizer::HfTokenizerWrapper),
48}
49
50/// The two ONNX graph topologies we can drive (GL #452).
51///
52/// Transformers take `[1, seq]` id/mask tensors and emit per-token hidden
53/// states `[1, seq, dim]` that we mean-pool. model2vec exports are
54/// EmbeddingBag graphs: flat `input_ids: [n_tokens]` plus `offsets: [batch]`,
55/// already pooled to `[batch, dim]` — ~500x faster, no attention pass.
56#[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/// Classify the graph topology from its input names (pure, unit-testable).
70/// The model2vec signature is exactly two inputs whose second is `offsets`;
71/// everything else is treated as a transformer.
72#[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    /// Load embedding model and vocabulary from a directory.
79    /// Downloads model automatically from HuggingFace if not present.
80    #[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    /// Load a specific embedding model from a directory.
87    #[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        // Deterministic mode (#895): single CPU thread + no GPU EP makes the
98        // model's output bit-identical across machines (reproducible embeddings),
99        // at the cost of throughput. Off by default — the normal path keeps the
100        // multi-threaded, GPU-capable providers. Extractive ranking stays
101        // deterministic regardless via score quantization; this is extra hardening.
102        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        // Topology detection (GL #452): model2vec EmbeddingBag graphs expose
147        // exactly (input_ids, offsets) — structurally incompatible with the
148        // transformer path, so they get their own input adapter.
149        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    /// Load from default model directory (~/.lean-ctx/models/).
221    pub fn load_default() -> anyhow::Result<Self> {
222        Self::load(&Self::model_directory())
223    }
224
225    /// Generate an embedding vector for a single text (document/code).
226    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    /// Generate an embedding vector for a query string.
239    /// Applies query-specific prefix if the model requires one.
240    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    /// Generate embedding vectors for multiple texts using true batched ONNX
253    /// inference. Sends a single `[batch, max_seq_len]` tensor through the model
254    /// instead of `batch` separate calls — up to 50× faster on CPU for typical
255    /// batch sizes (64–128) by leveraging matrix-matrix instead of matrix-vector
256    /// operations inside the transformer.
257    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        // Tokenize all texts upfront (parallel — wordpiece tokenization is CPU-bound)
275        let tokenized: Vec<TokenizedInput> = prefixed_refs
276            .par_iter()
277            .map(|t| tokenize(&self.tokenizer, t, self.max_seq_len))
278            .collect();
279
280        // Process in mini-batches to cap peak memory
281        // Override via LEAN_CTX_EMBEDDING_BATCH_SIZE env var (e.g. "128").
282        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    /// Resolve the model directory (respects LEAN_CTX_MODELS_DIR env).
308    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    /// Check if the model files are present and loadable.
319    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    /// Ensure the selected model's files exist on disk, downloading them from
329    /// HuggingFace if absent. Pure network/file IO — this never initializes the
330    /// ONNX Runtime, so a failed bootstrap costs nothing in ORT teardown (the
331    /// caller only loads ORT via [`shared_engine`] once the files are present).
332    /// After `Ok`, [`is_available`](Self::is_available) is guaranteed true; on a
333    /// warm cache it short-circuits without touching the network.
334    ///
335    /// The explicit index-build path uses this to cold-start a fresh machine.
336    /// `is_available()` alone is only a file check, so the build path must call
337    /// this to actually trigger the download instead of bailing (#545).
338    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    /// Run batched inference over multiple tokenized inputs.
414    ///
415    /// For the Transformer topology: pads all inputs to `max_seq_len` of the
416    /// batch, creates a `[batch, max_seq_len]` tensor, runs ONNX once, then
417    /// mean-pools and L2-normalizes each sequence individually.
418    ///
419    /// For the EmbeddingBag topology: concatenates tokens with per-row offsets,
420    /// runs ONNX once, and L2-normalizes each output row.
421    #[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
548/// Load the appropriate tokenizer for the model config.
549fn 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
570/// Tokenize text using whatever tokenizer backend is loaded.
571fn 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/// Detect embedding dimensions by running a dummy inference.
579#[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        // Shape is [batch=1, seq_len, dim].
645        GraphInputs::Transformer { .. } => shape.last().copied().map(|s| s as usize),
646        // Already pooled: [batch=1, dim] — the last axis IS the dim, but be
647        // explicit about the rank so a surprising graph fails loudly into
648        // the config fallback instead of mis-probing.
649        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
659/// Compute cosine similarity between two L2-normalized vectors.
660/// Both vectors must be pre-normalized for correct results.
661///
662/// Uses the chunked, autovectorizable dot product from [`crate::core::embedding_quant`]
663/// (turbovec-derived) so every semantic-search hot path gets SIMD throughput.
664pub 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
669/// Compute cosine similarity without requiring pre-normalization.
670pub 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/// Global singleton embedding engine. Loaded once, shared across all consumers.
687/// Returns None if the embeddings feature is disabled or the model fails to load.
688/// NOTE: This function BLOCKS on first call while loading the ONNX model.
689/// For non-blocking access, use `try_shared_engine()` instead.
690#[cfg(feature = "embeddings")]
691pub fn shared_engine() -> Option<&'static EmbeddingEngine> {
692    shared_engine_result().ok()
693}
694
695/// Global singleton embedding engine with the load error preserved for callers
696/// that can surface diagnostics to users.
697#[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/// Non-blocking variant: returns the engine ONLY if already loaded.
706/// Never triggers model loading or download. Safe to call on hot paths.
707#[cfg(feature = "embeddings")]
708pub fn try_shared_engine() -> Option<&'static EmbeddingEngine> {
709    SHARED_ENGINE.get()?.as_ref().ok()
710}
711
712/// Resolve the deterministic-inference policy (#895): the
713/// `LEAN_CTX_EMBEDDING_DETERMINISTIC` env var wins in either direction,
714/// otherwise `[embedding].deterministic` from config, otherwise `false`.
715#[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/// Whether this process may load the ONNX model on a **detached background
730/// thread** (#519).
731///
732/// ONNX Runtime registers its op schemas in global C++ static state while a
733/// model is loading. If a detached loader thread is still mid-load when the
734/// process returns from `main`, it races `libonnxruntime`'s static-destructor
735/// teardown — a use-after-free SIGSEGV inside `onnx::OpSchema` on an ORT worker
736/// thread. The shipped `lean-ctx` daemon/MCP server is long-lived, so its
737/// warmup always finishes well before exit; short-lived processes (`cargo test`/
738/// bench/doctest binaries, build-time generators) can exit mid-load, so we
739/// refuse the background spawn for them. Their semantic features simply stay
740/// cold — blocking, on-thread loads still work and always complete before exit,
741/// which is race-free.
742///
743/// Note: blocking [`shared_engine`] loads are intentionally NOT gated — they
744/// finish on the caller's thread before the process exits, so no worker is ever
745/// active during teardown.
746#[cfg(feature = "embeddings")]
747pub fn background_load_allowed() -> bool {
748    // Unit tests compile with cfg(test): a cheap, unambiguous short-circuit.
749    // Integration/bench/doctest binaries link the lib in its normal config
750    // (cfg(test) is false), so they are caught by the executable-path probe.
751    if cfg!(test) {
752        return false;
753    }
754    !current_exe_is_test_artifact()
755}
756
757/// `true` when the running executable is a Cargo test/bench/doctest artifact.
758#[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/// Pure predicate (unit-testable): Cargo places test, bench and doctest binaries
767/// directly inside `…/target/<profile>/deps/`. The shipped binary lives in a
768/// package `bin` directory (`/usr/bin`, `~/.local/bin`, `…/Homebrew/bin`, …) and
769/// is never a direct child of a `deps/` directory, so the parent-dir name is an
770/// install-location-independent signal (survives renames of the binary). (#519)
771#[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        // Serialize env mutation: LEAN_CTX_MODELS_DIR is process-global and
821        // shared with `ensure_downloaded_is_offline_noop_when_model_present`.
822        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    /// #545: with a complete model cache, `ensure_downloaded` is a no-op that
832    /// never touches the network and agrees with `is_available` on the model
833    /// directory. The build path relies on this so a cold availability check can
834    /// route through the (real) download without redundantly re-fetching a model
835    /// that is already present. Both gates use plain existence checks, so tiny
836    /// placeholder files are enough to exercise the short-circuit offline.
837    #[test]
838    fn ensure_downloaded_is_offline_noop_when_model_present() {
839        // Serialize env mutation (LEAN_CTX_MODELS_DIR is process-global) so a
840        // sibling env test can't clobber the dir between set_var and the checks.
841        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    /// #519: Cargo test/bench/doctest binaries live under `…/deps/`; the shipped
860    /// binary lives in a `bin` directory. The parent-dir probe must distinguish
861    /// them so background ORT loads are refused only in short-lived processes.
862    #[test]
863    #[cfg(feature = "embeddings")]
864    fn exe_path_test_artifact_detection() {
865        // Cargo test/bench/doctest artifacts: parent dir is `deps`.
866        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        // Shipped/installed binary: never a direct child of `deps`.
873        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        // A renamed shipped binary is still allowed (rename-independent signal).
880        assert!(!exe_path_is_test_artifact(Path::new("/opt/tools/ctx")));
881        // The plain target dir build (e.g. `target/debug/lean-ctx`) is not under
882        // `deps/` — treated as a product binary, not a test artifact.
883        assert!(!exe_path_is_test_artifact(Path::new(
884            "/repo/rust/target/debug/lean-ctx"
885        )));
886    }
887
888    /// In the unit-test binary `background_load_allowed` must be false via the
889    /// `cfg!(test)` short-circuit — no detached ORT load may ever be spawned
890    /// from a test process (the #519 teardown race).
891    #[test]
892    #[cfg(feature = "embeddings")]
893    fn background_load_disallowed_in_tests() {
894        assert!(!background_load_allowed());
895    }
896
897    /// GL #452: the EmbeddingBag detection is purely name-based — exactly two
898    /// inputs with the second named `offsets`. Everything else (classic 2-/
899    /// 3-input transformers, unnamed graphs) must stay on the transformer path.
900    #[test]
901    #[cfg(feature = "embeddings")]
902    fn embedding_bag_signature_detection() {
903        // model2vec / potion export.
904        assert!(is_embedding_bag_signature(&[
905            "input_ids".to_string(),
906            "offsets".to_string()
907        ]));
908        // Transformers: mask second, optional token types third.
909        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        // Wrong arity never flips the topology.
919        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    // NOTE: `try_shared_engine_returns_none_when_not_initialized` lives in
928    // `tests/embeddings_shared_engine.rs` (own process). SHARED_ENGINE is a
929    // process-wide OnceLock: in the unit-test suite any sibling test that
930    // legitimately loads the engine (or #551 background activation) would
931    // initialize it first and make the assertion order-dependent/flaky.
932
933    /// Live proof for GL #397: loads a real HuggingFace repo through the
934    /// `hf:org/repo@rev` scheme (download → SHA-256 lockfile → tokenizer.json →
935    /// ONNX inference → dimension probe). Ignored by default (network + ~91MB);
936    /// run explicitly:
937    /// `cargo test --lib --features embeddings -- --ignored custom_hf_model_end_to_end`
938    #[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        // Lockfile must exist and pin both artifacts.
962        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        // Semantic sanity: similar code closer than unrelated text.
969        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    /// Live proof for GL #452: a model2vec EmbeddingBag graph end-to-end
979    /// through the same `hf:` scheme (potion-base-8M, ~30MB). Ignored by
980    /// default (network); run explicitly:
981    /// `cargo test --lib --features embeddings -- --ignored model2vec_potion_end_to_end`
982    #[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        // potion-base-8M is 256d; the probe must read it off the rank-2
995        // output, not assume a [1, seq, dim] transformer shape.
996        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        // Distinct inputs must not collapse to one vector.
1004        let sql_vec = engine.embed("SELECT * FROM users WHERE id = 1").unwrap();
1005        assert!(cosine_similarity(&code_vec, &sql_vec) < 0.999);
1006
1007        // Semantic sanity survives the static-embedding quality trade-off.
1008        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        // Empty input: zero vector, no inference panic.
1017        let empty_vec = engine.embed("").unwrap();
1018        assert_eq!(empty_vec.len(), 256);
1019    }
1020}