1use std::collections::HashMap;
61
62#[derive(Debug, Clone)]
68pub enum ScoringModel {
69 DotProduct,
71 Cosine,
73 BilinearForm(Vec<f64>),
79 Linear { weights: Vec<f64>, bias: f64 },
85}
86
87#[derive(Debug, Clone)]
89pub struct CrossEncoderConfig {
90 pub model: ScoringModel,
92 pub max_doc_length: usize,
94 pub batch_size: usize,
97 pub normalize_scores: bool,
100}
101
102impl Default for CrossEncoderConfig {
103 fn default() -> Self {
104 Self {
105 model: ScoringModel::Cosine,
106 max_doc_length: 512,
107 batch_size: 64,
108 normalize_scores: true,
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct CandidateDoc {
116 pub doc_id: String,
118 pub embedding: Vec<f64>,
120 pub initial_score: f64,
122 pub metadata: HashMap<String, String>,
124}
125
126#[derive(Debug, Clone)]
128pub struct RerankedDoc {
129 pub doc_id: String,
131 pub cross_encoder_score: f64,
133 pub initial_score: f64,
135 pub final_rank: usize,
137 pub score_delta: f64,
140}
141
142#[derive(Debug, Clone, Default)]
144pub struct CrossEncoderStats {
145 pub total_reranks: u64,
147 pub total_docs_reranked: u64,
149 pub avg_rank_change: f64,
151}
152
153pub struct CrossEncoder {
162 config: CrossEncoderConfig,
163 stats: CrossEncoderStats,
164}
165
166impl CrossEncoder {
167 pub fn new(config: CrossEncoderConfig) -> Self {
171 Self {
172 config,
173 stats: CrossEncoderStats::default(),
174 }
175 }
176
177 pub fn score_pair(&self, query: &[f64], doc: &[f64]) -> f64 {
184 let max_len = self.config.max_doc_length;
185 let q = if query.len() > max_len {
186 &query[..max_len]
187 } else {
188 query
189 };
190 let d = if doc.len() > max_len {
191 &doc[..max_len]
192 } else {
193 doc
194 };
195
196 match &self.config.model {
197 ScoringModel::DotProduct => Self::dot_product(q, d),
198 ScoringModel::Cosine => Self::cosine_similarity(q, d),
199 ScoringModel::BilinearForm(weights) => Self::bilinear_score(q, d, weights),
200 ScoringModel::Linear { weights, bias } => Self::linear_score(q, d, weights, *bias),
201 }
202 }
203
204 pub fn rerank(&mut self, query: &[f64], candidates: Vec<CandidateDoc>) -> Vec<RerankedDoc> {
211 if candidates.is_empty() {
212 self.stats.total_reranks += 1;
213 return Vec::new();
214 }
215
216 let initial_order: Vec<String> = candidates.iter().map(|c| c.doc_id.clone()).collect();
218
219 let mut scored: Vec<(f64, CandidateDoc)> = candidates
221 .into_iter()
222 .map(|c| {
223 let score = self.score_pair(query, &c.embedding);
224 (score, c)
225 })
226 .collect();
227
228 scored.sort_by(|(a, _), (b, _)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
230
231 let mut reranked: Vec<RerankedDoc> = scored
233 .into_iter()
234 .enumerate()
235 .map(|(rank_idx, (ce_score, candidate))| RerankedDoc {
236 doc_id: candidate.doc_id,
237 cross_encoder_score: ce_score,
238 initial_score: candidate.initial_score,
239 final_rank: rank_idx + 1,
240 score_delta: ce_score - candidate.initial_score,
241 })
242 .collect();
243
244 if self.config.normalize_scores {
246 Self::normalize_scores(&mut reranked);
247 }
248
249 let initial_order_refs: Vec<&str> = initial_order.iter().map(String::as_str).collect();
251 let rank_changes = Self::rank_changed(&initial_order_refs, &reranked);
252 let doc_count = reranked.len() as u64;
253
254 self.stats.total_reranks += 1;
255 self.stats.total_docs_reranked += doc_count;
256
257 let n = self.stats.total_reranks as f64;
259 let change_rate = rank_changes as f64 / doc_count.max(1) as f64;
260 self.stats.avg_rank_change += (change_rate - self.stats.avg_rank_change) / n;
261
262 reranked
263 }
264
265 pub fn rerank_batch(
271 &mut self,
272 queries_and_candidates: Vec<(Vec<f64>, Vec<CandidateDoc>)>,
273 ) -> Vec<Vec<RerankedDoc>> {
274 queries_and_candidates
275 .into_iter()
276 .map(|(query, candidates)| self.rerank(&query, candidates))
277 .collect()
278 }
279
280 pub fn dot_product(a: &[f64], b: &[f64]) -> f64 {
286 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
287 }
288
289 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
294 const EPSILON: f64 = 1e-10;
295 let dot = Self::dot_product(a, b);
296 let norm_a = a.iter().map(|x| x * x).sum::<f64>().sqrt();
297 let norm_b = b.iter().map(|x| x * x).sum::<f64>().sqrt();
298 if norm_a < EPSILON || norm_b < EPSILON {
299 0.0
300 } else {
301 dot / (norm_a * norm_b)
302 }
303 }
304
305 pub fn bilinear_score(query: &[f64], doc: &[f64], weights: &[f64]) -> f64 {
310 if weights.is_empty() {
311 return 0.0;
312 }
313 let wlen = weights.len();
314 query
315 .iter()
316 .zip(doc.iter())
317 .enumerate()
318 .map(|(i, (q, d))| weights[i % wlen] * q * d)
319 .sum()
320 }
321
322 pub fn linear_score(query: &[f64], doc: &[f64], weights: &[f64], bias: f64) -> f64 {
329 let dot: f64 = query
330 .iter()
331 .zip(doc.iter())
332 .enumerate()
333 .map(|(i, (q, d))| {
334 let w = weights.get(i).copied().unwrap_or(0.0);
335 w * q * d
336 })
337 .sum();
338 dot + bias
339 }
340
341 pub fn normalize_scores(docs: &mut [RerankedDoc]) {
348 if docs.is_empty() {
349 return;
350 }
351
352 let min = docs
353 .iter()
354 .map(|d| d.cross_encoder_score)
355 .fold(f64::INFINITY, f64::min);
356 let max = docs
357 .iter()
358 .map(|d| d.cross_encoder_score)
359 .fold(f64::NEG_INFINITY, f64::max);
360
361 let range = max - min;
362 if range < f64::EPSILON {
363 for d in docs.iter_mut() {
365 d.cross_encoder_score = 1.0;
366 }
367 return;
368 }
369
370 for d in docs.iter_mut() {
371 d.cross_encoder_score = (d.cross_encoder_score - min) / range;
372 }
373 }
374
375 pub fn rank_changed(initial_order: &[&str], reranked: &[RerankedDoc]) -> usize {
383 let initial_positions: HashMap<&str, usize> = initial_order
385 .iter()
386 .enumerate()
387 .map(|(i, id)| (*id, i))
388 .collect();
389
390 reranked
391 .iter()
392 .enumerate()
393 .filter(|(new_pos, doc)| {
394 initial_positions
395 .get(doc.doc_id.as_str())
396 .map(|old_pos| *old_pos != *new_pos)
397 .unwrap_or(false)
398 })
399 .count()
400 }
401
402 pub fn stats(&self) -> &CrossEncoderStats {
406 &self.stats
407 }
408}
409
410#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::collections::HashMap;
418
419 fn make_candidate(id: &str, embedding: Vec<f64>, initial_score: f64) -> CandidateDoc {
422 CandidateDoc {
423 doc_id: id.to_string(),
424 embedding,
425 initial_score,
426 metadata: HashMap::new(),
427 }
428 }
429
430 fn encoder_with(model: ScoringModel, normalize: bool) -> CrossEncoder {
431 CrossEncoder::new(CrossEncoderConfig {
432 model,
433 max_doc_length: 512,
434 batch_size: 32,
435 normalize_scores: normalize,
436 })
437 }
438
439 #[test]
442 fn test_dot_product_basic() {
443 let a = vec![1.0, 2.0, 3.0];
444 let b = vec![4.0, 5.0, 6.0];
445 let score = CrossEncoder::dot_product(&a, &b);
446 assert!((score - 32.0).abs() < 1e-9, "expected 32, got {score}");
447 }
448
449 #[test]
450 fn test_dot_product_model_score_pair() {
451 let enc = encoder_with(ScoringModel::DotProduct, false);
452 let query = vec![1.0, 0.0];
453 let doc = vec![0.5, 0.5];
454 let s = enc.score_pair(&query, &doc);
456 assert!((s - 0.5).abs() < 1e-9);
457 let _ = enc.stats();
459 }
460
461 #[test]
462 fn test_dot_product_mismatched_lengths() {
463 let a = vec![1.0, 2.0, 3.0];
465 let b = vec![1.0, 1.0];
466 let score = CrossEncoder::dot_product(&a, &b);
467 assert!((score - 3.0).abs() < 1e-9);
468 }
469
470 #[test]
473 fn test_cosine_identical_vectors() {
474 let v = vec![1.0, 2.0, 3.0];
475 let score = CrossEncoder::cosine_similarity(&v, &v);
476 assert!((score - 1.0).abs() < 1e-9, "identical vectors => cos=1");
477 }
478
479 #[test]
480 fn test_cosine_orthogonal_vectors() {
481 let a = vec![1.0, 0.0];
482 let b = vec![0.0, 1.0];
483 let score = CrossEncoder::cosine_similarity(&a, &b);
484 assert!(score.abs() < 1e-9, "orthogonal vectors => cos=0");
485 }
486
487 #[test]
488 fn test_cosine_opposite_vectors() {
489 let a = vec![1.0, 0.0];
490 let b = vec![-1.0, 0.0];
491 let score = CrossEncoder::cosine_similarity(&a, &b);
492 assert!((score - (-1.0)).abs() < 1e-9, "opposite vectors => cos=-1");
493 }
494
495 #[test]
496 fn test_cosine_zero_vector_query() {
497 let zero = vec![0.0, 0.0, 0.0];
498 let doc = vec![1.0, 2.0, 3.0];
499 let score = CrossEncoder::cosine_similarity(&zero, &doc);
500 assert_eq!(score, 0.0, "zero query vector => 0");
501 }
502
503 #[test]
504 fn test_cosine_zero_vector_doc() {
505 let query = vec![1.0, 2.0, 3.0];
506 let zero = vec![0.0, 0.0, 0.0];
507 let score = CrossEncoder::cosine_similarity(&query, &zero);
508 assert_eq!(score, 0.0, "zero doc vector => 0");
509 }
510
511 #[test]
512 fn test_cosine_both_zero_vectors() {
513 let zero = vec![0.0, 0.0];
514 let score = CrossEncoder::cosine_similarity(&zero, &zero);
515 assert_eq!(score, 0.0, "both zero => 0");
516 }
517
518 #[test]
519 fn test_cosine_model_score_pair() {
520 let enc = encoder_with(ScoringModel::Cosine, false);
521 let a = vec![1.0, 1.0];
522 let b = vec![1.0, 1.0];
523 let s = enc.score_pair(&a, &b);
524 assert!((s - 1.0).abs() < 1e-9);
525 }
526
527 #[test]
530 fn test_bilinear_basic() {
531 let q = vec![1.0, 2.0, 3.0];
532 let d = vec![4.0, 5.0, 6.0];
533 let w = vec![1.0, 2.0, 3.0];
534 let score = CrossEncoder::bilinear_score(&q, &d, &w);
536 assert!((score - 78.0).abs() < 1e-9, "expected 78, got {score}");
537 }
538
539 #[test]
540 fn test_bilinear_cyclic_weights() {
541 let q = vec![1.0, 2.0, 3.0, 4.0];
542 let d = vec![1.0, 1.0, 1.0, 1.0];
543 let w = vec![2.0]; let score = CrossEncoder::bilinear_score(&q, &d, &w);
546 assert!((score - 20.0).abs() < 1e-9, "expected 20, got {score}");
547 }
548
549 #[test]
550 fn test_bilinear_empty_weights() {
551 let q = vec![1.0, 2.0];
552 let d = vec![3.0, 4.0];
553 let score = CrossEncoder::bilinear_score(&q, &d, &[]);
554 assert_eq!(score, 0.0);
555 }
556
557 #[test]
558 fn test_bilinear_model_score_pair() {
559 let w = vec![1.0, 0.0]; let enc = encoder_with(ScoringModel::BilinearForm(w), false);
561 let q = vec![1.0, 999.0];
562 let d = vec![0.5, 999.0];
563 let s = enc.score_pair(&q, &d);
564 assert!((s - 0.5).abs() < 1e-9, "only dim0 => 0.5");
565 }
566
567 #[test]
570 fn test_linear_basic() {
571 let q = vec![1.0, 2.0];
572 let d = vec![3.0, 4.0];
573 let w = vec![1.0, 1.0];
574 let bias = 0.5;
575 let score = CrossEncoder::linear_score(&q, &d, &w, bias);
577 assert!((score - 11.5).abs() < 1e-9, "expected 11.5, got {score}");
578 }
579
580 #[test]
581 fn test_linear_bias_only() {
582 let q = vec![1.0];
583 let d = vec![1.0];
584 let w = vec![0.0]; let score = CrossEncoder::linear_score(&q, &d, &w, 42.0);
586 assert!((score - 42.0).abs() < 1e-9);
587 }
588
589 #[test]
590 fn test_linear_shorter_weights() {
591 let q = vec![1.0, 2.0, 3.0];
592 let d = vec![1.0, 1.0, 1.0];
593 let w = vec![2.0]; let score = CrossEncoder::linear_score(&q, &d, &w, 0.0);
596 assert!((score - 2.0).abs() < 1e-9);
597 }
598
599 #[test]
600 fn test_linear_model_score_pair() {
601 let enc = encoder_with(
602 ScoringModel::Linear {
603 weights: vec![1.0, 1.0],
604 bias: 1.0,
605 },
606 false,
607 );
608 let q = vec![2.0, 3.0];
609 let d = vec![4.0, 5.0];
610 let s = enc.score_pair(&q, &d);
612 assert!((s - 24.0).abs() < 1e-9);
613 }
614
615 #[test]
618 fn test_rerank_changes_order() {
619 let mut enc = encoder_with(ScoringModel::Cosine, false);
620 let query = vec![1.0, 0.0];
621 let candidates = vec![
623 make_candidate("doc_a", vec![0.99, 0.01], 0.5),
624 make_candidate("doc_b", vec![0.0, 1.0], 0.9),
625 ];
626 let reranked = enc.rerank(&query, candidates);
627 assert_eq!(
628 reranked[0].doc_id, "doc_a",
629 "doc_a aligns better with query"
630 );
631 assert_eq!(reranked[1].doc_id, "doc_b");
632 }
633
634 #[test]
635 fn test_rerank_preserves_initial_score() {
636 let mut enc = encoder_with(ScoringModel::DotProduct, false);
637 let query = vec![1.0, 0.0];
638 let candidates = vec![make_candidate("doc_x", vec![0.5, 0.5], 0.77)];
639 let reranked = enc.rerank(&query, candidates);
640 assert!((reranked[0].initial_score - 0.77).abs() < 1e-9);
641 }
642
643 #[test]
644 fn test_rerank_final_rank_numbering() {
645 let mut enc = encoder_with(ScoringModel::Cosine, false);
646 let query = vec![1.0, 0.0, 0.0];
647 let candidates = vec![
648 make_candidate("a", vec![0.1, 0.9, 0.0], 0.3),
649 make_candidate("b", vec![0.9, 0.1, 0.0], 0.7),
650 make_candidate("c", vec![0.5, 0.5, 0.0], 0.5),
651 ];
652 let reranked = enc.rerank(&query, candidates);
653 for (i, doc) in reranked.iter().enumerate() {
654 assert_eq!(doc.final_rank, i + 1, "final_rank must be 1-indexed");
655 }
656 }
657
658 #[test]
659 fn test_rerank_empty_candidates() {
660 let mut enc = encoder_with(ScoringModel::Cosine, false);
661 let result = enc.rerank(&[1.0], vec![]);
662 assert!(result.is_empty());
663 assert_eq!(enc.stats().total_reranks, 1);
664 }
665
666 #[test]
669 fn test_score_delta_equals_cross_minus_initial() {
670 let mut enc = encoder_with(ScoringModel::DotProduct, false);
671 let query = vec![1.0, 0.0];
672 let candidates = vec![make_candidate("d1", vec![0.3, 0.7], 0.2)];
673 let reranked = enc.rerank(&query, candidates);
674 let doc = &reranked[0];
675 let expected_delta = doc.cross_encoder_score - doc.initial_score;
676 assert!(
677 (doc.score_delta - expected_delta).abs() < 1e-9,
678 "score_delta must equal cross_encoder_score - initial_score"
679 );
680 }
681
682 #[test]
685 fn test_normalize_scores_range() {
686 let mut docs = vec![
687 RerankedDoc {
688 doc_id: "a".into(),
689 cross_encoder_score: 2.0,
690 initial_score: 0.5,
691 final_rank: 1,
692 score_delta: 0.0,
693 },
694 RerankedDoc {
695 doc_id: "b".into(),
696 cross_encoder_score: 8.0,
697 initial_score: 0.5,
698 final_rank: 2,
699 score_delta: 0.0,
700 },
701 RerankedDoc {
702 doc_id: "c".into(),
703 cross_encoder_score: 5.0,
704 initial_score: 0.5,
705 final_rank: 3,
706 score_delta: 0.0,
707 },
708 ];
709 CrossEncoder::normalize_scores(&mut docs);
710 let scores: Vec<f64> = docs.iter().map(|d| d.cross_encoder_score).collect();
711 assert!(
712 scores.iter().all(|&s| (0.0..=1.0).contains(&s)),
713 "all in [0,1]"
714 );
715 let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
716 let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
717 assert!(min.abs() < 1e-9, "min should be 0");
718 assert!((max - 1.0).abs() < 1e-9, "max should be 1");
719 }
720
721 #[test]
722 fn test_normalize_scores_all_equal() {
723 let mut docs: Vec<RerankedDoc> = (0..3)
724 .map(|i| RerankedDoc {
725 doc_id: i.to_string(),
726 cross_encoder_score: 0.5,
727 initial_score: 0.5,
728 final_rank: i + 1,
729 score_delta: 0.0,
730 })
731 .collect();
732 CrossEncoder::normalize_scores(&mut docs);
733 for d in &docs {
734 assert!(
735 (d.cross_encoder_score - 1.0).abs() < 1e-9,
736 "equal scores => 1.0"
737 );
738 }
739 }
740
741 #[test]
742 fn test_normalize_scores_single_doc() {
743 let mut docs = vec![RerankedDoc {
744 doc_id: "solo".into(),
745 cross_encoder_score: 0.42,
746 initial_score: 0.1,
747 final_rank: 1,
748 score_delta: 0.32,
749 }];
750 CrossEncoder::normalize_scores(&mut docs);
751 assert!((docs[0].cross_encoder_score - 1.0).abs() < 1e-9);
753 }
754
755 #[test]
756 fn test_normalize_scores_empty_slice() {
757 let mut docs: Vec<RerankedDoc> = vec![];
758 CrossEncoder::normalize_scores(&mut docs);
760 }
761
762 #[test]
765 fn test_rank_changed_no_change() {
766 let initial = vec!["a", "b", "c"];
767 let reranked = vec![
768 RerankedDoc {
769 doc_id: "a".into(),
770 cross_encoder_score: 0.9,
771 initial_score: 0.9,
772 final_rank: 1,
773 score_delta: 0.0,
774 },
775 RerankedDoc {
776 doc_id: "b".into(),
777 cross_encoder_score: 0.7,
778 initial_score: 0.7,
779 final_rank: 2,
780 score_delta: 0.0,
781 },
782 RerankedDoc {
783 doc_id: "c".into(),
784 cross_encoder_score: 0.5,
785 initial_score: 0.5,
786 final_rank: 3,
787 score_delta: 0.0,
788 },
789 ];
790 let changed = CrossEncoder::rank_changed(&initial, &reranked);
791 assert_eq!(changed, 0, "order unchanged => 0 rank changes");
792 }
793
794 #[test]
795 fn test_rank_changed_full_reversal() {
796 let initial = vec!["a", "b", "c"];
797 let reranked = vec![
798 RerankedDoc {
799 doc_id: "c".into(),
800 cross_encoder_score: 0.9,
801 initial_score: 0.5,
802 final_rank: 1,
803 score_delta: 0.4,
804 },
805 RerankedDoc {
806 doc_id: "b".into(),
807 cross_encoder_score: 0.7,
808 initial_score: 0.7,
809 final_rank: 2,
810 score_delta: 0.0,
811 },
812 RerankedDoc {
813 doc_id: "a".into(),
814 cross_encoder_score: 0.3,
815 initial_score: 0.9,
816 final_rank: 3,
817 score_delta: -0.6,
818 },
819 ];
820 let changed = CrossEncoder::rank_changed(&initial, &reranked);
822 assert_eq!(changed, 2);
823 }
824
825 #[test]
826 fn test_rank_changed_partial() {
827 let initial = vec!["a", "b", "c", "d"];
828 let reranked = vec![
829 RerankedDoc {
830 doc_id: "a".into(),
831 cross_encoder_score: 0.9,
832 initial_score: 0.8,
833 final_rank: 1,
834 score_delta: 0.1,
835 },
836 RerankedDoc {
837 doc_id: "c".into(),
838 cross_encoder_score: 0.7,
839 initial_score: 0.6,
840 final_rank: 2,
841 score_delta: 0.1,
842 },
843 RerankedDoc {
844 doc_id: "b".into(),
845 cross_encoder_score: 0.5,
846 initial_score: 0.7,
847 final_rank: 3,
848 score_delta: -0.2,
849 },
850 RerankedDoc {
851 doc_id: "d".into(),
852 cross_encoder_score: 0.3,
853 initial_score: 0.3,
854 final_rank: 4,
855 score_delta: 0.0,
856 },
857 ];
858 let changed = CrossEncoder::rank_changed(&initial, &reranked);
860 assert_eq!(changed, 2);
861 }
862
863 #[test]
866 fn test_rerank_batch_two_queries() {
867 let mut enc = encoder_with(ScoringModel::Cosine, false);
868 let q1 = vec![1.0, 0.0];
869 let q2 = vec![0.0, 1.0];
870 let c1 = vec![
871 make_candidate("a", vec![0.9, 0.1], 0.5),
872 make_candidate("b", vec![0.1, 0.9], 0.6),
873 ];
874 let c2 = vec![
875 make_candidate("c", vec![0.1, 0.9], 0.5),
876 make_candidate("d", vec![0.9, 0.1], 0.6),
877 ];
878 let results = enc.rerank_batch(vec![(q1, c1), (q2, c2)]);
879 assert_eq!(results.len(), 2);
880 assert_eq!(results[0][0].doc_id, "a", "q1 should prefer a");
881 assert_eq!(results[1][0].doc_id, "c", "q2 should prefer c");
882 }
883
884 #[test]
885 fn test_rerank_batch_empty_input() {
886 let mut enc = encoder_with(ScoringModel::DotProduct, false);
887 let results = enc.rerank_batch(vec![]);
888 assert!(results.is_empty());
889 }
890
891 #[test]
892 fn test_rerank_batch_stats_accumulate() {
893 let mut enc = encoder_with(ScoringModel::DotProduct, false);
894 let inputs: Vec<(Vec<f64>, Vec<CandidateDoc>)> = (0..5)
895 .map(|i| {
896 let q = vec![1.0, 0.0];
897 let docs = vec![make_candidate(&format!("d{i}"), vec![0.5, 0.5], 0.5)];
898 (q, docs)
899 })
900 .collect();
901 enc.rerank_batch(inputs);
902 assert_eq!(enc.stats().total_reranks, 5);
903 assert_eq!(enc.stats().total_docs_reranked, 5);
904 }
905
906 #[test]
909 fn test_stats_initial_state() {
910 let enc = encoder_with(ScoringModel::Cosine, false);
911 let s = enc.stats();
912 assert_eq!(s.total_reranks, 0);
913 assert_eq!(s.total_docs_reranked, 0);
914 assert_eq!(s.avg_rank_change, 0.0);
915 }
916
917 #[test]
918 fn test_stats_after_single_rerank() {
919 let mut enc = encoder_with(ScoringModel::Cosine, false);
920 let query = vec![1.0, 0.0];
921 let candidates = vec![
922 make_candidate("x", vec![0.9, 0.1], 0.3),
923 make_candidate("y", vec![0.1, 0.9], 0.8),
924 ];
925 enc.rerank(&query, candidates);
926 assert_eq!(enc.stats().total_reranks, 1);
927 assert_eq!(enc.stats().total_docs_reranked, 2);
928 }
929
930 #[test]
931 fn test_stats_multiple_reranks() {
932 let mut enc = encoder_with(ScoringModel::DotProduct, false);
933 for i in 0..4u64 {
934 let q = vec![1.0];
935 let docs = vec![
936 make_candidate(&format!("d{i}a"), vec![0.5], 0.5),
937 make_candidate(&format!("d{i}b"), vec![0.3], 0.3),
938 ];
939 enc.rerank(&q, docs);
940 }
941 assert_eq!(enc.stats().total_reranks, 4);
942 assert_eq!(enc.stats().total_docs_reranked, 8);
943 }
944
945 #[test]
948 fn test_identical_embeddings_cosine() {
949 let enc = encoder_with(ScoringModel::Cosine, false);
950 let v = vec![0.3, 0.4, 0.5];
951 let s = enc.score_pair(&v, &v);
952 assert!((s - 1.0).abs() < 1e-9);
953 }
954
955 #[test]
956 fn test_identical_embeddings_dot() {
957 let enc = encoder_with(ScoringModel::DotProduct, false);
958 let v = vec![1.0, 2.0];
959 let s = enc.score_pair(&v, &v);
960 assert!((s - 5.0).abs() < 1e-9, "1+4=5");
961 }
962
963 #[test]
966 fn test_1d_cosine() {
967 let a = vec![3.0];
968 let b = vec![5.0];
969 let s = CrossEncoder::cosine_similarity(&a, &b);
970 assert!((s - 1.0).abs() < 1e-9, "same-sign scalars => cos=1");
971 }
972
973 #[test]
974 fn test_1d_dot_product() {
975 let a = vec![7.0];
976 let b = vec![3.0];
977 let s = CrossEncoder::dot_product(&a, &b);
978 assert!((s - 21.0).abs() < 1e-9);
979 }
980
981 #[test]
984 fn test_max_doc_length_truncation() {
985 let enc = CrossEncoder::new(CrossEncoderConfig {
986 model: ScoringModel::DotProduct,
987 max_doc_length: 2,
988 batch_size: 32,
989 normalize_scores: false,
990 });
991 let q = vec![1.0, 1.0, 100.0];
993 let d = vec![1.0, 1.0, 100.0];
994 let s = enc.score_pair(&q, &d);
995 assert!((s - 2.0).abs() < 1e-9, "truncated dot should be 2, got {s}");
997 }
998
999 #[test]
1002 fn test_rerank_with_normalization() {
1003 let mut enc = encoder_with(ScoringModel::DotProduct, true);
1004 let query = vec![1.0, 0.0];
1005 let candidates = vec![
1006 make_candidate("a", vec![10.0, 0.0], 0.5),
1007 make_candidate("b", vec![5.0, 0.0], 0.3),
1008 make_candidate("c", vec![1.0, 0.0], 0.1),
1009 ];
1010 let reranked = enc.rerank(&query, candidates);
1011 for doc in &reranked {
1012 assert!(
1013 doc.cross_encoder_score >= 0.0 && doc.cross_encoder_score <= 1.0,
1014 "normalized score out of [0,1]: {}",
1015 doc.cross_encoder_score
1016 );
1017 }
1018 assert!((reranked[0].cross_encoder_score - 1.0).abs() < 1e-9);
1020 }
1021
1022 #[test]
1025 fn test_large_candidate_set_50_docs() {
1026 let mut enc = encoder_with(ScoringModel::Cosine, true);
1027 let dim = 16;
1028 let query: Vec<f64> = (0..dim).map(|i| i as f64).collect();
1029
1030 let candidates: Vec<CandidateDoc> = (0..50)
1032 .map(|i| {
1033 let embedding: Vec<f64> = (0..dim).map(|j| (i * dim + j) as f64 * 0.01).collect();
1034 make_candidate(&format!("doc_{i:02}"), embedding, i as f64 * 0.02)
1035 })
1036 .collect();
1037
1038 let reranked = enc.rerank(&query, candidates);
1039 assert_eq!(reranked.len(), 50, "all 50 docs should be present");
1040
1041 for window in reranked.windows(2) {
1043 assert!(
1044 window[0].cross_encoder_score >= window[1].cross_encoder_score,
1045 "output must be sorted descending"
1046 );
1047 }
1048
1049 for d in &reranked {
1051 assert!(d.cross_encoder_score >= 0.0 && d.cross_encoder_score <= 1.0);
1052 }
1053
1054 assert_eq!(enc.stats().total_docs_reranked, 50);
1055 }
1056
1057 #[test]
1060 fn test_score_delta_positive_when_improved() {
1061 let mut enc = encoder_with(ScoringModel::DotProduct, false);
1062 let query = vec![1.0, 0.0];
1063 let candidates = vec![make_candidate("high", vec![0.9, 0.0], 0.1)];
1065 let reranked = enc.rerank(&query, candidates);
1066 assert!(
1067 reranked[0].score_delta > 0.0,
1068 "score improved => positive delta"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_score_delta_negative_when_demoted() {
1074 let mut enc = encoder_with(ScoringModel::DotProduct, false);
1075 let query = vec![0.01, 0.0];
1076 let candidates = vec![make_candidate("low", vec![0.9, 0.0], 0.9)];
1078 let reranked = enc.rerank(&query, candidates);
1079 assert!(
1080 reranked[0].score_delta < 0.0,
1081 "score demoted => negative delta"
1082 );
1083 }
1084}