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