1use candle_core::Tensor;
35
36use crate::{
37 Result,
38 codec::DecodeTable,
39 device::default_device,
40 distance::dot,
41 index::Index,
42};
43
44#[derive(Debug, Clone, Copy)]
46pub struct SearchParams {
47 pub top_k: usize,
49 pub n_probe: usize,
51 pub n_candidate_docs: Option<usize>,
57 pub centroid_score_threshold: Option<f32>,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct SearchResult {
68 pub doc_id: u64,
69 pub score: f32,
70}
71
72pub fn search(
91 index: &Index,
92 query_tokens: &[f32],
93 params: SearchParams,
94) -> Result<Vec<SearchResult>> {
95 let dim = index.params.dim;
96 assert!(params.top_k > 0, "search: top_k must be positive");
97 assert!(params.n_probe > 0, "search: n_probe must be positive");
98 assert!(
99 query_tokens.len().is_multiple_of(dim),
100 "search: query length {} is not a multiple of dim {}",
101 query_tokens.len(),
102 dim,
103 );
104
105 if query_tokens.is_empty() || index.num_documents() == 0 {
106 return Ok(Vec::new());
107 }
108
109 let n_centroids = index.codec.num_centroids();
110 let n_probe = params.n_probe.min(n_centroids);
111
112 let qc_scores: Option<Vec<f32>> = (params
119 .centroid_score_threshold
120 .is_some()
121 || params.n_candidate_docs.is_some())
122 .then(|| {
123 query_centroid_score_matrix(query_tokens, &index.codec.centroids, dim)
124 });
125 let pruned_mask: Option<Vec<bool>> =
126 match (qc_scores.as_ref(), params.centroid_score_threshold) {
127 (Some(scores), Some(threshold)) => {
128 let per_cent = per_centroid_max_scores(
129 scores,
130 query_tokens.len() / dim,
131 n_centroids,
132 );
133 Some(per_cent.iter().map(|&s| s < threshold).collect())
134 }
135 _ => None,
136 };
137
138 let mut candidate_docs: Vec<bool> = vec![false; index.num_documents()];
146 for query_token in query_tokens.chunks_exact(dim) {
147 for centroid_id in
148 top_n_centroids(query_token, &index.codec.centroids, dim, n_probe)
149 {
150 if let Some(mask) = pruned_mask.as_ref()
151 && mask[centroid_id]
152 {
153 continue;
154 }
155 for &doc_idx in index.ivf.docs_for_centroid(centroid_id) {
156 candidate_docs[doc_idx as usize] = true;
157 }
158 }
159 }
160
161 let mut candidate_idxs: Vec<usize> = candidate_docs
162 .iter()
163 .enumerate()
164 .filter_map(|(idx, &is_cand)| {
165 (is_cand && !index.doc_tokens[idx].is_empty()).then_some(idx)
166 })
167 .collect();
168
169 if let (Some(n_stage2), Some(qc_scores)) =
178 (params.n_candidate_docs, qc_scores.as_ref())
179 {
180 let n_q = query_tokens.len() / dim;
181 let n_c = index.codec.num_centroids();
182
183 candidate_idxs = shortlist_by_approx_score(
186 candidate_idxs,
187 index,
188 qc_scores,
189 n_q,
190 n_c,
191 pruned_mask.as_deref(),
192 n_stage2,
193 );
194
195 let n_stage3 = n_stage2.div_ceil(4).max(1);
202 candidate_idxs = shortlist_by_approx_score(
203 candidate_idxs,
204 index,
205 qc_scores,
206 n_q,
207 n_c,
208 None,
209 n_stage3,
210 );
211 }
212
213 let mut scored: Vec<SearchResult> = if candidate_idxs.is_empty() {
216 Vec::new()
217 } else {
218 batch_maxsim(query_tokens, &candidate_idxs, index, dim)?
219 .into_iter()
220 .map(|(doc_idx, score)| SearchResult {
221 doc_id: index.doc_ids[doc_idx],
222 score,
223 })
224 .collect()
225 };
226
227 scored.sort_by(|a, b| {
229 b.score
230 .partial_cmp(&a.score)
231 .unwrap_or(std::cmp::Ordering::Equal)
232 .then_with(|| a.doc_id.cmp(&b.doc_id))
233 });
234 scored.truncate(params.top_k);
235 Ok(scored)
236}
237
238pub fn top_n_centroids(
258 point: &[f32],
259 centroids: &[f32],
260 dim: usize,
261 n: usize,
262) -> Vec<usize> {
263 assert_eq!(
264 point.len(),
265 dim,
266 "top_n_centroids: point length {} does not match dim {}",
267 point.len(),
268 dim,
269 );
270 assert!(dim > 0, "top_n_centroids: dim must be positive");
271 assert!(
272 !centroids.is_empty() && centroids.len().is_multiple_of(dim),
273 "top_n_centroids: centroids length {} is not a positive multiple of dim {}",
274 centroids.len(),
275 dim,
276 );
277
278 let k = centroids.len() / dim;
279 let mut scored: Vec<(usize, f32)> = centroids
280 .chunks_exact(dim)
281 .enumerate()
282 .map(|(i, c)| (i, dot(point, c)))
283 .collect();
284 scored.sort_by(|a, b| {
285 b.1.partial_cmp(&a.1)
286 .unwrap_or(std::cmp::Ordering::Equal)
287 .then_with(|| a.0.cmp(&b.0))
288 });
289 scored.into_iter().take(n.min(k)).map(|(i, _)| i).collect()
290}
291
292fn query_centroid_score_matrix(
299 query_tokens: &[f32],
300 centroids: &[f32],
301 dim: usize,
302) -> Vec<f32> {
303 let n_q = query_tokens.len() / dim;
304 let n_c = centroids.len() / dim;
305 let mut out = vec![0.0f32; n_q * n_c];
306 for (qi, q) in query_tokens.chunks_exact(dim).enumerate() {
307 for (ci, c) in centroids.chunks_exact(dim).enumerate() {
308 out[qi * n_c + ci] = dot(q, c);
309 }
310 }
311 out
312}
313
314fn shortlist_by_approx_score(
322 candidate_idxs: Vec<usize>,
323 index: &Index,
324 qc_scores: &[f32],
325 n_q: usize,
326 n_centroids: usize,
327 mask: Option<&[bool]>,
328 limit: usize,
329) -> Vec<usize> {
330 if candidate_idxs.len() <= limit {
331 return candidate_idxs;
332 }
333 let mut approx: Vec<(usize, f32)> = candidate_idxs
334 .into_iter()
335 .map(|doc_idx| {
336 let score = approx_centroid_interaction_score(
337 &index.doc_tokens[doc_idx],
338 qc_scores,
339 n_q,
340 n_centroids,
341 mask,
342 );
343 (doc_idx, score)
344 })
345 .collect();
346 approx.sort_by(|a, b| {
347 b.1.partial_cmp(&a.1)
348 .unwrap_or(std::cmp::Ordering::Equal)
349 .then_with(|| a.0.cmp(&b.0))
350 });
351 approx.truncate(limit);
352 approx.into_iter().map(|(idx, _)| idx).collect()
353}
354
355fn per_centroid_max_scores(
363 qc_scores: &[f32],
364 n_q: usize,
365 n_centroids: usize,
366) -> Vec<f32> {
367 let mut out = vec![f32::NEG_INFINITY; n_centroids];
368 for q in 0..n_q {
369 let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
370 for (c, &s) in row.iter().enumerate() {
371 if s > out[c] {
372 out[c] = s;
373 }
374 }
375 }
376 out
377}
378
379fn approx_centroid_interaction_score(
394 doc: &[crate::codec::EncodedVector],
395 qc_scores: &[f32],
396 n_q: usize,
397 n_centroids: usize,
398 pruned_mask: Option<&[bool]>,
399) -> f32 {
400 if doc.is_empty() {
401 return 0.0;
402 }
403 if let Some(mask) = pruned_mask
407 && doc.iter().all(|ev| mask[ev.centroid_id as usize])
408 {
409 return f32::NEG_INFINITY;
410 }
411 let mut total = 0.0f32;
412 for q in 0..n_q {
413 let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
414 let mut best = f32::NEG_INFINITY;
415 for ev in doc {
416 if let Some(mask) = pruned_mask
417 && mask[ev.centroid_id as usize]
418 {
419 continue;
420 }
421 let s = row[ev.centroid_id as usize];
422 if s > best {
423 best = s;
424 }
425 }
426 if best.is_finite() {
427 total += best;
428 }
429 }
430 total
431}
432
433fn batch_maxsim(
451 query_tokens: &[f32],
452 candidate_idxs: &[usize],
453 index: &Index,
454 dim: usize,
455) -> Result<Vec<(usize, f32)>> {
456 let n_q = query_tokens.len() / dim;
457
458 let decode_table = DecodeTable::new(&index.codec);
459 let total_tokens_bound: usize = candidate_idxs
460 .iter()
461 .map(|&i| index.doc_tokens[i].len())
462 .sum();
463 let mut packed: Vec<f32> = Vec::with_capacity(total_tokens_bound * dim);
465 let mut offsets: Vec<usize> = Vec::with_capacity(candidate_idxs.len() + 1);
466 offsets.push(0);
467 for &doc_idx in candidate_idxs {
468 for ev in &index.doc_tokens[doc_idx] {
469 packed.extend(
470 index.codec.decode_vector_with_table(ev, &decode_table)?,
471 );
472 }
473 offsets.push(packed.len() / dim);
474 }
475 let total_tokens = packed.len() / dim;
476 if total_tokens == 0 || n_q == 0 {
477 return Ok(candidate_idxs.iter().map(|&i| (i, 0.0)).collect());
478 }
479
480 let device = default_device();
483 let docs_t = Tensor::from_vec(packed, (total_tokens, dim), device)?;
484 let q_t = Tensor::from_slice(query_tokens, (n_q, dim), device)?;
485 let q_transposed = q_t.t()?.contiguous()?;
486 let scores_flat: Vec<f32> = docs_t
487 .matmul(&q_transposed)?
488 .to_vec2::<f32>()?
489 .into_iter()
490 .flatten()
491 .collect();
492
493 let mut out = Vec::with_capacity(candidate_idxs.len());
495 for (i, &doc_idx) in candidate_idxs.iter().enumerate() {
496 let start = offsets[i];
497 let end = offsets[i + 1];
498 if start == end {
499 out.push((doc_idx, 0.0));
500 continue;
501 }
502 let mut total = 0.0f32;
503 for q in 0..n_q {
504 let mut best = f32::NEG_INFINITY;
505 for t in start..end {
506 let s = scores_flat[t * n_q + q];
507 if s > best {
508 best = s;
509 }
510 }
511 if best.is_finite() {
512 total += best;
513 }
514 }
515 out.push((doc_idx, total));
516 }
517 Ok(out)
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::{
524 distance::dot,
525 index::{DocumentTokens, IndexParams, build_index},
526 };
527
528 fn params() -> IndexParams {
529 IndexParams {
530 dim: 2,
531 nbits: 2,
532 k_centroids: 2,
533 max_kmeans_iters: 50,
534 }
535 }
536
537 fn corpus() -> Vec<DocumentTokens> {
543 let east_a = [1.0f32, 0.0];
545 let east_b = normalize([0.98, 0.2]);
546 let east_c = normalize([0.97, -0.24]);
547 let north_a = [0.0f32, 1.0];
548 let north_b = normalize([0.2, 0.98]);
549 let north_c = normalize([-0.24, 0.97]);
550
551 let mut doc1 = Vec::new();
552 doc1.extend_from_slice(&east_a);
553 doc1.extend_from_slice(&east_b);
554 doc1.extend_from_slice(&east_c);
555
556 let mut doc2 = Vec::new();
557 doc2.extend_from_slice(&north_a);
558 doc2.extend_from_slice(&north_b);
559 doc2.extend_from_slice(&north_c);
560
561 let mut doc3 = Vec::new();
562 doc3.extend_from_slice(&east_a);
563 doc3.extend_from_slice(&north_a);
564
565 vec![
566 DocumentTokens {
567 doc_id: 1,
568 tokens: doc1,
569 n_tokens: 3,
570 },
571 DocumentTokens {
572 doc_id: 2,
573 tokens: doc2,
574 n_tokens: 3,
575 },
576 DocumentTokens {
577 doc_id: 3,
578 tokens: doc3,
579 n_tokens: 2,
580 },
581 ]
582 }
583
584 fn normalize(v: [f32; 2]) -> [f32; 2] {
585 let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
586 [v[0] / norm, v[1] / norm]
587 }
588
589 #[test]
590 fn top_n_centroids_ranks_by_descending_dot_product() {
591 let centroids = [0.0, 0.0, 5.0, 0.0, 12.0, 0.0];
596 let out = top_n_centroids(&[4.0, 0.0], ¢roids, 2, 3);
597 assert_eq!(out, vec![2, 1, 0]);
598 }
599
600 #[test]
601 fn top_n_centroids_breaks_ties_toward_earlier_index() {
602 let centroids = [1.0, 0.0, 0.0, 1.0, -1.0, 0.0];
605 let out = top_n_centroids(&[0.0, 0.0], ¢roids, 2, 3);
606 assert_eq!(out, vec![0, 1, 2]);
607 }
608
609 #[test]
610 fn top_n_centroids_on_unit_norm_centroids_picks_most_aligned() {
611 let centroids = [
615 0.6, 0.8, 0.0, 1.0, -1.0, 0.0, 1.0, 0.0,
619 ];
620 let out = top_n_centroids(&[1.0, 0.0], ¢roids, 2, 2);
621 assert_eq!(out, vec![3, 0]);
622 }
623
624 #[test]
625 fn top_n_centroids_caps_at_num_centroids() {
626 let centroids = [0.0, 0.0, 5.0, 0.0];
627 let out = top_n_centroids(&[0.0, 0.0], ¢roids, 2, 10);
628 assert_eq!(out, vec![0, 1]);
629 }
630
631 #[test]
632 fn search_returns_empty_for_empty_query() {
633 let index = build_index(&corpus(), params()).unwrap();
634 let out = search(
635 &index,
636 &[],
637 SearchParams {
638 top_k: 3,
639 n_probe: 2,
640 n_candidate_docs: None,
641 centroid_score_threshold: None,
642 },
643 )
644 .unwrap();
645 assert!(out.is_empty());
646 }
647
648 #[test]
649 fn search_ranks_matching_cluster_highest() {
650 let index = build_index(&corpus(), params()).unwrap();
654 let out = search(
655 &index,
656 &[1.0, 0.0],
657 SearchParams {
658 top_k: 3,
659 n_probe: 2,
660 n_candidate_docs: None,
661 centroid_score_threshold: None,
662 },
663 )
664 .unwrap();
665 assert!(!out.is_empty(), "should surface at least one doc");
666 assert_eq!(out[0].doc_id, 1, "closest doc should rank first");
667 }
668
669 #[test]
670 fn search_respects_top_k() {
671 let index = build_index(&corpus(), params()).unwrap();
672 let out = search(
673 &index,
674 &[1.0, 0.0],
675 SearchParams {
676 top_k: 1,
677 n_probe: 2,
678 n_candidate_docs: None,
679 centroid_score_threshold: None,
680 },
681 )
682 .unwrap();
683 assert_eq!(out.len(), 1);
684 }
685
686 #[test]
687 fn search_scores_are_non_increasing() {
688 let index = build_index(&corpus(), params()).unwrap();
689 let out = search(
691 &index,
692 &[1.0, 0.0, 0.0, 1.0],
693 SearchParams {
694 top_k: 3,
695 n_probe: 2,
696 n_candidate_docs: None,
697 centroid_score_threshold: None,
698 },
699 )
700 .unwrap();
701 for pair in out.windows(2) {
702 assert!(
703 pair[0].score >= pair[1].score,
704 "scores must be descending: {pair:?}"
705 );
706 }
707 }
708
709 #[test]
710 fn search_with_single_probe_still_finds_the_right_cluster() {
711 let index = build_index(&corpus(), params()).unwrap();
715 let out = search(
716 &index,
717 &[0.0, 1.0],
718 SearchParams {
719 top_k: 1,
720 n_probe: 1,
721 n_candidate_docs: None,
722 centroid_score_threshold: None,
723 },
724 )
725 .unwrap();
726 assert_eq!(out[0].doc_id, 2);
727 }
728
729 #[test]
730 fn search_skips_documents_with_no_tokens() {
731 let mut docs = corpus();
732 docs.push(DocumentTokens {
733 doc_id: 42,
734 tokens: vec![],
735 n_tokens: 0,
736 });
737 let index = build_index(&docs, params()).unwrap();
738 let out = search(
739 &index,
740 &[1.0, 0.0],
741 SearchParams {
742 top_k: 10,
743 n_probe: 2,
744 n_candidate_docs: None,
745 centroid_score_threshold: None,
746 },
747 )
748 .unwrap();
749 assert!(
750 out.iter().all(|r| r.doc_id != 42),
751 "empty doc should not appear in results"
752 );
753 }
754
755 #[test]
756 fn search_score_equals_sum_of_maxsim_over_decoded_tokens() {
757 let index = build_index(&corpus(), params()).unwrap();
761 let query = [1.0f32, 0.0];
762
763 let out = search(
764 &index,
765 &query,
766 SearchParams {
767 top_k: 1,
768 n_probe: index.codec.num_centroids(),
769 n_candidate_docs: None,
770 centroid_score_threshold: None,
771 },
772 )
773 .unwrap();
774 assert!(!out.is_empty());
775
776 let top = out[0];
777 let doc_idx = index.position_of(top.doc_id).expect("doc present");
778 let decoded: Vec<Vec<f32>> = index.doc_tokens[doc_idx]
779 .iter()
780 .map(|ev| index.codec.decode_vector(ev).unwrap())
781 .collect();
782
783 let mut expected = 0.0f32;
785 for q in query.chunks_exact(2) {
786 let best = decoded
787 .iter()
788 .map(|d| dot(q, d))
789 .fold(f32::NEG_INFINITY, f32::max);
790 expected += best;
791 }
792 assert!(
793 (expected - top.score).abs() < 1e-5,
794 "score {} differs from ground-truth MaxSim {}",
795 top.score,
796 expected,
797 );
798 }
799
800 #[test]
801 fn search_works_with_four_bit_residuals() {
802 let params_4bit = IndexParams {
806 dim: 2,
807 nbits: 4,
808 k_centroids: 2,
809 max_kmeans_iters: 50,
810 };
811 let index = build_index(&corpus(), params_4bit).unwrap();
812 let out = search(
813 &index,
814 &[1.0, 0.0],
815 SearchParams {
816 top_k: 1,
817 n_probe: 2,
818 n_candidate_docs: None,
819 centroid_score_threshold: None,
820 },
821 )
822 .unwrap();
823 assert_eq!(out[0].doc_id, 1);
824 }
825
826 #[test]
827 fn search_survives_large_synthetic_corpus_on_both_clusters() {
828 let mut docs = Vec::new();
831 for i in 0..20 {
832 let jitter = i as f32 * 0.003;
833 let east = normalize([1.0 - jitter, 0.05 + jitter]);
834 docs.push(DocumentTokens {
835 doc_id: 100 + i,
836 tokens: east.to_vec(),
837 n_tokens: 1,
838 });
839 let north = normalize([0.05 + jitter, 1.0 - jitter]);
840 docs.push(DocumentTokens {
841 doc_id: 200 + i,
842 tokens: north.to_vec(),
843 n_tokens: 1,
844 });
845 }
846 let index = build_index(&docs, params()).unwrap();
847
848 let east = search(
849 &index,
850 &[1.0, 0.0],
851 SearchParams {
852 top_k: 5,
853 n_probe: 2,
854 n_candidate_docs: None,
855 centroid_score_threshold: None,
856 },
857 )
858 .unwrap();
859 for r in &east {
860 assert!(
861 (100..120).contains(&r.doc_id),
862 "east query should only surface east docs: got {}",
863 r.doc_id,
864 );
865 }
866
867 let north = search(
868 &index,
869 &[0.0, 1.0],
870 SearchParams {
871 top_k: 5,
872 n_probe: 2,
873 n_candidate_docs: None,
874 centroid_score_threshold: None,
875 },
876 )
877 .unwrap();
878 for r in &north {
879 assert!(
880 (200..220).contains(&r.doc_id),
881 "north query should only surface north docs: got {}",
882 r.doc_id,
883 );
884 }
885 }
886
887 #[test]
888 #[should_panic(expected = "top_k must be positive")]
889 fn search_panics_on_zero_top_k() {
890 let index = build_index(&corpus(), params()).unwrap();
891 let _ = search(
892 &index,
893 &[1.0, 0.0],
894 SearchParams {
895 top_k: 0,
896 n_probe: 1,
897 n_candidate_docs: None,
898 centroid_score_threshold: None,
899 },
900 )
901 .unwrap();
902 }
903
904 #[test]
905 #[should_panic(expected = "n_probe must be positive")]
906 fn search_panics_on_zero_n_probe() {
907 let index = build_index(&corpus(), params()).unwrap();
908 let _ = search(
909 &index,
910 &[1.0, 0.0],
911 SearchParams {
912 top_k: 1,
913 n_probe: 0,
914 n_candidate_docs: None,
915 centroid_score_threshold: None,
916 },
917 )
918 .unwrap();
919 }
920
921 #[test]
922 #[should_panic(expected = "query length")]
923 fn search_panics_on_ragged_query() {
924 let index = build_index(&corpus(), params()).unwrap();
925 let _ = search(
926 &index,
927 &[1.0, 0.0, 0.5],
928 SearchParams {
929 top_k: 1,
930 n_probe: 1,
931 n_candidate_docs: None,
932 centroid_score_threshold: None,
933 },
934 )
935 .unwrap();
936 }
937
938 #[test]
939 fn centroid_interaction_shortlist_caps_candidates_surviving_to_decode() {
940 let index = build_index(&corpus(), params()).unwrap();
944 let out = search(
945 &index,
946 &[1.0, 0.0],
947 SearchParams {
948 top_k: 5,
949 n_probe: index.codec.num_centroids(),
950 n_candidate_docs: Some(1),
951 centroid_score_threshold: None,
952 },
953 )
954 .unwrap();
955 assert_eq!(out.len(), 1, "shortlist of 1 must cap output at 1");
956 assert_eq!(
957 out[0].doc_id, 1,
958 "east query should surface the east doc as the survivor",
959 );
960 }
961
962 #[test]
963 fn centroid_interaction_with_large_shortlist_matches_no_shortlist() {
964 let index = build_index(&corpus(), params()).unwrap();
969 let query = [1.0, 0.0, 0.0, 1.0];
970
971 let legacy = search(
972 &index,
973 &query,
974 SearchParams {
975 top_k: 3,
976 n_probe: index.codec.num_centroids(),
977 n_candidate_docs: None,
978 centroid_score_threshold: None,
979 },
980 )
981 .unwrap();
982 let shortlisted = search(
983 &index,
984 &query,
985 SearchParams {
986 top_k: 3,
987 n_probe: index.codec.num_centroids(),
988 n_candidate_docs: Some(1_000),
989 centroid_score_threshold: None,
990 },
991 )
992 .unwrap();
993 assert_eq!(legacy, shortlisted);
994 }
995
996 #[test]
997 fn centroid_pruning_with_low_threshold_matches_no_pruning() {
998 let index = build_index(&corpus(), params()).unwrap();
1002 let query = [1.0, 0.0, 0.0, 1.0];
1003 let unpruned = search(
1004 &index,
1005 &query,
1006 SearchParams {
1007 top_k: 3,
1008 n_probe: index.codec.num_centroids(),
1009 n_candidate_docs: None,
1010 centroid_score_threshold: None,
1011 },
1012 )
1013 .unwrap();
1014 let pruned = search(
1015 &index,
1016 &query,
1017 SearchParams {
1018 top_k: 3,
1019 n_probe: index.codec.num_centroids(),
1020 n_candidate_docs: None,
1021 centroid_score_threshold: Some(-1.0),
1022 },
1023 )
1024 .unwrap();
1025 assert_eq!(unpruned, pruned);
1026 }
1027
1028 #[test]
1029 fn two_stage_interaction_caps_survivors_to_ndocs_div_four() {
1030 let mut docs = Vec::new();
1036 for i in 0..20 {
1037 let jitter = i as f32 * 0.003;
1038 let east = normalize([1.0 - jitter, 0.05 + jitter]);
1039 docs.push(DocumentTokens {
1040 doc_id: 100 + i,
1041 tokens: east.to_vec(),
1042 n_tokens: 1,
1043 });
1044 }
1045 let index = build_index(&docs, params()).unwrap();
1046 let out = search(
1047 &index,
1048 &[1.0, 0.0],
1049 SearchParams {
1050 top_k: 20,
1051 n_probe: index.codec.num_centroids(),
1052 n_candidate_docs: Some(16),
1053 centroid_score_threshold: None,
1054 },
1055 )
1056 .unwrap();
1057 assert!(
1058 out.len() <= 4,
1059 "Stage 3 must cap the decoded set at ndocs/4 = 4; got {}",
1060 out.len(),
1061 );
1062 assert!(
1063 !out.is_empty(),
1064 "Stage 3 should still return at least one result"
1065 );
1066 }
1067
1068 #[test]
1069 fn approx_score_skips_tokens_whose_centroid_is_pruned() {
1070 use crate::codec::EncodedVector;
1082 let n_q = 2;
1083 let n_c = 3;
1084 let qc = [0.9, 0.0, 0.4, 0.0, 0.9, 0.4];
1088 let doc = [
1089 EncodedVector {
1090 centroid_id: 0,
1091 codes: vec![0, 0],
1092 },
1093 EncodedVector {
1094 centroid_id: 2,
1095 codes: vec![0, 0],
1096 },
1097 ];
1098
1099 let unpruned =
1100 approx_centroid_interaction_score(&doc, &qc, n_q, n_c, None);
1101 assert!((unpruned - 1.3).abs() < 1e-5, "unpruned was {unpruned}");
1103
1104 let pruned_mask = [false, false, true];
1106 let pruned = approx_centroid_interaction_score(
1107 &doc,
1108 &qc,
1109 n_q,
1110 n_c,
1111 Some(&pruned_mask),
1112 );
1113 assert!((pruned - 0.9).abs() < 1e-5, "pruned was {pruned}");
1116 }
1117
1118 #[test]
1119 fn approx_score_on_all_pruned_doc_is_negative_infinity() {
1120 use crate::codec::EncodedVector;
1124 let qc = [0.5, 0.5];
1125 let doc = [EncodedVector {
1126 centroid_id: 0,
1127 codes: vec![0, 0],
1128 }];
1129 let mask = [true];
1130 let score =
1131 approx_centroid_interaction_score(&doc, &qc, 1, 1, Some(&mask));
1132 assert!(
1133 score == f32::NEG_INFINITY,
1134 "all-pruned doc should score -inf, got {score}",
1135 );
1136 }
1137
1138 #[test]
1139 fn centroid_pruning_excludes_docs_whose_only_centroid_is_below_threshold() {
1140 let index = build_index(&corpus(), params()).unwrap();
1146 let out = search(
1147 &index,
1148 &[1.0f32, 0.0],
1149 SearchParams {
1150 top_k: 5,
1151 n_probe: index.codec.num_centroids(),
1152 n_candidate_docs: None,
1153 centroid_score_threshold: Some(0.5),
1154 },
1155 )
1156 .unwrap();
1157 let ids: Vec<u64> = out.iter().map(|r| r.doc_id).collect();
1158 assert!(
1159 !ids.contains(&2),
1160 "pure-north doc must be pruned when only the east centroid survives the threshold: got {ids:?}",
1161 );
1162 assert!(
1163 ids.contains(&1),
1164 "east-cluster doc must still rank when its centroid survives: got {ids:?}",
1165 );
1166 }
1167
1168 #[test]
1169 fn centroid_pruning_with_unreachable_threshold_drops_every_candidate() {
1170 let index = build_index(&corpus(), params()).unwrap();
1174 let out = search(
1175 &index,
1176 &[1.0, 0.0],
1177 SearchParams {
1178 top_k: 3,
1179 n_probe: index.codec.num_centroids(),
1180 n_candidate_docs: None,
1181 centroid_score_threshold: Some(10.0),
1182 },
1183 )
1184 .unwrap();
1185 assert!(
1186 out.is_empty(),
1187 "every centroid below threshold should yield empty results",
1188 );
1189 }
1190
1191 #[test]
1192 fn centroid_interaction_preserves_exact_maxsim_on_surviving_candidates() {
1193 let index = build_index(&corpus(), params()).unwrap();
1197 let query = [1.0f32, 0.0];
1198 let out = search(
1199 &index,
1200 &query,
1201 SearchParams {
1202 top_k: 1,
1203 n_probe: index.codec.num_centroids(),
1204 n_candidate_docs: Some(1_000),
1205 centroid_score_threshold: None,
1206 },
1207 )
1208 .unwrap();
1209 let top = out[0];
1210 let doc_idx = index.position_of(top.doc_id).unwrap();
1211 let decoded: Vec<Vec<f32>> = index.doc_tokens[doc_idx]
1212 .iter()
1213 .map(|ev| index.codec.decode_vector(ev).unwrap())
1214 .collect();
1215 let expected: f32 = query
1216 .chunks_exact(2)
1217 .map(|q| {
1218 decoded
1219 .iter()
1220 .map(|d| dot(q, d))
1221 .fold(f32::NEG_INFINITY, f32::max)
1222 })
1223 .sum();
1224 assert!((expected - top.score).abs() < 1e-5);
1225 }
1226}