1#[derive(Debug, Clone, PartialEq)]
10pub struct HubnessScore {
11 pub item_id: String,
12 pub neighbor_hits: usize,
14 pub normalized_score: f32,
17}
18
19pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
24 if a.len() != b.len() || a.is_empty() {
25 return None;
26 }
27 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
28 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
29 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
30 if norm_a == 0.0 || norm_b == 0.0 {
31 return None;
32 }
33 Some(dot / (norm_a * norm_b))
34}
35
36pub fn compute_hubness_scores(
46 embeddings: &[(String, Vec<f32>)],
47 top_k: usize,
48) -> Vec<HubnessScore> {
49 let n = embeddings.len();
50 let mut hits = vec![0usize; n];
51
52 if top_k == 0 || n < 2 {
53 let max_hits = n.saturating_sub(1);
55 let mut scores: Vec<HubnessScore> = embeddings
56 .iter()
57 .map(|(id, _)| HubnessScore {
58 item_id: id.clone(),
59 neighbor_hits: 0,
60 normalized_score: 0.0,
61 })
62 .collect();
63 scores.sort_unstable_by(|a, b| {
64 b.neighbor_hits
65 .cmp(&a.neighbor_hits)
66 .then_with(|| a.item_id.cmp(&b.item_id))
67 });
68 let _ = max_hits; return scores;
70 }
71
72 let max_possible_hits = n.saturating_sub(1);
73
74 for i in 0..n {
75 let (_, ref qi) = embeddings[i];
76 let mut sims: Vec<(f32, &str)> = embeddings
78 .iter()
79 .enumerate()
80 .filter(|(j, _)| *j != i)
81 .filter_map(|(_, (id, emb))| cosine_similarity(qi, emb).map(|s| (s, id.as_str())))
82 .collect();
83
84 sims.sort_unstable_by(|a, b| {
86 b.0.partial_cmp(&a.0)
87 .unwrap_or(std::cmp::Ordering::Equal)
88 .then_with(|| a.1.cmp(b.1))
89 });
90
91 for (_, neighbour_id) in sims.iter().take(top_k) {
93 if let Some(j) = embeddings
94 .iter()
95 .position(|(id, _)| id.as_str() == *neighbour_id)
96 {
97 hits[j] += 1;
98 }
99 }
100 }
101
102 let mut scores: Vec<HubnessScore> = embeddings
103 .iter()
104 .enumerate()
105 .map(|(i, (id, _))| {
106 let h = hits[i];
107 let norm = if max_possible_hits == 0 {
108 0.0
109 } else {
110 h as f32 / max_possible_hits as f32
111 };
112 HubnessScore {
113 item_id: id.clone(),
114 neighbor_hits: h,
115 normalized_score: norm,
116 }
117 })
118 .collect();
119
120 scores.sort_unstable_by(|a, b| {
121 b.neighbor_hits
122 .cmp(&a.neighbor_hits)
123 .then_with(|| a.item_id.cmp(&b.item_id))
124 });
125
126 scores
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn mk(id: &str, v: Vec<f32>) -> (String, Vec<f32>) {
134 (id.to_string(), v)
135 }
136
137 #[test]
140 fn cosine_rejects_zero_vector() {
141 assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), None);
142 assert_eq!(cosine_similarity(&[1.0, 0.0], &[0.0, 0.0]), None);
143 assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), None);
144 }
145
146 #[test]
147 fn cosine_rejects_empty() {
148 assert_eq!(cosine_similarity(&[], &[]), None);
149 }
150
151 #[test]
152 fn cosine_rejects_dimension_mismatch() {
153 assert_eq!(cosine_similarity(&[1.0], &[1.0, 0.0]), None);
154 }
155
156 #[test]
157 fn cosine_identical_vectors() {
158 let v = [1.0_f32, 2.0, 3.0];
159 let s = cosine_similarity(&v, &v).unwrap();
160 assert!((s - 1.0).abs() < 1e-6, "expected 1.0, got {s}");
161 }
162
163 #[test]
164 fn cosine_orthogonal_vectors() {
165 let s = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).unwrap();
166 assert!(s.abs() < 1e-6, "expected 0.0, got {s}");
167 }
168
169 #[test]
172 fn empty_input_returns_empty() {
173 let scores = compute_hubness_scores(&[], 5);
174 assert!(scores.is_empty());
175 }
176
177 #[test]
178 fn top_k_zero_returns_zero_hits() {
179 let data = vec![mk("a", vec![1.0, 0.0]), mk("b", vec![0.0, 1.0])];
180 let scores = compute_hubness_scores(&data, 0);
181 assert!(scores.iter().all(|s| s.neighbor_hits == 0));
182 }
183
184 #[test]
185 fn central_vector_has_higher_or_equal_hubness() {
186 let data = vec![
189 mk("center", vec![1.0, 1.0, 0.0]),
190 mk("near_a", vec![1.0, 0.9, 0.0]),
191 mk("near_b", vec![0.9, 1.0, 0.0]),
192 mk("isolated", vec![0.0, 0.0, 1.0]),
193 ];
194 let scores = compute_hubness_scores(&data, 1);
195 let center_hits = scores
196 .iter()
197 .find(|s| s.item_id == "center")
198 .unwrap()
199 .neighbor_hits;
200 let isolated_hits = scores
201 .iter()
202 .find(|s| s.item_id == "isolated")
203 .unwrap()
204 .neighbor_hits;
205 assert!(
206 center_hits >= isolated_hits,
207 "center({center_hits}) should be >= isolated({isolated_hits})"
208 );
209 }
210
211 #[test]
212 fn dimension_mismatch_is_skipped_not_panicked() {
213 let data = vec![
214 mk("a", vec![1.0, 0.0]),
215 mk("b", vec![1.0, 0.0, 0.0]), mk("c", vec![1.0, 0.0]),
217 ];
218 let scores = compute_hubness_scores(&data, 1);
220 assert_eq!(scores.len(), 3);
221 let b = scores.iter().find(|s| s.item_id == "b").unwrap();
222 assert_eq!(b.neighbor_hits, 0);
224 }
225
226 #[test]
227 fn deterministic_tie_ordering() {
228 let data = vec![
230 mk("gamma", vec![1.0, 0.0]),
231 mk("alpha", vec![1.0, 0.0]),
232 mk("beta", vec![1.0, 0.0]),
233 ];
234 let scores = compute_hubness_scores(&data, 2);
235 let ids: Vec<&str> = scores.iter().map(|s| s.item_id.as_str()).collect();
237 assert_eq!(ids, vec!["alpha", "beta", "gamma"]);
238 }
239
240 #[test]
241 fn normalized_score_bounded() {
242 let data = vec![
243 mk("a", vec![1.0, 0.0]),
244 mk("b", vec![0.5, 0.5]),
245 mk("c", vec![0.0, 1.0]),
246 ];
247 let scores = compute_hubness_scores(&data, 2);
248 for s in &scores {
249 assert!(
250 s.normalized_score >= 0.0 && s.normalized_score <= 1.0,
251 "{}: normalized_score {} out of [0,1]",
252 s.item_id,
253 s.normalized_score
254 );
255 }
256 }
257
258 #[test]
259 fn single_item_returns_zero_hits() {
260 let data = vec![mk("only", vec![1.0, 0.0])];
261 let scores = compute_hubness_scores(&data, 1);
262 assert_eq!(scores.len(), 1);
263 assert_eq!(scores[0].neighbor_hits, 0);
264 assert_eq!(scores[0].normalized_score, 0.0);
265 }
266}