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