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