1use std::collections::{HashMap, HashSet};
10
11#[derive(Clone, Debug)]
13pub struct TfIdfVector {
14 pub weights: HashMap<String, f64>,
16 pub magnitude: f64,
18}
19
20#[inline]
28pub fn build_vector(tokens: &[String], idf_weights: &HashMap<String, f64>) -> TfIdfVector {
29 let mut tf: HashMap<String, f64> = HashMap::new();
31 for token in tokens {
32 *tf.entry(token.clone()).or_insert(0.0) += 1.0;
33 }
34
35 let doc_len = tokens.len() as f64;
37 let mut weights = HashMap::new();
38 let mut magnitude_sq = 0.0;
39
40 for (term, count) in &tf {
41 let tf_val = count / doc_len;
42 let idf = idf_weights.get(term).copied().unwrap_or(0.0);
43 let w = tf_val * idf;
44 magnitude_sq += w * w;
45 weights.insert(term.clone(), w);
46 }
47
48 TfIdfVector {
49 weights,
50 magnitude: magnitude_sq.sqrt(),
51 }
52}
53
54#[inline]
59pub fn compute_idf(documents: &[Vec<String>]) -> HashMap<String, f64> {
60 let n = documents.len() as f64;
61 let mut df: HashMap<String, usize> = HashMap::new();
62
63 for doc in documents {
64 let unique_terms: HashSet<&String> = doc.iter().collect();
65 for term in unique_terms {
66 *df.entry(term.clone()).or_insert(0) += 1;
67 }
68 }
69
70 let mut idf = HashMap::new();
71 for (term, count) in &df {
72 let idf_val = ((1.0 + n) / (1.0 + *count as f64)).ln() + 1.0;
73 idf.insert(term.clone(), idf_val);
74 }
75
76 idf
77}
78
79#[inline]
83pub fn cosine_similarity(a: &TfIdfVector, b: &TfIdfVector) -> f64 {
84 if a.magnitude == 0.0 || b.magnitude == 0.0 {
85 return if a.weights.is_empty() && b.weights.is_empty() {
86 1.0
87 } else {
88 0.0
89 };
90 }
91
92 let dot_product: f64 = a
94 .weights
95 .iter()
96 .filter_map(|(term, w_a)| b.weights.get(term).map(|w_b| w_a * w_b))
97 .sum();
98
99 dot_product / (a.magnitude * b.magnitude)
100}
101
102#[inline]
107pub fn similarity(a: &str, b: &str) -> f64 {
108 let tokens_a = tokenize(a);
109 let tokens_b = tokenize(b);
110 let documents = vec![tokens_a, tokens_b];
111 let idf = compute_idf(&documents);
112 let vec_a = build_vector(&documents[0], &idf);
113 let vec_b = build_vector(&documents[1], &idf);
114 cosine_similarity(&vec_a, &vec_b) + 0.0
117}
118
119#[inline]
121pub fn tokenize(text: &str) -> Vec<String> {
122 text.split(|c: char| c.is_whitespace() || c.is_ascii_punctuation())
123 .filter(|s| !s.is_empty())
124 .map(|s| s.to_lowercase())
125 .collect()
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn identical_texts() {
134 let s = similarity("the quick brown fox", "the quick brown fox");
135 assert!((s - 1.0).abs() < 0.01, "identical should be ~1.0, got {s}");
136 }
137
138 #[test]
139 fn completely_different() {
140 let s = similarity("hello world", "completely unrelated");
141 assert!(s < 0.3, "expected low similarity, got {s}");
142 }
143
144 #[test]
145 fn partial_match() {
146 let s = similarity("the quick brown fox", "the quick blue fox");
147 assert!(s > 0.3 && s < 1.0, "expected moderate, got {s}");
148 }
149
150 #[test]
151 fn empty_strings() {
152 let s = similarity("", "");
154 assert!((s - 1.0).abs() < f64::EPSILON);
155 }
156
157 #[test]
158 fn symmetric() {
159 let a = similarity("the cat sat on the mat", "the dog sat on the rug");
160 let b = similarity("the dog sat on the rug", "the cat sat on the mat");
161 assert!((a - b).abs() < f64::EPSILON);
162 }
163}