Skip to main content

semantic_memory/
hubness.rs

1// FFI: the workspace forbids unsafe, but this module calls a C kernel via FFI.
2// The `unsafe` is isolated to a single `extern "C"` call with validated inputs
3// (length + emptiness checked before the call).  The C side returns NaN for
4// degenerate inputs (zero norms), which we convert back to `None`.
5#![allow(unsafe_code)]
6
7/// Deterministic CPU-only hubness scoring over a set of dense embeddings.
8///
9/// Hubness is the tendency of a small number of vectors to appear in the
10/// k-nearest-neighbour lists of many other vectors in high-dimensional spaces.
11/// This module computes a lightweight hub score for each item given a flat
12/// slice of (id, embedding) pairs.
13
14/// Per-item hubness score.
15#[derive(Debug, Clone, PartialEq)]
16pub struct HubnessScore {
17    pub item_id: String,
18    /// How many times this item appeared in another item's top-k neighbour list.
19    pub neighbor_hits: usize,
20    /// `neighbor_hits / max_possible_hits` where `max_possible_hits = n - 1`.
21    /// Zero when there are fewer than two items.
22    pub normalized_score: f32,
23}
24
25// FFI to the C SIMD kernel (c-kernels/similarity.c, compiled via build.rs).
26// The C function returns NaN for zero-norm or degenerate inputs; we convert
27// NaN back to `None` so the public API is unchanged.
28extern "C" {
29    fn sm_cosine_similarity(a: *const f32, b: *const f32, n: usize) -> f32;
30}
31
32/// Cosine similarity between two equal-length vectors.
33///
34/// Returns `None` if the slices have different lengths, either has zero norm,
35/// or either is empty.
36///
37/// The actual computation is delegated to a C SIMD kernel
38/// (`c-kernels/similarity.c`) compiled with optimization enabled. AVX2/FMA
39/// flags are used only when Cargo reports those target features.
40pub 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
51/// Compute hubness scores for a collection of embeddings.
52///
53/// For each vector, the top-`top_k` most similar other vectors (by cosine
54/// similarity) are found. Each neighbour's `neighbor_hits` counter is
55/// incremented. Pairs whose embeddings differ in dimension or have a zero
56/// norm are skipped rather than panicked.
57///
58/// The returned list is sorted descending by `neighbor_hits`, with ties
59/// broken by `item_id` ascending for full determinism.
60pub 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        // Nothing to accumulate; still return correctly-zeroed scores.
69        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; // explicitly used below in the normal path
84        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        // Collect similarities to all other items with matching dimension.
92        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        // Sort descending by similarity; stable tie-break by item_id ascending.
100        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        // Increment hit counter for each top-k neighbour.
107        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    // ── cosine_similarity ────────────────────────────────────────────────────
153
154    #[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    // ── compute_hubness_scores ───────────────────────────────────────────────
196
197    #[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        // "center" is close to both "near_a" and "near_b".
213        // "isolated" is orthogonal to everything.
214        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]), // wrong dim — skipped
242            mk("c", vec![1.0, 0.0]),
243        ];
244        // Should not panic; "b" may get 0 hits because its pairs are skipped.
245        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        // "b" has a mismatched dim so it cannot appear in anyone's neighbour list.
249        assert_eq!(b.neighbor_hits, 0);
250    }
251
252    #[test]
253    fn deterministic_tie_ordering() {
254        // All three vectors are identical, so ties in neighbor_hits are resolved by item_id.
255        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        // All should have the same neighbor_hits; order must be alpha < beta < gamma.
262        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}