1use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex};
19use std::time::SystemTime;
20
21use crate::parallel::*;
22
23use crate::embeddings::{cosine_similarity_simd, EmbeddingProvider};
24
25#[allow(dead_code)]
27pub struct EmbeddingIndex {
28 data: memmap2::Mmap,
29 embedded_count: usize,
30 dim: usize,
31 max_depth: u32,
32}
33
34type CachedEmbeddingIndex = (PathBuf, u64, Option<SystemTime>, Arc<EmbeddingIndex>);
50
51static EMBED_INDEX_CACHE: Mutex<Option<CachedEmbeddingIndex>> = Mutex::new(None);
52
53pub fn open_embedding_cached(path: &Path) -> Option<Arc<EmbeddingIndex>> {
59 let meta = fs::metadata(path).ok()?;
60 let key = (meta.len(), meta.modified().ok());
61
62 let mut guard = EMBED_INDEX_CACHE.lock().ok()?;
63 let hit = match guard.as_ref() {
64 Some((cached_path, len, mtime, cached))
65 if cached_path == path && *len == key.0 && *mtime == key.1 =>
66 {
67 Some(Arc::clone(cached))
68 }
69 _ => None,
70 };
71 if let Some(hit) = hit {
72 return Some(hit);
73 }
74 let parsed = Arc::new(EmbeddingIndex::open(path)?);
75 let handle = Arc::clone(&parsed);
76 *guard = Some((path.to_path_buf(), key.0, key.1, parsed));
77 Some(handle)
78}
79
80const HEADER_SIZE: usize = 12; impl EmbeddingIndex {
83 pub fn open(path: &Path) -> Option<Self> {
85 if !path.exists() {
86 return None;
87 }
88 let file = fs::File::open(path).ok()?;
89 let data = unsafe { memmap2::Mmap::map(&file).ok()? };
90 if data.len() < HEADER_SIZE {
91 return None;
92 }
93
94 let embedded_count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
95 let dim = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
96 let max_depth = u32::from_le_bytes(data[8..12].try_into().unwrap());
97
98 let expected = HEADER_SIZE
99 + embedded_count
100 .checked_mul(4)
101 .and_then(|v| v.checked_add(embedded_count.checked_mul(dim * 4)?))
102 .unwrap_or(usize::MAX);
103 if expected > data.len() {
104 return None;
105 }
106
107 Some(EmbeddingIndex {
108 data,
109 embedded_count,
110 dim,
111 max_depth,
112 })
113 }
114
115 pub fn block_count(&self) -> usize {
117 self.embedded_count
118 }
119
120 pub fn embedding(&self, block_idx: usize) -> Option<&[f32]> {
122 let ids = self.block_ids();
123 let pos = ids.binary_search(&(block_idx as u32)).ok()?;
124 let offset = HEADER_SIZE + self.embedded_count * 4 + pos * self.dim * 4;
125 let ptr = self.data[offset..].as_ptr() as *const f32;
126 Some(unsafe { std::slice::from_raw_parts(ptr, self.dim) })
129 }
130
131 pub fn dim(&self) -> usize {
133 self.dim
134 }
135
136 #[allow(dead_code)]
138 pub fn max_depth(&self) -> u8 {
139 self.max_depth as u8
140 }
141
142 pub fn search(&self, query_emb: &[f32], k: usize) -> Vec<(f32, usize)> {
145 search_with_floor(
146 query_emb,
147 k,
148 self.dim,
149 &self.data,
150 HEADER_SIZE,
151 self.embedded_count,
152 self.block_ids(),
153 )
154 }
155
156 pub fn all_block_ids(&self) -> &[u32] {
161 self.block_ids()
162 }
163
164 pub fn similarity_of(&self, block_idx: usize, query_emb: &[f32]) -> Option<f32> {
166 if query_emb.len() != self.dim {
167 return None;
168 }
169 let ids = self.block_ids();
170 let pos = ids.binary_search(&(block_idx as u32)).ok()?;
171 let offset = HEADER_SIZE + self.embedded_count * 4 + pos * self.dim * 4;
172 let ptr = self.data[offset..].as_ptr() as *const f32;
173 let emb = unsafe { std::slice::from_raw_parts(ptr, self.dim) };
175 Some(cosine_similarity_simd(query_emb, emb))
176 }
177
178 pub fn full_rank_of(&self, block_idx: usize, query_emb: &[f32]) -> Option<(usize, f32)> {
184 if query_emb.len() != self.dim {
185 return None;
186 }
187 let ids = self.block_ids();
188 let target = ids.binary_search(&(block_idx as u32)).ok()?;
189 let target_off = HEADER_SIZE + self.embedded_count * 4 + target * self.dim * 4;
190 let tptr = self.data[target_off..].as_ptr() as *const f32;
191 let target_emb = unsafe { std::slice::from_raw_parts(tptr, self.dim) };
192 let target_sim = cosine_similarity_simd(query_emb, target_emb);
193 let mut higher = 0usize;
194 for i in 0..self.embedded_count {
195 let offset = HEADER_SIZE + self.embedded_count * 4 + i * self.dim * 4;
196 let ptr = self.data[offset..].as_ptr() as *const f32;
197 let emb = unsafe { std::slice::from_raw_parts(ptr, self.dim) };
199 if cosine_similarity_simd(query_emb, emb) > target_sim {
200 higher += 1;
201 }
202 }
203 Some((higher, target_sim))
204 }
205
206 fn block_ids(&self) -> &[u32] {
208 let start = HEADER_SIZE;
209 let end = start + self.embedded_count * 4;
210 let ptr = self.data[start..end].as_ptr() as *const u32;
211 unsafe { std::slice::from_raw_parts(ptr, self.embedded_count) }
213 }
214}
215
216pub fn min_embed_chars() -> usize {
244 std::env::var("MICROSCOPE_MIN_EMBED_CHARS")
245 .ok()
246 .and_then(|v| v.parse::<usize>().ok())
247 .unwrap_or(20)
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum EmbedVerdict {
256 Keep,
257 Short,
259 Unencodable,
261 Mojibake,
263}
264
265pub fn quality_gate(text: &str, min_chars: usize) -> EmbedVerdict {
270 if text == "<bin>" || text == "[out of bounds]" {
273 return EmbedVerdict::Unencodable;
274 }
275 let total = text.chars().count();
276 if total < min_chars {
277 return EmbedVerdict::Short;
278 }
279 let suspicious = text
282 .chars()
283 .filter(|c| ('\u{80}'..='\u{2ff}').contains(c))
284 .count();
285 if total > 0 && suspicious * 4 > total {
286 return EmbedVerdict::Mojibake;
287 }
288 EmbedVerdict::Keep
289}
290
291pub const APPEND_EMBEDDINGS_FILE: &str = "append_embeddings.bin";
308const APPEND_EMBEDDINGS_MAGIC: &[u8; 4] = b"AEM1";
309const APPEND_EMBEDDINGS_HEADER: usize = 12;
310
311pub struct AppendEmbeddings {
312 pub dim: usize,
313 pub entries: Vec<(u32, Vec<f32>)>,
315}
316
317impl AppendEmbeddings {
318 pub fn new(dim: usize) -> Self {
319 AppendEmbeddings {
320 dim,
321 entries: Vec::new(),
322 }
323 }
324
325 pub fn open(path: &Path, dim: usize) -> Option<Self> {
330 let data = fs::read(path).ok()?;
331 if data.len() < APPEND_EMBEDDINGS_HEADER || &data[0..4] != APPEND_EMBEDDINGS_MAGIC {
332 return None;
333 }
334 let file_dim = u32::from_le_bytes(data[4..8].try_into().ok()?) as usize;
335 if dim == 0 || file_dim != dim {
336 return None;
337 }
338 let count = u32::from_le_bytes(data[8..12].try_into().ok()?) as usize;
339 let stride = 4 + dim * 4;
340 let mut entries = Vec::with_capacity(count.min(65_536));
341 for i in 0..count {
342 let off = APPEND_EMBEDDINGS_HEADER + i * stride;
343 if off + stride > data.len() {
344 break;
346 }
347 let index = u32::from_le_bytes(data[off..off + 4].try_into().ok()?);
348 let mut v = Vec::with_capacity(dim);
349 for j in 0..dim {
350 let p = off + 4 + j * 4;
351 v.push(f32::from_le_bytes(data[p..p + 4].try_into().ok()?));
352 }
353 entries.push((index, v));
354 }
355 Some(AppendEmbeddings { dim, entries })
356 }
357
358 pub fn push(&mut self, append_index: u32, vector: Vec<f32>) {
359 self.entries.push((append_index, vector));
360 }
361}
362
363type CachedAppendIndex = (
386 PathBuf,
387 u64,
388 Option<SystemTime>,
389 usize,
390 Arc<AppendEmbeddings>,
391);
392
393static APPEND_CACHE: Mutex<Option<CachedAppendIndex>> = Mutex::new(None);
394
395pub fn open_append_cached(path: &Path, dim: usize) -> Option<Arc<AppendEmbeddings>> {
401 let meta = fs::metadata(path).ok()?;
403 let key = (meta.len(), meta.modified().ok());
404
405 let mut guard = APPEND_CACHE.lock().ok()?;
406 let hit = match guard.as_ref() {
408 Some((cached_path, len, mtime, cached_dim, cached))
409 if cached_path == path && *len == key.0 && *mtime == key.1 && *cached_dim == dim =>
410 {
411 Some(Arc::clone(cached))
412 }
413 _ => None,
414 };
415 if let Some(hit) = hit {
416 return Some(hit);
417 }
418 let parsed = Arc::new(AppendEmbeddings::open(path, dim)?);
419 let handle = Arc::clone(&parsed);
420 *guard = Some((path.to_path_buf(), key.0, key.1, dim, parsed));
421 Some(handle)
422}
423
424impl AppendEmbeddings {
425 pub fn save(&self, path: &Path) -> Result<(), String> {
427 let stride = 4 + self.dim * 4;
428 let mut buf = Vec::with_capacity(APPEND_EMBEDDINGS_HEADER + self.entries.len() * stride);
429 buf.extend_from_slice(APPEND_EMBEDDINGS_MAGIC);
430 buf.extend_from_slice(&(self.dim as u32).to_le_bytes());
431 buf.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
432 for (index, v) in &self.entries {
433 if v.len() != self.dim {
434 return Err(format!(
435 "append embedding width {} does not match header dim {}",
436 v.len(),
437 self.dim
438 ));
439 }
440 buf.extend_from_slice(&index.to_le_bytes());
441 for f in v {
442 buf.extend_from_slice(&f.to_le_bytes());
443 }
444 }
445 let tmp = path.with_extension("bin.tmp");
446 fs::write(&tmp, &buf).map_err(|e| format!("write {}: {}", APPEND_EMBEDDINGS_FILE, e))?;
447 fs::rename(&tmp, path).map_err(|e| format!("rename {}: {}", APPEND_EMBEDDINGS_FILE, e))?;
448 Ok(())
449 }
450
451 pub fn search(&self, query_emb: &[f32], k: usize) -> Vec<(f32, u32)> {
454 if query_emb.len() != self.dim {
455 return vec![];
456 }
457 let mut out: Vec<(f32, u32)> = self
458 .entries
459 .iter()
460 .filter_map(|(i, v)| {
461 let sim = cosine_similarity_simd(query_emb, v);
462 (sim > similarity_floor()).then_some((sim, *i))
463 })
464 .collect();
465 out.sort_by(|a, b| b.0.total_cmp(&a.0));
466 out.truncate(k);
467 out
468 }
469}
470
471pub fn similarity_floor() -> f32 {
491 static FLOOR: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
492 *FLOOR.get_or_init(|| {
493 std::env::var("MICROSCOPE_SIM_FLOOR")
494 .ok()
495 .and_then(|v| v.trim().parse::<f32>().ok())
496 .filter(|v| v.is_finite())
497 .unwrap_or(0.2)
498 })
499}
500
501fn search_with_floor(
506 query_emb: &[f32],
507 k: usize,
508 dim: usize,
509 data: &[u8],
510 header: usize,
511 count: usize,
512 ids: &[u32],
513) -> Vec<(f32, usize)> {
514 if query_emb.len() != dim {
515 return vec![];
516 }
517 let floor = similarity_floor();
518 let mut results: Vec<(f32, usize)> = (0..count)
519 .into_par_iter()
520 .filter_map(|i| {
521 let offset = header + count * 4 + i * dim * 4;
522 let ptr = data[offset..].as_ptr() as *const f32;
523 let emb = unsafe { std::slice::from_raw_parts(ptr, dim) };
525 let sim = cosine_similarity_simd(query_emb, emb);
526 (sim > floor).then_some((sim, ids[i] as usize))
527 })
528 .collect();
529
530 results.sort_by(|a, b| b.0.total_cmp(&a.0));
531 results.truncate(k);
532 results
533}
534
535pub fn build_embedding_index(
539 provider: &dyn EmbeddingProvider,
540 reader: &crate::MicroscopeReader,
541 max_depth: u8,
542 output_path: &Path,
543) -> Result<(), String> {
544 let dim = provider.dimension();
545 let total_blocks = reader.block_count;
546
547 let min_chars = min_embed_chars();
551 let mut skipped_short = 0usize;
552 let mut skipped_unencodable = 0usize;
553 let mut skipped_mojibake = 0usize;
554 let mut qualifying = Vec::new();
555 for i in 0..total_blocks {
556 let h = reader.header(i);
557 if h.depth > max_depth {
558 continue;
559 }
560 match quality_gate(reader.text(i), min_chars) {
561 EmbedVerdict::Keep => qualifying.push(i),
562 EmbedVerdict::Short => skipped_short += 1,
563 EmbedVerdict::Unencodable => skipped_unencodable += 1,
564 EmbedVerdict::Mojibake => skipped_mojibake += 1,
565 }
566 }
567 if skipped_short + skipped_unencodable + skipped_mojibake > 0 {
568 println!(
569 " Embedding quality gate: -{} short (<{} chars), -{} unencodable, -{} mojibake",
570 skipped_short, min_chars, skipped_unencodable, skipped_mojibake
571 );
572 }
573
574 println!(
575 " Embedding up to {} blocks (D0-D{}, dim={})...",
576 qualifying.len(),
577 max_depth,
578 dim
579 );
580
581 let mut block_ids: Vec<u32> = Vec::with_capacity(qualifying.len());
582 let mut vectors: Vec<f32> = Vec::with_capacity(qualifying.len() * dim);
583 let mut failures = 0usize;
584 let mut first_error: Option<String> = None;
585
586 for (n, &i) in qualifying.iter().enumerate() {
587 let text = reader.text(i);
588 match provider.embed(text) {
589 Ok(emb) if emb.len() == dim && emb.iter().any(|&v| v != 0.0) => {
590 block_ids.push(i as u32);
591 vectors.extend_from_slice(&emb);
592 }
593 Ok(emb) => {
594 failures += 1;
598 if first_error.is_none() {
599 first_error = Some(format!(
600 "block {} produced {} dims, expected {} ({})",
601 i,
602 emb.len(),
603 dim,
604 if emb.iter().all(|&v| v == 0.0) {
605 "all-zero vector"
606 } else {
607 "length mismatch"
608 }
609 ));
610 }
611 }
612 Err(e) => {
613 failures += 1;
614 if first_error.is_none() {
615 first_error = Some(format!("block {}: {}", i, e));
616 }
617 }
618 }
619 if n.is_multiple_of(1000) {
620 eprint!("\r Embedded {}/{}", n, qualifying.len());
621 }
622 }
623 eprintln!("\r Embedded {}/{}", qualifying.len(), qualifying.len());
624
625 if qualifying.is_empty() {
627 return Err("no blocks qualified for embedding".into());
628 }
629 if block_ids.is_empty() {
630 return Err(format!(
631 "all {} blocks failed to embed; first failure: {}",
632 qualifying.len(),
633 first_error.unwrap_or_else(|| "unknown".into())
634 ));
635 }
636 if failures > 0 {
637 eprintln!(
638 " WARN: {} of {} blocks were skipped",
639 failures,
640 qualifying.len()
641 );
642 }
643
644 let mut buf = Vec::with_capacity(HEADER_SIZE + block_ids.len() * 4 + vectors.len() * 4);
645 buf.extend_from_slice(&(block_ids.len() as u32).to_le_bytes());
646 buf.extend_from_slice(&(dim as u32).to_le_bytes());
647 buf.extend_from_slice(&(max_depth as u32).to_le_bytes());
648 for &id in &block_ids {
649 buf.extend_from_slice(&id.to_le_bytes());
650 }
651 for &v in &vectors {
652 buf.extend_from_slice(&v.to_le_bytes());
653 }
654
655 let tmp_path = output_path.with_extension("bin.tmp");
656 fs::write(&tmp_path, &buf).map_err(|e| format!("write embeddings.bin: {}", e))?;
657 fs::rename(&tmp_path, output_path).map_err(|e| format!("rename embeddings.bin: {}", e))?;
658 println!(
659 " embeddings.bin: {:.1} KB ({} stored vectors of {} blocks, dim {})",
660 buf.len() as f64 / 1024.0,
661 block_ids.len(),
662 total_blocks,
663 dim
664 );
665
666 Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use std::io::Write;
673
674 #[test]
675 fn quality_gate_admits_only_retrievable_text() {
676 assert_eq!(
678 quality_gate("The user has a pine nut allergy.", 24),
679 EmbedVerdict::Keep
680 );
681 assert_eq!(
684 quality_gate("The user drinks café lattes every morning.", 24),
685 EmbedVerdict::Keep
686 );
687 assert_eq!(quality_gate("LLM", 24), EmbedVerdict::Short);
689 assert_eq!(
690 quality_gate("The user is vegetarian.", 24),
691 EmbedVerdict::Short,
692 "23 characters is under the measured 24 floor"
693 );
694 assert_eq!(quality_gate("<bin>", 24), EmbedVerdict::Unencodable);
696 assert_eq!(
697 quality_gate("[out of bounds]", 24),
698 EmbedVerdict::Unencodable
699 );
700 assert_eq!(
702 quality_gate("đź§đź§đź§đź§đź§đź§đź§đź§", 24),
704 EmbedVerdict::Mojibake
705 );
706 assert_eq!(
708 quality_gate("The user is vegetarian.", 20),
709 EmbedVerdict::Keep,
710 "MICROSCOPE_MIN_EMBED_CHARS=20 would admit a 23-character fact"
711 );
712 }
713
714 #[test]
715 fn min_embed_chars_default_is_the_measured_floor() {
716 if std::env::var_os("MICROSCOPE_MIN_EMBED_CHARS").is_none() {
717 assert_eq!(min_embed_chars(), 20);
722 }
723 }
724
725 #[test]
726 fn append_embeddings_roundtrip_and_guards() {
727 let dir = std::env::temp_dir().join("mscope_append_emb_test");
728 let _ = fs::create_dir_all(&dir);
729 let path = dir.join(APPEND_EMBEDDINGS_FILE);
730
731 let mut side = AppendEmbeddings::new(4);
732 side.push(0, vec![1.0, 0.0, 0.0, 0.0]);
733 side.push(1, vec![0.0, 1.0, 0.0, 0.0]);
734 side.push(7, vec![0.0, 0.0, 0.0, 1.0]);
735 side.save(&path).expect("save");
736
737 let opened = AppendEmbeddings::open(&path, 4).expect("open");
738 assert_eq!(opened.dim, 4);
739 assert_eq!(opened.entries.len(), 3);
740 assert_eq!(
741 opened.entries[2].0, 7,
742 "append index survives the round trip"
743 );
744
745 let hits = opened.search(&[1.0, 0.0, 0.0, 0.0], 10);
747 assert_eq!(hits[0].1, 0);
748 assert!(hits[0].0 > 0.99, "identical vectors must score ~1.0");
749 let floor = similarity_floor();
754 assert!(
755 hits.iter().all(|&(sim, _)| sim > floor),
756 "everything returned must clear the floor ({floor})"
757 );
758
759 assert!(AppendEmbeddings::open(&path, 8).is_none());
762 let junk = dir.join("junk.bin");
764 fs::write(&junk, b"NOPE\x04\x00\x00\x00\x00\x00\x00\x00").unwrap();
765 assert!(AppendEmbeddings::open(&junk, 4).is_none());
766
767 let mut data = fs::read(&path).unwrap();
769 data.extend_from_slice(&[0xAB, 0xCD, 0xEF]);
770 fs::write(&path, &data).unwrap();
771 let partial = AppendEmbeddings::open(&path, 4).expect("valid prefix survives");
772 assert_eq!(partial.entries.len(), 3);
773
774 let mut bad = AppendEmbeddings::new(4);
776 bad.push(0, vec![1.0, 0.0]);
777 assert!(bad.save(&path).is_err());
778
779 assert!(partial.search(&[1.0, 0.0], 10).is_empty());
781
782 let _ = fs::remove_dir_all(&dir);
783 }
784
785 #[test]
786 fn append_cache_returns_the_same_content_as_a_fresh_open() {
787 let dir = std::env::temp_dir().join("mscope_append_cache_parity");
790 let _ = fs::remove_dir_all(&dir);
791 fs::create_dir_all(&dir).unwrap();
792 let path = dir.join(APPEND_EMBEDDINGS_FILE);
793
794 let mut side = AppendEmbeddings::new(4);
795 side.push(0, vec![1.0, 0.0, 0.0, 0.0]);
796 side.push(3, vec![0.0, 0.5, 0.0, 0.0]);
797 side.save(&path).unwrap();
798
799 let fresh = AppendEmbeddings::open(&path, 4).unwrap();
800 let cached = open_append_cached(&path, 4).unwrap();
801 assert_eq!(fresh.entries, cached.entries, "cache must match open()");
802 assert_eq!(cached.dim, 4);
803
804 let again = open_append_cached(&path, 4).unwrap();
806 assert_eq!(again.entries, fresh.entries);
807
808 assert!(open_append_cached(&path, 8).is_none());
810 assert!(open_append_cached(&dir.join("absent.bin"), 4).is_none());
811
812 let _ = fs::remove_dir_all(&dir);
813 }
814
815 #[test]
816 fn append_cache_invalidates_when_the_file_changes() {
817 let dir = std::env::temp_dir().join("mscope_append_cache_inval");
821 let _ = fs::remove_dir_all(&dir);
822 fs::create_dir_all(&dir).unwrap();
823 let path = dir.join(APPEND_EMBEDDINGS_FILE);
824
825 let mut first = AppendEmbeddings::new(4);
826 first.push(11, vec![1.0, 0.0, 0.0, 0.0]);
827 first.save(&path).unwrap();
828 assert_eq!(open_append_cached(&path, 4).unwrap().entries[0].0, 11);
829
830 let mut second = AppendEmbeddings::new(4);
833 second.push(22, vec![0.0, 1.0, 0.0, 0.0]);
834 second.push(33, vec![0.0, 0.0, 1.0, 0.0]);
835 second.push(44, vec![0.0, 0.0, 0.0, 1.0]);
836 second.save(&path).unwrap();
837
838 let after = open_append_cached(&path, 4).unwrap();
839 assert_eq!(
840 after.entries.len(),
841 3,
842 "a rewrite must be observed, not cached"
843 );
844 assert_eq!(after.entries[0].0, 22);
845 let fresh = AppendEmbeddings::open(&path, 4).unwrap();
846 assert_eq!(after.entries, fresh.entries, "and must match a fresh open");
847
848 let _ = fs::remove_dir_all(&dir);
849 }
850
851 #[test]
852 fn embedding_index_cache_matches_open() {
853 let dir = std::env::temp_dir().join("mscope_embed_cache_parity");
855 let _ = fs::remove_dir_all(&dir);
856 fs::create_dir_all(&dir).unwrap();
857 let path = dir.join("embeddings.bin");
858
859 let n = build_index(&dir, 3);
860 let fresh = EmbeddingIndex::open(&path).expect("open");
861 let cached = open_embedding_cached(&path).expect("cached open");
862 assert_eq!(cached.block_count(), n);
863 assert_eq!(cached.dim(), fresh.dim());
864 assert_eq!(open_embedding_cached(&path).unwrap().block_count(), n);
866 assert!(open_embedding_cached(&dir.join("nope.bin")).is_none());
868
869 let _ = fs::remove_dir_all(&dir);
870 }
871
872 #[cfg(unix)]
882 #[test]
883 fn embedding_index_cache_notices_a_rebuild() {
884 let dir = std::env::temp_dir().join("mscope_embed_cache_rebuild");
885 let _ = fs::remove_dir_all(&dir);
886 fs::create_dir_all(&dir).unwrap();
887 let path = dir.join("embeddings.bin");
888
889 let first = build_index(&dir, 2);
890 assert_eq!(open_embedding_cached(&path).unwrap().block_count(), first);
891
892 let bigger = build_index(&dir, 5);
893 assert_ne!(bigger, first, "the rebuild must actually differ");
894 let after = open_embedding_cached(&path).expect("reopen after rebuild");
895 assert_eq!(
896 after.block_count(),
897 bigger,
898 "a rebuilt index must not be served from the stale mapping"
899 );
900
901 let _ = fs::remove_dir_all(&dir);
902 }
903
904 fn build_index(dir: &Path, n: u32) -> usize {
907 let dim = 4usize;
908 let mut buf = Vec::new();
909 buf.extend_from_slice(&n.to_le_bytes());
910 buf.extend_from_slice(&(dim as u32).to_le_bytes());
911 buf.extend_from_slice(&3u32.to_le_bytes());
912 for i in 0..n {
913 buf.extend_from_slice(&i.to_le_bytes());
914 let mut v = vec![0.0f32; dim];
915 v[(i as usize) % dim] = 1.0;
916 for f in v {
917 buf.extend_from_slice(&f.to_le_bytes());
918 }
919 }
920 fs::write(dir.join("embeddings.bin"), &buf).unwrap();
921 n as usize
922 }
923
924 #[test]
925 fn append_cache_keeps_two_paths_apart() {
926 let dir = std::env::temp_dir().join("mscope_append_cache_paths");
930 let _ = fs::remove_dir_all(&dir);
931 fs::create_dir_all(&dir).unwrap();
932 let a = dir.join("a.bin");
933 let b = dir.join("b.bin");
934
935 let mut sa = AppendEmbeddings::new(4);
936 sa.push(101, vec![1.0, 0.0, 0.0, 0.0]);
937 sa.save(&a).unwrap();
938 let mut sb = AppendEmbeddings::new(4);
939 sb.push(202, vec![1.0, 0.0, 0.0, 0.0]);
940 sb.save(&b).unwrap();
941
942 let ca = open_append_cached(&a, 4).unwrap();
944 let cb = open_append_cached(&b, 4).unwrap();
945 assert_eq!(ca.entries[0].0, 101, "a must not be served b's vector");
946 assert_eq!(cb.entries[0].0, 202, "b must not be served a's vector");
947 assert_eq!(open_append_cached(&a, 4).unwrap().entries[0].0, 101);
949
950 let _ = fs::remove_dir_all(&dir);
951 }
952
953 #[test]
954 fn test_embedding_index_sparse_roundtrip() {
955 let dir = std::env::temp_dir().join("mscope_emb_sparse_test");
956 let _ = fs::create_dir_all(&dir);
957 let path = dir.join("embeddings.bin");
958
959 let mut buf = Vec::new();
962 buf.extend_from_slice(&3u32.to_le_bytes()); buf.extend_from_slice(&4u32.to_le_bytes()); buf.extend_from_slice(&2u32.to_le_bytes()); for &id in &[0u32, 2, 5] {
966 buf.extend_from_slice(&id.to_le_bytes());
967 }
968 for &v in &[1.0f32, 0.0, 0.0, 0.0] {
969 buf.extend_from_slice(&v.to_le_bytes());
970 }
971 for &v in &[0.0f32, 1.0, 0.0, 0.0] {
972 buf.extend_from_slice(&v.to_le_bytes());
973 }
974 for &v in &[0.0f32, 0.0, 1.0, 0.0] {
975 buf.extend_from_slice(&v.to_le_bytes());
976 }
977
978 let mut f = fs::File::create(&path).unwrap();
979 f.write_all(&buf).unwrap();
980
981 let idx = EmbeddingIndex::open(&path).unwrap();
982 assert_eq!(idx.block_count(), 3);
983 assert_eq!(idx.dim(), 4);
984 assert_eq!(idx.max_depth(), 2);
985
986 assert_eq!(idx.embedding(0).unwrap(), &[1.0, 0.0, 0.0, 0.0]);
987 assert_eq!(idx.embedding(2).unwrap(), &[0.0, 1.0, 0.0, 0.0]);
988 assert_eq!(idx.embedding(5).unwrap(), &[0.0, 0.0, 1.0, 0.0]);
989 assert!(idx.embedding(1).is_none());
990 assert!(idx.embedding(7).is_none());
991
992 let results = idx.search(&[1.0, 0.0, 0.0, 0.0], 10);
994 assert_eq!(results[0].1, 0);
995 assert!(!results.iter().any(|&(_, i)| i == 7));
996 assert!(!results.iter().any(|&(_, i)| i == 1));
997
998 let trunc = dir.join("truncated.bin");
1000 fs::write(&trunc, &buf[..buf.len() - 3]).unwrap();
1001 assert!(EmbeddingIndex::open(&trunc).is_none());
1002
1003 let _ = fs::remove_dir_all(&dir);
1004 }
1005
1006 #[test]
1007 fn test_empty_index_open_fails_gracefully() {
1008 let dir = std::env::temp_dir().join("mscope_emb_empty_test");
1009 let _ = fs::create_dir_all(&dir);
1010 let path = dir.join("embeddings.bin");
1011 fs::write(&path, [0u8; 4]).unwrap();
1012 assert!(EmbeddingIndex::open(&path).is_none());
1013 let _ = fs::remove_dir_all(&dir);
1014 }
1015}