1use std::collections::BinaryHeap;
17
18use crate::core::error::HmsError;
19
20pub const BLOCK: usize = 128;
22
23#[derive(Clone, Debug, Default, PartialEq)]
25pub struct SparseVector {
26 pub ids: Vec<u32>,
27 pub weights: Vec<f32>,
28}
29
30impl SparseVector {
31 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 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
76pub struct ImpactIndex {
78 vocab: u32,
79 docs: u32,
80 scale: f32,
81 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 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 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 pub fn postings(&self) -> u64 {
206 self.postings
207 }
208
209 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 pub fn scale(&self) -> f32 {
219 self.scale
220 }
221
222 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 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 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 pub fn top_k(&self, query: &SparseVector, k: usize) -> Vec<(u32, f32)> {
269 if k == 0 {
270 return Vec::new();
271 }
272 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 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 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 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#[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
373struct 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 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 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 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 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 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 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 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}