Skip to main content

diskann_quantization/spherical/
quantizer.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use std::num::NonZeroUsize;
7
8use diskann_utils::{
9    ReborrowMut,
10    views::rowmajor::{self, Matrix},
11};
12use diskann_vector::{
13    MathematicalValue, Norm, PureDistanceFunction, distance::InnerProduct, norm::FastL2Norm,
14};
15#[cfg(feature = "flatbuffers")]
16use flatbuffers::{FlatBufferBuilder, WIPOffset};
17use rand::{Rng, RngCore};
18use thiserror::Error;
19
20use super::{
21    CompensatedCosine, CompensatedIP, CompensatedSquaredL2, DataMeta, DataMetaError, DataMut,
22    FullQueryMeta, FullQueryMut, QueryMeta, QueryMut, SupportedMetric, iface,
23};
24use crate::{
25    AsFunctor, CompressIntoWith,
26    algorithms::{
27        heap::SliceHeap,
28        transforms::{NewTransformError, Transform, TransformFailed, TransformKind},
29    },
30    alloc::{Allocator, AllocatorError, GlobalAllocator, Poly, ScopedAllocator, TryClone},
31    bits::{PermutationStrategy, Representation, Unsigned},
32    num::Positive,
33    utils::{CannotBeEmpty, compute_means_and_average_norm, compute_normalized_means},
34};
35#[cfg(feature = "flatbuffers")]
36use crate::{
37    algorithms::transforms::TransformError, flatbuffers::spherical, spherical::InvalidMetric,
38};
39
40///////////////
41// Quantizer //
42///////////////
43
44#[derive(Debug)]
45#[cfg_attr(test, derive(PartialEq))]
46pub struct SphericalQuantizer<A = GlobalAllocator>
47where
48    A: Allocator,
49{
50    /// The offset to apply to each vector.
51    shift: Poly<[f32], A>,
52
53    /// The [`SphericalQuantizer`] supports several different strategies for performing the
54    /// distance-preserving transformation on dataset vectors, which may be applicable in
55    /// different scenarios.
56    ///
57    /// The different transformations may have restrictions on the number of supported dimensions.
58    /// While we will accept all non-zero input dimensions, the output dimension of a transform
59    /// may be higher or lower, depending on the configuration.
60    transform: Transform<A>,
61
62    /// The metric meant to be used by the quantizer.
63    metric: SupportedMetric,
64
65    /// When processing queries, it may be beneficial to modify the query norm to match the
66    /// dataset norm.
67    ///
68    /// This is only applicable when `InnerProduct` and `Cosine` are used, but serves to
69    /// move the query into the dynamic range of the quantization.
70    ///
71    /// You would think that the normalization step in RabitQ would mitigate this, but
72    /// that is not always right since range-adjustment happens before centering.
73    mean_norm: Positive<f32>,
74
75    /// To support 16-bit constants which have a limited dynamic range, we allow a
76    /// pre-scaling parameter that is multiplied to each value in compressed vectors.
77    ///
78    /// This allows to transparent handling of compressing integral data, which can
79    /// otherwise easily overflow `f16`.
80    pre_scale: Positive<f32>,
81}
82
83impl<A> TryClone for SphericalQuantizer<A>
84where
85    A: Allocator,
86{
87    fn try_clone(&self) -> Result<Self, AllocatorError> {
88        SphericalQuantizer::try_clone(self)
89    }
90}
91
92#[derive(Debug, Clone, Copy, Error)]
93#[non_exhaustive]
94pub enum TrainError {
95    #[error("data dim cannot be zero")]
96    DimCannotBeZero,
97    #[error("data cannot be empty")]
98    DataCannotBeEmpty,
99    #[error("pre-scale must be positive")]
100    PrescaleNotPositive,
101    #[error("norm must be positive")]
102    NormNotPositive,
103    #[error("computed norm contains infinity or NaN")]
104    NormNotFinite,
105    #[error("reciprocal norm contains infinity or NaN")]
106    ReciprocalNormNotFinite,
107    #[error(transparent)]
108    AllocatorError(#[from] AllocatorError),
109}
110
111#[derive(Debug, Clone, Copy, Error, PartialEq)]
112#[non_exhaustive]
113pub enum CentroidDistanceError {
114    #[error("centroid distance is not finite")]
115    NonFiniteDistance,
116    #[error("expected centroid to have length {expected}")]
117    DimensionMismatch { expected: usize },
118}
119
120impl<A> SphericalQuantizer<A>
121where
122    A: Allocator,
123{
124    /// Return the number dimensions this quantizer has been trained for.
125    pub fn input_dim(&self) -> usize {
126        self.shift.len()
127    }
128
129    /// Return the dimension of the post-transformed vector.
130    ///
131    /// Output storage vectors should use this dimension instead of `self.dim()` because
132    /// in general, the output dim **may** be different from the input dimension.
133    pub fn output_dim(&self) -> usize {
134        self.transform.output_dim()
135    }
136
137    /// Return the per-dimension shift vector.
138    ///
139    /// This vector is meant to accomplish two goals:
140    ///
141    /// 1. Centers the data around the training dataset mean.
142    /// 2. Offsets each dimension into a range that can be encoded in unsigned values.
143    pub fn shift(&self) -> &[f32] {
144        &self.shift
145    }
146
147    /// Return the approximate mean norm of the training data.
148    pub fn mean_norm(&self) -> Positive<f32> {
149        self.mean_norm
150    }
151
152    /// Return the pre-scaling parameter for data. Non-cosine metrics multiply every
153    /// compressed vector by this value to adjust its dynamic range. Cosine ignores it.
154    ///
155    /// A value of 1.0 means that no scaling is occurring.
156    pub fn pre_scale(&self) -> Positive<f32> {
157        self.pre_scale
158    }
159
160    /// Compute the effective squared L2 distance of `centroid` relative to the
161    /// quantizer's internal shift.
162    ///
163    /// For squared L2 and inner product, the training centroid is recovered from the
164    /// pre-scaled shift before comparison. Cosine compares the supplied centroid
165    /// directly with the stored centroid without normalization or pre-scaling.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if `centroid.len()` is not equal to [`Self::input_dim`] or
170    /// the computed distance is non-finite.
171    pub fn centroid_squared_distance(
172        &self,
173        centroid: &[f32],
174    ) -> Result<f32, CentroidDistanceError> {
175        if centroid.len() != self.input_dim() {
176            return Err(CentroidDistanceError::DimensionMismatch {
177                expected: self.input_dim(),
178            });
179        }
180
181        let stored_scale = match self.metric {
182            SupportedMetric::Cosine => 1.0,
183            SupportedMetric::SquaredL2 | SupportedMetric::InnerProduct => {
184                self.pre_scale.into_inner()
185            }
186        };
187
188        let distance = centroid
189            .iter()
190            .zip(self.shift.iter())
191            .map(|(&candidate, &shift)| {
192                let training = shift / stored_scale;
193                let delta = candidate - training;
194                delta * delta
195            })
196            .sum::<f32>();
197
198        if !distance.is_finite() {
199            return Err(CentroidDistanceError::NonFiniteDistance);
200        }
201
202        Ok(distance)
203    }
204
205    /// Return a reference to the allocator used by this data structure.
206    pub fn allocator(&self) -> &A {
207        self.shift.allocator()
208    }
209
210    /// Return an independently allocated copy of this quantizer.
211    pub fn try_clone(&self) -> Result<Self, AllocatorError> {
212        Ok(Self {
213            shift: self.shift.try_clone()?,
214            transform: self.transform.try_clone()?,
215            metric: self.metric,
216            mean_norm: self.mean_norm,
217            pre_scale: self.pre_scale,
218        })
219    }
220
221    /// A lower-level constructor that accepts a centroid, mean norm, and pre-scale directly.
222    pub fn generate(
223        mut centroid: Poly<[f32], A>,
224        mean_norm: f32,
225        transform: TransformKind,
226        metric: SupportedMetric,
227        pre_scale: Option<f32>,
228        rng: &mut dyn RngCore,
229        allocator: A,
230    ) -> Result<Self, TrainError> {
231        let pre_scale = match pre_scale {
232            Some(v) => Positive::new(v).map_err(|_| TrainError::PrescaleNotPositive)?,
233            None => crate::num::POSITIVE_ONE_F32,
234        };
235
236        let dim = match NonZeroUsize::new(centroid.len()) {
237            Some(dim) => dim,
238            None => {
239                return Err(TrainError::DimCannotBeZero);
240            }
241        };
242
243        let mean_norm = Positive::new(mean_norm).map_err(|_| TrainError::NormNotPositive)?;
244
245        // We passed in a 'rng' so `Transform::new` will not fail.
246        let transform = match Transform::new(transform, dim, Some(rng), allocator.clone()) {
247            Ok(v) => v,
248            Err(NewTransformError::RngMissing(_)) => unreachable!("An Rng was provided"),
249            Err(NewTransformError::AllocatorError(err)) => {
250                return Err(TrainError::AllocatorError(err));
251            }
252        };
253
254        // Cosine preprocessing normalizes each vector, so pre-scaling does not affect
255        // cosine vectors or their centroid.
256        if metric != SupportedMetric::Cosine {
257            centroid
258                .iter_mut()
259                .for_each(|v| *v *= pre_scale.into_inner());
260        }
261
262        Ok(SphericalQuantizer {
263            shift: centroid,
264            transform,
265            metric,
266            mean_norm,
267            pre_scale,
268        })
269    }
270
271    /// Return the metric used by this quantizer.
272    pub fn metric(&self) -> SupportedMetric {
273        self.metric
274    }
275
276    /// Construct a quantizer for vectors in the distribution of `data`.
277    ///
278    /// The type of distance-preserving transform to use is selected by the [`TransformKind`].
279    ///
280    /// Vectors compressed with this quantizer will be **metric specific** and optimized for
281    /// distance computations rather than reconstruction. This means that vectors compressed
282    /// targeting the inner-product distance will not return meaningful results if used for
283    /// L2 distance computations.
284    ///
285    /// Additionally, vectors compressed when using the [`SupportedMetric::Cosine`] distance
286    /// will be implicitly normalized before being compressed to enable better compression.
287    ///
288    /// If argument `pre_scale` is given, non-cosine vectors compressed by this quantizer
289    /// will first be scaled by this value. Cosine ignores `pre_scale`. Note that if given,
290    /// `pre_scale` **must** be positive.
291    pub fn train<T, R>(
292        data: rowmajor::Ref<T>,
293        transform: TransformKind,
294        metric: SupportedMetric,
295        pre_scale: PreScale,
296        rng: &mut R,
297        allocator: A,
298    ) -> Result<Self, TrainError>
299    where
300        T: Copy + Into<f64> + Into<f32>,
301        R: Rng,
302    {
303        // An inner implementation erasing the type of the random number generator to
304        // cut down on excess monomorphization.
305        #[inline(never)]
306        fn train<T, A>(
307            data: rowmajor::Ref<T>,
308            transform: TransformKind,
309            metric: SupportedMetric,
310            pre_scale: PreScale,
311            rng: &mut dyn RngCore,
312            allocator: A,
313        ) -> Result<SphericalQuantizer<A>, TrainError>
314        where
315            T: Copy + Into<f64> + Into<f32>,
316            A: Allocator,
317        {
318            // This check is repeated in `Self::generate`, but we prefer to bail as early
319            // as possible if we detect an error.
320            if data.ncols() == 0 {
321                return Err(TrainError::DimCannotBeZero);
322            }
323
324            let (centroid, mean_norm) = match metric {
325                SupportedMetric::SquaredL2 | SupportedMetric::InnerProduct => {
326                    compute_means_and_average_norm(data)
327                }
328                SupportedMetric::Cosine => (
329                    compute_normalized_means(data)
330                        .map_err(|_: CannotBeEmpty| TrainError::DataCannotBeEmpty)?,
331                    1.0,
332                ),
333            };
334
335            let mean_norm = mean_norm as f32;
336
337            if mean_norm <= 0.0 {
338                return Err(TrainError::NormNotPositive);
339            }
340
341            if !mean_norm.is_finite() {
342                return Err(TrainError::NormNotFinite);
343            }
344
345            // Determining if (and how) the pre-scaling term will be calculated.
346            let pre_scale: Positive<f32> = match pre_scale {
347                PreScale::None => crate::num::POSITIVE_ONE_F32,
348                PreScale::Some(v) => v,
349                PreScale::ReciprocalMeanNorm => {
350                    // We've checked that `mean_norm` is both positive and finite.
351                    //
352                    // It's possible that when converted to `f32`,
353                    //
354                    // Taking the reciprocal is well defined. However, since the norms
355                    // and scales in `compute_means_and_average_norm` are done using `f64`,
356                    // it's possible that the computed `mean_norm` is subnormal, leading
357                    // to the reciprocal being infinity.
358                    let pre_scale = Positive::new(1.0 / mean_norm)
359                        .map_err(|_| TrainError::ReciprocalNormNotFinite)?;
360
361                    if !pre_scale.into_inner().is_finite() {
362                        return Err(TrainError::ReciprocalNormNotFinite);
363                    }
364
365                    pre_scale
366                }
367            };
368
369            // Allow the pre-scaling to take place inside `Self::generate`.
370            let centroid =
371                Poly::from_iter(centroid.into_iter().map(|i| i as f32), allocator.clone())?;
372
373            SphericalQuantizer::generate(
374                centroid,
375                mean_norm,
376                transform,
377                metric,
378                Some(pre_scale.into_inner()),
379                rng,
380                allocator,
381            )
382        }
383
384        train(data, transform, metric, pre_scale, rng, allocator)
385    }
386
387    /// Rescale the argument `v` to be in the rough dynamic range of the training dataset.
388    pub fn rescale(&self, v: &mut [f32]) {
389        let norm = FastL2Norm.evaluate(&*v);
390        if norm != 0.0 {
391            let m = self.mean_norm.into_inner() / norm;
392            v.iter_mut().for_each(|i| *i *= m);
393        }
394    }
395
396    /// Private helper function to do common data pre-processing.
397    ///
398    /// # Panics
399    ///
400    /// Panics if `data.len() != self.dim()`.
401    fn preprocess<'a>(
402        &self,
403        data: &[f32],
404        allocator: ScopedAllocator<'a>,
405    ) -> Result<Preprocessed<'a>, CompressionError> {
406        assert_eq!(data.len(), self.input_dim(), "Data dimension is incorrect.");
407
408        // Fold in pre-scaling with the potential norm corretion for cosine.
409        //
410        // NOTE: When we're computing Cosine Similarity, we normalize the vector. As such,
411        // the `pre_scale` parameter become irrelvant since it just gets normalized away.
412        let scale = self.pre_scale.into_inner();
413        let mul: f32 = match self.metric {
414            SupportedMetric::Cosine => {
415                let norm: f32 = (FastL2Norm).evaluate(data);
416                if norm == 0.0 { 1.0 } else { 1.0 / norm }
417            }
418            SupportedMetric::SquaredL2 | SupportedMetric::InnerProduct => scale,
419        };
420
421        // Center the vector and compute the squared norm of the shifted vector.
422        let shifted = Poly::from_iter(
423            std::iter::zip(data.iter(), self.shift.iter()).map(|(&f, &s)| mul * f - s),
424            allocator,
425        )?;
426
427        let shifted_norm = FastL2Norm.evaluate(&*shifted);
428        if !shifted_norm.is_finite() {
429            return Err(CompressionError::InputContainsNaN);
430        }
431        let inner_product_with_centroid = match self.metric {
432            SupportedMetric::SquaredL2 => None,
433            SupportedMetric::InnerProduct | SupportedMetric::Cosine => {
434                let ip: MathematicalValue<f32> = InnerProduct::evaluate(&*shifted, &*self.shift);
435                Some(ip.into_inner())
436            }
437        };
438
439        Ok(Preprocessed {
440            shifted,
441            shifted_norm,
442            inner_product_with_centroid,
443        })
444    }
445
446    /// Construct an [`iface::Impl`] `self`.
447    pub fn as_quantizer_impl<const NBITS: usize>(
448        self,
449    ) -> Result<iface::Impl<NBITS, A>, AllocatorError>
450    where
451        A: 'static,
452        iface::Impl<NBITS, A>: iface::Constructible<A> + iface::Quantizer,
453    {
454        iface::Impl::<NBITS, A>::new(self)
455    }
456
457    /// Construct an [`iface::Quantizer`] trait object from `self`.
458    pub fn as_quantizer<const NBITS: usize>(
459        self,
460    ) -> Result<Poly<dyn iface::Quantizer>, AllocatorError>
461    where
462        A: 'static,
463        iface::Impl<NBITS, A>: iface::Constructible<A> + iface::Quantizer,
464    {
465        let iface = self.as_quantizer_impl()?;
466        crate::poly!({ iface::Quantizer }, iface, GlobalAllocator)
467    }
468}
469
470/// Pre-scaling selector for spherical quantization training. Pre-scaling adjusts the
471/// dynamic range of the data (usually decreasing it uniformly) to keep the correction terms
472/// within the range expressible by 16-bit floating point numbers.
473#[derive(Debug, Clone, Copy)]
474pub enum PreScale {
475    /// Do not use any pre-scaling.
476    None,
477    /// Pre-scale all data by the specified amount.
478    Some(Positive<f32>),
479    /// Heuristically estimate a pre-scaling parameter by using the inverse approximate
480    /// mean norm. This will nearly normalize in-distribution vectors.
481    ReciprocalMeanNorm,
482}
483
484#[cfg(feature = "flatbuffers")]
485#[cfg_attr(docsrs, doc(cfg(feature = "flatbuffers")))]
486#[derive(Debug, Clone, Error, PartialEq)]
487#[non_exhaustive]
488pub enum DeserializationError {
489    #[error(transparent)]
490    TransformError(#[from] TransformError),
491    #[error("unrecognized flatbuffer identifier")]
492    UnrecognizedIdentifier,
493    #[error("transform length not equal to centroid")]
494    DimMismatch,
495    #[error("norm is missing or is not positive")]
496    MissingNorm,
497    #[error("pre-scale is missing or is not positive")]
498    PreScaleNotPositive,
499
500    #[error(transparent)]
501    InvalidFlatBuffer(#[from] flatbuffers::InvalidFlatbuffer),
502
503    #[error(transparent)]
504    InvalidMetric(#[from] InvalidMetric),
505
506    #[error(transparent)]
507    AllocatorError(#[from] AllocatorError),
508}
509
510#[cfg(feature = "flatbuffers")]
511#[cfg_attr(docsrs, doc(cfg(feature = "flatbuffers")))]
512impl<A> SphericalQuantizer<A>
513where
514    A: Allocator + Clone,
515{
516    /// Pack `self` into `buf` using the [`spherical::SphericalQuantizer`] serialized
517    /// representation.
518    pub(crate) fn pack<'a, FA>(
519        &self,
520        buf: &mut FlatBufferBuilder<'a, FA>,
521    ) -> WIPOffset<spherical::SphericalQuantizer<'a>>
522    where
523        FA: flatbuffers::Allocator + 'a,
524    {
525        // Save the centroid vector.
526        let centroid = buf.create_vector(&self.shift);
527
528        // Save the transform.
529        let transform = self.transform.pack(buf);
530
531        // Finish up.
532        spherical::SphericalQuantizer::create(
533            buf,
534            &spherical::SphericalQuantizerArgs {
535                centroid: Some(centroid),
536                transform: Some(transform),
537                metric: self.metric.into(),
538                mean_norm: self.mean_norm.into_inner(),
539                pre_scale: self.pre_scale.into_inner(),
540            },
541        )
542    }
543
544    /// Attempt to unpack `self` from a serialized [`spherical::SphericalQuantizer`]
545    /// serialized representation, returning any encountered error.
546    pub(crate) fn try_unpack(
547        alloc: A,
548        proto: spherical::SphericalQuantizer<'_>,
549    ) -> Result<Self, DeserializationError> {
550        let metric: SupportedMetric = proto.metric().try_into()?;
551
552        // Unpack the centroid.
553        let shift = Poly::from_iter(proto.centroid().into_iter(), alloc.clone())?;
554
555        // Unpack the transform.
556        let transform = Transform::try_unpack(alloc, proto.transform())?;
557
558        // Ensure consistency between the shift dimensions and the transform.
559        if shift.len() != transform.input_dim() {
560            return Err(DeserializationError::DimMismatch);
561        }
562
563        // Make sure we get a sane value for the mean norm.
564        let mean_norm =
565            Positive::new(proto.mean_norm()).map_err(|_| DeserializationError::MissingNorm)?;
566
567        let pre_scale = Positive::new(proto.pre_scale())
568            .map_err(|_| DeserializationError::PreScaleNotPositive)?;
569
570        Ok(Self {
571            shift,
572            transform,
573            metric,
574            mean_norm,
575            pre_scale,
576        })
577    }
578}
579
580struct Preprocessed<'a> {
581    shifted: Poly<[f32], ScopedAllocator<'a>>,
582    shifted_norm: f32,
583    inner_product_with_centroid: Option<f32>,
584}
585
586impl Preprocessed<'_> {
587    /// Return the metric specific correction term as sumamrized below:
588    ///
589    /// * Inner Product: The inner product between the shifted vector and the centroid.
590    /// * Squared L2: The squared norm of the shifted vector.
591    fn metric_specific(&self) -> f32 {
592        match self.inner_product_with_centroid {
593            Some(ip) => ip,
594            None => self.shifted_norm * self.shifted_norm,
595        }
596    }
597}
598
599///////////////////////
600// Distance Functors //
601///////////////////////
602
603impl<A> AsFunctor<CompensatedSquaredL2> for SphericalQuantizer<A>
604where
605    A: Allocator,
606{
607    fn as_functor(&self) -> CompensatedSquaredL2 {
608        CompensatedSquaredL2::new(self.output_dim())
609    }
610}
611
612impl<A> AsFunctor<CompensatedIP> for SphericalQuantizer<A>
613where
614    A: Allocator,
615{
616    fn as_functor(&self) -> CompensatedIP {
617        CompensatedIP::new(&self.shift, self.output_dim())
618    }
619}
620
621impl<A> AsFunctor<CompensatedCosine> for SphericalQuantizer<A>
622where
623    A: Allocator,
624{
625    fn as_functor(&self) -> CompensatedCosine {
626        CompensatedCosine::new(self.as_functor())
627    }
628}
629
630/////////////////
631// Compression //
632/////////////////
633
634#[derive(Debug, Error, Clone, Copy, PartialEq)]
635#[non_exhaustive]
636pub enum CompressionError {
637    #[error("input contains NaN")]
638    InputContainsNaN,
639
640    #[error("expected source vector to have length {expected}")]
641    SourceDimensionMismatch { expected: usize },
642
643    #[error("expected destination vector to have length {expected}")]
644    DestinationDimensionMismatch { expected: usize },
645
646    #[error(
647        "encoding error - you may need to scale the entire dataset to reduce its dynamic range"
648    )]
649    EncodingError(#[from] DataMetaError),
650
651    #[error(transparent)]
652    AllocatorError(#[from] AllocatorError),
653}
654
655fn check_dims(
656    input: usize,
657    output: usize,
658    from: usize,
659    into: usize,
660) -> Result<(), CompressionError> {
661    if from != input {
662        return Err(CompressionError::SourceDimensionMismatch { expected: input });
663    }
664    if into != output {
665        return Err(CompressionError::DestinationDimensionMismatch { expected: output });
666    }
667    Ok(())
668}
669
670/// Helper trait to dispatch to a faster 1-bit implementation and use the slower
671/// maximum-cosine algorithm when more than 1 bit is used.
672trait FinishCompressing {
673    fn finish_compressing(
674        &mut self,
675        preprocessed: &Preprocessed<'_>,
676        transformed: &[f32],
677        transformed_norm: f32,
678        allocator: ScopedAllocator<'_>,
679    ) -> Result<(), CompressionError>;
680}
681
682impl FinishCompressing for DataMut<'_, 1> {
683    fn finish_compressing(
684        &mut self,
685        preprocessed: &Preprocessed<'_>,
686        transformed: &[f32],
687        transformed_norm: f32,
688        _: ScopedAllocator<'_>,
689    ) -> Result<(), CompressionError> {
690        // Compute signed quantized vector (-1 or 1)
691        // and also populate the unsigned bit representation in `into` output vector.
692        let mut quant_raw_inner_product = 0.0f32;
693        let mut bit_sum = 0u32;
694        transformed.iter().enumerate().for_each(|(i, &r)| {
695            let bit: u8 = if r > 0.0 { 1 } else { 0 };
696
697            quant_raw_inner_product += r.abs();
698            bit_sum += <u8 as Into<u32>>::into(bit);
699
700            // SAFETY: From check 1, we know that `i < into.len()`.
701            unsafe { self.vector_mut().set_unchecked(i, bit) };
702        });
703
704        // The value we just computed for `quant_raw_inner_product` is:
705        // ```
706        // Y = <x', x> * sqrt(D)                        [1]
707        // ```
708        // The inner product correction term is
709        // ```
710        //       2 |X|
711        // -----------------                            [2]
712        // <x', x> * sqrt(D)
713        // ```
714        // [1] substitutes directly into [2] and we get
715        // ```
716        // 2 |X|
717        // -----
718        //   Y
719        // ```
720        // Therefore, the inner product correction term is
721        // ```
722        // 2.0 * shifted_norm / quant_raw_inner_product
723        // ```
724        let inner_product_correction =
725            2.0 * transformed_norm * preprocessed.shifted_norm / quant_raw_inner_product;
726        self.set_meta(DataMeta::new(
727            inner_product_correction,
728            preprocessed.metric_specific(),
729            bit_sum,
730        )?);
731        Ok(())
732    }
733}
734
735impl FinishCompressing for DataMut<'_, 2> {
736    fn finish_compressing(
737        &mut self,
738        preprocessed: &Preprocessed<'_>,
739        transformed: &[f32],
740        transformed_norm: f32,
741        allocator: ScopedAllocator<'_>,
742    ) -> Result<(), CompressionError> {
743        compress_via_maximum_cosine(
744            self.reborrow_mut(),
745            preprocessed,
746            transformed,
747            transformed_norm,
748            allocator,
749        )
750    }
751}
752
753impl FinishCompressing for DataMut<'_, 3> {
754    fn finish_compressing(
755        &mut self,
756        preprocessed: &Preprocessed<'_>,
757        transformed: &[f32],
758        transformed_norm: f32,
759        allocator: ScopedAllocator<'_>,
760    ) -> Result<(), CompressionError> {
761        compress_via_maximum_cosine(
762            self.reborrow_mut(),
763            preprocessed,
764            transformed,
765            transformed_norm,
766            allocator,
767        )
768    }
769}
770
771impl FinishCompressing for DataMut<'_, 4> {
772    fn finish_compressing(
773        &mut self,
774        preprocessed: &Preprocessed<'_>,
775        transformed: &[f32],
776        transformed_norm: f32,
777        allocator: ScopedAllocator<'_>,
778    ) -> Result<(), CompressionError> {
779        compress_via_maximum_cosine(
780            self.reborrow_mut(),
781            preprocessed,
782            transformed,
783            transformed_norm,
784            allocator,
785        )
786    }
787}
788
789impl FinishCompressing for DataMut<'_, 8> {
790    fn finish_compressing(
791        &mut self,
792        preprocessed: &Preprocessed<'_>,
793        transformed: &[f32],
794        transformed_norm: f32,
795        allocator: ScopedAllocator<'_>,
796    ) -> Result<(), CompressionError> {
797        compress_via_maximum_cosine(
798            self.reborrow_mut(),
799            preprocessed,
800            transformed,
801            transformed_norm,
802            allocator,
803        )
804    }
805}
806
807//////////////////////
808// Data Compression //
809//////////////////////
810
811impl<A> CompressIntoWith<&[f32], FullQueryMut<'_>, ScopedAllocator<'_>> for SphericalQuantizer<A>
812where
813    A: Allocator,
814{
815    type Error = CompressionError;
816
817    /// Compress the input vector `from` into the bitslice `into`.
818    ///
819    /// # Error
820    ///
821    /// Returns an error if
822    /// * The input contains `NaN`.
823    /// * `from.len() != self.dim()`: Vector to be compressed must have the same
824    ///   dimensionality as the quantizer.
825    /// * `into.len() != self.output_dim()`: Compressed vector must have the same
826    ///   dimensionality as the output of the distance-preserving transform. Importantely,
827    ///   this **may** be different than `self.dim()` and should be retrieved from
828    ///   `self.output_dim()`.
829    fn compress_into_with(
830        &self,
831        from: &[f32],
832        mut into: FullQueryMut<'_>,
833        allocator: ScopedAllocator<'_>,
834    ) -> Result<(), Self::Error> {
835        let input_dim = self.shift.len();
836        let output_dim = self.output_dim();
837        check_dims(input_dim, output_dim, from.len(), into.len())?;
838
839        let mut preprocessed = self.preprocess(from, allocator)?;
840
841        // If the preprocessed norm is zero, then we tried to compress the center directly.
842        // In this case, we can get the correct behavior by setting `into` to all zeros.
843        if preprocessed.shifted_norm == 0.0 {
844            into.vector_mut().fill(0.0);
845            *into.meta_mut() = Default::default();
846            return Ok(());
847        }
848
849        preprocessed
850            .shifted
851            .iter_mut()
852            .for_each(|v| *v /= preprocessed.shifted_norm);
853
854        // Transformation can fail due to OOM - we want to handle that gracefully.
855        //
856        // If the transformation fails because we provided the wrong sizes, that is a hard
857        // program bug.
858        #[expect(clippy::panic, reason = "the dimensions should already be as expected")]
859        match self
860            .transform
861            .transform_into(into.vector_mut(), &preprocessed.shifted, allocator)
862        {
863            Ok(()) => {}
864            Err(TransformFailed::AllocatorError(err)) => {
865                return Err(CompressionError::AllocatorError(err));
866            }
867            Err(TransformFailed::SourceMismatch { .. })
868            | Err(TransformFailed::DestinationMismatch { .. }) => {
869                panic!(
870                    "The sizes of these arrays should already be checked - this is a logic error"
871                );
872            }
873            #[cfg(feature = "linalg")]
874            Err(TransformFailed::SgemmError(_)) => {
875                panic!("SGEMM should not fail with valid dimensions - this is a logic error");
876            }
877        }
878
879        *into.meta_mut() = FullQueryMeta {
880            sum: into.vector().iter().sum::<f32>(),
881            shifted_norm: preprocessed.shifted_norm,
882            metric_specific: preprocessed.metric_specific(),
883        };
884        Ok(())
885    }
886}
887
888impl<const NBITS: usize, A> CompressIntoWith<&[f32], DataMut<'_, NBITS>, ScopedAllocator<'_>>
889    for SphericalQuantizer<A>
890where
891    A: Allocator,
892    Unsigned: Representation<NBITS>,
893    for<'a> DataMut<'a, NBITS>: FinishCompressing,
894{
895    type Error = CompressionError;
896
897    /// Compress the input vector `from` into the bitslice `into`.
898    ///
899    /// # Error
900    ///
901    /// Returns an error if
902    /// * The input contains `NaN`.
903    /// * `from.len() != self.dim()`: Vector to be compressed must have the same
904    ///   dimensionality as the quantizer.
905    /// * `into.len() != self.output_dim()`: Compressed vector must have the same
906    ///   dimensionality as the output of the distance-preserving transform. Importantely,
907    ///   this **may** be different than `self.dim()` and should be retrieved from
908    ///   `self.output_dim()`.
909    fn compress_into_with(
910        &self,
911        from: &[f32],
912        mut into: DataMut<'_, NBITS>,
913        allocator: ScopedAllocator<'_>,
914    ) -> Result<(), Self::Error> {
915        let input_dim = self.shift.len();
916        let output_dim = self.output_dim();
917        check_dims(input_dim, output_dim, from.len(), into.len())?;
918
919        let mut preprocessed = self.preprocess(from, allocator)?;
920
921        if preprocessed.shifted_norm == 0.0 {
922            into.set_meta(DataMeta::default());
923            return Ok(());
924        }
925
926        let mut transformed = Poly::broadcast(0.0f32, output_dim, allocator)?;
927        preprocessed
928            .shifted
929            .iter_mut()
930            .for_each(|v| *v /= preprocessed.shifted_norm);
931
932        // Transformation can fail due to OOM - we want to handle that gracefully.
933        //
934        // If the transformation fails because we provided the wrong sizes, that is a hard
935        // program bug.
936        #[expect(clippy::panic, reason = "the dimensions should already be as expected")]
937        match self
938            .transform
939            .transform_into(&mut transformed, &preprocessed.shifted, allocator)
940        {
941            Ok(()) => {}
942            Err(TransformFailed::AllocatorError(err)) => {
943                return Err(CompressionError::AllocatorError(err));
944            }
945            Err(TransformFailed::SourceMismatch { .. })
946            | Err(TransformFailed::DestinationMismatch { .. }) => {
947                panic!(
948                    "The sizes of these arrays should already be checked - this is a logic error"
949                );
950            }
951            #[cfg(feature = "linalg")]
952            Err(TransformFailed::SgemmError(_)) => {
953                panic!("SGEMM should not fail with valid dimensions - this is a logic error");
954            }
955        }
956
957        let transformed_norm = if self.transform.preserves_norms() {
958            1.0
959        } else {
960            (FastL2Norm).evaluate(&*transformed)
961        };
962
963        into.finish_compressing(&preprocessed, &transformed, transformed_norm, allocator)?;
964        Ok(())
965    }
966}
967
968struct AsNonZero<const NBITS: usize>;
969impl<const NBITS: usize> AsNonZero<NBITS> {
970    const NON_ZERO: NonZeroUsize = NonZeroUsize::new(NBITS).unwrap();
971}
972
973fn compress_via_maximum_cosine<const NBITS: usize>(
974    mut data: DataMut<'_, NBITS>,
975    preprocessed: &Preprocessed<'_>,
976    transformed: &[f32],
977    transformed_norm: f32,
978    allocator: ScopedAllocator<'_>,
979) -> Result<(), CompressionError>
980where
981    Unsigned: Representation<NBITS>,
982{
983    assert_eq!(data.len(), transformed.len());
984
985    // Find the value we will use to multiply `transformed` to round it to the lattice
986    // element that has the maximum cosine-similarity.
987    let optimal_scale =
988        maximize_cosine_similarity(transformed, AsNonZero::<NBITS>::NON_ZERO, allocator)?;
989
990    let domain = Unsigned::domain_const::<NBITS>();
991    let min = *domain.start() as f32;
992    let max = *domain.end() as f32;
993    let offset = max / 2.0;
994
995    let mut self_inner_product = 0.0f32;
996    let mut bit_sum = 0u32;
997    for (i, t) in transformed.iter().enumerate() {
998        let v = (*t * optimal_scale + offset).clamp(min, max).round();
999        let dv = v - offset;
1000        self_inner_product = dv.mul_add(*t, self_inner_product);
1001
1002        let v = v as u8;
1003        bit_sum += <u8 as Into<u32>>::into(v);
1004
1005        // SAFETY: We have checked that `data.len() == transformed.len()`, so this access
1006        // is in-bounds.
1007        //
1008        // Further, by construction, `v` is encodable by the `Unsigned`.
1009        unsafe { data.vector_mut().set_unchecked(i, v) };
1010    }
1011
1012    let shifted_norm = preprocessed.shifted_norm;
1013    let inner_product_correction = (transformed_norm * shifted_norm) / self_inner_product;
1014    data.set_meta(DataMeta::new(
1015        inner_product_correction,
1016        preprocessed.metric_specific(),
1017        bit_sum,
1018    )?);
1019    Ok(())
1020}
1021
1022// This struct does 2 things:
1023//
1024// 1. Records the index in `v` and `rounded` and the scaling parameter so that
1025//   `value * v[position]` gets rounded to `rounded[position] + 1` while
1026//   `(value - epsilon) * v[position]` is rounded to `rounded[position]` for some small
1027//   epsilon.
1028//
1029//   Informally, "what's the smallest scaling factor so `v[position]` gets rounded to
1030//   the next value.
1031//
1032// 2. Imposes a total ordering on nonnegative, non-NaN critical values using unsigned
1033//   bit order, which matches numeric order for these values. Additionally, ordering
1034//   is reversed so that `BinaryHeap` models a min-heap instead of a max-heap.
1035#[derive(Debug, Clone, Copy)]
1036struct Pair {
1037    value: f32,
1038    position: u32,
1039}
1040
1041impl PartialEq for Pair {
1042    fn eq(&self, other: &Self) -> bool {
1043        self.value.to_bits().eq(&other.value.to_bits())
1044    }
1045}
1046
1047impl Eq for Pair {}
1048impl PartialOrd for Pair {
1049    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1050        Some(self.cmp(other))
1051    }
1052}
1053impl Ord for Pair {
1054    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1055        other.value.to_bits().cmp(&self.value.to_bits())
1056    }
1057}
1058
1059/// This is a tricky function - please read carefully.
1060///
1061/// Given a vector `v` compute the scaling factor `s` such that the cosine similarity
1062/// betwen `v` and `r` is maximized where `r` is defined as
1063/// ```math
1064/// let offset = (2^(num_bits) - 1) / 2;
1065/// let r = (s * v + offset).round().clamp(0, 2^num_bits - 1) - offset
1066/// ```
1067///
1068/// More informally, maximize the inner product between `v` and the points in a square
1069/// lattice with 2^num_bits values in each dimension, centered around zero. This latice
1070/// takes the values (+0.5, -0.5, +1.5, -1.5 ...) to give equal weight above and below zero.
1071///
1072/// It works by slowly increasing the factor `s` such that the rounding of only one
1073/// dimension in `v` is changed at a time. A running tally of the cosine similarity is
1074/// computed for each scaling factor until we've processed `D * 2^(num_bits - 1)` possible
1075/// scaling factors, where `D` is the length of `v`.
1076///
1077/// The best scaling factor is returned.
1078///
1079/// Refer to algorithm 1 in <https://arxiv.org/pdf/2409.09913>.
1080///
1081/// # Errors
1082///
1083/// Returns an error if `v` contains NaN or scratch allocation fails.
1084///
1085/// # Panics
1086///
1087/// Panics is `v.is_empty()`.
1088///
1089/// # Implementation details
1090///
1091/// We work with the absolute value of the elements in the vector `v`.
1092/// This does not affect the final result as the scaling works the same in both the
1093/// positive and negative directions but simplifies the book keeping.
1094fn maximize_cosine_similarity(
1095    v: &[f32],
1096    num_bits: NonZeroUsize,
1097    allocator: ScopedAllocator<'_>,
1098) -> Result<f32, CompressionError> {
1099    // Initially, the lattice element has the value `0.5` for all dimensions.
1100    // This means the initial inner product between `v` and the rounded term is simply
1101    // `0.5 * sum(abs.(v))`. The absolute value is used because the latice element is
1102    // always in the direction of the components in `v`.
1103    let mut current_ip = 0.5 * v.iter().map(|i| i.abs() as f64).sum::<f64>();
1104    if current_ip.is_nan() {
1105        return Err(CompressionError::InputContainsNaN);
1106    }
1107    let mut current_square_norm = 0.25 * (v.len() as f64);
1108
1109    // Book keeping for the current value of the rounded vector.
1110    // The true numeric value is 0.5 less than this (in the direction of `v`), but we use
1111    // integers for a smaller memory footprint.
1112    let mut rounded = Poly::broadcast(1u16, v.len(), allocator)?;
1113
1114    // Compute the critical values and store them on a heap.
1115    //
1116    // The binary heap will keep track of the minimum critical value. Multiplying `v` by the
1117    // minimum critical value `s` means that `s * v` will only change `rounded` from its
1118    // current value at a single index (the position associated with `s`).
1119    let eps = 0.0001f32;
1120    let one_and_change = 1.0 + eps;
1121    let mut base = Poly::from_iter(
1122        v.iter().enumerate().map(|(position, value)| {
1123            let value = one_and_change / value.abs();
1124            Pair {
1125                value,
1126                position: position as u32,
1127            }
1128        }),
1129        allocator,
1130    )?;
1131
1132    // Lint: This is a private method and all the callers have an invariant that they check
1133    // for non-empty inputs.
1134    #[expect(clippy::expect_used)]
1135    let mut critical_values =
1136        SliceHeap::new(&mut base).expect("calling code should not allow the slice to be empty");
1137
1138    let mut max_similarity = f64::NEG_INFINITY;
1139    let mut optimal_scale = f32::default();
1140    let stop = (2usize).pow(num_bits.get() as u32 - 1) as u16;
1141
1142    loop {
1143        let mut should_break = false;
1144        critical_values.update_root(|pair| {
1145            let Pair { value, position } = *pair;
1146            if value == f32::MAX {
1147                should_break = true;
1148                return;
1149            }
1150
1151            let r = &mut rounded[position as usize];
1152            let vp = &v[position as usize];
1153
1154            let old_r = *r;
1155            // By the nature of cricital values, only `r` will change in `rounded` when
1156            // multiplying by `value`. And that change will be to increase by 1.
1157            *r += 1;
1158
1159            // The inner product estimate simply increases by `vp.abs()` because:
1160            //
1161            // * `r` is the only value in `rounded` that changes.
1162            // * `r` is increased by 1.
1163            current_ip += vp.abs() as f64;
1164
1165            // This uses the formula
1166            // ```math
1167            // (x + 1)^2 - x^2 = x^2 + 2x + 1 - x^2
1168            //                 = 2x + 1
1169            // ```
1170            // substitute `x = y - 1/2` to obtain the true value associated with rounded and
1171            // we get
1172            // ```math
1173            // 2 ( y - 1/2 ) + 1 = 2y - 1 + 1
1174            //                   = 2y
1175            // ```
1176            // Therefore, the change in the estimate for the square norm of `rounded` is
1177            // `2 * old_r`.
1178            current_square_norm += (2 * old_r) as f64;
1179
1180            // Compute the current cosine similarity and update max if needed.
1181            let similarity = current_ip / current_square_norm.sqrt();
1182            if similarity > max_similarity {
1183                max_similarity = similarity;
1184                optimal_scale = value;
1185            }
1186
1187            // Compute the scaling factor that will change this dimension to the next value.
1188            if *r < stop {
1189                *pair = Pair {
1190                    value: (*r as f32 + eps) / vp.abs(),
1191                    position,
1192                };
1193            } else {
1194                *pair = Pair {
1195                    value: f32::MAX,
1196                    position,
1197                };
1198            }
1199        });
1200        if should_break {
1201            break;
1202        }
1203    }
1204
1205    Ok(optimal_scale)
1206}
1207
1208///////////////////////
1209// Query Compression //
1210///////////////////////
1211
1212impl<const NBITS: usize, Perm, A>
1213    CompressIntoWith<&[f32], QueryMut<'_, NBITS, Perm>, ScopedAllocator<'_>>
1214    for SphericalQuantizer<A>
1215where
1216    Unsigned: Representation<NBITS>,
1217    Perm: PermutationStrategy<NBITS>,
1218    A: Allocator,
1219{
1220    type Error = CompressionError;
1221
1222    /// Compress the input vector `from` into the bitslice `into`.
1223    ///
1224    /// # Error
1225    ///
1226    /// Returns an error if
1227    /// * The input contains `NaN`.
1228    /// * `from.len() != self.dim()`: Vector to be compressed must have the same
1229    ///   dimensionality as the quantizer.
1230    /// * `into.len() != self.output_dim()`: Compressed vector must have the same
1231    ///   dimensionality as the output of the distance-preserving transform. Importantely,
1232    ///   this **may** be different than `self.dim()` and should be retrieved from
1233    ///   `self.output_dim()`.
1234    fn compress_into_with(
1235        &self,
1236        from: &[f32],
1237        mut into: QueryMut<'_, NBITS, Perm>,
1238        allocator: ScopedAllocator<'_>,
1239    ) -> Result<(), Self::Error> {
1240        let input_dim = self.shift.len();
1241        let output_dim = self.output_dim();
1242        check_dims(input_dim, output_dim, from.len(), into.len())?;
1243
1244        let mut preprocessed = self.preprocess(from, allocator)?;
1245
1246        if preprocessed.shifted_norm == 0.0 {
1247            into.set_meta(QueryMeta::default());
1248            return Ok(());
1249        }
1250
1251        preprocessed
1252            .shifted
1253            .iter_mut()
1254            .for_each(|v| *v /= preprocessed.shifted_norm);
1255
1256        let mut transformed = Poly::broadcast(0.0f32, output_dim, allocator)?;
1257
1258        // Transformation can fail due to OOM - we want to handle that gracefully.
1259        //
1260        // If the transformation fails because we provided the wrong sizes, that is a hard
1261        // program bug.
1262        #[expect(clippy::panic, reason = "the dimensions should already be as expected")]
1263        match self
1264            .transform
1265            .transform_into(&mut transformed, &preprocessed.shifted, allocator)
1266        {
1267            Ok(()) => {}
1268            Err(TransformFailed::AllocatorError(err)) => {
1269                return Err(CompressionError::AllocatorError(err));
1270            }
1271            Err(TransformFailed::SourceMismatch { .. })
1272            | Err(TransformFailed::DestinationMismatch { .. }) => {
1273                panic!(
1274                    "The sizes of these arrays should already be checked - this is a logic error"
1275                );
1276            }
1277            #[cfg(feature = "linalg")]
1278            Err(TransformFailed::SgemmError(_)) => {
1279                panic!("SGEMM should not fail with valid dimensions - this is a logic error");
1280            }
1281        }
1282
1283        // Compute the minimum and maximum values of the transformed vector.
1284        let (min, max) = transformed
1285            .iter()
1286            .fold((f32::MAX, f32::MIN), |(min, max), i| {
1287                (i.min(min), i.max(max))
1288            });
1289
1290        let domain = Unsigned::domain_const::<NBITS>();
1291        let lo = (*domain.start()) as f32;
1292        let hi = (*domain.end()) as f32;
1293
1294        let scale = (max - min) / hi;
1295        let mut bit_sum: f32 = 0.0;
1296        transformed.iter().enumerate().for_each(|(i, v)| {
1297            let c = ((v - min) / scale).round().clamp(lo, hi);
1298            bit_sum += c;
1299
1300            // Lint: We have verified that `into.len() == transformed.len()`, so the index
1301            // `i` is in bounds.
1302            //
1303            // Further, `c` has beem clamped to `[0, 2^NBITS - 1]` and is thus encodable
1304            // with the NBITS-bit unsigned representation.
1305            #[expect(clippy::unwrap_used)]
1306            into.vector_mut().set(i, c as i64).unwrap();
1307        });
1308
1309        // Finish up the compensation terms.
1310        into.set_meta(QueryMeta {
1311            inner_product_correction: preprocessed.shifted_norm * scale,
1312            bit_sum,
1313            offset: min / scale,
1314            metric_specific: preprocessed.metric_specific(),
1315        });
1316
1317        Ok(())
1318    }
1319}
1320
1321///////////
1322// Tests //
1323///////////
1324
1325#[cfg(not(miri))]
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    use std::fmt::Display;
1331
1332    use diskann_utils::{ReborrowMut, lazy_format, views::rowmajor::MatrixMut};
1333    use diskann_vector::{PureDistanceFunction, norm::FastL2NormSquared};
1334    use diskann_wide::ARCH;
1335    use rand::{
1336        SeedableRng,
1337        distr::{Distribution, Uniform},
1338        rngs::StdRng,
1339    };
1340    use rand_distr::StandardNormal;
1341
1342    use crate::{
1343        algorithms::transforms::TargetDim,
1344        alloc::GlobalAllocator,
1345        bits::{BitTranspose, Dense},
1346        spherical::{Data, DataMetaF32, FullQuery, Query},
1347        test_util,
1348    };
1349
1350    #[test]
1351    fn cosine_similarity_maximizer_errors() {
1352        let num_bits = NonZeroUsize::new(4).unwrap();
1353        for nan in [f32::NAN, f32::from_bits(0xffc0_0001)] {
1354            for position in 0..4 {
1355                let mut target = [1.0; 4];
1356                target[position] = nan;
1357                assert_eq!(
1358                    maximize_cosine_similarity(
1359                        &target,
1360                        num_bits,
1361                        ScopedAllocator::new(&test_util::AlwaysFails),
1362                    ),
1363                    Err(CompressionError::InputContainsNaN),
1364                );
1365            }
1366        }
1367        for budget in 0..=2 {
1368            let result = maximize_cosine_similarity(
1369                &[1.0, 0.5],
1370                num_bits,
1371                ScopedAllocator::new(&test_util::LimitedAllocator::new(budget)),
1372            );
1373            if budget < 2 {
1374                assert_eq!(
1375                    result,
1376                    Err(CompressionError::AllocatorError(AllocatorError))
1377                );
1378            } else {
1379                assert!(result.is_ok());
1380            }
1381        }
1382    }
1383
1384    #[test]
1385    fn test_cosine_similarity_maximizer() {
1386        let mut rng = StdRng::seed_from_u64(0x070d9ff8cf5e0f8c);
1387        let num_trials = 10000;
1388        let num_bits = NonZeroUsize::new(3).unwrap();
1389
1390        let scale_distribution = Uniform::new(0.5f32, 10.0f32).unwrap();
1391
1392        let run_test = |target: [f32; 4]| {
1393            let scale =
1394                maximize_cosine_similarity(&target, num_bits, ScopedAllocator::global()).unwrap();
1395
1396            let mut best: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
1397            let mut best_similarity: f32 = f32::NEG_INFINITY;
1398
1399            // This crazy series of nested loops performs an exhaustive search over the
1400            // encoding space.
1401            let min = -3.5;
1402            for i0 in 0..8 {
1403                for i1 in 0..8 {
1404                    for i2 in 0..8 {
1405                        for i3 in 0..8 {
1406                            let p: [f32; 4] = [
1407                                min + (i0 as f32),
1408                                min + (i1 as f32),
1409                                min + (i2 as f32),
1410                                min + (i3 as f32),
1411                            ];
1412
1413                            let sim: MathematicalValue<f32> =
1414                                diskann_vector::distance::Cosine::evaluate(&p, &target);
1415                            let sim = sim.into_inner();
1416                            if sim > best_similarity {
1417                                best_similarity = sim;
1418                                // Transform into an integer starting at zero.
1419                                best = p.map(|i| i - min);
1420                            }
1421                        }
1422                    }
1423                }
1424            }
1425
1426            // Now, rescale the input vector, clamp, and round.
1427            // Check if they agree.
1428            let clamped = target.map(|i| (i * scale - min).round().clamp(0.0, 7.0));
1429            let clamped_cosine: MathematicalValue<f32> =
1430                diskann_vector::distance::Cosine::evaluate(&clamped.map(|i| i + min), &target);
1431
1432            // We expect to either get the best value found via exhaustive search, or some
1433            // scalar multiple of it (since that will have the same cosine similarity).
1434            let passed = if best == clamped
1435                || (best_similarity == clamped_cosine.into_inner()
1436                    && std::iter::zip(target, std::iter::zip(best, clamped))
1437                        .all(|(input, (expected, actual))| input == 0.0 || expected == actual))
1438            {
1439                true
1440            } else {
1441                let ratio: Vec<f32> = std::iter::zip(best, clamped)
1442                    .map(|(b, c)| {
1443                        let ratio = (b + min) / (c + min);
1444                        assert_ne!(
1445                            ratio, 0.0,
1446                            "ratio should never be zero because `b` is an integer and \
1447                             `min` is not"
1448                        );
1449                        ratio
1450                    })
1451                    .collect();
1452
1453                ratio.iter().all(|i| *i == ratio[0])
1454            };
1455
1456            if !passed {
1457                panic!(
1458                    "failed for input {:?}.\
1459                     Best = {:?}, Found = {:?}\
1460                     Best similarity = {}, similarity with clamped = {}",
1461                    target,
1462                    best,
1463                    clamped,
1464                    best_similarity,
1465                    clamped_cosine.into_inner()
1466                );
1467            }
1468        };
1469
1470        for target in [
1471            [1.0, 0.0, -0.0, -1.0],
1472            [0.0, 0.5, -0.5, 0.0],
1473            [1.0, 1.0, -1.0, -1.0],
1474            [
1475                f32::from_bits(1.0f32.to_bits() - 1),
1476                1.0,
1477                f32::from_bits(1.0f32.to_bits() + 1),
1478                -1.0,
1479            ],
1480        ] {
1481            run_test(target);
1482        }
1483
1484        // Run targeted tests.
1485        let min = -3.5;
1486        for i0 in (0..8).step_by(2) {
1487            for i1 in (1..9).step_by(2) {
1488                for i2 in (0..8).step_by(2) {
1489                    for i3 in (1..9).step_by(2) {
1490                        let p: [f32; 4] = [
1491                            min + (i0 as f32),
1492                            min + (i1 as f32),
1493                            min + (i2 as f32),
1494                            min + (i3 as f32),
1495                        ];
1496                        run_test(p)
1497                    }
1498                }
1499            }
1500        }
1501
1502        for _ in 0..num_trials {
1503            let this_scale: f32 = scale_distribution.sample(&mut rng);
1504            let v: [f32; 4] = [(); 4].map(|_| {
1505                let v: f32 = StandardNormal {}.sample(&mut rng);
1506                this_scale * v
1507            });
1508            run_test(v);
1509        }
1510    }
1511
1512    #[test]
1513    #[should_panic(expected = "calling code should not allow the slice to be empty")]
1514    fn empty_slice_panics() {
1515        maximize_cosine_similarity(
1516            &[],
1517            NonZeroUsize::new(4).unwrap(),
1518            ScopedAllocator::global(),
1519        )
1520        .unwrap();
1521    }
1522
1523    struct Setup {
1524        transform: TransformKind,
1525        nrows: usize,
1526        ncols: usize,
1527        num_trials: usize,
1528    }
1529
1530    fn get_scale(scale: PreScale, quantizer: &SphericalQuantizer) -> f32 {
1531        match scale {
1532            PreScale::None => 1.0,
1533            PreScale::Some(v) => v.into_inner(),
1534            PreScale::ReciprocalMeanNorm => 1.0 / quantizer.mean_norm().into_inner(),
1535        }
1536    }
1537
1538    fn test_l2<const Q: usize, const D: usize, Perm>(
1539        setup: &Setup,
1540        problem: &test_util::TestProblem,
1541        computed_means: &[f32],
1542        pre_scale: PreScale,
1543        rng: &mut StdRng,
1544    ) where
1545        Unsigned: Representation<Q>,
1546        Unsigned: Representation<D>,
1547        Perm: PermutationStrategy<Q>,
1548        for<'a> SphericalQuantizer:
1549            CompressIntoWith<&'a [f32], DataMut<'a, D>, ScopedAllocator<'a>>,
1550        for<'a> SphericalQuantizer:
1551            CompressIntoWith<&'a [f32], QueryMut<'a, Q, Perm>, ScopedAllocator<'a>>,
1552    {
1553        assert_eq!(setup.nrows, problem.data.nrows());
1554        assert_eq!(setup.ncols, problem.data.ncols());
1555
1556        let scoped_global = ScopedAllocator::global();
1557        let distribution = Uniform::new(0, setup.nrows).unwrap();
1558        let quantizer = SphericalQuantizer::train(
1559            problem.data.as_view(),
1560            setup.transform,
1561            SupportedMetric::SquaredL2,
1562            pre_scale,
1563            rng,
1564            GlobalAllocator,
1565        )
1566        .unwrap();
1567
1568        let scale = get_scale(pre_scale, &quantizer);
1569
1570        let mut b = Data::<D, _>::new_boxed(quantizer.output_dim());
1571        let mut q = Query::<Q, Perm, _>::new_boxed(quantizer.output_dim());
1572        let mut f = FullQuery::empty(quantizer.output_dim(), GlobalAllocator).unwrap();
1573
1574        assert_eq!(
1575            quantizer.mean_norm.into_inner(),
1576            problem.mean_norm as f32,
1577            "computed mean norm should not apply scale"
1578        );
1579        let scaled_means: Vec<_> = computed_means.iter().map(|i| scale * i).collect();
1580        assert_eq!(&*scaled_means, quantizer.shift());
1581
1582        let l2: CompensatedSquaredL2 = quantizer.as_functor();
1583        assert_eq!(l2.dim, quantizer.output_dim() as f32);
1584
1585        for _ in 0..setup.num_trials {
1586            let i = distribution.sample(rng);
1587            let v = problem.data.row(i);
1588
1589            quantizer
1590                .compress_into_with(v, b.reborrow_mut(), scoped_global)
1591                .unwrap();
1592            quantizer
1593                .compress_into_with(v, q.reborrow_mut(), scoped_global)
1594                .unwrap();
1595            quantizer
1596                .compress_into_with(v, f.reborrow_mut(), scoped_global)
1597                .unwrap();
1598
1599            let shifted: Vec<f32> = std::iter::zip(v.iter(), quantizer.shift().iter())
1600                .map(|(a, b)| scale * a - b)
1601                .collect();
1602
1603            // Check that the compensation coefficient were chosen correctly.
1604            {
1605                let DataMetaF32 {
1606                    inner_product_correction,
1607                    bit_sum,
1608                    metric_specific,
1609                } = b.meta().to_full(ARCH);
1610
1611                let shifted_square_norm = metric_specific;
1612
1613                // Check that the bit-count is correct. let bv = b.vector();
1614                let bv = b.vector();
1615                let s: usize = (0..bv.len()).map(|i| bv.get(i).unwrap() as usize).sum();
1616                assert_eq!(s, bit_sum as usize);
1617
1618                // Check that the shifted norm is correct.
1619                {
1620                    let expected = FastL2NormSquared.evaluate(&*shifted);
1621                    let err = (shifted_square_norm - expected).abs() / expected.abs();
1622                    assert!(
1623                        err < 5.0e-4, // twice the minimum normal f16 value.
1624                        "failed diff check, got {}, expected {} - relative error = {}",
1625                        shifted_square_norm,
1626                        expected,
1627                        err
1628                    );
1629                }
1630
1631                // Finaly, verify that the self-inner-product is clustered around 0.8 as
1632                // the RaBitQ paper suggests.
1633                if const { D == 1 } {
1634                    let self_inner_product = 2.0 * shifted_square_norm.sqrt()
1635                        / (inner_product_correction * (bv.len() as f32).sqrt());
1636                    assert!(
1637                        (self_inner_product - 0.8).abs() < 0.13,
1638                        "self inner-product should be close to 0.8. Instead, it's {}",
1639                        self_inner_product
1640                    );
1641                }
1642            }
1643
1644            {
1645                let QueryMeta {
1646                    inner_product_correction,
1647                    bit_sum,
1648                    offset,
1649                    metric_specific,
1650                } = q.meta();
1651
1652                let shifted_square_norm = metric_specific;
1653                let mut preprocessed = quantizer.preprocess(v, scoped_global).unwrap();
1654                preprocessed
1655                    .shifted
1656                    .iter_mut()
1657                    .for_each(|i| *i /= preprocessed.shifted_norm);
1658
1659                let mut transformed = vec![0.0f32; quantizer.output_dim()];
1660                quantizer
1661                    .transform
1662                    .transform_into(&mut transformed, &preprocessed.shifted, scoped_global)
1663                    .unwrap();
1664
1665                let min = transformed.iter().fold(f32::MAX, |min, &i| min.min(i));
1666                let max = transformed.iter().fold(f32::MIN, |max, &i| max.max(i));
1667
1668                let scale = (max - min) / ((2usize.pow(Q as u32) - 1) as f32);
1669
1670                // Shifted Norm
1671                {
1672                    let expected = FastL2NormSquared.evaluate(&*shifted);
1673                    let err = (shifted_square_norm - expected).abs() / expected.abs();
1674                    assert!(
1675                        err < 2e-7,
1676                        "failed diff check, got {}, expected {} - relative error = {}",
1677                        shifted_square_norm,
1678                        expected,
1679                        err
1680                    );
1681                }
1682
1683                // Inner product correction
1684                {
1685                    let expected = shifted_square_norm.sqrt() * scale;
1686                    let got = inner_product_correction;
1687
1688                    let err = (expected - got).abs();
1689                    assert!(
1690                        err < 1.0e-7,
1691                        "\"innerproduct_scale\": expected {}, got {}, error = {}",
1692                        expected,
1693                        got,
1694                        err
1695                    );
1696                }
1697
1698                // Offset
1699                {
1700                    let expected = min / scale;
1701                    let got = offset;
1702
1703                    let err = (expected - got).abs();
1704                    assert!(
1705                        err < 1.0e-7,
1706                        "\"sum_scale\": expected {}, got {}, error = {}",
1707                        expected,
1708                        got,
1709                        err
1710                    );
1711                }
1712
1713                // Bit Sum
1714                {
1715                    let expected = (0..q.len())
1716                        .map(|i| q.vector().get(i).unwrap())
1717                        .sum::<i64>() as f32;
1718
1719                    let got = bit_sum;
1720
1721                    let err = (expected - got).abs();
1722                    assert!(
1723                        err < 1.0e-7,
1724                        "\"offset\": expected {}, got {}, error = {}",
1725                        expected,
1726                        got,
1727                        err
1728                    );
1729                }
1730            }
1731
1732            // Check that the compensation coefficient were chosen correctly.
1733            {
1734                // Check that the bit-count is correct.
1735                let s: f32 = f.data.iter().sum::<f32>();
1736                assert_eq!(s, f.meta.sum);
1737
1738                // Check that the shifted norm is correct.
1739                {
1740                    let expected = FastL2Norm.evaluate(&*shifted);
1741                    let err = (f.meta.shifted_norm - expected).abs() / expected.abs();
1742                    assert!(
1743                        err < 2e-7,
1744                        "failed diff check, got {}, expected {} - relative error = {}",
1745                        f.meta.shifted_norm,
1746                        expected,
1747                        err
1748                    );
1749                }
1750
1751                assert_eq!(
1752                    f.meta.metric_specific,
1753                    f.meta.shifted_norm * f.meta.shifted_norm,
1754                    "metric specific data for squared l2 is the square shifted norm",
1755                );
1756            }
1757        }
1758
1759        // Finally - test that if we compress the centroid, the metadata coefficients get
1760        // zeroed correctly.
1761        quantizer
1762            .compress_into_with(computed_means, b.reborrow_mut(), scoped_global)
1763            .unwrap();
1764        assert_eq!(b.meta(), DataMeta::default());
1765
1766        quantizer
1767            .compress_into_with(computed_means, q.reborrow_mut(), scoped_global)
1768            .unwrap();
1769        assert_eq!(q.meta(), QueryMeta::default());
1770
1771        f.data.fill(f32::INFINITY);
1772        quantizer
1773            .compress_into_with(computed_means, f.reborrow_mut(), scoped_global)
1774            .unwrap();
1775        assert!(f.data.iter().all(|&i| i == 0.0));
1776        assert_eq!(f.meta.sum, 0.0);
1777        assert_eq!(f.meta.metric_specific, 0.0);
1778    }
1779
1780    fn test_ip<const Q: usize, const D: usize, Perm>(
1781        setup: &Setup,
1782        problem: &test_util::TestProblem,
1783        computed_means: &[f32],
1784        pre_scale: PreScale,
1785        rng: &mut StdRng,
1786        ctx: &dyn Display,
1787    ) where
1788        Unsigned: Representation<Q>,
1789        Unsigned: Representation<D>,
1790        Perm: PermutationStrategy<Q>,
1791        for<'a> SphericalQuantizer:
1792            CompressIntoWith<&'a [f32], DataMut<'a, D>, ScopedAllocator<'a>>,
1793        for<'a> SphericalQuantizer:
1794            CompressIntoWith<&'a [f32], QueryMut<'a, Q, Perm>, ScopedAllocator<'a>>,
1795    {
1796        assert_eq!(setup.nrows, problem.data.nrows());
1797        assert_eq!(setup.ncols, problem.data.ncols());
1798
1799        let scoped_global = ScopedAllocator::global();
1800        let distribution = Uniform::new(0, setup.nrows).unwrap();
1801        let quantizer = SphericalQuantizer::train(
1802            problem.data.as_view(),
1803            setup.transform,
1804            SupportedMetric::InnerProduct,
1805            pre_scale,
1806            rng,
1807            GlobalAllocator,
1808        )
1809        .unwrap();
1810
1811        let scale = get_scale(pre_scale, &quantizer);
1812
1813        let mut b = Data::<D, _>::new_boxed(quantizer.output_dim());
1814        let mut q = Query::<Q, Perm, _>::new_boxed(quantizer.output_dim());
1815        let mut f = FullQuery::empty(quantizer.output_dim(), GlobalAllocator).unwrap();
1816
1817        assert_eq!(
1818            quantizer.mean_norm.into_inner(),
1819            problem.mean_norm as f32,
1820            "computed mean norm should not apply scale"
1821        );
1822        let scaled_means: Vec<_> = computed_means.iter().map(|i| scale * i).collect();
1823        assert_eq!(&*scaled_means, quantizer.shift());
1824
1825        let ip: CompensatedIP = quantizer.as_functor();
1826
1827        assert_eq!(ip.dim, quantizer.output_dim() as f32);
1828        assert_eq!(
1829            ip.squared_shift_norm,
1830            FastL2NormSquared.evaluate(quantizer.shift())
1831        );
1832
1833        for _ in 0..setup.num_trials {
1834            let i = distribution.sample(rng);
1835            let v = problem.data.row(i);
1836
1837            quantizer
1838                .compress_into_with(v, b.reborrow_mut(), scoped_global)
1839                .unwrap();
1840            quantizer
1841                .compress_into_with(v, q.reborrow_mut(), scoped_global)
1842                .unwrap();
1843            quantizer
1844                .compress_into_with(v, f.reborrow_mut(), scoped_global)
1845                .unwrap();
1846
1847            let shifted: Vec<f32> = std::iter::zip(v.iter(), quantizer.shift().iter())
1848                .map(|(a, b)| scale * a - b)
1849                .collect();
1850
1851            // Check that the compensation coefficient were chosen correctly.
1852            {
1853                let DataMetaF32 {
1854                    inner_product_correction,
1855                    bit_sum,
1856                    metric_specific,
1857                } = b.meta().to_full(ARCH);
1858
1859                let inner_product_with_centroid = metric_specific;
1860
1861                // Check that the bit-count is correct.
1862                let bv = b.vector();
1863                let s: usize = (0..bv.len()).map(|i| bv.get(i).unwrap() as usize).sum();
1864                assert_eq!(s, bit_sum as usize);
1865
1866                // Check that the shifted norm is correct.
1867                let inner_product: MathematicalValue<f32> =
1868                    InnerProduct::evaluate(&*shifted, quantizer.shift());
1869
1870                let diff = (inner_product.into_inner() - inner_product_with_centroid).abs();
1871                assert!(
1872                    diff < 1.53e-5,
1873                    "got a diff of {}. Expected = {}, got = {} -- context: {}",
1874                    diff,
1875                    inner_product.into_inner(),
1876                    inner_product_with_centroid,
1877                    ctx,
1878                );
1879
1880                // Finaly, verify that the self-inner-product is clustered around 0.8 as
1881                // the RaBitQ paper suggests.
1882                if const { D == 1 } {
1883                    let self_inner_product = 2.0 * (FastL2Norm).evaluate(&*shifted)
1884                        / (inner_product_correction * (bv.len() as f32).sqrt());
1885                    assert!(
1886                        (self_inner_product - 0.8).abs() < 0.12,
1887                        "self inner-product should be close to 0.8. Instead, it's {}",
1888                        self_inner_product
1889                    );
1890                }
1891            }
1892
1893            {
1894                let QueryMeta {
1895                    inner_product_correction,
1896                    bit_sum,
1897                    offset,
1898                    metric_specific,
1899                } = q.meta();
1900
1901                let inner_product_with_centroid = metric_specific;
1902                let mut preprocessed = quantizer.preprocess(v, scoped_global).unwrap();
1903                preprocessed
1904                    .shifted
1905                    .iter_mut()
1906                    .for_each(|i| *i /= preprocessed.shifted_norm);
1907
1908                let mut transformed = vec![0.0f32; quantizer.output_dim()];
1909                quantizer
1910                    .transform
1911                    .transform_into(&mut transformed, &preprocessed.shifted, scoped_global)
1912                    .unwrap();
1913
1914                let min = transformed.iter().fold(f32::MAX, |min, &i| min.min(i));
1915                let max = transformed.iter().fold(f32::MIN, |max, &i| max.max(i));
1916
1917                let scale = (max - min) / ((2usize.pow(Q as u32) - 1) as f32);
1918
1919                // Inner product correction
1920                {
1921                    let expected = (FastL2Norm).evaluate(&*shifted) * scale;
1922                    let got = inner_product_correction;
1923
1924                    let err = (expected - got).abs();
1925                    assert!(
1926                        err < 1.0e-7,
1927                        "\"innerproduct_scale\": expected {}, got {}, error = {}",
1928                        expected,
1929                        got,
1930                        err
1931                    );
1932                }
1933
1934                // Offset
1935                {
1936                    let expected = min / scale;
1937                    let got = offset;
1938
1939                    let err = (expected - got).abs();
1940                    assert!(
1941                        err < 1.0e-7,
1942                        "\"sum_scale\": expected {}, got {}, error = {}",
1943                        expected,
1944                        got,
1945                        err
1946                    );
1947                }
1948
1949                // Bit Sum
1950                {
1951                    let expected = (0..q.len())
1952                        .map(|i| q.vector().get(i).unwrap())
1953                        .sum::<i64>() as f32;
1954
1955                    let got = bit_sum;
1956
1957                    let err = (expected - got).abs();
1958                    assert!(
1959                        err < 1.0e-7,
1960                        "\"offset\": expected {}, got {}, error = {}",
1961                        expected,
1962                        got,
1963                        err
1964                    );
1965                }
1966
1967                // Inner Product with Centroid
1968                {
1969                    // Check that the shifted norm is correct.
1970                    let inner_product: MathematicalValue<f32> =
1971                        InnerProduct::evaluate(&*shifted, quantizer.shift());
1972                    assert_eq!(inner_product.into_inner(), inner_product_with_centroid);
1973                }
1974            }
1975
1976            // Check that the compensation coefficient were chosen correctly.
1977            {
1978                // Check that the bit-count is correct.
1979                let s: f32 = f.data.iter().sum::<f32>();
1980                assert_eq!(s, f.meta.sum);
1981
1982                // Check that the shifted norm is correct.
1983                {
1984                    let expected = FastL2Norm.evaluate(&*shifted);
1985                    let err = (f.meta.shifted_norm - expected).abs() / expected.abs();
1986                    assert!(
1987                        err < 2e-7,
1988                        "failed diff check, got {}, expected {} - relative error = {}",
1989                        f.meta.shifted_norm,
1990                        expected,
1991                        err
1992                    );
1993                }
1994
1995                // Check that the shifted norm is correct. s
1996                let inner_product: MathematicalValue<f32> =
1997                    InnerProduct::evaluate(&*shifted, quantizer.shift());
1998                assert_eq!(inner_product.into_inner(), f.meta.metric_specific,);
1999            }
2000        }
2001
2002        // Finally - test that if we compress the centroid, the metadata coefficients get
2003        // zeroed correctly.
2004        quantizer
2005            .compress_into_with(computed_means, b.reborrow_mut(), scoped_global)
2006            .unwrap();
2007        assert_eq!(b.meta(), DataMeta::default());
2008
2009        quantizer
2010            .compress_into_with(computed_means, q.reborrow_mut(), scoped_global)
2011            .unwrap();
2012        assert_eq!(q.meta(), QueryMeta::default());
2013
2014        f.data.fill(f32::INFINITY);
2015        quantizer
2016            .compress_into_with(computed_means, f.reborrow_mut(), scoped_global)
2017            .unwrap();
2018        assert!(f.data.iter().all(|&i| i == 0.0));
2019        assert_eq!(f.meta.sum, 0.0);
2020        assert_eq!(f.meta.metric_specific, 0.0);
2021    }
2022
2023    fn test_cosine<const Q: usize, const D: usize, Perm>(
2024        setup: &Setup,
2025        problem: &test_util::TestProblem,
2026        pre_scale: PreScale,
2027        rng: &mut StdRng,
2028    ) where
2029        Unsigned: Representation<Q>,
2030        Unsigned: Representation<D>,
2031        Perm: PermutationStrategy<Q>,
2032        for<'a> SphericalQuantizer:
2033            CompressIntoWith<&'a [f32], DataMut<'a, D>, ScopedAllocator<'a>>,
2034        for<'a> SphericalQuantizer:
2035            CompressIntoWith<&'a [f32], QueryMut<'a, Q, Perm>, ScopedAllocator<'a>>,
2036    {
2037        assert_eq!(setup.nrows, problem.data.nrows());
2038        assert_eq!(setup.ncols, problem.data.ncols());
2039
2040        let scoped_global = ScopedAllocator::global();
2041        let distribution = Uniform::new(0, setup.nrows).unwrap();
2042        let quantizer = SphericalQuantizer::train(
2043            problem.data.as_view(),
2044            setup.transform,
2045            SupportedMetric::Cosine,
2046            pre_scale,
2047            rng,
2048            GlobalAllocator,
2049        )
2050        .unwrap();
2051
2052        let mut b = Data::<D, _>::new_boxed(quantizer.output_dim());
2053        let mut q = Query::<Q, Perm, _>::new_boxed(quantizer.output_dim());
2054        let mut f = FullQuery::empty(quantizer.output_dim(), GlobalAllocator).unwrap();
2055
2056        let cosine: CompensatedCosine = quantizer.as_functor();
2057
2058        assert_eq!(cosine.inner.dim, quantizer.output_dim() as f32);
2059        assert_eq!(
2060            cosine.inner.squared_shift_norm,
2061            FastL2NormSquared.evaluate(quantizer.shift())
2062        );
2063
2064        const IP_BOUND: f32 = 2.6e-3;
2065
2066        let mut test_row = |v: &[f32]| {
2067            let vnorm = (FastL2Norm).evaluate(v);
2068            let v_normalized: Vec<f32> = v
2069                .iter()
2070                .map(|i| if vnorm == 0.0 { 0.0 } else { *i / vnorm })
2071                .collect();
2072
2073            quantizer
2074                .compress_into_with(v, b.reborrow_mut(), scoped_global)
2075                .unwrap();
2076
2077            quantizer
2078                .compress_into_with(v, q.reborrow_mut(), scoped_global)
2079                .unwrap();
2080
2081            quantizer
2082                .compress_into_with(v, f.reborrow_mut(), scoped_global)
2083                .unwrap();
2084
2085            let shifted: Vec<f32> = std::iter::zip(v_normalized.iter(), quantizer.shift().iter())
2086                .map(|(a, b)| a - b)
2087                .collect();
2088
2089            // Check that the compensation coefficient were chosen correctly.
2090            {
2091                let DataMetaF32 {
2092                    inner_product_correction,
2093                    bit_sum,
2094                    metric_specific,
2095                } = b.meta().to_full(ARCH);
2096
2097                let inner_product_with_centroid = metric_specific;
2098
2099                // Check that the bit-count is correct.
2100                let bv = b.vector();
2101                let s: usize = (0..bv.len()).map(|i| bv.get(i).unwrap() as usize).sum();
2102                assert_eq!(s, bit_sum as usize);
2103
2104                // Check that the shifted norm is correct. Since they are computed slightly
2105                // differnetly, allow a small amount of error.
2106                let inner_product: MathematicalValue<f32> =
2107                    InnerProduct::evaluate(&*shifted, quantizer.shift());
2108
2109                let abs = (inner_product.into_inner() - inner_product_with_centroid).abs();
2110                let relative = abs / inner_product.into_inner().abs();
2111
2112                assert!(
2113                    abs < 1e-7 || relative < IP_BOUND,
2114                    "got an abs/rel of {}/{} with a bound of {}/{}",
2115                    abs,
2116                    relative,
2117                    1e-7,
2118                    IP_BOUND
2119                );
2120
2121                // Finaly, verify that the self-inner-product is clustered around 0.8 as
2122                // the RaBitQ paper suggests.
2123                if const { D == 1 } {
2124                    let self_inner_product = 2.0 * (FastL2Norm).evaluate(&*shifted)
2125                        / (inner_product_correction * (bv.len() as f32).sqrt());
2126                    assert!(
2127                        (self_inner_product - 0.8).abs() < 0.11,
2128                        "self inner-product should be close to 0.8. Instead, it's {}",
2129                        self_inner_product
2130                    );
2131                }
2132            }
2133
2134            {
2135                let QueryMeta {
2136                    inner_product_correction,
2137                    bit_sum,
2138                    offset,
2139                    metric_specific,
2140                } = q.meta();
2141
2142                let inner_product_with_centroid = metric_specific;
2143                let mut preprocessed = quantizer.preprocess(v, scoped_global).unwrap();
2144                preprocessed
2145                    .shifted
2146                    .iter_mut()
2147                    .for_each(|i| *i /= preprocessed.shifted_norm);
2148
2149                let mut transformed = vec![0.0f32; quantizer.output_dim()];
2150                quantizer
2151                    .transform
2152                    .transform_into(&mut transformed, &preprocessed.shifted, scoped_global)
2153                    .unwrap();
2154
2155                let min = transformed.iter().fold(f32::MAX, |min, &i| min.min(i));
2156                let max = transformed.iter().fold(f32::MIN, |max, &i| max.max(i));
2157
2158                let scale = (max - min) / ((2usize.pow(Q as u32) - 1) as f32);
2159
2160                // Inner product correction
2161                {
2162                    let expected = (FastL2Norm).evaluate(&*shifted) * scale;
2163                    let got = inner_product_correction;
2164
2165                    let err = (expected - got).abs();
2166                    assert!(
2167                        err < 1.0e-7,
2168                        "\"innerproduct_scale\": expected {}, got {}, error = {}",
2169                        expected,
2170                        got,
2171                        err
2172                    );
2173                }
2174
2175                // Offset
2176                {
2177                    let expected = min / scale;
2178                    let got = offset;
2179
2180                    let err = (expected - got).abs();
2181                    assert!(
2182                        err < 1.0e-7,
2183                        "\"sum_scale\": expected {}, got {}, error = {}",
2184                        expected,
2185                        got,
2186                        err
2187                    );
2188                }
2189
2190                // Bit Sum
2191                {
2192                    let expected = (0..q.len())
2193                        .map(|i| q.vector().get(i).unwrap())
2194                        .sum::<i64>() as f32;
2195
2196                    let got = bit_sum;
2197
2198                    let err = (expected - got).abs();
2199                    assert!(
2200                        err < 1.0e-7,
2201                        "\"offset\": expected {}, got {}, error = {}",
2202                        expected,
2203                        got,
2204                        err
2205                    );
2206                }
2207
2208                // Inner Product with Centroid
2209                {
2210                    // Check that the shifted norm is correct.
2211                    let inner_product: MathematicalValue<f32> =
2212                        InnerProduct::evaluate(&*shifted, quantizer.shift());
2213
2214                    let err = (inner_product.into_inner() - inner_product_with_centroid).abs()
2215                        / inner_product.into_inner().abs();
2216                    assert!(
2217                        err < IP_BOUND,
2218                        "\"offset\": expected {}, got {}, error = {}",
2219                        inner_product.into_inner(),
2220                        inner_product_with_centroid,
2221                        err
2222                    );
2223                }
2224            }
2225
2226            // Check that the compensation coefficient were chosen correctly.
2227            {
2228                // Check that the bit-count is correct.
2229                let s: f32 = f.data.iter().sum::<f32>();
2230                assert_eq!(s, f.meta.sum);
2231
2232                // Check that the shifted norm is correct.
2233                {
2234                    let expected = FastL2Norm.evaluate(&*shifted);
2235                    let err = (f.meta.shifted_norm - expected).abs() / expected.abs();
2236                    assert!(
2237                        err < 2e-7,
2238                        "failed diff check, got {}, expected {} - relative error = {}",
2239                        f.meta.shifted_norm,
2240                        expected,
2241                        err
2242                    );
2243                }
2244
2245                // Check that the shifted norm is correct. s
2246                let inner_product: MathematicalValue<f32> =
2247                    InnerProduct::evaluate(&*shifted, quantizer.shift());
2248                let err = (inner_product.into_inner() - f.meta.metric_specific).abs()
2249                    / inner_product.into_inner().abs();
2250                assert!(
2251                    err < IP_BOUND,
2252                    "\"offset\": expected {}, got {}, error = {}",
2253                    inner_product.into_inner(),
2254                    f.meta.metric_specific,
2255                    err
2256                );
2257            }
2258        };
2259
2260        for _ in 0..setup.num_trials {
2261            let i = distribution.sample(rng);
2262            let v = problem.data.row(i);
2263            test_row(v);
2264        }
2265
2266        // Ensure that if a zero vector is provided that we do not divide by zero.
2267        let zero = vec![0.0f32; quantizer.input_dim()];
2268        test_row(&zero);
2269    }
2270
2271    fn _test_oom_resiliance<T>(quantizer: &SphericalQuantizer, data: &[f32], dst: &mut T)
2272    where
2273        for<'a> T: ReborrowMut<'a>,
2274        for<'a> SphericalQuantizer: CompressIntoWith<
2275                &'a [f32],
2276                <T as ReborrowMut<'a>>::Target,
2277                ScopedAllocator<'a>,
2278                Error = CompressionError,
2279            >,
2280    {
2281        let mut succeeded = false;
2282        let mut failed = false;
2283        for max_allocations in 0..10 {
2284            match quantizer.compress_into_with(
2285                data,
2286                dst.reborrow_mut(),
2287                ScopedAllocator::new(&test_util::LimitedAllocator::new(max_allocations)),
2288            ) {
2289                Ok(()) => {
2290                    succeeded = true;
2291                }
2292                Err(CompressionError::AllocatorError(_)) => {
2293                    failed = true;
2294                }
2295                Err(other) => {
2296                    panic!("received an unexpected error: {:?}", other);
2297                }
2298            }
2299        }
2300        assert!(succeeded);
2301        assert!(failed);
2302    }
2303
2304    fn test_oom_resiliance<const Q: usize, const D: usize, Perm>(
2305        setup: &Setup,
2306        problem: &test_util::TestProblem,
2307        pre_scale: PreScale,
2308        rng: &mut StdRng,
2309    ) where
2310        Unsigned: Representation<Q>,
2311        Unsigned: Representation<D>,
2312        Perm: PermutationStrategy<Q>,
2313        for<'a> SphericalQuantizer: CompressIntoWith<
2314                &'a [f32],
2315                DataMut<'a, D>,
2316                ScopedAllocator<'a>,
2317                Error = CompressionError,
2318            >,
2319        for<'a> SphericalQuantizer: CompressIntoWith<
2320                &'a [f32],
2321                QueryMut<'a, Q, Perm>,
2322                ScopedAllocator<'a>,
2323                Error = CompressionError,
2324            >,
2325    {
2326        assert_eq!(setup.nrows, problem.data.nrows());
2327        assert_eq!(setup.ncols, problem.data.ncols());
2328
2329        let quantizer = SphericalQuantizer::train(
2330            problem.data.as_view(),
2331            setup.transform,
2332            SupportedMetric::SquaredL2,
2333            pre_scale,
2334            rng,
2335            GlobalAllocator,
2336        )
2337        .unwrap();
2338
2339        // Data.
2340        let data = problem.data.row(0);
2341        _test_oom_resiliance::<Data<D, _>>(
2342            &quantizer,
2343            data,
2344            &mut Data::new_boxed(quantizer.output_dim()),
2345        );
2346        _test_oom_resiliance::<Query<Q, Perm, _>>(
2347            &quantizer,
2348            data,
2349            &mut Query::new_boxed(quantizer.output_dim()),
2350        );
2351        _test_oom_resiliance::<FullQuery<_>>(
2352            &quantizer,
2353            data,
2354            &mut FullQuery::empty(quantizer.output_dim(), GlobalAllocator).unwrap(),
2355        );
2356    }
2357
2358    fn test_quantizer<const Q: usize, const D: usize, Perm>(setup: &Setup, rng: &mut StdRng)
2359    where
2360        Unsigned: Representation<Q>,
2361        Unsigned: Representation<D>,
2362        Perm: PermutationStrategy<Q>,
2363        for<'a> SphericalQuantizer: CompressIntoWith<
2364                &'a [f32],
2365                DataMut<'a, D>,
2366                ScopedAllocator<'a>,
2367                Error = CompressionError,
2368            >,
2369        for<'a> SphericalQuantizer: CompressIntoWith<
2370                &'a [f32],
2371                QueryMut<'a, Q, Perm>,
2372                ScopedAllocator<'a>,
2373                Error = CompressionError,
2374            >,
2375    {
2376        let problem = test_util::create_test_problem(setup.nrows, setup.ncols, rng);
2377        let computed_means_f32: Vec<_> = problem.means.iter().map(|i| *i as f32).collect();
2378
2379        let scales = [
2380            PreScale::Some(Positive::new(1.0 / 1024.0).unwrap()),
2381            PreScale::Some(Positive::new(1.0 / 1024.0).unwrap()),
2382            PreScale::ReciprocalMeanNorm,
2383        ];
2384
2385        for scale in scales {
2386            let ctx = &lazy_format!("dim = {}, scale = {:?}", setup.ncols, scale);
2387
2388            test_l2::<Q, D, Perm>(setup, &problem, &computed_means_f32, scale, rng);
2389            test_ip::<Q, D, Perm>(setup, &problem, &computed_means_f32, scale, rng, ctx);
2390            test_cosine::<Q, D, Perm>(setup, &problem, scale, rng);
2391        }
2392
2393        test_oom_resiliance::<Q, D, Perm>(setup, &problem, PreScale::ReciprocalMeanNorm, rng);
2394    }
2395
2396    #[test]
2397    fn test_spherical_quantizer() {
2398        let mut rng = StdRng::seed_from_u64(0xab516aef1ce61640);
2399        for dim in [56, 72, 128, 255] {
2400            let setup = Setup {
2401                transform: TransformKind::PaddingHadamard {
2402                    target_dim: TargetDim::Same,
2403                },
2404                nrows: 64,
2405                ncols: dim,
2406                num_trials: 10,
2407            };
2408
2409            test_quantizer::<4, 1, BitTranspose>(&setup, &mut rng);
2410            test_quantizer::<2, 2, Dense>(&setup, &mut rng);
2411            test_quantizer::<3, 3, Dense>(&setup, &mut rng);
2412            test_quantizer::<4, 4, Dense>(&setup, &mut rng);
2413            test_quantizer::<8, 8, Dense>(&setup, &mut rng);
2414
2415            let setup = Setup {
2416                transform: TransformKind::DoubleHadamard {
2417                    target_dim: TargetDim::Same,
2418                },
2419                nrows: 64,
2420                ncols: dim,
2421                num_trials: 10,
2422            };
2423            test_quantizer::<4, 1, BitTranspose>(&setup, &mut rng);
2424            test_quantizer::<2, 2, Dense>(&setup, &mut rng);
2425            test_quantizer::<3, 3, Dense>(&setup, &mut rng);
2426            test_quantizer::<4, 4, Dense>(&setup, &mut rng);
2427            test_quantizer::<8, 8, Dense>(&setup, &mut rng);
2428        }
2429    }
2430
2431    ////////////
2432    // Errors //
2433    ////////////
2434
2435    #[test]
2436    fn err_dim_cannot_be_zero() {
2437        let data = rowmajor::Owned::from_element(10, 0, 0.0f32);
2438        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2439        let err = SphericalQuantizer::train(
2440            data.as_view(),
2441            TransformKind::DoubleHadamard {
2442                target_dim: TargetDim::Same,
2443            },
2444            SupportedMetric::SquaredL2,
2445            PreScale::None,
2446            &mut rng,
2447            GlobalAllocator,
2448        )
2449        .unwrap_err();
2450        assert_eq!(err.to_string(), "data dim cannot be zero");
2451    }
2452
2453    #[test]
2454    fn err_norm_must_be_positive() {
2455        let data = rowmajor::Owned::from_element(10, 10, 0.0f32);
2456        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2457        let err = SphericalQuantizer::train(
2458            data.as_view(),
2459            TransformKind::DoubleHadamard {
2460                target_dim: TargetDim::Same,
2461            },
2462            SupportedMetric::SquaredL2,
2463            PreScale::None,
2464            &mut rng,
2465            GlobalAllocator,
2466        )
2467        .unwrap_err();
2468        assert_eq!(err.to_string(), "norm must be positive");
2469    }
2470
2471    #[test]
2472    fn err_norm_cannot_be_infinity() {
2473        let mut data = rowmajor::Owned::from_element(10, 10, 0.0f32);
2474        *data.element_mut(2, 5) = f32::INFINITY;
2475
2476        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2477        let err = SphericalQuantizer::train(
2478            data.as_view(),
2479            TransformKind::DoubleHadamard {
2480                target_dim: TargetDim::Same,
2481            },
2482            SupportedMetric::SquaredL2,
2483            PreScale::None,
2484            &mut rng,
2485            GlobalAllocator,
2486        )
2487        .unwrap_err();
2488        assert_eq!(err.to_string(), "computed norm contains infinity or NaN");
2489    }
2490
2491    #[test]
2492    fn err_reciprocal_norm_cannot_be_infinity() {
2493        let mut data = rowmajor::Owned::from_element(10, 10, 0.0f32);
2494        *data.element_mut(2, 5) = 2.93863e-39;
2495
2496        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2497        let err = SphericalQuantizer::train(
2498            data.as_view(),
2499            TransformKind::DoubleHadamard {
2500                target_dim: TargetDim::Same,
2501            },
2502            SupportedMetric::SquaredL2,
2503            PreScale::ReciprocalMeanNorm,
2504            &mut rng,
2505            GlobalAllocator,
2506        )
2507        .unwrap_err();
2508        assert_eq!(err.to_string(), "reciprocal norm contains infinity or NaN");
2509    }
2510
2511    #[test]
2512    fn err_mean_norm_cannot_be_zero_generate() {
2513        let centroid = Poly::broadcast(0.0f32, 10, GlobalAllocator).unwrap();
2514        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2515        let err = SphericalQuantizer::generate(
2516            centroid,
2517            0.0,
2518            TransformKind::DoubleHadamard {
2519                target_dim: TargetDim::Same,
2520            },
2521            SupportedMetric::SquaredL2,
2522            None,
2523            &mut rng,
2524            GlobalAllocator,
2525        )
2526        .unwrap_err();
2527        assert_eq!(err.to_string(), "norm must be positive");
2528    }
2529
2530    #[test]
2531    fn err_scale_cannot_be_zero_generate() {
2532        let centroid = Poly::broadcast(0.0f32, 10, GlobalAllocator).unwrap();
2533        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2534        let err = SphericalQuantizer::generate(
2535            centroid,
2536            1.0,
2537            TransformKind::DoubleHadamard {
2538                target_dim: TargetDim::Same,
2539            },
2540            SupportedMetric::SquaredL2,
2541            Some(0.0),
2542            &mut rng,
2543            GlobalAllocator,
2544        )
2545        .unwrap_err();
2546        assert_eq!(err.to_string(), "pre-scale must be positive");
2547    }
2548
2549    #[test]
2550    fn compression_errors_data() {
2551        let mut rng = StdRng::seed_from_u64(0xe3e9f42ed9f15883);
2552        let data = rowmajor::Owned::<f32>::from_fn(16, 12, |_| StandardNormal {}.sample(&mut rng));
2553
2554        let quantizer = SphericalQuantizer::train(
2555            data.as_view(),
2556            TransformKind::PaddingHadamard {
2557                target_dim: TargetDim::Same,
2558            },
2559            SupportedMetric::SquaredL2,
2560            PreScale::None,
2561            &mut rng,
2562            GlobalAllocator,
2563        )
2564        .unwrap();
2565
2566        let scoped_global = ScopedAllocator::global();
2567
2568        // Input contains NaN.
2569        {
2570            let mut query: Vec<f32> = quantizer.shift().to_vec();
2571            let mut d = Data::<1, _>::new_boxed(quantizer.output_dim());
2572            let mut multibit_data = Data::<4, _>::new_boxed(quantizer.output_dim());
2573            let mut q = Query::<4, BitTranspose, _>::new_boxed(quantizer.output_dim());
2574
2575            for i in 0..query.len() {
2576                let last = query[i];
2577                for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
2578                    query[i] = v;
2579
2580                    let err = quantizer
2581                        .compress_into_with(&*query, d.reborrow_mut(), scoped_global)
2582                        .unwrap_err();
2583
2584                    assert_eq!(err.to_string(), "input contains NaN", "failed for {}", v);
2585
2586                    let err = quantizer
2587                        .compress_into_with(&*query, multibit_data.reborrow_mut(), scoped_global)
2588                        .unwrap_err();
2589
2590                    assert_eq!(err, CompressionError::InputContainsNaN);
2591
2592                    let err = quantizer
2593                        .compress_into_with(&*query, q.reborrow_mut(), scoped_global)
2594                        .unwrap_err();
2595
2596                    assert_eq!(err.to_string(), "input contains NaN", "failed for {}", v);
2597                }
2598                query[i] = last;
2599            }
2600        }
2601
2602        // Input has a large value.
2603        {
2604            let query: Vec<f32> = vec![1000000.0; quantizer.input_dim()];
2605            let mut d = Data::<1, _>::new_boxed(quantizer.output_dim());
2606
2607            let err = quantizer
2608                .compress_into_with(&*query, d.reborrow_mut(), scoped_global)
2609                .unwrap_err();
2610
2611            let expected = "encoding error - you may need to scale the entire dataset to reduce its dynamic range";
2612
2613            assert_eq!(err.to_string(), expected, "failed for {:?}", query);
2614        }
2615
2616        // Input length
2617        for len in [quantizer.input_dim() - 1, quantizer.input_dim() + 1] {
2618            let query = vec![0.0f32; len];
2619            let mut d = Data::<1, _>::new_boxed(quantizer.output_dim());
2620            let mut q = Query::<4, BitTranspose, _>::new_boxed(quantizer.output_dim());
2621
2622            let err = quantizer
2623                .compress_into_with(&*query, d.reborrow_mut(), scoped_global)
2624                .unwrap_err();
2625            assert_eq!(
2626                err,
2627                CompressionError::SourceDimensionMismatch {
2628                    expected: quantizer.input_dim(),
2629                }
2630            );
2631
2632            let err = quantizer
2633                .compress_into_with(&*query, q.reborrow_mut(), scoped_global)
2634                .unwrap_err();
2635            assert_eq!(
2636                err,
2637                CompressionError::SourceDimensionMismatch {
2638                    expected: quantizer.input_dim(),
2639                }
2640            );
2641        }
2642
2643        for len in [quantizer.output_dim() - 1, quantizer.output_dim() + 1] {
2644            let query = vec![0.0f32; quantizer.input_dim()];
2645            let mut d = Data::<1, _>::new_boxed(len);
2646            let mut q = Query::<4, BitTranspose, _>::new_boxed(len);
2647
2648            let err = quantizer
2649                .compress_into_with(&*query, d.reborrow_mut(), scoped_global)
2650                .unwrap_err();
2651            assert_eq!(
2652                err,
2653                CompressionError::DestinationDimensionMismatch {
2654                    expected: quantizer.output_dim(),
2655                }
2656            );
2657
2658            let err = quantizer
2659                .compress_into_with(&*query, q.reborrow_mut(), scoped_global)
2660                .unwrap_err();
2661            assert_eq!(
2662                err,
2663                CompressionError::DestinationDimensionMismatch {
2664                    expected: quantizer.output_dim(),
2665                }
2666            );
2667        }
2668    }
2669
2670    #[test]
2671    fn centroid_scaling_happens_in_generate() {
2672        let centroid = Poly::from_iter(
2673            [1088.6732f32, 1393.32, 1547.877].into_iter(),
2674            GlobalAllocator,
2675        )
2676        .unwrap();
2677        let mean_norm = 2359.27;
2678        let pre_scale = 1.0 / mean_norm;
2679
2680        let quantizer = SphericalQuantizer::generate(
2681            centroid,
2682            mean_norm,
2683            TransformKind::Null,
2684            SupportedMetric::InnerProduct,
2685            Some(pre_scale),
2686            &mut StdRng::seed_from_u64(10),
2687            GlobalAllocator,
2688        )
2689        .unwrap();
2690
2691        let mut v = Data::<4, _>::new_boxed(quantizer.input_dim());
2692        let data: &[f32] = &[1000.34, 1456.32, 1234.5446];
2693        assert!(
2694            quantizer
2695                .compress_into_with(data, v.reborrow_mut(), ScopedAllocator::global())
2696                .is_ok(),
2697            "if this failed, the likely culprit is exceeding the value of the 16-bit correction terms"
2698        );
2699    }
2700
2701    #[test]
2702    fn centroid_squared_distance_validates_input() {
2703        let quantizer = SphericalQuantizer::generate(
2704            Poly::from_iter([2.0f32, 4.0].into_iter(), GlobalAllocator).unwrap(),
2705            1.0,
2706            TransformKind::Null,
2707            SupportedMetric::SquaredL2,
2708            Some(1.0),
2709            &mut StdRng::seed_from_u64(10),
2710            GlobalAllocator,
2711        )
2712        .unwrap();
2713
2714        assert_eq!(
2715            quantizer.centroid_squared_distance(&[2.0]),
2716            Err(CentroidDistanceError::DimensionMismatch { expected: 2 })
2717        );
2718        assert_eq!(
2719            quantizer.centroid_squared_distance(&[f32::NAN, 4.0]),
2720            Err(CentroidDistanceError::NonFiniteDistance)
2721        );
2722        assert_eq!(
2723            quantizer.centroid_squared_distance(&[f32::MAX, f32::MAX]),
2724            Err(CentroidDistanceError::NonFiniteDistance)
2725        );
2726    }
2727
2728    #[test]
2729    fn cosine_centroid_squared_distance_ignores_pre_scale() {
2730        let quantizer = SphericalQuantizer::generate(
2731            Poly::from_iter([0.5f32, 0.5].into_iter(), GlobalAllocator).unwrap(),
2732            1.0,
2733            TransformKind::Null,
2734            SupportedMetric::Cosine,
2735            Some(2.0),
2736            &mut StdRng::seed_from_u64(10),
2737            GlobalAllocator,
2738        )
2739        .unwrap();
2740
2741        assert_eq!(quantizer.shift(), &[0.5, 0.5]);
2742
2743        let actual = quantizer.centroid_squared_distance(&[0.6, 0.4]).unwrap();
2744        let expected = 0.02;
2745        assert!((actual - expected).abs() <= 1.0e-6);
2746    }
2747
2748    #[test]
2749    fn non_cosine_centroid_squared_distance_removes_pre_scale() {
2750        for metric in [SupportedMetric::SquaredL2, SupportedMetric::InnerProduct] {
2751            let quantizer = SphericalQuantizer::generate(
2752                Poly::from_iter([0.5f32, 0.5].into_iter(), GlobalAllocator).unwrap(),
2753                1.0,
2754                TransformKind::Null,
2755                metric,
2756                Some(2.0),
2757                &mut StdRng::seed_from_u64(10),
2758                GlobalAllocator,
2759            )
2760            .unwrap();
2761
2762            assert_eq!(quantizer.shift(), &[1.0, 1.0]);
2763
2764            let actual = quantizer.centroid_squared_distance(&[0.6, 0.4]).unwrap();
2765            let expected = 0.02;
2766            assert!((actual - expected).abs() <= 1.0e-6);
2767        }
2768    }
2769}
2770
2771#[cfg(feature = "flatbuffers")]
2772#[cfg(test)]
2773mod test_serialization {
2774    use rand::{SeedableRng, rngs::StdRng};
2775
2776    use super::*;
2777    use crate::{
2778        algorithms::transforms::TargetDim,
2779        flatbuffers::{self as fb, to_flatbuffer},
2780        poly, test_util,
2781    };
2782
2783    #[test]
2784    fn test_serialization_happy_path() {
2785        let mut rng = StdRng::seed_from_u64(0x070d9ff8cf5e0f8c);
2786        let problem = test_util::create_test_problem(10, 128, &mut rng);
2787
2788        let low = NonZeroUsize::new(100).unwrap();
2789        let high = NonZeroUsize::new(150).unwrap();
2790
2791        let kinds = [
2792            // Null
2793            TransformKind::Null,
2794            // Double Hadamard
2795            TransformKind::DoubleHadamard {
2796                target_dim: TargetDim::Same,
2797            },
2798            TransformKind::DoubleHadamard {
2799                target_dim: TargetDim::Natural,
2800            },
2801            TransformKind::DoubleHadamard {
2802                target_dim: TargetDim::Override(low),
2803            },
2804            TransformKind::DoubleHadamard {
2805                target_dim: TargetDim::Override(high),
2806            },
2807            // Padding Hadamard
2808            TransformKind::PaddingHadamard {
2809                target_dim: TargetDim::Same,
2810            },
2811            TransformKind::PaddingHadamard {
2812                target_dim: TargetDim::Natural,
2813            },
2814            TransformKind::PaddingHadamard {
2815                target_dim: TargetDim::Override(low),
2816            },
2817            TransformKind::PaddingHadamard {
2818                target_dim: TargetDim::Override(high),
2819            },
2820            // Random Rotation
2821            #[cfg(all(not(miri), feature = "linalg"))]
2822            TransformKind::RandomRotation {
2823                target_dim: TargetDim::Same,
2824            },
2825            #[cfg(all(not(miri), feature = "linalg"))]
2826            TransformKind::RandomRotation {
2827                target_dim: TargetDim::Natural,
2828            },
2829            #[cfg(all(not(miri), feature = "linalg"))]
2830            TransformKind::RandomRotation {
2831                target_dim: TargetDim::Override(low),
2832            },
2833            #[cfg(all(not(miri), feature = "linalg"))]
2834            TransformKind::RandomRotation {
2835                target_dim: TargetDim::Override(high),
2836            },
2837        ];
2838
2839        let pre_scales = [
2840            PreScale::None,
2841            PreScale::Some(Positive::new(0.5).unwrap()),
2842            PreScale::Some(Positive::new(1.0).unwrap()),
2843            PreScale::Some(Positive::new(1.5).unwrap()),
2844            PreScale::ReciprocalMeanNorm,
2845        ];
2846
2847        let alloc = GlobalAllocator;
2848        for kind in kinds.into_iter() {
2849            for metric in SupportedMetric::all() {
2850                for pre_scale in pre_scales {
2851                    let quantizer = SphericalQuantizer::train(
2852                        problem.data.as_view(),
2853                        kind,
2854                        metric,
2855                        pre_scale,
2856                        &mut rng,
2857                        alloc,
2858                    )
2859                    .unwrap();
2860
2861                    let data = to_flatbuffer(|buf| quantizer.pack(buf));
2862                    let proto =
2863                        flatbuffers::root::<fb::spherical::SphericalQuantizer>(&data).unwrap();
2864                    let reloaded = SphericalQuantizer::try_unpack(alloc, proto).unwrap();
2865                    assert_eq!(quantizer, reloaded, "failed on transform {:?}", kind);
2866                }
2867            }
2868        }
2869    }
2870
2871    #[test]
2872    fn test_error_checking() {
2873        let mut rng = StdRng::seed_from_u64(0x070d9ff8cf5e0f8c);
2874        let problem = test_util::create_test_problem(10, 128, &mut rng);
2875
2876        let transform = TransformKind::DoubleHadamard {
2877            target_dim: TargetDim::Same,
2878        };
2879
2880        let alloc = GlobalAllocator;
2881        let mut make_quantizer = || {
2882            SphericalQuantizer::train(
2883                problem.data.as_view(),
2884                transform,
2885                SupportedMetric::SquaredL2,
2886                PreScale::None,
2887                &mut rng,
2888                alloc,
2889            )
2890            .unwrap()
2891        };
2892
2893        type E = DeserializationError;
2894
2895        // Missing norm: 0.0
2896        {
2897            let mut quantizer = make_quantizer();
2898            // SAFETY: We do not do anything with the created value and the compiler
2899            // does not know about the layout of `Positive`, so we don't need to worry
2900            // about violating layout restrictions.
2901            quantizer.mean_norm = unsafe { Positive::new_unchecked(0.0) };
2902
2903            let data = to_flatbuffer(|buf| quantizer.pack(buf));
2904            let proto = flatbuffers::root::<fb::spherical::SphericalQuantizer>(&data).unwrap();
2905            let err = SphericalQuantizer::try_unpack(alloc, proto).unwrap_err();
2906            assert_eq!(err, E::MissingNorm);
2907        }
2908
2909        // Missing norm: negative
2910        {
2911            let mut quantizer = make_quantizer();
2912
2913            // SAFETY: We do not do anything with the created value and the compiler
2914            // does not know about the layout of `Positive`, so we don't need to worry
2915            // about violating layout restrictions.
2916            quantizer.mean_norm = unsafe { Positive::new_unchecked(-1.0) };
2917
2918            let data = to_flatbuffer(|buf| quantizer.pack(buf));
2919            let proto = flatbuffers::root::<fb::spherical::SphericalQuantizer>(&data).unwrap();
2920            let err = SphericalQuantizer::try_unpack(alloc, proto).unwrap_err();
2921            assert_eq!(err, E::MissingNorm);
2922        }
2923
2924        // PreScaleNotPositive
2925        {
2926            let mut quantizer = make_quantizer();
2927
2928            // SAFETY: This really isn't safe, but we are not using the improper value in a
2929            // way that will trigger undefined behavior.
2930            quantizer.pre_scale = unsafe { Positive::new_unchecked(0.0) };
2931
2932            let data = to_flatbuffer(|buf| quantizer.pack(buf));
2933            let proto = flatbuffers::root::<fb::spherical::SphericalQuantizer>(&data).unwrap();
2934            let err = SphericalQuantizer::try_unpack(alloc, proto).unwrap_err();
2935            assert_eq!(err, E::PreScaleNotPositive);
2936        }
2937
2938        // Dim Mismatch.
2939        {
2940            let mut quantizer = make_quantizer();
2941            quantizer.shift = poly!([1.0, 2.0, 3.0], alloc).unwrap();
2942
2943            let data = to_flatbuffer(|buf| quantizer.pack(buf));
2944            let proto = flatbuffers::root::<fb::spherical::SphericalQuantizer>(&data).unwrap();
2945            let err = SphericalQuantizer::try_unpack(alloc, proto).unwrap_err();
2946            assert_eq!(err, E::DimMismatch);
2947        }
2948    }
2949}