Skip to main content

velesdb_core/quantization/
pq_persistence.rs

1//! Persistence layer for Product Quantization codebooks and rotation matrices.
2//!
3//! Provides atomic file I/O using postcard serialization with crash-safe
4//! write-then-rename semantics. Extracted from [`super::pq`] to isolate
5//! the storage concern from the core PQ algorithm.
6
7use crate::error::Error;
8use serde::{Deserialize, Serialize};
9
10use super::pq::ProductQuantizer;
11
12/// Maximum accepted size of a persisted PQ artifact (codebook or rotation).
13///
14/// A valid codebook is `num_subspaces * num_centroids * subspace_dim` f32s
15/// plus small metadata; with the trained bounds (`num_subspaces <= 64`,
16/// `num_centroids <= u16::MAX`, `subspace_dim` modest) this stays well under
17/// 256 MiB. The cap rejects absurd/hostile files before they are decoded into
18/// a multi-gigabyte allocation. (Addresses the alloc-cap concern of #897.)
19const MAX_PQ_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
20
21/// RF-2: Serializes `value` with postcard and atomically writes to `dir/filename`.
22///
23/// Write goes to `.tmp` suffix first, then renamed for crash safety.
24fn postcard_save_atomic<T: Serialize>(
25    dir: &std::path::Path,
26    filename: &str,
27    value: &T,
28    label: &str,
29) -> Result<(), Error> {
30    let data = postcard::to_allocvec(value).map_err(|e| {
31        Error::Io(std::io::Error::new(
32            std::io::ErrorKind::InvalidData,
33            format!("failed to serialize {label}: {e}"),
34        ))
35    })?;
36    let tmp_path = dir.join(format!("{filename}.tmp"));
37    let final_path = dir.join(filename);
38    std::fs::write(&tmp_path, &data)?;
39    std::fs::rename(&tmp_path, &final_path)?;
40    Ok(())
41}
42
43/// RF-2: Loads and deserializes a postcard file from `dir/filename`.
44///
45/// Returns `Ok(None)` when the file does not exist.
46fn postcard_load<T: for<'de> Deserialize<'de>>(
47    dir: &std::path::Path,
48    filename: &str,
49    label: &str,
50) -> Result<Option<T>, Error> {
51    let path = dir.join(filename);
52    if !path.exists() {
53        return Ok(None);
54    }
55    let file_len = std::fs::metadata(&path)?.len();
56    if file_len > MAX_PQ_ARTIFACT_BYTES {
57        return Err(Error::IndexCorrupted(format!(
58            "{label} file is {file_len} bytes, exceeds cap {MAX_PQ_ARTIFACT_BYTES}"
59        )));
60    }
61    let data = std::fs::read(&path)?;
62    let value: T = postcard::from_bytes(&data).map_err(|e| {
63        Error::Io(std::io::Error::new(
64            std::io::ErrorKind::InvalidData,
65            format!("failed to deserialize {label}: {e}"),
66        ))
67    })?;
68    Ok(Some(value))
69}
70
71/// Persistence methods for codebook and rotation matrix storage.
72impl ProductQuantizer {
73    /// Save trained codebook to `<dir>/codebook.pq` using postcard.
74    /// Uses atomic write (write to .tmp, then rename).
75    ///
76    /// # Errors
77    ///
78    /// Returns `Error::Io` if serialization or file I/O fails.
79    pub fn save_codebook(&self, dir: &std::path::Path) -> Result<(), Error> {
80        postcard_save_atomic(dir, "codebook.pq", self, "PQ codebook")
81    }
82
83    /// Load codebook from `<dir>/codebook.pq`. Returns `None` if file doesn't exist.
84    ///
85    /// The decoded quantizer is structurally validated ([`Self::validate_loaded`])
86    /// before being returned, so a corrupt or tampered codebook is rejected here
87    /// rather than producing out-of-bounds indexing during search.
88    ///
89    /// # Errors
90    ///
91    /// Returns `Error::Io` if deserialization or file I/O fails, or
92    /// `Error::IndexCorrupted` if the decoded codebook/rotation is inconsistent
93    /// or the file exceeds `MAX_PQ_ARTIFACT_BYTES`.
94    pub fn load_codebook(dir: &std::path::Path) -> Result<Option<Self>, Error> {
95        let Some(quantizer): Option<Self> = postcard_load(dir, "codebook.pq", "PQ codebook")?
96        else {
97            return Ok(None);
98        };
99        quantizer.validate_loaded()?;
100        Ok(Some(quantizer))
101    }
102
103    /// Save OPQ rotation matrix to `<dir>/rotation.opq` using postcard.
104    ///
105    /// # Errors
106    ///
107    /// Returns `Error::Io` if the rotation is `None`, serialization, or file I/O fails.
108    pub fn save_rotation(&self, dir: &std::path::Path) -> Result<(), Error> {
109        let rotation = self.rotation.as_ref().ok_or_else(|| {
110            Error::Io(std::io::Error::new(
111                std::io::ErrorKind::InvalidData,
112                "no rotation matrix to save",
113            ))
114        })?;
115        postcard_save_atomic(dir, "rotation.opq", rotation, "OPQ rotation")
116    }
117
118    /// Load OPQ rotation matrix from `<dir>/rotation.opq`. Returns `None` if file doesn't exist.
119    ///
120    /// # Errors
121    ///
122    /// Returns `Error::Io` if deserialization or file I/O fails.
123    pub fn load_rotation(dir: &std::path::Path) -> Result<Option<Vec<f32>>, Error> {
124        postcard_load(dir, "rotation.opq", "OPQ rotation")
125    }
126}