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