Skip to main content

ipfrs_semantic/
semantic_reranker.rs

1//! Semantic Reranker — cross-encoder-style query-document pair scoring.
2//!
3//! This module provides a production-grade reranking engine that combines
4//! multiple feature signals (embedding similarity, keyword overlap, length
5//! penalty, position prior, title boost) using configurable weighted fusion.
6//! After initial retrieval (e.g., via HNSW or DiskANN), `SemanticReranker`
7//! rescores each candidate against the query to yield a refined ranking with
8//! substantially higher precision.
9//!
10//! # Pipeline
11//!
12//! 1. Retrieve an initial candidate set with rough scores.
13//! 2. Call [`SemanticReranker::rerank`] → sorted [`RerankResult`] list.
14//! 3. Optionally slice with [`SemanticReranker::top_k`].
15//! 4. Evaluate ranking quality via [`SemanticReranker::precision_at_k`] /
16//!    [`SemanticReranker::ndcg_at_k`].
17//!
18//! # Example
19//!
20//! ```rust
21//! use ipfrs_semantic::semantic_reranker::{
22//!     SemanticReranker, RerankConfig, RerankCandidate, RerankQuery, RerankFeature,
23//! };
24//! use std::collections::HashMap;
25//!
26//! let config = RerankConfig::default();
27//! let mut reranker = SemanticReranker::new(config);
28//!
29//! let query = RerankQuery {
30//!     text: "rust programming language".to_string(),
31//!     embedding: Some(vec![0.1, 0.9, 0.0]),
32//!     context: vec![],
33//! };
34//!
35//! let candidates = vec![
36//!     RerankCandidate {
37//!         id: "doc1".to_string(),
38//!         initial_score: 0.9,
39//!         content: "Rust is a systems programming language focused on safety and performance."
40//!             .to_string(),
41//!         embedding: Some(vec![0.15, 0.85, 0.05]),
42//!         metadata: HashMap::new(),
43//!     },
44//! ];
45//!
46//! let results = reranker.rerank(&query, &candidates);
47//! assert!(!results.is_empty());
48//! ```
49
50use std::collections::{HashMap, HashSet};
51
52// ─────────────────────────────────────────────────────────────────────────────
53// Core types
54// ─────────────────────────────────────────────────────────────────────────────
55
56/// A single candidate document to be reranked.
57#[derive(Debug, Clone)]
58pub struct RerankCandidate {
59    /// Unique document identifier.
60    pub id: String,
61    /// Score from the initial retrieval stage.
62    pub initial_score: f64,
63    /// Raw text content used for keyword-based features.
64    pub content: String,
65    /// Optional dense embedding vector.
66    pub embedding: Option<Vec<f64>>,
67    /// Arbitrary key-value metadata (e.g., `"title"` → document title).
68    pub metadata: HashMap<String, String>,
69}
70
71/// Query object supplied to the reranker.
72#[derive(Debug, Clone)]
73pub struct RerankQuery {
74    /// Query text used for keyword-based features.
75    pub text: String,
76    /// Optional dense query embedding.
77    pub embedding: Option<Vec<f64>>,
78    /// Additional context sentences that may supplement scoring.
79    pub context: Vec<String>,
80}
81
82/// Reranking feature variants.
83#[derive(Debug, Clone)]
84pub enum RerankFeature {
85    /// Cosine similarity between `query.embedding` and `candidate.embedding`.
86    EmbeddingScore,
87    /// Jaccard similarity between tokenised query and content term sets.
88    KeywordOverlap,
89    /// Penalty for very short (<50 chars) or very long (>5000 chars) content.
90    /// `score = 1.0 - |optimal - len| / optimal`, optimal = 500.
91    LengthPenalty,
92    /// Multiply score by `boost` when the title metadata contains a query term.
93    TitleBoost {
94        /// Multiplicative factor applied when the title matches (e.g., 1.5).
95        boost: f64,
96    },
97    /// Weighted initial score decay based on candidate rank in initial list.
98    /// `score = initial_score * (1 - decay * rank_fraction)`
99    PositionPrior {
100        /// Decay factor controlling how quickly position degrades score.
101        decay: f64,
102    },
103}
104
105impl RerankFeature {
106    /// Human-readable feature name used as key in [`RerankResult::feature_scores`].
107    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/// Configuration for [`SemanticReranker`].
119#[derive(Debug, Clone)]
120pub struct RerankConfig {
121    /// Weighted feature list.  Weights are normalised internally.
122    pub features: Vec<(RerankFeature, f64)>,
123    /// Whether to min-max normalise final scores across the candidate set.
124    pub normalize_scores: bool,
125    /// Candidates scoring below this threshold (after normalisation if enabled)
126    /// are dropped from the output.
127    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/// Result for a single reranked candidate.
146#[derive(Debug, Clone)]
147pub struct RerankResult {
148    /// Candidate identifier.
149    pub candidate_id: String,
150    /// Final combined rerank score.
151    pub rerank_score: f64,
152    /// Score from the initial retrieval stage.
153    pub initial_score: f64,
154    /// Per-feature scores (feature name → score).
155    pub feature_scores: HashMap<String, f64>,
156    /// 1-based rank in the final sorted list.
157    pub rank: usize,
158}
159
160/// Aggregate statistics produced by [`SemanticReranker`].
161#[derive(Debug, Clone)]
162pub struct RerankStats {
163    /// Total number of times [`SemanticReranker::rerank`] has been called.
164    pub total_rerankings: u64,
165    /// Average number of candidates processed per reranking call.
166    pub avg_candidates_per_reranking: f64,
167    /// Mean difference (rerank_score − initial_score) across all results.
168    pub avg_score_improvement: f64,
169}
170
171// ─────────────────────────────────────────────────────────────────────────────
172// SemanticReranker
173// ─────────────────────────────────────────────────────────────────────────────
174
175/// Internal per-call tracking record.
176#[derive(Debug, Default)]
177struct CallRecord {
178    candidate_count: usize,
179    total_improvement: f64,
180    result_count: usize,
181}
182
183/// Cross-encoder-style reranking engine.
184pub struct SemanticReranker {
185    /// Reranking configuration.
186    pub config: RerankConfig,
187    /// Total number of `rerank` invocations.
188    pub total_rerankings: u64,
189    /// Accumulated per-call statistics for aggregate reporting.
190    call_records: Vec<CallRecord>,
191}
192
193impl SemanticReranker {
194    /// Create a new reranker with the supplied configuration.
195    pub fn new(config: RerankConfig) -> Self {
196        Self {
197            config,
198            total_rerankings: 0,
199            call_records: Vec::new(),
200        }
201    }
202
203    /// Rerank `candidates` with respect to `query`.
204    ///
205    /// Steps:
206    /// 1. Compute weighted feature scores for every candidate.
207    /// 2. Optionally normalise scores across the candidate set.
208    /// 3. Filter by `min_rerank_score`.
209    /// 4. Sort descending and assign 1-based ranks.
210    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        // Normalised feature weights (guard against all-zero sum).
223        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        // Score every candidate.
231        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, // assigned later
251                };
252                (result, combined)
253            })
254            .collect();
255
256        // Optional min-max normalisation.
257        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        // Filter by threshold.
274        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        // Sort descending.
285        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        // Assign 1-based ranks.
292        for (i, result) in filtered.iter_mut().enumerate() {
293            result.rank = i + 1;
294        }
295
296        // Track statistics.
297        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    /// Compute per-feature scores for a single candidate.
312    ///
313    /// Returns a map from feature name to score in `[0.0, 1.0]` (approximately).
314    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    /// Compute the score for a single feature.
330    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    /// Cosine similarity between two vectors.  Returns 0.0 on zero vectors or
387    /// dimension mismatch.
388    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    /// Jaccard similarity: |A ∩ B| / |A ∪ B|.
402    /// Returns 0.0 when both sets are empty.
403    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    /// Tokenise text: lowercase, keep only alphanumeric characters, split on
416    /// whitespace/punctuation, deduplicate, and return sorted.
417    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    /// Return the top-`k` results by `rerank_score`.
435    pub fn top_k<'a>(&self, results: &'a [RerankResult], k: usize) -> Vec<&'a RerankResult> {
436        // Results are assumed to already be sorted descending from `rerank`.
437        results.iter().take(k).collect()
438    }
439
440    /// Fraction of the top-`k` results whose `candidate_id` appears in
441    /// `relevant_ids`.  Returns 0.0 when `k == 0`.
442    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    /// Normalised Discounted Cumulative Gain at depth `k` with binary relevance.
460    /// Returns 0.0 when `k == 0` or IDCG == 0.
461    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        // Actual DCG.
468        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()) // rel=1 → rel / log2(pos+1)
474            .sum();
475
476        // Ideal DCG: place all relevant docs first.
477        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    /// Return accumulated statistics.
490    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// ─────────────────────────────────────────────────────────────────────────────
516// Tests
517// ─────────────────────────────────────────────────────────────────────────────
518
519#[cfg(test)]
520mod tests {
521    use std::collections::HashMap;
522
523    use crate::semantic_reranker::{
524        RerankCandidate, RerankConfig, RerankFeature, RerankQuery, SemanticReranker,
525    };
526
527    // ── Helpers ───────────────────────────────────────────────────────────────
528
529    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    // ── cosine_similarity ─────────────────────────────────────────────────────
571
572    #[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    // ── jaccard_similarity ────────────────────────────────────────────────────
626
627    #[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        // |{b}| / |{a,b,c}| = 1/3
648        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    // ── tokenize ──────────────────────────────────────────────────────────────
665
666    #[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        // The key assertion: no punctuation characters in any token.
692        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    // ── rerank – empty candidates ─────────────────────────────────────────────
707
708    #[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    // ── rerank – rank assignment ──────────────────────────────────────────────
718
719    #[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        // First result should have highest score.
747        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        // Default features produce 4 named keys.
768        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    // ── min_rerank_score filter ───────────────────────────────────────────────
774
775    #[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        // d1: overlap with "rust" → 1.0; d2: no overlap → 0.0
785        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        // Only d1 should pass the threshold.
791        assert!(results.iter().all(|r| r.rerank_score >= 0.5));
792    }
793
794    // ── EmbeddingScore feature ────────────────────────────────────────────────
795
796    #[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        // d1 is parallel → cosine=1.0, d2 is orthogonal → cosine=0.0
811        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        // No embedding → score 0.
827        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    // ── LengthPenalty feature ─────────────────────────────────────────────────
835
836    #[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        // Build a string of exactly 500 chars.
846        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        // Very short content (10 chars) → large deviation.
866        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    // ── TitleBoost feature ────────────────────────────────────────────────────
877
878    #[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")]; // no metadata
943        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    // ── PositionPrior feature ─────────────────────────────────────────────────
952
953    #[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        // rank=0, total=5 → rank_fraction=0.0 → score = 0.8 * (1 - 0) = 0.8
964        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        // rank=4, total=5 → rank_fraction=0.8 → score = 1.0 * (1 - 1.0*0.8) = 0.2
985        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    // ── top_k ──────────────────────────────────────────────────────────────────
996
997    #[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    // ── precision_at_k ────────────────────────────────────────────────────────
1031
1032    #[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        // Mark 2 of 4 as relevant.
1076        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    // ── ndcg_at_k ─────────────────────────────────────────────────────────────
1082
1083    #[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        // Perfect: relevant doc at rank 1 → NDCG = 1.0
1100        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        // Two candidates, relevant one placed last.
1123        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        // Relevant doc at rank 2, not rank 1 → NDCG < 1.0
1138        assert!(ndcg < 1.0);
1139    }
1140
1141    // ── stats ─────────────────────────────────────────────────────────────────
1142
1143    #[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        // Average: (1 + 2) / 2 = 1.5
1165        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    // ── normalize_scores ──────────────────────────────────────────────────────
1179
1180    #[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            // After normalisation, max should be 1.0 and min should be 0.0
1203            // unless all scores are identical (in which case no normalisation occurs).
1204            assert!(max <= 1.0 + 1e-9);
1205            assert!(min >= -1e-9);
1206        }
1207    }
1208
1209    // ── default config ────────────────────────────────────────────────────────
1210
1211    #[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    // ── score_candidate keys ──────────────────────────────────────────────────
1225
1226    #[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    // ── single candidate edge case ────────────────────────────────────────────
1249
1250    #[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    // ── keyword overlap with context ──────────────────────────────────────────
1261
1262    #[test]
1263    fn test_keyword_overlap_case_insensitive() {
1264        let a = SemanticReranker::tokenize("Rust LANG");
1265        let b = SemanticReranker::tokenize("rust lang");
1266        // Both should yield the same tokens after lowercasing.
1267        assert_eq!(a, b);
1268    }
1269
1270    // ── multiple feature weights are normalised ───────────────────────────────
1271
1272    #[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        // Score should be finite.
1288        assert!(results[0].rerank_score.is_finite());
1289    }
1290}