Skip to main content

lc_core/
math.rs

1// src/core/math.rs
2//! Shared math utilities.
3
4/// Error type for math operations.
5#[derive(Debug, Clone, thiserror::Error)]
6#[non_exhaustive]
7pub enum MathError {
8    /// The input vectors have different lengths.
9    #[error("vector length mismatch: {0} vs {1}")]
10    LengthMismatch(usize, usize),
11}
12
13/// Compute cosine similarity between two vectors.
14///
15/// Returns a value in [-1, 1], where 1 means identical direction.
16/// Returns an error if vectors have different lengths.
17/// Returns 0.0 if either vector has zero norm (below epsilon threshold).
18///
19/// Internally computes in f64 for precision, but accepts and returns f32
20/// to match the project's embedding type.
21///
22/// # Examples
23/// ```no_run
24/// use lc_core::math::cosine_similarity;
25///
26/// let a = vec![1.0_f32, 0.0, 0.0];
27/// let b = vec![0.0_f32, 1.0, 0.0];
28/// let sim = cosine_similarity(&a, &b).unwrap();
29/// assert!((sim - 0.0).abs() < 1e-6);
30/// ```
31pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Result<f32, MathError> {
32    if a.len() != b.len() {
33        return Err(MathError::LengthMismatch(a.len(), b.len()));
34    }
35
36    // Compute in f64 for precision (M30), then cast back to f32.
37    let dot_product: f64 = a
38        .iter()
39        .zip(b.iter())
40        .map(|(x, y)| (*x as f64) * (*y as f64))
41        .sum();
42    let norm_a: f64 = a
43        .iter()
44        .map(|x| (*x as f64) * (*x as f64))
45        .sum::<f64>()
46        .sqrt();
47    let norm_b: f64 = b
48        .iter()
49        .map(|x| (*x as f64) * (*x as f64))
50        .sum::<f64>()
51        .sqrt();
52
53    // Use a real near-zero threshold instead of f64::EPSILON: EPSILON (~2.2e-16)
54    // only ever caught an *exact* zero norm, so near-degenerate vectors slipped
55    // through and produced a blown-up cosine. Embeddings are L2-normalized to ~1.0,
56    // so anything below MIN_NORM is genuinely degenerate — return 0.0 (no direction).
57    const MIN_NORM: f64 = 1e-8;
58    if norm_a < MIN_NORM || norm_b < MIN_NORM {
59        return Ok(0.0);
60    }
61
62    Ok((dot_product / (norm_a * norm_b)) as f32)
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn test_identical_vectors() {
71        let v = vec![1.0, 2.0, 3.0];
72        let sim = cosine_similarity(&v, &v).unwrap();
73        assert!((sim - 1.0).abs() < 1e-6);
74    }
75
76    #[test]
77    fn test_orthogonal_vectors() {
78        let a = vec![1.0, 0.0, 0.0];
79        let b = vec![0.0, 1.0, 0.0];
80        let sim = cosine_similarity(&a, &b).unwrap();
81        assert!((sim - 0.0).abs() < 1e-6);
82    }
83
84    #[test]
85    fn test_opposite_vectors() {
86        let a = vec![1.0, 0.0];
87        let b = vec![-1.0, 0.0];
88        let sim = cosine_similarity(&a, &b).unwrap();
89        assert!((sim - (-1.0)).abs() < 1e-6);
90    }
91
92    #[test]
93    fn test_different_lengths_returns_error() {
94        let a = vec![1.0, 2.0];
95        let b = vec![1.0];
96        assert!(cosine_similarity(&a, &b).is_err());
97    }
98
99    #[test]
100    fn test_zero_vector() {
101        let a = vec![0.0, 0.0];
102        let b = vec![1.0, 2.0];
103        assert_eq!(cosine_similarity(&a, &b).unwrap(), 0.0);
104    }
105
106    #[test]
107    fn test_near_zero_vector_is_degenerate() {
108        // R2: a norm that is tiny but non-zero (below MIN_NORM) must not divide into a
109        // wild cosine. Under the old f64::EPSILON gate this returned ~1.0; now it's 0.0.
110        let tiny = vec![1e-10_f32, 0.0, 0.0];
111        let unit = vec![1.0_f32, 0.0, 0.0];
112        assert_eq!(cosine_similarity(&tiny, &unit).unwrap(), 0.0);
113    }
114}