Skip to main content

fib_quant/
scoring.rs

1//! Approximate inner product scoring without full decode.
2//!
3//! The FibQuant codebook supports estimating the inner product of a query
4//! vector against a stored `FibCodeV1` without running the full decode
5//! pipeline (rotation inverse + norm scaling). The method:
6//!
7//! 1. Normalize the query, apply the same rotation → rotated query blocks.
8//! 2. For each block, find the nearest codeword index (same as encode).
9//! 3. Use a precomputed codebook Gram table `G[i,j] = <cw_i, cw_j>` to
10//!    estimate `<rotated_query_block, rotated_stored_block>` as
11//!    `G[query_idx, stored_idx]`.
12//! 4. Sum block-level estimates and scale by the stored norm.
13//!
14//! This avoids decoding the stored vector entirely — you only need the
15//! packed indices and the stored norm, both of which are in `FibCodeV1`.
16//!
17//! The estimate is approximate because the stored code uses the quantized
18//! codeword, not the original rotated block. The error is bounded by the
19//! codebook quantization noise and decreases as codebook size grows.
20
21use half::f16;
22
23use crate::{
24    bitpack::unpack_indices, codec::FibCodeV1, profile::FibQuantProfileV1, FibQuantError,
25    FibQuantizer, Result,
26};
27
28/// Precomputed codebook Gram table for approximate inner product scoring.
29///
30/// `G[i, j] = <codeword_i, codeword_j>` stored as a flat `N × N` f32 matrix
31/// in row-major order. For a codebook of size N=32, this is 1024 f32 values
32/// (4 KB). For N=256, it's 64K values (256 KB). The table is symmetric, so
33/// only the lower triangle is computed, but the full matrix is stored for
34/// O(1) lookup without index arithmetic.
35///
36/// The table is lazily computed and cached inside `FibScorer`.
37pub struct GramTable {
38    /// Flat `N × N` f32 matrix, row-major. `G[i * N + j] = <cw_i, cw_j>`.
39    values: Vec<f32>,
40    n: usize,
41}
42
43impl GramTable {
44    /// Build the Gram table from a codebook's codewords.
45    ///
46    /// `codewords` is the row-major `N × k` f32 array from `FibCodebookV1`.
47    /// `k` is the block dimension.
48    pub fn build(codewords: &[f32], n: usize, k: usize) -> Result<Self> {
49        if codewords.len() != n * k {
50            return Err(FibQuantError::CorruptPayload(format!(
51                "codewords has {} values, expected {} (n={} k={})",
52                codewords.len(),
53                n * k,
54                n,
55                k
56            )));
57        }
58        let mut values = vec![0.0f32; n * n];
59        for i in 0..n {
60            // Diagonal: <cw_i, cw_i> = ||cw_i||^2
61            let mut dot_ii = 0.0f32;
62            for d in 0..k {
63                let vi = codewords[i * k + d];
64                dot_ii += vi * vi;
65            }
66            values[i * n + i] = dot_ii;
67            // Off-diagonal: exploit symmetry
68            for j in (i + 1)..n {
69                let mut dot = 0.0f32;
70                for d in 0..k {
71                    dot += codewords[i * k + d] * codewords[j * k + d];
72                }
73                values[i * n + j] = dot;
74                values[j * n + i] = dot;
75            }
76        }
77        Ok(Self { values, n })
78    }
79
80    /// Lookup `<codeword_i, codeword_j>`.
81    #[inline]
82    pub fn get(&self, i: usize, j: usize) -> f32 {
83        debug_assert!(i < self.n && j < self.n);
84        self.values[i * self.n + j]
85    }
86
87    /// Size N of the Gram table.
88    pub fn n(&self) -> usize {
89        self.n
90    }
91
92    /// Raw values (for serialization/digest).
93    pub fn values(&self) -> &[f32] {
94        &self.values
95    }
96}
97
98/// Approximate scorer for FibQuant codes.
99///
100/// Wraps a `FibQuantizer` with a precomputed Gram table. The scorer can
101/// estimate inner products between a raw query vector and a stored
102/// `FibCodeV1` without decoding the stored code.
103///
104/// The scorer is cheap to construct (~1ms for N=32, k=4) and the Gram
105/// table is cached for the lifetime of the scorer.
106pub struct FibScorer {
107    quantizer: FibQuantizer,
108    gram: GramTable,
109}
110
111/// A scored candidate from approximate search.
112#[derive(Debug, Clone)]
113pub struct ScoredItem {
114    /// Index in the original input slice.
115    pub idx: usize,
116    /// Approximate inner product estimate.
117    pub score: f32,
118}
119
120/// Pre-rotated, pre-quantized query for batch scoring.
121///
122/// Produced by [`FibScorer::prepare_query`]. Contains everything needed to
123/// score against any number of `FibCodeV1` codes without recomputing the
124/// rotation or nearest-codeword search per code.
125///
126/// - `rotated_query`: normalized + rotated query in f32 (block-major)
127/// - `query_norm`: L2 norm of the original (un-normalized) query
128/// - `query_indices`: nearest codeword index per block (precomputed argmin)
129#[derive(Debug, Clone)]
130pub struct FibPreparedQuery {
131    /// Normalized, rotated query in f32, length = `ambient_dim`.
132    pub rotated_query: Vec<f32>,
133    /// L2 norm of the original query vector.
134    pub query_norm: f64,
135    /// Nearest codeword index for each block of the rotated query.
136    pub query_indices: Vec<u32>,
137}
138
139impl FibScorer {
140    /// Build a scorer from a quantizer. Computes the Gram table once.
141    pub fn new(quantizer: FibQuantizer) -> Result<Self> {
142        let n = quantizer.profile().codebook_size as usize;
143        let k = quantizer.profile().block_dim as usize;
144        let gram = GramTable::build(&quantizer.codebook().codewords, n, k)?;
145        Ok(Self { quantizer, gram })
146    }
147
148    /// Get a reference to the underlying quantizer.
149    pub fn quantizer(&self) -> &FibQuantizer {
150        &self.quantizer
151    }
152
153    /// Get a reference to the Gram table.
154    pub fn gram_table(&self) -> &GramTable {
155        &self.gram
156    }
157
158    /// Estimate the inner product `<query, stored_vector>` where
159    /// `stored_vector` is represented by `code`.
160    ///
161    /// This does NOT decode the stored vector. It:
162    /// 1. Normalizes the query, applies the rotation.
163    /// 2. For each block, finds the nearest codeword index for the query.
164    /// 3. Looks up `G[query_idx, stored_idx]` from the Gram table.
165    /// 4. Sums block-level estimates and scales by the stored norm.
166    ///
167    /// Returns the approximate inner product.
168    pub fn inner_product_estimate(&self, query: &[f32], code: &FibCodeV1) -> Result<f32> {
169        let d = self.quantizer.profile().ambient_dim as usize;
170        let k = self.quantizer.profile().block_dim as usize;
171        if query.len() != d {
172            return Err(FibQuantError::CorruptPayload(format!(
173                "query dimension {}, expected {}",
174                query.len(),
175                d
176            )));
177        }
178        // Check query is finite
179        if query.iter().any(|v| !v.is_finite()) {
180            return Err(FibQuantError::NonFiniteInput(0));
181        }
182        let query_norm: f64 = query
183            .iter()
184            .map(|v| (*v as f64) * (*v as f64))
185            .sum::<f64>()
186            .sqrt();
187        if query_norm == 0.0 {
188            return Ok(0.0);
189        }
190        // Normalize and rotate the query
191        let normalized: Vec<f64> = query.iter().map(|v| f64::from(*v) / query_norm).collect();
192        let rotated = self.quantizer.profile();
193        let _ = rotated; // suppress unused warning
194        let rotated_query = self.quantizer_codebook_rotation_apply(&normalized)?;
195
196        // Decode the stored norm
197        let stored_norm = decode_stored_norm(code, self.quantizer.profile())?;
198
199        // Unpack stored indices
200        let block_count = self.quantizer.profile().block_count() as usize;
201        let stored_indices = unpack_indices(
202            &code.indices,
203            block_count,
204            self.quantizer.profile().wire_index_bits,
205        )?;
206
207        // For each block, find the nearest codeword for the query block
208        let rotated_query_f32: Vec<f32> = rotated_query.iter().map(|&v| v as f32).collect();
209        let codewords = &self.quantizer.codebook().codewords;
210        let n = self.quantizer.profile().codebook_size as usize;
211
212        let mut total = 0.0f32;
213        for (block_idx, stored_idx) in stored_indices.iter().enumerate() {
214            let stored_idx = *stored_idx as usize;
215            if stored_idx >= n {
216                return Err(FibQuantError::IndexOutOfRange {
217                    index: stored_idx as u32,
218                    codebook_size: n as u32,
219                });
220            }
221            let query_block = &rotated_query_f32[block_idx * k..(block_idx + 1) * k];
222            let query_idx = gpu_backend::nearest_codeword_f32(query_block, codewords, k) as usize;
223            // Gram table lookup: <cw_query, cw_stored>
224            total += self.gram.get(query_idx, stored_idx);
225        }
226
227        // Scale by query norm and stored norm
228        Ok(total * (query_norm as f32) * (stored_norm as f32))
229    }
230
231    /// Score a batch of stored codes against a single query.
232    ///
233    /// Returns `Vec<(idx, score)>` sorted by descending score.
234    pub fn score_batch(&self, query: &[f32], codes: &[FibCodeV1]) -> Result<Vec<ScoredItem>> {
235        let mut results = Vec::with_capacity(codes.len());
236        for (idx, code) in codes.iter().enumerate() {
237            let score = self.inner_product_estimate(query, code)?;
238            results.push(ScoredItem { idx, score });
239        }
240        results.sort_by(|a, b| {
241            b.score
242                .partial_cmp(&a.score)
243                .unwrap_or(std::cmp::Ordering::Equal)
244        });
245        Ok(results)
246    }
247
248    /// Search the top-K closest codes to a query, with optional oversampling.
249    ///
250    /// Returns the top-K `ScoredItem`s by approximate inner product.
251    /// `oversample > 1` returns more candidates than `top_k` for downstream
252    /// exact reranking.
253    pub fn search(
254        &self,
255        query: &[f32],
256        codes: &[FibCodeV1],
257        top_k: usize,
258        oversample: usize,
259    ) -> Result<Vec<ScoredItem>> {
260        let limit = top_k.saturating_mul(oversample.max(1)).min(codes.len());
261        let scored = self.score_batch(query, codes)?;
262        Ok(scored.into_iter().take(limit).collect())
263    }
264
265    // ──────────────────────────────────────────────────────────────────
266    //  Prepared-query path — rotate/argmin ONCE, score many codes
267    // ──────────────────────────────────────────────────────────────────
268
269    /// Prepare a query for batch scoring.
270    ///
271    /// Normalizes the query, applies the rotation, converts to f32, and
272    /// precomputes the nearest-codeword index for each block. The resulting
273    /// [`FibPreparedQuery`] can be passed to [`score_prepared`](Self::score_prepared),
274    /// [`score_batch_prepared`](Self::score_batch_prepared), or
275    /// [`search_prepared`](Self::search_prepared) to avoid recomputing the
276    /// rotation and argmin for every code in a batch.
277    pub fn prepare_query(&self, query: &[f32]) -> Result<FibPreparedQuery> {
278        let d = self.quantizer.profile().ambient_dim as usize;
279        let k = self.quantizer.profile().block_dim as usize;
280        if query.len() != d {
281            return Err(FibQuantError::CorruptPayload(format!(
282                "query dimension {}, expected {}",
283                query.len(),
284                d
285            )));
286        }
287        if query.iter().any(|v| !v.is_finite()) {
288            return Err(FibQuantError::NonFiniteInput(0));
289        }
290
291        let query_norm: f64 = query
292            .iter()
293            .map(|v| (*v as f64) * (*v as f64))
294            .sum::<f64>()
295            .sqrt();
296        if query_norm == 0.0 {
297            // Return a zero-filled prepared query — all scores will be 0.
298            let block_count = self.quantizer.profile().block_count() as usize;
299            return Ok(FibPreparedQuery {
300                rotated_query: vec![0.0f32; d],
301                query_norm: 0.0,
302                query_indices: vec![0u32; block_count],
303            });
304        }
305
306        // Normalize and rotate
307        let normalized: Vec<f64> = query.iter().map(|v| f64::from(*v) / query_norm).collect();
308        let rotated_query = self.quantizer_codebook_rotation_apply(&normalized)?;
309        let rotated_query_f32: Vec<f32> = rotated_query.iter().map(|&v| v as f32).collect();
310
311        // Precompute nearest codeword index per block
312        let block_count = self.quantizer.profile().block_count() as usize;
313        let codewords = &self.quantizer.codebook().codewords;
314        let mut query_indices = Vec::with_capacity(block_count);
315        for block_idx in 0..block_count {
316            let block = &rotated_query_f32[block_idx * k..(block_idx + 1) * k];
317            let idx = gpu_backend::nearest_codeword_f32(block, codewords, k) as u32;
318            query_indices.push(idx);
319        }
320
321        Ok(FibPreparedQuery {
322            rotated_query: rotated_query_f32,
323            query_norm,
324            query_indices,
325        })
326    }
327
328    /// Score a single code against a prepared query.
329    ///
330    /// This skips the rotation and argmin steps (already done in
331    /// [`prepare_query`](Self::prepare_query)) and only performs:
332    /// 1. Unpack the stored code's indices.
333    /// 2. For each block: Gram table lookup `G[query_idx, stored_idx]`.
334    /// 3. Sum and scale by `query_norm * stored_norm`.
335    pub fn score_prepared(&self, prepared: &FibPreparedQuery, code: &FibCodeV1) -> Result<f32> {
336        if prepared.query_norm == 0.0 {
337            return Ok(0.0);
338        }
339
340        let block_count = self.quantizer.profile().block_count() as usize;
341        let stored_indices = unpack_indices(
342            &code.indices,
343            block_count,
344            self.quantizer.profile().wire_index_bits,
345        )?;
346
347        let stored_norm = decode_stored_norm(code, self.quantizer.profile())?;
348        let n = self.quantizer.profile().codebook_size as usize;
349
350        let mut total = 0.0f32;
351        for (block_idx, stored_idx) in stored_indices.iter().enumerate() {
352            let stored_idx = *stored_idx as usize;
353            if stored_idx >= n {
354                return Err(FibQuantError::IndexOutOfRange {
355                    index: stored_idx as u32,
356                    codebook_size: n as u32,
357                });
358            }
359            let query_idx = prepared.query_indices[block_idx] as usize;
360            total += self.gram.get(query_idx, stored_idx);
361        }
362
363        Ok(total * (prepared.query_norm as f32) * (stored_norm as f32))
364    }
365
366    /// Score a batch of codes against a prepared query.
367    ///
368    /// Like [`score_batch`](Self::score_batch) but uses the pre-rotated query,
369    /// avoiding redundant rotation + argmin per code.
370    /// Returns `Vec<ScoredItem>` sorted by descending score.
371    pub fn score_batch_prepared(
372        &self,
373        prepared: &FibPreparedQuery,
374        codes: &[FibCodeV1],
375    ) -> Result<Vec<ScoredItem>> {
376        let mut results = Vec::with_capacity(codes.len());
377        for (idx, code) in codes.iter().enumerate() {
378            let score = self.score_prepared(prepared, code)?;
379            results.push(ScoredItem { idx, score });
380        }
381        results.sort_by(|a, b| {
382            b.score
383                .partial_cmp(&a.score)
384                .unwrap_or(std::cmp::Ordering::Equal)
385        });
386        Ok(results)
387    }
388
389    /// Search top-K closest codes to a prepared query, with optional oversampling.
390    ///
391    /// Like [`search`](Self::search) but uses the pre-rotated query.
392    pub fn search_prepared(
393        &self,
394        prepared: &FibPreparedQuery,
395        codes: &[FibCodeV1],
396        top_k: usize,
397        oversample: usize,
398    ) -> Result<Vec<ScoredItem>> {
399        let limit = top_k.saturating_mul(oversample.max(1)).min(codes.len());
400        let scored = self.score_batch_prepared(prepared, codes)?;
401        Ok(scored.into_iter().take(limit).collect())
402    }
403
404    /// Apply the rotation to a query vector (internal helper).
405    fn quantizer_codebook_rotation_apply(&self, x: &[f64]) -> Result<Vec<f64>> {
406        // Use the exposed rotation from the quantizer — no reconstruction.
407        self.quantizer.rotation().apply(x)
408    }
409
410    /// Estimate L2 distance ||query - stored|| without decoding the stored vector.
411    ///
412    /// Uses the identity: ||q - v||^2 = ||q||^2 + ||v||^2 - 2<q, v>
413    /// where <q, v> is the approximate inner product from the Gram table.
414    /// Returns the estimated squared L2 distance (avoiding a sqrt for
415    /// comparison purposes — callers can sqrt if needed).
416    pub fn l2_distance_sq_estimate(&self, query: &[f32], code: &FibCodeV1) -> Result<f32> {
417        let ip = self.inner_product_estimate(query, code)?;
418        let q_norm_sq: f32 = query.iter().map(|v| v * v).sum();
419        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
420        let v_norm_sq = stored_norm * stored_norm;
421        Ok((q_norm_sq + v_norm_sq - 2.0 * ip).max(0.0))
422    }
423
424    /// Estimate cosine similarity <query, stored> / (||query|| * ||stored||)
425    /// without decoding the stored vector.
426    pub fn cosine_estimate(&self, query: &[f32], code: &FibCodeV1) -> Result<f32> {
427        let ip = self.inner_product_estimate(query, code)?;
428        let q_norm: f32 = query.iter().map(|v| v * v).sum::<f32>().sqrt();
429        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
430        if q_norm == 0.0 || stored_norm == 0.0 {
431            return Ok(0.0);
432        }
433        Ok(ip / (q_norm * stored_norm))
434    }
435
436    /// L2 distance using a prepared query — avoids recomputing query rotation.
437    pub fn l2_distance_sq_prepared(
438        &self,
439        prepared: &FibPreparedQuery,
440        code: &FibCodeV1,
441    ) -> Result<f32> {
442        let ip = self.score_prepared(prepared, code)?;
443        let q_norm_sq = (prepared.query_norm * prepared.query_norm) as f32;
444        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
445        let v_norm_sq = stored_norm * stored_norm;
446        Ok((q_norm_sq + v_norm_sq - 2.0 * ip).max(0.0))
447    }
448
449    /// Cosine similarity using a prepared query.
450    pub fn cosine_prepared(&self, prepared: &FibPreparedQuery, code: &FibCodeV1) -> Result<f32> {
451        let ip = self.score_prepared(prepared, code)?;
452        let q_norm = prepared.query_norm as f32;
453        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
454        if q_norm == 0.0 || stored_norm == 0.0 {
455            return Ok(0.0);
456        }
457        Ok(ip / (q_norm * stored_norm))
458    }
459}
460
461pub fn decode_stored_norm(code: &FibCodeV1, _profile: &FibQuantProfileV1) -> Result<f64> {
462    // Decode the norm from the code's norm_payload. We handle both
463    // Fp16Paper and F32Reference formats here to avoid depending on
464    // the private decode_norm function in codec.rs.
465    use crate::profile::NormFormat;
466    match code.norm_format {
467        NormFormat::Fp16Paper => {
468            let bytes: [u8; 2] =
469                code.norm_payload.as_slice().try_into().map_err(|_| {
470                    FibQuantError::CorruptPayload("fp16 norm payload length".into())
471                })?;
472            let value = f16::from_le_bytes(bytes).to_f32() as f64;
473            if value.is_finite() && value > 0.0 {
474                Ok(value)
475            } else {
476                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
477            }
478        }
479        NormFormat::F32Reference => {
480            let bytes: [u8; 4] = code
481                .norm_payload
482                .as_slice()
483                .try_into()
484                .map_err(|_| FibQuantError::CorruptPayload("f32 norm payload length".into()))?;
485            let value = f32::from_le_bytes(bytes) as f64;
486            if value.is_finite() && value > 0.0 {
487                Ok(value)
488            } else {
489                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
490            }
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn build_test_scorer() -> Result<FibScorer> {
500        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
501        profile.training_samples = 128;
502        profile.lloyd_restarts = 1;
503        profile.lloyd_iterations = 2;
504        let quantizer = FibQuantizer::new(profile)?;
505        FibScorer::new(quantizer)
506    }
507
508    #[test]
509    fn gram_table_diagonal_matches_codeword_norms() -> Result<()> {
510        let scorer = build_test_scorer()?;
511        let k = scorer.quantizer.profile().block_dim as usize;
512        let n = scorer.quantizer.profile().codebook_size as usize;
513        let codewords = &scorer.quantizer.codebook().codewords;
514        for i in 0..n {
515            let mut norm_sq = 0.0f32;
516            for d in 0..k {
517                let v = codewords[i * k + d];
518                norm_sq += v * v;
519            }
520            let gram_diag = scorer.gram.get(i, i);
521            assert!(
522                (norm_sq - gram_diag).abs() < 1e-5,
523                "gram diagonal mismatch at {}: ||cw||^2 = {}, gram = {}",
524                i,
525                norm_sq,
526                gram_diag
527            );
528        }
529        Ok(())
530    }
531
532    #[test]
533    fn gram_table_symmetric() -> Result<()> {
534        let scorer = build_test_scorer()?;
535        let n = scorer.gram.n();
536        for i in 0..n {
537            for j in 0..n {
538                assert!(
539                    (scorer.gram.get(i, j) - scorer.gram.get(j, i)).abs() < 1e-6,
540                    "gram not symmetric at ({}, {})",
541                    i,
542                    j
543                );
544            }
545        }
546        Ok(())
547    }
548
549    #[test]
550    fn inner_product_estimate_positive_for_self() -> Result<()> {
551        let scorer = build_test_scorer()?;
552        let d = scorer.quantizer.profile().ambient_dim as usize;
553        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
554        assert_eq!(input.len(), d);
555        let code = scorer.quantizer.encode(&input)?;
556        let est = scorer.inner_product_estimate(&input, &code)?;
557        // <x, x> > 0 for a non-zero vector
558        assert!(
559            est > 0.0,
560            "inner product estimate of self should be positive, got {}",
561            est
562        );
563        // It should be in the ballpark of ||x||^2
564        let true_ip: f32 = input.iter().map(|v| v * v).sum();
565        let ratio = est / true_ip;
566        assert!(
567            ratio > 0.5 && ratio < 2.0,
568            "estimate {} vs true {} — ratio {} out of [0.5, 2.0]",
569            est,
570            true_ip,
571            ratio
572        );
573        Ok(())
574    }
575
576    #[test]
577    fn search_returns_sorted_descending() -> Result<()> {
578        let scorer = build_test_scorer()?;
579        let d = scorer.quantizer.profile().ambient_dim as usize;
580        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
581        assert_eq!(query.len(), d);
582
583        // Encode several vectors
584        let vectors: Vec<Vec<f32>> = (0..16)
585            .map(|seed| {
586                (0..d)
587                    .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
588                    .collect()
589            })
590            .collect();
591        let codes: Vec<FibCodeV1> = vectors
592            .iter()
593            .map(|v| scorer.quantizer.encode(v).unwrap())
594            .collect();
595
596        let results = scorer.search(&query, &codes, 5, 2)?;
597        // top_k=5, oversample=2 → returns min(5*2, 16) = 10 candidates
598        assert_eq!(results.len(), 10);
599        for w in results.windows(2) {
600            assert!(
601                w[0].score >= w[1].score,
602                "results not sorted: {} before {}",
603                w[0].score,
604                w[1].score
605            );
606        }
607        Ok(())
608    }
609
610    #[test]
611    fn score_batch_handles_empty() -> Result<()> {
612        let scorer = build_test_scorer()?;
613        let d = scorer.quantizer.profile().ambient_dim as usize;
614        let query = vec![0.0f32; d];
615        let results = scorer.score_batch(&query, &[])?;
616        assert!(results.is_empty());
617        Ok(())
618    }
619
620    #[test]
621    fn prepared_query_matches_inner_product_estimate() -> Result<()> {
622        let scorer = build_test_scorer()?;
623        let d = scorer.quantizer.profile().ambient_dim as usize;
624        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
625        assert_eq!(query.len(), d);
626
627        // Encode several vectors
628        let vectors: Vec<Vec<f32>> = (0..16)
629            .map(|seed| {
630                (0..d)
631                    .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
632                    .collect()
633            })
634            .collect();
635        let codes: Vec<FibCodeV1> = vectors
636            .iter()
637            .map(|v| scorer.quantizer.encode(v).unwrap())
638            .collect();
639
640        let prepared = scorer.prepare_query(&query)?;
641        for (i, code) in codes.iter().enumerate() {
642            let direct = scorer.inner_product_estimate(&query, code)?;
643            let prepared_score = scorer.score_prepared(&prepared, code)?;
644            assert!(
645                (direct - prepared_score).abs() < 1e-4,
646                "mismatch at code {}: direct={}, prepared={}",
647                i,
648                direct,
649                prepared_score
650            );
651        }
652        Ok(())
653    }
654
655    #[test]
656    fn prepared_batch_matches_score_batch() -> Result<()> {
657        let scorer = build_test_scorer()?;
658        let d = scorer.quantizer.profile().ambient_dim as usize;
659        let query: Vec<f32> = vec![0.3, 0.7, -0.2, 0.9, -0.5, 0.1, -0.8, 0.4];
660        assert_eq!(query.len(), d);
661
662        let vectors: Vec<Vec<f32>> = (0..24)
663            .map(|seed| {
664                (0..d)
665                    .map(|i| ((seed as f32 + i as f32) * 0.13).cos())
666                    .collect()
667            })
668            .collect();
669        let codes: Vec<FibCodeV1> = vectors
670            .iter()
671            .map(|v| scorer.quantizer.encode(v).unwrap())
672            .collect();
673
674        let batch = scorer.score_batch(&query, &codes)?;
675        let prepared = scorer.prepare_query(&query)?;
676        let batch_prepared = scorer.score_batch_prepared(&prepared, &codes)?;
677
678        assert_eq!(batch.len(), batch_prepared.len());
679        for (a, b) in batch.iter().zip(batch_prepared.iter()) {
680            assert_eq!(a.idx, b.idx);
681            assert!(
682                (a.score - b.score).abs() < 1e-4,
683                "score mismatch at idx {}: batch={}, prepared={}",
684                a.idx,
685                a.score,
686                b.score
687            );
688        }
689        Ok(())
690    }
691
692    #[test]
693    fn prepared_search_matches_search() -> Result<()> {
694        let scorer = build_test_scorer()?;
695        let d = scorer.quantizer.profile().ambient_dim as usize;
696        let query: Vec<f32> = vec![0.6, -0.1, 0.3, -0.7, 0.8, 0.2, -0.4, 0.5];
697        assert_eq!(query.len(), d);
698
699        let vectors: Vec<Vec<f32>> = (0..32)
700            .map(|seed| {
701                (0..d)
702                    .map(|i| (seed as f32 * 0.17 + i as f32 * 0.03).sin())
703                    .collect()
704            })
705            .collect();
706        let codes: Vec<FibCodeV1> = vectors
707            .iter()
708            .map(|v| scorer.quantizer.encode(v).unwrap())
709            .collect();
710
711        let direct = scorer.search(&query, &codes, 5, 2)?;
712        let prepared = scorer.prepare_query(&query)?;
713        let prepared_results = scorer.search_prepared(&prepared, &codes, 5, 2)?;
714
715        assert_eq!(direct.len(), prepared_results.len());
716        for (a, b) in direct.iter().zip(prepared_results.iter()) {
717            assert_eq!(a.idx, b.idx);
718            assert!(
719                (a.score - b.score).abs() < 1e-4,
720                "search mismatch at idx {}: direct={}, prepared={}",
721                a.idx,
722                a.score,
723                b.score
724            );
725        }
726        Ok(())
727    }
728
729    #[test]
730    fn prepared_query_zero_norm() -> Result<()> {
731        let scorer = build_test_scorer()?;
732        let d = scorer.quantizer.profile().ambient_dim as usize;
733        let query = vec![0.0f32; d];
734        let prepared = scorer.prepare_query(&query)?;
735        assert_eq!(prepared.query_norm, 0.0);
736
737        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
738        let code = scorer.quantizer.encode(&input)?;
739        let score = scorer.score_prepared(&prepared, &code)?;
740        assert!(score.abs() < 1e-6, "zero query should give zero score");
741        Ok(())
742    }
743
744    #[test]
745    fn l2_distance_is_non_negative() -> Result<()> {
746        let scorer = build_test_scorer()?;
747        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
748        let code = scorer.quantizer.encode(&input)?;
749        let dist = scorer.l2_distance_sq_estimate(&input, &code)?;
750        assert!(
751            dist >= 0.0,
752            "L2 distance squared should be non-negative, got {}",
753            dist
754        );
755        Ok(())
756    }
757
758    #[test]
759    fn cosine_estimate_in_valid_range() -> Result<()> {
760        let scorer = build_test_scorer()?;
761        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
762        let code = scorer.quantizer.encode(&input)?;
763        let cos = scorer.cosine_estimate(&input, &code)?;
764        assert!(
765            (-1.5..=1.5).contains(&cos),
766            "cosine should be in [-1.5, 1.5], got {}",
767            cos
768        );
769        Ok(())
770    }
771
772    #[test]
773    fn cosine_prepared_matches_cosine_estimate() -> Result<()> {
774        let scorer = build_test_scorer()?;
775        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
776        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
777        let code = scorer.quantizer.encode(&input)?;
778        let cos_direct = scorer.cosine_estimate(&query, &code)?;
779        let prepared = scorer.prepare_query(&query)?;
780        let cos_prepared = scorer.cosine_prepared(&prepared, &code)?;
781        assert!(
782            (cos_direct - cos_prepared).abs() < 1e-5,
783            "prepared cosine {} should match direct {}",
784            cos_prepared,
785            cos_direct
786        );
787        Ok(())
788    }
789
790    #[test]
791    fn l2_prepared_matches_l2_estimate() -> Result<()> {
792        let scorer = build_test_scorer()?;
793        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
794        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
795        let code = scorer.quantizer.encode(&input)?;
796        let dist_direct = scorer.l2_distance_sq_estimate(&query, &code)?;
797        let prepared = scorer.prepare_query(&query)?;
798        let dist_prepared = scorer.l2_distance_sq_prepared(&prepared, &code)?;
799        assert!(
800            (dist_direct - dist_prepared).abs() < 1e-5,
801            "prepared L2 {} should match direct {}",
802            dist_prepared,
803            dist_direct
804        );
805        Ok(())
806    }
807}