Skip to main content

summa_core/structures/postings/
posting.rs

1//! Posting list implementation with compact representation
2//!
3//! Text blocks hold 128 postings: delta-coded doc ids followed by term
4//! frequencies, each array encoded by one of the [`PostingCodec`]s
5//! (`docs/posting-codecs.md`):
6//! - `Rounded` (default): widths rounded to 0/8/16/32 bits, SIMD widening
7//! - `Packed`: exact bit widths (BP128 style)
8//! - `Pfor`: exact width with patched exceptions (OptP4D style)
9//! - `Simd4x`: four-lane library packing and integrated strict document deltas
10//!
11//! The codec is stored per block in the header, so a single list (for example
12//! the output of a merge) may mix codecs.
13
14mod groups;
15mod impacts;
16mod reader;
17mod validation;
18use groups::GroupWords;
19use impacts::{ImpactBuilder, ImpactTable};
20
21pub(crate) use reader::{DeferredPosting, PostingListReader};
22
23#[cfg(feature = "native")]
24mod compact;
25#[cfg(feature = "native")]
26pub(crate) use compact::{PostingBlockSource, PostingStreamWriter};
27
28use byteorder::{LittleEndian, WriteBytesExt};
29use std::io::{self, Write};
30
31use super::bitpacking4x;
32use super::horizontal_bp128::{pack_block_n as pack_bits, unpack_block_n as unpack_bits};
33use super::opt_p4d::{find_optimal_bit_width, pack_with_exceptions};
34use crate::DocId;
35use crate::directories::OwnedBytes;
36use crate::structures::simd;
37
38/// Encoding of the packed arrays inside one posting block.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PostingCodec {
42    /// Widths rounded up to 0/8/16/32 bits; decodes with plain SIMD widening.
43    /// This is the low-overhead performance baseline.
44    #[default]
45    Rounded = 0,
46    /// Exact bit widths (BP128 style): ~1.8× smaller than `Rounded` on the
47    /// repository benchmark for ~10 % slower decoding.
48    Packed = 1,
49    /// Exact width with up to 10 % patched exceptions (OptP4D style):
50    /// smallest, ~30 % slower decoding than `Rounded`.
51    Pfor = 2,
52    /// Library SIMD packing of full blocks with exact horizontal tails.
53    /// Documents use gap-minus-one values; positions use the same block policy.
54    Simd4x = 3,
55}
56
57impl PostingCodec {
58    /// Codec id stored in the top two bits of the block header's `doc_bits`.
59    const HEADER_SHIFT: u32 = 6;
60    const WIDTH_MASK: u8 = 0x3F;
61
62    /// The two-bit id field is fully assigned. A fifth codec cannot be
63    /// signalled in the block header: it needs a footer flag plus an
64    /// `INDEX_META_FORMAT_VERSION` bump (see `docs/posting-codecs.md`).
65    fn from_header_byte(doc_bits: u8) -> io::Result<(Self, u8)> {
66        let width = doc_bits & Self::WIDTH_MASK;
67        let codec = match doc_bits >> Self::HEADER_SHIFT {
68            0 => PostingCodec::Rounded,
69            1 => PostingCodec::Packed,
70            2 => PostingCodec::Pfor,
71            _ => PostingCodec::Simd4x,
72        };
73        if width > 32 {
74            return Err(io::Error::new(
75                io::ErrorKind::InvalidData,
76                format!("posting block doc-id width {width} exceeds 32 bits"),
77            ));
78        }
79        Ok((codec, width))
80    }
81
82    /// The four-lane kernel requires a full block. Existing rounded tails
83    /// avoid scalar bit extraction on short runs preserved by normal merge.
84    pub(super) fn for_count(self, count: usize) -> Self {
85        if self == Self::Simd4x && count < BLOCK_SIZE {
86            Self::Rounded
87        } else {
88            self
89        }
90    }
91
92    fn header_byte(self, width: u8) -> u8 {
93        ((self as u8) << Self::HEADER_SHIFT) | width
94    }
95
96    pub fn parse(s: &str) -> Option<Self> {
97        match s.to_ascii_lowercase().as_str() {
98            "rounded" | "default" => Some(PostingCodec::Rounded),
99            "packed" | "bp128" | "exact" => Some(PostingCodec::Packed),
100            "pfor" | "optp4d" | "patched" => Some(PostingCodec::Pfor),
101            "simd4x" => Some(PostingCodec::Simd4x),
102            _ => None,
103        }
104    }
105}
106
107impl std::fmt::Display for PostingCodec {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(match self {
110            PostingCodec::Rounded => "rounded",
111            PostingCodec::Packed => "packed",
112            PostingCodec::Pfor => "pfor",
113            PostingCodec::Simd4x => "simd4x",
114        })
115    }
116}
117
118// ── Exact-width bit packing (Packed codec) ───────────────────────────────
119
120/// Bytes needed for `count` values at `width` bits.
121#[inline]
122fn packed_bytes(count: usize, width: u8) -> usize {
123    (count * width as usize).div_ceil(8)
124}
125
126// ── Patched packing (Pfor codec) ─────────────────────────────────────────
127
128/// Payload of one `Pfor` array: `[n_exceptions u8][packed low bits][(pos u8, high u32) × n]`.
129fn pack_pfor(values: &[u32], out: &mut Vec<u8>) -> u8 {
130    let (width, _, _) = find_optimal_bit_width(values);
131    let (packed, exceptions) = pack_with_exceptions(values, width);
132    out.push(exceptions.len() as u8);
133    out.extend_from_slice(&packed);
134    for (pos, high) in exceptions {
135        out.push(pos);
136        out.extend_from_slice(&high.to_le_bytes());
137    }
138    width
139}
140
141/// Byte length of a `Pfor` array payload for `count` values at `width`.
142fn pfor_payload_len(input: &[u8], count: usize, width: u8) -> io::Result<usize> {
143    let n_exceptions = *input
144        .first()
145        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "posting block truncated"))?
146        as usize;
147    Ok(1 + packed_bytes(count, width) + n_exceptions * 5)
148}
149
150/// Decode one `Pfor` array: the packed low bits, then each `(pos, high)`
151/// exception patched in place straight from the table. No per-decode scratch.
152fn unpack_pfor(input: &[u8], width: u8, out: &mut [u32], count: usize) -> io::Result<()> {
153    let n_exceptions = *input
154        .first()
155        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "posting block truncated"))?
156        as usize;
157    let table_at = 1 + packed_bytes(count, width);
158    let table_end = table_at + n_exceptions * 5;
159    if input.len() < table_end {
160        return Err(io::Error::new(
161            io::ErrorKind::InvalidData,
162            "posting block exception table truncated",
163        ));
164    }
165    let out = &mut out[..count];
166    unpack_bits(&input[1..table_at], width, out, count);
167    if width < 32 {
168        for entry in input[table_at..table_end].chunks_exact(5) {
169            let pos = entry[0] as usize;
170            let high = u32::from_le_bytes([entry[1], entry[2], entry[3], entry[4]]);
171            if pos < count {
172                out[pos] |= high << width;
173            }
174        }
175    }
176    Ok(())
177}
178
179/// A posting entry containing doc_id and term frequency
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct Posting {
182    pub doc_id: DocId,
183    pub term_freq: u32,
184}
185
186/// Compact posting list with delta encoding
187#[derive(Debug, Clone, Default)]
188pub struct PostingList {
189    postings: Vec<Posting>,
190}
191
192impl PostingList {
193    pub fn new() -> Self {
194        Self::default()
195    }
196
197    pub fn with_capacity(capacity: usize) -> Self {
198        Self {
199            postings: Vec::with_capacity(capacity),
200        }
201    }
202
203    /// Add a posting (must be added in doc_id order)
204    pub fn push(&mut self, doc_id: DocId, term_freq: u32) {
205        debug_assert!(
206            self.postings.is_empty() || self.postings.last().unwrap().doc_id < doc_id,
207            "Postings must be added in sorted order"
208        );
209        self.postings.push(Posting { doc_id, term_freq });
210    }
211
212    /// Add a posting, incrementing term_freq if doc already exists
213    pub fn add(&mut self, doc_id: DocId, term_freq: u32) {
214        if let Some(last) = self.postings.last_mut()
215            && last.doc_id == doc_id
216        {
217            last.term_freq += term_freq;
218            return;
219        }
220        self.postings.push(Posting { doc_id, term_freq });
221    }
222
223    /// Get document count
224    pub fn doc_count(&self) -> u32 {
225        self.postings.len() as u32
226    }
227
228    pub fn len(&self) -> usize {
229        self.postings.len()
230    }
231
232    pub fn is_empty(&self) -> bool {
233        self.postings.is_empty()
234    }
235
236    pub fn iter(&self) -> impl Iterator<Item = &Posting> {
237        self.postings.iter()
238    }
239}
240
241/// Iterator over posting list that supports seeking
242pub struct PostingListIterator<'a> {
243    postings: &'a [Posting],
244    position: usize,
245}
246
247impl<'a> PostingListIterator<'a> {
248    pub fn new(posting_list: &'a PostingList) -> Self {
249        Self {
250            postings: &posting_list.postings,
251            position: 0,
252        }
253    }
254
255    /// Current document ID, or TERMINATED if exhausted
256    pub fn doc(&self) -> DocId {
257        if self.position < self.postings.len() {
258            self.postings[self.position].doc_id
259        } else {
260            TERMINATED
261        }
262    }
263
264    /// Current term frequency
265    pub fn term_freq(&self) -> u32 {
266        if self.position < self.postings.len() {
267            self.postings[self.position].term_freq
268        } else {
269            0
270        }
271    }
272
273    /// Advance to next posting, returns new doc_id or TERMINATED
274    pub fn advance(&mut self) -> DocId {
275        self.position += 1;
276        self.doc()
277    }
278
279    /// Seek to first doc_id >= target (binary search on remaining postings)
280    pub fn seek(&mut self, target: DocId) -> DocId {
281        crate::observe::search_work!(posting_seeks += 1);
282        let remaining = &self.postings[self.position..];
283        let offset = remaining.partition_point(|p| p.doc_id < target);
284        self.position += offset;
285        self.doc()
286    }
287
288    /// Size hint for remaining elements
289    pub fn size_hint(&self) -> usize {
290        self.postings.len().saturating_sub(self.position)
291    }
292}
293
294/// Sentinel value indicating iterator is exhausted
295pub const TERMINATED: DocId = DocId::MAX;
296
297/// Block-based posting list with 2-level skip index.
298///
299/// Each block contains up to `BLOCK_SIZE` postings encoded as packed bit-width arrays.
300/// Skip entries use a compact 2-level structure for cache-friendly seeking:
301/// - **Level-0** (16 bytes/block): `first_doc`, `last_doc`, `offset`, `max_weight`
302/// - **Level-1** (4 bytes/group): `last_doc` per `L1_INTERVAL` blocks
303///
304/// Seek algorithm: binary search L1, then linear scan ≤`L1_INTERVAL` L0 entries.
305pub const BLOCK_SIZE: usize = 128;
306
307/// Number of L0 blocks per L1 skip entry.
308const L1_INTERVAL: usize = 8;
309
310/// Compact level-0 skip entry — 16 bytes.
311/// `length` is omitted: computable from the block's 8-byte header.
312const L0_SIZE: usize = 16;
313
314/// Level-1 skip entry — 4 bytes (just `last_doc`).
315const L1_SIZE: usize = 4;
316
317/// Legacy footer: stream_len(8) + l0_count(4) + l1_count(4) + doc_count(4) + max_tf(4) = 24 bytes.
318const FOOTER_SIZE: usize = 24;
319
320/// Current footer: the legacy footer followed by `total_positions(8) +
321/// flags(4) + min_len(4) + magic(4)`. A list ends with the magic iff it has
322/// the extended footer; a legacy footer ends with `max_tf`, which the u16
323/// term frequency of the builder keeps far below the magic, so both forms
324/// remain readable.
325const FOOTER_V2_SIZE: usize = FOOTER_SIZE + 20;
326
327/// "BPL2" little-endian.
328const FOOTER_MAGIC: u32 = 0x324C_5042;
329
330/// Footer flag: a `u64` position cursor per L0 block follows the L1 entries.
331const FLAG_POS_CURSORS: u32 = 1;
332
333/// Footer flag: the fourth L0 word packs `max_tf` (low 16 bits) and the
334/// block's minimum scoring-unit length (high 16 bits) instead of an `f32`
335/// max tf, so a block bound can use real length normalisation.
336const FLAG_LEN_BOUNDS: u32 = 2;
337
338/// Footer flag: a packed `(max_tf, min_len)` word per L1 group follows the
339/// L1 `last_doc` entries (superblock bounds: the maximum and minimum over
340/// the group's blocks), so an executor can skip eight blocks at once.
341const FLAG_L1_BOUNDS: u32 = 4;
342
343/// Optional downward-rounded length/TF minima, L0 then L1, after cursors.
344const FLAG_RATIO_BOUNDS: u32 = 8;
345
346/// Optional complete frequency/length envelopes after ratio metadata.
347const FLAG_IMPACT_BOUNDS: u32 = 16;
348/// Combined impact directory: L0 records followed by L1 group records.
349const FLAG_GROUP_IMPACT_BOUNDS: u32 = 32;
350const FLAG_COMPACT_HEADERS: u32 = 64;
351const FLAG_SHORT_CURSORS: u32 = 128;
352
353fn append_ratio_groups(ratios: &mut Vec<u8>, blocks: usize) {
354    for start in (0..blocks).step_by(L1_INTERVAL) {
355        let ratio = (start..(start + L1_INTERVAL).min(blocks))
356            .map(|i| read_ratio(ratios, i))
357            .fold(f32::INFINITY, f32::min);
358        ratios.extend_from_slice(&ratio.to_le_bytes());
359    }
360}
361
362#[inline]
363fn read_ratio(bytes: &[u8], index: usize) -> f32 {
364    let at = index * 4;
365    f32::from_le_bytes(bytes[at..at + 4].try_into().unwrap())
366}
367
368fn validate_ratios(bytes: &[u8]) -> io::Result<()> {
369    if bytes.chunks_exact(4).any(|value| {
370        let ratio = f32::from_le_bytes(value.try_into().unwrap());
371        !ratio.is_finite() || ratio < 0.0
372    }) {
373        return Err(io::Error::new(
374            io::ErrorKind::InvalidData,
375            "invalid posting ratio bound",
376        ));
377    }
378    Ok(())
379}
380
381fn lower_length_ratio(length: u32, tf: u32) -> f32 {
382    if tf == 0 {
383        return 0.0;
384    }
385    // f64 represents both inputs exactly. One f32 step down covers division
386    // and conversion rounding, including an exactly representable quotient.
387    ((length as f64 / tf as f64) as f32).next_down().max(0.0)
388}
389
390/// Superblock bounds derived from packed L0 words: per `L1_INTERVAL` group
391/// the maximum `max_tf` and minimum `min_len` of its blocks.
392fn group_bounds_from_l0(l0: &[u8], l0_count: usize) -> Vec<u32> {
393    let mut groups = Vec::with_capacity(l0_count.div_ceil(L1_INTERVAL));
394    let mut idx = 0;
395    while idx < l0_count {
396        let end = (idx + L1_INTERVAL).min(l0_count);
397        let mut max_tf = 0u32;
398        let mut min_len = u32::MAX;
399        for block in idx..end {
400            let (_, _, _, word) = read_l0(l0, block);
401            let (tf, len) = unpack_bounds(word, true);
402            max_tf = max_tf.max(tf);
403            min_len = min_len.min(len.unwrap_or(1));
404        }
405        groups.push(pack_bounds(max_tf, min_len));
406        idx = end;
407    }
408    groups
409}
410
411/// Pack block bounds into the fourth L0 word (both saturate at u16).
412#[inline]
413fn pack_bounds(max_tf: u32, min_len: u32) -> u32 {
414    max_tf.min(u16::MAX as u32) | (min_len.min(u16::MAX as u32) << 16)
415}
416
417/// Unpack the fourth L0 word: `(max_tf, min_len)`; `min_len` is `None` for
418/// legacy lists whose word is an `f32` max tf.
419#[inline]
420fn unpack_bounds(word: u32, packed: bool) -> (u32, Option<u32>) {
421    if packed {
422        (word & 0xFFFF, Some(word >> 16))
423    } else {
424        (f32::from_bits(word) as u32, None)
425    }
426}
427
428/// Size of one position cursor (`u64`: values before the block in the
429/// term's position stream).
430const CURSOR_SIZE: usize = 8;
431
432/// Parsed footer of either format plus the derived section layout.
433#[derive(Debug, Clone, Copy)]
434struct Footer {
435    compact_headers: bool,
436    short_cursors: bool,
437    stream_len: usize,
438    l0_count: usize,
439    l1_count: usize,
440    doc_count: u32,
441    max_tf: u32,
442    total_positions: u64,
443    has_cursors: bool,
444    len_bounds: bool,
445    l1_bounds: bool,
446    ratio_bounds: bool,
447    impact_bounds: bool,
448    group_impact_bounds: bool,
449    min_len: u32,
450}
451
452impl Footer {
453    fn parse(raw: &[u8]) -> io::Result<Self> {
454        Self::parse_tail(raw, raw.len())
455    }
456
457    fn parse_tail(raw: &[u8], total_len: usize) -> io::Result<Self> {
458        if raw.len() < FOOTER_SIZE {
459            return Err(io::Error::new(
460                io::ErrorKind::InvalidData,
461                "posting data too short",
462            ));
463        }
464        let extended = raw.len() >= FOOTER_V2_SIZE
465            && u32::from_le_bytes(raw[raw.len() - 4..].try_into().unwrap()) == FOOTER_MAGIC;
466        let f = raw.len()
467            - if extended {
468                FOOTER_V2_SIZE
469            } else {
470                FOOTER_SIZE
471            };
472        let stream_len = usize::try_from(u64::from_le_bytes(raw[f..f + 8].try_into().unwrap()))
473            .map_err(|_| {
474                io::Error::new(
475                    io::ErrorKind::InvalidData,
476                    "posting stream exceeds address space",
477                )
478            })?;
479        let l0_count = u32::from_le_bytes(raw[f + 8..f + 12].try_into().unwrap()) as usize;
480        let l1_count = u32::from_le_bytes(raw[f + 12..f + 16].try_into().unwrap()) as usize;
481        let doc_count = u32::from_le_bytes(raw[f + 16..f + 20].try_into().unwrap());
482        let max_tf = u32::from_le_bytes(raw[f + 20..f + 24].try_into().unwrap());
483        let (total_positions, flags, min_len) = if extended {
484            let total = u64::from_le_bytes(raw[f + 24..f + 32].try_into().unwrap());
485            let flags = u32::from_le_bytes(raw[f + 32..f + 36].try_into().unwrap());
486            let min_len = u32::from_le_bytes(raw[f + 36..f + 40].try_into().unwrap());
487            (total, flags, min_len)
488        } else {
489            (0, 0, 0)
490        };
491        if flags
492            & !(FLAG_POS_CURSORS
493                | FLAG_LEN_BOUNDS
494                | FLAG_L1_BOUNDS
495                | FLAG_RATIO_BOUNDS
496                | FLAG_IMPACT_BOUNDS
497                | FLAG_GROUP_IMPACT_BOUNDS
498                | FLAG_COMPACT_HEADERS
499                | FLAG_SHORT_CURSORS)
500            != 0
501        {
502            return Err(io::Error::new(
503                io::ErrorKind::InvalidData,
504                "unknown posting footer flags",
505            ));
506        }
507        let footer = Self {
508            compact_headers: flags & FLAG_COMPACT_HEADERS != 0,
509            short_cursors: flags & FLAG_SHORT_CURSORS != 0,
510            stream_len,
511            l0_count,
512            l1_count,
513            doc_count,
514            max_tf,
515            total_positions,
516            has_cursors: flags & FLAG_POS_CURSORS != 0,
517            len_bounds: flags & FLAG_LEN_BOUNDS != 0,
518            l1_bounds: flags & FLAG_L1_BOUNDS != 0,
519            ratio_bounds: flags & FLAG_RATIO_BOUNDS != 0,
520            impact_bounds: flags & FLAG_IMPACT_BOUNDS != 0,
521            group_impact_bounds: flags & FLAG_GROUP_IMPACT_BOUNDS != 0,
522            min_len,
523        };
524        if footer.short_cursors && (!footer.has_cursors || footer.total_positions > u32::MAX as u64)
525        {
526            return Err(io::Error::new(
527                io::ErrorKind::InvalidData,
528                "invalid short position cursors",
529            ));
530        }
531        if footer.group_impact_bounds && (!footer.impact_bounds || !footer.l1_bounds) {
532            return Err(io::Error::new(
533                io::ErrorKind::InvalidData,
534                "group impacts require L0 impacts and L1 bounds",
535            ));
536        }
537        let end = l0_count
538            .checked_mul(L0_SIZE + if footer.compact_headers { 4 } else { 0 })
539            .and_then(|n| {
540                l1_count
541                    .checked_mul(L1_SIZE + if footer.l1_bounds { 4 } else { 0 })
542                    .and_then(|m| n.checked_add(m))
543            })
544            .and_then(|n| {
545                l0_count
546                    .checked_mul(if footer.has_cursors {
547                        footer.cursor_size()
548                    } else {
549                        0
550                    })
551                    .and_then(|m| n.checked_add(m))
552            })
553            .and_then(|n| {
554                if footer.ratio_bounds {
555                    l0_count
556                        .checked_add(l1_count)
557                        .and_then(|m| m.checked_mul(4))
558                        .and_then(|m| n.checked_add(m))
559                } else {
560                    Some(n)
561                }
562            })
563            .and_then(|n| n.checked_add(stream_len));
564        let footer_offset = total_len.saturating_sub(raw.len() - f);
565        if end.is_none_or(|end| {
566            if footer.impact_bounds {
567                !footer.len_bounds
568                    || !footer.ratio_bounds
569                    || l0_count
570                        .checked_add(if footer.group_impact_bounds {
571                            l1_count
572                        } else {
573                            0
574                        })
575                        .and_then(|n| n.checked_add(1))
576                        .and_then(|n| n.checked_mul(4))
577                        .and_then(|n| n.checked_add(end))
578                        .is_none_or(|minimum| minimum > footer_offset)
579            } else {
580                end != footer_offset
581            }
582        }) {
583            return Err(io::Error::new(
584                io::ErrorKind::InvalidData,
585                "posting list sections do not match the footer offset",
586            ));
587        }
588        Ok(footer)
589    }
590
591    fn l0_start(&self) -> usize {
592        self.stream_len
593    }
594    fn impact_record_count(&self) -> usize {
595        self.l0_count
596            + if self.group_impact_bounds {
597                self.l1_count
598            } else {
599                0
600            }
601    }
602    fn l0_end(&self) -> usize {
603        self.l0_start() + self.l0_count * L0_SIZE
604    }
605    fn l1_start(&self) -> usize {
606        self.l0_end()
607            + if self.compact_headers {
608                self.l0_count * 4
609            } else {
610                0
611            }
612    }
613    fn cursor_size(&self) -> usize {
614        if self.short_cursors { 4 } else { CURSOR_SIZE }
615    }
616    fn l1_end(&self) -> usize {
617        self.l1_start() + self.l1_count * L1_SIZE
618    }
619    fn l1_bounds_end(&self) -> usize {
620        self.l1_end() + if self.l1_bounds { self.l1_count * 4 } else { 0 }
621    }
622    fn ratios_end(&self) -> usize {
623        self.cursors_end()
624            + if self.ratio_bounds {
625                (self.l0_count + self.l1_count) * 4
626            } else {
627                0
628            }
629    }
630    fn cursors_end(&self) -> usize {
631        self.l1_bounds_end()
632            + if self.has_cursors {
633                self.l0_count * self.cursor_size()
634            } else {
635                0
636            }
637    }
638}
639
640/// Read a compact L0 entry from raw bytes at the given index: `(first_doc,
641/// last_doc, offset, bounds word)`. The bounds word is packed `(max_tf,
642/// min_len)` for current lists and an `f32` max tf for legacy ones; see
643/// [`unpack_bounds`].
644///
645/// Uses a single bounds check (`[..L0_SIZE]`) instead of 4× `try_into().unwrap()`.
646#[inline]
647fn read_l0(bytes: &[u8], idx: usize) -> (u32, u32, u32, u32) {
648    let b = &bytes[idx * L0_SIZE..][..L0_SIZE];
649    let first_doc = u32::from_le_bytes([b[0], b[1], b[2], b[3]]);
650    let last_doc = u32::from_le_bytes([b[4], b[5], b[6], b[7]]);
651    let offset = u32::from_le_bytes([b[8], b[9], b[10], b[11]]);
652    let bounds = u32::from_le_bytes([b[12], b[13], b[14], b[15]]);
653    (first_doc, last_doc, offset, bounds)
654}
655
656/// Content check that structural admission cannot make without decoding:
657/// a block's ids must be strictly increasing and span exactly its L0 range.
658/// Branch-free so the 128-value pass vectorises on the decode path.
659#[inline]
660fn verify_block_docs(
661    docs: &[u32],
662    first: u32,
663    last: u32,
664    byte_gaps: Option<&[u8]>,
665    strict_gap_width: Option<u8>,
666) -> bool {
667    let ordered = if strict_gap_width.is_some_and(|width| width <= 25) {
668        // Gap-minus-one guarantees positive gaps. At most 127 gaps of at
669        // most 2^25 sum to less than 2^32: a wrap would put the endpoint
670        // below the start. Ordered matching endpoints prove every prefix.
671        // Full blocks' reserved first gap is checked before this call.
672        debug_assert!(docs.len() <= BLOCK_SIZE);
673        true
674    } else if let Some(gaps) = byte_gaps {
675        // At most 127 byte-sized gaps: their sum cannot wrap a u32 more than
676        // once. Nonzero gaps and matching ordered endpoints therefore prove
677        // strict ordering without re-reading the decoded u32 array.
678        debug_assert_eq!(gaps.len() + 1, docs.len());
679        let mut nonzero = true;
680        for &gap in gaps {
681            nonzero &= gap != 0;
682        }
683        nonzero
684    } else {
685        let mut ordered = true;
686        for pair in docs.windows(2) {
687            ordered &= pair[0] < pair[1];
688        }
689        ordered
690    };
691    ordered
692        && first <= last
693        && last != TERMINATED
694        && docs.first() == Some(&first)
695        && docs.last() == Some(&last)
696}
697
698/// Write a compact L0 entry.
699#[inline]
700fn write_l0(buf: &mut Vec<u8>, first_doc: u32, last_doc: u32, offset: u32, bounds: u32) {
701    buf.extend_from_slice(&first_doc.to_le_bytes());
702    buf.extend_from_slice(&last_doc.to_le_bytes());
703    buf.extend_from_slice(&offset.to_le_bytes());
704    buf.extend_from_slice(&bounds.to_le_bytes());
705}
706
707/// Byte length of block `idx` from the L0 offsets: the next block's offset
708/// (or the stream end) minus this block's offset. Header-independent, so a
709/// block payload may carry codec-specific variable-length data.
710#[inline]
711fn block_len_from_l0(l0_bytes: &[u8], l0_count: usize, stream_len: usize, idx: usize) -> usize {
712    let (_, _, offset, _) = read_l0(l0_bytes, idx);
713    let end = if idx + 1 < l0_count {
714        read_l0(l0_bytes, idx + 1).2 as usize
715    } else {
716        stream_len
717    };
718    end.saturating_sub(offset as usize)
719}
720
721/// Encoded doc-id delta array and tf array of one block, with the header
722/// width bytes to store for them.
723struct EncodedBlock {
724    doc_bits: u8,
725    tf_bits: u8,
726}
727
728/// Append the packed arrays of one block to `stream` using `codec`.
729fn encode_block_arrays(
730    codec: PostingCodec,
731    deltas: &[u32],
732    tfs: &[u32],
733    stream: &mut Vec<u8>,
734) -> EncodedBlock {
735    let codec = codec.for_count(tfs.len());
736    match codec {
737        PostingCodec::Simd4x => EncodedBlock {
738            doc_bits: codec.header_byte(bitpacking4x::encode_gaps(deltas, stream)),
739            tf_bits: bitpacking4x::encode(tfs, stream),
740        },
741        PostingCodec::Rounded => {
742            let max_delta = deltas.iter().copied().max().unwrap_or(0);
743            let doc_bits = simd::round_bit_width(simd::bits_needed(max_delta));
744            let max_tf = tfs.iter().copied().max().unwrap_or(0);
745            let tf_bits = simd::round_bit_width(simd::bits_needed(max_tf));
746            if !deltas.is_empty() {
747                let rounded = simd::RoundedBitWidth::from_u8(doc_bits);
748                let start = stream.len();
749                stream.resize(start + deltas.len() * rounded.bytes_per_value(), 0);
750                simd::pack_rounded(deltas, rounded, &mut stream[start..]);
751            }
752            {
753                let rounded = simd::RoundedBitWidth::from_u8(tf_bits);
754                let start = stream.len();
755                stream.resize(start + tfs.len() * rounded.bytes_per_value(), 0);
756                simd::pack_rounded(tfs, rounded, &mut stream[start..]);
757            }
758            EncodedBlock {
759                doc_bits: codec.header_byte(doc_bits),
760                tf_bits,
761            }
762        }
763        PostingCodec::Packed => {
764            let max_delta = deltas.iter().copied().max().unwrap_or(0);
765            let doc_bits = simd::bits_needed(max_delta);
766            let max_tf = tfs.iter().copied().max().unwrap_or(0);
767            let tf_bits = simd::bits_needed(max_tf);
768            pack_bits(deltas, doc_bits, stream);
769            pack_bits(tfs, tf_bits, stream);
770            EncodedBlock {
771                doc_bits: codec.header_byte(doc_bits),
772                tf_bits,
773            }
774        }
775        PostingCodec::Pfor => {
776            let doc_bits = if deltas.is_empty() {
777                0
778            } else {
779                pack_pfor(deltas, stream)
780            };
781            let tf_bits = pack_pfor(tfs, stream);
782            EncodedBlock {
783                doc_bits: codec.header_byte(doc_bits),
784                tf_bits,
785            }
786        }
787    }
788}
789
790#[derive(Debug, Clone)]
791pub struct BlockPostingList {
792    compact_headers: bool,
793    short_cursors: bool,
794    /// Explicit deserialization checks decoded ordering; segment queries trust the writer.
795    verify_content: bool,
796    /// First decoding failure detected in this immutable segment reader.
797    content_error: Option<std::sync::Arc<reader::PostingIntegrity>>,
798    /// Block data stream (packed blocks laid out sequentially).
799    stream: OwnedBytes,
800    /// Level-0 skip entries: `(first_doc, last_doc, offset, max_weight)` × `l0_count`.
801    /// 16 bytes per entry, followed by compact descriptors when present.
802    /// Supports O(1) random access without another reference-counted slice.
803    l0_bytes: OwnedBytes,
804    /// Number of blocks (= number of L0 entries).
805    l0_count: usize,
806    /// Level-1 skip `last_doc` values — one per `L1_INTERVAL` blocks.
807    /// Borrowed little-endian words; opening does not copy the group directory.
808    l1_docs: GroupWords,
809    /// Packed `(max_tf, min_len)` per L1 group (superblock bounds); empty
810    /// for legacy lists.
811    l1_bounds: GroupWords,
812    /// Optional L0 then L1 length/TF ratio minima, borrowed from index bytes.
813    ratios: Option<OwnedBytes>,
814    /// Validated borrowed offsets and compact integer envelope records.
815    impacts: Option<ImpactTable>,
816    /// Total posting count.
817    doc_count: u32,
818    /// Max TF across all blocks.
819    max_tf: u32,
820    /// Per-block position cursors (`u64` × `l0_count`): number of values in
821    /// the term's position stream before the block. `None` for terms
822    /// without positions and for legacy lists.
823    pos_cursors: Option<OwnedBytes>,
824    /// Sum of term frequencies (= values in the position stream) when
825    /// cursors are present.
826    total_positions: u64,
827    /// Whether L0 bounds words are packed `(max_tf, min_len)`.
828    len_bounds: bool,
829    /// Minimum scoring-unit length over the whole list (with `len_bounds`).
830    min_len: u32,
831}
832
833impl BlockPostingList {
834    /// Read L0 entry by block index. Returns `(first_doc, last_doc, offset, bounds word)`.
835    #[inline]
836    fn read_l0_entry(&self, idx: usize) -> (u32, u32, u32, u32) {
837        read_l0(&self.l0_bytes, idx)
838    }
839
840    /// Build from a posting list.
841    ///
842    /// Block format (8-byte header + packed arrays):
843    /// ```text
844    /// [count: u16][first_doc: u32][doc_id_bits: u8][tf_bits: u8]
845    /// [packed doc_id deltas: (count-1) × bytes_per_value(doc_id_bits)]
846    /// [packed tfs: count × bytes_per_value(tf_bits)]
847    /// ```
848    pub fn from_posting_list(list: &PostingList) -> io::Result<Self> {
849        Self::build(list, false, None, PostingCodec::Rounded, false, false)
850    }
851
852    /// Build a list using an explicit per-block codec.
853    pub fn from_posting_list_with_codec(
854        list: &PostingList,
855        codec: PostingCodec,
856    ) -> io::Result<Self> {
857        Self::build(list, false, None, codec, false, false)
858    }
859
860    /// Build with position cursors on demand and, when `length_of` is given,
861    /// the minimum scoring-unit length per block (and over the list) so
862    /// MaxScore bounds use real length normalisation. Without lengths the
863    /// minimum is 1, which any real unit satisfies.
864    pub fn from_posting_list_with(
865        list: &PostingList,
866        with_positions: bool,
867        length_of: Option<&dyn Fn(DocId) -> u32>,
868    ) -> io::Result<Self> {
869        Self::build(
870            list,
871            with_positions,
872            length_of,
873            PostingCodec::Rounded,
874            false,
875            false,
876        )
877    }
878
879    /// Build with the complete physical layout policy used by index writers.
880    pub fn from_posting_list_with_options(
881        list: &PostingList,
882        with_positions: bool,
883        length_of: Option<&dyn Fn(DocId) -> u32>,
884        codec: PostingCodec,
885    ) -> io::Result<Self> {
886        Self::build(list, with_positions, length_of, codec, false, false)
887    }
888
889    /// Build optional score-independent ratio bounds, retaining the existing codec.
890    pub fn from_posting_list_with_ratio_bounds(
891        list: &PostingList,
892        with_positions: bool,
893        length_of: Option<&dyn Fn(DocId) -> u32>,
894        codec: PostingCodec,
895    ) -> io::Result<Self> {
896        Self::build(list, with_positions, length_of, codec, true, false)
897    }
898
899    /// Build complete, bounded frequency/length envelopes for multi-block lists.
900    /// Implies ratio bounds and requires the effective scoring-length callback.
901    pub fn from_posting_list_with_impact_bounds(
902        list: &PostingList,
903        with_positions: bool,
904        length_of: Option<&dyn Fn(DocId) -> u32>,
905        codec: PostingCodec,
906    ) -> io::Result<Self> {
907        if length_of.is_none() {
908            return Err(io::Error::new(
909                io::ErrorKind::InvalidInput,
910                "impact bounds require scoring lengths",
911            ));
912        }
913        Self::build(list, with_positions, length_of, codec, true, true)
914    }
915
916    fn build(
917        list: &PostingList,
918        with_positions: bool,
919        length_of: Option<&dyn Fn(DocId) -> u32>,
920        codec: PostingCodec,
921        ratio_bounds: bool,
922        impact_bounds: bool,
923    ) -> io::Result<Self> {
924        // Persisted scoring lengths saturate at `MAX_CHUNK_LENGTH`. A ratio or
925        // envelope derived from a longer raw length would be over-tight, so
926        // the constructor caps here rather than trusting every caller to.
927        let capped = |id: DocId| {
928            length_of
929                .map_or(0, |length_of| length_of(id))
930                .min(crate::segment::chunk_map::MAX_CHUNK_LENGTH)
931        };
932        let length_of: Option<&dyn Fn(DocId) -> u32> = if ratio_bounds {
933            length_of.map(|_| &capped as &dyn Fn(DocId) -> u32)
934        } else {
935            length_of
936        };
937        let mut ratios = (ratio_bounds && length_of.is_some()).then(Vec::new);
938        let mut impacts = (impact_bounds && length_of.is_some() && list.len() > BLOCK_SIZE)
939            .then(|| ImpactBuilder::with_groups(list.len().div_ceil(BLOCK_SIZE)))
940            .transpose()?;
941        let mut points = [(0u32, 0u32); BLOCK_SIZE];
942        let mut stream: Vec<u8> = Vec::new();
943        let mut l0_buf: Vec<u8> = Vec::new();
944        let mut l1_docs: Vec<u32> = Vec::new();
945        let mut cursors: Vec<u8> = Vec::new();
946        let mut positions_so_far = 0u64;
947        let mut l0_count = 0usize;
948        let mut max_tf = 0u32;
949        let mut list_min_len = u32::MAX;
950
951        let postings = &list.postings;
952        let mut i = 0;
953
954        // Temp buffers reused across blocks
955        let mut deltas = Vec::with_capacity(BLOCK_SIZE);
956        let mut tf_buf = Vec::with_capacity(BLOCK_SIZE);
957
958        while i < postings.len() {
959            if stream.len() > u32::MAX as usize {
960                return Err(io::Error::new(
961                    io::ErrorKind::InvalidData,
962                    "posting list stream exceeds u32::MAX bytes",
963                ));
964            }
965            let block_start = stream.len() as u32;
966            let block_end = (i + BLOCK_SIZE).min(postings.len());
967            let block = &postings[i..block_end];
968            let count = block.len();
969
970            // Compute block's max term frequency for block-max pruning
971            let block_max_tf = block.iter().map(|p| p.term_freq).max().unwrap_or(0);
972            max_tf = max_tf.max(block_max_tf);
973
974            let base_doc_id = block.first().unwrap().doc_id;
975            let last_doc_id = block.last().unwrap().doc_id;
976
977            // Delta-encode doc IDs (skip first — stored in header)
978            deltas.clear();
979            let mut prev = base_doc_id;
980            for posting in block.iter().skip(1) {
981                deltas.push(posting.doc_id - prev);
982                prev = posting.doc_id;
983            }
984
985            // Collect TFs
986            tf_buf.clear();
987            tf_buf.extend(block.iter().map(|p| p.term_freq));
988
989            // Write 8-byte header: [count: u16][first_doc: u32][doc_bits: u8][tf_bits: u8]
990            // (`doc_bits` carries the codec id in its top two bits); the
991            // packed arrays follow.
992            stream.write_u16::<LittleEndian>(count as u16)?;
993            stream.write_u32::<LittleEndian>(base_doc_id)?;
994            let header_at = stream.len();
995            stream.push(0);
996            stream.push(0);
997            let encoded = encode_block_arrays(codec, &deltas, &tf_buf, &mut stream);
998            stream[header_at] = encoded.doc_bits;
999            stream[header_at + 1] = encoded.tf_bits;
1000
1001            // L0 skip entry with the block's bounds
1002            let block_min_len = length_of.map_or(1, |length_of| {
1003                block
1004                    .iter()
1005                    .map(|p| length_of(p.doc_id).max(1))
1006                    .min()
1007                    .unwrap_or(1)
1008            });
1009            list_min_len = list_min_len.min(block_min_len);
1010            if let Some(ratios) = &mut ratios {
1011                let ratio = block
1012                    .iter()
1013                    .map(|p| lower_length_ratio(length_of.unwrap()(p.doc_id).max(1), p.term_freq))
1014                    .fold(f32::INFINITY, f32::min);
1015                ratios.extend_from_slice(&ratio.to_le_bytes());
1016            }
1017            if let Some(impacts) = &mut impacts {
1018                for (point, posting) in points.iter_mut().zip(block) {
1019                    let length = length_of.unwrap()(posting.doc_id);
1020                    *point = (
1021                        posting.term_freq,
1022                        if length == 0 {
1023                            posting.term_freq
1024                        } else {
1025                            length
1026                        },
1027                    );
1028                }
1029                impacts.append_points(&mut points[..count])?;
1030            }
1031            write_l0(
1032                &mut l0_buf,
1033                base_doc_id,
1034                last_doc_id,
1035                block_start,
1036                pack_bounds(block_max_tf, block_min_len),
1037            );
1038            l0_count += 1;
1039            if with_positions {
1040                cursors.extend_from_slice(&positions_so_far.to_le_bytes());
1041                positions_so_far += block.iter().map(|p| p.term_freq as u64).sum::<u64>();
1042            }
1043
1044            // L1 entry at the end of each L1_INTERVAL group
1045            if l0_count.is_multiple_of(L1_INTERVAL) {
1046                l1_docs.push(last_doc_id);
1047            }
1048
1049            i = block_end;
1050        }
1051
1052        // Final L1 entry for partial group
1053        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
1054            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
1055            l1_docs.push(last_doc);
1056        }
1057        let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
1058        if let Some(ratios) = &mut ratios {
1059            append_ratio_groups(ratios, l0_count);
1060        }
1061
1062        Ok(Self {
1063            compact_headers: false,
1064            short_cursors: false,
1065            verify_content: true,
1066            content_error: None,
1067            stream: OwnedBytes::new(stream),
1068            l0_bytes: OwnedBytes::new(l0_buf),
1069            l0_count,
1070            l1_docs: l1_docs.into(),
1071            l1_bounds: l1_bounds.into(),
1072            ratios: ratios.map(OwnedBytes::new),
1073            impacts: if let Some(mut impacts) = impacts {
1074                impacts.append_groups_with(l0_count, |_| Ok(None))?;
1075                impacts.finish()
1076            } else {
1077                None
1078            },
1079            doc_count: postings.len() as u32,
1080            max_tf,
1081            pos_cursors: with_positions.then(|| OwnedBytes::new(cursors)),
1082            total_positions: positions_so_far,
1083            len_bounds: true,
1084            min_len: if list_min_len == u32::MAX {
1085                1
1086            } else {
1087                list_min_len
1088            },
1089        })
1090    }
1091
1092    /// Serialize the block posting list (footer-based: stream first).
1093    ///
1094    /// Format:
1095    /// ```text
1096    /// [stream: block data]
1097    /// [L0 entries: l0_count × 16 bytes (first_doc, last_doc, offset, max_weight)]
1098    /// [L1 entries: l1_count × 4 bytes (last_doc)]
1099    /// [L1 bounds: l1_count × 4 bytes (packed max_tf, min_len), FLAG_L1_BOUNDS]
1100    /// [position cursors: l0_count × 8 bytes, only with positions]
1101    /// [footer: stream_len(8) + l0_count(4) + l1_count(4) + doc_count(4) + max_tf(4)
1102    ///          + total_positions(8) + flags(4) + min_len(4) + magic(4) = 44 bytes]
1103    /// ```
1104    pub fn serialize<W: Write>(&self, writer: &mut W) -> io::Result<()> {
1105        self.serialize_layout(writer, self.compact_headers)
1106    }
1107
1108    /// Representation policy retained by explicit field reordering.
1109    #[cfg(all(feature = "native", test))]
1110    pub(crate) fn has_compact_headers(&self) -> bool {
1111        self.compact_headers
1112    }
1113
1114    /// Separate fixed-width block metadata from payload pages. Pfor retains
1115    /// its existing framing because exception structure lives in the payload.
1116    pub fn serialize_compact<W: Write>(&self, writer: &mut W) -> io::Result<()> {
1117        let compact =
1118            (0..self.num_blocks()).all(|i| self.block_codec(i) != Some(PostingCodec::Pfor));
1119        self.serialize_layout(writer, compact)
1120    }
1121
1122    fn serialize_layout<W: Write>(&self, writer: &mut W, compact: bool) -> io::Result<()> {
1123        let source_header = if self.compact_headers { 0 } else { 8 };
1124        let output_header = if compact { 0 } else { 8 };
1125        let output_offset =
1126            |offset: usize, block: usize| offset - block * source_header + block * output_header;
1127        let stream_len = output_offset(self.stream.len(), self.num_blocks());
1128        if compact == self.compact_headers {
1129            writer.write_all(&self.stream)?;
1130        } else {
1131            for i in 0..self.num_blocks() {
1132                if !compact {
1133                    writer.write_all(&self.block_header(i))?;
1134                }
1135                writer.write_all(self.block_payload(i))?;
1136            }
1137        }
1138        for i in 0..self.num_blocks() {
1139            let (first, last, offset, bounds) = self.read_l0_entry(i);
1140            writer.write_u32::<LittleEndian>(first)?;
1141            writer.write_u32::<LittleEndian>(last)?;
1142            writer.write_u32::<LittleEndian>(
1143                u32::try_from(output_offset(offset as usize, i))
1144                    .map_err(|_| io::Error::other("posting stream offset overflow"))?,
1145            )?;
1146            writer.write_u32::<LittleEndian>(bounds)?;
1147        }
1148        if compact {
1149            for i in 0..self.num_blocks() {
1150                let header = self.block_header(i);
1151                writer.write_all(&header[..2])?;
1152                writer.write_all(&header[6..])?;
1153            }
1154        }
1155        writer.write_all(self.l1_docs.bytes())?;
1156        writer.write_all(self.l1_bounds.bytes())?;
1157        let short_cursors =
1158            compact && self.pos_cursors.is_some() && self.total_positions <= u32::MAX as u64;
1159        if self.pos_cursors.is_some() {
1160            for i in 0..self.num_blocks() {
1161                let cursor = self.pos_cursor(i).unwrap();
1162                if short_cursors {
1163                    writer.write_u32::<LittleEndian>(cursor as u32)?;
1164                } else {
1165                    writer.write_u64::<LittleEndian>(cursor)?;
1166                }
1167            }
1168        }
1169        if let Some(ratios) = &self.ratios {
1170            writer.write_all(ratios)?;
1171        }
1172        if let Some(impacts) = &self.impacts {
1173            writer.write_all(impacts.bytes())?;
1174        }
1175        Self::write_footer(
1176            writer,
1177            stream_len as u64,
1178            self.l0_count,
1179            self.l1_docs.len(),
1180            self.doc_count,
1181            self.max_tf,
1182            self.total_positions,
1183            self.pos_cursors.is_some(),
1184            self.len_bounds.then_some(self.min_len),
1185            !self.l1_bounds.is_empty(),
1186            self.ratios.is_some(),
1187            self.impacts.is_some(),
1188            self.has_group_impact_bounds(),
1189            if compact { FLAG_COMPACT_HEADERS } else { 0 }
1190                | if short_cursors { FLAG_SHORT_CURSORS } else { 0 },
1191        )
1192    }
1193
1194    #[allow(clippy::too_many_arguments)]
1195    fn write_footer<W: Write>(
1196        writer: &mut W,
1197        stream_len: u64,
1198        l0_count: usize,
1199        l1_count: usize,
1200        doc_count: u32,
1201        max_tf: u32,
1202        total_positions: u64,
1203        has_cursors: bool,
1204        min_len: Option<u32>,
1205        l1_bounds: bool,
1206        ratio_bounds: bool,
1207        impact_bounds: bool,
1208        group_impact_bounds: bool,
1209        layout_flags: u32,
1210    ) -> io::Result<()> {
1211        writer.write_u64::<LittleEndian>(stream_len)?;
1212        writer.write_u32::<LittleEndian>(l0_count as u32)?;
1213        writer.write_u32::<LittleEndian>(l1_count as u32)?;
1214        writer.write_u32::<LittleEndian>(doc_count)?;
1215        writer.write_u32::<LittleEndian>(max_tf)?;
1216        writer.write_u64::<LittleEndian>(total_positions)?;
1217        let mut flags = layout_flags;
1218        if has_cursors {
1219            flags |= FLAG_POS_CURSORS;
1220        }
1221        if min_len.is_some() {
1222            flags |= FLAG_LEN_BOUNDS;
1223        }
1224        if l1_bounds {
1225            flags |= FLAG_L1_BOUNDS;
1226        }
1227        if ratio_bounds {
1228            flags |= FLAG_RATIO_BOUNDS;
1229        }
1230        if impact_bounds {
1231            flags |= FLAG_IMPACT_BOUNDS;
1232        }
1233        if group_impact_bounds {
1234            flags |= FLAG_GROUP_IMPACT_BOUNDS;
1235        }
1236        writer.write_u32::<LittleEndian>(flags)?;
1237        writer.write_u32::<LittleEndian>(min_len.unwrap_or(0))?;
1238        writer.write_u32::<LittleEndian>(FOOTER_MAGIC)?;
1239        Ok(())
1240    }
1241
1242    /// Deserialize from a byte slice (either footer format).
1243    pub fn deserialize(raw: &[u8]) -> io::Result<Self> {
1244        Self::deserialize_zero_copy(OwnedBytes::new(raw.to_vec()))
1245    }
1246
1247    /// Zero-copy deserialization from OwnedBytes.
1248    /// Stream, L0, L1 and cursors are sliced from the source without copying.
1249    pub fn deserialize_zero_copy(raw: OwnedBytes) -> io::Result<Self> {
1250        let footer = Self::validate_bytes(&raw)?;
1251        Ok(Self::from_layout(raw, footer))
1252    }
1253
1254    fn validate_bytes(raw: &[u8]) -> io::Result<Footer> {
1255        let footer = Footer::parse(raw)?;
1256        validation::validate_list(raw, &footer)?;
1257        if footer.ratio_bounds {
1258            validate_ratios(&raw[footer.cursors_end()..footer.ratios_end()])?;
1259        }
1260        if footer.impact_bounds {
1261            ImpactTable::validate(
1262                &raw[footer.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1263                footer.impact_record_count(),
1264            )?;
1265        }
1266        Ok(footer)
1267    }
1268
1269    // The footer proves section extents. The owning query reader trusts interior
1270    // contents; explicit deserialization additionally validates them.
1271    fn from_layout(raw: OwnedBytes, footer: Footer) -> Self {
1272        let ratios = footer
1273            .ratio_bounds
1274            .then(|| raw.slice(footer.cursors_end()..footer.ratios_end()));
1275        let l1_docs = GroupWords::borrowed(raw.slice(footer.l1_start()..footer.l1_end()));
1276        let l1_bounds = GroupWords::borrowed(raw.slice(footer.l1_end()..footer.l1_bounds_end()));
1277        let pos_cursors = footer
1278            .has_cursors
1279            .then(|| raw.slice(footer.l1_bounds_end()..footer.cursors_end()));
1280
1281        Self {
1282            compact_headers: footer.compact_headers,
1283            short_cursors: footer.short_cursors,
1284            verify_content: true,
1285            content_error: None,
1286            stream: raw.slice(0..footer.stream_len),
1287            l0_bytes: raw.slice(footer.l0_start()..footer.l1_start()),
1288            l0_count: footer.l0_count,
1289            l1_docs,
1290            l1_bounds,
1291            ratios,
1292            impacts: footer.impact_bounds.then(|| {
1293                ImpactTable::from_validated(
1294                    raw.slice(footer.ratios_end()..raw.len() - FOOTER_V2_SIZE),
1295                    footer.impact_record_count(),
1296                )
1297            }),
1298            doc_count: footer.doc_count,
1299            max_tf: footer.max_tf,
1300            pos_cursors,
1301            total_positions: footer.total_positions,
1302            len_bounds: footer.len_bounds,
1303            min_len: footer.min_len,
1304        }
1305    }
1306
1307    /// Minimum scoring-unit length over the list, when the list stores
1308    /// length bounds (`None` for legacy lists).
1309    pub fn min_len(&self) -> Option<u32> {
1310        self.len_bounds.then_some(self.min_len)
1311    }
1312
1313    /// Whether optional ratio bounds are present (zero entries mean unknown).
1314    pub fn has_ratio_bounds(&self) -> bool {
1315        self.ratios.is_some()
1316    }
1317
1318    /// Whether the list stores an impact directory, including unknown records.
1319    pub fn has_impact_bounds(&self) -> bool {
1320        self.impacts.is_some()
1321    }
1322
1323    /// Envelope diagnostics: absent directory/out-of-range is `None`; an unknown
1324    /// record is `Some(0)`; a populated record has 1–8 complete frontier points.
1325    pub fn block_impact_point_count(&self, block: usize) -> Option<usize> {
1326        if block >= self.l0_count {
1327            return None;
1328        }
1329        self.impacts
1330            .as_ref()?
1331            .record(block)
1332            .map(|r| r.first().copied().unwrap_or(0) as usize)
1333    }
1334
1335    /// Whether the optional table also stores group envelopes.
1336    pub fn has_group_impact_bounds(&self) -> bool {
1337        self.impacts
1338            .as_ref()
1339            .is_some_and(|t| t.record_count() > self.l0_count)
1340    }
1341
1342    /// Point count for the group containing this block; zero means unknown.
1343    #[cfg(test)]
1344    pub fn group_impact_point_count(&self, block: usize) -> Option<usize> {
1345        if block >= self.l0_count {
1346            return None;
1347        }
1348        self.impacts
1349            .as_ref()?
1350            .record(self.l0_count + block / L1_INTERVAL)
1351            .map(|r| r.first().copied().unwrap_or(0) as usize)
1352    }
1353
1354    pub(crate) fn group_impact_minimum(
1355        &self,
1356        block: usize,
1357        reciprocal: f64,
1358        ratio: f64,
1359    ) -> Option<f64> {
1360        if block >= self.l0_count {
1361            return None;
1362        }
1363        self.impacts
1364            .as_ref()?
1365            .minimum(self.l0_count + block / L1_INTERVAL, reciprocal, ratio)
1366    }
1367
1368    pub(crate) fn block_impact_minimum(
1369        &self,
1370        block: usize,
1371        reciprocal: f64,
1372        ratio: f64,
1373    ) -> Option<f64> {
1374        if block >= self.l0_count {
1375            return None;
1376        }
1377        self.impacts.as_ref()?.minimum(block, reciprocal, ratio)
1378    }
1379
1380    /// Conservative minimum length/TF for a block, or zero if unavailable.
1381    pub(crate) fn block_length_ratio(&self, block: usize) -> f32 {
1382        self.ratios
1383            .as_ref()
1384            .map_or(0.0, |ratios| read_ratio(ratios, block))
1385    }
1386
1387    pub(crate) fn group_length_ratio(&self, block: usize) -> f32 {
1388        self.ratios.as_ref().map_or(0.0, |ratios| {
1389            read_ratio(ratios, self.l0_count + block / L1_INTERVAL)
1390        })
1391    }
1392
1393    /// `(max_tf, min_len)` of a block; `min_len` is `None` for legacy lists.
1394    #[inline]
1395    pub fn block_bounds(&self, block_idx: usize) -> Option<(u32, Option<u32>)> {
1396        if block_idx >= self.l0_count {
1397            return None;
1398        }
1399        let (_, _, _, word) = self.read_l0_entry(block_idx);
1400        let (max_tf, min_len) = unpack_bounds(word, self.len_bounds);
1401        // A packed maximum can saturate; the full-width list maximum remains
1402        // a conservative bound. Never interpret saturation as an actual TF.
1403        let max_tf = if self.len_bounds && max_tf == u16::MAX as u32 {
1404            max_tf.max(self.max_tf)
1405        } else {
1406            max_tf
1407        };
1408        Some((max_tf, min_len))
1409    }
1410
1411    /// `(max_tf, min_len)` over the L1 group (`L1_INTERVAL` blocks) that
1412    /// contains `block_idx`; `None` for legacy lists without group bounds.
1413    #[inline]
1414    pub fn group_bounds(&self, block_idx: usize) -> Option<(u32, u32)> {
1415        if block_idx >= self.l0_count {
1416            return None;
1417        }
1418        let word = self.l1_bounds.get(block_idx / L1_INTERVAL)?;
1419        let (max_tf, min_len) = unpack_bounds(word, true);
1420        let max_tf = if max_tf == u16::MAX as u32 {
1421            max_tf.max(self.max_tf)
1422        } else {
1423            max_tf
1424        };
1425        Some((max_tf, min_len.unwrap_or(1)))
1426    }
1427
1428    /// Last doc of the L1 group containing `block_idx`.
1429    #[inline]
1430    pub fn group_last_doc(&self, block_idx: usize) -> Option<DocId> {
1431        self.l1_docs.get(block_idx / L1_INTERVAL)
1432    }
1433
1434    /// Whether `block_idx` opens an L1 group.
1435    #[inline]
1436    pub fn is_group_start(&self, block_idx: usize) -> bool {
1437        block_idx.is_multiple_of(L1_INTERVAL)
1438    }
1439
1440    /// Index of the first block after the L1 group containing `block_idx`
1441    /// (clamped to the block count).
1442    #[inline]
1443    pub fn next_group_block(&self, block_idx: usize) -> usize {
1444        ((block_idx / L1_INTERVAL + 1) * L1_INTERVAL).min(self.l0_count)
1445    }
1446
1447    /// Whether serialized bytes carry position cursors (cheap footer check).
1448    pub fn has_cursors_bytes(raw: &[u8]) -> bool {
1449        Footer::parse(raw).is_ok_and(|footer| footer.has_cursors)
1450    }
1451
1452    /// Whether this list carries a position cursor per block.
1453    pub fn has_position_cursors(&self) -> bool {
1454        self.pos_cursors.is_some()
1455    }
1456
1457    #[inline]
1458    fn block_header(&self, block: usize) -> [u8; 8] {
1459        let (first, _, offset, _) = self.read_l0_entry(block);
1460        if self.compact_headers {
1461            let headers = &self.l0_bytes[self.l0_count * L0_SIZE..];
1462            let mut header = [0; 8];
1463            header[..2].copy_from_slice(&headers[block * 4..block * 4 + 2]);
1464            header[2..6].copy_from_slice(&first.to_le_bytes());
1465            header[6..].copy_from_slice(&headers[block * 4 + 2..block * 4 + 4]);
1466            header
1467        } else {
1468            self.stream[offset as usize..offset as usize + 8]
1469                .try_into()
1470                .unwrap()
1471        }
1472    }
1473
1474    #[inline]
1475    fn block_payload(&self, block: usize) -> &[u8] {
1476        let offset = self.read_l0_entry(block).2 as usize;
1477        let header = if self.compact_headers { 0 } else { 8 };
1478        &self.stream[offset + header..offset + self.block_len(block)]
1479    }
1480
1481    /// Number of values in the term's position stream (0 without cursors).
1482    pub fn total_positions(&self) -> u64 {
1483        self.total_positions
1484    }
1485
1486    /// Values in the term's position stream before block `block_idx`.
1487    #[inline]
1488    pub fn pos_cursor(&self, block_idx: usize) -> Option<u64> {
1489        let cursors = self.pos_cursors.as_ref()?;
1490        let size = if self.short_cursors { 4 } else { CURSOR_SIZE };
1491        let p = block_idx * size;
1492        cursors.get(p..p + size).map(|b| {
1493            if self.short_cursors {
1494                u64::from(u32::from_le_bytes(b.try_into().unwrap()))
1495            } else {
1496                u64::from_le_bytes(b.try_into().unwrap())
1497            }
1498        })
1499    }
1500
1501    pub fn doc_count(&self) -> u32 {
1502        self.doc_count
1503    }
1504
1505    /// Get maximum term frequency (for MaxScore upper bound computation)
1506    pub fn max_tf(&self) -> u32 {
1507        self.max_tf
1508    }
1509
1510    /// Get number of blocks
1511    pub fn num_blocks(&self) -> usize {
1512        self.l0_count
1513    }
1514
1515    /// Get block's max term frequency for block-max pruning
1516    pub fn block_max_tf(&self, block_idx: usize) -> Option<u32> {
1517        self.block_bounds(block_idx).map(|(max_tf, _)| max_tf)
1518    }
1519
1520    /// Concatenate blocks from multiple posting lists with doc_id remapping.
1521    /// This is O(num_blocks) instead of O(num_postings).
1522    pub fn concatenate_blocks(sources: &[(BlockPostingList, u32)]) -> io::Result<Self> {
1523        // Admission precedes all output allocation/copying; typed sources have
1524        // already validated payloads, but their requested rebasing is new.
1525        let mut previous_last = None;
1526        let mut total_docs = 0u32;
1527        let mut total_positions = 0u64;
1528        for (source, offset) in sources {
1529            validation::validate_remap(
1530                &source.l0_bytes,
1531                source.l0_count,
1532                *offset,
1533                &mut previous_last,
1534            )?;
1535            total_docs = total_docs.checked_add(source.doc_count).ok_or_else(|| {
1536                io::Error::new(io::ErrorKind::InvalidData, "merged posting count overflow")
1537            })?;
1538            total_positions = total_positions
1539                .checked_add(source.total_positions)
1540                .ok_or_else(|| {
1541                    io::Error::new(
1542                        io::ErrorKind::InvalidData,
1543                        "merged position cursor overflow",
1544                    )
1545                })?;
1546        }
1547        let mut stream: Vec<u8> = Vec::new();
1548        let mut l0_buf: Vec<u8> = Vec::new();
1549        let mut l1_docs: Vec<u32> = Vec::new();
1550        let mut l0_count = 0usize;
1551        let mut ratios = sources
1552            .iter()
1553            .any(|(s, _)| s.has_ratio_bounds())
1554            .then(Vec::new);
1555        let mut impacts = if sources.iter().any(|(s, _)| s.has_impact_bounds()) {
1556            let blocks = sources
1557                .iter()
1558                .try_fold(0usize, |n, (s, _)| n.checked_add(s.num_blocks()))
1559                .ok_or_else(|| {
1560                    io::Error::new(io::ErrorKind::InvalidData, "impact block count overflow")
1561                })?;
1562            Some(ImpactBuilder::with_groups(blocks)?)
1563        } else {
1564            None
1565        };
1566        let mut max_tf = 0u32;
1567        let all_cursors = sources.iter().all(|(s, _)| s.has_position_cursors());
1568        if !all_cursors && sources.iter().any(|(s, _)| s.has_position_cursors()) {
1569            return Err(io::Error::new(
1570                io::ErrorKind::InvalidData,
1571                "cannot concatenate posting lists with and without position cursors",
1572            ));
1573        }
1574        let mut cursors: Vec<u8> = Vec::new();
1575        let mut positions_before = 0u64;
1576        let mut min_len = u32::MAX;
1577
1578        for (source, doc_offset) in sources {
1579            max_tf = max_tf.max(source.max_tf);
1580            min_len = min_len.min(source.min_len().unwrap_or(1));
1581            for block_idx in 0..source.num_blocks() {
1582                if let Some(impacts) = &mut impacts {
1583                    impacts.append(
1584                        source
1585                            .impacts
1586                            .as_ref()
1587                            .and_then(|t| t.record(block_idx))
1588                            .unwrap_or(&[]),
1589                    )?;
1590                }
1591                if let Some(ratios) = &mut ratios {
1592                    ratios.extend_from_slice(&source.block_length_ratio(block_idx).to_le_bytes());
1593                }
1594                if all_cursors {
1595                    let cursor = source.pos_cursor(block_idx).unwrap_or(0) + positions_before;
1596                    cursors.extend_from_slice(&cursor.to_le_bytes());
1597                }
1598                let (first_doc, last_doc, _, word) = source.read_l0_entry(block_idx);
1599                let (block_max_tf, block_min_len) = unpack_bounds(word, source.len_bounds);
1600                let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
1601                let header = source.block_header(block_idx);
1602                let count = u16::from_le_bytes(header[..2].try_into().unwrap());
1603                if stream.len() > u32::MAX as usize {
1604                    return Err(io::Error::new(
1605                        io::ErrorKind::InvalidData,
1606                        "posting list stream exceeds u32::MAX bytes during concatenation",
1607                    ));
1608                }
1609                let new_offset = stream.len() as u32;
1610
1611                // Write patched header + copy packed arrays verbatim
1612                stream.write_u16::<LittleEndian>(count)?;
1613                stream.write_u32::<LittleEndian>(first_doc + doc_offset)?;
1614                stream.extend_from_slice(&header[6..]);
1615                stream.extend_from_slice(source.block_payload(block_idx));
1616
1617                let new_last = last_doc + doc_offset;
1618                write_l0(
1619                    &mut l0_buf,
1620                    first_doc + doc_offset,
1621                    new_last,
1622                    new_offset,
1623                    bounds,
1624                );
1625                l0_count += 1;
1626
1627                if l0_count.is_multiple_of(L1_INTERVAL) {
1628                    l1_docs.push(new_last);
1629                }
1630            }
1631            positions_before += source.total_positions;
1632        }
1633
1634        // Final L1 entry for partial group
1635        if !l0_count.is_multiple_of(L1_INTERVAL) && l0_count > 0 {
1636            let (_, last_doc, _, _) = read_l0(&l0_buf, l0_count - 1);
1637            l1_docs.push(last_doc);
1638        }
1639        let l1_bounds = group_bounds_from_l0(&l0_buf, l0_count);
1640        if let Some(ratios) = &mut ratios {
1641            append_ratio_groups(ratios, l0_count);
1642        }
1643
1644        Ok(Self {
1645            compact_headers: false,
1646            short_cursors: false,
1647            verify_content: true,
1648            content_error: None,
1649            stream: OwnedBytes::new(stream),
1650            l0_bytes: OwnedBytes::new(l0_buf),
1651            l0_count,
1652            l1_docs: l1_docs.into(),
1653            l1_bounds: l1_bounds.into(),
1654            ratios: ratios.map(OwnedBytes::new),
1655            impacts: if let Some(mut impacts) = impacts {
1656                let mut source = 0;
1657                let mut base = 0;
1658                impacts.append_groups_with(l0_count, |range| {
1659                    while base + sources[source].0.num_blocks() <= range.start {
1660                        base += sources[source].0.num_blocks();
1661                        source += 1;
1662                    }
1663                    let list = &sources[source].0;
1664                    let local = range.start - base;
1665                    let record = if local.is_multiple_of(L1_INTERVAL)
1666                        && range.end - base == (local + L1_INTERVAL).min(list.num_blocks())
1667                    {
1668                        list.impacts
1669                            .as_ref()
1670                            .and_then(|t| t.record(list.l0_count + local / L1_INTERVAL))
1671                    } else {
1672                        None
1673                    };
1674                    Ok(record)
1675                })?;
1676                impacts.finish()
1677            } else {
1678                None
1679            },
1680            doc_count: total_docs,
1681            max_tf,
1682            pos_cursors: all_cursors.then(|| OwnedBytes::new(cursors)),
1683            total_positions: if all_cursors { total_positions } else { 0 },
1684            len_bounds: true,
1685            min_len: if min_len == u32::MAX { 1 } else { min_len },
1686        })
1687    }
1688
1689    /// Streaming merge: write blocks directly to output writer (bounded memory).
1690    ///
1691    /// **Zero-materializing**: reads L0 entries directly from source bytes
1692    /// (mmap or &[u8]) without parsing into Vecs. Block sizes come from the
1693    /// L0 offsets, so blocks of any codec are copied verbatim.
1694    ///
1695    /// Output L0 + L1 are buffered (bounded O(total_blocks × 16 + total_blocks/8 × 4)).
1696    /// Block data flows source → output writer without intermediate buffering.
1697    ///
1698    /// Returns `(doc_count, bytes_written)`.
1699    ///
1700    /// Preflights all source headers/directories and remapped document ranges
1701    /// before writing. Corrupt sources, overlapping ranges, or arithmetic
1702    /// overflow return `Error::Corruption`, including the single-source copy path.
1703    pub fn concatenate_streaming<W: Write>(
1704        sources: &[(&[u8], u32)], // (serialized_bytes, doc_offset)
1705        writer: &mut W,
1706    ) -> crate::Result<(u32, usize)> {
1707        let mut metas: Vec<Footer> = Vec::with_capacity(sources.len());
1708        let mut total_docs = 0u32;
1709        let mut merged_max_tf = 0u32;
1710        let mut merged_min_len = u32::MAX;
1711        let mut previous_last = None;
1712        let mut total_positions = 0u64;
1713
1714        for (source_index, (raw, offset)) in sources.iter().enumerate() {
1715            let invalid_source = |error: io::Error| {
1716                crate::Error::Corruption(format!(
1717                    "posting list source {source_index} is invalid: {error}"
1718                ))
1719            };
1720            // The zero-offset copy shortcut also needs validated payloads.
1721            // This checks headers/directories; it never decodes postings.
1722            let footer = Self::validate_bytes(raw).map_err(invalid_source)?;
1723            validation::validate_remap(
1724                &raw[footer.l0_start()..footer.l0_end()],
1725                footer.l0_count,
1726                *offset,
1727                &mut previous_last,
1728            )
1729            .map_err(invalid_source)?;
1730            total_docs = total_docs
1731                .checked_add(footer.doc_count)
1732                .ok_or_else(|| crate::Error::Corruption("merged posting count overflow".into()))?;
1733            total_positions = total_positions
1734                .checked_add(footer.total_positions)
1735                .ok_or_else(|| {
1736                    crate::Error::Corruption("merged position cursor overflow".into())
1737                })?;
1738            merged_max_tf = merged_max_tf.max(footer.max_tf);
1739            merged_min_len = merged_min_len.min(if footer.len_bounds { footer.min_len } else { 1 });
1740            metas.push(footer);
1741        }
1742
1743        // The common single-source term in the first segment needs no doc-id
1744        // rebasing and already has a valid index/footer. Copy it wholesale.
1745        if sources.len() == 1 && sources[0].1 == 0 {
1746            writer.write_all(sources[0].0)?;
1747            return Ok((metas[0].doc_count, sources[0].0.len()));
1748        }
1749
1750        let compact_output = !metas.is_empty() && metas.iter().all(|meta| meta.compact_headers);
1751        let short_cursors = compact_output && total_positions <= u32::MAX as u64;
1752        let mut out_headers = Vec::new();
1753        let all_cursors = metas.iter().all(|m| m.has_cursors);
1754        if !all_cursors && metas.iter().any(|m| m.has_cursors) {
1755            return Err(crate::Error::Corruption(
1756                "cannot concatenate posting lists with and without position cursors".into(),
1757            ));
1758        }
1759
1760        // Phase 1: Stream block data, reading L0 entries on-the-fly.
1761        // Accumulate output L0 + L1 + cursors (bounded).
1762        let mut out_impacts = if metas.iter().any(|m| m.impact_bounds) {
1763            let blocks = metas
1764                .iter()
1765                .try_fold(0usize, |n, m| n.checked_add(m.l0_count))
1766                .ok_or_else(|| crate::Error::Corruption("impact block count overflow".into()))?;
1767            Some(ImpactBuilder::with_groups(blocks)?)
1768        } else {
1769            None
1770        };
1771        let mut out_ratios = metas.iter().any(|m| m.ratio_bounds).then(Vec::new);
1772        let mut out_l0: Vec<u8> = Vec::new();
1773        let mut out_l1_docs: Vec<u32> = Vec::new();
1774        let mut out_cursors: Vec<u8> = Vec::new();
1775        let mut positions_before = 0u64;
1776        let mut out_l0_count = 0usize;
1777        let mut stream_written = 0u64;
1778        let mut patch_buf = [0u8; 8];
1779
1780        for (src_idx, meta) in metas.iter().enumerate() {
1781            let (raw, doc_offset) = &sources[src_idx];
1782            let l0_base = meta.l0_start(); // L0 entries start right after stream
1783            let src_stream = &raw[..meta.stream_len];
1784            let cursors_base = meta.l1_bounds_end();
1785
1786            for i in 0..meta.l0_count {
1787                if let Some(impacts) = &mut out_impacts {
1788                    let record = if meta.impact_bounds {
1789                        ImpactTable::record_from_validated(
1790                            &raw[meta.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1791                            meta.impact_record_count(),
1792                            i,
1793                        )
1794                    } else {
1795                        &[]
1796                    };
1797                    impacts.append(record)?;
1798                }
1799                if let Some(ratios) = &mut out_ratios {
1800                    let ratio = if meta.ratio_bounds {
1801                        read_ratio(&raw[meta.cursors_end()..], i)
1802                    } else {
1803                        0.0
1804                    };
1805                    ratios.extend_from_slice(&ratio.to_le_bytes());
1806                }
1807                // Read source L0 entry directly from raw bytes
1808                let (first_doc, last_doc, offset, word) = read_l0(&raw[l0_base..], i);
1809                let (block_max_tf, block_min_len) = unpack_bounds(word, meta.len_bounds);
1810                let bounds = pack_bounds(block_max_tf, block_min_len.unwrap_or(1));
1811                if all_cursors {
1812                    let size = meta.cursor_size();
1813                    let p = cursors_base + i * size;
1814                    let cursor = if meta.short_cursors {
1815                        u64::from(u32::from_le_bytes(raw[p..p + size].try_into().unwrap()))
1816                    } else {
1817                        u64::from_le_bytes(raw[p..p + size].try_into().unwrap())
1818                    };
1819                    if short_cursors {
1820                        out_cursors
1821                            .extend_from_slice(&((cursor + positions_before) as u32).to_le_bytes());
1822                    } else {
1823                        out_cursors.extend_from_slice(&(cursor + positions_before).to_le_bytes());
1824                    }
1825                }
1826
1827                // Block size from the neighbouring L0 offset (codec-independent)
1828                let blk_size =
1829                    block_len_from_l0(&raw[l0_base..], meta.l0_count, meta.stream_len, i);
1830                let block = &src_stream[offset as usize..offset as usize + blk_size];
1831
1832                // Write output L0 entry
1833                let new_last = last_doc + doc_offset;
1834                if stream_written > u32::MAX as u64 {
1835                    return Err(io::Error::new(
1836                        io::ErrorKind::InvalidData,
1837                        "posting list stream exceeds u32::MAX bytes during streaming merge",
1838                    )
1839                    .into());
1840                }
1841                write_l0(
1842                    &mut out_l0,
1843                    first_doc + doc_offset,
1844                    new_last,
1845                    stream_written as u32,
1846                    bounds,
1847                );
1848                out_l0_count += 1;
1849
1850                // L1 entry at group boundary
1851                if out_l0_count.is_multiple_of(L1_INTERVAL) {
1852                    out_l1_docs.push(new_last);
1853                }
1854
1855                // Patch 8-byte header: [count: u16][first_doc: u32][bits: 2 bytes]
1856                let payload = if meta.compact_headers {
1857                    let header = &raw[meta.l0_end() + i * 4..meta.l0_end() + i * 4 + 4];
1858                    patch_buf[..2].copy_from_slice(&header[..2]);
1859                    patch_buf[6..].copy_from_slice(&header[2..]);
1860                    block
1861                } else {
1862                    patch_buf.copy_from_slice(&block[..8]);
1863                    &block[8..]
1864                };
1865                patch_buf[2..6].copy_from_slice(&(first_doc + doc_offset).to_le_bytes());
1866                if compact_output {
1867                    out_headers.extend_from_slice(&patch_buf[..2]);
1868                    out_headers.extend_from_slice(&patch_buf[6..]);
1869                } else {
1870                    writer.write_all(&patch_buf)?;
1871                }
1872                writer.write_all(payload)?;
1873                stream_written += (if compact_output { 0 } else { 8 } + payload.len()) as u64;
1874            }
1875            positions_before += meta.total_positions;
1876        }
1877
1878        // Final L1 entry for partial group
1879        if !out_l0_count.is_multiple_of(L1_INTERVAL) && out_l0_count > 0 {
1880            let (_, last_doc, _, _) = read_l0(&out_l0, out_l0_count - 1);
1881            out_l1_docs.push(last_doc);
1882        }
1883
1884        // Phase 2: Write L0 + L1 + L1 bounds + cursors + footer
1885        let out_l1_bounds = group_bounds_from_l0(&out_l0, out_l0_count);
1886        writer.write_all(&out_l0)?;
1887        writer.write_all(&out_headers)?;
1888        for &doc in &out_l1_docs {
1889            writer.write_u32::<LittleEndian>(doc)?;
1890        }
1891        for &bounds in &out_l1_bounds {
1892            writer.write_u32::<LittleEndian>(bounds)?;
1893        }
1894        writer.write_all(&out_cursors)?;
1895        if let Some(ratios) = &mut out_ratios {
1896            append_ratio_groups(ratios, out_l0_count);
1897            writer.write_all(ratios)?;
1898        }
1899        let out_impacts = if let Some(mut impacts) = out_impacts {
1900            let mut source = 0;
1901            let mut base = 0;
1902            impacts.append_groups_with(out_l0_count, |range| {
1903                while base + metas[source].l0_count <= range.start {
1904                    base += metas[source].l0_count;
1905                    source += 1;
1906                }
1907                let meta = &metas[source];
1908                let local = range.start - base;
1909                let record = if meta.group_impact_bounds
1910                    && local.is_multiple_of(L1_INTERVAL)
1911                    && range.end - base == (local + L1_INTERVAL).min(meta.l0_count)
1912                {
1913                    let raw = sources[source].0;
1914                    Some(ImpactTable::record_from_validated(
1915                        &raw[meta.ratios_end()..raw.len() - FOOTER_V2_SIZE],
1916                        meta.impact_record_count(),
1917                        meta.l0_count + local / L1_INTERVAL,
1918                    ))
1919                } else {
1920                    None
1921                };
1922                Ok(record)
1923            })?;
1924            impacts.finish()
1925        } else {
1926            None
1927        };
1928        if let Some(impacts) = &out_impacts {
1929            writer.write_all(impacts.bytes())?;
1930        }
1931        Self::write_footer(
1932            writer,
1933            stream_written,
1934            out_l0_count,
1935            out_l1_docs.len(),
1936            total_docs,
1937            merged_max_tf,
1938            if all_cursors { total_positions } else { 0 },
1939            all_cursors,
1940            Some(if merged_min_len == u32::MAX {
1941                1
1942            } else {
1943                merged_min_len
1944            }),
1945            true,
1946            out_ratios.is_some(),
1947            out_impacts.is_some(),
1948            out_impacts.is_some(),
1949            if compact_output {
1950                FLAG_COMPACT_HEADERS
1951            } else {
1952                0
1953            } | if short_cursors && all_cursors {
1954                FLAG_SHORT_CURSORS
1955            } else {
1956                0
1957            },
1958        )?;
1959
1960        let l1_bytes_len = out_l1_docs.len() * L1_SIZE + out_l1_bounds.len() * 4;
1961        let total_bytes = stream_written as usize
1962            + out_l0.len()
1963            + out_headers.len()
1964            + l1_bytes_len
1965            + out_cursors.len()
1966            + out_ratios.as_ref().map_or(0, Vec::len)
1967            + out_impacts.as_ref().map_or(0, |t| t.bytes().len())
1968            + FOOTER_V2_SIZE;
1969        Ok((total_docs, total_bytes))
1970    }
1971
1972    /// Decode a specific block into caller-provided buffers.
1973    ///
1974    /// Returns `true` if the block was decoded, `false` if `block_idx` is out of range.
1975    /// Reuses `doc_ids` and `tfs` buffers (cleared before filling).
1976    ///
1977    /// Uses SIMD-accelerated unpack for 8/16/32-bit packed arrays.
1978    pub fn decode_block_into(
1979        &self,
1980        block_idx: usize,
1981        doc_ids: &mut Vec<u32>,
1982        tfs: &mut Vec<u32>,
1983    ) -> bool {
1984        if let Some((offset, tf_start, count)) = self.decode_block_doc_ids_only(block_idx, doc_ids)
1985        {
1986            self.decode_block_tfs_deferred(offset, tf_start, count, tfs);
1987            true
1988        } else {
1989            false
1990        }
1991    }
1992
1993    /// Decode only doc IDs from a block (no TF decoding).
1994    ///
1995    /// Returns `(block_data_offset, tf_start_within_block, count)` for deferred TF decode,
1996    /// or `None` if block_idx is out of range. A block whose decoded ids
1997    /// disagree with the L0 directory (content corruption that structural
1998    /// admission cannot see) also yields `None`, after an error log naming
1999    /// the block; callers treat it as the end of the list.
2000    pub fn decode_block_doc_ids_only(
2001        &self,
2002        block_idx: usize,
2003        doc_ids: &mut Vec<u32>,
2004    ) -> Option<(usize, usize, usize)> {
2005        match self.decode_block_doc_ids_checked(block_idx, doc_ids) {
2006            Ok(state) => state,
2007            Err(error) => {
2008                log::error!(
2009                    "posting block {block_idx} of {} is corrupt; the cursor ends here: {error}",
2010                    self.l0_count
2011                );
2012                None
2013            }
2014        }
2015    }
2016
2017    /// `Ok(None)` is out of range; `Err` is a block whose payload does not
2018    /// match its directory entry. On error `doc_ids` is left empty.
2019    fn decode_block_doc_ids_checked(
2020        &self,
2021        block_idx: usize,
2022        doc_ids: &mut Vec<u32>,
2023    ) -> io::Result<Option<(usize, usize, usize)>> {
2024        if block_idx >= self.l0_count {
2025            return Ok(None);
2026        }
2027        let decoded = (|| {
2028            let (first, last, offset, _) = self.read_l0_entry(block_idx);
2029            let header = self.block_header(block_idx);
2030            let payload = self.block_payload(block_idx);
2031            let state = self.decode_block_doc_ids_unchecked(
2032                offset as usize,
2033                block_idx,
2034                header,
2035                payload,
2036                doc_ids,
2037            )?;
2038            if self.verify_content {
2039                // Header 8 denotes Rounded with 8-bit raw gaps (no codec tag).
2040                let byte_gaps = (header[6] == 8).then(|| &payload[..doc_ids.len() - 1]);
2041                if header[6] >> PostingCodec::HEADER_SHIFT == PostingCodec::Simd4x as u8
2042                    && doc_ids.len() == BLOCK_SIZE
2043                    && !bitpacking4x::first_gap_is_zero(
2044                        payload,
2045                        header[6] & PostingCodec::WIDTH_MASK,
2046                    )
2047                {
2048                    return Err(io::Error::new(
2049                        io::ErrorKind::InvalidData,
2050                        "SIMD posting first gap must be zero",
2051                    ));
2052                }
2053                let strict_gap_width = (header[6] >> PostingCodec::HEADER_SHIFT
2054                    == PostingCodec::Simd4x as u8)
2055                    .then_some(header[6] & PostingCodec::WIDTH_MASK);
2056                if !verify_block_docs(doc_ids, first, last, byte_gaps, strict_gap_width) {
2057                    return Err(io::Error::new(
2058                        io::ErrorKind::InvalidData,
2059                        format!("decoded doc ids leave the directory range {first}..={last}"),
2060                    ));
2061                }
2062            }
2063            crate::observe::search_work!(
2064                doc_blocks += 1,
2065                doc_values += doc_ids.len(),
2066                doc_payload_bytes += state.1 - if self.compact_headers { 0 } else { 8 }
2067            );
2068            Ok(Some(state))
2069        })();
2070        if decoded.is_err() {
2071            doc_ids.clear();
2072            if let Some(error) = &self.content_error {
2073                // Write-once: no query may erase another query's failure.
2074                error.record(block_idx);
2075            }
2076        }
2077        decoded
2078    }
2079
2080    fn decode_block_doc_ids_unchecked(
2081        &self,
2082        pos: usize,
2083        block_idx: usize,
2084        header: [u8; 8],
2085        payload: &[u8],
2086        doc_ids: &mut Vec<u32>,
2087    ) -> io::Result<(usize, usize, usize)> {
2088        let invalid = |message: &str| io::Error::new(io::ErrorKind::InvalidData, message);
2089        let count = u16::from_le_bytes(header[..2].try_into().unwrap()) as usize;
2090        let first_doc = u32::from_le_bytes(header[2..6].try_into().unwrap());
2091        let (codec, doc_width) = PostingCodec::from_header_byte(header[6])?;
2092        let header_len = if self.compact_headers { 0 } else { 8 };
2093        let state = if self.compact_headers { block_idx } else { pos };
2094        if count == 0 || count > BLOCK_SIZE {
2095            return Err(invalid("invalid posting block count"));
2096        }
2097
2098        // Every decoder overwrites the complete output; retain initialized
2099        // storage so equal-sized blocks do not need a redundant zero pass.
2100        doc_ids.resize(count, 0);
2101        doc_ids[0] = first_doc;
2102
2103        if codec == PostingCodec::Simd4x {
2104            let values = if count == BLOCK_SIZE {
2105                count
2106            } else {
2107                count - 1
2108            };
2109            let bytes = bitpacking4x::encoded_len(values, doc_width);
2110            bitpacking4x::decode_docs(&payload[..bytes], doc_width, first_doc, doc_ids);
2111            return Ok((state, header_len + bytes, count));
2112        }
2113        let deltas_bytes = if count > 1 {
2114            match codec {
2115                PostingCodec::Rounded => {
2116                    let rounded = simd::RoundedBitWidth::try_from_u8(doc_width)
2117                        .ok_or_else(|| invalid("invalid rounded posting width"))?;
2118                    let bytes = (count - 1) * rounded.bytes_per_value();
2119                    simd::unpack_rounded_raw_delta_decode(
2120                        &payload[..bytes],
2121                        rounded,
2122                        doc_ids,
2123                        first_doc,
2124                        count,
2125                    );
2126                    return Ok((state, header_len + bytes, count));
2127                }
2128                PostingCodec::Packed => {
2129                    let bytes = packed_bytes(count - 1, doc_width);
2130                    unpack_bits(&payload[..bytes], doc_width, &mut doc_ids[1..], count - 1);
2131                    bytes
2132                }
2133                PostingCodec::Pfor => {
2134                    let bytes = pfor_payload_len(payload, count - 1, doc_width)?;
2135                    unpack_pfor(&payload[..bytes], doc_width, &mut doc_ids[1..], count - 1)?;
2136                    bytes
2137                }
2138                PostingCodec::Simd4x => unreachable!("SIMD documents were decoded above"),
2139            }
2140        } else {
2141            0
2142        };
2143        for i in 1..count {
2144            doc_ids[i] = doc_ids[i].wrapping_add(doc_ids[i - 1]);
2145        }
2146
2147        let tfs_start = header_len + deltas_bytes;
2148        Ok((state, tfs_start, count))
2149    }
2150
2151    /// Decode TFs from a previously loaded block (deferred decode).
2152    ///
2153    /// `block_offset` and `tf_start` are returned by `decode_block_doc_ids_only`.
2154    pub fn decode_block_tfs_deferred(
2155        &self,
2156        block_offset: usize,
2157        tf_start: usize,
2158        count: usize,
2159        tfs: &mut Vec<u32>,
2160    ) {
2161        tfs.resize(count, 0);
2162        self.decode_block_tfs_slice(block_offset, tf_start, tfs);
2163    }
2164
2165    /// Shared frequency decoder for vector and fixed-block consumers.
2166    fn decode_block_tfs_slice(&self, block_offset: usize, tf_start: usize, tfs: &mut [u32]) {
2167        let count = tfs.len();
2168        let (header, block_data) = if self.compact_headers {
2169            (
2170                self.block_header(block_offset),
2171                self.block_payload(block_offset),
2172            )
2173        } else {
2174            (
2175                self.stream[block_offset..block_offset + 8]
2176                    .try_into()
2177                    .unwrap(),
2178                &self.stream[block_offset..],
2179            )
2180        };
2181        let (codec, _) = PostingCodec::from_header_byte(header[6]).expect("admitted posting codec");
2182        let tf_bits = header[7];
2183        let payload = &block_data[tf_start..];
2184        crate::observe::search_work!(
2185            tf_blocks += 1,
2186            tf_values += count,
2187            tf_payload_bytes += match codec {
2188                PostingCodec::Pfor =>
2189                    pfor_payload_len(payload, count, tf_bits).expect("admitted frequency payload"),
2190                _ => packed_bytes(count, tf_bits),
2191            }
2192        );
2193        match codec {
2194            PostingCodec::Simd4x => {
2195                bitpacking4x::decode(&payload[..packed_bytes(count, tf_bits)], tf_bits, tfs);
2196            }
2197            PostingCodec::Rounded => {
2198                let rounded = simd::RoundedBitWidth::try_from_u8(tf_bits)
2199                    .expect("invalid rounded posting frequency width");
2200                simd::unpack_rounded(
2201                    &payload[..count * rounded.bytes_per_value()],
2202                    rounded,
2203                    tfs,
2204                    count,
2205                );
2206            }
2207            PostingCodec::Packed => {
2208                unpack_bits(
2209                    &payload[..packed_bytes(count, tf_bits)],
2210                    tf_bits,
2211                    tfs,
2212                    count,
2213                );
2214            }
2215            PostingCodec::Pfor => {
2216                let len = pfor_payload_len(payload, count, tf_bits)
2217                    .expect("invalid patched posting frequency payload");
2218                unpack_pfor(&payload[..len], tf_bits, tfs, count)
2219                    .expect("invalid patched posting frequency table");
2220            }
2221        }
2222    }
2223
2224    /// Byte length of block `block_idx` (from the L0 offsets).
2225    #[inline]
2226    fn block_len(&self, block_idx: usize) -> usize {
2227        block_len_from_l0(&self.l0_bytes, self.l0_count, self.stream.len(), block_idx)
2228    }
2229
2230    /// Codec of block `block_idx` (diagnostics).
2231    pub fn block_codec(&self, block_idx: usize) -> Option<PostingCodec> {
2232        if block_idx >= self.l0_count {
2233            return None;
2234        }
2235        PostingCodec::from_header_byte(self.block_header(block_idx)[6])
2236            .ok()
2237            .map(|(codec, _)| codec)
2238    }
2239
2240    /// First doc_id of a block (from L0 skip entry). Returns `None` if out of range.
2241    #[inline]
2242    pub fn block_first_doc(&self, block_idx: usize) -> Option<DocId> {
2243        if block_idx >= self.l0_count {
2244            return None;
2245        }
2246        let (first_doc, _, _, _) = self.read_l0_entry(block_idx);
2247        Some(first_doc)
2248    }
2249
2250    /// Last doc_id of a block (from L0 skip entry). Returns `None` if out of range.
2251    #[inline]
2252    pub fn block_last_doc(&self, block_idx: usize) -> Option<DocId> {
2253        if block_idx >= self.l0_count {
2254            return None;
2255        }
2256        let (_, last_doc, _, _) = self.read_l0_entry(block_idx);
2257        Some(last_doc)
2258    }
2259
2260    /// Find the first block whose `last_doc >= target`, starting from `from_block`.
2261    ///
2262    /// Checks the current block first, then gallops over the L1 group ends
2263    /// before a bounded L0 search. Near seeks are constant time; distant
2264    /// seeks inspect logarithmically many groups rather than scanning the gap.
2265    ///
2266    /// Returns `None` if no block contains `target`.
2267    pub fn seek_block(&self, target: DocId, from_block: usize) -> Option<usize> {
2268        if from_block >= self.l0_count {
2269            return None;
2270        }
2271
2272        if self
2273            .block_last_doc(from_block)
2274            .is_some_and(|last| last >= target)
2275        {
2276            return Some(from_block);
2277        }
2278        let from_l1 = from_block / L1_INTERVAL;
2279        let groups = self.l1_docs.words().get(from_l1..)?;
2280        let first = u32::from_le_bytes(*groups.first()?);
2281        let offset = if first >= target {
2282            0
2283        } else {
2284            let mut bound = 1usize;
2285            while bound < groups.len() && u32::from_le_bytes(groups[bound]) < target {
2286                bound = bound.saturating_mul(2);
2287            }
2288            let lo = bound / 2;
2289            let hi = bound.saturating_add(1).min(groups.len());
2290            lo + groups[lo..hi].partition_point(|&last| u32::from_le_bytes(last) < target)
2291        };
2292        let l1_idx = from_l1 + offset;
2293        if l1_idx >= self.l1_docs.len() {
2294            return None;
2295        }
2296
2297        // Search the validated entries in place instead of gathering every
2298        // strided last ID in the group before finding the lower bound.
2299        let start = (l1_idx * L1_INTERVAL).max(from_block + 1);
2300        let end = ((l1_idx + 1) * L1_INTERVAL).min(self.l0_count);
2301        let entries = self.l0_bytes.as_slice().as_chunks::<L0_SIZE>().0;
2302        let within = entries[start..end].partition_point(|entry| {
2303            u32::from_le_bytes([entry[4], entry[5], entry[6], entry[7]]) < target
2304        });
2305        let block_idx = start + within;
2306
2307        if block_idx < self.l0_count {
2308            Some(block_idx)
2309        } else {
2310            None
2311        }
2312    }
2313
2314    /// Create an iterator with skip support
2315    pub fn iterator(&self) -> BlockPostingIterator<'_> {
2316        BlockPostingIterator::new(self)
2317    }
2318
2319    /// Create an owned iterator that doesn't borrow self
2320    pub fn into_iterator(self) -> BlockPostingIterator<'static> {
2321        BlockPostingIterator::owned(self)
2322    }
2323
2324    /// Point probes start at their first requested block and reuse decode
2325    /// storage. Ordinary iteration still starts at the first posting.
2326    pub(crate) fn into_candidate_iterator(
2327        self,
2328        first_target: DocId,
2329        scratch: &mut PostingDecodeScratch,
2330    ) -> BlockPostingIterator<'static> {
2331        let first_block = self.seek_block(first_target, 0);
2332        let PostingDecodeScratch {
2333            doc_ids,
2334            term_freqs,
2335        } = std::mem::take(scratch);
2336        let mut block_tfs = term_freqs.unwrap_or_default();
2337        block_tfs.take();
2338        let mut iterator = BlockPostingIterator {
2339            block_list: std::borrow::Cow::Owned(self),
2340            current_block: first_block.unwrap_or(0),
2341            block_doc_ids: doc_ids,
2342            block_tfs,
2343            tf_state: (0, 0, 0),
2344            position_in_block: 0,
2345            position_offsets: None,
2346            position_offsets_ready: false,
2347            block_position_cursor: 0,
2348            exhausted: first_block.is_none(),
2349        };
2350        if let Some(block) = first_block {
2351            iterator.load_block(block);
2352        }
2353        iterator
2354    }
2355}
2356
2357#[derive(Default)]
2358pub(crate) struct PostingDecodeScratch {
2359    doc_ids: Vec<u32>,
2360    term_freqs: Option<std::sync::OnceLock<[u32; BLOCK_SIZE]>>,
2361}
2362
2363/// Iterator over block posting list with skip support
2364/// Can be either borrowed or owned via Cow
2365///
2366/// Document IDs and frequencies have separate bounded decode buffers. Document
2367/// movement does not initialize frequencies; score and position consumers do.
2368/// Only the current block is retained, including during scratch recycling.
2369pub struct BlockPostingIterator<'a> {
2370    block_list: std::borrow::Cow<'a, BlockPostingList>,
2371    current_block: usize,
2372    block_doc_ids: Vec<u32>,
2373    /// Fixed inline scratch, reused across blocks and initialized only by
2374    /// frequency/position consumers. OnceLock preserves immutable frequency
2375    /// access and the iterator's native Send+Sync contract; keeping it inline
2376    /// avoids a heap allocation per cursor per query.
2377    block_tfs: std::sync::OnceLock<[u32; BLOCK_SIZE]>,
2378    tf_state: (usize, usize, usize),
2379    position_in_block: usize,
2380    /// Lazily computed absolute position offsets, including the one-past-end
2381    /// offset. One bounded block (1 KiB) replaces per-document prefix reductions.
2382    position_offsets: Option<Box<[u64; BLOCK_SIZE + 1]>>,
2383    position_offsets_ready: bool,
2384    block_position_cursor: u64,
2385    exhausted: bool,
2386}
2387
2388impl<'a> BlockPostingIterator<'a> {
2389    pub(crate) fn recycle(self, scratch: &mut PostingDecodeScratch) {
2390        *scratch = PostingDecodeScratch {
2391            doc_ids: self.block_doc_ids,
2392            term_freqs: Some(self.block_tfs),
2393        };
2394    }
2395
2396    fn new(block_list: &'a BlockPostingList) -> Self {
2397        let exhausted = block_list.l0_count == 0;
2398        let mut iter = Self {
2399            block_list: std::borrow::Cow::Borrowed(block_list),
2400            current_block: 0,
2401            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
2402            block_tfs: std::sync::OnceLock::new(),
2403            tf_state: (0, 0, 0),
2404            position_in_block: 0,
2405            position_offsets: None,
2406            position_offsets_ready: false,
2407            block_position_cursor: 0,
2408            exhausted,
2409        };
2410        if !iter.exhausted {
2411            iter.load_block(0);
2412        }
2413        iter
2414    }
2415
2416    fn owned(block_list: BlockPostingList) -> BlockPostingIterator<'static> {
2417        let exhausted = block_list.l0_count == 0;
2418        let mut iter = BlockPostingIterator {
2419            block_list: std::borrow::Cow::Owned(block_list),
2420            current_block: 0,
2421            block_doc_ids: Vec::with_capacity(BLOCK_SIZE),
2422            block_tfs: std::sync::OnceLock::new(),
2423            tf_state: (0, 0, 0),
2424            position_in_block: 0,
2425            position_offsets: None,
2426            position_offsets_ready: false,
2427            block_position_cursor: 0,
2428            exhausted,
2429        };
2430        if !iter.exhausted {
2431            iter.load_block(0);
2432        }
2433        iter
2434    }
2435
2436    fn load_block(&mut self, block_idx: usize) {
2437        if block_idx >= self.block_list.l0_count {
2438            self.exhausted = true;
2439            return;
2440        }
2441
2442        self.current_block = block_idx;
2443        self.position_in_block = 0;
2444        self.position_offsets_ready = false;
2445        self.block_position_cursor = self.block_list.pos_cursor(block_idx).unwrap_or(0);
2446
2447        self.block_tfs.take();
2448        match self
2449            .block_list
2450            .decode_block_doc_ids_checked(block_idx, &mut self.block_doc_ids)
2451        {
2452            Ok(Some(state)) => self.tf_state = state,
2453            Ok(None) => unreachable!("block index was range-checked above"),
2454            Err(error) => {
2455                // The cursor API is infallible: end the list here rather than
2456                // expose ids outside the directory, and say so.
2457                log::error!(
2458                    "posting block {block_idx} of {} is corrupt; the cursor ends here: {error}",
2459                    self.block_list.l0_count
2460                );
2461                self.block_doc_ids.clear();
2462                self.tf_state = (0, 0, 0);
2463                self.exhausted = true;
2464            }
2465        }
2466    }
2467
2468    fn frequencies(&self) -> &[u32] {
2469        let (offset, start, count) = self.tf_state;
2470        if count == 0 {
2471            return &[];
2472        }
2473        &self.block_tfs.get_or_init(|| {
2474            let mut tfs = [0; BLOCK_SIZE];
2475            self.block_list
2476                .decode_block_tfs_slice(offset, start, &mut tfs[..count]);
2477            tfs
2478        })[..count]
2479    }
2480
2481    /// Offset of the current posting's positions in the term's position
2482    /// stream (see `structures::postings::positions_v2`): the block's cursor
2483    /// plus the term frequencies of the postings before it in the block.
2484    /// Meaningful only for lists built with position cursors.
2485    #[inline]
2486    pub fn position_cursor(&self) -> u64 {
2487        if self.position_offsets_ready {
2488            self.position_offsets.as_ref().unwrap()[self.position_in_block]
2489        } else {
2490            self.block_position_cursor
2491                + self.frequencies()[..self.position_in_block]
2492                    .iter()
2493                    .map(|&tf| u64::from(tf))
2494                    .sum::<u64>()
2495        }
2496    }
2497
2498    pub(crate) fn position_cursor_mut(&mut self) -> u64 {
2499        if !self.position_offsets_ready {
2500            self.initialize_position_offsets();
2501        }
2502        self.position_offsets.as_ref().unwrap()[self.position_in_block]
2503    }
2504
2505    /// Address and length of the current posting's positions. Prefixes are
2506    /// prepared once per block; document-only iteration does not initialize them.
2507    #[inline]
2508    pub(crate) fn position_range(&mut self) -> (u64, u32) {
2509        let cursor = self.position_cursor_mut();
2510        (cursor, self.frequencies()[self.position_in_block])
2511    }
2512
2513    fn initialize_position_offsets(&mut self) {
2514        self.frequencies();
2515        let offsets = self
2516            .position_offsets
2517            .get_or_insert_with(|| Box::new([0; BLOCK_SIZE + 1]));
2518        let mut total = self.block_position_cursor;
2519        offsets[0] = total;
2520        if let Some(frequencies) = self.block_tfs.get() {
2521            for (offset, &tf) in offsets[1..].iter_mut().zip(&frequencies[..self.tf_state.2]) {
2522                total += u64::from(tf);
2523                *offset = total;
2524            }
2525        }
2526        self.position_offsets_ready = true;
2527    }
2528
2529    pub fn doc(&self) -> DocId {
2530        if self.exhausted {
2531            TERMINATED
2532        } else if self.position_in_block < self.block_doc_ids.len() {
2533            self.block_doc_ids[self.position_in_block]
2534        } else {
2535            TERMINATED
2536        }
2537    }
2538
2539    pub fn term_freq(&self) -> u32 {
2540        if self.exhausted || self.position_in_block >= self.block_doc_ids.len() {
2541            0
2542        } else {
2543            self.frequencies()[self.position_in_block]
2544        }
2545    }
2546
2547    pub fn advance(&mut self) -> DocId {
2548        if self.exhausted {
2549            return TERMINATED;
2550        }
2551
2552        self.position_in_block += 1;
2553        if self.position_in_block >= self.block_doc_ids.len() {
2554            self.load_block(self.current_block + 1);
2555        }
2556        self.doc()
2557    }
2558
2559    #[inline]
2560    pub fn seek(&mut self, target: DocId) -> DocId {
2561        crate::observe::search_work!(posting_seeks += 1);
2562        if self.exhausted {
2563            return TERMINATED;
2564        }
2565        let current = self.block_doc_ids[self.position_in_block];
2566        if target <= current {
2567            return current;
2568        }
2569        if target > *self.block_doc_ids.last().unwrap() {
2570            return self.seek_later_block(target);
2571        }
2572        // Nearby intersection probes often need just one step. Distant probes
2573        // use logarithmic comparisons instead of scanning the decoded gap.
2574        let next = self.position_in_block + 1;
2575        self.position_in_block = if self.block_doc_ids[next] >= target {
2576            next
2577        } else {
2578            let remaining = &self.block_doc_ids[next + 1..];
2579            let mut bound = 1;
2580            while bound < remaining.len() && remaining[bound] < target {
2581                bound *= 2;
2582            }
2583            let lo = bound / 2;
2584            let hi = (bound + 1).min(remaining.len());
2585            next + 1 + lo + remaining[lo..hi].partition_point(|&doc| doc < target)
2586        };
2587        self.block_doc_ids[self.position_in_block]
2588    }
2589
2590    /// Select a posting from a bounded decoded suffix, leaving it parked for
2591    /// ordinary frequency/position reads. None yields after consuming a block.
2592    pub(crate) fn find_in_block(
2593        &mut self,
2594        mut find: impl FnMut(&[u32], &[u32]) -> Option<usize>,
2595    ) -> Option<DocId> {
2596        if self.exhausted {
2597            return Some(TERMINATED);
2598        }
2599        let start = self.position_in_block;
2600        if let Some(offset) = find(&self.block_doc_ids[start..], &self.frequencies()[start..]) {
2601            self.position_in_block += offset;
2602            return Some(self.block_doc_ids[self.position_in_block]);
2603        }
2604        self.load_block(self.current_block + 1);
2605        None
2606    }
2607
2608    /// Reposition a physical probe without discarding its bounded decode buffers.
2609    /// Logical document order can move backwards through an RGB permutation.
2610    pub(crate) fn seek_physical(&mut self, target: DocId) -> DocId {
2611        if !self.exhausted && target >= self.doc() {
2612            return self.seek(target);
2613        }
2614        let Some(block) = self.block_list.seek_block(target, 0) else {
2615            self.exhausted = true;
2616            return TERMINATED;
2617        };
2618        self.exhausted = false;
2619        if block != self.current_block || self.block_doc_ids.is_empty() {
2620            self.load_block(block);
2621        }
2622        if self.exhausted {
2623            return TERMINATED;
2624        }
2625        self.position_in_block = self.block_doc_ids.partition_point(|&doc| doc < target);
2626        self.doc()
2627    }
2628
2629    fn seek_later_block(&mut self, target: DocId) -> DocId {
2630        let Some(block_idx) = self.block_list.seek_block(target, self.current_block + 1) else {
2631            self.exhausted = true;
2632            return TERMINATED;
2633        };
2634        self.load_block(block_idx);
2635        if self.exhausted {
2636            return TERMINATED;
2637        }
2638        // Verified content: the block's last id is at least `target`.
2639        self.position_in_block = self.block_doc_ids.partition_point(|&doc| doc < target);
2640        self.block_doc_ids[self.position_in_block]
2641    }
2642
2643    /// Copy a bounded prefix without decoding frequencies or positions.
2644    pub(crate) fn fill_doc_batch(&mut self, docs: &mut [DocId]) -> usize {
2645        self.fill_batch::<false>(docs, &mut [])
2646    }
2647
2648    pub(crate) fn fill_scored_doc_batch(&mut self, docs: &mut [DocId], tfs: &mut [u32]) -> usize {
2649        assert!(tfs.len() >= docs.len());
2650        self.fill_batch::<true>(docs, tfs)
2651    }
2652
2653    fn fill_batch<const WITH_FREQUENCIES: bool>(
2654        &mut self,
2655        docs: &mut [DocId],
2656        tfs: &mut [u32],
2657    ) -> usize {
2658        assert!(docs.len() <= BLOCK_SIZE);
2659        let mut count = 0;
2660        while count < docs.len() && !self.exhausted {
2661            let remaining = &self.block_doc_ids[self.position_in_block..];
2662            let take = remaining.len().min(docs.len() - count);
2663            docs[count..count + take].copy_from_slice(&remaining[..take]);
2664            if WITH_FREQUENCIES {
2665                tfs[count..count + take].copy_from_slice(
2666                    &self.frequencies()[self.position_in_block..self.position_in_block + take],
2667                );
2668            }
2669            count += take;
2670            self.position_in_block += take;
2671            if self.position_in_block == self.block_doc_ids.len() {
2672                self.load_block(self.current_block + 1);
2673            }
2674        }
2675        count
2676    }
2677
2678    /// Probe sorted IDs within decoded blocks, amortizing directory checks.
2679    pub(crate) fn retain_doc_batch(&mut self, docs: &mut [DocId]) -> usize {
2680        self.retain_batch::<false>(docs, &mut [])
2681    }
2682
2683    pub(crate) fn retain_scored_doc_batch(&mut self, docs: &mut [DocId], tfs: &mut [u32]) -> usize {
2684        assert!(tfs.len() >= docs.len());
2685        self.retain_batch::<true>(docs, tfs)
2686    }
2687
2688    fn retain_batch<const WITH_FREQUENCIES: bool>(
2689        &mut self,
2690        docs: &mut [DocId],
2691        tfs: &mut [u32],
2692    ) -> usize {
2693        assert!(docs.len() <= BLOCK_SIZE);
2694        let mut input = 0;
2695        let mut kept = 0;
2696        while input < docs.len() {
2697            if self.seek(docs[input]) == TERMINATED {
2698                break;
2699            }
2700            let last = *self.block_doc_ids.last().unwrap();
2701            while input < docs.len() && docs[input] <= last {
2702                let doc = docs[input];
2703                self.position_in_block = simd::find_first_ge_block_from(
2704                    &self.block_doc_ids,
2705                    self.position_in_block,
2706                    doc,
2707                );
2708                if self.block_doc_ids[self.position_in_block] == doc {
2709                    docs[kept] = doc;
2710                    if WITH_FREQUENCIES {
2711                        tfs[kept] = self.frequencies()[self.position_in_block];
2712                    }
2713                    kept += 1;
2714                }
2715                input += 1;
2716            }
2717        }
2718        kept
2719    }
2720
2721    /// Consume a bounded ID range into caller-owned membership words. Retain the
2722    /// term-frequency prefix so subsequent scoring and position reads stay valid.
2723    pub(crate) fn fill_doc_window(&mut self, base: DocId, bits: &mut [u64]) {
2724        let span = u32::try_from(bits.len()).unwrap().checked_mul(64).unwrap();
2725        let end = base.saturating_add(span);
2726        bits.fill(0);
2727        self.seek(base);
2728        let list = self.block_list.as_ref();
2729        let dense = list.doc_count() >= 16
2730            && list
2731                .block_first_doc(0)
2732                .zip(list.block_last_doc(list.num_blocks().saturating_sub(1)))
2733                .is_some_and(|(first, last)| {
2734                    u64::from(last)
2735                        .checked_sub(u64::from(first))
2736                        .is_some_and(|span| span < u64::from(list.doc_count()) * 2)
2737                });
2738        if dense {
2739            self.fill_doc_words::<true>(base, end, bits);
2740        } else {
2741            self.fill_doc_words::<false>(base, end, bits);
2742        }
2743    }
2744
2745    fn fill_doc_words<const GROUPED: bool>(&mut self, base: DocId, end: DocId, bits: &mut [u64]) {
2746        self.visit_until::<false>(end, |docs, _| {
2747            // Decide once per window, outside the hot decoded-run loop.
2748            if !GROUPED {
2749                for &doc in docs {
2750                    let offset = (doc - base) as usize;
2751                    bits[offset / 64] |= 1u64 << (offset % 64);
2752                }
2753                return true;
2754            }
2755            let mut index = 0;
2756            while index < docs.len() {
2757                let offset = (docs[index] - base) as usize;
2758                let word_index = offset / 64;
2759                let mut mask = 1u64 << (offset % 64);
2760                index += 1;
2761                while index < docs.len() {
2762                    let offset = (docs[index] - base) as usize;
2763                    if offset / 64 != word_index {
2764                        break;
2765                    }
2766                    mask |= 1u64 << (offset % 64);
2767                    index += 1;
2768                }
2769                bits[word_index] |= mask;
2770            }
2771            true
2772        });
2773    }
2774
2775    /// Visit already decoded posting runs before `end` (exclusive). Each run
2776    /// contains at most one block. Returning false leaves that run unconsumed;
2777    /// callers can check cancellation without changing storage-layer policy.
2778    /// Preserve the TF prefix so subsequent position reads remain valid.
2779    pub(crate) fn visit_postings_until(
2780        &mut self,
2781        end: DocId,
2782        mut visit: impl FnMut(&[u32], &[u32]) -> bool,
2783    ) {
2784        self.visit_until::<true>(end, |docs, tfs| visit(docs, tfs.unwrap()));
2785    }
2786
2787    /// Consume bounded decoded ID runs without initializing frequencies.
2788    pub(crate) fn visit_doc_ids_until(
2789        &mut self,
2790        end: DocId,
2791        mut visit: impl FnMut(&[u32]) -> bool,
2792    ) {
2793        self.visit_until::<false>(end, |docs, _| visit(docs));
2794    }
2795
2796    fn visit_until<const WITH_FREQUENCIES: bool>(
2797        &mut self,
2798        end: DocId,
2799        mut visit: impl FnMut(&[u32], Option<&[u32]>) -> bool,
2800    ) {
2801        while self.doc() < end {
2802            let start = self.position_in_block;
2803            let count = self.block_doc_ids[start..].partition_point(|&doc| doc < end);
2804            let tfs = WITH_FREQUENCIES.then(|| &self.frequencies()[start..start + count]);
2805            if !visit(&self.block_doc_ids[start..start + count], tfs) {
2806                return;
2807            }
2808            self.position_in_block += count;
2809            if self.position_in_block == self.block_doc_ids.len() {
2810                self.load_block(self.current_block + 1);
2811            }
2812        }
2813    }
2814
2815    /// Skip to the next block, returning the first doc_id in the new block
2816    /// This is used for block-max pruning when the current block's
2817    /// max score can't beat the threshold.
2818    pub fn skip_to_next_block(&mut self) -> DocId {
2819        if self.exhausted {
2820            return TERMINATED;
2821        }
2822        self.load_block(self.current_block + 1);
2823        self.doc()
2824    }
2825
2826    /// Get the current block index
2827    #[inline]
2828    pub fn current_block_idx(&self) -> usize {
2829        self.current_block
2830    }
2831
2832    /// Get total number of blocks
2833    #[inline]
2834    pub fn num_blocks(&self) -> usize {
2835        self.block_list.l0_count
2836    }
2837
2838    /// Borrow immutable metadata for the currently decoded posting block.
2839    pub(crate) fn current_block_metadata(&self) -> Option<(&BlockPostingList, usize)> {
2840        (!self.exhausted).then_some((&self.block_list, self.current_block))
2841    }
2842
2843    /// Get the current block's max term frequency for block-max pruning
2844    #[inline]
2845    pub fn current_block_max_tf(&self) -> u32 {
2846        if self.exhausted || self.current_block >= self.block_list.l0_count {
2847            0
2848        } else {
2849            self.block_list
2850                .block_max_tf(self.current_block)
2851                .unwrap_or(0)
2852        }
2853    }
2854}
2855
2856/// Bounded intersection scratch for a fixed pair of monotonically advancing
2857/// posting iterators. Cursor positions stay on a match while its TF and positions
2858/// are consumed; the SIMD kernel runs once per overlapping block pair.
2859pub(crate) struct PostingIntersection {
2860    seek_driven: bool,
2861    left_is_rare: bool,
2862    blocks: (usize, usize),
2863    pairs: [(u8, u8); BLOCK_SIZE],
2864    next: usize,
2865    count: usize,
2866    ends: (usize, usize),
2867}
2868
2869impl Default for PostingIntersection {
2870    fn default() -> Self {
2871        Self {
2872            seek_driven: false,
2873            left_is_rare: true,
2874            blocks: (usize::MAX, usize::MAX),
2875            pairs: [(0, 0); BLOCK_SIZE],
2876            next: 0,
2877            count: 0,
2878            ends: (0, 0),
2879        }
2880    }
2881}
2882
2883impl PostingIntersection {
2884    pub(crate) fn with_costs(left: u32, right: u32) -> Self {
2885        Self {
2886            seek_driven: left.min(right).saturating_mul(4) < left.max(right),
2887            left_is_rare: left <= right,
2888            ..Self::default()
2889        }
2890    }
2891
2892    /// Invalidate cached pairs before a physical rewind through a document map.
2893    pub(crate) fn reset(&mut self) {
2894        self.blocks = (usize::MAX, usize::MAX);
2895    }
2896
2897    /// None yields at a block boundary so the caller can check cancellation.
2898    pub(crate) fn intersect_block(
2899        &mut self,
2900        left: &mut BlockPostingIterator<'_>,
2901        right: &mut BlockPostingIterator<'_>,
2902    ) -> Option<DocId> {
2903        if left.exhausted || right.exhausted {
2904            return Some(TERMINATED);
2905        }
2906        if left.doc() == right.doc() {
2907            return Some(left.doc());
2908        }
2909        if self.seek_driven {
2910            fn align(
2911                lead: &mut BlockPostingIterator<'_>,
2912                other: &mut BlockPostingIterator<'_>,
2913            ) -> Option<DocId> {
2914                let candidate = lead.doc();
2915                let next = other.seek(candidate);
2916                if next == candidate {
2917                    return Some(candidate);
2918                }
2919                lead.seek(next);
2920                None
2921            }
2922            return if self.left_is_rare {
2923                align(left, right)
2924            } else {
2925                align(right, left)
2926            };
2927        }
2928        if *left.block_doc_ids.last().unwrap() < right.doc() {
2929            left.seek_later_block(right.doc());
2930            return None;
2931        }
2932        if *right.block_doc_ids.last().unwrap() < left.doc() {
2933            right.seek_later_block(left.doc());
2934            return None;
2935        }
2936        let blocks = (left.current_block, right.current_block);
2937        if self.blocks != blocks {
2938            let mut a = left.position_in_block;
2939            let mut b = right.position_in_block;
2940            self.count = simd::intersect_posting_blocks(
2941                &left.block_doc_ids,
2942                &mut a,
2943                &right.block_doc_ids,
2944                &mut b,
2945                &mut self.pairs,
2946            );
2947            self.blocks = blocks;
2948            self.ends = (a, b);
2949            self.next = 0;
2950        }
2951        while self.next < self.count {
2952            let (a, b) = self.pairs[self.next];
2953            self.next += 1;
2954            let (a, b) = (usize::from(a), usize::from(b));
2955            if a >= left.position_in_block && b >= right.position_in_block {
2956                left.position_in_block = a;
2957                right.position_in_block = b;
2958                return Some(left.doc());
2959            }
2960        }
2961        left.position_in_block = left.position_in_block.max(self.ends.0);
2962        right.position_in_block = right.position_in_block.max(self.ends.1);
2963        if left.position_in_block == left.block_doc_ids.len() {
2964            left.load_block(left.current_block + 1);
2965        }
2966        if right.position_in_block == right.block_doc_ids.len() {
2967            right.load_block(right.current_block + 1);
2968        }
2969        None
2970    }
2971}
2972
2973#[cfg(test)]
2974mod compact_layout_tests {
2975    use super::*;
2976    #[test]
2977    fn batched_intersection_preserves_monotone_seeks_frequencies_and_position_offsets() {
2978        for codec in [
2979            PostingCodec::Rounded,
2980            PostingCodec::Packed,
2981            PostingCodec::Pfor,
2982            PostingCodec::Simd4x,
2983        ] {
2984            let make = |divisor| {
2985                let mut postings = PostingList::new();
2986                for doc in 0..3001 {
2987                    if doc % divisor != 1 {
2988                        postings.push(doc, 1 + doc % 7);
2989                    }
2990                }
2991                BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
2992                    .unwrap()
2993            };
2994            let a = make(3);
2995            let b = make(5);
2996            for (seek_driven, left_is_rare) in [(false, true), (true, true), (true, false)] {
2997                let mut left = a.iterator();
2998                let mut right = b.iterator();
2999                let mut reference_left = a.iterator();
3000                let mut reference_right = b.iterator();
3001                let mut intersection = PostingIntersection {
3002                    seek_driven,
3003                    left_is_rare,
3004                    ..Default::default()
3005                };
3006                let mut target = 0;
3007                loop {
3008                    left.seek(target);
3009                    right.seek(target);
3010                    let doc = loop {
3011                        if let Some(doc) = intersection.intersect_block(&mut left, &mut right) {
3012                            break doc;
3013                        }
3014                    };
3015                    let expected = (target..3001)
3016                        .find(|doc| doc % 3 != 1 && doc % 5 != 1)
3017                        .unwrap_or(TERMINATED);
3018                    assert_eq!(doc, expected);
3019                    assert_eq!(
3020                        intersection.intersect_block(&mut left, &mut right),
3021                        Some(doc)
3022                    );
3023                    if doc == TERMINATED {
3024                        break;
3025                    }
3026                    reference_left.seek(doc);
3027                    reference_right.seek(doc);
3028                    assert_eq!(left.term_freq(), reference_left.term_freq());
3029                    assert_eq!(right.term_freq(), reference_right.term_freq());
3030                    assert_eq!(left.position_cursor(), reference_left.position_cursor());
3031                    assert_eq!(right.position_cursor(), reference_right.position_cursor());
3032                    target = doc + if doc % 7 == 0 { 19 } else { 1 };
3033                }
3034                for target in [0, 200, 129, 3, 2048, 3000, 2] {
3035                    intersection.reset();
3036                    left.seek_physical(target);
3037                    right.seek_physical(target);
3038                    let doc = loop {
3039                        if let Some(doc) = intersection.intersect_block(&mut left, &mut right) {
3040                            break doc;
3041                        }
3042                    };
3043                    let expected = (target..3001)
3044                        .find(|doc| doc % 3 != 1 && doc % 5 != 1)
3045                        .unwrap_or(TERMINATED);
3046                    assert_eq!(doc, expected);
3047                    reference_left.seek_physical(doc);
3048                    reference_right.seek_physical(doc);
3049                    assert_eq!(left.position_cursor(), reference_left.position_cursor());
3050                    assert_eq!(right.position_cursor(), reference_right.position_cursor());
3051                }
3052            }
3053        }
3054    }
3055
3056    #[test]
3057    fn compact_postings_preserve_payload_scores_and_copy_merges() {
3058        for codec in [
3059            PostingCodec::Rounded,
3060            PostingCodec::Packed,
3061            PostingCodec::Simd4x,
3062            PostingCodec::Pfor,
3063        ] {
3064            for count in [1, 2, 127, 128, 129, 1025] {
3065                let mut postings = PostingList::new();
3066                for doc in 0..count {
3067                    postings.push(doc * 3, doc % 13);
3068                }
3069                let list = BlockPostingList::from_posting_list_with_ratio_bounds(
3070                    &postings,
3071                    true,
3072                    Some(&|doc| doc % 100 + 1),
3073                    codec,
3074                )
3075                .unwrap();
3076                let mut old = Vec::new();
3077                list.serialize(&mut old).unwrap();
3078                let mut bytes = Vec::new();
3079                list.serialize_compact(&mut bytes).unwrap();
3080                let compact = BlockPostingList::deserialize(&bytes).unwrap();
3081                assert_eq!(compact.compact_headers, codec != PostingCodec::Pfor);
3082                if codec != PostingCodec::Pfor {
3083                    assert_eq!(old.len() - bytes.len(), list.num_blocks() * 8);
3084                }
3085                let mut a = Vec::new();
3086                let mut b = Vec::new();
3087                for i in 0..list.num_blocks() {
3088                    assert_eq!(list.block_payload(i), compact.block_payload(i));
3089                    assert_eq!(list.pos_cursor(i), compact.pos_cursor(i));
3090                    assert!(compact.decode_block_into(i, &mut a, &mut b));
3091                    assert_eq!(
3092                        a,
3093                        postings.postings
3094                            [i * BLOCK_SIZE..(i * BLOCK_SIZE + BLOCK_SIZE).min(count as usize)]
3095                            .iter()
3096                            .map(|p| p.doc_id)
3097                            .collect::<Vec<_>>()
3098                    );
3099                    assert_eq!(b, a.iter().map(|doc| (doc / 3) % 13).collect::<Vec<_>>());
3100                }
3101                let mut repeated = Vec::new();
3102                compact.serialize(&mut repeated).unwrap();
3103                assert_eq!(bytes, repeated);
3104                for second in [&old, &bytes] {
3105                    let mut merged = Vec::new();
3106                    let (docs, len) = BlockPostingList::concatenate_streaming(
3107                        &[(&bytes, 0), (second, count * 3)],
3108                        &mut merged,
3109                    )
3110                    .unwrap();
3111                    assert_eq!(len, merged.len());
3112                    assert_eq!(docs, count * 2);
3113                    let merged = BlockPostingList::deserialize(&merged).unwrap();
3114                    for block in 0..merged.num_blocks() {
3115                        assert_eq!(
3116                            merged.block_payload(block),
3117                            list.block_payload(block % list.num_blocks())
3118                        );
3119                        assert!(merged.decode_block_into(block, &mut a, &mut b));
3120                    }
3121                }
3122            }
3123        }
3124    }
3125
3126    #[test]
3127    fn compact_posting_metadata_rejects_bad_descriptors_and_content_checks_remain_observable() {
3128        let mut postings = PostingList::new();
3129        for doc in 0..257 {
3130            postings.push(doc * 2, 1);
3131        }
3132        let list = BlockPostingList::from_posting_list_with_options(
3133            &postings,
3134            true,
3135            None,
3136            PostingCodec::Rounded,
3137        )
3138        .unwrap();
3139        let mut bytes = Vec::new();
3140        list.serialize_compact(&mut bytes).unwrap();
3141        let footer = Footer::parse(&bytes).unwrap();
3142        for (at, value) in [
3143            (footer.l0_end(), 0),
3144            (footer.l0_end() + 1, 1),
3145            (footer.l0_end() + 2, 33),
3146            (footer.l0_end() + 3, 33),
3147            (footer.l1_bounds_end(), 1),
3148        ] {
3149            let mut corrupt = bytes.clone();
3150            corrupt[at] = value;
3151            assert!(
3152                BlockPostingList::deserialize(&corrupt).is_err(),
3153                "byte {at}"
3154            );
3155        }
3156        let mut corrupt = bytes.clone();
3157        corrupt[0] = 0;
3158        let compact = BlockPostingList::deserialize(&corrupt).unwrap();
3159        let mut docs = Vec::new();
3160        assert!(compact.decode_block_doc_ids_checked(0, &mut docs).is_err());
3161        assert!(docs.is_empty());
3162    }
3163}
3164
3165#[cfg(test)]
3166mod tests {
3167    use super::*;
3168
3169    #[test]
3170    fn block_decoding_overwrites_stale_values_across_lengths_and_codecs() {
3171        let mut docs = vec![u32::MAX; BLOCK_SIZE * 2];
3172        let mut tfs = docs.clone();
3173        for codec in [
3174            PostingCodec::Rounded,
3175            PostingCodec::Packed,
3176            PostingCodec::Pfor,
3177            PostingCodec::Simd4x,
3178        ] {
3179            for count in [128, 1, 127, 128, 17, 256, 257] {
3180                for freq in [0, 1, 255, 65536] {
3181                    let mut postings = PostingList::new();
3182                    for i in 0..count {
3183                        postings.push(i * 3 + 7, freq);
3184                    }
3185                    let list = BlockPostingList::from_posting_list_with_options(
3186                        &postings, true, None, codec,
3187                    )
3188                    .unwrap();
3189                    for compact in [false, true] {
3190                        let mut bytes = Vec::new();
3191                        if compact {
3192                            list.serialize_compact(&mut bytes).unwrap();
3193                        } else {
3194                            list.serialize(&mut bytes).unwrap();
3195                        }
3196                        let list = BlockPostingList::deserialize(&bytes).unwrap();
3197                        for block in (0..list.num_blocks()).rev().chain(0..list.num_blocks()) {
3198                            docs.fill(u32::MAX);
3199                            tfs.fill(u32::MAX);
3200                            assert!(list.decode_block_into(block, &mut docs, &mut tfs));
3201                            let expected: Vec<_> = postings.postings[block * BLOCK_SIZE
3202                                ..postings.postings.len().min((block + 1) * BLOCK_SIZE)]
3203                                .iter()
3204                                .map(|p| p.doc_id)
3205                                .collect();
3206                            assert_eq!(docs, expected);
3207                            assert_eq!(tfs, vec![freq; expected.len()]);
3208                        }
3209                    }
3210                }
3211            }
3212        }
3213    }
3214
3215    #[test]
3216    fn compact_membership_batches_preserve_resume_frequencies_and_position_prefixes() {
3217        for codec in [
3218            PostingCodec::Rounded,
3219            PostingCodec::Packed,
3220            PostingCodec::Pfor,
3221            PostingCodec::Simd4x,
3222        ] {
3223            let mut postings = PostingList::new();
3224            let mut expected = Vec::new();
3225            let mut prefix = 0u64;
3226            for i in 0..701u32 {
3227                let tf = i % 19 + 1;
3228                let doc = i * 13 + 5;
3229                postings.push(doc, tf);
3230                expected.push((doc, tf, prefix));
3231                prefix += u64::from(tf);
3232            }
3233            let list =
3234                BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3235                    .unwrap();
3236            let bytes = serialize_bpl(&list);
3237            let mut cursor = list.iterator();
3238            let mut docs = [0; BLOCK_SIZE];
3239            let mut consumed = 0;
3240            while cursor.doc() != TERMINATED {
3241                let count = cursor.fill_doc_batch(&mut docs);
3242                assert_eq!(
3243                    &docs[..count],
3244                    &expected[consumed..consumed + count]
3245                        .iter()
3246                        .map(|e| e.0)
3247                        .collect::<Vec<_>>()
3248                );
3249                consumed += count;
3250                if consumed < expected.len() {
3251                    assert!(cursor.block_tfs.get().is_none(), "copy initialized TFs");
3252                    assert_eq!(cursor.doc(), expected[consumed].0);
3253                    assert_eq!(cursor.term_freq(), expected[consumed].1);
3254                    assert_eq!(cursor.position_cursor_mut(), expected[consumed].2);
3255                }
3256            }
3257            assert_eq!(consumed, expected.len());
3258            assert_eq!(cursor.fill_doc_batch(&mut docs), 0);
3259            let mut cursor = list.iterator();
3260            for start in (0..10_000u32).step_by(BLOCK_SIZE) {
3261                let mut candidates: Vec<_> = (start..start + BLOCK_SIZE as u32).collect();
3262                let wanted: Vec<_> = candidates
3263                    .iter()
3264                    .copied()
3265                    .filter(|doc| expected.iter().any(|e| e.0 == *doc))
3266                    .collect();
3267                let kept = cursor.retain_doc_batch(&mut candidates);
3268                assert_eq!(&candidates[..kept], wanted, "{codec:?} start={start}");
3269                if cursor.doc() != TERMINATED {
3270                    let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3271                    assert_eq!(cursor.term_freq(), entry.1);
3272                    assert_eq!(cursor.position_cursor_mut(), entry.2);
3273                }
3274            }
3275            assert_eq!(cursor.doc(), TERMINATED);
3276            assert_eq!(serialize_bpl(&list), bytes);
3277        }
3278    }
3279
3280    #[test]
3281    fn membership_words_preserve_unaligned_dense_sparse_windows_and_resume() {
3282        for codec in [
3283            PostingCodec::Rounded,
3284            PostingCodec::Packed,
3285            PostingCodec::Pfor,
3286            PostingCodec::Simd4x,
3287        ] {
3288            for stride in [1, 2, 3, 67, 129] {
3289                let docs: Vec<u32> = (0..10_000).step_by(stride).collect();
3290                let mut postings = PostingList::new();
3291                for &doc in &docs {
3292                    postings.push(doc, 1 + doc % 7);
3293                }
3294                let list =
3295                    BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3296                        .unwrap();
3297                for start in [0, 1, 17, 63, 64, 127, 511] {
3298                    let mut cursor = list.iterator();
3299                    for base in [start, start + 4096, start + 8192] {
3300                        let mut bits = [u64::MAX; 64];
3301                        cursor.fill_doc_window(base, &mut bits);
3302                        let mut expected = [0u64; 64];
3303                        for &doc in &docs {
3304                            if (base..base + 4096).contains(&doc) {
3305                                let offset = (doc - base) as usize;
3306                                expected[offset / 64] |= 1u64 << (offset % 64);
3307                            }
3308                        }
3309                        assert_eq!(bits, expected, "{codec:?}, stride={stride}, base={base}");
3310                        let next = docs
3311                            .iter()
3312                            .copied()
3313                            .find(|&doc| doc >= base + 4096)
3314                            .unwrap_or(TERMINATED);
3315                        assert_eq!(cursor.doc(), next);
3316                        if next != TERMINATED {
3317                            assert_eq!(cursor.term_freq(), 1 + next % 7);
3318                        }
3319                    }
3320                }
3321            }
3322        }
3323    }
3324
3325    #[test]
3326    fn document_only_navigation_defers_frequencies_and_resumes_exact_position_reads() {
3327        for codec in [
3328            PostingCodec::Rounded,
3329            PostingCodec::Packed,
3330            PostingCodec::Pfor,
3331            PostingCodec::Simd4x,
3332        ] {
3333            let mut postings = PostingList::new();
3334            let mut expected = Vec::new();
3335            let mut prefix = 0u64;
3336            for i in 0..701u32 {
3337                let tf = if i % 37 == 0 { u32::MAX } else { i % 23 + 1 };
3338                let doc = i * 13 + 5;
3339                postings.push(doc, tf);
3340                expected.push((doc, tf, prefix));
3341                prefix += u64::from(tf);
3342            }
3343            let list =
3344                BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3345                    .unwrap();
3346            let bytes = serialize_bpl(&list);
3347            for owned in [false, true] {
3348                let mut cursor = if owned {
3349                    list.clone().into_iterator()
3350                } else {
3351                    list.iterator()
3352                };
3353                assert!(
3354                    cursor.block_tfs.get().is_none(),
3355                    "{codec:?}: ID-only open decoded frequencies"
3356                );
3357                for target in [20, 205, 1800, 3900] {
3358                    let entry = expected.iter().find(|e| e.0 >= target).unwrap();
3359                    assert_eq!(cursor.seek(target), entry.0);
3360                    assert!(
3361                        cursor.block_tfs.get().is_none(),
3362                        "ID-only seek decoded frequencies"
3363                    );
3364                }
3365                let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3366                #[cfg(feature = "native")]
3367                std::thread::scope(|scope| {
3368                    for _ in 0..4 {
3369                        let shared = &cursor;
3370                        scope.spawn(move || {
3371                            assert_eq!(shared.term_freq(), entry.1);
3372                            assert_eq!(shared.position_cursor(), entry.2);
3373                        });
3374                    }
3375                });
3376                assert_eq!(cursor.position_cursor(), entry.2);
3377                assert_eq!(cursor.term_freq(), entry.1);
3378                assert_eq!(cursor.position_cursor_mut(), entry.2);
3379                let mut bits = [0u64; 64];
3380                cursor.fill_doc_window(4096, &mut bits);
3381                for &(doc, _, _) in &expected {
3382                    if (4096..8192).contains(&doc) {
3383                        assert_ne!(bits[(doc as usize - 4096) / 64] & (1 << (doc % 64)), 0);
3384                    }
3385                }
3386                assert_eq!(
3387                    bits.iter().map(|word| word.count_ones()).sum::<u32>(),
3388                    expected
3389                        .iter()
3390                        .filter(|e| (4096..8192).contains(&e.0))
3391                        .count() as u32
3392                );
3393                assert!(
3394                    cursor.block_tfs.get().is_none(),
3395                    "membership windows decoded frequencies"
3396                );
3397                let entry = expected.iter().find(|e| e.0 == cursor.doc()).unwrap();
3398                assert_eq!(cursor.position_cursor_mut(), entry.2);
3399                assert_eq!(cursor.term_freq(), entry.1);
3400                let parked = cursor.doc();
3401                cursor.visit_postings_until(TERMINATED, |docs, tfs| {
3402                    for (&doc, &tf) in docs.iter().zip(tfs) {
3403                        assert_eq!(tf, expected.iter().find(|e| e.0 == doc).unwrap().1);
3404                    }
3405                    false
3406                });
3407                assert_eq!(cursor.doc(), parked);
3408                assert_eq!(cursor.position_cursor(), entry.2);
3409                let mut seen = Vec::new();
3410                cursor.visit_postings_until(TERMINATED, |docs, tfs| {
3411                    seen.extend(docs.iter().copied().zip(tfs.iter().copied()));
3412                    true
3413                });
3414                assert_eq!(
3415                    seen,
3416                    expected
3417                        .iter()
3418                        .filter(|e| e.0 >= parked)
3419                        .map(|e| (e.0, e.1))
3420                        .collect::<Vec<_>>()
3421                );
3422                assert_eq!(cursor.doc(), TERMINATED);
3423                assert_eq!(cursor.term_freq(), 0);
3424                assert_eq!(cursor.seek(0), TERMINATED);
3425            }
3426            assert_eq!(serialize_bpl(&list), bytes);
3427        }
3428    }
3429
3430    #[test]
3431    fn document_navigation_defers_position_accounting_without_changing_cursors() {
3432        for codec in [
3433            PostingCodec::Rounded,
3434            PostingCodec::Packed,
3435            PostingCodec::Pfor,
3436            PostingCodec::Simd4x,
3437        ] {
3438            let mut postings = PostingList::new();
3439            let mut expected = Vec::new();
3440            let mut total = 0u64;
3441            for doc in 0..701u32 {
3442                expected.push(total);
3443                let tf = doc % 23 + 1;
3444                postings.push(doc * 7, tf);
3445                total += u64::from(tf);
3446            }
3447            let list =
3448                BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
3449                    .unwrap();
3450            let mut cursor = list.iterator();
3451            for target in [7, 70, 777, 896, 1400, 3500, 4900] {
3452                assert_eq!(cursor.seek(target), target);
3453                assert!(
3454                    cursor.position_offsets.is_none(),
3455                    "navigation must not prepare unused position offsets"
3456                );
3457                assert_eq!(cursor.position_cursor(), expected[target as usize / 7]);
3458                assert_eq!(cursor.term_freq(), target / 7 % 23 + 1);
3459                assert_eq!(cursor.seek(target - 1), target);
3460            }
3461            assert_eq!(cursor.advance(), TERMINATED);
3462        }
3463    }
3464
3465    #[test]
3466    fn deferred_position_accounting_resumes_after_reads_windows_and_stopped_runs() {
3467        for codec in [
3468            PostingCodec::Rounded,
3469            PostingCodec::Packed,
3470            PostingCodec::Pfor,
3471            PostingCodec::Simd4x,
3472        ] {
3473            let docs: Vec<_> = (0..600u32).map(|doc| (doc * 11, u32::MAX - doc)).collect();
3474            let prefixes = expected_cursors(&docs);
3475            let mut input = PostingList::new();
3476            for &(doc, tf) in &docs {
3477                input.push(doc, tf);
3478            }
3479            let list = BlockPostingList::build(&input, true, None, codec, false, false).unwrap();
3480            let before = serialize_bpl(&list);
3481            let mut cursor = list.iterator();
3482            for at in [13, 25, 127, 128, 199] {
3483                cursor.seek(docs[at].0);
3484                assert_eq!(cursor.position_range(), (prefixes[at], docs[at].1));
3485                assert_eq!(cursor.position_range(), (prefixes[at], docs[at].1));
3486                assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3487                assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3488                assert_eq!(cursor.position_cursor(), prefixes[at]);
3489                cursor.seek(docs[at].0 - 1);
3490                assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3491            }
3492            cursor.visit_postings_until(docs[220].0, |_, _| true);
3493            assert_eq!(cursor.position_cursor_mut(), prefixes[220]);
3494            cursor.visit_postings_until(docs[250].0, |_, _| false);
3495            assert_eq!(cursor.position_cursor_mut(), prefixes[220]);
3496            let mut bits = [0u64; 4];
3497            cursor.fill_doc_window(docs[220].0, &mut bits);
3498            let at = docs.partition_point(|&(doc, _)| doc < docs[220].0 + 256);
3499            assert_eq!(cursor.position_cursor_mut(), prefixes[at]);
3500            cursor.skip_to_next_block();
3501            assert_eq!(cursor.position_cursor_mut(), prefixes[256]);
3502            assert_eq!(serialize_bpl(&list), before);
3503        }
3504    }
3505
3506    #[test]
3507    fn posting_runs_preserve_frequencies_positions_and_resumption_after_stop() {
3508        let docs: Vec<_> = (0..1027u32).map(|i| (i * 7, i % 31 + 1)).collect();
3509        let prefixes = expected_cursors(&docs);
3510        let mut list = PostingList::new();
3511        for &(doc, tf) in &docs {
3512            list.push(doc, tf);
3513        }
3514        for codec in [
3515            PostingCodec::Rounded,
3516            PostingCodec::Packed,
3517            PostingCodec::Pfor,
3518            PostingCodec::Simd4x,
3519        ] {
3520            let postings = BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3521            let bytes = serialize_bpl(&postings);
3522            let mut cursor = postings.iterator();
3523            cursor.seek(docs[17].0);
3524            let mut actual = Vec::new();
3525            let mut calls = 0;
3526            cursor.visit_postings_until(TERMINATED, |ids, tfs| {
3527                calls += 1;
3528                assert_eq!(ids.len(), tfs.len());
3529                assert!(ids.len() <= BLOCK_SIZE);
3530                if calls == 3 {
3531                    return false;
3532                }
3533                actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3534                true
3535            });
3536            assert_eq!(actual, docs[17..256]);
3537            assert_eq!(cursor.doc(), docs[256].0);
3538            assert_eq!(cursor.position_cursor(), prefixes[256]);
3539            cursor.visit_postings_until(docs[331].0, |ids, tfs| {
3540                actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3541                true
3542            });
3543            assert_eq!(actual, docs[17..331]);
3544            assert_eq!(cursor.doc(), docs[331].0);
3545            assert_eq!(cursor.term_freq(), docs[331].1);
3546            assert_eq!(cursor.position_cursor(), prefixes[331]);
3547            cursor.visit_postings_until(TERMINATED, |ids, tfs| {
3548                actual.extend(ids.iter().copied().zip(tfs.iter().copied()));
3549                true
3550            });
3551            assert_eq!(actual, docs[17..]);
3552            cursor.visit_postings_until(TERMINATED, |_, _| panic!("already exhausted"));
3553            assert_eq!(cursor.doc(), TERMINATED);
3554            assert_eq!(serialize_bpl(&postings), bytes);
3555        }
3556    }
3557
3558    #[test]
3559    fn document_windows_preserve_posting_frequencies_positions_and_bytes() {
3560        for near_end in [false, true] {
3561            let base = if near_end { TERMINATED - 30_000 } else { 0 };
3562            let docs: Vec<_> = (0..5000u32).map(|i| (base + i * 5, i % 31 + 1)).collect();
3563            let prefixes = expected_cursors(&docs);
3564            let mut list = PostingList::new();
3565            for &(doc, tf) in &docs {
3566                list.push(doc, tf);
3567            }
3568            for codec in [
3569                PostingCodec::Rounded,
3570                PostingCodec::Packed,
3571                PostingCodec::Pfor,
3572                PostingCodec::Simd4x,
3573            ] {
3574                let postings =
3575                    BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3576                let bytes = serialize_bpl(&postings);
3577                let mut cursor = postings.iterator();
3578                let mut actual = Vec::new();
3579                while cursor.doc() != TERMINATED {
3580                    let base = cursor.doc();
3581                    let mut bits = [u64::MAX; 64];
3582                    cursor.fill_doc_window(base, &mut bits);
3583                    for (index, word) in bits.into_iter().enumerate() {
3584                        for bit in 0..64 {
3585                            if word & (1 << bit) != 0 {
3586                                actual.push(base + index as u32 * 64 + bit);
3587                            }
3588                        }
3589                    }
3590                    let at = docs.partition_point(|&(doc, _)| doc < base.saturating_add(4096));
3591                    assert_eq!(cursor.doc(), docs.get(at).map_or(TERMINATED, |p| p.0));
3592                    if at < docs.len() {
3593                        assert_eq!(cursor.term_freq(), docs[at].1);
3594                        assert_eq!(cursor.position_cursor(), prefixes[at]);
3595                    }
3596                }
3597                assert_eq!(actual, docs.iter().map(|p| p.0).collect::<Vec<_>>());
3598                assert_eq!(serialize_bpl(&postings), bytes);
3599            }
3600        }
3601    }
3602
3603    #[test]
3604    fn nearby_and_distant_seeks_preserve_postings_position_cursors_and_bytes() {
3605        let docs: Vec<_> = (0..32_769u32)
3606            .map(|i| (i * 5 + i % 3, i % 31 + 1))
3607            .collect();
3608        let prefixes = expected_cursors(&docs);
3609        let mut list = PostingList::new();
3610        for &(doc, tf) in &docs {
3611            list.push(doc, tf);
3612        }
3613        for codec in [
3614            PostingCodec::Rounded,
3615            PostingCodec::Packed,
3616            PostingCodec::Pfor,
3617            PostingCodec::Simd4x,
3618        ] {
3619            let postings = BlockPostingList::build(&list, true, None, codec, false, false).unwrap();
3620            let bytes = serialize_bpl(&postings);
3621            let lasts: Vec<_> = (0..postings.num_blocks())
3622                .map(|i| postings.block_last_doc(i).unwrap())
3623                .collect();
3624            for from in [0, 1, 7, 8, 64, 200, 256, 257] {
3625                for target in [0, 1, 100, 639, 640, 641, 700, 160_000, 163_840, TERMINATED] {
3626                    let expected = (from..lasts.len()).find(|&i| lasts[i] >= target);
3627                    assert_eq!(
3628                        postings.seek_block(target, from),
3629                        expected,
3630                        "{codec:?} from={from} target={target}"
3631                    );
3632                }
3633            }
3634            let mut cursor = postings.iterator();
3635            let mut at = 0;
3636            for target in [
3637                0,
3638                1,
3639                20,
3640                19,
3641                639,
3642                640,
3643                641,
3644                700,
3645                160_000,
3646                161_000,
3647                160_000,
3648                docs.last().unwrap().0 - 1,
3649                docs.last().unwrap().0,
3650                TERMINATED,
3651                0,
3652            ] {
3653                while at < docs.len() && docs[at].0 < target {
3654                    at += 1;
3655                }
3656                let expected = docs.get(at).map_or(TERMINATED, |&(doc, _)| doc);
3657                assert_eq!(cursor.seek(target), expected, "{codec:?} target={target}");
3658                if at < docs.len() {
3659                    assert_eq!(cursor.term_freq(), docs[at].1);
3660                    assert_eq!(cursor.position_cursor(), prefixes[at]);
3661                }
3662            }
3663            assert_eq!(serialize_bpl(&postings), bytes);
3664        }
3665    }
3666
3667    #[test]
3668    fn candidate_posting_probes_reuse_buffers_and_preserve_seek_and_position_cursors() {
3669        let mut list = PostingList::new();
3670        for i in 0..700 {
3671            list.push(i * 3, i % 7 + 1);
3672        }
3673        let postings = BlockPostingList::from_posting_list(&list).unwrap();
3674        let mut scratch = PostingDecodeScratch::default();
3675        let mut doc_buffer = None;
3676        for first in [0, 385, 900, 1800, 2100, 100, 2097] {
3677            let mut reference = postings.clone().into_iterator();
3678            let mut selected = postings
3679                .clone()
3680                .into_candidate_iterator(first, &mut scratch);
3681            assert_eq!(
3682                selected.current_block_idx(),
3683                postings.seek_block(first, 0).unwrap_or(0)
3684            );
3685            for target in (first..2200).step_by(17) {
3686                assert_eq!(selected.seek(target), reference.seek(target));
3687                assert_eq!(selected.term_freq(), reference.term_freq());
3688                if selected.doc() != TERMINATED {
3689                    assert_eq!(selected.position_cursor(), reference.position_cursor());
3690                }
3691            }
3692            selected.recycle(&mut scratch);
3693            assert!(scratch.doc_ids.capacity() >= BLOCK_SIZE);
3694            // The frequency scratch is inline (no heap allocation to reuse);
3695            // the document buffer is the one heap allocation and must be.
3696            assert!(scratch.term_freqs.is_some());
3697            let address = scratch.doc_ids.as_ptr() as usize;
3698            if let Some(previous) = doc_buffer {
3699                assert_eq!(
3700                    address, previous,
3701                    "document scratch allocation must be reused"
3702                );
3703            }
3704            doc_buffer = Some(address);
3705        }
3706    }
3707
3708    #[test]
3709    fn test_posting_list_basic() {
3710        let mut list = PostingList::new();
3711        list.push(1, 2);
3712        list.push(5, 1);
3713        list.push(10, 3);
3714
3715        assert_eq!(list.len(), 3);
3716
3717        let mut iter = PostingListIterator::new(&list);
3718        assert_eq!(iter.doc(), 1);
3719        assert_eq!(iter.term_freq(), 2);
3720
3721        assert_eq!(iter.advance(), 5);
3722        assert_eq!(iter.term_freq(), 1);
3723
3724        assert_eq!(iter.advance(), 10);
3725        assert_eq!(iter.term_freq(), 3);
3726
3727        assert_eq!(iter.advance(), TERMINATED);
3728    }
3729
3730    #[test]
3731    fn test_posting_list_seek() {
3732        let mut list = PostingList::new();
3733        for i in 0..100 {
3734            list.push(i * 2, 1);
3735        }
3736
3737        let mut iter = PostingListIterator::new(&list);
3738
3739        assert_eq!(iter.seek(50), 50);
3740        assert_eq!(iter.seek(51), 52);
3741        assert_eq!(iter.seek(200), TERMINATED);
3742    }
3743
3744    #[test]
3745    fn test_block_posting_list() {
3746        let mut list = PostingList::new();
3747        for i in 0..500 {
3748            list.push(i * 2, (i % 10) + 1);
3749        }
3750
3751        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
3752        assert_eq!(block_list.doc_count(), 500);
3753
3754        let mut iter = block_list.iterator();
3755        assert_eq!(iter.doc(), 0);
3756        assert_eq!(iter.term_freq(), 1);
3757
3758        // Test seek across blocks
3759        assert_eq!(iter.seek(500), 500);
3760        assert_eq!(iter.seek(998), 998);
3761        assert_eq!(iter.seek(1000), TERMINATED);
3762    }
3763
3764    #[test]
3765    fn test_block_posting_list_serialization() {
3766        let mut list = PostingList::new();
3767        for i in 0..300 {
3768            list.push(i * 3, i + 1);
3769        }
3770
3771        let block_list = BlockPostingList::from_posting_list(&list).unwrap();
3772
3773        let mut buffer = Vec::new();
3774        block_list.serialize(&mut buffer).unwrap();
3775
3776        let deserialized = BlockPostingList::deserialize(&buffer[..]).unwrap();
3777        assert_eq!(deserialized.doc_count(), block_list.doc_count());
3778
3779        // Verify iteration produces same results
3780        let mut iter1 = block_list.iterator();
3781        let mut iter2 = deserialized.iterator();
3782
3783        while iter1.doc() != TERMINATED {
3784            assert_eq!(iter1.doc(), iter2.doc());
3785            assert_eq!(iter1.term_freq(), iter2.term_freq());
3786            iter1.advance();
3787            iter2.advance();
3788        }
3789        assert_eq!(iter2.doc(), TERMINATED);
3790    }
3791
3792    /// Helper: collect all (doc_id, tf) from a BlockPostingIterator
3793    fn collect_postings(bpl: &BlockPostingList) -> Vec<(u32, u32)> {
3794        let mut result = Vec::new();
3795        let mut it = bpl.iterator();
3796        while it.doc() != TERMINATED {
3797            result.push((it.doc(), it.term_freq()));
3798            it.advance();
3799        }
3800        result
3801    }
3802
3803    /// Helper: build a BlockPostingList from (doc_id, tf) pairs
3804    #[test]
3805    fn deserialization_rejects_unsupported_width_instead_of_empty_results() {
3806        let list = build_bpl(&[(0, 1), (1, 2)]);
3807        let mut bytes = Vec::new();
3808        list.serialize(&mut bytes).unwrap();
3809        bytes[6] = 0xe1;
3810        let error = BlockPostingList::deserialize(&bytes).unwrap_err();
3811        assert_eq!(error.kind(), io::ErrorKind::InvalidData);
3812        assert!(error.to_string().contains("exceeds 32 bits"));
3813    }
3814
3815    fn build_bpl(postings: &[(u32, u32)]) -> BlockPostingList {
3816        let mut pl = PostingList::new();
3817        for &(doc_id, tf) in postings {
3818            pl.push(doc_id, tf);
3819        }
3820        BlockPostingList::from_posting_list(&pl).unwrap()
3821    }
3822
3823    /// Helper: serialize a BlockPostingList to bytes
3824    fn serialize_bpl(bpl: &BlockPostingList) -> Vec<u8> {
3825        let mut buf = Vec::new();
3826        bpl.serialize(&mut buf).unwrap();
3827        buf
3828    }
3829
3830    #[test]
3831    fn test_concatenate_blocks_two_segments() {
3832        // Segment A: docs 0,2,4,...,198 (100 docs, tf=1..100)
3833        let a: Vec<(u32, u32)> = (0..100).map(|i| (i * 2, i + 1)).collect();
3834        let bpl_a = build_bpl(&a);
3835
3836        // Segment B: docs 0,3,6,...,297 (100 docs, tf=2..101)
3837        let b: Vec<(u32, u32)> = (0..100).map(|i| (i * 3, i + 2)).collect();
3838        let bpl_b = build_bpl(&b);
3839
3840        // Merge: segment B starts at doc_offset=200
3841        let merged =
3842            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 200)])
3843                .unwrap();
3844
3845        assert_eq!(merged.doc_count(), 200);
3846
3847        let postings = collect_postings(&merged);
3848        assert_eq!(postings.len(), 200);
3849
3850        // First 100 from A (unchanged)
3851        for (i, p) in postings.iter().enumerate().take(100) {
3852            assert_eq!(*p, (i as u32 * 2, i as u32 + 1));
3853        }
3854        // Next 100 from B (doc_id += 200)
3855        for i in 0..100 {
3856            assert_eq!(postings[100 + i], (i as u32 * 3 + 200, i as u32 + 2));
3857        }
3858    }
3859
3860    #[test]
3861    fn test_concatenate_streaming_matches_blocks() {
3862        // Build 3 segments with different doc distributions
3863        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
3864        let seg_b: Vec<(u32, u32)> = (0..180).map(|i| (i * 5, (i % 3) + 1)).collect();
3865        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
3866
3867        let bpl_a = build_bpl(&seg_a);
3868        let bpl_b = build_bpl(&seg_b);
3869        let bpl_c = build_bpl(&seg_c);
3870
3871        let offset_b = 1000u32;
3872        let offset_c = 2000u32;
3873
3874        // Method 1: concatenate_blocks (in-memory reference)
3875        let ref_merged = BlockPostingList::concatenate_blocks(&[
3876            (bpl_a.clone(), 0),
3877            (bpl_b.clone(), offset_b),
3878            (bpl_c.clone(), offset_c),
3879        ])
3880        .unwrap();
3881        let mut ref_buf = Vec::new();
3882        ref_merged.serialize(&mut ref_buf).unwrap();
3883
3884        // Method 2: concatenate_streaming (footer-based, writes to output)
3885        let bytes_a = serialize_bpl(&bpl_a);
3886        let bytes_b = serialize_bpl(&bpl_b);
3887        let bytes_c = serialize_bpl(&bpl_c);
3888
3889        let sources: Vec<(&[u8], u32)> =
3890            vec![(&bytes_a, 0), (&bytes_b, offset_b), (&bytes_c, offset_c)];
3891        let mut stream_buf = Vec::new();
3892        let (doc_count, bytes_written) =
3893            BlockPostingList::concatenate_streaming(&sources, &mut stream_buf).unwrap();
3894
3895        assert_eq!(doc_count, 520); // 250 + 180 + 90
3896        assert_eq!(bytes_written, stream_buf.len());
3897
3898        // Deserialize both and verify identical postings
3899        let ref_postings = collect_postings(&BlockPostingList::deserialize(&ref_buf).unwrap());
3900        let stream_postings =
3901            collect_postings(&BlockPostingList::deserialize(&stream_buf).unwrap());
3902
3903        assert_eq!(ref_postings.len(), stream_postings.len());
3904        for (i, (r, s)) in ref_postings.iter().zip(stream_postings.iter()).enumerate() {
3905            assert_eq!(r, s, "mismatch at posting {}", i);
3906        }
3907    }
3908
3909    #[test]
3910    fn test_concatenate_streaming_short_source_returns_corruption() {
3911        // A source shorter than the 24-byte footer (e.g. a corrupt TermInfo
3912        // (offset, len) pointing at truncated bytes) must fail loudly.
3913        // Silently skipping it pairs every later source with the wrong
3914        // metadata (metas[i] vs sources[i]) — panicking or emitting garbage.
3915        let seg_a: Vec<(u32, u32)> = (0..250).map(|i| (i * 2, (i % 7) + 1)).collect();
3916        let seg_c: Vec<(u32, u32)> = (0..90).map(|i| (i * 10, (i % 11) + 1)).collect();
3917        let bytes_a = serialize_bpl(&build_bpl(&seg_a));
3918        let bytes_c = serialize_bpl(&build_bpl(&seg_c));
3919        let short = vec![0u8; FOOTER_SIZE - 1]; // corrupt: shorter than footer
3920
3921        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&short, 1000), (&bytes_c, 2000)];
3922        let mut out = Vec::new();
3923        let result = BlockPostingList::concatenate_streaming(&sources, &mut out);
3924        assert!(
3925            matches!(result, Err(crate::Error::Corruption(_))),
3926            "short/corrupt source must be a Corruption error, not silently skipped: {:?}",
3927            result.map(|r| r.0)
3928        );
3929    }
3930
3931    #[test]
3932    fn test_multi_round_merge() {
3933        // Simulate 3 rounds of merging (like tiered merge policy)
3934        //
3935        // Round 0: 4 small segments built independently
3936        // Round 1: merge pairs → 2 medium segments
3937        // Round 2: merge those → 1 large segment
3938
3939        let segments: Vec<Vec<(u32, u32)>> = (0..4)
3940            .map(|seg| (0..200).map(|i| (i * 3, (i + seg * 7) % 10 + 1)).collect())
3941            .collect();
3942
3943        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
3944        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
3945
3946        // Round 1: merge seg0+seg1 (offset=0,600), seg2+seg3 (offset=0,600)
3947        let mut merged_01 = Vec::new();
3948        let sources_01: Vec<(&[u8], u32)> = vec![(&serialized[0], 0), (&serialized[1], 600)];
3949        let (dc_01, _) =
3950            BlockPostingList::concatenate_streaming(&sources_01, &mut merged_01).unwrap();
3951        assert_eq!(dc_01, 400);
3952
3953        let mut merged_23 = Vec::new();
3954        let sources_23: Vec<(&[u8], u32)> = vec![(&serialized[2], 0), (&serialized[3], 600)];
3955        let (dc_23, _) =
3956            BlockPostingList::concatenate_streaming(&sources_23, &mut merged_23).unwrap();
3957        assert_eq!(dc_23, 400);
3958
3959        // Round 2: merge the two intermediate results (offset=0, 1200)
3960        let mut final_merged = Vec::new();
3961        let sources_final: Vec<(&[u8], u32)> = vec![(&merged_01, 0), (&merged_23, 1200)];
3962        let (dc_final, _) =
3963            BlockPostingList::concatenate_streaming(&sources_final, &mut final_merged).unwrap();
3964        assert_eq!(dc_final, 800);
3965
3966        // Verify final result has all 800 postings with correct doc_ids
3967        let final_bpl = BlockPostingList::deserialize(&final_merged).unwrap();
3968        let postings = collect_postings(&final_bpl);
3969        assert_eq!(postings.len(), 800);
3970
3971        // Verify doc_id ordering (must be monotonically non-decreasing within segments,
3972        // and segment boundaries at 0, 600, 1200, 1800)
3973        // Seg0: 0..597, Seg1: 600..1197, Seg2: 1200..1797, Seg3: 1800..2397
3974        assert_eq!(postings[0].0, 0); // first doc of seg0
3975        assert_eq!(postings[199].0, 597); // last doc of seg0 (199*3)
3976        assert_eq!(postings[200].0, 600); // first doc of seg1 (0+600)
3977        assert_eq!(postings[399].0, 1197); // last doc of seg1 (597+600)
3978        assert_eq!(postings[400].0, 1200); // first doc of seg2
3979        assert_eq!(postings[799].0, 2397); // last doc of seg3
3980
3981        // Verify TFs preserved through two rounds of merging
3982        // Creation formula: tf = (i + seg * 7) % 10 + 1
3983        for seg in 0u32..4 {
3984            for i in 0u32..200 {
3985                let idx = (seg * 200 + i) as usize;
3986                assert_eq!(
3987                    postings[idx].1,
3988                    (i + seg * 7) % 10 + 1,
3989                    "seg{} tf[{}]",
3990                    seg,
3991                    i
3992                );
3993            }
3994        }
3995
3996        // Verify seek works on final merged result
3997        let mut it = final_bpl.iterator();
3998        assert_eq!(it.seek(600), 600);
3999        assert_eq!(it.seek(1200), 1200);
4000        assert_eq!(it.seek(2397), 2397);
4001        assert_eq!(it.seek(2398), TERMINATED);
4002    }
4003
4004    #[test]
4005    fn test_large_scale_merge() {
4006        // 5 segments × 2000 docs each = 10,000 total docs
4007        // Each segment has 16 blocks (2000/128 = 15.6 → 16 blocks)
4008        let num_segments = 5;
4009        let docs_per_segment = 2000;
4010        let docs_gap = 3; // doc_ids: 0, 3, 6, ...
4011
4012        let segments: Vec<Vec<(u32, u32)>> = (0..num_segments)
4013            .map(|seg| {
4014                (0..docs_per_segment)
4015                    .map(|i| (i as u32 * docs_gap, (i as u32 + seg as u32) % 20 + 1))
4016                    .collect()
4017            })
4018            .collect();
4019
4020        let bpls: Vec<BlockPostingList> = segments.iter().map(|s| build_bpl(s)).collect();
4021
4022        // Verify each segment has multiple blocks
4023        for bpl in &bpls {
4024            assert!(
4025                bpl.num_blocks() >= 15,
4026                "expected >=15 blocks, got {}",
4027                bpl.num_blocks()
4028            );
4029        }
4030
4031        let serialized: Vec<Vec<u8>> = bpls.iter().map(serialize_bpl).collect();
4032
4033        // Compute offsets: each segment occupies max_doc+1 doc_id space
4034        let max_doc_per_seg = (docs_per_segment as u32 - 1) * docs_gap;
4035        let offsets: Vec<u32> = (0..num_segments)
4036            .map(|i| i as u32 * (max_doc_per_seg + 1))
4037            .collect();
4038
4039        let sources: Vec<(&[u8], u32)> = serialized
4040            .iter()
4041            .zip(offsets.iter())
4042            .map(|(b, o)| (b.as_slice(), *o))
4043            .collect();
4044
4045        let mut merged = Vec::new();
4046        let (doc_count, _) =
4047            BlockPostingList::concatenate_streaming(&sources, &mut merged).unwrap();
4048        assert_eq!(doc_count, (num_segments * docs_per_segment) as u32);
4049
4050        // Deserialize and verify
4051        let merged_bpl = BlockPostingList::deserialize(&merged).unwrap();
4052        let postings = collect_postings(&merged_bpl);
4053        assert_eq!(postings.len(), num_segments * docs_per_segment);
4054
4055        // Verify all doc_ids are strictly monotonically increasing across segment boundaries
4056        for i in 1..postings.len() {
4057            assert!(
4058                postings[i].0 > postings[i - 1].0 || (i % docs_per_segment == 0), // new segment can have lower absolute ID
4059                "doc_id not increasing at {}: {} vs {}",
4060                i,
4061                postings[i - 1].0,
4062                postings[i].0,
4063            );
4064        }
4065
4066        // Verify seek across all block boundaries
4067        let mut it = merged_bpl.iterator();
4068        for (seg, &expected_first) in offsets.iter().enumerate() {
4069            assert_eq!(
4070                it.seek(expected_first),
4071                expected_first,
4072                "seek to segment {} start",
4073                seg
4074            );
4075        }
4076    }
4077
4078    #[test]
4079    fn test_merge_edge_cases() {
4080        // Single doc per segment
4081        let bpl_a = build_bpl(&[(0, 5)]);
4082        let bpl_b = build_bpl(&[(0, 3)]);
4083
4084        let merged =
4085            BlockPostingList::concatenate_blocks(&[(bpl_a.clone(), 0), (bpl_b.clone(), 1)])
4086                .unwrap();
4087        assert_eq!(merged.doc_count(), 2);
4088        let p = collect_postings(&merged);
4089        assert_eq!(p, vec![(0, 5), (1, 3)]);
4090
4091        // Exactly BLOCK_SIZE docs (single full block)
4092        let exact_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32).map(|i| (i, i % 5 + 1)).collect();
4093        let bpl_exact = build_bpl(&exact_block);
4094        assert_eq!(bpl_exact.num_blocks(), 1);
4095
4096        let bytes = serialize_bpl(&bpl_exact);
4097        let mut out = Vec::new();
4098        let sources: Vec<(&[u8], u32)> = vec![(&bytes, 0), (&bytes, BLOCK_SIZE as u32)];
4099        let (dc, _) = BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4100        assert_eq!(dc, BLOCK_SIZE as u32 * 2);
4101
4102        let merged = BlockPostingList::deserialize(&out).unwrap();
4103        let postings = collect_postings(&merged);
4104        assert_eq!(postings.len(), BLOCK_SIZE * 2);
4105        // Second segment's docs offset by BLOCK_SIZE
4106        assert_eq!(postings[BLOCK_SIZE].0, BLOCK_SIZE as u32);
4107
4108        // BLOCK_SIZE + 1 docs (two blocks: 128 + 1)
4109        let over_block: Vec<(u32, u32)> = (0..BLOCK_SIZE as u32 + 1).map(|i| (i * 2, 1)).collect();
4110        let bpl_over = build_bpl(&over_block);
4111        assert_eq!(bpl_over.num_blocks(), 2);
4112    }
4113
4114    #[test]
4115    fn test_streaming_roundtrip_single_source() {
4116        // Streaming merge with a single source should produce equivalent output to serialize
4117        let docs: Vec<(u32, u32)> = (0..500).map(|i| (i * 7, i % 15 + 1)).collect();
4118        let bpl = build_bpl(&docs);
4119        let direct = serialize_bpl(&bpl);
4120
4121        let sources: Vec<(&[u8], u32)> = vec![(&direct, 0)];
4122        let mut streamed = Vec::new();
4123        BlockPostingList::concatenate_streaming(&sources, &mut streamed).unwrap();
4124
4125        // Both should deserialize to identical postings
4126        let p1 = collect_postings(&BlockPostingList::deserialize(&direct).unwrap());
4127        let p2 = collect_postings(&BlockPostingList::deserialize(&streamed).unwrap());
4128        assert_eq!(p1, p2);
4129    }
4130
4131    #[test]
4132    fn test_max_tf_preserved_through_merge() {
4133        // Segment A: max_tf = 50
4134        let mut a = Vec::new();
4135        for i in 0..200 {
4136            a.push((i * 2, if i == 100 { 50 } else { 1 }));
4137        }
4138        let bpl_a = build_bpl(&a);
4139        assert_eq!(bpl_a.max_tf(), 50);
4140
4141        // Segment B: max_tf = 30
4142        let mut b = Vec::new();
4143        for i in 0..200 {
4144            b.push((i * 2, if i == 50 { 30 } else { 2 }));
4145        }
4146        let bpl_b = build_bpl(&b);
4147        assert_eq!(bpl_b.max_tf(), 30);
4148
4149        // After merge, max_tf should be max(50, 30) = 50
4150        let bytes_a = serialize_bpl(&bpl_a);
4151        let bytes_b = serialize_bpl(&bpl_b);
4152        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 1000)];
4153        let mut out = Vec::new();
4154        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4155
4156        let merged = BlockPostingList::deserialize(&out).unwrap();
4157        assert_eq!(merged.max_tf(), 50);
4158        assert_eq!(merged.doc_count(), 400);
4159    }
4160
4161    // ── 2-level skip list format tests ──────────────────────────────────
4162
4163    #[test]
4164    fn test_l0_l1_counts() {
4165        // 1 block (< L1_INTERVAL) → 1 L1 entry (partial group)
4166        let bpl = build_bpl(&(0..50u32).map(|i| (i, 1)).collect::<Vec<_>>());
4167        assert_eq!(bpl.num_blocks(), 1);
4168        assert_eq!(bpl.l1_docs.len(), 1);
4169
4170        // Exactly L1_INTERVAL blocks → 1 L1 entry (full group)
4171        let n = BLOCK_SIZE * L1_INTERVAL;
4172        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4173        assert_eq!(bpl.num_blocks(), L1_INTERVAL);
4174        assert_eq!(bpl.l1_docs.len(), 1);
4175
4176        // L1_INTERVAL + 1 blocks → 2 L1 entries
4177        let n = BLOCK_SIZE * L1_INTERVAL + 1;
4178        let bpl = build_bpl(&(0..n as u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4179        assert_eq!(bpl.num_blocks(), L1_INTERVAL + 1);
4180        assert_eq!(bpl.l1_docs.len(), 2);
4181
4182        // 3 × L1_INTERVAL blocks → 3 L1 entries (all full groups)
4183        let n = BLOCK_SIZE * L1_INTERVAL * 3;
4184        let bpl = build_bpl(&(0..n as u32).map(|i| (i, 1)).collect::<Vec<_>>());
4185        assert_eq!(bpl.num_blocks(), L1_INTERVAL * 3);
4186        assert_eq!(bpl.l1_docs.len(), 3);
4187    }
4188
4189    #[test]
4190    fn test_l1_last_doc_values() {
4191        // 20 blocks: 2 full L1 groups (8+8) + 1 partial (4) → 3 L1 entries
4192        let n = BLOCK_SIZE * 20;
4193        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
4194        let bpl = build_bpl(&docs);
4195        assert_eq!(bpl.num_blocks(), 20);
4196        assert_eq!(bpl.l1_docs.len(), 3); // ceil(20/8) = 3
4197
4198        // L1[0] = last_doc of block 7 (end of first group)
4199        let expected_l1_0 = bpl.block_last_doc(7).unwrap();
4200        assert_eq!(bpl.l1_docs.get(0).unwrap(), expected_l1_0);
4201
4202        // L1[1] = last_doc of block 15 (end of second group)
4203        let expected_l1_1 = bpl.block_last_doc(15).unwrap();
4204        assert_eq!(bpl.l1_docs.get(1).unwrap(), expected_l1_1);
4205
4206        // L1[2] = last_doc of block 19 (end of partial group)
4207        let expected_l1_2 = bpl.block_last_doc(19).unwrap();
4208        assert_eq!(bpl.l1_docs.get(2).unwrap(), expected_l1_2);
4209    }
4210
4211    #[test]
4212    fn test_seek_block_basic() {
4213        // 20 blocks spanning large doc ID range
4214        let n = BLOCK_SIZE * 20;
4215        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 10, 1)).collect();
4216        let bpl = build_bpl(&docs);
4217
4218        // Seek to doc 0 → block 0
4219        assert_eq!(bpl.seek_block(0, 0), Some(0));
4220
4221        // Seek to the first doc of each block
4222        for blk in 0..20 {
4223            let first = bpl.block_first_doc(blk).unwrap();
4224            assert_eq!(
4225                bpl.seek_block(first, 0),
4226                Some(blk),
4227                "seek to block {} first_doc",
4228                blk
4229            );
4230        }
4231
4232        // Seek to the last doc of each block
4233        for blk in 0..20 {
4234            let last = bpl.block_last_doc(blk).unwrap();
4235            assert_eq!(
4236                bpl.seek_block(last, 0),
4237                Some(blk),
4238                "seek to block {} last_doc",
4239                blk
4240            );
4241        }
4242
4243        // Seek past all docs
4244        let max_doc = bpl.block_last_doc(19).unwrap();
4245        assert_eq!(bpl.seek_block(max_doc + 1, 0), None);
4246
4247        // Seek with from_block > 0 (skip early blocks)
4248        let mid_doc = bpl.block_first_doc(10).unwrap();
4249        assert_eq!(bpl.seek_block(mid_doc, 10), Some(10));
4250        assert_eq!(
4251            bpl.seek_block(mid_doc, 11),
4252            Some(11).or(bpl.seek_block(mid_doc, 11))
4253        );
4254    }
4255
4256    #[test]
4257    fn test_seek_block_across_l1_boundaries() {
4258        // 24 blocks = 3 L1 groups of 8
4259        let n = BLOCK_SIZE * 24;
4260        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 5, 1)).collect();
4261        let bpl = build_bpl(&docs);
4262        assert_eq!(bpl.l1_docs.len(), 3);
4263
4264        // Seek into each L1 group
4265        for group in 0..3 {
4266            let blk = group * L1_INTERVAL;
4267            let target = bpl.block_first_doc(blk).unwrap();
4268            assert_eq!(
4269                bpl.seek_block(target, 0),
4270                Some(blk),
4271                "seek to group {} block {}",
4272                group,
4273                blk
4274            );
4275        }
4276
4277        // Seek to doc in the middle of group 2 (block 20)
4278        let target = bpl.block_first_doc(20).unwrap() + 1;
4279        assert_eq!(bpl.seek_block(target, 0), Some(20));
4280    }
4281
4282    #[test]
4283    fn block_len_matches_l0_offsets() {
4284        // Block lengths derive from neighbouring L0 offsets and add up to the stream.
4285        let bpl = build_bpl(&(0..1000).map(|i| (i * 3, 1 + i % 4)).collect::<Vec<_>>());
4286        let mut total = 0usize;
4287        for b in 0..bpl.num_blocks() {
4288            let (_, _, offset, _) = bpl.read_l0_entry(b);
4289            assert_eq!(offset as usize, total, "block {b} offset");
4290            total += bpl.block_len(b);
4291        }
4292        assert_eq!(total, bpl.stream.len());
4293    }
4294
4295    /// Every codec round-trips doc ids and tfs exactly, `seek` agrees with
4296    /// `Rounded`, and `Rounded` output is byte-identical to the historic
4297    /// layout (codec id 0, widths 0/8/16/32).
4298    #[test]
4299    fn every_codec_round_trips_and_seeks() {
4300        let mut postings: Vec<(u32, u32)> = Vec::new();
4301        let mut doc = 0u32;
4302        for i in 0..5000u32 {
4303            // Mostly small gaps with rare huge ones (forces exceptions / wide
4304            // blocks), tfs mostly 1-3 with rare outliers.
4305            doc += if i % 97 == 0 { 100_000 } else { 1 + i % 7 };
4306            let tf = if i % 131 == 0 { 5000 } else { 1 + i % 3 };
4307            postings.push((doc, tf));
4308        }
4309        let mut list = PostingList::new();
4310        for &(d, tf) in &postings {
4311            list.push(d, tf);
4312        }
4313        let rounded = BlockPostingList::from_posting_list(&list).unwrap();
4314        let mut sizes = Vec::new();
4315        for codec in [
4316            PostingCodec::Rounded,
4317            PostingCodec::Packed,
4318            PostingCodec::Pfor,
4319            PostingCodec::Simd4x,
4320        ] {
4321            let bpl = BlockPostingList::from_posting_list_with_codec(&list, codec).unwrap();
4322            assert_eq!(collect_postings(&bpl), postings, "{codec}");
4323            for b in 0..bpl.num_blocks() {
4324                let count = (postings.len() - b * BLOCK_SIZE).min(BLOCK_SIZE);
4325                let expected_codec = if codec == PostingCodec::Simd4x && count < BLOCK_SIZE {
4326                    PostingCodec::Rounded
4327                } else {
4328                    codec
4329                };
4330                assert_eq!(bpl.block_codec(b), Some(expected_codec));
4331                assert_eq!(bpl.block_max_tf(b), rounded.block_max_tf(b));
4332            }
4333            // Serialized round trip (both copying and zero-copy paths).
4334            let bytes = serialize_bpl(&bpl);
4335            let back = BlockPostingList::deserialize(&bytes).unwrap();
4336            assert_eq!(collect_postings(&back), postings, "{codec} deserialize");
4337            let back =
4338                BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4339            assert_eq!(collect_postings(&back), postings, "{codec} zero-copy");
4340            // Seeks land on the same docs as the reference layout.
4341            let mut a = rounded.iterator();
4342            let mut b = back.iterator();
4343            for target in (0..postings.last().unwrap().0 + 10).step_by(2_003) {
4344                assert_eq!(a.seek(target), b.seek(target), "{codec} seek {target}");
4345                assert_eq!(a.term_freq(), b.term_freq());
4346            }
4347            sizes.push((codec, bytes.len()));
4348        }
4349        let rounded_bytes = serialize_bpl(&rounded);
4350        assert_eq!(
4351            serialize_bpl(
4352                &BlockPostingList::from_posting_list_with_codec(&list, PostingCodec::Rounded)
4353                    .unwrap()
4354            ),
4355            rounded_bytes,
4356            "Rounded must stay byte-identical"
4357        );
4358        // Header byte of a Rounded block: codec 0, rounded width.
4359        assert!(matches!(rounded.stream[6], 0 | 8 | 16 | 32));
4360        let size = |c: PostingCodec| sizes.iter().find(|(k, _)| *k == c).unwrap().1;
4361        assert!(size(PostingCodec::Packed) < size(PostingCodec::Rounded));
4362        assert!(size(PostingCodec::Pfor) < size(PostingCodec::Packed));
4363    }
4364
4365    /// Blocks of different codecs merge by verbatim copy and decode correctly.
4366    #[test]
4367    fn mixed_codec_sources_concatenate() {
4368        let a: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 5, 1 + i % 9)).collect();
4369        let b: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 11 + 3, 2 + i % 5)).collect();
4370        let list_a = {
4371            let mut l = PostingList::new();
4372            a.iter().for_each(|&(d, t)| l.push(d, t));
4373            BlockPostingList::from_posting_list_with_codec(&l, PostingCodec::Pfor).unwrap()
4374        };
4375        let list_b = {
4376            let mut l = PostingList::new();
4377            b.iter().for_each(|&(d, t)| l.push(d, t));
4378            BlockPostingList::from_posting_list_with_codec(&l, PostingCodec::Packed).unwrap()
4379        };
4380        let offset_b = a.last().unwrap().0 + 1;
4381        let expected: Vec<(u32, u32)> = a
4382            .iter()
4383            .copied()
4384            .chain(b.iter().map(|&(d, t)| (d + offset_b, t)))
4385            .collect();
4386
4387        let merged = BlockPostingList::concatenate_blocks(&[
4388            (list_a.clone(), 0),
4389            (list_b.clone(), offset_b),
4390        ])
4391        .unwrap();
4392        assert_eq!(collect_postings(&merged), expected);
4393
4394        let bytes_a = serialize_bpl(&list_a);
4395        let bytes_b = serialize_bpl(&list_b);
4396        let mut out = Vec::new();
4397        let (docs, written) = BlockPostingList::concatenate_streaming(
4398            &[(bytes_a.as_slice(), 0), (bytes_b.as_slice(), offset_b)],
4399            &mut out,
4400        )
4401        .unwrap();
4402        assert_eq!(docs, 600);
4403        assert_eq!(written, out.len());
4404        let streamed = BlockPostingList::deserialize(&out).unwrap();
4405        assert_eq!(collect_postings(&streamed), expected);
4406        assert_eq!(streamed.block_codec(0), Some(PostingCodec::Pfor));
4407        assert_eq!(
4408            streamed.block_codec(streamed.num_blocks() - 1),
4409            Some(PostingCodec::Packed)
4410        );
4411    }
4412
4413    #[test]
4414    fn test_l0_entry_roundtrip() {
4415        // Verify L0 entries survive serialize → deserialize
4416        let docs: Vec<(u32, u32)> = (0..1000u32).map(|i| (i * 3, (i % 10) + 1)).collect();
4417        let bpl = build_bpl(&docs);
4418
4419        let bytes = serialize_bpl(&bpl);
4420        let bpl2 = BlockPostingList::deserialize(&bytes).unwrap();
4421
4422        assert_eq!(bpl.num_blocks(), bpl2.num_blocks());
4423        for blk in 0..bpl.num_blocks() {
4424            assert_eq!(
4425                bpl.read_l0_entry(blk),
4426                bpl2.read_l0_entry(blk),
4427                "L0 entry mismatch at block {}",
4428                blk
4429            );
4430        }
4431
4432        // Verify L1 docs match
4433        assert_eq!(bpl.l1_docs.bytes(), bpl2.l1_docs.bytes());
4434    }
4435
4436    #[test]
4437    fn posting_open_borrows_unaligned_group_directories_and_preserves_bytes_and_seeks() {
4438        for codec in [
4439            PostingCodec::Rounded,
4440            PostingCodec::Packed,
4441            PostingCodec::Pfor,
4442            PostingCodec::Simd4x,
4443        ] {
4444            let mut postings = PostingList::new();
4445            for i in 0..3457u32 {
4446                postings.push(i * 7 + 3, i % 13 + 1);
4447            }
4448            let built = BlockPostingList::from_posting_list_with_codec(&postings, codec).unwrap();
4449            let encoded = serialize_bpl(&built);
4450            for prefix in [1, 3, 7] {
4451                let mut padded = vec![0; prefix];
4452                padded.extend_from_slice(&encoded);
4453                let owner = OwnedBytes::new(padded);
4454                let raw = owner.slice(prefix..owner.len());
4455                let footer = Footer::parse(&raw).unwrap();
4456                let docs_start = raw[footer.l1_start()..].as_ptr();
4457                let bounds_start = raw[footer.l1_end()..].as_ptr();
4458                let opened = BlockPostingList::deserialize_zero_copy(raw).unwrap();
4459                assert_eq!(
4460                    opened.l1_docs.bytes().as_ptr(),
4461                    docs_start,
4462                    "opening must borrow group document metadata"
4463                );
4464                assert_eq!(
4465                    opened.l1_bounds.bytes().as_ptr(),
4466                    bounds_start,
4467                    "opening must borrow group bound metadata"
4468                );
4469                assert_eq!(serialize_bpl(&opened), encoded);
4470                for from in 0..=built.num_blocks() {
4471                    for target in (0..=3457 * 7 + 4).step_by(37) {
4472                        let expected = (from..built.num_blocks())
4473                            .find(|&block| built.block_last_doc(block).unwrap() >= target);
4474                        assert_eq!(opened.seek_block(target, from), expected);
4475                    }
4476                }
4477                assert_eq!(collect_postings(&opened), collect_postings(&built));
4478            }
4479        }
4480    }
4481
4482    #[test]
4483    fn test_zero_copy_deserialize_matches() {
4484        let docs: Vec<(u32, u32)> = (0..2000u32).map(|i| (i * 2, (i % 5) + 1)).collect();
4485        let bpl = build_bpl(&docs);
4486        let bytes = serialize_bpl(&bpl);
4487
4488        let copied = BlockPostingList::deserialize(&bytes).unwrap();
4489        let zero_copy =
4490            BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4491
4492        // Same structure
4493        assert_eq!(copied.l0_count, zero_copy.l0_count);
4494        assert_eq!(copied.l1_docs.bytes(), zero_copy.l1_docs.bytes());
4495        assert_eq!(copied.doc_count, zero_copy.doc_count);
4496        assert_eq!(copied.max_tf, zero_copy.max_tf);
4497
4498        // Same iteration
4499        let p1 = collect_postings(&copied);
4500        let p2 = collect_postings(&zero_copy);
4501        assert_eq!(p1, p2);
4502    }
4503
4504    #[test]
4505    fn test_l1_preserved_through_streaming_merge() {
4506        // Merge 3 segments, verify L1 is correctly rebuilt
4507        let seg_a = build_bpl(&(0..1000u32).map(|i| (i * 2, 1)).collect::<Vec<_>>());
4508        let seg_b = build_bpl(&(0..800u32).map(|i| (i * 3, 2)).collect::<Vec<_>>());
4509        let seg_c = build_bpl(&(0..500u32).map(|i| (i * 5, 3)).collect::<Vec<_>>());
4510
4511        let bytes_a = serialize_bpl(&seg_a);
4512        let bytes_b = serialize_bpl(&seg_b);
4513        let bytes_c = serialize_bpl(&seg_c);
4514
4515        let sources: Vec<(&[u8], u32)> = vec![(&bytes_a, 0), (&bytes_b, 10000), (&bytes_c, 20000)];
4516        let mut out = Vec::new();
4517        BlockPostingList::concatenate_streaming(&sources, &mut out).unwrap();
4518
4519        let merged = BlockPostingList::deserialize(&out).unwrap();
4520        let expected_l1_count = merged.num_blocks().div_ceil(L1_INTERVAL);
4521        assert_eq!(merged.l1_docs.len(), expected_l1_count);
4522
4523        // Verify L1 values are correct
4524        for (i, word) in merged.l1_docs.words().iter().enumerate() {
4525            let l1_doc = u32::from_le_bytes(*word);
4526            let last_block_in_group = ((i + 1) * L1_INTERVAL - 1).min(merged.num_blocks() - 1);
4527            let expected = merged.block_last_doc(last_block_in_group).unwrap();
4528            assert_eq!(l1_doc, expected, "L1[{}] mismatch", i);
4529        }
4530
4531        // Verify seek_block works on merged result
4532        for blk in 0..merged.num_blocks() {
4533            let first = merged.block_first_doc(blk).unwrap();
4534            assert_eq!(merged.seek_block(first, 0), Some(blk));
4535        }
4536    }
4537
4538    #[test]
4539    fn test_seek_block_single_block() {
4540        // Edge case: single block (< L1_INTERVAL)
4541        let bpl = build_bpl(&[(0, 1), (10, 2), (20, 3)]);
4542        assert_eq!(bpl.num_blocks(), 1);
4543        assert_eq!(bpl.l1_docs.len(), 1);
4544
4545        assert_eq!(bpl.seek_block(0, 0), Some(0));
4546        assert_eq!(bpl.seek_block(10, 0), Some(0));
4547        assert_eq!(bpl.seek_block(20, 0), Some(0));
4548        assert_eq!(bpl.seek_block(21, 0), None);
4549    }
4550
4551    #[test]
4552    fn test_footer_size() {
4553        // Verify serialized size = stream + L0 + L1 + FOOTER_SIZE
4554        let docs: Vec<(u32, u32)> = (0..500u32).map(|i| (i * 2, 1)).collect();
4555        let bpl = build_bpl(&docs);
4556        let bytes = serialize_bpl(&bpl);
4557
4558        let expected = bpl.stream.len()
4559            + bpl.l0_count * L0_SIZE
4560            + bpl.l1_docs.len() * (L1_SIZE + 4)
4561            + FOOTER_V2_SIZE;
4562        assert_eq!(bytes.len(), expected);
4563    }
4564
4565    fn build_bpl_with_positions(postings: &[(u32, u32)]) -> BlockPostingList {
4566        let mut list = PostingList::new();
4567        for &(doc, tf) in postings {
4568            list.push(doc, tf);
4569        }
4570        BlockPostingList::from_posting_list_with(&list, true, None).unwrap()
4571    }
4572
4573    /// Expected cursor of every posting: the cumulative tf before it.
4574    fn expected_cursors(postings: &[(u32, u32)]) -> Vec<u64> {
4575        let mut acc = 0u64;
4576        postings
4577            .iter()
4578            .map(|&(_, tf)| {
4579                let c = acc;
4580                acc += tf as u64;
4581                c
4582            })
4583            .collect()
4584    }
4585
4586    fn iterator_cursors(bpl: &BlockPostingList) -> Vec<u64> {
4587        let mut it = bpl.iterator();
4588        let mut out = Vec::new();
4589        while it.doc() != TERMINATED {
4590            out.push(it.position_cursor());
4591            it.advance();
4592        }
4593        out
4594    }
4595
4596    #[test]
4597    fn position_cursors_survive_serialization_and_seeks() {
4598        let docs: Vec<(u32, u32)> = (0..700u32).map(|i| (i * 3, i % 5 + 1)).collect();
4599        let bpl = build_bpl_with_positions(&docs);
4600        assert!(bpl.has_position_cursors());
4601        assert_eq!(
4602            bpl.total_positions(),
4603            docs.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
4604        );
4605        assert_eq!(bpl.pos_cursor(0), Some(0));
4606        assert_eq!(
4607            bpl.pos_cursor(1),
4608            Some(docs[..128].iter().map(|&(_, tf)| tf as u64).sum::<u64>())
4609        );
4610        assert_eq!(iterator_cursors(&bpl), expected_cursors(&docs));
4611
4612        let bytes = serialize_bpl(&bpl);
4613        assert_eq!(
4614            bytes.len(),
4615            bpl.stream.len()
4616                + bpl.l0_count * (L0_SIZE + CURSOR_SIZE)
4617                + bpl.l1_docs.len() * (L1_SIZE + 4)
4618                + FOOTER_V2_SIZE
4619        );
4620        assert!(BlockPostingList::has_cursors_bytes(&bytes));
4621        let decoded =
4622            BlockPostingList::deserialize_zero_copy(OwnedBytes::new(bytes.clone())).unwrap();
4623        assert_eq!(iterator_cursors(&decoded), expected_cursors(&docs));
4624        assert_eq!(decoded.total_positions(), bpl.total_positions());
4625
4626        // Seeking within and across blocks keeps the cursor exact.
4627        let mut it = decoded.iterator();
4628        let expected = expected_cursors(&docs);
4629        for (i, &(doc, _)) in docs.iter().enumerate().step_by(37) {
4630            assert_eq!(it.seek(doc), doc);
4631            assert_eq!(it.position_cursor(), expected[i], "cursor at doc {doc}");
4632        }
4633        let mut it = decoded.iterator();
4634        assert_eq!(it.seek(docs[600].0 + 1), docs[601].0);
4635        assert_eq!(it.position_cursor(), expected[601]);
4636
4637        // Lists without positions carry no cursors (the iterator's prefix
4638        // sum is then relative to nothing and never consulted).
4639        let plain = build_bpl(&docs);
4640        assert!(!plain.has_position_cursors());
4641        assert_eq!(plain.pos_cursor(0), None);
4642        assert_eq!(plain.total_positions(), 0);
4643    }
4644
4645    #[test]
4646    fn length_bounds_are_packed_per_block_and_survive_merges() {
4647        let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i, i % 3 + 1)).collect();
4648        let length_of = |doc: u32| 10 + (doc % 50) * 7;
4649        let mut list = PostingList::new();
4650        for &(doc, tf) in &docs {
4651            list.push(doc, tf);
4652        }
4653        let bpl = BlockPostingList::from_posting_list_with(&list, true, Some(&length_of)).unwrap();
4654        assert_eq!(bpl.min_len(), Some(10));
4655        assert_eq!(bpl.block_bounds(0), Some((3, Some(10))));
4656        // Block 2 covers docs 256..300: min length there is doc 256 (256 % 50 = 6 → 52).
4657        assert_eq!(bpl.block_bounds(2), Some((3, Some(52))));
4658        assert_eq!(bpl.block_max_tf(2), Some(3));
4659
4660        let bytes = serialize_bpl(&bpl);
4661        let decoded = BlockPostingList::deserialize(&bytes).unwrap();
4662        assert_eq!(decoded.min_len(), Some(10));
4663        assert_eq!(decoded.block_bounds(2), Some((3, Some(52))));
4664        // Superblock bounds: one group of three blocks here, max tf 3 and
4665        // the smallest length of the whole list.
4666        assert_eq!(decoded.group_bounds(0), Some((3, 10)));
4667        assert_eq!(decoded.group_bounds(2), Some((3, 10)));
4668        assert_eq!(decoded.group_bounds(3), None);
4669        assert_eq!(decoded.group_last_doc(1), Some(299));
4670        assert_eq!(decoded.next_group_block(1), 3);
4671
4672        // Without lengths the minimum is 1, which every real unit satisfies.
4673        let plain = build_bpl(&docs);
4674        assert_eq!(plain.min_len(), Some(1));
4675        assert_eq!(plain.block_bounds(0), Some((3, Some(1))));
4676
4677        // Streaming merge keeps per-block bounds and takes the list minimum.
4678        let mut out = Vec::new();
4679        BlockPostingList::concatenate_streaming(&[(&bytes, 0), (&bytes, 1000)], &mut out).unwrap();
4680        let merged = BlockPostingList::deserialize(&out).unwrap();
4681        assert_eq!(merged.min_len(), Some(10));
4682        assert_eq!(merged.block_bounds(2), Some((3, Some(52))));
4683        assert_eq!(merged.block_bounds(3), Some((3, Some(10))));
4684        assert_eq!(merged.block_max_tf(5), Some(3));
4685        // Six blocks: one full group of eight would need more; here both
4686        // lists' blocks share group 0.
4687        assert_eq!(merged.group_bounds(5), Some((3, 10)));
4688        assert_eq!(merged.group_last_doc(5), Some(1299));
4689        assert_eq!(merged.next_group_block(5), 6);
4690    }
4691
4692    #[test]
4693    fn legacy_footer_without_magic_still_deserializes() {
4694        let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 2, 1 + i % 3)).collect();
4695        let bpl = build_bpl(&docs);
4696        let bytes = serialize_bpl(&bpl);
4697        // A real pre-magic layout has no L1 bounds or footer extension, and
4698        // stores f32 maxima rather than packed TF/length L0 words.
4699        let footer = Footer::parse(&bytes).unwrap();
4700        let mut legacy = bytes[..footer.l1_end()].to_vec();
4701        for block in 0..bpl.num_blocks() {
4702            let at = footer.l0_start() + block * L0_SIZE + 12;
4703            legacy[at..at + 4]
4704                .copy_from_slice(&(bpl.block_max_tf(block).unwrap() as f32).to_le_bytes());
4705        }
4706        legacy.extend_from_slice(
4707            &bytes[bytes.len() - FOOTER_V2_SIZE..bytes.len() - (FOOTER_V2_SIZE - FOOTER_SIZE)],
4708        );
4709        assert!(!BlockPostingList::has_cursors_bytes(&legacy));
4710        // Legacy lists carry an f32 max tf per block and no lengths.
4711        let decoded = BlockPostingList::deserialize(&legacy).unwrap();
4712        assert_eq!(collect_postings(&decoded), docs);
4713        assert_eq!(decoded.max_tf(), 3);
4714        assert!(!decoded.has_position_cursors());
4715        assert_eq!(decoded.min_len(), None);
4716        assert_eq!(decoded.group_bounds(0), None);
4717        // And the legacy bytes concatenate into a current-format list.
4718        let mut out = Vec::new();
4719        let (count, written) =
4720            BlockPostingList::concatenate_streaming(&[(&legacy, 0), (&legacy, 1000)], &mut out)
4721                .unwrap();
4722        assert_eq!(count, 600);
4723        assert_eq!(written, out.len());
4724        let merged = BlockPostingList::deserialize(&out).unwrap();
4725        assert_eq!(merged.doc_count(), 600);
4726        assert!(!merged.has_position_cursors());
4727    }
4728
4729    #[test]
4730    fn streaming_merge_rebases_position_cursors() {
4731        let a: Vec<(u32, u32)> = (0..200u32).map(|i| (i, i % 4 + 1)).collect();
4732        let b: Vec<(u32, u32)> = (0..150u32).map(|i| (i * 2, 2)).collect();
4733        let bytes_a = serialize_bpl(&build_bpl_with_positions(&a));
4734        let bytes_b = serialize_bpl(&build_bpl_with_positions(&b));
4735        let mut out = Vec::new();
4736        let (count, written) =
4737            BlockPostingList::concatenate_streaming(&[(&bytes_a, 0), (&bytes_b, 1000)], &mut out)
4738                .unwrap();
4739        assert_eq!(count, 350);
4740        assert_eq!(written, out.len());
4741        let merged = BlockPostingList::deserialize(&out).unwrap();
4742        assert!(merged.has_position_cursors());
4743        let all: Vec<(u32, u32)> = a
4744            .iter()
4745            .copied()
4746            .chain(b.iter().map(|&(d, tf)| (d + 1000, tf)))
4747            .collect();
4748        assert_eq!(collect_postings(&merged), all);
4749        assert_eq!(iterator_cursors(&merged), expected_cursors(&all));
4750        assert_eq!(
4751            merged.total_positions(),
4752            all.iter().map(|&(_, tf)| tf as u64).sum::<u64>()
4753        );
4754        // The in-memory reference agrees.
4755        let reference = BlockPostingList::concatenate_blocks(&[
4756            (build_bpl_with_positions(&a), 0),
4757            (build_bpl_with_positions(&b), 1000),
4758        ])
4759        .unwrap();
4760        assert_eq!(iterator_cursors(&reference), expected_cursors(&all));
4761        // Mixing lists with and without cursors is refused.
4762        let plain = serialize_bpl(&build_bpl(&b));
4763        assert!(
4764            BlockPostingList::concatenate_streaming(
4765                &[(&bytes_a, 0), (&plain, 1000)],
4766                &mut Vec::new()
4767            )
4768            .is_err()
4769        );
4770    }
4771
4772    #[test]
4773    fn test_seek_block_from_block_skips_earlier() {
4774        // 16 blocks: seek with from_block should skip earlier blocks
4775        let n = BLOCK_SIZE * 16;
4776        let docs: Vec<(u32, u32)> = (0..n as u32).map(|i| (i * 3, 1)).collect();
4777        let bpl = build_bpl(&docs);
4778
4779        // Target is in block 5, but from_block=8 → should find block >= 8
4780        let target_in_5 = bpl.block_first_doc(5).unwrap() + 1;
4781        // from_block=8 means we only look at blocks 8+
4782        // target_in_5 < last_doc of block 8, so seek_block(target, 8) should return 8
4783        let result = bpl.seek_block(target_in_5, 8);
4784        assert!(result.is_some());
4785        assert!(result.unwrap() >= 8);
4786    }
4787    #[test]
4788    fn saturated_tf_bounds_remain_upper_bounds_without_changing_encoded_bytes() {
4789        let docs: Vec<_> = (0..(BLOCK_SIZE * 18) as u32)
4790            .map(|doc| {
4791                (
4792                    doc,
4793                    if doc == (BLOCK_SIZE * 17) as u32 {
4794                        100_000
4795                    } else {
4796                        1
4797                    },
4798                )
4799            })
4800            .collect();
4801        let list = build_bpl(&docs);
4802        let bytes = serialize_bpl(&list);
4803        assert_eq!(list.max_tf(), 100_000);
4804        assert!(list.block_bounds(17).unwrap().0 >= 100_000);
4805        assert!(list.group_bounds(17).unwrap().0 >= 100_000);
4806        assert_eq!(list.block_bounds(0).unwrap().0, 1);
4807        let decoded = BlockPostingList::deserialize(&bytes).unwrap();
4808        assert!(decoded.block_bounds(17).unwrap().0 >= 100_000);
4809        assert!(decoded.group_bounds(17).unwrap().0 >= 100_000);
4810        assert_eq!(serialize_bpl(&decoded), bytes);
4811        assert_eq!(collect_postings(&decoded), docs);
4812    }
4813    #[test]
4814    fn ratio_bounds_preserve_payload_bytes_and_copy_merge_with_legacy_blocks() {
4815        for codec in [
4816            PostingCodec::Rounded,
4817            PostingCodec::Packed,
4818            PostingCodec::Pfor,
4819            PostingCodec::Simd4x,
4820        ] {
4821            let mut postings = PostingList::new();
4822            for i in 0..1100 {
4823                postings.push(i * 3, 1 + i % 97);
4824            }
4825            let length = |id| 10 + id % 997;
4826            let plain = BlockPostingList::from_posting_list_with_options(
4827                &postings,
4828                true,
4829                Some(&length),
4830                codec,
4831            )
4832            .unwrap();
4833            let tight = BlockPostingList::from_posting_list_with_ratio_bounds(
4834                &postings,
4835                true,
4836                Some(&length),
4837                codec,
4838            )
4839            .unwrap();
4840            assert_eq!(plain.stream.as_slice(), tight.stream.as_slice());
4841            assert_eq!(plain.l0_bytes.as_slice(), tight.l0_bytes.as_slice());
4842            assert_eq!(
4843                plain.pos_cursors.as_ref().map(OwnedBytes::as_slice),
4844                tight.pos_cursors.as_ref().map(OwnedBytes::as_slice)
4845            );
4846            let mut raw = Vec::new();
4847            tight.serialize(&mut raw).unwrap();
4848            let decoded = BlockPostingList::deserialize(&raw).unwrap();
4849            assert!(decoded.has_ratio_bounds());
4850            for block in 0..tight.num_blocks() {
4851                assert!(decoded.block_length_ratio(block) > 0.0);
4852                for posting in &postings.postings
4853                    [block * BLOCK_SIZE..((block + 1) * BLOCK_SIZE).min(postings.len())]
4854                {
4855                    assert!(
4856                        decoded.block_length_ratio(block) as f64
4857                            <= length(posting.doc_id) as f64 / posting.term_freq as f64
4858                    );
4859                    assert!(decoded.group_length_ratio(block) <= decoded.block_length_ratio(block));
4860                }
4861            }
4862            let merged = BlockPostingList::concatenate_blocks(&[
4863                (tight.clone(), 0),
4864                (plain.clone(), 10_000),
4865            ])
4866            .unwrap();
4867            let mut plain_raw = Vec::new();
4868            plain.serialize(&mut plain_raw).unwrap();
4869            let mut streaming = Vec::new();
4870            let (_, size) = BlockPostingList::concatenate_streaming(
4871                &[(&raw, 0), (&plain_raw, 10_000)],
4872                &mut streaming,
4873            )
4874            .unwrap();
4875            let mut expected = Vec::new();
4876            merged.serialize(&mut expected).unwrap();
4877            assert_eq!(streaming, expected);
4878            assert_eq!(streaming.len(), size);
4879            for block in 0..tight.num_blocks() {
4880                assert_eq!(
4881                    merged.block_length_ratio(block),
4882                    tight.block_length_ratio(block)
4883                );
4884                assert_eq!(merged.block_length_ratio(block + tight.num_blocks()), 0.0);
4885                let mut before = Vec::new();
4886                let mut after = Vec::new();
4887                let mut tfs_before = Vec::new();
4888                let mut tfs_after = Vec::new();
4889                tight.decode_block_into(block, &mut before, &mut tfs_before);
4890                merged.decode_block_into(block, &mut after, &mut tfs_after);
4891                assert_eq!((before, tfs_before), (after, tfs_after));
4892            }
4893            // Model an actual list without the optional extension: remove
4894            // its bytes as well as its flag. Unaddressed trailers are corrupt.
4895            let footer = Footer::parse(&raw).unwrap();
4896            raw.drain(footer.cursors_end()..footer.ratios_end());
4897            let flags = raw.len() - 12;
4898            raw[flags] &= !(FLAG_RATIO_BOUNDS as u8);
4899            let legacy_view = BlockPostingList::deserialize(&raw).unwrap();
4900            assert!(!legacy_view.has_ratio_bounds());
4901            assert_eq!(legacy_view.stream.as_slice(), tight.stream.as_slice());
4902        }
4903    }
4904
4905    #[test]
4906    fn ratio_bounds_reject_truncation_nonfinite_negative_and_unknown_flags() {
4907        let mut postings = PostingList::new();
4908        postings.push(1, 3);
4909        let list = BlockPostingList::from_posting_list_with_ratio_bounds(
4910            &postings,
4911            false,
4912            Some(&|_| 7),
4913            PostingCodec::Rounded,
4914        )
4915        .unwrap();
4916        let mut raw = Vec::new();
4917        list.serialize(&mut raw).unwrap();
4918        let footer = Footer::parse(&raw).unwrap();
4919        for bad in [f32::NAN, f32::INFINITY, -1.0] {
4920            let mut corrupt = raw.clone();
4921            corrupt[footer.cursors_end()..footer.cursors_end() + 4]
4922                .copy_from_slice(&bad.to_le_bytes());
4923            assert!(BlockPostingList::deserialize(&corrupt).is_err());
4924            assert!(
4925                BlockPostingList::concatenate_streaming(&[(&corrupt, 0)], &mut Vec::new()).is_err()
4926            );
4927        }
4928        let mut short = raw.clone();
4929        short.remove(footer.cursors_end());
4930        assert!(BlockPostingList::deserialize(&short).is_err());
4931        let at = raw.len() - 12;
4932        raw[at] |= 128;
4933        assert!(BlockPostingList::deserialize(&raw).is_err());
4934    }
4935
4936    const ALL_CODECS: [PostingCodec; 4] = [
4937        PostingCodec::Rounded,
4938        PostingCodec::Packed,
4939        PostingCodec::Pfor,
4940        PostingCodec::Simd4x,
4941    ];
4942
4943    /// Byte offset of the first document delta of `block` in the stream.
4944    fn first_delta_byte(list: &BlockPostingList, block: usize) -> usize {
4945        let (_, _, offset, _) = list.read_l0_entry(block);
4946        let header = offset as usize;
4947        // Pfor arrays start with their exception count.
4948        header + 8 + usize::from(list.block_codec(block) == Some(PostingCodec::Pfor))
4949    }
4950
4951    /// Structurally valid bytes whose block content disagrees with the L0
4952    /// directory must end the cursor explicitly, never index out of range.
4953    #[test]
4954    fn content_corrupt_block_never_panics_or_truncates_silently() {
4955        for codec in ALL_CODECS {
4956            // Block 0: docs 0..128 (full); block 1: [200, 300] (a tail, so
4957            // Simd4x falls back to Rounded there).
4958            let mut postings = PostingList::new();
4959            for doc in 0..128 {
4960                postings.push(doc, doc % 5 + 1);
4961            }
4962            postings.push(200, 2);
4963            postings.push(300, 3);
4964            let list =
4965                BlockPostingList::from_posting_list_with_options(&postings, true, None, codec)
4966                    .unwrap();
4967            let bytes = serialize_bpl(&list);
4968            let at = first_delta_byte(&list, 1);
4969            assert_eq!(
4970                bytes[at], 100,
4971                "{codec}: delta 300-200 at the first payload byte"
4972            );
4973            let mut corrupt = bytes.clone();
4974            corrupt[at] = 1; // block 1 now decodes to [200, 201]; L0 still says 200..=300
4975            let admitted = BlockPostingList::deserialize(&corrupt)
4976                .unwrap_or_else(|e| panic!("{codec}: structural admission must pass: {e}"));
4977            // Before the content check this indexed past the decoded block.
4978            let mut cursor = admitted.iterator();
4979            assert_eq!(cursor.seek(250), TERMINATED, "{codec}");
4980            assert_eq!(cursor.term_freq(), 0);
4981            assert_eq!(cursor.advance(), TERMINATED);
4982            let mut decoded = Vec::new();
4983            assert!(
4984                admitted
4985                    .decode_block_doc_ids_only(0, &mut decoded)
4986                    .is_some()
4987            );
4988            assert_eq!(decoded.len(), 128);
4989            assert!(
4990                admitted
4991                    .decode_block_doc_ids_only(1, &mut decoded)
4992                    .is_none(),
4993                "{codec}: a content-corrupt block must be reported, not decoded"
4994            );
4995            assert!(decoded.is_empty(), "no ids escape a corrupt block");
4996            assert!(!admitted.decode_block_into(1, &mut decoded, &mut Vec::new()));
4997            // Sequential traversal stops at the corrupt block instead of
4998            // yielding ids outside the directory range.
4999            assert_eq!(
5000                collect_postings(&admitted),
5001                collect_postings(&list)[..128],
5002                "{codec}"
5003            );
5004            let mut window = admitted.iterator();
5005            let mut bits = [0u64; 8];
5006            window.fill_doc_window(0, &mut bits);
5007            assert_eq!(bits[..2], [u64::MAX, u64::MAX]);
5008            assert_eq!(window.doc(), TERMINATED);
5009            // Untouched bytes still decode completely.
5010            assert_eq!(
5011                collect_postings(&BlockPostingList::deserialize(&bytes).unwrap()).len(),
5012                130
5013            );
5014        }
5015        // A full Simd4x block: corrupt one lane word after the reserved zero
5016        // first gap so the decoded ids drift below the directory's last id.
5017        let mut postings = PostingList::new();
5018        for doc in 0..128 {
5019            postings.push(doc, 1);
5020        }
5021        for i in 0..128 {
5022            postings.push(200 + i * 2, 1);
5023        }
5024        let list = BlockPostingList::from_posting_list_with_codec(&postings, PostingCodec::Simd4x)
5025            .unwrap();
5026        assert_eq!(list.block_codec(1), Some(PostingCodec::Simd4x));
5027        let mut bytes = serialize_bpl(&list);
5028        let at = first_delta_byte(&list, 1) + 1;
5029        assert_ne!(bytes[at], 0);
5030        bytes[at] = 0;
5031        let admitted = BlockPostingList::deserialize(&bytes).unwrap();
5032        let mut cursor = admitted.iterator();
5033        assert_eq!(cursor.seek(450), TERMINATED);
5034        assert!(
5035            admitted
5036                .decode_block_doc_ids_only(1, &mut Vec::new())
5037                .is_none()
5038        );
5039        assert_eq!(collect_postings(&admitted).len(), 128);
5040    }
5041
5042    /// Single-block list whose tail (count < 128) is encoded with codec id 3,
5043    /// as the first Simd4x prototype wrote; the builder now emits Rounded
5044    /// tails but readers keep accepting these bytes.
5045    fn simd_exact_tail_bytes(docs: &[(u32, u32)]) -> Vec<u8> {
5046        assert!(!docs.is_empty() && docs.len() < BLOCK_SIZE);
5047        let count = docs.len();
5048        let (first, last) = (docs[0].0, docs[count - 1].0);
5049        let deltas: Vec<u32> = docs.windows(2).map(|w| w[1].0 - w[0].0).collect();
5050        let tfs: Vec<u32> = docs.iter().map(|d| d.1).collect();
5051        let max_tf = tfs.iter().copied().max().unwrap();
5052        let mut stream = Vec::new();
5053        stream.write_u16::<LittleEndian>(count as u16).unwrap();
5054        stream.write_u32::<LittleEndian>(first).unwrap();
5055        let header_at = stream.len();
5056        stream.extend_from_slice(&[0, 0]);
5057        let doc_bits = bitpacking4x::encode_gaps(&deltas, &mut stream);
5058        let tf_bits = bitpacking4x::encode(&tfs, &mut stream);
5059        stream[header_at] = PostingCodec::Simd4x.header_byte(doc_bits);
5060        stream[header_at + 1] = tf_bits;
5061        let mut bytes = stream.clone();
5062        write_l0(&mut bytes, first, last, 0, pack_bounds(max_tf, 1));
5063        bytes.extend_from_slice(&last.to_le_bytes());
5064        bytes.extend_from_slice(&pack_bounds(max_tf, 1).to_le_bytes());
5065        BlockPostingList::write_footer(
5066            &mut bytes,
5067            stream.len() as u64,
5068            1,
5069            1,
5070            count as u32,
5071            max_tf,
5072            0,
5073            false,
5074            Some(1),
5075            true,
5076            false,
5077            false,
5078            false,
5079            0,
5080        )
5081        .unwrap();
5082        bytes
5083    }
5084
5085    #[test]
5086    fn simd4x_exact_tail_blocks_decode_seek_and_copy_through_merge() {
5087        for count in [1usize, 2, 3, 17, 127] {
5088            let docs: Vec<(u32, u32)> = (0..count as u32)
5089                .map(|i| (i * 3 + 7, i % 4 + 1 + (i == 5) as u32 * 900))
5090                .collect();
5091            let bytes = simd_exact_tail_bytes(&docs);
5092            let list = BlockPostingList::deserialize(&bytes).unwrap();
5093            assert_eq!(
5094                list.block_codec(0),
5095                Some(PostingCodec::Simd4x),
5096                "count={count}"
5097            );
5098            assert_eq!(collect_postings(&list), docs, "count={count}");
5099            let mut cursor = list.iterator();
5100            for &(doc, tf) in &docs {
5101                assert_eq!(cursor.seek(doc.saturating_sub(1)), doc);
5102                assert_eq!(cursor.term_freq(), tf);
5103            }
5104            assert_eq!(cursor.seek(docs[count - 1].0 + 1), TERMINATED);
5105            assert_eq!(serialize_bpl(&list), bytes, "byte-identical round trip");
5106            // Merges copy the tail verbatim, keeping codec id 3.
5107            let mut out = Vec::new();
5108            let (merged_count, written) =
5109                BlockPostingList::concatenate_streaming(&[(&bytes, 0), (&bytes, 1000)], &mut out)
5110                    .unwrap();
5111            assert_eq!((merged_count as usize, written), (2 * count, out.len()));
5112            let merged = BlockPostingList::deserialize(&out).unwrap();
5113            assert_eq!(merged.block_codec(1), Some(PostingCodec::Simd4x));
5114            let expected: Vec<(u32, u32)> = docs
5115                .iter()
5116                .copied()
5117                .chain(docs.iter().map(|&(d, t)| (d + 1000, t)))
5118                .collect();
5119            assert_eq!(collect_postings(&merged), expected, "count={count}");
5120            let typed =
5121                BlockPostingList::concatenate_blocks(&[(list.clone(), 0), (list.clone(), 1000)])
5122                    .unwrap();
5123            assert_eq!(serialize_bpl(&typed), out);
5124            // Header corruption of the tail is still caught structurally.
5125            let mut short = bytes.clone();
5126            short[0] = (count + 1) as u8;
5127            assert!(BlockPostingList::deserialize(&short).is_err());
5128        }
5129    }
5130
5131    /// A legacy 24-byte footer ending in a `max_tf` equal to the extended
5132    /// footer magic is ambiguous; it must be rejected, never parsed as the
5133    /// extended layout.
5134    #[test]
5135    fn legacy_footer_whose_max_tf_equals_the_magic_is_rejected_not_misread() {
5136        for count in [1u32, 2, 130, 700] {
5137            let mut postings = PostingList::new();
5138            for i in 0..count {
5139                postings.push(i * 2, if i == 0 { FOOTER_MAGIC } else { 1 });
5140            }
5141            let bpl = BlockPostingList::from_posting_list(&postings).unwrap();
5142            assert_eq!(bpl.max_tf(), FOOTER_MAGIC);
5143            let bytes = serialize_bpl(&bpl);
5144            let footer = Footer::parse(&bytes).unwrap();
5145            let mut legacy = bytes[..footer.l1_end()].to_vec();
5146            for block in 0..bpl.num_blocks() {
5147                let at = footer.l0_start() + block * L0_SIZE + 12;
5148                legacy[at..at + 4]
5149                    .copy_from_slice(&(bpl.block_max_tf(block).unwrap() as f32).to_le_bytes());
5150            }
5151            legacy.extend_from_slice(
5152                &bytes[bytes.len() - FOOTER_V2_SIZE..bytes.len() - (FOOTER_V2_SIZE - FOOTER_SIZE)],
5153            );
5154            assert_eq!(
5155                u32::from_le_bytes(legacy[legacy.len() - 4..].try_into().unwrap()),
5156                FOOTER_MAGIC
5157            );
5158            assert!(
5159                BlockPostingList::deserialize(&legacy).is_err(),
5160                "count={count}: ambiguous footer must not be read as either layout"
5161            );
5162            assert!(!BlockPostingList::has_cursors_bytes(&legacy));
5163            let mut out = vec![0xab];
5164            assert!(BlockPostingList::concatenate_streaming(&[(&legacy, 0)], &mut out).is_err());
5165            assert_eq!(out, [0xab]);
5166            // The same postings with the extended footer read back exactly.
5167            assert_eq!(
5168                BlockPostingList::deserialize(&bytes).unwrap().max_tf(),
5169                FOOTER_MAGIC
5170            );
5171        }
5172    }
5173
5174    #[test]
5175    fn mixed_cursor_and_cursorless_sources_fail_loudly_before_output() {
5176        let docs: Vec<(u32, u32)> = (0..300u32).map(|i| (i * 2, i % 3 + 1)).collect();
5177        let with = build_bpl_with_positions(&docs);
5178        let without = build_bpl(&docs);
5179        assert!(with.has_position_cursors() && !without.has_position_cursors());
5180        for sources in [
5181            [(with.clone(), 0), (without.clone(), 1000)],
5182            [(without.clone(), 0), (with.clone(), 1000)],
5183        ] {
5184            let error = BlockPostingList::concatenate_blocks(&sources).unwrap_err();
5185            assert!(
5186                error
5187                    .to_string()
5188                    .contains("with and without position cursors"),
5189                "{error}"
5190            );
5191            let bytes: Vec<Vec<u8>> = sources.iter().map(|(s, _)| serialize_bpl(s)).collect();
5192            let mut out = vec![0xab];
5193            let result = BlockPostingList::concatenate_streaming(
5194                &[(&bytes[0], 0), (&bytes[1], 1000)],
5195                &mut out,
5196            );
5197            match result {
5198                Err(crate::Error::Corruption(message)) => {
5199                    assert!(
5200                        message.contains("with and without position cursors"),
5201                        "{message}"
5202                    )
5203                }
5204                other => panic!("expected a loud corruption error, got {other:?}"),
5205            }
5206            assert_eq!(out, [0xab], "nothing is written for a rejected source set");
5207        }
5208    }
5209}
5210
5211#[cfg(test)]
5212#[path = "posting/merge_admission_tests.rs"]
5213mod merge_admission_tests;
5214
5215#[cfg(test)]
5216mod byte_gap_validation_tests {
5217    use super::*;
5218
5219    #[test]
5220    fn strict_gap_validation_agrees_with_full_scan_at_width_and_wrap_boundaries() {
5221        for count in 1..=BLOCK_SIZE {
5222            for width in 0..=32 {
5223                let mask = if width == 0 {
5224                    0
5225                } else {
5226                    u32::MAX >> (32 - width)
5227                };
5228                for first in [0u32, 17, u32::MAX / 2, u32::MAX - 1] {
5229                    for pattern in 0..3 {
5230                        let mut docs = vec![first];
5231                        for i in 1..count {
5232                            let encoded = match pattern {
5233                                0 => mask,
5234                                1 => 0,
5235                                _ => (i as u32).wrapping_mul(0x9e3779b9) & mask,
5236                            };
5237                            docs.push(docs.last().unwrap().wrapping_add(encoded).wrapping_add(1));
5238                        }
5239                        let end = *docs.last().unwrap();
5240                        for last in [end, end.wrapping_add(1), end.wrapping_sub(1)] {
5241                            assert_eq!(
5242                                verify_block_docs(&docs, first, last, None, Some(width)),
5243                                verify_block_docs(&docs, first, last, None, None),
5244                                "count={count}, width={width}, first={first}, pattern={pattern}, last={last}"
5245                            );
5246                        }
5247                    }
5248                }
5249            }
5250        }
5251    }
5252
5253    #[test]
5254    fn byte_gap_validation_agrees_with_decoded_order_for_tails_and_unsigned_wraps() {
5255        for count in 1..=BLOCK_SIZE {
5256            for first in [0u32, 17, u32::MAX - 400, u32::MAX - 1] {
5257                for pattern in 0..5 {
5258                    let gaps: Vec<u8> = (1..count)
5259                        .map(|i| match pattern {
5260                            0 => 1,
5261                            1 => 255,
5262                            2 => {
5263                                if i == count / 2 {
5264                                    0
5265                                } else {
5266                                    1
5267                                }
5268                            }
5269                            3 => (i * 71) as u8,
5270                            _ => ((i * 71) as u8).max(1),
5271                        })
5272                        .collect();
5273                    let mut docs = vec![first];
5274                    for &gap in &gaps {
5275                        docs.push(docs.last().unwrap().wrapping_add(u32::from(gap)));
5276                    }
5277                    let end = *docs.last().unwrap();
5278                    for last in [end, end.wrapping_add(1), end.wrapping_sub(1)] {
5279                        assert_eq!(
5280                            verify_block_docs(&docs, first, last, Some(&gaps), None),
5281                            verify_block_docs(&docs, first, last, None, None),
5282                            "count={count}, first={first}, pattern={pattern}, last={last}"
5283                        );
5284                    }
5285                }
5286            }
5287        }
5288    }
5289}