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 = crate::core::agent_identity::hex_encode(&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    crate::core::agent_identity::hex_encode(&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(
336            file.to_string(),
337            crate::core::agent_identity::hex_encode(&hasher.finalize()),
338        );
339    }
340    out
341}
342
343fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
344    use super::bm25_index::ChunkKind;
345    match kind {
346        ChunkKind::Function => 1,
347        ChunkKind::Struct => 2,
348        ChunkKind::Impl => 3,
349        ChunkKind::Module => 4,
350        ChunkKind::Class => 5,
351        ChunkKind::Method => 6,
352        ChunkKind::Other => 7,
353        ChunkKind::Issue => 8,
354        ChunkKind::PullRequest => 9,
355        ChunkKind::WikiPage => 10,
356        ChunkKind::DbSchema => 11,
357        ChunkKind::ApiEndpoint => 12,
358        ChunkKind::Ticket => 13,
359        ChunkKind::ExternalOther => 14,
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::core::bm25_index::{ChunkKind, CodeChunk};
367
368    fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
369        CodeChunk {
370            file_path: file.to_string(),
371            symbol_name: name.to_string(),
372            kind: ChunkKind::Function,
373            start_line: start,
374            end_line: end,
375            content: content.to_string(),
376            tokens: vec![name.to_string()],
377            token_count: 1,
378        }
379    }
380
381    fn dummy_embedding(dim: usize) -> Vec<f32> {
382        vec![0.1; dim]
383    }
384
385    #[test]
386    fn new_index_is_empty() {
387        let idx = EmbeddingIndex::new(384);
388        assert!(idx.entries.is_empty());
389        assert!(idx.file_hashes.is_empty());
390        assert_eq!(idx.dimensions, 384);
391    }
392
393    #[test]
394    fn files_needing_update_all_new() {
395        let idx = EmbeddingIndex::new(384);
396        let chunks = vec![
397            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
398            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
399        ];
400        let needs = idx.files_needing_update(&chunks);
401        assert_eq!(needs.len(), 2);
402    }
403
404    #[test]
405    fn files_needing_update_unchanged() {
406        let mut idx = EmbeddingIndex::new(384);
407        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
408
409        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
410
411        let needs = idx.files_needing_update(&chunks);
412        assert!(needs.is_empty(), "unchanged file should not need update");
413    }
414
415    #[test]
416    fn files_needing_update_changed_content() {
417        let mut idx = EmbeddingIndex::new(384);
418        let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
419        idx.update(
420            &chunks_v1,
421            &[(0, dummy_embedding(384))],
422            &["a.rs".to_string()],
423        );
424
425        let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
426        let needs = idx.files_needing_update(&chunks_v2);
427        assert!(
428            needs.contains(&"a.rs".to_string()),
429            "changed file should need update"
430        );
431    }
432
433    #[test]
434    fn files_needing_update_detects_change_in_later_chunk() {
435        let mut idx = EmbeddingIndex::new(3);
436        let chunks_v1 = vec![
437            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
438            make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
439        ];
440        idx.update(
441            &chunks_v1,
442            &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
443            &["a.rs".to_string()],
444        );
445
446        let chunks_v2 = vec![
447            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
448            make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
449        ];
450        let needs = idx.files_needing_update(&chunks_v2);
451        assert!(
452            needs.contains(&"a.rs".to_string()),
453            "changing a later chunk should trigger re-embedding"
454        );
455    }
456
457    #[test]
458    fn files_needing_update_deleted_file() {
459        let mut idx = EmbeddingIndex::new(384);
460        let chunks = vec![
461            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
462            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
463        ];
464        idx.update(
465            &chunks,
466            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
467            &["a.rs".to_string(), "b.rs".to_string()],
468        );
469
470        let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
471        let needs = idx.files_needing_update(&chunks_after);
472        assert!(
473            needs.contains(&"b.rs".to_string()),
474            "deleted file should trigger update"
475        );
476    }
477
478    #[test]
479    fn update_preserves_unchanged() {
480        let mut idx = EmbeddingIndex::new(384);
481        let chunks = vec![
482            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
483            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
484        ];
485        idx.update(
486            &chunks,
487            &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
488            &["a.rs".to_string(), "b.rs".to_string()],
489        );
490        assert_eq!(idx.entries.len(), 2);
491
492        idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()]);
493        assert_eq!(idx.entries.len(), 2);
494
495        let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
496        assert!(
497            (b_entry.embedding_f32()[0] - 0.1).abs() < 1e-6,
498            "b.rs embedding should be preserved"
499        );
500    }
501
502    #[test]
503    fn get_aligned_embeddings() {
504        let mut idx = EmbeddingIndex::new(2);
505        let chunks = vec![
506            make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
507            make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
508        ];
509        idx.update(
510            &chunks,
511            &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
512            &["a.rs".to_string(), "b.rs".to_string()],
513        );
514
515        let aligned = idx.get_aligned_embeddings(&chunks).unwrap();
516        assert_eq!(aligned.len(), 2);
517        assert!((aligned[0][0] - 1.0).abs() < 1e-6);
518        assert!((aligned[1][1] - 1.0).abs() < 1e-6);
519    }
520
521    #[test]
522    fn get_aligned_embeddings_missing() {
523        let idx = EmbeddingIndex::new(384);
524        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
525        assert!(idx.get_aligned_embeddings(&chunks).is_none());
526    }
527
528    #[test]
529    fn coverage_calculation() {
530        let mut idx = EmbeddingIndex::new(384);
531        assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
532
533        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
534        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
535        assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
536        assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
537    }
538
539    #[test]
540    fn save_and_load_roundtrip() {
541        let _lock = crate::core::data_dir::test_env_lock();
542        let data_dir = tempfile::tempdir().unwrap();
543        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
544
545        let project_dir = tempfile::tempdir().unwrap();
546
547        let mut idx = EmbeddingIndex::new(3);
548        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
549        idx.update(&chunks, &[(0, vec![1.0, 2.0, 3.0])], &["a.rs".to_string()]);
550        idx.save(project_dir.path()).unwrap();
551
552        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
553        assert_eq!(loaded.dimensions, 3);
554        assert_eq!(loaded.entries.len(), 1);
555        // int8-quantized round-trip: within one quantization step of the original.
556        let recon = loaded.entries[0].embedding_f32();
557        assert!((recon[0] - 1.0).abs() < 0.02);
558        assert!((recon[1] - 2.0).abs() < 0.02);
559        assert!((recon[2] - 3.0).abs() < 0.02);
560
561        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
562    }
563
564    #[test]
565    fn new_with_model_sets_model_id() {
566        let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
567        assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
568        assert_eq!(idx.dimensions, 768);
569    }
570
571    #[test]
572    fn model_mismatch_detection() {
573        let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
574        assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
575        assert!(idx.model_mismatch("jina-code-v2").is_some());
576
577        let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
578        assert_eq!(stored, "all-MiniLM-L6-v2");
579        assert_eq!(current, "jina-code-v2");
580    }
581
582    #[test]
583    fn model_mismatch_none_when_no_model_id() {
584        let idx = EmbeddingIndex::new(384);
585        assert!(idx.model_mismatch("anything").is_none());
586    }
587
588    #[test]
589    fn dimension_mismatch_detection() {
590        let mut idx = EmbeddingIndex::new(384);
591        assert!(!idx.dimension_mismatch(384));
592        assert!(!idx.dimension_mismatch(768)); // no entries = no mismatch
593
594        let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
595        idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
596        assert!(!idx.dimension_mismatch(384));
597        assert!(idx.dimension_mismatch(768));
598    }
599
600    #[test]
601    fn v1_index_migration() {
602        let _lock = crate::core::data_dir::test_env_lock();
603        let data_dir = tempfile::tempdir().unwrap();
604        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
605        let project_dir = tempfile::tempdir().unwrap();
606
607        let v1_json = serde_json::json!({
608            "version": 1,
609            "dimensions": 384,
610            "entries": [],
611            "file_hashes": {}
612        });
613
614        let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
615        std::fs::create_dir_all(&dir).unwrap();
616        std::fs::write(dir.join("embeddings.json"), v1_json.to_string()).unwrap();
617
618        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
619        assert_eq!(loaded.version, CURRENT_VERSION);
620        assert_eq!(loaded.dimensions, 384);
621        assert!(loaded.model_id.is_none());
622
623        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
624    }
625
626    #[test]
627    fn v2_index_quantizes_on_migration() {
628        let _lock = crate::core::data_dir::test_env_lock();
629        let data_dir = tempfile::tempdir().unwrap();
630        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
631        let project_dir = tempfile::tempdir().unwrap();
632
633        // A v2 index with a full-precision f32 entry (pre-quantization on-disk form).
634        let v2_json = serde_json::json!({
635            "version": 2,
636            "dimensions": 3,
637            "model_id": "all-MiniLM-L6-v2",
638            "entries": [{
639                "file_path": "a.rs",
640                "symbol_name": "fn_a",
641                "start_line": 1,
642                "end_line": 3,
643                "embedding": [1.0, 2.0, 3.0],
644                "content_hash": "abc"
645            }],
646            "file_hashes": {}
647        });
648
649        let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
650        std::fs::create_dir_all(&dir).unwrap();
651        std::fs::write(dir.join("embeddings.json"), v2_json.to_string()).unwrap();
652
653        let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
654        assert_eq!(loaded.version, CURRENT_VERSION);
655        // The legacy f32 was migrated to int8 codes and the f32 field emptied.
656        let entry = &loaded.entries[0];
657        assert!(
658            entry.embedding.is_empty(),
659            "f32 field cleared after migration"
660        );
661        assert!(entry.quant.is_some(), "entry is now quantized");
662        let recon = entry.embedding_f32();
663        assert!((recon[2] - 3.0).abs() < 0.02);
664
665        // Migration persisted the smaller form: re-loading sees v3 directly.
666        let reloaded = EmbeddingIndex::load(project_dir.path()).unwrap();
667        assert_eq!(reloaded.version, CURRENT_VERSION);
668        assert!(reloaded.entries[0].quant.is_some());
669
670        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
671    }
672}