Skip to main content

nodedb_vector/quantize/
pq.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Product Quantization (PQ): 8-16x compression for large datasets.
4//!
5//! Splits D-dimensional vectors into M subvectors, clusters each subspace
6//! with K=256 centroids via k-means. Each vector is encoded as M bytes
7//! (one centroid index per subvector).
8//!
9//! Distance is computed via precomputed lookup tables: for each query,
10//! build a `[M][K]` table of distances from the query's subvectors to
11//! all centroids. Then the distance to any encoded vector is just M
12//! table lookups + additions — O(M) per candidate vs O(D) for FP32.
13//!
14//! Trade-off: 2-5% recall loss vs SQ8's <1%, but 2-4x more compression
15//! (8-16x total vs 4x for SQ8). Best for cost-sensitive large datasets.
16
17use std::mem::size_of;
18
19use nodedb_mem::{ReservationToken, ScopedMemory};
20use nodedb_types::decode_bounds::checked_decode_capacity;
21
22use crate::error::{VectorError, check_dim};
23
24use super::pq_kmeans::{kmeans, l2_sub};
25
26/// Hard ceiling for a decoded PQ vector. This bounds corrupted persisted
27/// configuration even when the codec has no scoped memory handle attached.
28const MAX_PQ_DECODE_DIM: usize = 1_048_576;
29const MAX_PQ_CODEBOOK_BYTES: usize = 64 * 1024 * 1024;
30// MessagePack stores each f32 as a marker plus four payload bytes and also
31// carries nested-array headers. A 96 MiB envelope covers every codec whose
32// complete decoded allocation (floats plus Vec headers) fits the 64 MiB cap.
33const MAX_PQ_SERIALIZED_BYTES: usize = 96 * 1024 * 1024;
34
35fn pq_codebook_allocation_bytes(m: usize, k: usize, sub_dim: usize) -> Option<usize> {
36    let outer_headers = m.checked_mul(size_of::<Vec<Vec<f32>>>())?;
37    let centroid_count = m.checked_mul(k)?;
38    let centroid_headers = centroid_count.checked_mul(size_of::<Vec<f32>>())?;
39    let float_bytes = centroid_count
40        .checked_mul(sub_dim)?
41        .checked_mul(size_of::<f32>())?;
42    outer_headers
43        .checked_add(centroid_headers)?
44        .checked_add(float_bytes)
45}
46
47/// Reserve `bytes` from `memory`. The returned token must be kept alive for
48/// the duration of the allocation it covers.
49#[inline]
50fn try_reserve_or_skip(
51    memory: &ScopedMemory,
52    bytes: usize,
53) -> Result<ReservationToken, VectorError> {
54    Ok(memory.reserve(bytes)?)
55}
56
57/// The on-disk shape of a [`PqCodec`] — everything except the runtime
58/// memory scope, which is never serialized.
59#[derive(Clone, Debug, zerompk::ToMessagePack, zerompk::FromMessagePack)]
60struct PqCodecData {
61    dim: usize,
62    m: usize,
63    k: usize,
64    sub_dim: usize,
65    codebooks: Vec<Vec<Vec<f32>>>,
66}
67
68/// PQ codec with trained codebooks.
69#[derive(Clone, Debug)]
70pub struct PqCodec {
71    /// Original vector dimensionality.
72    pub dim: usize,
73    /// Number of subvectors (subspaces).
74    pub m: usize,
75    /// Centroids per subvector (fixed at 256 for u8 encoding).
76    pub k: usize,
77    /// Dimensions per subvector: `dim / m`.
78    pub sub_dim: usize,
79    /// Codebooks: `codebooks[subspace][centroid][sub_dim_component]`.
80    /// Total: M × K × sub_dim floats.
81    codebooks: Vec<Vec<Vec<f32>>>,
82
83    /// Charges heap-significant operations (`train`, `encode_batch`,
84    /// `build_distance_table`, `decode`, `to_bytes`) against the bound
85    /// database, tenant, and engine budget before allocating, releasing
86    /// the reservation when the returned value drops (RAII). A runtime
87    /// concern only — it is never serialized.
88    memory: ScopedMemory,
89}
90
91impl PqCodec {
92    /// Train PQ codebooks from a set of training vectors via k-means.
93    ///
94    /// `m` = number of subvectors (must divide `dim` evenly).
95    /// `k` = centroids per subvector (typically 256).
96    /// `max_iter` = k-means iterations (20 is usually sufficient).
97    pub fn train(
98        vectors: &[&[f32]],
99        dim: usize,
100        m: usize,
101        k: usize,
102        max_iter: usize,
103        memory: ScopedMemory,
104    ) -> Result<Self, VectorError> {
105        let invalid = |detail: String| Err(VectorError::InvalidInput { detail });
106        if vectors.is_empty() {
107            return invalid("PQ training needs at least one vector".into());
108        }
109        if dim == 0 || dim > MAX_PQ_DECODE_DIM {
110            return invalid(format!(
111                "PQ dimension {dim} is outside 1..={MAX_PQ_DECODE_DIM}"
112            ));
113        }
114        if m == 0 || !dim.is_multiple_of(m) {
115            return invalid(format!("PQ dimension {dim} must be divisible by m ({m})"));
116        }
117        if k == 0 || k > usize::from(u8::MAX) + 1 || k > vectors.len() {
118            return invalid(format!(
119                "PQ centroid count {k} must be in 1..=256 and at most the {} training vectors",
120                vectors.len()
121            ));
122        }
123        let sub_dim = dim / m;
124        if pq_codebook_allocation_bytes(m, k, sub_dim)
125            .is_none_or(|bytes| bytes > MAX_PQ_CODEBOOK_BYTES)
126        {
127            return invalid(format!(
128                "PQ codebook for m={m}, k={k}, sub-dimension {sub_dim} exceeds \
129                 {MAX_PQ_CODEBOOK_BYTES} bytes"
130            ));
131        }
132        // Parameters are checked before any vector is read.
133        for v in vectors {
134            check_dim(dim, v.len())?;
135        }
136
137        let mut codebooks = Vec::with_capacity(m);
138
139        for sub in 0..m {
140            let offset = sub * sub_dim;
141            // Extract sub-vectors for this subspace.
142            let sub_vectors: Vec<&[f32]> = vectors
143                .iter()
144                .map(|v| &v[offset..offset + sub_dim])
145                .collect();
146
147            let centroids = kmeans(&sub_vectors, sub_dim, k, max_iter);
148            codebooks.push(centroids);
149        }
150
151        Ok(Self {
152            dim,
153            m,
154            k,
155            sub_dim,
156            codebooks,
157            memory,
158        })
159    }
160
161    /// Encode a vector: for each subvector, find the nearest centroid index.
162    ///
163    /// This is a per-vector hot-path operation.  Governor charging is
164    /// intentionally skipped here to avoid atomic overhead on every candidate
165    /// during search; use [`encode_batch`] for bulk encoding with budget
166    /// enforcement.
167    ///
168    /// Precondition: `vector.len() == self.dim`. This per-candidate hot path
169    /// does not re-check it; every caller checks the dimension first
170    /// (`encode_batch`, the IVF-PQ `add`, and the codec-index entry points).
171    pub fn encode(&self, vector: &[f32]) -> Vec<u8> {
172        let mut code = Vec::with_capacity(self.m);
173        for sub in 0..self.m {
174            let offset = sub * self.sub_dim;
175            let sub_vec = &vector[offset..offset + self.sub_dim];
176            let nearest = self.nearest_centroid(sub, sub_vec);
177            code.push(nearest as u8);
178        }
179        code
180    }
181
182    /// Batch encode all vectors into a contiguous byte array.
183    ///
184    /// Charges `m * vectors.len()` bytes to the bound budget (if set)
185    /// before allocating the output buffer.  The guard is released at
186    /// the end of this call — the buffer itself remains alive.
187    pub fn encode_batch(&self, vectors: &[&[f32]]) -> Result<Vec<u8>, VectorError> {
188        for v in vectors {
189            check_dim(self.dim, v.len())?;
190        }
191        let capacity = self.m * vectors.len();
192        let _g = try_reserve_or_skip(&self.memory, capacity * size_of::<u8>())?;
193        let mut out = Vec::with_capacity(capacity);
194        for v in vectors {
195            out.extend(self.encode(v));
196        }
197        Ok(out)
198    }
199
200    /// Build an asymmetric distance table for a query vector.
201    ///
202    /// Returns `table[sub][centroid]` = distance from query's sub-vector
203    /// to each centroid. Pre-computing this table makes distance evaluation
204    /// O(M) per candidate instead of O(D).
205    ///
206    /// Charges `m * k * size_of::<f32>()` bytes to the bound budget (if set)
207    /// before allocating the table.
208    pub fn build_distance_table(&self, query: &[f32]) -> Result<Vec<Vec<f32>>, VectorError> {
209        check_dim(self.dim, query.len())?;
210        let total_bytes = self.m * self.k * size_of::<f32>();
211        let _g = try_reserve_or_skip(&self.memory, total_bytes)?;
212        let mut table = Vec::with_capacity(self.m);
213        for sub in 0..self.m {
214            let offset = sub * self.sub_dim;
215            let sub_query = &query[offset..offset + self.sub_dim];
216            let mut dists = Vec::with_capacity(self.k);
217            for centroid in &self.codebooks[sub] {
218                let d = l2_sub(sub_query, centroid);
219                dists.push(d);
220            }
221            table.push(dists);
222        }
223        Ok(table)
224    }
225
226    /// Compute asymmetric distance using a precomputed distance table.
227    ///
228    /// O(M) per candidate — just M table lookups and additions.
229    #[inline]
230    pub fn asymmetric_distance(&self, table: &[Vec<f32>], code: &[u8]) -> f32 {
231        debug_assert_eq!(code.len(), self.m);
232        let mut dist = 0.0f32;
233        for (sub, &c) in code.iter().enumerate() {
234            dist += table[sub][c as usize];
235        }
236        dist
237    }
238
239    /// Decode a PQ code back to an approximate FP32 vector.
240    ///
241    /// Charges `dim * size_of::<f32>()` bytes to the bound budget (if set)
242    /// before allocating the output buffer.
243    pub fn decode(&self, code: &[u8]) -> Result<Vec<f32>, VectorError> {
244        self.validate_shape()?;
245        let decoded_dim = self
246            .m
247            .checked_mul(self.sub_dim)
248            .filter(|&value| value == self.dim && value <= MAX_PQ_DECODE_DIM)
249            .ok_or(VectorError::StoredDimensionMismatch {
250                expected: self.dim,
251                got: 0,
252            })?;
253        if code.len() != self.m {
254            return Err(VectorError::StoredDimensionMismatch {
255                expected: self.m,
256                got: code.len(),
257            });
258        }
259        if code.iter().any(|&index| usize::from(index) >= self.k) {
260            return Err(VectorError::DeserializationFailed(
261                "PQ code contains an out-of-range centroid index".to_string(),
262            ));
263        }
264        let output_capacity = checked_decode_capacity(
265            decoded_dim,
266            size_of::<f32>(),
267            0,
268            0,
269            MAX_PQ_DECODE_DIM,
270            MAX_PQ_DECODE_DIM * size_of::<f32>(),
271        )
272        .ok_or(VectorError::StoredDimensionMismatch {
273            expected: self.dim,
274            got: 0,
275        })?;
276        let allocation_bytes = output_capacity * size_of::<f32>();
277        let _g = try_reserve_or_skip(&self.memory, allocation_bytes)?;
278        let mut out = Vec::with_capacity(output_capacity);
279        for (sub, &c) in code.iter().enumerate() {
280            out.extend_from_slice(&self.codebooks[sub][c as usize]);
281        }
282        Ok(out)
283    }
284
285    /// Serialize the codec to bytes with a versioned magic header.
286    ///
287    /// Format: `[NDPQ\0\0 (6 bytes)][version: u8 = 1][msgpack payload]`
288    ///
289    /// Charges the estimated serialized size to the bound budget (if set) before
290    /// allocating the output buffer.  The estimate is conservative:
291    /// `m * k * sub_dim * size_of::<f32>() + 64` (header + framing overhead).
292    pub fn to_bytes(&self) -> Result<Vec<u8>, VectorError> {
293        self.validate_shape()?;
294        const MAGIC: &[u8; 6] = b"NDPQ\0\0";
295        const VERSION: u8 = 1;
296        let estimated = self.m * self.k * self.sub_dim * size_of::<f32>() + 64;
297        let _g = try_reserve_or_skip(&self.memory, estimated)?;
298        let data = PqCodecData {
299            dim: self.dim,
300            m: self.m,
301            k: self.k,
302            sub_dim: self.sub_dim,
303            codebooks: self.codebooks.clone(),
304        };
305        let payload = zerompk::to_msgpack_vec(&data).map_err(|e| {
306            VectorError::CheckpointSerializationError {
307                detail: format!("PQ codec encode: {e}"),
308            }
309        })?;
310        let mut out = Vec::with_capacity(7 + payload.len());
311        out.extend_from_slice(MAGIC);
312        out.push(VERSION);
313        out.extend_from_slice(&payload);
314        Ok(out)
315    }
316
317    /// Deserialize the codec from bytes produced by [`Self::to_bytes`].
318    ///
319    /// Returns `VectorError::InvalidMagic` if the header does not match
320    /// `NDPQ\0\0`, and `VectorError::UnsupportedVersion` for unknown versions.
321    pub fn from_bytes(bytes: &[u8], memory: ScopedMemory) -> Result<Self, VectorError> {
322        const MAGIC: &[u8; 6] = b"NDPQ\0\0";
323        const PQ_FORMAT_VERSION: u8 = 1;
324
325        if bytes.len() < 7 || &bytes[0..6] != MAGIC {
326            return Err(VectorError::InvalidMagic);
327        }
328        let version = bytes[6];
329        if version != PQ_FORMAT_VERSION {
330            return Err(VectorError::UnsupportedVersion {
331                found: version,
332                expected: PQ_FORMAT_VERSION,
333            });
334        }
335        let payload = &bytes[7..];
336        if payload.len() > MAX_PQ_SERIALIZED_BYTES {
337            return Err(VectorError::DeserializationFailed(
338                "PQ codec payload exceeds the decode limit".to_string(),
339            ));
340        }
341        super::pq_decode::preflight_pq_payload(payload, MAX_PQ_DECODE_DIM, MAX_PQ_CODEBOOK_BYTES)?;
342        let data = zerompk::from_msgpack::<PqCodecData>(payload)
343            .map_err(|e| VectorError::DeserializationFailed(e.to_string()))?;
344        let codec = Self {
345            dim: data.dim,
346            m: data.m,
347            k: data.k,
348            sub_dim: data.sub_dim,
349            codebooks: data.codebooks,
350            memory,
351        };
352        codec.validate_shape()?;
353        Ok(codec)
354    }
355
356    fn validate_shape(&self) -> Result<(), VectorError> {
357        let valid_scalar_shape = self.dim > 0
358            && self.m > 0
359            && self.k > 0
360            && self.k <= usize::from(u8::MAX) + 1
361            && self.sub_dim > 0
362            && self
363                .m
364                .checked_mul(self.sub_dim)
365                .is_some_and(|dim| dim == self.dim && dim <= MAX_PQ_DECODE_DIM);
366        let codebook_bytes = pq_codebook_allocation_bytes(self.m, self.k, self.sub_dim);
367        let valid_codebooks = self.codebooks.len() == self.m
368            && self.codebooks.iter().all(|book| {
369                book.len() == self.k && book.iter().all(|centroid| centroid.len() == self.sub_dim)
370            });
371        if !valid_scalar_shape
372            || codebook_bytes.is_none_or(|bytes| bytes > MAX_PQ_CODEBOOK_BYTES)
373            || !valid_codebooks
374        {
375            return Err(VectorError::DeserializationFailed(
376                "invalid PQ codec shape".to_string(),
377            ));
378        }
379        Ok(())
380    }
381
382    fn nearest_centroid(&self, subspace: usize, sub_vec: &[f32]) -> usize {
383        let mut best_idx = 0;
384        let mut best_dist = f32::MAX;
385        for (i, centroid) in self.codebooks[subspace].iter().enumerate() {
386            let d = l2_sub(sub_vec, centroid);
387            if d < best_dist {
388                best_dist = d;
389                best_idx = i;
390            }
391        }
392        best_idx
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::test_support::test_memory;
400
401    fn assert_invalid(result: Result<PqCodec, VectorError>) {
402        assert!(
403            matches!(result, Err(VectorError::InvalidInput { .. })),
404            "expected InvalidInput"
405        );
406    }
407
408    #[test]
409    fn train_rejects_a_vector_of_the_wrong_dimension() {
410        let good = [0.0_f32, 1.0];
411        let short = [0.0_f32];
412        let result = PqCodec::train(&[&good, &short], 2, 1, 1, 1, test_memory());
413        assert!(matches!(
414            result,
415            Err(VectorError::DimensionMismatch {
416                expected: 2,
417                got: 1
418            })
419        ));
420    }
421
422    #[test]
423    fn distance_table_rejects_a_query_of_the_wrong_dimension() {
424        let a = [0.0_f32, 1.0];
425        let b = [1.0_f32, 0.0];
426        let codec = PqCodec::train(&[&a, &b], 2, 1, 2, 1, test_memory()).unwrap();
427        assert!(matches!(
428            codec.build_distance_table(&[1.0]),
429            Err(VectorError::DimensionMismatch {
430                expected: 2,
431                got: 1
432            })
433        ));
434        assert!(matches!(
435            codec.encode_batch(&[&[1.0]]),
436            Err(VectorError::DimensionMismatch {
437                expected: 2,
438                got: 1
439            })
440        ));
441    }
442
443    fn make_clustered_data() -> Vec<Vec<f32>> {
444        // 4 clusters in 4D space, 50 points each.
445        let mut vecs = Vec::new();
446        for cluster in 0..4 {
447            let center = cluster as f32 * 10.0;
448            for i in 0..50 {
449                vecs.push(vec![
450                    center + (i as f32) * 0.1,
451                    center + (i as f32) * 0.05,
452                    center - (i as f32) * 0.1,
453                    center + (i as f32) * 0.02,
454                ]);
455            }
456        }
457        vecs
458    }
459
460    #[test]
461    fn train_rejects_dimension_above_decode_limit() {
462        let vector = [0.0];
463        assert_invalid(PqCodec::train(
464            &[&vector],
465            MAX_PQ_DECODE_DIM + 1,
466            1,
467            1,
468            1,
469            test_memory(),
470        ));
471    }
472
473    #[test]
474    fn train_rejects_raw_64_mib_codebook_once_container_overhead_is_counted() {
475        let vector = [0.0];
476        let vectors = vec![vector.as_slice(); 256];
477        assert_invalid(PqCodec::train(&vectors, 65_536, 1, 256, 1, test_memory()));
478    }
479
480    #[test]
481    fn train_rejects_codebook_above_decode_limit() {
482        let vector = [0.0];
483        let vectors = vec![vector.as_slice(); 17];
484        assert_invalid(PqCodec::train(
485            &vectors,
486            MAX_PQ_DECODE_DIM,
487            1,
488            17,
489            1,
490            test_memory(),
491        ));
492    }
493
494    #[test]
495    fn train_rejects_more_centroids_than_training_vectors() {
496        let vector = [0.0];
497        assert_invalid(PqCodec::train(&[&vector], 1, 1, 2, 1, test_memory()));
498    }
499
500    #[test]
501    fn train_rejects_centroid_count_above_u8_encoding_range() {
502        let vector = [0.0];
503        assert_invalid(PqCodec::train(&[&vector], 1, 1, 257, 1, test_memory()));
504    }
505
506    #[test]
507    fn decode_rejects_unbounded_configured_dimension_before_allocation() {
508        let codec = PqCodec {
509            dim: MAX_PQ_DECODE_DIM + 1,
510            m: 1,
511            k: 1,
512            sub_dim: MAX_PQ_DECODE_DIM + 1,
513            codebooks: vec![vec![vec![]]],
514            memory: test_memory(),
515        };
516        assert!(codec.decode(&[0]).is_err());
517    }
518
519    #[test]
520    fn from_bytes_rejects_oversized_outer_array_before_allocation() {
521        // PqCodec's zerompk representation is a five-element array:
522        // dim, m, k, sub_dim, codebooks.
523        let mut payload = vec![0x95, 1, 1, 1, 1, 0xdd];
524        payload.extend_from_slice(&u32::try_from(MAX_PQ_DECODE_DIM + 1).unwrap().to_be_bytes());
525        let mut bytes = b"NDPQ\0\0\x01".to_vec();
526        bytes.extend_from_slice(&payload);
527        assert!(matches!(
528            PqCodec::from_bytes(&bytes, test_memory()),
529            Err(VectorError::DeserializationFailed(_))
530        ));
531    }
532
533    #[test]
534    fn from_bytes_rejects_malformed_codebook_shape() {
535        let malformed = PqCodecData {
536            dim: 4,
537            m: 2,
538            k: 1,
539            sub_dim: 2,
540            codebooks: vec![vec![vec![0.0, 0.0]]],
541        };
542        let payload = zerompk::to_msgpack_vec(&malformed).unwrap();
543        let mut bytes = b"NDPQ\0\0\x01".to_vec();
544        bytes.extend_from_slice(&payload);
545        assert!(matches!(
546            PqCodec::from_bytes(&bytes, test_memory()),
547            Err(VectorError::DeserializationFailed(_))
548        ));
549    }
550
551    #[test]
552    fn decode_rejects_out_of_range_centroid_index() {
553        let codec = PqCodec {
554            dim: 1,
555            m: 1,
556            k: 1,
557            sub_dim: 1,
558            codebooks: vec![vec![vec![0.0]]],
559            memory: test_memory(),
560        };
561        assert!(matches!(
562            codec.decode(&[1]),
563            Err(VectorError::DeserializationFailed(_))
564        ));
565    }
566
567    #[test]
568    fn encode_decode_roundtrip() {
569        let vecs = make_clustered_data();
570        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
571        let codec = PqCodec::train(&refs, 4, 2, 16, 10, test_memory()).unwrap();
572
573        for v in &vecs {
574            let code = codec.encode(v);
575            assert_eq!(code.len(), 2); // M=2 bytes
576            let decoded = codec.decode(&code).unwrap();
577            assert_eq!(decoded.len(), 4);
578        }
579    }
580
581    #[test]
582    fn distance_table_gives_correct_ordering() {
583        let vecs = make_clustered_data();
584        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
585        let codec = PqCodec::train(&refs, 4, 2, 16, 10, test_memory()).unwrap();
586
587        let codes: Vec<Vec<u8>> = vecs.iter().map(|v| codec.encode(v)).collect();
588        let query = &[5.0, 5.0, 5.0, 5.0];
589        let table = codec.build_distance_table(query).unwrap();
590
591        // Find nearest via PQ distance.
592        let mut pq_dists: Vec<(usize, f32)> = codes
593            .iter()
594            .enumerate()
595            .map(|(i, c)| (i, codec.asymmetric_distance(&table, c)))
596            .collect();
597        pq_dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
598
599        // Find nearest via exact L2.
600        let mut exact_dists: Vec<(usize, f32)> = vecs
601            .iter()
602            .enumerate()
603            .map(|(i, v)| (i, l2_sub(query, v)))
604            .collect();
605        exact_dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
606
607        // Top-5 from PQ should have significant overlap with exact top-10.
608        let pq_top: std::collections::HashSet<usize> = pq_dists[..5].iter().map(|x| x.0).collect();
609        let exact_top: std::collections::HashSet<usize> =
610            exact_dists[..10].iter().map(|x| x.0).collect();
611        let overlap = pq_top.intersection(&exact_top).count();
612        assert!(overlap >= 3, "PQ recall too low: {overlap}/5 in top-10");
613    }
614
615    #[test]
616    fn batch_encode() {
617        let vecs = make_clustered_data();
618        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
619        let codec = PqCodec::train(&refs, 4, 2, 16, 10, test_memory()).unwrap();
620
621        let batch = codec.encode_batch(&refs).unwrap();
622        assert_eq!(batch.len(), 2 * 200); // M=2, N=200
623    }
624
625    // golden format test — verifies the on-disk layout is stable.
626    #[test]
627    fn pq_codec_golden_format() {
628        let vecs = make_clustered_data();
629        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
630        let codec = PqCodec::train(&refs, 4, 2, 16, 10, test_memory()).unwrap();
631
632        let bytes = codec.to_bytes().unwrap();
633
634        // Magic header.
635        assert_eq!(&bytes[0..6], b"NDPQ\0\0", "magic mismatch");
636        // Version byte.
637        assert_eq!(bytes[6], 1u8, "version must be 1");
638        // The complete persisted envelope must pass bounded preflight and decode.
639        let restored =
640            PqCodec::from_bytes(&bytes, test_memory()).expect("persisted PQ codec must decode");
641        assert_eq!(restored.dim, codec.dim);
642        assert_eq!(restored.m, codec.m);
643    }
644
645    #[test]
646    fn pq_version_mismatch_returns_error() {
647        // Craft a header with magic correct but version = 0 (unsupported).
648        let mut crafted = b"NDPQ\0\0".to_vec();
649        crafted.push(0u8); // wrong version
650        crafted.extend_from_slice(b"\x80"); // minimal valid msgpack map
651
652        let err = PqCodec::from_bytes(&crafted, test_memory()).unwrap_err();
653        assert!(
654            matches!(
655                err,
656                VectorError::UnsupportedVersion {
657                    found: 0,
658                    expected: 1
659                }
660            ),
661            "expected UnsupportedVersion, got: {err:?}"
662        );
663    }
664
665    #[test]
666    fn pq_invalid_magic_returns_error() {
667        let bad: &[u8] = b"JUNK\0\0\x01some-payload";
668        let err = PqCodec::from_bytes(bad, test_memory()).unwrap_err();
669        assert!(
670            matches!(err, VectorError::InvalidMagic),
671            "expected InvalidMagic, got: {err:?}"
672        );
673    }
674}