Skip to main content

summa_core/structures/postings/sparse/
mod.rs

1//! Sparse vector posting list with quantized weights
2//!
3//! Sparse vectors are stored as inverted index posting lists where:
4//! - Each dimension ID is a "term"
5//! - Each document has a weight for that dimension
6//!
7//! ## Configurable Components
8//!
9//! **Index (term/dimension ID) size:**
10//! - `IndexSize::U16`: 16-bit indices (0-65535), ideal for SPLADE (~30K vocab)
11//! - `IndexSize::U32`: 32-bit indices (0-4B), for large vocabularies
12//!
13//! **Weight quantization:**
14//! - `Float32`: Full precision (4 bytes per weight)
15//! - `Float16`: Half precision (2 bytes per weight)
16//! - `UInt8`: 8-bit quantization with scale factor (1 byte per weight)
17//! - `UInt4`: 4-bit quantization with scale factor (0.5 bytes per weight)
18//!
19//! ## Block Format (v2)
20//!
21//! The block-based format separates data into 3 sub-blocks per 128-entry block:
22//! - **Doc IDs**: Delta-encoded, bit-packed (SIMD-friendly)
23//! - **Ordinals**: Bit-packed small integers (lazy decode, only for results)
24//! - **Weights**: Quantized (f32/f16/u8/u4)
25
26mod block;
27mod config;
28pub(crate) mod dimensions;
29mod weights;
30pub(crate) use weights::decode_weight_at as decode_sparse_weight_at;
31#[cfg(any(feature = "native", feature = "wasm", test))]
32pub(crate) use weights::encode_weights as encode_sparse_weights;
33mod partitioner;
34
35pub use block::{BlockSparsePostingIterator, BlockSparsePostingList, SparseBlock};
36pub use config::{
37    IndexSize, QueryWeighting, SeismicConfig, SparseEntry, SparseFormat, SparseQueryConfig,
38    SparseVector, SparseVectorConfig, WeightQuantization,
39};
40pub use partitioner::optimal_partition;
41
42use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
43use std::io::{self, Read, Write};
44
45use super::posting_common::{read_vint, write_vint};
46use crate::DocId;
47
48/// A sparse posting entry: doc_id with quantized weight
49#[derive(Debug, Clone, Copy)]
50pub struct SparsePosting {
51    pub doc_id: DocId,
52    pub weight: f32,
53}
54
55/// Block size for sparse posting lists (matches OptP4D for SIMD alignment)
56pub const SPARSE_BLOCK_SIZE: usize = 128;
57
58/// Skip entry for sparse posting lists with block-max support
59///
60/// Extends the basic skip entry with `max_weight` for block-max pruning optimization.
61/// Used for lazy block loading - only skip list is loaded, blocks loaded on-demand.
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct SparseSkipEntry {
64    /// First doc_id in the block (absolute)
65    pub first_doc: DocId,
66    /// Last doc_id in the block
67    pub last_doc: DocId,
68    /// Byte offset to block data (relative to data section start)
69    pub offset: u64,
70    /// Byte length of block data
71    pub length: u32,
72    /// Maximum weight in this block (for Block-Max optimization)
73    pub max_weight: f32,
74}
75
76impl SparseSkipEntry {
77    /// Size in bytes when serialized
78    pub const SIZE: usize = 24; // 4 + 4 + 8 + 4 + 4
79
80    pub fn new(
81        first_doc: DocId,
82        last_doc: DocId,
83        offset: u64,
84        length: u32,
85        max_weight: f32,
86    ) -> Self {
87        Self {
88            first_doc,
89            last_doc,
90            offset,
91            length,
92            max_weight,
93        }
94    }
95
96    /// Compute the maximum possible contribution of this block to a dot product
97    ///
98    /// For a query dimension with weight `query_weight`, the maximum contribution
99    /// from this block is `query_weight * max_weight`.
100    #[inline]
101    pub fn block_max_contribution(&self, query_weight: f32) -> f32 {
102        query_weight * self.max_weight
103    }
104
105    /// Read a skip entry from a 24-byte LE slice (zero-copy, no I/O).
106    #[inline]
107    pub fn from_bytes(b: &[u8]) -> Self {
108        Self {
109            first_doc: u32::from_le_bytes(b[0..4].try_into().unwrap()),
110            last_doc: u32::from_le_bytes(b[4..8].try_into().unwrap()),
111            offset: u64::from_le_bytes(b[8..16].try_into().unwrap()),
112            length: u32::from_le_bytes(b[16..20].try_into().unwrap()),
113            max_weight: f32::from_le_bytes(b[20..24].try_into().unwrap()),
114        }
115    }
116
117    /// Append the 24-byte LE representation to a byte buffer.
118    #[inline]
119    pub fn write_to_vec(&self, buf: &mut Vec<u8>) {
120        buf.extend_from_slice(&self.first_doc.to_le_bytes());
121        buf.extend_from_slice(&self.last_doc.to_le_bytes());
122        buf.extend_from_slice(&self.offset.to_le_bytes());
123        buf.extend_from_slice(&self.length.to_le_bytes());
124        buf.extend_from_slice(&self.max_weight.to_le_bytes());
125    }
126
127    /// Read a skip entry at `idx` from a contiguous skip byte slice.
128    #[inline]
129    pub fn read_at(skip_bytes: &[u8], idx: usize) -> Self {
130        let off = idx * Self::SIZE;
131        Self::from_bytes(&skip_bytes[off..off + Self::SIZE])
132    }
133
134    /// Write skip entry to writer
135    pub fn write<W: Write + ?Sized>(&self, writer: &mut W) -> io::Result<()> {
136        writer.write_u32::<LittleEndian>(self.first_doc)?;
137        writer.write_u32::<LittleEndian>(self.last_doc)?;
138        writer.write_u64::<LittleEndian>(self.offset)?;
139        writer.write_u32::<LittleEndian>(self.length)?;
140        writer.write_f32::<LittleEndian>(self.max_weight)?;
141        Ok(())
142    }
143
144    /// Read skip entry from reader
145    pub fn read<R: Read>(reader: &mut R) -> io::Result<Self> {
146        let first_doc = reader.read_u32::<LittleEndian>()?;
147        let last_doc = reader.read_u32::<LittleEndian>()?;
148        let offset = reader.read_u64::<LittleEndian>()?;
149        let length = reader.read_u32::<LittleEndian>()?;
150        let max_weight = reader.read_f32::<LittleEndian>()?;
151        Ok(Self {
152            first_doc,
153            last_doc,
154            offset,
155            length,
156            max_weight,
157        })
158    }
159}
160
161/// Skip list for sparse posting lists with block-max support
162#[derive(Debug, Clone, Default)]
163pub struct SparseSkipList {
164    entries: Vec<SparseSkipEntry>,
165    /// Global maximum weight across all blocks (for MaxScore pruning)
166    global_max_weight: f32,
167}
168
169impl SparseSkipList {
170    pub fn new() -> Self {
171        Self::default()
172    }
173
174    /// Add a skip entry
175    pub fn push(
176        &mut self,
177        first_doc: DocId,
178        last_doc: DocId,
179        offset: u64,
180        length: u32,
181        max_weight: f32,
182    ) {
183        self.global_max_weight = self.global_max_weight.max(max_weight);
184        self.entries.push(SparseSkipEntry::new(
185            first_doc, last_doc, offset, length, max_weight,
186        ));
187    }
188
189    /// Number of blocks
190    pub fn len(&self) -> usize {
191        self.entries.len()
192    }
193
194    pub fn is_empty(&self) -> bool {
195        self.entries.is_empty()
196    }
197
198    /// Get entry by index
199    pub fn get(&self, index: usize) -> Option<&SparseSkipEntry> {
200        self.entries.get(index)
201    }
202
203    /// Global maximum weight across all blocks
204    pub fn global_max_weight(&self) -> f32 {
205        self.global_max_weight
206    }
207
208    /// Find block index containing doc_id >= target (binary search, O(log n))
209    pub fn find_block(&self, target: DocId) -> Option<usize> {
210        if self.entries.is_empty() {
211            return None;
212        }
213        // Binary search: find first entry where last_doc >= target
214        let idx = self.entries.partition_point(|e| e.last_doc < target);
215        if idx < self.entries.len() {
216            Some(idx)
217        } else {
218            None
219        }
220    }
221
222    /// Iterate over entries
223    pub fn iter(&self) -> impl Iterator<Item = &SparseSkipEntry> {
224        self.entries.iter()
225    }
226
227    /// Write skip list to writer
228    pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
229        writer.write_u32::<LittleEndian>(self.entries.len() as u32)?;
230        writer.write_f32::<LittleEndian>(self.global_max_weight)?;
231        for entry in &self.entries {
232            entry.write(writer)?;
233        }
234        Ok(())
235    }
236
237    /// Read skip list from reader
238    pub fn read<R: Read>(reader: &mut R) -> io::Result<Self> {
239        let count = reader.read_u32::<LittleEndian>()? as usize;
240        let global_max_weight = reader.read_f32::<LittleEndian>()?;
241        let mut entries = Vec::with_capacity(count);
242        for _ in 0..count {
243            entries.push(SparseSkipEntry::read(reader)?);
244        }
245        Ok(Self {
246            entries,
247            global_max_weight,
248        })
249    }
250}
251
252/// Sparse posting list for a single dimension
253///
254/// Stores (doc_id, weight) pairs for all documents that have a non-zero
255/// weight for this dimension. Weights are quantized according to the
256/// specified quantization format.
257#[derive(Debug, Clone)]
258pub struct SparsePostingList {
259    /// Quantization format
260    quantization: WeightQuantization,
261    /// Scale factor for UInt8/UInt4 quantization (weight = quantized * scale)
262    scale: f32,
263    /// Minimum value for UInt8/UInt4 quantization (weight = quantized * scale + min)
264    min_val: f32,
265    /// Number of postings
266    doc_count: u32,
267    /// Compressed data: [doc_ids...][weights...]
268    data: Vec<u8>,
269}
270
271impl SparsePostingList {
272    /// Create from postings with specified quantization
273    pub fn from_postings(
274        postings: &[(DocId, f32)],
275        quantization: WeightQuantization,
276    ) -> io::Result<Self> {
277        if postings.is_empty() {
278            return Ok(Self {
279                quantization,
280                scale: 1.0,
281                min_val: 0.0,
282                doc_count: 0,
283                data: Vec::new(),
284            });
285        }
286
287        // Compute min/max for quantization
288        let weights: Vec<f32> = postings.iter().map(|(_, w)| *w).collect();
289        let min_val = weights.iter().cloned().fold(f32::INFINITY, f32::min);
290        let max_val = weights.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
291
292        let (scale, adjusted_min) = match quantization {
293            WeightQuantization::Float32 | WeightQuantization::Float16 => (1.0, 0.0),
294            WeightQuantization::UInt8 => {
295                let range = max_val - min_val;
296                if range < f32::EPSILON {
297                    (1.0, min_val)
298                } else {
299                    (range / 255.0, min_val)
300                }
301            }
302            WeightQuantization::UInt4 => {
303                let range = max_val - min_val;
304                if range < f32::EPSILON {
305                    (1.0, min_val)
306                } else {
307                    (range / 15.0, min_val)
308                }
309            }
310        };
311
312        let mut data = Vec::new();
313
314        // Write doc IDs with delta encoding
315        let mut prev_doc_id = 0u32;
316        for (doc_id, _) in postings {
317            let delta = doc_id - prev_doc_id;
318            write_vint(&mut data, delta as u64)?;
319            prev_doc_id = *doc_id;
320        }
321
322        // Write weights based on quantization
323        match quantization {
324            WeightQuantization::Float32 => {
325                for (_, weight) in postings {
326                    data.write_f32::<LittleEndian>(*weight)?;
327                }
328            }
329            WeightQuantization::Float16 => {
330                // Use SIMD-accelerated batch conversion via half::slice
331                use half::slice::HalfFloatSliceExt;
332                let weights: Vec<f32> = postings.iter().map(|(_, w)| *w).collect();
333                let mut f16_slice: Vec<half::f16> = vec![half::f16::ZERO; weights.len()];
334                f16_slice.convert_from_f32_slice(&weights);
335                for h in f16_slice {
336                    data.write_u16::<LittleEndian>(h.to_bits())?;
337                }
338            }
339            WeightQuantization::UInt8 => {
340                for (_, weight) in postings {
341                    let quantized = ((*weight - adjusted_min) / scale).round() as u8;
342                    data.write_u8(quantized)?;
343                }
344            }
345            WeightQuantization::UInt4 => {
346                // Pack two 4-bit values per byte
347                let mut i = 0;
348                while i < postings.len() {
349                    let q1 = ((postings[i].1 - adjusted_min) / scale).round() as u8 & 0x0F;
350                    let q2 = if i + 1 < postings.len() {
351                        ((postings[i + 1].1 - adjusted_min) / scale).round() as u8 & 0x0F
352                    } else {
353                        0
354                    };
355                    data.write_u8((q2 << 4) | q1)?;
356                    i += 2;
357                }
358            }
359        }
360
361        Ok(Self {
362            quantization,
363            scale,
364            min_val: adjusted_min,
365            doc_count: postings.len() as u32,
366            data,
367        })
368    }
369
370    /// Serialize to bytes
371    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
372        writer.write_u8(self.quantization as u8)?;
373        writer.write_f32::<LittleEndian>(self.scale)?;
374        writer.write_f32::<LittleEndian>(self.min_val)?;
375        writer.write_u32::<LittleEndian>(self.doc_count)?;
376        writer.write_u32::<LittleEndian>(self.data.len() as u32)?;
377        writer.write_all(&self.data)?;
378        Ok(())
379    }
380
381    /// Deserialize from bytes
382    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
383        let quant_byte = reader.read_u8()?;
384        let quantization = WeightQuantization::from_u8(quant_byte).ok_or_else(|| {
385            io::Error::new(io::ErrorKind::InvalidData, "Invalid quantization type")
386        })?;
387        let scale = reader.read_f32::<LittleEndian>()?;
388        let min_val = reader.read_f32::<LittleEndian>()?;
389        let doc_count = reader.read_u32::<LittleEndian>()?;
390        let data_len = reader.read_u32::<LittleEndian>()? as usize;
391        let mut data = vec![0u8; data_len];
392        reader.read_exact(&mut data)?;
393
394        Ok(Self {
395            quantization,
396            scale,
397            min_val,
398            doc_count,
399            data,
400        })
401    }
402
403    /// Number of documents in this posting list
404    pub fn doc_count(&self) -> u32 {
405        self.doc_count
406    }
407
408    /// Get quantization format
409    pub fn quantization(&self) -> WeightQuantization {
410        self.quantization
411    }
412
413    /// Create an iterator
414    pub fn iterator(&self) -> SparsePostingIterator<'_> {
415        SparsePostingIterator::new(self)
416    }
417
418    /// Decode all postings (for merge operations)
419    pub fn decode_all(&self) -> io::Result<Vec<(DocId, f32)>> {
420        let mut result = Vec::with_capacity(self.doc_count as usize);
421        let mut iter = self.iterator();
422
423        while !iter.exhausted {
424            result.push((iter.doc_id, iter.weight));
425            iter.advance();
426        }
427
428        Ok(result)
429    }
430}
431
432/// Iterator over sparse posting list
433pub struct SparsePostingIterator<'a> {
434    posting_list: &'a SparsePostingList,
435    /// Current position in doc_id stream
436    doc_id_offset: usize,
437    /// Current position in weight stream
438    weight_offset: usize,
439    /// Current index
440    index: usize,
441    /// Current doc_id
442    doc_id: DocId,
443    /// Current weight
444    weight: f32,
445    /// Whether iterator is exhausted
446    exhausted: bool,
447}
448
449impl<'a> SparsePostingIterator<'a> {
450    fn new(posting_list: &'a SparsePostingList) -> Self {
451        let mut iter = Self {
452            posting_list,
453            doc_id_offset: 0,
454            weight_offset: 0,
455            index: 0,
456            doc_id: 0,
457            weight: 0.0,
458            exhausted: posting_list.doc_count == 0,
459        };
460
461        if !iter.exhausted {
462            // Calculate weight offset (after all doc_id deltas)
463            iter.weight_offset = iter.calculate_weight_offset();
464            iter.load_current();
465        }
466
467        iter
468    }
469
470    fn calculate_weight_offset(&self) -> usize {
471        // Read through all doc_id deltas to find where weights start
472        let mut offset = 0;
473        let mut reader = &self.posting_list.data[..];
474
475        for _ in 0..self.posting_list.doc_count {
476            if read_vint(&mut reader).is_ok() {
477                offset = self.posting_list.data.len() - reader.len();
478            }
479        }
480
481        offset
482    }
483
484    fn load_current(&mut self) {
485        if self.index >= self.posting_list.doc_count as usize {
486            self.exhausted = true;
487            return;
488        }
489
490        // Read doc_id delta
491        let mut reader = &self.posting_list.data[self.doc_id_offset..];
492        if let Ok(delta) = read_vint(&mut reader) {
493            self.doc_id = self.doc_id.wrapping_add(delta as u32);
494            self.doc_id_offset = self.posting_list.data.len() - reader.len();
495        }
496
497        // Read weight based on quantization
498        let weight_idx = self.index;
499        let pl = self.posting_list;
500
501        self.weight = match pl.quantization {
502            WeightQuantization::Float32 => {
503                let offset = self.weight_offset + weight_idx * 4;
504                if offset + 4 <= pl.data.len() {
505                    let bytes = &pl.data[offset..offset + 4];
506                    f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
507                } else {
508                    0.0
509                }
510            }
511            WeightQuantization::Float16 => {
512                let offset = self.weight_offset + weight_idx * 2;
513                if offset + 2 <= pl.data.len() {
514                    let bits = u16::from_le_bytes([pl.data[offset], pl.data[offset + 1]]);
515                    half::f16::from_bits(bits).to_f32()
516                } else {
517                    0.0
518                }
519            }
520            WeightQuantization::UInt8 => {
521                let offset = self.weight_offset + weight_idx;
522                if offset < pl.data.len() {
523                    let quantized = pl.data[offset];
524                    quantized as f32 * pl.scale + pl.min_val
525                } else {
526                    0.0
527                }
528            }
529            WeightQuantization::UInt4 => {
530                let byte_offset = self.weight_offset + weight_idx / 2;
531                if byte_offset < pl.data.len() {
532                    let byte = pl.data[byte_offset];
533                    let quantized = if weight_idx.is_multiple_of(2) {
534                        byte & 0x0F
535                    } else {
536                        (byte >> 4) & 0x0F
537                    };
538                    quantized as f32 * pl.scale + pl.min_val
539                } else {
540                    0.0
541                }
542            }
543        };
544    }
545
546    /// Current document ID
547    pub fn doc(&self) -> DocId {
548        if self.exhausted {
549            super::TERMINATED
550        } else {
551            self.doc_id
552        }
553    }
554
555    /// Current weight
556    pub fn weight(&self) -> f32 {
557        if self.exhausted { 0.0 } else { self.weight }
558    }
559
560    /// Advance to next posting
561    pub fn advance(&mut self) -> DocId {
562        if self.exhausted {
563            return super::TERMINATED;
564        }
565
566        self.index += 1;
567        if self.index >= self.posting_list.doc_count as usize {
568            self.exhausted = true;
569            return super::TERMINATED;
570        }
571
572        self.load_current();
573        self.doc_id
574    }
575
576    /// Seek to first doc_id >= target
577    pub fn seek(&mut self, target: DocId) -> DocId {
578        while !self.exhausted && self.doc_id < target {
579            self.advance();
580        }
581        self.doc()
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn test_sparse_vector_dot_product() {
591        let v1 = SparseVector::from_entries(&[0, 2, 5], &[1.0, 2.0, 3.0]);
592        let v2 = SparseVector::from_entries(&[1, 2, 5], &[1.0, 4.0, 2.0]);
593
594        // dot = 0 + 2*4 + 3*2 = 14
595        assert!((v1.dot(&v2) - 14.0).abs() < 1e-6);
596    }
597
598    #[test]
599    fn test_sparse_posting_list_float32() {
600        let postings = vec![(0, 1.5), (5, 2.3), (10, 0.8), (100, 3.15)];
601        let pl = SparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
602
603        assert_eq!(pl.doc_count(), 4);
604
605        let mut iter = pl.iterator();
606        assert_eq!(iter.doc(), 0);
607        assert!((iter.weight() - 1.5).abs() < 1e-6);
608
609        iter.advance();
610        assert_eq!(iter.doc(), 5);
611        assert!((iter.weight() - 2.3).abs() < 1e-6);
612
613        iter.advance();
614        assert_eq!(iter.doc(), 10);
615
616        iter.advance();
617        assert_eq!(iter.doc(), 100);
618        assert!((iter.weight() - 3.15).abs() < 1e-6);
619
620        iter.advance();
621        assert_eq!(iter.doc(), super::super::TERMINATED);
622    }
623
624    #[test]
625    fn test_sparse_posting_list_uint8() {
626        let postings = vec![(0, 0.0), (5, 0.5), (10, 1.0)];
627        let pl = SparsePostingList::from_postings(&postings, WeightQuantization::UInt8).unwrap();
628
629        let decoded = pl.decode_all().unwrap();
630        assert_eq!(decoded.len(), 3);
631
632        // UInt8 quantization should preserve relative ordering
633        assert!(decoded[0].1 < decoded[1].1);
634        assert!(decoded[1].1 < decoded[2].1);
635    }
636
637    #[test]
638    fn test_block_sparse_posting_list() {
639        // Create enough postings to span multiple blocks
640        let postings: Vec<(DocId, u16, f32)> =
641            (0..300).map(|i| (i * 2, 0, (i as f32) * 0.1)).collect();
642
643        let pl =
644            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
645
646        assert_eq!(pl.doc_count(), 300);
647        assert!(pl.num_blocks() >= 2);
648
649        // Test iteration
650        let mut iter = pl.iterator();
651        for (expected_doc, _, expected_weight) in &postings {
652            assert_eq!(iter.doc(), *expected_doc);
653            assert!((iter.weight() - expected_weight).abs() < 1e-6);
654            iter.advance();
655        }
656        assert_eq!(iter.doc(), super::super::TERMINATED);
657    }
658
659    #[test]
660    fn test_block_sparse_seek() {
661        let postings: Vec<(DocId, u16, f32)> = (0..500).map(|i| (i * 3, 0, i as f32)).collect();
662
663        let pl =
664            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
665
666        let mut iter = pl.iterator();
667
668        // Seek to exact match
669        assert_eq!(iter.seek(300), 300);
670
671        // Seek to non-exact (should find next)
672        assert_eq!(iter.seek(301), 303);
673
674        // Seek beyond end
675        assert_eq!(iter.seek(2000), super::super::TERMINATED);
676    }
677
678    #[test]
679    fn test_serialization_roundtrip() {
680        let postings: Vec<(DocId, u16, f32)> = vec![(0, 0, 1.0), (10, 0, 2.0), (100, 0, 3.0)];
681
682        for quant in [
683            WeightQuantization::Float32,
684            WeightQuantization::Float16,
685            WeightQuantization::UInt8,
686        ] {
687            let pl = BlockSparsePostingList::from_postings(&postings, quant).unwrap();
688
689            let (block_data, skip_entries) = pl.serialize().unwrap();
690            let pl2 =
691                BlockSparsePostingList::from_parts(pl.doc_count(), &block_data, &skip_entries)
692                    .unwrap();
693
694            assert_eq!(pl.doc_count(), pl2.doc_count());
695
696            // Verify iteration produces same results
697            let mut iter1 = pl.iterator();
698            let mut iter2 = pl2.iterator();
699
700            while iter1.doc() != super::super::TERMINATED {
701                assert_eq!(iter1.doc(), iter2.doc());
702                assert!((iter1.weight() - iter2.weight()).abs() < 0.1);
703                iter1.advance();
704                iter2.advance();
705            }
706        }
707    }
708
709    #[test]
710    fn test_concatenate() {
711        let postings1: Vec<(DocId, u16, f32)> = vec![(0, 0, 1.0), (5, 1, 2.0)];
712        let postings2: Vec<(DocId, u16, f32)> = vec![(0, 0, 3.0), (10, 1, 4.0)];
713
714        let pl1 =
715            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
716        let pl2 =
717            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
718
719        // Merge manually
720        let mut all: Vec<(DocId, u16, f32)> = pl1.decode_all();
721        for (doc_id, ord, w) in pl2.decode_all() {
722            all.push((doc_id + 100, ord, w));
723        }
724        let merged =
725            BlockSparsePostingList::from_postings(&all, WeightQuantization::Float32).unwrap();
726
727        assert_eq!(merged.doc_count(), 4);
728
729        let decoded = merged.decode_all();
730        assert_eq!(decoded[0], (0, 0, 1.0));
731        assert_eq!(decoded[1], (5, 1, 2.0));
732        assert_eq!(decoded[2], (100, 0, 3.0));
733        assert_eq!(decoded[3], (110, 1, 4.0));
734    }
735
736    #[test]
737    fn test_sparse_vector_config() {
738        // Test default config
739        let default = SparseVectorConfig::default();
740        assert_eq!(default.index_size, IndexSize::U32);
741        assert_eq!(default.weight_quantization, WeightQuantization::Float32);
742        assert_eq!(default.bytes_per_entry(), 8.0); // 4 + 4
743
744        // The SPLADE preset compresses weights but keeps quality-sensitive
745        // posting/query pruning opt-in.
746        let splade = SparseVectorConfig::splade();
747        assert_eq!(splade.index_size, IndexSize::U16);
748        assert_eq!(splade.weight_quantization, WeightQuantization::UInt8);
749        assert_eq!(splade.bytes_per_entry(), 3.0); // 2 + 1
750        assert_eq!(splade.weight_threshold, 0.01);
751        assert_eq!(splade.pruning, None);
752        assert!(splade.query_config.is_some());
753        let query_cfg = splade.query_config.as_ref().unwrap();
754        assert_eq!(query_cfg.heap_factor, 1.0);
755        assert_eq!(query_cfg.max_query_dims, None);
756        assert_eq!(query_cfg.pruning, None);
757
758        let bmp = SparseVectorConfig::splade_bmp();
759        assert_eq!(bmp.format, SparseFormat::Bmp);
760        assert_eq!(bmp.pruning, None);
761        let bmp_query_cfg = bmp.query_config.as_ref().unwrap();
762        assert_eq!(bmp_query_cfg.heap_factor, 1.0);
763        assert_eq!(bmp_query_cfg.max_query_dims, None);
764        assert_eq!(bmp_query_cfg.pruning, None);
765
766        // Test compact config
767        let compact = SparseVectorConfig::compact();
768        assert_eq!(compact.index_size, IndexSize::U16);
769        assert_eq!(compact.weight_quantization, WeightQuantization::UInt4);
770        assert_eq!(compact.bytes_per_entry(), 2.5); // 2 + 0.5
771
772        // Test conservative config
773        let conservative = SparseVectorConfig::conservative();
774        assert_eq!(conservative.index_size, IndexSize::U32);
775        assert_eq!(
776            conservative.weight_quantization,
777            WeightQuantization::Float16
778        );
779        assert_eq!(conservative.weight_threshold, 0.005);
780        assert_eq!(conservative.pruning, None);
781
782        // Test byte serialization roundtrip (only index_size and weight_quantization are serialized)
783        let byte = splade.to_byte();
784        let restored = SparseVectorConfig::from_byte(byte).unwrap();
785        assert_eq!(restored.index_size, splade.index_size);
786        assert_eq!(restored.weight_quantization, splade.weight_quantization);
787        // Note: Other fields (weight_threshold, pruning, query_config) are not
788        // serialized in the byte format, so they revert to defaults after deserialization
789    }
790
791    #[test]
792    fn test_index_size() {
793        assert_eq!(IndexSize::U16.bytes(), 2);
794        assert_eq!(IndexSize::U32.bytes(), 4);
795        assert_eq!(IndexSize::U16.max_value(), 65535);
796        assert_eq!(IndexSize::U32.max_value(), u32::MAX);
797    }
798
799    #[test]
800    fn test_block_max_weight() {
801        let postings: Vec<(DocId, u16, f32)> = (0..300)
802            .map(|i| (i as DocId, 0, (i as f32) * 0.1))
803            .collect();
804
805        let pl =
806            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
807
808        assert!((pl.global_max_weight() - 29.9).abs() < 0.01);
809        assert!(pl.num_blocks() >= 3);
810
811        let block0_max = pl.block_max_weight(0).unwrap();
812        assert!((block0_max - 12.7).abs() < 0.01);
813
814        let block1_max = pl.block_max_weight(1).unwrap();
815        assert!((block1_max - 25.5).abs() < 0.01);
816
817        let block2_max = pl.block_max_weight(2).unwrap();
818        assert!((block2_max - 29.9).abs() < 0.01);
819
820        // Test iterator block_max methods
821        let query_weight = 2.0;
822        let mut iter = pl.iterator();
823        assert!((iter.current_block_max_weight() - 12.7).abs() < 0.01);
824        assert!((iter.current_block_max_contribution(query_weight) - 25.4).abs() < 0.1);
825
826        iter.seek(128);
827        assert!((iter.current_block_max_weight() - 25.5).abs() < 0.01);
828    }
829
830    #[test]
831    fn test_sparse_skip_list_serialization() {
832        let mut skip_list = SparseSkipList::new();
833        skip_list.push(0, 127, 0, 50, 12.7);
834        skip_list.push(128, 255, 100, 60, 25.5);
835        skip_list.push(256, 299, 200, 40, 29.9);
836
837        assert_eq!(skip_list.len(), 3);
838        assert!((skip_list.global_max_weight() - 29.9).abs() < 0.01);
839
840        // Serialize
841        let mut buffer = Vec::new();
842        skip_list.write(&mut buffer).unwrap();
843
844        // Deserialize
845        let restored = SparseSkipList::read(&mut buffer.as_slice()).unwrap();
846
847        assert_eq!(restored.len(), 3);
848        assert!((restored.global_max_weight() - 29.9).abs() < 0.01);
849
850        // Verify entries
851        let e0 = restored.get(0).unwrap();
852        assert_eq!(e0.first_doc, 0);
853        assert_eq!(e0.last_doc, 127);
854        assert!((e0.max_weight - 12.7).abs() < 0.01);
855
856        let e1 = restored.get(1).unwrap();
857        assert_eq!(e1.first_doc, 128);
858        assert!((e1.max_weight - 25.5).abs() < 0.01);
859    }
860}