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