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}