Skip to main content

ipfrs_semantic/
cross_encoder.rs

1//! Cross-encoder reranking for semantic search results.
2//!
3//! This module provides pairwise query-document scoring for reranking
4//! candidate documents retrieved from an initial retrieval stage. Unlike
5//! bi-encoder models that score query and document independently, cross-encoders
6//! jointly score the (query, document) pair for higher precision.
7//!
8//! # Architecture
9//!
10//! The pipeline is:
11//! 1. Initial retrieval (e.g., HNSW ANN search) yields `CandidateDoc` list with `initial_score`.
12//! 2. `CrossEncoder::rerank` scores each (query, doc) pair via the configured `ScoringModel`.
13//! 3. Results are sorted by `cross_encoder_score` and optionally min-max normalized.
14//! 4. `RerankedDoc` carries the original rank metadata and `score_delta` for analysis.
15//!
16//! # Scoring Models
17//!
18//! - [`ScoringModel::DotProduct`] — raw inner product, fast, unnormalized.
19//! - [`ScoringModel::Cosine`] — cosine similarity in `[-1, 1]`, direction-sensitive.
20//! - [`ScoringModel::BilinearForm`] — diagonal bilinear `Σ w_i q_i d_i`, learned weights.
21//! - [`ScoringModel::Linear`] — `dot(weights, q ⊙ d) + bias`, affine combination.
22//!
23//! # Example
24//!
25//! ```rust
26//! use ipfrs_semantic::cross_encoder::{
27//!     CrossEncoder, CrossEncoderConfig, ScoringModel, CandidateDoc,
28//! };
29//! use std::collections::HashMap;
30//!
31//! let config = CrossEncoderConfig {
32//!     model: ScoringModel::Cosine,
33//!     max_doc_length: 512,
34//!     batch_size: 32,
35//!     normalize_scores: true,
36//! };
37//! let mut encoder = CrossEncoder::new(config);
38//!
39//! let query = vec![1.0, 0.0, 0.0];
40//! let candidates = vec![
41//!     CandidateDoc {
42//!         doc_id: "doc_a".to_string(),
43//!         embedding: vec![0.9, 0.1, 0.0],
44//!         initial_score: 0.8,
45//!         metadata: HashMap::new(),
46//!     },
47//!     CandidateDoc {
48//!         doc_id: "doc_b".to_string(),
49//!         embedding: vec![0.0, 1.0, 0.0],
50//!         initial_score: 0.9,
51//!         metadata: HashMap::new(),
52//!     },
53//! ];
54//!
55//! let reranked = encoder.rerank(&query, candidates);
56//! // doc_a should now rank #1 because it aligns better with query [1,0,0]
57//! assert_eq!(reranked[0].doc_id, "doc_a");
58//! ```
59
60use std::collections::HashMap;
61
62// ────────────────────────────────────────────────────────────────────────────
63// Public types
64// ────────────────────────────────────────────────────────────────────────────
65
66/// Relevance scoring model used by the [`CrossEncoder`].
67#[derive(Debug, Clone)]
68pub enum ScoringModel {
69    /// Raw dot product: `Σ q_i · d_i`.
70    DotProduct,
71    /// Cosine similarity: `dot(q, d) / (|q| · |d|)`.
72    Cosine,
73    /// Diagonal bilinear form: `Σ w_i · q_i · d_i`.
74    ///
75    /// `weights` is a diagonal weight vector of length `dim`.
76    /// Weights are reused cyclically when the embedding dimension exceeds
77    /// the weight length, so a length-1 weight vector acts as a global scalar.
78    BilinearForm(Vec<f64>),
79    /// Affine linear model: `dot(weights, q ⊙ d) + bias`.
80    ///
81    /// `q ⊙ d` is the element-wise product of query and document embeddings.
82    /// `weights` length should match the embedding dimension; shorter weights
83    /// are extended with zeros, longer weights are truncated.
84    Linear { weights: Vec<f64>, bias: f64 },
85}
86
87/// Configuration for the [`CrossEncoder`].
88#[derive(Debug, Clone)]
89pub struct CrossEncoderConfig {
90    /// Scoring model to use for pairwise relevance estimation.
91    pub model: ScoringModel,
92    /// Maximum document embedding length to consider (truncates longer ones).
93    pub max_doc_length: usize,
94    /// Number of (query, doc) pairs to score in a single batch during
95    /// [`CrossEncoder::rerank_batch`].
96    pub batch_size: usize,
97    /// Whether to apply min-max normalization to `cross_encoder_score` values
98    /// so they fall in `[0, 1]`.
99    pub normalize_scores: bool,
100}
101
102impl Default for CrossEncoderConfig {
103    fn default() -> Self {
104        Self {
105            model: ScoringModel::Cosine,
106            max_doc_length: 512,
107            batch_size: 64,
108            normalize_scores: true,
109        }
110    }
111}
112
113/// A candidate document produced by an upstream retrieval system.
114#[derive(Debug, Clone)]
115pub struct CandidateDoc {
116    /// Unique document identifier.
117    pub doc_id: String,
118    /// Dense embedding vector.
119    pub embedding: Vec<f64>,
120    /// Retrieval score assigned by the first-stage retriever.
121    pub initial_score: f64,
122    /// Arbitrary key-value metadata (e.g., title, source, date).
123    pub metadata: HashMap<String, String>,
124}
125
126/// A reranked document with updated score and rank metadata.
127#[derive(Debug, Clone)]
128pub struct RerankedDoc {
129    /// Document identifier (mirrors [`CandidateDoc::doc_id`]).
130    pub doc_id: String,
131    /// Score assigned by the cross-encoder.
132    pub cross_encoder_score: f64,
133    /// Original first-stage retrieval score.
134    pub initial_score: f64,
135    /// Final rank after reranking (1-indexed, best = 1).
136    pub final_rank: usize,
137    /// `cross_encoder_score - initial_score`; positive means reranking improved
138    /// the document's perceived relevance.
139    pub score_delta: f64,
140}
141
142/// Aggregate statistics collected across all reranking calls.
143#[derive(Debug, Clone, Default)]
144pub struct CrossEncoderStats {
145    /// Total number of `rerank` or `rerank_batch` calls.
146    pub total_reranks: u64,
147    /// Total number of individual documents scored.
148    pub total_docs_reranked: u64,
149    /// Running average absolute rank change per reranking call.
150    pub avg_rank_change: f64,
151}
152
153// ────────────────────────────────────────────────────────────────────────────
154// CrossEncoder
155// ────────────────────────────────────────────────────────────────────────────
156
157/// Cross-encoder that jointly scores (query, document) pairs for reranking.
158///
159/// Construct with [`CrossEncoder::new`] and call [`CrossEncoder::rerank`] to
160/// reorder a `Vec<CandidateDoc>` by cross-encoder relevance scores.
161pub struct CrossEncoder {
162    config: CrossEncoderConfig,
163    stats: CrossEncoderStats,
164}
165
166impl CrossEncoder {
167    // ── Construction ────────────────────────────────────────────────────────
168
169    /// Create a new cross-encoder with the given configuration.
170    pub fn new(config: CrossEncoderConfig) -> Self {
171        Self {
172            config,
173            stats: CrossEncoderStats::default(),
174        }
175    }
176
177    // ── Scoring ─────────────────────────────────────────────────────────────
178
179    /// Compute the relevance score for a single (query, document) pair.
180    ///
181    /// The embedding lengths are independently capped at
182    /// [`CrossEncoderConfig::max_doc_length`] before scoring.
183    pub fn score_pair(&self, query: &[f64], doc: &[f64]) -> f64 {
184        let max_len = self.config.max_doc_length;
185        let q = if query.len() > max_len {
186            &query[..max_len]
187        } else {
188            query
189        };
190        let d = if doc.len() > max_len {
191            &doc[..max_len]
192        } else {
193            doc
194        };
195
196        match &self.config.model {
197            ScoringModel::DotProduct => Self::dot_product(q, d),
198            ScoringModel::Cosine => Self::cosine_similarity(q, d),
199            ScoringModel::BilinearForm(weights) => Self::bilinear_score(q, d, weights),
200            ScoringModel::Linear { weights, bias } => Self::linear_score(q, d, weights, *bias),
201        }
202    }
203
204    // ── Reranking ────────────────────────────────────────────────────────────
205
206    /// Rerank a list of candidate documents for the given query embedding.
207    ///
208    /// Returns documents sorted in descending order of `cross_encoder_score`.
209    /// Stats are updated after each call.
210    pub fn rerank(&mut self, query: &[f64], candidates: Vec<CandidateDoc>) -> Vec<RerankedDoc> {
211        if candidates.is_empty() {
212            self.stats.total_reranks += 1;
213            return Vec::new();
214        }
215
216        // Capture original order (by doc_id) so we can count rank changes.
217        let initial_order: Vec<String> = candidates.iter().map(|c| c.doc_id.clone()).collect();
218
219        // Score every candidate.
220        let mut scored: Vec<(f64, CandidateDoc)> = candidates
221            .into_iter()
222            .map(|c| {
223                let score = self.score_pair(query, &c.embedding);
224                (score, c)
225            })
226            .collect();
227
228        // Sort descending by cross-encoder score, stable for ties.
229        scored.sort_by(|(a, _), (b, _)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
230
231        // Build RerankedDoc list.
232        let mut reranked: Vec<RerankedDoc> = scored
233            .into_iter()
234            .enumerate()
235            .map(|(rank_idx, (ce_score, candidate))| RerankedDoc {
236                doc_id: candidate.doc_id,
237                cross_encoder_score: ce_score,
238                initial_score: candidate.initial_score,
239                final_rank: rank_idx + 1,
240                score_delta: ce_score - candidate.initial_score,
241            })
242            .collect();
243
244        // Optionally normalize scores in-place.
245        if self.config.normalize_scores {
246            Self::normalize_scores(&mut reranked);
247        }
248
249        // Update statistics.
250        let initial_order_refs: Vec<&str> = initial_order.iter().map(String::as_str).collect();
251        let rank_changes = Self::rank_changed(&initial_order_refs, &reranked);
252        let doc_count = reranked.len() as u64;
253
254        self.stats.total_reranks += 1;
255        self.stats.total_docs_reranked += doc_count;
256
257        // Incremental average: avg = avg + (new - avg) / n
258        let n = self.stats.total_reranks as f64;
259        let change_rate = rank_changes as f64 / doc_count.max(1) as f64;
260        self.stats.avg_rank_change += (change_rate - self.stats.avg_rank_change) / n;
261
262        reranked
263    }
264
265    /// Rerank multiple (query, candidates) pairs in a single call.
266    ///
267    /// Each element of the input slice is processed independently. The method
268    /// is equivalent to calling [`rerank`](CrossEncoder::rerank) for each pair
269    /// sequentially.
270    pub fn rerank_batch(
271        &mut self,
272        queries_and_candidates: Vec<(Vec<f64>, Vec<CandidateDoc>)>,
273    ) -> Vec<Vec<RerankedDoc>> {
274        queries_and_candidates
275            .into_iter()
276            .map(|(query, candidates)| self.rerank(&query, candidates))
277            .collect()
278    }
279
280    // ── Primitive scoring functions (static) ────────────────────────────────
281
282    /// Raw dot product: `Σ q_i · d_i`.
283    ///
284    /// Stops at the shorter slice length.
285    pub fn dot_product(a: &[f64], b: &[f64]) -> f64 {
286        a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
287    }
288
289    /// Cosine similarity: `dot(a, b) / (|a| · |b|)`.
290    ///
291    /// Returns `0.0` when either vector has near-zero norm to avoid division
292    /// by zero; the epsilon used is `1e-10`.
293    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
294        const EPSILON: f64 = 1e-10;
295        let dot = Self::dot_product(a, b);
296        let norm_a = a.iter().map(|x| x * x).sum::<f64>().sqrt();
297        let norm_b = b.iter().map(|x| x * x).sum::<f64>().sqrt();
298        if norm_a < EPSILON || norm_b < EPSILON {
299            0.0
300        } else {
301            dot / (norm_a * norm_b)
302        }
303    }
304
305    /// Diagonal bilinear form: `Σ w_i · q_i · d_i`.
306    ///
307    /// Weights are cycled when the embedding dimension exceeds `weights.len()`.
308    /// A zero-length weights slice returns `0.0`.
309    pub fn bilinear_score(query: &[f64], doc: &[f64], weights: &[f64]) -> f64 {
310        if weights.is_empty() {
311            return 0.0;
312        }
313        let wlen = weights.len();
314        query
315            .iter()
316            .zip(doc.iter())
317            .enumerate()
318            .map(|(i, (q, d))| weights[i % wlen] * q * d)
319            .sum()
320    }
321
322    /// Affine linear model: `dot(weights, q ⊙ d) + bias`.
323    ///
324    /// `q ⊙ d` is the element-wise product, capped at the minimum length of
325    /// `query` and `doc`. Weights beyond the element-wise product length are
326    /// ignored; product components without a corresponding weight are treated
327    /// as having weight `0.0`.
328    pub fn linear_score(query: &[f64], doc: &[f64], weights: &[f64], bias: f64) -> f64 {
329        let dot: f64 = query
330            .iter()
331            .zip(doc.iter())
332            .enumerate()
333            .map(|(i, (q, d))| {
334                let w = weights.get(i).copied().unwrap_or(0.0);
335                w * q * d
336            })
337            .sum();
338        dot + bias
339    }
340
341    // ── Post-processing helpers ──────────────────────────────────────────────
342
343    /// Min-max normalize `cross_encoder_score` values to `[0, 1]` in-place.
344    ///
345    /// When all scores are identical the method maps every score to `1.0` to
346    /// avoid a degenerate zero-range normalization.
347    pub fn normalize_scores(docs: &mut [RerankedDoc]) {
348        if docs.is_empty() {
349            return;
350        }
351
352        let min = docs
353            .iter()
354            .map(|d| d.cross_encoder_score)
355            .fold(f64::INFINITY, f64::min);
356        let max = docs
357            .iter()
358            .map(|d| d.cross_encoder_score)
359            .fold(f64::NEG_INFINITY, f64::max);
360
361        let range = max - min;
362        if range < f64::EPSILON {
363            // All scores are equal; map everything to 1.0 (perfectly relevant).
364            for d in docs.iter_mut() {
365                d.cross_encoder_score = 1.0;
366            }
367            return;
368        }
369
370        for d in docs.iter_mut() {
371            d.cross_encoder_score = (d.cross_encoder_score - min) / range;
372        }
373    }
374
375    /// Count the number of documents whose position changed after reranking.
376    ///
377    /// `initial_order` holds document IDs in the order returned by the first-
378    /// stage retriever; `reranked` is the cross-encoder output. A document is
379    /// counted as "changed" when its 0-indexed position in `reranked` differs
380    /// from its position in `initial_order`. Documents present in only one list
381    /// are not counted.
382    pub fn rank_changed(initial_order: &[&str], reranked: &[RerankedDoc]) -> usize {
383        // Build position map for initial order.
384        let initial_positions: HashMap<&str, usize> = initial_order
385            .iter()
386            .enumerate()
387            .map(|(i, id)| (*id, i))
388            .collect();
389
390        reranked
391            .iter()
392            .enumerate()
393            .filter(|(new_pos, doc)| {
394                initial_positions
395                    .get(doc.doc_id.as_str())
396                    .map(|old_pos| *old_pos != *new_pos)
397                    .unwrap_or(false)
398            })
399            .count()
400    }
401
402    // ── Introspection ────────────────────────────────────────────────────────
403
404    /// Return a reference to the accumulated runtime statistics.
405    pub fn stats(&self) -> &CrossEncoderStats {
406        &self.stats
407    }
408}
409
410// ────────────────────────────────────────────────────────────────────────────
411// Tests
412// ────────────────────────────────────────────────────────────────────────────
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use std::collections::HashMap;
418
419    // ── Helpers ──────────────────────────────────────────────────────────────
420
421    fn make_candidate(id: &str, embedding: Vec<f64>, initial_score: f64) -> CandidateDoc {
422        CandidateDoc {
423            doc_id: id.to_string(),
424            embedding,
425            initial_score,
426            metadata: HashMap::new(),
427        }
428    }
429
430    fn encoder_with(model: ScoringModel, normalize: bool) -> CrossEncoder {
431        CrossEncoder::new(CrossEncoderConfig {
432            model,
433            max_doc_length: 512,
434            batch_size: 32,
435            normalize_scores: normalize,
436        })
437    }
438
439    // ── 1. DotProduct scoring ─────────────────────────────────────────────
440
441    #[test]
442    fn test_dot_product_basic() {
443        let a = vec![1.0, 2.0, 3.0];
444        let b = vec![4.0, 5.0, 6.0];
445        let score = CrossEncoder::dot_product(&a, &b);
446        assert!((score - 32.0).abs() < 1e-9, "expected 32, got {score}");
447    }
448
449    #[test]
450    fn test_dot_product_model_score_pair() {
451        let enc = encoder_with(ScoringModel::DotProduct, false);
452        let query = vec![1.0, 0.0];
453        let doc = vec![0.5, 0.5];
454        // score_pair should forward to dot_product
455        let s = enc.score_pair(&query, &doc);
456        assert!((s - 0.5).abs() < 1e-9);
457        // also exercise mutability guard (no mutation happens here)
458        let _ = enc.stats();
459    }
460
461    #[test]
462    fn test_dot_product_mismatched_lengths() {
463        // zip stops at shorter
464        let a = vec![1.0, 2.0, 3.0];
465        let b = vec![1.0, 1.0];
466        let score = CrossEncoder::dot_product(&a, &b);
467        assert!((score - 3.0).abs() < 1e-9);
468    }
469
470    // ── 2. Cosine scoring ─────────────────────────────────────────────────
471
472    #[test]
473    fn test_cosine_identical_vectors() {
474        let v = vec![1.0, 2.0, 3.0];
475        let score = CrossEncoder::cosine_similarity(&v, &v);
476        assert!((score - 1.0).abs() < 1e-9, "identical vectors => cos=1");
477    }
478
479    #[test]
480    fn test_cosine_orthogonal_vectors() {
481        let a = vec![1.0, 0.0];
482        let b = vec![0.0, 1.0];
483        let score = CrossEncoder::cosine_similarity(&a, &b);
484        assert!(score.abs() < 1e-9, "orthogonal vectors => cos=0");
485    }
486
487    #[test]
488    fn test_cosine_opposite_vectors() {
489        let a = vec![1.0, 0.0];
490        let b = vec![-1.0, 0.0];
491        let score = CrossEncoder::cosine_similarity(&a, &b);
492        assert!((score - (-1.0)).abs() < 1e-9, "opposite vectors => cos=-1");
493    }
494
495    #[test]
496    fn test_cosine_zero_vector_query() {
497        let zero = vec![0.0, 0.0, 0.0];
498        let doc = vec![1.0, 2.0, 3.0];
499        let score = CrossEncoder::cosine_similarity(&zero, &doc);
500        assert_eq!(score, 0.0, "zero query vector => 0");
501    }
502
503    #[test]
504    fn test_cosine_zero_vector_doc() {
505        let query = vec![1.0, 2.0, 3.0];
506        let zero = vec![0.0, 0.0, 0.0];
507        let score = CrossEncoder::cosine_similarity(&query, &zero);
508        assert_eq!(score, 0.0, "zero doc vector => 0");
509    }
510
511    #[test]
512    fn test_cosine_both_zero_vectors() {
513        let zero = vec![0.0, 0.0];
514        let score = CrossEncoder::cosine_similarity(&zero, &zero);
515        assert_eq!(score, 0.0, "both zero => 0");
516    }
517
518    #[test]
519    fn test_cosine_model_score_pair() {
520        let enc = encoder_with(ScoringModel::Cosine, false);
521        let a = vec![1.0, 1.0];
522        let b = vec![1.0, 1.0];
523        let s = enc.score_pair(&a, &b);
524        assert!((s - 1.0).abs() < 1e-9);
525    }
526
527    // ── 3. BilinearForm scoring ───────────────────────────────────────────
528
529    #[test]
530    fn test_bilinear_basic() {
531        let q = vec![1.0, 2.0, 3.0];
532        let d = vec![4.0, 5.0, 6.0];
533        let w = vec![1.0, 2.0, 3.0];
534        // 1*1*4 + 2*2*5 + 3*3*6 = 4 + 20 + 54 = 78
535        let score = CrossEncoder::bilinear_score(&q, &d, &w);
536        assert!((score - 78.0).abs() < 1e-9, "expected 78, got {score}");
537    }
538
539    #[test]
540    fn test_bilinear_cyclic_weights() {
541        let q = vec![1.0, 2.0, 3.0, 4.0];
542        let d = vec![1.0, 1.0, 1.0, 1.0];
543        let w = vec![2.0]; // single weight cycles over all dims
544                           // 2*(1+2+3+4) = 20
545        let score = CrossEncoder::bilinear_score(&q, &d, &w);
546        assert!((score - 20.0).abs() < 1e-9, "expected 20, got {score}");
547    }
548
549    #[test]
550    fn test_bilinear_empty_weights() {
551        let q = vec![1.0, 2.0];
552        let d = vec![3.0, 4.0];
553        let score = CrossEncoder::bilinear_score(&q, &d, &[]);
554        assert_eq!(score, 0.0);
555    }
556
557    #[test]
558    fn test_bilinear_model_score_pair() {
559        let w = vec![1.0, 0.0]; // only first dimension contributes
560        let enc = encoder_with(ScoringModel::BilinearForm(w), false);
561        let q = vec![1.0, 999.0];
562        let d = vec![0.5, 999.0];
563        let s = enc.score_pair(&q, &d);
564        assert!((s - 0.5).abs() < 1e-9, "only dim0 => 0.5");
565    }
566
567    // ── 4. Linear scoring ─────────────────────────────────────────────────
568
569    #[test]
570    fn test_linear_basic() {
571        let q = vec![1.0, 2.0];
572        let d = vec![3.0, 4.0];
573        let w = vec![1.0, 1.0];
574        let bias = 0.5;
575        // dot([1,1], [3,8]) + 0.5 = (1*3 + 1*8) + 0.5 = 11.5
576        let score = CrossEncoder::linear_score(&q, &d, &w, bias);
577        assert!((score - 11.5).abs() < 1e-9, "expected 11.5, got {score}");
578    }
579
580    #[test]
581    fn test_linear_bias_only() {
582        let q = vec![1.0];
583        let d = vec![1.0];
584        let w = vec![0.0]; // zero weight => only bias contributes
585        let score = CrossEncoder::linear_score(&q, &d, &w, 42.0);
586        assert!((score - 42.0).abs() < 1e-9);
587    }
588
589    #[test]
590    fn test_linear_shorter_weights() {
591        let q = vec![1.0, 2.0, 3.0];
592        let d = vec![1.0, 1.0, 1.0];
593        let w = vec![2.0]; // only first element has weight
594                           // 2*1*1 + 0*2*1 + 0*3*1 = 2
595        let score = CrossEncoder::linear_score(&q, &d, &w, 0.0);
596        assert!((score - 2.0).abs() < 1e-9);
597    }
598
599    #[test]
600    fn test_linear_model_score_pair() {
601        let enc = encoder_with(
602            ScoringModel::Linear {
603                weights: vec![1.0, 1.0],
604                bias: 1.0,
605            },
606            false,
607        );
608        let q = vec![2.0, 3.0];
609        let d = vec![4.0, 5.0];
610        // dot([1,1],[8,15]) + 1 = 23 + 1 = 24
611        let s = enc.score_pair(&q, &d);
612        assert!((s - 24.0).abs() < 1e-9);
613    }
614
615    // ── 5. rerank changes order ───────────────────────────────────────────
616
617    #[test]
618    fn test_rerank_changes_order() {
619        let mut enc = encoder_with(ScoringModel::Cosine, false);
620        let query = vec![1.0, 0.0];
621        // doc_b has higher initial_score but lower cosine with query
622        let candidates = vec![
623            make_candidate("doc_a", vec![0.99, 0.01], 0.5),
624            make_candidate("doc_b", vec![0.0, 1.0], 0.9),
625        ];
626        let reranked = enc.rerank(&query, candidates);
627        assert_eq!(
628            reranked[0].doc_id, "doc_a",
629            "doc_a aligns better with query"
630        );
631        assert_eq!(reranked[1].doc_id, "doc_b");
632    }
633
634    #[test]
635    fn test_rerank_preserves_initial_score() {
636        let mut enc = encoder_with(ScoringModel::DotProduct, false);
637        let query = vec![1.0, 0.0];
638        let candidates = vec![make_candidate("doc_x", vec![0.5, 0.5], 0.77)];
639        let reranked = enc.rerank(&query, candidates);
640        assert!((reranked[0].initial_score - 0.77).abs() < 1e-9);
641    }
642
643    #[test]
644    fn test_rerank_final_rank_numbering() {
645        let mut enc = encoder_with(ScoringModel::Cosine, false);
646        let query = vec![1.0, 0.0, 0.0];
647        let candidates = vec![
648            make_candidate("a", vec![0.1, 0.9, 0.0], 0.3),
649            make_candidate("b", vec![0.9, 0.1, 0.0], 0.7),
650            make_candidate("c", vec![0.5, 0.5, 0.0], 0.5),
651        ];
652        let reranked = enc.rerank(&query, candidates);
653        for (i, doc) in reranked.iter().enumerate() {
654            assert_eq!(doc.final_rank, i + 1, "final_rank must be 1-indexed");
655        }
656    }
657
658    #[test]
659    fn test_rerank_empty_candidates() {
660        let mut enc = encoder_with(ScoringModel::Cosine, false);
661        let result = enc.rerank(&[1.0], vec![]);
662        assert!(result.is_empty());
663        assert_eq!(enc.stats().total_reranks, 1);
664    }
665
666    // ── 6. score_delta ────────────────────────────────────────────────────
667
668    #[test]
669    fn test_score_delta_equals_cross_minus_initial() {
670        let mut enc = encoder_with(ScoringModel::DotProduct, false);
671        let query = vec![1.0, 0.0];
672        let candidates = vec![make_candidate("d1", vec![0.3, 0.7], 0.2)];
673        let reranked = enc.rerank(&query, candidates);
674        let doc = &reranked[0];
675        let expected_delta = doc.cross_encoder_score - doc.initial_score;
676        assert!(
677            (doc.score_delta - expected_delta).abs() < 1e-9,
678            "score_delta must equal cross_encoder_score - initial_score"
679        );
680    }
681
682    // ── 7. normalize_scores ───────────────────────────────────────────────
683
684    #[test]
685    fn test_normalize_scores_range() {
686        let mut docs = vec![
687            RerankedDoc {
688                doc_id: "a".into(),
689                cross_encoder_score: 2.0,
690                initial_score: 0.5,
691                final_rank: 1,
692                score_delta: 0.0,
693            },
694            RerankedDoc {
695                doc_id: "b".into(),
696                cross_encoder_score: 8.0,
697                initial_score: 0.5,
698                final_rank: 2,
699                score_delta: 0.0,
700            },
701            RerankedDoc {
702                doc_id: "c".into(),
703                cross_encoder_score: 5.0,
704                initial_score: 0.5,
705                final_rank: 3,
706                score_delta: 0.0,
707            },
708        ];
709        CrossEncoder::normalize_scores(&mut docs);
710        let scores: Vec<f64> = docs.iter().map(|d| d.cross_encoder_score).collect();
711        assert!(
712            scores.iter().all(|&s| (0.0..=1.0).contains(&s)),
713            "all in [0,1]"
714        );
715        let min = scores.iter().cloned().fold(f64::INFINITY, f64::min);
716        let max = scores.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
717        assert!(min.abs() < 1e-9, "min should be 0");
718        assert!((max - 1.0).abs() < 1e-9, "max should be 1");
719    }
720
721    #[test]
722    fn test_normalize_scores_all_equal() {
723        let mut docs: Vec<RerankedDoc> = (0..3)
724            .map(|i| RerankedDoc {
725                doc_id: i.to_string(),
726                cross_encoder_score: 0.5,
727                initial_score: 0.5,
728                final_rank: i + 1,
729                score_delta: 0.0,
730            })
731            .collect();
732        CrossEncoder::normalize_scores(&mut docs);
733        for d in &docs {
734            assert!(
735                (d.cross_encoder_score - 1.0).abs() < 1e-9,
736                "equal scores => 1.0"
737            );
738        }
739    }
740
741    #[test]
742    fn test_normalize_scores_single_doc() {
743        let mut docs = vec![RerankedDoc {
744            doc_id: "solo".into(),
745            cross_encoder_score: 0.42,
746            initial_score: 0.1,
747            final_rank: 1,
748            score_delta: 0.32,
749        }];
750        CrossEncoder::normalize_scores(&mut docs);
751        // Single doc: min == max => all-equal path => 1.0
752        assert!((docs[0].cross_encoder_score - 1.0).abs() < 1e-9);
753    }
754
755    #[test]
756    fn test_normalize_scores_empty_slice() {
757        let mut docs: Vec<RerankedDoc> = vec![];
758        // Should not panic
759        CrossEncoder::normalize_scores(&mut docs);
760    }
761
762    // ── 8. rank_changed counting ──────────────────────────────────────────
763
764    #[test]
765    fn test_rank_changed_no_change() {
766        let initial = vec!["a", "b", "c"];
767        let reranked = vec![
768            RerankedDoc {
769                doc_id: "a".into(),
770                cross_encoder_score: 0.9,
771                initial_score: 0.9,
772                final_rank: 1,
773                score_delta: 0.0,
774            },
775            RerankedDoc {
776                doc_id: "b".into(),
777                cross_encoder_score: 0.7,
778                initial_score: 0.7,
779                final_rank: 2,
780                score_delta: 0.0,
781            },
782            RerankedDoc {
783                doc_id: "c".into(),
784                cross_encoder_score: 0.5,
785                initial_score: 0.5,
786                final_rank: 3,
787                score_delta: 0.0,
788            },
789        ];
790        let changed = CrossEncoder::rank_changed(&initial, &reranked);
791        assert_eq!(changed, 0, "order unchanged => 0 rank changes");
792    }
793
794    #[test]
795    fn test_rank_changed_full_reversal() {
796        let initial = vec!["a", "b", "c"];
797        let reranked = vec![
798            RerankedDoc {
799                doc_id: "c".into(),
800                cross_encoder_score: 0.9,
801                initial_score: 0.5,
802                final_rank: 1,
803                score_delta: 0.4,
804            },
805            RerankedDoc {
806                doc_id: "b".into(),
807                cross_encoder_score: 0.7,
808                initial_score: 0.7,
809                final_rank: 2,
810                score_delta: 0.0,
811            },
812            RerankedDoc {
813                doc_id: "a".into(),
814                cross_encoder_score: 0.3,
815                initial_score: 0.9,
816                final_rank: 3,
817                score_delta: -0.6,
818            },
819        ];
820        // 'a' moved 0->2, 'b' stayed, 'c' moved 2->0 => 2 changes
821        let changed = CrossEncoder::rank_changed(&initial, &reranked);
822        assert_eq!(changed, 2);
823    }
824
825    #[test]
826    fn test_rank_changed_partial() {
827        let initial = vec!["a", "b", "c", "d"];
828        let reranked = vec![
829            RerankedDoc {
830                doc_id: "a".into(),
831                cross_encoder_score: 0.9,
832                initial_score: 0.8,
833                final_rank: 1,
834                score_delta: 0.1,
835            },
836            RerankedDoc {
837                doc_id: "c".into(),
838                cross_encoder_score: 0.7,
839                initial_score: 0.6,
840                final_rank: 2,
841                score_delta: 0.1,
842            },
843            RerankedDoc {
844                doc_id: "b".into(),
845                cross_encoder_score: 0.5,
846                initial_score: 0.7,
847                final_rank: 3,
848                score_delta: -0.2,
849            },
850            RerankedDoc {
851                doc_id: "d".into(),
852                cross_encoder_score: 0.3,
853                initial_score: 0.3,
854                final_rank: 4,
855                score_delta: 0.0,
856            },
857        ];
858        // 'a' stayed(0->0), 'b' moved(1->2), 'c' moved(2->1), 'd' stayed(3->3) => 2
859        let changed = CrossEncoder::rank_changed(&initial, &reranked);
860        assert_eq!(changed, 2);
861    }
862
863    // ── 9. batch reranking ────────────────────────────────────────────────
864
865    #[test]
866    fn test_rerank_batch_two_queries() {
867        let mut enc = encoder_with(ScoringModel::Cosine, false);
868        let q1 = vec![1.0, 0.0];
869        let q2 = vec![0.0, 1.0];
870        let c1 = vec![
871            make_candidate("a", vec![0.9, 0.1], 0.5),
872            make_candidate("b", vec![0.1, 0.9], 0.6),
873        ];
874        let c2 = vec![
875            make_candidate("c", vec![0.1, 0.9], 0.5),
876            make_candidate("d", vec![0.9, 0.1], 0.6),
877        ];
878        let results = enc.rerank_batch(vec![(q1, c1), (q2, c2)]);
879        assert_eq!(results.len(), 2);
880        assert_eq!(results[0][0].doc_id, "a", "q1 should prefer a");
881        assert_eq!(results[1][0].doc_id, "c", "q2 should prefer c");
882    }
883
884    #[test]
885    fn test_rerank_batch_empty_input() {
886        let mut enc = encoder_with(ScoringModel::DotProduct, false);
887        let results = enc.rerank_batch(vec![]);
888        assert!(results.is_empty());
889    }
890
891    #[test]
892    fn test_rerank_batch_stats_accumulate() {
893        let mut enc = encoder_with(ScoringModel::DotProduct, false);
894        let inputs: Vec<(Vec<f64>, Vec<CandidateDoc>)> = (0..5)
895            .map(|i| {
896                let q = vec![1.0, 0.0];
897                let docs = vec![make_candidate(&format!("d{i}"), vec![0.5, 0.5], 0.5)];
898                (q, docs)
899            })
900            .collect();
901        enc.rerank_batch(inputs);
902        assert_eq!(enc.stats().total_reranks, 5);
903        assert_eq!(enc.stats().total_docs_reranked, 5);
904    }
905
906    // ── 10. stats tracking ────────────────────────────────────────────────
907
908    #[test]
909    fn test_stats_initial_state() {
910        let enc = encoder_with(ScoringModel::Cosine, false);
911        let s = enc.stats();
912        assert_eq!(s.total_reranks, 0);
913        assert_eq!(s.total_docs_reranked, 0);
914        assert_eq!(s.avg_rank_change, 0.0);
915    }
916
917    #[test]
918    fn test_stats_after_single_rerank() {
919        let mut enc = encoder_with(ScoringModel::Cosine, false);
920        let query = vec![1.0, 0.0];
921        let candidates = vec![
922            make_candidate("x", vec![0.9, 0.1], 0.3),
923            make_candidate("y", vec![0.1, 0.9], 0.8),
924        ];
925        enc.rerank(&query, candidates);
926        assert_eq!(enc.stats().total_reranks, 1);
927        assert_eq!(enc.stats().total_docs_reranked, 2);
928    }
929
930    #[test]
931    fn test_stats_multiple_reranks() {
932        let mut enc = encoder_with(ScoringModel::DotProduct, false);
933        for i in 0..4u64 {
934            let q = vec![1.0];
935            let docs = vec![
936                make_candidate(&format!("d{i}a"), vec![0.5], 0.5),
937                make_candidate(&format!("d{i}b"), vec![0.3], 0.3),
938            ];
939            enc.rerank(&q, docs);
940        }
941        assert_eq!(enc.stats().total_reranks, 4);
942        assert_eq!(enc.stats().total_docs_reranked, 8);
943    }
944
945    // ── 11. identical embeddings ──────────────────────────────────────────
946
947    #[test]
948    fn test_identical_embeddings_cosine() {
949        let enc = encoder_with(ScoringModel::Cosine, false);
950        let v = vec![0.3, 0.4, 0.5];
951        let s = enc.score_pair(&v, &v);
952        assert!((s - 1.0).abs() < 1e-9);
953    }
954
955    #[test]
956    fn test_identical_embeddings_dot() {
957        let enc = encoder_with(ScoringModel::DotProduct, false);
958        let v = vec![1.0, 2.0];
959        let s = enc.score_pair(&v, &v);
960        assert!((s - 5.0).abs() < 1e-9, "1+4=5");
961    }
962
963    // ── 12. 1-D edge case ─────────────────────────────────────────────────
964
965    #[test]
966    fn test_1d_cosine() {
967        let a = vec![3.0];
968        let b = vec![5.0];
969        let s = CrossEncoder::cosine_similarity(&a, &b);
970        assert!((s - 1.0).abs() < 1e-9, "same-sign scalars => cos=1");
971    }
972
973    #[test]
974    fn test_1d_dot_product() {
975        let a = vec![7.0];
976        let b = vec![3.0];
977        let s = CrossEncoder::dot_product(&a, &b);
978        assert!((s - 21.0).abs() < 1e-9);
979    }
980
981    // ── 13. max_doc_length truncation ─────────────────────────────────────
982
983    #[test]
984    fn test_max_doc_length_truncation() {
985        let enc = CrossEncoder::new(CrossEncoderConfig {
986            model: ScoringModel::DotProduct,
987            max_doc_length: 2,
988            batch_size: 32,
989            normalize_scores: false,
990        });
991        // Only first 2 dims should count
992        let q = vec![1.0, 1.0, 100.0];
993        let d = vec![1.0, 1.0, 100.0];
994        let s = enc.score_pair(&q, &d);
995        // truncated to [1,1] · [1,1] = 2
996        assert!((s - 2.0).abs() < 1e-9, "truncated dot should be 2, got {s}");
997    }
998
999    // ── 14. normalize_scores via rerank pipeline ──────────────────────────
1000
1001    #[test]
1002    fn test_rerank_with_normalization() {
1003        let mut enc = encoder_with(ScoringModel::DotProduct, true);
1004        let query = vec![1.0, 0.0];
1005        let candidates = vec![
1006            make_candidate("a", vec![10.0, 0.0], 0.5),
1007            make_candidate("b", vec![5.0, 0.0], 0.3),
1008            make_candidate("c", vec![1.0, 0.0], 0.1),
1009        ];
1010        let reranked = enc.rerank(&query, candidates);
1011        for doc in &reranked {
1012            assert!(
1013                doc.cross_encoder_score >= 0.0 && doc.cross_encoder_score <= 1.0,
1014                "normalized score out of [0,1]: {}",
1015                doc.cross_encoder_score
1016            );
1017        }
1018        // Best doc should have score 1.0
1019        assert!((reranked[0].cross_encoder_score - 1.0).abs() < 1e-9);
1020    }
1021
1022    // ── 15. large candidate set ───────────────────────────────────────────
1023
1024    #[test]
1025    fn test_large_candidate_set_50_docs() {
1026        let mut enc = encoder_with(ScoringModel::Cosine, true);
1027        let dim = 16;
1028        let query: Vec<f64> = (0..dim).map(|i| i as f64).collect();
1029
1030        // Generate 50 candidates with varying embeddings
1031        let candidates: Vec<CandidateDoc> = (0..50)
1032            .map(|i| {
1033                let embedding: Vec<f64> = (0..dim).map(|j| (i * dim + j) as f64 * 0.01).collect();
1034                make_candidate(&format!("doc_{i:02}"), embedding, i as f64 * 0.02)
1035            })
1036            .collect();
1037
1038        let reranked = enc.rerank(&query, candidates);
1039        assert_eq!(reranked.len(), 50, "all 50 docs should be present");
1040
1041        // Verify descending order of cross_encoder_score
1042        for window in reranked.windows(2) {
1043            assert!(
1044                window[0].cross_encoder_score >= window[1].cross_encoder_score,
1045                "output must be sorted descending"
1046            );
1047        }
1048
1049        // All scores in [0, 1] due to normalize_scores=true
1050        for d in &reranked {
1051            assert!(d.cross_encoder_score >= 0.0 && d.cross_encoder_score <= 1.0);
1052        }
1053
1054        assert_eq!(enc.stats().total_docs_reranked, 50);
1055    }
1056
1057    // ── 16. score_delta sign ──────────────────────────────────────────────
1058
1059    #[test]
1060    fn test_score_delta_positive_when_improved() {
1061        let mut enc = encoder_with(ScoringModel::DotProduct, false);
1062        let query = vec![1.0, 0.0];
1063        // initial_score=0.1, but dot-product will be 0.9 => positive delta
1064        let candidates = vec![make_candidate("high", vec![0.9, 0.0], 0.1)];
1065        let reranked = enc.rerank(&query, candidates);
1066        assert!(
1067            reranked[0].score_delta > 0.0,
1068            "score improved => positive delta"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_score_delta_negative_when_demoted() {
1074        let mut enc = encoder_with(ScoringModel::DotProduct, false);
1075        let query = vec![0.01, 0.0];
1076        // initial_score=0.9, but dot-product will be 0.009 => negative delta
1077        let candidates = vec![make_candidate("low", vec![0.9, 0.0], 0.9)];
1078        let reranked = enc.rerank(&query, candidates);
1079        assert!(
1080            reranked[0].score_delta < 0.0,
1081            "score demoted => negative delta"
1082        );
1083    }
1084}