1use 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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub embedding: Vec<f32>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub quant: Option<QuantizedVector>,
44 pub content_hash: String,
45}
46
47impl EmbeddingEntry {
48 fn embedding_f32(&self) -> Vec<f32> {
51 match &self.quant {
52 Some(q) => q.dequantize(),
53 None => self.embedding.clone(),
54 }
55 }
56}
57
58const 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 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 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 pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
94 self.dimensions != engine_dimensions && !self.entries.is_empty()
95 }
96
97 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 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 pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
133 Self::load(root).unwrap_or_else(|| Self::new(dimensions))
134 }
135
136 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 ¤t_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 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 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 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 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 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)); 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 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 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 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}