1#[derive(Debug, Clone)]
8pub enum RankingSignal {
9 VectorSimilarity { weight: f64 },
11 RecencyBoost { weight: f64, decay_secs: u64 },
13 TagOverlap {
15 weight: f64,
16 query_tags: Vec<String>,
17 },
18 PeerReliability { weight: f64 },
20}
21
22impl RankingSignal {
23 pub fn weight(&self) -> f64 {
25 match self {
26 RankingSignal::VectorSimilarity { weight } => *weight,
27 RankingSignal::RecencyBoost { weight, .. } => *weight,
28 RankingSignal::TagOverlap { weight, .. } => *weight,
29 RankingSignal::PeerReliability { weight } => *weight,
30 }
31 }
32
33 pub fn name(&self) -> &'static str {
35 match self {
36 RankingSignal::VectorSimilarity { .. } => "similarity",
37 RankingSignal::RecencyBoost { .. } => "recency",
38 RankingSignal::TagOverlap { .. } => "tag_overlap",
39 RankingSignal::PeerReliability { .. } => "peer_reliability",
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct RawCandidate {
47 pub id: u64,
49 pub cid: String,
51 pub similarity_score: f32,
53 pub created_at_secs: u64,
55 pub tags: Vec<String>,
57 pub peer_reliability: f64,
59 pub metadata: String,
61}
62
63#[derive(Debug, Clone)]
65pub struct RankedResult {
66 pub candidate: RawCandidate,
68 pub final_score: f64,
70 pub signal_scores: Vec<(String, f64)>,
72}
73
74#[derive(Debug, Clone)]
76pub struct RankerConfig {
77 pub signals: Vec<RankingSignal>,
79 pub now_secs: u64,
81}
82
83impl RankerConfig {
84 pub fn total_weight(&self) -> f64 {
86 self.signals.iter().map(|s| s.weight()).sum()
87 }
88}
89
90pub struct VectorSearchRanker {
92 pub config: RankerConfig,
94}
95
96impl VectorSearchRanker {
97 pub fn new(config: RankerConfig) -> Self {
99 Self { config }
100 }
101
102 pub fn score_candidate(&self, candidate: &RawCandidate) -> RankedResult {
107 let total_weight = self.config.total_weight();
108 let mut weighted_sum = 0.0_f64;
109 let mut signal_scores: Vec<(String, f64)> = Vec::with_capacity(self.config.signals.len());
110
111 for signal in &self.config.signals {
112 let (raw_score, weight) = match signal {
113 RankingSignal::VectorSimilarity { weight } => {
114 let raw = candidate.similarity_score as f64;
115 (raw, *weight)
116 }
117 RankingSignal::RecencyBoost { weight, decay_secs } => {
118 let age = self
119 .config
120 .now_secs
121 .saturating_sub(candidate.created_at_secs);
122 let decay = if *decay_secs == 0 {
123 1.0_f64
125 } else {
126 (-(age as f64) / (*decay_secs as f64)).exp()
127 };
128 (decay, *weight)
129 }
130 RankingSignal::TagOverlap { weight, query_tags } => {
131 let raw = if query_tags.is_empty() {
132 0.0_f64
133 } else {
134 let matches = query_tags
135 .iter()
136 .filter(|qt| candidate.tags.contains(qt))
137 .count();
138 matches as f64 / query_tags.len() as f64
139 };
140 (raw, *weight)
141 }
142 RankingSignal::PeerReliability { weight } => (candidate.peer_reliability, *weight),
143 };
144
145 let weighted = weight * raw_score;
146 weighted_sum += weighted;
147 signal_scores.push((signal.name().to_owned(), weighted));
148 }
149
150 let final_score = if total_weight == 0.0 {
151 0.0
152 } else {
153 weighted_sum / total_weight
154 };
155
156 RankedResult {
157 candidate: candidate.clone(),
158 final_score,
159 signal_scores,
160 }
161 }
162
163 pub fn rank(&self, candidates: &[RawCandidate]) -> Vec<RankedResult> {
165 let mut results: Vec<RankedResult> =
166 candidates.iter().map(|c| self.score_candidate(c)).collect();
167 results.sort_by(|a, b| {
168 b.final_score
169 .partial_cmp(&a.final_score)
170 .unwrap_or(std::cmp::Ordering::Equal)
171 });
172 results
173 }
174
175 pub fn rank_top_k(&self, candidates: &[RawCandidate], k: usize) -> Vec<RankedResult> {
177 let mut ranked = self.rank(candidates);
178 ranked.truncate(k);
179 ranked
180 }
181
182 pub fn explain(&self, result: &RankedResult) -> String {
186 let signals_str: Vec<String> = result
187 .signal_scores
188 .iter()
189 .map(|(name, score)| format!("{}={:.4}", name, score))
190 .collect();
191 format!(
192 "id={} score={:.4} [{}]",
193 result.candidate.id,
194 result.final_score,
195 signals_str.join(", ")
196 )
197 }
198}
199
200#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn make_candidate(
209 id: u64,
210 similarity: f32,
211 created_at: u64,
212 tags: Vec<&str>,
213 peer_reliability: f64,
214 ) -> RawCandidate {
215 RawCandidate {
216 id,
217 cid: format!("cid-{}", id),
218 similarity_score: similarity,
219 created_at_secs: created_at,
220 tags: tags.into_iter().map(str::to_owned).collect(),
221 peer_reliability,
222 metadata: String::new(),
223 }
224 }
225
226 #[test]
228 fn test_new_stores_config() {
229 let config = RankerConfig {
230 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
231 now_secs: 1000,
232 };
233 let ranker = VectorSearchRanker::new(config.clone());
234 assert_eq!(ranker.config.now_secs, 1000);
235 assert_eq!(ranker.config.signals.len(), 1);
236 }
237
238 #[test]
240 fn test_score_candidate_similarity_only() {
241 let config = RankerConfig {
242 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
243 now_secs: 0,
244 };
245 let ranker = VectorSearchRanker::new(config);
246 let candidate = make_candidate(1, 0.75, 0, vec![], 0.0);
247 let result = ranker.score_candidate(&candidate);
248 let diff = (result.final_score - 0.75).abs();
249 assert!(diff < 1e-9, "expected 0.75, got {}", result.final_score);
250 }
251
252 #[test]
254 fn test_score_candidate_recency_age_zero() {
255 let now = 5000_u64;
256 let config = RankerConfig {
257 signals: vec![RankingSignal::RecencyBoost {
258 weight: 1.0,
259 decay_secs: 3600,
260 }],
261 now_secs: now,
262 };
263 let ranker = VectorSearchRanker::new(config);
264 let candidate = make_candidate(1, 0.5, now, vec![], 0.0);
266 let result = ranker.score_candidate(&candidate);
267 let diff = (result.final_score - 1.0).abs();
268 assert!(diff < 1e-9, "expected 1.0, got {}", result.final_score);
269 }
270
271 #[test]
273 fn test_score_candidate_recency_decayed() {
274 let decay_secs = 3600_u64;
275 let now = 7200_u64;
276 let created_at = 0_u64;
277 let expected_raw = (-2.0_f64).exp();
279 let config = RankerConfig {
280 signals: vec![RankingSignal::RecencyBoost {
281 weight: 1.0,
282 decay_secs,
283 }],
284 now_secs: now,
285 };
286 let ranker = VectorSearchRanker::new(config);
287 let candidate = make_candidate(1, 0.5, created_at, vec![], 0.0);
288 let result = ranker.score_candidate(&candidate);
289 let diff = (result.final_score - expected_raw).abs();
290 assert!(
291 diff < 1e-9,
292 "expected {}, got {}",
293 expected_raw,
294 result.final_score
295 );
296 }
297
298 #[test]
300 fn test_score_candidate_tag_overlap_empty_query() {
301 let config = RankerConfig {
302 signals: vec![RankingSignal::TagOverlap {
303 weight: 1.0,
304 query_tags: vec![],
305 }],
306 now_secs: 0,
307 };
308 let ranker = VectorSearchRanker::new(config);
309 let candidate = make_candidate(1, 0.5, 0, vec!["rust", "ipfs"], 0.0);
310 let result = ranker.score_candidate(&candidate);
311 assert_eq!(result.final_score, 0.0);
312 }
313
314 #[test]
316 fn test_score_candidate_tag_overlap_full_match() {
317 let config = RankerConfig {
318 signals: vec![RankingSignal::TagOverlap {
319 weight: 1.0,
320 query_tags: vec!["rust".to_owned(), "ipfs".to_owned()],
321 }],
322 now_secs: 0,
323 };
324 let ranker = VectorSearchRanker::new(config);
325 let candidate = make_candidate(1, 0.5, 0, vec!["rust", "ipfs"], 0.0);
326 let result = ranker.score_candidate(&candidate);
327 let diff = (result.final_score - 1.0).abs();
328 assert!(diff < 1e-9, "expected 1.0, got {}", result.final_score);
329 }
330
331 #[test]
333 fn test_score_candidate_tag_overlap_partial_match() {
334 let config = RankerConfig {
336 signals: vec![RankingSignal::TagOverlap {
337 weight: 1.0,
338 query_tags: vec!["rust".to_owned(), "ipfs".to_owned(), "p2p".to_owned()],
339 }],
340 now_secs: 0,
341 };
342 let ranker = VectorSearchRanker::new(config);
343 let candidate = make_candidate(1, 0.5, 0, vec!["rust", "p2p"], 0.0);
344 let result = ranker.score_candidate(&candidate);
345 let expected = 2.0 / 3.0;
346 let diff = (result.final_score - expected).abs();
347 assert!(
348 diff < 1e-9,
349 "expected {}, got {}",
350 expected,
351 result.final_score
352 );
353 }
354
355 #[test]
357 fn test_score_candidate_peer_reliability() {
358 let config = RankerConfig {
359 signals: vec![RankingSignal::PeerReliability { weight: 1.0 }],
360 now_secs: 0,
361 };
362 let ranker = VectorSearchRanker::new(config);
363 let candidate = make_candidate(1, 0.0, 0, vec![], 0.85);
364 let result = ranker.score_candidate(&candidate);
365 let diff = (result.final_score - 0.85).abs();
366 assert!(diff < 1e-9, "expected 0.85, got {}", result.final_score);
367 }
368
369 #[test]
371 fn test_score_candidate_multi_signal_weighted_average() {
372 let config = RankerConfig {
376 signals: vec![
377 RankingSignal::VectorSimilarity { weight: 2.0 },
378 RankingSignal::PeerReliability { weight: 1.0 },
379 ],
380 now_secs: 0,
381 };
382 let ranker = VectorSearchRanker::new(config);
383 let candidate = make_candidate(1, 0.8, 0, vec![], 0.5);
384 let result = ranker.score_candidate(&candidate);
385 let sim_f64 = 0.8_f32 as f64;
388 let expected = (2.0 * sim_f64 + 1.0 * 0.5) / 3.0;
389 let diff = (result.final_score - expected).abs();
390 assert!(
391 diff < 1e-9,
392 "expected {}, got {}",
393 expected,
394 result.final_score
395 );
396 }
397
398 #[test]
400 fn test_score_candidate_signal_scores_length() {
401 let config = RankerConfig {
402 signals: vec![
403 RankingSignal::VectorSimilarity { weight: 1.0 },
404 RankingSignal::RecencyBoost {
405 weight: 1.0,
406 decay_secs: 3600,
407 },
408 RankingSignal::TagOverlap {
409 weight: 1.0,
410 query_tags: vec!["a".to_owned()],
411 },
412 RankingSignal::PeerReliability { weight: 1.0 },
413 ],
414 now_secs: 1000,
415 };
416 let ranker = VectorSearchRanker::new(config);
417 let candidate = make_candidate(1, 0.5, 1000, vec!["a"], 0.9);
418 let result = ranker.score_candidate(&candidate);
419 assert_eq!(result.signal_scores.len(), 4);
420 }
421
422 #[test]
424 fn test_rank_sorts_descending() {
425 let config = RankerConfig {
426 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
427 now_secs: 0,
428 };
429 let ranker = VectorSearchRanker::new(config);
430 let candidates = vec![
431 make_candidate(1, 0.3, 0, vec![], 0.0),
432 make_candidate(2, 0.9, 0, vec![], 0.0),
433 make_candidate(3, 0.6, 0, vec![], 0.0),
434 ];
435 let ranked = ranker.rank(&candidates);
436 assert_eq!(ranked[0].candidate.id, 2);
437 assert_eq!(ranked[1].candidate.id, 3);
438 assert_eq!(ranked[2].candidate.id, 1);
439 }
440
441 #[test]
443 fn test_rank_empty_candidates() {
444 let config = RankerConfig {
445 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
446 now_secs: 0,
447 };
448 let ranker = VectorSearchRanker::new(config);
449 let ranked = ranker.rank(&[]);
450 assert!(ranked.is_empty());
451 }
452
453 #[test]
455 fn test_rank_top_k_truncates() {
456 let config = RankerConfig {
457 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
458 now_secs: 0,
459 };
460 let ranker = VectorSearchRanker::new(config);
461 let candidates = vec![
462 make_candidate(1, 0.3, 0, vec![], 0.0),
463 make_candidate(2, 0.9, 0, vec![], 0.0),
464 make_candidate(3, 0.6, 0, vec![], 0.0),
465 ];
466 let top2 = ranker.rank_top_k(&candidates, 2);
467 assert_eq!(top2.len(), 2);
468 assert_eq!(top2[0].candidate.id, 2);
469 assert_eq!(top2[1].candidate.id, 3);
470 }
471
472 #[test]
474 fn test_rank_top_k_k_exceeds_len() {
475 let config = RankerConfig {
476 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
477 now_secs: 0,
478 };
479 let ranker = VectorSearchRanker::new(config);
480 let candidates = vec![
481 make_candidate(1, 0.5, 0, vec![], 0.0),
482 make_candidate(2, 0.8, 0, vec![], 0.0),
483 ];
484 let top10 = ranker.rank_top_k(&candidates, 10);
485 assert_eq!(top10.len(), 2);
486 }
487
488 #[test]
490 fn test_explain_contains_id() {
491 let config = RankerConfig {
492 signals: vec![RankingSignal::VectorSimilarity { weight: 1.0 }],
493 now_secs: 0,
494 };
495 let ranker = VectorSearchRanker::new(config);
496 let candidate = make_candidate(42, 0.7, 0, vec![], 0.0);
497 let result = ranker.score_candidate(&candidate);
498 let explanation = ranker.explain(&result);
499 assert!(!explanation.is_empty());
500 assert!(
501 explanation.contains("id=42"),
502 "explanation should contain 'id=42', got: {}",
503 explanation
504 );
505 }
506
507 #[test]
509 fn test_total_weight_sum() {
510 let config = RankerConfig {
511 signals: vec![
512 RankingSignal::VectorSimilarity { weight: 2.0 },
513 RankingSignal::PeerReliability { weight: 3.0 },
514 RankingSignal::RecencyBoost {
515 weight: 1.5,
516 decay_secs: 60,
517 },
518 ],
519 now_secs: 0,
520 };
521 let diff = (config.total_weight() - 6.5).abs();
522 assert!(diff < 1e-9, "expected 6.5, got {}", config.total_weight());
523 }
524
525 #[test]
527 fn test_zero_total_weight_gives_zero_final_score() {
528 let config = RankerConfig {
529 signals: vec![RankingSignal::VectorSimilarity { weight: 0.0 }],
530 now_secs: 0,
531 };
532 let ranker = VectorSearchRanker::new(config);
533 let candidate = make_candidate(1, 1.0, 0, vec![], 1.0);
534 let result = ranker.score_candidate(&candidate);
535 assert_eq!(result.final_score, 0.0);
536 }
537}
538
539use std::collections::HashMap;
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
547pub enum RankSignal {
548 Similarity,
550 Recency,
552 Popularity,
554 UserBoost,
556}
557
558#[derive(Debug, Clone)]
560pub struct SearchCandidate {
561 pub id: u64,
563 pub cid: String,
565 pub similarity: f32,
567 pub created_at_secs: u64,
569 pub access_count: u64,
571 pub user_boost: f32,
573}
574
575#[derive(Debug, Clone)]
577pub struct SemanticRankerConfig {
578 pub similarity_weight: f32,
580 pub recency_weight: f32,
582 pub popularity_weight: f32,
584 pub recency_half_life_secs: u64,
586 pub max_access_count: u64,
588}
589
590impl Default for SemanticRankerConfig {
591 fn default() -> Self {
592 Self {
593 similarity_weight: 0.6,
594 recency_weight: 0.2,
595 popularity_weight: 0.2,
596 recency_half_life_secs: 86_400,
597 max_access_count: 10_000,
598 }
599 }
600}
601
602#[derive(Debug, Clone)]
604pub struct SemanticRankedResult {
605 pub candidate: SearchCandidate,
607 pub signal_scores: HashMap<RankSignal, f32>,
609 pub final_score: f32,
611 pub rank: usize,
613}
614
615#[derive(Debug, Clone)]
617pub struct RankerStats {
618 pub total_ranked: u64,
620 pub avg_final_score: f64,
623 pub avg_candidates_per_call: f64,
626}
627
628pub struct SemanticSearchRanker {
632 pub config: SemanticRankerConfig,
634 pub total_ranked: u64,
636 pub total_candidates: u64,
639 pub total_score_sum: f64,
641 call_count: u64,
643}
644
645impl SemanticSearchRanker {
646 pub fn new(config: SemanticRankerConfig) -> Self {
648 Self {
649 config,
650 total_ranked: 0,
651 total_candidates: 0,
652 total_score_sum: 0.0,
653 call_count: 0,
654 }
655 }
656
657 pub fn rank(
663 &mut self,
664 candidates: Vec<SearchCandidate>,
665 now_secs: u64,
666 ) -> Vec<SemanticRankedResult> {
667 let n = candidates.len();
668 self.call_count = self.call_count.saturating_add(1);
669
670 if n == 0 {
671 return Vec::new();
672 }
673
674 let half_life = self.config.recency_half_life_secs;
675 let max_ac = self.config.max_access_count;
676 let sim_w = self.config.similarity_weight;
677 let rec_w = self.config.recency_weight;
678 let pop_w = self.config.popularity_weight;
679
680 let mut results: Vec<SemanticRankedResult> = candidates
681 .into_iter()
682 .map(|c| {
683 let age_secs = now_secs.saturating_sub(c.created_at_secs);
685 let recency_score = if half_life == 0 {
686 1.0_f32
688 } else {
689 0.5_f32.powf(age_secs as f32 / half_life as f32)
690 };
691
692 let popularity_score = if max_ac == 0 {
694 0.0_f32
696 } else {
697 c.access_count.min(max_ac) as f32 / max_ac as f32
698 };
699
700 let weighted =
702 sim_w * c.similarity + rec_w * recency_score + pop_w * popularity_score;
703 let final_score = weighted * c.user_boost;
704
705 let mut signal_scores: HashMap<RankSignal, f32> = HashMap::with_capacity(4);
707 signal_scores.insert(RankSignal::Similarity, c.similarity);
708 signal_scores.insert(RankSignal::Recency, recency_score);
709 signal_scores.insert(RankSignal::Popularity, popularity_score);
710 signal_scores.insert(RankSignal::UserBoost, c.user_boost);
711
712 SemanticRankedResult {
713 candidate: c,
714 signal_scores,
715 final_score,
716 rank: 0, }
718 })
719 .collect();
720
721 results.sort_by(|a, b| {
723 b.final_score
724 .partial_cmp(&a.final_score)
725 .unwrap_or(std::cmp::Ordering::Equal)
726 });
727
728 for (i, result) in results.iter_mut().enumerate() {
730 result.rank = i + 1;
731 }
732
733 let score_sum: f64 = results.iter().map(|r| r.final_score as f64).sum();
735 self.total_ranked = self.total_ranked.saturating_add(n as u64);
736 self.total_candidates = self.total_candidates.saturating_add(n as u64);
737 self.total_score_sum += score_sum;
738
739 results
740 }
741
742 pub fn stats(&self) -> RankerStats {
744 let avg_final_score = if self.total_ranked == 0 {
745 0.0
746 } else {
747 self.total_score_sum / self.total_ranked as f64
748 };
749
750 let avg_candidates_per_call = if self.call_count == 0 {
751 0.0
752 } else {
753 self.total_candidates as f64 / self.call_count as f64
754 };
755
756 RankerStats {
757 total_ranked: self.total_ranked,
758 avg_final_score,
759 avg_candidates_per_call,
760 }
761 }
762}
763
764#[cfg(test)]
769mod semantic_ranker_tests {
770 use super::*;
771
772 fn make_sc(
774 id: u64,
775 similarity: f32,
776 created_at_secs: u64,
777 access_count: u64,
778 user_boost: f32,
779 ) -> SearchCandidate {
780 SearchCandidate {
781 id,
782 cid: format!("cid-{}", id),
783 similarity,
784 created_at_secs,
785 access_count,
786 user_boost,
787 }
788 }
789
790 fn default_ranker() -> SemanticSearchRanker {
791 SemanticSearchRanker::new(SemanticRankerConfig::default())
792 }
793
794 #[test]
796 fn test_new_zero_stats() {
797 let ranker = default_ranker();
798 assert_eq!(ranker.total_ranked, 0);
799 assert_eq!(ranker.total_candidates, 0);
800 assert_eq!(ranker.total_score_sum, 0.0);
801 let stats = ranker.stats();
802 assert_eq!(stats.total_ranked, 0);
803 assert_eq!(stats.avg_final_score, 0.0);
804 assert_eq!(stats.avg_candidates_per_call, 0.0);
805 }
806
807 #[test]
809 fn test_rank_empty_returns_empty() {
810 let mut ranker = default_ranker();
811 let results = ranker.rank(vec![], 1_000_000);
812 assert!(results.is_empty());
813 }
814
815 #[test]
817 fn test_rank_single_assigns_rank_one() {
818 let mut ranker = default_ranker();
819 let c = make_sc(7, 0.8, 0, 0, 1.0);
820 let results = ranker.rank(vec![c], 1_000);
821 assert_eq!(results.len(), 1);
822 assert_eq!(results[0].rank, 1);
823 }
824
825 #[test]
827 fn test_similarity_signal_stored() {
828 let mut ranker = default_ranker();
829 let c = make_sc(1, 0.75, 0, 0, 1.0);
830 let results = ranker.rank(vec![c], 86_400);
831 let sim = results[0].signal_scores[&RankSignal::Similarity];
832 let diff = (sim - 0.75).abs();
833 assert!(diff < 1e-6, "expected 0.75, got {}", sim);
834 }
835
836 #[test]
838 fn test_recency_age_zero_is_one() {
839 let now = 500_000_u64;
840 let mut ranker = default_ranker();
841 let c = make_sc(1, 0.0, now, 0, 1.0); let results = ranker.rank(vec![c], now);
843 let rec = results[0].signal_scores[&RankSignal::Recency];
844 let diff = (rec - 1.0).abs();
845 assert!(diff < 1e-6, "expected 1.0, got {}", rec);
846 }
847
848 #[test]
850 fn test_recency_age_equals_half_life_gives_half() {
851 let half_life = 86_400_u64;
852 let now = half_life * 2;
853 let created_at = half_life; let mut ranker = default_ranker();
855 let c = make_sc(1, 0.0, created_at, 0, 1.0);
856 let results = ranker.rank(vec![c], now);
857 let rec = results[0].signal_scores[&RankSignal::Recency];
858 let diff = (rec - 0.5).abs();
859 assert!(diff < 1e-6, "expected ~0.5, got {}", rec);
860 }
861
862 #[test]
864 fn test_recency_large_age_approaches_zero() {
865 let half_life = 86_400_u64;
866 let now = half_life * 100; let mut ranker = default_ranker();
868 let c = make_sc(1, 0.0, 0, 0, 1.0);
869 let results = ranker.rank(vec![c], now);
870 let rec = results[0].signal_scores[&RankSignal::Recency];
871 assert!(rec < 1e-6, "expected near 0, got {}", rec);
872 }
873
874 #[test]
876 fn test_popularity_zero_access_count() {
877 let mut ranker = default_ranker();
878 let c = make_sc(1, 0.0, 0, 0, 1.0);
879 let results = ranker.rank(vec![c], 0);
880 let pop = results[0].signal_scores[&RankSignal::Popularity];
881 assert_eq!(pop, 0.0);
882 }
883
884 #[test]
886 fn test_popularity_max_access_count() {
887 let max_ac = 10_000_u64;
888 let mut ranker = default_ranker();
889 let c = make_sc(1, 0.0, 0, max_ac, 1.0);
890 let results = ranker.rank(vec![c], 0);
891 let pop = results[0].signal_scores[&RankSignal::Popularity];
892 let diff = (pop - 1.0).abs();
893 assert!(diff < 1e-6, "expected 1.0, got {}", pop);
894 }
895
896 #[test]
898 fn test_popularity_capped_at_max() {
899 let max_ac = 10_000_u64;
900 let mut ranker = default_ranker();
901 let c = make_sc(1, 0.0, 0, max_ac * 5, 1.0);
903 let results = ranker.rank(vec![c], 0);
904 let pop = results[0].signal_scores[&RankSignal::Popularity];
905 let diff = (pop - 1.0).abs();
906 assert!(diff < 1e-6, "expected 1.0 after capping, got {}", pop);
907 }
908
909 #[test]
911 fn test_user_boost_multiplies_final_score() {
912 let config = SemanticRankerConfig {
913 similarity_weight: 1.0,
914 recency_weight: 0.0,
915 popularity_weight: 0.0,
916 recency_half_life_secs: 86_400,
917 max_access_count: 10_000,
918 };
919 let mut ranker = SemanticSearchRanker::new(config);
920 let boost = 2.5_f32;
921 let sim = 0.8_f32;
922 let c = make_sc(1, sim, 0, 0, boost);
923 let results = ranker.rank(vec![c], 0);
924 let expected = sim * boost;
925 let diff = (results[0].final_score - expected).abs();
926 assert!(
927 diff < 1e-5,
928 "expected {}, got {}",
929 expected,
930 results[0].final_score
931 );
932 }
933
934 #[test]
936 fn test_user_boost_one_no_effect() {
937 let config = SemanticRankerConfig {
938 similarity_weight: 1.0,
939 recency_weight: 0.0,
940 popularity_weight: 0.0,
941 recency_half_life_secs: 86_400,
942 max_access_count: 10_000,
943 };
944 let mut ranker = SemanticSearchRanker::new(config);
945 let sim = 0.6_f32;
946 let c = make_sc(1, sim, 0, 0, 1.0);
947 let results = ranker.rank(vec![c], 0);
948 let diff = (results[0].final_score - sim).abs();
949 assert!(
950 diff < 1e-6,
951 "expected {}, got {}",
952 sim,
953 results[0].final_score
954 );
955 }
956
957 #[test]
959 fn test_higher_similarity_ranks_first() {
960 let config = SemanticRankerConfig {
961 similarity_weight: 1.0,
962 recency_weight: 0.0,
963 popularity_weight: 0.0,
964 recency_half_life_secs: 86_400,
965 max_access_count: 10_000,
966 };
967 let mut ranker = SemanticSearchRanker::new(config);
968 let c1 = make_sc(1, 0.3, 0, 0, 1.0);
969 let c2 = make_sc(2, 0.9, 0, 0, 1.0);
970 let c3 = make_sc(3, 0.6, 0, 0, 1.0);
971 let results = ranker.rank(vec![c1, c2, c3], 0);
972 assert_eq!(results[0].candidate.id, 2);
973 assert_eq!(results[1].candidate.id, 3);
974 assert_eq!(results[2].candidate.id, 1);
975 }
976
977 #[test]
979 fn test_weights_produce_correct_combined_score() {
980 let config = SemanticRankerConfig {
982 similarity_weight: 0.6,
983 recency_weight: 0.2,
984 popularity_weight: 0.2,
985 recency_half_life_secs: 86_400,
986 max_access_count: 10_000,
987 };
988 let now = 0_u64;
989 let mut ranker = SemanticSearchRanker::new(config);
990 let sim = 0.5_f32;
991 let c = make_sc(1, sim, now, 10_000, 1.0);
992 let results = ranker.rank(vec![c], now);
993 let expected = 0.7_f32;
995 let diff = (results[0].final_score - expected).abs();
996 assert!(
997 diff < 1e-5,
998 "expected {}, got {}",
999 expected,
1000 results[0].final_score
1001 );
1002 }
1003
1004 #[test]
1006 fn test_rank_field_is_one_based() {
1007 let mut ranker = default_ranker();
1008 let c = make_sc(1, 0.5, 0, 0, 1.0);
1009 let results = ranker.rank(vec![c], 0);
1010 assert_eq!(results[0].rank, 1);
1011 }
1012
1013 #[test]
1015 fn test_rank_field_sequential() {
1016 let mut ranker = default_ranker();
1017 let candidates = vec![
1018 make_sc(1, 0.9, 0, 0, 1.0),
1019 make_sc(2, 0.7, 0, 0, 1.0),
1020 make_sc(3, 0.5, 0, 0, 1.0),
1021 ];
1022 let results = ranker.rank(candidates, 0);
1023 for (i, r) in results.iter().enumerate() {
1024 assert_eq!(r.rank, i + 1, "expected rank {} at position {}", i + 1, i);
1025 }
1026 }
1027
1028 #[test]
1030 fn test_stats_total_ranked_accumulates() {
1031 let mut ranker = default_ranker();
1032 ranker.rank(
1033 vec![make_sc(1, 0.5, 0, 0, 1.0), make_sc(2, 0.6, 0, 0, 1.0)],
1034 0,
1035 );
1036 ranker.rank(vec![make_sc(3, 0.7, 0, 0, 1.0)], 0);
1037 let stats = ranker.stats();
1038 assert_eq!(stats.total_ranked, 3);
1039 }
1040
1041 #[test]
1043 fn test_stats_avg_final_score_computed() {
1044 let config = SemanticRankerConfig {
1046 similarity_weight: 1.0,
1047 recency_weight: 0.0,
1048 popularity_weight: 0.0,
1049 recency_half_life_secs: 86_400,
1050 max_access_count: 10_000,
1051 };
1052 let mut ranker = SemanticSearchRanker::new(config);
1053 ranker.rank(
1054 vec![make_sc(1, 0.4, 0, 0, 1.0), make_sc(2, 0.8, 0, 0, 1.0)],
1055 0,
1056 );
1057 let stats = ranker.stats();
1058 let diff = (stats.avg_final_score - 0.6).abs();
1060 assert!(
1061 diff < 1e-5,
1062 "expected avg ~0.6, got {}",
1063 stats.avg_final_score
1064 );
1065 }
1066
1067 #[test]
1069 fn test_stats_avg_candidates_per_call() {
1070 let mut ranker = default_ranker();
1071 ranker.rank(
1073 vec![
1074 make_sc(1, 0.5, 0, 0, 1.0),
1075 make_sc(2, 0.6, 0, 0, 1.0),
1076 make_sc(3, 0.7, 0, 0, 1.0),
1077 ],
1078 0,
1079 );
1080 ranker.rank(vec![make_sc(4, 0.4, 0, 0, 1.0)], 0);
1081 let stats = ranker.stats();
1082 let diff = (stats.avg_candidates_per_call - 2.0).abs();
1083 assert!(
1084 diff < 1e-9,
1085 "expected 2.0, got {}",
1086 stats.avg_candidates_per_call
1087 );
1088 }
1089
1090 #[test]
1092 fn test_multiple_calls_accumulate_stats() {
1093 let config = SemanticRankerConfig {
1094 similarity_weight: 1.0,
1095 recency_weight: 0.0,
1096 popularity_weight: 0.0,
1097 recency_half_life_secs: 86_400,
1098 max_access_count: 10_000,
1099 };
1100 let mut ranker = SemanticSearchRanker::new(config);
1101 for _ in 0..5 {
1102 ranker.rank(vec![make_sc(1, 1.0, 0, 0, 1.0)], 0);
1103 }
1104 let stats = ranker.stats();
1105 assert_eq!(stats.total_ranked, 5);
1106 let diff = (stats.avg_final_score - 1.0).abs();
1107 assert!(
1108 diff < 1e-5,
1109 "expected avg 1.0, got {}",
1110 stats.avg_final_score
1111 );
1112 }
1113
1114 #[test]
1116 fn test_signal_scores_hashmap_populated() {
1117 let mut ranker = default_ranker();
1118 let c = make_sc(1, 0.5, 0, 500, 2.0);
1119 let results = ranker.rank(vec![c], 0);
1120 let scores = &results[0].signal_scores;
1121 assert!(
1122 scores.contains_key(&RankSignal::Similarity),
1123 "missing Similarity"
1124 );
1125 assert!(scores.contains_key(&RankSignal::Recency), "missing Recency");
1126 assert!(
1127 scores.contains_key(&RankSignal::Popularity),
1128 "missing Popularity"
1129 );
1130 assert!(
1131 scores.contains_key(&RankSignal::UserBoost),
1132 "missing UserBoost"
1133 );
1134 assert_eq!(scores.len(), 4);
1135 }
1136
1137 #[test]
1139 fn test_empty_rank_does_not_corrupt_stats() {
1140 let mut ranker = default_ranker();
1141 ranker.rank(vec![make_sc(1, 0.9, 0, 0, 1.0)], 0);
1142 ranker.rank(vec![], 0); let stats = ranker.stats();
1144 assert_eq!(stats.total_ranked, 1);
1145 }
1146
1147 #[test]
1149 fn test_zero_weights_and_user_boost() {
1150 let config = SemanticRankerConfig {
1151 similarity_weight: 0.0,
1152 recency_weight: 0.0,
1153 popularity_weight: 0.0,
1154 recency_half_life_secs: 86_400,
1155 max_access_count: 10_000,
1156 };
1157 let mut ranker = SemanticSearchRanker::new(config);
1158 let c = make_sc(1, 0.9, 0, 9999, 3.0);
1159 let results = ranker.rank(vec![c], 0);
1160 assert_eq!(results[0].final_score, 0.0);
1162 }
1163}