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}. Check with `lean-ctx embeddings status`"
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 clear_partial_marker(root);
189 return EmbeddingBuildOutcome::Ready;
190 }
191
192 let changed_set: std::collections::HashSet<&str> =
193 changed_files.iter().map(String::as_str).collect();
194 let changed_indices: Vec<usize> = bm25
195 .chunks
196 .iter()
197 .enumerate()
198 .filter(|(_, c)| changed_set.contains(c.file_path.as_str()))
199 .map(|(i, _)| i)
200 .collect();
201
202 let count = changed_files.len();
203 let chunk_total = changed_indices.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 let file_hashes = compute_file_hashes(&bm25.chunks);
219 let mut remaining: HashMap<&str, usize> = HashMap::new();
220 for &i in &changed_indices {
221 *remaining
222 .entry(bm25.chunks[i].file_path.as_str())
223 .or_default() += 1;
224 }
225 let mut checkpoint = EmbedCheckpoint {
226 pending: Vec::new(),
227 ready_files: changed_files
228 .iter()
229 .filter(|f| !remaining.contains_key(f.as_str()))
230 .cloned()
231 .collect(),
232 };
233
234 const EMBED_PROGRESS_BATCH: usize = 32;
237 let mut embedded = 0usize;
238 let mut last_save = std::time::Instant::now();
239 for batch in changed_indices.chunks(EMBED_PROGRESS_BATCH) {
240 let texts: Vec<&str> = batch
241 .iter()
242 .map(|&i| bm25.chunks[i].content.as_str())
243 .collect();
244 match engine.embed_batch(&texts) {
245 Ok(vectors) => {
246 embedded += batch.len();
247 for (&i, vector) in batch.iter().zip(vectors) {
248 let file = bm25.chunks[i].file_path.as_str();
249 checkpoint.pending.push((i, vector));
250 if let Some(left) = remaining.get_mut(file) {
251 *left -= 1;
252 if *left == 0 {
253 checkpoint.ready_files.push(file.to_string());
254 }
255 }
256 }
257 crate::core::index_progress::report(
258 &root_key,
259 crate::core::index_progress::IndexComponent::Semantic,
260 embedded as u64,
261 chunk_total,
262 );
263 }
264 Err(e) => {
265 tracing::error!("[embedding_index] batch embed failed: {e}");
266 checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes);
268 if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
269 tracing::warn!("[embedding_index] checkpoint save failed: {e}");
270 }
271 return EmbeddingBuildOutcome::Failed;
272 }
273 }
274 if last_save.elapsed() >= EMBED_CHECKPOINT_INTERVAL
277 && checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes)
278 {
279 if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
280 tracing::warn!("[embedding_index] checkpoint save failed: {e}");
281 }
282 last_save = std::time::Instant::now();
283 }
284 }
285
286 checkpoint.commit(&mut idx, &bm25.chunks, &file_hashes);
287
288 if let Err(e) = save_progress(&idx, root, bm25.chunks.len()) {
289 tracing::error!("[embedding_index] save failed: {e}");
290 return EmbeddingBuildOutcome::Failed;
291 }
292
293 tracing::info!(
294 "[embedding_index] successfully persisted {count} file embeddings ({total} chunks)",
295 total = bm25.chunks.len()
296 );
297 EmbeddingBuildOutcome::Ready
298 }
299
300 #[cfg(not(feature = "embeddings"))]
301 {
302 let _ = (root, bm25);
303 EmbeddingBuildOutcome::Skipped
304 }
305}
306
307#[cfg(feature = "embeddings")]
309const EMBED_CHECKPOINT_INTERVAL: std::time::Duration = std::time::Duration::from_mins(1);
310
311#[cfg(feature = "embeddings")]
315struct EmbedCheckpoint {
316 pending: Vec<(usize, Vec<f32>)>,
317 ready_files: Vec<String>,
318}
319
320#[cfg(feature = "embeddings")]
321impl EmbedCheckpoint {
322 fn commit(
324 &mut self,
325 idx: &mut EmbeddingIndex,
326 chunks: &[CodeChunk],
327 file_hashes: &HashMap<String, String>,
328 ) -> bool {
329 if self.ready_files.is_empty() {
330 return false;
331 }
332 let ready: std::collections::HashSet<&str> =
333 self.ready_files.iter().map(String::as_str).collect();
334 let (done, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending)
335 .into_iter()
336 .partition(|(i, _)| ready.contains(chunks[*i].file_path.as_str()));
337 drop(ready);
338 idx.update(chunks, &done, &self.ready_files, Some(file_hashes));
339 self.pending = rest;
340 self.ready_files.clear();
341 true
342 }
343}
344
345const CURRENT_VERSION: u32 = 3;
347
348impl EmbeddingIndex {
349 pub fn new(dimensions: usize) -> Self {
350 Self {
351 version: CURRENT_VERSION,
352 dimensions,
353 model_id: None,
354 entries: Vec::new(),
355 file_hashes: HashMap::new(),
356 }
357 }
358
359 pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
361 Self {
362 version: CURRENT_VERSION,
363 dimensions,
364 model_id: Some(model_id.to_string()),
365 entries: Vec::new(),
366 file_hashes: HashMap::new(),
367 }
368 }
369
370 pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
373 match &self.model_id {
374 Some(stored) if stored != current_model => Some((stored, current_model)),
375 _ => None,
376 }
377 }
378
379 pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
381 self.dimensions != engine_dimensions && !self.entries.is_empty()
382 }
383
384 pub fn memory_usage_bytes(&self) -> usize {
386 let entries_size: usize = self
387 .entries
388 .iter()
389 .map(|e| {
390 e.file_path.len()
391 + e.symbol_name.len()
392 + e.content_hash.len()
393 + e.quant.code.len()
394 + 4
395 + 48
396 })
397 .sum();
398 let hashes_size: usize = self
399 .file_hashes
400 .iter()
401 .map(|(k, v)| k.len() + v.len() + 32)
402 .sum();
403 entries_size + hashes_size
404 }
405
406 pub fn unload(&mut self) {
408 let usage = self.memory_usage_bytes();
409 self.entries = Vec::new();
410 self.file_hashes = HashMap::new();
411 tracing::info!(
412 "[embeddings] unloaded index, freed ~{:.1}MB",
413 usage as f64 / 1_048_576.0
414 );
415 }
416
417 pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
419 Self::load(root).unwrap_or_else(|| Self::new(dimensions))
420 }
421
422 pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
427 if self.file_hashes.is_empty() {
429 let mut files: Vec<String> = chunks.iter().map(|c| c.file_path.clone()).collect();
430 files.sort();
431 files.dedup();
432 return files;
433 }
434
435 let current_hashes = compute_file_hashes(chunks);
436
437 let mut needs_update = Vec::new();
438 for (file, hash) in ¤t_hashes {
439 match self.file_hashes.get(file) {
440 Some(old_hash) if old_hash == hash => {}
441 _ => needs_update.push(file.clone()),
442 }
443 }
444
445 for file in self.file_hashes.keys() {
446 if !current_hashes.contains_key(file) {
447 needs_update.push(file.clone());
448 }
449 }
450
451 needs_update
452 }
453
454 pub fn pending_chunk_count(&self, chunks: &[CodeChunk]) -> usize {
463 let changed = self.files_needing_update(chunks);
464 if changed.is_empty() {
465 return 0;
466 }
467 let changed: std::collections::HashSet<&str> = changed.iter().map(String::as_str).collect();
468 chunks
469 .iter()
470 .filter(|c| changed.contains(c.file_path.as_str()))
471 .count()
472 }
473
474 pub fn update(
481 &mut self,
482 chunks: &[CodeChunk],
483 new_embeddings: &[(usize, Vec<f32>)],
484 changed_files: &[String],
485 precomputed_hashes: Option<&HashMap<String, String>>,
486 ) {
487 let changed: std::collections::HashSet<&str> =
488 changed_files.iter().map(String::as_str).collect();
489 self.entries
490 .retain(|e| !changed.contains(e.file_path.as_str()));
491
492 for file in changed_files {
493 self.file_hashes.remove(file);
494 }
495
496 let computed;
497 let current_hashes = if let Some(hashes) = precomputed_hashes {
498 hashes
499 } else {
500 computed = compute_file_hashes(chunks);
501 &computed
502 };
503 for file in changed_files {
504 if let Some(hash) = current_hashes.get(file) {
505 self.file_hashes.insert(file.clone(), hash.clone());
506 }
507 }
508
509 for &(chunk_idx, ref embedding) in new_embeddings {
510 if let Some(chunk) = chunks.get(chunk_idx) {
511 let content_hash = hash_content(&chunk.content);
512 self.entries.push(EmbeddingEntry {
513 file_path: chunk.file_path.clone(),
514 symbol_name: chunk.symbol_name.clone(),
515 start_line: chunk.start_line,
516 end_line: chunk.end_line,
517 quant: embedding_quant::quantize(embedding),
518 content_hash,
519 });
520 }
521 }
522 }
523
524 pub fn get_aligned_flat(&self, chunks: &[CodeChunk]) -> Option<FlatEmbeddings> {
532 let dim = self.dimensions;
533 let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
534 HashMap::with_capacity(self.entries.len());
535 for e in &self.entries {
536 map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
537 }
538
539 let n = chunks.len();
540 let mut data = Vec::with_capacity(n * dim);
541 for chunk in chunks {
542 let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
543 entry.write_into_flat(&mut data);
544 }
545 Some(FlatEmbeddings {
546 data: Arc::from(data),
547 dim,
548 })
549 }
550
551 pub fn coverage(&self, total_chunks: usize) -> f64 {
552 if total_chunks == 0 {
553 return 0.0;
554 }
555 self.entries.len() as f64 / total_chunks as f64
556 }
557
558 pub fn save(&self, root: &Path) -> std::io::Result<()> {
559 let dir = index_dir(root);
560 std::fs::create_dir_all(&dir)?;
561 let data = postcard::to_allocvec(self).map_err(std::io::Error::other)?;
563 crate::core::atomic_fs::try_atomic_write(&dir.join("embeddings.bin"), &data, None)?;
566 Ok(())
567 }
568
569 pub fn load(root: &Path) -> Option<Self> {
570 let bin_path = index_dir(root).join("embeddings.bin");
571 let data = std::fs::read(&bin_path).ok()?;
572 match postcard::from_bytes::<Self>(&data) {
573 Ok(idx) if idx.version == CURRENT_VERSION => Some(idx),
575 Ok(idx) => {
579 tracing::warn!(
580 "[embeddings] index format v{} != current v{CURRENT_VERSION} — \
581 removing and rebuilding from scratch",
582 idx.version
583 );
584 let _ = std::fs::remove_file(&bin_path);
585 None
586 }
587 Err(_) => {
588 tracing::warn!(
589 "[embeddings] corrupt embeddings.bin — removing and will rebuild from scratch"
590 );
591 let _ = std::fs::remove_file(&bin_path);
592 None
593 }
594 }
595 }
596}
597
598fn index_dir(root: &Path) -> PathBuf {
599 crate::core::index_namespace::vectors_dir(root)
600}
601
602fn partial_marker_path(root: &Path) -> PathBuf {
605 index_dir(root).join("embeddings.partial")
606}
607
608pub fn partial_progress(root: &Path) -> Option<(u64, u64)> {
611 let text = std::fs::read_to_string(partial_marker_path(root)).ok()?;
612 let mut parts = text.split_whitespace().map(str::parse::<u64>);
613 match (parts.next(), parts.next()) {
614 (Some(Ok(done)), Some(Ok(total))) => Some((done, total)),
615 _ => None,
616 }
617}
618
619pub fn clear_partial_marker(root: &Path) {
621 let _ = std::fs::remove_file(partial_marker_path(root));
622}
623
624#[cfg(feature = "embeddings")]
627fn save_progress(idx: &EmbeddingIndex, root: &Path, total_chunks: usize) -> std::io::Result<()> {
628 idx.save(root)?;
629 let marker = partial_marker_path(root);
630 if idx.entries.len() < total_chunks {
631 let body = format!("{} {total_chunks}\n", idx.entries.len());
632 crate::core::atomic_fs::try_atomic_write(&marker, body.as_bytes(), None)?;
633 } else {
634 match std::fs::remove_file(&marker) {
635 Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
636 _ => {}
637 }
638 }
639 Ok(())
640}
641
642fn hash_content(content: &str) -> String {
643 let mut hasher = Md5::new();
644 hasher.update(content.as_bytes());
645 crate::core::agent_identity::hex_encode(&hasher.finalize())
646}
647
648fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
649 let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
650 for chunk in chunks {
651 by_file
652 .entry(chunk.file_path.as_str())
653 .or_default()
654 .push(chunk);
655 }
656
657 let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
658 for (file, mut file_chunks) in by_file {
659 file_chunks.sort_by(|a, b| {
660 (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
661 b.start_line,
662 b.end_line,
663 b.symbol_name.as_str(),
664 ))
665 });
666
667 let mut hasher = Md5::new();
668 hasher.update(file.as_bytes());
669 for c in file_chunks {
670 hasher.update(c.start_line.to_le_bytes());
671 hasher.update(c.end_line.to_le_bytes());
672 hasher.update(c.symbol_name.as_bytes());
673 hasher.update([kind_tag(&c.kind)]);
674 hasher.update(c.content.as_bytes());
675 }
676 out.insert(
677 file.to_string(),
678 crate::core::agent_identity::hex_encode(&hasher.finalize()),
679 );
680 }
681 out
682}
683
684fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
685 use super::bm25_index::ChunkKind;
686 match kind {
687 ChunkKind::Function => 1,
688 ChunkKind::Struct => 2,
689 ChunkKind::Impl => 3,
690 ChunkKind::Module => 4,
691 ChunkKind::Class => 5,
692 ChunkKind::Method => 6,
693 ChunkKind::Other => 7,
694 ChunkKind::Issue => 8,
695 ChunkKind::PullRequest => 9,
696 ChunkKind::WikiPage => 10,
697 ChunkKind::DbSchema => 11,
698 ChunkKind::ApiEndpoint => 12,
699 ChunkKind::Ticket => 13,
700 ChunkKind::ExternalOther => 14,
701 }
702}
703
704#[cfg(test)]
705pub mod tests {
706 use super::*;
707 use crate::core::bm25_index::{ChunkKind, CodeChunk};
708
709 fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
710 CodeChunk {
711 input_origin: None,
712 file_path: file.to_string(),
713 symbol_name: name.to_string(),
714 kind: ChunkKind::Function,
715 start_line: start,
716 end_line: end,
717 content: content.to_string(),
718 tokens: vec![name.to_string()],
719 token_count: 1,
720 }
721 }
722
723 fn dummy_embedding(dim: usize) -> Vec<f32> {
724 vec![0.1; dim]
725 }
726
727 #[test]
728 fn new_index_is_empty() {
729 let idx = EmbeddingIndex::new(384);
730 assert!(idx.entries.is_empty());
731 assert!(idx.file_hashes.is_empty());
732 assert_eq!(idx.dimensions, 384);
733 }
734
735 #[test]
736 fn files_needing_update_all_new() {
737 let idx = EmbeddingIndex::new(384);
738 let chunks = vec![
739 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
740 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
741 ];
742 let needs = idx.files_needing_update(&chunks);
743 assert_eq!(needs.len(), 2);
744 }
745
746 #[cfg(feature = "embeddings")]
747 #[test]
748 fn checkpoint_commits_only_fully_embedded_files() {
749 let chunks = vec![
750 make_chunk("a.rs", "fn_a1", "fn a1() {}", 1, 3),
751 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
752 make_chunk("a.rs", "fn_a2", "fn a2() {}", 5, 7),
753 ];
754 let hashes = compute_file_hashes(&chunks);
755 let mut idx = EmbeddingIndex::new(384);
756 let mut checkpoint = EmbedCheckpoint {
758 pending: vec![(0, dummy_embedding(384)), (1, dummy_embedding(384))],
759 ready_files: vec!["b.rs".to_string()],
760 };
761
762 assert!(checkpoint.commit(&mut idx, &chunks, &hashes));
763 assert_eq!(idx.entries.len(), 1);
764 assert_eq!(idx.entries[0].file_path, "b.rs");
765 assert_eq!(
766 checkpoint.pending.len(),
767 1,
768 "a.rs chunk waits for its sibling"
769 );
770 assert!(
771 checkpoint.ready_files.is_empty(),
772 "{:?}",
773 checkpoint.ready_files
774 );
775 assert_eq!(idx.files_needing_update(&chunks), vec!["a.rs".to_string()]);
777 assert!(!checkpoint.commit(&mut idx, &chunks, &hashes));
778
779 checkpoint.pending.push((2, dummy_embedding(384)));
780 checkpoint.ready_files.push("a.rs".to_string());
781 assert!(checkpoint.commit(&mut idx, &chunks, &hashes));
782 assert_eq!(idx.entries.len(), 3);
783 {
784 let found = &idx.files_needing_update(&chunks);
785 assert!(found.is_empty(), "{found:?}");
786 }
787 assert!(idx.get_aligned_flat(&chunks).is_some());
788 }
789
790 #[cfg(feature = "embeddings")]
791 #[test]
792 fn checkpoint_marker_tracks_partial_and_clears_on_completion() {
793 let _lock = crate::core::data_dir::test_env_lock();
794 let data_dir = tempfile::tempdir().unwrap();
795 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
796 let project_dir = tempfile::tempdir().unwrap();
797 let root = project_dir.path();
798
799 let chunks = vec![
800 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
801 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
802 ];
803 let mut idx = EmbeddingIndex::new(3);
804 idx.update(&chunks, &[(0, vec![1.0, 0.0, 0.0])], &["a.rs".into()], None);
805 save_progress(&idx, root, chunks.len()).unwrap();
806 assert_eq!(partial_progress(root), Some((1, 2)));
807 let resumed = EmbeddingIndex::load(root).unwrap();
809 assert_eq!(
810 resumed.files_needing_update(&chunks),
811 vec!["b.rs".to_string()]
812 );
813
814 idx.update(&chunks, &[(1, vec![0.0, 1.0, 0.0])], &["b.rs".into()], None);
815 save_progress(&idx, root, chunks.len()).unwrap();
816 assert_eq!(partial_progress(root), None);
817
818 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
819 }
820
821 #[test]
822 fn files_needing_update_unchanged() {
823 let mut idx = EmbeddingIndex::new(384);
824 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
825
826 idx.update(
827 &chunks,
828 &[(0, dummy_embedding(384))],
829 &["a.rs".to_string()],
830 None,
831 );
832
833 let needs = idx.files_needing_update(&chunks);
834 assert!(needs.is_empty(), "unchanged file should not need update");
835 }
836
837 #[test]
838 fn files_needing_update_changed_content() {
839 let mut idx = EmbeddingIndex::new(384);
840 let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
841 idx.update(
842 &chunks_v1,
843 &[(0, dummy_embedding(384))],
844 &["a.rs".to_string()],
845 None,
846 );
847
848 let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
849 let needs = idx.files_needing_update(&chunks_v2);
850 assert!(
851 needs.contains(&"a.rs".to_string()),
852 "changed file should need update"
853 );
854 }
855
856 #[test]
857 fn files_needing_update_detects_change_in_later_chunk() {
858 let mut idx = EmbeddingIndex::new(3);
859 let chunks_v1 = vec![
860 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
861 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
862 ];
863 idx.update(
864 &chunks_v1,
865 &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
866 &["a.rs".to_string()],
867 None,
868 );
869
870 let chunks_v2 = vec![
871 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
872 make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
873 ];
874 let needs = idx.files_needing_update(&chunks_v2);
875 assert!(
876 needs.contains(&"a.rs".to_string()),
877 "changing a later chunk should trigger re-embedding"
878 );
879 }
880
881 #[test]
882 fn files_needing_update_deleted_file() {
883 let mut idx = EmbeddingIndex::new(384);
884 let chunks = vec![
885 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
886 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
887 ];
888 idx.update(
889 &chunks,
890 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
891 &["a.rs".to_string(), "b.rs".to_string()],
892 None,
893 );
894
895 let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
896 let needs = idx.files_needing_update(&chunks_after);
897 assert!(
898 needs.contains(&"b.rs".to_string()),
899 "deleted file should trigger update"
900 );
901 }
902
903 #[test]
904 fn pending_chunk_count_cold_start_counts_every_chunk() {
905 let idx = EmbeddingIndex::new(384);
906 let chunks = vec![
907 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
908 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
909 make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
910 ];
911 assert_eq!(
912 idx.pending_chunk_count(&chunks),
913 3,
914 "an empty index must report every chunk as pending (cold start)"
915 );
916 }
917
918 #[test]
919 fn pending_chunk_count_zero_when_fully_embedded() {
920 let mut idx = EmbeddingIndex::new(384);
921 let chunks = vec![
922 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
923 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
924 ];
925 idx.update(
926 &chunks,
927 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
928 &["a.rs".to_string(), "b.rs".to_string()],
929 None,
930 );
931 assert_eq!(
932 idx.pending_chunk_count(&chunks),
933 0,
934 "a fully-embedded index has no pending chunks (warm path stays inline)"
935 );
936 }
937
938 #[test]
939 fn pending_chunk_count_only_counts_changed_files_chunks() {
940 let mut idx = EmbeddingIndex::new(384);
941 let chunks_v1 = vec![
942 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
943 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
944 make_chunk("b.rs", "fn_c", "fn c() {}", 1, 3),
945 ];
946 idx.update(
947 &chunks_v1,
948 &[
949 (0, dummy_embedding(384)),
950 (1, dummy_embedding(384)),
951 (2, dummy_embedding(384)),
952 ],
953 &["a.rs".to_string(), "b.rs".to_string()],
954 None,
955 );
956
957 let chunks_v2 = vec![
959 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
960 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
961 make_chunk("b.rs", "fn_c", "fn c() { changed }", 1, 3),
962 ];
963 assert_eq!(
964 idx.pending_chunk_count(&chunks_v2),
965 1,
966 "incremental update must only count the changed file's chunks"
967 );
968 }
969
970 #[test]
971 fn update_preserves_unchanged() {
972 let mut idx = EmbeddingIndex::new(384);
973 let chunks = vec![
974 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
975 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
976 ];
977 idx.update(
978 &chunks,
979 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
980 &["a.rs".to_string(), "b.rs".to_string()],
981 None,
982 );
983 assert_eq!(idx.entries.len(), 2);
984
985 idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()], None);
986 assert_eq!(idx.entries.len(), 2);
987
988 let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
989 let b_embed = b_entry.quant.dequantize();
990 assert!(
991 (b_embed[0] - 0.1).abs() < 1e-6,
992 "b.rs embedding should be preserved"
993 );
994 }
995
996 #[test]
997 fn get_aligned_flat_ok() {
998 let mut idx = EmbeddingIndex::new(2);
999 let chunks = vec![
1000 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
1001 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
1002 ];
1003 idx.update(
1004 &chunks,
1005 &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
1006 &["a.rs".to_string(), "b.rs".to_string()],
1007 None,
1008 );
1009
1010 let flat = idx.get_aligned_flat(&chunks).unwrap();
1011 assert_eq!(flat.n_vectors(), 2);
1012 assert_eq!(flat.dim, 2);
1013 assert!((flat.get(0)[0] - 1.0).abs() < 1e-6);
1014 assert!((flat.get(1)[1] - 1.0).abs() < 1e-6);
1015 }
1016
1017 #[test]
1018 fn get_aligned_flat_missing() {
1019 let idx = EmbeddingIndex::new(384);
1020 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1021 assert!(idx.get_aligned_flat(&chunks).is_none());
1022 }
1023
1024 #[test]
1025 fn coverage_calculation() {
1026 let mut idx = EmbeddingIndex::new(384);
1027 assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
1028
1029 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1030 idx.update(
1031 &chunks,
1032 &[(0, dummy_embedding(384))],
1033 &["a.rs".to_string()],
1034 None,
1035 );
1036 assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
1037 assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
1038 }
1039
1040 #[test]
1041 fn save_and_load_roundtrip() {
1042 let _lock = crate::core::data_dir::test_env_lock();
1043 let data_dir = tempfile::tempdir().unwrap();
1044 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
1045
1046 let project_dir = tempfile::tempdir().unwrap();
1047
1048 let mut idx = EmbeddingIndex::new(3);
1049 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1050 idx.update(
1051 &chunks,
1052 &[(0, vec![1.0, 2.0, 3.0])],
1053 &["a.rs".to_string()],
1054 None,
1055 );
1056 idx.save(project_dir.path()).unwrap();
1057
1058 let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
1059 assert_eq!(loaded.dimensions, 3);
1060 assert_eq!(loaded.entries.len(), 1);
1061 let recon = loaded.entries[0].quant.dequantize();
1063 assert!((recon[0] - 1.0).abs() < 0.02);
1064 assert!((recon[1] - 2.0).abs() < 0.02);
1065 assert!((recon[2] - 3.0).abs() < 0.02);
1066
1067 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
1068 }
1069
1070 #[test]
1071 fn new_with_model_sets_model_id() {
1072 let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
1073 assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
1074 assert_eq!(idx.dimensions, 768);
1075 }
1076
1077 #[test]
1078 fn model_mismatch_detection() {
1079 let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
1080 assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
1081 assert!(idx.model_mismatch("jina-code-v2").is_some());
1082
1083 let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
1084 assert_eq!(stored, "all-MiniLM-L6-v2");
1085 assert_eq!(current, "jina-code-v2");
1086 }
1087
1088 #[test]
1089 fn model_mismatch_none_when_no_model_id() {
1090 let idx = EmbeddingIndex::new(384);
1091 assert!(idx.model_mismatch("anything").is_none());
1092 }
1093
1094 #[test]
1095 fn dimension_mismatch_detection() {
1096 let mut idx = EmbeddingIndex::new(384);
1097 assert!(!idx.dimension_mismatch(384));
1098 assert!(!idx.dimension_mismatch(768)); let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
1101 idx.update(
1102 &chunks,
1103 &[(0, dummy_embedding(384))],
1104 &["a.rs".to_string()],
1105 None,
1106 );
1107 assert!(!idx.dimension_mismatch(384));
1108 assert!(idx.dimension_mismatch(768));
1109 }
1110}