Skip to main content

slate_core/
config.rs

1//! Build, search, and storage configuration.
2//!
3//! These structs are the tunable surface of the engine. They derive
4//! `Serialize`/`Deserialize` because the resolved [`BuildConfig`] is persisted
5//! verbatim into the on-disk metadata file, making an index self-describing.
6
7use serde::{Deserialize, Serialize};
8
9use crate::dtype::Dtype;
10use crate::error::{Error, Result};
11use crate::metric::Metric;
12
13/// Which approximate-nearest-neighbor backend to build.
14///
15/// Both backends sit behind the same index trait and share the two-level
16/// hybrid search; this only selects the graph/partition structure.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
18pub enum IndexBackend {
19    /// Hierarchical Navigable Small World graph with high-degree-preserving
20    /// pruning. Best recall/latency tradeoff; the default.
21    #[default]
22    Hnsw,
23    /// Inverted file (k-means) partitioning. Naturally sequential per-list
24    /// reads make it friendly to spinning disks.
25    Ivf,
26}
27
28/// Storage device profile, used to pick I/O strategy and `madvise` hints.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
30pub enum IoProfile {
31    /// Spinning disk: favor large sequential `pread`s and the elevator
32    /// scheduler; avoid page-fault-driven 4KB random reads.
33    Hdd,
34    /// Solid-state: random reads are cheap; mmap page faults are fine.
35    Ssd,
36    /// Detect at open time, defaulting to the more conservative `Hdd` strategy
37    /// when unsure.
38    #[default]
39    Auto,
40}
41
42/// On-disk vector storage parameters.
43#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
44pub struct StorageParams {
45    /// Element type for exact vectors on disk (controls read volume/precision).
46    pub dtype: Dtype,
47    /// Disk block size in bytes. Large by default (64 KiB) so each seek on a
48    /// spinning disk amortizes over many co-located vectors.
49    pub block_size: usize,
50    /// Device profile controlling I/O strategy.
51    pub io_profile: IoProfile,
52}
53
54impl Default for StorageParams {
55    fn default() -> Self {
56        Self {
57            dtype: Dtype::F32,
58            block_size: 64 * 1024,
59            io_profile: IoProfile::Auto,
60        }
61    }
62}
63
64impl StorageParams {
65    /// Validate the storage parameters.
66    pub fn validate(&self) -> Result<()> {
67        if self.block_size < 512 {
68            return Err(Error::invalid_config(format!(
69                "block_size must be >= 512 bytes, got {}",
70                self.block_size
71            )));
72        }
73        if !self.block_size.is_power_of_two() {
74            return Err(Error::invalid_config(format!(
75                "block_size must be a power of two, got {}",
76                self.block_size
77            )));
78        }
79        Ok(())
80    }
81}
82
83/// Product-quantization parameters for the RAM-resident approximate tier.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub struct PqParams {
86    /// Number of subquantizers (subspaces). The vector dimensionality must be
87    /// divisible by this value.
88    pub num_subquantizers: usize,
89    /// Bits per subspace code. `8` gives the standard 256-centroid codebook.
90    pub bits_per_code: u8,
91}
92
93impl Default for PqParams {
94    fn default() -> Self {
95        Self {
96            num_subquantizers: 16,
97            bits_per_code: 8,
98        }
99    }
100}
101
102impl PqParams {
103    /// Number of centroids per subspace (`2^bits_per_code`).
104    #[inline]
105    pub const fn centroids_per_subspace(&self) -> usize {
106        1usize << self.bits_per_code
107    }
108
109    /// Validate the PQ parameters in isolation (divisibility against the vector
110    /// dimensionality is checked by [`BuildConfig::validate`]).
111    pub fn validate(&self) -> Result<()> {
112        if self.num_subquantizers == 0 {
113            return Err(Error::invalid_config("num_subquantizers must be >= 1"));
114        }
115        if self.bits_per_code == 0 || self.bits_per_code > 8 {
116            return Err(Error::invalid_config(format!(
117                "bits_per_code must be in 1..=8, got {}",
118                self.bits_per_code
119            )));
120        }
121        Ok(())
122    }
123}
124
125/// HNSW construction parameters, including LEANN-style high-degree-preserving
126/// pruning.
127///
128/// Most nodes are capped at out-degree `m`; the top `hub_fraction` of nodes by
129/// degree ("navigation hubs") may grow to `m_max`. Every node is additionally
130/// allowed to form bidirectional links with newly inserted nodes up to `m_max`,
131/// which preserves navigability toward hubs even for low-degree nodes.
132#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
133pub struct HnswParams {
134    /// Base out-degree cap for ordinary nodes (LEANN `m`).
135    pub m: usize,
136    /// Maximum out-degree for hub nodes and new-node links (LEANN `M`).
137    pub m_max: usize,
138    /// Candidate-list size during construction (`ef_construction`).
139    pub ef_construction: usize,
140    /// Fraction of nodes preserved as high-degree hubs (LEANN `beta`, ~0.02).
141    pub hub_fraction: f32,
142}
143
144impl Default for HnswParams {
145    fn default() -> Self {
146        Self {
147            m: 8,
148            m_max: 32,
149            ef_construction: 200,
150            hub_fraction: 0.02,
151        }
152    }
153}
154
155impl HnswParams {
156    /// Validate the HNSW parameters.
157    pub fn validate(&self) -> Result<()> {
158        if self.m == 0 {
159            return Err(Error::invalid_config("hnsw.m must be >= 1"));
160        }
161        if self.m_max < self.m {
162            return Err(Error::invalid_config(format!(
163                "hnsw.m_max ({}) must be >= hnsw.m ({})",
164                self.m_max, self.m
165            )));
166        }
167        if self.ef_construction == 0 {
168            return Err(Error::invalid_config("hnsw.ef_construction must be >= 1"));
169        }
170        if !(0.0..=1.0).contains(&self.hub_fraction) {
171            return Err(Error::invalid_config(format!(
172                "hnsw.hub_fraction must be in [0, 1], got {}",
173                self.hub_fraction
174            )));
175        }
176        Ok(())
177    }
178}
179
180/// IVF (inverted file / k-means) construction parameters.
181#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
182pub struct IvfParams {
183    /// Number of coarse centroids / inverted lists (`k`).
184    pub num_lists: usize,
185    /// Lists probed per query (`nprobe`).
186    pub num_probes: usize,
187    /// Number of nearest lists each vector is assigned to (LEANN soft
188    /// assignment uses 2 for cross-list connectivity).
189    pub soft_assign: usize,
190    /// Maximum k-means iterations during training.
191    pub max_kmeans_iters: usize,
192}
193
194impl Default for IvfParams {
195    fn default() -> Self {
196        Self {
197            num_lists: 256,
198            num_probes: 16,
199            soft_assign: 2,
200            max_kmeans_iters: 25,
201        }
202    }
203}
204
205impl IvfParams {
206    /// Validate the IVF parameters.
207    pub fn validate(&self) -> Result<()> {
208        if self.num_lists == 0 {
209            return Err(Error::invalid_config("ivf.num_lists must be >= 1"));
210        }
211        if self.num_probes == 0 || self.num_probes > self.num_lists {
212            return Err(Error::invalid_config(format!(
213                "ivf.num_probes must be in 1..={}, got {}",
214                self.num_lists, self.num_probes
215            )));
216        }
217        if self.soft_assign == 0 || self.soft_assign > self.num_lists {
218            return Err(Error::invalid_config(format!(
219                "ivf.soft_assign must be in 1..={}, got {}",
220                self.num_lists, self.soft_assign
221            )));
222        }
223        if self.max_kmeans_iters == 0 {
224            return Err(Error::invalid_config("ivf.max_kmeans_iters must be >= 1"));
225        }
226        Ok(())
227    }
228}
229
230/// Top-level configuration for building an index.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct BuildConfig {
233    /// Vector dimensionality. Must be set (> 0) and divisible by
234    /// `pq.num_subquantizers`.
235    pub dimensions: usize,
236    /// Distance metric.
237    pub metric: Metric,
238    /// Which ANN backend to build.
239    pub backend: IndexBackend,
240    /// Disk storage parameters.
241    pub storage: StorageParams,
242    /// Product-quantization parameters (RAM approximate tier).
243    pub pq: PqParams,
244    /// HNSW parameters (used when `backend == Hnsw`).
245    pub hnsw: HnswParams,
246    /// IVF parameters (used when `backend == Ivf`).
247    pub ivf: IvfParams,
248    /// Number of shards for the storage-efficient sharded build pipeline.
249    /// `1` disables sharding.
250    pub num_shards: usize,
251}
252
253impl Default for BuildConfig {
254    fn default() -> Self {
255        Self {
256            dimensions: 0,
257            metric: Metric::L2,
258            backend: IndexBackend::Hnsw,
259            storage: StorageParams::default(),
260            pq: PqParams::default(),
261            hnsw: HnswParams::default(),
262            ivf: IvfParams::default(),
263            num_shards: 1,
264        }
265    }
266}
267
268impl BuildConfig {
269    /// Create a build configuration with the given dimensionality, metric, and
270    /// backend, leaving all other parameters at their defaults.
271    pub fn new(dimensions: usize, metric: Metric, backend: IndexBackend) -> Self {
272        Self {
273            dimensions,
274            metric,
275            backend,
276            ..Self::default()
277        }
278    }
279
280    /// Validate the entire configuration, including cross-field constraints.
281    pub fn validate(&self) -> Result<()> {
282        if self.dimensions == 0 {
283            return Err(Error::invalid_config("dimensions must be set (> 0)"));
284        }
285        self.storage.validate()?;
286        self.pq.validate()?;
287        self.hnsw.validate()?;
288        self.ivf.validate()?;
289        if !self.dimensions.is_multiple_of(self.pq.num_subquantizers) {
290            return Err(Error::invalid_config(format!(
291                "dimensions ({}) must be divisible by pq.num_subquantizers ({})",
292                self.dimensions, self.pq.num_subquantizers
293            )));
294        }
295        if self.num_shards == 0 {
296            return Err(Error::invalid_config("num_shards must be >= 1"));
297        }
298        Ok(())
299    }
300}
301
302/// Per-query search configuration.
303#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304pub struct SearchConfig {
305    /// Number of nearest neighbors to return.
306    pub k: usize,
307    /// Search-time candidate-list size (`ef`); the primary recall/latency knob.
308    pub ef_search: usize,
309    /// Re-ranking ratio `alpha`: the top fraction of the approximate queue whose
310    /// exact vectors are fetched from disk and re-ranked each step.
311    pub rerank_ratio: f32,
312    /// Number of exact-vector fetches accumulated before issuing a batched,
313    /// seek-ordered disk read (amortizes HDD seeks across exploration steps).
314    pub fetch_batch_size: usize,
315}
316
317impl Default for SearchConfig {
318    fn default() -> Self {
319        Self {
320            k: 10,
321            ef_search: 64,
322            rerank_ratio: 0.2,
323            fetch_batch_size: 64,
324        }
325    }
326}
327
328impl SearchConfig {
329    /// Validate the search configuration.
330    pub fn validate(&self) -> Result<()> {
331        if self.k == 0 {
332            return Err(Error::invalid_config("k must be >= 1"));
333        }
334        if self.ef_search < self.k {
335            return Err(Error::invalid_config(format!(
336                "ef_search ({}) must be >= k ({})",
337                self.ef_search, self.k
338            )));
339        }
340        if !(self.rerank_ratio > 0.0 && self.rerank_ratio <= 1.0) {
341            return Err(Error::invalid_config(format!(
342                "rerank_ratio must be in (0, 1], got {}",
343                self.rerank_ratio
344            )));
345        }
346        if self.fetch_batch_size == 0 {
347            return Err(Error::invalid_config("fetch_batch_size must be >= 1"));
348        }
349        Ok(())
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    // Several negative tests deliberately mutate one field of a defaulted
356    // struct and re-validate between mutations; struct-update syntax does not
357    // fit that pattern cleanly.
358    #![allow(clippy::field_reassign_with_default)]
359
360    use super::*;
361
362    #[test]
363    fn default_build_config_requires_dimensions() {
364        let cfg = BuildConfig::default();
365        assert!(cfg.validate().is_err(), "dimensions=0 must be rejected");
366    }
367
368    #[test]
369    fn valid_build_config_passes() {
370        let cfg = BuildConfig::new(768, Metric::Cosine, IndexBackend::Hnsw);
371        assert!(cfg.validate().is_ok(), "{:?}", cfg.validate());
372    }
373
374    #[test]
375    fn pq_divisibility_enforced() {
376        let mut cfg = BuildConfig::new(770, Metric::L2, IndexBackend::Hnsw);
377        cfg.pq.num_subquantizers = 16; // 770 % 16 != 0
378        assert!(cfg.validate().is_err());
379    }
380
381    #[test]
382    fn pq_centroid_count() {
383        assert_eq!(PqParams::default().centroids_per_subspace(), 256);
384    }
385
386    #[test]
387    fn storage_block_size_must_be_pow2() {
388        let mut sp = StorageParams::default();
389        sp.block_size = 1000;
390        assert!(sp.validate().is_err());
391        sp.block_size = 4096;
392        assert!(sp.validate().is_ok());
393    }
394
395    #[test]
396    fn hnsw_degree_ordering() {
397        let mut p = HnswParams::default();
398        p.m = 40;
399        p.m_max = 32;
400        assert!(p.validate().is_err());
401    }
402
403    #[test]
404    fn ivf_probe_bounds() {
405        let mut p = IvfParams::default();
406        p.num_probes = p.num_lists + 1;
407        assert!(p.validate().is_err());
408    }
409
410    #[test]
411    fn search_config_bounds() {
412        let mut s = SearchConfig::default();
413        assert!(s.validate().is_ok());
414        s.ef_search = 1;
415        s.k = 10;
416        assert!(s.validate().is_err());
417        s = SearchConfig::default();
418        s.rerank_ratio = 0.0;
419        assert!(s.validate().is_err());
420    }
421
422    #[test]
423    fn config_roundtrips_through_json() {
424        // Exercises serde wiring that the on-disk metadata format relies on.
425        let cfg = BuildConfig::new(128, Metric::InnerProduct, IndexBackend::Ivf);
426        let json = serde_json::to_string(&cfg).expect("serialize");
427        let back: BuildConfig = serde_json::from_str(&json).expect("deserialize");
428        assert_eq!(cfg, back);
429    }
430}