Skip to main content

ipfrs_semantic/
quantization_error.rs

1//! Quantization error tracking for INT8/binary quantization.
2//!
3//! This module measures and bounds quantization error introduced by INT8 or binary
4//! quantization, enabling monitoring of search quality degradation over time.
5//!
6//! ## Overview
7//!
8//! Quantization reduces memory usage and speeds up distance computations, but at the
9//! cost of precision. The [`QuantizationErrorTracker`] accumulates per-vector error
10//! measurements and exposes rolling statistics (MSE, MAE, p99 MSE, SNR) to help
11//! operators decide when re-indexing or parameter tuning is necessary.
12//!
13//! ## Example
14//!
15//! ```rust
16//! use ipfrs_semantic::quantization_error::{QuantizationErrorTracker, QErrorError};
17//!
18//! let mut tracker = QuantizationErrorTracker::new();
19//!
20//! let original  = vec![0.1_f32, 0.2, 0.3, 0.4];
21//! let quantized = vec![0.1_f32, 0.2, 0.3, 0.4]; // perfect quantization
22//!
23//! let err = tracker.compute_error(&original, &quantized).unwrap();
24//! assert!(err.mse < 1e-9);
25//!
26//! tracker.record(err);
27//! assert_eq!(tracker.history_len(), 1);
28//! ```
29
30use std::collections::VecDeque;
31
32use thiserror::Error;
33
34// ──────────────────────────────────────────────────────────────────────────────
35// Error type
36// ──────────────────────────────────────────────────────────────────────────────
37
38/// Errors that can be returned from [`QuantizationErrorTracker`] operations.
39#[derive(Debug, Error, PartialEq, Eq)]
40pub enum QErrorError {
41    /// Original and quantized vectors have different lengths.
42    #[error("vector length mismatch: expected {expected}, got {got}")]
43    LengthMismatch {
44        /// Length of the original vector.
45        expected: usize,
46        /// Length of the quantized vector.
47        got: usize,
48    },
49    /// Both vectors are empty; error metrics are undefined.
50    #[error("cannot compute quantization error for empty vectors")]
51    EmptyVector,
52}
53
54// ──────────────────────────────────────────────────────────────────────────────
55// QuantizationError
56// ──────────────────────────────────────────────────────────────────────────────
57
58/// Quantization error metrics for a single vector pair.
59#[derive(Debug, Clone, PartialEq)]
60pub struct QuantizationError {
61    /// Mean squared error between original and quantized vectors.
62    pub mse: f64,
63    /// Mean absolute error between original and quantized vectors.
64    pub mae: f64,
65    /// Maximum element-wise absolute error.
66    pub max_error: f64,
67    /// Signal-to-noise ratio in decibels.
68    ///
69    /// Defined as `10 * log10(signal_power / noise_power)` where
70    /// `signal_power = mean(orig²)` and `noise_power = mse`.
71    /// Returns `-∞` when signal power is zero and noise power is also zero
72    /// (perfect quantization of a zero vector), or `+∞` when MSE is zero
73    /// and signal power is non-zero.
74    pub snr_db: f64,
75}
76
77impl QuantizationError {
78    /// Returns `true` when this error measurement is within an acceptable threshold.
79    ///
80    /// # Arguments
81    /// * `max_mse` – the maximum allowable MSE.
82    #[inline]
83    pub fn is_acceptable(&self, max_mse: f64) -> bool {
84        self.mse <= max_mse
85    }
86}
87
88// ──────────────────────────────────────────────────────────────────────────────
89// QuantizationErrorTracker
90// ──────────────────────────────────────────────────────────────────────────────
91
92/// Capacity of the rolling history buffer.
93const HISTORY_CAPACITY: usize = 256;
94
95/// Tracks quantization error across a rolling window of vectors.
96///
97/// The tracker maintains the last `HISTORY_CAPACITY` (256) measurements and
98/// exposes rolling statistics that let callers detect when quantization error
99/// is accumulating to unacceptable levels.
100#[derive(Debug, Clone)]
101pub struct QuantizationErrorTracker {
102    /// Rolling window of recent error measurements (capped at 256).
103    pub history: VecDeque<QuantizationError>,
104    /// Total number of vectors recorded since creation (monotonically increasing).
105    pub total_vectors: u64,
106}
107
108impl QuantizationErrorTracker {
109    /// Create a new, empty tracker.
110    pub fn new() -> Self {
111        Self {
112            history: VecDeque::with_capacity(HISTORY_CAPACITY),
113            total_vectors: 0,
114        }
115    }
116
117    /// Compute [`QuantizationError`] between an original and a quantized vector.
118    ///
119    /// # Errors
120    /// - [`QErrorError::EmptyVector`] – if `original` is empty.
121    /// - [`QErrorError::LengthMismatch`] – if lengths differ.
122    pub fn compute_error(
123        &self,
124        original: &[f32],
125        quantized: &[f32],
126    ) -> Result<QuantizationError, QErrorError> {
127        let n = original.len();
128
129        if n == 0 {
130            return Err(QErrorError::EmptyVector);
131        }
132        if quantized.len() != n {
133            return Err(QErrorError::LengthMismatch {
134                expected: n,
135                got: quantized.len(),
136            });
137        }
138
139        let n_f64 = n as f64;
140        let mut sum_sq_err = 0.0_f64;
141        let mut sum_abs_err = 0.0_f64;
142        let mut max_abs_err = 0.0_f64;
143        let mut sum_sq_orig = 0.0_f64;
144
145        for (&o, &q) in original.iter().zip(quantized.iter()) {
146            let o64 = o as f64;
147            let q64 = q as f64;
148            let diff = o64 - q64;
149            let abs_diff = diff.abs();
150
151            sum_sq_err += diff * diff;
152            sum_abs_err += abs_diff;
153            if abs_diff > max_abs_err {
154                max_abs_err = abs_diff;
155            }
156            sum_sq_orig += o64 * o64;
157        }
158
159        let mse = sum_sq_err / n_f64;
160        let mae = sum_abs_err / n_f64;
161        let max_error = max_abs_err;
162        let signal_power = sum_sq_orig / n_f64;
163
164        // SNR in dB: 10 * log10(signal / noise)
165        // Edge cases handled explicitly so the result is always well-defined:
166        //   - signal == 0 and mse == 0  → 0 dB (both are zero vectors, no error)
167        //   - signal == 0 and mse  > 0  → -∞ dB (noise with no signal)
168        //   - signal  > 0 and mse == 0  → +∞ dB (perfect reconstruction)
169        let snr_db = if signal_power == 0.0 && mse == 0.0 {
170            0.0
171        } else if mse == 0.0 {
172            f64::INFINITY
173        } else {
174            10.0 * (signal_power / mse).log10()
175        };
176
177        Ok(QuantizationError {
178            mse,
179            mae,
180            max_error,
181            snr_db,
182        })
183    }
184
185    /// Record a [`QuantizationError`] measurement into the rolling history.
186    ///
187    /// When the history reaches 256 entries the oldest entry is evicted.
188    pub fn record(&mut self, error: QuantizationError) {
189        if self.history.len() == HISTORY_CAPACITY {
190            self.history.pop_front();
191        }
192        self.history.push_back(error);
193        self.total_vectors += 1;
194    }
195
196    /// Average MSE over all entries in the rolling history.
197    ///
198    /// Returns `0.0` when the history is empty.
199    pub fn rolling_mse(&self) -> f64 {
200        if self.history.is_empty() {
201            return 0.0;
202        }
203        let sum: f64 = self.history.iter().map(|e| e.mse).sum();
204        sum / self.history.len() as f64
205    }
206
207    /// Average MAE over all entries in the rolling history.
208    ///
209    /// Returns `0.0` when the history is empty.
210    pub fn rolling_mae(&self) -> f64 {
211        if self.history.is_empty() {
212            return 0.0;
213        }
214        let sum: f64 = self.history.iter().map(|e| e.mae).sum();
215        sum / self.history.len() as f64
216    }
217
218    /// 99th-percentile MSE across the rolling history.
219    ///
220    /// Uses the nearest-rank method: the index is `ceil(0.99 * n) - 1` (0-based)
221    /// on a sorted copy of the MSE values.
222    ///
223    /// Returns `0.0` when the history is empty.
224    pub fn p99_mse(&self) -> f64 {
225        if self.history.is_empty() {
226            return 0.0;
227        }
228        let mut values: Vec<f64> = self.history.iter().map(|e| e.mse).collect();
229        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
230
231        let n = values.len();
232        // Nearest-rank: index = ceil(p * n) - 1, clamped to [0, n-1]
233        let rank = ((0.99_f64 * n as f64).ceil() as usize)
234            .saturating_sub(1)
235            .min(n - 1);
236        values[rank]
237    }
238
239    /// Returns `true` when the rolling MSE is within `max_mse`.
240    pub fn is_quality_acceptable(&self, max_mse: f64) -> bool {
241        self.rolling_mse() <= max_mse
242    }
243
244    /// Returns the number of entries currently in the rolling history.
245    pub fn history_len(&self) -> usize {
246        self.history.len()
247    }
248
249    /// Clear all history entries.  `total_vectors` is not reset.
250    pub fn clear_history(&mut self) {
251        self.history.clear();
252    }
253}
254
255impl Default for QuantizationErrorTracker {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261// ──────────────────────────────────────────────────────────────────────────────
262// Tests
263// ──────────────────────────────────────────────────────────────────────────────
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    // ── helpers ──────────────────────────────────────────────────────────────
270
271    fn make_tracker() -> QuantizationErrorTracker {
272        QuantizationErrorTracker::new()
273    }
274
275    // ── 1. identical vectors → mse=0, mae=0 ─────────────────────────────────
276    #[test]
277    fn test_compute_error_identical_vectors() {
278        let t = make_tracker();
279        let v = vec![0.1_f32, 0.5, -0.3, 0.9, 0.0];
280        let err = t.compute_error(&v, &v).expect("should succeed");
281        assert!(
282            err.mse < 1e-12,
283            "MSE for identical vectors must be ~0, got {}",
284            err.mse
285        );
286        assert!(
287            err.mae < 1e-12,
288            "MAE for identical vectors must be ~0, got {}",
289            err.mae
290        );
291        assert!(
292            err.max_error < 1e-12,
293            "max_error for identical vectors must be ~0, got {}",
294            err.max_error
295        );
296    }
297
298    // ── 2. SNR is +∞ for identical non-zero vectors ──────────────────────────
299    #[test]
300    fn test_compute_error_identical_snr_infinite() {
301        let t = make_tracker();
302        let v = vec![1.0_f32, 2.0, 3.0];
303        let err = t.compute_error(&v, &v).expect("should succeed");
304        assert!(
305            err.snr_db.is_infinite() && err.snr_db.is_sign_positive(),
306            "SNR should be +∞ for perfect reconstruction, got {}",
307            err.snr_db
308        );
309    }
310
311    // ── 3. known constant offset ─────────────────────────────────────────────
312    #[test]
313    fn test_compute_error_constant_offset() {
314        let t = make_tracker();
315        let offset = 0.5_f32;
316        let original = vec![1.0_f32, 2.0, 3.0, 4.0];
317        let quantized: Vec<f32> = original.iter().map(|x| x + offset).collect();
318
319        let err = t
320            .compute_error(&original, &quantized)
321            .expect("should succeed");
322
323        let expected_mse = (offset as f64).powi(2);
324        let expected_mae = offset as f64;
325
326        assert!(
327            (err.mse - expected_mse).abs() < 1e-9,
328            "MSE = {}, expected {}",
329            err.mse,
330            expected_mse
331        );
332        assert!(
333            (err.mae - expected_mae).abs() < 1e-9,
334            "MAE = {}, expected {}",
335            err.mae,
336            expected_mae
337        );
338        assert!(
339            (err.max_error - offset as f64).abs() < 1e-9,
340            "max_error = {}, expected {}",
341            err.max_error,
342            offset
343        );
344    }
345
346    // ── 4. length mismatch returns error ─────────────────────────────────────
347    #[test]
348    fn test_compute_error_length_mismatch() {
349        let t = make_tracker();
350        let original = vec![1.0_f32, 2.0, 3.0];
351        let quantized = vec![1.0_f32, 2.0];
352
353        match t.compute_error(&original, &quantized) {
354            Err(QErrorError::LengthMismatch {
355                expected: 3,
356                got: 2,
357            }) => {}
358            other => panic!("expected LengthMismatch(3, 2), got {:?}", other),
359        }
360    }
361
362    // ── 5. empty vector returns error ────────────────────────────────────────
363    #[test]
364    fn test_compute_error_empty_vector() {
365        let t = make_tracker();
366        match t.compute_error(&[], &[]) {
367            Err(QErrorError::EmptyVector) => {}
368            other => panic!("expected EmptyVector, got {:?}", other),
369        }
370    }
371
372    // ── 6. snr_db is positive for low-noise quantization ─────────────────────
373    #[test]
374    fn test_snr_positive_for_low_noise() {
375        let t = make_tracker();
376        // Large signal, tiny noise
377        let original: Vec<f32> = (0..64).map(|i| (i as f32) * 0.1).collect();
378        let quantized: Vec<f32> = original.iter().map(|&x| x + 1e-4).collect();
379
380        let err = t
381            .compute_error(&original, &quantized)
382            .expect("should succeed");
383        assert!(
384            err.snr_db > 0.0,
385            "SNR should be positive for low-noise quantization, got {}",
386            err.snr_db
387        );
388    }
389
390    // ── 7. record appends to history ─────────────────────────────────────────
391    #[test]
392    fn test_record_appends_to_history() {
393        let mut tracker = make_tracker();
394        assert_eq!(tracker.history_len(), 0);
395
396        let t = make_tracker();
397        let v = vec![0.5_f32; 4];
398        let err = t.compute_error(&v, &v).expect("should succeed");
399
400        tracker.record(err);
401        assert_eq!(tracker.history_len(), 1);
402        assert_eq!(tracker.total_vectors, 1);
403    }
404
405    // ── 8. history is capped at 256 ───────────────────────────────────────────
406    #[test]
407    fn test_record_capped_at_256() {
408        let mut tracker = make_tracker();
409        let t = make_tracker();
410        let v = vec![1.0_f32; 8];
411
412        for _ in 0..300 {
413            let err = t.compute_error(&v, &v).expect("should succeed");
414            tracker.record(err);
415        }
416
417        assert_eq!(tracker.history_len(), 256, "history must be capped at 256");
418        assert_eq!(
419            tracker.total_vectors, 300,
420            "total_vectors must reflect all recordings"
421        );
422    }
423
424    // ── 9. rolling_mse average correctness ───────────────────────────────────
425    #[test]
426    fn test_rolling_mse_average() {
427        let mut tracker = make_tracker();
428        let t = make_tracker();
429
430        // Create errors with known MSE values by using constant-offset vectors.
431        // offset=1 → mse=1, offset=2 → mse=4, offset=3 → mse=9
432        for offset in [1.0_f32, 2.0, 3.0] {
433            let original = vec![0.0_f32; 4];
434            let quantized = vec![offset; 4];
435            let err = t
436                .compute_error(&original, &quantized)
437                .expect("should succeed");
438            tracker.record(err);
439        }
440
441        // Expected average = (1 + 4 + 9) / 3 = 14/3 ≈ 4.6667
442        let expected = (1.0_f64 + 4.0 + 9.0) / 3.0;
443        let got = tracker.rolling_mse();
444        assert!(
445            (got - expected).abs() < 1e-9,
446            "rolling_mse = {}, expected {}",
447            got,
448            expected
449        );
450    }
451
452    // ── 10. rolling_mae average correctness ──────────────────────────────────
453    #[test]
454    fn test_rolling_mae_average() {
455        let mut tracker = make_tracker();
456        let t = make_tracker();
457
458        for offset in [1.0_f32, 3.0] {
459            let original = vec![0.0_f32; 4];
460            let quantized = vec![offset; 4];
461            let err = t
462                .compute_error(&original, &quantized)
463                .expect("should succeed");
464            tracker.record(err);
465        }
466
467        // MAE values: 1 and 3 → average = 2
468        let got = tracker.rolling_mae();
469        assert!(
470            (got - 2.0).abs() < 1e-9,
471            "rolling_mae = {}, expected 2.0",
472            got
473        );
474    }
475
476    // ── 11. p99_mse correctness ────────────────────────────────────────────────
477    #[test]
478    fn test_p99_mse_correctness() {
479        let mut tracker = make_tracker();
480        let t = make_tracker();
481        let original = vec![0.0_f32; 1];
482
483        // Insert 100 measurements with MSE = i² (i in 1..=100)
484        for i in 1_u32..=100 {
485            let offset = i as f32;
486            let quantized = vec![offset];
487            let err = t.compute_error(&original, &quantized).expect("ok");
488            tracker.record(err);
489        }
490
491        // Sorted MSE values: 1, 4, 9, …, 10000
492        // p99 nearest-rank index = ceil(0.99 * 100) - 1 = 99 - 1 = 98 (0-based)
493        // → 99th value in sorted order = 99² = 9801
494        let got = tracker.p99_mse();
495        let expected = 99.0_f64 * 99.0;
496        assert!(
497            (got - expected).abs() < 1e-6,
498            "p99_mse = {}, expected {}",
499            got,
500            expected
501        );
502    }
503
504    // ── 12. p99_mse on empty history ──────────────────────────────────────────
505    #[test]
506    fn test_p99_mse_empty_history() {
507        let tracker = make_tracker();
508        assert_eq!(tracker.p99_mse(), 0.0);
509    }
510
511    // ── 13. is_quality_acceptable threshold ──────────────────────────────────
512    #[test]
513    fn test_is_quality_acceptable_threshold() {
514        let mut tracker = make_tracker();
515        let t = make_tracker();
516
517        let original = vec![0.0_f32; 4];
518        let quantized = vec![1.0_f32; 4]; // mse = 1.0
519
520        let err = t.compute_error(&original, &quantized).expect("ok");
521        tracker.record(err);
522
523        assert!(
524            tracker.is_quality_acceptable(1.0),
525            "rolling_mse == max_mse should be acceptable"
526        );
527        assert!(
528            tracker.is_quality_acceptable(2.0),
529            "rolling_mse < max_mse should be acceptable"
530        );
531        assert!(
532            !tracker.is_quality_acceptable(0.5),
533            "rolling_mse > max_mse should be unacceptable"
534        );
535    }
536
537    // ── 14. clear_history empties the buffer ─────────────────────────────────
538    #[test]
539    fn test_clear_history_empties() {
540        let mut tracker = make_tracker();
541        let t = make_tracker();
542        let v = vec![1.0_f32; 4];
543
544        for _ in 0..10 {
545            let err = t.compute_error(&v, &v).expect("ok");
546            tracker.record(err);
547        }
548
549        assert_eq!(tracker.history_len(), 10);
550        tracker.clear_history();
551        assert_eq!(
552            tracker.history_len(),
553            0,
554            "clear_history must empty the buffer"
555        );
556        // total_vectors must NOT be reset
557        assert_eq!(
558            tracker.total_vectors, 10,
559            "total_vectors must survive clear_history"
560        );
561    }
562
563    // ── 15. is_acceptable on QuantizationError ───────────────────────────────
564    #[test]
565    fn test_quantization_error_is_acceptable() {
566        let err = QuantizationError {
567            mse: 0.01,
568            mae: 0.05,
569            max_error: 0.1,
570            snr_db: 20.0,
571        };
572        assert!(err.is_acceptable(0.01));
573        assert!(err.is_acceptable(0.05));
574        assert!(!err.is_acceptable(0.009));
575    }
576
577    // ── 16. snr_db = 0 for zero-vector pair ─────────────────────────────────
578    #[test]
579    fn test_snr_zero_vector_pair() {
580        let t = make_tracker();
581        let zeros = vec![0.0_f32; 4];
582        let err = t.compute_error(&zeros, &zeros).expect("ok");
583        // Both signal and noise are zero → defined as 0 dB
584        assert_eq!(err.snr_db, 0.0);
585    }
586
587    // ── 17. default() and new() are equivalent ───────────────────────────────
588    #[test]
589    fn test_default_equals_new() {
590        let a = QuantizationErrorTracker::new();
591        let b = QuantizationErrorTracker::default();
592        assert_eq!(a.history_len(), b.history_len());
593        assert_eq!(a.total_vectors, b.total_vectors);
594    }
595}