Skip to main content

fib_quant/
sidecar.rs

1//! Generic sidecar search index wrapping [`FibScorer`].
2//!
3//! `FibSidecarIndex<Id>` stores caller-owned IDs alongside encoded
4//! [`FibCodeV1`] artifacts and provides approximate nearest-inner-product
5//! search via the Gram-table estimator in [`FibScorer`]. The index is a
6//! *sidecar*: it produces approximate candidates that callers must rerank
7//! with an exact inner-product computation against the original (un-encoded)
8//! vectors before acting on the result.
9//!
10//! ## Model
11//!
12//! 1. The caller constructs a [`FibScorer`] once (which builds the codebook
13//!    Gram table).
14//! 2. The caller encodes vectors with [`FibQuantizer::encode`] and adds them
15//!    to the sidecar index via [`FibSidecarIndex::add`] / `add_batch`.
16//! 3. At query time, the caller passes a raw query slice. The sidecar calls
17//!    [`FibScorer::inner_product_estimate`] for every stored code, sorts the
18//!    results in descending order of approximate score, and returns the top
19//!    `top_k * oversample` candidates.
20//! 4. [`FibSidecarIndex::search_with_receipt`] additionally returns a
21//!    [`SearchReceiptV1`] documenting the approximate nature of the results.
22
23use std::time::Instant;
24
25use crate::{codec::FibCodeV1, profile::FibQuantProfileV1, scoring::FibScorer, Result};
26
27/// A scored candidate from approximate sidecar search.
28///
29/// `id` is the caller-owned identifier passed to [`FibSidecarIndex::add`].
30/// `approximate_score` is the Gram-table inner-product estimate — **not**
31/// the exact inner product. `rank` is zero-indexed; rank 0 is the
32/// highest-scoring candidate.
33#[derive(Debug, Clone, PartialEq)]
34pub struct ScoredCandidate<Id> {
35    /// Caller-owned identifier.
36    pub id: Id,
37    /// Approximate inner product estimate from the Gram table.
38    pub approximate_score: f32,
39    /// Zero-indexed rank (0 = best).
40    pub rank: usize,
41}
42
43/// Receipt documenting an approximate sidecar search.
44///
45/// This is deliberately lightweight compared to the turbo-quant analogue:
46/// the fib-quant sidecar does not own the original vectors or codebook
47/// digests beyond the profile, so the receipt captures only the operational
48/// parameters and timing of the search.
49#[derive(Debug, Clone, PartialEq)]
50pub struct SearchReceiptV1 {
51    /// Schema marker, always `"fib_sidecar_search_receipt_v1"`.
52    pub schema: String,
53    /// Number of encoded entries currently in the index.
54    pub indexed_count: usize,
55    /// Requested `top_k`.
56    pub top_k: usize,
57    /// Requested `oversample` factor.
58    pub oversample: usize,
59    /// Number of candidates actually returned (≤ `top_k * oversample`).
60    pub candidate_count: usize,
61    /// Always `true`: this is an approximate index.
62    pub approximate_only: bool,
63    /// Always `true`: callers must rerank with exact inner product.
64    pub exact_rerank_required: bool,
65    /// Elapsed time of the search in microseconds.
66    pub elapsed_micros: u128,
67}
68
69/// Receipt documenting an IVF-accelerated approximate sidecar search.
70///
71/// Extends [`SearchReceiptV1`] with IVF-specific metadata: number of
72/// centroids, cells probed, and entries scored.
73#[derive(Debug, Clone, PartialEq)]
74pub struct SearchReceiptIvfV1 {
75    /// Schema marker, always `"fib_sidecar_search_ivf_receipt_v1"`.
76    pub schema: String,
77    /// Number of encoded entries currently in the index.
78    pub indexed_count: usize,
79    /// Requested `top_k`.
80    pub top_k: usize,
81    /// Requested `oversample` factor.
82    pub oversample: usize,
83    /// Number of candidates actually returned (≤ `top_k * oversample`).
84    pub candidate_count: usize,
85    /// Always `true`: this is an approximate index.
86    pub approximate_only: bool,
87    /// Always `true`: callers must rerank with exact inner product.
88    pub exact_rerank_required: bool,
89    /// Elapsed time of the search in microseconds.
90    pub elapsed_micros: u128,
91    /// Number of centroids in the IVF coarse quantizer.
92    pub num_centroids: usize,
93    /// Number of cells (centroids) probed at query time.
94    pub nprobe: usize,
95    /// Number of entries that fell into the probed cells (actually scored).
96    pub entries_scored: usize,
97    /// Whether IVF was used (false if fallback to linear scan).
98    pub ivf_used: bool,
99}
100
101/// IVF (Inverted File) coarse quantizer for sub-linear search.
102///
103/// Stores k centroids and a mapping from each entry index to its
104/// nearest centroid. At query time, only the `nprobe` nearest cells
105/// to the query are scanned, reducing search from O(N) to O(N/k * nprobe).
106/// With k = sqrt(N) and nprobe constant, this is O(sqrt(N)).
107#[derive(Debug, Clone)]
108pub struct IvfCoarseQuantizer {
109    /// Centroid vectors (k × ambient_dim).
110    centroids: Vec<Vec<f32>>,
111    /// Which centroid each entry belongs to (length = number of entries).
112    assignments: Vec<usize>,
113    /// Default number of cells to probe at query time.
114    nprobe: usize,
115}
116
117impl IvfCoarseQuantizer {
118    /// Whether the IVF structure has been built (has centroids).
119    pub fn is_built(&self) -> bool {
120        !self.centroids.is_empty()
121    }
122
123    /// Number of centroids.
124    pub fn num_centroids(&self) -> usize {
125        self.centroids.len()
126    }
127
128    /// Default nprobe value.
129    pub fn nprobe(&self) -> usize {
130        self.nprobe
131    }
132
133    /// Set the default nprobe value.
134    pub fn set_nprobe(&mut self, nprobe: usize) {
135        self.nprobe = nprobe;
136    }
137
138    /// Return the assignments slice.
139    pub fn assignments(&self) -> &[usize] {
140        &self.assignments
141    }
142
143    /// Return the centroids slice.
144    pub fn centroids(&self) -> &[Vec<f32>] {
145        &self.centroids
146    }
147
148    /// Build inverted lists: for each centroid, the list of entry indices
149    /// assigned to it.
150    pub fn inverted_lists(&self) -> Vec<Vec<usize>> {
151        let k = self.centroids.len();
152        let mut lists: Vec<Vec<usize>> = vec![Vec::new(); k];
153        for (entry_idx, &centroid_idx) in self.assignments.iter().enumerate() {
154            if centroid_idx < k {
155                lists[centroid_idx].push(entry_idx);
156            }
157        }
158        lists
159    }
160}
161
162/// Generic ID-typed sidecar search index.
163///
164/// Wraps a [`FibScorer`] and stores a list of `(Id, FibCodeV1)` entries.
165/// Search uses the Gram-table estimator for each stored code and returns
166/// candidates sorted by descending approximate inner product.
167///
168/// The index is cheap to clone (via `Clone` on the scorer and entries) but
169/// in typical usage a single instance is shared by reference across queries.
170///
171/// ## Generics
172///
173/// `Id` must be `Clone + Eq + Debug`. Common choices are `u64`, `String`,
174/// or a newtype key. The index does not enforce uniqueness — adding the
175/// same ID twice will produce two entries.
176pub struct FibSidecarIndex<Id>
177where
178    Id: Clone + Eq + std::fmt::Debug,
179{
180    scorer: FibScorer,
181    entries: Vec<(Id, FibCodeV1)>,
182    profile: FibQuantProfileV1,
183    /// Optional IVF coarse quantizer for sub-linear search.
184    /// Built by [`build_ivf`](Self::build_ivf). When present and populated,
185    /// [`search_ivf`](Self::search_ivf) probes only `nprobe` cells.
186    ivf: Option<IvfCoarseQuantizer>,
187}
188
189impl<Id> FibSidecarIndex<Id>
190where
191    Id: Clone + Eq + std::fmt::Debug,
192{
193    /// Construct a new sidecar index from a [`FibScorer`].
194    ///
195    /// The scorer's profile is cloned for validation of incoming codes.
196    /// The index starts empty.
197    pub fn new(scorer: FibScorer) -> Self {
198        let profile = scorer.quantizer().profile().clone();
199        Self {
200            scorer,
201            entries: Vec::new(),
202            profile,
203            ivf: None,
204        }
205    }
206
207    /// Return a reference to the underlying scorer.
208    pub fn scorer(&self) -> &FibScorer {
209        &self.scorer
210    }
211
212    /// Return a reference to the profile used for validation.
213    pub fn profile(&self) -> &FibQuantProfileV1 {
214        &self.profile
215    }
216
217    /// Add a single encoded vector with a caller-owned ID.
218    ///
219    /// The code's `ambient_dim` and `block_dim` are checked against the
220    /// scorer's profile to catch mismatches early.
221    pub fn add(&mut self, id: Id, code: FibCodeV1) {
222        debug_assert!(
223            code.ambient_dim == self.profile.ambient_dim,
224            "code ambient_dim {} != profile {}",
225            code.ambient_dim,
226            self.profile.ambient_dim
227        );
228        debug_assert!(
229            code.block_dim == self.profile.block_dim,
230            "code block_dim {} != profile {}",
231            code.block_dim,
232            self.profile.block_dim
233        );
234        self.entries.push((id, code));
235    }
236
237    /// Add many encoded vectors at once.
238    ///
239    /// Equivalent to calling [`add`](Self::add) in a loop but avoids
240    /// repeated capacity growth.
241    pub fn add_batch(&mut self, entries: Vec<(Id, FibCodeV1)>) {
242        self.entries.reserve(entries.len());
243        for (id, code) in entries {
244            self.add(id, code);
245        }
246    }
247
248    /// Number of encoded entries currently stored.
249    pub fn len(&self) -> usize {
250        self.entries.len()
251    }
252
253    /// Return a slice of all `(Id, FibCodeV1)` entries.
254    ///
255    /// Used by [`persistence`](crate::persistence) to serialize the index
256    /// to disk. The slice borrows from the index.
257    pub fn entries(&self) -> &[(Id, FibCodeV1)] {
258        &self.entries
259    }
260
261    /// Whether the index is empty.
262    pub fn is_empty(&self) -> bool {
263        self.entries.is_empty()
264    }
265
266    // ------------------------------------------------------------------
267    // Internal search core
268    // ------------------------------------------------------------------
269
270    /// Run the approximate scoring loop and return sorted candidates
271    /// (without truncation). The returned vector has `self.entries.len()`
272    /// elements sorted by descending `approximate_score`, ties broken by
273    /// insertion order (stable sort).
274    fn score_all(&self, query: &[f32]) -> Result<Vec<(usize, f32)>> {
275        // Use prepared query: rotate+quantize the query ONCE, then
276        // do O(1) Gram table lookups per stored code. This is ~380x
277        // faster than calling inner_product_estimate per code (which
278        // re-rotates the query each time).
279        let prepared = self.scorer.prepare_query(query)?;
280        let mut scored: Vec<(usize, f32)> = Vec::with_capacity(self.entries.len());
281        for (idx, (_, code)) in self.entries.iter().enumerate() {
282            let s = self.scorer.score_prepared(&prepared, code)?;
283            scored.push((idx, s));
284        }
285        // Sort descending by score. Stable to preserve insertion order
286        // for equal scores.
287        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
288        Ok(scored)
289    }
290
291    /// Approximate search returning sorted candidates.
292    ///
293    /// Scores every stored code against the query, sorts by descending
294    /// approximate inner product, and returns the top `top_k * oversample`
295    /// candidates (or fewer if the index has fewer entries).
296    ///
297    /// The returned candidates have `rank` assigned starting from 0.
298    /// Callers **must** rerank these candidates with an exact inner product
299    /// against the original (un-encoded) vectors before acting on results.
300    pub fn search(
301        &self,
302        query: &[f32],
303        top_k: usize,
304        oversample: usize,
305    ) -> Result<Vec<ScoredCandidate<Id>>> {
306        let scored = self.score_all(query)?;
307        let limit = top_k.saturating_mul(oversample.max(1)).min(scored.len());
308        let candidates = scored
309            .into_iter()
310            .take(limit)
311            .enumerate()
312            .map(|(rank, (idx, score))| {
313                let id = self.entries[idx].0.clone();
314                ScoredCandidate {
315                    id,
316                    approximate_score: score,
317                    rank,
318                }
319            })
320            .collect();
321        Ok(candidates)
322    }
323
324    /// Approximate search returning both candidates and a [`SearchReceiptV1`].
325    ///
326    /// See [`search`](Self::search) for search semantics. The receipt
327    /// documents the operational parameters and timing of the search.
328    pub fn search_with_receipt(
329        &self,
330        query: &[f32],
331        top_k: usize,
332        oversample: usize,
333    ) -> Result<(Vec<ScoredCandidate<Id>>, SearchReceiptV1)> {
334        let started = Instant::now();
335        let candidates = self.search(query, top_k, oversample)?;
336        let elapsed = started.elapsed().as_micros();
337
338        let receipt = SearchReceiptV1 {
339            schema: "fib_sidecar_search_receipt_v1".to_string(),
340            indexed_count: self.entries.len(),
341            top_k,
342            oversample,
343            candidate_count: candidates.len(),
344            approximate_only: true,
345            exact_rerank_required: true,
346            elapsed_micros: elapsed,
347        };
348
349        Ok((candidates, receipt))
350    }
351
352    // ------------------------------------------------------------------
353    // IVF coarse quantizer
354    // ------------------------------------------------------------------
355
356    /// Return a reference to the IVF coarse quantizer, if built.
357    pub fn ivf(&self) -> Option<&IvfCoarseQuantizer> {
358        self.ivf.as_ref()
359    }
360
361    /// Whether the IVF coarse quantizer has been built.
362    pub fn ivf_is_built(&self) -> bool {
363        self.ivf.as_ref().is_some_and(|ivf| ivf.is_built())
364    }
365
366    /// Build the IVF coarse quantizer by running k-means on decoded vectors.
367    ///
368    /// Decodes all stored `FibCodeV1` entries to their approximate vectors
369    /// using the scorer's quantizer, runs k-means with `num_centroids`
370    /// centroids (default: sqrt(N)), and stores the centroids and
371    /// assignments. After this call, [`search_ivf`](Self::search_ivf)
372    /// will use the IVF path instead of falling back to linear scan.
373    ///
374    /// # Arguments
375    /// * `num_centroids` — number of k-means centroids (k). Pass
376    ///   `(self.len() as f64).sqrt() as usize` or let `build_ivf_default`
377    ///   compute it.
378    pub fn build_ivf(&mut self, num_centroids: usize) -> Result<()> {
379        let n = self.entries.len();
380        if n == 0 {
381            // Nothing to cluster — store an empty IVF so ivf_is_built() is true.
382            self.ivf = Some(IvfCoarseQuantizer {
383                centroids: Vec::new(),
384                assignments: Vec::new(),
385                nprobe: 8,
386            });
387            return Ok(());
388        }
389
390        let k = num_centroids.max(1).min(n);
391        let d = self.profile.ambient_dim as usize;
392
393        // Decode all stored codes to approximate vectors for k-means.
394        let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(n);
395        for (_, code) in &self.entries {
396            let v = self.scorer.quantizer().decode(code)?;
397            vectors.push(v);
398        }
399
400        // K-means initialization: pick k evenly spaced entries as initial
401        // centroids (deterministic, no RNG dependency).
402        let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
403        if k == 1 {
404            // Single centroid = mean of all vectors.
405            let mut mean = vec![0.0f32; d];
406            for v in &vectors {
407                for (mi, &vi) in mean.iter_mut().zip(v.iter()) {
408                    *mi += vi;
409                }
410            }
411            for m in &mut mean {
412                *m /= n as f32;
413            }
414            centroids.push(mean);
415        } else {
416            for i in 0..k {
417                let idx = (i * n) / k;
418                centroids.push(vectors[idx].clone());
419            }
420
421            // K-means iterations (Lloyd's algorithm).
422            const MAX_ITERS: usize = 20;
423            const CONVERGE_THRESHOLD: f32 = 1e-4;
424
425            let mut assignments = vec![0usize; n];
426            let mut sums: Vec<Vec<f64>> = vec![vec![0.0f64; d]; k];
427            let mut counts: Vec<usize> = vec![0; k];
428
429            for _iter in 0..MAX_ITERS {
430                // Assignment step: assign each vector to nearest centroid.
431                for (vi, v) in vectors.iter().enumerate() {
432                    let mut best_dist = f32::MAX;
433                    let mut best_c = 0;
434                    for (ci, c) in centroids.iter().enumerate() {
435                        // Squared L2 distance
436                        let mut dist = 0.0f32;
437                        for j in 0..d {
438                            let diff = v[j] - c[j];
439                            dist += diff * diff;
440                        }
441                        if dist < best_dist {
442                            best_dist = dist;
443                            best_c = ci;
444                        }
445                    }
446                    assignments[vi] = best_c;
447                }
448
449                // Update step: recompute centroids as means.
450                for s in sums.iter_mut() {
451                    for x in s.iter_mut() {
452                        *x = 0.0;
453                    }
454                }
455                counts.fill(0);
456
457                for (vi, v) in vectors.iter().enumerate() {
458                    let c = assignments[vi];
459                    counts[c] += 1;
460                    for j in 0..d {
461                        sums[c][j] += v[j] as f64;
462                    }
463                }
464
465                let mut total_shift = 0.0f32;
466                for ci in 0..k {
467                    if counts[ci] == 0 {
468                        // Empty cluster: reinitialize to a random-ish vector
469                        // (pick the furthest entry from its centroid).
470                        let mut furthest = 0;
471                        let mut max_dist = 0.0f32;
472                        for (vi, v) in vectors.iter().enumerate() {
473                            let c = assignments[vi];
474                            let mut dist = 0.0f32;
475                            for j in 0..d {
476                                let diff = v[j] - centroids[c][j];
477                                dist += diff * diff;
478                            }
479                            if dist > max_dist {
480                                max_dist = dist;
481                                furthest = vi;
482                            }
483                        }
484                        centroids[ci] = vectors[furthest].clone();
485                        assignments[furthest] = ci;
486                        continue;
487                    }
488                    let new_centroid: Vec<f32> = (0..d)
489                        .map(|j| (sums[ci][j] / counts[ci] as f64) as f32)
490                        .collect();
491                    // Compute shift
492                    for j in 0..d {
493                        let diff = new_centroid[j] - centroids[ci][j];
494                        total_shift += diff * diff;
495                    }
496                    centroids[ci] = new_centroid;
497                }
498
499                if total_shift < CONVERGE_THRESHOLD {
500                    break;
501                }
502            }
503        }
504
505        // Final assignment pass with the converged centroids.
506        let mut assignments = vec![0usize; n];
507        for (vi, v) in vectors.iter().enumerate() {
508            let mut best_dist = f32::MAX;
509            let mut best_c = 0;
510            for (ci, c) in centroids.iter().enumerate() {
511                let mut dist = 0.0f32;
512                for j in 0..d {
513                    let diff = v[j] - c[j];
514                    dist += diff * diff;
515                }
516                if dist < best_dist {
517                    best_dist = dist;
518                    best_c = ci;
519                }
520            }
521            assignments[vi] = best_c;
522        }
523
524        let nprobe = 8usize.min(k);
525        self.ivf = Some(IvfCoarseQuantizer {
526            centroids,
527            assignments,
528            nprobe,
529        });
530
531        Ok(())
532    }
533
534    /// Build IVF with default k = sqrt(N) centroids.
535    pub fn build_ivf_default(&mut self) -> Result<()> {
536        let k = (self.len() as f64).sqrt().round() as usize;
537        let k = k.max(1);
538        self.build_ivf(k)
539    }
540
541    /// Find the `nprobe` nearest centroids to the query (exact, small k).
542    /// Returns sorted (centroid_index, squared_distance) pairs.
543    fn nearest_centroids(&self, query: &[f32], nprobe: usize) -> Vec<(usize, f32)> {
544        let ivf = self.ivf.as_ref().expect("IVF must be built");
545        let d = self.profile.ambient_dim as usize;
546        let mut dists: Vec<(usize, f32)> = Vec::with_capacity(ivf.centroids.len());
547        for (ci, c) in ivf.centroids.iter().enumerate() {
548            let mut dist = 0.0f32;
549            for j in 0..d.min(query.len()).min(c.len()) {
550                let diff = query[j] - c[j];
551                dist += diff * diff;
552            }
553            dists.push((ci, dist));
554        }
555        dists.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
556        dists.into_iter().take(nprobe).collect()
557    }
558
559    /// IVF-accelerated search.
560    ///
561    /// Finds the `nprobe` nearest centroids to the query, gathers all
562    /// entries assigned to those centroids, scores them using the prepared
563    /// query (Gram table), and returns the top `top_k * oversample`
564    /// candidates sorted by descending approximate inner product.
565    ///
566    /// If IVF is not built (centroids empty or `ivf` is `None`), falls
567    /// back to a full linear scan via [`search`](Self::search).
568    pub fn search_ivf(
569        &self,
570        query: &[f32],
571        top_k: usize,
572        oversample: usize,
573        nprobe: usize,
574    ) -> Result<Vec<ScoredCandidate<Id>>> {
575        // Fallback to linear scan if IVF not built.
576        if !self.ivf_is_built() {
577            return self.search(query, top_k, oversample);
578        }
579
580        let ivf = self.ivf.as_ref().unwrap();
581        let nprobe_eff = nprobe.min(ivf.centroids.len()).max(1);
582
583        // Find nearest centroids.
584        let nearest = self.nearest_centroids(query, nprobe_eff);
585
586        // Gather candidate entry indices from probed cells.
587        let inverted = ivf.inverted_lists();
588        let mut candidate_idxs: Vec<usize> = Vec::new();
589        for (ci, _) in &nearest {
590            candidate_idxs.extend_from_slice(&inverted[*ci]);
591        }
592
593        if candidate_idxs.is_empty() {
594            return Ok(Vec::new());
595        }
596
597        // Score candidates using prepared query.
598        let prepared = self.scorer.prepare_query(query)?;
599        let mut scored: Vec<(usize, f32)> = Vec::with_capacity(candidate_idxs.len());
600        for &idx in &candidate_idxs {
601            let code = &self.entries[idx].1;
602            let s = self.scorer.score_prepared(&prepared, code)?;
603            scored.push((idx, s));
604        }
605        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
606
607        let limit = top_k.saturating_mul(oversample.max(1)).min(scored.len());
608        let candidates = scored
609            .into_iter()
610            .take(limit)
611            .enumerate()
612            .map(|(rank, (idx, score))| {
613                let id = self.entries[idx].0.clone();
614                ScoredCandidate {
615                    id,
616                    approximate_score: score,
617                    rank,
618                }
619            })
620            .collect();
621        Ok(candidates)
622    }
623
624    /// IVF-accelerated search with receipt.
625    ///
626    /// Like [`search_ivf`](Self::search_ivf) but returns a
627    /// [`SearchReceiptIvfV1`] documenting the IVF parameters used.
628    pub fn search_with_receipt_ivf(
629        &self,
630        query: &[f32],
631        top_k: usize,
632        oversample: usize,
633        nprobe: usize,
634    ) -> Result<(Vec<ScoredCandidate<Id>>, SearchReceiptIvfV1)> {
635        let started = Instant::now();
636        let ivf_used = self.ivf_is_built();
637
638        let (candidates, entries_scored, nprobe_actual, num_centroids) = if ivf_used {
639            let ivf = self.ivf.as_ref().unwrap();
640            let nprobe_eff = nprobe.min(ivf.centroids.len()).max(1);
641            // Count entries we'll score.
642            let nearest = self.nearest_centroids(query, nprobe_eff);
643            let inverted = ivf.inverted_lists();
644            let count: usize = nearest.iter().map(|(ci, _)| inverted[*ci].len()).sum();
645            let cands = self.search_ivf(query, top_k, oversample, nprobe)?;
646            (cands, count, nprobe_eff, ivf.centroids.len())
647        } else {
648            // Linear scan fallback.
649            let cands = self.search(query, top_k, oversample)?;
650            let es = self.entries.len();
651            (cands, es, 0, 0)
652        };
653
654        let elapsed = started.elapsed().as_micros();
655
656        let receipt = SearchReceiptIvfV1 {
657            schema: "fib_sidecar_search_ivf_receipt_v1".to_string(),
658            indexed_count: self.entries.len(),
659            top_k,
660            oversample,
661            candidate_count: candidates.len(),
662            approximate_only: true,
663            exact_rerank_required: true,
664            elapsed_micros: elapsed,
665            num_centroids,
666            nprobe: nprobe_actual,
667            entries_scored,
668            ivf_used,
669        };
670
671        Ok((candidates, receipt))
672    }
673}
674
675// ======================================================================
676// Tests
677// ======================================================================
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use crate::profile::FibQuantProfileV1;
683    use crate::{FibQuantizer, FibScorer};
684
685    fn build_test_scorer() -> Result<FibScorer> {
686        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
687        profile.training_samples = 128;
688        profile.lloyd_restarts = 1;
689        profile.lloyd_iterations = 2;
690        let quantizer = FibQuantizer::new(profile)?;
691        FibScorer::new(quantizer)
692    }
693
694    fn make_vectors(d: usize, count: usize) -> Vec<Vec<f32>> {
695        (0..count)
696            .map(|seed| {
697                (0..d)
698                    .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
699                    .collect()
700            })
701            .collect()
702    }
703
704    #[test]
705    fn add_and_search_returns_correct_top_k() -> Result<()> {
706        let scorer = build_test_scorer()?;
707        let d = scorer.quantizer().profile().ambient_dim as usize;
708        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
709
710        let vectors = make_vectors(d, 16);
711        for (i, v) in vectors.iter().enumerate() {
712            let code = index.scorer().quantizer().encode(v)?;
713            index.add(i as u32, code);
714        }
715
716        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
717        assert_eq!(query.len(), d);
718        let results = index.search(&query, 5, 1)?;
719
720        assert_eq!(results.len(), 5, "should return exactly top_k=5");
721        // ranks 0..5
722        for (i, r) in results.iter().enumerate() {
723            assert_eq!(r.rank, i, "rank should be sequential from 0");
724        }
725        Ok(())
726    }
727
728    #[test]
729    fn empty_index_search_returns_empty() -> Result<()> {
730        let scorer = build_test_scorer()?;
731        let d = scorer.quantizer().profile().ambient_dim as usize;
732        let index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
733
734        let query = vec![0.0f32; d];
735        let results = index.search(&query, 5, 2)?;
736        assert!(
737            results.is_empty(),
738            "empty index should return empty results"
739        );
740
741        let (results, receipt) = index.search_with_receipt(&query, 5, 2)?;
742        assert!(results.is_empty());
743        assert_eq!(receipt.indexed_count, 0);
744        assert_eq!(receipt.candidate_count, 0);
745        Ok(())
746    }
747
748    #[test]
749    fn oversample_returns_more_than_top_k() -> Result<()> {
750        let scorer = build_test_scorer()?;
751        let d = scorer.quantizer().profile().ambient_dim as usize;
752        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
753
754        let vectors = make_vectors(d, 20);
755        for (i, v) in vectors.iter().enumerate() {
756            let code = index.scorer().quantizer().encode(v)?;
757            index.add(i as u32, code);
758        }
759
760        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
761        let results = index.search(&query, 5, 3)?;
762        // top_k=5, oversample=3, entries=20 → min(15, 20) = 15
763        assert_eq!(results.len(), 15, "oversample=3 should give 15 candidates");
764        assert!(results.len() > 5, "should return more than top_k alone");
765        Ok(())
766    }
767
768    #[test]
769    fn results_sorted_descending() -> Result<()> {
770        let scorer = build_test_scorer()?;
771        let d = scorer.quantizer().profile().ambient_dim as usize;
772        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
773
774        let vectors = make_vectors(d, 16);
775        for (i, v) in vectors.iter().enumerate() {
776            let code = index.scorer().quantizer().encode(v)?;
777            index.add(i as u32, code);
778        }
779
780        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
781        let results = index.search(&query, 8, 1)?;
782        for w in results.windows(2) {
783            assert!(
784                w[0].approximate_score >= w[1].approximate_score,
785                "results not sorted descending: {} before {}",
786                w[0].approximate_score,
787                w[1].approximate_score
788            );
789        }
790        Ok(())
791    }
792
793    #[test]
794    fn receipt_fields_correct() -> Result<()> {
795        let scorer = build_test_scorer()?;
796        let d = scorer.quantizer().profile().ambient_dim as usize;
797        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
798
799        let vectors = make_vectors(d, 12);
800        for (i, v) in vectors.iter().enumerate() {
801            let code = index.scorer().quantizer().encode(v)?;
802            index.add(i as u32, code);
803        }
804
805        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
806        let (results, receipt) = index.search_with_receipt(&query, 5, 2)?;
807
808        assert_eq!(receipt.schema, "fib_sidecar_search_receipt_v1");
809        assert_eq!(receipt.indexed_count, 12);
810        assert_eq!(receipt.top_k, 5);
811        assert_eq!(receipt.oversample, 2);
812        assert_eq!(receipt.candidate_count, results.len());
813        assert!(receipt.approximate_only);
814        assert!(receipt.exact_rerank_required);
815        // elapsed_micros should be non-negative; can't assert upper bound
816        // reliably but it should be a valid u128.
817        let _ = receipt.elapsed_micros;
818        Ok(())
819    }
820
821    #[test]
822    fn add_batch_works() -> Result<()> {
823        let scorer = build_test_scorer()?;
824        let d = scorer.quantizer().profile().ambient_dim as usize;
825        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
826
827        let vectors = make_vectors(d, 10);
828        let entries: Vec<(u32, FibCodeV1)> = vectors
829            .iter()
830            .enumerate()
831            .map(|(i, v)| {
832                let code = index.scorer().quantizer().encode(v).unwrap();
833                (i as u32, code)
834            })
835            .collect();
836
837        index.add_batch(entries);
838        assert_eq!(index.len(), 10);
839        assert!(!index.is_empty());
840
841        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
842        let results = index.search(&query, 3, 1)?;
843        assert_eq!(results.len(), 3);
844        Ok(())
845    }
846
847    #[test]
848    fn len_and_is_empty() -> Result<()> {
849        let scorer = build_test_scorer()?;
850        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
851
852        assert!(index.is_empty());
853        assert_eq!(index.len(), 0);
854
855        let d = index.scorer().quantizer().profile().ambient_dim as usize;
856        let v = vec![0.1f32; d];
857        let code = index.scorer().quantizer().encode(&v)?;
858        index.add(42, code);
859        assert!(!index.is_empty());
860        assert_eq!(index.len(), 1);
861        Ok(())
862    }
863
864    // ------------------------------------------------------------------
865    // IVF tests
866    // ------------------------------------------------------------------
867
868    #[test]
869    fn ivf_build_and_search_returns_results() -> Result<()> {
870        let scorer = build_test_scorer()?;
871        let d = scorer.quantizer().profile().ambient_dim as usize;
872        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
873
874        let vectors = make_vectors(d, 100);
875        for (i, v) in vectors.iter().enumerate() {
876            let code = index.scorer().quantizer().encode(v)?;
877            index.add(i as u32, code);
878        }
879
880        // Build IVF with 10 centroids.
881        index.build_ivf(10)?;
882        assert!(index.ivf_is_built(), "IVF should be built");
883        assert_eq!(index.ivf().unwrap().num_centroids(), 10);
884        assert_eq!(index.ivf().unwrap().assignments().len(), 100);
885
886        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
887        assert_eq!(query.len(), d);
888
889        // Search with nprobe=4 (less than 10 centroids).
890        let results = index.search_ivf(&query, 5, 2, 4)?;
891        assert!(!results.is_empty(), "IVF search should return results");
892        assert!(
893            results.len() <= 10,
894            "should return at most top_k*oversample=10"
895        );
896
897        // Verify results are sorted descending.
898        for w in results.windows(2) {
899            assert!(
900                w[0].approximate_score >= w[1].approximate_score,
901                "IVF results not sorted descending"
902            );
903        }
904        Ok(())
905    }
906
907    #[test]
908    fn ivf_nprobe_fewer_than_all_probes_fewer_entries() -> Result<()> {
909        let scorer = build_test_scorer()?;
910        let d = scorer.quantizer().profile().ambient_dim as usize;
911        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
912
913        let vectors = make_vectors(d, 100);
914        for (i, v) in vectors.iter().enumerate() {
915            let code = index.scorer().quantizer().encode(v)?;
916            index.add(i as u32, code);
917        }
918
919        index.build_ivf(10)?;
920
921        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
922
923        // nprobe=1 should score fewer entries than nprobe=10 (all cells).
924        let (_results1, receipt1) = index.search_with_receipt_ivf(&query, 5, 2, 1)?;
925        let (_results_all, receipt_all) = index.search_with_receipt_ivf(&query, 5, 2, 10)?;
926
927        assert!(receipt1.ivf_used, "IVF should be used");
928        assert_eq!(receipt1.nprobe, 1);
929        assert_eq!(receipt_all.nprobe, 10);
930        assert!(
931            receipt1.entries_scored <= receipt_all.entries_scored,
932            "nprobe=1 should score <= entries than nprobe=10: {} vs {}",
933            receipt1.entries_scored,
934            receipt_all.entries_scored
935        );
936        assert_eq!(
937            receipt_all.entries_scored, 100,
938            "nprobe=10 (all centroids) should score all 100 entries"
939        );
940        Ok(())
941    }
942
943    #[test]
944    fn ivf_fallback_when_not_built() -> Result<()> {
945        let scorer = build_test_scorer()?;
946        let d = scorer.quantizer().profile().ambient_dim as usize;
947        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
948
949        let vectors = make_vectors(d, 20);
950        for (i, v) in vectors.iter().enumerate() {
951            let code = index.scorer().quantizer().encode(v)?;
952            index.add(i as u32, code);
953        }
954
955        // Do NOT build IVF — should fall back to linear scan.
956        assert!(!index.ivf_is_built(), "IVF should not be built");
957
958        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
959        let results = index.search_ivf(&query, 5, 2, 4)?;
960        assert_eq!(
961            results.len(),
962            10,
963            "fallback linear scan should return top_k*oversample=10"
964        );
965
966        // Verify receipt shows fallback.
967        let (results, receipt) = index.search_with_receipt_ivf(&query, 5, 2, 4)?;
968        assert!(!receipt.ivf_used, "receipt should show IVF not used");
969        assert_eq!(receipt.num_centroids, 0);
970        assert_eq!(receipt.nprobe, 0);
971        assert_eq!(receipt.entries_scored, 20);
972        assert_eq!(results.len(), 10);
973        Ok(())
974    }
975
976    #[test]
977    fn ivf_build_default_uses_sqrt_n() -> Result<()> {
978        let scorer = build_test_scorer()?;
979        let d = scorer.quantizer().profile().ambient_dim as usize;
980        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
981
982        let vectors = make_vectors(d, 100);
983        for (i, v) in vectors.iter().enumerate() {
984            let code = index.scorer().quantizer().encode(v)?;
985            index.add(i as u32, code);
986        }
987
988        // sqrt(100) = 10 centroids.
989        index.build_ivf_default()?;
990        assert!(index.ivf_is_built());
991        assert_eq!(index.ivf().unwrap().num_centroids(), 10);
992
993        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
994        let results = index.search_ivf(&query, 5, 1, 8)?;
995        assert!(!results.is_empty());
996        Ok(())
997    }
998
999    #[test]
1000    fn ivf_receipt_fields_correct() -> Result<()> {
1001        let scorer = build_test_scorer()?;
1002        let d = scorer.quantizer().profile().ambient_dim as usize;
1003        let mut index: FibSidecarIndex<u32> = FibSidecarIndex::new(scorer);
1004
1005        let vectors = make_vectors(d, 100);
1006        for (i, v) in vectors.iter().enumerate() {
1007            let code = index.scorer().quantizer().encode(v)?;
1008            index.add(i as u32, code);
1009        }
1010
1011        index.build_ivf(10)?;
1012
1013        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
1014        let (results, receipt) = index.search_with_receipt_ivf(&query, 5, 2, 4)?;
1015
1016        assert_eq!(receipt.schema, "fib_sidecar_search_ivf_receipt_v1");
1017        assert_eq!(receipt.indexed_count, 100);
1018        assert_eq!(receipt.top_k, 5);
1019        assert_eq!(receipt.oversample, 2);
1020        assert_eq!(receipt.candidate_count, results.len());
1021        assert!(receipt.approximate_only);
1022        assert!(receipt.exact_rerank_required);
1023        assert_eq!(receipt.num_centroids, 10);
1024        assert_eq!(receipt.nprobe, 4);
1025        assert!(receipt.ivf_used);
1026        assert!(
1027            receipt.entries_scored <= 100,
1028            "entries_scored should be <= total entries"
1029        );
1030        assert!(
1031            receipt.entries_scored > 0,
1032            "entries_scored should be > 0 with 100 entries and nprobe=4"
1033        );
1034        Ok(())
1035    }
1036}