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