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/// Legacy 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/// Current footer: the legacy footer followed by `total_positions(8) +
191/// flags(4) + min_len(4) + magic(4)`. A list ends with the magic iff it has
192/// the extended footer; a legacy footer ends with `max_tf`, which the u16
193/// term frequency of the builder keeps far below the magic, so both forms
194/// remain readable.
195const FOOTER_V2_SIZE: usize = FOOTER_SIZE + 20;
196
197/// "BPL2" little-endian.
198const FOOTER_MAGIC: u32 = 0x324C_5042;
199
200/// Footer flag: a `u64` position cursor per L0 block follows the L1 entries.
201const FLAG_POS_CURSORS: u32 = 1;
202
203/// Footer flag: the fourth L0 word packs `max_tf` (low 16 bits) and the
204/// block's minimum scoring-unit length (high 16 bits) instead of an `f32`
205/// max tf, so a block bound can use real length normalisation.
206const FLAG_LEN_BOUNDS: u32 = 2;
207
208/// Footer flag: a packed `(max_tf, min_len)` word per L1 group follows the
209/// L1 `last_doc` entries (superblock bounds: the maximum and minimum over
210/// the group's blocks), so an executor can skip eight blocks at once.
211const FLAG_L1_BOUNDS: u32 = 4;
212
213/// Superblock bounds derived from packed L0 words: per `L1_INTERVAL` group
214/// the maximum `max_tf` and minimum `min_len` of its blocks.
215fn group_bounds_from_l0(l0: &[u8], l0_count: usize) -> Vec<u32> {
216    let mut groups = Vec::with_capacity(l0_count.div_ceil(L1_INTERVAL));
217    let mut idx = 0;
218    while idx < l0_count {
219        let end = (idx + L1_INTERVAL).min(l0_count);
220        let mut max_tf = 0u32;
221        let mut min_len = u32::MAX;
222        for block in idx..end {
223            let (_, _, _, word) = read_l0(l0, block);
224            let (tf, len) = unpack_bounds(word, true);
225            max_tf = max_tf.max(tf);
226            min_len = min_len.min(len.unwrap_or(1));
227        }
228        groups.push(pack_bounds(max_tf, min_len));
229        idx = end;
230    }
231    groups
232}
233
234/// Pack block bounds into the fourth L0 word (both saturate at u16).
235#[inline]
236fn pack_bounds(max_tf: u32, min_len: u32) -> u32 {
237    max_tf.min(u16::MAX as u32) | (min_len.min(u16::MAX as u32) << 16)
238}
239
240/// Unpack the fourth L0 word: `(max_tf, min_len)`; `min_len` is `None` for
241/// legacy lists whose word is an `f32` max tf.
242#[inline]
243fn unpack_bounds(word: u32, packed: bool) -> (u32, Option<u32>) {
244    if packed {
245        (word & 0xFFFF, Some(word >> 16))
246    } else {
247        (f32::from_bits(word) as u32, None)
248    }
249}
250
251/// Size of one position cursor (`u64`: values before the block in the
252/// term's position stream).
253const CURSOR_SIZE: usize = 8;
254
255/// Parsed footer of either format plus the derived section layout.
256struct Footer {
257    stream_len: usize,
258    l0_count: usize,
259    l1_count: usize,
260    doc_count: u32,
261    max_tf: u32,
262    total_positions: u64,
263    has_cursors: bool,
264    len_bounds: bool,
265    l1_bounds: bool,
266    min_len: u32,
267}
268
269impl Footer {
270    fn parse(raw: &[u8]) -> io::Result<Self> {
271        if raw.len() < FOOTER_SIZE {
272            return Err(io::Error::new(
273                io::ErrorKind::InvalidData,
274                "posting data too short",
275            ));
276        }
277        let extended = raw.len() >= FOOTER_V2_SIZE
278            && u32::from_le_bytes(raw[raw.len() - 4..].try_into().unwrap()) == FOOTER_MAGIC;
279        let f = raw.len()
280            - if extended {
281                FOOTER_V2_SIZE
282            } else {
283                FOOTER_SIZE
284            };
285        let stream_len = u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()) as usize;
286        let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
287        let l1_count = u32::from_le_bytes(raw[f + 12..f + 16].try_into().unwrap()) as usize;
288        let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
289        let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
290        let (total_positions, flags, min_len) = if extended {
291            let total = u64::from_le_bytes(raw[f + 24..f + 32].try_into().unwrap());
292            let flags = u32::from_le_bytes(raw[f + 32..f + 36].try_into().unwrap());
293            let min_len = u32::from_le_bytes(raw[f + 36..f + 40].try_into().unwrap());
294            (total, flags, min_len)
295        } else {
296            (0, 0, 0)
297        };
298        let footer = Self {
299            stream_len,
300            l0_count,
301            l1_count,
302            doc_count,
303            max_tf,
304            total_positions,
305            has_cursors: flags & FLAG_POS_CURSORS != 0,
306            len_bounds: flags & FLAG_LEN_BOUNDS != 0,
307            l1_bounds: flags & FLAG_L1_BOUNDS != 0,
308            min_len,
309        };
310        if footer.cursors_end() > f {
311            return Err(io::Error::new(
312                io::ErrorKind::InvalidData,
313                "posting list sections exceed the footer offset",
314            ));
315        }
316        Ok(footer)
317    }
318
319    fn l0_start(&self) -> usize {
320        self.stream_len
321    }
322    fn l0_end(&self) -> usize {
323        self.l0_start() + self.l0_count * L0_SIZE
324    }
325    fn l1_end(&self) -> usize {
326        self.l0_end() + self.l1_count * L1_SIZE
327    }
328    fn l1_bounds_end(&self) -> usize {
329        self.l1_end() + if self.l1_bounds { self.l1_count * 4 } else { 0 }
330    }
331    fn cursors_end(&self) -> usize {
332        self.l1_bounds_end()
333            + if self.has_cursors {
334                self.l0_count * CURSOR_SIZE
335            } else {
336                0
337            }
338    }
339}
340
341/// Read a compact L0 entry from raw bytes at the given index: `(first_doc,
342/// last_doc, offset, bounds word)`. The bounds word is packed `(max_tf,
343/// min_len)` for current lists and an `f32` max tf for legacy ones; see
344/// [`unpack_bounds`].
345///
346/// Uses a single bounds check (`[..L0_SIZE]`) instead of 4× `try_into().unwrap()`.
347#[inline]
348fn read_l0(bytes: &[u8], idx: usize) -> (u32, u32, u32, u32) {
349    let b = &bytes[idx * L0_SIZE..][..L0_SIZE];
350    let first_doc = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
351    let last_doc = u32::from_le_bytes([b[4], b[5], b[6], b[7]]);
352    let offset = u32::from_le_bytes([b[8], b[9], b[10], b[11]]);
353    let bounds = u32::from_le_bytes([b[12], b[13], b[14], b[15]]);
354    (first_doc, last_doc, offset, bounds)
355}
356
357/// Write a compact L0 entry.
358#[inline]
359fn write_l0(buf: &mut Vec<u8>, first_doc: u32, last_doc: u32, offset: u32, bounds: u32) {
360    buf.extend_from_slice(&first_doc.to_le_bytes());
361    buf.extend_from_slice(&last_doc.to_le_bytes());
362    buf.extend_from_slice(&offset.to_le_bytes());
363    buf.extend_from_slice(&bounds.to_le_bytes());
364}
365
366/// Compute block data size from the 8-byte header at `stream[pos..]`.
367///
368/// Header: `[count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]`
369/// Data size = 8 + (count-1) × bytes_per_value(doc_id_bits) + count × bytes_per_value(tf_bits)
370#[inline]
371fn block_data_size(stream: &[u8], pos: usize) -> usize {
372    let count = u16::from_le_bytes(stream[pos..pos + 2].try_into().unwrap()) as usize;
373    let doc_rounded = simd::RoundedBitWidth::from_u8(stream[pos + 6]);
374    let tf_rounded = simd::RoundedBitWidth::from_u8(stream[pos + 7]);
375    let delta_bytes = if count > 1 {
376        (count - 1) * doc_rounded.bytes_per_value()
377    } else {
378        0
379    };
380    8 + delta_bytes + count * tf_rounded.bytes_per_value()
381}
382
383#[derive(Debug, Clone)]
384pub struct BlockPostingList {
385    /// Block data stream (packed blocks laid out sequentially).
386    stream: OwnedBytes,
387    /// Level-0 skip entries: `(first_doc, last_doc, offset, max_weight)` × `l0_count`.
388    /// 16 bytes per entry. Supports O(1) random access by block index.
389    l0_bytes: OwnedBytes,
390    /// Number of blocks (= number of L0 entries).
391    l0_count: usize,
392    /// Level-1 skip `last_doc` values — one per `L1_INTERVAL` blocks.
393    /// Stored as `Vec<u32>` for direct SIMD-accelerated `find_first_ge_u32`.
394    l1_docs: Vec<u32>,
395    /// Packed `(max_tf, min_len)` per L1 group (superblock bounds); empty
396    /// for legacy lists.
397    l1_bounds: Vec<u32>,
398    /// Total posting count.
399    doc_count: u32,
400    /// Max TF across all blocks.
401    max_tf: u32,
402    /// Per-block position cursors (`u64` × `l0_count`): number of values in
403    /// the term's position stream before the block. `None` for terms
404    /// without positions and for legacy lists.
405    pos_cursors: Option<OwnedBytes>,
406    /// Sum of term frequencies (= values in the position stream) when
407    /// cursors are present.
408    total_positions: u64,
409    /// Whether L0 bounds words are packed `(max_tf, min_len)`.
410    len_bounds: bool,
411    /// Minimum scoring-unit length over the whole list (with `len_bounds`).
412    min_len: u32,
413}
414
415impl BlockPostingList {
416    /// Read L0 entry by block index. Returns `(first_doc, last_doc, offset, bounds word)`.
417    #[inline]
418    fn read_l0_entry(&self, idx: usize) -> (u32, u32, u32, u32) {
419        read_l0(&self.l0_bytes, idx)
420    }
421
422    /// Build from a posting list.
423    ///
424    /// Block format (8-byte header + packed arrays):
425    /// ```text
426    /// [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
427    /// [packed doc_id deltas: (count-1) × bytes_per_value(doc_id_bits)]
428    /// [packed tfs: count × bytes_per_value(tf_bits)]
429    /// ```
430    pub fn from_posting_list(list: &PostingList) -> io::Result<Self> {
431        Self::build(list, false, None)
432    }
433
434    /// Like [`Self::from_posting_list`], for a term whose positions are
435    /// stored as a v2 stream: every block records how many positions precede
436    /// it (the cumulative term frequency), so a reader can address the
437    /// stream from the doc postings alone.
438    pub fn from_posting_list_with_positions(list: &PostingList) -> io::Result<Self> {
439        Self::build(list, true, None)
440    }
441
442    /// Build with position cursors on demand and, when `length_of` is given,
443    /// the minimum scoring-unit length per block (and over the list) so
444    /// MaxScore bounds use real length normalisation. Without lengths the
445    /// minimum is 1, which any real unit satisfies.
446    pub fn from_posting_list_with(
447        list: &PostingList,
448        with_positions: bool,
449        length_of: Option<&dyn Fn(DocId) -> u32>,
450    ) -> io::Result<Self> {
451        Self::build(list, with_positions, length_of)
452    }
453
454    fn build(
455        list: &PostingList,
456        with_positions: bool,
457        length_of: Option<&dyn Fn(DocId) -> u32>,
458    ) -> io::Result<Self> {
459        let mut stream: Vec<u8> = Vec::new();
460        let mut l0_buf: Vec<u8> = Vec::new();
461        let mut l1_docs: Vec<u32> = Vec::new();
462        let mut cursors: Vec<u8> = Vec::new();
463        let mut positions_so_far = 0u64;
464        let mut l0_count = 0usize;
465        let mut max_tf = 0u32;
466        let mut list_min_len = u32::MAX;
467
468        let postings = &list.postings;
469        let mut i = 0;
470
471        // Temp buffers reused across blocks
472        let mut deltas = Vec::with_capacity(BLOCK_SIZE);
473        let mut tf_buf = Vec::with_capacity(BLOCK_SIZE);
474
475        while i < postings.len() {
476            if stream.len() > u32::MAX as usize {
477                return Err(io::Error::new(
478                    io::ErrorKind::InvalidData,
479                    "posting list stream exceeds u32::MAX bytes",
480                ));
481            }
482            let block_start = stream.len() as u32;
483            let block_end = (i + BLOCK_SIZE).min(postings.len());
484            let block = &postings[i..block_end];
485            let count = block.len();
486
487            // Compute block's max term frequency for block-max pruning
488            let block_max_tf = block.iter().map(|p| p.term_freq).max().unwrap_or(0);
489            max_tf = max_tf.max(block_max_tf);
490
491            let base_doc_id = block.first().unwrap().doc_id;
492            let last_doc_id = block.last().unwrap().doc_id;
493
494            // Delta-encode doc IDs (skip first — stored in header)
495            deltas.clear();
496            let mut prev = base_doc_id;
497            for posting in block.iter().skip(1) {
498                deltas.push(posting.doc_id - prev);
499                prev = posting.doc_id;
500            }
501            let max_delta = deltas.iter().copied().max().unwrap_or(0);
502            let doc_id_bits = simd::round_bit_width(simd::bits_needed(max_delta));
503
504            // Collect TFs
505            tf_buf.clear();
506            tf_buf.extend(block.iter().map(|p| p.term_freq));
507            let tf_bits = simd::round_bit_width(simd::bits_needed(block_max_tf));
508
509            // Write 8-byte header: [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
510            stream.write_u16::<LittleEndian>(count as u16)?;
511            stream.write_u32::<LittleEndian>(base_doc_id)?;
512            stream.push(doc_id_bits);
513            stream.push(tf_bits);
514
515            // Write packed doc_id deltas ((count-1) values)
516            if count > 1 {
517                let rounded = simd::RoundedBitWidth::from_u8(doc_id_bits);
518                let byte_count = (count - 1) * rounded.bytes_per_value();
519                let start = stream.len();
520                stream.resize(start + byte_count, 0);
521                simd::pack_rounded(&deltas, rounded, &mut stream[start..]);
522            }
523
524            // Write packed TFs (count values)
525            {
526                let rounded = simd::RoundedBitWidth::from_u8(tf_bits);
527                let byte_count = count * rounded.bytes_per_value();
528                let start = stream.len();
529                stream.resize(start + byte_count, 0);
530                simd::pack_rounded(&tf_buf, rounded, &mut stream[start..]);
531            }
532
533            // L0 skip entry with the block's bounds
534            let block_min_len = length_of.map_or(1, |length_of| {
535                block
536                    .iter()
537                    .map(|p| length_of(p.doc_id).max(1))
538                    .min()
539                    .unwrap_or(1)
540            });
541            list_min_len = list_min_len.min(block_min_len);
542            write_l0(
543                &mut l0_buf,
544                base_doc_id,
545                last_doc_id,
546                block_start,
547                pack_bounds(block_max_tf, block_min_len),
548            );
549            l0_count += 1;
550            if with_positions {
551                cursors.extend_from_slice(&positions_so_far.to_le_bytes());
552                positions_so_far += block.iter().map(|p| p.term_freq as u64).sum::<u64>();
553            }
554
555            // L1 entry at the end of each L1_INTERVAL group
556            if l0_count.is_multiple_of(L1_INTERVAL) {
557                l1_docs.push(last_doc_id);
558            }
559
560            i = block_end;
561        }
562
563        // Final L1 entry for partial group
564        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
565            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
566            l1_docs.push(last_doc);
567        }
568        let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
569
570        Ok(Self {
571            stream: OwnedBytes::new(stream),
572            l0_bytes: OwnedBytes::new(l0_buf),
573            l0_count,
574            l1_docs,
575            l1_bounds,
576            doc_count: postings.len() as u32,
577            max_tf,
578            pos_cursors: with_positions.then(|| OwnedBytes::new(cursors)),
579            total_positions: positions_so_far,
580            len_bounds: true,
581            min_len: if list_min_len == u32::MAX {
582                1
583            } else {
584                list_min_len
585            },
586        })
587    }
588
589    /// Serialize the block posting list (footer-based: stream first).
590    ///
591    /// Format:
592    /// ```text
593    /// [stream: block data]
594    /// [L0 entries: l0_count × 16 bytes (first_doc, last_doc, offset, max_weight)]
595    /// [L1 entries: l1_count × 4 bytes (last_doc)]
596    /// [L1 bounds: l1_count × 4 bytes (packed max_tf, min_len), FLAG_L1_BOUNDS]
597    /// [position cursors: l0_count × 8 bytes, only with positions]
598    /// [footer: stream_len(8) + l0_count(4) + l1_count(4) + doc_count(4) + max_tf(4)
599    ///          + total_positions(8) + flags(4) + min_len(4) + magic(4) = 44 bytes]
600    /// ```
601    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
602        writer.write_all(&self.stream)?;
603        writer.write_all(&self.l0_bytes)?;
604        for &doc in &self.l1_docs {
605            writer.write_u32::<LittleEndian>(doc)?;
606        }
607        for &bounds in &self.l1_bounds {
608            writer.write_u32::<LittleEndian>(bounds)?;
609        }
610        if let Some(cursors) = &self.pos_cursors {
611            writer.write_all(cursors)?;
612        }
613        Self::write_footer(
614            writer,
615            self.stream.len() as u64,
616            self.l0_count,
617            self.l1_docs.len(),
618            self.doc_count,
619            self.max_tf,
620            self.total_positions,
621            self.pos_cursors.is_some(),
622            self.len_bounds.then_some(self.min_len),
623            !self.l1_bounds.is_empty(),
624        )
625    }
626
627    #[allow(clippy::too_many_arguments)]
628    fn write_footer<W: Write>(
629        writer: &mut W,
630        stream_len: u64,
631        l0_count: usize,
632        l1_count: usize,
633        doc_count: u32,
634        max_tf: u32,
635        total_positions: u64,
636        has_cursors: bool,
637        min_len: Option<u32>,
638        l1_bounds: bool,
639    ) -> io::Result<()> {
640        writer.write_u64::<LittleEndian>(stream_len)?;
641        writer.write_u32::<LittleEndian>(l0_count as u32)?;
642        writer.write_u32::<LittleEndian>(l1_count as u32)?;
643        writer.write_u32::<LittleEndian>(doc_count)?;
644        writer.write_u32::<LittleEndian>(max_tf)?;
645        writer.write_u64::<LittleEndian>(total_positions)?;
646        let mut flags = 0u32;
647        if has_cursors {
648            flags |= FLAG_POS_CURSORS;
649        }
650        if min_len.is_some() {
651            flags |= FLAG_LEN_BOUNDS;
652        }
653        if l1_bounds {
654            flags |= FLAG_L1_BOUNDS;
655        }
656        writer.write_u32::<LittleEndian>(flags)?;
657        writer.write_u32::<LittleEndian>(min_len.unwrap_or(0))?;
658        writer.write_u32::<LittleEndian>(FOOTER_MAGIC)?;
659        Ok(())
660    }
661
662    /// Deserialize from a byte slice (either footer format).
663    pub fn deserialize(raw: &[u8]) -> io::Result<Self> {
664        Self::deserialize_zero_copy(OwnedBytes::new(raw.to_vec()))
665    }
666
667    /// Zero-copy deserialization from OwnedBytes.
668    /// Stream, L0 and cursors are sliced from the source without copying.
669    /// L1 is extracted into a `Vec<u32>` for SIMD-friendly access (tiny: ≤ N/8 entries).
670    pub fn deserialize_zero_copy(raw: OwnedBytes) -> io::Result<Self> {
671        let footer = Footer::parse(raw.as_slice())?;
672        let l1_docs = Self::extract_l1_docs(&raw[footer.l0_end()..], footer.l1_count);
673        let l1_bounds = if footer.l1_bounds {
674            Self::extract_l1_docs(&raw[footer.l1_end()..], footer.l1_count)
675        } else {
676            Vec::new()
677        };
678        let pos_cursors = footer
679            .has_cursors
680            .then(|| raw.slice(footer.l1_bounds_end()..footer.cursors_end()));
681
682        Ok(Self {
683            stream: raw.slice(0..footer.stream_len),
684            l0_bytes: raw.slice(footer.l0_start()..footer.l0_end()),
685            l0_count: footer.l0_count,
686            l1_docs,
687            l1_bounds,
688            doc_count: footer.doc_count,
689            max_tf: footer.max_tf,
690            pos_cursors,
691            total_positions: footer.total_positions,
692            len_bounds: footer.len_bounds,
693            min_len: footer.min_len,
694        })
695    }
696
697    /// Minimum scoring-unit length over the list, when the list stores
698    /// length bounds (`None` for legacy lists).
699    pub fn min_len(&self) -> Option<u32> {
700        self.len_bounds.then_some(self.min_len)
701    }
702
703    /// `(max_tf, min_len)` of a block; `min_len` is `None` for legacy lists.
704    #[inline]
705    pub fn block_bounds(&self, block_idx: usize) -> Option<(u32, Option<u32>)> {
706        if block_idx >= self.l0_count {
707            return None;
708        }
709        let (_, _, _, word) = self.read_l0_entry(block_idx);
710        Some(unpack_bounds(word, self.len_bounds))
711    }
712
713    /// `(max_tf, min_len)` over the L1 group (`L1_INTERVAL` blocks) that
714    /// contains `block_idx`; `None` for legacy lists without group bounds.
715    #[inline]
716    pub fn group_bounds(&self, block_idx: usize) -> Option<(u32, u32)> {
717        if block_idx >= self.l0_count {
718            return None;
719        }
720        let word = *self.l1_bounds.get(block_idx / L1_INTERVAL)?;
721        let (max_tf, min_len) = unpack_bounds(word, true);
722        Some((max_tf, min_len.unwrap_or(1)))
723    }
724
725    /// Last doc of the L1 group containing `block_idx`.
726    #[inline]
727    pub fn group_last_doc(&self, block_idx: usize) -> Option<DocId> {
728        self.l1_docs.get(block_idx / L1_INTERVAL).copied()
729    }
730
731    /// Whether `block_idx` opens an L1 group.
732    #[inline]
733    pub fn is_group_start(&self, block_idx: usize) -> bool {
734        block_idx.is_multiple_of(L1_INTERVAL)
735    }
736
737    /// Index of the first block after the L1 group containing `block_idx`
738    /// (clamped to the block count).
739    #[inline]
740    pub fn next_group_block(&self, block_idx: usize) -> usize {
741        ((block_idx / L1_INTERVAL + 1) * L1_INTERVAL).min(self.l0_count)
742    }
743
744    /// Whether serialized bytes carry position cursors (cheap footer check).
745    pub fn has_cursors_bytes(raw: &[u8]) -> bool {
746        Footer::parse(raw).is_ok_and(|footer| footer.has_cursors)
747    }
748
749    /// Whether this list carries a position cursor per block.
750    pub fn has_position_cursors(&self) -> bool {
751        self.pos_cursors.is_some()
752    }
753
754    /// Number of values in the term's position stream (0 without cursors).
755    pub fn total_positions(&self) -> u64 {
756        self.total_positions
757    }
758
759    /// Values in the term's position stream before block `block_idx`.
760    #[inline]
761    pub fn pos_cursor(&self, block_idx: usize) -> Option<u64> {
762        let cursors = self.pos_cursors.as_ref()?;
763        let p = block_idx * CURSOR_SIZE;
764        cursors
765            .get(p..p + CURSOR_SIZE)
766            .map(|b| u64::from_le_bytes(b.try_into().unwrap()))
767    }
768
769    /// Extract L1 last_doc values from raw LE bytes into a Vec<u32>.
770    fn extract_l1_docs(bytes: &[u8], count: usize) -> Vec<u32> {
771        let mut docs = Vec::with_capacity(count);
772        for i in 0..count {
773            let p = i * L1_SIZE;
774            docs.push(u32::from_le_bytes(bytes[p..p + 4].try_into().unwrap()));
775        }
776        docs
777    }
778
779    pub fn doc_count(&self) -> u32 {
780        self.doc_count
781    }
782
783    /// Get maximum term frequency (for MaxScore upper bound computation)
784    pub fn max_tf(&self) -> u32 {
785        self.max_tf
786    }
787
788    /// Get number of blocks
789    pub fn num_blocks(&self) -> usize {
790        self.l0_count
791    }
792
793    /// Get block's max term frequency for block-max pruning
794    pub fn block_max_tf(&self, block_idx: usize) -> Option<u32> {
795        self.block_bounds(block_idx).map(|(max_tf, _)| max_tf)
796    }
797
798    /// Concatenate blocks from multiple posting lists with doc_id remapping.
799    /// This is O(num_blocks) instead of O(num_postings).
800    pub fn concatenate_blocks(sources: &[(BlockPostingList, u32)]) -> io::Result<Self> {
801        let mut stream: Vec<u8> = Vec::new();
802        let mut l0_buf: Vec<u8> = Vec::new();
803        let mut l1_docs: Vec<u32> = Vec::new();
804        let mut l0_count = 0usize;
805        let mut total_docs = 0u32;
806        let mut max_tf = 0u32;
807        let all_cursors = sources.iter().all(|(s, _)| s.has_position_cursors());
808        if !all_cursors && sources.iter().any(|(s, _)| s.has_position_cursors()) {
809            return Err(io::Error::new(
810                io::ErrorKind::InvalidData,
811                "cannot concatenate posting lists with and without position cursors",
812            ));
813        }
814        let mut cursors: Vec<u8> = Vec::new();
815        let mut positions_before = 0u64;
816        let mut min_len = u32::MAX;
817
818        for (source, doc_offset) in sources {
819            max_tf = max_tf.max(source.max_tf);
820            min_len = min_len.min(source.min_len().unwrap_or(1));
821            for block_idx in 0..source.num_blocks() {
822                if all_cursors {
823                    let cursor = source.pos_cursor(block_idx).unwrap_or(0) + positions_before;
824                    cursors.extend_from_slice(&cursor.to_le_bytes());
825                }
826                let (first_doc, last_doc, offset, word) = source.read_l0_entry(block_idx);
827                let (block_max_tf, block_min_len) = unpack_bounds(word, source.len_bounds);
828                let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
829                let blk_size = block_data_size(&source.stream, offset as usize);
830                let block_bytes = &source.stream[offset as usize..offset as usize + blk_size];
831
832                let count = u16::from_le_bytes(block_bytes[0..2].try_into().unwrap());
833                if stream.len() > u32::MAX as usize {
834                    return Err(io::Error::new(
835                        io::ErrorKind::InvalidData,
836                        "posting list stream exceeds u32::MAX bytes during concatenation",
837                    ));
838                }
839                let new_offset = stream.len() as u32;
840
841                // Write patched header + copy packed arrays verbatim
842                stream.write_u16::<LittleEndian>(count)?;
843                stream.write_u32::<LittleEndian>(first_doc + doc_offset)?;
844                stream.extend_from_slice(&block_bytes[6..]);
845
846                let new_last = last_doc + doc_offset;
847                write_l0(
848                    &mut l0_buf,
849                    first_doc + doc_offset,
850                    new_last,
851                    new_offset,
852                    bounds,
853                );
854                l0_count += 1;
855                total_docs += count as u32;
856
857                if l0_count.is_multiple_of(L1_INTERVAL) {
858                    l1_docs.push(new_last);
859                }
860            }
861            positions_before += source.total_positions;
862        }
863
864        // Final L1 entry for partial group
865        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
866            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
867            l1_docs.push(last_doc);
868        }
869        let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
870
871        Ok(Self {
872            stream: OwnedBytes::new(stream),
873            l0_bytes: OwnedBytes::new(l0_buf),
874            l0_count,
875            l1_docs,
876            l1_bounds,
877            doc_count: total_docs,
878            max_tf,
879            pos_cursors: all_cursors.then(|| OwnedBytes::new(cursors)),
880            total_positions: if all_cursors { positions_before } else { 0 },
881            len_bounds: true,
882            min_len: if min_len == u32::MAX { 1 } else { min_len },
883        })
884    }
885
886    /// Streaming merge: write blocks directly to output writer (bounded memory).
887    ///
888    /// **Zero-materializing**: reads L0 entries directly from source bytes
889    /// (mmap or &[u8]) without parsing into Vecs. Block sizes computed from
890    /// the 8-byte header (deterministic with packed encoding).
891    ///
892    /// Output L0 + L1 are buffered (bounded O(total_blocks × 16 + total_blocks/8 × 4)).
893    /// Block data flows source → output writer without intermediate buffering.
894    ///
895    /// Returns `(doc_count, bytes_written)`.
896    ///
897    /// Returns `Error::Corruption` if any source is shorter than its footer:
898    /// metas are paired with sources positionally, so a short/corrupt source
899    /// must fail loudly instead of misassigning every subsequent source.
900    pub fn concatenate_streaming<W: Write>(
901        sources: &[(&[u8], u32)], // (serialized_bytes, doc_offset)
902        writer: &mut W,
903    ) -> crate::Result<(u32, usize)> {
904        let mut metas: Vec<Footer> = Vec::with_capacity(sources.len());
905        let mut total_docs = 0u32;
906        let mut merged_max_tf = 0u32;
907        let mut merged_min_len = u32::MAX;
908
909        for (source_index, (raw, _)) in sources.iter().enumerate() {
910            let footer = Footer::parse(raw).map_err(|e| {
911                crate::Error::Corruption(format!(
912                    "posting list source {source_index} has an invalid footer: {e}"
913                ))
914            })?;
915            total_docs += footer.doc_count;
916            merged_max_tf = merged_max_tf.max(footer.max_tf);
917            merged_min_len = merged_min_len.min(if footer.len_bounds { footer.min_len } else { 1 });
918            metas.push(footer);
919        }
920        let all_cursors = metas.iter().all(|m| m.has_cursors);
921        if !all_cursors && metas.iter().any(|m| m.has_cursors) {
922            return Err(crate::Error::Corruption(
923                "cannot concatenate posting lists with and without position cursors".into(),
924            ));
925        }
926
927        // Phase 1: Stream block data, reading L0 entries on-the-fly.
928        // Accumulate output L0 + L1 + cursors (bounded).
929        let mut out_l0: Vec<u8> = Vec::new();
930        let mut out_l1_docs: Vec<u32> = Vec::new();
931        let mut out_cursors: Vec<u8> = Vec::new();
932        let mut positions_before = 0u64;
933        let mut out_l0_count = 0usize;
934        let mut stream_written = 0u64;
935        let mut patch_buf = [0u8; 8];
936
937        for (src_idx, meta) in metas.iter().enumerate() {
938            let (raw, doc_offset) = &sources[src_idx];
939            let l0_base = meta.l0_start(); // L0 entries start right after stream
940            let src_stream = &raw[..meta.stream_len];
941            let cursors_base = meta.l1_bounds_end();
942
943            for i in 0..meta.l0_count {
944                // Read source L0 entry directly from raw bytes
945                let (first_doc, last_doc, offset, word) = read_l0(&raw[l0_base..], i);
946                let (block_max_tf, block_min_len) = unpack_bounds(word, meta.len_bounds);
947                let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
948                if all_cursors {
949                    let p = cursors_base + i * CURSOR_SIZE;
950                    let cursor = u64::from_le_bytes(raw[p..p + CURSOR_SIZE].try_into().unwrap());
951                    out_cursors.extend_from_slice(&(cursor + positions_before).to_le_bytes());
952                }
953
954                // Compute block size from header
955                let blk_size = block_data_size(src_stream, offset as usize);
956                let block = &src_stream[offset as usize..offset as usize + blk_size];
957
958                // Write output L0 entry
959                let new_last = last_doc + doc_offset;
960                if stream_written > u32::MAX as u64 {
961                    return Err(io::Error::new(
962                        io::ErrorKind::InvalidData,
963                        "posting list stream exceeds u32::MAX bytes during streaming merge",
964                    )
965                    .into());
966                }
967                write_l0(
968                    &mut out_l0,
969                    first_doc + doc_offset,
970                    new_last,
971                    stream_written as u32,
972                    bounds,
973                );
974                out_l0_count += 1;
975
976                // L1 entry at group boundary
977                if out_l0_count.is_multiple_of(L1_INTERVAL) {
978                    out_l1_docs.push(new_last);
979                }
980
981                // Patch 8-byte header: [count: u16][first_doc: u32][bits: 2 bytes]
982                patch_buf.copy_from_slice(&block[0..8]);
983                let blk_first = u32::from_le_bytes(patch_buf[2..6].try_into().unwrap());
984                patch_buf[2..6].copy_from_slice(&(blk_first + doc_offset).to_le_bytes());
985                writer.write_all(&patch_buf)?;
986                writer.write_all(&block[8..])?;
987
988                stream_written += blk_size as u64;
989            }
990            positions_before += meta.total_positions;
991        }
992
993        // Final L1 entry for partial group
994        if !out_l0_count.is_multiple_of(L1_INTERVAL) && out_l0_count > 0 {
995            let (_, last_doc, _, _) = read_l0(&out_l0, out_l0_count - 1);
996            out_l1_docs.push(last_doc);
997        }
998
999        // Phase 2: Write L0 + L1 + L1 bounds + cursors + footer
1000        let out_l1_bounds = group_bounds_from_l0(&out_l0, out_l0_count);
1001        writer.write_all(&out_l0)?;
1002        for &doc in &out_l1_docs {
1003            writer.write_u32::<LittleEndian>(doc)?;
1004        }
1005        for &bounds in &out_l1_bounds {
1006            writer.write_u32::<LittleEndian>(bounds)?;
1007        }
1008        writer.write_all(&out_cursors)?;
1009        Self::write_footer(
1010            writer,
1011            stream_written,
1012            out_l0_count,
1013            out_l1_docs.len(),
1014            total_docs,
1015            merged_max_tf,
1016            if all_cursors { positions_before } else { 0 },
1017            all_cursors,
1018            Some(if merged_min_len == u32::MAX {
1019                1
1020            } else {
1021                merged_min_len
1022            }),
1023            true,
1024        )?;
1025
1026        let l1_bytes_len = out_l1_docs.len() * L1_SIZE + out_l1_bounds.len() * 4;
1027        let total_bytes = stream_written as usize
1028            + out_l0.len()
1029            + l1_bytes_len
1030            + out_cursors.len()
1031            + FOOTER_V2_SIZE;
1032        Ok((total_docs, total_bytes))
1033    }
1034
1035    /// Decode a specific block into caller-provided buffers.
1036    ///
1037    /// Returns `true` if the block was decoded, `false` if `block_idx` is out of range.
1038    /// Reuses `doc_ids` and `tfs` buffers (cleared before filling).
1039    ///
1040    /// Uses SIMD-accelerated unpack for 8/16/32-bit packed arrays.
1041    pub fn decode_block_into(
1042        &self,
1043        block_idx: usize,
1044        doc_ids: &mut Vec<u32>,
1045        tfs: &mut Vec<u32>,
1046    ) -> bool {
1047        if let Some((offset, tf_start, count)) = self.decode_block_doc_ids_only(block_idx, doc_ids)
1048        {
1049            self.decode_block_tfs_deferred(offset, tf_start, count, tfs);
1050            true
1051        } else {
1052            false
1053        }
1054    }
1055
1056    /// Decode only doc IDs from a block (no TF decoding).
1057    ///
1058    /// Returns `(block_data_offset, tf_start_within_block, count)` for deferred TF decode,
1059    /// or `None` if block_idx is out of range.
1060    pub fn decode_block_doc_ids_only(
1061        &self,
1062        block_idx: usize,
1063        doc_ids: &mut Vec<u32>,
1064    ) -> Option<(usize, usize, usize)> {
1065        if block_idx >= self.l0_count {
1066            return None;
1067        }
1068
1069        let (_, _, offset, _) = self.read_l0_entry(block_idx);
1070        let pos = offset as usize;
1071        let blk_size = block_data_size(&self.stream, pos);
1072        let block_data = &self.stream[pos..pos + blk_size];
1073
1074        // 8-byte header: [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
1075        let count = u16::from_le_bytes(block_data[0..2].try_into().unwrap()) as usize;
1076        let first_doc = u32::from_le_bytes(block_data[2..6].try_into().unwrap());
1077        let doc_id_bits = block_data[6];
1078
1079        doc_ids.clear();
1080        doc_ids.resize(count, 0);
1081        doc_ids[0] = first_doc;
1082
1083        let doc_rounded = simd::RoundedBitWidth::from_u8(doc_id_bits);
1084        let deltas_bytes = if count > 1 {
1085            (count - 1) * doc_rounded.bytes_per_value()
1086        } else {
1087            0
1088        };
1089
1090        if count > 1 {
1091            simd::unpack_rounded(
1092                &block_data[8..8 + deltas_bytes],
1093                doc_rounded,
1094                &mut doc_ids[1..],
1095                count - 1,
1096            );
1097            for i in 1..count {
1098                doc_ids[i] += doc_ids[i - 1];
1099            }
1100        }
1101
1102        let tfs_start = 8 + deltas_bytes;
1103        Some((pos, tfs_start, count))
1104    }
1105
1106    /// Decode TFs from a previously loaded block (deferred decode).
1107    ///
1108    /// `block_offset` and `tf_start` are returned by `decode_block_doc_ids_only`.
1109    pub fn decode_block_tfs_deferred(
1110        &self,
1111        block_offset: usize,
1112        tf_start: usize,
1113        count: usize,
1114        tfs: &mut Vec<u32>,
1115    ) {
1116        let blk_size = block_data_size(&self.stream, block_offset);
1117        let block_data = &self.stream[block_offset..block_offset + blk_size];
1118        let tf_bits = block_data[7];
1119        let tf_rounded = simd::RoundedBitWidth::from_u8(tf_bits);
1120
1121        tfs.clear();
1122        tfs.resize(count, 0);
1123        simd::unpack_rounded(
1124            &block_data[tf_start..tf_start + count * tf_rounded.bytes_per_value()],
1125            tf_rounded,
1126            tfs,
1127            count,
1128        );
1129    }
1130
1131    /// First doc_id of a block (from L0 skip entry). Returns `None` if out of range.
1132    #[inline]
1133    pub fn block_first_doc(&self, block_idx: usize) -> Option<DocId> {
1134        if block_idx >= self.l0_count {
1135            return None;
1136        }
1137        let (first_doc, _, _, _) = self.read_l0_entry(block_idx);
1138        Some(first_doc)
1139    }
1140
1141    /// Last doc_id of a block (from L0 skip entry). Returns `None` if out of range.
1142    #[inline]
1143    pub fn block_last_doc(&self, block_idx: usize) -> Option<DocId> {
1144        if block_idx >= self.l0_count {
1145            return None;
1146        }
1147        let (_, last_doc, _, _) = self.read_l0_entry(block_idx);
1148        Some(last_doc)
1149    }
1150
1151    /// Find the first block whose `last_doc >= target`, starting from `from_block`.
1152    ///
1153    /// Uses SIMD-accelerated linear scan:
1154    /// 1. `find_first_ge_u32` on the contiguous L1 `last_doc` array
1155    /// 2. Extract ≤`L1_INTERVAL` L0 `last_doc` values into a stack buffer → `find_first_ge_u32`
1156    ///
1157    /// Returns `None` if no block contains `target`.
1158    pub fn seek_block(&self, target: DocId, from_block: usize) -> Option<usize> {
1159        if from_block >= self.l0_count {
1160            return None;
1161        }
1162
1163        let from_l1 = from_block / L1_INTERVAL;
1164
1165        // SIMD scan L1 to find the group containing target
1166        let l1_idx = if !self.l1_docs.is_empty() {
1167            let idx = from_l1 + simd::find_first_ge_u32(&self.l1_docs[from_l1..], target);
1168            if idx >= self.l1_docs.len() {
1169                return None;
1170            }
1171            idx
1172        } else {
1173            return None;
1174        };
1175
1176        // Extract L0 last_doc values within the group into a stack buffer for SIMD scan
1177        let start = (l1_idx * L1_INTERVAL).max(from_block);
1178        let end = ((l1_idx + 1) * L1_INTERVAL).min(self.l0_count);
1179        let count = end - start;
1180
1181        let mut last_docs = [u32::MAX; L1_INTERVAL];
1182        for (j, idx) in (start..end).enumerate() {
1183            let (_, ld, _, _) = read_l0(&self.l0_bytes, idx);
1184            last_docs[j] = ld;
1185        }
1186        let within = simd::find_first_ge_u32(&last_docs[..count], target);
1187        let block_idx = start + within;
1188
1189        if block_idx < self.l0_count {
1190            Some(block_idx)
1191        } else {
1192            None
1193        }
1194    }
1195
1196    /// Create an iterator with skip support
1197    pub fn iterator(&self) -> BlockPostingIterator<'_> {
1198        BlockPostingIterator::new(self)
1199    }
1200
1201    /// Create an owned iterator that doesn't borrow self
1202    pub fn into_iterator(self) -> BlockPostingIterator<'static> {
1203        BlockPostingIterator::owned(self)
1204    }
1205}
1206
1207/// Iterator over block posting list with skip support
1208/// Can be either borrowed or owned via Cow
1209///
1210/// Uses struct-of-arrays layout: separate `Vec<u32>` for doc_ids and term_freqs.
1211/// This is more cache-friendly for SIMD seek (contiguous doc_ids) and halves
1212/// memory vs the previous AoS + separate doc_ids approach.
1213pub struct BlockPostingIterator<'a> {
1214    block_list: std::borrow::Cow<'a, BlockPostingList>,
1215    current_block: usize,
1216    block_doc_ids: Vec<u32>,
1217    block_tfs: Vec<u32>,
1218    position_in_block: usize,
1219    /// Sum of the term frequencies of the postings before
1220    /// `position_in_block` in the current block (position stream offset
1221    /// relative to the block's cursor).
1222    tf_prefix: u64,
1223    exhausted: bool,
1224}
1225
1226impl<'a> BlockPostingIterator<'a> {
1227    fn new(block_list: &'a BlockPostingList) -> Self {
1228        let exhausted = block_list.l0_count == 0;
1229        let mut iter = Self {
1230            block_list: std::borrow::Cow::Borrowed(block_list),
1231            current_block: 0,
1232            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
1233            block_tfs: Vec::with_capacity(BLOCK_SIZE),
1234            position_in_block: 0,
1235            tf_prefix: 0,
1236            exhausted,
1237        };
1238        if !iter.exhausted {
1239            iter.load_block(0);
1240        }
1241        iter
1242    }
1243
1244    fn owned(block_list: BlockPostingList) -> BlockPostingIterator<'static> {
1245        let exhausted = block_list.l0_count == 0;
1246        let mut iter = BlockPostingIterator {
1247            block_list: std::borrow::Cow::Owned(block_list),
1248            current_block: 0,
1249            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
1250            block_tfs: Vec::with_capacity(BLOCK_SIZE),
1251            position_in_block: 0,
1252            tf_prefix: 0,
1253            exhausted,
1254        };
1255        if !iter.exhausted {
1256            iter.load_block(0);
1257        }
1258        iter
1259    }
1260
1261    fn load_block(&mut self, block_idx: usize) {
1262        if block_idx >= self.block_list.l0_count {
1263            self.exhausted = true;
1264            return;
1265        }
1266
1267        self.current_block = block_idx;
1268        self.position_in_block = 0;
1269        self.tf_prefix = 0;
1270
1271        self.block_list
1272            .decode_block_into(block_idx, &mut self.block_doc_ids, &mut self.block_tfs);
1273    }
1274
1275    /// Offset of the current posting's positions in the term's position
1276    /// stream (see `structures::postings::positions_v2`): the block's cursor
1277    /// plus the term frequencies of the postings before it in the block.
1278    /// Meaningful only for lists built with position cursors.
1279    #[inline]
1280    pub fn position_cursor(&self) -> u64 {
1281        self.block_list.pos_cursor(self.current_block).unwrap_or(0) + self.tf_prefix
1282    }
1283
1284    pub fn doc(&self) -> DocId {
1285        if self.exhausted {
1286            TERMINATED
1287        } else if self.position_in_block < self.block_doc_ids.len() {
1288            self.block_doc_ids[self.position_in_block]
1289        } else {
1290            TERMINATED
1291        }
1292    }
1293
1294    pub fn term_freq(&self) -> u32 {
1295        if self.exhausted || self.position_in_block >= self.block_tfs.len() {
1296            0
1297        } else {
1298            self.block_tfs[self.position_in_block]
1299        }
1300    }
1301
1302    pub fn advance(&mut self) -> DocId {
1303        if self.exhausted {
1304            return TERMINATED;
1305        }
1306
1307        if let Some(&tf) = self.block_tfs.get(self.position_in_block) {
1308            self.tf_prefix += tf as u64;
1309        }
1310        self.position_in_block += 1;
1311        if self.position_in_block >= self.block_doc_ids.len() {
1312            self.load_block(self.current_block + 1);
1313        }
1314        self.doc()
1315    }
1316
1317    pub fn seek(&mut self, target: DocId) -> DocId {
1318        if self.exhausted {
1319            return TERMINATED;
1320        }
1321
1322        // SIMD-accelerated 2-level seek (forward from current block)
1323        let block_idx = match self.block_list.seek_block(target, self.current_block) {
1324            Some(idx) => idx,
1325            None => {
1326                self.exhausted = true;
1327                return TERMINATED;
1328            }
1329        };
1330
1331        if block_idx != self.current_block {
1332            self.load_block(block_idx);
1333        }
1334
1335        // SIMD linear scan within block on cached doc_ids
1336        let remaining = &self.block_doc_ids[self.position_in_block..];
1337        let pos = crate::structures::simd::find_first_ge_u32(remaining, target);
1338        self.tf_prefix += self.block_tfs[self.position_in_block..self.position_in_block + pos]
1339            .iter()
1340            .map(|&tf| tf as u64)
1341            .sum::<u64>();
1342        self.position_in_block += pos;
1343
1344        if self.position_in_block >= self.block_doc_ids.len() {
1345            self.load_block(self.current_block + 1);
1346        }
1347        self.doc()
1348    }
1349
1350    /// Skip to the next block, returning the first doc_id in the new block
1351    /// This is used for block-max pruning when the current block's
1352    /// max score can't beat the threshold.
1353    pub fn skip_to_next_block(&mut self) -> DocId {
1354        if self.exhausted {
1355            return TERMINATED;
1356        }
1357        self.load_block(self.current_block + 1);
1358        self.doc()
1359    }
1360
1361    /// Get the current block index
1362    #[inline]
1363    pub fn current_block_idx(&self) -> usize {
1364        self.current_block
1365    }
1366
1367    /// Get total number of blocks
1368    #[inline]
1369    pub fn num_blocks(&self) -> usize {
1370        self.block_list.l0_count
1371    }
1372
1373    /// Get the current block's max term frequency for block-max pruning
1374    #[inline]
1375    pub fn current_block_max_tf(&self) -> u32 {
1376        if self.exhausted || self.current_block >= self.block_list.l0_count {
1377            0
1378        } else {
1379            self.block_list
1380                .block_max_tf(self.current_block)
1381                .unwrap_or(0)
1382        }
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389
1390    #[test]
1391    fn test_posting_list_basic() {
1392        let mut list = PostingList::new();
1393        list.push(1, 2);
1394        list.push(5, 1);
1395        list.push(10, 3);
1396
1397        assert_eq!(list.len(), 3);
1398
1399        let mut iter = PostingListIterator::new(&list);
1400        assert_eq!(iter.doc(), 1);
1401        assert_eq!(iter.term_freq(), 2);
1402
1403        assert_eq!(iter.advance(), 5);
1404        assert_eq!(iter.term_freq(), 1);
1405
1406        assert_eq!(iter.advance(), 10);
1407        assert_eq!(iter.term_freq(), 3);
1408
1409        assert_eq!(iter.advance(), TERMINATED);
1410    }
1411
1412    #[test]
1413    fn test_posting_list_serialization() {
1414        let mut list = PostingList::new();
1415        for i in 0..100 {
1416            list.push(i * 3, (i % 5) + 1);
1417        }
1418
1419        let mut buffer = Vec::new();
1420        list.serialize(&mut buffer).unwrap();
1421
1422        let deserialized = PostingList::deserialize(&mut &buffer[..]).unwrap();
1423        assert_eq!(deserialized.len(), list.len());
1424
1425        for (a, b) in list.iter().zip(deserialized.iter()) {
1426            assert_eq!(a, b);
1427        }
1428    }
1429
1430    #[test]
1431    fn test_posting_list_seek() {
1432        let mut list = PostingList::new();
1433        for i in 0..100 {
1434            list.push(i * 2, 1);
1435        }
1436
1437        let mut iter = PostingListIterator::new(&list);
1438
1439        assert_eq!(iter.seek(50), 50);
1440        assert_eq!(iter.seek(51), 52);
1441        assert_eq!(iter.seek(200), TERMINATED);
1442    }
1443
1444    #[test]
1445    fn test_block_posting_list() {
1446        let mut list = PostingList::new();
1447        for i in 0..500 {
1448            list.push(i * 2, (i % 10) + 1);
1449        }
1450
1451        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
1452        assert_eq!(block_list.doc_count(), 500);
1453
1454        let mut iter = block_list.iterator();
1455        assert_eq!(iter.doc(), 0);
1456        assert_eq!(iter.term_freq(), 1);
1457
1458        // Test seek across blocks
1459        assert_eq!(iter.seek(500), 500);
1460        assert_eq!(iter.seek(998), 998);
1461        assert_eq!(iter.seek(1000), TERMINATED);
1462    }
1463
1464    #[test]
1465    fn test_block_posting_list_serialization() {
1466        let mut list = PostingList::new();
1467        for i in 0..300 {
1468            list.push(i * 3, i + 1);
1469        }
1470
1471        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
1472
1473        let mut buffer = Vec::new();
1474        block_list.serialize(&mut buffer).unwrap();
1475
1476        let deserialized = BlockPostingList::deserialize(&buffer[..]).unwrap();
1477        assert_eq!(deserialized.doc_count(), block_list.doc_count());
1478
1479        // Verify iteration produces same results
1480        let mut iter1 = block_list.iterator();
1481        let mut iter2 = deserialized.iterator();
1482
1483        while iter1.doc() != TERMINATED {
1484            assert_eq!(iter1.doc(), iter2.doc());
1485            assert_eq!(iter1.term_freq(), iter2.term_freq());
1486            iter1.advance();
1487            iter2.advance();
1488        }
1489        assert_eq!(iter2.doc(), TERMINATED);
1490    }
1491
1492    /// Helper: collect all (doc_id, tf) from a BlockPostingIterator
1493    fn collect_postings(bpl: &BlockPostingList) -> Vec<(u32, u32)> {
1494        let mut result = Vec::new();
1495        let mut it = bpl.iterator();
1496        while it.doc() != TERMINATED {
1497            result.push((it.doc(), it.term_freq()));
1498            it.advance();
1499        }
1500        result
1501    }
1502
1503    /// Helper: build a BlockPostingList from (doc_id, tf) pairs
1504    fn build_bpl(postings: &[(u32, u32)]) -> BlockPostingList {
1505        let mut pl = PostingList::new();
1506        for &(doc_id, tf) in postings {
1507            pl.push(doc_id, tf);
1508        }
1509        BlockPostingList::from_posting_list(&pl).unwrap()
1510    }
1511
1512    /// Helper: serialize a BlockPostingList to bytes
1513    fn serialize_bpl(bpl: &BlockPostingList) -> Vec<u8> {
1514        let mut buf = Vec::new();
1515        bpl.serialize(&mut buf).unwrap();
1516        buf
1517    }
1518
1519    #[test]
1520    fn test_concatenate_blocks_two_segments() {
1521        // Segment A: docs 0,2,4,...,198 (100 docs, tf=1..100)
1522        let a: Vec<(u32, u32)> = (0..100).map(|i| (i * 2, i + 1)).collect();
1523        let bpl_a = build_bpl(&a);
1524
1525        // Segment B: docs 0,3,6,...,297 (100 docs, tf=2..101)
1526        let b: Vec<(u32, u32)> = (0..100).map(|i| (i * 3, i + 2)).collect();
1527        let bpl_b = build_bpl(&b);
1528
1529        // Merge: segment B starts at doc_offset=200
1530        let merged =
1531            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 200)])
1532                .unwrap();
1533
1534        assert_eq!(merged.doc_count(), 200);
1535
1536        let postings = collect_postings(&merged);
1537        assert_eq!(postings.len(), 200);
1538
1539        // First 100 from A (unchanged)
1540        for (i, p) in postings.iter().enumerate().take(100) {
1541            assert_eq!(*p, (i as u32 * 2, i as u32 + 1));
1542        }
1543        // Next 100 from B (doc_id += 200)
1544        for i in 0..100 {
1545            assert_eq!(postings[100 + i], (i as u32 * 3 + 200, i as u32 + 2));
1546        }
1547    }
1548
1549    #[test]
1550    fn test_concatenate_streaming_matches_blocks() {
1551        // Build 3 segments with different doc distributions
1552        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
1553        let seg_b: Vec<(u32, u32)> = (0..180).map(|i| (i * 5, (i % 3) + 1)).collect();
1554        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
1555
1556        let bpl_a = build_bpl(&seg_a);
1557        let bpl_b = build_bpl(&seg_b);
1558        let bpl_c = build_bpl(&seg_c);
1559
1560        let offset_b = 1000u32;
1561        let offset_c = 2000u32;
1562
1563        // Method 1: concatenate_blocks (in-memory reference)
1564        let ref_merged = BlockPostingList::concatenate_blocks(&[
1565            (bpl_a.clone(), 0),
1566            (bpl_b.clone(), offset_b),
1567            (bpl_c.clone(), offset_c),
1568        ])
1569        .unwrap();
1570        let mut ref_buf = Vec::new();
1571        ref_merged.serialize(&mut ref_buf).unwrap();
1572
1573        // Method 2: concatenate_streaming (footer-based, writes to output)
1574        let bytes_a = serialize_bpl(&bpl_a);
1575        let bytes_b = serialize_bpl(&bpl_b);
1576        let bytes_c = serialize_bpl(&bpl_c);
1577
1578        let sources: Vec<(&[u8], u32)> =
1579            vec![(&bytes_a, 0), (&bytes_b, offset_b), (&bytes_c, offset_c)];
1580        let mut stream_buf = Vec::new();
1581        let (doc_count, bytes_written) =
1582            BlockPostingList::concatenate_streaming(&sources, &mut stream_buf).unwrap();
1583
1584        assert_eq!(doc_count, 520); // 250 + 180 + 90
1585        assert_eq!(bytes_written, stream_buf.len());
1586
1587        // Deserialize both and verify identical postings
1588        let ref_postings = collect_postings(&BlockPostingList::deserialize(&ref_buf).unwrap());
1589        let stream_postings =
1590            collect_postings(&BlockPostingList::deserialize(&stream_buf).unwrap());
1591
1592        assert_eq!(ref_postings.len(), stream_postings.len());
1593        for (i, (r, s)) in ref_postings.iter().zip(stream_postings.iter()).enumerate() {
1594            assert_eq!(r, s, "mismatch at posting {}", i);
1595        }
1596    }
1597
1598    #[test]
1599    fn test_concatenate_streaming_short_source_returns_corruption() {
1600        // A source shorter than the 24-byte footer (e.g. a corrupt TermInfo
1601        // (offset, len) pointing at truncated bytes) must fail loudly.
1602        // Silently skipping it pairs every later source with the wrong
1603        // metadata (metas[i] vs sources[i]) — panicking or emitting garbage.
1604        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
1605        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
1606        let bytes_a = serialize_bpl(&build_bpl(&seg_a));
1607        let bytes_c = serialize_bpl(&build_bpl(&seg_c));
1608        let short = vec![0u8; FOOTER_SIZE - 1]; // corrupt: shorter than footer
1609
1610        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&short, 1000), (&bytes_c, 2000)];
1611        let mut out = Vec::new();
1612        let result = BlockPostingList::concatenate_streaming(&sources, &mut out);
1613        assert!(
1614            matches!(result, Err(crate::Error::Corruption(_))),
1615            "short/corrupt source must be a Corruption error, not silently skipped: {:?}",
1616            result.map(|r| r.0)
1617        );
1618    }
1619
1620    #[test]
1621    fn test_multi_round_merge() {
1622        // Simulate 3 rounds of merging (like tiered merge policy)
1623        //
1624        // Round 0: 4 small segments built independently
1625        // Round 1: merge pairs → 2 medium segments
1626        // Round 2: merge those → 1 large segment
1627
1628        let segments: Vec<Vec<(u32, u32)>> = (0..4)
1629            .map(|seg| (0..200).map(|i| (i * 3, (i + seg * 7) % 10 + 1)).collect())
1630            .collect();
1631
1632        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
1633        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
1634
1635        // Round 1: merge seg0+seg1 (offset=0,600), seg2+seg3 (offset=0,600)
1636        let mut merged_01 = Vec::new();
1637        let sources_01: Vec<(&[u8], u32)> = vec![(&serialized[0], 0), (&serialized[1], 600)];
1638        let (dc_01, _) =
1639            BlockPostingList::concatenate_streaming(&sources_01, &mut merged_01).unwrap();
1640        assert_eq!(dc_01, 400);
1641
1642        let mut merged_23 = Vec::new();
1643        let sources_23: Vec<(&[u8], u32)> = vec![(&serialized[2], 0), (&serialized[3], 600)];
1644        let (dc_23, _) =
1645            BlockPostingList::concatenate_streaming(&sources_23, &mut merged_23).unwrap();
1646        assert_eq!(dc_23, 400);
1647
1648        // Round 2: merge the two intermediate results (offset=0, 1200)
1649        let mut final_merged = Vec::new();
1650        let sources_final: Vec<(&[u8], u32)> = vec![(&merged_01, 0), (&merged_23, 1200)];
1651        let (dc_final, _) =
1652            BlockPostingList::concatenate_streaming(&sources_final, &mut final_merged).unwrap();
1653        assert_eq!(dc_final, 800);
1654
1655        // Verify final result has all 800 postings with correct doc_ids
1656        let final_bpl = BlockPostingList::deserialize(&final_merged).unwrap();
1657        let postings = collect_postings(&final_bpl);
1658        assert_eq!(postings.len(), 800);
1659
1660        // Verify doc_id ordering (must be monotonically non-decreasing within segments,
1661        // and segment boundaries at 0, 600, 1200, 1800)
1662        // Seg0: 0..597, Seg1: 600..1197, Seg2: 1200..1797, Seg3: 1800..2397
1663        assert_eq!(postings[0].0, 0); // first doc of seg0
1664        assert_eq!(postings[199].0, 597); // last doc of seg0 (199*3)
1665        assert_eq!(postings[200].0, 600); // first doc of seg1 (0+600)
1666        assert_eq!(postings[399].0, 1197); // last doc of seg1 (597+600)
1667        assert_eq!(postings[400].0, 1200); // first doc of seg2
1668        assert_eq!(postings[799].0, 2397); // last doc of seg3
1669
1670        // Verify TFs preserved through two rounds of merging
1671        // Creation formula: tf = (i + seg * 7) % 10 + 1
1672        for seg in 0u32..4 {
1673            for i in 0u32..200 {
1674                let idx = (seg * 200 + i) as usize;
1675                assert_eq!(
1676                    postings[idx].1,
1677                    (i + seg * 7) % 10 + 1,
1678                    "seg{} tf[{}]",
1679                    seg,
1680                    i
1681                );
1682            }
1683        }
1684
1685        // Verify seek works on final merged result
1686        let mut it = final_bpl.iterator();
1687        assert_eq!(it.seek(600), 600);
1688        assert_eq!(it.seek(1200), 1200);
1689        assert_eq!(it.seek(2397), 2397);
1690        assert_eq!(it.seek(2398), TERMINATED);
1691    }
1692
1693    #[test]
1694    fn test_large_scale_merge() {
1695        // 5 segments × 2000 docs each = 10,000 total docs
1696        // Each segment has 16 blocks (2000/128 = 15.6 → 16 blocks)
1697        let num_segments = 5;
1698        let docs_per_segment = 2000;
1699        let docs_gap = 3; // doc_ids: 0, 3, 6, ...
1700
1701        let segments: Vec<Vec<(u32, u32)>> = (0..num_segments)
1702            .map(|seg| {
1703                (0..docs_per_segment)
1704                    .map(|i| (i as u32 * docs_gap, (i as u32 + seg as u32) % 20 + 1))
1705                    .collect()
1706            })
1707            .collect();
1708
1709        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
1710
1711        // Verify each segment has multiple blocks
1712        for bpl in &bpls {
1713            assert!(
1714                bpl.num_blocks() >= 15,
1715                "expected >=15 blocks, got {}",
1716                bpl.num_blocks()
1717            );
1718        }
1719
1720        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
1721
1722        // Compute offsets: each segment occupies max_doc+1 doc_id space
1723        let max_doc_per_seg = (docs_per_segment as u32 - 1) * docs_gap;
1724        let offsets: Vec<u32> = (0..num_segments)
1725            .map(|i| i as u32 * (max_doc_per_seg + 1))
1726            .collect();
1727
1728        let sources: Vec<(&[u8], u32)> = serialized
1729            .iter()
1730            .zip(offsets.iter())
1731            .map(|(b, o)| (b.as_slice(), *o))
1732            .collect();
1733
1734        let mut merged = Vec::new();
1735        let (doc_count, _) =
1736            BlockPostingList::concatenate_streaming(&sources, &mut merged).unwrap();
1737        assert_eq!(doc_count, (num_segments * docs_per_segment) as u32);
1738
1739        // Deserialize and verify
1740        let merged_bpl = BlockPostingList::deserialize(&merged).unwrap();
1741        let postings = collect_postings(&merged_bpl);
1742        assert_eq!(postings.len(), num_segments * docs_per_segment);
1743
1744        // Verify all doc_ids are strictly monotonically increasing across segment boundaries
1745        for i in 1..postings.len() {
1746            assert!(
1747                postings[i].0 > postings[i - 1].0 || (i % docs_per_segment == 0), // new segment can have lower absolute ID
1748                "doc_id not increasing at {}: {} vs {}",
1749                i,
1750                postings[i - 1].0,
1751                postings[i].0,
1752            );
1753        }
1754
1755        // Verify seek across all block boundaries
1756        let mut it = merged_bpl.iterator();
1757        for (seg, &expected_first) in offsets.iter().enumerate() {
1758            assert_eq!(
1759                it.seek(expected_first),
1760                expected_first,
1761                "seek to segment {} start",
1762                seg
1763            );
1764        }
1765    }
1766
1767    #[test]
1768    fn test_merge_edge_cases() {
1769        // Single doc per segment
1770        let bpl_a = build_bpl(&[(0, 5)]);
1771        let bpl_b = build_bpl(&[(0, 3)]);
1772
1773        let merged =
1774            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 1)])
1775                .unwrap();
1776        assert_eq!(merged.doc_count(), 2);
1777        let p = collect_postings(&merged);
1778        assert_eq!(p, vec![(0, 5), (1, 3)]);
1779
1780        // Exactly BLOCK_SIZE docs (single full block)
1781        let exact_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32).map(|i| (i, i % 5 + 1)).collect();
1782        let bpl_exact = build_bpl(&exact_block);
1783        assert_eq!(bpl_exact.num_blocks(), 1);
1784
1785        let bytes = serialize_bpl(&bpl_exact);
1786        let mut out = Vec::new();
1787        let sources: Vec<(&[u8], u32)> = vec![(&bytes, 0), (&bytes, BLOCK_SIZE as u32)];
1788        let (dc, _) = BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
1789        assert_eq!(dc, BLOCK_SIZE as u32 * 2);
1790
1791        let merged = BlockPostingList::deserialize(&out).unwrap();
1792        let postings = collect_postings(&merged);
1793        assert_eq!(postings.len(), BLOCK_SIZE * 2);
1794        // Second segment's docs offset by BLOCK_SIZE
1795        assert_eq!(postings[BLOCK_SIZE].0, BLOCK_SIZE as u32);
1796
1797        // BLOCK_SIZE + 1 docs (two blocks: 128 + 1)
1798        let over_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32 + 1).map(|i| (i * 2, 1)).collect();
1799        let bpl_over = build_bpl(&over_block);
1800        assert_eq!(bpl_over.num_blocks(), 2);
1801    }
1802
1803    #[test]
1804    fn test_streaming_roundtrip_single_source() {
1805        // Streaming merge with a single source should produce equivalent output to serialize
1806        let docs: Vec<(u32, u32)> = (0..500).map(|i| (i * 7, i % 15 + 1)).collect();
1807        let bpl = build_bpl(&docs);
1808        let direct = serialize_bpl(&bpl);
1809
1810        let sources: Vec<(&[u8], u32)> = vec![(&direct, 0)];
1811        let mut streamed = Vec::new();
1812        BlockPostingList::concatenate_streaming(&sources, &mut streamed).unwrap();
1813
1814        // Both should deserialize to identical postings
1815        let p1 = collect_postings(&BlockPostingList::deserialize(&direct).unwrap());
1816        let p2 = collect_postings(&BlockPostingList::deserialize(&streamed).unwrap());
1817        assert_eq!(p1, p2);
1818    }
1819
1820    #[test]
1821    fn test_max_tf_preserved_through_merge() {
1822        // Segment A: max_tf = 50
1823        let mut a = Vec::new();
1824        for i in 0..200 {
1825            a.push((i * 2, if i == 100 { 50 } else { 1 }));
1826        }
1827        let bpl_a = build_bpl(&a);
1828        assert_eq!(bpl_a.max_tf(), 50);
1829
1830        // Segment B: max_tf = 30
1831        let mut b = Vec::new();
1832        for i in 0..200 {
1833            b.push((i * 2, if i == 50 { 30 } else { 2 }));
1834        }
1835        let bpl_b = build_bpl(&b);
1836        assert_eq!(bpl_b.max_tf(), 30);
1837
1838        // After merge, max_tf should be max(50, 30) = 50
1839        let bytes_a = serialize_bpl(&bpl_a);
1840        let bytes_b = serialize_bpl(&bpl_b);
1841        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 1000)];
1842        let mut out = Vec::new();
1843        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
1844
1845        let merged = BlockPostingList::deserialize(&out).unwrap();
1846        assert_eq!(merged.max_tf(), 50);
1847        assert_eq!(merged.doc_count(), 400);
1848    }
1849
1850    // ── 2-level skip list format tests ──────────────────────────────────
1851
1852    #[test]
1853    fn test_l0_l1_counts() {
1854        // 1 block (< L1_INTERVAL) → 1 L1 entry (partial group)
1855        let bpl = build_bpl(&(0..50u32).map(|i| (i, 1)).collect::<Vec<_>>());
1856        assert_eq!(bpl.num_blocks(), 1);
1857        assert_eq!(bpl.l1_docs.len(), 1);
1858
1859        // Exactly L1_INTERVAL blocks → 1 L1 entry (full group)
1860        let n = BLOCK_SIZE * L1_INTERVAL;
1861        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
1862        assert_eq!(bpl.num_blocks(), L1_INTERVAL);
1863        assert_eq!(bpl.l1_docs.len(), 1);
1864
1865        // L1_INTERVAL + 1 blocks → 2 L1 entries
1866        let n = BLOCK_SIZE * L1_INTERVAL + 1;
1867        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
1868        assert_eq!(bpl.num_blocks(), L1_INTERVAL + 1);
1869        assert_eq!(bpl.l1_docs.len(), 2);
1870
1871        // 3 × L1_INTERVAL blocks → 3 L1 entries (all full groups)
1872        let n = BLOCK_SIZE * L1_INTERVAL * 3;
1873        let bpl = build_bpl(&(0..n as u32).map(|i| (i, 1)).collect::<Vec<_>>());
1874        assert_eq!(bpl.num_blocks(), L1_INTERVAL * 3);
1875        assert_eq!(bpl.l1_docs.len(), 3);
1876    }
1877
1878    #[test]
1879    fn test_l1_last_doc_values() {
1880        // 20 blocks: 2 full L1 groups (8+8) + 1 partial (4) → 3 L1 entries
1881        let n = BLOCK_SIZE * 20;
1882        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
1883        let bpl = build_bpl(&docs);
1884        assert_eq!(bpl.num_blocks(), 20);
1885        assert_eq!(bpl.l1_docs.len(), 3); // ceil(20/8) = 3
1886
1887        // L1[0] = last_doc of block 7 (end of first group)
1888        let expected_l1_0 = bpl.block_last_doc(7).unwrap();
1889        assert_eq!(bpl.l1_docs[0], expected_l1_0);
1890
1891        // L1[1] = last_doc of block 15 (end of second group)
1892        let expected_l1_1 = bpl.block_last_doc(15).unwrap();
1893        assert_eq!(bpl.l1_docs[1], expected_l1_1);
1894
1895        // L1[2] = last_doc of block 19 (end of partial group)
1896        let expected_l1_2 = bpl.block_last_doc(19).unwrap();
1897        assert_eq!(bpl.l1_docs[2], expected_l1_2);
1898    }
1899
1900    #[test]
1901    fn test_seek_block_basic() {
1902        // 20 blocks spanning large doc ID range
1903        let n = BLOCK_SIZE * 20;
1904        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 10, 1)).collect();
1905        let bpl = build_bpl(&docs);
1906
1907        // Seek to doc 0 → block 0
1908        assert_eq!(bpl.seek_block(0, 0), Some(0));
1909
1910        // Seek to the first doc of each block
1911        for blk in 0..20 {
1912            let first = bpl.block_first_doc(blk).unwrap();
1913            assert_eq!(
1914                bpl.seek_block(first, 0),
1915                Some(blk),
1916                "seek to block {} first_doc",
1917                blk
1918            );
1919        }
1920
1921        // Seek to the last doc of each block
1922        for blk in 0..20 {
1923            let last = bpl.block_last_doc(blk).unwrap();
1924            assert_eq!(
1925                bpl.seek_block(last, 0),
1926                Some(blk),
1927                "seek to block {} last_doc",
1928                blk
1929            );
1930        }
1931
1932        // Seek past all docs
1933        let max_doc = bpl.block_last_doc(19).unwrap();
1934        assert_eq!(bpl.seek_block(max_doc + 1, 0), None);
1935
1936        // Seek with from_block > 0 (skip early blocks)
1937        let mid_doc = bpl.block_first_doc(10).unwrap();
1938        assert_eq!(bpl.seek_block(mid_doc, 10), Some(10));
1939        assert_eq!(
1940            bpl.seek_block(mid_doc, 11),
1941            Some(11).or(bpl.seek_block(mid_doc, 11))
1942        );
1943    }
1944
1945    #[test]
1946    fn test_seek_block_across_l1_boundaries() {
1947        // 24 blocks = 3 L1 groups of 8
1948        let n = BLOCK_SIZE * 24;
1949        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 5, 1)).collect();
1950        let bpl = build_bpl(&docs);
1951        assert_eq!(bpl.l1_docs.len(), 3);
1952
1953        // Seek into each L1 group
1954        for group in 0..3 {
1955            let blk = group * L1_INTERVAL;
1956            let target = bpl.block_first_doc(blk).unwrap();
1957            assert_eq!(
1958                bpl.seek_block(target, 0),
1959                Some(blk),
1960                "seek to group {} block {}",
1961                group,
1962                blk
1963            );
1964        }
1965
1966        // Seek to doc in the middle of group 2 (block 20)
1967        let target = bpl.block_first_doc(20).unwrap() + 1;
1968        assert_eq!(bpl.seek_block(target, 0), Some(20));
1969    }
1970
1971    #[test]
1972    fn test_block_data_size_helper() {
1973        // Build a posting list and verify block_data_size matches actual block sizes
1974        let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 7, (i % 20) + 1)).collect();
1975        let bpl = build_bpl(&docs);
1976
1977        for blk in 0..bpl.num_blocks() {
1978            let (_, _, offset, _) = bpl.read_l0_entry(blk);
1979            let computed_size = block_data_size(&bpl.stream, offset as usize);
1980
1981            // Verify: next block's offset - this block's offset should equal computed_size
1982            // (for all but last block)
1983            if blk + 1 < bpl.num_blocks() {
1984                let (_, _, next_offset, _) = bpl.read_l0_entry(blk + 1);
1985                assert_eq!(
1986                    computed_size,
1987                    (next_offset - offset) as usize,
1988                    "block_data_size mismatch at block {}",
1989                    blk
1990                );
1991            } else {
1992                // Last block: offset + size should equal stream length
1993                assert_eq!(
1994                    offset as usize + computed_size,
1995                    bpl.stream.len(),
1996                    "last block size mismatch"
1997                );
1998            }
1999        }
2000    }
2001
2002    #[test]
2003    fn test_l0_entry_roundtrip() {
2004        // Verify L0 entries survive serialize → deserialize
2005        let docs: Vec<(u32, u32)> = (0..1000u32).map(|i| (i * 3, (i % 10) + 1)).collect();
2006        let bpl = build_bpl(&docs);
2007
2008        let bytes = serialize_bpl(&bpl);
2009        let bpl2 = BlockPostingList::deserialize(&bytes).unwrap();
2010
2011        assert_eq!(bpl.num_blocks(), bpl2.num_blocks());
2012        for blk in 0..bpl.num_blocks() {
2013            assert_eq!(
2014                bpl.read_l0_entry(blk),
2015                bpl2.read_l0_entry(blk),
2016                "L0 entry mismatch at block {}",
2017                blk
2018            );
2019        }
2020
2021        // Verify L1 docs match
2022        assert_eq!(bpl.l1_docs, bpl2.l1_docs);
2023    }
2024
2025    #[test]
2026    fn test_zero_copy_deserialize_matches() {
2027        let docs: Vec<(u32, u32)> = (0..2000u32).map(|i| (i * 2, (i % 5) + 1)).collect();
2028        let bpl = build_bpl(&docs);
2029        let bytes = serialize_bpl(&bpl);
2030
2031        let copied = BlockPostingList::deserialize(&bytes).unwrap();
2032        let zero_copy =
2033            BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
2034
2035        // Same structure
2036        assert_eq!(copied.l0_count, zero_copy.l0_count);
2037        assert_eq!(copied.l1_docs, zero_copy.l1_docs);
2038        assert_eq!(copied.doc_count, zero_copy.doc_count);
2039        assert_eq!(copied.max_tf, zero_copy.max_tf);
2040
2041        // Same iteration
2042        let p1 = collect_postings(&copied);
2043        let p2 = collect_postings(&zero_copy);
2044        assert_eq!(p1, p2);
2045    }
2046
2047    #[test]
2048    fn test_l1_preserved_through_streaming_merge() {
2049        // Merge 3 segments, verify L1 is correctly rebuilt
2050        let seg_a = build_bpl(&(0..1000u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
2051        let seg_b = build_bpl(&(0..800u32).map(|i| (i * 3, 2)).collect::<Vec<_>>());
2052        let seg_c = build_bpl(&(0..500u32).map(|i| (i * 5, 3)).collect::<Vec<_>>());
2053
2054        let bytes_a = serialize_bpl(&seg_a);
2055        let bytes_b = serialize_bpl(&seg_b);
2056        let bytes_c = serialize_bpl(&seg_c);
2057
2058        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 10000), (&bytes_c, 20000)];
2059        let mut out = Vec::new();
2060        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
2061
2062        let merged = BlockPostingList::deserialize(&out).unwrap();
2063        let expected_l1_count = merged.num_blocks().div_ceil(L1_INTERVAL);
2064        assert_eq!(merged.l1_docs.len(), expected_l1_count);
2065
2066        // Verify L1 values are correct
2067        for (i, &l1_doc) in merged.l1_docs.iter().enumerate() {
2068            let last_block_in_group = ((i + 1) * L1_INTERVAL - 1).min(merged.num_blocks() - 1);
2069            let expected = merged.block_last_doc(last_block_in_group).unwrap();
2070            assert_eq!(l1_doc, expected, "L1[{}] mismatch", i);
2071        }
2072
2073        // Verify seek_block works on merged result
2074        for blk in 0..merged.num_blocks() {
2075            let first = merged.block_first_doc(blk).unwrap();
2076            assert_eq!(merged.seek_block(first, 0), Some(blk));
2077        }
2078    }
2079
2080    #[test]
2081    fn test_seek_block_single_block() {
2082        // Edge case: single block (< L1_INTERVAL)
2083        let bpl = build_bpl(&[(0, 1), (10, 2), (20, 3)]);
2084        assert_eq!(bpl.num_blocks(), 1);
2085        assert_eq!(bpl.l1_docs.len(), 1);
2086
2087        assert_eq!(bpl.seek_block(0, 0), Some(0));
2088        assert_eq!(bpl.seek_block(10, 0), Some(0));
2089        assert_eq!(bpl.seek_block(20, 0), Some(0));
2090        assert_eq!(bpl.seek_block(21, 0), None);
2091    }
2092
2093    #[test]
2094    fn test_footer_size() {
2095        // Verify serialized size = stream + L0 + L1 + FOOTER_SIZE
2096        let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 2, 1)).collect();
2097        let bpl = build_bpl(&docs);
2098        let bytes = serialize_bpl(&bpl);
2099
2100        let expected = bpl.stream.len()
2101            + bpl.l0_count * L0_SIZE
2102            + bpl.l1_docs.len() * (L1_SIZE + 4)
2103            + FOOTER_V2_SIZE;
2104        assert_eq!(bytes.len(), expected);
2105    }
2106
2107    fn build_bpl_with_positions(postings: &[(u32, u32)]) -> BlockPostingList {
2108        let mut list = PostingList::new();
2109        for &(doc, tf) in postings {
2110            list.push(doc, tf);
2111        }
2112        BlockPostingList::from_posting_list_with_positions(&list).unwrap()
2113    }
2114
2115    /// Expected cursor of every posting: the cumulative tf before it.
2116    fn expected_cursors(postings: &[(u32, u32)]) -> Vec<u64> {
2117        let mut acc = 0u64;
2118        postings
2119            .iter()
2120            .map(|&(_, tf)| {
2121                let c = acc;
2122                acc += tf as u64;
2123                c
2124            })
2125            .collect()
2126    }
2127
2128    fn iterator_cursors(bpl: &BlockPostingList) -> Vec<u64> {
2129        let mut it = bpl.iterator();
2130        let mut out = Vec::new();
2131        while it.doc() != TERMINATED {
2132            out.push(it.position_cursor());
2133            it.advance();
2134        }
2135        out
2136    }
2137
2138    #[test]
2139    fn position_cursors_survive_serialization_and_seeks() {
2140        let docs: Vec<(u32, u32)> = (0..700u32).map(|i| (i * 3, i % 5 + 1)).collect();
2141        let bpl = build_bpl_with_positions(&docs);
2142        assert!(bpl.has_position_cursors());
2143        assert_eq!(
2144            bpl.total_positions(),
2145            docs.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
2146        );
2147        assert_eq!(bpl.pos_cursor(0), Some(0));
2148        assert_eq!(
2149            bpl.pos_cursor(1),
2150            Some(docs[..128].iter().map(|&(_, tf)| tf as u64).sum::<u64>())
2151        );
2152        assert_eq!(iterator_cursors(&bpl), expected_cursors(&docs));
2153
2154        let bytes = serialize_bpl(&bpl);
2155        assert_eq!(
2156            bytes.len(),
2157            bpl.stream.len()
2158                + bpl.l0_count * (L0_SIZE + CURSOR_SIZE)
2159                + bpl.l1_docs.len() * (L1_SIZE + 4)
2160                + FOOTER_V2_SIZE
2161        );
2162        assert!(BlockPostingList::has_cursors_bytes(&bytes));
2163        let decoded =
2164            BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
2165        assert_eq!(iterator_cursors(&decoded), expected_cursors(&docs));
2166        assert_eq!(decoded.total_positions(), bpl.total_positions());
2167
2168        // Seeking within and across blocks keeps the cursor exact.
2169        let mut it = decoded.iterator();
2170        let expected = expected_cursors(&docs);
2171        for (i, &(doc, _)) in docs.iter().enumerate().step_by(37) {
2172            assert_eq!(it.seek(doc), doc);
2173            assert_eq!(it.position_cursor(), expected[i], "cursor at doc {doc}");
2174        }
2175        let mut it = decoded.iterator();
2176        assert_eq!(it.seek(docs[600].0 + 1), docs[601].0);
2177        assert_eq!(it.position_cursor(), expected[601]);
2178
2179        // Lists without positions carry no cursors (the iterator's prefix
2180        // sum is then relative to nothing and never consulted).
2181        let plain = build_bpl(&docs);
2182        assert!(!plain.has_position_cursors());
2183        assert_eq!(plain.pos_cursor(0), None);
2184        assert_eq!(plain.total_positions(), 0);
2185    }
2186
2187    #[test]
2188    fn length_bounds_are_packed_per_block_and_survive_merges() {
2189        let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i, i % 3 + 1)).collect();
2190        let length_of = |doc: u32| 10 + (doc % 50) * 7;
2191        let mut list = PostingList::new();
2192        for &(doc, tf) in &docs {
2193            list.push(doc, tf);
2194        }
2195        let bpl = BlockPostingList::from_posting_list_with(&list, true, Some(&length_of)).unwrap();
2196        assert_eq!(bpl.min_len(), Some(10));
2197        assert_eq!(bpl.block_bounds(0), Some((3, Some(10))));
2198        // Block 2 covers docs 256..300: min length there is doc 256 (256 % 50 = 6 → 52).
2199        assert_eq!(bpl.block_bounds(2), Some((3, Some(52))));
2200        assert_eq!(bpl.block_max_tf(2), Some(3));
2201
2202        let bytes = serialize_bpl(&bpl);
2203        let decoded = BlockPostingList::deserialize(&bytes).unwrap();
2204        assert_eq!(decoded.min_len(), Some(10));
2205        assert_eq!(decoded.block_bounds(2), Some((3, Some(52))));
2206        // Superblock bounds: one group of three blocks here, max tf 3 and
2207        // the smallest length of the whole list.
2208        assert_eq!(decoded.group_bounds(0), Some((3, 10)));
2209        assert_eq!(decoded.group_bounds(2), Some((3, 10)));
2210        assert_eq!(decoded.group_bounds(3), None);
2211        assert_eq!(decoded.group_last_doc(1), Some(299));
2212        assert_eq!(decoded.next_group_block(1), 3);
2213
2214        // Without lengths the minimum is 1, which every real unit satisfies.
2215        let plain = build_bpl(&docs);
2216        assert_eq!(plain.min_len(), Some(1));
2217        assert_eq!(plain.block_bounds(0), Some((3, Some(1))));
2218
2219        // Streaming merge keeps per-block bounds and takes the list minimum.
2220        let mut out = Vec::new();
2221        BlockPostingList::concatenate_streaming(&[(&bytes, 0), (&bytes, 1000)], &mut out).unwrap();
2222        let merged = BlockPostingList::deserialize(&out).unwrap();
2223        assert_eq!(merged.min_len(), Some(10));
2224        assert_eq!(merged.block_bounds(2), Some((3, Some(52))));
2225        assert_eq!(merged.block_bounds(3), Some((3, Some(10))));
2226        assert_eq!(merged.block_max_tf(5), Some(3));
2227        // Six blocks: one full group of eight would need more; here both
2228        // lists' blocks share group 0.
2229        assert_eq!(merged.group_bounds(5), Some((3, 10)));
2230        assert_eq!(merged.group_last_doc(5), Some(1299));
2231        assert_eq!(merged.next_group_block(5), 6);
2232    }
2233
2234    #[test]
2235    fn legacy_footer_without_magic_still_deserializes() {
2236        let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 2, 1 + i % 3)).collect();
2237        let bpl = build_bpl(&docs);
2238        let bytes = serialize_bpl(&bpl);
2239        // A pre-magic list is the same bytes without the 16-byte extension.
2240        let legacy = bytes[..bytes.len() - (FOOTER_V2_SIZE - FOOTER_SIZE)].to_vec();
2241        assert!(!BlockPostingList::has_cursors_bytes(&legacy));
2242        // Legacy lists carry an f32 max tf per block and no lengths.
2243        let decoded = BlockPostingList::deserialize(&legacy).unwrap();
2244        assert_eq!(collect_postings(&decoded), docs);
2245        assert_eq!(decoded.max_tf(), 3);
2246        assert!(!decoded.has_position_cursors());
2247        assert_eq!(decoded.min_len(), None);
2248        assert_eq!(decoded.group_bounds(0), None);
2249        // And the legacy bytes concatenate into a current-format list.
2250        let mut out = Vec::new();
2251        let (count, written) =
2252            BlockPostingList::concatenate_streaming(&[(&legacy, 0), (&legacy, 1000)], &mut out)
2253                .unwrap();
2254        assert_eq!(count, 600);
2255        assert_eq!(written, out.len());
2256        let merged = BlockPostingList::deserialize(&out).unwrap();
2257        assert_eq!(merged.doc_count(), 600);
2258        assert!(!merged.has_position_cursors());
2259    }
2260
2261    #[test]
2262    fn streaming_merge_rebases_position_cursors() {
2263        let a: Vec<(u32, u32)> = (0..200u32).map(|i| (i, i % 4 + 1)).collect();
2264        let b: Vec<(u32, u32)> = (0..150u32).map(|i| (i * 2, 2)).collect();
2265        let bytes_a = serialize_bpl(&build_bpl_with_positions(&a));
2266        let bytes_b = serialize_bpl(&build_bpl_with_positions(&b));
2267        let mut out = Vec::new();
2268        let (count, written) =
2269            BlockPostingList::concatenate_streaming(&[(&bytes_a, 0), (&bytes_b, 1000)], &mut out)
2270                .unwrap();
2271        assert_eq!(count, 350);
2272        assert_eq!(written, out.len());
2273        let merged = BlockPostingList::deserialize(&out).unwrap();
2274        assert!(merged.has_position_cursors());
2275        let all: Vec<(u32, u32)> = a
2276            .iter()
2277            .copied()
2278            .chain(b.iter().map(|&(d, tf)| (d + 1000, tf)))
2279            .collect();
2280        assert_eq!(collect_postings(&merged), all);
2281        assert_eq!(iterator_cursors(&merged), expected_cursors(&all));
2282        assert_eq!(
2283            merged.total_positions(),
2284            all.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
2285        );
2286        // The in-memory reference agrees.
2287        let reference = BlockPostingList::concatenate_blocks(&[
2288            (build_bpl_with_positions(&a), 0),
2289            (build_bpl_with_positions(&b), 1000),
2290        ])
2291        .unwrap();
2292        assert_eq!(iterator_cursors(&reference), expected_cursors(&all));
2293        // Mixing lists with and without cursors is refused.
2294        let plain = serialize_bpl(&build_bpl(&b));
2295        assert!(
2296            BlockPostingList::concatenate_streaming(
2297                &[(&bytes_a, 0), (&plain, 1000)],
2298                &mut Vec::new()
2299            )
2300            .is_err()
2301        );
2302    }
2303
2304    #[test]
2305    fn test_seek_block_from_block_skips_earlier() {
2306        // 16 blocks: seek with from_block should skip earlier blocks
2307        let n = BLOCK_SIZE * 16;
2308        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
2309        let bpl = build_bpl(&docs);
2310
2311        // Target is in block 5, but from_block=8 → should find block >= 8
2312        let target_in_5 = bpl.block_first_doc(5).unwrap() + 1;
2313        // from_block=8 means we only look at blocks 8+
2314        // target_in_5 < last_doc of block 8, so seek_block(target, 8) should return 8
2315        let result = bpl.seek_block(target_in_5, 8);
2316        assert!(result.is_some());
2317        assert!(result.unwrap() >= 8);
2318    }
2319}