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 =
223                crate::ffi::c_encode_vector_block(query_block, codewords, n, k)[0] as usize;
224            // Gram table lookup: <cw_query, cw_stored>
225            total += self.gram.get(query_idx, stored_idx);
226        }
227
228        // Scale by query norm and stored norm
229        Ok(total * (query_norm as f32) * (stored_norm as f32))
230    }
231
232    /// Score a batch of stored codes against a single query.
233    ///
234    /// Returns `Vec<(idx, score)>` sorted by descending score.
235    pub fn score_batch(&self, query: &[f32], codes: &[FibCodeV1]) -> Result<Vec<ScoredItem>> {
236        let mut results = Vec::with_capacity(codes.len());
237        for (idx, code) in codes.iter().enumerate() {
238            let score = self.inner_product_estimate(query, code)?;
239            results.push(ScoredItem { idx, score });
240        }
241        results.sort_by(|a, b| {
242            b.score
243                .partial_cmp(&a.score)
244                .unwrap_or(std::cmp::Ordering::Equal)
245        });
246        Ok(results)
247    }
248
249    /// Search the top-K closest codes to a query, with optional oversampling.
250    ///
251    /// Returns the top-K `ScoredItem`s by approximate inner product.
252    /// `oversample > 1` returns more candidates than `top_k` for downstream
253    /// exact reranking.
254    pub fn search(
255        &self,
256        query: &[f32],
257        codes: &[FibCodeV1],
258        top_k: usize,
259        oversample: usize,
260    ) -> Result<Vec<ScoredItem>> {
261        let limit = top_k.saturating_mul(oversample.max(1)).min(codes.len());
262        let scored = self.score_batch(query, codes)?;
263        Ok(scored.into_iter().take(limit).collect())
264    }
265
266    // ──────────────────────────────────────────────────────────────────
267    //  Prepared-query path — rotate/argmin ONCE, score many codes
268    // ──────────────────────────────────────────────────────────────────
269
270    /// Prepare a query for batch scoring.
271    ///
272    /// Normalizes the query, applies the rotation, converts to f32, and
273    /// precomputes the nearest-codeword index for each block. The resulting
274    /// [`FibPreparedQuery`] can be passed to [`score_prepared`](Self::score_prepared),
275    /// [`score_batch_prepared`](Self::score_batch_prepared), or
276    /// [`search_prepared`](Self::search_prepared) to avoid recomputing the
277    /// rotation and argmin for every code in a batch.
278    pub fn prepare_query(&self, query: &[f32]) -> Result<FibPreparedQuery> {
279        let d = self.quantizer.profile().ambient_dim as usize;
280        let k = self.quantizer.profile().block_dim as usize;
281        if query.len() != d {
282            return Err(FibQuantError::CorruptPayload(format!(
283                "query dimension {}, expected {}",
284                query.len(),
285                d
286            )));
287        }
288        if query.iter().any(|v| !v.is_finite()) {
289            return Err(FibQuantError::NonFiniteInput(0));
290        }
291
292        let query_norm: f64 = query
293            .iter()
294            .map(|v| (*v as f64) * (*v as f64))
295            .sum::<f64>()
296            .sqrt();
297        if query_norm == 0.0 {
298            // Return a zero-filled prepared query — all scores will be 0.
299            let block_count = self.quantizer.profile().block_count() as usize;
300            return Ok(FibPreparedQuery {
301                rotated_query: vec![0.0f32; d],
302                query_norm: 0.0,
303                query_indices: vec![0u32; block_count],
304            });
305        }
306
307        // Normalize and rotate
308        let normalized: Vec<f64> = query.iter().map(|v| f64::from(*v) / query_norm).collect();
309        let rotated_query = self.quantizer_codebook_rotation_apply(&normalized)?;
310        let rotated_query_f32: Vec<f32> = rotated_query.iter().map(|&v| v as f32).collect();
311
312        // Precompute nearest codeword index per block
313        let _block_count = self.quantizer.profile().block_count() as usize;
314        let codewords = &self.quantizer.codebook().codewords;
315        let n = self.quantizer.profile().codebook_size as usize;
316        let c_indices = crate::ffi::c_encode_vector_block(&rotated_query_f32, codewords, n, k);
317        let query_indices: Vec<u32> = c_indices.iter().map(|&i| i as u32).collect();
318
319        Ok(FibPreparedQuery {
320            rotated_query: rotated_query_f32,
321            query_norm,
322            query_indices,
323        })
324    }
325
326    /// Score a single code against a prepared query.
327    ///
328    /// This skips the rotation and argmin steps (already done in
329    /// [`prepare_query`](Self::prepare_query)) and only performs:
330    /// 1. Unpack the stored code's indices.
331    /// 2. For each block: Gram table lookup `G[query_idx, stored_idx]`.
332    /// 3. Sum and scale by `query_norm * stored_norm`.
333    pub fn score_prepared(&self, prepared: &FibPreparedQuery, code: &FibCodeV1) -> Result<f32> {
334        if prepared.query_norm == 0.0 {
335            return Ok(0.0);
336        }
337
338        let block_count = self.quantizer.profile().block_count() as usize;
339        let stored_indices = unpack_indices(
340            &code.indices,
341            block_count,
342            self.quantizer.profile().wire_index_bits,
343        )?;
344
345        let stored_norm = decode_stored_norm(code, self.quantizer.profile())?;
346        let n = self.quantizer.profile().codebook_size as usize;
347
348        let mut total = 0.0f32;
349        for (block_idx, stored_idx) in stored_indices.iter().enumerate() {
350            let stored_idx = *stored_idx as usize;
351            if stored_idx >= n {
352                return Err(FibQuantError::IndexOutOfRange {
353                    index: stored_idx as u32,
354                    codebook_size: n as u32,
355                });
356            }
357            let query_idx = prepared.query_indices[block_idx] as usize;
358            total += self.gram.get(query_idx, stored_idx);
359        }
360
361        Ok(total * (prepared.query_norm as f32) * (stored_norm as f32))
362    }
363
364    /// Score a batch of codes against a prepared query.
365    ///
366    /// Like [`score_batch`](Self::score_batch) but uses the pre-rotated query,
367    /// avoiding redundant rotation + argmin per code.
368    /// Returns `Vec<ScoredItem>` sorted by descending score.
369    pub fn score_batch_prepared(
370        &self,
371        prepared: &FibPreparedQuery,
372        codes: &[FibCodeV1],
373    ) -> Result<Vec<ScoredItem>> {
374        let mut results = Vec::with_capacity(codes.len());
375        for (idx, code) in codes.iter().enumerate() {
376            let score = self.score_prepared(prepared, code)?;
377            results.push(ScoredItem { idx, score });
378        }
379        results.sort_by(|a, b| {
380            b.score
381                .partial_cmp(&a.score)
382                .unwrap_or(std::cmp::Ordering::Equal)
383        });
384        Ok(results)
385    }
386
387    /// Search top-K closest codes to a prepared query, with optional oversampling.
388    ///
389    /// Like [`search`](Self::search) but uses the pre-rotated query.
390    pub fn search_prepared(
391        &self,
392        prepared: &FibPreparedQuery,
393        codes: &[FibCodeV1],
394        top_k: usize,
395        oversample: usize,
396    ) -> Result<Vec<ScoredItem>> {
397        let limit = top_k.saturating_mul(oversample.max(1)).min(codes.len());
398        let scored = self.score_batch_prepared(prepared, codes)?;
399        Ok(scored.into_iter().take(limit).collect())
400    }
401
402    /// Apply the rotation to a query vector (internal helper).
403    fn quantizer_codebook_rotation_apply(&self, x: &[f64]) -> Result<Vec<f64>> {
404        // Use the exposed rotation from the quantizer — no reconstruction.
405        self.quantizer.rotation().apply(x)
406    }
407
408    /// Estimate L2 distance ||query - stored|| without decoding the stored vector.
409    ///
410    /// Uses the identity: ||q - v||^2 = ||q||^2 + ||v||^2 - 2<q, v>
411    /// where <q, v> is the approximate inner product from the Gram table.
412    /// Returns the estimated squared L2 distance (avoiding a sqrt for
413    /// comparison purposes — callers can sqrt if needed).
414    pub fn l2_distance_sq_estimate(&self, query: &[f32], code: &FibCodeV1) -> Result<f32> {
415        let ip = self.inner_product_estimate(query, code)?;
416        let q_norm_sq: f32 = query.iter().map(|v| v * v).sum();
417        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
418        let v_norm_sq = stored_norm * stored_norm;
419        Ok((q_norm_sq + v_norm_sq - 2.0 * ip).max(0.0))
420    }
421
422    /// Estimate cosine similarity <query, stored> / (||query|| * ||stored||)
423    /// without decoding the stored vector.
424    pub fn cosine_estimate(&self, query: &[f32], code: &FibCodeV1) -> Result<f32> {
425        let ip = self.inner_product_estimate(query, code)?;
426        let q_norm: f32 = query.iter().map(|v| v * v).sum::<f32>().sqrt();
427        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
428        if q_norm == 0.0 || stored_norm == 0.0 {
429            return Ok(0.0);
430        }
431        Ok(ip / (q_norm * stored_norm))
432    }
433
434    /// L2 distance using a prepared query — avoids recomputing query rotation.
435    pub fn l2_distance_sq_prepared(
436        &self,
437        prepared: &FibPreparedQuery,
438        code: &FibCodeV1,
439    ) -> Result<f32> {
440        let ip = self.score_prepared(prepared, code)?;
441        let q_norm_sq = (prepared.query_norm * prepared.query_norm) as f32;
442        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
443        let v_norm_sq = stored_norm * stored_norm;
444        Ok((q_norm_sq + v_norm_sq - 2.0 * ip).max(0.0))
445    }
446
447    /// Cosine similarity using a prepared query.
448    pub fn cosine_prepared(&self, prepared: &FibPreparedQuery, code: &FibCodeV1) -> Result<f32> {
449        let ip = self.score_prepared(prepared, code)?;
450        let q_norm = prepared.query_norm as f32;
451        let stored_norm = decode_stored_norm(code, self.quantizer.profile())? as f32;
452        if q_norm == 0.0 || stored_norm == 0.0 {
453            return Ok(0.0);
454        }
455        Ok(ip / (q_norm * stored_norm))
456    }
457}
458
459pub fn decode_stored_norm(code: &FibCodeV1, _profile: &FibQuantProfileV1) -> Result<f64> {
460    // Decode the norm from the code's norm_payload. We handle both
461    // Fp16Paper and F32Reference formats here to avoid depending on
462    // the private decode_norm function in codec.rs.
463    use crate::profile::NormFormat;
464    match code.norm_format {
465        NormFormat::Fp16Paper => {
466            let bytes: [u8; 2] =
467                code.norm_payload.as_slice().try_into().map_err(|_| {
468                    FibQuantError::CorruptPayload("fp16 norm payload length".into())
469                })?;
470            let value = f16::from_le_bytes(bytes).to_f32() as f64;
471            if value.is_finite() && value > 0.0 {
472                Ok(value)
473            } else {
474                Err(FibQuantError::CorruptPayload("invalid fp16 norm".into()))
475            }
476        }
477        NormFormat::F32Reference => {
478            let bytes: [u8; 4] = code
479                .norm_payload
480                .as_slice()
481                .try_into()
482                .map_err(|_| FibQuantError::CorruptPayload("f32 norm payload length".into()))?;
483            let value = f32::from_le_bytes(bytes) as f64;
484            if value.is_finite() && value > 0.0 {
485                Ok(value)
486            } else {
487                Err(FibQuantError::CorruptPayload("invalid f32 norm".into()))
488            }
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn build_test_scorer() -> Result<FibScorer> {
498        let mut profile = FibQuantProfileV1::paper_default(8, 2, 8, 7)?;
499        profile.training_samples = 128;
500        profile.lloyd_restarts = 1;
501        profile.lloyd_iterations = 2;
502        let quantizer = FibQuantizer::new(profile)?;
503        FibScorer::new(quantizer)
504    }
505
506    #[test]
507    fn gram_table_diagonal_matches_codeword_norms() -> Result<()> {
508        let scorer = build_test_scorer()?;
509        let k = scorer.quantizer.profile().block_dim as usize;
510        let n = scorer.quantizer.profile().codebook_size as usize;
511        let codewords = &scorer.quantizer.codebook().codewords;
512        for i in 0..n {
513            let mut norm_sq = 0.0f32;
514            for d in 0..k {
515                let v = codewords[i * k + d];
516                norm_sq += v * v;
517            }
518            let gram_diag = scorer.gram.get(i, i);
519            assert!(
520                (norm_sq - gram_diag).abs() < 1e-5,
521                "gram diagonal mismatch at {}: ||cw||^2 = {}, gram = {}",
522                i,
523                norm_sq,
524                gram_diag
525            );
526        }
527        Ok(())
528    }
529
530    #[test]
531    fn gram_table_symmetric() -> Result<()> {
532        let scorer = build_test_scorer()?;
533        let n = scorer.gram.n();
534        for i in 0..n {
535            for j in 0..n {
536                assert!(
537                    (scorer.gram.get(i, j) - scorer.gram.get(j, i)).abs() < 1e-6,
538                    "gram not symmetric at ({}, {})",
539                    i,
540                    j
541                );
542            }
543        }
544        Ok(())
545    }
546
547    #[test]
548    fn inner_product_estimate_positive_for_self() -> Result<()> {
549        let scorer = build_test_scorer()?;
550        let d = scorer.quantizer.profile().ambient_dim as usize;
551        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
552        assert_eq!(input.len(), d);
553        let code = scorer.quantizer.encode(&input)?;
554        let est = scorer.inner_product_estimate(&input, &code)?;
555        // <x, x> > 0 for a non-zero vector
556        assert!(
557            est > 0.0,
558            "inner product estimate of self should be positive, got {}",
559            est
560        );
561        // It should be in the ballpark of ||x||^2
562        let true_ip: f32 = input.iter().map(|v| v * v).sum();
563        let ratio = est / true_ip;
564        assert!(
565            ratio > 0.5 && ratio < 2.0,
566            "estimate {} vs true {} — ratio {} out of [0.5, 2.0]",
567            est,
568            true_ip,
569            ratio
570        );
571        Ok(())
572    }
573
574    #[test]
575    fn search_returns_sorted_descending() -> Result<()> {
576        let scorer = build_test_scorer()?;
577        let d = scorer.quantizer.profile().ambient_dim as usize;
578        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
579        assert_eq!(query.len(), d);
580
581        // Encode several vectors
582        let vectors: Vec<Vec<f32>> = (0..16)
583            .map(|seed| {
584                (0..d)
585                    .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
586                    .collect()
587            })
588            .collect();
589        let codes: Vec<FibCodeV1> = vectors
590            .iter()
591            .map(|v| scorer.quantizer.encode(v).unwrap())
592            .collect();
593
594        let results = scorer.search(&query, &codes, 5, 2)?;
595        // top_k=5, oversample=2 → returns min(5*2, 16) = 10 candidates
596        assert_eq!(results.len(), 10);
597        for w in results.windows(2) {
598            assert!(
599                w[0].score >= w[1].score,
600                "results not sorted: {} before {}",
601                w[0].score,
602                w[1].score
603            );
604        }
605        Ok(())
606    }
607
608    #[test]
609    fn score_batch_handles_empty() -> Result<()> {
610        let scorer = build_test_scorer()?;
611        let d = scorer.quantizer.profile().ambient_dim as usize;
612        let query = vec![0.0f32; d];
613        let results = scorer.score_batch(&query, &[])?;
614        assert!(results.is_empty());
615        Ok(())
616    }
617
618    #[test]
619    fn prepared_query_matches_inner_product_estimate() -> Result<()> {
620        let scorer = build_test_scorer()?;
621        let d = scorer.quantizer.profile().ambient_dim as usize;
622        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
623        assert_eq!(query.len(), d);
624
625        // Encode several vectors
626        let vectors: Vec<Vec<f32>> = (0..16)
627            .map(|seed| {
628                (0..d)
629                    .map(|i| (seed as f32 * 0.1 + i as f32 * 0.05 - 0.3).sin())
630                    .collect()
631            })
632            .collect();
633        let codes: Vec<FibCodeV1> = vectors
634            .iter()
635            .map(|v| scorer.quantizer.encode(v).unwrap())
636            .collect();
637
638        let prepared = scorer.prepare_query(&query)?;
639        for (i, code) in codes.iter().enumerate() {
640            let direct = scorer.inner_product_estimate(&query, code)?;
641            let prepared_score = scorer.score_prepared(&prepared, code)?;
642            assert!(
643                (direct - prepared_score).abs() < 1e-4,
644                "mismatch at code {}: direct={}, prepared={}",
645                i,
646                direct,
647                prepared_score
648            );
649        }
650        Ok(())
651    }
652
653    #[test]
654    fn prepared_batch_matches_score_batch() -> Result<()> {
655        let scorer = build_test_scorer()?;
656        let d = scorer.quantizer.profile().ambient_dim as usize;
657        let query: Vec<f32> = vec![0.3, 0.7, -0.2, 0.9, -0.5, 0.1, -0.8, 0.4];
658        assert_eq!(query.len(), d);
659
660        let vectors: Vec<Vec<f32>> = (0..24)
661            .map(|seed| {
662                (0..d)
663                    .map(|i| ((seed as f32 + i as f32) * 0.13).cos())
664                    .collect()
665            })
666            .collect();
667        let codes: Vec<FibCodeV1> = vectors
668            .iter()
669            .map(|v| scorer.quantizer.encode(v).unwrap())
670            .collect();
671
672        let batch = scorer.score_batch(&query, &codes)?;
673        let prepared = scorer.prepare_query(&query)?;
674        let batch_prepared = scorer.score_batch_prepared(&prepared, &codes)?;
675
676        assert_eq!(batch.len(), batch_prepared.len());
677        for (a, b) in batch.iter().zip(batch_prepared.iter()) {
678            assert_eq!(a.idx, b.idx);
679            assert!(
680                (a.score - b.score).abs() < 1e-4,
681                "score mismatch at idx {}: batch={}, prepared={}",
682                a.idx,
683                a.score,
684                b.score
685            );
686        }
687        Ok(())
688    }
689
690    #[test]
691    fn prepared_search_matches_search() -> Result<()> {
692        let scorer = build_test_scorer()?;
693        let d = scorer.quantizer.profile().ambient_dim as usize;
694        let query: Vec<f32> = vec![0.6, -0.1, 0.3, -0.7, 0.8, 0.2, -0.4, 0.5];
695        assert_eq!(query.len(), d);
696
697        let vectors: Vec<Vec<f32>> = (0..32)
698            .map(|seed| {
699                (0..d)
700                    .map(|i| (seed as f32 * 0.17 + i as f32 * 0.03).sin())
701                    .collect()
702            })
703            .collect();
704        let codes: Vec<FibCodeV1> = vectors
705            .iter()
706            .map(|v| scorer.quantizer.encode(v).unwrap())
707            .collect();
708
709        let direct = scorer.search(&query, &codes, 5, 2)?;
710        let prepared = scorer.prepare_query(&query)?;
711        let prepared_results = scorer.search_prepared(&prepared, &codes, 5, 2)?;
712
713        assert_eq!(direct.len(), prepared_results.len());
714        for (a, b) in direct.iter().zip(prepared_results.iter()) {
715            assert_eq!(a.idx, b.idx);
716            assert!(
717                (a.score - b.score).abs() < 1e-4,
718                "search mismatch at idx {}: direct={}, prepared={}",
719                a.idx,
720                a.score,
721                b.score
722            );
723        }
724        Ok(())
725    }
726
727    #[test]
728    fn prepared_query_zero_norm() -> Result<()> {
729        let scorer = build_test_scorer()?;
730        let d = scorer.quantizer.profile().ambient_dim as usize;
731        let query = vec![0.0f32; d];
732        let prepared = scorer.prepare_query(&query)?;
733        assert_eq!(prepared.query_norm, 0.0);
734
735        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
736        let code = scorer.quantizer.encode(&input)?;
737        let score = scorer.score_prepared(&prepared, &code)?;
738        assert!(score.abs() < 1e-6, "zero query should give zero score");
739        Ok(())
740    }
741
742    #[test]
743    fn l2_distance_is_non_negative() -> Result<()> {
744        let scorer = build_test_scorer()?;
745        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
746        let code = scorer.quantizer.encode(&input)?;
747        let dist = scorer.l2_distance_sq_estimate(&input, &code)?;
748        assert!(
749            dist >= 0.0,
750            "L2 distance squared should be non-negative, got {}",
751            dist
752        );
753        Ok(())
754    }
755
756    #[test]
757    fn cosine_estimate_in_valid_range() -> Result<()> {
758        let scorer = build_test_scorer()?;
759        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
760        let code = scorer.quantizer.encode(&input)?;
761        let cos = scorer.cosine_estimate(&input, &code)?;
762        assert!(
763            (-1.5..=1.5).contains(&cos),
764            "cosine should be in [-1.5, 1.5], got {}",
765            cos
766        );
767        Ok(())
768    }
769
770    #[test]
771    fn cosine_prepared_matches_cosine_estimate() -> Result<()> {
772        let scorer = build_test_scorer()?;
773        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
774        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
775        let code = scorer.quantizer.encode(&input)?;
776        let cos_direct = scorer.cosine_estimate(&query, &code)?;
777        let prepared = scorer.prepare_query(&query)?;
778        let cos_prepared = scorer.cosine_prepared(&prepared, &code)?;
779        assert!(
780            (cos_direct - cos_prepared).abs() < 1e-5,
781            "prepared cosine {} should match direct {}",
782            cos_prepared,
783            cos_direct
784        );
785        Ok(())
786    }
787
788    #[test]
789    fn l2_prepared_matches_l2_estimate() -> Result<()> {
790        let scorer = build_test_scorer()?;
791        let query: Vec<f32> = vec![0.5, -0.3, 0.8, -0.1, 0.2, -0.4, 0.7, -0.6];
792        let input: Vec<f32> = vec![0.25, -0.5, 0.75, 1.0, -1.25, 0.5, 0.125, -0.875];
793        let code = scorer.quantizer.encode(&input)?;
794        let dist_direct = scorer.l2_distance_sq_estimate(&query, &code)?;
795        let prepared = scorer.prepare_query(&query)?;
796        let dist_prepared = scorer.l2_distance_sq_prepared(&prepared, &code)?;
797        assert!(
798            (dist_direct - dist_prepared).abs() < 1e-5,
799            "prepared L2 {} should match direct {}",
800            dist_prepared,
801            dist_direct
802        );
803        Ok(())
804    }
805}