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}. Check with `lean-ctx embeddings status`"
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            clear_partial_marker(root);
189            return EmbeddingBuildOutcome::Ready;
190        }
191
192        let changed_set: std::collections::HashSet<&str> =
193            changed_files.iter().map(String::as_str).collect();
194        let changed_indices: Vec<usize> = bm25
195            .chunks
196            .iter()
197            .enumerate()
198            .filter(|(_, c)| changed_set.contains(c.file_path.as_str()))
199            .map(|(i, _)| i)
200            .collect();
201
202        let count = changed_files.len();
203        let chunk_total = changed_indices.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        // A file is committed to the index once all of its chunks are embedded.
217        // Files without chunks (deleted since the last build) are ready at once.
218        let file_hashes = compute_file_hashes(&bm25.chunks);
219        let mut remaining: HashMap<&str, usize> = HashMap::new();
220        for &i in &changed_indices {
221            *remaining
222                .entry(bm25.chunks[i].file_path.as_str())
223                .or_default() += 1;
224        }
225        let mut checkpoint = EmbedCheckpoint {
226            pending: Vec::new(),
227            ready_files: changed_files
228                .iter()
229                .filter(|f| !remaining.contains_key(f.as_str()))
230                .cloned()
231                .collect(),
232        };
233
234        // Micro-batch so CLI can show determinate % (single giant embed_batch
235        // would only report 0% → 100%).
236        const EMBED_PROGRESS_BATCH: usize = 32;
237        let mut embedded = 0usize;
238        let mut last_save = std::time::Instant::now();
239        for batch in changed_indices.chunks(EMBED_PROGRESS_BATCH) {
240            let texts: Vec<&str> = batch
241                .iter()
242                .map(|&i| bm25.chunks[i].content.as_str())
243                .collect();
244            match engine.embed_batch(&texts) {
245                Ok(vectors) => {
246                    embedded += batch.len();
247                    for (&i, vector) in batch.iter().zip(vectors) {
248                        let file = bm25.chunks[i].file_path.as_str();
249                        checkpoint.pending.push((i, vector));
250                        if let Some(left) = remaining.get_mut(file) {
251                            *left -= 1;
252                            if *left == 0 {
253                                checkpoint.ready_files.push(file.to_string());
254                            }
255                        }
256                    }
257                    crate::core::index_progress::report(
258                        &root_key,
259                        crate::core::index_progress::IndexComponent::Semantic,
260                        embedded as u64,
261                        chunk_total,
262                    );
263                }
264                Err(e) => {
265                    tracing::error!("[embedding_index] batch embed failed: {e}");
266                    // Keep what is done: the next build resumes from here.
267                    checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes);
268                    if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
269                        tracing::warn!("[embedding_index] checkpoint save failed: {e}");
270                    }
271                    return EmbeddingBuildOutcome::Failed;
272                }
273            }
274            // Periodic checkpoint: an interrupted build (CLI exit, editor
275            // restart, memory abort) must not throw away hours of CPU work.
276            if last_save.elapsed() >= EMBED_CHECKPOINT_INTERVAL
277                && checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes)
278            {
279                if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
280                    tracing::warn!("[embedding_index] checkpoint save failed: {e}");
281                }
282                last_save = std::time::Instant::now();
283            }
284        }
285
286        checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes);
287
288        if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
289            tracing::error!("[embedding_index] save failed: {e}");
290            return EmbeddingBuildOutcome::Failed;
291        }
292
293        tracing::info!(
294            "[embedding_index] successfully persisted {count} file embeddings ({total} chunks)",
295            total = bm25.chunks.len()
296        );
297        EmbeddingBuildOutcome::Ready
298    }
299
300    #[cfg(not(feature = "embeddings"))]
301    {
302        let _ = (root, bm25);
303        EmbeddingBuildOutcome::Skipped
304    }
305}
306
307/// Minimum time between checkpoint saves during a build.
308#[cfg(feature = "embeddings")]
309const EMBED_CHECKPOINT_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
310
311/// Embeddings computed but not yet merged into the index. Only whole files are
312/// merged, so a persisted checkpoint never records a hash for a half-embedded
313/// file and the next build re-embeds exactly the unfinished ones.
314#[cfg(feature = "embeddings")]
315struct EmbedCheckpoint {
316    pending: Vec<(usize, Vec<f32>)>,
317    ready_files: Vec<String>,
318}
319
320#[cfg(feature = "embeddings")]
321impl EmbedCheckpoint {
322    /// Merge every fully embedded file into `idx`. Returns whether anything changed.
323    fn commit(
324        &mut self,
325        idx: &mut EmbeddingIndex,
326        chunks: &[CodeChunk],
327        file_hashes: &HashMap<String, String>,
328    ) -> bool {
329        if self.ready_files.is_empty() {
330            return false;
331        }
332        let ready: std::collections::HashSet<&str> =
333            self.ready_files.iter().map(String::as_str).collect();
334        let (done, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
335            .into_iter()
336            .partition(|(i, _)| ready.contains(chunks[*i].file_path.as_str()));
337        drop(ready);
338        idx.update(chunks, &done, &self.ready_files, Some(file_hashes));
339        self.pending = rest;
340        self.ready_files.clear();
341        true
342    }
343}
344
345/// Current on-disk format version. Used for forward-compatibility checks.
346const CURRENT_VERSION: u32 = 3;
347
348impl EmbeddingIndex {
349    pub fn new(dimensions: usize) -> Self {
350        Self {
351            version: CURRENT_VERSION,
352            dimensions,
353            model_id: None,
354            entries: Vec::new(),
355            file_hashes: HashMap::new(),
356        }
357    }
358
359    /// Create a new index tagged with a specific model identity.
360    pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
361        Self {
362            version: CURRENT_VERSION,
363            dimensions,
364            model_id: Some(model_id.to_string()),
365            entries: Vec::new(),
366            file_hashes: HashMap::new(),
367        }
368    }
369
370    /// Check if the index was built with a different model than currently selected.
371    /// Returns `Some((stored_model, current_model))` on mismatch, `None` if compatible.
372    pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
373        match &self.model_id {
374            Some(stored) if stored != current_model => Some((stored, current_model)),
375            _ => None,
376        }
377    }
378
379    /// Check if index dimensions are incompatible with the current engine.
380    pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
381        self.dimensions != engine_dimensions && !self.entries.is_empty()
382    }
383
384    /// Approximate heap memory used by this index in bytes.
385    pub fn memory_usage_bytes(&self) -> usize {
386        let entries_size: usize = self
387            .entries
388            .iter()
389            .map(|e| {
390                e.file_path.len()
391                    + e.symbol_name.len()
392                    + e.content_hash.len()
393                    + e.quant.code.len()
394                    + 4
395                    + 48
396            })
397            .sum();
398        let hashes_size: usize = self
399            .file_hashes
400            .iter()
401            .map(|(k, v)| k.len() + v.len() + 32)
402            .sum();
403        entries_size + hashes_size
404    }
405
406    /// Drops all in-memory data to free heap. Index can be re-loaded from disk.
407    pub fn unload(&mut self) {
408        let usage = self.memory_usage_bytes();
409        self.entries = Vec::new();
410        self.file_hashes = HashMap::new();
411        tracing::info!(
412            "[embeddings] unloaded index, freed ~{:.1}MB",
413            usage as f64 / 1_048_576.0
414        );
415    }
416
417    /// Load a previously saved index, or create a new empty one.
418    pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
419        Self::load(root).unwrap_or_else(|| Self::new(dimensions))
420    }
421
422    /// Determine which files need re-embedding based on content hashes.
423    ///
424    /// When the index is empty (no prior embeddings), skips hash computation
425    /// entirely by returning all unique file paths from chunks directly.
426    pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
427        // Empty index: every file needs embedding — skip O(chunks) hash iteration.
428        if self.file_hashes.is_empty() {
429            let mut files: Vec<String> = chunks.iter().map(|c| c.file_path.clone()).collect();
430            files.sort();
431            files.dedup();
432            return files;
433        }
434
435        let current_hashes = compute_file_hashes(chunks);
436
437        let mut needs_update = Vec::new();
438        for (file, hash) in &current_hashes {
439            match self.file_hashes.get(file) {
440                Some(old_hash) if old_hash == hash => {}
441                _ => needs_update.push(file.clone()),
442            }
443        }
444
445        for file in self.file_hashes.keys() {
446            if !current_hashes.contains_key(file) {
447                needs_update.push(file.clone());
448            }
449        }
450
451        needs_update
452    }
453
454    /// Number of `chunks` a re-embed pass would have to embed right now — i.e.
455    /// the chunks belonging to files flagged by [`Self::files_needing_update`].
456    ///
457    /// Used by the hybrid/dense cold-start guard (#512): on a server that came
458    /// up before the on-disk index existed, the first query would otherwise embed
459    /// the *entire* corpus inline under the request watchdog, producing a runaway
460    /// the watchdog abandons but cannot cancel. Counting the pending chunks up
461    /// front lets the caller fall back instead of starting that embed.
462    pub fn pending_chunk_count(&self, chunks: &[CodeChunk]) -> usize {
463        let changed = self.files_needing_update(chunks);
464        if changed.is_empty() {
465            return 0;
466        }
467        let changed: std::collections::HashSet<&str> = changed.iter().map(String::as_str).collect();
468        chunks
469            .iter()
470            .filter(|c| changed.contains(c.file_path.as_str()))
471            .count()
472    }
473
474    /// Update the index with new embeddings for changed files.
475    /// Preserves existing embeddings for unchanged files.
476    ///
477    /// `precomputed_hashes` can be passed to avoid re-computing file hashes
478    /// when the caller already has them (e.g. from `files_needing_update`).
479    /// When `None`, hashes are computed from `chunks`.
480    pub fn update(
481        &mut self,
482        chunks: &[CodeChunk],
483        new_embeddings: &[(usize, Vec<f32>)],
484        changed_files: &[String],
485        precomputed_hashes: Option<&HashMap<String, String>>,
486    ) {
487        let changed: std::collections::HashSet<&str> =
488            changed_files.iter().map(String::as_str).collect();
489        self.entries
490            .retain(|e| !changed.contains(e.file_path.as_str()));
491
492        for file in changed_files {
493            self.file_hashes.remove(file);
494        }
495
496        let computed;
497        let current_hashes = if let Some(hashes) = precomputed_hashes {
498            hashes
499        } else {
500            computed = compute_file_hashes(chunks);
501            &computed
502        };
503        for file in changed_files {
504            if let Some(hash) = current_hashes.get(file) {
505                self.file_hashes.insert(file.clone(), hash.clone());
506            }
507        }
508
509        for &(chunk_idx, ref embedding) in new_embeddings {
510            if let Some(chunk) = chunks.get(chunk_idx) {
511                let content_hash = hash_content(&chunk.content);
512                self.entries.push(EmbeddingEntry {
513                    file_path: chunk.file_path.clone(),
514                    symbol_name: chunk.symbol_name.clone(),
515                    start_line: chunk.start_line,
516                    end_line: chunk.end_line,
517                    quant: embedding_quant::quantize(embedding),
518                    content_hash,
519                });
520            }
521        }
522    }
523
524    /// Get all embeddings in chunk order (aligned with BM25Index.chunks) as a
525    /// single contiguous [`FlatEmbeddings`] allocation. Returns None if the index
526    /// doesn't cover all chunks.
527    ///
528    /// The flat layout (_n_vectors × _dim_ in row-major order) gives sequential
529    /// memory access during dot-product scoring — one dereference instead of the
530    /// two-level indirection of `Arc<[Vec<f32>]>`.
531    pub fn get_aligned_flat(&self, chunks: &[CodeChunk]) -> Option<FlatEmbeddings> {
532        let dim = self.dimensions;
533        let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
534            HashMap::with_capacity(self.entries.len());
535        for e in &self.entries {
536            map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
537        }
538
539        let n = chunks.len();
540        let mut data = Vec::with_capacity(n * dim);
541        for chunk in chunks {
542            let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
543            entry.write_into_flat(&mut data);
544        }
545        Some(FlatEmbeddings {
546            data: Arc::from(data),
547            dim,
548        })
549    }
550
551    pub fn coverage(&self, total_chunks: usize) -> f64 {
552        if total_chunks == 0 {
553            return 0.0;
554        }
555        self.entries.len() as f64 / total_chunks as f64
556    }
557
558    pub fn save(&self, root: &Path) -> std::io::Result<()> {
559        let dir = index_dir(root);
560        std::fs::create_dir_all(&dir)?;
561        // Binary (postcard) — compact, fast, deterministic.
562        let data = postcard::to_allocvec(self).map_err(std::io::Error::other)?;
563        // `load` deletes an undecodable index; a torn write would force a
564        // full re-embed instead of costing nothing.
565        crate::core::atomic_fs::try_atomic_write(&dir.join("embeddings.bin"), &data, None)?;
566        Ok(())
567    }
568
569    pub fn load(root: &Path) -> Option<Self> {
570        let bin_path = index_dir(root).join("embeddings.bin");
571        let data = std::fs::read(&bin_path).ok()?;
572        match postcard::from_bytes::<Self>(&data) {
573            // Only accept an index whose on-disk schema matches the current one.
574            Ok(idx) if idx.version == CURRENT_VERSION => Some(idx),
575            // A structurally-valid but stale schema (older/newer bin layout) must
576            // not be trusted — postcard can silently mis-decode a changed struct.
577            // Drop it and rebuild rather than serving garbage vectors.
578            Ok(idx) => {
579                tracing::warn!(
580                    "[embeddings] index format v{} != current v{CURRENT_VERSION} — \
581                     removing and rebuilding from scratch",
582                    idx.version
583                );
584                let _ = std::fs::remove_file(&bin_path);
585                None
586            }
587            Err(_) => {
588                tracing::warn!(
589                    "[embeddings] corrupt embeddings.bin — removing and will rebuild from scratch"
590                );
591                let _ = std::fs::remove_file(&bin_path);
592                None
593            }
594        }
595    }
596}
597
598fn index_dir(root: &Path) -> PathBuf {
599    crate::core::index_namespace::vectors_dir(root)
600}
601
602/// Written next to `embeddings.bin` while it holds an interrupted build's
603/// checkpoint: `<embedded chunks> <total chunks>`. Removed by the final save.
604fn partial_marker_path(root: &Path) -> PathBuf {
605    index_dir(root).join("embeddings.partial")
606}
607
608/// `(embedded, total)` chunks when the persisted index is an incomplete
609/// checkpoint that `lean-ctx index build-semantic` would resume.
610pub fn partial_progress(root: &Path) -> Option<(u64, u64)> {
611    let text = std::fs::read_to_string(partial_marker_path(root)).ok()?;
612    let mut parts = text.split_whitespace().map(str::parse::<u64>);
613    match (parts.next(), parts.next()) {
614        (Some(Ok(done)), Some(Ok(total))) => Some((done, total)),
615        _ => None,
616    }
617}
618
619/// Drop the partial marker once a caller has persisted a fully covering index.
620pub fn clear_partial_marker(root: &Path) {
621    let _ = std::fs::remove_file(partial_marker_path(root));
622}
623
624/// Persist `idx` as a resumable checkpoint (`done < total`) or as the finished
625/// index, keeping the partial marker in step with what is on disk.
626#[cfg(feature = "embeddings")]
627fn save_progress(idx: &EmbeddingIndex, root: &Path, total_chunks: usize) -> std::io::Result<()> {
628    idx.save(root)?;
629    let marker = partial_marker_path(root);
630    if idx.entries.len() < total_chunks {
631        let body = format!("{} {total_chunks}\n", idx.entries.len());
632        crate::core::atomic_fs::try_atomic_write(&marker, body.as_bytes(), None)?;
633    } else {
634        match std::fs::remove_file(&marker) {
635            Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
636            _ => {}
637        }
638    }
639    Ok(())
640}
641
642fn hash_content(content: &str) -> String {
643    let mut hasher = Md5::new();
644    hasher.update(content.as_bytes());
645    crate::core::agent_identity::hex_encode(&hasher.finalize())
646}
647
648fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
649    let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
650    for chunk in chunks {
651        by_file
652            .entry(chunk.file_path.as_str())
653            .or_default()
654            .push(chunk);
655    }
656
657    let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
658    for (file, mut file_chunks) in by_file {
659        file_chunks.sort_by(|a, b| {
660            (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
661                b.start_line,
662                b.end_line,
663                b.symbol_name.as_str(),
664            ))
665        });
666
667        let mut hasher = Md5::new();
668        hasher.update(file.as_bytes());
669        for c in file_chunks {
670            hasher.update(c.start_line.to_le_bytes());
671            hasher.update(c.end_line.to_le_bytes());
672            hasher.update(c.symbol_name.as_bytes());
673            hasher.update([kind_tag(&c.kind)]);
674            hasher.update(c.content.as_bytes());
675        }
676        out.insert(
677            file.to_string(),
678            crate::core::agent_identity::hex_encode(&hasher.finalize()),
679        );
680    }
681    out
682}
683
684fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
685    use super::bm25_index::ChunkKind;
686    match kind {
687        ChunkKind::Function => 1,
688        ChunkKind::Struct => 2,
689        ChunkKind::Impl => 3,
690        ChunkKind::Module => 4,
691        ChunkKind::Class => 5,
692        ChunkKind::Method => 6,
693        ChunkKind::Other => 7,
694        ChunkKind::Issue => 8,
695        ChunkKind::PullRequest => 9,
696        ChunkKind::WikiPage => 10,
697        ChunkKind::DbSchema => 11,
698        ChunkKind::ApiEndpoint => 12,
699        ChunkKind::Ticket => 13,
700        ChunkKind::ExternalOther => 14,
701    }
702}
703
704#[cfg(test)]
705pub mod tests {
706    use super::*;
707    use crate::core::bm25_index::{ChunkKind, CodeChunk};
708
709    fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
710        CodeChunk {
711            file_path: file.to_string(),
712            symbol_name: name.to_string(),
713            kind: ChunkKind::Function,
714            start_line: start,
715            end_line: end,
716            content: content.to_string(),
717            tokens: vec![name.to_string()],
718            token_count: 1,
719        }
720    }
721
722    fn dummy_embedding(dim: usize) -> Vec<f32> {
723        vec![0.1; dim]
724    }
725
726    #[test]
727    fn new_index_is_empty() {
728        let idx = EmbeddingIndex::new(384);
729        assert!(idx.entries.is_empty());
730        assert!(idx.file_hashes.is_empty());
731        assert_eq!(idx.dimensions, 384);
732    }
733
734    #[test]
735    fn files_needing_update_all_new() {
736        let idx = EmbeddingIndex::new(384);
737        let chunks = vec![
738            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
739            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
740        ];
741        let needs = idx.files_needing_update(&chunks);
742        assert_eq!(needs.len(), 2);
743    }
744
745    #[cfg(feature = "embeddings")]
746    #[test]
747    fn checkpoint_commits_only_fully_embedded_files() {
748        let chunks = vec![
749            make_chunk("a.rs", "fn_a1", "fn a1() {}", 1, 3),
750            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
751            make_chunk("a.rs", "fn_a2", "fn a2() {}", 5, 7),
752        ];
753        let hashes = compute_file_hashes(&chunks);
754        let mut idx = EmbeddingIndex::new(384);
755        // a.rs has one of its two chunks embedded; b.rs is complete.
756        let mut checkpoint = EmbedCheckpoint {
757            pending: vec![(0, dummy_embedding(384)), (1, dummy_embedding(384))],
758            ready_files: vec!["b.rs".to_string()],
759        };
760
761        assert!(checkpoint.commit(&mut idx, &chunks, &hashes));
762        assert_eq!(idx.entries.len(), 1);
763        assert_eq!(idx.entries[0].file_path, "b.rs");
764        assert_eq!(
765            checkpoint.pending.len(),
766            1,
767            "a.rs chunk waits for its sibling"
768        );
769        assert!(
770            checkpoint.ready_files.is_empty(),
771            "{:?}",
772            checkpoint.ready_files
773        );
774        // The half-embedded file gets no hash, so a resumed build re-embeds it.
775        assert_eq!(idx.files_needing_update(&chunks), vec!["a.rs".to_string()]);
776        assert!(!checkpoint.commit(&mut idx, &chunks, &hashes));
777
778        checkpoint.pending.push((2, dummy_embedding(384)));
779        checkpoint.ready_files.push("a.rs".to_string());
780        assert!(checkpoint.commit(&mut idx, &chunks, &hashes));
781        assert_eq!(idx.entries.len(), 3);
782        {
783            let found = &idx.files_needing_update(&chunks);
784            assert!(found.is_empty(), "{found:?}");
785        }
786        assert!(idx.get_aligned_flat(&chunks).is_some());
787    }
788
789    #[cfg(feature = "embeddings")]
790    #[test]
791    fn checkpoint_marker_tracks_partial_and_clears_on_completion() {
792        let _lock = crate::core::data_dir::test_env_lock();
793        let data_dir = tempfile::tempdir().unwrap();
794        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
795        let project_dir = tempfile::tempdir().unwrap();
796        let root = project_dir.path();
797
798        let chunks = vec![
799            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
800            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
801        ];
802        let mut idx = EmbeddingIndex::new(3);
803        idx.update(&chunks, &[(0, vec![1.0, 0.0, 0.0])], &["a.rs".into()], None);
804        save_progress(&idx, root, chunks.len()).unwrap();
805        assert_eq!(partial_progress(root), Some((1, 2)));
806        // The checkpoint itself is a loadable index that resumes at b.rs.
807        let resumed = EmbeddingIndex::load(root).unwrap();
808        assert_eq!(
809            resumed.files_needing_update(&chunks),
810            vec!["b.rs".to_string()]
811        );
812
813        idx.update(&chunks, &[(1, vec![0.0, 1.0, 0.0])], &["b.rs".into()], None);
814        save_progress(&idx, root, chunks.len()).unwrap();
815        assert_eq!(partial_progress(root), None);
816
817        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
818    }
819
820    #[test]
821    fn files_needing_update_unchanged() {
822        let mut idx = EmbeddingIndex::new(384);
823        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
824
825        idx.update(
826            &chunks,
827            &[(0, dummy_embedding(384))],
828            &["a.rs".to_string()],
829            None,
830        );
831
832        let needs = idx.files_needing_update(&chunks);
833        assert!(needs.is_empty(), "unchanged file should not need update");
834    }
835
836    #[test]
837    fn files_needing_update_changed_content() {
838        let mut idx = EmbeddingIndex::new(384);
839        let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
840        idx.update(
841            &chunks_v1,
842            &[(0, dummy_embedding(384))],
843            &["a.rs".to_string()],
844            None,
845        );
846
847        let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
848        let needs = idx.files_needing_update(&chunks_v2);
849        assert!(
850            needs.contains(&"a.rs".to_string()),
851            "changed file should need update"
852        );
853    }
854
855    #[test]
856    fn files_needing_update_detects_change_in_later_chunk() {
857        let mut idx = EmbeddingIndex::new(3);
858        let chunks_v1 = vec![
859            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
860            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
861        ];
862        idx.update(
863            &chunks_v1,
864            &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
865            &["a.rs".to_string()],
866            None,
867        );
868
869        let chunks_v2 = vec![
870            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
871            make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
872        ];
873        let needs = idx.files_needing_update(&chunks_v2);
874        assert!(
875            needs.contains(&"a.rs".to_string()),
876            "changing a later chunk should trigger re-embedding"
877        );
878    }
879
880    #[test]
881    fn files_needing_update_deleted_file() {
882        let mut idx = EmbeddingIndex::new(384);
883        let chunks = vec![
884            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
885            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
886        ];
887        idx.update(
888            &chunks,
889            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
890            &["a.rs".to_string(), "b.rs".to_string()],
891            None,
892        );
893
894        let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
895        let needs = idx.files_needing_update(&chunks_after);
896        assert!(
897            needs.contains(&"b.rs".to_string()),
898            "deleted file should trigger update"
899        );
900    }
901
902    #[test]
903    fn pending_chunk_count_cold_start_counts_every_chunk() {
904        let idx = EmbeddingIndex::new(384);
905        let chunks = vec![
906            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
907            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
908            make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
909        ];
910        assert_eq!(
911            idx.pending_chunk_count(&chunks),
912            3,
913            "an empty index must report every chunk as pending (cold start)"
914        );
915    }
916
917    #[test]
918    fn pending_chunk_count_zero_when_fully_embedded() {
919        let mut idx = EmbeddingIndex::new(384);
920        let chunks = vec![
921            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
922            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
923        ];
924        idx.update(
925            &chunks,
926            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
927            &["a.rs".to_string(), "b.rs".to_string()],
928            None,
929        );
930        assert_eq!(
931            idx.pending_chunk_count(&chunks),
932            0,
933            "a fully-embedded index has no pending chunks (warm path stays inline)"
934        );
935    }
936
937    #[test]
938    fn pending_chunk_count_only_counts_changed_files_chunks() {
939        let mut idx = EmbeddingIndex::new(384);
940        let chunks_v1 = vec![
941            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
942            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
943            make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
944        ];
945        idx.update(
946            &chunks_v1,
947            &[
948                (0, dummy_embedding(384)),
949                (1, dummy_embedding(384)),
950                (2, dummy_embedding(384)),
951            ],
952            &["a.rs".to_string(), "b.rs".to_string()],
953            None,
954        );
955
956        // Only b.rs changed → its single chunk is pending, a.rs's two are not.
957        let chunks_v2 = vec![
958            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
959            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
960            make_chunk("b.rs", "fn_c", "fn c() { changed }", 1, 3),
961        ];
962        assert_eq!(
963            idx.pending_chunk_count(&chunks_v2),
964            1,
965            "incremental update must only count the changed file's chunks"
966        );
967    }
968
969    #[test]
970    fn update_preserves_unchanged() {
971        let mut idx = EmbeddingIndex::new(384);
972        let chunks = vec![
973            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
974            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
975        ];
976        idx.update(
977            &chunks,
978            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
979            &["a.rs".to_string(), "b.rs".to_string()],
980            None,
981        );
982        assert_eq!(idx.entries.len(), 2);
983
984        idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()], None);
985        assert_eq!(idx.entries.len(), 2);
986
987        let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
988        let b_embed = b_entry.quant.dequantize();
989        assert!(
990            (b_embed[0] - 0.1).abs() < 1e-6,
991            "b.rs embedding should be preserved"
992        );
993    }
994
995    #[test]
996    fn get_aligned_flat_ok() {
997        let mut idx = EmbeddingIndex::new(2);
998        let chunks = vec![
999            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
1000            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
1001        ];
1002        idx.update(
1003            &chunks,
1004            &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
1005            &["a.rs".to_string(), "b.rs".to_string()],
1006            None,
1007        );
1008
1009        let flat = idx.get_aligned_flat(&chunks).unwrap();
1010        assert_eq!(flat.n_vectors(), 2);
1011        assert_eq!(flat.dim, 2);
1012        assert!((flat.get(0)[0] - 1.0).abs() < 1e-6);
1013        assert!((flat.get(1)[1] - 1.0).abs() < 1e-6);
1014    }
1015
1016    #[test]
1017    fn get_aligned_flat_missing() {
1018        let idx = EmbeddingIndex::new(384);
1019        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1020        assert!(idx.get_aligned_flat(&chunks).is_none());
1021    }
1022
1023    #[test]
1024    fn coverage_calculation() {
1025        let mut idx = EmbeddingIndex::new(384);
1026        assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
1027
1028        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1029        idx.update(
1030            &chunks,
1031            &[(0, dummy_embedding(384))],
1032            &["a.rs".to_string()],
1033            None,
1034        );
1035        assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
1036        assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
1037    }
1038
1039    #[test]
1040    fn save_and_load_roundtrip() {
1041        let _lock = crate::core::data_dir::test_env_lock();
1042        let data_dir = tempfile::tempdir().unwrap();
1043        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
1044
1045        let project_dir = tempfile::tempdir().unwrap();
1046
1047        let mut idx = EmbeddingIndex::new(3);
1048        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1049        idx.update(
1050            &chunks,
1051            &[(0, vec![1.0, 2.0, 3.0])],
1052            &["a.rs".to_string()],
1053            None,
1054        );
1055        idx.save(project_dir.path()).unwrap();
1056
1057        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
1058        assert_eq!(loaded.dimensions, 3);
1059        assert_eq!(loaded.entries.len(), 1);
1060        // int8-quantized round-trip: within one quantization step of the original.
1061        let recon = loaded.entries[0].quant.dequantize();
1062        assert!((recon[0] - 1.0).abs() < 0.02);
1063        assert!((recon[1] - 2.0).abs() < 0.02);
1064        assert!((recon[2] - 3.0).abs() < 0.02);
1065
1066        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1067    }
1068
1069    #[test]
1070    fn new_with_model_sets_model_id() {
1071        let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
1072        assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
1073        assert_eq!(idx.dimensions, 768);
1074    }
1075
1076    #[test]
1077    fn model_mismatch_detection() {
1078        let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
1079        assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
1080        assert!(idx.model_mismatch("jina-code-v2").is_some());
1081
1082        let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
1083        assert_eq!(stored, "all-MiniLM-L6-v2");
1084        assert_eq!(current, "jina-code-v2");
1085    }
1086
1087    #[test]
1088    fn model_mismatch_none_when_no_model_id() {
1089        let idx = EmbeddingIndex::new(384);
1090        assert!(idx.model_mismatch("anything").is_none());
1091    }
1092
1093    #[test]
1094    fn dimension_mismatch_detection() {
1095        let mut idx = EmbeddingIndex::new(384);
1096        assert!(!idx.dimension_mismatch(384));
1097        assert!(!idx.dimension_mismatch(768)); // no entries = no mismatch
1098
1099        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1100        idx.update(
1101            &chunks,
1102            &[(0, dummy_embedding(384))],
1103            &["a.rs".to_string()],
1104            None,
1105        );
1106        assert!(!idx.dimension_mismatch(384));
1107        assert!(idx.dimension_mismatch(768));
1108    }
1109}