Skip to main content

writerslogic_warm/core/index/
sparse_impact.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inner-product top-k over learned sparse vectors (SPLADE-style), with a blocked inverted
5//! index and a forward index.
6//!
7//! The layout follows the blocked inverted index of SEISMIC (Bruch et al., arXiv 2404.18812):
8//!
9//! - every coordinate keeps its `postings_per_term` largest postings (static pruning);
10//! - each kept list is split into blocks by assigning every document to the most similar of a
11//!   random sample of the list's documents (shallow clustering);
12//! - every block stores a summary: the coordinate-wise maximum of its documents, pruned to the
13//!   coordinates that carry `summary_energy` of its mass and quantized to one byte;
14//! - a forward index holds every document in full, so a candidate's score is its exact inner
15//!   product with the query.
16//!
17//! A search walks the lists of the `query_cut` heaviest query coordinates and scores a block's
18//! documents only when the summary's inner product with the query, times `heap_factor`, reaches
19//! the current k-th best score.
20//!
21//! With [`SparseBuildParams::exact`] and [`SparseSearchParams::exact`] (no static pruning, full
22//! summaries rounded up, every query coordinate, `heap_factor` 1) a summary bounds the score of
23//! every document in its block, so a skipped block cannot hold a result and the search returns
24//! the exact top-k, ties broken by the lower document id. That needs non-negative document
25//! values, which the build enforces; negative query weights only lower a score, so the bound
26//! uses the positive part of the query.
27
28use std::cmp::Ordering;
29use std::collections::BinaryHeap;
30
31use rayon::prelude::*;
32
33/// Largest supported coordinate count: coordinate ids are stored in two bytes.
34pub const MAX_DIMS: usize = 1 << 16;
35
36/// Errors from building a [`SparseImpactIndex`] or validating a query.
37#[derive(Debug, Clone, PartialEq)]
38pub enum SparseIndexError {
39    /// The CSR arrays are inconsistent (lengths, offsets or an out-of-range coordinate).
40    Malformed(String),
41    /// A document value is negative or not finite.
42    BadValue { doc: usize, value: f32 },
43    /// More coordinates or documents than the format holds.
44    TooLarge(String),
45}
46
47impl std::fmt::Display for SparseIndexError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Malformed(m) => write!(f, "malformed sparse matrix: {m}"),
51            Self::BadValue { doc, value } => {
52                write!(
53                    f,
54                    "document {doc} has value {value}; values must be finite and >= 0"
55                )
56            }
57            Self::TooLarge(m) => write!(f, "sparse matrix too large: {m}"),
58        }
59    }
60}
61
62impl std::error::Error for SparseIndexError {}
63
64/// A borrowed CSR matrix: row `r` holds `indices[indptr[r]..indptr[r + 1]]` with `data` values.
65#[derive(Clone, Copy)]
66pub struct CsrView<'a> {
67    pub indptr: &'a [i64],
68    pub indices: &'a [i32],
69    pub data: &'a [f32],
70    pub n_dims: usize,
71}
72
73impl<'a> CsrView<'a> {
74    /// Reads the big-ann-benchmarks `spmat` layout: int64 rows, cols and nnz, then int64
75    /// indptr, int32 indices and float32 data. `bytes` must be 8-byte aligned (a memory map is).
76    pub fn from_spmat(bytes: &'a [u8]) -> Result<Self, SparseIndexError> {
77        let bad = |m: String| SparseIndexError::Malformed(m);
78        let header: &[i64] = bytes
79            .get(..24)
80            .and_then(|h| bytemuck::try_cast_slice(h).ok())
81            .ok_or_else(|| bad("spmat header is truncated or misaligned".into()))?;
82        let size = |v: i64| usize::try_from(v).map_err(|_| bad(format!("negative size {v}")));
83        let (rows, cols, nnz) = (size(header[0])?, size(header[1])?, size(header[2])?);
84        let span = |n: usize, w: usize| {
85            n.checked_mul(w)
86                .ok_or_else(|| bad("spmat size overflows".into()))
87        };
88        let indptr_end = 24 + span(rows + 1, 8)?;
89        let indices_end = indptr_end + span(nnz, 4)?;
90        let data_end = indices_end + span(nnz, 4)?;
91        if bytes.len() != data_end {
92            return Err(bad(format!(
93                "file is {} bytes, header implies {data_end}",
94                bytes.len()
95            )));
96        }
97        let cast_err = |e: bytemuck::PodCastError| bad(format!("misaligned section: {e:?}"));
98        Ok(Self {
99            indptr: bytemuck::try_cast_slice(&bytes[24..indptr_end]).map_err(cast_err)?,
100            indices: bytemuck::try_cast_slice(&bytes[indptr_end..indices_end]).map_err(cast_err)?,
101            data: bytemuck::try_cast_slice(&bytes[indices_end..data_end]).map_err(cast_err)?,
102            n_dims: cols,
103        })
104    }
105
106    /// Checks lengths, monotone offsets and coordinate range.
107    pub fn validate(&self) -> Result<usize, SparseIndexError> {
108        let rows = self
109            .indptr
110            .len()
111            .checked_sub(1)
112            .ok_or_else(|| SparseIndexError::Malformed("indptr is empty".into()))?;
113        if self.indices.len() != self.data.len() {
114            return Err(SparseIndexError::Malformed(format!(
115                "{} indices but {} values",
116                self.indices.len(),
117                self.data.len()
118            )));
119        }
120        if self.indptr[0] != 0 || self.indptr[rows] != self.indices.len() as i64 {
121            return Err(SparseIndexError::Malformed(
122                "indptr must start at 0 and end at nnz".into(),
123            ));
124        }
125        if self.indptr.windows(2).any(|w| w[0] > w[1]) {
126            return Err(SparseIndexError::Malformed("indptr decreases".into()));
127        }
128        if let Some(&bad) = self
129            .indices
130            .iter()
131            .find(|&&c| c < 0 || c as usize >= self.n_dims)
132        {
133            return Err(SparseIndexError::Malformed(format!(
134                "coordinate {bad} outside [0, {})",
135                self.n_dims
136            )));
137        }
138        Ok(rows)
139    }
140
141    /// Coordinates and values of row `r`.
142    pub fn row(&self, r: usize) -> (&'a [i32], &'a [f32]) {
143        let (a, b) = (self.indptr[r] as usize, self.indptr[r + 1] as usize);
144        (&self.indices[a..b], &self.data[a..b])
145    }
146}
147
148/// Build-time parameters.
149#[derive(Clone, Copy, Debug)]
150pub struct SparseBuildParams {
151    /// Postings kept per coordinate (the largest values); `usize::MAX` keeps every posting.
152    pub postings_per_term: usize,
153    /// Blocks per kept list as a fraction of its length (at least one block).
154    pub block_fraction: f32,
155    /// Fraction of a summary's mass its kept coordinates must carry; 1 keeps all of them.
156    pub summary_energy: f32,
157    /// Postings transposed at once during the build (bounds the build's working memory).
158    pub transpose_budget: usize,
159    /// Seed of the centroid sample.
160    pub seed: u64,
161    /// Store forward values as half floats (2 bytes, relative error below 2^-11); scores are
162    /// then exact inner products with the rounded values.
163    pub half_values: bool,
164}
165
166impl SparseBuildParams {
167    /// Parameters under which a search with [`SparseSearchParams::exact`] is exact.
168    pub fn exact() -> Self {
169        Self {
170            postings_per_term: usize::MAX,
171            summary_energy: 1.0,
172            half_values: false,
173            ..Self::default()
174        }
175    }
176}
177
178impl Default for SparseBuildParams {
179    fn default() -> Self {
180        Self {
181            postings_per_term: 4000,
182            block_fraction: 0.1,
183            summary_energy: 0.4,
184            transpose_budget: 1 << 26,
185            seed: 0x5eed,
186            half_values: true,
187        }
188    }
189}
190
191/// Query-time parameters.
192#[derive(Clone, Copy, Debug)]
193pub struct SparseSearchParams {
194    /// Query coordinates (heaviest first) whose lists are walked.
195    pub query_cut: usize,
196    /// A block is scored when `summary score * heap_factor` reaches the k-th best score.
197    pub heap_factor: f32,
198}
199
200impl SparseSearchParams {
201    pub fn exact() -> Self {
202        Self {
203            query_cut: usize::MAX,
204            heap_factor: 1.0,
205        }
206    }
207}
208
209/// Blocked inverted index with summaries plus a forward index.
210pub struct SparseImpactIndex {
211    n_dims: usize,
212    n_docs: usize,
213    /// Forward index: document `d` is `fwd_ids/fwd_vals[fwd_offsets[d]..fwd_offsets[d + 1]]`.
214    fwd_offsets: Vec<u64>,
215    fwd_ids: Vec<u16>,
216    fwd_vals: Vec<f32>,
217    /// The forward values as IEEE half floats when [`SparseBuildParams::half_values`] is set
218    /// (`fwd_vals` is then empty).
219    fwd_half: Vec<u16>,
220    /// Blocks of coordinate `t`: `term_blocks[t]..term_blocks[t + 1]`.
221    term_blocks: Vec<u32>,
222    /// Documents of block `b`: `block_docs[block_doc_offsets[b]..block_doc_offsets[b + 1]]`.
223    block_doc_offsets: Vec<u64>,
224    block_docs: Vec<u32>,
225    /// Summary of block `b`: `sum_ids/sum_codes[block_sum_offsets[b]..block_sum_offsets[b + 1]]`,
226    /// a coordinate's maximum being at most `sum_codes * block_scales[b]`.
227    block_sum_offsets: Vec<u64>,
228    sum_ids: Vec<u16>,
229    sum_codes: Vec<u8>,
230    block_scales: Vec<f32>,
231}
232
233/// One kept list after clustering: its blocks' documents and summaries.
234struct TermBlocks {
235    doc_counts: Vec<u32>,
236    docs: Vec<u32>,
237    sum_counts: Vec<u32>,
238    sum_ids: Vec<u16>,
239    sum_codes: Vec<u8>,
240    scales: Vec<f32>,
241}
242
243fn splitmix(state: &mut u64) -> u64 {
244    *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
245    let mut z = *state;
246    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
247    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
248    z ^ (z >> 31)
249}
250
251/// Per-thread scratch for the build: dense maxima and a coordinate-to-centroid map.
252struct BuildScratch {
253    dense: Vec<f32>,
254    touched: Vec<u16>,
255    starts: Vec<u32>,
256}
257
258impl BuildScratch {
259    fn new(n_dims: usize) -> Self {
260        Self {
261            dense: vec![0.0; n_dims],
262            touched: Vec::new(),
263            starts: vec![u32::MAX; n_dims + 1],
264        }
265    }
266}
267
268impl SparseImpactIndex {
269    /// Builds the index over the rows of `csr`. Zero values are dropped.
270    pub fn build(csr: CsrView<'_>, params: SparseBuildParams) -> Result<Self, SparseIndexError> {
271        let n_docs = csr.validate()?;
272        if csr.n_dims > MAX_DIMS {
273            return Err(SparseIndexError::TooLarge(format!(
274                "{} coordinates, at most {MAX_DIMS}",
275                csr.n_dims
276            )));
277        }
278        if n_docs > u32::MAX as usize {
279            return Err(SparseIndexError::TooLarge(format!("{n_docs} documents")));
280        }
281        if let Some(i) = csr.data.iter().position(|v| !(v.is_finite() && *v >= 0.0)) {
282            let doc = csr.indptr.partition_point(|&p| p <= i as i64) - 1;
283            return Err(SparseIndexError::BadValue {
284                doc,
285                value: csr.data[i],
286            });
287        }
288        let n_dims = csr.n_dims;
289
290        // Forward index (rows keep their order; zeros dropped).
291        let mut fwd_offsets = Vec::with_capacity(n_docs + 1);
292        fwd_offsets.push(0u64);
293        let nnz = csr.data.iter().filter(|&&v| v > 0.0).count();
294        let mut fwd_ids = Vec::with_capacity(nnz);
295        // Exactly one of the value arrays is filled: half floats never pass through f32 storage.
296        let (mut fwd_vals, mut fwd_half) = if params.half_values {
297            (Vec::new(), Vec::with_capacity(nnz))
298        } else {
299            (Vec::with_capacity(nnz), Vec::new())
300        };
301        let mut df = vec![0u64; n_dims];
302        for r in 0..n_docs {
303            let (ids, vals) = csr.row(r);
304            for (&c, &v) in ids.iter().zip(vals) {
305                if v > 0.0 {
306                    fwd_ids.push(c as u16);
307                    if params.half_values {
308                        fwd_half.push(f16_bits(v));
309                    } else {
310                        fwd_vals.push(v);
311                    }
312                    df[c as usize] += 1;
313                }
314            }
315            fwd_offsets.push(fwd_ids.len() as u64);
316        }
317
318        let mut index = Self {
319            n_dims,
320            n_docs,
321            fwd_offsets,
322            fwd_ids,
323            fwd_vals,
324            fwd_half,
325            term_blocks: Vec::with_capacity(n_dims + 1),
326            block_doc_offsets: vec![0],
327            block_docs: Vec::new(),
328            block_sum_offsets: vec![0],
329            sum_ids: Vec::new(),
330            sum_codes: Vec::new(),
331            block_scales: Vec::new(),
332        };
333        index.term_blocks.push(0);
334
335        // Transpose a range of coordinates at a time, then cluster each list in parallel.
336        let budget = params.transpose_budget.max(1) as u64;
337        let mut lo = 0usize;
338        while lo < n_dims {
339            let mut hi = lo;
340            let mut total = 0u64;
341            while hi < n_dims && (hi == lo || total + df[hi] <= budget) {
342                total += df[hi];
343                hi += 1;
344            }
345            let mut starts = Vec::with_capacity(hi - lo + 1);
346            starts.push(0u64);
347            for &d in &df[lo..hi] {
348                starts.push(starts.last().copied().unwrap_or(0) + d);
349            }
350            let mut cursor = starts.clone();
351            let mut postings = vec![(0u32, 0f32); total as usize];
352            for d in 0..n_docs {
353                for (c, v) in index.entries(d as u32) {
354                    let c = c as usize;
355                    if (lo..hi).contains(&c) {
356                        let slot = &mut cursor[c - lo];
357                        postings[*slot as usize] = (d as u32, v);
358                        *slot += 1;
359                    }
360                }
361            }
362            let lists: Vec<&mut [(u32, f32)]> = {
363                let mut rest = postings.as_mut_slice();
364                let mut out = Vec::with_capacity(hi - lo);
365                for t in 0..hi - lo {
366                    let (head, tail) = rest.split_at_mut((starts[t + 1] - starts[t]) as usize);
367                    out.push(head);
368                    rest = tail;
369                }
370                out
371            };
372            let this = &index;
373            let built: Vec<TermBlocks> = lists
374                .into_par_iter()
375                .enumerate()
376                .map_init(
377                    || BuildScratch::new(n_dims),
378                    |scratch, (i, list)| this.cluster_list(lo + i, list, params, scratch),
379                )
380                .collect();
381            for tb in built {
382                index.append(tb);
383            }
384            lo = hi;
385        }
386        Ok(index)
387    }
388
389    /// Coordinates and stored values of document `d`.
390    fn entries(&self, d: u32) -> impl Iterator<Item = (u16, f32)> + '_ {
391        let (a, b) = (
392            self.fwd_offsets[d as usize] as usize,
393            self.fwd_offsets[d as usize + 1] as usize,
394        );
395        let half = !self.fwd_half.is_empty();
396        (a..b).map(move |i| {
397            let v = if half {
398                f16_value(self.fwd_half[i])
399            } else {
400                self.fwd_vals[i]
401            };
402            (self.fwd_ids[i], v)
403        })
404    }
405
406    /// Inner product of document `d` (after `build`) with a dense query.
407    #[inline]
408    fn score(&self, d: u32, dense: &[f32]) -> f32 {
409        let (a, b) = (
410            self.fwd_offsets[d as usize] as usize,
411            self.fwd_offsets[d as usize + 1] as usize,
412        );
413        if self.fwd_half.is_empty() {
414            dot(&self.fwd_ids[a..b], &self.fwd_vals[a..b], dense)
415        } else {
416            dot_half(&self.fwd_ids[a..b], &self.fwd_half[a..b], dense)
417        }
418    }
419
420    /// Hint the cache to load the forward row of document `d`.
421    #[inline]
422    fn prefetch_row(&self, d: u32) {
423        let (a, b) = (
424            self.fwd_offsets[d as usize] as usize,
425            self.fwd_offsets[d as usize + 1] as usize,
426        );
427        prefetch(self.fwd_ids[a..].as_ptr().cast(), (b - a) * 2);
428        if self.fwd_half.is_empty() {
429            prefetch(self.fwd_vals[a..].as_ptr().cast(), (b - a) * 4);
430        } else {
431            prefetch(self.fwd_half[a..].as_ptr().cast(), (b - a) * 2);
432        }
433    }
434
435    fn append(&mut self, tb: TermBlocks) {
436        let mut doc_at = 0usize;
437        let mut sum_at = 0usize;
438        for ((&dc, &sc), &scale) in tb.doc_counts.iter().zip(&tb.sum_counts).zip(&tb.scales) {
439            let (dc, sc) = (dc as usize, sc as usize);
440            self.block_docs
441                .extend_from_slice(&tb.docs[doc_at..doc_at + dc]);
442            self.block_doc_offsets.push(self.block_docs.len() as u64);
443            self.sum_ids
444                .extend_from_slice(&tb.sum_ids[sum_at..sum_at + sc]);
445            self.sum_codes
446                .extend_from_slice(&tb.sum_codes[sum_at..sum_at + sc]);
447            self.block_sum_offsets.push(self.sum_ids.len() as u64);
448            self.block_scales.push(scale);
449            doc_at += dc;
450            sum_at += sc;
451        }
452        let blocks = self.block_scales.len() as u32;
453        self.term_blocks.push(blocks);
454    }
455
456    /// Prunes, clusters and summarizes the list of coordinate `term`.
457    fn cluster_list(
458        &self,
459        term: usize,
460        list: &mut [(u32, f32)],
461        params: SparseBuildParams,
462        scratch: &mut BuildScratch,
463    ) -> TermBlocks {
464        let mut out = TermBlocks {
465            doc_counts: Vec::new(),
466            docs: Vec::new(),
467            sum_counts: Vec::new(),
468            sum_ids: Vec::new(),
469            sum_codes: Vec::new(),
470            scales: Vec::new(),
471        };
472        if list.is_empty() {
473            return out;
474        }
475        let by_value_desc =
476            |a: &(u32, f32), b: &(u32, f32)| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0));
477        let keep = params.postings_per_term.min(list.len());
478        if keep == 0 {
479            return out;
480        }
481        if keep < list.len() {
482            list.select_nth_unstable_by(keep - 1, by_value_desc);
483        }
484        let list = &mut list[..keep];
485
486        let n_blocks = ((keep as f64 * f64::from(params.block_fraction)).ceil() as usize)
487            .max(1)
488            .min(keep);
489        let assign: Vec<u32> = if n_blocks == 1 {
490            vec![0; keep]
491        } else {
492            self.assign_to_centroids(term, list, n_blocks, params.seed, scratch)
493        };
494
495        // Group by block, documents ascending within a block.
496        let mut order: Vec<u32> = (0..keep as u32).collect();
497        order.sort_unstable_by_key(|&i| (assign[i as usize], list[i as usize].0));
498        let mut groups: Vec<(f32, &[u32])> = Vec::with_capacity(n_blocks);
499        let mut start = 0;
500        while start < keep {
501            let b = assign[order[start] as usize];
502            let mut end = start;
503            let mut top = 0f32;
504            while end < keep && assign[order[end] as usize] == b {
505                top = top.max(list[order[end] as usize].1);
506                end += 1;
507            }
508            groups.push((top, &order[start..end]));
509            start = end;
510        }
511        // Blocks with the largest value of this coordinate first: the threshold rises early.
512        groups.sort_by(|a, b| b.0.total_cmp(&a.0));
513
514        for (_, members) in groups {
515            for &i in members {
516                out.docs.push(list[i as usize].0);
517            }
518            out.doc_counts.push(members.len() as u32);
519            self.summarize(
520                members.iter().map(|&i| list[i as usize].0),
521                params.summary_energy,
522                scratch,
523                &mut out,
524            );
525        }
526        out
527    }
528
529    /// Picks `n_blocks` documents of `list` as centroids and assigns every document to the
530    /// centroid with the largest inner product (ties to the lower centroid).
531    fn assign_to_centroids(
532        &self,
533        term: usize,
534        list: &[(u32, f32)],
535        n_blocks: usize,
536        seed: u64,
537        scratch: &mut BuildScratch,
538    ) -> Vec<u32> {
539        let mut state = seed ^ (term as u64).wrapping_mul(0x2545_f491_4f6c_dd1d);
540        // Partial Fisher-Yates over positions.
541        let mut pos: Vec<u32> = (0..list.len() as u32).collect();
542        for i in 0..n_blocks {
543            let j = i + (splitmix(&mut state) % (list.len() - i) as u64) as usize;
544            pos.swap(i, j);
545        }
546        // Inverted map of the centroids: coordinate -> (centroid, value).
547        let mut entries: Vec<(u16, u32, f32)> = Vec::new();
548        for (c, &p) in pos[..n_blocks].iter().enumerate() {
549            entries.extend(
550                self.entries(list[p as usize].0)
551                    .map(|(t, v)| (t, c as u32, v)),
552            );
553        }
554        entries.sort_unstable_by_key(|e| (e.0, e.1));
555        let starts = &mut scratch.starts;
556        let mut touched = Vec::new();
557        for (i, e) in entries.iter().enumerate() {
558            let t = e.0 as usize;
559            if starts[t] == u32::MAX {
560                starts[t] = i as u32;
561                touched.push(t);
562            }
563        }
564        let mut scores = vec![0f32; n_blocks];
565        let mut assign = Vec::with_capacity(list.len());
566        for &(doc, _) in list {
567            scores.iter_mut().for_each(|s| *s = 0.0);
568            for (t, v) in self.entries(doc) {
569                let s = starts[t as usize];
570                if s == u32::MAX {
571                    continue;
572                }
573                for e in entries[s as usize..].iter().take_while(|e| e.0 == t) {
574                    scores[e.1 as usize] += v * e.2;
575                }
576            }
577            let mut best = 0usize;
578            for (c, &s) in scores.iter().enumerate() {
579                if s > scores[best] {
580                    best = c;
581                }
582            }
583            assign.push(best as u32);
584        }
585        for t in touched {
586            starts[t] = u32::MAX;
587        }
588        assign
589    }
590
591    /// Appends the summary of `docs`: coordinate maxima pruned to `energy` of their mass,
592    /// quantized upward to one byte.
593    fn summarize(
594        &self,
595        docs: impl Iterator<Item = u32>,
596        energy: f32,
597        scratch: &mut BuildScratch,
598        out: &mut TermBlocks,
599    ) {
600        let BuildScratch { dense, touched, .. } = scratch;
601        for d in docs {
602            for (t, v) in self.entries(d) {
603                let slot = &mut dense[t as usize];
604                if *slot == 0.0 {
605                    touched.push(t);
606                }
607                *slot = slot.max(v);
608            }
609        }
610        let mut coords: Vec<(u16, f32)> = touched.iter().map(|&t| (t, dense[t as usize])).collect();
611        for &t in touched.iter() {
612            dense[t as usize] = 0.0;
613        }
614        touched.clear();
615        if energy < 1.0 {
616            coords.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
617            let total: f64 = coords.iter().map(|c| f64::from(c.1)).sum();
618            let target = total * f64::from(energy.max(0.0));
619            let mut acc = 0f64;
620            let mut keep = 0;
621            while keep < coords.len() && (keep == 0 || acc < target) {
622                acc += f64::from(coords[keep].1);
623                keep += 1;
624            }
625            coords.truncate(keep);
626        }
627        coords.sort_unstable_by_key(|c| c.0);
628        let max = coords.iter().fold(0f32, |m, c| m.max(c.1));
629        // Above max / 255 so that the bound survives rounding in the summed inner products.
630        let scale = if max > 0.0 {
631            max / 255.0 * (1.0 + 1e-4)
632        } else {
633            1.0
634        };
635        for &(t, v) in &coords {
636            let mut code = (v / scale).ceil().min(255.0) as u8;
637            while code < 255 && f32::from(code) * scale < v {
638                code += 1;
639            }
640            out.sum_ids.push(t);
641            out.sum_codes.push(code);
642        }
643        out.sum_counts.push(coords.len() as u32);
644        out.scales.push(scale);
645    }
646
647    pub fn n_docs(&self) -> usize {
648        self.n_docs
649    }
650
651    pub fn n_dims(&self) -> usize {
652        self.n_dims
653    }
654
655    /// Bytes held by the index arrays.
656    pub fn memory_bytes(&self) -> usize {
657        self.fwd_offsets.len() * 8
658            + self.fwd_ids.len() * 2
659            + self.fwd_vals.len() * 4
660            + self.fwd_half.len() * 2
661            + self.term_blocks.len() * 4
662            + self.block_doc_offsets.len() * 8
663            + self.block_docs.len() * 4
664            + self.block_sum_offsets.len() * 8
665            + self.sum_ids.len() * 2
666            + self.sum_codes.len()
667            + self.block_scales.len() * 4
668    }
669
670    /// Number of blocks and kept postings.
671    pub fn block_stats(&self) -> (usize, usize) {
672        (self.block_scales.len(), self.block_docs.len())
673    }
674
675    /// Scratch for [`SparseSearcher::search`]; one per thread.
676    pub fn searcher(&self) -> SparseSearcher<'_> {
677        SparseSearcher {
678            index: self,
679            dense: vec![0.0; self.n_dims],
680            dense_pos: vec![0.0; self.n_dims],
681            visited: vec![0; self.n_docs.div_ceil(64)],
682            touched: Vec::new(),
683            heap: BinaryHeap::new(),
684            skipped_blocks: 0,
685        }
686    }
687
688    /// The exact top-k by scanning the forward index (reference for tests and ground truth).
689    pub fn brute_force(&self, ids: &[u32], vals: &[f32], k: usize) -> Vec<(u32, f32)> {
690        let mut dense = vec![0f32; self.n_dims];
691        for (&c, &v) in ids.iter().zip(vals) {
692            if (c as usize) < self.n_dims {
693                dense[c as usize] += v;
694            }
695        }
696        let mut heap = BinaryHeap::new();
697        for d in 0..self.n_docs as u32 {
698            let s = self.score(d, &dense);
699            if s > 0.0 {
700                offer(&mut heap, k, Hit { score: s, doc: d });
701            }
702        }
703        into_sorted(heap)
704    }
705}
706
707#[inline]
708fn dot(ids: &[u16], vals: &[f32], dense: &[f32]) -> f32 {
709    let mut acc = [0f32; 4];
710    let (id4, id_rest) = ids.as_chunks::<4>();
711    let (v4, v_rest) = vals.as_chunks::<4>();
712    for (i, v) in id4.iter().zip(v4) {
713        for j in 0..4 {
714            acc[j] += dense[i[j] as usize] * v[j];
715        }
716    }
717    let mut tail = 0f32;
718    for (&i, &v) in id_rest.iter().zip(v_rest) {
719        tail += dense[i as usize] * v;
720    }
721    (acc[0] + acc[1]) + (acc[2] + acc[3]) + tail
722}
723
724/// Hint the cache to load `bytes` bytes at `ptr`. A no-op where no prefetch is wired up.
725#[inline]
726fn prefetch(ptr: *const u8, bytes: usize) {
727    let end = ptr as usize + bytes;
728    let mut line = ptr as usize & !63;
729    while line < end {
730        #[cfg(target_arch = "aarch64")]
731        // SAFETY: PRFM is a hint: it never faults and writes no register or memory, whatever
732        // the address.
733        unsafe {
734            std::arch::asm!(
735                "prfm pldl1keep, [{0}]",
736                in(reg) line,
737                options(nostack, preserves_flags, readonly)
738            );
739        }
740        #[cfg(target_arch = "x86_64")]
741        // SAFETY: PREFETCHT0 (SSE, part of the x86_64 baseline) is a hint: it never faults
742        // and writes no register or memory, whatever the address.
743        unsafe {
744            std::arch::x86_64::_mm_prefetch::<{ std::arch::x86_64::_MM_HINT_T0 }>(
745                line as *const i8,
746            );
747        }
748        line += 64;
749    }
750}
751
752/// IEEE binary16 bits of a non-negative finite `v`, rounded to nearest; values below the
753/// smallest normal half become that normal (`v > 0`) or zero, values above the largest
754/// become the largest.
755fn f16_bits(v: f32) -> u16 {
756    const MIN_NORMAL: f32 = 6.103_515_6e-5;
757    if v <= 0.0 {
758        return 0;
759    }
760    let v = v.clamp(MIN_NORMAL, 65504.0);
761    let bits = v.to_bits() - ((127 - 15) << 23);
762    let rounded = bits + 0x0fff + ((bits >> 13) & 1);
763    ((rounded >> 13).min(0x7bff)) as u16
764}
765
766/// Value of half bits produced by [`f16_bits`] (zero or normal).
767#[inline]
768fn f16_value(h: u16) -> f32 {
769    let bits = (u32::from(h) << 13) + ((127 - 15) << 23);
770    if h == 0 {
771        0.0
772    } else {
773        f32::from_bits(bits)
774    }
775}
776
777#[inline]
778fn dot_half(ids: &[u16], vals: &[u16], dense: &[f32]) -> f32 {
779    let mut acc = [0f32; 4];
780    let (id4, id_rest) = ids.as_chunks::<4>();
781    let (v4, v_rest) = vals.as_chunks::<4>();
782    for (i, v) in id4.iter().zip(v4) {
783        for j in 0..4 {
784            acc[j] += dense[i[j] as usize] * f16_value(v[j]);
785        }
786    }
787    let mut tail = 0f32;
788    for (&i, &v) in id_rest.iter().zip(v_rest) {
789        tail += dense[i as usize] * f16_value(v);
790    }
791    (acc[0] + acc[1]) + (acc[2] + acc[3]) + tail
792}
793
794/// A scored document; `Ord` puts the worst result (lowest score, then highest id) on top of
795/// the max-heap so it is the one evicted.
796#[derive(Clone, Copy, PartialEq)]
797struct Hit {
798    score: f32,
799    doc: u32,
800}
801
802impl Eq for Hit {}
803
804impl Ord for Hit {
805    fn cmp(&self, other: &Self) -> Ordering {
806        other
807            .score
808            .total_cmp(&self.score)
809            .then(self.doc.cmp(&other.doc))
810    }
811}
812
813impl PartialOrd for Hit {
814    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
815        Some(self.cmp(other))
816    }
817}
818
819fn offer(heap: &mut BinaryHeap<Hit>, k: usize, hit: Hit) {
820    if heap.len() < k {
821        heap.push(hit);
822    } else if let Some(mut worst) = heap.peek_mut() {
823        if hit < *worst {
824            *worst = hit;
825        }
826    }
827}
828
829fn into_sorted(heap: BinaryHeap<Hit>) -> Vec<(u32, f32)> {
830    heap.into_sorted_vec()
831        .into_iter()
832        .map(|h| (h.doc, h.score))
833        .collect()
834}
835
836/// Per-thread search state.
837pub struct SparseSearcher<'a> {
838    index: &'a SparseImpactIndex,
839    dense: Vec<f32>,
840    dense_pos: Vec<f32>,
841    visited: Vec<u64>,
842    touched: Vec<u32>,
843    heap: BinaryHeap<Hit>,
844    /// Blocks passed over by the summary test, over the searcher's lifetime.
845    skipped_blocks: u64,
846}
847
848impl SparseSearcher<'_> {
849    /// The top `k` documents by inner product with the query `(ids, vals)`, best first, as
850    /// `(doc, score)`; documents scoring zero or less are never returned. Duplicate query
851    /// coordinates add up; coordinates outside the index are ignored.
852    pub fn search(
853        &mut self,
854        ids: &[u32],
855        vals: &[f32],
856        k: usize,
857        params: SparseSearchParams,
858    ) -> Vec<(u32, f32)> {
859        let index = self.index;
860        if k == 0 {
861            return Vec::new();
862        }
863        let mut terms: Vec<(u32, f32)> = Vec::with_capacity(ids.len());
864        for (&c, &v) in ids.iter().zip(vals) {
865            if (c as usize) < index.n_dims && v.is_finite() && v != 0.0 {
866                if self.dense[c as usize] == 0.0 && self.dense_pos[c as usize] == 0.0 {
867                    terms.push((c, 0.0));
868                }
869                self.dense[c as usize] += v;
870            }
871        }
872        for t in terms.iter_mut() {
873            let w = self.dense[t.0 as usize];
874            t.1 = w;
875            self.dense_pos[t.0 as usize] = w.max(0.0);
876        }
877        terms.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
878        let walked = terms
879            .iter()
880            .take(params.query_cut)
881            .take_while(|t| t.1 > 0.0);
882
883        let heap = &mut self.heap;
884        heap.clear();
885        for &(t, _) in walked {
886            let blocks =
887                index.term_blocks[t as usize] as usize..index.term_blocks[t as usize + 1] as usize;
888            for b in blocks {
889                if heap.len() == k {
890                    let (s0, s1) = (
891                        index.block_sum_offsets[b] as usize,
892                        index.block_sum_offsets[b + 1] as usize,
893                    );
894                    let mut bound = 0f32;
895                    for (&c, &code) in index.sum_ids[s0..s1].iter().zip(&index.sum_codes[s0..s1]) {
896                        bound += self.dense_pos[c as usize] * f32::from(code);
897                    }
898                    bound *= index.block_scales[b];
899                    let threshold = heap.peek().map_or(f32::MIN, |h| h.score);
900                    if bound * params.heap_factor < threshold {
901                        self.skipped_blocks += 1;
902                        continue;
903                    }
904                }
905                let (d0, d1) = (
906                    index.block_doc_offsets[b] as usize,
907                    index.block_doc_offsets[b + 1] as usize,
908                );
909                let docs = &index.block_docs[d0..d1];
910                if let Some(&first) = docs.first() {
911                    index.prefetch_row(first);
912                }
913                for (i, &d) in docs.iter().enumerate() {
914                    if let Some(&next) = docs.get(i + 1) {
915                        index.prefetch_row(next);
916                    }
917                    let (w, bit) = ((d >> 6) as usize, 1u64 << (d & 63));
918                    if self.visited[w] & bit != 0 {
919                        continue;
920                    }
921                    if self.visited[w] == 0 {
922                        self.touched.push(w as u32);
923                    }
924                    self.visited[w] |= bit;
925                    let s = index.score(d, &self.dense);
926                    if s > 0.0 {
927                        offer(heap, k, Hit { score: s, doc: d });
928                    }
929                }
930            }
931        }
932        for &w in &self.touched {
933            self.visited[w as usize] = 0;
934        }
935        self.touched.clear();
936        for t in &terms {
937            self.dense[t.0 as usize] = 0.0;
938            self.dense_pos[t.0 as usize] = 0.0;
939        }
940        into_sorted(std::mem::take(heap))
941    }
942}
943
944impl SparseImpactIndex {
945    /// Searches the CSR query rows in parallel; row `i` of the result holds query `i`'s ids,
946    /// best first, padded with `u32::MAX`.
947    pub fn search_batch(
948        &self,
949        queries: CsrView<'_>,
950        k: usize,
951        params: SparseSearchParams,
952    ) -> Result<Vec<u32>, SparseIndexError> {
953        let nq = queries.validate()?;
954        let mut out = vec![u32::MAX; nq * k];
955        if k == 0 {
956            return Ok(out);
957        }
958        // A searcher holds dense query arrays and a visited bitset over all documents: give each
959        // task several queries so the per-task setup is amortized.
960        let min_len = nq.div_ceil(rayon::current_num_threads() * 4).max(1);
961        out.par_chunks_mut(k)
962            .enumerate()
963            .with_min_len(min_len)
964            .for_each_init(
965                || (self.searcher(), Vec::new()),
966                |(searcher, ids), (q, row)| {
967                    let (qi, qv) = queries.row(q);
968                    ids.clear();
969                    ids.extend(qi.iter().map(|&c| c as u32));
970                    for (slot, (doc, _)) in row.iter_mut().zip(searcher.search(ids, qv, k, params))
971                    {
972                        *slot = doc;
973                    }
974                },
975            );
976        Ok(out)
977    }
978}
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    struct Csr {
985        indptr: Vec<i64>,
986        indices: Vec<i32>,
987        data: Vec<f32>,
988        n_dims: usize,
989    }
990
991    impl Csr {
992        fn view(&self) -> CsrView<'_> {
993            CsrView {
994                indptr: &self.indptr,
995                indices: &self.indices,
996                data: &self.data,
997                n_dims: self.n_dims,
998            }
999        }
1000    }
1001
1002    /// Random rows with a skewed coordinate distribution; values on a coarse grid so that ties
1003    /// occur, some rows empty and some duplicated.
1004    fn random_csr(rows: usize, n_dims: usize, max_nnz: usize, state: &mut u64) -> Csr {
1005        let mut indptr = vec![0i64];
1006        let mut indices = Vec::new();
1007        let mut data = Vec::new();
1008        for r in 0..rows {
1009            if r % 17 == 5 && r > 0 {
1010                let (a, b) = (indptr[r - 1] as usize, indptr[r] as usize);
1011                indices.extend_from_within(a..b);
1012                data.extend_from_within(a..b);
1013            } else {
1014                let nnz = (splitmix(state) % (max_nnz as u64 + 1)) as usize;
1015                let mut cols: Vec<i32> = (0..nnz)
1016                    .map(|_| {
1017                        let u = splitmix(state) % (n_dims as u64 * n_dims as u64);
1018                        (u as f64).sqrt() as i32
1019                    })
1020                    .collect();
1021                cols.sort_unstable();
1022                cols.dedup();
1023                for c in cols {
1024                    indices.push(c);
1025                    data.push((1 + splitmix(state) % 8) as f32 * 0.25);
1026                }
1027            }
1028            indptr.push(indices.len() as i64);
1029        }
1030        Csr {
1031            indptr,
1032            indices,
1033            data,
1034            n_dims,
1035        }
1036    }
1037
1038    #[test]
1039    fn exact_mode_matches_brute_force() {
1040        let mut state = 7u64;
1041        for (rows, n_dims, frac) in [(600, 64, 0.1f32), (2000, 300, 0.05), (50, 16, 0.5)] {
1042            let docs = random_csr(rows, n_dims, 24, &mut state);
1043            let params = SparseBuildParams {
1044                block_fraction: frac,
1045                transpose_budget: 997,
1046                ..SparseBuildParams::exact()
1047            };
1048            let index = SparseImpactIndex::build(docs.view(), params).unwrap();
1049            let queries = random_csr(80, n_dims + 4, 12, &mut state);
1050            let mut searcher = index.searcher();
1051            for q in 0..80 {
1052                let (a, b) = (queries.indptr[q] as usize, queries.indptr[q + 1] as usize);
1053                let ids: Vec<u32> = queries.indices[a..b].iter().map(|&c| c as u32).collect();
1054                let mut vals = queries.data[a..b].to_vec();
1055                if q % 9 == 0 && !vals.is_empty() {
1056                    vals[0] = -vals[0];
1057                }
1058                for k in [1, 10, 37] {
1059                    let want = index.brute_force(&ids, &vals, k);
1060                    let got = searcher.search(&ids, &vals, k, SparseSearchParams::exact());
1061                    assert_eq!(got, want, "rows {rows} query {q} k {k}");
1062                }
1063            }
1064            // The bound must actually prune, or this compares brute force with itself.
1065            assert!(
1066                searcher.skipped_blocks > 0,
1067                "rows {rows}: no block was skipped"
1068            );
1069        }
1070    }
1071
1072    #[test]
1073    fn approximate_mode_returns_true_scores() {
1074        let mut state = 11u64;
1075        let docs = random_csr(3000, 200, 30, &mut state);
1076        let index = SparseImpactIndex::build(
1077            docs.view(),
1078            SparseBuildParams {
1079                postings_per_term: 100,
1080                ..SparseBuildParams::default()
1081            },
1082        )
1083        .unwrap();
1084        let params = SparseSearchParams {
1085            query_cut: 5,
1086            heap_factor: 0.8,
1087        };
1088        let mut searcher = index.searcher();
1089        let (ids, vals) = ([3u32, 40, 41, 150], [1.0f32, 0.5, 0.75, 2.0]);
1090        let got = searcher.search(&ids, &vals, 10, params);
1091        assert!(!got.is_empty());
1092        let mut dense = vec![0f32; 200];
1093        for (&c, &v) in ids.iter().zip(&vals) {
1094            dense[c as usize] = v;
1095        }
1096        for w in got.windows(2) {
1097            assert!(w[0].1 >= w[1].1);
1098        }
1099        for &(d, s) in &got {
1100            assert_eq!(s, index.score(d, &dense));
1101        }
1102        let batch = index
1103            .search_batch(
1104                CsrView {
1105                    indptr: &[0, 4],
1106                    indices: &[3, 40, 41, 150],
1107                    data: &vals,
1108                    n_dims: 200,
1109                },
1110                10,
1111                params,
1112            )
1113            .unwrap();
1114        let ids_only: Vec<u32> = got.iter().map(|h| h.0).collect();
1115        assert_eq!(&batch[..ids_only.len()], ids_only.as_slice());
1116    }
1117
1118    #[test]
1119    fn half_values_round_to_nearest() {
1120        for v in [
1121            6.2e-5f32, 0.001, 0.1, 0.333, 1.0, 1.5, 2.75, 7.9, 1000.0, 65504.0,
1122        ] {
1123            let h = f16_value(f16_bits(v));
1124            assert!((h - v).abs() <= v * (1.0 / 2048.0), "{v} -> {h}");
1125        }
1126        assert_eq!(f16_value(f16_bits(0.0)), 0.0);
1127        assert_eq!(f16_value(f16_bits(1e9)), 65504.0);
1128        assert!(f16_value(f16_bits(1e-9)) > 0.0);
1129    }
1130
1131    #[test]
1132    fn rejects_bad_input() {
1133        let bad = |indptr: Vec<i64>, indices: Vec<i32>, data: Vec<f32>| {
1134            SparseImpactIndex::build(
1135                CsrView {
1136                    indptr: &indptr,
1137                    indices: &indices,
1138                    data: &data,
1139                    n_dims: 4,
1140                },
1141                SparseBuildParams::exact(),
1142            )
1143            .is_err()
1144        };
1145        assert!(bad(vec![0, 1], vec![4], vec![1.0]));
1146        assert!(bad(vec![0, 1], vec![0], vec![-1.0]));
1147        assert!(bad(vec![0, 1], vec![0], vec![f32::NAN]));
1148        assert!(bad(vec![0, 2], vec![0], vec![1.0]));
1149        assert!(bad(vec![], vec![], vec![]));
1150    }
1151}