Skip to main content

ipfrs_semantic/
search_ranker.rs

1//! Vector Search Re-Ranker
2//!
3//! Re-ranks raw k-NN candidate results using a multi-signal scoring model:
4//! vector similarity, recency, tag overlap, and peer reliability.
5
6/// A single ranking signal with its weight and parameters.
7#[derive(Debug, Clone)]
8pub enum RankingSignal {
9    /// Cosine similarity score in \[0,1\].
10    VectorSimilarity { weight: f64 },
11    /// Exponential recency decay: exp(-age_secs / decay_secs).
12    RecencyBoost { weight: f64, decay_secs: u64 },
13    /// Fraction of query_tags present in the result's tags.
14    TagOverlap {
15        weight: f64,
16        query_tags: Vec<String>,
17    },
18    /// Peer reliability score in \[0,1\].
19    PeerReliability { weight: f64 },
20}
21
22impl RankingSignal {
23    /// Returns the weight of the signal.
24    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    /// Returns the canonical name of the signal for explainability output.
34    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/// Raw k-NN candidate returned by the vector index.
45#[derive(Debug, Clone)]
46pub struct RawCandidate {
47    /// Numeric identifier for the candidate.
48    pub id: u64,
49    /// Content identifier (CID) string.
50    pub cid: String,
51    /// Raw cosine similarity score in \[0,1\].
52    pub similarity_score: f32,
53    /// Unix timestamp (seconds) at which the content was created.
54    pub created_at_secs: u64,
55    /// Tags associated with the content.
56    pub tags: Vec<String>,
57    /// Peer reliability score in [0.0, 1.0].
58    pub peer_reliability: f64,
59    /// Arbitrary metadata string.
60    pub metadata: String,
61}
62
63/// A candidate after re-ranking, carrying per-signal scores for explainability.
64#[derive(Debug, Clone)]
65pub struct RankedResult {
66    /// The original candidate.
67    pub candidate: RawCandidate,
68    /// Weighted final score in \[0,1\].
69    pub final_score: f64,
70    /// Per-signal (name, weighted_score) pairs.
71    pub signal_scores: Vec<(String, f64)>,
72}
73
74/// Configuration for the `VectorSearchRanker`.
75#[derive(Debug, Clone)]
76pub struct RankerConfig {
77    /// Ordered list of ranking signals to apply.
78    pub signals: Vec<RankingSignal>,
79    /// Current Unix timestamp in seconds — injected for testability.
80    pub now_secs: u64,
81}
82
83impl RankerConfig {
84    /// Sum of all signal weights.
85    pub fn total_weight(&self) -> f64 {
86        self.signals.iter().map(|s| s.weight()).sum()
87    }
88}
89
90/// Re-ranks raw k-NN candidates using a configurable multi-signal scoring model.
91pub struct VectorSearchRanker {
92    /// Configuration including signals and the reference timestamp.
93    pub config: RankerConfig,
94}
95
96impl VectorSearchRanker {
97    /// Creates a new `VectorSearchRanker` with the given configuration.
98    pub fn new(config: RankerConfig) -> Self {
99        Self { config }
100    }
101
102    /// Scores a single candidate against all configured signals.
103    ///
104    /// Returns a `RankedResult` with the computed `final_score` and per-signal
105    /// breakdown for explainability.
106    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                        // Avoid divide-by-zero: treat zero decay_secs as no decay (score = 1)
124                        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    /// Scores all candidates and returns them sorted by `final_score` descending.
164    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    /// Returns at most `k` top-ranked candidates.
176    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    /// Returns a human-readable explanation of a `RankedResult`.
183    ///
184    /// Format: `"id=X score=Y.YY [signal=A, ...]"`
185    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// ────────────────────────────────────────────────────────────────────────────
201// Tests
202// ────────────────────────────────────────────────────────────────────────────
203
204#[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    // ── 1. new() stores config ───────────────────────────────────────────────
227    #[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    // ── 2. similarity only → final_score = similarity_score ─────────────────
239    #[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    // ── 3. recency at age=0 → exp(0) = 1.0 ──────────────────────────────────
253    #[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        // created_at == now → age = 0 → exp(0) = 1.0
265        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    // ── 4. recency with age > 0 → decayed correctly ──────────────────────────
272    #[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        // age = 7200, raw = exp(-7200/3600) = exp(-2)
278        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    // ── 5. tag_overlap no query_tags → 0.0 ───────────────────────────────────
299    #[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    // ── 6. tag_overlap all match → 1.0 ───────────────────────────────────────
315    #[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    // ── 7. tag_overlap partial match → correct fraction ───────────────────────
332    #[test]
333    fn test_score_candidate_tag_overlap_partial_match() {
334        // query_tags = ["rust", "ipfs", "p2p"], candidate has ["rust", "p2p"] → 2/3
335        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    // ── 8. peer_reliability signal ────────────────────────────────────────────
356    #[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    // ── 9. multi-signal weighted average ──────────────────────────────────────
370    #[test]
371    fn test_score_candidate_multi_signal_weighted_average() {
372        // similarity w=2 score=0.8 → weighted=1.6
373        // peer_reliability w=1 score=0.5 → weighted=0.5
374        // total_weight=3, final = (1.6+0.5)/3 = 0.7
375        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        // similarity_score is stored as f32 (0.8f32), so casting to f64 introduces
386        // a small representation error (~1e-8). Use f32 cast for the expected value.
387        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    // ── 10. signal_scores length matches signals count ─────────────────────────
399    #[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    // ── 11. rank() sorts descending by final_score ────────────────────────────
423    #[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    // ── 12. rank() empty candidates returns empty ─────────────────────────────
442    #[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    // ── 13. rank_top_k() truncates correctly ──────────────────────────────────
454    #[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    // ── 14. rank_top_k() k > len returns all ─────────────────────────────────
473    #[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    // ── 15. explain() returns non-empty string containing the id ──────────────
489    #[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    // ── 16. total_weight() sum ────────────────────────────────────────────────
508    #[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    // ── 17. RankerConfig with zero total_weight → final_score = 0.0 ──────────
526    #[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
539// ════════════════════════════════════════════════════════════════════════════
540// SemanticSearchRanker — multi-signal re-ranking with stats accumulation
541// ════════════════════════════════════════════════════════════════════════════
542
543use std::collections::HashMap;
544
545/// Individual ranking signal identifiers used as keys in the per-result score map.
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
547pub enum RankSignal {
548    /// Cosine similarity score in [0, 1].
549    Similarity,
550    /// Freshness signal: 0.5^(age / half_life), yielding [0, 1].
551    Recency,
552    /// Access-frequency signal normalised to [0, 1].
553    Popularity,
554    /// User-defined multiplicative boost (stored as the raw multiplier).
555    UserBoost,
556}
557
558/// A single candidate produced by an upstream vector-search query.
559#[derive(Debug, Clone)]
560pub struct SearchCandidate {
561    /// Numeric identifier for the vector-index entry.
562    pub id: u64,
563    /// Content-addressed identifier string.
564    pub cid: String,
565    /// Cosine similarity score from the vector index; must be in [0.0, 1.0].
566    pub similarity: f32,
567    /// Unix timestamp (seconds) at which this content was created.
568    pub created_at_secs: u64,
569    /// Number of times this content has been accessed.
570    pub access_count: u64,
571    /// Multiplicative user-defined relevance boost; default should be 1.0.
572    pub user_boost: f32,
573}
574
575/// Configuration knobs for `SemanticSearchRanker`.
576#[derive(Debug, Clone)]
577pub struct SemanticRankerConfig {
578    /// Weight applied to the similarity signal (default 0.6).
579    pub similarity_weight: f32,
580    /// Weight applied to the recency signal (default 0.2).
581    pub recency_weight: f32,
582    /// Weight applied to the popularity signal (default 0.2).
583    pub popularity_weight: f32,
584    /// Half-life in seconds for the recency decay (default 86 400 = 1 day).
585    pub recency_half_life_secs: u64,
586    /// Access-count ceiling used to normalise popularity (default 10 000).
587    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/// A candidate after multi-signal re-ranking, ready for presentation.
603#[derive(Debug, Clone)]
604pub struct SemanticRankedResult {
605    /// The original candidate, cloned for ownership.
606    pub candidate: SearchCandidate,
607    /// Per-signal normalised scores (before applying `user_boost`).
608    pub signal_scores: HashMap<RankSignal, f32>,
609    /// Weighted combination multiplied by `user_boost`.
610    pub final_score: f32,
611    /// 1-based position in the sorted result list.
612    pub rank: usize,
613}
614
615/// Aggregate statistics emitted by `SemanticSearchRanker`.
616#[derive(Debug, Clone)]
617pub struct RankerStats {
618    /// Total number of candidates ranked across all `rank()` calls.
619    pub total_ranked: u64,
620    /// Mean `final_score` across all ranked candidates; 0.0 when nothing has
621    /// been ranked yet.
622    pub avg_final_score: f64,
623    /// Mean number of candidates processed per `rank()` call; 0.0 when no
624    /// call has been made.
625    pub avg_candidates_per_call: f64,
626}
627
628/// Stateful re-ranker that combines similarity, recency, popularity, and a
629/// user-defined boost into a single `final_score`, then accumulates
630/// performance statistics across calls.
631pub struct SemanticSearchRanker {
632    /// Weighting and decay configuration.
633    pub config: SemanticRankerConfig,
634    /// Running total of candidates ranked (for stats).
635    pub total_ranked: u64,
636    /// Alias of `total_ranked` — kept separately so `avg_candidates_per_call`
637    /// can be derived without an extra counter.
638    pub total_candidates: u64,
639    /// Cumulative sum of all `final_score` values (for average computation).
640    pub total_score_sum: f64,
641    /// Number of times `rank()` has been called (denominator for per-call avg).
642    call_count: u64,
643}
644
645impl SemanticSearchRanker {
646    /// Constructs a new ranker with the supplied configuration and zeroed stats.
647    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    /// Re-ranks `candidates` using the configured signals and `now_secs` as
658    /// the reference timestamp for recency computation.
659    ///
660    /// Returns the results sorted by `final_score` descending with 1-based
661    /// `rank` fields assigned.  An empty input yields an empty output.
662    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                // ── recency ─────────────────────────────────────────────────
684                let age_secs = now_secs.saturating_sub(c.created_at_secs);
685                let recency_score = if half_life == 0 {
686                    // Degenerate config: treat as fully fresh.
687                    1.0_f32
688                } else {
689                    0.5_f32.powf(age_secs as f32 / half_life as f32)
690                };
691
692                // ── popularity ───────────────────────────────────────────────
693                let popularity_score = if max_ac == 0 {
694                    // Degenerate config: no normalisation possible.
695                    0.0_f32
696                } else {
697                    c.access_count.min(max_ac) as f32 / max_ac as f32
698                };
699
700                // ── weighted combination ─────────────────────────────────────
701                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                // ── signal map ───────────────────────────────────────────────
706                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, // filled in after sorting
717                }
718            })
719            .collect();
720
721        // ── sort descending by final_score ───────────────────────────────────
722        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        // ── assign 1-based ranks ─────────────────────────────────────────────
729        for (i, result) in results.iter_mut().enumerate() {
730            result.rank = i + 1;
731        }
732
733        // ── accumulate stats ─────────────────────────────────────────────────
734        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    /// Returns a snapshot of the accumulated statistics.
743    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// ────────────────────────────────────────────────────────────────────────────
765// Tests for SemanticSearchRanker
766// ────────────────────────────────────────────────────────────────────────────
767
768#[cfg(test)]
769mod semantic_ranker_tests {
770    use super::*;
771
772    // Helper: build a SearchCandidate with sensible defaults.
773    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    // ── 1. new() starts with zero stats ─────────────────────────────────────
795    #[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    // ── 2. rank empty returns empty ──────────────────────────────────────────
808    #[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    // ── 3. rank single candidate assigns rank 1 ──────────────────────────────
816    #[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    // ── 4. similarity signal stored correctly ────────────────────────────────
826    #[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    // ── 5. recency signal: age=0 → 1.0 ──────────────────────────────────────
837    #[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); // created_at == now → age 0
842        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    // ── 6. recency signal: age=half_life → ~0.5 ─────────────────────────────
849    #[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; // age = half_life
854        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    // ── 7. recency signal: age >> half_life → approaches 0 ──────────────────
863    #[test]
864    fn test_recency_large_age_approaches_zero() {
865        let half_life = 86_400_u64;
866        let now = half_life * 100; // age = 100 * half_life → 0.5^100 ≈ 0
867        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    // ── 8. popularity signal: access_count=0 → 0.0 ──────────────────────────
875    #[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    // ── 9. popularity signal: access_count=max → 1.0 ────────────────────────
885    #[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    // ── 10. popularity capped at max_access_count ────────────────────────────
897    #[test]
898    fn test_popularity_capped_at_max() {
899        let max_ac = 10_000_u64;
900        let mut ranker = default_ranker();
901        // access_count > max → still clamped to 1.0
902        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    // ── 11. user_boost multiplies final_score ────────────────────────────────
910    #[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    // ── 12. user_boost=1.0 has no effect ────────────────────────────────────
935    #[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    // ── 13. higher similarity ranks first ───────────────────────────────────
958    #[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    // ── 14. weights sum to correct combined score ────────────────────────────
978    #[test]
979    fn test_weights_produce_correct_combined_score() {
980        // Use age=0 so recency=1.0, access_count=max_ac so popularity=1.0, user_boost=1.0
981        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        // expected = 0.6*0.5 + 0.2*1.0 + 0.2*1.0 = 0.3 + 0.2 + 0.2 = 0.7
994        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    // ── 15. rank field is 1-based ────────────────────────────────────────────
1005    #[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    // ── 16. rank field is sequential ────────────────────────────────────────
1014    #[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    // ── 17. stats total_ranked accumulates across calls ──────────────────────
1029    #[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    // ── 18. stats avg_final_score computed ──────────────────────────────────
1042    #[test]
1043    fn test_stats_avg_final_score_computed() {
1044        // similarity_weight=1, others=0, user_boost=1 → final_score == similarity
1045        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        // avg = (0.4 + 0.8) / 2 = 0.6
1059        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    // ── 19. stats avg_candidates_per_call ───────────────────────────────────
1068    #[test]
1069    fn test_stats_avg_candidates_per_call() {
1070        let mut ranker = default_ranker();
1071        // call 1: 3 candidates; call 2: 1 candidate → avg = (3+1)/2 = 2.0
1072        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    // ── 20. multiple calls accumulate stats ──────────────────────────────────
1091    #[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    // ── 21. signal_scores HashMap populated with all four keys ───────────────
1115    #[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    // ── 22. empty rank() call does not corrupt stats ─────────────────────────
1138    #[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); // empty — should not crash or corrupt
1143        let stats = ranker.stats();
1144        assert_eq!(stats.total_ranked, 1);
1145    }
1146
1147    // ── 23. final_score clamping edge case: zero weights → zero weighted, user_boost applied ──
1148    #[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        // weighted = 0*0.9 + 0*... = 0; final_score = 0 * 3.0 = 0
1161        assert_eq!(results[0].final_score, 0.0);
1162    }
1163}