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