Skip to main content

diskann_quantization/minmax/
quantizer.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6#[cfg(feature = "flatbuffers")]
7use thiserror::Error;
8
9use super::vectors::{DataMutRef, FullQueryMut, MinMaxCompensation, MinMaxIP, MinMaxL2Squared};
10use core::f32;
11
12use crate::{
13    AsFunctor, CompressInto,
14    algorithms::Transform,
15    alloc::{GlobalAllocator, ScopedAllocator},
16    bits::{Representation, Unsigned},
17    minmax::{MinMaxCosine, MinMaxCosineNormalized, vectors::FullQueryMeta},
18    num::Positive,
19    scalar::{InputContainsNaN, bit_scale},
20};
21#[cfg(feature = "flatbuffers")]
22use crate::{
23    algorithms::transforms::TransformError,
24    alloc::{Allocator, AllocatorError, Poly},
25    flatbuffers as fb,
26};
27
28/// The base number of bytes to allocate when attempting to serialize a quantizer.
29#[cfg(all(not(test), feature = "flatbuffers"))]
30const DEFAULT_SERIALIZED_BYTES: usize = 1024;
31
32// When testing, use a small value so we trigger the reallocation logic.
33#[cfg(all(test, feature = "flatbuffers"))]
34const DEFAULT_SERIALIZED_BYTES: usize = 1;
35
36/// Recall that from the module-level documentation, MinMaxQuantizer, quantizes X
37/// into `n` bit vectors as follows  -
38/// ```math
39/// X' = round((X - s) * (2^n - 1) / c).clamp(0, 2^n - 1))
40/// ```
41/// where `s` is a shift value and `c` is a scaling parameter computed from the range of values.
42///
43/// For most bit widths (>1), given a positive scaling parameter `grid_scale : f32`,
44/// these are computed as:
45/// ```math
46/// - m = (max_i X[i] + min_i X[i]) / 2.0
47/// - w = max_i X[i] - min_i X[i]
48///
49/// - s = m - w * grid_scale
50/// - c = 2 * w * grid_scale
51///
52/// where `grid_scale` is an input to the quantizer.
53/// ```
54/// For 1-bit quantization, to avoid outliers, `s` and `c` are derived differently:
55/// - Values are first split into two groups: those below and above the mean.
56/// - `s` is the average of values below the mean.
57/// - `c` is the difference between the average of values above the mean and `s`.
58///
59/// See [`MinMaxCompensation`] for notation.
60/// We have then that
61/// ```math
62/// X = X' * (c / (2^n - 1)) + s
63///          --------------    -
64///                 |          |
65///                ax          bx
66/// ```
67#[derive(Debug)]
68#[cfg_attr(test, derive(PartialEq))]
69pub struct MinMaxQuantizer {
70    /// Support for different strategies of pre-transforming vectors before applying compression.
71    /// See [`Transform`] for more details on supported types. The input dimension of vectors
72    /// to the quantizer is derived from `transform.input_dim()`.
73    transform: Transform<GlobalAllocator>,
74
75    /// Scaling parameter used to scale the range (min, max) in order to avoid outliers.
76    /// The input must be a positive value. In general, any value between [0.8, 1] does well.
77    grid_scale: Positive<f32>,
78}
79
80impl MinMaxQuantizer {
81    /// Instantiates a new quantizer with specific transform.
82    pub fn new(transform: Transform<GlobalAllocator>, grid_scale: Positive<f32>) -> Self {
83        Self {
84            transform,
85            grid_scale,
86        }
87    }
88
89    /// Input dimension of vectors to quantizer.
90    pub fn dim(&self) -> usize {
91        self.transform.input_dim()
92    }
93
94    /// Output dimension of vectors after applying transform.
95    ///
96    /// Output storage vectors should use this dimension instead of `self.dim()` because
97    /// in general, the output dim **may** be different from the input dimension.
98    pub fn output_dim(&self) -> usize {
99        self.transform.output_dim()
100    }
101
102    /// Outputs the minimum and maximum value of the range of values
103    /// for an input vector `vec`. The function cases based on the
104    /// intended number of bits `NBITS` per dimension.
105    ///
106    /// * `1-bit` - In order to avoid outlier values, the range
107    ///   is defined by taking the values larger and smaller than
108    ///   the numeric mean, and then taking the respective means of
109    ///   each of these sets as the `max` and `min`.
110    ///
111    /// * `N-bits` - Computes the `min` and `max` of the vector values.
112    ///
113    /// # Returns
114    ///
115    /// * `(m - w * g, m + w * g)` - the lower and upper end of the range, where,
116    ///   `m = (max + min) / 2.0`, `w = (max - min) / 2.0`, and `g = self.grid_scale`.
117    fn get_range<const NBITS: usize>(&self, vec: &[f32]) -> (f32, f32) {
118        let (min, max) = match NBITS {
119            1 => {
120                let (mut min, mut min_count) = (0.0f32, 0.0f32);
121                let (mut max, mut max_count) = (0.0f32, 0.0f32);
122
123                let mean = vec.iter().sum::<f32>() / (vec.len() as f32);
124
125                vec.iter().for_each(|x| {
126                    let m = f32::from((*x < mean) as u8);
127                    min += m * x;
128                    min_count += m;
129                    max += (1.0 - m) * x;
130                    max_count += 1.0 - m;
131                });
132
133                ((min / min_count).min(mean), (max / max_count).max(mean))
134            }
135            _ => {
136                vec // Using `f32::NAN` since [`core::f32::min`] and `max` output the other value if one of them is NAN .
137                    .iter()
138                    .fold((f32::NAN, f32::NAN), |(cmin, cmax), &e| {
139                        (cmin.min(e), cmax.max(e))
140                    })
141            }
142        };
143
144        let width = (max - min) / 2.0;
145        let mid = min + width;
146
147        (
148            mid - width * self.grid_scale.into_inner(),
149            mid + width * self.grid_scale.into_inner(),
150        )
151    }
152
153    fn compress<const NBITS: usize, T>(
154        &self,
155        from: &[T],
156        mut into: DataMutRef<'_, NBITS>,
157    ) -> Result<L2Loss, InputContainsNaN>
158    where
159        T: Copy + Into<f32>,
160        Unsigned: Representation<NBITS>,
161    {
162        let mut into_vec = into.vector_mut();
163
164        assert_eq!(from.len(), self.dim());
165        assert_eq!(self.output_dim(), into_vec.len());
166
167        let domain = Unsigned::domain_const::<NBITS>();
168        let domain_min = *domain.start() as f32;
169        let domain_max = *domain.end() as f32;
170
171        let mut vec = vec![f32::default(); self.output_dim()];
172
173        // We know vec.len() == self.output_dim() and `from.len() == self.dim`
174        #[allow(clippy::unwrap_used)]
175        self.transform
176            .transform_into(
177                &mut vec,
178                &from.iter().map(|&x| x.into()).collect::<Vec<f32>>(),
179                ScopedAllocator::global(),
180            )
181            .unwrap();
182
183        let (min, max) = self.get_range::<NBITS>(&vec);
184
185        let inverse_scale = (max - min).max(1e-8) / bit_scale::<NBITS>(); // To avoid NaN. This is ONLY possible if the vector is all the same value.
186        let mut norm_squared: f32 = 0.0;
187        let mut code_sum: f32 = 0.0;
188        let mut loss: f32 = 0.0;
189
190        let mut nan_check = false;
191
192        vec.iter().enumerate().for_each(|(i, &v)| {
193            nan_check |= v.is_nan();
194
195            let code = ((v - min) / inverse_scale)
196                .clamp(domain_min, domain_max)
197                .round();
198
199            let v_r = (code * inverse_scale) + min; // reconstructed value for `v`.
200            norm_squared += v_r * v_r;
201            code_sum += code;
202            loss += (v_r - v).powi(2);
203
204            //SAFETY: we checked that the lengths of `from` and `into_vec` are the same.
205            unsafe {
206                into_vec.set_unchecked(i, code as u8);
207            }
208        });
209
210        let meta = MinMaxCompensation {
211            dim: self.output_dim() as u32,
212            b: min,
213            a: inverse_scale,
214            n: inverse_scale * code_sum,
215            norm_squared,
216        };
217
218        into.set_meta(meta);
219
220        if nan_check {
221            Err(InputContainsNaN)
222        } else {
223            Ok(match Positive::new(loss) {
224                Ok(p) => L2Loss::Positive(p),
225                Err(_) => L2Loss::Zero,
226            })
227        }
228    }
229
230    /// Attempt to deserialize a FlatBuffer `fb::minmax::Quantizer` (as produced by [`MinMaxQuantizer::serialize`]) into [`MinMaxQuantizer`].
231    #[cfg(feature = "flatbuffers")]
232    #[cfg_attr(docsrs, doc(cfg(feature = "flatbuffers")))]
233    pub fn try_deserialize(data: &[u8]) -> Result<Self, DeserializationError> {
234        // Check that this is one of the known identifiers.
235        if !fb::minmax::quantizer_buffer_has_identifier(data) {
236            return Err(DeserializationError::InvalidIdentifier);
237        }
238
239        let root = fb::minmax::root_as_quantizer(data)?;
240
241        Ok(Self::new(
242            Transform::try_unpack(GlobalAllocator, root.transform())?,
243            Positive::new(root.grid_scale())
244                .map_err(|_| DeserializationError::GridScaleNotPositive)?,
245        ))
246    }
247
248    #[cfg(feature = "flatbuffers")]
249    pub fn serialize<A>(&self, allocator: A) -> Result<Poly<[u8], A>, AllocatorError>
250    where
251        A: Allocator + std::panic::UnwindSafe,
252    {
253        use flatbuffers::FlatBufferBuilder;
254
255        let mut buf = FlatBufferBuilder::new_in(Poly::broadcast(
256            0u8,
257            DEFAULT_SERIALIZED_BYTES,
258            allocator.clone(),
259        )?);
260
261        let transform = &self.transform;
262
263        let (root, mut buf) = match std::panic::catch_unwind(move || {
264            let offset = transform.pack(&mut buf);
265
266            let root = fb::minmax::Quantizer::create(
267                &mut buf,
268                &fb::minmax::QuantizerArgs {
269                    transform: Some(offset),
270                    grid_scale: self.grid_scale.into_inner(),
271                },
272            );
273            (root, buf)
274        }) {
275            Ok(ret) => ret,
276            Err(err) => match err.downcast_ref::<String>() {
277                Some(msg) => {
278                    if msg.contains("AllocatorError") {
279                        return Err(AllocatorError);
280                    } else {
281                        std::panic::resume_unwind(err);
282                    }
283                }
284                None => std::panic::resume_unwind(err),
285            },
286        };
287
288        // Finish serializing and then copy out the finished data into a newly allocated buffer.
289        fb::minmax::finish_quantizer_buffer(&mut buf, root);
290        Poly::from_iter(buf.finished_data().iter().copied(), allocator)
291    }
292}
293
294////////////////
295// Flatbuffer //
296////////////////
297
298#[cfg(feature = "flatbuffers")]
299#[cfg_attr(docsrs, doc(cfg(feature = "flatbuffers")))]
300#[derive(Debug, Clone, Error)]
301#[non_exhaustive]
302pub enum DeserializationError {
303    #[error("unhandled file identifier in flatbuffer")]
304    InvalidIdentifier,
305    #[error(transparent)]
306    InvalidFlatBuffer(#[from] flatbuffers::InvalidFlatbuffer),
307    #[error(transparent)]
308    AllocatorError(#[from] AllocatorError),
309    #[error(transparent)]
310    TransformError(#[from] TransformError),
311    #[error("grid-scale is missing or is not positive")]
312    GridScaleNotPositive,
313}
314
315/////////////////
316// Compression //
317/////////////////
318
319/// A struct defining euclidean loss from quantization.
320///
321/// For an input vector `x` and its representation `x'`,
322/// this is supposed to store `||x - x'||^2`.
323#[derive(Clone, Copy, Debug)]
324pub enum L2Loss {
325    Zero,
326    Positive(Positive<f32>),
327}
328
329impl L2Loss {
330    /// Euclidean loss as a `f32` value
331    pub fn as_f32(&self) -> f32 {
332        match self {
333            L2Loss::Zero => 0.0,
334            L2Loss::Positive(p) => p.into_inner(),
335        }
336    }
337}
338
339impl<const NBITS: usize, T> CompressInto<&[T], DataMutRef<'_, NBITS>> for MinMaxQuantizer
340where
341    T: Copy + Into<f32>,
342    Unsigned: Representation<NBITS>,
343{
344    type Error = InputContainsNaN;
345
346    type Output = L2Loss;
347
348    /// Compress the input vector `from` into a mut ref of Data `to`.
349    ///
350    /// This method computes and stores the compensation coefficients required for computing
351    /// distances correctly.
352    ///
353    /// # Error
354    ///
355    /// Returns an error if the input contains `NaN`.
356    ///
357    /// # Panics
358    ///
359    /// Panics if:
360    /// * `from.len() != self.dim()`: Vector to be compressed must have the same
361    ///   dimensionality as the quantizer.
362    /// * `to.vector().len() != self.output_dim()`: Compressed vector must have the same dimensionality
363    ///   as the quantizer.
364    fn compress_into(&self, from: &[T], to: DataMutRef<'_, NBITS>) -> Result<L2Loss, Self::Error> {
365        self.compress::<NBITS, T>(from, to)
366    }
367}
368
369impl<'a, T> CompressInto<&[T], FullQueryMut<'a>> for MinMaxQuantizer
370where
371    T: Copy + Into<f32>,
372{
373    type Error = InputContainsNaN;
374
375    type Output = ();
376
377    /// Compress the input vector `from` into a [`FullQueryMut`] `to`.
378    ///
379    /// This method simply applies the transformation to the input without
380    /// any compression.
381    ///
382    /// # Error
383    ///
384    /// Returns an error if the input contains `NaN`.
385    ///
386    /// # Panics
387    ///
388    /// Panics if:
389    /// * `from.len() != self.dim()`: Vector to be compressed must have the same
390    ///   dimensionality as the quantizer.
391    /// * `to.len() != self.output_dim()`: Compressed vector must have the same dimensionality
392    ///   as the quantizer.
393    fn compress_into(&self, from: &[T], mut to: FullQueryMut<'a>) -> Result<(), Self::Error> {
394        assert_eq!(from.len(), self.dim());
395        assert_eq!(self.output_dim(), to.len());
396
397        // Transform the input vector and return error if it contains NaN
398        let from: Vec<f32> = from.iter().map(|&x| x.into()).collect();
399        if from.iter().any(|x| x.is_nan()) {
400            return Err(InputContainsNaN);
401        }
402
403        // We know vec.len() == self.output_dim() and `from.len() == self.dim`
404        #[allow(clippy::unwrap_used)]
405        self.transform
406            .transform_into(to.vector_mut(), &from, ScopedAllocator::global())
407            .unwrap();
408
409        let norm_squared = to.vector().iter().map(|x| *x * *x).sum::<f32>();
410        let sum = to.vector().iter().sum::<f32>();
411
412        *to.meta_mut() = FullQueryMeta { norm_squared, sum };
413
414        Ok(())
415    }
416}
417
418///////////////////////
419// Distance Functors //
420///////////////////////
421
422macro_rules! impl_functor {
423    ($dist:ident) => {
424        impl AsFunctor<$dist> for MinMaxQuantizer {
425            // no need to do any work here.
426            fn as_functor(&self) -> $dist {
427                $dist
428            }
429        }
430    };
431}
432
433impl_functor!(MinMaxIP);
434impl_functor!(MinMaxL2Squared);
435impl_functor!(MinMaxCosine);
436impl_functor!(MinMaxCosineNormalized);
437
438///////////
439// Tests //
440///////////
441#[cfg(test)]
442#[cfg(not(miri))]
443mod minmax_quantizer_tests {
444    use std::num::NonZeroUsize;
445
446    use diskann_utils::{Reborrow, ReborrowMut};
447    use diskann_vector::{PureDistanceFunction, distance::SquaredL2};
448    use rand::{
449        SeedableRng,
450        distr::{Distribution, Uniform},
451        rngs::StdRng,
452    };
453
454    use super::*;
455    use crate::{
456        algorithms::transforms::NullTransform,
457        alloc::GlobalAllocator,
458        minmax::vectors::{Data, DataRef, FullQuery, FullQueryMut},
459    };
460
461    fn reconstruct_minmax<const NBITS: usize>(v: DataRef<'_, NBITS>) -> Vec<f32>
462    where
463        Unsigned: Representation<NBITS>,
464    {
465        (0..v.len())
466            .map(|i| {
467                let m = v.meta();
468                v.vector().get(i).unwrap() as f32 * m.a + m.b
469            })
470            .collect()
471    }
472
473    fn test_quantizer_encoding_random<const NBITS: usize>(
474        dim: usize,
475        rng: &mut StdRng,
476        relative_err: f32,
477        scale: f32,
478    ) where
479        Unsigned: Representation<NBITS>,
480        MinMaxQuantizer: for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, NBITS>, Output = L2Loss>
481            + for<'a, 'b> CompressInto<&'a [f32], FullQueryMut<'b>, Output = ()>,
482    {
483        let distribution = Uniform::new_inclusive::<f32, f32>(-1.0, 1.0).unwrap();
484
485        let quantizer = MinMaxQuantizer::new(
486            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
487            Positive::new(scale).unwrap(),
488        );
489
490        assert_eq!(quantizer.dim(), dim);
491
492        let vector: Vec<f32> = distribution.sample_iter(rng).take(dim).collect();
493
494        let mut encoded = Data::new_boxed(dim);
495        let loss = quantizer
496            .compress_into(&*vector, encoded.reborrow_mut())
497            .unwrap();
498
499        let reconstructed = reconstruct_minmax::<NBITS>(encoded.reborrow());
500        assert_eq!(reconstructed.len(), dim);
501
502        let reconstruction_error: f32 = SquaredL2::evaluate(&*vector, &*reconstructed);
503        let norm = vector.iter().map(|x| x * x).sum::<f32>();
504        assert!(
505            (reconstruction_error / norm) <= relative_err,
506            "Expected vector : {:?} to be reconstructed within error {} but instead got : {:?}, with error {} for dim : {}",
507            vector,
508            relative_err,
509            reconstructed,
510            reconstruction_error / norm,
511            dim,
512        );
513
514        assert!((loss.as_f32() - reconstruction_error) <= 1e-4);
515
516        let expected_code_sum = (0..dim)
517            .map(|i| encoded.vector().get(i).unwrap() as f32)
518            .sum::<f32>();
519        let code_sum = encoded.reborrow().meta().n / encoded.reborrow().meta().a;
520        assert!(
521            (code_sum - expected_code_sum).abs() <= 2e-5 * (dim as f32),
522            "Encoded vector with dim : {dim} is {:?}, got error : {} for vector : {:?}",
523            encoded.reborrow(),
524            (code_sum - expected_code_sum).abs(),
525            vector,
526        );
527        let recon_norm_sq = reconstructed.iter().map(|x| x * x).sum::<f32>();
528        assert!((encoded.reborrow().meta().norm_squared - recon_norm_sq).abs() <= 1e-3);
529
530        // FullQuery
531        let mut f = FullQuery::new_in(dim, GlobalAllocator).unwrap();
532        quantizer
533            .compress_into(vector.as_slice(), f.reborrow_mut())
534            .unwrap();
535
536        f.vector()
537            .iter()
538            .enumerate()
539            .zip(vector.iter())
540            .for_each(|((i, x), y)| {
541                assert!(
542                    (*x - *y).abs() < 1e-10,
543                    "Full Query did not compress dimension {i} with value {} correctly, got {} instead.",
544                    *y,
545                    *x,
546                )
547            });
548
549        assert!(
550            (f.meta().norm_squared - norm).abs() < 1e-10,
551            "Full Query norm in meta should be {norm} but instead got {}",
552            f.meta().norm_squared
553        );
554
555        let sum = vector.iter().sum::<f32>();
556        assert!(
557            (f.meta().sum - sum) < 1e-10,
558            "Full Query norm in meta should be {sum} but instead got {}",
559            f.meta().sum
560        );
561    }
562
563    cfg_if::cfg_if! {
564        if #[cfg(miri)] {
565            // The max dim does not need to be as high for `CompensatedVectors` because they
566            // defer their distance function implementation to `BitSlice`, which is more
567            // heavily tested.
568            const TRIALS: usize = 2;
569        } else {
570            const TRIALS: usize = 10;
571        }
572    }
573
574    macro_rules! test_minmax_quantizer_encoding {
575        ($name:ident, $dim:literal, $nbits:literal, $seed:literal, $err:expr) => {
576            #[test]
577            fn $name() {
578                let mut rng = StdRng::seed_from_u64($seed);
579                let scales = [1.0, 1.1, 0.9];
580                for (s, e) in scales.iter().zip($err) {
581                    for d in 10..$dim {
582                        for _ in 0..TRIALS {
583                            test_quantizer_encoding_random::<$nbits>(d, &mut rng, e, *s);
584                        }
585                    }
586                }
587            }
588        };
589    }
590    test_minmax_quantizer_encoding!(
591        test_minmax_encoding_1bit,
592        100,
593        1,
594        0xa32d5658097a1c35,
595        vec![0.5, 0.5, 0.5]
596    );
597    test_minmax_quantizer_encoding!(
598        test_minmax_encoding_2bit,
599        100,
600        2,
601        0xf60c0c8d1aadc126,
602        vec![0.5, 0.5, 0.5]
603    );
604    test_minmax_quantizer_encoding!(
605        test_minmax_encoding_4bit,
606        100,
607        4,
608        0x09fa14c42a9d7d98,
609        vec![1.0e-2, 1.0e-2, 3.0e-2]
610    );
611    test_minmax_quantizer_encoding!(
612        test_minmax_encoding_8bit,
613        100,
614        8,
615        0xaedf3d2a223b7b77,
616        vec![2.0e-3, 2.0e-3, 7.0e-3]
617    );
618
619    macro_rules! expand_to_bitrates {
620        ($name:ident, $func:ident) => {
621            #[test]
622            fn $name() {
623                $func::<1>();
624                $func::<2>();
625                $func::<4>();
626                $func::<8>();
627            }
628        };
629    }
630
631    /// Tests the edge case where min == max but both are non-zero.
632    fn test_all_same_value_vector<const NBITS: usize>()
633    where
634        Unsigned: Representation<NBITS>,
635        MinMaxQuantizer:
636            for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, NBITS>, Output = L2Loss>,
637    {
638        let dim = 30;
639        let quantizer = MinMaxQuantizer::new(
640            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
641            Positive::new(1.0).unwrap(),
642        );
643        let constant_value = 42.5f32;
644        let vector = vec![constant_value; dim];
645
646        let mut encoded = Data::new_boxed(dim);
647        let result = quantizer.compress_into(&vector, encoded.reborrow_mut());
648
649        assert!(
650            result.is_ok(),
651            "Constant-value vector should compress successfully"
652        );
653
654        assert!(result.unwrap().as_f32().abs() <= 1e-6);
655
656        // Reconstruction should yield the original constant value (approximately)
657        let reconstructed = reconstruct_minmax(encoded.reborrow());
658        for &val in &reconstructed {
659            assert!(
660                (val - constant_value).abs() < 1e-3,
661                "Reconstructed value {} should be close to original {}. Compressed vector is {:?}",
662                val,
663                constant_value,
664                encoded.meta(),
665            );
666        }
667    }
668
669    /// This tests boundary conditions in the quantization logic.
670    fn test_two_distinct_values<const NBITS: usize>()
671    where
672        Unsigned: Representation<NBITS>,
673        MinMaxQuantizer:
674            for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, NBITS>, Output = L2Loss>,
675    {
676        let dim = 20;
677        let quantizer = MinMaxQuantizer::new(
678            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
679            Positive::new(1.0).unwrap(),
680        );
681
682        let val1 = -10.0f32;
683        let val2 = 15.0f32;
684        let mut vector = vec![val1; dim];
685        // Make half the vector the second value
686        for i in vector.iter_mut().skip(dim) {
687            *i = val2;
688        }
689
690        let mut encoded = Data::new_boxed(dim);
691        let result = quantizer.compress_into(&vector, encoded.reborrow_mut());
692
693        assert!(
694            result.is_ok(),
695            "Two-value vector should compress successfully"
696        );
697
698        assert!(result.unwrap().as_f32().abs() <= 1e-6);
699
700        // Verify that only two distinct codes are used
701        let mut codes_used = std::collections::HashSet::new();
702        for i in 0..dim {
703            codes_used.insert(encoded.vector().get(i).unwrap());
704        }
705
706        // For most bit widths, we should see exactly 2 codes (min and max of domain)
707        if NBITS > 1 {
708            assert!(
709                codes_used.len() <= 2,
710                "Should use at most 2 distinct codes for 2-value input, but used: {:?}",
711                codes_used
712            );
713        }
714
715        // Verify reconstruction maintains the two-value structure approximately
716        let reconstructed = reconstruct_minmax(encoded.reborrow());
717        for ((i, val), v) in reconstructed.into_iter().enumerate().zip(&vector) {
718            // Round to nearest 0.1 to account for quantization error
719            assert!(
720                (val - v).abs() < 1e-4,
721                "Reconstructed value in dim : {i} is {val}, when it should be {v}."
722            );
723        }
724    }
725
726    /// Verifies that NaN values in the input cause the expected error but
727    /// dimension in meta is correctly set.
728    fn test_nan_input_error<const NBITS: usize>()
729    where
730        Unsigned: Representation<NBITS>,
731        MinMaxQuantizer:
732            for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, NBITS>, Output = L2Loss>,
733    {
734        let dim = 100;
735        let quantizer = MinMaxQuantizer::new(
736            Transform::Null(NullTransform::new(NonZeroUsize::new(dim).unwrap())),
737            Positive::new(1.0).unwrap(),
738        );
739
740        // Test vector with NaN in the middle.
741        let mut vector_nan = vec![1.0f32; dim];
742        vector_nan[33] = f32::NAN;
743        let mut encoded = Data::new_boxed(dim);
744        let result = quantizer.compress_into(&vector_nan, encoded.reborrow_mut());
745        assert!(result.is_err(), "Vector with NaN should cause an error");
746
747        let meta = encoded.meta();
748        assert_eq!(meta.dim as usize, dim);
749    }
750
751    expand_to_bitrates!(all_same_values_vector, test_all_same_value_vector);
752    expand_to_bitrates!(two_distinct_values, test_two_distinct_values);
753    expand_to_bitrates!(nan_input_error, test_nan_input_error);
754
755    /// Verifies that providing a vector with wrong dimensionality causes a panic.
756    #[test]
757    #[should_panic(expected = "assertion `left == right` failed\n  left: 15\n right: 10")]
758    fn test_dimension_mismatch_panic()
759    where
760        Unsigned: Representation<8>,
761        MinMaxQuantizer: for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, 8>, Output = L2Loss>,
762    {
763        let expected_dim = 10;
764        let quantizer = MinMaxQuantizer::new(
765            Transform::Null(NullTransform::new(NonZeroUsize::new(expected_dim).unwrap())),
766            Positive::new(1.0).unwrap(),
767        );
768
769        // Provide vector with wrong dimension
770        let wrong_vector = vec![1.0f32; expected_dim + 5]; // Too many dimensions
771        let mut encoded = Data::new_boxed(expected_dim);
772
773        // This should panic due to assertion in compress_into
774        let _ = quantizer.compress_into(&wrong_vector, encoded.reborrow_mut());
775    }
776
777    #[cfg(feature = "flatbuffers")]
778    mod serialization {
779        use std::sync::{
780            Arc,
781            atomic::{AtomicBool, Ordering},
782        };
783
784        use super::*;
785        use crate::{
786            algorithms::{TransformKind, transforms::TargetDim},
787            alloc::{AllocatorCore, AllocatorError},
788        };
789
790        /// Build a quantizer backed by a random `DoubleHadamard` transform so that
791        /// serialization has to round-trip non-trivial transform state (the random
792        /// sign flips), not just the scalar grid-scale.
793        fn make_hadamard_quantizer(dim: usize, grid_scale: f32, seed: u64) -> MinMaxQuantizer {
794            let mut rng = StdRng::seed_from_u64(seed);
795            let transform = Transform::new(
796                TransformKind::DoubleHadamard {
797                    target_dim: TargetDim::Same,
798                },
799                NonZeroUsize::new(dim).unwrap(),
800                Some(&mut rng),
801                GlobalAllocator,
802            )
803            .unwrap();
804
805            MinMaxQuantizer::new(transform, Positive::new(grid_scale).unwrap())
806        }
807
808        fn test_roundtrip_inner<const NBITS: usize>(seed: u64)
809        where
810            Unsigned: Representation<NBITS>,
811            MinMaxQuantizer:
812                for<'a, 'b> CompressInto<&'a [f32], DataMutRef<'b, NBITS>, Output = L2Loss>,
813        {
814            let dim = 16;
815            let quantizer = make_hadamard_quantizer(dim, 0.9, seed);
816
817            let serialized = quantizer.serialize(GlobalAllocator).unwrap();
818            let deserialized = MinMaxQuantizer::try_deserialize(&serialized).unwrap();
819
820            assert_eq!(deserialized, quantizer);
821        }
822
823        macro_rules! roundtrip_test {
824            ($name:ident, $nbits:literal, $seed:literal) => {
825                #[test]
826                fn $name() {
827                    test_roundtrip_inner::<$nbits>($seed);
828                }
829            };
830        }
831        roundtrip_test!(serialize_roundtrip_1bit, 1, 0x1b3f_8c21_6d90_a54e);
832        roundtrip_test!(serialize_roundtrip_2bit, 2, 0x77c2_e0a4_35bd_1f08);
833        roundtrip_test!(serialize_roundtrip_4bit, 4, 0x0a9d_4471_c8e2_9b6f);
834        roundtrip_test!(serialize_roundtrip_8bit, 8, 0xd4e1_5aa9_3f7c_2210);
835
836        /// Deserializing a buffer without the expected file identifier is rejected
837        /// (rather than panicking or silently misparsing).
838        #[test]
839        fn deserialize_rejects_invalid_identifier() {
840            // A zero-filled buffer long enough to hold an identifier slot has the wrong
841            // identifier and must be rejected.
842            assert!(matches!(
843                MinMaxQuantizer::try_deserialize(&[0u8; 32]),
844                Err(DeserializationError::InvalidIdentifier),
845            ));
846
847            // Corrupting the file identifier of an otherwise-valid buffer is also rejected.
848            let quantizer = make_hadamard_quantizer(16, 1.0, 0x51ed_9c33);
849            let mut serialized = quantizer.serialize(GlobalAllocator).unwrap().to_vec();
850            // The file identifier occupies bytes 4..8 of a (non size-prefixed) flatbuffer.
851            serialized[4] ^= 0xff;
852            assert!(matches!(
853                MinMaxQuantizer::try_deserialize(&serialized),
854                Err(DeserializationError::InvalidIdentifier),
855            ));
856        }
857
858        /// An allocator that succeeds on its first allocation but fails on every
859        /// subsequent one, used to exercise the reallocation path in `serialize`.
860        #[derive(Debug, Clone)]
861        struct FlakyAllocator {
862            have_allocated: Arc<AtomicBool>,
863        }
864
865        impl FlakyAllocator {
866            fn new(have_allocated: Arc<AtomicBool>) -> Self {
867                Self { have_allocated }
868            }
869        }
870
871        // SAFETY: This wraps `GlobalAllocator` and only changes when allocation fails.
872        unsafe impl AllocatorCore for FlakyAllocator {
873            fn allocate(
874                &self,
875                layout: std::alloc::Layout,
876            ) -> Result<std::ptr::NonNull<[u8]>, AllocatorError> {
877                if self.have_allocated.swap(true, Ordering::Relaxed) {
878                    Err(AllocatorError)
879                } else {
880                    GlobalAllocator.allocate(layout)
881                }
882            }
883
884            unsafe fn deallocate(&self, ptr: std::ptr::NonNull<[u8]>, layout: std::alloc::Layout) {
885                // SAFETY: Inherited from caller.
886                unsafe { GlobalAllocator.deallocate(ptr, layout) }
887            }
888        }
889
890        /// Serialization must surface an [`AllocatorError`] instead of panicking when the
891        /// buffer needs to grow but reallocation fails. Under `cfg(test)` the initial
892        /// buffer is intentionally tiny, so growth (and thus the second allocation) always
893        /// happens.
894        #[test]
895        fn serialize_allocation_failure_does_not_panic() {
896            let quantizer = make_hadamard_quantizer(16, 1.0, 0x7a11_36bd);
897            let have_allocated = Arc::new(AtomicBool::new(false));
898            let _: AllocatorError = quantizer
899                .serialize(FlakyAllocator::new(have_allocated.clone()))
900                .unwrap_err();
901            assert!(have_allocated.load(Ordering::Relaxed));
902        }
903    }
904}