1use std::time::Instant;
24
25use crate::{codec::FibCodeV1, profile::FibQuantProfileV1, scoring::FibScorer, Result};
26
27#[derive(Debug, Clone, PartialEq)]
34pub struct ScoredCandidate<Id> {
35 pub id: Id,
37 pub approximate_score: f32,
39 pub rank: usize,
41}
42
43#[derive(Debug, Clone, PartialEq)]
50pub struct SearchReceiptV1 {
51 pub schema: String,
53 pub indexed_count: usize,
55 pub top_k: usize,
57 pub oversample: usize,
59 pub candidate_count: usize,
61 pub approximate_only: bool,
63 pub exact_rerank_required: bool,
65 pub elapsed_micros: u128,
67}
68
69#[derive(Debug, Clone, PartialEq)]
74pub struct SearchReceiptIvfV1 {
75 pub schema: String,
77 pub indexed_count: usize,
79 pub top_k: usize,
81 pub oversample: usize,
83 pub candidate_count: usize,
85 pub approximate_only: bool,
87 pub exact_rerank_required: bool,
89 pub elapsed_micros: u128,
91 pub num_centroids: usize,
93 pub nprobe: usize,
95 pub entries_scored: usize,
97 pub ivf_used: bool,
99}
100
101#[derive(Debug, Clone)]
108pub struct IvfCoarseQuantizer {
109 centroids: Vec<Vec<f32>>,
111 assignments: Vec<usize>,
113 nprobe: usize,
115}
116
117impl IvfCoarseQuantizer {
118 pub fn is_built(&self) -> bool {
120 !self.centroids.is_empty()
121 }
122
123 pub fn num_centroids(&self) -> usize {
125 self.centroids.len()
126 }
127
128 pub fn nprobe(&self) -> usize {
130 self.nprobe
131 }
132
133 pub fn set_nprobe(&mut self, nprobe: usize) {
135 self.nprobe = nprobe;
136 }
137
138 pub fn assignments(&self) -> &[usize] {
140 &self.assignments
141 }
142
143 pub fn centroids(&self) -> &[Vec<f32>] {
145 &self.centroids
146 }
147
148 pub fn inverted_lists(&self) -> Vec<Vec<usize>> {
151 let k = self.centroids.len();
152 let mut lists: Vec<Vec<usize>> = vec![Vec::new(); k];
153 for (entry_idx, ¢roid_idx) in self.assignments.iter().enumerate() {
154 if centroid_idx < k {
155 lists[centroid_idx].push(entry_idx);
156 }
157 }
158 lists
159 }
160}
161
162pub struct FibSidecarIndex<Id>
177where
178 Id: Clone + Eq + std::fmt::Debug,
179{
180 scorer: FibScorer,
181 entries: Vec<(Id, FibCodeV1)>,
182 profile: FibQuantProfileV1,
183 ivf: Option<IvfCoarseQuantizer>,
187}
188
189impl<Id> FibSidecarIndex<Id>
190where
191 Id: Clone + Eq + std::fmt::Debug,
192{
193 pub fn new(scorer: FibScorer) -> Self {
198 let profile = scorer.quantizer().profile().clone();
199 Self {
200 scorer,
201 entries: Vec::new(),
202 profile,
203 ivf: None,
204 }
205 }
206
207 pub fn scorer(&self) -> &FibScorer {
209 &self.scorer
210 }
211
212 pub fn profile(&self) -> &FibQuantProfileV1 {
214 &self.profile
215 }
216
217 pub fn add(&mut self, id: Id, code: FibCodeV1) {
222 debug_assert!(
223 code.ambient_dim == self.profile.ambient_dim,
224 "code ambient_dim {} != profile {}",
225 code.ambient_dim,
226 self.profile.ambient_dim
227 );
228 debug_assert!(
229 code.block_dim == self.profile.block_dim,
230 "code block_dim {} != profile {}",
231 code.block_dim,
232 self.profile.block_dim
233 );
234 self.entries.push((id, code));
235 }
236
237 pub fn add_batch(&mut self, entries: Vec<(Id, FibCodeV1)>) {
242 self.entries.reserve(entries.len());
243 for (id, code) in entries {
244 self.add(id, code);
245 }
246 }
247
248 pub fn len(&self) -> usize {
250 self.entries.len()
251 }
252
253 pub fn entries(&self) -> &[(Id, FibCodeV1)] {
258 &self.entries
259 }
260
261 pub fn is_empty(&self) -> bool {
263 self.entries.is_empty()
264 }
265
266 fn score_all(&self, query: &[f32]) -> Result<Vec<(usize, f32)>> {
275 let prepared = self.scorer.prepare_query(query)?;
280 let mut scored: Vec<(usize, f32)> = Vec::with_capacity(self.entries.len());
281 for (idx, (_, code)) in self.entries.iter().enumerate() {
282 let s = self.scorer.score_prepared(&prepared, code)?;
283 scored.push((idx, s));
284 }
285 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
288 Ok(scored)
289 }
290
291 pub fn search(
301 &self,
302 query: &[f32],
303 top_k: usize,
304 oversample: usize,
305 ) -> Result<Vec<ScoredCandidate<Id>>> {
306 let scored = self.score_all(query)?;
307 let limit = top_k.saturating_mul(oversample.max(1)).min(scored.len());
308 let candidates = scored
309 .into_iter()
310 .take(limit)
311 .enumerate()
312 .map(|(rank, (idx, score))| {
313 let id = self.entries[idx].0.clone();
314 ScoredCandidate {
315 id,
316 approximate_score: score,
317 rank,
318 }
319 })
320 .collect();
321 Ok(candidates)
322 }
323
324 pub fn search_with_receipt(
329 &self,
330 query: &[f32],
331 top_k: usize,
332 oversample: usize,
333 ) -> Result<(Vec<ScoredCandidate<Id>>, SearchReceiptV1)> {
334 let started = Instant::now();
335 let candidates = self.search(query, top_k, oversample)?;
336 let elapsed = started.elapsed().as_micros();
337
338 let receipt = SearchReceiptV1 {
339 schema: "fib_sidecar_search_receipt_v1".to_string(),
340 indexed_count: self.entries.len(),
341 top_k,
342 oversample,
343 candidate_count: candidates.len(),
344 approximate_only: true,
345 exact_rerank_required: true,
346 elapsed_micros: elapsed,
347 };
348
349 Ok((candidates, receipt))
350 }
351
352 pub fn ivf(&self) -> Option<&IvfCoarseQuantizer> {
358 self.ivf.as_ref()
359 }
360
361 pub fn ivf_is_built(&self) -> bool {
363 self.ivf.as_ref().is_some_and(|ivf| ivf.is_built())
364 }
365
366 pub fn build_ivf(&mut self, num_centroids: usize) -> Result<()> {
379 let n = self.entries.len();
380 if n == 0 {
381 self.ivf = Some(IvfCoarseQuantizer {
383 centroids: Vec::new(),
384 assignments: Vec::new(),
385 nprobe: 8,
386 });
387 return Ok(());
388 }
389
390 let k = num_centroids.max(1).min(n);
391 let d = self.profile.ambient_dim as usize;
392
393 let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(n);
395 for (_, code) in &self.entries {
396 let v = self.scorer.quantizer().decode(code)?;
397 vectors.push(v);
398 }
399
400 let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
403 if k == 1 {
404 let mut mean = vec![0.0f32; d];
406 for v in &vectors {
407 for (mi, &vi) in mean.iter_mut().zip(v.iter()) {
408 *mi += vi;
409 }
410 }
411 for m in &mut mean {
412 *m /= n as f32;
413 }
414 centroids.push(mean);
415 } else {
416 for i in 0..k {
417 let idx = (i * n) / k;
418 centroids.push(vectors[idx].clone());
419 }
420
421 const MAX_ITERS: usize = 20;
423 const CONVERGE_THRESHOLD: f32 = 1e-4;
424
425 let mut assignments = vec![0usize; n];
426 let mut sums: Vec<Vec<f64>> = vec![vec![0.0f64; d]; k];
427 let mut counts: Vec<usize> = vec![0; k];
428
429 for _iter in 0..MAX_ITERS {
430 for (vi, v) in vectors.iter().enumerate() {
432 let mut best_dist = f32::MAX;
433 let mut best_c = 0;
434 for (ci, c) in centroids.iter().enumerate() {
435 let mut dist = 0.0f32;
437 for j in 0..d {
438 let diff = v[j] - c[j];
439 dist += diff * diff;
440 }
441 if dist < best_dist {
442 best_dist = dist;
443 best_c = ci;
444 }
445 }
446 assignments[vi] = best_c;
447 }
448
449 for s in sums.iter_mut() {
451 for x in s.iter_mut() {
452 *x = 0.0;
453 }
454 }
455 counts.fill(0);
456
457 for (vi, v) in vectors.iter().enumerate() {
458 let c = assignments[vi];
459 counts[c] += 1;
460 for j in 0..d {
461 sums[c][j] += v[j] as f64;
462 }
463 }
464
465 let mut total_shift = 0.0f32;
466 for ci in 0..k {
467 if counts[ci] == 0 {
468 let mut furthest = 0;
471 let mut max_dist = 0.0f32;
472 for (vi, v) in vectors.iter().enumerate() {
473 let c = assignments[vi];
474 let mut dist = 0.0f32;
475 for j in 0..d {
476 let diff = v[j] - centroids[c][j];
477 dist += diff * diff;
478 }
479 if dist > max_dist {
480 max_dist = dist;
481 furthest = vi;
482 }
483 }
484 centroids[ci] = vectors[furthest].clone();
485 assignments[furthest] = ci;
486 continue;
487 }
488 let new_centroid: Vec<f32> = (0..d)
489 .map(|j| (sums[ci][j] / counts[ci] as f64) as f32)
490 .collect();
491 for j in 0..d {
493 let diff = new_centroid[j] - centroids[ci][j];
494 total_shift += diff * diff;
495 }
496 centroids[ci] = new_centroid;
497 }
498
499 if total_shift < CONVERGE_THRESHOLD {
500 break;
501 }
502 }
503 }
504
505 let mut assignments = vec![0usize; n];
507 for (vi, v) in vectors.iter().enumerate() {
508 let mut best_dist = f32::MAX;
509 let mut best_c = 0;
510 for (ci, c) in centroids.iter().enumerate() {
511 let mut dist = 0.0f32;
512 for j in 0..d {
513 let diff = v[j] - c[j];
514 dist += diff * diff;
515 }
516 if dist < best_dist {
517 best_dist = dist;
518 best_c = ci;
519 }
520 }
521 assignments[vi] = best_c;
522 }
523
524 let nprobe = 8usize.min(k);
525 self.ivf = Some(IvfCoarseQuantizer {
526 centroids,
527 assignments,
528 nprobe,
529 });
530
531 Ok(())
532 }
533
534 pub fn build_ivf_default(&mut self) -> Result<()> {
536 let k = (self.len() as f64).sqrt().round() as usize;
537 let k = k.max(1);
538 self.build_ivf(k)
539 }
540
541 fn nearest_centroids(&self, query: &[f32], nprobe: usize) -> Vec<(usize, f32)> {
544 let ivf = self.ivf.as_ref().expect("IVF must be built");
545 let d = self.profile.ambient_dim as usize;
546 let mut dists: Vec<(usize, f32)> = Vec::with_capacity(ivf.centroids.len());
547 for (ci, c) in ivf.centroids.iter().enumerate() {
548 let mut dist = 0.0f32;
549 for j in 0..d.min(query.len()).min(c.len()) {
550 let diff = query[j] - c[j];
551 dist += diff * diff;
552 }
553 dists.push((ci, dist));
554 }
555 dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
556 dists.into_iter().take(nprobe).collect()
557 }
558
559 pub fn search_ivf(
569 &self,
570 query: &[f32],
571 top_k: usize,
572 oversample: usize,
573 nprobe: usize,
574 ) -> Result<Vec<ScoredCandidate<Id>>> {
575 if !self.ivf_is_built() {
577 return self.search(query, top_k, oversample);
578 }
579
580 let ivf = self.ivf.as_ref().unwrap();
581 let nprobe_eff = nprobe.min(ivf.centroids.len()).max(1);
582
583 let nearest = self.nearest_centroids(query, nprobe_eff);
585
586 let inverted = ivf.inverted_lists();
588 let mut candidate_idxs: Vec<usize> = Vec::new();
589 for (ci, _) in &nearest {
590 candidate_idxs.extend_from_slice(&inverted[*ci]);
591 }
592
593 if candidate_idxs.is_empty() {
594 return Ok(Vec::new());
595 }
596
597 let prepared = self.scorer.prepare_query(query)?;
599 let mut scored: Vec<(usize, f32)> = Vec::with_capacity(candidate_idxs.len());
600 for &idx in &candidate_idxs {
601 let code = &self.entries[idx].1;
602 let s = self.scorer.score_prepared(&prepared, code)?;
603 scored.push((idx, s));
604 }
605 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
606
607 let limit = top_k.saturating_mul(oversample.max(1)).min(scored.len());
608 let candidates = scored
609 .into_iter()
610 .take(limit)
611 .enumerate()
612 .map(|(rank, (idx, score))| {
613 let id = self.entries[idx].0.clone();
614 ScoredCandidate {
615 id,
616 approximate_score: score,
617 rank,
618 }
619 })
620 .collect();
621 Ok(candidates)
622 }
623
624 pub fn search_with_receipt_ivf(
629 &self,
630 query: &[f32],
631 top_k: usize,
632 oversample: usize,
633 nprobe: usize,
634 ) -> Result<(Vec<ScoredCandidate<Id>>, SearchReceiptIvfV1)> {
635 let started = Instant::now();
636 let ivf_used = self.ivf_is_built();
637
638 let (candidates, entries_scored, nprobe_actual, num_centroids) = if ivf_used {
639 let ivf = self.ivf.as_ref().unwrap();
640 let nprobe_eff = nprobe.min(ivf.centroids.len()).max(1);
641 let nearest = self.nearest_centroids(query, nprobe_eff);
643 let inverted = ivf.inverted_lists();
644 let count: usize = nearest.iter().map(|(ci, _)| inverted[*ci].len()).sum();
645 let cands = self.search_ivf(query, top_k, oversample, nprobe)?;
646 (cands, count, nprobe_eff, ivf.centroids.len())
647 } else {
648 let cands = self.search(query, top_k, oversample)?;
650 let es = self.entries.len();
651 (cands, es, 0, 0)
652 };
653
654 let elapsed = started.elapsed().as_micros();
655
656 let receipt = SearchReceiptIvfV1 {
657 schema: "fib_sidecar_search_ivf_receipt_v1".to_string(),
658 indexed_count: self.entries.len(),
659 top_k,
660 oversample,
661 candidate_count: candidates.len(),
662 approximate_only: true,
663 exact_rerank_required: true,
664 elapsed_micros: elapsed,
665 num_centroids,
666 nprobe: nprobe_actual,
667 entries_scored,
668 ivf_used,
669 };
670
671 Ok((candidates, receipt))
672 }
673}
674
675#[cfg(test)]
680mod tests {
681 use super::*;
682 use crate::profile::FibQuantProfileV1;
683 use crate::{FibQuantizer, FibScorer};
684
685 fn build_test_scorer() -> Result<FibScorer> {
686 let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
687 profile.training_samples = 128;
688 profile.lloyd_restarts = 1;
689 profile.lloyd_iterations = 2;
690 let quantizer = FibQuantizer::new(profile)?;
691 FibScorer::new(quantizer)
692 }
693
694 fn make_vectors(d: usize, count: usize) -> Vec<Vec<f32>> {
695 (0..count)
696 .map(|seed| {
697 (0..d)
698 .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
699 .collect()
700 })
701 .collect()
702 }
703
704 #[test]
705 fn add_and_search_returns_correct_top_k() -> Result<()> {
706 let scorer = build_test_scorer()?;
707 let d = scorer.quantizer().profile().ambient_dim as usize;
708 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
709
710 let vectors = make_vectors(d, 16);
711 for (i, v) in vectors.iter().enumerate() {
712 let code = index.scorer().quantizer().encode(v)?;
713 index.add(i as u32, code);
714 }
715
716 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
717 assert_eq!(query.len(), d);
718 let results = index.search(&query, 5, 1)?;
719
720 assert_eq!(results.len(), 5, "should return exactly top_k=5");
721 for (i, r) in results.iter().enumerate() {
723 assert_eq!(r.rank, i, "rank should be sequential from 0");
724 }
725 Ok(())
726 }
727
728 #[test]
729 fn empty_index_search_returns_empty() -> Result<()> {
730 let scorer = build_test_scorer()?;
731 let d = scorer.quantizer().profile().ambient_dim as usize;
732 let index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
733
734 let query = vec![0.0f32; d];
735 let results = index.search(&query, 5, 2)?;
736 assert!(
737 results.is_empty(),
738 "empty index should return empty results"
739 );
740
741 let (results, receipt) = index.search_with_receipt(&query, 5, 2)?;
742 assert!(results.is_empty());
743 assert_eq!(receipt.indexed_count, 0);
744 assert_eq!(receipt.candidate_count, 0);
745 Ok(())
746 }
747
748 #[test]
749 fn oversample_returns_more_than_top_k() -> Result<()> {
750 let scorer = build_test_scorer()?;
751 let d = scorer.quantizer().profile().ambient_dim as usize;
752 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
753
754 let vectors = make_vectors(d, 20);
755 for (i, v) in vectors.iter().enumerate() {
756 let code = index.scorer().quantizer().encode(v)?;
757 index.add(i as u32, code);
758 }
759
760 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
761 let results = index.search(&query, 5, 3)?;
762 assert_eq!(results.len(), 15, "oversample=3 should give 15 candidates");
764 assert!(results.len() > 5, "should return more than top_k alone");
765 Ok(())
766 }
767
768 #[test]
769 fn results_sorted_descending() -> Result<()> {
770 let scorer = build_test_scorer()?;
771 let d = scorer.quantizer().profile().ambient_dim as usize;
772 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
773
774 let vectors = make_vectors(d, 16);
775 for (i, v) in vectors.iter().enumerate() {
776 let code = index.scorer().quantizer().encode(v)?;
777 index.add(i as u32, code);
778 }
779
780 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
781 let results = index.search(&query, 8, 1)?;
782 for w in results.windows(2) {
783 assert!(
784 w[0].approximate_score >= w[1].approximate_score,
785 "results not sorted descending: {} before {}",
786 w[0].approximate_score,
787 w[1].approximate_score
788 );
789 }
790 Ok(())
791 }
792
793 #[test]
794 fn receipt_fields_correct() -> Result<()> {
795 let scorer = build_test_scorer()?;
796 let d = scorer.quantizer().profile().ambient_dim as usize;
797 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
798
799 let vectors = make_vectors(d, 12);
800 for (i, v) in vectors.iter().enumerate() {
801 let code = index.scorer().quantizer().encode(v)?;
802 index.add(i as u32, code);
803 }
804
805 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
806 let (results, receipt) = index.search_with_receipt(&query, 5, 2)?;
807
808 assert_eq!(receipt.schema, "fib_sidecar_search_receipt_v1");
809 assert_eq!(receipt.indexed_count, 12);
810 assert_eq!(receipt.top_k, 5);
811 assert_eq!(receipt.oversample, 2);
812 assert_eq!(receipt.candidate_count, results.len());
813 assert!(receipt.approximate_only);
814 assert!(receipt.exact_rerank_required);
815 let _ = receipt.elapsed_micros;
818 Ok(())
819 }
820
821 #[test]
822 fn add_batch_works() -> Result<()> {
823 let scorer = build_test_scorer()?;
824 let d = scorer.quantizer().profile().ambient_dim as usize;
825 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
826
827 let vectors = make_vectors(d, 10);
828 let entries: Vec<(u32, FibCodeV1)> = vectors
829 .iter()
830 .enumerate()
831 .map(|(i, v)| {
832 let code = index.scorer().quantizer().encode(v).unwrap();
833 (i as u32, code)
834 })
835 .collect();
836
837 index.add_batch(entries);
838 assert_eq!(index.len(), 10);
839 assert!(!index.is_empty());
840
841 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
842 let results = index.search(&query, 3, 1)?;
843 assert_eq!(results.len(), 3);
844 Ok(())
845 }
846
847 #[test]
848 fn len_and_is_empty() -> Result<()> {
849 let scorer = build_test_scorer()?;
850 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
851
852 assert!(index.is_empty());
853 assert_eq!(index.len(), 0);
854
855 let d = index.scorer().quantizer().profile().ambient_dim as usize;
856 let v = vec![0.1f32; d];
857 let code = index.scorer().quantizer().encode(&v)?;
858 index.add(42, code);
859 assert!(!index.is_empty());
860 assert_eq!(index.len(), 1);
861 Ok(())
862 }
863
864 #[test]
869 fn ivf_build_and_search_returns_results() -> Result<()> {
870 let scorer = build_test_scorer()?;
871 let d = scorer.quantizer().profile().ambient_dim as usize;
872 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
873
874 let vectors = make_vectors(d, 100);
875 for (i, v) in vectors.iter().enumerate() {
876 let code = index.scorer().quantizer().encode(v)?;
877 index.add(i as u32, code);
878 }
879
880 index.build_ivf(10)?;
882 assert!(index.ivf_is_built(), "IVF should be built");
883 assert_eq!(index.ivf().unwrap().num_centroids(), 10);
884 assert_eq!(index.ivf().unwrap().assignments().len(), 100);
885
886 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
887 assert_eq!(query.len(), d);
888
889 let results = index.search_ivf(&query, 5, 2, 4)?;
891 assert!(!results.is_empty(), "IVF search should return results");
892 assert!(
893 results.len() <= 10,
894 "should return at most top_k*oversample=10"
895 );
896
897 for w in results.windows(2) {
899 assert!(
900 w[0].approximate_score >= w[1].approximate_score,
901 "IVF results not sorted descending"
902 );
903 }
904 Ok(())
905 }
906
907 #[test]
908 fn ivf_nprobe_fewer_than_all_probes_fewer_entries() -> Result<()> {
909 let scorer = build_test_scorer()?;
910 let d = scorer.quantizer().profile().ambient_dim as usize;
911 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
912
913 let vectors = make_vectors(d, 100);
914 for (i, v) in vectors.iter().enumerate() {
915 let code = index.scorer().quantizer().encode(v)?;
916 index.add(i as u32, code);
917 }
918
919 index.build_ivf(10)?;
920
921 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
922
923 let (_results1, receipt1) = index.search_with_receipt_ivf(&query, 5, 2, 1)?;
925 let (_results_all, receipt_all) = index.search_with_receipt_ivf(&query, 5, 2, 10)?;
926
927 assert!(receipt1.ivf_used, "IVF should be used");
928 assert_eq!(receipt1.nprobe, 1);
929 assert_eq!(receipt_all.nprobe, 10);
930 assert!(
931 receipt1.entries_scored <= receipt_all.entries_scored,
932 "nprobe=1 should score <= entries than nprobe=10: {} vs {}",
933 receipt1.entries_scored,
934 receipt_all.entries_scored
935 );
936 assert_eq!(
937 receipt_all.entries_scored, 100,
938 "nprobe=10 (all centroids) should score all 100 entries"
939 );
940 Ok(())
941 }
942
943 #[test]
944 fn ivf_fallback_when_not_built() -> Result<()> {
945 let scorer = build_test_scorer()?;
946 let d = scorer.quantizer().profile().ambient_dim as usize;
947 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
948
949 let vectors = make_vectors(d, 20);
950 for (i, v) in vectors.iter().enumerate() {
951 let code = index.scorer().quantizer().encode(v)?;
952 index.add(i as u32, code);
953 }
954
955 assert!(!index.ivf_is_built(), "IVF should not be built");
957
958 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
959 let results = index.search_ivf(&query, 5, 2, 4)?;
960 assert_eq!(
961 results.len(),
962 10,
963 "fallback linear scan should return top_k*oversample=10"
964 );
965
966 let (results, receipt) = index.search_with_receipt_ivf(&query, 5, 2, 4)?;
968 assert!(!receipt.ivf_used, "receipt should show IVF not used");
969 assert_eq!(receipt.num_centroids, 0);
970 assert_eq!(receipt.nprobe, 0);
971 assert_eq!(receipt.entries_scored, 20);
972 assert_eq!(results.len(), 10);
973 Ok(())
974 }
975
976 #[test]
977 fn ivf_build_default_uses_sqrt_n() -> Result<()> {
978 let scorer = build_test_scorer()?;
979 let d = scorer.quantizer().profile().ambient_dim as usize;
980 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
981
982 let vectors = make_vectors(d, 100);
983 for (i, v) in vectors.iter().enumerate() {
984 let code = index.scorer().quantizer().encode(v)?;
985 index.add(i as u32, code);
986 }
987
988 index.build_ivf_default()?;
990 assert!(index.ivf_is_built());
991 assert_eq!(index.ivf().unwrap().num_centroids(), 10);
992
993 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
994 let results = index.search_ivf(&query, 5, 1, 8)?;
995 assert!(!results.is_empty());
996 Ok(())
997 }
998
999 #[test]
1000 fn ivf_receipt_fields_correct() -> Result<()> {
1001 let scorer = build_test_scorer()?;
1002 let d = scorer.quantizer().profile().ambient_dim as usize;
1003 let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
1004
1005 let vectors = make_vectors(d, 100);
1006 for (i, v) in vectors.iter().enumerate() {
1007 let code = index.scorer().quantizer().encode(v)?;
1008 index.add(i as u32, code);
1009 }
1010
1011 index.build_ivf(10)?;
1012
1013 let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
1014 let (results, receipt) = index.search_with_receipt_ivf(&query, 5, 2, 4)?;
1015
1016 assert_eq!(receipt.schema, "fib_sidecar_search_ivf_receipt_v1");
1017 assert_eq!(receipt.indexed_count, 100);
1018 assert_eq!(receipt.top_k, 5);
1019 assert_eq!(receipt.oversample, 2);
1020 assert_eq!(receipt.candidate_count, results.len());
1021 assert!(receipt.approximate_only);
1022 assert!(receipt.exact_rerank_required);
1023 assert_eq!(receipt.num_centroids, 10);
1024 assert_eq!(receipt.nprobe, 4);
1025 assert!(receipt.ivf_used);
1026 assert!(
1027 receipt.entries_scored <= 100,
1028 "entries_scored should be <= total entries"
1029 );
1030 assert!(
1031 receipt.entries_scored > 0,
1032 "entries_scored should be > 0 with 100 entries and nprobe=4"
1033 );
1034 Ok(())
1035 }
1036}