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};
18use super::hnsw::FlatEmbeddings;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct EmbeddingIndex {
22 pub version: u32,
23 pub dimensions: usize,
24 #[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 pub quant: QuantizedVector,
40 pub content_hash: String,
41}
42
43impl EmbeddingEntry {
44 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#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum EmbeddingBuildOutcome {
62 Ready,
64 Skipped,
66 ModelNotAvailable(String),
69 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
91pub fn build_or_update(root: &Path, bm25: &super::bm25_index::BM25Index) -> EmbeddingBuildOutcome {
104 #[cfg(feature = "embeddings")]
105 {
106 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 if !crate::core::embeddings::EmbeddingEngine::is_available() {
131 tracing::info!(
132 "[embedding_index] embedding model absent — downloading from HuggingFace"
133 );
134 if let Err(e) = crate::core::embeddings::EmbeddingEngine::ensure_downloaded() {
135 let reason = format!("embedding model auto-download from HuggingFace failed: {e}");
136 tracing::warn!("[embedding_index] build_or_update failed: {reason}");
137 return EmbeddingBuildOutcome::ModelNotAvailable(reason);
138 }
139 }
140
141 let engine = match crate::core::embeddings::shared_engine_result() {
142 Ok(engine) => engine,
143 Err(e) => {
144 let reason = format!("embedding model files found but engine failed to load: {e}");
145 tracing::warn!("[embedding_index] build_or_update failed: {reason}");
146 return EmbeddingBuildOutcome::ModelNotAvailable(reason);
147 }
148 };
149
150 let model_name = engine.model_name();
151 let mut idx = EmbeddingIndex::load(root)
152 .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
153
154 if let Some((stored, current)) = idx.model_mismatch(model_name) {
156 tracing::info!(
157 "[embedding_index] model changed: {stored} → {current}. Re-building from scratch."
158 );
159 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
160 } else if idx.dimension_mismatch(engine.dimensions()) {
161 tracing::info!(
162 "[embedding_index] dimension mismatch: index={}d, engine={}d. Re-building.",
163 idx.dimensions,
164 engine.dimensions()
165 );
166 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
167 }
168
169 let mut changed_files = idx.files_needing_update(&bm25.chunks);
170 changed_files.sort();
171 changed_files.dedup();
172
173 if changed_files.is_empty() {
174 tracing::info!(
175 "[embedding_index] all {} chunks up-to-date, nothing to embed",
176 bm25.chunks.len()
177 );
178 return EmbeddingBuildOutcome::Ready;
179 }
180
181 let changed_set: std::collections::HashSet<&str> =
182 changed_files.iter().map(String::as_str).collect();
183 let mut changed_indices: Vec<usize> = Vec::new();
184 let mut changed_texts: Vec<&str> = Vec::new();
185 for (i, c) in bm25.chunks.iter().enumerate() {
186 if changed_set.contains(c.file_path.as_str()) {
187 changed_indices.push(i);
188 changed_texts.push(&c.content);
189 }
190 }
191
192 let count = changed_files.len();
193 tracing::info!(
194 "[embedding_index] embedding {count} changed files ({total} chunks in index)",
195 total = bm25.chunks.len()
196 );
197
198 let batch_embeddings = match engine.embed_batch(&changed_texts) {
199 Ok(v) => v,
200 Err(e) => {
201 tracing::error!("[embedding_index] batch embed failed: {e}");
202 return EmbeddingBuildOutcome::Failed;
203 }
204 };
205
206 let new_embeddings: Vec<(usize, Vec<f32>)> =
207 changed_indices.into_iter().zip(batch_embeddings).collect();
208
209 idx.update(&bm25.chunks, &new_embeddings, &changed_files, None);
210
211 if let Err(e) = idx.save(root) {
212 tracing::error!("[embedding_index] save failed: {e}");
213 return EmbeddingBuildOutcome::Failed;
214 }
215
216 tracing::info!(
217 "[embedding_index] successfully persisted {count} file embeddings ({total} chunks)",
218 total = bm25.chunks.len()
219 );
220 EmbeddingBuildOutcome::Ready
221 }
222
223 #[cfg(not(feature = "embeddings"))]
224 {
225 let _ = (root, bm25);
226 EmbeddingBuildOutcome::Skipped
227 }
228}
229
230const CURRENT_VERSION: u32 = 3;
232
233impl EmbeddingIndex {
234 pub fn new(dimensions: usize) -> Self {
235 Self {
236 version: CURRENT_VERSION,
237 dimensions,
238 model_id: None,
239 entries: Vec::new(),
240 file_hashes: HashMap::new(),
241 }
242 }
243
244 pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
246 Self {
247 version: CURRENT_VERSION,
248 dimensions,
249 model_id: Some(model_id.to_string()),
250 entries: Vec::new(),
251 file_hashes: HashMap::new(),
252 }
253 }
254
255 pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
258 match &self.model_id {
259 Some(stored) if stored != current_model => Some((stored, current_model)),
260 _ => None,
261 }
262 }
263
264 pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
266 self.dimensions != engine_dimensions && !self.entries.is_empty()
267 }
268
269 pub fn memory_usage_bytes(&self) -> usize {
271 let entries_size: usize = self
272 .entries
273 .iter()
274 .map(|e| {
275 e.file_path.len()
276 + e.symbol_name.len()
277 + e.content_hash.len()
278 + e.quant.code.len()
279 + 4
280 + 48
281 })
282 .sum();
283 let hashes_size: usize = self
284 .file_hashes
285 .iter()
286 .map(|(k, v)| k.len() + v.len() + 32)
287 .sum();
288 entries_size + hashes_size
289 }
290
291 pub fn unload(&mut self) {
293 let usage = self.memory_usage_bytes();
294 self.entries = Vec::new();
295 self.file_hashes = HashMap::new();
296 tracing::info!(
297 "[embeddings] unloaded index, freed ~{:.1}MB",
298 usage as f64 / 1_048_576.0
299 );
300 }
301
302 pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
304 Self::load(root).unwrap_or_else(|| Self::new(dimensions))
305 }
306
307 pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
312 if self.file_hashes.is_empty() {
314 let mut files: Vec<String> = chunks.iter().map(|c| c.file_path.clone()).collect();
315 files.sort();
316 files.dedup();
317 return files;
318 }
319
320 let current_hashes = compute_file_hashes(chunks);
321
322 let mut needs_update = Vec::new();
323 for (file, hash) in ¤t_hashes {
324 match self.file_hashes.get(file) {
325 Some(old_hash) if old_hash == hash => {}
326 _ => needs_update.push(file.clone()),
327 }
328 }
329
330 for file in self.file_hashes.keys() {
331 if !current_hashes.contains_key(file) {
332 needs_update.push(file.clone());
333 }
334 }
335
336 needs_update
337 }
338
339 pub fn pending_chunk_count(&self, chunks: &[CodeChunk]) -> usize {
348 let changed = self.files_needing_update(chunks);
349 if changed.is_empty() {
350 return 0;
351 }
352 let changed: std::collections::HashSet<&str> = changed.iter().map(String::as_str).collect();
353 chunks
354 .iter()
355 .filter(|c| changed.contains(c.file_path.as_str()))
356 .count()
357 }
358
359 pub fn update(
366 &mut self,
367 chunks: &[CodeChunk],
368 new_embeddings: &[(usize, Vec<f32>)],
369 changed_files: &[String],
370 precomputed_hashes: Option<HashMap<String, String>>,
371 ) {
372 self.entries
373 .retain(|e| !changed_files.contains(&e.file_path));
374
375 for file in changed_files {
376 self.file_hashes.remove(file);
377 }
378
379 let current_hashes = precomputed_hashes.unwrap_or_else(|| compute_file_hashes(chunks));
380 for file in changed_files {
381 if let Some(hash) = current_hashes.get(file) {
382 self.file_hashes.insert(file.clone(), hash.clone());
383 }
384 }
385
386 for &(chunk_idx, ref embedding) in new_embeddings {
387 if let Some(chunk) = chunks.get(chunk_idx) {
388 let content_hash = hash_content(&chunk.content);
389 self.entries.push(EmbeddingEntry {
390 file_path: chunk.file_path.clone(),
391 symbol_name: chunk.symbol_name.clone(),
392 start_line: chunk.start_line,
393 end_line: chunk.end_line,
394 quant: embedding_quant::quantize(embedding),
395 content_hash,
396 });
397 }
398 }
399 }
400
401 pub fn get_aligned_flat(&self, chunks: &[CodeChunk]) -> Option<FlatEmbeddings> {
409 let dim = self.dimensions;
410 let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
411 HashMap::with_capacity(self.entries.len());
412 for e in &self.entries {
413 map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
414 }
415
416 let n = chunks.len();
417 let mut data = Vec::with_capacity(n * dim);
418 for chunk in chunks {
419 let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
420 entry.write_into_flat(&mut data);
421 }
422 Some(FlatEmbeddings {
423 data: Arc::from(data),
424 dim,
425 })
426 }
427
428 pub fn coverage(&self, total_chunks: usize) -> f64 {
429 if total_chunks == 0 {
430 return 0.0;
431 }
432 self.entries.len() as f64 / total_chunks as f64
433 }
434
435 pub fn save(&self, root: &Path) -> std::io::Result<()> {
436 let dir = index_dir(root);
437 std::fs::create_dir_all(&dir)?;
438 let data = postcard::to_allocvec(self).map_err(std::io::Error::other)?;
440 std::fs::write(dir.join("embeddings.bin"), data)?;
441 Ok(())
442 }
443
444 pub fn load(root: &Path) -> Option<Self> {
445 let bin_path = index_dir(root).join("embeddings.bin");
446 let data = std::fs::read(&bin_path).ok()?;
447 match postcard::from_bytes::<Self>(&data) {
448 Ok(idx) if idx.version == CURRENT_VERSION => Some(idx),
450 Ok(idx) => {
454 tracing::warn!(
455 "[embeddings] index format v{} != current v{CURRENT_VERSION} — \
456 removing and rebuilding from scratch",
457 idx.version
458 );
459 let _ = std::fs::remove_file(&bin_path);
460 None
461 }
462 Err(_) => {
463 tracing::warn!(
464 "[embeddings] corrupt embeddings.bin — removing and will rebuild from scratch"
465 );
466 let _ = std::fs::remove_file(&bin_path);
467 None
468 }
469 }
470 }
471}
472
473fn index_dir(root: &Path) -> PathBuf {
474 crate::core::index_namespace::vectors_dir(root)
475}
476
477fn hash_content(content: &str) -> String {
478 let mut hasher = Md5::new();
479 hasher.update(content.as_bytes());
480 crate::core::agent_identity::hex_encode(&hasher.finalize())
481}
482
483fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
484 let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
485 for chunk in chunks {
486 by_file
487 .entry(chunk.file_path.as_str())
488 .or_default()
489 .push(chunk);
490 }
491
492 let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
493 for (file, mut file_chunks) in by_file {
494 file_chunks.sort_by(|a, b| {
495 (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
496 b.start_line,
497 b.end_line,
498 b.symbol_name.as_str(),
499 ))
500 });
501
502 let mut hasher = Md5::new();
503 hasher.update(file.as_bytes());
504 for c in file_chunks {
505 hasher.update(c.start_line.to_le_bytes());
506 hasher.update(c.end_line.to_le_bytes());
507 hasher.update(c.symbol_name.as_bytes());
508 hasher.update([kind_tag(&c.kind)]);
509 hasher.update(c.content.as_bytes());
510 }
511 out.insert(
512 file.to_string(),
513 crate::core::agent_identity::hex_encode(&hasher.finalize()),
514 );
515 }
516 out
517}
518
519fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
520 use super::bm25_index::ChunkKind;
521 match kind {
522 ChunkKind::Function => 1,
523 ChunkKind::Struct => 2,
524 ChunkKind::Impl => 3,
525 ChunkKind::Module => 4,
526 ChunkKind::Class => 5,
527 ChunkKind::Method => 6,
528 ChunkKind::Other => 7,
529 ChunkKind::Issue => 8,
530 ChunkKind::PullRequest => 9,
531 ChunkKind::WikiPage => 10,
532 ChunkKind::DbSchema => 11,
533 ChunkKind::ApiEndpoint => 12,
534 ChunkKind::Ticket => 13,
535 ChunkKind::ExternalOther => 14,
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use crate::core::bm25_index::{ChunkKind, CodeChunk};
543
544 fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
545 CodeChunk {
546 file_path: file.to_string(),
547 symbol_name: name.to_string(),
548 kind: ChunkKind::Function,
549 start_line: start,
550 end_line: end,
551 content: content.to_string(),
552 tokens: vec![name.to_string()],
553 token_count: 1,
554 }
555 }
556
557 fn dummy_embedding(dim: usize) -> Vec<f32> {
558 vec![0.1; dim]
559 }
560
561 #[test]
562 fn new_index_is_empty() {
563 let idx = EmbeddingIndex::new(384);
564 assert!(idx.entries.is_empty());
565 assert!(idx.file_hashes.is_empty());
566 assert_eq!(idx.dimensions, 384);
567 }
568
569 #[test]
570 fn files_needing_update_all_new() {
571 let idx = EmbeddingIndex::new(384);
572 let chunks = vec![
573 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
574 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
575 ];
576 let needs = idx.files_needing_update(&chunks);
577 assert_eq!(needs.len(), 2);
578 }
579
580 #[test]
581 fn files_needing_update_unchanged() {
582 let mut idx = EmbeddingIndex::new(384);
583 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
584
585 idx.update(
586 &chunks,
587 &[(0, dummy_embedding(384))],
588 &["a.rs".to_string()],
589 None,
590 );
591
592 let needs = idx.files_needing_update(&chunks);
593 assert!(needs.is_empty(), "unchanged file should not need update");
594 }
595
596 #[test]
597 fn files_needing_update_changed_content() {
598 let mut idx = EmbeddingIndex::new(384);
599 let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
600 idx.update(
601 &chunks_v1,
602 &[(0, dummy_embedding(384))],
603 &["a.rs".to_string()],
604 None,
605 );
606
607 let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
608 let needs = idx.files_needing_update(&chunks_v2);
609 assert!(
610 needs.contains(&"a.rs".to_string()),
611 "changed file should need update"
612 );
613 }
614
615 #[test]
616 fn files_needing_update_detects_change_in_later_chunk() {
617 let mut idx = EmbeddingIndex::new(3);
618 let chunks_v1 = vec![
619 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
620 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
621 ];
622 idx.update(
623 &chunks_v1,
624 &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
625 &["a.rs".to_string()],
626 None,
627 );
628
629 let chunks_v2 = vec![
630 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
631 make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
632 ];
633 let needs = idx.files_needing_update(&chunks_v2);
634 assert!(
635 needs.contains(&"a.rs".to_string()),
636 "changing a later chunk should trigger re-embedding"
637 );
638 }
639
640 #[test]
641 fn files_needing_update_deleted_file() {
642 let mut idx = EmbeddingIndex::new(384);
643 let chunks = vec![
644 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
645 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
646 ];
647 idx.update(
648 &chunks,
649 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
650 &["a.rs".to_string(), "b.rs".to_string()],
651 None,
652 );
653
654 let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
655 let needs = idx.files_needing_update(&chunks_after);
656 assert!(
657 needs.contains(&"b.rs".to_string()),
658 "deleted file should trigger update"
659 );
660 }
661
662 #[test]
663 fn pending_chunk_count_cold_start_counts_every_chunk() {
664 let idx = EmbeddingIndex::new(384);
665 let chunks = vec![
666 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
667 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
668 make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
669 ];
670 assert_eq!(
671 idx.pending_chunk_count(&chunks),
672 3,
673 "an empty index must report every chunk as pending (cold start)"
674 );
675 }
676
677 #[test]
678 fn pending_chunk_count_zero_when_fully_embedded() {
679 let mut idx = EmbeddingIndex::new(384);
680 let chunks = vec![
681 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
682 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
683 ];
684 idx.update(
685 &chunks,
686 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
687 &["a.rs".to_string(), "b.rs".to_string()],
688 None,
689 );
690 assert_eq!(
691 idx.pending_chunk_count(&chunks),
692 0,
693 "a fully-embedded index has no pending chunks (warm path stays inline)"
694 );
695 }
696
697 #[test]
698 fn pending_chunk_count_only_counts_changed_files_chunks() {
699 let mut idx = EmbeddingIndex::new(384);
700 let chunks_v1 = vec![
701 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
702 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
703 make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
704 ];
705 idx.update(
706 &chunks_v1,
707 &[
708 (0, dummy_embedding(384)),
709 (1, dummy_embedding(384)),
710 (2, dummy_embedding(384)),
711 ],
712 &["a.rs".to_string(), "b.rs".to_string()],
713 None,
714 );
715
716 let chunks_v2 = vec![
718 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
719 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
720 make_chunk("b.rs", "fn_c", "fn c() { changed }", 1, 3),
721 ];
722 assert_eq!(
723 idx.pending_chunk_count(&chunks_v2),
724 1,
725 "incremental update must only count the changed file's chunks"
726 );
727 }
728
729 #[test]
730 fn update_preserves_unchanged() {
731 let mut idx = EmbeddingIndex::new(384);
732 let chunks = vec![
733 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
734 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
735 ];
736 idx.update(
737 &chunks,
738 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
739 &["a.rs".to_string(), "b.rs".to_string()],
740 None,
741 );
742 assert_eq!(idx.entries.len(), 2);
743
744 idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()], None);
745 assert_eq!(idx.entries.len(), 2);
746
747 let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
748 let b_embed = b_entry.quant.dequantize();
749 assert!(
750 (b_embed[0] - 0.1).abs() < 1e-6,
751 "b.rs embedding should be preserved"
752 );
753 }
754
755 #[test]
756 fn get_aligned_flat_ok() {
757 let mut idx = EmbeddingIndex::new(2);
758 let chunks = vec![
759 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
760 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
761 ];
762 idx.update(
763 &chunks,
764 &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
765 &["a.rs".to_string(), "b.rs".to_string()],
766 None,
767 );
768
769 let flat = idx.get_aligned_flat(&chunks).unwrap();
770 assert_eq!(flat.n_vectors(), 2);
771 assert_eq!(flat.dim, 2);
772 assert!((flat.get(0)[0] - 1.0).abs() < 1e-6);
773 assert!((flat.get(1)[1] - 1.0).abs() < 1e-6);
774 }
775
776 #[test]
777 fn get_aligned_flat_missing() {
778 let idx = EmbeddingIndex::new(384);
779 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
780 assert!(idx.get_aligned_flat(&chunks).is_none());
781 }
782
783 #[test]
784 fn coverage_calculation() {
785 let mut idx = EmbeddingIndex::new(384);
786 assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
787
788 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
789 idx.update(
790 &chunks,
791 &[(0, dummy_embedding(384))],
792 &["a.rs".to_string()],
793 None,
794 );
795 assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
796 assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
797 }
798
799 #[test]
800 fn save_and_load_roundtrip() {
801 let _lock = crate::core::data_dir::test_env_lock();
802 let data_dir = tempfile::tempdir().unwrap();
803 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
804
805 let project_dir = tempfile::tempdir().unwrap();
806
807 let mut idx = EmbeddingIndex::new(3);
808 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
809 idx.update(
810 &chunks,
811 &[(0, vec![1.0, 2.0, 3.0])],
812 &["a.rs".to_string()],
813 None,
814 );
815 idx.save(project_dir.path()).unwrap();
816
817 let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
818 assert_eq!(loaded.dimensions, 3);
819 assert_eq!(loaded.entries.len(), 1);
820 let recon = loaded.entries[0].quant.dequantize();
822 assert!((recon[0] - 1.0).abs() < 0.02);
823 assert!((recon[1] - 2.0).abs() < 0.02);
824 assert!((recon[2] - 3.0).abs() < 0.02);
825
826 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
827 }
828
829 #[test]
830 fn new_with_model_sets_model_id() {
831 let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
832 assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
833 assert_eq!(idx.dimensions, 768);
834 }
835
836 #[test]
837 fn model_mismatch_detection() {
838 let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
839 assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
840 assert!(idx.model_mismatch("jina-code-v2").is_some());
841
842 let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
843 assert_eq!(stored, "all-MiniLM-L6-v2");
844 assert_eq!(current, "jina-code-v2");
845 }
846
847 #[test]
848 fn model_mismatch_none_when_no_model_id() {
849 let idx = EmbeddingIndex::new(384);
850 assert!(idx.model_mismatch("anything").is_none());
851 }
852
853 #[test]
854 fn dimension_mismatch_detection() {
855 let mut idx = EmbeddingIndex::new(384);
856 assert!(!idx.dimension_mismatch(384));
857 assert!(!idx.dimension_mismatch(768)); let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
860 idx.update(
861 &chunks,
862 &[(0, dummy_embedding(384))],
863 &["a.rs".to_string()],
864 None,
865 );
866 assert!(!idx.dimension_mismatch(384));
867 assert!(idx.dimension_mismatch(768));
868 }
869}