Skip to main content

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