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}