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