1use std::collections::{HashMap, HashSet};
51
52#[derive(Debug, Clone)]
58pub struct RerankCandidate {
59 pub id: String,
61 pub initial_score: f64,
63 pub content: String,
65 pub embedding: Option<Vec<f64>>,
67 pub metadata: HashMap<String, String>,
69}
70
71#[derive(Debug, Clone)]
73pub struct RerankQuery {
74 pub text: String,
76 pub embedding: Option<Vec<f64>>,
78 pub context: Vec<String>,
80}
81
82#[derive(Debug, Clone)]
84pub enum RerankFeature {
85 EmbeddingScore,
87 KeywordOverlap,
89 LengthPenalty,
92 TitleBoost {
94 boost: f64,
96 },
97 PositionPrior {
100 decay: f64,
102 },
103}
104
105impl RerankFeature {
106 pub fn name(&self) -> &'static str {
108 match self {
109 RerankFeature::EmbeddingScore => "embedding_score",
110 RerankFeature::KeywordOverlap => "keyword_overlap",
111 RerankFeature::LengthPenalty => "length_penalty",
112 RerankFeature::TitleBoost { .. } => "title_boost",
113 RerankFeature::PositionPrior { .. } => "position_prior",
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct RerankConfig {
121 pub features: Vec<(RerankFeature, f64)>,
123 pub normalize_scores: bool,
125 pub min_rerank_score: f64,
128}
129
130impl Default for RerankConfig {
131 fn default() -> Self {
132 Self {
133 features: vec![
134 (RerankFeature::EmbeddingScore, 0.5),
135 (RerankFeature::KeywordOverlap, 0.3),
136 (RerankFeature::LengthPenalty, 0.1),
137 (RerankFeature::PositionPrior { decay: 0.1 }, 0.1),
138 ],
139 normalize_scores: true,
140 min_rerank_score: 0.0,
141 }
142 }
143}
144
145#[derive(Debug, Clone)]
147pub struct RerankResult {
148 pub candidate_id: String,
150 pub rerank_score: f64,
152 pub initial_score: f64,
154 pub feature_scores: HashMap<String, f64>,
156 pub rank: usize,
158}
159
160#[derive(Debug, Clone)]
162pub struct RerankStats {
163 pub total_rerankings: u64,
165 pub avg_candidates_per_reranking: f64,
167 pub avg_score_improvement: f64,
169}
170
171#[derive(Debug, Default)]
177struct CallRecord {
178 candidate_count: usize,
179 total_improvement: f64,
180 result_count: usize,
181}
182
183pub struct SemanticReranker {
185 pub config: RerankConfig,
187 pub total_rerankings: u64,
189 call_records: Vec<CallRecord>,
191}
192
193impl SemanticReranker {
194 pub fn new(config: RerankConfig) -> Self {
196 Self {
197 config,
198 total_rerankings: 0,
199 call_records: Vec::new(),
200 }
201 }
202
203 pub fn rerank(
211 &mut self,
212 query: &RerankQuery,
213 candidates: &[RerankCandidate],
214 ) -> Vec<RerankResult> {
215 let total = candidates.len();
216 if total == 0 {
217 self.total_rerankings += 1;
218 self.call_records.push(CallRecord::default());
219 return Vec::new();
220 }
221
222 let weight_sum: f64 = self.config.features.iter().map(|(_, w)| w.abs()).sum();
224 let weight_sum = if weight_sum < f64::EPSILON {
225 1.0
226 } else {
227 weight_sum
228 };
229
230 let mut raw: Vec<(RerankResult, f64)> = candidates
232 .iter()
233 .enumerate()
234 .map(|(rank_idx, candidate)| {
235 let feature_scores = self.score_candidate(query, candidate, rank_idx, total);
236 let combined: f64 = self
237 .config
238 .features
239 .iter()
240 .map(|(feat, weight)| {
241 let score = feature_scores.get(feat.name()).copied().unwrap_or(0.0);
242 score * weight / weight_sum
243 })
244 .sum();
245 let result = RerankResult {
246 candidate_id: candidate.id.clone(),
247 rerank_score: combined,
248 initial_score: candidate.initial_score,
249 feature_scores,
250 rank: 0, };
252 (result, combined)
253 })
254 .collect();
255
256 if self.config.normalize_scores && raw.len() > 1 {
258 let min_score = raw.iter().map(|(_, s)| *s).fold(f64::INFINITY, f64::min);
259 let max_score = raw
260 .iter()
261 .map(|(_, s)| *s)
262 .fold(f64::NEG_INFINITY, f64::max);
263 let range = max_score - min_score;
264 if range > f64::EPSILON {
265 for (result, score) in raw.iter_mut() {
266 let normalised = (*score - min_score) / range;
267 *score = normalised;
268 result.rerank_score = normalised;
269 }
270 }
271 }
272
273 let threshold = self.config.min_rerank_score;
275 let mut filtered: Vec<RerankResult> = raw
276 .into_iter()
277 .filter(|(_, s)| *s >= threshold)
278 .map(|(mut r, s)| {
279 r.rerank_score = s;
280 r
281 })
282 .collect();
283
284 filtered.sort_by(|a, b| {
286 b.rerank_score
287 .partial_cmp(&a.rerank_score)
288 .unwrap_or(std::cmp::Ordering::Equal)
289 });
290
291 for (i, result) in filtered.iter_mut().enumerate() {
293 result.rank = i + 1;
294 }
295
296 let record = CallRecord {
298 candidate_count: total,
299 total_improvement: filtered
300 .iter()
301 .map(|r| r.rerank_score - r.initial_score)
302 .sum(),
303 result_count: filtered.len(),
304 };
305 self.call_records.push(record);
306 self.total_rerankings += 1;
307
308 filtered
309 }
310
311 pub fn score_candidate(
315 &self,
316 query: &RerankQuery,
317 candidate: &RerankCandidate,
318 rank: usize,
319 total: usize,
320 ) -> HashMap<String, f64> {
321 let mut scores = HashMap::new();
322 for (feature, _) in &self.config.features {
323 let score = self.compute_feature(feature, query, candidate, rank, total);
324 scores.insert(feature.name().to_string(), score);
325 }
326 scores
327 }
328
329 pub fn compute_feature(
331 &self,
332 feature: &RerankFeature,
333 query: &RerankQuery,
334 candidate: &RerankCandidate,
335 rank: usize,
336 total: usize,
337 ) -> f64 {
338 match feature {
339 RerankFeature::EmbeddingScore => match (&query.embedding, &candidate.embedding) {
340 (Some(qe), Some(ce)) => Self::cosine_similarity(qe, ce),
341 _ => 0.0,
342 },
343
344 RerankFeature::KeywordOverlap => {
345 let query_terms = Self::tokenize(&query.text);
346 let content_terms = Self::tokenize(&candidate.content);
347 Self::jaccard_similarity(&query_terms, &content_terms)
348 }
349
350 RerankFeature::LengthPenalty => {
351 const OPTIMAL: f64 = 500.0;
352 let len = candidate.content.len();
353 let deviation = (OPTIMAL - len as f64).abs() / OPTIMAL;
354 (1.0 - deviation).max(0.0)
355 }
356
357 RerankFeature::TitleBoost { boost } => {
358 let title = candidate
359 .metadata
360 .get("title")
361 .map(|s| s.to_lowercase())
362 .unwrap_or_default();
363 if title.is_empty() {
364 1.0
365 } else {
366 let query_terms = Self::tokenize(&query.text);
367 let has_match = query_terms.iter().any(|term| title.contains(term.as_str()));
368 if has_match {
369 *boost
370 } else {
371 1.0
372 }
373 }
374 }
375
376 RerankFeature::PositionPrior { decay } => {
377 if total == 0 {
378 return candidate.initial_score;
379 }
380 let rank_fraction = rank as f64 / total as f64;
381 candidate.initial_score * (1.0 - decay * rank_fraction)
382 }
383 }
384 }
385
386 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
389 if a.len() != b.len() || a.is_empty() {
390 return 0.0;
391 }
392 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
393 let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
394 let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
395 if norm_a < f64::EPSILON || norm_b < f64::EPSILON {
396 return 0.0;
397 }
398 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
399 }
400
401 pub fn jaccard_similarity(a: &[String], b: &[String]) -> f64 {
404 let set_a: HashSet<&str> = a.iter().map(|s| s.as_str()).collect();
405 let set_b: HashSet<&str> = b.iter().map(|s| s.as_str()).collect();
406 let intersection = set_a.intersection(&set_b).count();
407 let union = set_a.union(&set_b).count();
408 if union == 0 {
409 0.0
410 } else {
411 intersection as f64 / union as f64
412 }
413 }
414
415 pub fn tokenize(text: &str) -> Vec<String> {
418 let mut terms: HashSet<String> = HashSet::new();
419 for word in text.split(|c: char| !c.is_alphanumeric()) {
420 let token: String = word
421 .chars()
422 .filter(|c| c.is_alphanumeric())
423 .map(|c| c.to_lowercase().next().unwrap_or(c))
424 .collect();
425 if !token.is_empty() {
426 terms.insert(token);
427 }
428 }
429 let mut sorted: Vec<String> = terms.into_iter().collect();
430 sorted.sort_unstable();
431 sorted
432 }
433
434 pub fn top_k<'a>(&self, results: &'a [RerankResult], k: usize) -> Vec<&'a RerankResult> {
436 results.iter().take(k).collect()
438 }
439
440 pub fn precision_at_k(
443 &self,
444 results: &[RerankResult],
445 k: usize,
446 relevant_ids: &[String],
447 ) -> f64 {
448 if k == 0 {
449 return 0.0;
450 }
451 let relevant_set: HashSet<&str> = relevant_ids.iter().map(|s| s.as_str()).collect();
452 let top = results.iter().take(k);
453 let hits = top
454 .filter(|r| relevant_set.contains(r.candidate_id.as_str()))
455 .count();
456 hits as f64 / k as f64
457 }
458
459 pub fn ndcg_at_k(&self, results: &[RerankResult], k: usize, relevant_ids: &[String]) -> f64 {
462 if k == 0 {
463 return 0.0;
464 }
465 let relevant_set: HashSet<&str> = relevant_ids.iter().map(|s| s.as_str()).collect();
466
467 let dcg: f64 = results
469 .iter()
470 .take(k)
471 .enumerate()
472 .filter(|(_, r)| relevant_set.contains(r.candidate_id.as_str()))
473 .map(|(i, _)| 1.0 / (i as f64 + 2.0).log2()) .sum();
475
476 let num_relevant = relevant_set.len().min(k);
478 let idcg: f64 = (0..num_relevant)
479 .map(|i| 1.0 / (i as f64 + 2.0).log2())
480 .sum();
481
482 if idcg < f64::EPSILON {
483 0.0
484 } else {
485 dcg / idcg
486 }
487 }
488
489 pub fn stats(&self) -> RerankStats {
491 let total = self.total_rerankings;
492 if total == 0 {
493 return RerankStats {
494 total_rerankings: 0,
495 avg_candidates_per_reranking: 0.0,
496 avg_score_improvement: 0.0,
497 };
498 }
499 let total_candidates: usize = self.call_records.iter().map(|r| r.candidate_count).sum();
500 let total_improvement: f64 = self.call_records.iter().map(|r| r.total_improvement).sum();
501 let total_results: usize = self.call_records.iter().map(|r| r.result_count).sum();
502
503 RerankStats {
504 total_rerankings: total,
505 avg_candidates_per_reranking: total_candidates as f64 / total as f64,
506 avg_score_improvement: if total_results == 0 {
507 0.0
508 } else {
509 total_improvement / total_results as f64
510 },
511 }
512 }
513}
514
515#[cfg(test)]
520mod tests {
521 use std::collections::HashMap;
522
523 use crate::semantic_reranker::{
524 RerankCandidate, RerankConfig, RerankFeature, RerankQuery, SemanticReranker,
525 };
526
527 fn make_candidate(id: &str, score: f64, content: &str) -> RerankCandidate {
530 RerankCandidate {
531 id: id.to_string(),
532 initial_score: score,
533 content: content.to_string(),
534 embedding: None,
535 metadata: HashMap::new(),
536 }
537 }
538
539 fn make_candidate_with_embedding(
540 id: &str,
541 score: f64,
542 content: &str,
543 emb: Vec<f64>,
544 ) -> RerankCandidate {
545 RerankCandidate {
546 id: id.to_string(),
547 initial_score: score,
548 content: content.to_string(),
549 embedding: Some(emb),
550 metadata: HashMap::new(),
551 }
552 }
553
554 fn make_query(text: &str) -> RerankQuery {
555 RerankQuery {
556 text: text.to_string(),
557 embedding: None,
558 context: vec![],
559 }
560 }
561
562 fn make_query_with_embedding(text: &str, emb: Vec<f64>) -> RerankQuery {
563 RerankQuery {
564 text: text.to_string(),
565 embedding: Some(emb),
566 context: vec![],
567 }
568 }
569
570 #[test]
573 fn test_cosine_identical_vectors() {
574 let v = vec![1.0, 2.0, 3.0];
575 let sim = SemanticReranker::cosine_similarity(&v, &v);
576 assert!((sim - 1.0).abs() < 1e-9);
577 }
578
579 #[test]
580 fn test_cosine_orthogonal_vectors() {
581 let a = vec![1.0, 0.0];
582 let b = vec![0.0, 1.0];
583 let sim = SemanticReranker::cosine_similarity(&a, &b);
584 assert!(sim.abs() < 1e-9);
585 }
586
587 #[test]
588 fn test_cosine_opposite_vectors() {
589 let a = vec![1.0, 0.0];
590 let b = vec![-1.0, 0.0];
591 let sim = SemanticReranker::cosine_similarity(&a, &b);
592 assert!((sim - (-1.0)).abs() < 1e-9);
593 }
594
595 #[test]
596 fn test_cosine_zero_vector_returns_zero() {
597 let a = vec![0.0, 0.0];
598 let b = vec![1.0, 2.0];
599 let sim = SemanticReranker::cosine_similarity(&a, &b);
600 assert_eq!(sim, 0.0);
601 }
602
603 #[test]
604 fn test_cosine_dimension_mismatch_returns_zero() {
605 let a = vec![1.0, 2.0];
606 let b = vec![1.0];
607 let sim = SemanticReranker::cosine_similarity(&a, &b);
608 assert_eq!(sim, 0.0);
609 }
610
611 #[test]
612 fn test_cosine_empty_vectors_returns_zero() {
613 let sim = SemanticReranker::cosine_similarity(&[], &[]);
614 assert_eq!(sim, 0.0);
615 }
616
617 #[test]
618 fn test_cosine_near_parallel() {
619 let a = vec![1.0, 0.001];
620 let b = vec![1.0, 0.001];
621 let sim = SemanticReranker::cosine_similarity(&a, &b);
622 assert!((sim - 1.0).abs() < 1e-6);
623 }
624
625 #[test]
628 fn test_jaccard_identical_sets() {
629 let terms = vec!["a".to_string(), "b".to_string(), "c".to_string()];
630 let sim = SemanticReranker::jaccard_similarity(&terms, &terms);
631 assert!((sim - 1.0).abs() < 1e-9);
632 }
633
634 #[test]
635 fn test_jaccard_disjoint_sets() {
636 let a = vec!["a".to_string()];
637 let b = vec!["b".to_string()];
638 let sim = SemanticReranker::jaccard_similarity(&a, &b);
639 assert_eq!(sim, 0.0);
640 }
641
642 #[test]
643 fn test_jaccard_partial_overlap() {
644 let a = vec!["a".to_string(), "b".to_string()];
645 let b = vec!["b".to_string(), "c".to_string()];
646 let sim = SemanticReranker::jaccard_similarity(&a, &b);
647 assert!((sim - 1.0 / 3.0).abs() < 1e-9);
649 }
650
651 #[test]
652 fn test_jaccard_empty_sets() {
653 let sim = SemanticReranker::jaccard_similarity(&[], &[]);
654 assert_eq!(sim, 0.0);
655 }
656
657 #[test]
658 fn test_jaccard_one_empty() {
659 let a = vec!["rust".to_string()];
660 let sim = SemanticReranker::jaccard_similarity(&a, &[]);
661 assert_eq!(sim, 0.0);
662 }
663
664 #[test]
667 fn test_tokenize_basic() {
668 let tokens = SemanticReranker::tokenize("Hello, World!");
669 assert!(tokens.contains(&"hello".to_string()));
670 assert!(tokens.contains(&"world".to_string()));
671 }
672
673 #[test]
674 fn test_tokenize_deduplicates() {
675 let tokens = SemanticReranker::tokenize("rust rust RUST");
676 assert_eq!(tokens, vec!["rust".to_string()]);
677 }
678
679 #[test]
680 fn test_tokenize_sorted() {
681 let tokens = SemanticReranker::tokenize("zebra apple mango");
682 assert_eq!(tokens, vec!["apple", "mango", "zebra"]);
683 }
684
685 #[test]
686 fn test_tokenize_strips_punctuation() {
687 let tokens = SemanticReranker::tokenize("hello-world foo.bar");
688 assert!(
689 tokens.contains(&"hello".to_string()) || tokens.contains(&"helloworld".to_string())
690 );
691 for t in &tokens {
693 assert!(
694 t.chars().all(|c| c.is_alphanumeric()),
695 "token '{t}' contains non-alphanumeric"
696 );
697 }
698 }
699
700 #[test]
701 fn test_tokenize_empty_string() {
702 let tokens = SemanticReranker::tokenize("");
703 assert!(tokens.is_empty());
704 }
705
706 #[test]
709 fn test_rerank_empty_candidates() {
710 let mut reranker = SemanticReranker::new(RerankConfig::default());
711 let query = make_query("test");
712 let results = reranker.rerank(&query, &[]);
713 assert!(results.is_empty());
714 assert_eq!(reranker.total_rerankings, 1);
715 }
716
717 #[test]
720 fn test_rerank_ranks_are_1_based_and_sequential() {
721 let mut reranker = SemanticReranker::new(RerankConfig {
722 normalize_scores: false,
723 min_rerank_score: f64::NEG_INFINITY,
724 ..Default::default()
725 });
726 let query = make_query("rust language");
727 let candidates = vec![
728 make_candidate("d1", 0.8, "rust systems language"),
729 make_candidate("d2", 0.6, "python scripting"),
730 make_candidate("d3", 0.7, "rust memory safety"),
731 ];
732 let results = reranker.rerank(&query, &candidates);
733 let ranks: Vec<usize> = results.iter().map(|r| r.rank).collect();
734 assert_eq!(ranks, vec![1, 2, 3]);
735 }
736
737 #[test]
738 fn test_rerank_sorted_descending() {
739 let mut reranker = SemanticReranker::new(RerankConfig::default());
740 let query = make_query("rust programming");
741 let candidates = vec![
742 make_candidate("d1", 0.5, "unrelated topic about cooking"),
743 make_candidate("d2", 0.9, "rust programming language systems"),
744 ];
745 let results = reranker.rerank(&query, &candidates);
746 assert!(results[0].rerank_score >= results[results.len() - 1].rerank_score);
748 }
749
750 #[test]
751 fn test_rerank_preserves_initial_score() {
752 let mut reranker = SemanticReranker::new(RerankConfig::default());
753 let query = make_query("test query");
754 let candidates = vec![make_candidate("d1", 0.75, "some content here")];
755 let results = reranker.rerank(&query, &candidates);
756 assert!(!results.is_empty());
757 assert!((results[0].initial_score - 0.75).abs() < 1e-9);
758 }
759
760 #[test]
761 fn test_rerank_feature_scores_populated() {
762 let mut reranker = SemanticReranker::new(RerankConfig::default());
763 let query = make_query("rust programming");
764 let candidates = vec![make_candidate("d1", 0.5, "rust programming language")];
765 let results = reranker.rerank(&query, &candidates);
766 assert!(!results.is_empty());
767 assert!(results[0].feature_scores.contains_key("keyword_overlap"));
769 assert!(results[0].feature_scores.contains_key("length_penalty"));
770 assert!(results[0].feature_scores.contains_key("position_prior"));
771 }
772
773 #[test]
776 fn test_rerank_min_score_filter() {
777 let config = RerankConfig {
778 features: vec![(RerankFeature::KeywordOverlap, 1.0)],
779 normalize_scores: false,
780 min_rerank_score: 0.5,
781 };
782 let mut reranker = SemanticReranker::new(config);
783 let query = make_query("rust");
784 let candidates = vec![
786 make_candidate("d1", 0.9, "rust systems programming"),
787 make_candidate("d2", 0.8, "python machine learning"),
788 ];
789 let results = reranker.rerank(&query, &candidates);
790 assert!(results.iter().all(|r| r.rerank_score >= 0.5));
792 }
793
794 #[test]
797 fn test_embedding_feature_present_both() {
798 let config = RerankConfig {
799 features: vec![(RerankFeature::EmbeddingScore, 1.0)],
800 normalize_scores: false,
801 min_rerank_score: f64::NEG_INFINITY,
802 };
803 let mut reranker = SemanticReranker::new(config);
804 let query = make_query_with_embedding("query", vec![1.0, 0.0]);
805 let candidates = vec![
806 make_candidate_with_embedding("d1", 0.5, "doc", vec![1.0, 0.0]),
807 make_candidate_with_embedding("d2", 0.5, "doc", vec![0.0, 1.0]),
808 ];
809 let results = reranker.rerank(&query, &candidates);
810 assert_eq!(results[0].candidate_id, "d1");
812 assert!(results[0].rerank_score > results[1].rerank_score);
813 }
814
815 #[test]
816 fn test_embedding_feature_missing_embedding_returns_zero() {
817 let config = RerankConfig {
818 features: vec![(RerankFeature::EmbeddingScore, 1.0)],
819 normalize_scores: false,
820 min_rerank_score: f64::NEG_INFINITY,
821 };
822 let mut reranker = SemanticReranker::new(config);
823 let query = make_query("no embedding");
824 let candidates = vec![make_candidate("d1", 0.5, "content")];
825 let results = reranker.rerank(&query, &candidates);
826 let score = *results[0]
828 .feature_scores
829 .get("embedding_score")
830 .unwrap_or(&-1.0);
831 assert_eq!(score, 0.0);
832 }
833
834 #[test]
837 fn test_length_penalty_optimal_length() {
838 let config = RerankConfig {
839 features: vec![(RerankFeature::LengthPenalty, 1.0)],
840 normalize_scores: false,
841 min_rerank_score: f64::NEG_INFINITY,
842 };
843 let mut reranker = SemanticReranker::new(config);
844 let query = make_query("anything");
845 let content_500 = "x".repeat(500);
847 let candidates = vec![make_candidate("d1", 0.5, &content_500)];
848 let results = reranker.rerank(&query, &candidates);
849 let score = *results[0]
850 .feature_scores
851 .get("length_penalty")
852 .unwrap_or(&-1.0);
853 assert!((score - 1.0).abs() < 1e-9);
854 }
855
856 #[test]
857 fn test_length_penalty_very_short_content() {
858 let config = RerankConfig {
859 features: vec![(RerankFeature::LengthPenalty, 1.0)],
860 normalize_scores: false,
861 min_rerank_score: f64::NEG_INFINITY,
862 };
863 let mut reranker = SemanticReranker::new(config);
864 let query = make_query("anything");
865 let candidates = vec![make_candidate("d1", 0.5, "short txt.")];
867 let results = reranker.rerank(&query, &candidates);
868 let score = *results[0]
869 .feature_scores
870 .get("length_penalty")
871 .unwrap_or(&-1.0);
872 assert!(score < 1.0);
873 assert!(score >= 0.0);
874 }
875
876 #[test]
879 fn test_title_boost_match() {
880 let config = RerankConfig {
881 features: vec![(RerankFeature::TitleBoost { boost: 2.0 }, 1.0)],
882 normalize_scores: false,
883 min_rerank_score: f64::NEG_INFINITY,
884 };
885 let mut reranker = SemanticReranker::new(config);
886 let query = make_query("rust programming");
887 let mut meta = HashMap::new();
888 meta.insert(
889 "title".to_string(),
890 "Introduction to Rust Programming".to_string(),
891 );
892 let candidate = RerankCandidate {
893 id: "d1".to_string(),
894 initial_score: 0.5,
895 content: "content".to_string(),
896 embedding: None,
897 metadata: meta,
898 };
899 let results = reranker.rerank(&query, &[candidate]);
900 let score = *results[0]
901 .feature_scores
902 .get("title_boost")
903 .unwrap_or(&-1.0);
904 assert!((score - 2.0).abs() < 1e-9);
905 }
906
907 #[test]
908 fn test_title_boost_no_match() {
909 let config = RerankConfig {
910 features: vec![(RerankFeature::TitleBoost { boost: 2.0 }, 1.0)],
911 normalize_scores: false,
912 min_rerank_score: f64::NEG_INFINITY,
913 };
914 let mut reranker = SemanticReranker::new(config);
915 let query = make_query("python");
916 let mut meta = HashMap::new();
917 meta.insert("title".to_string(), "Introduction to Rust".to_string());
918 let candidate = RerankCandidate {
919 id: "d1".to_string(),
920 initial_score: 0.5,
921 content: "content".to_string(),
922 embedding: None,
923 metadata: meta,
924 };
925 let results = reranker.rerank(&query, &[candidate]);
926 let score = *results[0]
927 .feature_scores
928 .get("title_boost")
929 .unwrap_or(&-1.0);
930 assert!((score - 1.0).abs() < 1e-9);
931 }
932
933 #[test]
934 fn test_title_boost_missing_title_returns_one() {
935 let config = RerankConfig {
936 features: vec![(RerankFeature::TitleBoost { boost: 3.0 }, 1.0)],
937 normalize_scores: false,
938 min_rerank_score: f64::NEG_INFINITY,
939 };
940 let mut reranker = SemanticReranker::new(config);
941 let query = make_query("anything");
942 let candidates = vec![make_candidate("d1", 0.5, "content")]; let results = reranker.rerank(&query, &candidates);
944 let score = *results[0]
945 .feature_scores
946 .get("title_boost")
947 .unwrap_or(&-1.0);
948 assert!((score - 1.0).abs() < 1e-9);
949 }
950
951 #[test]
954 fn test_position_prior_first_rank() {
955 let config = RerankConfig {
956 features: vec![(RerankFeature::PositionPrior { decay: 0.5 }, 1.0)],
957 normalize_scores: false,
958 min_rerank_score: f64::NEG_INFINITY,
959 };
960 let reranker = SemanticReranker::new(config);
961 let query = make_query("q");
962 let candidate = make_candidate("d1", 0.8, "content");
963 let score = reranker.compute_feature(
965 &RerankFeature::PositionPrior { decay: 0.5 },
966 &query,
967 &candidate,
968 0,
969 5,
970 );
971 assert!((score - 0.8).abs() < 1e-9);
972 }
973
974 #[test]
975 fn test_position_prior_last_rank() {
976 let config = RerankConfig {
977 features: vec![(RerankFeature::PositionPrior { decay: 1.0 }, 1.0)],
978 normalize_scores: false,
979 min_rerank_score: f64::NEG_INFINITY,
980 };
981 let reranker = SemanticReranker::new(config);
982 let query = make_query("q");
983 let candidate = make_candidate("d1", 1.0, "content");
984 let score = reranker.compute_feature(
986 &RerankFeature::PositionPrior { decay: 1.0 },
987 &query,
988 &candidate,
989 4,
990 5,
991 );
992 assert!((score - 0.2).abs() < 1e-9);
993 }
994
995 #[test]
998 fn test_top_k_returns_correct_count() {
999 let mut reranker = SemanticReranker::new(RerankConfig::default());
1000 let query = make_query("rust");
1001 let candidates: Vec<RerankCandidate> = (0..10)
1002 .map(|i| make_candidate(&format!("d{i}"), i as f64 / 10.0, "rust content"))
1003 .collect();
1004 let results = reranker.rerank(&query, &candidates);
1005 let top3 = reranker.top_k(&results, 3);
1006 assert_eq!(top3.len(), 3);
1007 }
1008
1009 #[test]
1010 fn test_top_k_larger_than_results() {
1011 let mut reranker = SemanticReranker::new(RerankConfig::default());
1012 let query = make_query("rust");
1013 let candidates = vec![
1014 make_candidate("d1", 0.9, "rust lang"),
1015 make_candidate("d2", 0.5, "python"),
1016 ];
1017 let results = reranker.rerank(&query, &candidates);
1018 let top10 = reranker.top_k(&results, 10);
1019 assert_eq!(top10.len(), results.len());
1020 }
1021
1022 #[test]
1023 fn test_top_k_zero() {
1024 let reranker = SemanticReranker::new(RerankConfig::default());
1025 let results: Vec<crate::semantic_reranker::RerankResult> = vec![];
1026 let top = reranker.top_k(&results, 0);
1027 assert!(top.is_empty());
1028 }
1029
1030 #[test]
1033 fn test_precision_at_k_all_relevant() {
1034 let mut reranker = SemanticReranker::new(RerankConfig::default());
1035 let query = make_query("rust");
1036 let candidates = vec![
1037 make_candidate("d1", 0.9, "rust lang"),
1038 make_candidate("d2", 0.8, "rust systems"),
1039 ];
1040 let results = reranker.rerank(&query, &candidates);
1041 let relevant = vec!["d1".to_string(), "d2".to_string()];
1042 let p = reranker.precision_at_k(&results, 2, &relevant);
1043 assert!((p - 1.0).abs() < 1e-9);
1044 }
1045
1046 #[test]
1047 fn test_precision_at_k_none_relevant() {
1048 let mut reranker = SemanticReranker::new(RerankConfig::default());
1049 let query = make_query("rust");
1050 let candidates = vec![make_candidate("d1", 0.9, "rust lang")];
1051 let results = reranker.rerank(&query, &candidates);
1052 let relevant: Vec<String> = vec![];
1053 let p = reranker.precision_at_k(&results, 1, &relevant);
1054 assert_eq!(p, 0.0);
1055 }
1056
1057 #[test]
1058 fn test_precision_at_k_zero_k() {
1059 let reranker = SemanticReranker::new(RerankConfig::default());
1060 let p = reranker.precision_at_k(&[], 0, &[]);
1061 assert_eq!(p, 0.0);
1062 }
1063
1064 #[test]
1065 fn test_precision_at_k_partial() {
1066 let mut reranker = SemanticReranker::new(RerankConfig::default());
1067 let query = make_query("rust");
1068 let candidates = vec![
1069 make_candidate("d1", 0.9, "rust lang"),
1070 make_candidate("d2", 0.8, "python"),
1071 make_candidate("d3", 0.7, "rust sys"),
1072 make_candidate("d4", 0.6, "java"),
1073 ];
1074 let results = reranker.rerank(&query, &candidates);
1075 let relevant = vec!["d1".to_string(), "d3".to_string()];
1077 let p = reranker.precision_at_k(&results, 4, &relevant);
1078 assert!((p - 0.5).abs() < 1e-9);
1079 }
1080
1081 #[test]
1084 fn test_ndcg_perfect_ranking() {
1085 let config = RerankConfig {
1086 features: vec![(RerankFeature::KeywordOverlap, 1.0)],
1087 normalize_scores: false,
1088 min_rerank_score: f64::NEG_INFINITY,
1089 };
1090 let mut reranker = SemanticReranker::new(config);
1091 let query = make_query("rust lang");
1092 let candidates = vec![
1093 make_candidate("d1", 0.9, "rust lang systems"),
1094 make_candidate("d2", 0.5, "python scripting"),
1095 ];
1096 let results = reranker.rerank(&query, &candidates);
1097 let relevant = vec!["d1".to_string()];
1098 let ndcg = reranker.ndcg_at_k(&results, 2, &relevant);
1099 assert!((ndcg - 1.0).abs() < 1e-9);
1101 }
1102
1103 #[test]
1104 fn test_ndcg_zero_k() {
1105 let reranker = SemanticReranker::new(RerankConfig::default());
1106 let ndcg = reranker.ndcg_at_k(&[], 0, &[]);
1107 assert_eq!(ndcg, 0.0);
1108 }
1109
1110 #[test]
1111 fn test_ndcg_no_relevant_docs() {
1112 let mut reranker = SemanticReranker::new(RerankConfig::default());
1113 let query = make_query("rust");
1114 let candidates = vec![make_candidate("d1", 0.9, "rust lang")];
1115 let results = reranker.rerank(&query, &candidates);
1116 let ndcg = reranker.ndcg_at_k(&results, 1, &[]);
1117 assert_eq!(ndcg, 0.0);
1118 }
1119
1120 #[test]
1121 fn test_ndcg_worst_case_ordering() {
1122 let config = RerankConfig {
1124 features: vec![(RerankFeature::PositionPrior { decay: 0.0 }, 1.0)],
1125 normalize_scores: false,
1126 min_rerank_score: f64::NEG_INFINITY,
1127 };
1128 let mut reranker = SemanticReranker::new(config);
1129 let query = make_query("q");
1130 let candidates = vec![
1131 make_candidate("irrelevant", 0.9, "unrelated content"),
1132 make_candidate("relevant", 0.1, "matching content"),
1133 ];
1134 let results = reranker.rerank(&query, &candidates);
1135 let relevant = vec!["relevant".to_string()];
1136 let ndcg = reranker.ndcg_at_k(&results, 2, &relevant);
1137 assert!(ndcg < 1.0);
1139 }
1140
1141 #[test]
1144 fn test_stats_initial_zero() {
1145 let reranker = SemanticReranker::new(RerankConfig::default());
1146 let stats = reranker.stats();
1147 assert_eq!(stats.total_rerankings, 0);
1148 assert_eq!(stats.avg_candidates_per_reranking, 0.0);
1149 }
1150
1151 #[test]
1152 fn test_stats_after_rerankings() {
1153 let mut reranker = SemanticReranker::new(RerankConfig::default());
1154 let query = make_query("rust");
1155 let c1 = vec![make_candidate("d1", 0.9, "rust lang")];
1156 let c2 = vec![
1157 make_candidate("d2", 0.7, "rust sys"),
1158 make_candidate("d3", 0.5, "python"),
1159 ];
1160 reranker.rerank(&query, &c1);
1161 reranker.rerank(&query, &c2);
1162 let stats = reranker.stats();
1163 assert_eq!(stats.total_rerankings, 2);
1164 assert!((stats.avg_candidates_per_reranking - 1.5).abs() < 1e-9);
1166 }
1167
1168 #[test]
1169 fn test_stats_total_rerankings_increments() {
1170 let mut reranker = SemanticReranker::new(RerankConfig::default());
1171 let query = make_query("test");
1172 for _ in 0..5 {
1173 reranker.rerank(&query, &[]);
1174 }
1175 assert_eq!(reranker.total_rerankings, 5);
1176 }
1177
1178 #[test]
1181 fn test_normalize_scores_range() {
1182 let config = RerankConfig {
1183 features: vec![(RerankFeature::KeywordOverlap, 1.0)],
1184 normalize_scores: true,
1185 min_rerank_score: f64::NEG_INFINITY,
1186 };
1187 let mut reranker = SemanticReranker::new(config);
1188 let query = make_query("rust lang");
1189 let candidates: Vec<RerankCandidate> = (0..5)
1190 .map(|i| make_candidate(&format!("d{i}"), 0.5, &format!("rust lang doc {i}")))
1191 .collect();
1192 let results = reranker.rerank(&query, &candidates);
1193 if results.len() > 1 {
1194 let max = results
1195 .iter()
1196 .map(|r| r.rerank_score)
1197 .fold(f64::NEG_INFINITY, f64::max);
1198 let min = results
1199 .iter()
1200 .map(|r| r.rerank_score)
1201 .fold(f64::INFINITY, f64::min);
1202 assert!(max <= 1.0 + 1e-9);
1205 assert!(min >= -1e-9);
1206 }
1207 }
1208
1209 #[test]
1212 fn test_default_config_has_four_features() {
1213 let config = RerankConfig::default();
1214 assert_eq!(config.features.len(), 4);
1215 }
1216
1217 #[test]
1218 fn test_default_config_weights_sum_to_one() {
1219 let config = RerankConfig::default();
1220 let total: f64 = config.features.iter().map(|(_, w)| w).sum();
1221 assert!((total - 1.0).abs() < 1e-9);
1222 }
1223
1224 #[test]
1227 fn test_score_candidate_all_feature_keys_present() {
1228 let config = RerankConfig {
1229 features: vec![
1230 (RerankFeature::EmbeddingScore, 0.25),
1231 (RerankFeature::KeywordOverlap, 0.25),
1232 (RerankFeature::LengthPenalty, 0.25),
1233 (RerankFeature::PositionPrior { decay: 0.1 }, 0.25),
1234 ],
1235 normalize_scores: false,
1236 min_rerank_score: f64::NEG_INFINITY,
1237 };
1238 let reranker = SemanticReranker::new(config);
1239 let query = make_query("test");
1240 let candidate = make_candidate("d1", 0.5, "some content here");
1241 let scores = reranker.score_candidate(&query, &candidate, 0, 1);
1242 assert!(scores.contains_key("embedding_score"));
1243 assert!(scores.contains_key("keyword_overlap"));
1244 assert!(scores.contains_key("length_penalty"));
1245 assert!(scores.contains_key("position_prior"));
1246 }
1247
1248 #[test]
1251 fn test_single_candidate_rank_is_one() {
1252 let mut reranker = SemanticReranker::new(RerankConfig::default());
1253 let query = make_query("test");
1254 let candidates = vec![make_candidate("d1", 0.5, "some content")];
1255 let results = reranker.rerank(&query, &candidates);
1256 assert_eq!(results.len(), 1);
1257 assert_eq!(results[0].rank, 1);
1258 }
1259
1260 #[test]
1263 fn test_keyword_overlap_case_insensitive() {
1264 let a = SemanticReranker::tokenize("Rust LANG");
1265 let b = SemanticReranker::tokenize("rust lang");
1266 assert_eq!(a, b);
1268 }
1269
1270 #[test]
1273 fn test_unequal_weights_still_produce_valid_scores() {
1274 let config = RerankConfig {
1275 features: vec![
1276 (RerankFeature::KeywordOverlap, 10.0),
1277 (RerankFeature::LengthPenalty, 5.0),
1278 ],
1279 normalize_scores: false,
1280 min_rerank_score: f64::NEG_INFINITY,
1281 };
1282 let mut reranker = SemanticReranker::new(config);
1283 let query = make_query("rust lang");
1284 let candidates = vec![make_candidate("d1", 0.9, "rust lang systems")];
1285 let results = reranker.rerank(&query, &candidates);
1286 assert!(!results.is_empty());
1287 assert!(results[0].rerank_score.is_finite());
1289 }
1290}