1use 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
26const MAX_PQ_DECODE_DIM: usize = 1_048_576;
29const MAX_PQ_CODEBOOK_BYTES: usize = 64 * 1024 * 1024;
30const 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#[inline]
50fn try_reserve_or_skip(
51 memory: &ScopedMemory,
52 bytes: usize,
53) -> Result<ReservationToken, VectorError> {
54 Ok(memory.reserve(bytes)?)
55}
56
57#[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#[derive(Clone, Debug)]
70pub struct PqCodec {
71 pub dim: usize,
73 pub m: usize,
75 pub k: usize,
77 pub sub_dim: usize,
79 codebooks: Vec<Vec<Vec<f32>>>,
82
83 memory: ScopedMemory,
89}
90
91impl PqCodec {
92 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 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 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 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 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 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 #[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 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 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 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 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 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); 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 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 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 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); }
624
625 #[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 assert_eq!(&bytes[0..6], b"NDPQ\0\0", "magic mismatch");
636 assert_eq!(bytes[6], 1u8, "version must be 1");
638 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 let mut crafted = b"NDPQ\0\0".to_vec();
649 crafted.push(0u8); crafted.extend_from_slice(b"\x80"); 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}