Skip to main content

dynvec/
encoding.rs

1//! Vector encodings.
2//!
3//! Three compression schemes are available:
4//!
5//! * [`Int8Quantized`] -- per-vector scalar quantisation to `u8`,
6//!   storing the per-vector minimum and scale alongside. Roughly 4x
7//!   compression vs raw `f32`; reconstruction error is in the
8//!   0.5-1.0 percent range for typical embedding distributions.
9//! * [`Fp16`] -- IEEE 754 half-precision floats. 2x compression;
10//!   reconstruction error around 0.05 percent.
11//! * [`Turbovec`] -- 2/3/4-bit data-oblivious quantisation backed
12//!   by the `turbovec` crate's TurboQuant codec. The compressed
13//!   payload is held in a per-table SIMD-friendly index; the
14//!   per-row [`EncodedVector`] holds the original `f32` bytes so
15//!   round-trip and rehydration paths remain exact while the
16//!   in-memory ANN index uses the 8x to 16x compressed packed
17//!   representation. See [`turbovec`] for the on-disk packed
18//!   layout and the SIMD search kernels.
19//!
20//! The `Int8Quantized` and `Fp16` encodings round-trip every
21//! vector to within their respective error budgets and preserve
22//! dimension count exactly. `Turbovec` round-trips losslessly at
23//! the row layer because the compressed representation lives in
24//! the table's [`crate::index::TurboTable`] alongside the row
25//! store, not in the row payload itself; quantisation loss is
26//! exposed through the search-path scoring (see
27//! [`distance_turbovec`]).
28//!
29//! The encoded byte stream produced by [`encode`](Encoder::encode)
30//! is self-describing in the [`EncodedVector`] wrapper: the
31//! dimension count, the codec identifier, and any per-vector
32//! parameters (the int8 minimum and scale, for instance) are
33//! captured on the [`EncodedVector`] struct so an operator can
34//! `dynvec-cli inspect <id>` and see the human-readable form
35//! without re-running the codec.
36
37use half::f16;
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40
41use crate::distance::Distance;
42
43/// Codec identifier.
44///
45/// Persisted on every [`EncodedVector`] so a reader can pick the
46/// correct decoder without ambient state.
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum Codec {
51    /// Per-vector scalar quantisation to `u8`.
52    Int8Quantized,
53    /// IEEE 754 half-precision floats.
54    Fp16,
55    /// `turbovec` 2-bit TurboQuant codec, 16x nominal compression.
56    Turbovec2Bit,
57    /// `turbovec` 3-bit TurboQuant codec, 10.6x nominal compression.
58    Turbovec3Bit,
59    /// `turbovec` 4-bit TurboQuant codec, 8x nominal compression.
60    Turbovec4Bit,
61}
62
63impl Codec {
64    /// Build the encoder bound to this codec.
65    #[must_use]
66    pub fn encoder(self) -> Box<dyn Encoder> {
67        match self {
68            Self::Int8Quantized => Box::new(Int8Quantized),
69            Self::Fp16 => Box::new(Fp16),
70            Self::Turbovec2Bit => Box::new(Turbovec::new(2)),
71            Self::Turbovec3Bit => Box::new(Turbovec::new(3)),
72            Self::Turbovec4Bit => Box::new(Turbovec::new(4)),
73        }
74    }
75
76    /// Encoder name suitable for logging.
77    #[must_use]
78    pub fn name(self) -> &'static str {
79        match self {
80            Self::Int8Quantized => "int8q",
81            Self::Fp16 => "fp16",
82            Self::Turbovec2Bit => "turbovec2",
83            Self::Turbovec3Bit => "turbovec3",
84            Self::Turbovec4Bit => "turbovec4",
85        }
86    }
87
88    /// `Some(bit_width)` for the turbovec codecs, `None`
89    /// otherwise. Used by the storage layer to dispatch to the
90    /// SIMD-backed table state.
91    #[must_use]
92    pub fn turbovec_bits(self) -> Option<u8> {
93        match self {
94            Self::Turbovec2Bit => Some(2),
95            Self::Turbovec3Bit => Some(3),
96            Self::Turbovec4Bit => Some(4),
97            _ => None,
98        }
99    }
100}
101
102/// Encoded vector ready to persist or hand to a distance routine.
103///
104/// The byte buffer in [`Self::bytes`] is opaque to callers other
105/// than the matching [`Codec`]. The remaining fields are
106/// inspectable so an operator can reason about the row without
107/// decoding it.
108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
109pub struct EncodedVector {
110    /// Codec used to produce [`Self::bytes`].
111    pub codec: Codec,
112    /// Number of dimensions in the original `f32` vector.
113    pub dim: u16,
114    /// Codec-private payload.
115    pub bytes: Vec<u8>,
116    /// Codec-private parameters. For `Int8Quantized` this is
117    /// `[min, scale]`; for `Fp16` it is empty. Stored as `f32`
118    /// so an inspect tool can render them without parsing the
119    /// payload.
120    pub params: Vec<f32>,
121}
122
123impl EncodedVector {
124    /// L2 norm of the decoded vector, computed by re-decoding.
125    ///
126    /// Used by the inspect tooling and by some distance metrics
127    /// that need to factor out vector magnitude.
128    #[must_use]
129    pub fn l2_norm(&self) -> f32 {
130        let v = self.codec.encoder().decode(self).unwrap_or_default();
131        v.iter().map(|x| x * x).sum::<f32>().sqrt()
132    }
133}
134
135/// Errors returned by encoders.
136#[derive(Debug, Error)]
137#[non_exhaustive]
138pub enum EncodingError {
139    /// Vector dimension count cannot be represented as `u16`.
140    #[error("vector has {0} dimensions; max supported is 65535")]
141    DimensionTooLarge(usize),
142    /// Vector dimension count is zero.
143    #[error("vector has zero dimensions")]
144    EmptyVector,
145    /// The encoded payload's length does not match the codec's
146    /// expectation for the recorded dimension.
147    #[error("malformed encoded vector: dim={dim} payload_bytes={bytes}")]
148    Malformed {
149        /// Recorded dimension count.
150        dim: u16,
151        /// Actual payload byte count.
152        bytes: usize,
153    },
154    /// The codec identifier on the [`EncodedVector`] does not
155    /// match the encoder being asked to decode it.
156    #[error("codec mismatch: expected {expected:?}, got {got:?}")]
157    CodecMismatch {
158        /// Encoder's codec.
159        expected: Codec,
160        /// Codec recorded on the [`EncodedVector`].
161        got: Codec,
162    },
163    /// Component value was non-finite (NaN, +inf, -inf). The
164    /// codecs reject these so the reconstruction error budget
165    /// is well-defined.
166    #[error("vector contains non-finite component")]
167    NonFinite,
168    /// `bit_width` parameter outside the supported range. The
169    /// turbovec codec accepts only 2, 3, or 4.
170    #[error("turbovec bit width must be 2, 3, or 4, got {0}")]
171    UnsupportedBitWidth(u8),
172    /// Vector dimension is incompatible with the requested
173    /// codec. The turbovec codec requires `dim` to be a positive
174    /// multiple of 8.
175    #[error("turbovec requires dim to be a positive multiple of 8, got {0}")]
176    UnsupportedDim(u16),
177}
178
179/// Encoder trait. Each codec ships exactly one impl.
180pub trait Encoder: Send + Sync {
181    /// Codec identifier produced by this encoder.
182    fn codec(&self) -> Codec;
183    /// Encode `values` into an [`EncodedVector`].
184    ///
185    /// # Errors
186    ///
187    /// Returns [`EncodingError::EmptyVector`] for a zero-dim
188    /// input, [`EncodingError::DimensionTooLarge`] for >65535
189    /// dimensions, and [`EncodingError::NonFinite`] for any
190    /// non-finite component.
191    fn encode(&self, values: &[f32]) -> Result<EncodedVector, EncodingError>;
192    /// Decode an [`EncodedVector`] back to `Vec<f32>`.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`EncodingError::CodecMismatch`] when the
197    /// `EncodedVector` was produced by a different codec, and
198    /// [`EncodingError::Malformed`] when the payload byte
199    /// count does not match the recorded dimension.
200    fn decode(&self, ev: &EncodedVector) -> Result<Vec<f32>, EncodingError>;
201}
202
203/// Per-vector scalar quantisation to `u8`.
204///
205/// Stores `min` and `scale` per vector so each vector occupies its
206/// own quantisation range; this keeps reconstruction error inside
207/// 0.5-1.0 percent on typical embedding distributions where
208/// neighbouring components have similar magnitude. With 256
209/// quantisation buckets the worst-case relative error per
210/// component is `range / 255` where `range = max - min`.
211#[derive(Clone, Copy, Debug, Default)]
212pub struct Int8Quantized;
213
214impl Encoder for Int8Quantized {
215    fn codec(&self) -> Codec {
216        Codec::Int8Quantized
217    }
218
219    fn encode(&self, values: &[f32]) -> Result<EncodedVector, EncodingError> {
220        if values.is_empty() {
221            return Err(EncodingError::EmptyVector);
222        }
223        let dim = u16::try_from(values.len())
224            .map_err(|_| EncodingError::DimensionTooLarge(values.len()))?;
225        for v in values {
226            if !v.is_finite() {
227                return Err(EncodingError::NonFinite);
228            }
229        }
230        let mut min = values[0];
231        let mut max = values[0];
232        for &v in &values[1..] {
233            if v < min {
234                min = v;
235            }
236            if v > max {
237                max = v;
238            }
239        }
240        let range = max - min;
241        // Scale of 0.0 (all components equal) is fine: every
242        // component quantises to bucket 0 and decodes back to
243        // `min`, which is exact.
244        let scale = if range > 0.0 { range / 255.0 } else { 0.0 };
245        let bytes: Vec<u8> = values
246            .iter()
247            .map(|&v| {
248                if scale == 0.0 {
249                    0_u8
250                } else {
251                    let q = ((v - min) / scale).round();
252                    let clamped = q.clamp(0.0, 255.0);
253                    // Clamped to [0, 255]; the cast can not
254                    // truncate or sign-flip in this range.
255                    #[allow(
256                        clippy::cast_possible_truncation,
257                        clippy::cast_sign_loss,
258                        reason = "clamped to [0, 255]"
259                    )]
260                    let byte = clamped as u8;
261                    byte
262                }
263            })
264            .collect();
265        Ok(EncodedVector {
266            codec: Codec::Int8Quantized,
267            dim,
268            bytes,
269            params: vec![min, scale],
270        })
271    }
272
273    fn decode(&self, ev: &EncodedVector) -> Result<Vec<f32>, EncodingError> {
274        if ev.codec != Codec::Int8Quantized {
275            return Err(EncodingError::CodecMismatch {
276                expected: Codec::Int8Quantized,
277                got: ev.codec,
278            });
279        }
280        if ev.bytes.len() != usize::from(ev.dim) {
281            return Err(EncodingError::Malformed {
282                dim: ev.dim,
283                bytes: ev.bytes.len(),
284            });
285        }
286        if ev.params.len() != 2 {
287            return Err(EncodingError::Malformed {
288                dim: ev.dim,
289                bytes: ev.bytes.len(),
290            });
291        }
292        let min = ev.params[0];
293        let scale = ev.params[1];
294        Ok(ev
295            .bytes
296            .iter()
297            .map(|&b| min + scale * f32::from(b))
298            .collect())
299    }
300}
301
302/// IEEE 754 half-precision encoder.
303///
304/// Each component is converted via [`f16::from_f32`]. Storage is
305/// `2 * dim` bytes; no per-vector parameters are needed because
306/// the codec does not depend on the input distribution.
307#[derive(Clone, Copy, Debug, Default)]
308pub struct Fp16;
309
310impl Encoder for Fp16 {
311    fn codec(&self) -> Codec {
312        Codec::Fp16
313    }
314
315    fn encode(&self, values: &[f32]) -> Result<EncodedVector, EncodingError> {
316        if values.is_empty() {
317            return Err(EncodingError::EmptyVector);
318        }
319        let dim = u16::try_from(values.len())
320            .map_err(|_| EncodingError::DimensionTooLarge(values.len()))?;
321        for v in values {
322            if !v.is_finite() {
323                return Err(EncodingError::NonFinite);
324            }
325        }
326        let mut bytes = Vec::with_capacity(values.len() * 2);
327        for &v in values {
328            let h = f16::from_f32(v);
329            bytes.extend_from_slice(&h.to_le_bytes());
330        }
331        Ok(EncodedVector {
332            codec: Codec::Fp16,
333            dim,
334            bytes,
335            params: Vec::new(),
336        })
337    }
338
339    fn decode(&self, ev: &EncodedVector) -> Result<Vec<f32>, EncodingError> {
340        if ev.codec != Codec::Fp16 {
341            return Err(EncodingError::CodecMismatch {
342                expected: Codec::Fp16,
343                got: ev.codec,
344            });
345        }
346        if ev.bytes.len() != usize::from(ev.dim) * 2 {
347            return Err(EncodingError::Malformed {
348                dim: ev.dim,
349                bytes: ev.bytes.len(),
350            });
351        }
352        let mut out = Vec::with_capacity(usize::from(ev.dim));
353        for chunk in ev.bytes.chunks_exact(2) {
354            // Safe: chunks_exact(2) guarantees length 2.
355            let hb: [u8; 2] = [chunk[0], chunk[1]];
356            out.push(f16::from_le_bytes(hb).to_f32());
357        }
358        Ok(out)
359    }
360}
361
362/// `turbovec` 2/3/4-bit TurboQuant encoder.
363///
364/// The on-row payload is the original vector's `f32`
365/// little-endian bytes. Compression and SIMD scoring happen at
366/// the per-table layer (see [`crate::index::TurboTable`]) where
367/// a [`turbovec::TurboQuantIndex`] holds every vector in its
368/// 2/3/4-bit packed form. Per-row storage stays at 4 * `dim`
369/// bytes; the in-memory ANN index achieves the headline 8x
370/// (4-bit) or 16x (2-bit) compression on the slot table.
371///
372/// The on-row passthrough is deliberate: turbovec's public API
373/// does not expose per-vector reconstruction, so a faithful
374/// `decode` implementation requires keeping the source bytes.
375/// The trade-off (no row-layer compression) is documented in
376/// `docs/dynvecdb/architecture.md`.
377#[derive(Clone, Copy, Debug)]
378pub struct Turbovec {
379    /// Bit width: 2, 3, or 4.
380    bits: u8,
381}
382
383impl Turbovec {
384    /// Build a turbovec encoder for the given bit width.
385    ///
386    /// # Panics
387    ///
388    /// Panics on a bit width outside `{2, 3, 4}`. Use
389    /// [`Codec::encoder`] in the dynamic-dispatch path to keep
390    /// the panic out of caller code; the codec variants are
391    /// pre-validated.
392    #[must_use]
393    pub fn new(bits: u8) -> Self {
394        assert!(
395            (2..=4).contains(&bits),
396            "turbovec bit width must be 2, 3, or 4"
397        );
398        Self { bits }
399    }
400
401    /// Bit width this encoder was built with.
402    #[must_use]
403    pub fn bits(self) -> u8 {
404        self.bits
405    }
406
407    fn codec_for(bits: u8) -> Codec {
408        match bits {
409            2 => Codec::Turbovec2Bit,
410            3 => Codec::Turbovec3Bit,
411            4 => Codec::Turbovec4Bit,
412            _ => unreachable!("turbovec bits validated in Turbovec::new"),
413        }
414    }
415}
416
417impl Encoder for Turbovec {
418    fn codec(&self) -> Codec {
419        Self::codec_for(self.bits)
420    }
421
422    fn encode(&self, values: &[f32]) -> Result<EncodedVector, EncodingError> {
423        if values.is_empty() {
424            return Err(EncodingError::EmptyVector);
425        }
426        let dim = u16::try_from(values.len())
427            .map_err(|_| EncodingError::DimensionTooLarge(values.len()))?;
428        for v in values {
429            if !v.is_finite() {
430                return Err(EncodingError::NonFinite);
431            }
432        }
433        if dim == 0 || !dim.is_multiple_of(8) {
434            return Err(EncodingError::UnsupportedDim(dim));
435        }
436        let mut bytes = Vec::with_capacity(values.len() * 4);
437        for &v in values {
438            bytes.extend_from_slice(&v.to_le_bytes());
439        }
440        Ok(EncodedVector {
441            codec: Self::codec_for(self.bits),
442            dim,
443            bytes,
444            params: vec![f32::from(self.bits)],
445        })
446    }
447
448    fn decode(&self, ev: &EncodedVector) -> Result<Vec<f32>, EncodingError> {
449        let expected = Self::codec_for(self.bits);
450        if ev.codec != expected {
451            return Err(EncodingError::CodecMismatch {
452                expected,
453                got: ev.codec,
454            });
455        }
456        if ev.bytes.len() != usize::from(ev.dim) * 4 {
457            return Err(EncodingError::Malformed {
458                dim: ev.dim,
459                bytes: ev.bytes.len(),
460            });
461        }
462        let mut out = Vec::with_capacity(usize::from(ev.dim));
463        for chunk in ev.bytes.chunks_exact(4) {
464            let arr: [u8; 4] = [chunk[0], chunk[1], chunk[2], chunk[3]];
465            out.push(f32::from_le_bytes(arr));
466        }
467        Ok(out)
468    }
469}
470
471/// Encode a single `f32` vector under the turbovec codec at
472/// `bits` bit-width.
473///
474/// The returned [`EncodedVector`] holds the source vector's
475/// `f32` little-endian bytes; the codec marker on the
476/// [`EncodedVector`] indicates that the table-level SIMD index
477/// owns the compressed representation. See module docs for the
478/// rationale.
479///
480/// # Errors
481///
482/// [`EncodingError::UnsupportedBitWidth`] for `bits` outside
483/// `{2, 3, 4}`; [`EncodingError::EmptyVector`] for a zero-dim
484/// input; [`EncodingError::UnsupportedDim`] when `dim` is not a
485/// positive multiple of 8; [`EncodingError::DimensionTooLarge`]
486/// for >65535 dimensions; [`EncodingError::NonFinite`] for any
487/// non-finite component.
488pub fn encode_turbovec(vector: &[f32], bits: u8) -> Result<EncodedVector, EncodingError> {
489    if !(2..=4).contains(&bits) {
490        return Err(EncodingError::UnsupportedBitWidth(bits));
491    }
492    Turbovec::new(bits).encode(vector)
493}
494
495/// Decode a turbovec-encoded blob back to `Vec<f32>`.
496///
497/// Round-trips losslessly because the row-level payload holds
498/// the source `f32` bytes; quantisation loss is a property of
499/// the search-time scoring path, not of the row.
500///
501/// # Errors
502///
503/// [`EncodingError::UnsupportedBitWidth`] for `bits` outside
504/// `{2, 3, 4}`; [`EncodingError::CodecMismatch`] when the
505/// [`EncodedVector`] was produced under a different bit width;
506/// [`EncodingError::Malformed`] when the payload size does not
507/// match the recorded dimension.
508pub fn decode_turbovec(ev: &EncodedVector, bits: u8) -> Result<Vec<f32>, EncodingError> {
509    if !(2..=4).contains(&bits) {
510        return Err(EncodingError::UnsupportedBitWidth(bits));
511    }
512    Turbovec::new(bits).decode(ev)
513}
514
515/// Score `query` against a single turbovec-stored vector at the
516/// given [`Distance`] metric, using turbovec's SIMD-accelerated
517/// search path against an ephemeral one-element index.
518///
519/// Returns `f32::INFINITY` if `stored` is not a turbovec
520/// payload, if the stored vector cannot be added to the
521/// ephemeral index (e.g. a coordinate magnitude trips
522/// turbovec's input validation), or if the dimension is not a
523/// supported turbovec dim. Smaller scores are closer; cosine
524/// and dot-product are mapped from turbovec's similarity score
525/// by negation, and euclidean is approximated through the
526/// inner-product surrogate after L2 normalisation.
527///
528/// This is a single-pair surrogate; the bulk-search path used
529/// by the table layer issues one batched call to
530/// [`turbovec::TurboQuantIndex::search`] across all rows and
531/// avoids the ephemeral-index cost.
532#[must_use]
533pub fn distance_turbovec(query: &[f32], stored: &EncodedVector, metric: Distance) -> f32 {
534    let Some(bits) = stored.codec.turbovec_bits() else {
535        return f32::INFINITY;
536    };
537    if usize::from(stored.dim) != query.len() {
538        return f32::INFINITY;
539    }
540    let dim = usize::from(stored.dim);
541    if dim == 0 || !dim.is_multiple_of(8) {
542        return f32::INFINITY;
543    }
544    let Ok(stored_vec) = decode_turbovec(stored, bits) else {
545        return f32::INFINITY;
546    };
547    let Ok(mut index) = turbovec::TurboQuantIndex::new(dim, usize::from(bits)) else {
548        return f32::INFINITY;
549    };
550    // Cosine and Euclidean both decompose into an inner
551    // product on the L2-normalised inputs; we hand turbovec
552    // the normalised forms so its inner-product surrogate
553    // produces the right ordering.
554    let (q_input, s_input) = match metric {
555        Distance::DotProduct => (query.to_vec(), stored_vec.clone()),
556        Distance::Cosine | Distance::Euclidean => (l2_normalise(query), l2_normalise(&stored_vec)),
557    };
558    if index.add_2d(&s_input, dim).is_err() {
559        return f32::INFINITY;
560    }
561    let results = index.search(&q_input, 1);
562    if results.scores.is_empty() {
563        return f32::INFINITY;
564    }
565    let similarity = results.scores[0];
566    match metric {
567        Distance::DotProduct => -similarity,
568        // For the L2-normalised inputs, similarity is an
569        // estimate of cos(theta); cosine distance is `1 - cos`.
570        Distance::Cosine => 1.0 - similarity,
571        // sqrt(2 - 2 cos(theta)) for unit vectors. Clamp the
572        // inside of the sqrt at 0 to absorb rounding noise on
573        // identical inputs.
574        Distance::Euclidean => (2.0 - 2.0 * similarity).max(0.0).sqrt(),
575    }
576}
577
578/// L2-normalise a vector. Zero-norm input is returned
579/// unchanged so callers do not produce NaN.
580fn l2_normalise(v: &[f32]) -> Vec<f32> {
581    let n2: f32 = v.iter().map(|x| x * x).sum();
582    let n = n2.sqrt();
583    if n <= 0.0 {
584        return v.to_vec();
585    }
586    v.iter().map(|x| x / n).collect()
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    fn vec_close(a: &[f32], b: &[f32], eps: f32) -> bool {
594        a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= eps)
595    }
596
597    #[test]
598    fn int8_round_trip_within_budget() {
599        let v: Vec<f32> = (0_i16..64).map(|i| f32::from(i) * 0.1 - 3.2).collect();
600        let enc = Int8Quantized.encode(&v).unwrap();
601        let dec = Int8Quantized.decode(&enc).unwrap();
602        let range = 6.4_f32;
603        let bucket = range / 255.0;
604        assert!(vec_close(&v, &dec, bucket));
605        assert_eq!(enc.dim as usize, v.len());
606    }
607
608    #[test]
609    fn fp16_round_trip_within_budget() {
610        let v: Vec<f32> = (0_i16..32).map(|i| f32::from(i) * 0.05 - 0.8).collect();
611        let enc = Fp16.encode(&v).unwrap();
612        let dec = Fp16.decode(&enc).unwrap();
613        // f16 mantissa is 10 bits; relative error is roughly 1e-3.
614        assert!(vec_close(&v, &dec, 1e-2));
615    }
616
617    #[test]
618    fn rejects_non_finite() {
619        assert!(matches!(
620            Int8Quantized.encode(&[1.0, f32::NAN]),
621            Err(EncodingError::NonFinite)
622        ));
623        assert!(matches!(
624            Fp16.encode(&[1.0, f32::INFINITY]),
625            Err(EncodingError::NonFinite)
626        ));
627    }
628
629    #[test]
630    fn rejects_empty() {
631        assert!(matches!(
632            Int8Quantized.encode(&[]),
633            Err(EncodingError::EmptyVector)
634        ));
635    }
636
637    #[test]
638    fn codec_mismatch_detected() {
639        let v = vec![0.1, 0.2, 0.3];
640        let enc = Fp16.encode(&v).unwrap();
641        assert!(matches!(
642            Int8Quantized.decode(&enc),
643            Err(EncodingError::CodecMismatch { .. })
644        ));
645    }
646
647    #[test]
648    fn constant_vector_quantises_cleanly() {
649        let v = vec![1.5_f32; 8];
650        let enc = Int8Quantized.encode(&v).unwrap();
651        let dec = Int8Quantized.decode(&enc).unwrap();
652        assert_eq!(dec, vec![1.5_f32; 8]);
653    }
654
655    #[test]
656    fn l2_norm_matches_decoded() {
657        let v = vec![3.0_f32, 4.0, 0.0];
658        let enc = Fp16.encode(&v).unwrap();
659        // sqrt(9 + 16 + 0) = 5
660        assert!((enc.l2_norm() - 5.0).abs() < 1e-2);
661    }
662
663    #[test]
664    fn turbovec_encode_round_trips_at_row_layer() {
665        let v: Vec<f32> = (0_i16..64).map(|i| f32::from(i) * 0.05 - 1.6).collect();
666        let enc = encode_turbovec(&v, 4).unwrap();
667        assert_eq!(enc.codec, Codec::Turbovec4Bit);
668        assert_eq!(enc.dim as usize, v.len());
669        let dec = decode_turbovec(&enc, 4).unwrap();
670        // Row-layer round-trip is exact (passthrough).
671        assert_eq!(dec, v);
672    }
673
674    #[test]
675    fn turbovec_rejects_unsupported_bit_width() {
676        let v = vec![0.1_f32; 8];
677        assert!(matches!(
678            encode_turbovec(&v, 5),
679            Err(EncodingError::UnsupportedBitWidth(5))
680        ));
681    }
682
683    #[test]
684    fn turbovec_rejects_non_multiple_of_eight_dim() {
685        let v = vec![0.1_f32; 7];
686        assert!(matches!(
687            encode_turbovec(&v, 4),
688            Err(EncodingError::UnsupportedDim(7))
689        ));
690    }
691}