Skip to main content

khive_score/
ops.rs

1//! Aggregation and fusion operations for deterministic scores.
2
3use crate::DeterministicScore;
4use std::fmt;
5use std::num::NonZeroUsize;
6
7/// Validation errors from aggregation and distance conversion.
8///
9/// See `crates/khive-score/docs/api/aggregation-and-ranking.md` and
10/// `crates/khive-score/docs/api/distance-conversion.md`.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ScoreError {
13    /// The two input slices have different lengths.
14    LengthMismatch {
15        /// Human-readable description of which two slices were compared.
16        expected_desc: &'static str,
17        /// Length of the first slice.
18        first_len: usize,
19        /// Length of the second slice.
20        second_len: usize,
21    },
22    /// A weight at the given index is NaN or infinite.
23    NonFiniteWeight {
24        /// Zero-based index of the offending weight.
25        index: usize,
26    },
27    /// The distance value is NaN, `+Inf`, or `-Inf`.
28    NonFiniteDistance,
29    /// The distance is finite but outside the valid range for the metric.
30    InvalidDistanceRange {
31        /// The metric whose range was violated.
32        metric_name: &'static str,
33        /// Bit-representation of the out-of-range distance for diagnostics.
34        dist_bits: u32,
35    },
36    /// The distance metric is not one of the three currently supported variants.
37    UnsupportedMetric,
38}
39
40impl fmt::Display for ScoreError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            ScoreError::LengthMismatch {
44                expected_desc,
45                first_len,
46                second_len,
47            } => write!(
48                f,
49                "{expected_desc}: first has {first_len} elements, second has {second_len}"
50            ),
51            ScoreError::NonFiniteWeight { index } => {
52                write!(f, "weight at index {index} must be finite")
53            }
54            ScoreError::NonFiniteDistance => {
55                write!(f, "distance must be finite (not NaN or infinity)")
56            }
57            ScoreError::InvalidDistanceRange {
58                metric_name,
59                dist_bits,
60            } => write!(
61                f,
62                "distance value (bits=0x{dist_bits:08x}) is out of valid range for metric {metric_name}"
63            ),
64            ScoreError::UnsupportedMetric => {
65                write!(f, "unsupported distance metric")
66            }
67        }
68    }
69}
70
71impl std::error::Error for ScoreError {}
72
73/// Return the saturating sum of `scores`, clamped to `[NEG_INF, MAX]`.
74#[inline]
75pub fn sum_scores(scores: &[DeterministicScore]) -> DeterministicScore {
76    if scores.is_empty() {
77        return DeterministicScore::ZERO;
78    }
79    let sum: i128 = scores.iter().map(|s| s.to_raw() as i128).sum();
80    DeterministicScore::from_raw(sum.clamp(
81        DeterministicScore::NEG_INF.to_raw() as i128,
82        i64::MAX as i128,
83    ) as i64)
84}
85
86/// Return the arithmetic mean of `scores`, clamped to `[NEG_INF, MAX]`.
87#[inline]
88pub fn avg_scores(scores: &[DeterministicScore]) -> DeterministicScore {
89    if scores.is_empty() {
90        return DeterministicScore::ZERO;
91    }
92    let sum: i128 = scores.iter().map(|s| s.to_raw() as i128).sum();
93    let mean = sum / scores.len() as i128;
94    DeterministicScore::from_raw(mean.clamp(
95        DeterministicScore::NEG_INF.to_raw() as i128,
96        i64::MAX as i128,
97    ) as i64)
98}
99
100/// Return the mean and whether absolute input mass or the result approaches saturation.
101#[inline]
102pub fn avg_scores_checked(scores: &[DeterministicScore]) -> (DeterministicScore, bool) {
103    if scores.is_empty() {
104        return (DeterministicScore::ZERO, false);
105    }
106    const SATURATION_THRESHOLD: i128 = (i64::MAX as i128) * 9 / 10;
107    let sum: i128 = scores.iter().map(|s| s.to_raw() as i128).sum();
108    let mean = sum / scores.len() as i128;
109    // Absolute mass makes the warning independent of iteration order and sign cancellation.
110    let abs_mass: i128 = scores
111        .iter()
112        .map(|s| (s.to_raw() as i128).unsigned_abs() as i128)
113        .sum();
114    let near_saturation =
115        abs_mass > SATURATION_THRESHOLD || mean.unsigned_abs() as i128 > SATURATION_THRESHOLD;
116    let result = DeterministicScore::from_raw(mean.clamp(
117        DeterministicScore::NEG_INF.to_raw() as i128,
118        i64::MAX as i128,
119    ) as i64);
120    (result, near_saturation)
121}
122
123/// Return the maximum score, or [`DeterministicScore::NEG_INF`] for an empty slice.
124#[inline]
125pub fn max_score(scores: &[DeterministicScore]) -> DeterministicScore {
126    scores
127        .iter()
128        .copied()
129        .max()
130        .unwrap_or(DeterministicScore::NEG_INF)
131}
132
133/// Return the minimum score, or [`DeterministicScore::MAX`] for an empty slice.
134#[inline]
135pub fn min_score(scores: &[DeterministicScore]) -> DeterministicScore {
136    scores
137        .iter()
138        .copied()
139        .min()
140        .unwrap_or(DeterministicScore::MAX)
141}
142
143/// RRF score `1 / (k + rank)`. Rank is 1-based; prefer `rrf_score_one_based` or `rrf_score_zero_based`.
144#[inline]
145pub fn rrf_score(rank: usize, k: usize) -> DeterministicScore {
146    let Some(denominator) = k.checked_add(rank) else {
147        return DeterministicScore::ZERO;
148    };
149    if denominator == 0 {
150        return DeterministicScore::ZERO;
151    }
152    DeterministicScore::from_f64(1.0 / (denominator as f64))
153}
154
155/// RRF score with 1-based rank (first result = rank 1). `k` is the smoothing constant.
156#[inline]
157pub fn rrf_score_one_based(rank: NonZeroUsize, k: usize) -> DeterministicScore {
158    let Some(denominator) = k.checked_add(rank.get()) else {
159        return DeterministicScore::ZERO;
160    };
161    DeterministicScore::from_f64(1.0 / denominator as f64)
162}
163
164/// RRF score with 0-based index (index 0 → rank 1 internally).
165#[inline]
166pub fn rrf_score_zero_based(index: usize, k: usize) -> DeterministicScore {
167    let Some(rank) = index.checked_add(1).and_then(NonZeroUsize::new) else {
168        return DeterministicScore::ZERO;
169    };
170    rrf_score_one_based(rank, k)
171}
172
173const SCALE_RAW: i128 = 4_294_967_296; // 2^32 — matches DeterministicScore::SCALE
174
175/// Return a saturating weighted sum.
176///
177/// Errors on length mismatch or the first non-finite weight. See
178/// `crates/khive-score/docs/api/aggregation-and-ranking.md`.
179#[inline]
180pub fn weighted_sum(
181    scores: &[DeterministicScore],
182    weights: &[f64],
183) -> Result<DeterministicScore, ScoreError> {
184    if scores.len() != weights.len() {
185        return Err(ScoreError::LengthMismatch {
186            expected_desc: "scores and weights must have same length",
187            first_len: scores.len(),
188            second_len: weights.len(),
189        });
190    }
191    let mut acc = 0i128;
192    for (index, (&score, &weight)) in scores.iter().zip(weights.iter()).enumerate() {
193        if !weight.is_finite() {
194            return Err(ScoreError::NonFiniteWeight { index });
195        }
196        let w = DeterministicScore::from_f64(weight);
197        acc += (score.to_raw() as i128 * w.to_raw() as i128) / SCALE_RAW;
198    }
199    Ok(DeterministicScore::from_raw(acc.clamp(
200        DeterministicScore::NEG_INF.to_raw() as i128,
201        DeterministicScore::MAX.to_raw() as i128,
202    ) as i64))
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn s(v: f64) -> DeterministicScore {
210        DeterministicScore::from_f64(v)
211    }
212
213    #[test]
214    fn sum_basic() {
215        let scores = [s(0.1), s(0.2), s(0.3)];
216        let result = sum_scores(&scores);
217        assert!((result.to_f64() - 0.6).abs() < 1e-9);
218    }
219
220    #[test]
221    fn sum_empty() {
222        let result = sum_scores(&[]);
223        assert_eq!(result, DeterministicScore::ZERO);
224    }
225
226    #[test]
227    fn avg_basic() {
228        let scores = [s(0.1), s(0.2), s(0.3)];
229        let result = avg_scores(&scores);
230        assert!((result.to_f64() - 0.2).abs() < 1e-9);
231    }
232
233    #[test]
234    fn rrf_basic() {
235        let r1 = rrf_score(1, 60);
236        let r2 = rrf_score(2, 60);
237        assert!(r1 > r2);
238        assert!((r1.to_f64() - 1.0 / 61.0).abs() < 1e-9);
239    }
240
241    #[test]
242    fn weighted_sum_basic() {
243        let scores = [s(0.5), s(1.0)];
244        let weights = [0.4, 0.6];
245        let result = weighted_sum(&scores, &weights).unwrap();
246        assert!((result.to_f64() - 0.8).abs() < 1e-6);
247    }
248
249    #[test]
250    fn weighted_sum_length_mismatch() {
251        let err = weighted_sum(&[s(0.1)], &[0.5, 0.5]).unwrap_err();
252        assert!(matches!(err, ScoreError::LengthMismatch { .. }));
253    }
254
255    #[test]
256    fn weighted_sum_rejects_nan() {
257        let err = weighted_sum(&[s(0.1)], &[f64::NAN]).unwrap_err();
258        assert!(matches!(err, ScoreError::NonFiniteWeight { index: 0 }));
259    }
260
261    #[test]
262    fn sum_negative_saturation_clamps_to_neg_inf() {
263        let big_neg = DeterministicScore::NEG_INF;
264        let result = sum_scores(&[big_neg, big_neg, big_neg]);
265        assert_eq!(result, DeterministicScore::NEG_INF);
266        assert!(result.is_infinite());
267        assert_eq!(result.to_f64(), f64::NEG_INFINITY);
268    }
269
270    #[test]
271    fn avg_negative_saturation_clamps_to_neg_inf() {
272        let big_neg = DeterministicScore::NEG_INF;
273        let result = avg_scores(&[big_neg, big_neg]);
274        assert_eq!(result, DeterministicScore::NEG_INF);
275    }
276
277    #[test]
278    fn sum_order_independent() {
279        let a = DeterministicScore::from_f64(1e9);
280        let b = DeterministicScore::from_f64(-1e9);
281        let c = DeterministicScore::from_f64(0.5);
282        let r1 = sum_scores(&[a, b, c]);
283        let r2 = sum_scores(&[c, a, b]);
284        let r3 = sum_scores(&[b, c, a]);
285        assert_eq!(r1, r2);
286        assert_eq!(r2, r3);
287    }
288
289    #[test]
290    fn avg_scores_checked_empty_returns_zero_no_flag() {
291        let (mean, flag) = avg_scores_checked(&[]);
292        assert_eq!(mean, DeterministicScore::ZERO);
293        assert!(!flag);
294    }
295
296    #[test]
297    fn avg_scores_checked_near_saturation_sets_flag() {
298        let (_, flag) = avg_scores_checked(&[DeterministicScore::MAX, DeterministicScore::MAX]);
299        assert!(flag);
300    }
301
302    #[test]
303    fn max_score_empty_returns_neg_inf() {
304        assert_eq!(max_score(&[]), DeterministicScore::NEG_INF);
305    }
306
307    #[test]
308    fn min_score_empty_returns_max() {
309        assert_eq!(min_score(&[]), DeterministicScore::MAX);
310    }
311
312    #[test]
313    fn rrf_score_zero_denominator_returns_zero() {
314        assert_eq!(rrf_score(0, 0), DeterministicScore::ZERO);
315    }
316
317    #[test]
318    fn rrf_score_overflow_returns_zero() {
319        assert_eq!(rrf_score(usize::MAX, 1), DeterministicScore::ZERO);
320    }
321
322    #[test]
323    fn weighted_sum_empty_returns_zero() {
324        assert_eq!(weighted_sum(&[], &[]).unwrap(), DeterministicScore::ZERO);
325    }
326
327    #[test]
328    fn weighted_sum_rejects_infinite_weight() {
329        let err = weighted_sum(&[s(1.0)], &[f64::INFINITY]).unwrap_err();
330        assert_eq!(err, ScoreError::NonFiniteWeight { index: 0 });
331    }
332
333    #[test]
334    fn rrf_one_based_rank_1_equals_legacy_rank_1() {
335        use std::num::NonZeroUsize;
336        let one_based = rrf_score_one_based(NonZeroUsize::new(1).unwrap(), 60);
337        let legacy = rrf_score(1, 60);
338        assert_eq!(
339            one_based, legacy,
340            "rrf_score_one_based(1,60) must match rrf_score(1,60)"
341        );
342    }
343
344    #[test]
345    fn rrf_zero_based_index_0_equals_one_based_rank_1() {
346        use std::num::NonZeroUsize;
347        let zero_based = rrf_score_zero_based(0, 60);
348        let one_based = rrf_score_one_based(NonZeroUsize::new(1).unwrap(), 60);
349        assert_eq!(zero_based, one_based);
350    }
351
352    #[test]
353    fn rrf_one_based_monotone_decreasing() {
354        use std::num::NonZeroUsize;
355        let r1 = rrf_score_one_based(NonZeroUsize::new(1).unwrap(), 60);
356        let r2 = rrf_score_one_based(NonZeroUsize::new(2).unwrap(), 60);
357        let r10 = rrf_score_one_based(NonZeroUsize::new(10).unwrap(), 60);
358        assert!(r1 > r2);
359        assert!(r2 > r10);
360    }
361
362    #[test]
363    fn rrf_one_based_value_matches_formula() {
364        use std::num::NonZeroUsize;
365        let score = rrf_score_one_based(NonZeroUsize::new(1).unwrap(), 60);
366        assert!((score.to_f64() - 1.0 / 61.0).abs() < 1e-9);
367    }
368
369    #[test]
370    fn rrf_one_based_overflow_returns_zero() {
371        use std::num::NonZeroUsize;
372        let score = rrf_score_one_based(NonZeroUsize::new(usize::MAX).unwrap(), 1);
373        assert_eq!(score, DeterministicScore::ZERO);
374    }
375
376    #[test]
377    fn rrf_zero_based_overflow_returns_zero() {
378        let score = rrf_score_zero_based(usize::MAX, 1);
379        assert_eq!(score, DeterministicScore::ZERO);
380    }
381
382    #[test]
383    fn avg_scores_checked_saturation_flag_is_order_independent() {
384        // The warning must be invariant under permutation of the same multiset.
385        let big = DeterministicScore::from_raw(i64::MAX / 2);
386        let neg = DeterministicScore::from_raw(i64::MIN / 2 + 1);
387        let scores_order_a = [big, neg, big, neg];
388        let scores_order_b = [big, big, neg, neg];
389        let (_, flag_a) = avg_scores_checked(&scores_order_a);
390        let (_, flag_b) = avg_scores_checked(&scores_order_b);
391        assert_eq!(
392            flag_a, flag_b,
393            "near_saturation flag must be order-independent: order_a={flag_a}, order_b={flag_b}"
394        );
395    }
396
397    #[test]
398    fn avg_scores_checked_normal_values_no_saturation_flag() {
399        let scores = [s(0.1), s(0.2), s(-0.1), s(0.3)];
400        let (_, flag) = avg_scores_checked(&scores);
401        assert!(!flag, "small scores should not trigger near_saturation");
402    }
403}