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