Skip to main content

ipfrs_semantic/
document_ranker.rs

1//! Multi-factor document ranking combining BM25 lexical scoring with semantic similarity.
2//!
3//! This module provides a [`DocumentRanker`] that fuses traditional BM25 term-based scoring
4//! with dense-vector cosine similarity to produce a single combined relevance score for
5//! document retrieval.
6//!
7//! ## Algorithm Overview
8//!
9//! For each document `d` and query `q`:
10//!
11//! ```text
12//! combined(d, q) = lexical_weight * BM25(d, q) + semantic_weight * cosine(emb_d, emb_q)
13//! ```
14//!
15//! BM25 per-term contribution:
16//!
17//! ```text
18//! idf(t) * tf(t,d)*(k1+1) / (tf(t,d) + k1*(1 - b + b*|d|/avgdl))
19//! ```
20//!
21//! IDF formula (Robertson–Sparck Jones with smoothing):
22//!
23//! ```text
24//! idf(t) = ln((N - df + 0.5) / (df + 0.5) + 1)
25//! ```
26
27use std::collections::HashMap;
28
29// ---------------------------------------------------------------------------
30// Configuration
31// ---------------------------------------------------------------------------
32
33/// Configuration for the [`DocumentRanker`].
34#[derive(Debug, Clone)]
35pub struct RankingConfig {
36    /// BM25 term-frequency saturation constant (default 1.5).
37    pub bm25_k1: f64,
38    /// BM25 length-normalisation constant (default 0.75).
39    pub bm25_b: f64,
40    /// Weight applied to the semantic (cosine) score in [0, 1].
41    pub semantic_weight: f64,
42    /// Weight applied to the BM25 lexical score in [0, 1].
43    pub lexical_weight: f64,
44    /// Maximum number of results to return.
45    pub max_results: usize,
46    /// Minimum combined score threshold; documents below this are dropped.
47    pub min_score: f64,
48}
49
50impl Default for RankingConfig {
51    fn default() -> Self {
52        Self {
53            bm25_k1: 1.5,
54            bm25_b: 0.75,
55            semantic_weight: 0.5,
56            lexical_weight: 0.5,
57            max_results: 10,
58            min_score: 0.0,
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// DocumentIndex
65// ---------------------------------------------------------------------------
66
67/// A document representation stored inside the ranker index.
68#[derive(Debug, Clone)]
69pub struct DocumentIndex {
70    /// Unique document identifier.
71    pub doc_id: String,
72    /// Pre-computed term frequency map: term → raw count (normalised to `f64`).
73    pub term_frequencies: HashMap<String, f64>,
74    /// Total number of tokens in the document.
75    pub doc_length: usize,
76    /// Optional dense embedding used for semantic scoring.
77    pub embedding: Option<Vec<f64>>,
78}
79
80impl DocumentIndex {
81    /// Constructs a [`DocumentIndex`] from a plain token list.
82    ///
83    /// Term frequencies are computed as raw counts; the caller may pass a
84    /// pre-embedded vector if semantic ranking is desired.
85    pub fn from_tokens(
86        doc_id: impl Into<String>,
87        tokens: &[&str],
88        embedding: Option<Vec<f64>>,
89    ) -> Self {
90        let mut term_frequencies: HashMap<String, f64> = HashMap::new();
91        for &tok in tokens {
92            let entry = term_frequencies.entry(tok.to_lowercase()).or_insert(0.0);
93            *entry += 1.0;
94        }
95        let doc_length = tokens.len();
96        Self {
97            doc_id: doc_id.into(),
98            term_frequencies,
99            doc_length,
100            embedding,
101        }
102    }
103}
104
105// ---------------------------------------------------------------------------
106// RankedDocument
107// ---------------------------------------------------------------------------
108
109/// A scored document returned by [`DocumentRanker::rank`].
110#[derive(Debug, Clone)]
111pub struct RankedDocument {
112    /// Document identifier.
113    pub doc_id: String,
114    /// Raw BM25 lexical score (un-weighted).
115    pub bm25_score: f64,
116    /// Raw cosine semantic score in \[0, 1\] (un-weighted), or 0.0 if unavailable.
117    pub semantic_score: f64,
118    /// Weighted combined score: `lexical_weight*bm25 + semantic_weight*cosine`.
119    pub combined_score: f64,
120    /// 1-based rank position in the result list.
121    pub rank: usize,
122}
123
124// ---------------------------------------------------------------------------
125// RankerStats
126// ---------------------------------------------------------------------------
127
128/// Aggregate statistics collected by [`DocumentRanker`] across all queries.
129#[derive(Debug, Clone, Default)]
130pub struct RankerStats {
131    /// Total number of `rank()` calls executed.
132    pub total_queries: u64,
133    /// Total number of documents that appeared in at least one result set.
134    pub documents_ranked: u64,
135    /// Rolling average of the result-set size across all queries.
136    pub avg_results_per_query: f64,
137}
138
139// ---------------------------------------------------------------------------
140// DocumentRanker
141// ---------------------------------------------------------------------------
142
143/// Multi-factor document ranker combining BM25 lexical scoring with semantic similarity.
144///
145/// # Usage
146///
147/// ```rust
148/// use ipfrs_semantic::document_ranker::{DocumentRanker, RankingConfig, DocumentIndex};
149///
150/// let config = RankingConfig::default();
151/// let mut ranker = DocumentRanker::new(config);
152///
153/// let doc = DocumentIndex::from_tokens("doc1", &["hello", "world"], None);
154/// ranker.index_document(doc);
155///
156/// let results = ranker.rank(&["hello".to_string()], None);
157/// assert!(!results.is_empty());
158/// ```
159pub struct DocumentRanker {
160    config: RankingConfig,
161    documents: HashMap<String, DocumentIndex>,
162    avg_doc_length: f64,
163    idf_cache: HashMap<String, f64>,
164    stats: RankerStats,
165}
166
167impl DocumentRanker {
168    // -----------------------------------------------------------------------
169    // Construction
170    // -----------------------------------------------------------------------
171
172    /// Creates a new [`DocumentRanker`] with the given configuration.
173    pub fn new(config: RankingConfig) -> Self {
174        Self {
175            config,
176            documents: HashMap::new(),
177            avg_doc_length: 0.0,
178            idf_cache: HashMap::new(),
179            stats: RankerStats::default(),
180        }
181    }
182
183    // -----------------------------------------------------------------------
184    // Index management
185    // -----------------------------------------------------------------------
186
187    /// Indexes (or re-indexes) a document.
188    ///
189    /// Inserting a document with the same `doc_id` as an existing one will
190    /// overwrite the previous entry.  After insertion the average document
191    /// length and IDF cache are refreshed for all terms present in the new
192    /// document.
193    pub fn index_document(&mut self, doc: DocumentIndex) {
194        let terms: Vec<String> = doc.term_frequencies.keys().cloned().collect();
195        self.documents.insert(doc.doc_id.clone(), doc);
196        self.update_avg_length();
197        self.update_idf_cache(&terms);
198    }
199
200    // -----------------------------------------------------------------------
201    // Core ranking
202    // -----------------------------------------------------------------------
203
204    /// Ranks all indexed documents against the given query terms and optional
205    /// query embedding.
206    ///
207    /// Results are filtered by [`RankingConfig::min_score`], sorted by
208    /// descending combined score, and truncated to at most
209    /// [`RankingConfig::max_results`] entries.  Each returned [`RankedDocument`]
210    /// carries a 1-based `rank` field.
211    pub fn rank(
212        &mut self,
213        query_terms: &[String],
214        query_embedding: Option<&[f64]>,
215    ) -> Vec<RankedDocument> {
216        // Ensure IDF cache is populated for all query terms.
217        self.update_idf_cache(query_terms);
218
219        let mut scored: Vec<RankedDocument> = self
220            .documents
221            .values()
222            .map(|doc| {
223                let bm25 = self.bm25_score(doc, query_terms);
224                let sem = match (query_embedding, doc.embedding.as_deref()) {
225                    (Some(qe), Some(de)) => Self::cosine_similarity(qe, de),
226                    _ => 0.0,
227                };
228                let combined =
229                    self.config.lexical_weight * bm25 + self.config.semantic_weight * sem;
230                RankedDocument {
231                    doc_id: doc.doc_id.clone(),
232                    bm25_score: bm25,
233                    semantic_score: sem,
234                    combined_score: combined,
235                    rank: 0, // filled in below
236                }
237            })
238            .filter(|rd| rd.combined_score >= self.config.min_score)
239            .collect();
240
241        // Sort descending by combined score; break ties alphabetically by doc_id.
242        scored.sort_unstable_by(|a, b| {
243            b.combined_score
244                .partial_cmp(&a.combined_score)
245                .unwrap_or(std::cmp::Ordering::Equal)
246                .then_with(|| a.doc_id.cmp(&b.doc_id))
247        });
248
249        scored.truncate(self.config.max_results);
250
251        // Assign 1-based ranks.
252        for (i, rd) in scored.iter_mut().enumerate() {
253            rd.rank = i + 1;
254        }
255
256        // Update stats.
257        let result_count = scored.len() as u64;
258        self.stats.total_queries += 1;
259        self.stats.documents_ranked += result_count;
260        let n = self.stats.total_queries as f64;
261        self.stats.avg_results_per_query =
262            (self.stats.avg_results_per_query * (n - 1.0) + result_count as f64) / n;
263
264        scored
265    }
266
267    // -----------------------------------------------------------------------
268    // BM25
269    // -----------------------------------------------------------------------
270
271    /// Computes the BM25 score for a single document given the query terms.
272    ///
273    /// Uses the Robertson–Sparck Jones IDF with BM25+ numerator adjustment.
274    pub fn bm25_score(&self, doc: &DocumentIndex, query_terms: &[String]) -> f64 {
275        let k1 = self.config.bm25_k1;
276        let b = self.config.bm25_b;
277        let avgdl = self.avg_doc_length.max(1.0);
278        let dl = doc.doc_length as f64;
279
280        query_terms.iter().fold(0.0_f64, |acc, term| {
281            let tf = doc
282                .term_frequencies
283                .get(term.as_str())
284                .copied()
285                .unwrap_or(0.0);
286            if tf == 0.0 {
287                return acc;
288            }
289            let idf = self
290                .idf_cache
291                .get(term.as_str())
292                .copied()
293                .unwrap_or_else(|| self.compute_idf(term));
294            let numerator = tf * (k1 + 1.0);
295            let denominator = tf + k1 * (1.0 - b + b * dl / avgdl);
296            acc + idf * numerator / denominator
297        })
298    }
299
300    /// Computes the IDF of a term using Robertson–Sparck Jones smoothed formula:
301    ///
302    /// ```text
303    /// ln((N - df + 0.5) / (df + 0.5) + 1)
304    /// ```
305    ///
306    /// where `N` is the total number of indexed documents and `df` is the
307    /// document frequency of `term`.
308    pub fn compute_idf(&self, term: &str) -> f64 {
309        let n = self.documents.len() as f64;
310        let df = self
311            .documents
312            .values()
313            .filter(|doc| doc.term_frequencies.contains_key(term))
314            .count() as f64;
315        ((n - df + 0.5) / (df + 0.5) + 1.0).ln()
316    }
317
318    // -----------------------------------------------------------------------
319    // Semantic similarity
320    // -----------------------------------------------------------------------
321
322    /// Computes the cosine similarity between two embedding vectors.
323    ///
324    /// Returns 0.0 when either vector is zero-length or the lengths differ.
325    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
326        if a.len() != b.len() || a.is_empty() {
327            return 0.0;
328        }
329        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
330        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
331        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
332        if norm_a == 0.0 || norm_b == 0.0 {
333            return 0.0;
334        }
335        (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
336    }
337
338    // -----------------------------------------------------------------------
339    // Index maintenance helpers
340    // -----------------------------------------------------------------------
341
342    /// Recomputes the average document length across all indexed documents.
343    ///
344    /// Called automatically after each [`index_document`](Self::index_document).
345    pub fn update_avg_length(&mut self) {
346        if self.documents.is_empty() {
347            self.avg_doc_length = 0.0;
348            return;
349        }
350        let total: usize = self.documents.values().map(|d| d.doc_length).sum();
351        self.avg_doc_length = total as f64 / self.documents.len() as f64;
352    }
353
354    /// Refreshes the IDF cache for the given term list.
355    ///
356    /// Existing cache entries for terms *not* in `terms` are preserved.
357    pub fn update_idf_cache(&mut self, terms: &[String]) {
358        for term in terms {
359            let idf = self.compute_idf(term);
360            self.idf_cache.insert(term.clone(), idf);
361        }
362    }
363
364    // -----------------------------------------------------------------------
365    // Accessors
366    // -----------------------------------------------------------------------
367
368    /// Returns the number of documents currently in the index.
369    pub fn document_count(&self) -> usize {
370        self.documents.len()
371    }
372
373    /// Returns a reference to the accumulated query statistics.
374    pub fn stats(&self) -> &RankerStats {
375        &self.stats
376    }
377
378    /// Returns the current average document length used by BM25.
379    pub fn avg_doc_length(&self) -> f64 {
380        self.avg_doc_length
381    }
382
383    /// Returns a reference to a specific indexed document, if present.
384    pub fn get_document(&self, doc_id: &str) -> Option<&DocumentIndex> {
385        self.documents.get(doc_id)
386    }
387}
388
389// ---------------------------------------------------------------------------
390// Tests
391// ---------------------------------------------------------------------------
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    // -----------------------------------------------------------------------
398    // Helpers
399    // -----------------------------------------------------------------------
400
401    fn make_ranker() -> DocumentRanker {
402        DocumentRanker::new(RankingConfig::default())
403    }
404
405    fn simple_doc(id: &str, tokens: &[&str]) -> DocumentIndex {
406        DocumentIndex::from_tokens(id, tokens, None)
407    }
408
409    fn embed_doc(id: &str, tokens: &[&str], emb: Vec<f64>) -> DocumentIndex {
410        DocumentIndex::from_tokens(id, tokens, Some(emb))
411    }
412
413    // -----------------------------------------------------------------------
414    // 1. Index and rank single doc
415    // -----------------------------------------------------------------------
416    #[test]
417    fn test_single_doc_indexed_and_ranked() {
418        let mut ranker = make_ranker();
419        ranker.index_document(simple_doc("d1", &["hello", "world"]));
420        let results = ranker.rank(&["hello".to_string()], None);
421        assert_eq!(results.len(), 1);
422        assert_eq!(results[0].doc_id, "d1");
423        assert_eq!(results[0].rank, 1);
424    }
425
426    // -----------------------------------------------------------------------
427    // 2. BM25 term saturation: doubling TF should not double score
428    // -----------------------------------------------------------------------
429    #[test]
430    fn test_bm25_term_saturation() {
431        let config = RankingConfig {
432            lexical_weight: 1.0,
433            semantic_weight: 0.0,
434            ..RankingConfig::default()
435        };
436        let mut ranker = DocumentRanker::new(config);
437        // Doc A: "rust" appears once; Doc B: "rust" appears many times.
438        ranker.index_document(simple_doc("sparse", &["rust"]));
439        ranker.index_document(simple_doc(
440            "dense",
441            &[
442                "rust", "rust", "rust", "rust", "rust", "rust", "rust", "rust", "rust", "rust",
443            ],
444        ));
445        let results = ranker.rank(&["rust".to_string()], None);
446        assert_eq!(results.len(), 2);
447        let sparse_score = results
448            .iter()
449            .find(|r| r.doc_id == "sparse")
450            .map(|r| r.bm25_score)
451            .unwrap_or(0.0);
452        let dense_score = results
453            .iter()
454            .find(|r| r.doc_id == "dense")
455            .map(|r| r.bm25_score)
456            .unwrap_or(0.0);
457        // Dense should score higher but not proportionally more (saturation).
458        assert!(
459            dense_score > sparse_score,
460            "dense={dense_score}, sparse={sparse_score}"
461        );
462        assert!(
463            dense_score < sparse_score * 10.0,
464            "no saturation? dense={dense_score}, sparse={sparse_score}"
465        );
466    }
467
468    // -----------------------------------------------------------------------
469    // 3. Length normalisation: shorter docs score higher for same TF
470    // -----------------------------------------------------------------------
471    #[test]
472    fn test_bm25_length_normalisation() {
473        let config = RankingConfig {
474            lexical_weight: 1.0,
475            semantic_weight: 0.0,
476            ..RankingConfig::default()
477        };
478        let mut ranker = DocumentRanker::new(config);
479        // Short doc: "rust" in a 2-token document.
480        // Long doc: "rust" buried among many other tokens.
481        let long_tokens: Vec<&str> = std::iter::once("rust")
482            .chain(std::iter::repeat_n("filler", 49))
483            .collect();
484        ranker.index_document(simple_doc("short", &["rust", "code"]));
485        ranker.index_document(simple_doc("long", &long_tokens));
486        let results = ranker.rank(&["rust".to_string()], None);
487        let short_score = results
488            .iter()
489            .find(|r| r.doc_id == "short")
490            .map(|r| r.bm25_score)
491            .unwrap_or(0.0);
492        let long_score = results
493            .iter()
494            .find(|r| r.doc_id == "long")
495            .map(|r| r.bm25_score)
496            .unwrap_or(0.0);
497        assert!(
498            short_score > long_score,
499            "short={short_score}, long={long_score}"
500        );
501    }
502
503    // -----------------------------------------------------------------------
504    // 4. IDF computation — rare term gets higher IDF
505    // -----------------------------------------------------------------------
506    #[test]
507    fn test_idf_rare_term_higher() {
508        let mut ranker = make_ranker();
509        // "common" appears in all 3 docs; "rare" only in 1.
510        ranker.index_document(simple_doc("d1", &["common", "rare"]));
511        ranker.index_document(simple_doc("d2", &["common"]));
512        ranker.index_document(simple_doc("d3", &["common"]));
513        let idf_common = ranker.compute_idf("common");
514        let idf_rare = ranker.compute_idf("rare");
515        assert!(
516            idf_rare > idf_common,
517            "rare={idf_rare}, common={idf_common}"
518        );
519    }
520
521    // -----------------------------------------------------------------------
522    // 5. IDF is positive for all cases
523    // -----------------------------------------------------------------------
524    #[test]
525    fn test_idf_always_positive() {
526        let mut ranker = make_ranker();
527        ranker.index_document(simple_doc("d1", &["alpha", "beta"]));
528        ranker.index_document(simple_doc("d2", &["alpha", "gamma"]));
529        for term in &["alpha", "beta", "gamma", "unseen"] {
530            let idf = ranker.compute_idf(term);
531            assert!(idf >= 0.0, "negative IDF for '{term}': {idf}");
532        }
533    }
534
535    // -----------------------------------------------------------------------
536    // 6. Semantic ranking — embedding alone selects correct doc
537    // -----------------------------------------------------------------------
538    #[test]
539    fn test_semantic_ranking_selects_closest() {
540        let config = RankingConfig {
541            lexical_weight: 0.0,
542            semantic_weight: 1.0,
543            ..RankingConfig::default()
544        };
545        let mut ranker = DocumentRanker::new(config);
546        ranker.index_document(embed_doc("near", &[], vec![1.0, 0.0, 0.0]));
547        ranker.index_document(embed_doc("far", &[], vec![0.0, 1.0, 0.0]));
548        let query_emb = vec![1.0, 0.0, 0.0];
549        let results = ranker.rank(&[], Some(&query_emb));
550        assert!(!results.is_empty());
551        assert_eq!(results[0].doc_id, "near");
552    }
553
554    // -----------------------------------------------------------------------
555    // 7. Combined score weighting (50/50 split)
556    // -----------------------------------------------------------------------
557    #[test]
558    fn test_combined_score_weighting() {
559        let config = RankingConfig {
560            lexical_weight: 0.5,
561            semantic_weight: 0.5,
562            ..RankingConfig::default()
563        };
564        let mut ranker = DocumentRanker::new(config);
565        // doc_a: perfect semantic match, no lexical match.
566        ranker.index_document(embed_doc("doc_a", &["foo"], vec![1.0, 0.0]));
567        // doc_b: exact lexical match, poor semantic match.
568        ranker.index_document(embed_doc("doc_b", &["rust"], vec![0.0, 1.0]));
569        let query_emb = vec![1.0, 0.0];
570        let results = ranker.rank(&["rust".to_string()], Some(&query_emb));
571        let a = results
572            .iter()
573            .find(|r| r.doc_id == "doc_a")
574            .expect("doc_a missing");
575        let b = results
576            .iter()
577            .find(|r| r.doc_id == "doc_b")
578            .expect("doc_b missing");
579        // doc_a should have higher semantic contribution.
580        assert!(a.semantic_score > b.semantic_score);
581        // doc_b should have higher BM25 contribution.
582        assert!(b.bm25_score > a.bm25_score);
583    }
584
585    // -----------------------------------------------------------------------
586    // 8. max_results limits output
587    // -----------------------------------------------------------------------
588    #[test]
589    fn test_max_results_limits_output() {
590        let config = RankingConfig {
591            max_results: 3,
592            ..RankingConfig::default()
593        };
594        let mut ranker = DocumentRanker::new(config);
595        for i in 0..10_usize {
596            ranker.index_document(simple_doc(&format!("d{i}"), &["rust"]));
597        }
598        let results = ranker.rank(&["rust".to_string()], None);
599        assert_eq!(results.len(), 3);
600    }
601
602    // -----------------------------------------------------------------------
603    // 9. min_score filter removes low-scoring documents
604    // -----------------------------------------------------------------------
605    #[test]
606    fn test_min_score_filter() {
607        let config = RankingConfig {
608            min_score: 999.0, // impossibly high threshold
609            ..RankingConfig::default()
610        };
611        let mut ranker = DocumentRanker::new(config);
612        ranker.index_document(simple_doc("d1", &["hello"]));
613        let results = ranker.rank(&["hello".to_string()], None);
614        assert!(results.is_empty());
615    }
616
617    // -----------------------------------------------------------------------
618    // 10. Multi-doc ranking order is deterministic and correct
619    // -----------------------------------------------------------------------
620    #[test]
621    fn test_multi_doc_ranking_order() {
622        let config = RankingConfig {
623            lexical_weight: 1.0,
624            semantic_weight: 0.0,
625            ..RankingConfig::default()
626        };
627        let mut ranker = DocumentRanker::new(config);
628        ranker.index_document(simple_doc("d1", &["rust"]));
629        ranker.index_document(simple_doc("d2", &["rust", "rust"]));
630        ranker.index_document(simple_doc("d3", &["python"]));
631        let results = ranker.rank(&["rust".to_string()], None);
632        // d3 should rank last (no rust), d2 should rank above d1 (higher tf).
633        assert!(results
634            .iter()
635            .position(|r| r.doc_id == "d3")
636            .map(|p| p > results.iter().position(|r| r.doc_id == "d1").unwrap_or(0))
637            .unwrap_or(true));
638        // Scores descend.
639        for w in results.windows(2) {
640            assert!(w[0].combined_score >= w[1].combined_score);
641        }
642    }
643
644    // -----------------------------------------------------------------------
645    // 11. Empty query returns all docs with zero BM25 score
646    // -----------------------------------------------------------------------
647    #[test]
648    fn test_empty_query_returns_zero_bm25() {
649        let mut ranker = make_ranker();
650        ranker.index_document(simple_doc("d1", &["rust"]));
651        ranker.index_document(simple_doc("d2", &["python"]));
652        let results = ranker.rank(&[], None);
653        // With no query terms BM25=0 and no embedding, combined_score=0.
654        // Both docs pass min_score=0.0 (0 >= 0).
655        assert_eq!(results.len(), 2);
656        for r in &results {
657            assert_eq!(r.bm25_score, 0.0);
658            assert_eq!(r.semantic_score, 0.0);
659        }
660    }
661
662    // -----------------------------------------------------------------------
663    // 12. Missing embedding is handled gracefully (no panic)
664    // -----------------------------------------------------------------------
665    #[test]
666    fn test_missing_embedding_graceful() {
667        let config = RankingConfig {
668            semantic_weight: 1.0,
669            lexical_weight: 0.0,
670            ..RankingConfig::default()
671        };
672        let mut ranker = DocumentRanker::new(config);
673        // Doc without embedding.
674        ranker.index_document(simple_doc("no_emb", &["hello"]));
675        let query_emb = vec![1.0, 0.0];
676        // Should not panic; semantic_score should be 0.
677        let results = ranker.rank(&[], Some(&query_emb));
678        assert_eq!(results.len(), 1);
679        assert_eq!(results[0].semantic_score, 0.0);
680    }
681
682    // -----------------------------------------------------------------------
683    // 13. Query embedding missing for doc that has one
684    // -----------------------------------------------------------------------
685    #[test]
686    fn test_no_query_embedding_graceful() {
687        let mut ranker = make_ranker();
688        ranker.index_document(embed_doc("d1", &["hello"], vec![1.0, 0.0]));
689        let results = ranker.rank(&["hello".to_string()], None);
690        assert!(!results.is_empty());
691        assert_eq!(results[0].semantic_score, 0.0);
692    }
693
694    // -----------------------------------------------------------------------
695    // 14. Stats tracking — total_queries increments
696    // -----------------------------------------------------------------------
697    #[test]
698    fn test_stats_total_queries() {
699        let mut ranker = make_ranker();
700        ranker.index_document(simple_doc("d1", &["hello"]));
701        ranker.rank(&["hello".to_string()], None);
702        ranker.rank(&["world".to_string()], None);
703        assert_eq!(ranker.stats().total_queries, 2);
704    }
705
706    // -----------------------------------------------------------------------
707    // 15. Stats tracking — documents_ranked accumulates
708    // -----------------------------------------------------------------------
709    #[test]
710    fn test_stats_documents_ranked() {
711        let config = RankingConfig {
712            max_results: 100,
713            min_score: 0.0,
714            ..RankingConfig::default()
715        };
716        let mut ranker = DocumentRanker::new(config);
717        for i in 0..5_usize {
718            ranker.index_document(simple_doc(&format!("d{i}"), &["rust"]));
719        }
720        ranker.rank(&["rust".to_string()], None);
721        // All 5 docs match with non-zero score (actually 0.0 == min_score so still pass).
722        assert_eq!(ranker.stats().documents_ranked, 5);
723    }
724
725    // -----------------------------------------------------------------------
726    // 16. Stats tracking — avg_results_per_query
727    // -----------------------------------------------------------------------
728    #[test]
729    fn test_stats_avg_results() {
730        let config = RankingConfig {
731            max_results: 100,
732            min_score: 0.0,
733            ..RankingConfig::default()
734        };
735        let mut ranker = DocumentRanker::new(config);
736        ranker.index_document(simple_doc("d1", &["rust"]));
737        ranker.index_document(simple_doc("d2", &["python"]));
738        ranker.rank(&["rust".to_string()], None); // 2 docs pass (score >= 0)
739        ranker.rank(&["python".to_string()], None); // 2 docs again
740        let avg = ranker.stats().avg_results_per_query;
741        assert!((avg - 2.0).abs() < 1e-9, "expected 2.0 got {avg}");
742    }
743
744    // -----------------------------------------------------------------------
745    // 17. avg_doc_length update
746    // -----------------------------------------------------------------------
747    #[test]
748    fn test_avg_doc_length_update() {
749        let mut ranker = make_ranker();
750        assert_eq!(ranker.avg_doc_length(), 0.0);
751        ranker.index_document(simple_doc("d1", &["a", "b"])); // length=2
752        ranker.index_document(simple_doc("d2", &["x", "y", "z"])); // length=3
753        let expected = (2.0 + 3.0) / 2.0;
754        assert!((ranker.avg_doc_length() - expected).abs() < 1e-9);
755    }
756
757    // -----------------------------------------------------------------------
758    // 18. document_count
759    // -----------------------------------------------------------------------
760    #[test]
761    fn test_document_count() {
762        let mut ranker = make_ranker();
763        assert_eq!(ranker.document_count(), 0);
764        ranker.index_document(simple_doc("d1", &["a"]));
765        ranker.index_document(simple_doc("d2", &["b"]));
766        assert_eq!(ranker.document_count(), 2);
767    }
768
769    // -----------------------------------------------------------------------
770    // 19. Re-indexing the same doc_id overwrites
771    // -----------------------------------------------------------------------
772    #[test]
773    fn test_reindex_overwrites() {
774        let mut ranker = make_ranker();
775        ranker.index_document(simple_doc("d1", &["rust"]));
776        ranker.index_document(simple_doc("d1", &["python"])); // overwrite
777        assert_eq!(ranker.document_count(), 1);
778        let doc = ranker.get_document("d1").expect("d1 should exist");
779        assert!(doc.term_frequencies.contains_key("python"));
780        assert!(!doc.term_frequencies.contains_key("rust"));
781    }
782
783    // -----------------------------------------------------------------------
784    // 20. cosine_similarity — identical vectors give 1.0
785    // -----------------------------------------------------------------------
786    #[test]
787    fn test_cosine_identical() {
788        let v = vec![0.3, 0.4, 0.5];
789        let sim = DocumentRanker::cosine_similarity(&v, &v);
790        assert!((sim - 1.0).abs() < 1e-9);
791    }
792
793    // -----------------------------------------------------------------------
794    // 21. cosine_similarity — orthogonal vectors give 0.0
795    // -----------------------------------------------------------------------
796    #[test]
797    fn test_cosine_orthogonal() {
798        let a = vec![1.0, 0.0];
799        let b = vec![0.0, 1.0];
800        assert_eq!(DocumentRanker::cosine_similarity(&a, &b), 0.0);
801    }
802
803    // -----------------------------------------------------------------------
804    // 22. cosine_similarity — zero vector gives 0.0 (no NaN)
805    // -----------------------------------------------------------------------
806    #[test]
807    fn test_cosine_zero_vector() {
808        let a = vec![0.0, 0.0];
809        let b = vec![1.0, 0.0];
810        assert_eq!(DocumentRanker::cosine_similarity(&a, &b), 0.0);
811    }
812
813    // -----------------------------------------------------------------------
814    // 23. cosine_similarity — mismatched lengths give 0.0 (no panic)
815    // -----------------------------------------------------------------------
816    #[test]
817    fn test_cosine_length_mismatch() {
818        let a = vec![1.0, 2.0];
819        let b = vec![1.0];
820        assert_eq!(DocumentRanker::cosine_similarity(&a, &b), 0.0);
821    }
822
823    // -----------------------------------------------------------------------
824    // 24. cosine_similarity — empty vectors give 0.0
825    // -----------------------------------------------------------------------
826    #[test]
827    fn test_cosine_empty() {
828        assert_eq!(DocumentRanker::cosine_similarity(&[], &[]), 0.0);
829    }
830
831    // -----------------------------------------------------------------------
832    // 25. IDF cache is populated after update_idf_cache
833    // -----------------------------------------------------------------------
834    #[test]
835    fn test_idf_cache_populated() {
836        let mut ranker = make_ranker();
837        ranker.index_document(simple_doc("d1", &["alpha"]));
838        let terms = vec!["alpha".to_string(), "beta".to_string()];
839        ranker.update_idf_cache(&terms);
840        // Cache should have entries for both (beta may have idf even if df=0).
841        assert!(ranker.idf_cache.contains_key("alpha"));
842        assert!(ranker.idf_cache.contains_key("beta"));
843    }
844
845    // -----------------------------------------------------------------------
846    // 26. rank result set rank values are 1..=N
847    // -----------------------------------------------------------------------
848    #[test]
849    fn test_rank_values_sequential() {
850        let mut ranker = make_ranker();
851        for i in 0..5_usize {
852            ranker.index_document(simple_doc(&format!("d{i}"), &["rust"]));
853        }
854        let results = ranker.rank(&["rust".to_string()], None);
855        for (i, r) in results.iter().enumerate() {
856            assert_eq!(r.rank, i + 1);
857        }
858    }
859
860    // -----------------------------------------------------------------------
861    // 27. RankingConfig default values
862    // -----------------------------------------------------------------------
863    #[test]
864    fn test_ranking_config_defaults() {
865        let cfg = RankingConfig::default();
866        assert_eq!(cfg.bm25_k1, 1.5);
867        assert_eq!(cfg.bm25_b, 0.75);
868        assert_eq!(cfg.semantic_weight, 0.5);
869        assert_eq!(cfg.lexical_weight, 0.5);
870        assert_eq!(cfg.max_results, 10);
871        assert_eq!(cfg.min_score, 0.0);
872    }
873
874    // -----------------------------------------------------------------------
875    // 28. DocumentIndex from_tokens term normalisation (lowercase)
876    // -----------------------------------------------------------------------
877    #[test]
878    fn test_from_tokens_lowercase() {
879        let doc = DocumentIndex::from_tokens("d1", &["Rust", "RUST", "rust"], None);
880        assert_eq!(
881            doc.term_frequencies.get("rust").copied().unwrap_or(0.0),
882            3.0
883        );
884        assert!(!doc.term_frequencies.contains_key("Rust"));
885    }
886
887    // -----------------------------------------------------------------------
888    // 29. BM25 — term not in doc contributes 0
889    // -----------------------------------------------------------------------
890    #[test]
891    fn test_bm25_missing_term_zero() {
892        let mut ranker = make_ranker();
893        ranker.index_document(simple_doc("d1", &["hello"]));
894        let doc = ranker.get_document("d1").expect("d1 missing").clone();
895        let score = ranker.bm25_score(&doc, &["nonexistent".to_string()]);
896        assert_eq!(score, 0.0);
897    }
898
899    // -----------------------------------------------------------------------
900    // 30. Semantic-only mode with zero lexical weight
901    // -----------------------------------------------------------------------
902    #[test]
903    fn test_semantic_only_mode() {
904        let config = RankingConfig {
905            lexical_weight: 0.0,
906            semantic_weight: 1.0,
907            ..RankingConfig::default()
908        };
909        let mut ranker = DocumentRanker::new(config);
910        ranker.index_document(embed_doc("close", &[], vec![0.9, 0.1]));
911        ranker.index_document(embed_doc("distant", &[], vec![0.1, 0.9]));
912        let qe = vec![1.0, 0.0];
913        let results = ranker.rank(&[], Some(&qe));
914        assert!(!results.is_empty());
915        assert_eq!(results[0].doc_id, "close");
916    }
917}