Skip to main content

holographic_memory/core/sparse/
mod.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Learned sparse retrieval: an impact index over learned sparse vectors (SPLADE-style
4//! vocabulary-sized vectors, or any sparse term weights) with exact dot-product top-k.
5//!
6//! Documents are stored as postings per term, ordered by document id in blocks of at most
7//! [`BLOCK`] entries: a 12-byte block header (first id, count, block maximum impact, payload
8//! offset) and a payload of varint id gaps followed by one byte per impact. Learned weights are
9//! quantized to 8-bit impacts with one global scale, so a score is `scale * sum q_t * impact_t`.
10//! Queries run document-at-a-time MaxScore (Turtle and Flood, 1995): terms sorted by their
11//! maximal contribution, the lowest ones become non-essential once the k-th best score exceeds
12//! their summed bound and are only consulted for documents that can still qualify, with the
13//! block maxima skipping whole blocks that cannot. The result is the exact top-k over the
14//! quantized impacts.
15
16use std::collections::BinaryHeap;
17
18use crate::core::error::HmsError;
19
20/// Postings per block.
21pub const BLOCK: usize = 128;
22
23/// A sparse vector: term ids ascending with their weights.
24#[derive(Clone, Debug, Default, PartialEq)]
25pub struct SparseVector {
26    pub ids: Vec<u32>,
27    pub weights: Vec<f32>,
28}
29
30impl SparseVector {
31    /// Builds a vector from `(id, weight)` pairs, sorting them by id and dropping zeros and
32    /// negative weights; duplicate ids are summed.
33    pub fn from_pairs(pairs: impl IntoIterator<Item = (u32, f32)>) -> Self {
34        let mut pairs: Vec<(u32, f32)> = pairs.into_iter().filter(|p| p.1 > 0.0).collect();
35        pairs.sort_unstable_by_key(|p| p.0);
36        let mut out = Self::default();
37        for (id, w) in pairs {
38            if out.ids.last() == Some(&id) {
39                *out.weights.last_mut().expect("parallel") += w;
40            } else {
41                out.ids.push(id);
42                out.weights.push(w);
43            }
44        }
45        out
46    }
47
48    /// Exact dot product with another vector (both ascending).
49    pub fn dot(&self, other: &SparseVector) -> f32 {
50        let (mut i, mut j, mut s) = (0, 0, 0f32);
51        while i < self.ids.len() && j < other.ids.len() {
52            match self.ids[i].cmp(&other.ids[j]) {
53                std::cmp::Ordering::Less => i += 1,
54                std::cmp::Ordering::Greater => j += 1,
55                std::cmp::Ordering::Equal => {
56                    s += self.weights[i] * other.weights[j];
57                    i += 1;
58                    j += 1;
59                }
60            }
61        }
62        s
63    }
64}
65
66#[derive(Clone, Copy, Debug)]
67#[repr(C)]
68struct BlockHeader {
69    first: u32,
70    offset: u32,
71    count: u16,
72    max: u8,
73    _pad: u8,
74}
75
76/// The impact index.
77pub struct ImpactIndex {
78    vocab: u32,
79    docs: u32,
80    scale: f32,
81    /// Block range of term `t`: `term_blocks[t]..term_blocks[t + 1]`.
82    term_blocks: Vec<u32>,
83    term_max: Vec<u8>,
84    blocks: Vec<BlockHeader>,
85    payload: Vec<u8>,
86    postings: u64,
87}
88
89fn put_varint(out: &mut Vec<u8>, mut v: u32) {
90    while v >= 0x80 {
91        out.push((v as u8) | 0x80);
92        v >>= 7;
93    }
94    out.push(v as u8);
95}
96
97#[inline]
98fn get_varint(bytes: &[u8], at: &mut usize) -> u32 {
99    let mut v = 0u32;
100    let mut shift = 0;
101    loop {
102        let b = bytes[*at];
103        *at += 1;
104        v |= u32::from(b & 0x7f) << shift;
105        if b & 0x80 == 0 {
106            return v;
107        }
108        shift += 7;
109    }
110}
111
112impl ImpactIndex {
113    /// Builds the index over `docs` (document id = position) with a vocabulary of `vocab`
114    /// terms. Weights are quantized to impacts `1..=255` with `scale = max weight / 255`;
115    /// weights that round to zero are dropped.
116    pub fn build(docs: &[SparseVector], vocab: u32) -> Result<Self, HmsError> {
117        if docs.len() >= u32::MAX as usize {
118            return Err(HmsError::InvalidParam {
119                details: "too many documents for u32 ids".into(),
120            });
121        }
122        let mut max_w = 0f32;
123        for d in docs {
124            if d.ids.len() != d.weights.len() || d.ids.windows(2).any(|p| p[0] >= p[1]) {
125                return Err(HmsError::InvalidParam {
126                    details: "sparse vector ids must be strictly ascending with one weight each"
127                        .into(),
128                });
129            }
130            if let Some(&last) = d.ids.last() {
131                if last >= vocab {
132                    return Err(HmsError::InvalidParam {
133                        details: format!("term id {last} outside the vocabulary of {vocab}"),
134                    });
135                }
136            }
137            for &w in &d.weights {
138                if !w.is_finite() || w < 0.0 {
139                    return Err(HmsError::InvalidParam {
140                        details: "sparse weights must be finite and non-negative".into(),
141                    });
142                }
143                max_w = max_w.max(w);
144            }
145        }
146        let scale = if max_w > 0.0 { max_w / 255.0 } else { 1.0 };
147        // Postings per term, in document order (documents are visited in id order).
148        let mut lists: Vec<Vec<(u32, u8)>> = vec![Vec::new(); vocab as usize];
149        let mut postings = 0u64;
150        for (doc, d) in docs.iter().enumerate() {
151            for (&t, &w) in d.ids.iter().zip(&d.weights) {
152                let q = (w / scale).round().clamp(0.0, 255.0) as u8;
153                if q > 0 {
154                    lists[t as usize].push((doc as u32, q));
155                    postings += 1;
156                }
157            }
158        }
159        let mut term_blocks = Vec::with_capacity(vocab as usize + 1);
160        let mut term_max = Vec::with_capacity(vocab as usize);
161        let mut blocks = Vec::new();
162        let mut payload = Vec::new();
163        for list in &lists {
164            term_blocks.push(blocks.len() as u32);
165            term_max.push(list.iter().map(|p| p.1).max().unwrap_or(0));
166            for chunk in list.chunks(BLOCK) {
167                let offset = payload.len() as u32;
168                let mut prev = chunk[0].0;
169                for &(doc, _) in &chunk[1..] {
170                    put_varint(&mut payload, doc - prev);
171                    prev = doc;
172                }
173                payload.extend(chunk.iter().map(|p| p.1));
174                blocks.push(BlockHeader {
175                    first: chunk[0].0,
176                    offset,
177                    count: chunk.len() as u16,
178                    max: chunk.iter().map(|p| p.1).max().unwrap_or(0),
179                    _pad: 0,
180                });
181            }
182        }
183        term_blocks.push(blocks.len() as u32);
184        Ok(Self {
185            vocab,
186            docs: docs.len() as u32,
187            scale,
188            term_blocks,
189            term_max,
190            blocks,
191            payload,
192            postings,
193        })
194    }
195
196    pub fn documents(&self) -> u32 {
197        self.docs
198    }
199
200    pub fn vocab(&self) -> u32 {
201        self.vocab
202    }
203
204    /// Stored postings.
205    pub fn postings(&self) -> u64 {
206        self.postings
207    }
208
209    /// Bytes of the index: block directory, payload, and the per-term tables.
210    pub fn bytes(&self) -> usize {
211        self.blocks.len() * std::mem::size_of::<BlockHeader>()
212            + self.payload.len()
213            + self.term_blocks.len() * 4
214            + self.term_max.len()
215    }
216
217    /// Quantization step of the impacts.
218    pub fn scale(&self) -> f32 {
219        self.scale
220    }
221
222    /// Decodes block `b` into `docs` and `impacts`.
223    fn decode(&self, b: usize, docs: &mut Vec<u32>, impacts: &mut Vec<u8>) {
224        let h = self.blocks[b];
225        let n = usize::from(h.count);
226        docs.clear();
227        impacts.clear();
228        let mut at = h.offset as usize;
229        let mut doc = h.first;
230        docs.push(doc);
231        for _ in 1..n {
232            doc += get_varint(&self.payload, &mut at);
233            docs.push(doc);
234        }
235        impacts.extend_from_slice(&self.payload[at..at + n]);
236    }
237
238    /// Exact score of document `doc` for `query` over the quantized impacts (reference).
239    pub fn score_document(&self, query: &SparseVector, doc: u32) -> f32 {
240        let (mut docs, mut impacts) = (Vec::new(), Vec::new());
241        let mut s = 0f32;
242        for (&t, &qw) in query.ids.iter().zip(&query.weights) {
243            if t >= self.vocab {
244                continue;
245            }
246            let (lo, hi) = (
247                self.term_blocks[t as usize] as usize,
248                self.term_blocks[t as usize + 1] as usize,
249            );
250            if lo == hi {
251                continue;
252            }
253            // Last block whose first id is at most `doc`.
254            let b = lo + self.blocks[lo..hi].partition_point(|h| h.first <= doc);
255            if b == lo {
256                continue;
257            }
258            self.decode(b - 1, &mut docs, &mut impacts);
259            if let Ok(i) = docs.binary_search(&doc) {
260                s += qw * f32::from(impacts[i]);
261            }
262        }
263        s * self.scale
264    }
265
266    /// The `k` best documents by dot product with `query` (score descending, ties by id),
267    /// exact over the quantized impacts.
268    pub fn top_k(&self, query: &SparseVector, k: usize) -> Vec<(u32, f32)> {
269        if k == 0 {
270            return Vec::new();
271        }
272        // Query terms with postings, ascending by their maximal contribution.
273        let mut terms: Vec<(u32, f32, f32)> = query
274            .ids
275            .iter()
276            .zip(&query.weights)
277            .filter(|(&t, &w)| {
278                w > 0.0
279                    && t < self.vocab
280                    && self.term_blocks[t as usize] < self.term_blocks[t as usize + 1]
281            })
282            .map(|(&t, &w)| (t, w, w * f32::from(self.term_max[t as usize])))
283            .collect();
284        terms.sort_by(|a, b| a.2.total_cmp(&b.2));
285        let n = terms.len();
286        if n == 0 {
287            return Vec::new();
288        }
289        let mut prefix = vec![0f32; n];
290        let mut acc = 0f32;
291        for (i, t) in terms.iter().enumerate() {
292            acc += t.2;
293            prefix[i] = acc;
294        }
295        let mut cursors: Vec<Cursor> = terms
296            .iter()
297            .map(|&(t, qw, _)| Cursor::new(self, t, qw))
298            .collect();
299        let mut heap: BinaryHeap<std::cmp::Reverse<(OrdF32, std::cmp::Reverse<u32>)>> =
300            BinaryHeap::with_capacity(k + 1);
301        let mut theta = f32::NEG_INFINITY;
302        // Terms `p..n` are essential: without any of them a document cannot beat `theta`.
303        let mut p = 0usize;
304        loop {
305            let mut doc = u32::MAX;
306            for c in &cursors[p..] {
307                doc = doc.min(c.doc);
308            }
309            if doc == u32::MAX {
310                break;
311            }
312            let mut score = 0f32;
313            for c in &mut cursors[p..] {
314                if c.doc == doc {
315                    score += c.qw * f32::from(c.impact);
316                    c.advance(self);
317                }
318            }
319            // Non-essential terms, largest bound first; each one's absence is a bound.
320            let mut i = p;
321            while i > 0 {
322                i -= 1;
323                if score + prefix[i] <= theta {
324                    break;
325                }
326                let c = &mut cursors[i];
327                let remaining = if i > 0 { prefix[i - 1] } else { 0.0 };
328                if let Some(imp) = c.seek(self, doc, theta - score - remaining) {
329                    score += c.qw * f32::from(imp);
330                }
331            }
332            if score > theta {
333                heap.push(std::cmp::Reverse((OrdF32(score), std::cmp::Reverse(doc))));
334                if heap.len() > k {
335                    heap.pop();
336                }
337                if heap.len() == k {
338                    theta = heap.peek().expect("k >= 1").0 .0 .0;
339                    while p < n && prefix[p] <= theta {
340                        p += 1;
341                    }
342                }
343            }
344            if p >= n {
345                // Every term is non-essential: no unseen document can beat theta.
346                break;
347            }
348        }
349        let mut out: Vec<(u32, f32)> = heap
350            .into_iter()
351            .map(|r| ((r.0).1 .0, (r.0).0 .0 * self.scale))
352            .collect();
353        out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
354        out
355    }
356}
357
358/// f32 with a total order, for the result heap.
359#[derive(Clone, Copy, PartialEq)]
360struct OrdF32(f32);
361impl Eq for OrdF32 {}
362impl PartialOrd for OrdF32 {
363    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
364        Some(self.cmp(other))
365    }
366}
367impl Ord for OrdF32 {
368    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
369        self.0.total_cmp(&other.0)
370    }
371}
372
373/// Traversal state of one query term's postings.
374struct Cursor {
375    qw: f32,
376    lo: usize,
377    hi: usize,
378    block: usize,
379    pos: usize,
380    docs: Vec<u32>,
381    impacts: Vec<u8>,
382    /// Current document (`u32::MAX` at the end) and its impact.
383    doc: u32,
384    impact: u8,
385}
386
387impl Cursor {
388    fn new(index: &ImpactIndex, term: u32, qw: f32) -> Self {
389        let (lo, hi) = (
390            index.term_blocks[term as usize] as usize,
391            index.term_blocks[term as usize + 1] as usize,
392        );
393        let mut c = Self {
394            qw,
395            lo,
396            hi,
397            block: lo,
398            pos: 0,
399            docs: Vec::with_capacity(BLOCK),
400            impacts: Vec::with_capacity(BLOCK),
401            doc: u32::MAX,
402            impact: 0,
403        };
404        c.load(index, lo);
405        c
406    }
407
408    fn load(&mut self, index: &ImpactIndex, block: usize) {
409        self.block = block;
410        self.pos = 0;
411        if block < self.hi {
412            index.decode(block, &mut self.docs, &mut self.impacts);
413            self.doc = self.docs[0];
414            self.impact = self.impacts[0];
415        } else {
416            self.doc = u32::MAX;
417            self.impact = 0;
418        }
419    }
420
421    #[inline]
422    fn advance(&mut self, index: &ImpactIndex) {
423        self.pos += 1;
424        if self.pos < self.docs.len() {
425            self.doc = self.docs[self.pos];
426            self.impact = self.impacts[self.pos];
427        } else {
428            let next = self.block + 1;
429            self.load(index, next);
430        }
431    }
432
433    /// Moves to the first document at or beyond `target` and returns its impact if it is
434    /// `target` itself. Blocks whose maximal contribution cannot lift the score above the
435    /// threshold (`need`, in impact units times `qw`) are skipped without decoding.
436    fn seek(&mut self, index: &ImpactIndex, target: u32, need: f32) -> Option<u8> {
437        if self.doc > target {
438            return None;
439        }
440        if self.doc == target {
441            return Some(self.impact);
442        }
443        // Past the current block? Find the last block starting at or before the target.
444        let in_block = self.block < self.hi
445            && self.pos < self.docs.len()
446            && target <= *self.docs.last().expect("non-empty block");
447        if !in_block {
448            let b = self.lo + index.blocks[self.lo..self.hi].partition_point(|h| h.first <= target);
449            if b == self.lo {
450                return None;
451            }
452            let b = b - 1;
453            if b != self.block || self.pos >= self.docs.len() {
454                if self.qw * f32::from(index.blocks[b].max) <= need {
455                    // The block cannot matter for this document; stay positioned before it
456                    // logically by jumping to it without decoding when later seeks pass it.
457                    self.block = b;
458                    self.pos = usize::MAX;
459                    self.doc = index.blocks[b].first.max(target);
460                    self.impact = 0;
461                    self.docs.clear();
462                    self.impacts.clear();
463                    return None;
464                }
465                self.load(index, b);
466            }
467        }
468        // Within the loaded block.
469        let start = if self.pos == usize::MAX { 0 } else { self.pos };
470        let at = start + self.docs[start..].partition_point(|&d| d < target);
471        if at < self.docs.len() {
472            self.pos = at;
473            self.doc = self.docs[at];
474            self.impact = self.impacts[at];
475            (self.doc == target).then_some(self.impact)
476        } else {
477            let next = self.block + 1;
478            self.load(index, next);
479            None
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    /// SplitMix64, for reproducible random corpora.
489    struct SplitMix(u64);
490    impl SplitMix {
491        fn next_u64(&mut self) -> u64 {
492            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
493            let mut z = self.0;
494            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
495            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
496            z ^ (z >> 31)
497        }
498    }
499
500    fn random_corpus(n: usize, vocab: u32, nnz: usize, seed: u64) -> Vec<SparseVector> {
501        let mut rng = SplitMix(seed);
502        (0..n)
503            .map(|_| {
504                SparseVector::from_pairs((0..nnz).map(|_| {
505                    (
506                        (rng.next_u64() % u64::from(vocab)) as u32,
507                        (rng.next_u64() % 1000) as f32 / 100.0 + 0.01,
508                    )
509                }))
510            })
511            .collect()
512    }
513
514    #[test]
515    fn postings_round_trip_and_block_maxima_bound_every_impact() {
516        let docs = random_corpus(2000, 300, 40, 1);
517        let index = ImpactIndex::build(&docs, 300).expect("build");
518        let (mut ids, mut impacts) = (Vec::new(), Vec::new());
519        let mut seen = 0u64;
520        for t in 0..300u32 {
521            let (lo, hi) = (
522                index.term_blocks[t as usize] as usize,
523                index.term_blocks[t as usize + 1] as usize,
524            );
525            let mut last = None;
526            for b in lo..hi {
527                index.decode(b, &mut ids, &mut impacts);
528                assert_eq!(ids.len(), impacts.len());
529                assert!(ids.len() <= BLOCK && !ids.is_empty());
530                assert!(ids.windows(2).all(|p| p[0] < p[1]));
531                assert!(impacts.iter().all(|&i| i >= 1 && i <= index.blocks[b].max));
532                assert!(impacts.contains(&index.blocks[b].max));
533                assert!(index.blocks[b].max <= index.term_max[t as usize]);
534                if let Some(l) = last {
535                    assert!(ids[0] > l);
536                }
537                last = ids.last().copied();
538                for (&d, &i) in ids.iter().zip(&impacts) {
539                    let doc = &docs[d as usize];
540                    let at = doc
541                        .ids
542                        .binary_search(&t)
543                        .expect("posting belongs to document");
544                    let want = (doc.weights[at] / index.scale()).round() as u8;
545                    assert_eq!(i, want);
546                    seen += 1;
547                }
548            }
549        }
550        assert_eq!(seen, index.postings());
551        assert!(
552            index.bytes() < seen as usize * 5,
553            "{} bytes for {seen} postings",
554            index.bytes()
555        );
556    }
557
558    #[test]
559    fn top_k_matches_brute_force_over_quantized_impacts() {
560        let docs = random_corpus(3000, 500, 30, 2);
561        let index = ImpactIndex::build(&docs, 500).expect("build");
562        let queries = random_corpus(60, 500, 12, 3);
563        for q in &queries {
564            for &k in &[1usize, 10, 100] {
565                let got = index.top_k(q, k);
566                let mut want: Vec<(u32, f32)> = (0..docs.len() as u32)
567                    .map(|d| (d, index.score_document(q, d)))
568                    .collect();
569                want.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
570                want.truncate(k);
571                assert_eq!(got.len(), want.len().min(k));
572                // Summation order differs between the two paths, so scores agree to f32
573                // rounding; ids must agree wherever the scores are not tied within it.
574                let tol = |w: f32| 1e-5 * w.abs().max(1.0);
575                for (i, (g, w)) in got.iter().zip(&want).enumerate() {
576                    assert!((g.1 - w.1).abs() <= tol(w.1), "{g:?} vs {w:?}");
577                    let tied = |j: usize| j < want.len() && (want[j].1 - w.1).abs() <= tol(w.1);
578                    if g.0 != w.0 {
579                        let lo = i.checked_sub(1).is_some_and(tied);
580                        assert!(lo || tied(i + 1), "rank {i}: {g:?} vs {w:?} is not a tie");
581                    }
582                }
583            }
584        }
585        assert!(index.top_k(&SparseVector::default(), 10).is_empty());
586        assert!(index.top_k(&queries[0], 0).is_empty());
587    }
588
589    #[test]
590    fn invalid_vectors_are_rejected() {
591        let bad = SparseVector {
592            ids: vec![3, 1],
593            weights: vec![1.0, 1.0],
594        };
595        assert!(ImpactIndex::build(&[bad], 10).is_err());
596        let outside = SparseVector {
597            ids: vec![12],
598            weights: vec![1.0],
599        };
600        assert!(ImpactIndex::build(&[outside], 10).is_err());
601        let nan = SparseVector {
602            ids: vec![1],
603            weights: vec![f32::NAN],
604        };
605        assert!(ImpactIndex::build(&[nan], 10).is_err());
606        let v = SparseVector::from_pairs([(5, 1.0), (2, 0.5), (5, 2.0), (7, 0.0)]);
607        assert_eq!(v.ids, vec![2, 5]);
608        assert_eq!(v.weights, vec![0.5, 3.0]);
609        assert_eq!(v.dot(&SparseVector::from_pairs([(5, 2.0), (9, 1.0)])), 6.0);
610    }
611}