Skip to main content

iqdb_ivf/
config.rs

1//! [`IvfConfig`] — the typed configuration consumed by
2//! [`iqdb_index::Index::new`] for [`crate::IvfIndex`].
3//!
4//! Mirrors the seed-carrying shape of `iqdb_hnsw::HnswConfig` so the
5//! determinism contract surfaces on the public type: identical
6//! `seed` + identical training sample → identical centroids. Use
7//! [`IvfConfig::default`] for the operating point or the builder-style
8//! `with_*` methods to override a single field.
9
10use iqdb_types::{IqdbError, Result};
11
12/// Default number of inverted-list partitions produced by k-means.
13///
14/// The spec heuristic is `sqrt(N)` (or `4 * sqrt(N)` for larger
15/// corpora). `256` is the operating point for `N ≈ 65_536`, which is
16/// the inflection point above which IVF starts to beat FlatIndex on
17/// real datasets. The default is conservative; tuning to a corpus
18/// happens at construction.
19const DEFAULT_N_CLUSTERS: usize = 256;
20
21/// Default number of clusters probed at query time.
22///
23/// `8` of `256` is a strong recall/latency baseline for the FAISS-style
24/// `n_probes ≈ sqrt(n_clusters)` heuristic. Probes are the latency knob
25/// queries reach for through [`crate::IvfIndex::set_n_probes`].
26const DEFAULT_N_PROBES: usize = 8;
27
28/// Default cap on training-sample size.
29///
30/// Above this the trainer subsamples deterministically via the seeded
31/// PRNG; smaller samples run faster and produce essentially identical
32/// centroids on real distributions. `65_536` is the canonical FAISS
33/// default and is large enough to over-sample the corpus when
34/// `n_clusters = 256`.
35const DEFAULT_TRAINING_SAMPLE_SIZE: usize = 65_536;
36
37/// Default seed for the k-means++ PRNG.
38///
39/// Same constant style as `HnswConfig`'s default so a project that pins
40/// one seed across the index family gets reproducible builds with
41/// minimal ceremony.
42const DEFAULT_SEED: u64 = 0xDEAD_BEEF_CAFE_F00D;
43
44/// Default IVF-PQ refine factor.
45///
46/// `4` is the standard FAISS production default. With `pq_refine_factor
47/// = 4`, the IVF-PQ search shortlists `4 × k` candidates by ADC and
48/// then exact-reranks them using the retained `Arc<[f32]>` vectors
49/// before returning top-`k`. Set to `0` to disable refine and return
50/// the pure ADC top-`k`. Ignored when [`IvfConfig::use_pq`] is `false`.
51const DEFAULT_PQ_REFINE_FACTOR: u32 = 4;
52
53/// Configuration for [`crate::IvfIndex`] construction (see
54/// [`iqdb_index::Index::new`]).
55///
56/// All fields have documented defaults; see the field-level docs and
57/// the crate `README.md` for the tradeoffs each one controls.
58///
59/// # Examples
60///
61/// ```
62/// use iqdb_ivf::IvfConfig;
63///
64/// let cfg = IvfConfig::default();
65/// assert_eq!(cfg.n_clusters, 256);
66/// assert_eq!(cfg.n_probes, 8);
67///
68/// let tuned = IvfConfig::default()
69///     .with_n_clusters(64)
70///     .with_n_probes(4)
71///     .with_seed(42);
72/// assert_eq!(tuned.n_clusters, 64);
73/// assert_eq!(tuned.n_probes, 4);
74/// assert_eq!(tuned.seed, 42);
75/// ```
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct IvfConfig {
78    /// Number of k-means partitions (inverted lists) the trainer
79    /// produces.
80    ///
81    /// Spec heuristic: `sqrt(N)` for moderate corpora, `4 * sqrt(N)`
82    /// for very large ones. Must be at least `1`. Default `256`.
83    pub n_clusters: usize,
84
85    /// Number of clusters searched at query time.
86    ///
87    /// Larger values raise recall at higher per-query cost. Must be
88    /// at least `1` and no greater than [`n_clusters`](Self::n_clusters).
89    /// Default `8`.
90    pub n_probes: usize,
91
92    /// Cap on the training sample passed to k-means.
93    ///
94    /// When the caller supplies more vectors than this, the trainer
95    /// subsamples down to this many via the seeded PRNG. Must be at
96    /// least `1`. Default `65_536`.
97    pub training_sample_size: usize,
98
99    /// Enable Product Quantization within each inverted list.
100    ///
101    /// When `true`, [`Self::pq_subvectors`] must be `Some(m)` with
102    /// `m >= 1` and `m | dim` at index-construction time. The IVF-PQ
103    /// branch trains a [`iqdb_quantize::ProductQuantizer`] over the
104    /// same working set used for the coarse k-means (plain-PQ), stores
105    /// a per-entry [`iqdb_quantize::PqCode`] alongside the retained
106    /// `Arc<[f32]>` vector, and scores intra-cluster candidates via
107    /// ADC. Supported metrics: `Euclidean`, `DotProduct`, `Manhattan`
108    /// — `Cosine` and `Hamming` are rejected at construction with
109    /// [`IqdbError::InvalidMetric`]. Defaults to `false` (IVF-Flat).
110    pub use_pq: bool,
111
112    /// Subvector count `M` for IVF-PQ.
113    ///
114    /// Required to be `Some(m)` with `m >= 1` and `m | dim` whenever
115    /// [`use_pq`](Self::use_pq) is `true`. Ignored when `use_pq` is
116    /// `false`. Each subvector compresses to one byte (`K = 256`),
117    /// so smaller `m` compresses harder at the cost of more
118    /// reconstruction error per code.
119    pub pq_subvectors: Option<usize>,
120
121    /// IVF-PQ refine factor.
122    ///
123    /// `0` disables refine: the search returns the pure ADC top-`k`.
124    /// `N >= 1` enables refine: the search shortlists `N × k`
125    /// candidates by ADC, then exact-reranks the shortlist using the
126    /// retained `Arc<[f32]>` vectors (same distance path as IVF-Flat,
127    /// same DotProduct sign convention) before returning top-`k`.
128    /// Default `4`. Ignored when [`use_pq`](Self::use_pq) is `false`.
129    /// Tunable at runtime via [`crate::IvfIndex::set_pq_refine_factor`].
130    pub pq_refine_factor: u32,
131
132    /// Seed for the internal SplitMix64 PRNG used by k-means++
133    /// initialization and by deterministic subsampling of the
134    /// training set.
135    ///
136    /// Identical `seed` + identical training sample → byte-identical
137    /// centroids on every platform. When [`use_pq`](Self::use_pq) is
138    /// `true`, the same seed flows into the PQ codebook trainer so
139    /// the per-subvector codebooks are also reproducible.
140    pub seed: u64,
141}
142
143impl IvfConfig {
144    /// Override `n_clusters`.
145    #[must_use]
146    pub fn with_n_clusters(mut self, n_clusters: usize) -> Self {
147        self.n_clusters = n_clusters;
148        self
149    }
150
151    /// Override `n_probes`.
152    #[must_use]
153    pub fn with_n_probes(mut self, n_probes: usize) -> Self {
154        self.n_probes = n_probes;
155        self
156    }
157
158    /// Override `training_sample_size`.
159    #[must_use]
160    pub fn with_training_sample_size(mut self, training_sample_size: usize) -> Self {
161        self.training_sample_size = training_sample_size;
162        self
163    }
164
165    /// Override `use_pq`.
166    ///
167    /// When `true`, [`Self::pq_subvectors`] must also be set; the
168    /// metric/dim divisibility checks happen at
169    /// [`IvfIndex::new_unconfigured`](iqdb_index::Index::new) time
170    /// when both `dim` and `metric` are known.
171    #[must_use]
172    pub fn with_use_pq(mut self, use_pq: bool) -> Self {
173        self.use_pq = use_pq;
174        self
175    }
176
177    /// Override `pq_subvectors`.
178    ///
179    /// Required to be `Some(m)` with `m >= 1` and `m | dim` whenever
180    /// [`use_pq`](Self::use_pq) is `true`; otherwise ignored.
181    #[must_use]
182    pub fn with_pq_subvectors(mut self, pq_subvectors: Option<usize>) -> Self {
183        self.pq_subvectors = pq_subvectors;
184        self
185    }
186
187    /// Override `pq_refine_factor`.
188    ///
189    /// `0` disables refine; `N >= 1` shortlists `N × k` candidates by
190    /// ADC and exact-reranks. Ignored when [`use_pq`](Self::use_pq) is
191    /// `false`.
192    #[must_use]
193    pub fn with_pq_refine_factor(mut self, pq_refine_factor: u32) -> Self {
194        self.pq_refine_factor = pq_refine_factor;
195        self
196    }
197
198    /// Override the PRNG seed.
199    #[must_use]
200    pub fn with_seed(mut self, seed: u64) -> Self {
201        self.seed = seed;
202        self
203    }
204
205    /// Validate the configuration.
206    ///
207    /// Called by [`IvfIndex::new`](iqdb_index::Index::new) before the
208    /// index is built.
209    /// The error variant is always [`IqdbError::InvalidConfig`] with a
210    /// short `&'static str` `reason` naming exactly which check failed,
211    /// so a caller can branch on the message or thread it into a log.
212    pub fn validate(&self) -> Result<()> {
213        if self.n_clusters == 0 {
214            return Err(IqdbError::InvalidConfig {
215                reason: "IvfConfig.n_clusters must be greater than zero",
216            });
217        }
218        if self.n_probes == 0 {
219            return Err(IqdbError::InvalidConfig {
220                reason: "IvfConfig.n_probes must be greater than zero",
221            });
222        }
223        if self.n_probes > self.n_clusters {
224            return Err(IqdbError::InvalidConfig {
225                reason: "IvfConfig.n_probes must be <= n_clusters",
226            });
227        }
228        if self.training_sample_size == 0 {
229            return Err(IqdbError::InvalidConfig {
230                reason: "IvfConfig.training_sample_size must be greater than zero",
231            });
232        }
233        if self.use_pq {
234            match self.pq_subvectors {
235                Some(m) if m >= 1 => {}
236                Some(_) => {
237                    return Err(IqdbError::InvalidConfig {
238                        reason: "IvfConfig.pq_subvectors must be >= 1 when use_pq = true",
239                    });
240                }
241                None => {
242                    return Err(IqdbError::InvalidConfig {
243                        reason: "IvfConfig.use_pq = true requires pq_subvectors = Some(_)",
244                    });
245                }
246            }
247            // The `m | dim` divisibility check and the metric guard
248            // (Cosine/Hamming → InvalidMetric) happen at
249            // `IvfIndex::new_unconfigured` time, where both `dim` and
250            // `metric` are known. `pq_refine_factor` is always legal
251            // (a `u32` can't be negative; `0` = no refine).
252        }
253        Ok(())
254    }
255}
256
257impl Default for IvfConfig {
258    fn default() -> Self {
259        Self {
260            n_clusters: DEFAULT_N_CLUSTERS,
261            n_probes: DEFAULT_N_PROBES,
262            training_sample_size: DEFAULT_TRAINING_SAMPLE_SIZE,
263            use_pq: false,
264            pq_subvectors: None,
265            pq_refine_factor: DEFAULT_PQ_REFINE_FACTOR,
266            seed: DEFAULT_SEED,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    #![allow(clippy::unwrap_used)]
274
275    use super::*;
276
277    #[test]
278    fn default_values_are_the_documented_operating_point() {
279        let cfg = IvfConfig::default();
280        assert_eq!(cfg.n_clusters, 256);
281        assert_eq!(cfg.n_probes, 8);
282        assert_eq!(cfg.training_sample_size, 65_536);
283        assert!(!cfg.use_pq);
284        assert_eq!(cfg.pq_subvectors, None);
285        assert_eq!(cfg.pq_refine_factor, 4);
286        assert_eq!(cfg.seed, 0xDEAD_BEEF_CAFE_F00D);
287    }
288
289    #[test]
290    fn with_helpers_compose() {
291        let cfg = IvfConfig::default()
292            .with_n_clusters(16)
293            .with_n_probes(4)
294            .with_training_sample_size(1_024)
295            .with_seed(42);
296        assert_eq!(cfg.n_clusters, 16);
297        assert_eq!(cfg.n_probes, 4);
298        assert_eq!(cfg.training_sample_size, 1_024);
299        assert_eq!(cfg.seed, 42);
300    }
301
302    #[test]
303    fn validate_accepts_defaults() {
304        assert!(IvfConfig::default().validate().is_ok());
305    }
306
307    #[test]
308    fn validate_rejects_zero_n_clusters() {
309        let err = IvfConfig::default()
310            .with_n_clusters(0)
311            .validate()
312            .unwrap_err();
313        match err {
314            IqdbError::InvalidConfig { reason } => {
315                assert!(reason.contains("n_clusters"));
316            }
317            other => panic!("expected InvalidConfig, got {other:?}"),
318        }
319    }
320
321    #[test]
322    fn validate_rejects_zero_n_probes() {
323        let err = IvfConfig::default()
324            .with_n_probes(0)
325            .validate()
326            .unwrap_err();
327        match err {
328            IqdbError::InvalidConfig { reason } => {
329                assert!(reason.contains("n_probes"));
330            }
331            other => panic!("expected InvalidConfig, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn validate_rejects_n_probes_exceeding_n_clusters() {
337        let err = IvfConfig::default()
338            .with_n_clusters(4)
339            .with_n_probes(8)
340            .validate()
341            .unwrap_err();
342        match err {
343            IqdbError::InvalidConfig { reason } => {
344                assert!(reason.contains("n_probes"));
345            }
346            other => panic!("expected InvalidConfig, got {other:?}"),
347        }
348    }
349
350    #[test]
351    fn validate_rejects_zero_training_sample_size() {
352        let err = IvfConfig::default()
353            .with_training_sample_size(0)
354            .validate()
355            .unwrap_err();
356        match err {
357            IqdbError::InvalidConfig { reason } => {
358                assert!(reason.contains("training_sample_size"));
359            }
360            other => panic!("expected InvalidConfig, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn validate_rejects_use_pq_true_without_pq_subvectors() {
366        let err = IvfConfig::default()
367            .with_use_pq(true)
368            .validate()
369            .unwrap_err();
370        match err {
371            IqdbError::InvalidConfig { reason } => {
372                assert!(reason.contains("pq_subvectors"));
373                assert!(reason.contains("Some"));
374            }
375            other => panic!("expected InvalidConfig, got {other:?}"),
376        }
377    }
378
379    #[test]
380    fn validate_rejects_use_pq_true_with_zero_pq_subvectors() {
381        let err = IvfConfig::default()
382            .with_use_pq(true)
383            .with_pq_subvectors(Some(0))
384            .validate()
385            .unwrap_err();
386        match err {
387            IqdbError::InvalidConfig { reason } => {
388                assert!(reason.contains("pq_subvectors"));
389                assert!(reason.contains(">= 1"));
390            }
391            other => panic!("expected InvalidConfig, got {other:?}"),
392        }
393    }
394
395    #[test]
396    fn validate_accepts_use_pq_true_with_valid_pq_subvectors() {
397        // The `m | dim` check moves to `IvfIndex::new_unconfigured`,
398        // so config-level validate accepts any `Some(m >= 1)`.
399        let cfg = IvfConfig::default()
400            .with_use_pq(true)
401            .with_pq_subvectors(Some(8));
402        assert!(cfg.validate().is_ok());
403    }
404
405    #[test]
406    fn validate_accepts_pq_refine_factor_zero() {
407        let cfg = IvfConfig::default()
408            .with_use_pq(true)
409            .with_pq_subvectors(Some(8))
410            .with_pq_refine_factor(0);
411        assert!(cfg.validate().is_ok());
412    }
413
414    #[test]
415    fn with_pq_refine_factor_sets_field() {
416        let cfg = IvfConfig::default().with_pq_refine_factor(16);
417        assert_eq!(cfg.pq_refine_factor, 16);
418    }
419}