Skip to main content

hermes_core/structures/postings/
posting.rs

1//! Posting list implementation with compact representation
2//!
3//! Text blocks use SIMD-friendly packed bit-width encoding:
4//! - Doc IDs: delta-encoded, packed at rounded bit width (0/8/16/32)
5//! - Term frequencies: packed at rounded bit width
6//! - Same SIMD primitives as sparse blocks (`simd::pack_rounded` / `unpack_rounded`)
7
8use byteorder::{LittleEndian, WriteBytesExt};
9use std::io::{self, Read, Write};
10
11use super::posting_common::{read_vint, write_vint};
12use crate::DocId;
13use crate::directories::OwnedBytes;
14use crate::structures::simd;
15
16/// A posting entry containing doc_id and term frequency
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Posting {
19    pub doc_id: DocId,
20    pub term_freq: u32,
21}
22
23/// Compact posting list with delta encoding
24#[derive(Debug, Clone, Default)]
25pub struct PostingList {
26    postings: Vec<Posting>,
27}
28
29impl PostingList {
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    pub fn with_capacity(capacity: usize) -> Self {
35        Self {
36            postings: Vec::with_capacity(capacity),
37        }
38    }
39
40    /// Add a posting (must be added in doc_id order)
41    pub fn push(&mut self, doc_id: DocId, term_freq: u32) {
42        debug_assert!(
43            self.postings.is_empty() || self.postings.last().unwrap().doc_id < doc_id,
44            "Postings must be added in sorted order"
45        );
46        self.postings.push(Posting { doc_id, term_freq });
47    }
48
49    /// Add a posting, incrementing term_freq if doc already exists
50    pub fn add(&mut self, doc_id: DocId, term_freq: u32) {
51        if let Some(last) = self.postings.last_mut()
52            && last.doc_id == doc_id
53        {
54            last.term_freq += term_freq;
55            return;
56        }
57        self.postings.push(Posting { doc_id, term_freq });
58    }
59
60    /// Get document count
61    pub fn doc_count(&self) -> u32 {
62        self.postings.len() as u32
63    }
64
65    pub fn len(&self) -> usize {
66        self.postings.len()
67    }
68
69    pub fn is_empty(&self) -> bool {
70        self.postings.is_empty()
71    }
72
73    pub fn iter(&self) -> impl Iterator<Item = &Posting> {
74        self.postings.iter()
75    }
76
77    /// Serialize to bytes using delta encoding and varint
78    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
79        // Write number of postings
80        write_vint(writer, self.postings.len() as u64)?;
81
82        let mut prev_doc_id = 0u32;
83        for posting in &self.postings {
84            // Delta encode doc_id
85            let delta = posting.doc_id - prev_doc_id;
86            write_vint(writer, delta as u64)?;
87            write_vint(writer, posting.term_freq as u64)?;
88            prev_doc_id = posting.doc_id;
89        }
90
91        Ok(())
92    }
93
94    /// Deserialize from bytes
95    pub fn deserialize<R: Read>(reader: &mut R) -> io::Result<Self> {
96        let count = read_vint(reader)? as usize;
97        let mut postings = Vec::with_capacity(count);
98
99        let mut prev_doc_id = 0u32;
100        for _ in 0..count {
101            let delta = read_vint(reader)? as u32;
102            let term_freq = read_vint(reader)? as u32;
103            let doc_id = prev_doc_id + delta;
104            postings.push(Posting { doc_id, term_freq });
105            prev_doc_id = doc_id;
106        }
107
108        Ok(Self { postings })
109    }
110}
111
112/// Iterator over posting list that supports seeking
113pub struct PostingListIterator<'a> {
114    postings: &'a [Posting],
115    position: usize,
116}
117
118impl<'a> PostingListIterator<'a> {
119    pub fn new(posting_list: &'a PostingList) -> Self {
120        Self {
121            postings: &posting_list.postings,
122            position: 0,
123        }
124    }
125
126    /// Current document ID, or TERMINATED if exhausted
127    pub fn doc(&self) -> DocId {
128        if self.position < self.postings.len() {
129            self.postings[self.position].doc_id
130        } else {
131            TERMINATED
132        }
133    }
134
135    /// Current term frequency
136    pub fn term_freq(&self) -> u32 {
137        if self.position < self.postings.len() {
138            self.postings[self.position].term_freq
139        } else {
140            0
141        }
142    }
143
144    /// Advance to next posting, returns new doc_id or TERMINATED
145    pub fn advance(&mut self) -> DocId {
146        self.position += 1;
147        self.doc()
148    }
149
150    /// Seek to first doc_id >= target (binary search on remaining postings)
151    pub fn seek(&mut self, target: DocId) -> DocId {
152        let remaining = &self.postings[self.position..];
153        let offset = remaining.partition_point(|p| p.doc_id < target);
154        self.position += offset;
155        self.doc()
156    }
157
158    /// Size hint for remaining elements
159    pub fn size_hint(&self) -> usize {
160        self.postings.len().saturating_sub(self.position)
161    }
162}
163
164/// Sentinel value indicating iterator is exhausted
165pub const TERMINATED: DocId = DocId::MAX;
166
167/// Block-based posting list with 2-level skip index.
168///
169/// Each block contains up to `BLOCK_SIZE` postings encoded as packed bit-width arrays.
170/// Skip entries use a compact 2-level structure for cache-friendly seeking:
171/// - **Level-0** (16 bytes/block): `first_doc`, `last_doc`, `offset`, `max_weight`
172/// - **Level-1** (4 bytes/group): `last_doc` per `L1_INTERVAL` blocks
173///
174/// Seek algorithm: binary search L1, then linear scan ≤`L1_INTERVAL` L0 entries.
175pub const BLOCK_SIZE: usize = 128;
176
177/// Number of L0 blocks per L1 skip entry.
178const L1_INTERVAL: usize = 8;
179
180/// Compact level-0 skip entry — 16 bytes.
181/// `length` is omitted: computable from the block's 8-byte header.
182const L0_SIZE: usize = 16;
183
184/// Level-1 skip entry — 4 bytes (just `last_doc`).
185const L1_SIZE: usize = 4;
186
187/// Footer: stream_len(8) + l0_count(4) + l1_count(4) + doc_count(4) + max_tf(4) = 24 bytes.
188const FOOTER_SIZE: usize = 24;
189
190/// Read a compact L0 entry from raw bytes at the given index.
191///
192/// Uses a single bounds check (`[..L0_SIZE]`) instead of 4× `try_into().unwrap()`.
193#[inline]
194fn read_l0(bytes: &[u8], idx: usize) -> (u32, u32, u32, f32) {
195    let b = &bytes[idx * L0_SIZE..][..L0_SIZE];
196    let first_doc = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
197    let last_doc = u32::from_le_bytes([b[4], b[5], b[6], b[7]]);
198    let offset = u32::from_le_bytes([b[8], b[9], b[10], b[11]]);
199    let max_weight = f32::from_le_bytes([b[12], b[13], b[14], b[15]]);
200    (first_doc, last_doc, offset, max_weight)
201}
202
203/// Write a compact L0 entry.
204#[inline]
205fn write_l0(buf: &mut Vec<u8>, first_doc: u32, last_doc: u32, offset: u32, max_weight: f32) {
206    buf.extend_from_slice(&first_doc.to_le_bytes());
207    buf.extend_from_slice(&last_doc.to_le_bytes());
208    buf.extend_from_slice(&offset.to_le_bytes());
209    buf.extend_from_slice(&max_weight.to_le_bytes());
210}
211
212/// Compute block data size from the 8-byte header at `stream[pos..]`.
213///
214/// Header: `[count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]`
215/// Data size = 8 + (count-1) × bytes_per_value(doc_id_bits) + count × bytes_per_value(tf_bits)
216#[inline]
217fn block_data_size(stream: &[u8], pos: usize) -> usize {
218    let count = u16::from_le_bytes(stream[pos..pos + 2].try_into().unwrap()) as usize;
219    let doc_rounded = simd::RoundedBitWidth::from_u8(stream[pos + 6]);
220    let tf_rounded = simd::RoundedBitWidth::from_u8(stream[pos + 7]);
221    let delta_bytes = if count > 1 {
222        (count - 1) * doc_rounded.bytes_per_value()
223    } else {
224        0
225    };
226    8 + delta_bytes + count * tf_rounded.bytes_per_value()
227}
228
229#[derive(Debug, Clone)]
230pub struct BlockPostingList {
231    /// Block data stream (packed blocks laid out sequentially).
232    stream: OwnedBytes,
233    /// Level-0 skip entries: `(first_doc, last_doc, offset, max_weight)` × `l0_count`.
234    /// 16 bytes per entry. Supports O(1) random access by block index.
235    l0_bytes: OwnedBytes,
236    /// Number of blocks (= number of L0 entries).
237    l0_count: usize,
238    /// Level-1 skip `last_doc` values — one per `L1_INTERVAL` blocks.
239    /// Stored as `Vec<u32>` for direct SIMD-accelerated `find_first_ge_u32`.
240    l1_docs: Vec<u32>,
241    /// Total posting count.
242    doc_count: u32,
243    /// Max TF across all blocks.
244    max_tf: u32,
245}
246
247impl BlockPostingList {
248    /// Read L0 entry by block index. Returns `(first_doc, last_doc, offset, max_weight)`.
249    #[inline]
250    fn read_l0_entry(&self, idx: usize) -> (u32, u32, u32, f32) {
251        read_l0(&self.l0_bytes, idx)
252    }
253
254    /// Build from a posting list.
255    ///
256    /// Block format (8-byte header + packed arrays):
257    /// ```text
258    /// [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
259    /// [packed doc_id deltas: (count-1) × bytes_per_value(doc_id_bits)]
260    /// [packed tfs: count × bytes_per_value(tf_bits)]
261    /// ```
262    pub fn from_posting_list(list: &PostingList) -> io::Result<Self> {
263        let mut stream: Vec<u8> = Vec::new();
264        let mut l0_buf: Vec<u8> = Vec::new();
265        let mut l1_docs: Vec<u32> = Vec::new();
266        let mut l0_count = 0usize;
267        let mut max_tf = 0u32;
268
269        let postings = &list.postings;
270        let mut i = 0;
271
272        // Temp buffers reused across blocks
273        let mut deltas = Vec::with_capacity(BLOCK_SIZE);
274        let mut tf_buf = Vec::with_capacity(BLOCK_SIZE);
275
276        while i < postings.len() {
277            if stream.len() > u32::MAX as usize {
278                return Err(io::Error::new(
279                    io::ErrorKind::InvalidData,
280                    "posting list stream exceeds u32::MAX bytes",
281                ));
282            }
283            let block_start = stream.len() as u32;
284            let block_end = (i + BLOCK_SIZE).min(postings.len());
285            let block = &postings[i..block_end];
286            let count = block.len();
287
288            // Compute block's max term frequency for block-max pruning
289            let block_max_tf = block.iter().map(|p| p.term_freq).max().unwrap_or(0);
290            max_tf = max_tf.max(block_max_tf);
291
292            let base_doc_id = block.first().unwrap().doc_id;
293            let last_doc_id = block.last().unwrap().doc_id;
294
295            // Delta-encode doc IDs (skip first — stored in header)
296            deltas.clear();
297            let mut prev = base_doc_id;
298            for posting in block.iter().skip(1) {
299                deltas.push(posting.doc_id - prev);
300                prev = posting.doc_id;
301            }
302            let max_delta = deltas.iter().copied().max().unwrap_or(0);
303            let doc_id_bits = simd::round_bit_width(simd::bits_needed(max_delta));
304
305            // Collect TFs
306            tf_buf.clear();
307            tf_buf.extend(block.iter().map(|p| p.term_freq));
308            let tf_bits = simd::round_bit_width(simd::bits_needed(block_max_tf));
309
310            // Write 8-byte header: [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
311            stream.write_u16::<LittleEndian>(count as u16)?;
312            stream.write_u32::<LittleEndian>(base_doc_id)?;
313            stream.push(doc_id_bits);
314            stream.push(tf_bits);
315
316            // Write packed doc_id deltas ((count-1) values)
317            if count > 1 {
318                let rounded = simd::RoundedBitWidth::from_u8(doc_id_bits);
319                let byte_count = (count - 1) * rounded.bytes_per_value();
320                let start = stream.len();
321                stream.resize(start + byte_count, 0);
322                simd::pack_rounded(&deltas, rounded, &mut stream[start..]);
323            }
324
325            // Write packed TFs (count values)
326            {
327                let rounded = simd::RoundedBitWidth::from_u8(tf_bits);
328                let byte_count = count * rounded.bytes_per_value();
329                let start = stream.len();
330                stream.resize(start + byte_count, 0);
331                simd::pack_rounded(&tf_buf, rounded, &mut stream[start..]);
332            }
333
334            // L0 skip entry
335            write_l0(
336                &mut l0_buf,
337                base_doc_id,
338                last_doc_id,
339                block_start,
340                block_max_tf as f32,
341            );
342            l0_count += 1;
343
344            // L1 entry at the end of each L1_INTERVAL group
345            if l0_count.is_multiple_of(L1_INTERVAL) {
346                l1_docs.push(last_doc_id);
347            }
348
349            i = block_end;
350        }
351
352        // Final L1 entry for partial group
353        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
354            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
355            l1_docs.push(last_doc);
356        }
357
358        Ok(Self {
359            stream: OwnedBytes::new(stream),
360            l0_bytes: OwnedBytes::new(l0_buf),
361            l0_count,
362            l1_docs,
363            doc_count: postings.len() as u32,
364            max_tf,
365        })
366    }
367
368    /// Serialize the block posting list (footer-based: stream first).
369    ///
370    /// Format:
371    /// ```text
372    /// [stream: block data]
373    /// [L0 entries: l0_count × 16 bytes (first_doc, last_doc, offset, max_weight)]
374    /// [L1 entries: l1_count × 4 bytes (last_doc)]
375    /// [footer: stream_len(8) + l0_count(4) + l1_count(4) + doc_count(4) + max_tf(4) = 24 bytes]
376    /// ```
377    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
378        writer.write_all(&self.stream)?;
379        writer.write_all(&self.l0_bytes)?;
380        for &doc in &self.l1_docs {
381            writer.write_u32::<LittleEndian>(doc)?;
382        }
383
384        // Footer (24 bytes)
385        writer.write_u64::<LittleEndian>(self.stream.len() as u64)?;
386        writer.write_u32::<LittleEndian>(self.l0_count as u32)?;
387        writer.write_u32::<LittleEndian>(self.l1_docs.len() as u32)?;
388        writer.write_u32::<LittleEndian>(self.doc_count)?;
389        writer.write_u32::<LittleEndian>(self.max_tf)?;
390
391        Ok(())
392    }
393
394    /// Deserialize from a byte slice (footer-based format).
395    pub fn deserialize(raw: &[u8]) -> io::Result<Self> {
396        if raw.len() < FOOTER_SIZE {
397            return Err(io::Error::new(
398                io::ErrorKind::InvalidData,
399                "posting data too short",
400            ));
401        }
402
403        let f = raw.len() - FOOTER_SIZE;
404        let stream_len = u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()) as usize;
405        let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
406        let l1_count = u32::from_le_bytes(raw[f + 12..f + 16].try_into().unwrap()) as usize;
407        let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
408        let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
409
410        let l0_start = stream_len;
411        let l0_end = l0_start + l0_count * L0_SIZE;
412        let l1_start = l0_end;
413
414        let l1_docs = Self::extract_l1_docs(&raw[l1_start..], l1_count);
415
416        Ok(Self {
417            stream: OwnedBytes::new(raw[..stream_len].to_vec()),
418            l0_bytes: OwnedBytes::new(raw[l0_start..l0_end].to_vec()),
419            l0_count,
420            l1_docs,
421            doc_count,
422            max_tf,
423        })
424    }
425
426    /// Zero-copy deserialization from OwnedBytes.
427    /// Stream and L0 are sliced from the source without copying.
428    /// L1 is extracted into a `Vec<u32>` for SIMD-friendly access (tiny: ≤ N/8 entries).
429    pub fn deserialize_zero_copy(raw: OwnedBytes) -> io::Result<Self> {
430        if raw.len() < FOOTER_SIZE {
431            return Err(io::Error::new(
432                io::ErrorKind::InvalidData,
433                "posting data too short",
434            ));
435        }
436
437        let f = raw.len() - FOOTER_SIZE;
438        let stream_len = u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()) as usize;
439        let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
440        let l1_count = u32::from_le_bytes(raw[f + 12..f + 16].try_into().unwrap()) as usize;
441        let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
442        let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
443
444        let l0_start = stream_len;
445        let l0_end = l0_start + l0_count * L0_SIZE;
446        let l1_start = l0_end;
447
448        let l1_docs = Self::extract_l1_docs(&raw[l1_start..], l1_count);
449
450        Ok(Self {
451            stream: raw.slice(0..stream_len),
452            l0_bytes: raw.slice(l0_start..l0_end),
453            l0_count,
454            l1_docs,
455            doc_count,
456            max_tf,
457        })
458    }
459
460    /// Extract L1 last_doc values from raw LE bytes into a Vec<u32>.
461    fn extract_l1_docs(bytes: &[u8], count: usize) -> Vec<u32> {
462        let mut docs = Vec::with_capacity(count);
463        for i in 0..count {
464            let p = i * L1_SIZE;
465            docs.push(u32::from_le_bytes(bytes[p..p + 4].try_into().unwrap()));
466        }
467        docs
468    }
469
470    pub fn doc_count(&self) -> u32 {
471        self.doc_count
472    }
473
474    /// Get maximum term frequency (for MaxScore upper bound computation)
475    pub fn max_tf(&self) -> u32 {
476        self.max_tf
477    }
478
479    /// Get number of blocks
480    pub fn num_blocks(&self) -> usize {
481        self.l0_count
482    }
483
484    /// Get block's max term frequency for block-max pruning
485    pub fn block_max_tf(&self, block_idx: usize) -> Option<u32> {
486        if block_idx >= self.l0_count {
487            return None;
488        }
489        let (_, _, _, max_weight) = self.read_l0_entry(block_idx);
490        Some(max_weight as u32)
491    }
492
493    /// Concatenate blocks from multiple posting lists with doc_id remapping.
494    /// This is O(num_blocks) instead of O(num_postings).
495    pub fn concatenate_blocks(sources: &[(BlockPostingList, u32)]) -> io::Result<Self> {
496        let mut stream: Vec<u8> = Vec::new();
497        let mut l0_buf: Vec<u8> = Vec::new();
498        let mut l1_docs: Vec<u32> = Vec::new();
499        let mut l0_count = 0usize;
500        let mut total_docs = 0u32;
501        let mut max_tf = 0u32;
502
503        for (source, doc_offset) in sources {
504            max_tf = max_tf.max(source.max_tf);
505            for block_idx in 0..source.num_blocks() {
506                let (first_doc, last_doc, offset, max_weight) = source.read_l0_entry(block_idx);
507                let blk_size = block_data_size(&source.stream, offset as usize);
508                let block_bytes = &source.stream[offset as usize..offset as usize + blk_size];
509
510                let count = u16::from_le_bytes(block_bytes[0..2].try_into().unwrap());
511                if stream.len() > u32::MAX as usize {
512                    return Err(io::Error::new(
513                        io::ErrorKind::InvalidData,
514                        "posting list stream exceeds u32::MAX bytes during concatenation",
515                    ));
516                }
517                let new_offset = stream.len() as u32;
518
519                // Write patched header + copy packed arrays verbatim
520                stream.write_u16::<LittleEndian>(count)?;
521                stream.write_u32::<LittleEndian>(first_doc + doc_offset)?;
522                stream.extend_from_slice(&block_bytes[6..]);
523
524                let new_last = last_doc + doc_offset;
525                write_l0(
526                    &mut l0_buf,
527                    first_doc + doc_offset,
528                    new_last,
529                    new_offset,
530                    max_weight,
531                );
532                l0_count += 1;
533                total_docs += count as u32;
534
535                if l0_count.is_multiple_of(L1_INTERVAL) {
536                    l1_docs.push(new_last);
537                }
538            }
539        }
540
541        // Final L1 entry for partial group
542        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
543            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
544            l1_docs.push(last_doc);
545        }
546
547        Ok(Self {
548            stream: OwnedBytes::new(stream),
549            l0_bytes: OwnedBytes::new(l0_buf),
550            l0_count,
551            l1_docs,
552            doc_count: total_docs,
553            max_tf,
554        })
555    }
556
557    /// Streaming merge: write blocks directly to output writer (bounded memory).
558    ///
559    /// **Zero-materializing**: reads L0 entries directly from source bytes
560    /// (mmap or &[u8]) without parsing into Vecs. Block sizes computed from
561    /// the 8-byte header (deterministic with packed encoding).
562    ///
563    /// Output L0 + L1 are buffered (bounded O(total_blocks × 16 + total_blocks/8 × 4)).
564    /// Block data flows source → output writer without intermediate buffering.
565    ///
566    /// Returns `(doc_count, bytes_written)`.
567    ///
568    /// Returns `Error::Corruption` if any source is shorter than its footer:
569    /// metas are paired with sources positionally, so a short/corrupt source
570    /// must fail loudly instead of misassigning every subsequent source.
571    pub fn concatenate_streaming<W: Write>(
572        sources: &[(&[u8], u32)], // (serialized_bytes, doc_offset)
573        writer: &mut W,
574    ) -> crate::Result<(u32, usize)> {
575        struct SourceMeta {
576            stream_len: usize,
577            l0_count: usize,
578        }
579
580        let mut metas: Vec<SourceMeta> = Vec::with_capacity(sources.len());
581        let mut total_docs = 0u32;
582        let mut merged_max_tf = 0u32;
583
584        for (source_index, (raw, _)) in sources.iter().enumerate() {
585            if raw.len() < FOOTER_SIZE {
586                return Err(crate::Error::Corruption(format!(
587                    "posting list source {} is shorter than its footer: {} bytes < {}",
588                    source_index,
589                    raw.len(),
590                    FOOTER_SIZE,
591                )));
592            }
593            let f = raw.len() - FOOTER_SIZE;
594            let stream_len = u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()) as usize;
595            let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
596            // l1_count not needed — we rebuild L1
597            let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
598            let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
599            total_docs += doc_count;
600            merged_max_tf = merged_max_tf.max(max_tf);
601            metas.push(SourceMeta {
602                stream_len,
603                l0_count,
604            });
605        }
606
607        // Phase 1: Stream block data, reading L0 entries on-the-fly.
608        // Accumulate output L0 + L1 (bounded).
609        let mut out_l0: Vec<u8> = Vec::new();
610        let mut out_l1_docs: Vec<u32> = Vec::new();
611        let mut out_l0_count = 0usize;
612        let mut stream_written = 0u64;
613        let mut patch_buf = [0u8; 8];
614
615        for (src_idx, meta) in metas.iter().enumerate() {
616            let (raw, doc_offset) = &sources[src_idx];
617            let l0_base = meta.stream_len; // L0 entries start right after stream
618            let src_stream = &raw[..meta.stream_len];
619
620            for i in 0..meta.l0_count {
621                // Read source L0 entry directly from raw bytes
622                let (first_doc, last_doc, offset, max_weight) = read_l0(&raw[l0_base..], i);
623
624                // Compute block size from header
625                let blk_size = block_data_size(src_stream, offset as usize);
626                let block = &src_stream[offset as usize..offset as usize + blk_size];
627
628                // Write output L0 entry
629                let new_last = last_doc + doc_offset;
630                if stream_written > u32::MAX as u64 {
631                    return Err(io::Error::new(
632                        io::ErrorKind::InvalidData,
633                        "posting list stream exceeds u32::MAX bytes during streaming merge",
634                    )
635                    .into());
636                }
637                write_l0(
638                    &mut out_l0,
639                    first_doc + doc_offset,
640                    new_last,
641                    stream_written as u32,
642                    max_weight,
643                );
644                out_l0_count += 1;
645
646                // L1 entry at group boundary
647                if out_l0_count.is_multiple_of(L1_INTERVAL) {
648                    out_l1_docs.push(new_last);
649                }
650
651                // Patch 8-byte header: [count: u16][first_doc: u32][bits: 2 bytes]
652                patch_buf.copy_from_slice(&block[0..8]);
653                let blk_first = u32::from_le_bytes(patch_buf[2..6].try_into().unwrap());
654                patch_buf[2..6].copy_from_slice(&(blk_first + doc_offset).to_le_bytes());
655                writer.write_all(&patch_buf)?;
656                writer.write_all(&block[8..])?;
657
658                stream_written += blk_size as u64;
659            }
660        }
661
662        // Final L1 entry for partial group
663        if !out_l0_count.is_multiple_of(L1_INTERVAL) && out_l0_count > 0 {
664            let (_, last_doc, _, _) = read_l0(&out_l0, out_l0_count - 1);
665            out_l1_docs.push(last_doc);
666        }
667
668        // Phase 2: Write L0 + L1 + footer
669        writer.write_all(&out_l0)?;
670        for &doc in &out_l1_docs {
671            writer.write_u32::<LittleEndian>(doc)?;
672        }
673
674        writer.write_u64::<LittleEndian>(stream_written)?;
675        writer.write_u32::<LittleEndian>(out_l0_count as u32)?;
676        writer.write_u32::<LittleEndian>(out_l1_docs.len() as u32)?;
677        writer.write_u32::<LittleEndian>(total_docs)?;
678        writer.write_u32::<LittleEndian>(merged_max_tf)?;
679
680        let l1_bytes_len = out_l1_docs.len() * L1_SIZE;
681        let total_bytes = stream_written as usize + out_l0.len() + l1_bytes_len + FOOTER_SIZE;
682        Ok((total_docs, total_bytes))
683    }
684
685    /// Decode a specific block into caller-provided buffers.
686    ///
687    /// Returns `true` if the block was decoded, `false` if `block_idx` is out of range.
688    /// Reuses `doc_ids` and `tfs` buffers (cleared before filling).
689    ///
690    /// Uses SIMD-accelerated unpack for 8/16/32-bit packed arrays.
691    pub fn decode_block_into(
692        &self,
693        block_idx: usize,
694        doc_ids: &mut Vec<u32>,
695        tfs: &mut Vec<u32>,
696    ) -> bool {
697        if let Some((offset, tf_start, count)) = self.decode_block_doc_ids_only(block_idx, doc_ids)
698        {
699            self.decode_block_tfs_deferred(offset, tf_start, count, tfs);
700            true
701        } else {
702            false
703        }
704    }
705
706    /// Decode only doc IDs from a block (no TF decoding).
707    ///
708    /// Returns `(block_data_offset, tf_start_within_block, count)` for deferred TF decode,
709    /// or `None` if block_idx is out of range.
710    pub fn decode_block_doc_ids_only(
711        &self,
712        block_idx: usize,
713        doc_ids: &mut Vec<u32>,
714    ) -> Option<(usize, usize, usize)> {
715        if block_idx >= self.l0_count {
716            return None;
717        }
718
719        let (_, _, offset, _) = self.read_l0_entry(block_idx);
720        let pos = offset as usize;
721        let blk_size = block_data_size(&self.stream, pos);
722        let block_data = &self.stream[pos..pos + blk_size];
723
724        // 8-byte header: [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
725        let count = u16::from_le_bytes(block_data[0..2].try_into().unwrap()) as usize;
726        let first_doc = u32::from_le_bytes(block_data[2..6].try_into().unwrap());
727        let doc_id_bits = block_data[6];
728
729        doc_ids.clear();
730        doc_ids.resize(count, 0);
731        doc_ids[0] = first_doc;
732
733        let doc_rounded = simd::RoundedBitWidth::from_u8(doc_id_bits);
734        let deltas_bytes = if count > 1 {
735            (count - 1) * doc_rounded.bytes_per_value()
736        } else {
737            0
738        };
739
740        if count > 1 {
741            simd::unpack_rounded(
742                &block_data[8..8 + deltas_bytes],
743                doc_rounded,
744                &mut doc_ids[1..],
745                count - 1,
746            );
747            for i in 1..count {
748                doc_ids[i] += doc_ids[i - 1];
749            }
750        }
751
752        let tfs_start = 8 + deltas_bytes;
753        Some((pos, tfs_start, count))
754    }
755
756    /// Decode TFs from a previously loaded block (deferred decode).
757    ///
758    /// `block_offset` and `tf_start` are returned by `decode_block_doc_ids_only`.
759    pub fn decode_block_tfs_deferred(
760        &self,
761        block_offset: usize,
762        tf_start: usize,
763        count: usize,
764        tfs: &mut Vec<u32>,
765    ) {
766        let blk_size = block_data_size(&self.stream, block_offset);
767        let block_data = &self.stream[block_offset..block_offset + blk_size];
768        let tf_bits = block_data[7];
769        let tf_rounded = simd::RoundedBitWidth::from_u8(tf_bits);
770
771        tfs.clear();
772        tfs.resize(count, 0);
773        simd::unpack_rounded(
774            &block_data[tf_start..tf_start + count * tf_rounded.bytes_per_value()],
775            tf_rounded,
776            tfs,
777            count,
778        );
779    }
780
781    /// First doc_id of a block (from L0 skip entry). Returns `None` if out of range.
782    #[inline]
783    pub fn block_first_doc(&self, block_idx: usize) -> Option<DocId> {
784        if block_idx >= self.l0_count {
785            return None;
786        }
787        let (first_doc, _, _, _) = self.read_l0_entry(block_idx);
788        Some(first_doc)
789    }
790
791    /// Last doc_id of a block (from L0 skip entry). Returns `None` if out of range.
792    #[inline]
793    pub fn block_last_doc(&self, block_idx: usize) -> Option<DocId> {
794        if block_idx >= self.l0_count {
795            return None;
796        }
797        let (_, last_doc, _, _) = self.read_l0_entry(block_idx);
798        Some(last_doc)
799    }
800
801    /// Find the first block whose `last_doc >= target`, starting from `from_block`.
802    ///
803    /// Uses SIMD-accelerated linear scan:
804    /// 1. `find_first_ge_u32` on the contiguous L1 `last_doc` array
805    /// 2. Extract ≤`L1_INTERVAL` L0 `last_doc` values into a stack buffer → `find_first_ge_u32`
806    ///
807    /// Returns `None` if no block contains `target`.
808    pub fn seek_block(&self, target: DocId, from_block: usize) -> Option<usize> {
809        if from_block >= self.l0_count {
810            return None;
811        }
812
813        let from_l1 = from_block / L1_INTERVAL;
814
815        // SIMD scan L1 to find the group containing target
816        let l1_idx = if !self.l1_docs.is_empty() {
817            let idx = from_l1 + simd::find_first_ge_u32(&self.l1_docs[from_l1..], target);
818            if idx >= self.l1_docs.len() {
819                return None;
820            }
821            idx
822        } else {
823            return None;
824        };
825
826        // Extract L0 last_doc values within the group into a stack buffer for SIMD scan
827        let start = (l1_idx * L1_INTERVAL).max(from_block);
828        let end = ((l1_idx + 1) * L1_INTERVAL).min(self.l0_count);
829        let count = end - start;
830
831        let mut last_docs = [u32::MAX; L1_INTERVAL];
832        for (j, idx) in (start..end).enumerate() {
833            let (_, ld, _, _) = read_l0(&self.l0_bytes, idx);
834            last_docs[j] = ld;
835        }
836        let within = simd::find_first_ge_u32(&last_docs[..count], target);
837        let block_idx = start + within;
838
839        if block_idx < self.l0_count {
840            Some(block_idx)
841        } else {
842            None
843        }
844    }
845
846    /// Create an iterator with skip support
847    pub fn iterator(&self) -> BlockPostingIterator<'_> {
848        BlockPostingIterator::new(self)
849    }
850
851    /// Create an owned iterator that doesn't borrow self
852    pub fn into_iterator(self) -> BlockPostingIterator<'static> {
853        BlockPostingIterator::owned(self)
854    }
855}
856
857/// Iterator over block posting list with skip support
858/// Can be either borrowed or owned via Cow
859///
860/// Uses struct-of-arrays layout: separate Vec<u32> for doc_ids and term_freqs.
861/// This is more cache-friendly for SIMD seek (contiguous doc_ids) and halves
862/// memory vs the previous AoS + separate doc_ids approach.
863pub struct BlockPostingIterator<'a> {
864    block_list: std::borrow::Cow<'a, BlockPostingList>,
865    current_block: usize,
866    block_doc_ids: Vec<u32>,
867    block_tfs: Vec<u32>,
868    position_in_block: usize,
869    exhausted: bool,
870}
871
872impl<'a> BlockPostingIterator<'a> {
873    fn new(block_list: &'a BlockPostingList) -> Self {
874        let exhausted = block_list.l0_count == 0;
875        let mut iter = Self {
876            block_list: std::borrow::Cow::Borrowed(block_list),
877            current_block: 0,
878            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
879            block_tfs: Vec::with_capacity(BLOCK_SIZE),
880            position_in_block: 0,
881            exhausted,
882        };
883        if !iter.exhausted {
884            iter.load_block(0);
885        }
886        iter
887    }
888
889    fn owned(block_list: BlockPostingList) -> BlockPostingIterator<'static> {
890        let exhausted = block_list.l0_count == 0;
891        let mut iter = BlockPostingIterator {
892            block_list: std::borrow::Cow::Owned(block_list),
893            current_block: 0,
894            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
895            block_tfs: Vec::with_capacity(BLOCK_SIZE),
896            position_in_block: 0,
897            exhausted,
898        };
899        if !iter.exhausted {
900            iter.load_block(0);
901        }
902        iter
903    }
904
905    fn load_block(&mut self, block_idx: usize) {
906        if block_idx >= self.block_list.l0_count {
907            self.exhausted = true;
908            return;
909        }
910
911        self.current_block = block_idx;
912        self.position_in_block = 0;
913
914        self.block_list
915            .decode_block_into(block_idx, &mut self.block_doc_ids, &mut self.block_tfs);
916    }
917
918    pub fn doc(&self) -> DocId {
919        if self.exhausted {
920            TERMINATED
921        } else if self.position_in_block < self.block_doc_ids.len() {
922            self.block_doc_ids[self.position_in_block]
923        } else {
924            TERMINATED
925        }
926    }
927
928    pub fn term_freq(&self) -> u32 {
929        if self.exhausted || self.position_in_block >= self.block_tfs.len() {
930            0
931        } else {
932            self.block_tfs[self.position_in_block]
933        }
934    }
935
936    pub fn advance(&mut self) -> DocId {
937        if self.exhausted {
938            return TERMINATED;
939        }
940
941        self.position_in_block += 1;
942        if self.position_in_block >= self.block_doc_ids.len() {
943            self.load_block(self.current_block + 1);
944        }
945        self.doc()
946    }
947
948    pub fn seek(&mut self, target: DocId) -> DocId {
949        if self.exhausted {
950            return TERMINATED;
951        }
952
953        // SIMD-accelerated 2-level seek (forward from current block)
954        let block_idx = match self.block_list.seek_block(target, self.current_block) {
955            Some(idx) => idx,
956            None => {
957                self.exhausted = true;
958                return TERMINATED;
959            }
960        };
961
962        if block_idx != self.current_block {
963            self.load_block(block_idx);
964        }
965
966        // SIMD linear scan within block on cached doc_ids
967        let remaining = &self.block_doc_ids[self.position_in_block..];
968        let pos = crate::structures::simd::find_first_ge_u32(remaining, target);
969        self.position_in_block += pos;
970
971        if self.position_in_block >= self.block_doc_ids.len() {
972            self.load_block(self.current_block + 1);
973        }
974        self.doc()
975    }
976
977    /// Skip to the next block, returning the first doc_id in the new block
978    /// This is used for block-max pruning when the current block's
979    /// max score can't beat the threshold.
980    pub fn skip_to_next_block(&mut self) -> DocId {
981        if self.exhausted {
982            return TERMINATED;
983        }
984        self.load_block(self.current_block + 1);
985        self.doc()
986    }
987
988    /// Get the current block index
989    #[inline]
990    pub fn current_block_idx(&self) -> usize {
991        self.current_block
992    }
993
994    /// Get total number of blocks
995    #[inline]
996    pub fn num_blocks(&self) -> usize {
997        self.block_list.l0_count
998    }
999
1000    /// Get the current block's max term frequency for block-max pruning
1001    #[inline]
1002    pub fn current_block_max_tf(&self) -> u32 {
1003        if self.exhausted || self.current_block >= self.block_list.l0_count {
1004            0
1005        } else {
1006            let (_, _, _, max_weight) = self.block_list.read_l0_entry(self.current_block);
1007            max_weight as u32
1008        }
1009    }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014    use super::*;
1015
1016    #[test]
1017    fn test_posting_list_basic() {
1018        let mut list = PostingList::new();
1019        list.push(1, 2);
1020        list.push(5, 1);
1021        list.push(10, 3);
1022
1023        assert_eq!(list.len(), 3);
1024
1025        let mut iter = PostingListIterator::new(&list);
1026        assert_eq!(iter.doc(), 1);
1027        assert_eq!(iter.term_freq(), 2);
1028
1029        assert_eq!(iter.advance(), 5);
1030        assert_eq!(iter.term_freq(), 1);
1031
1032        assert_eq!(iter.advance(), 10);
1033        assert_eq!(iter.term_freq(), 3);
1034
1035        assert_eq!(iter.advance(), TERMINATED);
1036    }
1037
1038    #[test]
1039    fn test_posting_list_serialization() {
1040        let mut list = PostingList::new();
1041        for i in 0..100 {
1042            list.push(i * 3, (i % 5) + 1);
1043        }
1044
1045        let mut buffer = Vec::new();
1046        list.serialize(&mut buffer).unwrap();
1047
1048        let deserialized = PostingList::deserialize(&mut &buffer[..]).unwrap();
1049        assert_eq!(deserialized.len(), list.len());
1050
1051        for (a, b) in list.iter().zip(deserialized.iter()) {
1052            assert_eq!(a, b);
1053        }
1054    }
1055
1056    #[test]
1057    fn test_posting_list_seek() {
1058        let mut list = PostingList::new();
1059        for i in 0..100 {
1060            list.push(i * 2, 1);
1061        }
1062
1063        let mut iter = PostingListIterator::new(&list);
1064
1065        assert_eq!(iter.seek(50), 50);
1066        assert_eq!(iter.seek(51), 52);
1067        assert_eq!(iter.seek(200), TERMINATED);
1068    }
1069
1070    #[test]
1071    fn test_block_posting_list() {
1072        let mut list = PostingList::new();
1073        for i in 0..500 {
1074            list.push(i * 2, (i % 10) + 1);
1075        }
1076
1077        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
1078        assert_eq!(block_list.doc_count(), 500);
1079
1080        let mut iter = block_list.iterator();
1081        assert_eq!(iter.doc(), 0);
1082        assert_eq!(iter.term_freq(), 1);
1083
1084        // Test seek across blocks
1085        assert_eq!(iter.seek(500), 500);
1086        assert_eq!(iter.seek(998), 998);
1087        assert_eq!(iter.seek(1000), TERMINATED);
1088    }
1089
1090    #[test]
1091    fn test_block_posting_list_serialization() {
1092        let mut list = PostingList::new();
1093        for i in 0..300 {
1094            list.push(i * 3, i + 1);
1095        }
1096
1097        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
1098
1099        let mut buffer = Vec::new();
1100        block_list.serialize(&mut buffer).unwrap();
1101
1102        let deserialized = BlockPostingList::deserialize(&buffer[..]).unwrap();
1103        assert_eq!(deserialized.doc_count(), block_list.doc_count());
1104
1105        // Verify iteration produces same results
1106        let mut iter1 = block_list.iterator();
1107        let mut iter2 = deserialized.iterator();
1108
1109        while iter1.doc() != TERMINATED {
1110            assert_eq!(iter1.doc(), iter2.doc());
1111            assert_eq!(iter1.term_freq(), iter2.term_freq());
1112            iter1.advance();
1113            iter2.advance();
1114        }
1115        assert_eq!(iter2.doc(), TERMINATED);
1116    }
1117
1118    /// Helper: collect all (doc_id, tf) from a BlockPostingIterator
1119    fn collect_postings(bpl: &BlockPostingList) -> Vec<(u32, u32)> {
1120        let mut result = Vec::new();
1121        let mut it = bpl.iterator();
1122        while it.doc() != TERMINATED {
1123            result.push((it.doc(), it.term_freq()));
1124            it.advance();
1125        }
1126        result
1127    }
1128
1129    /// Helper: build a BlockPostingList from (doc_id, tf) pairs
1130    fn build_bpl(postings: &[(u32, u32)]) -> BlockPostingList {
1131        let mut pl = PostingList::new();
1132        for &(doc_id, tf) in postings {
1133            pl.push(doc_id, tf);
1134        }
1135        BlockPostingList::from_posting_list(&pl).unwrap()
1136    }
1137
1138    /// Helper: serialize a BlockPostingList to bytes
1139    fn serialize_bpl(bpl: &BlockPostingList) -> Vec<u8> {
1140        let mut buf = Vec::new();
1141        bpl.serialize(&mut buf).unwrap();
1142        buf
1143    }
1144
1145    #[test]
1146    fn test_concatenate_blocks_two_segments() {
1147        // Segment A: docs 0,2,4,...,198 (100 docs, tf=1..100)
1148        let a: Vec<(u32, u32)> = (0..100).map(|i| (i * 2, i + 1)).collect();
1149        let bpl_a = build_bpl(&a);
1150
1151        // Segment B: docs 0,3,6,...,297 (100 docs, tf=2..101)
1152        let b: Vec<(u32, u32)> = (0..100).map(|i| (i * 3, i + 2)).collect();
1153        let bpl_b = build_bpl(&b);
1154
1155        // Merge: segment B starts at doc_offset=200
1156        let merged =
1157            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 200)])
1158                .unwrap();
1159
1160        assert_eq!(merged.doc_count(), 200);
1161
1162        let postings = collect_postings(&merged);
1163        assert_eq!(postings.len(), 200);
1164
1165        // First 100 from A (unchanged)
1166        for (i, p) in postings.iter().enumerate().take(100) {
1167            assert_eq!(*p, (i as u32 * 2, i as u32 + 1));
1168        }
1169        // Next 100 from B (doc_id += 200)
1170        for i in 0..100 {
1171            assert_eq!(postings[100 + i], (i as u32 * 3 + 200, i as u32 + 2));
1172        }
1173    }
1174
1175    #[test]
1176    fn test_concatenate_streaming_matches_blocks() {
1177        // Build 3 segments with different doc distributions
1178        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
1179        let seg_b: Vec<(u32, u32)> = (0..180).map(|i| (i * 5, (i % 3) + 1)).collect();
1180        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
1181
1182        let bpl_a = build_bpl(&seg_a);
1183        let bpl_b = build_bpl(&seg_b);
1184        let bpl_c = build_bpl(&seg_c);
1185
1186        let offset_b = 1000u32;
1187        let offset_c = 2000u32;
1188
1189        // Method 1: concatenate_blocks (in-memory reference)
1190        let ref_merged = BlockPostingList::concatenate_blocks(&[
1191            (bpl_a.clone(), 0),
1192            (bpl_b.clone(), offset_b),
1193            (bpl_c.clone(), offset_c),
1194        ])
1195        .unwrap();
1196        let mut ref_buf = Vec::new();
1197        ref_merged.serialize(&mut ref_buf).unwrap();
1198
1199        // Method 2: concatenate_streaming (footer-based, writes to output)
1200        let bytes_a = serialize_bpl(&bpl_a);
1201        let bytes_b = serialize_bpl(&bpl_b);
1202        let bytes_c = serialize_bpl(&bpl_c);
1203
1204        let sources: Vec<(&[u8], u32)> =
1205            vec![(&bytes_a, 0), (&bytes_b, offset_b), (&bytes_c, offset_c)];
1206        let mut stream_buf = Vec::new();
1207        let (doc_count, bytes_written) =
1208            BlockPostingList::concatenate_streaming(&sources, &mut stream_buf).unwrap();
1209
1210        assert_eq!(doc_count, 520); // 250 + 180 + 90
1211        assert_eq!(bytes_written, stream_buf.len());
1212
1213        // Deserialize both and verify identical postings
1214        let ref_postings = collect_postings(&BlockPostingList::deserialize(&ref_buf).unwrap());
1215        let stream_postings =
1216            collect_postings(&BlockPostingList::deserialize(&stream_buf).unwrap());
1217
1218        assert_eq!(ref_postings.len(), stream_postings.len());
1219        for (i, (r, s)) in ref_postings.iter().zip(stream_postings.iter()).enumerate() {
1220            assert_eq!(r, s, "mismatch at posting {}", i);
1221        }
1222    }
1223
1224    #[test]
1225    fn test_concatenate_streaming_short_source_returns_corruption() {
1226        // A source shorter than the 24-byte footer (e.g. a corrupt TermInfo
1227        // (offset, len) pointing at truncated bytes) must fail loudly.
1228        // Silently skipping it pairs every later source with the wrong
1229        // metadata (metas[i] vs sources[i]) — panicking or emitting garbage.
1230        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
1231        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
1232        let bytes_a = serialize_bpl(&build_bpl(&seg_a));
1233        let bytes_c = serialize_bpl(&build_bpl(&seg_c));
1234        let short = vec![0u8; FOOTER_SIZE - 1]; // corrupt: shorter than footer
1235
1236        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&short, 1000), (&bytes_c, 2000)];
1237        let mut out = Vec::new();
1238        let result = BlockPostingList::concatenate_streaming(&sources, &mut out);
1239        assert!(
1240            matches!(result, Err(crate::Error::Corruption(_))),
1241            "short/corrupt source must be a Corruption error, not silently skipped: {:?}",
1242            result.map(|r| r.0)
1243        );
1244    }
1245
1246    #[test]
1247    fn test_multi_round_merge() {
1248        // Simulate 3 rounds of merging (like tiered merge policy)
1249        //
1250        // Round 0: 4 small segments built independently
1251        // Round 1: merge pairs → 2 medium segments
1252        // Round 2: merge those → 1 large segment
1253
1254        let segments: Vec<Vec<(u32, u32)>> = (0..4)
1255            .map(|seg| (0..200).map(|i| (i * 3, (i + seg * 7) % 10 + 1)).collect())
1256            .collect();
1257
1258        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
1259        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
1260
1261        // Round 1: merge seg0+seg1 (offset=0,600), seg2+seg3 (offset=0,600)
1262        let mut merged_01 = Vec::new();
1263        let sources_01: Vec<(&[u8], u32)> = vec![(&serialized[0], 0), (&serialized[1], 600)];
1264        let (dc_01, _) =
1265            BlockPostingList::concatenate_streaming(&sources_01, &mut merged_01).unwrap();
1266        assert_eq!(dc_01, 400);
1267
1268        let mut merged_23 = Vec::new();
1269        let sources_23: Vec<(&[u8], u32)> = vec![(&serialized[2], 0), (&serialized[3], 600)];
1270        let (dc_23, _) =
1271            BlockPostingList::concatenate_streaming(&sources_23, &mut merged_23).unwrap();
1272        assert_eq!(dc_23, 400);
1273
1274        // Round 2: merge the two intermediate results (offset=0, 1200)
1275        let mut final_merged = Vec::new();
1276        let sources_final: Vec<(&[u8], u32)> = vec![(&merged_01, 0), (&merged_23, 1200)];
1277        let (dc_final, _) =
1278            BlockPostingList::concatenate_streaming(&sources_final, &mut final_merged).unwrap();
1279        assert_eq!(dc_final, 800);
1280
1281        // Verify final result has all 800 postings with correct doc_ids
1282        let final_bpl = BlockPostingList::deserialize(&final_merged).unwrap();
1283        let postings = collect_postings(&final_bpl);
1284        assert_eq!(postings.len(), 800);
1285
1286        // Verify doc_id ordering (must be monotonically non-decreasing within segments,
1287        // and segment boundaries at 0, 600, 1200, 1800)
1288        // Seg0: 0..597, Seg1: 600..1197, Seg2: 1200..1797, Seg3: 1800..2397
1289        assert_eq!(postings[0].0, 0); // first doc of seg0
1290        assert_eq!(postings[199].0, 597); // last doc of seg0 (199*3)
1291        assert_eq!(postings[200].0, 600); // first doc of seg1 (0+600)
1292        assert_eq!(postings[399].0, 1197); // last doc of seg1 (597+600)
1293        assert_eq!(postings[400].0, 1200); // first doc of seg2
1294        assert_eq!(postings[799].0, 2397); // last doc of seg3
1295
1296        // Verify TFs preserved through two rounds of merging
1297        // Creation formula: tf = (i + seg * 7) % 10 + 1
1298        for seg in 0u32..4 {
1299            for i in 0u32..200 {
1300                let idx = (seg * 200 + i) as usize;
1301                assert_eq!(
1302                    postings[idx].1,
1303                    (i + seg * 7) % 10 + 1,
1304                    "seg{} tf[{}]",
1305                    seg,
1306                    i
1307                );
1308            }
1309        }
1310
1311        // Verify seek works on final merged result
1312        let mut it = final_bpl.iterator();
1313        assert_eq!(it.seek(600), 600);
1314        assert_eq!(it.seek(1200), 1200);
1315        assert_eq!(it.seek(2397), 2397);
1316        assert_eq!(it.seek(2398), TERMINATED);
1317    }
1318
1319    #[test]
1320    fn test_large_scale_merge() {
1321        // 5 segments × 2000 docs each = 10,000 total docs
1322        // Each segment has 16 blocks (2000/128 = 15.6 → 16 blocks)
1323        let num_segments = 5;
1324        let docs_per_segment = 2000;
1325        let docs_gap = 3; // doc_ids: 0, 3, 6, ...
1326
1327        let segments: Vec<Vec<(u32, u32)>> = (0..num_segments)
1328            .map(|seg| {
1329                (0..docs_per_segment)
1330                    .map(|i| (i as u32 * docs_gap, (i as u32 + seg as u32) % 20 + 1))
1331                    .collect()
1332            })
1333            .collect();
1334
1335        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
1336
1337        // Verify each segment has multiple blocks
1338        for bpl in &bpls {
1339            assert!(
1340                bpl.num_blocks() >= 15,
1341                "expected >=15 blocks, got {}",
1342                bpl.num_blocks()
1343            );
1344        }
1345
1346        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
1347
1348        // Compute offsets: each segment occupies max_doc+1 doc_id space
1349        let max_doc_per_seg = (docs_per_segment as u32 - 1) * docs_gap;
1350        let offsets: Vec<u32> = (0..num_segments)
1351            .map(|i| i as u32 * (max_doc_per_seg + 1))
1352            .collect();
1353
1354        let sources: Vec<(&[u8], u32)> = serialized
1355            .iter()
1356            .zip(offsets.iter())
1357            .map(|(b, o)| (b.as_slice(), *o))
1358            .collect();
1359
1360        let mut merged = Vec::new();
1361        let (doc_count, _) =
1362            BlockPostingList::concatenate_streaming(&sources, &mut merged).unwrap();
1363        assert_eq!(doc_count, (num_segments * docs_per_segment) as u32);
1364
1365        // Deserialize and verify
1366        let merged_bpl = BlockPostingList::deserialize(&merged).unwrap();
1367        let postings = collect_postings(&merged_bpl);
1368        assert_eq!(postings.len(), num_segments * docs_per_segment);
1369
1370        // Verify all doc_ids are strictly monotonically increasing across segment boundaries
1371        for i in 1..postings.len() {
1372            assert!(
1373                postings[i].0 > postings[i - 1].0 || (i % docs_per_segment == 0), // new segment can have lower absolute ID
1374                "doc_id not increasing at {}: {} vs {}",
1375                i,
1376                postings[i - 1].0,
1377                postings[i].0,
1378            );
1379        }
1380
1381        // Verify seek across all block boundaries
1382        let mut it = merged_bpl.iterator();
1383        for (seg, &expected_first) in offsets.iter().enumerate() {
1384            assert_eq!(
1385                it.seek(expected_first),
1386                expected_first,
1387                "seek to segment {} start",
1388                seg
1389            );
1390        }
1391    }
1392
1393    #[test]
1394    fn test_merge_edge_cases() {
1395        // Single doc per segment
1396        let bpl_a = build_bpl(&[(0, 5)]);
1397        let bpl_b = build_bpl(&[(0, 3)]);
1398
1399        let merged =
1400            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 1)])
1401                .unwrap();
1402        assert_eq!(merged.doc_count(), 2);
1403        let p = collect_postings(&merged);
1404        assert_eq!(p, vec![(0, 5), (1, 3)]);
1405
1406        // Exactly BLOCK_SIZE docs (single full block)
1407        let exact_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32).map(|i| (i, i % 5 + 1)).collect();
1408        let bpl_exact = build_bpl(&exact_block);
1409        assert_eq!(bpl_exact.num_blocks(), 1);
1410
1411        let bytes = serialize_bpl(&bpl_exact);
1412        let mut out = Vec::new();
1413        let sources: Vec<(&[u8], u32)> = vec![(&bytes, 0), (&bytes, BLOCK_SIZE as u32)];
1414        let (dc, _) = BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
1415        assert_eq!(dc, BLOCK_SIZE as u32 * 2);
1416
1417        let merged = BlockPostingList::deserialize(&out).unwrap();
1418        let postings = collect_postings(&merged);
1419        assert_eq!(postings.len(), BLOCK_SIZE * 2);
1420        // Second segment's docs offset by BLOCK_SIZE
1421        assert_eq!(postings[BLOCK_SIZE].0, BLOCK_SIZE as u32);
1422
1423        // BLOCK_SIZE + 1 docs (two blocks: 128 + 1)
1424        let over_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32 + 1).map(|i| (i * 2, 1)).collect();
1425        let bpl_over = build_bpl(&over_block);
1426        assert_eq!(bpl_over.num_blocks(), 2);
1427    }
1428
1429    #[test]
1430    fn test_streaming_roundtrip_single_source() {
1431        // Streaming merge with a single source should produce equivalent output to serialize
1432        let docs: Vec<(u32, u32)> = (0..500).map(|i| (i * 7, i % 15 + 1)).collect();
1433        let bpl = build_bpl(&docs);
1434        let direct = serialize_bpl(&bpl);
1435
1436        let sources: Vec<(&[u8], u32)> = vec![(&direct, 0)];
1437        let mut streamed = Vec::new();
1438        BlockPostingList::concatenate_streaming(&sources, &mut streamed).unwrap();
1439
1440        // Both should deserialize to identical postings
1441        let p1 = collect_postings(&BlockPostingList::deserialize(&direct).unwrap());
1442        let p2 = collect_postings(&BlockPostingList::deserialize(&streamed).unwrap());
1443        assert_eq!(p1, p2);
1444    }
1445
1446    #[test]
1447    fn test_max_tf_preserved_through_merge() {
1448        // Segment A: max_tf = 50
1449        let mut a = Vec::new();
1450        for i in 0..200 {
1451            a.push((i * 2, if i == 100 { 50 } else { 1 }));
1452        }
1453        let bpl_a = build_bpl(&a);
1454        assert_eq!(bpl_a.max_tf(), 50);
1455
1456        // Segment B: max_tf = 30
1457        let mut b = Vec::new();
1458        for i in 0..200 {
1459            b.push((i * 2, if i == 50 { 30 } else { 2 }));
1460        }
1461        let bpl_b = build_bpl(&b);
1462        assert_eq!(bpl_b.max_tf(), 30);
1463
1464        // After merge, max_tf should be max(50, 30) = 50
1465        let bytes_a = serialize_bpl(&bpl_a);
1466        let bytes_b = serialize_bpl(&bpl_b);
1467        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 1000)];
1468        let mut out = Vec::new();
1469        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
1470
1471        let merged = BlockPostingList::deserialize(&out).unwrap();
1472        assert_eq!(merged.max_tf(), 50);
1473        assert_eq!(merged.doc_count(), 400);
1474    }
1475
1476    // ── 2-level skip list format tests ──────────────────────────────────
1477
1478    #[test]
1479    fn test_l0_l1_counts() {
1480        // 1 block (< L1_INTERVAL) → 1 L1 entry (partial group)
1481        let bpl = build_bpl(&(0..50u32).map(|i| (i, 1)).collect::<Vec<_>>());
1482        assert_eq!(bpl.num_blocks(), 1);
1483        assert_eq!(bpl.l1_docs.len(), 1);
1484
1485        // Exactly L1_INTERVAL blocks → 1 L1 entry (full group)
1486        let n = BLOCK_SIZE * L1_INTERVAL;
1487        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
1488        assert_eq!(bpl.num_blocks(), L1_INTERVAL);
1489        assert_eq!(bpl.l1_docs.len(), 1);
1490
1491        // L1_INTERVAL + 1 blocks → 2 L1 entries
1492        let n = BLOCK_SIZE * L1_INTERVAL + 1;
1493        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
1494        assert_eq!(bpl.num_blocks(), L1_INTERVAL + 1);
1495        assert_eq!(bpl.l1_docs.len(), 2);
1496
1497        // 3 × L1_INTERVAL blocks → 3 L1 entries (all full groups)
1498        let n = BLOCK_SIZE * L1_INTERVAL * 3;
1499        let bpl = build_bpl(&(0..n as u32).map(|i| (i, 1)).collect::<Vec<_>>());
1500        assert_eq!(bpl.num_blocks(), L1_INTERVAL * 3);
1501        assert_eq!(bpl.l1_docs.len(), 3);
1502    }
1503
1504    #[test]
1505    fn test_l1_last_doc_values() {
1506        // 20 blocks: 2 full L1 groups (8+8) + 1 partial (4) → 3 L1 entries
1507        let n = BLOCK_SIZE * 20;
1508        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
1509        let bpl = build_bpl(&docs);
1510        assert_eq!(bpl.num_blocks(), 20);
1511        assert_eq!(bpl.l1_docs.len(), 3); // ceil(20/8) = 3
1512
1513        // L1[0] = last_doc of block 7 (end of first group)
1514        let expected_l1_0 = bpl.block_last_doc(7).unwrap();
1515        assert_eq!(bpl.l1_docs[0], expected_l1_0);
1516
1517        // L1[1] = last_doc of block 15 (end of second group)
1518        let expected_l1_1 = bpl.block_last_doc(15).unwrap();
1519        assert_eq!(bpl.l1_docs[1], expected_l1_1);
1520
1521        // L1[2] = last_doc of block 19 (end of partial group)
1522        let expected_l1_2 = bpl.block_last_doc(19).unwrap();
1523        assert_eq!(bpl.l1_docs[2], expected_l1_2);
1524    }
1525
1526    #[test]
1527    fn test_seek_block_basic() {
1528        // 20 blocks spanning large doc ID range
1529        let n = BLOCK_SIZE * 20;
1530        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 10, 1)).collect();
1531        let bpl = build_bpl(&docs);
1532
1533        // Seek to doc 0 → block 0
1534        assert_eq!(bpl.seek_block(0, 0), Some(0));
1535
1536        // Seek to the first doc of each block
1537        for blk in 0..20 {
1538            let first = bpl.block_first_doc(blk).unwrap();
1539            assert_eq!(
1540                bpl.seek_block(first, 0),
1541                Some(blk),
1542                "seek to block {} first_doc",
1543                blk
1544            );
1545        }
1546
1547        // Seek to the last doc of each block
1548        for blk in 0..20 {
1549            let last = bpl.block_last_doc(blk).unwrap();
1550            assert_eq!(
1551                bpl.seek_block(last, 0),
1552                Some(blk),
1553                "seek to block {} last_doc",
1554                blk
1555            );
1556        }
1557
1558        // Seek past all docs
1559        let max_doc = bpl.block_last_doc(19).unwrap();
1560        assert_eq!(bpl.seek_block(max_doc + 1, 0), None);
1561
1562        // Seek with from_block > 0 (skip early blocks)
1563        let mid_doc = bpl.block_first_doc(10).unwrap();
1564        assert_eq!(bpl.seek_block(mid_doc, 10), Some(10));
1565        assert_eq!(
1566            bpl.seek_block(mid_doc, 11),
1567            Some(11).or(bpl.seek_block(mid_doc, 11))
1568        );
1569    }
1570
1571    #[test]
1572    fn test_seek_block_across_l1_boundaries() {
1573        // 24 blocks = 3 L1 groups of 8
1574        let n = BLOCK_SIZE * 24;
1575        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 5, 1)).collect();
1576        let bpl = build_bpl(&docs);
1577        assert_eq!(bpl.l1_docs.len(), 3);
1578
1579        // Seek into each L1 group
1580        for group in 0..3 {
1581            let blk = group * L1_INTERVAL;
1582            let target = bpl.block_first_doc(blk).unwrap();
1583            assert_eq!(
1584                bpl.seek_block(target, 0),
1585                Some(blk),
1586                "seek to group {} block {}",
1587                group,
1588                blk
1589            );
1590        }
1591
1592        // Seek to doc in the middle of group 2 (block 20)
1593        let target = bpl.block_first_doc(20).unwrap() + 1;
1594        assert_eq!(bpl.seek_block(target, 0), Some(20));
1595    }
1596
1597    #[test]
1598    fn test_block_data_size_helper() {
1599        // Build a posting list and verify block_data_size matches actual block sizes
1600        let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 7, (i % 20) + 1)).collect();
1601        let bpl = build_bpl(&docs);
1602
1603        for blk in 0..bpl.num_blocks() {
1604            let (_, _, offset, _) = bpl.read_l0_entry(blk);
1605            let computed_size = block_data_size(&bpl.stream, offset as usize);
1606
1607            // Verify: next block's offset - this block's offset should equal computed_size
1608            // (for all but last block)
1609            if blk + 1 < bpl.num_blocks() {
1610                let (_, _, next_offset, _) = bpl.read_l0_entry(blk + 1);
1611                assert_eq!(
1612                    computed_size,
1613                    (next_offset - offset) as usize,
1614                    "block_data_size mismatch at block {}",
1615                    blk
1616                );
1617            } else {
1618                // Last block: offset + size should equal stream length
1619                assert_eq!(
1620                    offset as usize + computed_size,
1621                    bpl.stream.len(),
1622                    "last block size mismatch"
1623                );
1624            }
1625        }
1626    }
1627
1628    #[test]
1629    fn test_l0_entry_roundtrip() {
1630        // Verify L0 entries survive serialize → deserialize
1631        let docs: Vec<(u32, u32)> = (0..1000u32).map(|i| (i * 3, (i % 10) + 1)).collect();
1632        let bpl = build_bpl(&docs);
1633
1634        let bytes = serialize_bpl(&bpl);
1635        let bpl2 = BlockPostingList::deserialize(&bytes).unwrap();
1636
1637        assert_eq!(bpl.num_blocks(), bpl2.num_blocks());
1638        for blk in 0..bpl.num_blocks() {
1639            assert_eq!(
1640                bpl.read_l0_entry(blk),
1641                bpl2.read_l0_entry(blk),
1642                "L0 entry mismatch at block {}",
1643                blk
1644            );
1645        }
1646
1647        // Verify L1 docs match
1648        assert_eq!(bpl.l1_docs, bpl2.l1_docs);
1649    }
1650
1651    #[test]
1652    fn test_zero_copy_deserialize_matches() {
1653        let docs: Vec<(u32, u32)> = (0..2000u32).map(|i| (i * 2, (i % 5) + 1)).collect();
1654        let bpl = build_bpl(&docs);
1655        let bytes = serialize_bpl(&bpl);
1656
1657        let copied = BlockPostingList::deserialize(&bytes).unwrap();
1658        let zero_copy =
1659            BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
1660
1661        // Same structure
1662        assert_eq!(copied.l0_count, zero_copy.l0_count);
1663        assert_eq!(copied.l1_docs, zero_copy.l1_docs);
1664        assert_eq!(copied.doc_count, zero_copy.doc_count);
1665        assert_eq!(copied.max_tf, zero_copy.max_tf);
1666
1667        // Same iteration
1668        let p1 = collect_postings(&copied);
1669        let p2 = collect_postings(&zero_copy);
1670        assert_eq!(p1, p2);
1671    }
1672
1673    #[test]
1674    fn test_l1_preserved_through_streaming_merge() {
1675        // Merge 3 segments, verify L1 is correctly rebuilt
1676        let seg_a = build_bpl(&(0..1000u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
1677        let seg_b = build_bpl(&(0..800u32).map(|i| (i * 3, 2)).collect::<Vec<_>>());
1678        let seg_c = build_bpl(&(0..500u32).map(|i| (i * 5, 3)).collect::<Vec<_>>());
1679
1680        let bytes_a = serialize_bpl(&seg_a);
1681        let bytes_b = serialize_bpl(&seg_b);
1682        let bytes_c = serialize_bpl(&seg_c);
1683
1684        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 10000), (&bytes_c, 20000)];
1685        let mut out = Vec::new();
1686        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
1687
1688        let merged = BlockPostingList::deserialize(&out).unwrap();
1689        let expected_l1_count = merged.num_blocks().div_ceil(L1_INTERVAL);
1690        assert_eq!(merged.l1_docs.len(), expected_l1_count);
1691
1692        // Verify L1 values are correct
1693        for (i, &l1_doc) in merged.l1_docs.iter().enumerate() {
1694            let last_block_in_group = ((i + 1) * L1_INTERVAL - 1).min(merged.num_blocks() - 1);
1695            let expected = merged.block_last_doc(last_block_in_group).unwrap();
1696            assert_eq!(l1_doc, expected, "L1[{}] mismatch", i);
1697        }
1698
1699        // Verify seek_block works on merged result
1700        for blk in 0..merged.num_blocks() {
1701            let first = merged.block_first_doc(blk).unwrap();
1702            assert_eq!(merged.seek_block(first, 0), Some(blk));
1703        }
1704    }
1705
1706    #[test]
1707    fn test_seek_block_single_block() {
1708        // Edge case: single block (< L1_INTERVAL)
1709        let bpl = build_bpl(&[(0, 1), (10, 2), (20, 3)]);
1710        assert_eq!(bpl.num_blocks(), 1);
1711        assert_eq!(bpl.l1_docs.len(), 1);
1712
1713        assert_eq!(bpl.seek_block(0, 0), Some(0));
1714        assert_eq!(bpl.seek_block(10, 0), Some(0));
1715        assert_eq!(bpl.seek_block(20, 0), Some(0));
1716        assert_eq!(bpl.seek_block(21, 0), None);
1717    }
1718
1719    #[test]
1720    fn test_footer_size() {
1721        // Verify serialized size = stream + L0 + L1 + FOOTER_SIZE
1722        let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 2, 1)).collect();
1723        let bpl = build_bpl(&docs);
1724        let bytes = serialize_bpl(&bpl);
1725
1726        let expected =
1727            bpl.stream.len() + bpl.l0_count * L0_SIZE + bpl.l1_docs.len() * L1_SIZE + FOOTER_SIZE;
1728        assert_eq!(bytes.len(), expected);
1729    }
1730
1731    #[test]
1732    fn test_seek_block_from_block_skips_earlier() {
1733        // 16 blocks: seek with from_block should skip earlier blocks
1734        let n = BLOCK_SIZE * 16;
1735        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
1736        let bpl = build_bpl(&docs);
1737
1738        // Target is in block 5, but from_block=8 → should find block >= 8
1739        let target_in_5 = bpl.block_first_doc(5).unwrap() + 1;
1740        // from_block=8 means we only look at blocks 8+
1741        // target_in_5 < last_doc of block 8, so seek_block(target, 8) should return 8
1742        let result = bpl.seek_block(target_in_5, 8);
1743        assert!(result.is_some());
1744        assert!(result.unwrap() >= 8);
1745    }
1746}