uqa_scoring/
vector_score.rs1use crate::calibration::VectorProbabilityTransform;
21use crate::error::{invalid_input, require_finite};
22use crate::{ScoringError, ScoringResult};
23
24pub struct VectorScorer;
25
26impl VectorScorer {
27 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 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 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}