Skip to main content

lean_ctx/core/
embedding_index.rs

1//! Persistent, incremental embedding index.
2//!
3//! Stores pre-computed chunk embeddings alongside file content hashes.
4//! On re-index, only files whose hash has changed get re-embedded,
5//! avoiding expensive model inference for unchanged code.
6//!
7//! Storage format: `~/.lean-ctx/vectors/<project_hash>/embeddings.bin` (postcard)
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use md5::{Digest, Md5};
14use serde::{Deserialize, Serialize};
15
16use super::bm25_index::CodeChunk;
17use super::embedding_quant::{self, QuantizedVector};
18use super::hnsw::FlatEmbeddings;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct EmbeddingIndex {
22    pub version: u32,
23    pub dimensions: usize,
24    /// Model identifier that generated these embeddings.
25    /// Used for mismatch detection when the user switches models.
26    #[serde(default)]
27    pub model_id: Option<String>,
28    pub entries: Vec<EmbeddingEntry>,
29    pub file_hashes: HashMap<String, String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EmbeddingEntry {
34    pub file_path: String,
35    pub symbol_name: String,
36    pub start_line: usize,
37    pub end_line: usize,
38    /// int8-quantized embedding (turbovec-derived) — 4× smaller on disk.
39    pub quant: QuantizedVector,
40    pub content_hash: String,
41}
42
43impl EmbeddingEntry {
44    /// Write the dequantized embedding directly into `dest`, avoiding
45    /// intermediate `Vec<f32>` allocation.
46    fn write_into_flat(&self, dest: &mut Vec<f32>) {
47        let q = &self.quant;
48        let scale = q.scale;
49        if scale == 0.0 {
50            dest.resize(dest.len() + q.code.len(), 0.0);
51        } else {
52            for &c in &q.code {
53                dest.push(f32::from(c) * scale);
54            }
55        }
56    }
57}
58
59/// Outcome of a `build_or_update` call from the index orchestrator.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum EmbeddingBuildOutcome {
62    /// Embeddings were built or already up-to-date.
63    Ready,
64    /// Skipped because the embeddings feature is not enabled or disabled by config.
65    Skipped,
66    /// The embedding engine (ONNX model) is not available.
67    /// Carries the reason so the orchestrator can show a helpful message.
68    ModelNotAvailable(String),
69    /// Build failed with an error.
70    Failed,
71}
72
73impl EmbeddingBuildOutcome {
74    pub fn label(&self) -> &'static str {
75        match self {
76            Self::Ready => "ready",
77            Self::Skipped => "skipped",
78            Self::ModelNotAvailable(_) => "model-not-available",
79            Self::Failed => "failed",
80        }
81    }
82
83    pub fn reason(&self) -> Option<&str> {
84        match self {
85            Self::ModelNotAvailable(r) => Some(r.as_str()),
86            _ => None,
87        }
88    }
89}
90
91/// Build or update the persistent embedding index for a project.
92///
93/// Called by the index orchestrator during `build-full` / `build` after the BM25
94/// index is ready.  This is a *background-friendly* operation: it runs the ONNX
95/// model incrementally (only chunks whose content hash changed) and persists
96/// `embeddings.bin` (postcard) so subsequent `ctx_semantic_search` calls find a warm cache.
97///
98/// Returns [`EmbeddingBuildOutcome`] — the orchestrator uses this to set the
99/// semantic component state without aborting the overall index build.
100///
101/// Feature-gated: when `embeddings` is not compiled in, this is a no-op that
102/// returns `Skipped`.
103pub fn build_or_update(root: &Path, bm25: &super::bm25_index::BM25Index) -> EmbeddingBuildOutcome {
104    #[cfg(feature = "embeddings")]
105    {
106        // Respect the config gates so the orchestrator does not force-embed when
107        // the user explicitly opted out.
108        let cfg = crate::core::config::Config::load();
109        if !cfg.search.dense_enabled {
110            tracing::info!("[embedding_index] build_or_update skipped: search.dense_enabled=false");
111            return EmbeddingBuildOutcome::Skipped;
112        }
113        let profile = crate::core::config::MemoryProfile::effective(&cfg);
114        if !profile.embeddings_enabled() {
115            tracing::info!(
116                "[embedding_index] build_or_update skipped: memory_profile disables embeddings"
117            );
118            return EmbeddingBuildOutcome::Skipped;
119        }
120
121        // Bootstrap the model if it isn't on disk yet. `build_or_update` only
122        // runs for an explicit build request (`index build` / `build-full` /
123        // `build-semantic`), so a cold machine should download the model now
124        // rather than dead-end. `is_available()` is just a file check, so the
125        // earlier short-circuit on it meant the auto-download never started
126        // (#545). `ensure_downloaded()` is pure network/file IO and never
127        // initializes ORT, so the teardown-safety rationale for deferring the
128        // `shared_engine()` load still holds: on download failure we return
129        // without ever having touched the ONNX Runtime.
130        if !crate::core::embeddings::EmbeddingEngine::is_available() {
131            tracing::info!(
132                "[embedding_index] embedding model absent — downloading from HuggingFace"
133            );
134            if let Err(e) = crate::core::embeddings::EmbeddingEngine::ensure_downloaded() {
135                let reason = format!("embedding model auto-download from HuggingFace failed: {e}");
136                tracing::warn!("[embedding_index] build_or_update failed: {reason}");
137                return EmbeddingBuildOutcome::ModelNotAvailable(reason);
138            }
139        }
140
141        let engine = match crate::core::embeddings::shared_engine_result() {
142            Ok(engine) => engine,
143            Err(e) => {
144                let reason = format!("embedding model files found but engine failed to load: {e}");
145                tracing::warn!("[embedding_index] build_or_update failed: {reason}");
146                return EmbeddingBuildOutcome::ModelNotAvailable(reason);
147            }
148        };
149
150        let model_name = engine.model_name();
151        let mut idx = EmbeddingIndex::load(root)
152            .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
153
154        // Detect model / dimension changes → rebuild from scratch.
155        if let Some((stored, current)) = idx.model_mismatch(model_name) {
156            tracing::info!(
157                "[embedding_index] model changed: {stored} → {current}. Re-building from scratch."
158            );
159            idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
160        } else if idx.dimension_mismatch(engine.dimensions()) {
161            tracing::info!(
162                "[embedding_index] dimension mismatch: index={}d, engine={}d. Re-building.",
163                idx.dimensions,
164                engine.dimensions()
165            );
166            idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
167        }
168
169        let mut changed_files = idx.files_needing_update(&bm25.chunks);
170        changed_files.sort();
171        changed_files.dedup();
172
173        if changed_files.is_empty() {
174            tracing::info!(
175                "[embedding_index] all {} chunks up-to-date, nothing to embed",
176                bm25.chunks.len()
177            );
178            return EmbeddingBuildOutcome::Ready;
179        }
180
181        let changed_set: std::collections::HashSet<&str> =
182            changed_files.iter().map(String::as_str).collect();
183        let mut changed_indices: Vec<usize> = Vec::new();
184        let mut changed_texts: Vec<&str> = Vec::new();
185        for (i, c) in bm25.chunks.iter().enumerate() {
186            if changed_set.contains(c.file_path.as_str()) {
187                changed_indices.push(i);
188                changed_texts.push(&c.content);
189            }
190        }
191
192        let count = changed_files.len();
193        tracing::info!(
194            "[embedding_index] embedding {count} changed files ({total} chunks in index)",
195            total = bm25.chunks.len()
196        );
197
198        let batch_embeddings = match engine.embed_batch(&changed_texts) {
199            Ok(v) => v,
200            Err(e) => {
201                tracing::error!("[embedding_index] batch embed failed: {e}");
202                return EmbeddingBuildOutcome::Failed;
203            }
204        };
205
206        let new_embeddings: Vec<(usize, Vec<f32>)> =
207            changed_indices.into_iter().zip(batch_embeddings).collect();
208
209        idx.update(&bm25.chunks, &new_embeddings, &changed_files, None);
210
211        if let Err(e) = idx.save(root) {
212            tracing::error!("[embedding_index] save failed: {e}");
213            return EmbeddingBuildOutcome::Failed;
214        }
215
216        tracing::info!(
217            "[embedding_index] successfully persisted {count} file embeddings ({total} chunks)",
218            total = bm25.chunks.len()
219        );
220        EmbeddingBuildOutcome::Ready
221    }
222
223    #[cfg(not(feature = "embeddings"))]
224    {
225        let _ = (root, bm25);
226        EmbeddingBuildOutcome::Skipped
227    }
228}
229
230/// Current on-disk format version. Used for forward-compatibility checks.
231const CURRENT_VERSION: u32 = 3;
232
233impl EmbeddingIndex {
234    pub fn new(dimensions: usize) -> Self {
235        Self {
236            version: CURRENT_VERSION,
237            dimensions,
238            model_id: None,
239            entries: Vec::new(),
240            file_hashes: HashMap::new(),
241        }
242    }
243
244    /// Create a new index tagged with a specific model identity.
245    pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
246        Self {
247            version: CURRENT_VERSION,
248            dimensions,
249            model_id: Some(model_id.to_string()),
250            entries: Vec::new(),
251            file_hashes: HashMap::new(),
252        }
253    }
254
255    /// Check if the index was built with a different model than currently selected.
256    /// Returns `Some((stored_model, current_model))` on mismatch, `None` if compatible.
257    pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
258        match &self.model_id {
259            Some(stored) if stored != current_model => Some((stored, current_model)),
260            _ => None,
261        }
262    }
263
264    /// Check if index dimensions are incompatible with the current engine.
265    pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
266        self.dimensions != engine_dimensions && !self.entries.is_empty()
267    }
268
269    /// Approximate heap memory used by this index in bytes.
270    pub fn memory_usage_bytes(&self) -> usize {
271        let entries_size: usize = self
272            .entries
273            .iter()
274            .map(|e| {
275                e.file_path.len()
276                    + e.symbol_name.len()
277                    + e.content_hash.len()
278                    + e.quant.code.len()
279                    + 4
280                    + 48
281            })
282            .sum();
283        let hashes_size: usize = self
284            .file_hashes
285            .iter()
286            .map(|(k, v)| k.len() + v.len() + 32)
287            .sum();
288        entries_size + hashes_size
289    }
290
291    /// Drops all in-memory data to free heap. Index can be re-loaded from disk.
292    pub fn unload(&mut self) {
293        let usage = self.memory_usage_bytes();
294        self.entries = Vec::new();
295        self.file_hashes = HashMap::new();
296        tracing::info!(
297            "[embeddings] unloaded index, freed ~{:.1}MB",
298            usage as f64 / 1_048_576.0
299        );
300    }
301
302    /// Load a previously saved index, or create a new empty one.
303    pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
304        Self::load(root).unwrap_or_else(|| Self::new(dimensions))
305    }
306
307    /// Determine which files need re-embedding based on content hashes.
308    ///
309    /// When the index is empty (no prior embeddings), skips hash computation
310    /// entirely by returning all unique file paths from chunks directly.
311    pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
312        // Empty index: every file needs embedding — skip O(chunks) hash iteration.
313        if self.file_hashes.is_empty() {
314            let mut files: Vec<String> = chunks.iter().map(|c| c.file_path.clone()).collect();
315            files.sort();
316            files.dedup();
317            return files;
318        }
319
320        let current_hashes = compute_file_hashes(chunks);
321
322        let mut needs_update = Vec::new();
323        for (file, hash) in &current_hashes {
324            match self.file_hashes.get(file) {
325                Some(old_hash) if old_hash == hash => {}
326                _ => needs_update.push(file.clone()),
327            }
328        }
329
330        for file in self.file_hashes.keys() {
331            if !current_hashes.contains_key(file) {
332                needs_update.push(file.clone());
333            }
334        }
335
336        needs_update
337    }
338
339    /// Number of `chunks` a re-embed pass would have to embed right now — i.e.
340    /// the chunks belonging to files flagged by [`Self::files_needing_update`].
341    ///
342    /// Used by the hybrid/dense cold-start guard (#512): on a server that came
343    /// up before the on-disk index existed, the first query would otherwise embed
344    /// the *entire* corpus inline under the request watchdog, producing a runaway
345    /// the watchdog abandons but cannot cancel. Counting the pending chunks up
346    /// front lets the caller fall back instead of starting that embed.
347    pub fn pending_chunk_count(&self, chunks: &[CodeChunk]) -> usize {
348        let changed = self.files_needing_update(chunks);
349        if changed.is_empty() {
350            return 0;
351        }
352        let changed: std::collections::HashSet<&str> = changed.iter().map(String::as_str).collect();
353        chunks
354            .iter()
355            .filter(|c| changed.contains(c.file_path.as_str()))
356            .count()
357    }
358
359    /// Update the index with new embeddings for changed files.
360    /// Preserves existing embeddings for unchanged files.
361    ///
362    /// `precomputed_hashes` can be passed to avoid re-computing file hashes
363    /// when the caller already has them (e.g. from `files_needing_update`).
364    /// When `None`, hashes are computed from `chunks`.
365    pub fn update(
366        &mut self,
367        chunks: &[CodeChunk],
368        new_embeddings: &[(usize, Vec<f32>)],
369        changed_files: &[String],
370        precomputed_hashes: Option<HashMap<String, String>>,
371    ) {
372        self.entries
373            .retain(|e| !changed_files.contains(&e.file_path));
374
375        for file in changed_files {
376            self.file_hashes.remove(file);
377        }
378
379        let current_hashes = precomputed_hashes.unwrap_or_else(|| compute_file_hashes(chunks));
380        for file in changed_files {
381            if let Some(hash) = current_hashes.get(file) {
382                self.file_hashes.insert(file.clone(), hash.clone());
383            }
384        }
385
386        for &(chunk_idx, ref embedding) in new_embeddings {
387            if let Some(chunk) = chunks.get(chunk_idx) {
388                let content_hash = hash_content(&chunk.content);
389                self.entries.push(EmbeddingEntry {
390                    file_path: chunk.file_path.clone(),
391                    symbol_name: chunk.symbol_name.clone(),
392                    start_line: chunk.start_line,
393                    end_line: chunk.end_line,
394                    quant: embedding_quant::quantize(embedding),
395                    content_hash,
396                });
397            }
398        }
399    }
400
401    /// Get all embeddings in chunk order (aligned with BM25Index.chunks) as a
402    /// single contiguous [`FlatEmbeddings`] allocation. Returns None if the index
403    /// doesn't cover all chunks.
404    ///
405    /// The flat layout (_n_vectors × _dim_ in row-major order) gives sequential
406    /// memory access during dot-product scoring — one dereference instead of the
407    /// two-level indirection of `Arc<[Vec<f32>]>`.
408    pub fn get_aligned_flat(&self, chunks: &[CodeChunk]) -> Option<FlatEmbeddings> {
409        let dim = self.dimensions;
410        let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
411            HashMap::with_capacity(self.entries.len());
412        for e in &self.entries {
413            map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
414        }
415
416        let n = chunks.len();
417        let mut data = Vec::with_capacity(n * dim);
418        for chunk in chunks {
419            let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
420            entry.write_into_flat(&mut data);
421        }
422        Some(FlatEmbeddings {
423            data: Arc::from(data),
424            dim,
425        })
426    }
427
428    pub fn coverage(&self, total_chunks: usize) -> f64 {
429        if total_chunks == 0 {
430            return 0.0;
431        }
432        self.entries.len() as f64 / total_chunks as f64
433    }
434
435    pub fn save(&self, root: &Path) -> std::io::Result<()> {
436        let dir = index_dir(root);
437        std::fs::create_dir_all(&dir)?;
438        // Binary (postcard) — compact, fast, deterministic.
439        let data = postcard::to_allocvec(self).map_err(std::io::Error::other)?;
440        std::fs::write(dir.join("embeddings.bin"), data)?;
441        Ok(())
442    }
443
444    pub fn load(root: &Path) -> Option<Self> {
445        let bin_path = index_dir(root).join("embeddings.bin");
446        let data = std::fs::read(&bin_path).ok()?;
447        match postcard::from_bytes::<Self>(&data) {
448            // Only accept an index whose on-disk schema matches the current one.
449            Ok(idx) if idx.version == CURRENT_VERSION => Some(idx),
450            // A structurally-valid but stale schema (older/newer bin layout) must
451            // not be trusted — postcard can silently mis-decode a changed struct.
452            // Drop it and rebuild rather than serving garbage vectors.
453            Ok(idx) => {
454                tracing::warn!(
455                    "[embeddings] index format v{} != current v{CURRENT_VERSION} — \
456                     removing and rebuilding from scratch",
457                    idx.version
458                );
459                let _ = std::fs::remove_file(&bin_path);
460                None
461            }
462            Err(_) => {
463                tracing::warn!(
464                    "[embeddings] corrupt embeddings.bin — removing and will rebuild from scratch"
465                );
466                let _ = std::fs::remove_file(&bin_path);
467                None
468            }
469        }
470    }
471}
472
473fn index_dir(root: &Path) -> PathBuf {
474    crate::core::index_namespace::vectors_dir(root)
475}
476
477fn hash_content(content: &str) -> String {
478    let mut hasher = Md5::new();
479    hasher.update(content.as_bytes());
480    crate::core::agent_identity::hex_encode(&hasher.finalize())
481}
482
483fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
484    let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
485    for chunk in chunks {
486        by_file
487            .entry(chunk.file_path.as_str())
488            .or_default()
489            .push(chunk);
490    }
491
492    let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
493    for (file, mut file_chunks) in by_file {
494        file_chunks.sort_by(|a, b| {
495            (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
496                b.start_line,
497                b.end_line,
498                b.symbol_name.as_str(),
499            ))
500        });
501
502        let mut hasher = Md5::new();
503        hasher.update(file.as_bytes());
504        for c in file_chunks {
505            hasher.update(c.start_line.to_le_bytes());
506            hasher.update(c.end_line.to_le_bytes());
507            hasher.update(c.symbol_name.as_bytes());
508            hasher.update([kind_tag(&c.kind)]);
509            hasher.update(c.content.as_bytes());
510        }
511        out.insert(
512            file.to_string(),
513            crate::core::agent_identity::hex_encode(&hasher.finalize()),
514        );
515    }
516    out
517}
518
519fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
520    use super::bm25_index::ChunkKind;
521    match kind {
522        ChunkKind::Function => 1,
523        ChunkKind::Struct => 2,
524        ChunkKind::Impl => 3,
525        ChunkKind::Module => 4,
526        ChunkKind::Class => 5,
527        ChunkKind::Method => 6,
528        ChunkKind::Other => 7,
529        ChunkKind::Issue => 8,
530        ChunkKind::PullRequest => 9,
531        ChunkKind::WikiPage => 10,
532        ChunkKind::DbSchema => 11,
533        ChunkKind::ApiEndpoint => 12,
534        ChunkKind::Ticket => 13,
535        ChunkKind::ExternalOther => 14,
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::core::bm25_index::{ChunkKind, CodeChunk};
543
544    fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
545        CodeChunk {
546            file_path: file.to_string(),
547            symbol_name: name.to_string(),
548            kind: ChunkKind::Function,
549            start_line: start,
550            end_line: end,
551            content: content.to_string(),
552            tokens: vec![name.to_string()],
553            token_count: 1,
554        }
555    }
556
557    fn dummy_embedding(dim: usize) -> Vec<f32> {
558        vec![0.1; dim]
559    }
560
561    #[test]
562    fn new_index_is_empty() {
563        let idx = EmbeddingIndex::new(384);
564        assert!(idx.entries.is_empty());
565        assert!(idx.file_hashes.is_empty());
566        assert_eq!(idx.dimensions, 384);
567    }
568
569    #[test]
570    fn files_needing_update_all_new() {
571        let idx = EmbeddingIndex::new(384);
572        let chunks = vec![
573            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
574            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
575        ];
576        let needs = idx.files_needing_update(&chunks);
577        assert_eq!(needs.len(), 2);
578    }
579
580    #[test]
581    fn files_needing_update_unchanged() {
582        let mut idx = EmbeddingIndex::new(384);
583        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
584
585        idx.update(
586            &chunks,
587            &[(0, dummy_embedding(384))],
588            &["a.rs".to_string()],
589            None,
590        );
591
592        let needs = idx.files_needing_update(&chunks);
593        assert!(needs.is_empty(), "unchanged file should not need update");
594    }
595
596    #[test]
597    fn files_needing_update_changed_content() {
598        let mut idx = EmbeddingIndex::new(384);
599        let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
600        idx.update(
601            &chunks_v1,
602            &[(0, dummy_embedding(384))],
603            &["a.rs".to_string()],
604            None,
605        );
606
607        let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
608        let needs = idx.files_needing_update(&chunks_v2);
609        assert!(
610            needs.contains(&"a.rs".to_string()),
611            "changed file should need update"
612        );
613    }
614
615    #[test]
616    fn files_needing_update_detects_change_in_later_chunk() {
617        let mut idx = EmbeddingIndex::new(3);
618        let chunks_v1 = vec![
619            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
620            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
621        ];
622        idx.update(
623            &chunks_v1,
624            &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
625            &["a.rs".to_string()],
626            None,
627        );
628
629        let chunks_v2 = vec![
630            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
631            make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
632        ];
633        let needs = idx.files_needing_update(&chunks_v2);
634        assert!(
635            needs.contains(&"a.rs".to_string()),
636            "changing a later chunk should trigger re-embedding"
637        );
638    }
639
640    #[test]
641    fn files_needing_update_deleted_file() {
642        let mut idx = EmbeddingIndex::new(384);
643        let chunks = vec![
644            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
645            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
646        ];
647        idx.update(
648            &chunks,
649            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
650            &["a.rs".to_string(), "b.rs".to_string()],
651            None,
652        );
653
654        let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
655        let needs = idx.files_needing_update(&chunks_after);
656        assert!(
657            needs.contains(&"b.rs".to_string()),
658            "deleted file should trigger update"
659        );
660    }
661
662    #[test]
663    fn pending_chunk_count_cold_start_counts_every_chunk() {
664        let idx = EmbeddingIndex::new(384);
665        let chunks = vec![
666            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
667            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
668            make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
669        ];
670        assert_eq!(
671            idx.pending_chunk_count(&chunks),
672            3,
673            "an empty index must report every chunk as pending (cold start)"
674        );
675    }
676
677    #[test]
678    fn pending_chunk_count_zero_when_fully_embedded() {
679        let mut idx = EmbeddingIndex::new(384);
680        let chunks = vec![
681            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
682            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
683        ];
684        idx.update(
685            &chunks,
686            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
687            &["a.rs".to_string(), "b.rs".to_string()],
688            None,
689        );
690        assert_eq!(
691            idx.pending_chunk_count(&chunks),
692            0,
693            "a fully-embedded index has no pending chunks (warm path stays inline)"
694        );
695    }
696
697    #[test]
698    fn pending_chunk_count_only_counts_changed_files_chunks() {
699        let mut idx = EmbeddingIndex::new(384);
700        let chunks_v1 = vec![
701            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
702            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
703            make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
704        ];
705        idx.update(
706            &chunks_v1,
707            &[
708                (0, dummy_embedding(384)),
709                (1, dummy_embedding(384)),
710                (2, dummy_embedding(384)),
711            ],
712            &["a.rs".to_string(), "b.rs".to_string()],
713            None,
714        );
715
716        // Only b.rs changed → its single chunk is pending, a.rs's two are not.
717        let chunks_v2 = vec![
718            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
719            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
720            make_chunk("b.rs", "fn_c", "fn c() { changed }", 1, 3),
721        ];
722        assert_eq!(
723            idx.pending_chunk_count(&chunks_v2),
724            1,
725            "incremental update must only count the changed file's chunks"
726        );
727    }
728
729    #[test]
730    fn update_preserves_unchanged() {
731        let mut idx = EmbeddingIndex::new(384);
732        let chunks = vec![
733            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
734            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
735        ];
736        idx.update(
737            &chunks,
738            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
739            &["a.rs".to_string(), "b.rs".to_string()],
740            None,
741        );
742        assert_eq!(idx.entries.len(), 2);
743
744        idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()], None);
745        assert_eq!(idx.entries.len(), 2);
746
747        let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
748        let b_embed = b_entry.quant.dequantize();
749        assert!(
750            (b_embed[0] - 0.1).abs() < 1e-6,
751            "b.rs embedding should be preserved"
752        );
753    }
754
755    #[test]
756    fn get_aligned_flat_ok() {
757        let mut idx = EmbeddingIndex::new(2);
758        let chunks = vec![
759            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
760            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
761        ];
762        idx.update(
763            &chunks,
764            &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
765            &["a.rs".to_string(), "b.rs".to_string()],
766            None,
767        );
768
769        let flat = idx.get_aligned_flat(&chunks).unwrap();
770        assert_eq!(flat.n_vectors(), 2);
771        assert_eq!(flat.dim, 2);
772        assert!((flat.get(0)[0] - 1.0).abs() < 1e-6);
773        assert!((flat.get(1)[1] - 1.0).abs() < 1e-6);
774    }
775
776    #[test]
777    fn get_aligned_flat_missing() {
778        let idx = EmbeddingIndex::new(384);
779        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
780        assert!(idx.get_aligned_flat(&chunks).is_none());
781    }
782
783    #[test]
784    fn coverage_calculation() {
785        let mut idx = EmbeddingIndex::new(384);
786        assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
787
788        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
789        idx.update(
790            &chunks,
791            &[(0, dummy_embedding(384))],
792            &["a.rs".to_string()],
793            None,
794        );
795        assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
796        assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
797    }
798
799    #[test]
800    fn save_and_load_roundtrip() {
801        let _lock = crate::core::data_dir::test_env_lock();
802        let data_dir = tempfile::tempdir().unwrap();
803        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
804
805        let project_dir = tempfile::tempdir().unwrap();
806
807        let mut idx = EmbeddingIndex::new(3);
808        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
809        idx.update(
810            &chunks,
811            &[(0, vec![1.0, 2.0, 3.0])],
812            &["a.rs".to_string()],
813            None,
814        );
815        idx.save(project_dir.path()).unwrap();
816
817        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
818        assert_eq!(loaded.dimensions, 3);
819        assert_eq!(loaded.entries.len(), 1);
820        // int8-quantized round-trip: within one quantization step of the original.
821        let recon = loaded.entries[0].quant.dequantize();
822        assert!((recon[0] - 1.0).abs() < 0.02);
823        assert!((recon[1] - 2.0).abs() < 0.02);
824        assert!((recon[2] - 3.0).abs() < 0.02);
825
826        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
827    }
828
829    #[test]
830    fn new_with_model_sets_model_id() {
831        let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
832        assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
833        assert_eq!(idx.dimensions, 768);
834    }
835
836    #[test]
837    fn model_mismatch_detection() {
838        let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
839        assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
840        assert!(idx.model_mismatch("jina-code-v2").is_some());
841
842        let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
843        assert_eq!(stored, "all-MiniLM-L6-v2");
844        assert_eq!(current, "jina-code-v2");
845    }
846
847    #[test]
848    fn model_mismatch_none_when_no_model_id() {
849        let idx = EmbeddingIndex::new(384);
850        assert!(idx.model_mismatch("anything").is_none());
851    }
852
853    #[test]
854    fn dimension_mismatch_detection() {
855        let mut idx = EmbeddingIndex::new(384);
856        assert!(!idx.dimension_mismatch(384));
857        assert!(!idx.dimension_mismatch(768)); // no entries = no mismatch
858
859        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
860        idx.update(
861            &chunks,
862            &[(0, dummy_embedding(384))],
863            &["a.rs".to_string()],
864            None,
865        );
866        assert!(!idx.dimension_mismatch(384));
867        assert!(idx.dimension_mismatch(768));
868    }
869}