Skip to main content

summa_core/structures/postings/sparse/
block.rs

1//! Block-based sparse posting list with 3 sub-blocks
2//!
3//! Format per block (128 entries for SIMD alignment):
4//! - Doc IDs: delta-encoded, bit-packed
5//! - Ordinals: bit-packed small integers (lazy decode)
6//! - Weights: quantized (f32/f16/u8/u4)
7
8use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
9use std::io::{self, Cursor, Read, Write};
10
11use super::config::WeightQuantization;
12use super::weights::{decode_weights_into, encode_weights};
13use crate::DocId;
14use crate::directories::OwnedBytes;
15use crate::structures::postings::TERMINATED;
16use crate::structures::simd;
17
18pub const BLOCK_SIZE: usize = 128;
19pub const MAX_BLOCK_SIZE: usize = 256;
20
21#[derive(Debug, Clone, Copy)]
22pub struct BlockHeader {
23    pub count: u16,
24    pub doc_id_bits: u8,
25    pub ordinal_bits: u8,
26    pub weight_quant: WeightQuantization,
27    pub first_doc_id: DocId,
28    pub max_weight: f32,
29}
30
31impl BlockHeader {
32    pub const SIZE: usize = 16;
33
34    pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
35        w.write_u16::<LittleEndian>(self.count)?;
36        w.write_u8(self.doc_id_bits)?;
37        w.write_u8(self.ordinal_bits)?;
38        w.write_u8(self.weight_quant as u8)?;
39        w.write_u8(0)?;
40        w.write_u16::<LittleEndian>(0)?;
41        w.write_u32::<LittleEndian>(self.first_doc_id)?;
42        w.write_f32::<LittleEndian>(self.max_weight)?;
43        Ok(())
44    }
45
46    pub fn read<R: Read>(r: &mut R) -> io::Result<Self> {
47        let count = r.read_u16::<LittleEndian>()?;
48        let doc_id_bits = r.read_u8()?;
49        let ordinal_bits = r.read_u8()?;
50        let weight_quant_byte = r.read_u8()?;
51        let _ = r.read_u8()?;
52        let _ = r.read_u16::<LittleEndian>()?;
53        let first_doc_id = r.read_u32::<LittleEndian>()?;
54        let max_weight = r.read_f32::<LittleEndian>()?;
55
56        let weight_quant = WeightQuantization::from_u8(weight_quant_byte)
57            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "Invalid weight quant"))?;
58
59        Ok(Self {
60            count,
61            doc_id_bits,
62            ordinal_bits,
63            weight_quant,
64            first_doc_id,
65            max_weight,
66        })
67    }
68}
69
70#[derive(Debug, Clone)]
71pub struct SparseBlock {
72    pub header: BlockHeader,
73    /// Delta-encoded, bit-packed doc IDs (zero-copy from mmap when loaded lazily)
74    pub doc_ids_data: OwnedBytes,
75    /// Bit-packed ordinals (zero-copy from mmap when loaded lazily)
76    pub ordinals_data: OwnedBytes,
77    /// Quantized weights (zero-copy from mmap when loaded lazily)
78    pub weights_data: OwnedBytes,
79    /// Cached last doc_id in the block (avoids full decode in serialize())
80    last_doc_id: DocId,
81}
82
83impl SparseBlock {
84    pub fn from_postings(
85        postings: &[(DocId, u16, f32)],
86        weight_quant: WeightQuantization,
87    ) -> io::Result<Self> {
88        assert!(!postings.is_empty() && postings.len() <= MAX_BLOCK_SIZE);
89
90        let count = postings.len();
91        let first_doc_id = postings[0].0;
92
93        // Blocks are hard-bounded at 256 entries. Keep the transient columns
94        // on the stack so building each block allocates only its three encoded
95        // output buffers.
96        let mut deltas = [0u32; MAX_BLOCK_SIZE];
97        let mut ordinals = [0u32; MAX_BLOCK_SIZE];
98        let mut weights = [0.0f32; MAX_BLOCK_SIZE];
99        let mut prev = first_doc_id;
100        let mut max_ordinal = 0u16;
101        let mut max_weight = 0.0f32;
102        for (index, &(doc_id, ordinal, weight)) in postings.iter().enumerate() {
103            deltas[index] = doc_id.saturating_sub(prev);
104            ordinals[index] = u32::from(ordinal);
105            weights[index] = weight;
106            max_ordinal = max_ordinal.max(ordinal);
107            max_weight = max_weight.max(weight.abs());
108            prev = doc_id;
109        }
110        deltas[0] = 0;
111
112        let doc_id_bits = simd::round_bit_width(find_optimal_bit_width(&deltas[1..count]));
113        let ordinal_bits = if max_ordinal == 0 {
114            0
115        } else {
116            simd::round_bit_width(bits_needed_u16(max_ordinal))
117        };
118
119        let doc_ids_data = OwnedBytes::new({
120            let rounded = simd::RoundedBitWidth::from_u8(doc_id_bits);
121            let num_deltas = count - 1;
122            let byte_count = num_deltas * rounded.bytes_per_value();
123            let mut data = vec![0u8; byte_count];
124            simd::pack_rounded(&deltas[1..count], rounded, &mut data);
125            data
126        });
127        let ordinals_data = OwnedBytes::new(if ordinal_bits > 0 {
128            let rounded = simd::RoundedBitWidth::from_u8(ordinal_bits);
129            let byte_count = count * rounded.bytes_per_value();
130            let mut data = vec![0u8; byte_count];
131            simd::pack_rounded(&ordinals[..count], rounded, &mut data);
132            data
133        } else {
134            Vec::new()
135        });
136        let weights_data = OwnedBytes::new(encode_weights(&weights[..count], weight_quant)?);
137
138        let last_doc_id = postings.last().unwrap().0;
139
140        Ok(Self {
141            header: BlockHeader {
142                count: count as u16,
143                doc_id_bits,
144                ordinal_bits,
145                weight_quant,
146                first_doc_id,
147                max_weight,
148            },
149            doc_ids_data,
150            ordinals_data,
151            weights_data,
152            last_doc_id,
153        })
154    }
155
156    /// Last doc_id in the block (cached from construction).
157    #[inline]
158    pub fn last_doc_id(&self) -> DocId {
159        self.last_doc_id
160    }
161
162    pub fn decode_doc_ids(&self) -> Vec<DocId> {
163        let mut out = Vec::with_capacity(self.header.count as usize);
164        self.decode_doc_ids_into(&mut out);
165        out
166    }
167
168    /// Decode doc IDs into an existing Vec (avoids allocation on reuse).
169    ///
170    /// Uses SIMD-accelerated unpacking for rounded bit widths (0, 8, 16, 32).
171    pub fn decode_doc_ids_into(&self, out: &mut Vec<DocId>) {
172        let count = self.header.count as usize;
173        out.clear();
174        out.resize(count, 0);
175        out[0] = self.header.first_doc_id;
176
177        if count > 1 {
178            let bits = self.header.doc_id_bits;
179            if bits == 0 {
180                // All deltas are 0 (multi-value same doc_id repeats)
181                out[1..].fill(self.header.first_doc_id);
182            } else {
183                // SIMD-accelerated unpack (bits is always 8, 16, or 32)
184                simd::unpack_rounded(
185                    &self.doc_ids_data,
186                    simd::RoundedBitWidth::from_u8(bits),
187                    &mut out[1..],
188                    count - 1,
189                );
190                // In-place prefix sum (pure delta, NOT gap-1)
191                for i in 1..count {
192                    out[i] += out[i - 1];
193                }
194            }
195        }
196    }
197
198    pub fn decode_ordinals(&self) -> Vec<u16> {
199        let mut out = Vec::with_capacity(self.header.count as usize);
200        self.decode_ordinals_into(&mut out);
201        out
202    }
203
204    /// Decode ordinals into an existing Vec (avoids allocation on reuse).
205    ///
206    /// Uses SIMD-accelerated unpacking for rounded bit widths (0, 8, 16, 32).
207    pub fn decode_ordinals_into(&self, out: &mut Vec<u16>) {
208        let count = self.header.count as usize;
209        out.clear();
210        if self.header.ordinal_bits == 0 {
211            out.resize(count, 0u16);
212        } else {
213            // SIMD-accelerated unpack (bits is always 8, 16, or 32)
214            let mut temp = [0u32; MAX_BLOCK_SIZE];
215            simd::unpack_rounded(
216                &self.ordinals_data,
217                simd::RoundedBitWidth::from_u8(self.header.ordinal_bits),
218                &mut temp[..count],
219                count,
220            );
221            out.reserve(count);
222            for &v in &temp[..count] {
223                out.push(v as u16);
224            }
225        }
226    }
227
228    pub fn decode_weights(&self) -> Vec<f32> {
229        let mut out = Vec::with_capacity(self.header.count as usize);
230        self.decode_weights_into(&mut out);
231        out
232    }
233
234    /// Decode weights into an existing Vec (avoids allocation on reuse).
235    pub fn decode_weights_into(&self, out: &mut Vec<f32>) {
236        out.clear();
237        decode_weights_into(
238            &self.weights_data,
239            self.header.weight_quant,
240            self.header.count as usize,
241            out,
242        );
243    }
244
245    /// Decode weights pre-multiplied by `query_weight` directly from quantized data.
246    ///
247    /// For UInt8: computes `(qw * scale) * q + (qw * min)` via SIMD — avoids
248    /// allocating an intermediate f32 dequantized buffer. The effective_scale and
249    /// effective_bias are computed once per block (not per element).
250    ///
251    /// For F32/F16/UInt4: falls back to decode + scalar multiply.
252    pub fn decode_scored_weights_into(&self, query_weight: f32, out: &mut Vec<f32>) {
253        out.clear();
254        let count = self.header.count as usize;
255        match self.header.weight_quant {
256            WeightQuantization::UInt8 if self.weights_data.len() >= 8 => {
257                // UInt8 layout: [scale: f32][min: f32][q0, q1, ..., q_{n-1}]
258                let scale = f32::from_le_bytes([
259                    self.weights_data[0],
260                    self.weights_data[1],
261                    self.weights_data[2],
262                    self.weights_data[3],
263                ]);
264                let min_val = f32::from_le_bytes([
265                    self.weights_data[4],
266                    self.weights_data[5],
267                    self.weights_data[6],
268                    self.weights_data[7],
269                ]);
270                // Fused: qw * (q * scale + min) = q * (qw * scale) + (qw * min)
271                let eff_scale = query_weight * scale;
272                let eff_bias = query_weight * min_val;
273                out.resize(count, 0.0);
274                simd::dequantize_uint8(&self.weights_data[8..], out, eff_scale, eff_bias, count);
275            }
276            _ => {
277                // Fallback: decode to f32, then multiply
278                decode_weights_into(&self.weights_data, self.header.weight_quant, count, out);
279                for w in out.iter_mut() {
280                    *w *= query_weight;
281                }
282            }
283        }
284    }
285
286    /// Fused decode + multiply + scatter-accumulate into flat_scores array.
287    ///
288    /// Equivalent to:
289    ///
290    /// ```text
291    /// decode_scored_weights_into(qw, &mut weights_buf);
292    /// for i in 0..count { flat_scores[doc_ids[i] - base] += weights_buf[i]; }
293    /// ```
294    ///
295    /// But avoids allocating/filling weights_buf — decodes directly into flat_scores.
296    /// Tracks dirty entries (first touch) for efficient collection.
297    ///
298    /// `doc_ids` must already be decoded via `decode_doc_ids_into`.
299    /// Returns the number of postings accumulated.
300    #[inline]
301    pub fn accumulate_scored_weights(
302        &self,
303        query_weight: f32,
304        doc_ids: &[u32],
305        flat_scores: &mut [f32],
306        base_doc: u32,
307        dirty: &mut Vec<u32>,
308    ) -> usize {
309        let count = self.header.count as usize;
310        match self.header.weight_quant {
311            WeightQuantization::UInt8 if self.weights_data.len() >= 8 => {
312                // UInt8 layout: [scale: f32][min: f32][q0, q1, ..., q_{n-1}]
313                let scale = f32::from_le_bytes([
314                    self.weights_data[0],
315                    self.weights_data[1],
316                    self.weights_data[2],
317                    self.weights_data[3],
318                ]);
319                let min_val = f32::from_le_bytes([
320                    self.weights_data[4],
321                    self.weights_data[5],
322                    self.weights_data[6],
323                    self.weights_data[7],
324                ]);
325                let eff_scale = query_weight * scale;
326                let eff_bias = query_weight * min_val;
327                let quant_data = &self.weights_data[8..];
328
329                for i in 0..count.min(quant_data.len()).min(doc_ids.len()) {
330                    let w = quant_data[i] as f32 * eff_scale + eff_bias;
331                    let off = (doc_ids[i] - base_doc) as usize;
332                    if off >= flat_scores.len() {
333                        continue;
334                    }
335                    if flat_scores[off] == 0.0 {
336                        dirty.push(doc_ids[i]);
337                    }
338                    flat_scores[off] += w;
339                }
340                count
341            }
342            _ => {
343                // Fallback: decode to temp buffer, then scatter
344                let mut weights_buf = Vec::with_capacity(count);
345                decode_weights_into(
346                    &self.weights_data,
347                    self.header.weight_quant,
348                    count,
349                    &mut weights_buf,
350                );
351                for i in 0..count.min(weights_buf.len()).min(doc_ids.len()) {
352                    let w = weights_buf[i] * query_weight;
353                    let off = (doc_ids[i] - base_doc) as usize;
354                    if off >= flat_scores.len() {
355                        continue;
356                    }
357                    if flat_scores[off] == 0.0 {
358                        dirty.push(doc_ids[i]);
359                    }
360                    flat_scores[off] += w;
361                }
362                count
363            }
364        }
365    }
366
367    pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> {
368        self.header.write(w)?;
369        if self.doc_ids_data.len() > u16::MAX as usize
370            || self.ordinals_data.len() > u16::MAX as usize
371            || self.weights_data.len() > u16::MAX as usize
372        {
373            return Err(io::Error::new(
374                io::ErrorKind::InvalidData,
375                format!(
376                    "sparse sub-block too large for u16 length: doc_ids={}B ords={}B wts={}B",
377                    self.doc_ids_data.len(),
378                    self.ordinals_data.len(),
379                    self.weights_data.len()
380                ),
381            ));
382        }
383        w.write_u16::<LittleEndian>(self.doc_ids_data.len() as u16)?;
384        w.write_u16::<LittleEndian>(self.ordinals_data.len() as u16)?;
385        w.write_u16::<LittleEndian>(self.weights_data.len() as u16)?;
386        w.write_u16::<LittleEndian>(0)?;
387        w.write_all(&self.doc_ids_data)?;
388        w.write_all(&self.ordinals_data)?;
389        w.write_all(&self.weights_data)?;
390        Ok(())
391    }
392
393    pub fn read<R: Read>(r: &mut R) -> io::Result<Self> {
394        let header = BlockHeader::read(r)?;
395        let doc_ids_len = r.read_u16::<LittleEndian>()? as usize;
396        let ordinals_len = r.read_u16::<LittleEndian>()? as usize;
397        let weights_len = r.read_u16::<LittleEndian>()? as usize;
398        let _ = r.read_u16::<LittleEndian>()?;
399
400        let mut doc_ids_vec = vec![0u8; doc_ids_len];
401        r.read_exact(&mut doc_ids_vec)?;
402        let mut ordinals_vec = vec![0u8; ordinals_len];
403        r.read_exact(&mut ordinals_vec)?;
404        let mut weights_vec = vec![0u8; weights_len];
405        r.read_exact(&mut weights_vec)?;
406
407        // Compute last_doc_id from deltas (stack-allocated, no heap alloc)
408        let last_doc_id = compute_last_doc(&header, &doc_ids_vec);
409
410        Ok(Self {
411            header,
412            doc_ids_data: OwnedBytes::new(doc_ids_vec),
413            ordinals_data: OwnedBytes::new(ordinals_vec),
414            weights_data: OwnedBytes::new(weights_vec),
415            last_doc_id,
416        })
417    }
418
419    /// Zero-copy constructor from OwnedBytes (mmap-backed).
420    ///
421    /// Parses the block header and sub-block length prefix, then slices the
422    /// OwnedBytes into doc_ids/ordinals/weights without any heap allocation.
423    /// Sub-slices share the underlying mmap Arc — no data is copied.
424    pub fn from_owned_bytes(data: crate::directories::OwnedBytes) -> crate::Result<Self> {
425        let b = data.as_slice();
426        if b.len() < BlockHeader::SIZE + 8 {
427            return Err(crate::Error::Corruption(
428                "sparse block too small".to_string(),
429            ));
430        }
431        let mut cursor = Cursor::new(&b[..BlockHeader::SIZE]);
432        let header =
433            BlockHeader::read(&mut cursor).map_err(|e| crate::Error::Corruption(e.to_string()))?;
434
435        if header.count == 0 {
436            let hex: String = b
437                .iter()
438                .take(32)
439                .map(|x| format!("{x:02x}"))
440                .collect::<Vec<_>>()
441                .join(" ");
442            return Err(crate::Error::Corruption(format!(
443                "sparse block has count=0 (data_len={}, first_32_bytes=[{}])",
444                b.len(),
445                hex
446            )));
447        }
448
449        let p = BlockHeader::SIZE;
450        let doc_ids_len = u16::from_le_bytes([b[p], b[p + 1]]) as usize;
451        let ordinals_len = u16::from_le_bytes([b[p + 2], b[p + 3]]) as usize;
452        let weights_len = u16::from_le_bytes([b[p + 4], b[p + 5]]) as usize;
453        // p+6..p+8 is padding
454
455        let data_start = p + 8;
456        let ord_start = data_start + doc_ids_len;
457        let wt_start = ord_start + ordinals_len;
458        let expected_end = wt_start + weights_len;
459
460        if expected_end > b.len() {
461            let hex: String = b
462                .iter()
463                .take(32)
464                .map(|x| format!("{x:02x}"))
465                .collect::<Vec<_>>()
466                .join(" ");
467            return Err(crate::Error::Corruption(format!(
468                "sparse block sub-block overflow: count={} doc_ids={}B ords={}B wts={}B need={}B have={}B (first_32=[{}])",
469                header.count,
470                doc_ids_len,
471                ordinals_len,
472                weights_len,
473                expected_end,
474                b.len(),
475                hex
476            )));
477        }
478
479        let doc_ids_slice = data.slice(data_start..ord_start);
480        // Compute last_doc_id from deltas (stack-allocated, no heap alloc)
481        let last_doc_id = compute_last_doc(&header, &doc_ids_slice);
482
483        Ok(Self {
484            header,
485            doc_ids_data: doc_ids_slice,
486            ordinals_data: data.slice(ord_start..wt_start),
487            weights_data: data.slice(wt_start..wt_start + weights_len),
488            last_doc_id,
489        })
490    }
491
492    /// Create a copy of this block with first_doc_id adjusted by offset.
493    ///
494    /// This is used during merge to remap doc_ids from different segments.
495    /// Only the first_doc_id needs adjustment - deltas within the block
496    /// remain unchanged since they're relative to the previous doc.
497    pub fn with_doc_offset(&self, doc_offset: u32) -> Self {
498        Self {
499            header: BlockHeader {
500                first_doc_id: self.header.first_doc_id + doc_offset,
501                ..self.header
502            },
503            doc_ids_data: self.doc_ids_data.clone(),
504            ordinals_data: self.ordinals_data.clone(),
505            weights_data: self.weights_data.clone(),
506            last_doc_id: self.last_doc_id + doc_offset,
507        }
508    }
509}
510
511// ============================================================================
512// BlockSparsePostingList
513// ============================================================================
514
515#[derive(Debug, Clone)]
516pub struct BlockSparsePostingList {
517    pub doc_count: u32,
518    pub blocks: Vec<SparseBlock>,
519}
520
521impl BlockSparsePostingList {
522    /// Create from postings with configurable block size
523    pub fn from_postings_with_block_size(
524        postings: &[(DocId, u16, f32)],
525        weight_quant: WeightQuantization,
526        block_size: usize,
527    ) -> io::Result<Self> {
528        if postings.is_empty() {
529            return Ok(Self {
530                doc_count: 0,
531                blocks: Vec::new(),
532            });
533        }
534
535        let block_size = block_size.clamp(16, MAX_BLOCK_SIZE);
536        let mut blocks = Vec::with_capacity(postings.len().div_ceil(block_size));
537        for chunk in postings.chunks(block_size) {
538            blocks.push(SparseBlock::from_postings(chunk, weight_quant)?);
539        }
540
541        // Count unique document IDs (not total postings).
542        // For multi-value fields, the same doc_id appears multiple times
543        // with different ordinals. Postings are sorted by (doc_id, ordinal),
544        // so we count transitions.
545        let mut unique_docs = 1u32;
546        for i in 1..postings.len() {
547            if postings[i].0 != postings[i - 1].0 {
548                unique_docs += 1;
549            }
550        }
551
552        Ok(Self {
553            doc_count: unique_docs,
554            blocks,
555        })
556    }
557
558    /// Create from postings with default block size (128)
559    pub fn from_postings(
560        postings: &[(DocId, u16, f32)],
561        weight_quant: WeightQuantization,
562    ) -> io::Result<Self> {
563        Self::from_postings_with_block_size(postings, weight_quant, BLOCK_SIZE)
564    }
565
566    /// Create from postings using a pre-computed variable-size partition plan.
567    ///
568    /// `partition` is a slice of block sizes (e.g., [64, 128, 32, ...]) whose
569    /// sum must equal `postings.len()`. Each block size must be ≤ MAX_BLOCK_SIZE.
570    /// Produced by `optimal_partition()`.
571    pub fn from_postings_with_partition(
572        postings: &[(DocId, u16, f32)],
573        weight_quant: WeightQuantization,
574        partition: &[usize],
575    ) -> io::Result<Self> {
576        if postings.is_empty() {
577            return Ok(Self {
578                doc_count: 0,
579                blocks: Vec::new(),
580            });
581        }
582
583        let mut blocks = Vec::with_capacity(partition.len());
584        let mut offset = 0;
585        for &block_size in partition {
586            let end = (offset + block_size).min(postings.len());
587            blocks.push(SparseBlock::from_postings(
588                &postings[offset..end],
589                weight_quant,
590            )?);
591            offset = end;
592        }
593
594        let mut unique_docs = 1u32;
595        for i in 1..postings.len() {
596            if postings[i].0 != postings[i - 1].0 {
597                unique_docs += 1;
598            }
599        }
600
601        Ok(Self {
602            doc_count: unique_docs,
603            blocks,
604        })
605    }
606
607    pub fn doc_count(&self) -> u32 {
608        self.doc_count
609    }
610
611    pub fn num_blocks(&self) -> usize {
612        self.blocks.len()
613    }
614
615    pub fn global_max_weight(&self) -> f32 {
616        self.blocks
617            .iter()
618            .map(|b| b.header.max_weight)
619            .fold(0.0f32, f32::max)
620    }
621
622    pub fn block_max_weight(&self, block_idx: usize) -> Option<f32> {
623        self.blocks.get(block_idx).map(|b| b.header.max_weight)
624    }
625
626    /// Approximate memory usage in bytes
627    pub fn size_bytes(&self) -> usize {
628        use std::mem::size_of;
629
630        let header_size = size_of::<u32>() * 2; // doc_count + num_blocks
631        let blocks_size: usize = self
632            .blocks
633            .iter()
634            .map(|b| {
635                size_of::<BlockHeader>()
636                    + b.doc_ids_data.len()
637                    + b.ordinals_data.len()
638                    + b.weights_data.len()
639            })
640            .sum();
641        header_size + blocks_size
642    }
643
644    pub fn iterator(&self) -> BlockSparsePostingIterator<'_> {
645        BlockSparsePostingIterator::new(self)
646    }
647
648    /// Serialize: returns (block_data, skip_entries) separately.
649    ///
650    /// Block data and skip entries are written to different file sections.
651    /// The caller writes block data first, accumulates skip entries, then
652    /// writes all skip entries in a contiguous section at the file tail.
653    pub fn serialize(&self) -> io::Result<(Vec<u8>, Vec<super::SparseSkipEntry>)> {
654        let serialized_bytes = self.blocks.iter().try_fold(0usize, |total, block| {
655            total
656                .checked_add(
657                    BlockHeader::SIZE
658                        + 8
659                        + block.doc_ids_data.len()
660                        + block.ordinals_data.len()
661                        + block.weights_data.len(),
662                )
663                .ok_or_else(|| {
664                    io::Error::new(io::ErrorKind::InvalidInput, "sparse posting size overflow")
665                })
666        })?;
667        let mut block_data = Vec::with_capacity(serialized_bytes);
668        let mut skip_entries = Vec::with_capacity(self.blocks.len());
669
670        for block in &self.blocks {
671            let offset = block_data.len();
672            block.write(&mut block_data)?;
673            let length = u32::try_from(block_data.len() - offset).map_err(|_| {
674                io::Error::new(
675                    io::ErrorKind::InvalidData,
676                    "serialized sparse block is too large",
677                )
678            })?;
679
680            let first_doc = block.header.first_doc_id;
681            let last_doc = block.last_doc_id;
682
683            skip_entries.push(super::SparseSkipEntry::new(
684                first_doc,
685                last_doc,
686                offset as u64,
687                length,
688                block.header.max_weight,
689            ));
690        }
691
692        Ok((block_data, skip_entries))
693    }
694
695    /// Reconstruct from V3 serialized parts (block_data + skip_entries).
696    ///
697    /// Parses each block from the raw data using skip entry offsets.
698    /// Used for testing roundtrips; production uses lazy block loading.
699    #[cfg(test)]
700    pub fn from_parts(
701        doc_count: u32,
702        block_data: &[u8],
703        skip_entries: &[super::SparseSkipEntry],
704    ) -> io::Result<Self> {
705        let mut blocks = Vec::with_capacity(skip_entries.len());
706        for entry in skip_entries {
707            let start = entry.offset as usize;
708            let end = start + entry.length as usize;
709            blocks.push(SparseBlock::read(&mut std::io::Cursor::new(
710                &block_data[start..end],
711            ))?);
712        }
713        Ok(Self { doc_count, blocks })
714    }
715
716    pub fn decode_all(&self) -> Vec<(DocId, u16, f32)> {
717        let total_postings: usize = self.blocks.iter().map(|b| b.header.count as usize).sum();
718        let mut result = Vec::with_capacity(total_postings);
719        for block in &self.blocks {
720            let doc_ids = block.decode_doc_ids();
721            let ordinals = block.decode_ordinals();
722            let weights = block.decode_weights();
723            for i in 0..block.header.count as usize {
724                result.push((doc_ids[i], ordinals[i], weights[i]));
725            }
726        }
727        result
728    }
729
730    /// Merge multiple posting lists from different segments with doc_id offsets.
731    ///
732    /// This is an optimized O(1) merge that stacks blocks without decode/re-encode.
733    /// Each posting list's blocks have their first_doc_id adjusted by the corresponding offset.
734    ///
735    /// # Arguments
736    /// * `lists` - Slice of (posting_list, doc_offset) pairs from each segment
737    ///
738    /// # Returns
739    /// A new posting list with all blocks concatenated and doc_ids remapped
740    pub fn merge_with_offsets(lists: &[(&BlockSparsePostingList, u32)]) -> Self {
741        if lists.is_empty() {
742            return Self {
743                doc_count: 0,
744                blocks: Vec::new(),
745            };
746        }
747
748        // Pre-calculate total capacity
749        let total_blocks: usize = lists.iter().map(|(pl, _)| pl.blocks.len()).sum();
750        let total_docs: u32 = lists.iter().map(|(pl, _)| pl.doc_count).sum();
751
752        let mut merged_blocks = Vec::with_capacity(total_blocks);
753
754        // Stack blocks from each segment with doc_id offset adjustment
755        for (posting_list, doc_offset) in lists {
756            for block in &posting_list.blocks {
757                merged_blocks.push(block.with_doc_offset(*doc_offset));
758            }
759        }
760
761        Self {
762            doc_count: total_docs,
763            blocks: merged_blocks,
764        }
765    }
766
767    fn find_block(&self, target: DocId) -> Option<usize> {
768        if self.blocks.is_empty() {
769            return None;
770        }
771        // Binary search on first_doc_id: find the last block whose first_doc_id <= target.
772        // O(log N) header comparisons — no block decode needed.
773        let idx = self
774            .blocks
775            .partition_point(|b| b.header.first_doc_id <= target);
776        if idx == 0 {
777            // target < first_doc_id of block 0 — return block 0 so caller can check
778            Some(0)
779        } else {
780            Some(idx - 1)
781        }
782    }
783}
784
785// ============================================================================
786// Iterator
787// ============================================================================
788
789pub struct BlockSparsePostingIterator<'a> {
790    posting_list: &'a BlockSparsePostingList,
791    block_idx: usize,
792    in_block_idx: usize,
793    current_doc_ids: Vec<DocId>,
794    current_ordinals: Vec<u16>,
795    current_weights: Vec<f32>,
796    /// Whether ordinals have been decoded for current block (lazy decode)
797    ordinals_decoded: bool,
798    exhausted: bool,
799}
800
801impl<'a> BlockSparsePostingIterator<'a> {
802    fn new(posting_list: &'a BlockSparsePostingList) -> Self {
803        let mut iter = Self {
804            posting_list,
805            block_idx: 0,
806            in_block_idx: 0,
807            current_doc_ids: Vec::with_capacity(128),
808            current_ordinals: Vec::with_capacity(128),
809            current_weights: Vec::with_capacity(128),
810            ordinals_decoded: false,
811            exhausted: posting_list.blocks.is_empty(),
812        };
813        if !iter.exhausted {
814            iter.load_block(0);
815        }
816        iter
817    }
818
819    fn load_block(&mut self, block_idx: usize) {
820        if let Some(block) = self.posting_list.blocks.get(block_idx) {
821            block.decode_doc_ids_into(&mut self.current_doc_ids);
822            block.decode_weights_into(&mut self.current_weights);
823            // Defer ordinal decode until ordinal() is called (lazy)
824            self.ordinals_decoded = false;
825            self.block_idx = block_idx;
826            self.in_block_idx = 0;
827        }
828    }
829
830    /// Ensure ordinals are decoded for the current block (lazy decode)
831    #[inline]
832    fn ensure_ordinals_decoded(&mut self) {
833        if !self.ordinals_decoded {
834            if let Some(block) = self.posting_list.blocks.get(self.block_idx) {
835                block.decode_ordinals_into(&mut self.current_ordinals);
836            }
837            self.ordinals_decoded = true;
838        }
839    }
840
841    #[inline]
842    pub fn doc(&self) -> DocId {
843        if self.exhausted {
844            TERMINATED
845        } else {
846            // Safety: load_block guarantees in_block_idx < current_doc_ids.len()
847            self.current_doc_ids[self.in_block_idx]
848        }
849    }
850
851    #[inline]
852    pub fn weight(&self) -> f32 {
853        if self.exhausted {
854            return 0.0;
855        }
856        // Safety: load_block guarantees in_block_idx < current_weights.len()
857        self.current_weights[self.in_block_idx]
858    }
859
860    #[inline]
861    pub fn ordinal(&mut self) -> u16 {
862        if self.exhausted {
863            return 0;
864        }
865        self.ensure_ordinals_decoded();
866        self.current_ordinals[self.in_block_idx]
867    }
868
869    pub fn advance(&mut self) -> DocId {
870        if self.exhausted {
871            return TERMINATED;
872        }
873        self.in_block_idx += 1;
874        if self.in_block_idx >= self.current_doc_ids.len() {
875            self.block_idx += 1;
876            if self.block_idx >= self.posting_list.blocks.len() {
877                self.exhausted = true;
878            } else {
879                self.load_block(self.block_idx);
880            }
881        }
882        self.doc()
883    }
884
885    pub fn seek(&mut self, target: DocId) -> DocId {
886        if self.exhausted {
887            return TERMINATED;
888        }
889        if self.doc() >= target {
890            return self.doc();
891        }
892
893        // Check current block — binary search within decoded doc_ids
894        if let Some(&last_doc) = self.current_doc_ids.last()
895            && last_doc >= target
896        {
897            let remaining = &self.current_doc_ids[self.in_block_idx..];
898            let pos = crate::structures::simd::find_first_ge_u32(remaining, target);
899            self.in_block_idx += pos;
900            if self.in_block_idx >= self.current_doc_ids.len() {
901                self.block_idx += 1;
902                if self.block_idx >= self.posting_list.blocks.len() {
903                    self.exhausted = true;
904                } else {
905                    self.load_block(self.block_idx);
906                }
907            }
908            return self.doc();
909        }
910
911        // Find correct block
912        if let Some(block_idx) = self.posting_list.find_block(target) {
913            self.load_block(block_idx);
914            let pos = crate::structures::simd::find_first_ge_u32(&self.current_doc_ids, target);
915            self.in_block_idx = pos;
916            if self.in_block_idx >= self.current_doc_ids.len() {
917                self.block_idx += 1;
918                if self.block_idx >= self.posting_list.blocks.len() {
919                    self.exhausted = true;
920                } else {
921                    self.load_block(self.block_idx);
922                }
923            }
924        } else {
925            self.exhausted = true;
926        }
927        self.doc()
928    }
929
930    /// Skip to the start of the next block, returning its first doc_id.
931    /// Used by block-max pruning to skip entire blocks that can't beat threshold.
932    pub fn skip_to_next_block(&mut self) -> DocId {
933        if self.exhausted {
934            return TERMINATED;
935        }
936        let next = self.block_idx + 1;
937        if next >= self.posting_list.blocks.len() {
938            self.exhausted = true;
939            return TERMINATED;
940        }
941        self.load_block(next);
942        self.doc()
943    }
944
945    pub fn is_exhausted(&self) -> bool {
946        self.exhausted
947    }
948
949    pub fn current_block_max_weight(&self) -> f32 {
950        self.posting_list
951            .blocks
952            .get(self.block_idx)
953            .map(|b| b.header.max_weight)
954            .unwrap_or(0.0)
955    }
956
957    pub fn current_block_max_contribution(&self, query_weight: f32) -> f32 {
958        query_weight * self.current_block_max_weight()
959    }
960}
961
962// ============================================================================
963// Bit-packing utilities
964// ============================================================================
965
966/// Compute the last doc_id from the block header + delta-encoded data.
967/// Uses a stack array (no heap allocation).
968fn compute_last_doc(header: &BlockHeader, doc_ids_data: &[u8]) -> DocId {
969    let count = header.count as usize;
970    if count <= 1 {
971        return header.first_doc_id;
972    }
973    let bits = header.doc_id_bits;
974    if bits == 0 {
975        return header.first_doc_id; // all deltas are 0
976    }
977    let rounded = simd::RoundedBitWidth::from_u8(bits);
978    let num_deltas = count - 1;
979    let mut deltas = [0u32; MAX_BLOCK_SIZE];
980    simd::unpack_rounded(doc_ids_data, rounded, &mut deltas[..num_deltas], num_deltas);
981    let sum: u32 = deltas[..num_deltas].iter().sum();
982    header.first_doc_id + sum
983}
984
985fn find_optimal_bit_width(values: &[u32]) -> u8 {
986    if values.is_empty() {
987        return 0;
988    }
989    let max_val = values.iter().copied().max().unwrap_or(0);
990    simd::bits_needed(max_val)
991}
992
993fn bits_needed_u16(val: u16) -> u8 {
994    if val == 0 {
995        0
996    } else {
997        16 - val.leading_zeros() as u8
998    }
999}
1000
1001// ============================================================================
1002// Weight encoding/decoding
1003// ============================================================================
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    #[test]
1010    fn test_block_roundtrip() {
1011        let postings = vec![
1012            (10u32, 0u16, 1.5f32),
1013            (15, 0, 2.0),
1014            (20, 1, 0.5),
1015            (100, 0, 3.0),
1016        ];
1017        let block = SparseBlock::from_postings(&postings, WeightQuantization::Float32).unwrap();
1018
1019        assert_eq!(block.decode_doc_ids(), vec![10, 15, 20, 100]);
1020        assert_eq!(block.decode_ordinals(), vec![0, 0, 1, 0]);
1021        let weights = block.decode_weights();
1022        assert!((weights[0] - 1.5).abs() < 0.01);
1023    }
1024
1025    #[test]
1026    fn test_max_size_block_ordinal_decode() {
1027        let postings: Vec<(DocId, u16, f32)> = (0..MAX_BLOCK_SIZE)
1028            .map(|i| (i as DocId, i as u16, i as f32 + 1.0))
1029            .collect();
1030        let list = BlockSparsePostingList::from_postings_with_block_size(
1031            &postings,
1032            WeightQuantization::Float32,
1033            MAX_BLOCK_SIZE,
1034        )
1035        .unwrap();
1036
1037        assert_eq!(list.num_blocks(), 1);
1038        assert_eq!(list.blocks[0].decode_ordinals().len(), MAX_BLOCK_SIZE);
1039        assert_eq!(list.blocks[0].decode_ordinals()[255], 255);
1040    }
1041
1042    #[test]
1043    fn test_configurable_block_size_is_honored() {
1044        let postings: Vec<(DocId, u16, f32)> = (0..300).map(|i| (i, 0, i as f32 + 1.0)).collect();
1045
1046        let small = BlockSparsePostingList::from_postings_with_block_size(
1047            &postings,
1048            WeightQuantization::Float32,
1049            64,
1050        )
1051        .unwrap();
1052        let large = BlockSparsePostingList::from_postings_with_block_size(
1053            &postings,
1054            WeightQuantization::Float32,
1055            256,
1056        )
1057        .unwrap();
1058
1059        assert_eq!(small.num_blocks(), 5);
1060        assert_eq!(small.blocks[0].header.count, 64);
1061        assert_eq!(large.num_blocks(), 2);
1062        assert_eq!(large.blocks[0].header.count, 256);
1063    }
1064
1065    #[test]
1066    fn test_posting_list() {
1067        let postings: Vec<(DocId, u16, f32)> =
1068            (0..300).map(|i| (i * 2, 0, i as f32 * 0.1)).collect();
1069        let list =
1070            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
1071
1072        assert_eq!(list.doc_count(), 300);
1073        assert_eq!(list.num_blocks(), 3);
1074
1075        let mut iter = list.iterator();
1076        assert_eq!(iter.doc(), 0);
1077        iter.advance();
1078        assert_eq!(iter.doc(), 2);
1079    }
1080
1081    #[test]
1082    fn test_serialization() {
1083        let postings = vec![(1u32, 0u16, 0.5f32), (10, 1, 1.5), (100, 0, 2.5)];
1084        let list =
1085            BlockSparsePostingList::from_postings(&postings, WeightQuantization::UInt8).unwrap();
1086
1087        let (block_data, skip_entries) = list.serialize().unwrap();
1088        let list2 =
1089            BlockSparsePostingList::from_parts(list.doc_count(), &block_data, &skip_entries)
1090                .unwrap();
1091
1092        assert_eq!(list.doc_count(), list2.doc_count());
1093    }
1094
1095    #[test]
1096    fn streaming_serialization_is_byte_identical_to_per_block_buffers() {
1097        let postings: Vec<(DocId, u16, f32)> = (0..1_000)
1098            .map(|index| {
1099                (
1100                    index / 2,
1101                    (index % 2) as u16,
1102                    (index * 17 % 101) as f32 / 101.0,
1103                )
1104            })
1105            .collect();
1106        let list = BlockSparsePostingList::from_postings_with_block_size(
1107            &postings,
1108            WeightQuantization::Float16,
1109            64,
1110        )
1111        .unwrap();
1112
1113        let mut reference_data = Vec::new();
1114        let mut reference_skip = Vec::new();
1115        for block in &list.blocks {
1116            let mut buffered = Vec::new();
1117            block.write(&mut buffered).unwrap();
1118            reference_skip.push(super::super::SparseSkipEntry::new(
1119                block.header.first_doc_id,
1120                block.last_doc_id,
1121                reference_data.len() as u64,
1122                buffered.len() as u32,
1123                block.header.max_weight,
1124            ));
1125            reference_data.extend_from_slice(&buffered);
1126        }
1127
1128        let (data, skip) = list.serialize().unwrap();
1129        assert_eq!(data, reference_data);
1130        assert_eq!(skip.len(), reference_skip.len());
1131        for (actual, expected) in skip.iter().zip(&reference_skip) {
1132            assert_eq!(actual.first_doc, expected.first_doc);
1133            assert_eq!(actual.last_doc, expected.last_doc);
1134            assert_eq!(actual.offset, expected.offset);
1135            assert_eq!(actual.length, expected.length);
1136            assert_eq!(actual.max_weight.to_bits(), expected.max_weight.to_bits());
1137        }
1138    }
1139
1140    #[test]
1141    fn test_seek() {
1142        let postings: Vec<(DocId, u16, f32)> = (0..500).map(|i| (i * 3, 0, i as f32)).collect();
1143        let list =
1144            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
1145
1146        let mut iter = list.iterator();
1147        assert_eq!(iter.seek(300), 300);
1148        assert_eq!(iter.seek(301), 303);
1149        assert_eq!(iter.seek(2000), TERMINATED);
1150    }
1151
1152    #[test]
1153    fn test_merge_with_offsets() {
1154        // Segment 1: docs 0, 5, 10 with weights
1155        let postings1: Vec<(DocId, u16, f32)> = vec![(0, 0, 1.0), (5, 0, 2.0), (10, 1, 3.0)];
1156        let list1 =
1157            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1158
1159        // Segment 2: docs 0, 3, 7 with weights (will become 100, 103, 107 after merge)
1160        let postings2: Vec<(DocId, u16, f32)> = vec![(0, 0, 4.0), (3, 1, 5.0), (7, 0, 6.0)];
1161        let list2 =
1162            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1163
1164        // Merge with offsets: segment 1 at offset 0, segment 2 at offset 100
1165        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 100)]);
1166
1167        assert_eq!(merged.doc_count(), 6);
1168
1169        // Verify all doc_ids are correct after merge
1170        let decoded = merged.decode_all();
1171        assert_eq!(decoded.len(), 6);
1172
1173        // Segment 1 docs (offset 0)
1174        assert_eq!(decoded[0].0, 0);
1175        assert_eq!(decoded[1].0, 5);
1176        assert_eq!(decoded[2].0, 10);
1177
1178        // Segment 2 docs (offset 100)
1179        assert_eq!(decoded[3].0, 100); // 0 + 100
1180        assert_eq!(decoded[4].0, 103); // 3 + 100
1181        assert_eq!(decoded[5].0, 107); // 7 + 100
1182
1183        // Verify weights preserved
1184        assert!((decoded[0].2 - 1.0).abs() < 0.01);
1185        assert!((decoded[3].2 - 4.0).abs() < 0.01);
1186
1187        // Verify ordinals preserved
1188        assert_eq!(decoded[2].1, 1); // ordinal from segment 1
1189        assert_eq!(decoded[4].1, 1); // ordinal from segment 2
1190    }
1191
1192    #[test]
1193    fn test_merge_with_offsets_multi_block() {
1194        // Create posting lists that span multiple blocks
1195        let postings1: Vec<(DocId, u16, f32)> = (0..200).map(|i| (i * 2, 0, i as f32)).collect();
1196        let list1 =
1197            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1198        assert!(list1.num_blocks() > 1, "Should have multiple blocks");
1199
1200        let postings2: Vec<(DocId, u16, f32)> = (0..150).map(|i| (i * 3, 1, i as f32)).collect();
1201        let list2 =
1202            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1203
1204        // Merge with offset 1000 for segment 2
1205        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 1000)]);
1206
1207        assert_eq!(merged.doc_count(), 350);
1208        assert_eq!(merged.num_blocks(), list1.num_blocks() + list2.num_blocks());
1209
1210        // Verify via iterator
1211        let mut iter = merged.iterator();
1212
1213        // First segment docs start at 0
1214        assert_eq!(iter.doc(), 0);
1215
1216        // Seek to segment 2 (should be at offset 1000)
1217        let doc = iter.seek(1000);
1218        assert_eq!(doc, 1000); // First doc of segment 2: 0 + 1000 = 1000
1219
1220        // Next doc in segment 2
1221        iter.advance();
1222        assert_eq!(iter.doc(), 1003); // 3 + 1000 = 1003
1223    }
1224
1225    #[test]
1226    fn test_merge_with_offsets_serialize_roundtrip() {
1227        // Verify that serialization preserves adjusted doc_ids
1228        let postings1: Vec<(DocId, u16, f32)> = vec![(0, 0, 1.0), (5, 0, 2.0), (10, 1, 3.0)];
1229        let list1 =
1230            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1231
1232        let postings2: Vec<(DocId, u16, f32)> = vec![(0, 0, 4.0), (3, 1, 5.0), (7, 0, 6.0)];
1233        let list2 =
1234            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1235
1236        // Merge with offset 100 for segment 2
1237        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 100)]);
1238
1239        // Serialize + reconstruct
1240        let (block_data, skip_entries) = merged.serialize().unwrap();
1241        let loaded =
1242            BlockSparsePostingList::from_parts(merged.doc_count(), &block_data, &skip_entries)
1243                .unwrap();
1244
1245        // Verify doc_ids are preserved after round-trip
1246        let decoded = loaded.decode_all();
1247        assert_eq!(decoded.len(), 6);
1248
1249        // Segment 1 docs (offset 0)
1250        assert_eq!(decoded[0].0, 0);
1251        assert_eq!(decoded[1].0, 5);
1252        assert_eq!(decoded[2].0, 10);
1253
1254        // Segment 2 docs (offset 100) - CRITICAL: these must be offset-adjusted
1255        assert_eq!(decoded[3].0, 100, "First doc of seg2 should be 0+100=100");
1256        assert_eq!(decoded[4].0, 103, "Second doc of seg2 should be 3+100=103");
1257        assert_eq!(decoded[5].0, 107, "Third doc of seg2 should be 7+100=107");
1258
1259        // Verify iterator also works correctly
1260        let mut iter = loaded.iterator();
1261        assert_eq!(iter.doc(), 0);
1262        iter.advance();
1263        assert_eq!(iter.doc(), 5);
1264        iter.advance();
1265        assert_eq!(iter.doc(), 10);
1266        iter.advance();
1267        assert_eq!(iter.doc(), 100);
1268        iter.advance();
1269        assert_eq!(iter.doc(), 103);
1270        iter.advance();
1271        assert_eq!(iter.doc(), 107);
1272    }
1273
1274    #[test]
1275    fn test_merge_seek_after_roundtrip() {
1276        // Create posting lists that span multiple blocks to test seek after merge
1277        let postings1: Vec<(DocId, u16, f32)> = (0..200).map(|i| (i * 2, 0, 1.0)).collect();
1278        let list1 =
1279            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1280
1281        let postings2: Vec<(DocId, u16, f32)> = (0..150).map(|i| (i * 3, 0, 2.0)).collect();
1282        let list2 =
1283            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1284
1285        // Merge with offset 1000 for segment 2
1286        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 1000)]);
1287
1288        // Serialize + reconstruct
1289        let (block_data, skip_entries) = merged.serialize().unwrap();
1290        let loaded =
1291            BlockSparsePostingList::from_parts(merged.doc_count(), &block_data, &skip_entries)
1292                .unwrap();
1293
1294        // Test seeking to various positions
1295        let mut iter = loaded.iterator();
1296
1297        // Seek to doc in segment 1
1298        let doc = iter.seek(100);
1299        assert_eq!(doc, 100, "Seek to 100 in segment 1");
1300
1301        // Seek to doc in segment 2 (1000 + offset)
1302        let doc = iter.seek(1000);
1303        assert_eq!(doc, 1000, "Seek to 1000 (first doc of segment 2)");
1304
1305        // Seek to middle of segment 2
1306        let doc = iter.seek(1050);
1307        assert!(
1308            doc >= 1050,
1309            "Seek to 1050 should find doc >= 1050, got {}",
1310            doc
1311        );
1312
1313        // Seek backwards should stay at current position (seek only goes forward)
1314        let doc = iter.seek(500);
1315        assert!(
1316            doc >= 1050,
1317            "Seek backwards should not go back, got {}",
1318            doc
1319        );
1320
1321        // Fresh iterator - verify block boundaries work
1322        let mut iter2 = loaded.iterator();
1323
1324        // Verify we can iterate through all docs
1325        let mut count = 0;
1326        let mut prev_doc = 0;
1327        while iter2.doc() != super::TERMINATED {
1328            let current = iter2.doc();
1329            if count > 0 {
1330                assert!(
1331                    current > prev_doc,
1332                    "Docs should be monotonically increasing: {} vs {}",
1333                    prev_doc,
1334                    current
1335                );
1336            }
1337            prev_doc = current;
1338            iter2.advance();
1339            count += 1;
1340        }
1341        assert_eq!(count, 350, "Should have 350 total docs");
1342    }
1343
1344    #[test]
1345    fn test_doc_count_multi_value() {
1346        // Multi-value: same doc_id with different ordinals
1347        // doc 0 has 3 ordinals, doc 5 has 2, doc 10 has 1 = 3 unique docs
1348        let postings: Vec<(DocId, u16, f32)> = vec![
1349            (0, 0, 1.0),
1350            (0, 1, 1.5),
1351            (0, 2, 2.0),
1352            (5, 0, 3.0),
1353            (5, 1, 3.5),
1354            (10, 0, 4.0),
1355        ];
1356        let list =
1357            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
1358
1359        // doc_count should be 3 (unique docs), not 6 (total postings)
1360        assert_eq!(list.doc_count(), 3);
1361
1362        // But we should still have all 6 postings accessible
1363        let decoded = list.decode_all();
1364        assert_eq!(decoded.len(), 6);
1365    }
1366
1367    /// Test the zero-copy merge path used by the actual sparse merger:
1368    /// serialize → get raw skip entries + block data → patch first_doc_id → reassemble.
1369    /// This mirrors the code path in `segment/merger/sparse.rs`.
1370    #[test]
1371    fn test_zero_copy_merge_patches_first_doc_id() {
1372        use crate::structures::SparseSkipEntry;
1373
1374        // Build two multi-block posting lists
1375        let postings1: Vec<(DocId, u16, f32)> = (0..200).map(|i| (i * 2, 0, i as f32)).collect();
1376        let list1 =
1377            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1378        assert!(list1.num_blocks() > 1);
1379
1380        let postings2: Vec<(DocId, u16, f32)> = (0..150).map(|i| (i * 3, 1, i as f32)).collect();
1381        let list2 =
1382            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1383
1384        // Serialize both using V3 format (block_data + skip_entries)
1385        let (raw1, skip1) = list1.serialize().unwrap();
1386        let (raw2, skip2) = list2.serialize().unwrap();
1387
1388        // --- Simulate the merger's zero-copy reassembly ---
1389        let doc_offset: u32 = 1000; // segment 2 starts at doc 1000
1390        let total_docs = list1.doc_count() + list2.doc_count();
1391
1392        // Accumulate adjusted skip entries
1393        let mut merged_skip = Vec::new();
1394        let mut cumulative_offset = 0u64;
1395        for entry in &skip1 {
1396            merged_skip.push(SparseSkipEntry::new(
1397                entry.first_doc,
1398                entry.last_doc,
1399                cumulative_offset + entry.offset,
1400                entry.length,
1401                entry.max_weight,
1402            ));
1403        }
1404        if let Some(last) = skip1.last() {
1405            cumulative_offset += last.offset + last.length as u64;
1406        }
1407        for entry in &skip2 {
1408            merged_skip.push(SparseSkipEntry::new(
1409                entry.first_doc + doc_offset,
1410                entry.last_doc + doc_offset,
1411                cumulative_offset + entry.offset,
1412                entry.length,
1413                entry.max_weight,
1414            ));
1415        }
1416
1417        // Concatenate raw block data: source 1 verbatim, source 2 with first_doc_id patched
1418        let mut merged_block_data = Vec::new();
1419        merged_block_data.extend_from_slice(&raw1);
1420
1421        const FIRST_DOC_ID_OFFSET: usize = 8;
1422        let mut buf2 = raw2.to_vec();
1423        for entry in &skip2 {
1424            let off = entry.offset as usize + FIRST_DOC_ID_OFFSET;
1425            if off + 4 <= buf2.len() {
1426                let old = u32::from_le_bytes(buf2[off..off + 4].try_into().unwrap());
1427                let patched = (old + doc_offset).to_le_bytes();
1428                buf2[off..off + 4].copy_from_slice(&patched);
1429            }
1430        }
1431        merged_block_data.extend_from_slice(&buf2);
1432
1433        // --- Reconstruct and verify ---
1434        let loaded =
1435            BlockSparsePostingList::from_parts(total_docs, &merged_block_data, &merged_skip)
1436                .unwrap();
1437        assert_eq!(loaded.doc_count(), 350);
1438
1439        let mut iter = loaded.iterator();
1440
1441        // Segment 1: docs 0, 2, 4, ..., 398
1442        assert_eq!(iter.doc(), 0);
1443        let doc = iter.seek(100);
1444        assert_eq!(doc, 100);
1445        let doc = iter.seek(398);
1446        assert_eq!(doc, 398);
1447
1448        // Segment 2: docs 1000, 1003, 1006, ..., 1000 + 149*3 = 1447
1449        let doc = iter.seek(1000);
1450        assert_eq!(doc, 1000, "First doc of segment 2 should be 1000");
1451        iter.advance();
1452        assert_eq!(iter.doc(), 1003, "Second doc of segment 2 should be 1003");
1453        let doc = iter.seek(1447);
1454        assert_eq!(doc, 1447, "Last doc of segment 2 should be 1447");
1455
1456        // Exhausted
1457        iter.advance();
1458        assert_eq!(iter.doc(), super::TERMINATED);
1459
1460        // Also verify with merge_with_offsets to confirm identical results
1461        let reference =
1462            BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, doc_offset)]);
1463        let mut ref_iter = reference.iterator();
1464        let mut zc_iter = loaded.iterator();
1465        while ref_iter.doc() != super::TERMINATED {
1466            assert_eq!(
1467                ref_iter.doc(),
1468                zc_iter.doc(),
1469                "Zero-copy and reference merge should produce identical doc_ids"
1470            );
1471            assert!(
1472                (ref_iter.weight() - zc_iter.weight()).abs() < 0.01,
1473                "Weights should match: {} vs {}",
1474                ref_iter.weight(),
1475                zc_iter.weight()
1476            );
1477            ref_iter.advance();
1478            zc_iter.advance();
1479        }
1480        assert_eq!(zc_iter.doc(), super::TERMINATED);
1481    }
1482
1483    #[test]
1484    fn test_doc_count_single_value() {
1485        // Single-value: each doc_id appears once (ordinal always 0)
1486        let postings: Vec<(DocId, u16, f32)> =
1487            vec![(0, 0, 1.0), (5, 0, 2.0), (10, 0, 3.0), (15, 0, 4.0)];
1488        let list =
1489            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
1490
1491        // doc_count == total postings for single-value
1492        assert_eq!(list.doc_count(), 4);
1493    }
1494
1495    #[test]
1496    fn test_doc_count_multi_value_serialization_roundtrip() {
1497        // Verify doc_count survives serialization
1498        let postings: Vec<(DocId, u16, f32)> =
1499            vec![(0, 0, 1.0), (0, 1, 1.5), (5, 0, 2.0), (5, 1, 2.5)];
1500        let list =
1501            BlockSparsePostingList::from_postings(&postings, WeightQuantization::Float32).unwrap();
1502        assert_eq!(list.doc_count(), 2);
1503
1504        let (block_data, skip_entries) = list.serialize().unwrap();
1505        let loaded =
1506            BlockSparsePostingList::from_parts(list.doc_count(), &block_data, &skip_entries)
1507                .unwrap();
1508        assert_eq!(loaded.doc_count(), 2);
1509    }
1510
1511    #[test]
1512    fn test_merge_preserves_weights_and_ordinals() {
1513        // Test that weights and ordinals are preserved after merge + roundtrip
1514        let postings1: Vec<(DocId, u16, f32)> = vec![(0, 0, 1.5), (5, 1, 2.5), (10, 2, 3.5)];
1515        let list1 =
1516            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1517
1518        let postings2: Vec<(DocId, u16, f32)> = vec![(0, 0, 4.5), (3, 1, 5.5), (7, 3, 6.5)];
1519        let list2 =
1520            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1521
1522        // Merge with offset 100 for segment 2
1523        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 100)]);
1524
1525        // Serialize + reconstruct
1526        let (block_data, skip_entries) = merged.serialize().unwrap();
1527        let loaded =
1528            BlockSparsePostingList::from_parts(merged.doc_count(), &block_data, &skip_entries)
1529                .unwrap();
1530
1531        // Verify all postings via iterator
1532        let mut iter = loaded.iterator();
1533
1534        // Segment 1 postings
1535        assert_eq!(iter.doc(), 0);
1536        assert!(
1537            (iter.weight() - 1.5).abs() < 0.01,
1538            "Weight should be 1.5, got {}",
1539            iter.weight()
1540        );
1541        assert_eq!(iter.ordinal(), 0);
1542
1543        iter.advance();
1544        assert_eq!(iter.doc(), 5);
1545        assert!(
1546            (iter.weight() - 2.5).abs() < 0.01,
1547            "Weight should be 2.5, got {}",
1548            iter.weight()
1549        );
1550        assert_eq!(iter.ordinal(), 1);
1551
1552        iter.advance();
1553        assert_eq!(iter.doc(), 10);
1554        assert!(
1555            (iter.weight() - 3.5).abs() < 0.01,
1556            "Weight should be 3.5, got {}",
1557            iter.weight()
1558        );
1559        assert_eq!(iter.ordinal(), 2);
1560
1561        // Segment 2 postings (with offset 100)
1562        iter.advance();
1563        assert_eq!(iter.doc(), 100);
1564        assert!(
1565            (iter.weight() - 4.5).abs() < 0.01,
1566            "Weight should be 4.5, got {}",
1567            iter.weight()
1568        );
1569        assert_eq!(iter.ordinal(), 0);
1570
1571        iter.advance();
1572        assert_eq!(iter.doc(), 103);
1573        assert!(
1574            (iter.weight() - 5.5).abs() < 0.01,
1575            "Weight should be 5.5, got {}",
1576            iter.weight()
1577        );
1578        assert_eq!(iter.ordinal(), 1);
1579
1580        iter.advance();
1581        assert_eq!(iter.doc(), 107);
1582        assert!(
1583            (iter.weight() - 6.5).abs() < 0.01,
1584            "Weight should be 6.5, got {}",
1585            iter.weight()
1586        );
1587        assert_eq!(iter.ordinal(), 3);
1588
1589        // Verify exhausted
1590        iter.advance();
1591        assert_eq!(iter.doc(), super::TERMINATED);
1592    }
1593
1594    #[test]
1595    fn test_merge_global_max_weight() {
1596        // Verify global_max_weight is correct after merge
1597        let postings1: Vec<(DocId, u16, f32)> = vec![
1598            (0, 0, 3.0),
1599            (1, 0, 7.0), // max in segment 1
1600            (2, 0, 2.0),
1601        ];
1602        let list1 =
1603            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1604
1605        let postings2: Vec<(DocId, u16, f32)> = vec![
1606            (0, 0, 5.0),
1607            (1, 0, 4.0),
1608            (2, 0, 6.0), // max in segment 2
1609        ];
1610        let list2 =
1611            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1612
1613        // Verify original global max weights
1614        assert!((list1.global_max_weight() - 7.0).abs() < 0.01);
1615        assert!((list2.global_max_weight() - 6.0).abs() < 0.01);
1616
1617        // Merge
1618        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 100)]);
1619
1620        // Global max should be 7.0 (from segment 1)
1621        assert!(
1622            (merged.global_max_weight() - 7.0).abs() < 0.01,
1623            "Global max should be 7.0, got {}",
1624            merged.global_max_weight()
1625        );
1626
1627        // Roundtrip
1628        let (block_data, skip_entries) = merged.serialize().unwrap();
1629        let loaded =
1630            BlockSparsePostingList::from_parts(merged.doc_count(), &block_data, &skip_entries)
1631                .unwrap();
1632
1633        assert!(
1634            (loaded.global_max_weight() - 7.0).abs() < 0.01,
1635            "After roundtrip, global max should still be 7.0, got {}",
1636            loaded.global_max_weight()
1637        );
1638    }
1639
1640    #[test]
1641    fn test_scoring_simulation_after_merge() {
1642        // Simulate scoring: compute query_weight * stored_weight
1643        let postings1: Vec<(DocId, u16, f32)> = vec![
1644            (0, 0, 0.5), // doc 0, weight 0.5
1645            (5, 0, 0.8), // doc 5, weight 0.8
1646        ];
1647        let list1 =
1648            BlockSparsePostingList::from_postings(&postings1, WeightQuantization::Float32).unwrap();
1649
1650        let postings2: Vec<(DocId, u16, f32)> = vec![
1651            (0, 0, 0.6), // doc 100 after offset, weight 0.6
1652            (3, 0, 0.9), // doc 103 after offset, weight 0.9
1653        ];
1654        let list2 =
1655            BlockSparsePostingList::from_postings(&postings2, WeightQuantization::Float32).unwrap();
1656
1657        // Merge with offset 100
1658        let merged = BlockSparsePostingList::merge_with_offsets(&[(&list1, 0), (&list2, 100)]);
1659
1660        // Roundtrip
1661        let (block_data, skip_entries) = merged.serialize().unwrap();
1662        let loaded =
1663            BlockSparsePostingList::from_parts(merged.doc_count(), &block_data, &skip_entries)
1664                .unwrap();
1665
1666        // Simulate scoring with query_weight = 2.0
1667        let query_weight = 2.0f32;
1668        let mut iter = loaded.iterator();
1669
1670        // Expected scores: query_weight * stored_weight
1671        // Doc 0: 2.0 * 0.5 = 1.0
1672        assert_eq!(iter.doc(), 0);
1673        let score = query_weight * iter.weight();
1674        assert!(
1675            (score - 1.0).abs() < 0.01,
1676            "Doc 0 score should be 1.0, got {}",
1677            score
1678        );
1679
1680        iter.advance();
1681        // Doc 5: 2.0 * 0.8 = 1.6
1682        assert_eq!(iter.doc(), 5);
1683        let score = query_weight * iter.weight();
1684        assert!(
1685            (score - 1.6).abs() < 0.01,
1686            "Doc 5 score should be 1.6, got {}",
1687            score
1688        );
1689
1690        iter.advance();
1691        // Doc 100: 2.0 * 0.6 = 1.2
1692        assert_eq!(iter.doc(), 100);
1693        let score = query_weight * iter.weight();
1694        assert!(
1695            (score - 1.2).abs() < 0.01,
1696            "Doc 100 score should be 1.2, got {}",
1697            score
1698        );
1699
1700        iter.advance();
1701        // Doc 103: 2.0 * 0.9 = 1.8
1702        assert_eq!(iter.doc(), 103);
1703        let score = query_weight * iter.weight();
1704        assert!(
1705            (score - 1.8).abs() < 0.01,
1706            "Doc 103 score should be 1.8, got {}",
1707            score
1708        );
1709    }
1710}
1711
1712#[cfg(feature = "native")]
1713impl SparseBlock {
1714    /// Rewrite address columns while preserving original quantizer parameters
1715    /// and code bytes, including when deleted rows held the old extrema.
1716    pub(crate) fn remap_documents(
1717        &self,
1718        remap: impl Fn(DocId) -> Option<DocId>,
1719    ) -> io::Result<Option<Self>> {
1720        let docs = self.decode_doc_ids();
1721        let ordinals = self.decode_ordinals();
1722        let mut kept = Vec::new();
1723        let mut postings = Vec::new();
1724        for (i, doc) in docs.into_iter().enumerate() {
1725            if let Some(new) = remap(doc) {
1726                kept.push(i);
1727                postings.push((new, ordinals[i], 0.0));
1728            }
1729        }
1730        if kept.is_empty() {
1731            return Ok(None);
1732        }
1733        let mut output = Self::from_postings(&postings, WeightQuantization::Float32)?;
1734        let source = self.weights_data.as_slice();
1735        let mut weights = Vec::new();
1736        match self.header.weight_quant {
1737            WeightQuantization::Float32 | WeightQuantization::Float16 => {
1738                let width = if self.header.weight_quant == WeightQuantization::Float32 {
1739                    4
1740                } else {
1741                    2
1742                };
1743                for i in kept {
1744                    weights.extend_from_slice(&source[i * width..(i + 1) * width]);
1745                }
1746            }
1747            WeightQuantization::UInt8 => {
1748                weights.extend_from_slice(&source[..8]);
1749                for i in kept {
1750                    weights.push(source[8 + i]);
1751                }
1752            }
1753            WeightQuantization::UInt4 => {
1754                weights.extend_from_slice(&source[..8]);
1755                for (new, old) in kept.into_iter().enumerate() {
1756                    let nibble = (source[8 + old / 2] >> (old % 2 * 4)) & 15;
1757                    if new % 2 == 0 {
1758                        weights.push(nibble);
1759                    } else {
1760                        *weights.last_mut().unwrap() |= nibble << 4;
1761                    }
1762                }
1763            }
1764        }
1765        output.header.weight_quant = self.header.weight_quant;
1766        output.header.max_weight = self.header.max_weight;
1767        output.weights_data = OwnedBytes::new(weights);
1768        Ok(Some(output))
1769    }
1770}
1771
1772#[cfg(all(test, feature = "native"))]
1773#[test]
1774fn deletion_remapping_preserves_quantized_weights_and_ordinals() {
1775    for quant in [
1776        WeightQuantization::Float32,
1777        WeightQuantization::Float16,
1778        WeightQuantization::UInt8,
1779        WeightQuantization::UInt4,
1780    ] {
1781        let input: Vec<_> = (0..255u32)
1782            .map(|i| (i / 2, (i % 2) as u16, 0.1 + i as f32 / 7.0))
1783            .collect();
1784        let source = SparseBlock::from_postings(&input, quant).unwrap();
1785        let source_weights = source.decode_weights();
1786        let compacted = source
1787            .remap_documents(|doc| (doc % 3 == 2).then_some(doc / 3))
1788            .unwrap()
1789            .unwrap();
1790        let expected: Vec<_> = input
1791            .iter()
1792            .enumerate()
1793            .filter(|(_, (doc, _, _))| doc % 3 == 2)
1794            .map(|(i, (_, ordinal, _))| (*ordinal, source_weights[i].to_bits()))
1795            .collect();
1796        assert_eq!(
1797            compacted
1798                .decode_ordinals()
1799                .into_iter()
1800                .zip(compacted.decode_weights().into_iter().map(f32::to_bits))
1801                .collect::<Vec<_>>(),
1802            expected
1803        );
1804        if matches!(quant, WeightQuantization::UInt8 | WeightQuantization::UInt4) {
1805            assert_eq!(&compacted.weights_data[..8], &source.weights_data[..8]);
1806        }
1807    }
1808}