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}