1use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum WeightingScheme {
15 TfIdf,
17 Bm25,
19 Binary,
21}
22
23#[derive(Debug, Clone)]
25pub struct WeighterConfig {
26 pub scheme: WeightingScheme,
28 pub bm25_k1: f64,
30 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#[derive(Debug, Clone)]
46pub struct TermWeight {
47 pub term: String,
49 pub weight: f64,
51 pub tf: f64,
53 pub idf: f64,
55}
56
57#[derive(Debug, Clone)]
59pub struct DocumentProfile {
60 pub doc_id: String,
62 pub term_counts: HashMap<String, u64>,
64 pub total_terms: u64,
66}
67
68#[derive(Debug, Clone)]
70pub struct TermWeighterStats {
71 pub total_docs: u64,
73 pub vocab_size: usize,
75 pub avg_doc_length: f64,
77 pub scheme: WeightingScheme,
79}
80
81pub struct SemanticTermWeighter {
87 config: WeighterConfig,
88 documents: HashMap<String, DocumentProfile>,
89 doc_freq: HashMap<String, u64>,
91 total_docs: u64,
92 avg_doc_length: f64,
93}
94
95impl SemanticTermWeighter {
96 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 pub fn add_document(&mut self, doc_id: &str, terms: &[&str]) {
114 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 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 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 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 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 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 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 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 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 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 let vec_a = self.tfidf_vector(profile_a);
242 let vec_b = self.tfidf_vector(profile_b);
243
244 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 pub fn doc_count(&self) -> usize {
264 self.total_docs as usize
265 }
266
267 pub fn vocab_size(&self) -> usize {
269 self.doc_freq.len()
270 }
271
272 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 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#[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 #[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 assert_eq!(w.vocab_size(), 2);
403 }
404
405 #[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 #[test]
436 fn test_idf_unseen_term() {
437 let mut w = default_tfidf_weighter();
438 w.add_document("d1", &["a"]);
439 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 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 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 #[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); 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 #[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 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 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 #[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 #[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 #[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"]); w.add_document("d2", &["c", "d", "e", "f"]); assert!((w.stats().avg_doc_length - 3.0).abs() < 1e-9);
681 w.remove_document("d2");
682 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 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 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 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 assert!(s1 > 0.0);
739 assert!(s2 > 0.0);
740 assert!(s3 > 0.0);
741
742 let delta_1_2 = s2 - s1;
744 let delta_2_3 = s3 - s2;
745 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); 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}