Skip to main content

uqa_scoring/
vector_score.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Vector similarity to probability conversion.
8//!
9//! Two modes:
10//!
11//! * Uncalibrated (Definition 7.1.2, Paper 3):
12//!   `P_vector = (1 + cos_sim) / 2`.
13//! * Calibrated (Theorem 3.1.1, Paper 5): likelihood-ratio calibration
14//!   via [`crate::calibration::VectorProbabilityTransform`] -- the
15//!   caller supplies a pre-fitted transform built from a background
16//!   distance distribution.
17//!
18//! [`VectorScorer`] exposes associated functions and carries no instance state.
19
20use crate::calibration::VectorProbabilityTransform;
21use crate::error::{invalid_input, require_finite};
22use crate::{ScoringError, ScoringResult};
23
24pub struct VectorScorer;
25
26impl VectorScorer {
27    /// Cosine similarity between two equal-length vectors. Returns 0
28    /// when either side has zero norm.
29    pub fn cosine_similarity(a: &[f32], b: &[f32]) -> ScoringResult<f64> {
30        if a.len() != b.len() {
31            return Err(invalid_input(format!(
32                "vector dimensions differ: {} versus {}",
33                a.len(),
34                b.len()
35            )));
36        }
37        if a.is_empty() {
38            return Err(invalid_input("vectors must not be empty"));
39        }
40        let mut dot = 0.0_f64;
41        let mut na = 0.0_f64;
42        let mut nb = 0.0_f64;
43        for (index, (x, y)) in a.iter().zip(b.iter()).enumerate() {
44            let x = f64::from(*x);
45            let y = f64::from(*y);
46            require_finite(x, &format!("left vector component {index}"))?;
47            require_finite(y, &format!("right vector component {index}"))?;
48            dot += x * y;
49            na += x * x;
50            nb += y * y;
51            if !dot.is_finite() || !na.is_finite() || !nb.is_finite() {
52                return Err(ScoringError::ArithmeticOverflow(
53                    "cosine similarity accumulation is not finite".to_string(),
54                ));
55            }
56        }
57        let denom = na.sqrt() * nb.sqrt();
58        if denom == 0.0 {
59            Ok(0.0)
60        } else {
61            let score = dot / denom;
62            if !score.is_finite() || !(-1.0 - 1e-12..=1.0 + 1e-12).contains(&score) {
63                return Err(ScoringError::ArithmeticOverflow(format!(
64                    "cosine similarity is outside its numeric range: {score}"
65                )));
66            }
67            Ok(score.clamp(-1.0, 1.0))
68        }
69    }
70
71    /// `P_vector = (1 + score) / 2` -- Definition 7.1.2.
72    pub fn similarity_to_probability(cosine_sim: f64) -> ScoringResult<f64> {
73        require_finite(cosine_sim, "cosine similarity")?;
74        if !(-1.0..=1.0).contains(&cosine_sim) {
75            return Err(invalid_input(format!(
76                "cosine similarity must be in [-1, 1], got {cosine_sim}"
77            )));
78        }
79        Ok(f64::midpoint(1.0 + cosine_sim, 0.0))
80    }
81
82    /// Likelihood-ratio calibration for a batch of similarities. The caller
83    /// pre-fits the [`VectorProbabilityTransform`]; this method maps cosine
84    /// similarities to distances (`1 - sim`) and forwards them.
85    pub fn calibrated_probabilities(
86        similarities: &[f64],
87        calibrator: &VectorProbabilityTransform,
88        weights: Option<&[f64]>,
89    ) -> ScoringResult<Vec<f64>> {
90        let mut distances = Vec::with_capacity(similarities.len());
91        for (index, similarity) in similarities.iter().copied().enumerate() {
92            require_finite(similarity, &format!("similarities[{index}]"))?;
93            if !(-1.0..=1.0).contains(&similarity) {
94                return Err(invalid_input(format!(
95                    "similarities[{index}] must be in [-1, 1], got {similarity}"
96                )));
97            }
98            distances.push(1.0 - similarity);
99        }
100        calibrator.calibrate(&distances, weights)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn cosine_identity_is_one() {
110        let v: Vec<f32> = vec![1.0, 0.5, -0.3];
111        let c = VectorScorer::cosine_similarity(&v, &v).unwrap();
112        assert!((c - 1.0).abs() < 1e-9);
113    }
114
115    #[test]
116    fn similarity_to_probability_maps_unit() {
117        assert!((VectorScorer::similarity_to_probability(1.0).unwrap() - 1.0).abs() < 1e-9);
118        assert!((VectorScorer::similarity_to_probability(0.0).unwrap() - 0.5).abs() < 1e-9);
119        assert!((VectorScorer::similarity_to_probability(-1.0).unwrap()).abs() < 1e-9);
120    }
121
122    #[test]
123    fn cosine_zero_vectors_return_zero() {
124        let v: Vec<f32> = vec![0.0, 0.0];
125        let c = VectorScorer::cosine_similarity(&v, &v).unwrap();
126        assert_eq!(c, 0.0);
127    }
128
129    #[test]
130    fn invalid_vectors_are_errors() {
131        assert!(VectorScorer::cosine_similarity(&[1.0], &[1.0, 2.0]).is_err());
132        assert!(VectorScorer::cosine_similarity(&[f32::NAN], &[1.0]).is_err());
133        assert!(VectorScorer::similarity_to_probability(1.1).is_err());
134    }
135}