Skip to main content

ipfrs_semantic/quantization/
mod.rs

1//! Vector quantization for memory-efficient storage
2//!
3//! This module provides various quantization methods to compress
4//! high-dimensional vectors while preserving similarity search accuracy.
5
6pub mod product;
7pub mod scalar;
8pub mod stores;
9
10// Re-export public API
11pub use product::{
12    OptimizedProductQuantizer, PQCode, ProductQuantizer, ProductQuantizerConfig,
13    QuantizationBenchmark, QuantizationBenchmarker, QuantizationComparison,
14};
15pub use scalar::{QuantizedVector, ScalarQuantizer, ScalarQuantizerConfig};
16pub use stores::{BinaryVectorStore, QuantizedVectorStore};
17
18// ─────────────────────────────────────────────────────────────────────────────
19// INT8 per-vector quantization helpers
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Quantize an f32 slice to INT8 using symmetric min-max scaling.
23///
24/// The scale factor maps the full dynamic range of `v` to `[-127, 127]`.
25/// Zero-point shifts so that the true zero maps to quantized zero.
26///
27/// # Returns
28/// `(quantized, scale, zero_point)` where
29/// - `quantized[i] = round((v[i] - zero_point) / scale).clamp(-128, 127)`
30/// - dequantization: `v'[i] = quantized[i] * scale + zero_point`
31pub fn quantize_f32_to_i8(v: &[f32]) -> (Vec<i8>, f32, f32) {
32    if v.is_empty() {
33        return (Vec::new(), 1.0, 0.0);
34    }
35
36    let min_val = v.iter().cloned().fold(f32::INFINITY, f32::min);
37    let max_val = v.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
38
39    // Symmetric range around mid-point
40    let zero_point = (min_val + max_val) * 0.5;
41    let half_range = (max_val - min_val) * 0.5;
42
43    // Avoid division by zero for constant vectors
44    let scale = if half_range < 1e-9 {
45        1.0_f32
46    } else {
47        half_range / 127.0
48    };
49
50    let quantized: Vec<i8> = v
51        .iter()
52        .map(|&x| {
53            let q = ((x - zero_point) / scale).round();
54            q.clamp(-128.0, 127.0) as i8
55        })
56        .collect();
57
58    (quantized, scale, zero_point)
59}
60
61/// Dequantize INT8 vector back to f32.
62///
63/// Inverse of [`quantize_f32_to_i8`]:
64/// `v'[i] = quantized[i] * scale + zero_point`
65pub fn dequantize_i8_to_f32(q: &[i8], scale: f32, zero_point: f32) -> Vec<f32> {
66    q.iter().map(|&qi| qi as f32 * scale + zero_point).collect()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_scalar_quantizer_uint8() {
75        let mut quantizer = ScalarQuantizer::uint8(4);
76
77        // Train on some vectors
78        let vectors = vec![
79            vec![0.0, 0.5, 1.0, -0.5],
80            vec![1.0, 0.0, 0.5, 0.5],
81            vec![0.5, 0.5, 0.0, 0.0],
82        ];
83
84        quantizer.train(&vectors).expect("train");
85        assert!(quantizer.is_trained());
86
87        // Quantize and dequantize
88        let original = vec![0.5, 0.25, 0.75, 0.0];
89        let quantized = quantizer.quantize(&original).expect("quantize");
90        let restored = quantizer.dequantize(&quantized).expect("dequantize");
91
92        // Check approximate equality
93        for (o, r) in original.iter().zip(restored.iter()) {
94            assert!((o - r).abs() < 0.05, "Expected {} ~= {}", o, r);
95        }
96
97        assert_eq!(quantizer.compression_ratio(), 4.0);
98    }
99
100    #[test]
101    fn test_scalar_quantizer_int8() {
102        let mut quantizer = ScalarQuantizer::int8(4);
103
104        let vectors = vec![vec![-1.0, 0.0, 1.0, -0.5], vec![1.0, -1.0, 0.5, 0.5]];
105
106        quantizer.train(&vectors).expect("train");
107
108        let original = vec![0.0, -0.5, 0.5, 0.25];
109        let quantized = quantizer.quantize(&original).expect("quantize");
110        let restored = quantizer.dequantize(&quantized).expect("dequantize");
111
112        for (o, r) in original.iter().zip(restored.iter()) {
113            assert!((o - r).abs() < 0.1, "Expected {} ~= {}", o, r);
114        }
115    }
116
117    #[test]
118    fn test_scalar_distance() {
119        let mut quantizer = ScalarQuantizer::uint8(4);
120
121        let vectors = vec![vec![0.0, 0.0, 0.0, 0.0], vec![1.0, 1.0, 1.0, 1.0]];
122
123        quantizer.train(&vectors).expect("train");
124
125        let a = quantizer.quantize(&[0.0, 0.0, 0.0, 0.0]).expect("qa");
126        let b = quantizer.quantize(&[1.0, 1.0, 1.0, 1.0]).expect("qb");
127
128        let dist = quantizer.distance_l2_quantized(&a, &b).expect("dist");
129        assert!(dist > 0.0, "Distance should be positive");
130    }
131
132    #[test]
133    fn test_product_quantizer() {
134        // 8-dimensional vectors with 2 sub-quantizers
135        let mut pq = ProductQuantizer::new(8, 2, 4).expect("new pq"); // 4 bits = 16 centroids
136
137        let vectors: Vec<Vec<f32>> = (0..100)
138            .map(|i| (0..8).map(|j| i as f32 * 0.01 + j as f32 * 0.1).collect())
139            .collect();
140
141        pq.train(&vectors, 10).expect("train");
142        assert!(pq.is_trained());
143
144        // Quantize and dequantize
145        let original = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
146        let code = pq.quantize(&original).expect("quantize");
147        let restored = pq.dequantize(&code).expect("dequantize");
148
149        // Check code size
150        assert_eq!(code.codes.len(), 2);
151        assert_eq!(restored.len(), 8);
152
153        // Compression ratio should be 8*4 / 2 = 16
154        assert_eq!(pq.compression_ratio(), 16.0);
155    }
156
157    #[test]
158    fn test_pq_distance_table() {
159        let mut pq = ProductQuantizer::new(8, 2, 4).expect("new pq");
160
161        let vectors: Vec<Vec<f32>> = (0..50)
162            .map(|i| (0..8).map(|j| i as f32 * 0.02 + j as f32 * 0.1).collect())
163            .collect();
164
165        pq.train(&vectors, 5).expect("train");
166
167        let query = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
168        let code = pq.quantize(&vectors[0]).expect("quantize");
169
170        // Compute distance two ways
171        let direct_dist = pq.asymmetric_distance(&query, &code).expect("dist");
172        let table = pq.compute_distance_table(&query).expect("table");
173        let table_dist = pq.distance_from_table(&table, &code);
174
175        // Should be equal (within floating point tolerance)
176        assert!((direct_dist - table_dist).abs() < 1e-5);
177    }
178
179    #[test]
180    fn test_quantization_benchmarker() {
181        // Create test dataset
182        let dim = 8;
183        let n_vectors = 50;
184        let n_queries = 5;
185
186        let vectors: Vec<Vec<f32>> = (0..n_vectors)
187            .map(|i| (0..dim).map(|j| (i as f32 + j as f32) * 0.1).collect())
188            .collect();
189
190        let queries: Vec<Vec<f32>> = (0..n_queries)
191            .map(|i| {
192                (0..dim)
193                    .map(|j| (i as f32 * 2.0 + j as f32) * 0.1)
194                    .collect()
195            })
196            .collect();
197
198        // Test ground truth computation
199        let gt = QuantizationBenchmarker::compute_ground_truth(&vectors, &queries, 10);
200        assert_eq!(gt.len(), n_queries);
201        assert_eq!(gt[0].len(), 10);
202
203        // Test scalar quantization benchmark
204        let mut sq = ScalarQuantizer::uint8(dim);
205        sq.train(&vectors).expect("train");
206
207        let sq_benchmark =
208            QuantizationBenchmarker::benchmark_scalar(&sq, &vectors, &queries, &gt, &[1, 5, 10])
209                .expect("benchmark");
210
211        assert_eq!(sq_benchmark.recall_at_k.len(), 3);
212        assert!(sq_benchmark.compression_ratio > 1.0);
213        assert!(sq_benchmark.memory_savings > 0);
214    }
215
216    #[test]
217    fn test_quantization_comparison() {
218        let dim = 8;
219        let n_vectors = 100;
220        let n_queries = 10;
221
222        let vectors: Vec<Vec<f32>> = (0..n_vectors)
223            .map(|i| (0..dim).map(|j| (i as f32 + j as f32) * 0.05).collect())
224            .collect();
225
226        let queries: Vec<Vec<f32>> = (0..n_queries)
227            .map(|i| {
228                (0..dim)
229                    .map(|j| (i as f32 * 3.0 + j as f32) * 0.05)
230                    .collect()
231            })
232            .collect();
233
234        let comparison =
235            QuantizationBenchmarker::compare_methods(&vectors, &queries, &[1, 5, 10]).expect("cmp");
236
237        assert!(comparison.dataset_size == n_vectors);
238        assert!(comparison.dimension == dim);
239
240        let summary = comparison.summary();
241        assert!(!summary.is_empty());
242
243        let (method, _recall) = comparison.best_method_for_k(5);
244        assert!(!method.is_empty());
245    }
246
247    #[test]
248    fn test_opq_basic() {
249        let mut opq = OptimizedProductQuantizer::new(8, 2, 4).expect("new opq");
250
251        let vectors: Vec<Vec<f32>> = (0..100)
252            .map(|i| (0..8).map(|j| i as f32 * 0.01 + j as f32 * 0.1).collect())
253            .collect();
254
255        // Train with rotation learning (5 iterations)
256        opq.train(&vectors, 10, 5).expect("train");
257        assert!(opq.is_trained());
258
259        // Quantize and dequantize
260        let original = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
261        let code = opq.quantize(&original).expect("quantize");
262        let restored = opq.dequantize(&code).expect("dequantize");
263
264        assert_eq!(code.codes.len(), 2);
265        assert_eq!(restored.len(), 8);
266
267        // Compression ratio should be same as PQ
268        assert_eq!(opq.compression_ratio(), 16.0);
269
270        // Test asymmetric distance
271        let query = vec![0.15, 0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.85];
272        let dist = opq.asymmetric_distance(&query, &code).expect("dist");
273        assert!(dist >= 0.0);
274    }
275
276    #[test]
277    fn test_opq_distance_table() {
278        let mut opq = OptimizedProductQuantizer::new(8, 2, 4).expect("new opq");
279
280        let vectors: Vec<Vec<f32>> = (0..50)
281            .map(|i| (0..8).map(|j| i as f32 * 0.02 + j as f32 * 0.1).collect())
282            .collect();
283
284        opq.train(&vectors, 5, 3).expect("train");
285
286        let query = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
287        let code = opq.quantize(&vectors[0]).expect("quantize");
288
289        // Compute distance two ways
290        let direct_dist = opq.asymmetric_distance(&query, &code).expect("dist");
291        let table = opq.compute_distance_table(&query).expect("table");
292        let table_dist = opq.distance_from_table(&table, &code);
293
294        // Should be equal (within floating point tolerance)
295        assert!((direct_dist - table_dist).abs() < 1e-4);
296    }
297
298    #[test]
299    fn test_opq_serialization() {
300        let mut opq = OptimizedProductQuantizer::new(8, 2, 4).expect("new opq");
301
302        let vectors: Vec<Vec<f32>> = (0..50)
303            .map(|i| (0..8).map(|j| i as f32 * 0.02 + j as f32 * 0.1).collect())
304            .collect();
305
306        opq.train(&vectors, 5, 3).expect("train");
307
308        // Serialize
309        let serialized =
310            oxicode::serde::encode_to_vec(&opq, oxicode::config::standard()).expect("serialize");
311
312        // Deserialize
313        let deserialized: OptimizedProductQuantizer =
314            oxicode::serde::decode_owned_from_slice(&serialized, oxicode::config::standard())
315                .map(|(v, _)| v)
316                .expect("deserialize");
317
318        assert!(deserialized.is_trained());
319        assert_eq!(deserialized.compression_ratio(), opq.compression_ratio());
320
321        // Test that deserialized works correctly
322        let original = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
323        let code1 = opq.quantize(&original).expect("q1");
324        let code2 = deserialized.quantize(&original).expect("q2");
325
326        // Codes should be identical
327        assert_eq!(code1.codes, code2.codes);
328    }
329
330    // ─────────────────────────────────────────────────────────────────────────
331    // Tests for INT8 / Binary quantization
332    // ─────────────────────────────────────────────────────────────────────────
333
334    /// Helper: cosine similarity of two f32 slices.
335    fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
336        let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
337        let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
338        let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
339        if na < 1e-9 || nb < 1e-9 {
340            0.0
341        } else {
342            dot / (na * nb)
343        }
344    }
345
346    #[test]
347    fn test_quantize_dequantize_roundtrip() {
348        // For vectors with moderate dynamic range, max reconstruction error < 0.01
349        let v: Vec<f32> = (0..16).map(|i| i as f32 * 0.01).collect(); // [0.0, 0.01, ..., 0.15]
350        let (q, scale, zero_point) = quantize_f32_to_i8(&v);
351        let restored = dequantize_i8_to_f32(&q, scale, zero_point);
352
353        assert_eq!(restored.len(), v.len());
354        let max_err = v
355            .iter()
356            .zip(restored.iter())
357            .map(|(a, b)| (a - b).abs())
358            .fold(0.0_f32, f32::max);
359        assert!(max_err < 0.01, "max reconstruction error {max_err} >= 0.01");
360    }
361
362    #[test]
363    fn test_quantized_store_push_get() {
364        let dim = 32;
365        let mut store = QuantizedVectorStore::new(dim);
366
367        // Push 10 simple unit-like vectors
368        let originals: Vec<Vec<f32>> = (0..10usize)
369            .map(|i| {
370                let v: Vec<f32> = (0..dim)
371                    .map(|d| ((i * dim + d) as f32) * 0.001 + 1.0)
372                    .collect();
373                let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
374                v.iter().map(|x| x / norm).collect()
375            })
376            .collect();
377        for (i, v_norm) in originals.iter().enumerate() {
378            let id = store.push(v_norm);
379            assert_eq!(id, i);
380        }
381
382        assert_eq!(store.len(), 10);
383        assert!(!store.is_empty());
384        assert_eq!(store.dim(), dim);
385
386        // Dequantize and verify cosine similarity > 0.99 with original
387        for (i, original) in originals.iter().enumerate() {
388            let recovered = store.get(i).expect("id must be valid");
389            let sim = cosine_sim(original, &recovered);
390            assert!(sim > 0.99, "cosine similarity {sim} <= 0.99 for vector {i}");
391        }
392
393        // Out-of-range should return None
394        assert!(store.get(10).is_none());
395    }
396
397    #[test]
398    fn test_quantized_store_bytes_per_vector() {
399        let dim = 384;
400        let store = QuantizedVectorStore::new(dim);
401        // dim * 1 byte (i8) per vector = 384 bytes
402        assert_eq!(store.bytes_per_vector(), 384.0);
403        // Compare to f32 which would be 384 * 4 = 1536 bytes
404        let f32_bytes = dim as f64 * 4.0;
405        assert!(store.bytes_per_vector() < f32_bytes / 3.0);
406    }
407
408    #[test]
409    fn test_binary_store_hamming() {
410        let dim = 128;
411        let mut store = BinaryVectorStore::new(dim);
412
413        // "High" vector: alternating +2 / -2 so mean = 0.
414        // Bits set where val >= mean (0): the even indices (val = +2).
415        let high: Vec<f32> = (0..dim)
416            .map(|i| if i % 2 == 0 { 2.0_f32 } else { -2.0_f32 })
417            .collect();
418        let id_high = store.push(&high);
419
420        // "Low" vector: negation of "high", also mean = 0.
421        // These are the COMPLEMENT of the high bits → max Hamming distance.
422        let low: Vec<f32> = high.iter().map(|x| -x).collect();
423        let id_low = store.push(&low);
424
425        // Distance to itself must be 0
426        assert_eq!(store.hamming_distance(id_high, id_high), 0);
427        assert_eq!(store.hamming_distance(id_low, id_low), 0);
428
429        // Negated vector: all bits flipped → max Hamming distance = dim
430        assert_eq!(
431            store.hamming_distance(id_high, id_low),
432            dim as u32,
433            "negated vectors should have max hamming distance"
434        );
435
436        // Out-of-range returns u32::MAX
437        assert_eq!(store.hamming_distance(0, 99), u32::MAX);
438    }
439
440    #[test]
441    fn test_binary_store_bytes_per_vector() {
442        let dim = 384;
443        let store = BinaryVectorStore::new(dim);
444        // ceil(384/64) = 6 words, 6 * 8 = 48 bytes
445        let expected_words = dim.div_ceil(64); // 6
446        let expected_bytes = (expected_words * 8) as f64; // 48
447        assert_eq!(store.bytes_per_vector(), expected_bytes);
448        assert_eq!(store.bytes_per_vector(), 48.0);
449    }
450}