Skip to main content

ipfrs_semantic/
term_weighter.rs

1//! # Semantic Term Weighter
2//!
3//! TF-IDF and BM25 term weighting for semantic search within IPFRS.
4//!
5//! Supports multiple weighting schemes:
6//! - **TF-IDF**: Term Frequency–Inverse Document Frequency
7//! - **BM25**: Okapi BM25 ranking function
8//! - **Binary**: Simple presence/absence weighting
9
10use std::collections::HashMap;
11
12/// Weighting scheme selection.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WeightingScheme {
15    /// Classic TF-IDF: tf * idf
16    TfIdf,
17    /// Okapi BM25 with saturation and length normalization
18    Bm25,
19    /// Binary weighting: 1.0 if the term is present, 0.0 otherwise
20    Binary,
21}
22
23/// Configuration for `SemanticTermWeighter`.
24#[derive(Debug, Clone)]
25pub struct WeighterConfig {
26    /// The weighting scheme to use when computing term weights.
27    pub scheme: WeightingScheme,
28    /// BM25 term-frequency saturation parameter (default 1.2).
29    pub bm25_k1: f64,
30    /// BM25 document-length normalization parameter (default 0.75).
31    pub bm25_b: f64,
32}
33
34impl Default for WeighterConfig {
35    fn default() -> Self {
36        Self {
37            scheme: WeightingScheme::TfIdf,
38            bm25_k1: 1.2,
39            bm25_b: 0.75,
40        }
41    }
42}
43
44/// A single term with its computed weight components.
45#[derive(Debug, Clone)]
46pub struct TermWeight {
47    /// The term string.
48    pub term: String,
49    /// The final computed weight (depends on scheme).
50    pub weight: f64,
51    /// Term frequency component.
52    pub tf: f64,
53    /// Inverse document frequency component.
54    pub idf: f64,
55}
56
57/// Profile of a single document in the corpus.
58#[derive(Debug, Clone)]
59pub struct DocumentProfile {
60    /// Unique document identifier.
61    pub doc_id: String,
62    /// Mapping from term to its raw count in the document.
63    pub term_counts: HashMap<String, u64>,
64    /// Total number of terms (including duplicates) in the document.
65    pub total_terms: u64,
66}
67
68/// Aggregate statistics for a `SemanticTermWeighter` instance.
69#[derive(Debug, Clone)]
70pub struct TermWeighterStats {
71    /// Number of documents in the corpus.
72    pub total_docs: u64,
73    /// Number of unique terms across all documents.
74    pub vocab_size: usize,
75    /// Mean document length (in terms).
76    pub avg_doc_length: f64,
77    /// Active weighting scheme.
78    pub scheme: WeightingScheme,
79}
80
81/// TF-IDF / BM25 term weighter for a corpus of documents.
82///
83/// Maintains document profiles and document-frequency counts so that
84/// term weights can be computed incrementally as documents are added or
85/// removed.
86pub struct SemanticTermWeighter {
87    config: WeighterConfig,
88    documents: HashMap<String, DocumentProfile>,
89    /// term -> number of documents containing that term
90    doc_freq: HashMap<String, u64>,
91    total_docs: u64,
92    avg_doc_length: f64,
93}
94
95impl SemanticTermWeighter {
96    /// Create a new weighter with the given configuration.
97    pub fn new(config: WeighterConfig) -> Self {
98        Self {
99            config,
100            documents: HashMap::new(),
101            doc_freq: HashMap::new(),
102            total_docs: 0,
103            avg_doc_length: 0.0,
104        }
105    }
106
107    /// Register a document by its id and a slice of terms.
108    ///
109    /// Counts each term occurrence and updates corpus-level statistics
110    /// (document frequencies, average document length).
111    ///
112    /// If a document with the same `doc_id` already exists it is replaced.
113    pub fn add_document(&mut self, doc_id: &str, terms: &[&str]) {
114        // If the document already exists, remove it first so stats stay correct.
115        if self.documents.contains_key(doc_id) {
116            self.remove_document(doc_id);
117        }
118
119        let mut term_counts: HashMap<String, u64> = HashMap::new();
120        for term in terms {
121            *term_counts.entry((*term).to_string()).or_insert(0) += 1;
122        }
123
124        let total_terms = terms.len() as u64;
125
126        // Update document frequencies (each unique term gets +1).
127        for term in term_counts.keys() {
128            *self.doc_freq.entry(term.clone()).or_insert(0) += 1;
129        }
130
131        let profile = DocumentProfile {
132            doc_id: doc_id.to_string(),
133            term_counts,
134            total_terms,
135        };
136
137        self.documents.insert(doc_id.to_string(), profile);
138        self.total_docs += 1;
139        self.recompute_avg_doc_length();
140    }
141
142    /// Remove a document from the corpus. Returns `true` if it existed.
143    pub fn remove_document(&mut self, doc_id: &str) -> bool {
144        let profile = match self.documents.remove(doc_id) {
145            Some(p) => p,
146            None => return false,
147        };
148
149        // Decrement document frequencies for each unique term.
150        for term in profile.term_counts.keys() {
151            if let Some(freq) = self.doc_freq.get_mut(term) {
152                *freq = freq.saturating_sub(1);
153                if *freq == 0 {
154                    self.doc_freq.remove(term);
155                }
156            }
157        }
158
159        self.total_docs = self.total_docs.saturating_sub(1);
160        self.recompute_avg_doc_length();
161        true
162    }
163
164    /// Compute weights for every term in the specified document.
165    pub fn weight_terms(&self, doc_id: &str) -> Result<Vec<TermWeight>, String> {
166        let profile = self
167            .documents
168            .get(doc_id)
169            .ok_or_else(|| format!("document '{}' not found", doc_id))?;
170
171        let mut weights: Vec<TermWeight> = Vec::with_capacity(profile.term_counts.len());
172
173        for (term, &count) in &profile.term_counts {
174            let tf_val = self.compute_tf(count, profile.total_terms);
175            let idf_val = self.idf(term);
176
177            let weight = match self.config.scheme {
178                WeightingScheme::TfIdf => tf_val * idf_val,
179                WeightingScheme::Bm25 => self.compute_bm25(count, profile.total_terms, idf_val),
180                WeightingScheme::Binary => {
181                    if count > 0 {
182                        1.0
183                    } else {
184                        0.0
185                    }
186                }
187            };
188
189            weights.push(TermWeight {
190                term: term.clone(),
191                weight,
192                tf: tf_val,
193                idf: idf_val,
194            });
195        }
196
197        // Sort by weight descending for convenience.
198        weights.sort_by(|a, b| {
199            b.weight
200                .partial_cmp(&a.weight)
201                .unwrap_or(std::cmp::Ordering::Equal)
202        });
203
204        Ok(weights)
205    }
206
207    /// Raw term frequency: count / total_terms.
208    pub fn tf(&self, term: &str, doc_id: &str) -> Option<f64> {
209        let profile = self.documents.get(doc_id)?;
210        let count = profile.term_counts.get(term).copied().unwrap_or(0);
211        Some(self.compute_tf(count, profile.total_terms))
212    }
213
214    /// Inverse document frequency: ln((N + 1) / (df + 1)) + 1.
215    pub fn idf(&self, term: &str) -> f64 {
216        let df = self.doc_freq.get(term).copied().unwrap_or(0) as f64;
217        let n = self.total_docs as f64;
218        ((n + 1.0) / (df + 1.0)).ln() + 1.0
219    }
220
221    /// BM25 score for a single term in a document.
222    pub fn bm25_score(&self, term: &str, doc_id: &str) -> Option<f64> {
223        let profile = self.documents.get(doc_id)?;
224        let count = profile.term_counts.get(term).copied().unwrap_or(0);
225        let idf_val = self.idf(term);
226        Some(self.compute_bm25(count, profile.total_terms, idf_val))
227    }
228
229    /// Cosine similarity between the TF-IDF vectors of two documents.
230    pub fn similarity(&self, doc_a: &str, doc_b: &str) -> Result<f64, String> {
231        let profile_a = self
232            .documents
233            .get(doc_a)
234            .ok_or_else(|| format!("document '{}' not found", doc_a))?;
235        let profile_b = self
236            .documents
237            .get(doc_b)
238            .ok_or_else(|| format!("document '{}' not found", doc_b))?;
239
240        // Build TF-IDF vectors keyed by term.
241        let vec_a = self.tfidf_vector(profile_a);
242        let vec_b = self.tfidf_vector(profile_b);
243
244        // Compute dot product over shared terms.
245        let mut dot = 0.0_f64;
246        for (term, wa) in &vec_a {
247            if let Some(wb) = vec_b.get(term) {
248                dot += wa * wb;
249            }
250        }
251
252        let mag_a = vec_a.values().map(|v| v * v).sum::<f64>().sqrt();
253        let mag_b = vec_b.values().map(|v| v * v).sum::<f64>().sqrt();
254
255        if mag_a == 0.0 || mag_b == 0.0 {
256            return Ok(0.0);
257        }
258
259        Ok(dot / (mag_a * mag_b))
260    }
261
262    /// Number of documents in the corpus.
263    pub fn doc_count(&self) -> usize {
264        self.total_docs as usize
265    }
266
267    /// Number of unique terms across all documents.
268    pub fn vocab_size(&self) -> usize {
269        self.doc_freq.len()
270    }
271
272    /// Aggregate statistics snapshot.
273    pub fn stats(&self) -> TermWeighterStats {
274        TermWeighterStats {
275            total_docs: self.total_docs,
276            vocab_size: self.vocab_size(),
277            avg_doc_length: self.avg_doc_length,
278            scheme: self.config.scheme,
279        }
280    }
281
282    // ---- private helpers ----
283
284    fn compute_tf(&self, count: u64, total: u64) -> f64 {
285        if total == 0 {
286            return 0.0;
287        }
288        count as f64 / total as f64
289    }
290
291    fn compute_bm25(&self, count: u64, doc_len: u64, idf_val: f64) -> f64 {
292        let tf = count as f64;
293        let k1 = self.config.bm25_k1;
294        let b = self.config.bm25_b;
295        let dl = doc_len as f64;
296        let avgdl = if self.avg_doc_length > 0.0 {
297            self.avg_doc_length
298        } else {
299            1.0
300        };
301
302        let numerator = tf * (k1 + 1.0);
303        let denominator = tf + k1 * (1.0 - b + b * (dl / avgdl));
304
305        idf_val * numerator / denominator
306    }
307
308    fn tfidf_vector(&self, profile: &DocumentProfile) -> HashMap<String, f64> {
309        let mut vec = HashMap::with_capacity(profile.term_counts.len());
310        for (term, &count) in &profile.term_counts {
311            let tf_val = self.compute_tf(count, profile.total_terms);
312            let idf_val = self.idf(term);
313            vec.insert(term.clone(), tf_val * idf_val);
314        }
315        vec
316    }
317
318    fn recompute_avg_doc_length(&mut self) {
319        if self.total_docs == 0 {
320            self.avg_doc_length = 0.0;
321            return;
322        }
323        let total_terms: u64 = self.documents.values().map(|d| d.total_terms).sum();
324        self.avg_doc_length = total_terms as f64 / self.total_docs as f64;
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Tests
330// ---------------------------------------------------------------------------
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    fn default_tfidf_weighter() -> SemanticTermWeighter {
336        SemanticTermWeighter::new(WeighterConfig::default())
337    }
338
339    fn bm25_weighter() -> SemanticTermWeighter {
340        SemanticTermWeighter::new(WeighterConfig {
341            scheme: WeightingScheme::Bm25,
342            ..Default::default()
343        })
344    }
345
346    fn binary_weighter() -> SemanticTermWeighter {
347        SemanticTermWeighter::new(WeighterConfig {
348            scheme: WeightingScheme::Binary,
349            ..Default::default()
350        })
351    }
352
353    // -- basic add / remove --
354
355    #[test]
356    fn test_add_single_document() {
357        let mut w = default_tfidf_weighter();
358        w.add_document("d1", &["hello", "world"]);
359        assert_eq!(w.doc_count(), 1);
360        assert_eq!(w.vocab_size(), 2);
361    }
362
363    #[test]
364    fn test_add_multiple_documents() {
365        let mut w = default_tfidf_weighter();
366        w.add_document("d1", &["hello", "world"]);
367        w.add_document("d2", &["foo", "bar", "baz"]);
368        assert_eq!(w.doc_count(), 2);
369        assert_eq!(w.vocab_size(), 5);
370    }
371
372    #[test]
373    fn test_add_document_replaces_existing() {
374        let mut w = default_tfidf_weighter();
375        w.add_document("d1", &["hello", "world"]);
376        w.add_document("d1", &["foo"]);
377        assert_eq!(w.doc_count(), 1);
378        assert_eq!(w.vocab_size(), 1);
379    }
380
381    #[test]
382    fn test_remove_document_returns_true() {
383        let mut w = default_tfidf_weighter();
384        w.add_document("d1", &["hello"]);
385        assert!(w.remove_document("d1"));
386        assert_eq!(w.doc_count(), 0);
387    }
388
389    #[test]
390    fn test_remove_nonexistent_returns_false() {
391        let mut w = default_tfidf_weighter();
392        assert!(!w.remove_document("nope"));
393    }
394
395    #[test]
396    fn test_remove_updates_vocab() {
397        let mut w = default_tfidf_weighter();
398        w.add_document("d1", &["alpha", "beta"]);
399        w.add_document("d2", &["beta", "gamma"]);
400        w.remove_document("d1");
401        // "alpha" should be gone, "beta" and "gamma" remain
402        assert_eq!(w.vocab_size(), 2);
403    }
404
405    // -- TF --
406
407    #[test]
408    fn test_tf_present_term() {
409        let mut w = default_tfidf_weighter();
410        w.add_document("d1", &["a", "b", "a", "c"]);
411        let tf = w.tf("a", "d1");
412        assert!(tf.is_some());
413        let val = tf.expect("tf should be some");
414        assert!((val - 0.5).abs() < 1e-9, "expected 2/4 = 0.5, got {}", val);
415    }
416
417    #[test]
418    fn test_tf_absent_term() {
419        let mut w = default_tfidf_weighter();
420        w.add_document("d1", &["a", "b"]);
421        let tf = w.tf("z", "d1");
422        assert!(tf.is_some());
423        let val = tf.expect("tf should be some");
424        assert!((val - 0.0).abs() < 1e-9);
425    }
426
427    #[test]
428    fn test_tf_missing_doc() {
429        let w = default_tfidf_weighter();
430        assert!(w.tf("a", "nope").is_none());
431    }
432
433    // -- IDF --
434
435    #[test]
436    fn test_idf_unseen_term() {
437        let mut w = default_tfidf_weighter();
438        w.add_document("d1", &["a"]);
439        // df=0, N=1 => ln(2/1)+1 = ln(2)+1
440        let val = w.idf("z");
441        let expected = (2.0_f64 / 1.0).ln() + 1.0;
442        assert!((val - expected).abs() < 1e-9, "got {}", val);
443    }
444
445    #[test]
446    fn test_idf_common_term() {
447        let mut w = default_tfidf_weighter();
448        w.add_document("d1", &["a", "b"]);
449        w.add_document("d2", &["a", "c"]);
450        // df=2, N=2 => ln(3/3)+1 = 1.0
451        let val = w.idf("a");
452        let expected = (3.0_f64 / 3.0).ln() + 1.0;
453        assert!((val - expected).abs() < 1e-9, "got {}", val);
454    }
455
456    #[test]
457    fn test_idf_rare_term() {
458        let mut w = default_tfidf_weighter();
459        w.add_document("d1", &["a"]);
460        w.add_document("d2", &["b"]);
461        w.add_document("d3", &["c"]);
462        // df=1, N=3 => ln(4/2)+1 = ln(2)+1
463        let val = w.idf("a");
464        let expected = (4.0_f64 / 2.0).ln() + 1.0;
465        assert!((val - expected).abs() < 1e-9, "got {}", val);
466    }
467
468    // -- TF-IDF weighting --
469
470    #[test]
471    fn test_tfidf_weight_terms() {
472        let mut w = default_tfidf_weighter();
473        w.add_document("d1", &["rust", "rust", "code"]);
474        w.add_document("d2", &["code", "python"]);
475
476        let weights = w.weight_terms("d1").expect("should succeed");
477        assert_eq!(weights.len(), 2); // "rust" and "code"
478
479        for tw in &weights {
480            assert!(tw.weight > 0.0, "weight should be positive: {}", tw.term);
481            assert!(tw.tf > 0.0);
482            assert!(tw.idf > 0.0);
483        }
484    }
485
486    #[test]
487    fn test_tfidf_missing_doc() {
488        let w = default_tfidf_weighter();
489        let res = w.weight_terms("nope");
490        assert!(res.is_err());
491    }
492
493    // -- BM25 --
494
495    #[test]
496    fn test_bm25_weight_terms() {
497        let mut w = bm25_weighter();
498        w.add_document("d1", &["a", "b", "a"]);
499        w.add_document("d2", &["b", "c"]);
500
501        let weights = w.weight_terms("d1").expect("should succeed");
502        assert!(!weights.is_empty());
503        for tw in &weights {
504            assert!(tw.weight > 0.0, "bm25 weight should be > 0 for {}", tw.term);
505        }
506    }
507
508    #[test]
509    fn test_bm25_score_present() {
510        let mut w = bm25_weighter();
511        w.add_document("d1", &["a", "b", "a"]);
512        let score = w.bm25_score("a", "d1");
513        assert!(score.is_some());
514        assert!(score.expect("some") > 0.0);
515    }
516
517    #[test]
518    fn test_bm25_score_absent_term() {
519        let mut w = bm25_weighter();
520        w.add_document("d1", &["a", "b"]);
521        let score = w.bm25_score("z", "d1").expect("some");
522        // term not in any doc, but idf still > 0 due to smoothing; count = 0 => numerator = 0
523        assert!((score - 0.0).abs() < 1e-9);
524    }
525
526    #[test]
527    fn test_bm25_score_missing_doc() {
528        let w = bm25_weighter();
529        assert!(w.bm25_score("a", "nope").is_none());
530    }
531
532    #[test]
533    fn test_bm25_longer_doc_lower_score() {
534        // BM25 length normalization: same term count in a longer doc should score lower.
535        let mut w = bm25_weighter();
536        w.add_document("short", &["a", "a"]);
537        w.add_document("long", &["a", "a", "b", "c", "d", "e", "f", "g"]);
538
539        let s_short = w.bm25_score("a", "short").expect("some");
540        let s_long = w.bm25_score("a", "long").expect("some");
541        assert!(
542            s_short > s_long,
543            "short doc ({}) should score higher than long doc ({})",
544            s_short,
545            s_long
546        );
547    }
548
549    #[test]
550    fn test_bm25_custom_params() {
551        let mut w = SemanticTermWeighter::new(WeighterConfig {
552            scheme: WeightingScheme::Bm25,
553            bm25_k1: 2.0,
554            bm25_b: 0.5,
555        });
556        w.add_document("d1", &["x", "y", "x"]);
557        let score = w.bm25_score("x", "d1").expect("some");
558        assert!(score > 0.0);
559    }
560
561    // -- Binary --
562
563    #[test]
564    fn test_binary_weight_terms() {
565        let mut w = binary_weighter();
566        w.add_document("d1", &["a", "b", "a"]);
567        let weights = w.weight_terms("d1").expect("should succeed");
568        for tw in &weights {
569            assert!(
570                (tw.weight - 1.0).abs() < 1e-9,
571                "binary weight should be 1.0, got {}",
572                tw.weight
573            );
574        }
575    }
576
577    #[test]
578    fn test_binary_no_extra_terms() {
579        let mut w = binary_weighter();
580        w.add_document("d1", &["a", "b"]);
581        let weights = w.weight_terms("d1").expect("should succeed");
582        assert_eq!(weights.len(), 2);
583    }
584
585    // -- similarity --
586
587    #[test]
588    fn test_similarity_identical_docs() {
589        let mut w = default_tfidf_weighter();
590        w.add_document("d1", &["a", "b", "c"]);
591        w.add_document("d2", &["a", "b", "c"]);
592        let sim = w.similarity("d1", "d2").expect("ok");
593        assert!(
594            (sim - 1.0).abs() < 1e-9,
595            "identical docs should have similarity 1.0, got {}",
596            sim
597        );
598    }
599
600    #[test]
601    fn test_similarity_disjoint_docs() {
602        let mut w = default_tfidf_weighter();
603        w.add_document("d1", &["a", "b"]);
604        w.add_document("d2", &["c", "d"]);
605        let sim = w.similarity("d1", "d2").expect("ok");
606        assert!(
607            sim.abs() < 1e-9,
608            "disjoint docs should have similarity 0.0, got {}",
609            sim
610        );
611    }
612
613    #[test]
614    fn test_similarity_partial_overlap() {
615        let mut w = default_tfidf_weighter();
616        w.add_document("d1", &["a", "b", "c"]);
617        w.add_document("d2", &["b", "c", "d"]);
618        let sim = w.similarity("d1", "d2").expect("ok");
619        assert!(sim > 0.0 && sim < 1.0, "partial overlap: {}", sim);
620    }
621
622    #[test]
623    fn test_similarity_missing_doc() {
624        let mut w = default_tfidf_weighter();
625        w.add_document("d1", &["a"]);
626        assert!(w.similarity("d1", "nope").is_err());
627        assert!(w.similarity("nope", "d1").is_err());
628    }
629
630    // -- stats / edge cases --
631
632    #[test]
633    fn test_stats_accuracy() {
634        let mut w = default_tfidf_weighter();
635        w.add_document("d1", &["a", "b"]);
636        w.add_document("d2", &["c", "d", "e"]);
637        let s = w.stats();
638        assert_eq!(s.total_docs, 2);
639        assert_eq!(s.vocab_size, 5);
640        assert!((s.avg_doc_length - 2.5).abs() < 1e-9);
641        assert_eq!(s.scheme, WeightingScheme::TfIdf);
642    }
643
644    #[test]
645    fn test_empty_corpus() {
646        let w = default_tfidf_weighter();
647        assert_eq!(w.doc_count(), 0);
648        assert_eq!(w.vocab_size(), 0);
649        let s = w.stats();
650        assert_eq!(s.total_docs, 0);
651        assert!((s.avg_doc_length - 0.0).abs() < 1e-9);
652    }
653
654    #[test]
655    fn test_single_doc_corpus() {
656        let mut w = default_tfidf_weighter();
657        w.add_document("d1", &["only"]);
658        assert_eq!(w.doc_count(), 1);
659        assert_eq!(w.vocab_size(), 1);
660        let wts = w.weight_terms("d1").expect("ok");
661        assert_eq!(wts.len(), 1);
662        assert!(wts[0].weight > 0.0);
663    }
664
665    #[test]
666    fn test_duplicate_terms_in_document() {
667        let mut w = default_tfidf_weighter();
668        w.add_document("d1", &["dup", "dup", "dup", "other"]);
669        assert_eq!(w.vocab_size(), 2);
670        let tf_dup = w.tf("dup", "d1").expect("some");
671        assert!((tf_dup - 0.75).abs() < 1e-9, "3/4 = 0.75, got {}", tf_dup);
672    }
673
674    #[test]
675    fn test_avg_doc_length_updates_on_remove() {
676        let mut w = default_tfidf_weighter();
677        w.add_document("d1", &["a", "b"]); // len 2
678        w.add_document("d2", &["c", "d", "e", "f"]); // len 4
679                                                     // avg = 3.0
680        assert!((w.stats().avg_doc_length - 3.0).abs() < 1e-9);
681        w.remove_document("d2");
682        // avg = 2.0
683        assert!((w.stats().avg_doc_length - 2.0).abs() < 1e-9);
684    }
685
686    #[test]
687    fn test_empty_document() {
688        let mut w = default_tfidf_weighter();
689        w.add_document("empty", &[]);
690        assert_eq!(w.doc_count(), 1);
691        assert_eq!(w.vocab_size(), 0);
692        let wts = w.weight_terms("empty").expect("ok");
693        assert!(wts.is_empty());
694    }
695
696    #[test]
697    fn test_vocab_size_after_full_removal() {
698        let mut w = default_tfidf_weighter();
699        w.add_document("d1", &["a"]);
700        w.remove_document("d1");
701        assert_eq!(w.vocab_size(), 0);
702    }
703
704    #[test]
705    fn test_idf_empty_corpus() {
706        let w = default_tfidf_weighter();
707        // N=0, df=0 => ln(1/1)+1 = 1.0
708        let val = w.idf("anything");
709        assert!((val - 1.0).abs() < 1e-9, "got {}", val);
710    }
711
712    #[test]
713    fn test_weight_terms_sorted_descending() {
714        let mut w = default_tfidf_weighter();
715        // "a" appears more often than "b", so should have higher tf and thus higher weight
716        w.add_document("d1", &["a", "a", "a", "b"]);
717        let wts = w.weight_terms("d1").expect("ok");
718        assert!(wts.len() == 2);
719        assert!(
720            wts[0].weight >= wts[1].weight,
721            "should be sorted descending"
722        );
723    }
724
725    #[test]
726    fn test_bm25_saturation() {
727        // Increasing term count should increase score but with diminishing returns.
728        let mut w = bm25_weighter();
729        w.add_document("d1", &["a"]);
730        w.add_document("d2", &["a", "a"]);
731        w.add_document("d3", &["a", "a", "a", "a", "a", "a", "a", "a", "a", "a"]);
732
733        let s1 = w.bm25_score("a", "d1").expect("some");
734        let s2 = w.bm25_score("a", "d2").expect("some");
735        let s3 = w.bm25_score("a", "d3").expect("some");
736
737        // All should be positive and s1 < s2 < s3 (more occurrences = higher, but saturating)
738        assert!(s1 > 0.0);
739        assert!(s2 > 0.0);
740        assert!(s3 > 0.0);
741
742        // The increment should diminish
743        let delta_1_2 = s2 - s1;
744        let delta_2_3 = s3 - s2;
745        // With length normalization in play and varying doc lengths, the increments
746        // should generally show saturation. We just verify they're all positive.
747        assert!(delta_1_2 > 0.0 || delta_2_3 > 0.0, "scores should differ");
748    }
749
750    #[test]
751    fn test_similarity_is_symmetric() {
752        let mut w = default_tfidf_weighter();
753        w.add_document("d1", &["a", "b", "c"]);
754        w.add_document("d2", &["b", "c", "d"]);
755        let s1 = w.similarity("d1", "d2").expect("ok");
756        let s2 = w.similarity("d2", "d1").expect("ok");
757        assert!((s1 - s2).abs() < 1e-12, "similarity should be symmetric");
758    }
759
760    #[test]
761    fn test_large_corpus() {
762        let mut w = default_tfidf_weighter();
763        for i in 0..100 {
764            let id = format!("doc_{}", i);
765            let terms: Vec<&str> = if i % 2 == 0 {
766                vec!["common", "even"]
767            } else {
768                vec!["common", "odd"]
769            };
770            w.add_document(&id, &terms);
771        }
772        assert_eq!(w.doc_count(), 100);
773        assert_eq!(w.vocab_size(), 3); // common, even, odd
774        assert!((w.stats().avg_doc_length - 2.0).abs() < 1e-9);
775    }
776
777    #[test]
778    fn test_default_config() {
779        let cfg = WeighterConfig::default();
780        assert_eq!(cfg.scheme, WeightingScheme::TfIdf);
781        assert!((cfg.bm25_k1 - 1.2).abs() < 1e-9);
782        assert!((cfg.bm25_b - 0.75).abs() < 1e-9);
783    }
784}