Skip to main content

docbert_plaid/
search.rs

1//! Query-time search over a built [`Index`].
2//!
3//! The flow mirrors PLAID's reference implementation:
4//!
5//! 1. For every query token, find the `n_probe` top dot-product coarse
6//!    centroids (matching `S_c,q = C · Q^T`). This is paper Stage 1.
7//! 2. *Optional* centroid pruning: drop probed centroids whose best
8//!    score across all query tokens sits below
9//!    `centroid_score_threshold`. This is used both here (to shrink the
10//!    reachable centroid set before postings are gathered) and below,
11//!    at token level, when scoring `D̃`.
12//! 3. Union the doc-level IVF postings for the surviving centroids to
13//!    get a set of candidate documents.
14//! 4. *Optional* centroid interaction. When `n_candidate_docs` is set,
15//!    the cascade runs in two refinement passes:
16//!    - Stage 2 (paper §4.3): approximate MaxSim with the pruned mask
17//!      applied at token level; keep top `n_candidate_docs` (paper's
18//!      `ndocs`).
19//!    - Stage 3 (paper §4.2): approximate MaxSim without pruning on
20//!      the Stage 2 survivors; keep top `n_candidate_docs / 4` (paper's
21//!      `ndocs/4` empirical heuristic), clamped to at least `top_k`.
22//! 5. Decode the survivors' stored residual codes into approximate
23//!    embeddings and compute exact MaxSim against the query.
24//! 6. Sort by exact MaxSim score and return the top-`top_k`.
25//!
26//! The pruning and interaction stages share a precomputed
27//! `[n_query_tokens, n_centroids]` query-centroid score matrix that
28//! step 1 would conceptually compute anyway, and together they
29//! drastically shrink the set of candidates reaching the expensive
30//! decode in step 5. Callers that want the legacy behaviour (every
31//! probed candidate decodes, no pruning) can pass
32//! `n_candidate_docs = None` and `centroid_score_threshold = None`.
33
34use candle_core::{Device, Tensor};
35
36use crate::{
37    Result,
38    codec::DecodeTable,
39    device::default_device,
40    distance::dot,
41    index::Index,
42};
43
44/// Tunable knobs for a single search call.
45#[derive(Debug, Clone, Copy)]
46pub struct SearchParams {
47    /// Number of top-scoring documents to return.
48    pub top_k: usize,
49    /// Number of top dot-product centroids each query token probes.
50    pub n_probe: usize,
51    /// Maximum number of candidates surviving PLAID's
52    /// centroid-interaction stage that proceed to full decode +
53    /// exact MaxSim. `None` disables centroid interaction and sends
54    /// every probed candidate straight to decode — correct but
55    /// slower for large corpora.
56    pub n_candidate_docs: Option<usize>,
57    /// Minimum per-centroid score (max dot product against any query
58    /// token) required for a centroid to contribute to candidate
59    /// generation and centroid interaction. Centroids below this
60    /// threshold are treated as unreachable for this query. `None`
61    /// disables pruning, matching the legacy probe behaviour.
62    pub centroid_score_threshold: Option<f32>,
63}
64
65impl SearchParams {
66    /// Defaults from Table 2 of the PLAID paper (Santhanam et al., 2022),
67    /// bucketed by requested `top_k`.
68    ///
69    /// | top_k bucket | nprobe | t_cs | ndocs |
70    /// |---           |---     |---   |---    |
71    /// | ≤ 10         | 1      | 0.5  | 256   |
72    /// | ≤ 100        | 2      | 0.45 | 1024  |
73    /// | > 100        | 4      | 0.4  | 4096  |
74    ///
75    /// The paper derives these empirically on MS MARCO and LoTTE; they
76    /// trade a small recall hit for large latency wins versus an
77    /// exhaustive probe. `ndocs` is always at least `4 * top_k` so
78    /// Stage 3's `ndocs/4` shortlist can still return enough candidates
79    /// for the caller's requested result count.
80    pub const fn paper_defaults(top_k: usize) -> Self {
81        let (n_probe, t_cs, ndocs) = if top_k <= 10 {
82            (1, 0.5_f32, 256)
83        } else if top_k <= 100 {
84            (2, 0.45_f32, 1024)
85        } else {
86            (4, 0.4_f32, 4096)
87        };
88        let ndocs = if ndocs < top_k.saturating_mul(4) {
89            top_k.saturating_mul(4)
90        } else {
91            ndocs
92        };
93        Self {
94            top_k,
95            n_probe,
96            n_candidate_docs: Some(ndocs),
97            centroid_score_threshold: Some(t_cs),
98        }
99    }
100}
101
102/// One entry in a ranked search result list.
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct SearchResult {
105    pub doc_id: u64,
106    pub score: f32,
107}
108
109/// Run a PLAID-style search over `index` and return the top-`top_k`
110/// documents ranked by ColBERT MaxSim against `query_tokens`.
111///
112/// `query_tokens` is a flat row-major `n_query_tokens × dim` buffer.
113/// Results are sorted by score, highest first. Ties are broken by
114/// `doc_id` ascending to keep the output deterministic.
115///
116/// # Errors
117///
118/// Returns [`PlaidError::Tensor`] if the batched MaxSim matmul fails
119/// (e.g. CUDA OOM on the final decode tensor).
120///
121/// # Panics
122///
123/// Panics if `query_tokens.len() % index.params.dim != 0`, if
124/// `params.top_k == 0`, or if `params.n_probe == 0`.
125///
126/// [`PlaidError::Tensor`]: crate::PlaidError::Tensor
127pub fn search(
128    index: &Index,
129    query_tokens: &[f32],
130    params: SearchParams,
131) -> Result<Vec<SearchResult>> {
132    let dim = index.params.dim;
133    assert!(params.top_k > 0, "search: top_k must be positive");
134    assert!(params.n_probe > 0, "search: n_probe must be positive");
135    assert!(
136        query_tokens.len().is_multiple_of(dim),
137        "search: query length {} is not a multiple of dim {}",
138        query_tokens.len(),
139        dim,
140    );
141
142    if query_tokens.is_empty() || index.num_documents() == 0 {
143        return Ok(Vec::new());
144    }
145
146    let n_centroids = index.codec.num_centroids();
147    let n_probe = params.n_probe.min(n_centroids);
148
149    // Precompute the per-centroid max dot product against any query
150    // token when either pruning or centroid interaction is active.
151    // Pruning uses it to build a pruned-centroid bitmask for use in
152    // both candidate generation and centroid interaction; centroid
153    // interaction uses the full qc_scores matrix for approximate
154    // per-doc scoring.
155    let qc_scores: Option<Vec<f32>> = (params
156        .centroid_score_threshold
157        .is_some()
158        || params.n_candidate_docs.is_some())
159    .then(|| {
160        query_centroid_score_matrix(query_tokens, &index.codec.centroids, dim)
161    });
162    let pruned_mask: Option<Vec<bool>> =
163        match (qc_scores.as_ref(), params.centroid_score_threshold) {
164            (Some(scores), Some(threshold)) => {
165                let per_cent = per_centroid_max_scores(
166                    scores,
167                    query_tokens.len() / dim,
168                    n_centroids,
169                );
170                Some(per_cent.iter().map(|&s| s < threshold).collect())
171            }
172            _ => None,
173        };
174
175    // 1-2. Gather the union of candidate doc indices reachable via the
176    //      probed centroids. IVF postings are already deduplicated per
177    //      doc, so each centroid contributes at most one write per doc.
178    //      Centroid pruning drops probed centroids whose best per-query
179    //      score sits below the caller's threshold — skipping them
180    //      both shrinks the candidate set and saves work later in
181    //      centroid interaction.
182    let mut candidate_docs: Vec<bool> = vec![false; index.num_documents()];
183    for query_token in query_tokens.chunks_exact(dim) {
184        for centroid_id in
185            top_n_centroids(query_token, &index.codec.centroids, dim, n_probe)
186        {
187            if let Some(mask) = pruned_mask.as_ref()
188                && mask[centroid_id]
189            {
190                continue;
191            }
192            for &doc_idx in index.ivf.docs_for_centroid(centroid_id) {
193                candidate_docs[doc_idx as usize] = true;
194            }
195        }
196    }
197
198    let mut candidate_idxs: Vec<usize> = candidate_docs
199        .iter()
200        .enumerate()
201        .filter_map(|(idx, &is_cand)| {
202            (is_cand && index.doc_token_count(idx) > 0).then_some(idx)
203        })
204        .collect();
205
206    // 3. Centroid interaction: when the caller set `n_candidate_docs`,
207    //    cheaply rank candidates via the precomputed query-centroid
208    //    score matrix and keep only the top survivors before paying
209    //    for full decode + exact MaxSim.
210    //
211    //    The `if let` pattern-matches both fields at once so the
212    //    compiler can prove qc_scores is Some under this arm — no
213    //    `expect` gymnastics needed.
214    if let (Some(n_stage2), Some(qc_scores)) =
215        (params.n_candidate_docs, qc_scores.as_ref())
216    {
217        let n_q = query_tokens.len() / dim;
218        let n_c = index.codec.num_centroids();
219
220        // Stage 2 — cheap pruned centroid interaction. Keep top
221        // `n_stage2` candidates (the paper's `ndocs`).
222        candidate_idxs = shortlist_by_approx_score(
223            candidate_idxs,
224            index,
225            qc_scores,
226            n_q,
227            n_c,
228            pruned_mask.as_deref(),
229            n_stage2,
230        );
231
232        // Stage 3 — unpruned centroid interaction. Refines the Stage 2
233        // survivors down to `ndocs/4`, matching the paper's empirical
234        // heuristic. Callers that want top-`k` results should set
235        // `n_candidate_docs >= 4 * top_k` so Stage 3 doesn't undershoot
236        // their requested result count. docbert-core's default already
237        // does this (8 * top_k).
238        let n_stage3 = n_stage2.div_ceil(4).max(1);
239        candidate_idxs = shortlist_by_approx_score(
240            candidate_idxs,
241            index,
242            qc_scores,
243            n_q,
244            n_c,
245            None,
246            n_stage3,
247        );
248    }
249
250    // 5. Score every surviving candidate with one batched MaxSim matmul
251    //    on decoded tokens.
252    let mut scored: Vec<SearchResult> = if candidate_idxs.is_empty() {
253        Vec::new()
254    } else {
255        batch_maxsim(query_tokens, &candidate_idxs, index, dim)?
256            .into_iter()
257            .map(|(doc_idx, score)| SearchResult {
258                doc_id: index.doc_ids[doc_idx],
259                score,
260            })
261            .collect()
262    };
263
264    // 6. Rank by score (desc), tie-break by doc_id (asc) for determinism.
265    scored.sort_by(|a, b| {
266        b.score
267            .partial_cmp(&a.score)
268            .unwrap_or(std::cmp::Ordering::Equal)
269            .then_with(|| a.doc_id.cmp(&b.doc_id))
270    });
271    scored.truncate(params.top_k);
272    Ok(scored)
273}
274
275/// Indices of the `n` centroids with the highest dot-product against
276/// `point`, most relevant first.
277///
278/// This is the multi-centroid counterpart to
279/// [`crate::kmeans::nearest_centroid`]. It implements PLAID's
280/// query-centroid scoring `S_c,q = C · Q^T` (one row per query token),
281/// which is the ranking the paper uses for candidate generation.
282///
283/// Dot product is used here instead of squared L2 because the paper and
284/// ColBERT's downstream MaxSim both operate on dot-product similarity.
285/// For centroids with varying magnitudes the two metrics disagree, and
286/// dot product is what keeps probe ordering consistent with the scorer.
287/// Ties are broken toward the earlier centroid index for determinism.
288///
289/// The output vector has length `min(n, num_centroids)`.
290///
291/// # Panics
292///
293/// Panics on any shape mismatch between `point`, `centroids`, and `dim`.
294pub fn top_n_centroids(
295    point: &[f32],
296    centroids: &[f32],
297    dim: usize,
298    n: usize,
299) -> Vec<usize> {
300    assert_eq!(
301        point.len(),
302        dim,
303        "top_n_centroids: point length {} does not match dim {}",
304        point.len(),
305        dim,
306    );
307    assert!(dim > 0, "top_n_centroids: dim must be positive");
308    assert!(
309        !centroids.is_empty() && centroids.len().is_multiple_of(dim),
310        "top_n_centroids: centroids length {} is not a positive multiple of dim {}",
311        centroids.len(),
312        dim,
313    );
314
315    let k = centroids.len() / dim;
316    let mut scored: Vec<(usize, f32)> = centroids
317        .chunks_exact(dim)
318        .enumerate()
319        .map(|(i, c)| (i, dot(point, c)))
320        .collect();
321    scored.sort_by(|a, b| {
322        b.1.partial_cmp(&a.1)
323            .unwrap_or(std::cmp::Ordering::Equal)
324            .then_with(|| a.0.cmp(&b.0))
325    });
326    scored.into_iter().take(n.min(k)).map(|(i, _)| i).collect()
327}
328
329/// Compute the row-major `[n_q, n_centroids]` matrix of query-to-centroid
330/// dot products used by PLAID's centroid-interaction stage.
331///
332/// Materialising the whole matrix once amortises the per-centroid work
333/// across every candidate document — the same query-centroid entries
334/// get hit by every doc that touches each centroid.
335fn query_centroid_score_matrix(
336    query_tokens: &[f32],
337    centroids: &[f32],
338    dim: usize,
339) -> Vec<f32> {
340    let n_q = query_tokens.len() / dim;
341    let n_c = centroids.len() / dim;
342    let mut out = vec![0.0f32; n_q * n_c];
343    for (qi, q) in query_tokens.chunks_exact(dim).enumerate() {
344        for (ci, c) in centroids.chunks_exact(dim).enumerate() {
345            out[qi * n_c + ci] = dot(q, c);
346        }
347    }
348    out
349}
350
351/// Rank `candidate_idxs` by approximate centroid-interaction score
352/// and keep the top `limit` survivors.
353///
354/// A no-op when the input is already smaller than `limit`. Used by
355/// both Stage 2 (pruned) and Stage 3 (unpruned) of the paper's
356/// centroid-interaction cascade; the only difference between the two
357/// callers is the `mask` argument.
358fn shortlist_by_approx_score(
359    candidate_idxs: Vec<usize>,
360    index: &Index,
361    qc_scores: &[f32],
362    n_q: usize,
363    n_centroids: usize,
364    mask: Option<&[bool]>,
365    limit: usize,
366) -> Vec<usize> {
367    if candidate_idxs.len() <= limit {
368        return candidate_idxs;
369    }
370    let mut approx: Vec<(usize, f32)> = candidate_idxs
371        .into_iter()
372        .map(|doc_idx| {
373            let score = approx_centroid_interaction_score(
374                index.doc_centroid_ids(doc_idx),
375                qc_scores,
376                n_q,
377                n_centroids,
378                mask,
379            );
380            (doc_idx, score)
381        })
382        .collect();
383    approx.sort_by(|a, b| {
384        b.1.partial_cmp(&a.1)
385            .unwrap_or(std::cmp::Ordering::Equal)
386            .then_with(|| a.0.cmp(&b.0))
387    });
388    approx.truncate(limit);
389    approx.into_iter().map(|(idx, _)| idx).collect()
390}
391
392/// For every centroid, return the max dot-product score achieved by
393/// any query token against that centroid.
394///
395/// PLAID's centroid pruning uses this "best any-query score" to cheaply
396/// rank centroids by query relevance: a centroid whose best score
397/// across all query tokens is still small cannot usefully participate
398/// in MaxSim, so it can be dropped without hurting top-K.
399fn per_centroid_max_scores(
400    qc_scores: &[f32],
401    n_q: usize,
402    n_centroids: usize,
403) -> Vec<f32> {
404    let mut out = vec![f32::NEG_INFINITY; n_centroids];
405    for q in 0..n_q {
406        let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
407        for (c, &s) in row.iter().enumerate() {
408            if s > out[c] {
409                out[c] = s;
410            }
411        }
412    }
413    out
414}
415
416/// Approximate MaxSim for one candidate doc using only the centroids
417/// the doc touches. This is PLAID's centroid-interaction score:
418///
419/// `Σ_q max_{c ∈ unique_centroids(doc)} qc_scores[q, c]`
420///
421/// Duplicate centroid ids within the doc are filtered implicitly: a
422/// centroid that appears twice can't beat itself in the max, so the
423/// running-max shortcut is correct without an explicit dedup pass.
424///
425/// When `pruned_mask` is `Some`, tokens whose centroid is marked
426/// pruned are skipped before the max, matching PLAID §4.3's token-level
427/// pruning of `D̃`. If every token in the doc is pruned the result is
428/// `f32::NEG_INFINITY`, which sorts below every non-pruned doc in the
429/// centroid-interaction shortlist.
430fn approx_centroid_interaction_score(
431    doc_centroid_ids: &[u32],
432    qc_scores: &[f32],
433    n_q: usize,
434    n_centroids: usize,
435    pruned_mask: Option<&[bool]>,
436) -> f32 {
437    if doc_centroid_ids.is_empty() {
438        return 0.0;
439    }
440    // If every token's centroid is pruned, the doc has no D̃ rows left;
441    // report −∞ so the caller's ranking drops it instead of a
442    // deceptive 0.0.
443    if let Some(mask) = pruned_mask
444        && doc_centroid_ids.iter().all(|cid| mask[*cid as usize])
445    {
446        return f32::NEG_INFINITY;
447    }
448    let mut total = 0.0f32;
449    for q in 0..n_q {
450        let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
451        let mut best = f32::NEG_INFINITY;
452        for &cid in doc_centroid_ids {
453            if let Some(mask) = pruned_mask
454                && mask[cid as usize]
455            {
456                continue;
457            }
458            let s = row[cid as usize];
459            if s > best {
460                best = s;
461            }
462        }
463        if best.is_finite() {
464            total += best;
465        }
466    }
467    total
468}
469
470/// Score a batch of candidate docs against `query_tokens` with a
471/// padding-free packed MaxSim, matching PLAID §4.5.
472///
473/// Two decode strategies share the downstream MaxSim reduction:
474///
475/// - On CPU the fastest path is the hand-rolled LUT walk — a byte
476///   lookup per packed slot beats candle's generic `index_select`
477///   because there's no kernel-launch overhead to amortise.
478/// - On CUDA the right move is to keep the whole flow on-device:
479///   upload the centroid bank and weights LUT once, gather token
480///   residuals via two `index_select` ops, add, and feed straight
481///   into the MaxSim GEMM. This mirrors PLAID's §4.5 CUDA kernel
482///   (one thread per packed byte, centroid-add, matmul) without
483///   re-implementing it by hand.
484///
485/// The per-doc max-then-sum reduction still runs on the host because
486/// candle doesn't expose a native segmented-max primitive and we want
487/// to avoid materialising the padded `[n_docs, max_len, n_q]` tensor
488/// the paper explicitly rejects.
489fn batch_maxsim(
490    query_tokens: &[f32],
491    candidate_idxs: &[usize],
492    index: &Index,
493    dim: usize,
494) -> Result<Vec<(usize, f32)>> {
495    let n_q = query_tokens.len() / dim;
496
497    let decode_table = DecodeTable::new(&index.codec);
498    let total_tokens: usize = candidate_idxs
499        .iter()
500        .map(|&i| index.doc_token_count(i))
501        .sum();
502    let mut offsets: Vec<usize> = Vec::with_capacity(candidate_idxs.len() + 1);
503    offsets.push(0);
504
505    if total_tokens == 0 || n_q == 0 {
506        return Ok(candidate_idxs.iter().map(|&i| (i, 0.0)).collect());
507    }
508
509    let device = default_device();
510    let ctx = DecodeCtx {
511        index,
512        candidate_idxs,
513        dim,
514        total_tokens,
515        decode_table: &decode_table,
516        device,
517    };
518    let decoded = if matches!(device, Device::Cpu) {
519        decode_on_cpu(&ctx, &mut offsets)?
520    } else {
521        decode_on_device(&ctx, &mut offsets)?
522    };
523
524    // Single `[total_tokens, dim] × [dim, n_q]` GEMM. No padding, no
525    // mask — the packed layout means every row is a real token.
526    let q_t = Tensor::from_slice(query_tokens, (n_q, dim), device)?;
527    let q_transposed = q_t.t()?.contiguous()?;
528    let scores_flat: Vec<f32> = decoded
529        .matmul(&q_transposed)?
530        .to_vec2::<f32>()?
531        .into_iter()
532        .flatten()
533        .collect();
534
535    // Per-doc MaxSim reduction over the doc's slice of score rows.
536    let mut out = Vec::with_capacity(candidate_idxs.len());
537    for (i, &doc_idx) in candidate_idxs.iter().enumerate() {
538        let start = offsets[i];
539        let end = offsets[i + 1];
540        if start == end {
541            out.push((doc_idx, 0.0));
542            continue;
543        }
544        let mut total = 0.0f32;
545        for q in 0..n_q {
546            let mut best = f32::NEG_INFINITY;
547            for t in start..end {
548                let s = scores_flat[t * n_q + q];
549                if s > best {
550                    best = s;
551                }
552            }
553            if best.is_finite() {
554                total += best;
555            }
556        }
557        out.push((doc_idx, total));
558    }
559    Ok(out)
560}
561
562/// Inputs the two decode strategies share. Grouping them keeps the
563/// call-site readable and satisfies clippy's `too_many_arguments`
564/// without leaking implementation details into the public API.
565struct DecodeCtx<'a> {
566    index: &'a Index,
567    candidate_idxs: &'a [usize],
568    dim: usize,
569    total_tokens: usize,
570    decode_table: &'a DecodeTable,
571    device: &'a Device,
572}
573
574/// CPU decode path: walk every candidate token, look up each packed
575/// byte in the precomputed LUT, add the centroid dimensions, push
576/// into one big row-major buffer, then upload as a single tensor.
577///
578/// Fast on CPU because the whole loop is contiguous writes plus a
579/// 256-entry table of 4 KiB at `nbits=2` that lives in L1 the entire
580/// call — measurably faster than routing every token through candle
581/// `index_select`, which pays kernel-launch overhead per gather.
582fn decode_on_cpu(
583    ctx: &DecodeCtx<'_>,
584    offsets: &mut Vec<usize>,
585) -> Result<Tensor> {
586    let codec = &ctx.index.codec;
587    let packed_bytes = codec.packed_bytes();
588    let mut packed: Vec<f32> = Vec::with_capacity(ctx.total_tokens * ctx.dim);
589    let mut ev = crate::codec::EncodedVector {
590        centroid_id: 0,
591        codes: vec![0u8; packed_bytes],
592    };
593    for &doc_idx in ctx.candidate_idxs {
594        let cids = ctx.index.doc_centroid_ids(doc_idx);
595        let bytes = ctx.index.doc_residual_bytes(doc_idx);
596        for (i, &cid) in cids.iter().enumerate() {
597            ev.centroid_id = cid;
598            ev.codes.copy_from_slice(
599                &bytes[i * packed_bytes..(i + 1) * packed_bytes],
600            );
601            let decoded =
602                codec.decode_vector_with_table(&ev, ctx.decode_table)?;
603            // Row-wise L2 normalise, mirroring decode_on_device's tail
604            // so CPU and GPU paths return comparable scores. See
605            // `l2_normalize_rows` for why this matters.
606            let norm = decoded
607                .iter()
608                .map(|v| v * v)
609                .sum::<f32>()
610                .sqrt()
611                .max(1e-12_f32);
612            packed.extend(decoded.iter().map(|v| v / norm));
613        }
614        offsets.push(packed.len() / ctx.dim);
615    }
616    Ok(Tensor::from_vec(
617        packed,
618        (ctx.total_tokens, ctx.dim),
619        ctx.device,
620    )?)
621}
622
623/// GPU decode path: upload the centroid bank and the 256-entry LUT,
624/// then recover every candidate token via two `index_select` gathers
625/// and an add — all on-device. This keeps the decode inside the same
626/// kernel stream as the MaxSim matmul, which is the win PLAID §4.5
627/// describes for its CUDA implementation.
628///
629/// When `dim` isn't a multiple of `codes_per_byte`, the packed
630/// residual buffer has `packed_bytes * codes_per_byte - dim` trailing
631/// slack slots. We decode the padded row width and `narrow` back to
632/// `dim` so the output tensor has exactly the expected shape.
633fn decode_on_device(
634    ctx: &DecodeCtx<'_>,
635    offsets: &mut Vec<usize>,
636) -> Result<Tensor> {
637    let packed_bytes_per_vec = ctx.index.codec.packed_bytes();
638    let codes_per_byte = ctx.decode_table.codes_per_byte();
639    let decoded_row_width = packed_bytes_per_vec * codes_per_byte;
640    debug_assert!(decoded_row_width >= ctx.dim);
641
642    // Flat gather buffers — slice directly out of the index's
643    // StridedTensor-style storage. For each candidate we contribute
644    // its `doc_centroid_ids` slice (`[n_tokens]` u32) and its
645    // `doc_residual_bytes` slice (`[n_tokens, packed_bytes_per_vec]`
646    // u8), extended into the flat upload buffers.
647    let mut centroid_ids: Vec<u32> = Vec::with_capacity(ctx.total_tokens);
648    let mut packed_bytes: Vec<u32> =
649        Vec::with_capacity(ctx.total_tokens * packed_bytes_per_vec);
650    for &doc_idx in ctx.candidate_idxs {
651        let doc_cids = ctx.index.doc_centroid_ids(doc_idx);
652        let doc_bytes = ctx.index.doc_residual_bytes(doc_idx);
653        centroid_ids.extend_from_slice(doc_cids);
654        packed_bytes.extend(doc_bytes.iter().map(|&b| b as u32));
655        offsets.push(centroid_ids.len());
656    }
657
658    let n_centroids = ctx.index.codec.num_centroids();
659    let centroids_t = Tensor::from_slice(
660        ctx.index.codec.centroids.as_slice(),
661        (n_centroids, ctx.dim),
662        ctx.device,
663    )?;
664    let weights_t = Tensor::from_slice(
665        ctx.decode_table.weights_flat(),
666        (256, codes_per_byte),
667        ctx.device,
668    )?;
669    let cent_idx_t =
670        Tensor::from_vec(centroid_ids, (ctx.total_tokens,), ctx.device)?;
671    let byte_idx_t = Tensor::from_vec(
672        packed_bytes,
673        (ctx.total_tokens * packed_bytes_per_vec,),
674        ctx.device,
675    )?;
676
677    let centroid_emb = centroids_t.index_select(&cent_idx_t, 0)?;
678    let residuals_flat = weights_t.index_select(&byte_idx_t, 0)?;
679    let residuals_padded =
680        residuals_flat.reshape((ctx.total_tokens, decoded_row_width))?;
681    let residuals = if decoded_row_width == ctx.dim {
682        residuals_padded
683    } else {
684        residuals_padded.narrow(1, 0, ctx.dim)?
685    };
686    let decoded = (centroid_emb + residuals)?;
687    l2_normalize_rows(&decoded)
688}
689
690/// Row-wise L2-normalise an `[n, dim]` tensor. Matches fast-plaid's
691/// `decompress_residuals` tail: ColBERT was trained with unit-norm
692/// token embeddings, so MaxSim (a dot product) assumes unit rows
693/// downstream. Quantise → centroid+residual reconstruction drifts
694/// norms a little away from 1; this pulls them back so the matmul
695/// that follows stays a proper cosine similarity.
696fn l2_normalize_rows(decoded: &Tensor) -> Result<Tensor> {
697    let squared = decoded.sqr()?;
698    let norm_sq = squared.sum_keepdim(1)?; // [n, 1]
699    // Clamp away from zero before sqrt — a stray all-zero row (possible
700    // with pathological residuals in tests) would otherwise produce a
701    // division by zero below.
702    let norm = norm_sq.sqrt()?.clamp(1e-12f32, f32::INFINITY)?;
703    Ok(decoded.broadcast_div(&norm)?)
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use crate::{
710        distance::dot,
711        index::{DocumentTokens, IndexParams, build_index},
712    };
713
714    fn params() -> IndexParams {
715        IndexParams {
716            dim: 2,
717            nbits: 2,
718            k_centroids: 2,
719            max_kmeans_iters: 50,
720        }
721    }
722
723    /// Two well-separated clusters of **unit-norm** 2-D tokens in three
724    /// documents. This mirrors the real-world ColBERT invariant that
725    /// token embeddings live on the unit sphere, which is what makes
726    /// the dot-product MaxSim meaningful. Doc 1 points east, doc 2
727    /// points north, doc 3 has one token in each cluster.
728    fn corpus() -> Vec<DocumentTokens> {
729        // Unit vectors by construction.
730        let east_a = [1.0f32, 0.0];
731        let east_b = normalize([0.98, 0.2]);
732        let east_c = normalize([0.97, -0.24]);
733        let north_a = [0.0f32, 1.0];
734        let north_b = normalize([0.2, 0.98]);
735        let north_c = normalize([-0.24, 0.97]);
736
737        let mut doc1 = Vec::new();
738        doc1.extend_from_slice(&east_a);
739        doc1.extend_from_slice(&east_b);
740        doc1.extend_from_slice(&east_c);
741
742        let mut doc2 = Vec::new();
743        doc2.extend_from_slice(&north_a);
744        doc2.extend_from_slice(&north_b);
745        doc2.extend_from_slice(&north_c);
746
747        let mut doc3 = Vec::new();
748        doc3.extend_from_slice(&east_a);
749        doc3.extend_from_slice(&north_a);
750
751        vec![
752            DocumentTokens {
753                doc_id: 1,
754                tokens: doc1,
755                n_tokens: 3,
756            },
757            DocumentTokens {
758                doc_id: 2,
759                tokens: doc2,
760                n_tokens: 3,
761            },
762            DocumentTokens {
763                doc_id: 3,
764                tokens: doc3,
765                n_tokens: 2,
766            },
767        ]
768    }
769
770    fn normalize(v: [f32; 2]) -> [f32; 2] {
771        let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
772        [v[0] / norm, v[1] / norm]
773    }
774
775    #[test]
776    fn top_n_centroids_ranks_by_descending_dot_product() {
777        // Three centroids on the +x axis at magnitudes 0, 5, and 12.
778        // Query at (4, 0). Dot products are 0, 20, 48; PLAID ranks by
779        // descending relevance (`S_c,q = C · Q^T`), so the biggest
780        // dot product wins.
781        let centroids = [0.0, 0.0, 5.0, 0.0, 12.0, 0.0];
782        let out = top_n_centroids(&[4.0, 0.0], &centroids, 2, 3);
783        assert_eq!(out, vec![2, 1, 0]);
784    }
785
786    #[test]
787    fn top_n_centroids_breaks_ties_toward_earlier_index() {
788        // Zero query makes every dot product 0. The deterministic
789        // tie-break is "earliest index wins".
790        let centroids = [1.0, 0.0, 0.0, 1.0, -1.0, 0.0];
791        let out = top_n_centroids(&[0.0, 0.0], &centroids, 2, 3);
792        assert_eq!(out, vec![0, 1, 2]);
793    }
794
795    #[test]
796    fn top_n_centroids_on_unit_norm_centroids_picks_most_aligned() {
797        // All centroids and the query are unit-norm. Dot product then
798        // reduces to cosine similarity, so the best-aligned centroid
799        // wins. Here (0.6, 0.8) is the closest direction to (1, 0).
800        let centroids = [
801            0.6, 0.8, //
802            0.0, 1.0, //
803            -1.0, 0.0, //
804            1.0, 0.0,
805        ];
806        let out = top_n_centroids(&[1.0, 0.0], &centroids, 2, 2);
807        assert_eq!(out, vec![3, 0]);
808    }
809
810    #[test]
811    fn top_n_centroids_caps_at_num_centroids() {
812        let centroids = [0.0, 0.0, 5.0, 0.0];
813        let out = top_n_centroids(&[0.0, 0.0], &centroids, 2, 10);
814        assert_eq!(out, vec![0, 1]);
815    }
816
817    #[test]
818    fn search_returns_empty_for_empty_query() {
819        let index = build_index(&corpus(), params()).unwrap();
820        let out = search(
821            &index,
822            &[],
823            SearchParams {
824                top_k: 3,
825                n_probe: 2,
826                n_candidate_docs: None,
827                centroid_score_threshold: None,
828            },
829        )
830        .unwrap();
831        assert!(out.is_empty());
832    }
833
834    #[test]
835    fn search_ranks_matching_cluster_highest() {
836        // Unit-norm query pointing east. Doc 1 has three east tokens,
837        // doc 3 has one east and one north token, doc 2 is entirely
838        // north. MaxSim should prefer doc 1.
839        let index = build_index(&corpus(), params()).unwrap();
840        let out = search(
841            &index,
842            &[1.0, 0.0],
843            SearchParams {
844                top_k: 3,
845                n_probe: 2,
846                n_candidate_docs: None,
847                centroid_score_threshold: None,
848            },
849        )
850        .unwrap();
851        assert!(!out.is_empty(), "should surface at least one doc");
852        assert_eq!(out[0].doc_id, 1, "closest doc should rank first");
853    }
854
855    #[test]
856    fn search_respects_top_k() {
857        let index = build_index(&corpus(), params()).unwrap();
858        let out = search(
859            &index,
860            &[1.0, 0.0],
861            SearchParams {
862                top_k: 1,
863                n_probe: 2,
864                n_candidate_docs: None,
865                centroid_score_threshold: None,
866            },
867        )
868        .unwrap();
869        assert_eq!(out.len(), 1);
870    }
871
872    #[test]
873    fn search_scores_are_non_increasing() {
874        let index = build_index(&corpus(), params()).unwrap();
875        // Two query tokens, one east, one north.
876        let out = search(
877            &index,
878            &[1.0, 0.0, 0.0, 1.0],
879            SearchParams {
880                top_k: 3,
881                n_probe: 2,
882                n_candidate_docs: None,
883                centroid_score_threshold: None,
884            },
885        )
886        .unwrap();
887        for pair in out.windows(2) {
888            assert!(
889                pair[0].score >= pair[1].score,
890                "scores must be descending: {pair:?}"
891            );
892        }
893    }
894
895    #[test]
896    fn search_with_single_probe_still_finds_the_right_cluster() {
897        // With n_probe=1, only one centroid is probed per query token.
898        // A query firmly inside one cluster should still surface its
899        // corresponding document first.
900        let index = build_index(&corpus(), params()).unwrap();
901        let out = search(
902            &index,
903            &[0.0, 1.0],
904            SearchParams {
905                top_k: 1,
906                n_probe: 1,
907                n_candidate_docs: None,
908                centroid_score_threshold: None,
909            },
910        )
911        .unwrap();
912        assert_eq!(out[0].doc_id, 2);
913    }
914
915    #[test]
916    fn search_skips_documents_with_no_tokens() {
917        let mut docs = corpus();
918        docs.push(DocumentTokens {
919            doc_id: 42,
920            tokens: vec![],
921            n_tokens: 0,
922        });
923        let index = build_index(&docs, params()).unwrap();
924        let out = search(
925            &index,
926            &[1.0, 0.0],
927            SearchParams {
928                top_k: 10,
929                n_probe: 2,
930                n_candidate_docs: None,
931                centroid_score_threshold: None,
932            },
933        )
934        .unwrap();
935        assert!(
936            out.iter().all(|r| r.doc_id != 42),
937            "empty doc should not appear in results"
938        );
939    }
940
941    #[test]
942    fn search_score_equals_sum_of_maxsim_over_decoded_tokens() {
943        // Sanity: the score we return should match a ground-truth
944        // MaxSim computed with the same decoded tokens, confirming our
945        // per-token similarity is dot product (not -squared_l2).
946        let index = build_index(&corpus(), params()).unwrap();
947        let query = [1.0f32, 0.0];
948
949        let out = search(
950            &index,
951            &query,
952            SearchParams {
953                top_k: 1,
954                n_probe: index.codec.num_centroids(),
955                n_candidate_docs: None,
956                centroid_score_threshold: None,
957            },
958        )
959        .unwrap();
960        assert!(!out.is_empty());
961
962        let top = out[0];
963        let doc_idx = index.position_of(top.doc_id).expect("doc present");
964        // search() normalises each decoded token to unit norm before
965        // MaxSim, matching fast-plaid. The reference we compute here
966        // must mirror that to be byte-comparable.
967        let decoded: Vec<Vec<f32>> = index
968            .doc_tokens_vec(doc_idx)
969            .iter()
970            .map(|ev| {
971                let raw = index.codec.decode_vector(ev).unwrap();
972                let norm =
973                    raw.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-12);
974                raw.iter().map(|v| v / norm).collect()
975            })
976            .collect();
977
978        // Standard MaxSim: Σ max_j q_i · d_j.
979        let mut expected = 0.0f32;
980        for q in query.chunks_exact(2) {
981            let best = decoded
982                .iter()
983                .map(|d| dot(q, d))
984                .fold(f32::NEG_INFINITY, f32::max);
985            expected += best;
986        }
987        assert!(
988            (expected - top.score).abs() < 1e-5,
989            "score {} differs from ground-truth MaxSim {}",
990            top.score,
991            expected,
992        );
993    }
994
995    #[test]
996    fn search_works_with_four_bit_residuals() {
997        // Build an index with 4-bit residual quantization (16 buckets)
998        // and confirm the MaxSim-ranked top result is still the
999        // matching-cluster doc.
1000        let params_4bit = IndexParams {
1001            dim: 2,
1002            nbits: 4,
1003            k_centroids: 2,
1004            max_kmeans_iters: 50,
1005        };
1006        let index = build_index(&corpus(), params_4bit).unwrap();
1007        let out = search(
1008            &index,
1009            &[1.0, 0.0],
1010            SearchParams {
1011                top_k: 1,
1012                n_probe: 2,
1013                n_candidate_docs: None,
1014                centroid_score_threshold: None,
1015            },
1016        )
1017        .unwrap();
1018        assert_eq!(out[0].doc_id, 1);
1019    }
1020
1021    #[test]
1022    fn search_survives_large_synthetic_corpus_on_both_clusters() {
1023        // Bigger synthetic corpus with two distinct unit-norm clusters.
1024        // An east query must top-rank an east document; same for north.
1025        let mut docs = Vec::new();
1026        for i in 0..20 {
1027            let jitter = i as f32 * 0.003;
1028            let east = normalize([1.0 - jitter, 0.05 + jitter]);
1029            docs.push(DocumentTokens {
1030                doc_id: 100 + i,
1031                tokens: east.to_vec(),
1032                n_tokens: 1,
1033            });
1034            let north = normalize([0.05 + jitter, 1.0 - jitter]);
1035            docs.push(DocumentTokens {
1036                doc_id: 200 + i,
1037                tokens: north.to_vec(),
1038                n_tokens: 1,
1039            });
1040        }
1041        let index = build_index(&docs, params()).unwrap();
1042
1043        let east = search(
1044            &index,
1045            &[1.0, 0.0],
1046            SearchParams {
1047                top_k: 5,
1048                n_probe: 2,
1049                n_candidate_docs: None,
1050                centroid_score_threshold: None,
1051            },
1052        )
1053        .unwrap();
1054        for r in &east {
1055            assert!(
1056                (100..120).contains(&r.doc_id),
1057                "east query should only surface east docs: got {}",
1058                r.doc_id,
1059            );
1060        }
1061
1062        let north = search(
1063            &index,
1064            &[0.0, 1.0],
1065            SearchParams {
1066                top_k: 5,
1067                n_probe: 2,
1068                n_candidate_docs: None,
1069                centroid_score_threshold: None,
1070            },
1071        )
1072        .unwrap();
1073        for r in &north {
1074            assert!(
1075                (200..220).contains(&r.doc_id),
1076                "north query should only surface north docs: got {}",
1077                r.doc_id,
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    #[should_panic(expected = "top_k must be positive")]
1084    fn search_panics_on_zero_top_k() {
1085        let index = build_index(&corpus(), params()).unwrap();
1086        let _ = search(
1087            &index,
1088            &[1.0, 0.0],
1089            SearchParams {
1090                top_k: 0,
1091                n_probe: 1,
1092                n_candidate_docs: None,
1093                centroid_score_threshold: None,
1094            },
1095        )
1096        .unwrap();
1097    }
1098
1099    #[test]
1100    #[should_panic(expected = "n_probe must be positive")]
1101    fn search_panics_on_zero_n_probe() {
1102        let index = build_index(&corpus(), params()).unwrap();
1103        let _ = search(
1104            &index,
1105            &[1.0, 0.0],
1106            SearchParams {
1107                top_k: 1,
1108                n_probe: 0,
1109                n_candidate_docs: None,
1110                centroid_score_threshold: None,
1111            },
1112        )
1113        .unwrap();
1114    }
1115
1116    #[test]
1117    #[should_panic(expected = "query length")]
1118    fn search_panics_on_ragged_query() {
1119        let index = build_index(&corpus(), params()).unwrap();
1120        let _ = search(
1121            &index,
1122            &[1.0, 0.0, 0.5],
1123            SearchParams {
1124                top_k: 1,
1125                n_probe: 1,
1126                n_candidate_docs: None,
1127                centroid_score_threshold: None,
1128            },
1129        )
1130        .unwrap();
1131    }
1132
1133    #[test]
1134    fn centroid_interaction_shortlist_caps_candidates_surviving_to_decode() {
1135        // With only one candidate allowed past centroid interaction we
1136        // should get exactly one doc in the results, and it must be the
1137        // best-scoring one under the approximate stage.
1138        let index = build_index(&corpus(), params()).unwrap();
1139        let out = search(
1140            &index,
1141            &[1.0, 0.0],
1142            SearchParams {
1143                top_k: 5,
1144                n_probe: index.codec.num_centroids(),
1145                n_candidate_docs: Some(1),
1146                centroid_score_threshold: None,
1147            },
1148        )
1149        .unwrap();
1150        assert_eq!(out.len(), 1, "shortlist of 1 must cap output at 1");
1151        assert_eq!(
1152            out[0].doc_id, 1,
1153            "east query should surface the east doc as the survivor",
1154        );
1155    }
1156
1157    #[test]
1158    fn centroid_interaction_with_large_shortlist_matches_no_shortlist() {
1159        // A shortlist size at least equal to the number of candidates
1160        // is effectively no shortlisting — the centroid-interaction
1161        // stage picks everything through, so results must agree with
1162        // the legacy `None` path byte-for-byte.
1163        let index = build_index(&corpus(), params()).unwrap();
1164        let query = [1.0, 0.0, 0.0, 1.0];
1165
1166        let legacy = search(
1167            &index,
1168            &query,
1169            SearchParams {
1170                top_k: 3,
1171                n_probe: index.codec.num_centroids(),
1172                n_candidate_docs: None,
1173                centroid_score_threshold: None,
1174            },
1175        )
1176        .unwrap();
1177        let shortlisted = search(
1178            &index,
1179            &query,
1180            SearchParams {
1181                top_k: 3,
1182                n_probe: index.codec.num_centroids(),
1183                n_candidate_docs: Some(1_000),
1184                centroid_score_threshold: None,
1185            },
1186        )
1187        .unwrap();
1188        assert_eq!(legacy, shortlisted);
1189    }
1190
1191    #[test]
1192    fn centroid_pruning_with_low_threshold_matches_no_pruning() {
1193        // A threshold below every attainable centroid score is a
1194        // no-op. For unit-norm centroids the dot product ceiling is
1195        // ~1, so using −1.0 guarantees every centroid survives.
1196        let index = build_index(&corpus(), params()).unwrap();
1197        let query = [1.0, 0.0, 0.0, 1.0];
1198        let unpruned = search(
1199            &index,
1200            &query,
1201            SearchParams {
1202                top_k: 3,
1203                n_probe: index.codec.num_centroids(),
1204                n_candidate_docs: None,
1205                centroid_score_threshold: None,
1206            },
1207        )
1208        .unwrap();
1209        let pruned = search(
1210            &index,
1211            &query,
1212            SearchParams {
1213                top_k: 3,
1214                n_probe: index.codec.num_centroids(),
1215                n_candidate_docs: None,
1216                centroid_score_threshold: Some(-1.0),
1217            },
1218        )
1219        .unwrap();
1220        assert_eq!(unpruned, pruned);
1221    }
1222
1223    #[test]
1224    fn two_stage_interaction_caps_survivors_to_ndocs_div_four() {
1225        // Paper §4.2/§4.3: Stage 2 shortlists to `ndocs`, Stage 3
1226        // refines to `ndocs/4`. With 20 synthetic east/north docs, a
1227        // Stage 2 shortlist of 16, and `top_k` well above the Stage 3
1228        // cap, we should see at most `16/4 = 4` docs in the final
1229        // result — the Stage 3 bound, not `top_k` nor Stage 2's 16.
1230        let mut docs = Vec::new();
1231        for i in 0..20 {
1232            let jitter = i as f32 * 0.003;
1233            let east = normalize([1.0 - jitter, 0.05 + jitter]);
1234            docs.push(DocumentTokens {
1235                doc_id: 100 + i,
1236                tokens: east.to_vec(),
1237                n_tokens: 1,
1238            });
1239        }
1240        let index = build_index(&docs, params()).unwrap();
1241        let out = search(
1242            &index,
1243            &[1.0, 0.0],
1244            SearchParams {
1245                top_k: 20,
1246                n_probe: index.codec.num_centroids(),
1247                n_candidate_docs: Some(16),
1248                centroid_score_threshold: None,
1249            },
1250        )
1251        .unwrap();
1252        assert!(
1253            out.len() <= 4,
1254            "Stage 3 must cap the decoded set at ndocs/4 = 4; got {}",
1255            out.len(),
1256        );
1257        assert!(
1258            !out.is_empty(),
1259            "Stage 3 should still return at least one result"
1260        );
1261    }
1262
1263    #[test]
1264    fn approx_score_skips_tokens_whose_centroid_is_pruned() {
1265        // Paper §4.3: D̃ must only be comprised of tokens whose centroid
1266        // passes the t_cs threshold. This directly exercises that
1267        // behaviour on a controlled qc_scores matrix.
1268        //
1269        // 3 centroids, 2 query tokens. Centroid 0 is strong for q0 only,
1270        // centroid 1 is strong for q1 only, centroid 2 is mediocre for
1271        // both. A doc with tokens in centroids [0, 2] would normally
1272        // pull qc_scores[2] into the q1 max (because centroid 0 is weak
1273        // for q1 and centroid 2 at least has 0.4). Pruning centroid 2
1274        // drops that contribution and only centroid 0's qc values
1275        // remain.
1276        let n_q = 2;
1277        let n_c = 3;
1278        // qc_scores layout is row-major [n_q, n_c].
1279        // q0 row: centroid 0 = 0.9, centroid 1 = 0.0, centroid 2 = 0.4.
1280        // q1 row: centroid 0 = 0.0, centroid 1 = 0.9, centroid 2 = 0.4.
1281        let qc = [0.9, 0.0, 0.4, 0.0, 0.9, 0.4];
1282        let doc_cids: [u32; 2] = [0, 2];
1283
1284        let unpruned =
1285            approx_centroid_interaction_score(&doc_cids, &qc, n_q, n_c, None);
1286        // q0: max(0.9, 0.4) = 0.9; q1: max(0.0, 0.4) = 0.4. Total 1.3.
1287        assert!((unpruned - 1.3).abs() < 1e-5, "unpruned was {unpruned}");
1288
1289        // Prune centroid 2 (per-centroid max 0.4 < t_cs=0.5).
1290        let pruned_mask = [false, false, true];
1291        let pruned = approx_centroid_interaction_score(
1292            &doc_cids,
1293            &qc,
1294            n_q,
1295            n_c,
1296            Some(&pruned_mask),
1297        );
1298        // q0: max over {centroid 0} = 0.9; q1: max over {centroid 0} =
1299        // 0.0. Total 0.9.
1300        assert!((pruned - 0.9).abs() < 1e-5, "pruned was {pruned}");
1301    }
1302
1303    #[test]
1304    fn approx_score_on_all_pruned_doc_is_negative_infinity() {
1305        // Every centroid in the doc is masked. The scorer must return a
1306        // score low enough that the doc loses to any non-empty doc in
1307        // the centroid-interaction sort.
1308        let qc = [0.5, 0.5];
1309        let doc_cids: [u32; 1] = [0];
1310        let mask = [true];
1311        let score = approx_centroid_interaction_score(
1312            &doc_cids,
1313            &qc,
1314            1,
1315            1,
1316            Some(&mask),
1317        );
1318        assert!(
1319            score == f32::NEG_INFINITY,
1320            "all-pruned doc should score -inf, got {score}",
1321        );
1322    }
1323
1324    #[test]
1325    fn centroid_pruning_excludes_docs_whose_only_centroid_is_below_threshold() {
1326        // Build an index with two clusters. An east-pointing query
1327        // scores the east centroid near 1.0 and the north centroid
1328        // near 0.0. A threshold of 0.5 prunes the north centroid,
1329        // so doc 2 (pure-north) must be filtered out — but doc 3
1330        // (mixed east + north) still has an east token and survives.
1331        let index = build_index(&corpus(), params()).unwrap();
1332        let out = search(
1333            &index,
1334            &[1.0f32, 0.0],
1335            SearchParams {
1336                top_k: 5,
1337                n_probe: index.codec.num_centroids(),
1338                n_candidate_docs: None,
1339                centroid_score_threshold: Some(0.5),
1340            },
1341        )
1342        .unwrap();
1343        let ids: Vec<u64> = out.iter().map(|r| r.doc_id).collect();
1344        assert!(
1345            !ids.contains(&2),
1346            "pure-north doc must be pruned when only the east centroid survives the threshold: got {ids:?}",
1347        );
1348        assert!(
1349            ids.contains(&1),
1350            "east-cluster doc must still rank when its centroid survives: got {ids:?}",
1351        );
1352    }
1353
1354    #[test]
1355    fn centroid_pruning_with_unreachable_threshold_drops_every_candidate() {
1356        // No centroid can score higher than ~1.0 against a unit-norm
1357        // query on unit-norm centroids. A threshold of 10.0 prunes
1358        // everything, so no docs survive.
1359        let index = build_index(&corpus(), params()).unwrap();
1360        let out = search(
1361            &index,
1362            &[1.0, 0.0],
1363            SearchParams {
1364                top_k: 3,
1365                n_probe: index.codec.num_centroids(),
1366                n_candidate_docs: None,
1367                centroid_score_threshold: Some(10.0),
1368            },
1369        )
1370        .unwrap();
1371        assert!(
1372            out.is_empty(),
1373            "every centroid below threshold should yield empty results",
1374        );
1375    }
1376
1377    #[test]
1378    fn centroid_interaction_preserves_exact_maxsim_on_surviving_candidates() {
1379        // The final ranking stage still runs exact decoded MaxSim.
1380        // With a shortlist that keeps everyone, the top-1 score must
1381        // equal the MaxSim over decoded tokens for that doc.
1382        let index = build_index(&corpus(), params()).unwrap();
1383        let query = [1.0f32, 0.0];
1384        let out = search(
1385            &index,
1386            &query,
1387            SearchParams {
1388                top_k: 1,
1389                n_probe: index.codec.num_centroids(),
1390                n_candidate_docs: Some(1_000),
1391                centroid_score_threshold: None,
1392            },
1393        )
1394        .unwrap();
1395        let top = out[0];
1396        let doc_idx = index.position_of(top.doc_id).unwrap();
1397        // search() L2-normalises each decoded token before MaxSim;
1398        // the reference has to mirror that.
1399        let decoded: Vec<Vec<f32>> = index
1400            .doc_tokens_vec(doc_idx)
1401            .iter()
1402            .map(|ev| {
1403                let raw = index.codec.decode_vector(ev).unwrap();
1404                let norm =
1405                    raw.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-12);
1406                raw.iter().map(|v| v / norm).collect()
1407            })
1408            .collect();
1409        let expected: f32 = query
1410            .chunks_exact(2)
1411            .map(|q| {
1412                decoded
1413                    .iter()
1414                    .map(|d| dot(q, d))
1415                    .fold(f32::NEG_INFINITY, f32::max)
1416            })
1417            .sum();
1418        assert!((expected - top.score).abs() < 1e-5);
1419    }
1420
1421    // -- GPU decode parity --
1422
1423    /// When run on CPU-backed candle, `decode_on_device` still lives on
1424    /// the code path the CUDA build takes. This test forces that path
1425    /// and compares every decoded row to the byte-walking CPU decoder
1426    /// at f32 precision. Any drift in the gather/reshape/narrow chain
1427    /// would show up here without needing a real GPU.
1428    #[test]
1429    fn gpu_decode_path_matches_cpu_decode_element_wise() {
1430        let index = build_index(&corpus(), params()).unwrap();
1431        let candidate_idxs: Vec<usize> = (0..index.num_documents()).collect();
1432        let total_tokens: usize = candidate_idxs
1433            .iter()
1434            .map(|&i| index.doc_token_count(i))
1435            .sum();
1436        let decode_table = DecodeTable::new(&index.codec);
1437        let device = Device::Cpu;
1438
1439        let ctx = DecodeCtx {
1440            index: &index,
1441            candidate_idxs: &candidate_idxs,
1442            dim: index.params.dim,
1443            total_tokens,
1444            decode_table: &decode_table,
1445            device: &device,
1446        };
1447
1448        let mut offsets_gpu = vec![0usize];
1449        let decoded_gpu = decode_on_device(&ctx, &mut offsets_gpu).unwrap();
1450        let gpu_rows: Vec<f32> = decoded_gpu
1451            .to_vec2::<f32>()
1452            .unwrap()
1453            .into_iter()
1454            .flatten()
1455            .collect();
1456
1457        let mut offsets_cpu = vec![0usize];
1458        let decoded_cpu = decode_on_cpu(&ctx, &mut offsets_cpu).unwrap();
1459        let cpu_rows: Vec<f32> = decoded_cpu
1460            .to_vec2::<f32>()
1461            .unwrap()
1462            .into_iter()
1463            .flatten()
1464            .collect();
1465
1466        assert_eq!(offsets_gpu, offsets_cpu);
1467        assert_eq!(gpu_rows.len(), cpu_rows.len());
1468        for (g, c) in gpu_rows.iter().zip(cpu_rows.iter()) {
1469            assert!(
1470                (g - c).abs() < 1e-6,
1471                "GPU decode value {g} != CPU decode value {c}",
1472            );
1473        }
1474    }
1475
1476    // -- Paper Table 2 defaults --
1477
1478    #[test]
1479    fn paper_defaults_match_table_2_verbatim() {
1480        // PLAID paper Table 2: (nprobe, t_cs, ndocs)
1481        //   k=10   → (1, 0.50, 256)
1482        //   k=100  → (2, 0.45, 1024)
1483        //   k=1000 → (4, 0.40, 4096)
1484        let p10 = SearchParams::paper_defaults(10);
1485        assert_eq!(p10.n_probe, 1);
1486        assert_eq!(p10.centroid_score_threshold, Some(0.5));
1487        assert_eq!(p10.n_candidate_docs, Some(256));
1488
1489        let p100 = SearchParams::paper_defaults(100);
1490        assert_eq!(p100.n_probe, 2);
1491        assert_eq!(p100.centroid_score_threshold, Some(0.45));
1492        assert_eq!(p100.n_candidate_docs, Some(1024));
1493
1494        let p1000 = SearchParams::paper_defaults(1000);
1495        assert_eq!(p1000.n_probe, 4);
1496        assert_eq!(p1000.centroid_score_threshold, Some(0.4));
1497        assert_eq!(p1000.n_candidate_docs, Some(4096));
1498    }
1499
1500    #[test]
1501    fn paper_defaults_clamp_ndocs_to_at_least_4x_top_k() {
1502        // If a caller asks for top_k = 2000, Table 2's 4096 ndocs would
1503        // leave Stage 3's ndocs/4 shortlist at 1024 — less than the
1504        // caller's requested 2000. The constructor bumps ndocs to
1505        // 4 * top_k so Stage 3 can still return enough candidates.
1506        let p = SearchParams::paper_defaults(2000);
1507        assert!(p.n_candidate_docs.unwrap() >= 4 * 2000);
1508    }
1509
1510    #[test]
1511    fn paper_defaults_always_enable_pruning() {
1512        // Stage 2 centroid pruning is the single biggest algorithmic
1513        // win in Figure 6 of the paper. `paper_defaults` must never
1514        // leave it off by accident.
1515        for &k in &[1_usize, 10, 50, 100, 500, 1000, 10_000] {
1516            assert!(
1517                SearchParams::paper_defaults(k)
1518                    .centroid_score_threshold
1519                    .is_some(),
1520                "paper_defaults({k}) left pruning disabled"
1521            );
1522        }
1523    }
1524}