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