Skip to main content

next_plaid/
codec.rs

1//! Residual codec for quantization and decompression
2
3use ndarray::{s, Array1, Array2, ArrayView1, ArrayView2, Axis};
4
5use crate::error::{Error, Result};
6
7/// Default maximum memory (bytes) to allocate for nearest centroid computation in
8/// `compress_into_codes`. This limits the size of the `[batch_size, num_centroids]`
9/// scores matrix. Keeping this lower reduces page-fault and zero-fill overhead
10/// from giant temporary score buffers.
11const DEFAULT_MAX_NEAREST_CENTROID_MEMORY: usize = 1024 * 1024 * 1024; // 1GB
12
13fn max_nearest_centroid_memory() -> usize {
14    std::env::var("NEXT_PLAID_MAX_NEAREST_CENTROID_MEMORY_MB")
15        .ok()
16        .and_then(|v| v.parse::<usize>().ok())
17        .filter(|&mb| mb > 0)
18        .map(|mb| mb.saturating_mul(1024 * 1024))
19        .unwrap_or(DEFAULT_MAX_NEAREST_CENTROID_MEMORY)
20}
21
22#[inline]
23fn cmp_f32_for_max(a: &f32, b: &f32) -> std::cmp::Ordering {
24    match (a.is_finite(), b.is_finite()) {
25        (true, true) => a.total_cmp(b),
26        (true, false) => std::cmp::Ordering::Greater,
27        (false, true) => std::cmp::Ordering::Less,
28        (false, false) => std::cmp::Ordering::Equal,
29    }
30}
31
32/// Storage backend for centroids, supporting both owned arrays and memory-mapped files.
33///
34/// This enum enables `ResidualCodec` to work with centroids stored either:
35/// - In memory as an owned `Array2<f32>` (default, for `Index` and `LoadedIndex`)
36/// - Memory-mapped from disk (for `MmapIndex`, reducing RAM usage)
37pub enum CentroidStore {
38    /// Centroids stored as an owned ndarray (loaded into RAM)
39    Owned(Array2<f32>),
40    /// Centroids stored as a memory-mapped NPY file (OS-managed paging)
41    Mmap(crate::mmap::MmapNpyArray2F32),
42}
43
44impl CentroidStore {
45    /// Get a view of the centroids as ArrayView2.
46    ///
47    /// This is zero-copy for both owned and mmap variants.
48    pub fn view(&self) -> ArrayView2<'_, f32> {
49        match self {
50            CentroidStore::Owned(arr) => arr.view(),
51            CentroidStore::Mmap(mmap) => mmap.view(),
52        }
53    }
54
55    /// Get the number of centroids (rows).
56    pub fn nrows(&self) -> usize {
57        match self {
58            CentroidStore::Owned(arr) => arr.nrows(),
59            CentroidStore::Mmap(mmap) => mmap.nrows(),
60        }
61    }
62
63    /// Get the embedding dimension (columns).
64    pub fn ncols(&self) -> usize {
65        match self {
66            CentroidStore::Owned(arr) => arr.ncols(),
67            CentroidStore::Mmap(mmap) => mmap.ncols(),
68        }
69    }
70
71    /// Get a view of a single centroid row.
72    pub fn row(&self, idx: usize) -> ArrayView1<'_, f32> {
73        match self {
74            CentroidStore::Owned(arr) => arr.row(idx),
75            CentroidStore::Mmap(mmap) => mmap.row(idx),
76        }
77    }
78
79    /// Get a view of rows [start..end] as ArrayView2.
80    ///
81    /// This is zero-copy for both owned and mmap variants.
82    pub fn slice_rows(&self, start: usize, end: usize) -> ArrayView2<'_, f32> {
83        match self {
84            CentroidStore::Owned(arr) => arr.slice(s![start..end, ..]),
85            CentroidStore::Mmap(mmap) => mmap.slice_rows(start, end),
86        }
87    }
88}
89
90impl Clone for CentroidStore {
91    fn clone(&self) -> Self {
92        match self {
93            // For owned, just clone the array
94            CentroidStore::Owned(arr) => CentroidStore::Owned(arr.clone()),
95            // For mmap, we need to convert to owned since Mmap isn't Clone
96            CentroidStore::Mmap(mmap) => CentroidStore::Owned(mmap.to_owned()),
97        }
98    }
99}
100
101/// A codec that manages quantization parameters and lookup tables for the index.
102///
103/// This struct contains all tensors required to compress embeddings during indexing
104/// and decompress vectors during search. It uses pre-computed lookup tables to
105/// accelerate bit unpacking operations.
106#[derive(Clone)]
107pub struct ResidualCodec {
108    /// Number of bits used to represent each residual bucket (e.g., 2 or 4)
109    pub nbits: usize,
110    /// Coarse centroids (codebook) of shape `[num_centroids, dim]`.
111    /// Can be either owned (in-memory) or memory-mapped for reduced RAM usage.
112    pub centroids: CentroidStore,
113    /// Average residual vector, used to reduce reconstruction error
114    pub avg_residual: Array1<f32>,
115    /// Boundaries defining which bucket a residual value falls into
116    pub bucket_cutoffs: Option<Array1<f32>>,
117    /// Values (weights) corresponding to each quantization bucket
118    pub bucket_weights: Option<Array1<f32>>,
119    /// Lookup table (256 entries) for byte-to-bits unpacking
120    pub byte_reversed_bits_map: Vec<u8>,
121    /// Maps byte values directly to bucket indices for fast decompression
122    pub bucket_weight_indices_lookup: Option<Array2<usize>>,
123}
124
125impl ResidualCodec {
126    /// Creates a new ResidualCodec and pre-computes lookup tables.
127    ///
128    /// # Arguments
129    ///
130    /// * `nbits` - Number of bits per code (e.g., 2 bits = 4 buckets)
131    /// * `centroids` - Coarse centroids of shape `[num_centroids, dim]`
132    /// * `avg_residual` - Global average residual
133    /// * `bucket_cutoffs` - Quantization boundaries (optional, for indexing)
134    /// * `bucket_weights` - Reconstruction values (optional, for search)
135    pub fn new(
136        nbits: usize,
137        centroids: Array2<f32>,
138        avg_residual: Array1<f32>,
139        bucket_cutoffs: Option<Array1<f32>>,
140        bucket_weights: Option<Array1<f32>>,
141    ) -> Result<Self> {
142        Self::new_with_store(
143            nbits,
144            CentroidStore::Owned(centroids),
145            avg_residual,
146            bucket_cutoffs,
147            bucket_weights,
148        )
149    }
150
151    /// Creates a new ResidualCodec with a specified centroid storage backend.
152    ///
153    /// This is the internal constructor that supports both owned and mmap centroids.
154    pub fn new_with_store(
155        nbits: usize,
156        centroids: CentroidStore,
157        avg_residual: Array1<f32>,
158        bucket_cutoffs: Option<Array1<f32>>,
159        bucket_weights: Option<Array1<f32>>,
160    ) -> Result<Self> {
161        if nbits == 0 || 8 % nbits != 0 {
162            return Err(Error::Codec(format!(
163                "nbits must be a divisor of 8, got {}",
164                nbits
165            )));
166        }
167
168        // Build bit reversal map for unpacking
169        let nbits_mask = (1u32 << nbits) - 1;
170        let mut byte_reversed_bits_map = vec![0u8; 256];
171
172        for (i, byte_slot) in byte_reversed_bits_map.iter_mut().enumerate() {
173            let val = i as u32;
174            let mut out = 0u32;
175            let mut pos = 8i32;
176
177            while pos >= nbits as i32 {
178                let segment = (val >> (pos as u32 - nbits as u32)) & nbits_mask;
179
180                let mut rev_segment = 0u32;
181                for k in 0..nbits {
182                    if (segment & (1 << k)) != 0 {
183                        rev_segment |= 1 << (nbits - 1 - k);
184                    }
185                }
186
187                out |= rev_segment;
188
189                if pos > nbits as i32 {
190                    out <<= nbits;
191                }
192
193                pos -= nbits as i32;
194            }
195            *byte_slot = out as u8;
196        }
197
198        // Build lookup table for bucket weight indices
199        let keys_per_byte = 8 / nbits;
200        let bucket_weight_indices_lookup = if bucket_weights.is_some() {
201            let mask = (1usize << nbits) - 1;
202            let mut table = Array2::<usize>::zeros((256, keys_per_byte));
203
204            for byte_val in 0..256usize {
205                for k in (0..keys_per_byte).rev() {
206                    let shift = k * nbits;
207                    let index = (byte_val >> shift) & mask;
208                    table[[byte_val, keys_per_byte - 1 - k]] = index;
209                }
210            }
211            Some(table)
212        } else {
213            None
214        };
215
216        Ok(Self {
217            nbits,
218            centroids,
219            avg_residual,
220            bucket_cutoffs,
221            bucket_weights,
222            byte_reversed_bits_map,
223            bucket_weight_indices_lookup,
224        })
225    }
226
227    /// Returns the embedding dimension
228    pub fn embedding_dim(&self) -> usize {
229        self.centroids.ncols()
230    }
231
232    /// Returns the number of centroids
233    pub fn num_centroids(&self) -> usize {
234        self.centroids.nrows()
235    }
236
237    /// Returns a view of the centroids.
238    ///
239    /// This is zero-copy for both owned and mmap centroids.
240    pub fn centroids_view(&self) -> ArrayView2<'_, f32> {
241        self.centroids.view()
242    }
243
244    /// Compress embeddings into centroid codes using nearest neighbor search.
245    ///
246    /// Uses batch matrix multiplication for efficiency:
247    /// `scores = embeddings @ centroids.T  -> [N, K]`
248    /// `codes = argmax(scores, axis=1)     -> [N]`
249    ///
250    /// When the `cuda` feature is enabled and a GPU is available, this function
251    /// automatically uses CUDA acceleration. No code changes required.
252    ///
253    /// # Arguments
254    ///
255    /// * `embeddings` - Embeddings of shape `[N, dim]`
256    ///
257    /// # Returns
258    ///
259    /// Centroid indices of shape `[N]`
260    pub fn compress_into_codes(&self, embeddings: &Array2<f32>) -> Array1<usize> {
261        // Try CUDA acceleration if available
262        #[cfg(feature = "_cuda")]
263        {
264            let force_gpu = crate::is_force_gpu();
265            if let Some(ctx) = crate::cuda::get_global_context() {
266                let centroids = self.centroids_view();
267                match crate::cuda::compress_into_codes_cuda_batched(
268                    &ctx,
269                    &embeddings.view(),
270                    &centroids,
271                    None,
272                ) {
273                    Ok(codes) => return codes,
274                    Err(e) => {
275                        if force_gpu {
276                            panic!(
277                                "FORCE_GPU is set but CUDA compress_into_codes failed: {}",
278                                e
279                            );
280                        }
281                        eprintln!(
282                            "[next-plaid] CUDA compression error: {}. Falling back to CPU.",
283                            e
284                        );
285                    }
286                }
287            } else if force_gpu {
288                panic!("FORCE_GPU is set but CUDA context is unavailable");
289            }
290        }
291
292        self.compress_into_codes_cpu(embeddings)
293    }
294
295    /// CPU implementation of compress_into_codes.
296    /// This is useful when you want to explicitly avoid CUDA overhead for small batches.
297    pub fn compress_into_codes_cpu(&self, embeddings: &Array2<f32>) -> Array1<usize> {
298        use rayon::prelude::*;
299
300        let n = embeddings.nrows();
301        if n == 0 {
302            return Array1::zeros(0);
303        }
304
305        // Get centroids view once (zero-copy for both owned and mmap)
306        let centroids = self.centroids_view();
307        let num_centroids = centroids.nrows();
308
309        // Dynamic batch size to stay within memory budget.
310        // The scores matrix has shape [batch_size, num_centroids] with f32 elements.
311        // With 2.5M centroids and 4GB budget: batch_size = 4GB / (2.5M * 4) = 400
312        let max_batch_by_memory =
313            max_nearest_centroid_memory() / (num_centroids * std::mem::size_of::<f32>());
314        let batch_size = max_batch_by_memory.clamp(1, 1024);
315        let batch_ranges: Vec<(usize, usize)> = (0..n)
316            .step_by(batch_size)
317            .map(|start| (start, (start + batch_size).min(n)))
318            .collect();
319
320        let chunked_codes: Vec<Vec<usize>> = batch_ranges
321            .into_par_iter()
322            .map(|(start, end)| {
323                let batch = embeddings.slice(ndarray::s![start..end, ..]);
324
325                // Batch matrix multiplication: [batch, dim] @ [dim, K] -> [batch, K]
326                let scores = batch.dot(&centroids.t());
327
328                // Keep the per-row scan local to avoid nested parallelism.
329                scores
330                    .axis_iter(Axis(0))
331                    .map(|row| {
332                        row.iter()
333                            .enumerate()
334                            .max_by(|(_, a), (_, b)| cmp_f32_for_max(a, b))
335                            .map(|(idx, _)| idx)
336                            .unwrap_or(0)
337                    })
338                    .collect()
339            })
340            .collect();
341
342        Array1::from_vec(chunked_codes.into_iter().flatten().collect())
343    }
344
345    /// Quantize residuals into packed bytes.
346    ///
347    /// Uses vectorized bucket search and parallel processing for efficiency.
348    ///
349    /// # Arguments
350    ///
351    /// * `residuals` - Residual vectors of shape `[N, dim]`
352    ///
353    /// # Returns
354    ///
355    /// Packed residuals of shape `[N, dim * nbits / 8]` as bytes
356    pub fn quantize_residuals(&self, residuals: &Array2<f32>) -> Result<Array2<u8>> {
357        use rayon::prelude::*;
358
359        let cutoffs = self
360            .bucket_cutoffs
361            .as_ref()
362            .ok_or_else(|| Error::Codec("bucket_cutoffs required for quantization".into()))?;
363
364        let n = residuals.nrows();
365        let dim = residuals.ncols();
366        let packed_dim = dim * self.nbits / 8;
367        let nbits = self.nbits;
368
369        if n == 0 {
370            return Ok(Array2::zeros((0, packed_dim)));
371        }
372
373        // Convert cutoffs to a slice for faster access
374        let cutoffs_slice = cutoffs.as_slice().unwrap();
375
376        // Process rows in parallel
377        let packed_rows: Vec<Vec<u8>> = residuals
378            .axis_iter(Axis(0))
379            .into_par_iter()
380            .map(|row| {
381                let mut packed_row = vec![0u8; packed_dim];
382                let mut bit_idx = 0;
383
384                for &val in row.iter() {
385                    // Binary search for bucket (searchsorted equivalent)
386                    let bucket = cutoffs_slice.iter().filter(|&&c| val > c).count();
387
388                    // Pack bits directly into bytes
389                    for b in 0..nbits {
390                        let bit = ((bucket >> b) & 1) as u8;
391                        let byte_idx = bit_idx / 8;
392                        let bit_pos = 7 - (bit_idx % 8);
393                        packed_row[byte_idx] |= bit << bit_pos;
394                        bit_idx += 1;
395                    }
396                }
397
398                packed_row
399            })
400            .collect();
401
402        // Assemble into array
403        let mut packed = Array2::<u8>::zeros((n, packed_dim));
404        for (i, row) in packed_rows.into_iter().enumerate() {
405            for (j, val) in row.into_iter().enumerate() {
406                packed[[i, j]] = val;
407            }
408        }
409
410        Ok(packed)
411    }
412
413    /// Decompress residuals from packed bytes using lookup tables.
414    ///
415    /// # Arguments
416    ///
417    /// * `packed_residuals` - Packed residuals of shape `[N, packed_dim]`
418    /// * `codes` - Centroid codes of shape `[N]`
419    ///
420    /// # Returns
421    ///
422    /// Reconstructed embeddings of shape `[N, dim]`
423    pub fn decompress(
424        &self,
425        packed_residuals: &Array2<u8>,
426        codes: &ArrayView1<usize>,
427    ) -> Result<Array2<f32>> {
428        let bucket_weights = self
429            .bucket_weights
430            .as_ref()
431            .ok_or_else(|| Error::Codec("bucket_weights required for decompression".into()))?;
432
433        let lookup = self
434            .bucket_weight_indices_lookup
435            .as_ref()
436            .ok_or_else(|| Error::Codec("bucket_weight_indices_lookup required".into()))?;
437
438        let n = packed_residuals.nrows();
439        let dim = self.embedding_dim();
440
441        let mut output = Array2::<f32>::zeros((n, dim));
442
443        for i in 0..n {
444            // Get centroid for this embedding (zero-copy via CentroidStore)
445            let centroid = self.centroids.row(codes[i]);
446
447            // Unpack residuals
448            let mut residual_idx = 0;
449            for &byte_val in packed_residuals.row(i).iter() {
450                let reversed = self.byte_reversed_bits_map[byte_val as usize];
451                let indices = lookup.row(reversed as usize);
452
453                for &bucket_idx in indices.iter() {
454                    if residual_idx < dim {
455                        output[[i, residual_idx]] =
456                            centroid[residual_idx] + bucket_weights[bucket_idx];
457                        residual_idx += 1;
458                    }
459                }
460            }
461        }
462
463        // Normalize
464        for mut row in output.axis_iter_mut(Axis(0)) {
465            let norm = row.dot(&row).sqrt().max(1e-12);
466            row /= norm;
467        }
468
469        Ok(output)
470    }
471
472    /// Load codec from index directory
473    pub fn load_from_dir(index_path: &std::path::Path) -> Result<Self> {
474        use ndarray_npy::ReadNpyExt;
475        use std::fs::File;
476
477        let centroids_path = index_path.join("centroids.npy");
478        let centroids: Array2<f32> = Array2::read_npy(
479            File::open(&centroids_path)
480                .map_err(|e| Error::IndexLoad(format!("Failed to open centroids.npy: {}", e)))?,
481        )
482        .map_err(|e| Error::IndexLoad(format!("Failed to read centroids.npy: {}", e)))?;
483
484        let avg_residual_path = index_path.join("avg_residual.npy");
485        let avg_residual: Array1<f32> =
486            Array1::read_npy(File::open(&avg_residual_path).map_err(|e| {
487                Error::IndexLoad(format!("Failed to open avg_residual.npy: {}", e))
488            })?)
489            .map_err(|e| Error::IndexLoad(format!("Failed to read avg_residual.npy: {}", e)))?;
490
491        let bucket_cutoffs_path = index_path.join("bucket_cutoffs.npy");
492        let bucket_cutoffs: Option<Array1<f32>> = if bucket_cutoffs_path.exists() {
493            Some(
494                Array1::read_npy(File::open(&bucket_cutoffs_path).map_err(|e| {
495                    Error::IndexLoad(format!("Failed to open bucket_cutoffs.npy: {}", e))
496                })?)
497                .map_err(|e| {
498                    Error::IndexLoad(format!("Failed to read bucket_cutoffs.npy: {}", e))
499                })?,
500            )
501        } else {
502            None
503        };
504
505        let bucket_weights_path = index_path.join("bucket_weights.npy");
506        let bucket_weights: Option<Array1<f32>> = if bucket_weights_path.exists() {
507            Some(
508                Array1::read_npy(File::open(&bucket_weights_path).map_err(|e| {
509                    Error::IndexLoad(format!("Failed to open bucket_weights.npy: {}", e))
510                })?)
511                .map_err(|e| {
512                    Error::IndexLoad(format!("Failed to read bucket_weights.npy: {}", e))
513                })?,
514            )
515        } else {
516            None
517        };
518
519        // Read nbits from metadata
520        let metadata_path = index_path.join("metadata.json");
521        let metadata: serde_json::Value = serde_json::from_reader(
522            File::open(&metadata_path)
523                .map_err(|e| Error::IndexLoad(format!("Failed to open metadata.json: {}", e)))?,
524        )
525        .map_err(|e| Error::IndexLoad(format!("Failed to parse metadata.json: {}", e)))?;
526
527        let nbits = metadata["nbits"]
528            .as_u64()
529            .ok_or_else(|| Error::IndexLoad("nbits not found in metadata".into()))?
530            as usize;
531
532        Self::new(
533            nbits,
534            centroids,
535            avg_residual,
536            bucket_cutoffs,
537            bucket_weights,
538        )
539    }
540
541    /// Load codec from index directory with memory-mapped centroids.
542    ///
543    /// This is similar to `load_from_dir` but uses memory-mapped I/O for the
544    /// centroids file, reducing RAM usage. The other small tensors (bucket weights,
545    /// etc.) are still loaded into memory as they are negligible in size.
546    ///
547    /// Use this when loading for `MmapIndex` to minimize memory footprint.
548    pub fn load_mmap_from_dir(index_path: &std::path::Path) -> Result<Self> {
549        use ndarray_npy::ReadNpyExt;
550        use std::fs::File;
551
552        // Memory-map centroids instead of loading into RAM
553        let centroids_path = index_path.join("centroids.npy");
554        let mmap_centroids = crate::mmap::MmapNpyArray2F32::from_npy_file(&centroids_path)?;
555
556        // Load small tensors into memory (negligible size)
557        let avg_residual_path = index_path.join("avg_residual.npy");
558        let avg_residual: Array1<f32> =
559            Array1::read_npy(File::open(&avg_residual_path).map_err(|e| {
560                Error::IndexLoad(format!("Failed to open avg_residual.npy: {}", e))
561            })?)
562            .map_err(|e| Error::IndexLoad(format!("Failed to read avg_residual.npy: {}", e)))?;
563
564        let bucket_cutoffs_path = index_path.join("bucket_cutoffs.npy");
565        let bucket_cutoffs: Option<Array1<f32>> = if bucket_cutoffs_path.exists() {
566            Some(
567                Array1::read_npy(File::open(&bucket_cutoffs_path).map_err(|e| {
568                    Error::IndexLoad(format!("Failed to open bucket_cutoffs.npy: {}", e))
569                })?)
570                .map_err(|e| {
571                    Error::IndexLoad(format!("Failed to read bucket_cutoffs.npy: {}", e))
572                })?,
573            )
574        } else {
575            None
576        };
577
578        let bucket_weights_path = index_path.join("bucket_weights.npy");
579        let bucket_weights: Option<Array1<f32>> = if bucket_weights_path.exists() {
580            Some(
581                Array1::read_npy(File::open(&bucket_weights_path).map_err(|e| {
582                    Error::IndexLoad(format!("Failed to open bucket_weights.npy: {}", e))
583                })?)
584                .map_err(|e| {
585                    Error::IndexLoad(format!("Failed to read bucket_weights.npy: {}", e))
586                })?,
587            )
588        } else {
589            None
590        };
591
592        // Read nbits from metadata
593        let metadata_path = index_path.join("metadata.json");
594        let metadata: serde_json::Value = serde_json::from_reader(
595            File::open(&metadata_path)
596                .map_err(|e| Error::IndexLoad(format!("Failed to open metadata.json: {}", e)))?,
597        )
598        .map_err(|e| Error::IndexLoad(format!("Failed to parse metadata.json: {}", e)))?;
599
600        let nbits = metadata["nbits"]
601            .as_u64()
602            .ok_or_else(|| Error::IndexLoad("nbits not found in metadata".into()))?
603            as usize;
604
605        Self::new_with_store(
606            nbits,
607            CentroidStore::Mmap(mmap_centroids),
608            avg_residual,
609            bucket_cutoffs,
610            bucket_weights,
611        )
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn test_codec_creation() {
621        let centroids =
622            Array2::from_shape_vec((4, 8), (0..32).map(|x| x as f32).collect()).unwrap();
623        let avg_residual = Array1::zeros(8);
624        let bucket_cutoffs = Some(Array1::from_vec(vec![-0.5, 0.0, 0.5]));
625        let bucket_weights = Some(Array1::from_vec(vec![-0.75, -0.25, 0.25, 0.75]));
626
627        let codec = ResidualCodec::new(2, centroids, avg_residual, bucket_cutoffs, bucket_weights);
628        assert!(codec.is_ok());
629
630        let codec = codec.unwrap();
631        assert_eq!(codec.nbits, 2);
632        assert_eq!(codec.embedding_dim(), 8);
633        assert_eq!(codec.num_centroids(), 4);
634    }
635
636    #[test]
637    fn test_compress_into_codes() {
638        let centroids = Array2::from_shape_vec(
639            (3, 4),
640            vec![
641                1.0, 0.0, 0.0, 0.0, // centroid 0
642                0.0, 1.0, 0.0, 0.0, // centroid 1
643                0.0, 0.0, 1.0, 0.0, // centroid 2
644            ],
645        )
646        .unwrap();
647
648        let avg_residual = Array1::zeros(4);
649        let codec = ResidualCodec::new(2, centroids, avg_residual, None, None).unwrap();
650
651        let embeddings = Array2::from_shape_vec(
652            (2, 4),
653            vec![
654                0.9, 0.1, 0.0, 0.0, // should match centroid 0
655                0.0, 0.0, 0.95, 0.05, // should match centroid 2
656            ],
657        )
658        .unwrap();
659
660        let codes = codec.compress_into_codes(&embeddings);
661        assert_eq!(codes[0], 0);
662        assert_eq!(codes[1], 2);
663    }
664
665    #[test]
666    fn test_quantize_decompress_roundtrip_4bit() {
667        // Test round-trip with 4-bit quantization
668        let dim = 8;
669        let centroids = Array2::zeros((4, dim));
670        let avg_residual = Array1::zeros(dim);
671
672        // Create bucket cutoffs and weights for 16 buckets
673        // Cutoffs at quantiles 1/16, 2/16, ..., 15/16
674        let bucket_cutoffs: Vec<f32> = (1..16).map(|i| (i as f32 / 16.0 - 0.5) * 2.0).collect();
675        // Weights at quantile midpoints
676        let bucket_weights: Vec<f32> = (0..16)
677            .map(|i| ((i as f32 + 0.5) / 16.0 - 0.5) * 2.0)
678            .collect();
679
680        let codec = ResidualCodec::new(
681            4,
682            centroids,
683            avg_residual,
684            Some(Array1::from_vec(bucket_cutoffs)),
685            Some(Array1::from_vec(bucket_weights)),
686        )
687        .unwrap();
688
689        // Create test residuals that span different bucket ranges
690        let residuals = Array2::from_shape_vec(
691            (2, dim),
692            vec![
693                -0.9, -0.7, -0.5, -0.3, 0.0, 0.3, 0.5, 0.9, // various bucket values
694                -0.8, -0.4, 0.0, 0.4, 0.8, -0.6, 0.2, 0.6,
695            ],
696        )
697        .unwrap();
698
699        // Quantize
700        let packed = codec.quantize_residuals(&residuals).unwrap();
701        assert_eq!(packed.ncols(), dim * 4 / 8); // 4 bytes per row for dim=8, nbits=4
702
703        // Create a temporary centroid assignment (all zeros)
704        let codes = Array1::from_vec(vec![0, 0]);
705
706        // Decompress and verify the reconstruction is reasonable
707        let decompressed = codec.decompress(&packed, &codes.view()).unwrap();
708
709        // The decompressed values should be close to the quantized bucket weights
710        // (plus centroid, which is zero here)
711        for i in 0..residuals.nrows() {
712            for j in 0..residuals.ncols() {
713                let orig = residuals[[i, j]];
714                let recon = decompressed[[i, j]];
715                // After normalization, values should be in similar direction
716                // The reconstruction won't be exact due to quantization, but
717                // the sign should generally match for non-zero values
718                if orig.abs() > 0.2 {
719                    assert!(
720                        (orig > 0.0) == (recon > 0.0) || recon.abs() < 0.1,
721                        "Sign mismatch at [{}, {}]: orig={}, recon={}",
722                        i,
723                        j,
724                        orig,
725                        recon
726                    );
727                }
728            }
729        }
730    }
731
732    #[test]
733    fn test_compress_into_codes_ignores_nan_scores_when_finite_choices_exist() {
734        let centroids = Array2::from_shape_vec(
735            (3, 2),
736            vec![
737                f32::NAN,
738                0.0, //
739                1.0,
740                0.0, //
741                0.0,
742                1.0, //
743            ],
744        )
745        .unwrap();
746        let avg_residual = Array1::zeros(2);
747        let codec = ResidualCodec::new(2, centroids, avg_residual, None, None).unwrap();
748        let embeddings = Array2::from_shape_vec((1, 2), vec![1.0, 0.0]).unwrap();
749
750        let codes = codec.compress_into_codes_cpu(&embeddings);
751        assert_eq!(codes[0], 1);
752    }
753}