Skip to main content

semantic_memory/
hubness.rs

1/// Deterministic CPU-only hubness scoring over a set of dense embeddings.
2///
3/// Hubness is the tendency of a small number of vectors to appear in the
4/// k-nearest-neighbour lists of many other vectors in high-dimensional spaces.
5/// This module computes a lightweight hub score for each item given a flat
6/// slice of (id, embedding) pairs.
7
8/// Per-item hubness score.
9#[derive(Debug, Clone, PartialEq)]
10pub struct HubnessScore {
11    pub item_id: String,
12    /// How many times this item appeared in another item's top-k neighbour list.
13    pub neighbor_hits: usize,
14    /// `neighbor_hits / max_possible_hits` where `max_possible_hits = n - 1`.
15    /// Zero when there are fewer than two items.
16    pub normalized_score: f32,
17}
18
19/// Cosine similarity between two equal-length vectors.
20///
21/// Returns `None` if the slices have different lengths, either has zero norm,
22/// or either is empty.
23pub 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
36/// Compute hubness scores for a collection of embeddings.
37///
38/// For each vector, the top-`top_k` most similar other vectors (by cosine
39/// similarity) are found. Each neighbour's `neighbor_hits` counter is
40/// incremented. Pairs whose embeddings differ in dimension or have a zero
41/// norm are skipped rather than panicked.
42///
43/// The returned list is sorted descending by `neighbor_hits`, with ties
44/// broken by `item_id` ascending for full determinism.
45pub 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        // Nothing to accumulate; still return correctly-zeroed scores.
54        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; // explicitly used below in the normal path
69        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        // Collect similarities to all other items with matching dimension.
77        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        // Sort descending by similarity; stable tie-break by item_id ascending.
85        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        // Increment hit counter for each top-k neighbour.
92        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    // ── cosine_similarity ────────────────────────────────────────────────────
138
139    #[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    // ── compute_hubness_scores ───────────────────────────────────────────────
170
171    #[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        // "center" is close to both "near_a" and "near_b".
187        // "isolated" is orthogonal to everything.
188        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]), // wrong dim — skipped
216            mk("c", vec![1.0, 0.0]),
217        ];
218        // Should not panic; "b" may get 0 hits because its pairs are skipped.
219        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        // "b" has a mismatched dim so it cannot appear in anyone's neighbour list.
223        assert_eq!(b.neighbor_hits, 0);
224    }
225
226    #[test]
227    fn deterministic_tie_ordering() {
228        // All three vectors are identical, so ties in neighbor_hits are resolved by item_id.
229        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        // All should have the same neighbor_hits; order must be alpha < beta < gamma.
236        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}