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