1#![allow(unsafe_code)]
6
7#[derive(Debug, Clone, PartialEq)]
16pub struct HubnessScore {
17 pub item_id: String,
18 pub neighbor_hits: usize,
20 pub normalized_score: f32,
23}
24
25extern "C" {
29 fn sm_cosine_similarity(a: *const f32, b: *const f32, n: usize) -> f32;
30}
31
32pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
41 if a.len() != b.len() || a.is_empty() {
42 return None;
43 }
44 let result = unsafe { sm_cosine_similarity(a.as_ptr(), b.as_ptr(), a.len()) };
45 if result.is_nan() {
46 return None;
47 }
48 Some(result)
49}
50
51pub fn compute_hubness_scores(
61 embeddings: &[(String, Vec<f32>)],
62 top_k: usize,
63) -> Vec<HubnessScore> {
64 let n = embeddings.len();
65 let mut hits = vec![0usize; n];
66
67 if top_k == 0 || n < 2 {
68 let max_hits = n.saturating_sub(1);
70 let mut scores: Vec<HubnessScore> = embeddings
71 .iter()
72 .map(|(id, _)| HubnessScore {
73 item_id: id.clone(),
74 neighbor_hits: 0,
75 normalized_score: 0.0,
76 })
77 .collect();
78 scores.sort_unstable_by(|a, b| {
79 b.neighbor_hits
80 .cmp(&a.neighbor_hits)
81 .then_with(|| a.item_id.cmp(&b.item_id))
82 });
83 let _ = max_hits; return scores;
85 }
86
87 let max_possible_hits = n.saturating_sub(1);
88
89 for i in 0..n {
90 let (_, ref qi) = embeddings[i];
91 let mut sims: Vec<(f32, &str)> = embeddings
93 .iter()
94 .enumerate()
95 .filter(|(j, _)| *j != i)
96 .filter_map(|(_, (id, emb))| cosine_similarity(qi, emb).map(|s| (s, id.as_str())))
97 .collect();
98
99 sims.sort_unstable_by(|a, b| {
101 b.0.partial_cmp(&a.0)
102 .unwrap_or(std::cmp::Ordering::Equal)
103 .then_with(|| a.1.cmp(b.1))
104 });
105
106 for (_, neighbour_id) in sims.iter().take(top_k) {
108 if let Some(j) = embeddings
109 .iter()
110 .position(|(id, _)| id.as_str() == *neighbour_id)
111 {
112 hits[j] += 1;
113 }
114 }
115 }
116
117 let mut scores: Vec<HubnessScore> = embeddings
118 .iter()
119 .enumerate()
120 .map(|(i, (id, _))| {
121 let h = hits[i];
122 let norm = if max_possible_hits == 0 {
123 0.0
124 } else {
125 h as f32 / max_possible_hits as f32
126 };
127 HubnessScore {
128 item_id: id.clone(),
129 neighbor_hits: h,
130 normalized_score: norm,
131 }
132 })
133 .collect();
134
135 scores.sort_unstable_by(|a, b| {
136 b.neighbor_hits
137 .cmp(&a.neighbor_hits)
138 .then_with(|| a.item_id.cmp(&b.item_id))
139 });
140
141 scores
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn mk(id: &str, v: Vec<f32>) -> (String, Vec<f32>) {
149 (id.to_string(), v)
150 }
151
152 #[test]
155 fn cosine_rejects_zero_vector() {
156 assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), None);
157 assert_eq!(cosine_similarity(&[1.0, 0.0], &[0.0, 0.0]), None);
158 assert_eq!(cosine_similarity(&[0.0, 0.0], &[0.0, 0.0]), None);
159 }
160
161 #[test]
162 fn cosine_rejects_empty() {
163 assert_eq!(cosine_similarity(&[], &[]), None);
164 }
165
166 #[test]
167 fn cosine_rejects_dimension_mismatch() {
168 assert_eq!(cosine_similarity(&[1.0], &[1.0, 0.0]), None);
169 }
170
171 #[test]
172 fn cosine_identical_vectors() {
173 let v = [1.0_f32, 2.0, 3.0];
174 let s = cosine_similarity(&v, &v).unwrap();
175 assert!((s - 1.0).abs() < 1e-6, "expected 1.0, got {s}");
176 }
177
178 #[test]
179 fn cosine_matches_archived_rust_reference() {
180 for dim in [1, 2, 3, 16, 128, 768] {
181 let a: Vec<f32> = (0..dim).map(|i| ((i as f32 + 1.0) * 0.017).sin()).collect();
182 let b: Vec<f32> = (0..dim).map(|i| ((i as f32 + 3.0) * 0.023).cos()).collect();
183 let c = cosine_similarity(&a, &b).unwrap();
184 let rust = crate::archive::hubness_rust::cosine_similarity(&a, &b).unwrap();
185 assert!((c - rust).abs() <= 2.0e-5, "dim={dim}: C={c}, Rust={rust}");
186 }
187 }
188
189 #[test]
190 fn cosine_orthogonal_vectors() {
191 let s = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).unwrap();
192 assert!(s.abs() < 1e-6, "expected 0.0, got {s}");
193 }
194
195 #[test]
198 fn empty_input_returns_empty() {
199 let scores = compute_hubness_scores(&[], 5);
200 assert!(scores.is_empty());
201 }
202
203 #[test]
204 fn top_k_zero_returns_zero_hits() {
205 let data = vec![mk("a", vec![1.0, 0.0]), mk("b", vec![0.0, 1.0])];
206 let scores = compute_hubness_scores(&data, 0);
207 assert!(scores.iter().all(|s| s.neighbor_hits == 0));
208 }
209
210 #[test]
211 fn central_vector_has_higher_or_equal_hubness() {
212 let data = vec![
215 mk("center", vec![1.0, 1.0, 0.0]),
216 mk("near_a", vec![1.0, 0.9, 0.0]),
217 mk("near_b", vec![0.9, 1.0, 0.0]),
218 mk("isolated", vec![0.0, 0.0, 1.0]),
219 ];
220 let scores = compute_hubness_scores(&data, 1);
221 let center_hits = scores
222 .iter()
223 .find(|s| s.item_id == "center")
224 .unwrap()
225 .neighbor_hits;
226 let isolated_hits = scores
227 .iter()
228 .find(|s| s.item_id == "isolated")
229 .unwrap()
230 .neighbor_hits;
231 assert!(
232 center_hits >= isolated_hits,
233 "center({center_hits}) should be >= isolated({isolated_hits})"
234 );
235 }
236
237 #[test]
238 fn dimension_mismatch_is_skipped_not_panicked() {
239 let data = vec![
240 mk("a", vec![1.0, 0.0]),
241 mk("b", vec![1.0, 0.0, 0.0]), mk("c", vec![1.0, 0.0]),
243 ];
244 let scores = compute_hubness_scores(&data, 1);
246 assert_eq!(scores.len(), 3);
247 let b = scores.iter().find(|s| s.item_id == "b").unwrap();
248 assert_eq!(b.neighbor_hits, 0);
250 }
251
252 #[test]
253 fn deterministic_tie_ordering() {
254 let data = vec![
256 mk("gamma", vec![1.0, 0.0]),
257 mk("alpha", vec![1.0, 0.0]),
258 mk("beta", vec![1.0, 0.0]),
259 ];
260 let scores = compute_hubness_scores(&data, 2);
261 let ids: Vec<&str> = scores.iter().map(|s| s.item_id.as_str()).collect();
263 assert_eq!(ids, vec!["alpha", "beta", "gamma"]);
264 }
265
266 #[test]
267 fn normalized_score_bounded() {
268 let data = vec![
269 mk("a", vec![1.0, 0.0]),
270 mk("b", vec![0.5, 0.5]),
271 mk("c", vec![0.0, 1.0]),
272 ];
273 let scores = compute_hubness_scores(&data, 2);
274 for s in &scores {
275 assert!(
276 s.normalized_score >= 0.0 && s.normalized_score <= 1.0,
277 "{}: normalized_score {} out of [0,1]",
278 s.item_id,
279 s.normalized_score
280 );
281 }
282 }
283
284 #[test]
285 fn single_item_returns_zero_hits() {
286 let data = vec![mk("only", vec![1.0, 0.0])];
287 let scores = compute_hubness_scores(&data, 1);
288 assert_eq!(scores.len(), 1);
289 assert_eq!(scores[0].neighbor_hits, 0);
290 assert_eq!(scores[0].normalized_score, 0.0);
291 }
292}