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.json`
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};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct EmbeddingIndex {
21    pub version: u32,
22    pub dimensions: usize,
23    /// Model identifier that generated these embeddings.
24    /// Used for mismatch detection when the user switches models.
25    #[serde(default)]
26    pub model_id: Option<String>,
27    pub entries: Vec<EmbeddingEntry>,
28    pub file_hashes: HashMap<String, String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct EmbeddingEntry {
33    pub file_path: String,
34    pub symbol_name: String,
35    pub start_line: usize,
36    pub end_line: usize,
37    /// Legacy full-precision vector (v1/v2 indices). Migrated to `quant` on load
38    /// and then emptied; only present in files written by pre-v3 binaries.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub embedding: Vec<f32>,
41    /// int8-quantized embedding (turbovec-derived) — 4× smaller on disk.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub quant: Option<QuantizedVector>,
44    pub content_hash: String,
45}
46
47impl EmbeddingEntry {
48    /// Full-precision embedding for scoring: reconstructs from int8 codes, or
49    /// returns the legacy vector for not-yet-migrated entries.
50    fn embedding_f32(&self) -> Vec<f32> {
51        match &self.quant {
52            Some(q) => q.dequantize(),
53            None => self.embedding.clone(),
54        }
55    }
56}
57
58/// v1→v2 added `model_id`; v2→v3 stores embeddings as int8 (`quant`) instead of f32.
59const CURRENT_VERSION: u32 = 3;
60
61impl EmbeddingIndex {
62    pub fn new(dimensions: usize) -> Self {
63        Self {
64            version: CURRENT_VERSION,
65            dimensions,
66            model_id: None,
67            entries: Vec::new(),
68            file_hashes: HashMap::new(),
69        }
70    }
71
72    /// Create a new index tagged with a specific model identity.
73    pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
74        Self {
75            version: CURRENT_VERSION,
76            dimensions,
77            model_id: Some(model_id.to_string()),
78            entries: Vec::new(),
79            file_hashes: HashMap::new(),
80        }
81    }
82
83    /// Check if the index was built with a different model than currently selected.
84    /// Returns `Some((stored_model, current_model))` on mismatch, `None` if compatible.
85    pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
86        match &self.model_id {
87            Some(stored) if stored != current_model => Some((stored, current_model)),
88            _ => None,
89        }
90    }
91
92    /// Check if index dimensions are incompatible with the current engine.
93    pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
94        self.dimensions != engine_dimensions && !self.entries.is_empty()
95    }
96
97    /// Approximate heap memory used by this index in bytes.
98    pub fn memory_usage_bytes(&self) -> usize {
99        let entries_size: usize = self
100            .entries
101            .iter()
102            .map(|e| {
103                e.file_path.len()
104                    + e.symbol_name.len()
105                    + e.content_hash.len()
106                    + e.quant
107                        .as_ref()
108                        .map_or(e.embedding.len() * 4, |q| q.code.len() + 4)
109                    + 48
110            })
111            .sum();
112        let hashes_size: usize = self
113            .file_hashes
114            .iter()
115            .map(|(k, v)| k.len() + v.len() + 32)
116            .sum();
117        entries_size + hashes_size
118    }
119
120    /// Drops all in-memory data to free heap. Index can be re-loaded from disk.
121    pub fn unload(&mut self) {
122        let usage = self.memory_usage_bytes();
123        self.entries = Vec::new();
124        self.file_hashes = HashMap::new();
125        tracing::info!(
126            "[embeddings] unloaded index, freed ~{:.1}MB",
127            usage as f64 / 1_048_576.0
128        );
129    }
130
131    /// Load a previously saved index, or create a new empty one.
132    pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
133        Self::load(root).unwrap_or_else(|| Self::new(dimensions))
134    }
135
136    /// Determine which files need re-embedding based on content hashes.
137    pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
138        let current_hashes = compute_file_hashes(chunks);
139
140        let mut needs_update = Vec::new();
141        for (file, hash) in &current_hashes {
142            match self.file_hashes.get(file) {
143                Some(old_hash) if old_hash == hash => {}
144                _ => needs_update.push(file.clone()),
145            }
146        }
147
148        for file in self.file_hashes.keys() {
149            if !current_hashes.contains_key(file) {
150                needs_update.push(file.clone());
151            }
152        }
153
154        needs_update
155    }
156
157    /// Update the index with new embeddings for changed files.
158    /// Preserves existing embeddings for unchanged files.
159    pub fn update(
160        &mut self,
161        chunks: &[CodeChunk],
162        new_embeddings: &[(usize, Vec<f32>)],
163        changed_files: &[String],
164    ) {
165        self.entries
166            .retain(|e| !changed_files.contains(&e.file_path));
167
168        for file in changed_files {
169            self.file_hashes.remove(file);
170        }
171
172        let current_hashes = compute_file_hashes(chunks);
173        for file in changed_files {
174            if let Some(hash) = current_hashes.get(file) {
175                self.file_hashes.insert(file.clone(), hash.clone());
176            }
177        }
178
179        for &(chunk_idx, ref embedding) in new_embeddings {
180            if let Some(chunk) = chunks.get(chunk_idx) {
181                let content_hash = hash_content(&chunk.content);
182                self.entries.push(EmbeddingEntry {
183                    file_path: chunk.file_path.clone(),
184                    symbol_name: chunk.symbol_name.clone(),
185                    start_line: chunk.start_line,
186                    end_line: chunk.end_line,
187                    embedding: Vec::new(),
188                    quant: Some(embedding_quant::quantize(embedding)),
189                    content_hash,
190                });
191            }
192        }
193    }
194
195    /// Upgrades any legacy f32 entries to int8 in place. Returns true if anything
196    /// changed, so the caller can persist the 4×-smaller form once.
197    fn migrate_legacy_entries(&mut self) -> bool {
198        let mut changed = false;
199        for e in &mut self.entries {
200            if e.quant.is_none() && !e.embedding.is_empty() {
201                e.quant = Some(embedding_quant::quantize(&e.embedding));
202                e.embedding = Vec::new();
203                changed = true;
204            }
205        }
206        changed
207    }
208
209    /// Get all embeddings in chunk order (aligned with BM25Index.chunks).
210    /// Returns None if index doesn't cover all chunks.
211    ///
212    /// Returns `Arc<[Vec<f32>]>` so this single corpus allocation can be shared
213    /// (via `Arc::clone`) with the process-wide cached HNSW
214    /// [`AnnIndex`](crate::core::hnsw::AnnIndex) instead
215    /// of being copied a second time. `Arc::from(Vec<_>)` moves the per-vector
216    /// handles into the shared buffer once; the f32 heap data is never copied.
217    pub fn get_aligned_embeddings(&self, chunks: &[CodeChunk]) -> Option<Arc<[Vec<f32>]>> {
218        let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
219            HashMap::with_capacity(self.entries.len());
220        for e in &self.entries {
221            map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
222        }
223
224        let mut result = Vec::with_capacity(chunks.len());
225        for chunk in chunks {
226            let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
227            result.push(entry.embedding_f32());
228        }
229        Some(Arc::from(result))
230    }
231
232    pub fn coverage(&self, total_chunks: usize) -> f64 {
233        if total_chunks == 0 {
234            return 0.0;
235        }
236        self.entries.len() as f64 / total_chunks as f64
237    }
238
239    pub fn save(&self, root: &Path) -> std::io::Result<()> {
240        let dir = index_dir(root);
241        std::fs::create_dir_all(&dir)?;
242        let data = serde_json::to_string(self).map_err(std::io::Error::other)?;
243        std::fs::write(dir.join("embeddings.json"), data)?;
244        Ok(())
245    }
246
247    pub fn load(root: &Path) -> Option<Self> {
248        let dir = index_dir(root);
249        let path = dir.join("embeddings.json");
250        let data = std::fs::read_to_string(&path)
251            .or_else(|_| {
252                let legacy_dir = legacy_embedding_dir(root);
253                if legacy_dir == dir {
254                    return Err(std::io::Error::new(
255                        std::io::ErrorKind::NotFound,
256                        "same path",
257                    ));
258                }
259                let legacy_path = legacy_dir.join("embeddings.json");
260                let content = std::fs::read_to_string(&legacy_path)?;
261                let _ = std::fs::create_dir_all(&dir);
262                let _ = std::fs::copy(&legacy_path, &path);
263                Ok(content)
264            })
265            .ok()?;
266        let mut idx: Self = serde_json::from_str(&data).ok()?;
267        match idx.version {
268            CURRENT_VERSION => Some(idx),
269            1 | 2 => {
270                tracing::info!(
271                    "[embeddings] migrating index v{} → v{CURRENT_VERSION} (int8 quantization)",
272                    idx.version
273                );
274                idx.version = CURRENT_VERSION;
275                let quantized = idx.migrate_legacy_entries();
276                // Persist the upgraded (4×-smaller) form once so the cost is amortized.
277                if quantized {
278                    let _ = idx.save(root);
279                }
280                Some(idx)
281            }
282            _ => None,
283        }
284    }
285}
286
287fn index_dir(root: &Path) -> PathBuf {
288    crate::core::index_namespace::vectors_dir(root)
289}
290
291fn legacy_embedding_dir(root: &Path) -> PathBuf {
292    let mut hasher = Md5::new();
293    hasher.update(root.to_string_lossy().as_bytes());
294    let hash = format!("{:x}", hasher.finalize());
295    crate::core::data_dir::lean_ctx_data_dir()
296        .unwrap_or_else(|_| PathBuf::from("."))
297        .join("vectors")
298        .join(hash)
299}
300
301fn hash_content(content: &str) -> String {
302    let mut hasher = Md5::new();
303    hasher.update(content.as_bytes());
304    format!("{:x}", hasher.finalize())
305}
306
307fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
308    let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
309    for chunk in chunks {
310        by_file
311            .entry(chunk.file_path.as_str())
312            .or_default()
313            .push(chunk);
314    }
315
316    let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
317    for (file, mut file_chunks) in by_file {
318        file_chunks.sort_by(|a, b| {
319            (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
320                b.start_line,
321                b.end_line,
322                b.symbol_name.as_str(),
323            ))
324        });
325
326        let mut hasher = Md5::new();
327        hasher.update(file.as_bytes());
328        for c in file_chunks {
329            hasher.update(c.start_line.to_le_bytes());
330            hasher.update(c.end_line.to_le_bytes());
331            hasher.update(c.symbol_name.as_bytes());
332            hasher.update([kind_tag(&c.kind)]);
333            hasher.update(c.content.as_bytes());
334        }
335        out.insert(file.to_string(), format!("{:x}", hasher.finalize()));
336    }
337    out
338}
339
340fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
341    use super::bm25_index::ChunkKind;
342    match kind {
343        ChunkKind::Function => 1,
344        ChunkKind::Struct => 2,
345        ChunkKind::Impl => 3,
346        ChunkKind::Module => 4,
347        ChunkKind::Class => 5,
348        ChunkKind::Method => 6,
349        ChunkKind::Other => 7,
350        ChunkKind::Issue => 8,
351        ChunkKind::PullRequest => 9,
352        ChunkKind::WikiPage => 10,
353        ChunkKind::DbSchema => 11,
354        ChunkKind::ApiEndpoint => 12,
355        ChunkKind::Ticket => 13,
356        ChunkKind::ExternalOther => 14,
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::core::bm25_index::{ChunkKind, CodeChunk};
364
365    fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
366        CodeChunk {
367            file_path: file.to_string(),
368            symbol_name: name.to_string(),
369            kind: ChunkKind::Function,
370            start_line: start,
371            end_line: end,
372            content: content.to_string(),
373            tokens: vec![name.to_string()],
374            token_count: 1,
375        }
376    }
377
378    fn dummy_embedding(dim: usize) -> Vec<f32> {
379        vec![0.1; dim]
380    }
381
382    #[test]
383    fn new_index_is_empty() {
384        let idx = EmbeddingIndex::new(384);
385        assert!(idx.entries.is_empty());
386        assert!(idx.file_hashes.is_empty());
387        assert_eq!(idx.dimensions, 384);
388    }
389
390    #[test]
391    fn files_needing_update_all_new() {
392        let idx = EmbeddingIndex::new(384);
393        let chunks = vec![
394            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
395            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
396        ];
397        let needs = idx.files_needing_update(&chunks);
398        assert_eq!(needs.len(), 2);
399    }
400
401    #[test]
402    fn files_needing_update_unchanged() {
403        let mut idx = EmbeddingIndex::new(384);
404        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
405
406        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
407
408        let needs = idx.files_needing_update(&chunks);
409        assert!(needs.is_empty(), "unchanged file should not need update");
410    }
411
412    #[test]
413    fn files_needing_update_changed_content() {
414        let mut idx = EmbeddingIndex::new(384);
415        let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
416        idx.update(
417            &chunks_v1,
418            &[(0, dummy_embedding(384))],
419            &["a.rs".to_string()],
420        );
421
422        let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
423        let needs = idx.files_needing_update(&chunks_v2);
424        assert!(
425            needs.contains(&"a.rs".to_string()),
426            "changed file should need update"
427        );
428    }
429
430    #[test]
431    fn files_needing_update_detects_change_in_later_chunk() {
432        let mut idx = EmbeddingIndex::new(3);
433        let chunks_v1 = vec![
434            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
435            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
436        ];
437        idx.update(
438            &chunks_v1,
439            &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
440            &["a.rs".to_string()],
441        );
442
443        let chunks_v2 = vec![
444            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
445            make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
446        ];
447        let needs = idx.files_needing_update(&chunks_v2);
448        assert!(
449            needs.contains(&"a.rs".to_string()),
450            "changing a later chunk should trigger re-embedding"
451        );
452    }
453
454    #[test]
455    fn files_needing_update_deleted_file() {
456        let mut idx = EmbeddingIndex::new(384);
457        let chunks = vec![
458            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
459            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
460        ];
461        idx.update(
462            &chunks,
463            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
464            &["a.rs".to_string(), "b.rs".to_string()],
465        );
466
467        let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
468        let needs = idx.files_needing_update(&chunks_after);
469        assert!(
470            needs.contains(&"b.rs".to_string()),
471            "deleted file should trigger update"
472        );
473    }
474
475    #[test]
476    fn update_preserves_unchanged() {
477        let mut idx = EmbeddingIndex::new(384);
478        let chunks = vec![
479            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
480            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
481        ];
482        idx.update(
483            &chunks,
484            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
485            &["a.rs".to_string(), "b.rs".to_string()],
486        );
487        assert_eq!(idx.entries.len(), 2);
488
489        idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()]);
490        assert_eq!(idx.entries.len(), 2);
491
492        let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
493        assert!(
494            (b_entry.embedding_f32()[0] - 0.1).abs() < 1e-6,
495            "b.rs embedding should be preserved"
496        );
497    }
498
499    #[test]
500    fn get_aligned_embeddings() {
501        let mut idx = EmbeddingIndex::new(2);
502        let chunks = vec![
503            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
504            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
505        ];
506        idx.update(
507            &chunks,
508            &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
509            &["a.rs".to_string(), "b.rs".to_string()],
510        );
511
512        let aligned = idx.get_aligned_embeddings(&chunks).unwrap();
513        assert_eq!(aligned.len(), 2);
514        assert!((aligned[0][0] - 1.0).abs() < 1e-6);
515        assert!((aligned[1][1] - 1.0).abs() < 1e-6);
516    }
517
518    #[test]
519    fn get_aligned_embeddings_missing() {
520        let idx = EmbeddingIndex::new(384);
521        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
522        assert!(idx.get_aligned_embeddings(&chunks).is_none());
523    }
524
525    #[test]
526    fn coverage_calculation() {
527        let mut idx = EmbeddingIndex::new(384);
528        assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
529
530        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
531        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
532        assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
533        assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
534    }
535
536    #[test]
537    fn save_and_load_roundtrip() {
538        let _lock = crate::core::data_dir::test_env_lock();
539        let data_dir = tempfile::tempdir().unwrap();
540        std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
541
542        let project_dir = tempfile::tempdir().unwrap();
543
544        let mut idx = EmbeddingIndex::new(3);
545        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
546        idx.update(&chunks, &[(0, vec![1.0, 2.0, 3.0])], &["a.rs".to_string()]);
547        idx.save(project_dir.path()).unwrap();
548
549        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
550        assert_eq!(loaded.dimensions, 3);
551        assert_eq!(loaded.entries.len(), 1);
552        // int8-quantized round-trip: within one quantization step of the original.
553        let recon = loaded.entries[0].embedding_f32();
554        assert!((recon[0] - 1.0).abs() < 0.02);
555        assert!((recon[1] - 2.0).abs() < 0.02);
556        assert!((recon[2] - 3.0).abs() < 0.02);
557
558        std::env::remove_var("LEAN_CTX_DATA_DIR");
559    }
560
561    #[test]
562    fn new_with_model_sets_model_id() {
563        let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
564        assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
565        assert_eq!(idx.dimensions, 768);
566    }
567
568    #[test]
569    fn model_mismatch_detection() {
570        let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
571        assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
572        assert!(idx.model_mismatch("jina-code-v2").is_some());
573
574        let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
575        assert_eq!(stored, "all-MiniLM-L6-v2");
576        assert_eq!(current, "jina-code-v2");
577    }
578
579    #[test]
580    fn model_mismatch_none_when_no_model_id() {
581        let idx = EmbeddingIndex::new(384);
582        assert!(idx.model_mismatch("anything").is_none());
583    }
584
585    #[test]
586    fn dimension_mismatch_detection() {
587        let mut idx = EmbeddingIndex::new(384);
588        assert!(!idx.dimension_mismatch(384));
589        assert!(!idx.dimension_mismatch(768)); // no entries = no mismatch
590
591        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
592        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
593        assert!(!idx.dimension_mismatch(384));
594        assert!(idx.dimension_mismatch(768));
595    }
596
597    #[test]
598    fn v1_index_migration() {
599        let _lock = crate::core::data_dir::test_env_lock();
600        let data_dir = tempfile::tempdir().unwrap();
601        std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
602        let project_dir = tempfile::tempdir().unwrap();
603
604        let v1_json = serde_json::json!({
605            "version": 1,
606            "dimensions": 384,
607            "entries": [],
608            "file_hashes": {}
609        });
610
611        let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
612        std::fs::create_dir_all(&dir).unwrap();
613        std::fs::write(dir.join("embeddings.json"), v1_json.to_string()).unwrap();
614
615        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
616        assert_eq!(loaded.version, CURRENT_VERSION);
617        assert_eq!(loaded.dimensions, 384);
618        assert!(loaded.model_id.is_none());
619
620        std::env::remove_var("LEAN_CTX_DATA_DIR");
621    }
622
623    #[test]
624    fn v2_index_quantizes_on_migration() {
625        let _lock = crate::core::data_dir::test_env_lock();
626        let data_dir = tempfile::tempdir().unwrap();
627        std::env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
628        let project_dir = tempfile::tempdir().unwrap();
629
630        // A v2 index with a full-precision f32 entry (pre-quantization on-disk form).
631        let v2_json = serde_json::json!({
632            "version": 2,
633            "dimensions": 3,
634            "model_id": "all-MiniLM-L6-v2",
635            "entries": [{
636                "file_path": "a.rs",
637                "symbol_name": "fn_a",
638                "start_line": 1,
639                "end_line": 3,
640                "embedding": [1.0, 2.0, 3.0],
641                "content_hash": "abc"
642            }],
643            "file_hashes": {}
644        });
645
646        let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
647        std::fs::create_dir_all(&dir).unwrap();
648        std::fs::write(dir.join("embeddings.json"), v2_json.to_string()).unwrap();
649
650        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
651        assert_eq!(loaded.version, CURRENT_VERSION);
652        // The legacy f32 was migrated to int8 codes and the f32 field emptied.
653        let entry = &loaded.entries[0];
654        assert!(
655            entry.embedding.is_empty(),
656            "f32 field cleared after migration"
657        );
658        assert!(entry.quant.is_some(), "entry is now quantized");
659        let recon = entry.embedding_f32();
660        assert!((recon[2] - 3.0).abs() < 0.02);
661
662        // Migration persisted the smaller form: re-loading sees v3 directly.
663        let reloaded = EmbeddingIndex::load(project_dir.path()).unwrap();
664        assert_eq!(reloaded.version, CURRENT_VERSION);
665        assert!(reloaded.entries[0].quant.is_some());
666
667        std::env::remove_var("LEAN_CTX_DATA_DIR");
668    }
669}