Skip to main content

zeppelin_embed/fts/
postings.rs

1//! The persisted posting format: bit-packed FOR blocks with positions.
2//!
3//! # Geometry, and why the block size is a header field
4//!
5//! Blocks hold
6//! [`DEFAULT_POSTINGS_PER_BLOCK`](crate::fts::postings::DEFAULT_POSTINGS_PER_BLOCK)
7//! postings. The research
8//! disagrees with itself usefully here: ~40 postings per block prunes best
9//! on GOV2 (3.6 ms against 4.2 ms at 128 — `research/02a:283`), but 128 is
10//! what makes SIMD decode clean in Lucene and tantivy. Both numbers are
11//! x86, on a 25M-document collection; neither transfers to a machine whose
12//! cache line is 128 bytes (`tasks/evidence/19-M0-platform-premises.md:14`).
13//!
14//! 64 is the judgement call: 64 sixteen-bit deltas is exactly one 128-byte
15//! line, and the block metadata row below is exactly 32 bytes, so four rows
16//! also fill one line and the array indexes by shift rather than by
17//! multiply. It is a cache-line argument, not a measurement, and it is
18//! **not** a size-saving choice.
19//!
20//! The block size is written into the region header, so re-tuning it later
21//! (task 27-B4 sweeps {32, 40, 64, 128}) changes a constant, never the
22//! format. Do not "fix" a golden by editing the constant.
23//!
24//! # Streams
25//!
26//! Structure-of-arrays, all little-endian, all hand-written:
27//!
28//! - **docid deltas**, frame-of-reference bit-packed per block;
29//! - **term frequencies**, bit-packed per block;
30//! - **positions**, delta-packed per posting, `tf` of them per posting —
31//!   the term frequency *is* the position count, so no separate count
32//!   stream exists.
33//!
34//! Positions are in the format from day one because retrofitting them is a
35//! format break (`research/03:484`). Task 15 reads them; task 13 only
36//! stores them.
37//!
38//! # Block maxima
39//!
40//! Each block carries a `u8` quantized ceiling on the BM25 contribution of
41//! any posting inside it. Task 14 reads it; task 13 writes it. Quantization
42//! **rounds up**, so a stored maximum is never below a true score — a bound
43//! that is too low would let pruning drop a document that belonged in the
44//! top-k, and that is a wrong answer, not a slow one.
45//!
46//! `u8` is enough: 8-bit uniform impact quantization is statistically
47//! indistinguishable from exact weights (Lin & Trotman, IRJ 2017). It is
48//! kept for metadata density per cache line, not to save bytes, and the
49//! same slot is deliberately shaped so a future caller-supplied impact
50//! could reuse it.
51
52use super::bm25::{DocLen, Tf};
53
54/// Postings per block. Persisted; see the module docs before changing it.
55pub const DEFAULT_POSTINGS_PER_BLOCK: u16 = 64;
56
57/// Bytes in one block-metadata row. Four rows fill a 128-byte cache line.
58pub const BLOCK_META_LEN: usize = 32;
59
60/// Magic prefixing an encoded posting list.
61const POSTINGS_MAGIC: [u8; 4] = *b"ZPST";
62
63/// The original encoded-postings format version.
64///
65/// Its six reserved metadata bytes are zero. It remains readable, and the
66/// frozen golden is a v1 stream, but the query path refuses it: a v1 block
67/// carries no impact pair, and a silent exhaustive fallback would make a
68/// stale index look correct and slow forever.
69pub const POSTINGS_VERSION_V1: u16 = 1;
70
71/// The current encoded-postings format version.
72///
73/// Version 2 spends the six reserved metadata bytes on the impact pair
74/// `(max_tf: u32, min_len: u16)`. See [`BlockImpact`] for why that is a
75/// soundness fix and not merely a wider bound.
76pub const POSTINGS_VERSION: u16 = 2;
77
78/// A rejected or malformed posting stream.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum PostingsError {
81    /// The byte stream ended inside a structure.
82    Truncated {
83        /// Bytes the decoder needed.
84        needed: usize,
85        /// Bytes actually available.
86        available: usize,
87    },
88    /// The leading magic did not match.
89    BadMagic,
90    /// The declared version is not readable.
91    UnsupportedVersion {
92        /// Version found in the stream.
93        found: u16,
94    },
95    /// A declared bit width exceeded 32.
96    BitWidthTooLarge {
97        /// The rejected width.
98        bits: u8,
99    },
100    /// The block size in the header was zero.
101    ZeroBlockSize,
102    /// Document identifiers were not strictly ascending.
103    DocidsNotAscending {
104        /// The offending identifier.
105        docid: u32,
106    },
107    /// Positions within one posting were not strictly ascending.
108    PositionsNotAscending {
109        /// The offending position.
110        position: u32,
111    },
112    /// A term frequency was zero, which cannot occur in a posting list.
113    ZeroTermFrequency,
114    /// The stream declared more postings than its blocks can hold.
115    InconsistentPostingCount {
116        /// Postings declared in the header.
117        declared: u32,
118        /// Postings the blocks actually describe.
119        described: u32,
120    },
121}
122
123impl std::fmt::Display for PostingsError {
124    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Truncated { needed, available } => write!(
127                formatter,
128                "posting stream truncated: needed {needed} bytes, had {available}"
129            ),
130            Self::BadMagic => formatter.write_str("posting stream magic did not match"),
131            Self::UnsupportedVersion { found } => {
132                write!(formatter, "posting stream version {found} is not readable")
133            }
134            Self::BitWidthTooLarge { bits } => {
135                write!(formatter, "declared bit width {bits} exceeds 32")
136            }
137            Self::ZeroBlockSize => formatter.write_str("posting block size was zero"),
138            Self::DocidsNotAscending { docid } => {
139                write!(formatter, "document ids are not ascending at {docid}")
140            }
141            Self::PositionsNotAscending { position } => {
142                write!(formatter, "positions are not ascending at {position}")
143            }
144            Self::ZeroTermFrequency => formatter.write_str("a posting had a zero term frequency"),
145            Self::InconsistentPostingCount {
146                declared,
147                described,
148            } => write!(
149                formatter,
150                "header declares {declared} postings, blocks describe {described}"
151            ),
152        }
153    }
154}
155
156impl std::error::Error for PostingsError {}
157
158/// One document's entry in a posting list.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct Posting {
161    /// Segment-local dense row id.
162    pub docid: u32,
163    /// Occurrences of the term in the document. Always at least one.
164    pub tf: u32,
165    /// Token positions, strictly ascending. Length equals `tf`.
166    pub positions: Vec<u32>,
167}
168
169#[cfg(test)]
170thread_local! {
171    static ACCOUNTING_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
172}
173
174#[cfg(test)]
175pub(crate) fn take_accounting_visits() -> usize {
176    ACCOUNTING_VISITS.with(|count| count.replace(0))
177}
178
179#[inline]
180pub(crate) fn count_accounting_visits(amount: usize) {
181    #[cfg(test)]
182    ACCOUNTING_VISITS.with(|count| count.set(count.get() + amount));
183    #[cfg(not(test))]
184    let _ = amount;
185}
186
187/// An in-memory posting list for one term.
188#[derive(Clone, Debug, Default, Eq, PartialEq)]
189pub struct PostingList {
190    postings: Vec<Posting>,
191}
192
193impl PostingList {
194    /// Creates an empty list.
195    #[must_use]
196    pub const fn new() -> Self {
197        Self {
198            postings: Vec::new(),
199        }
200    }
201
202    /// Returns the postings, ascending by document id.
203    #[must_use]
204    pub fn postings(&self) -> &[Posting] {
205        &self.postings
206    }
207
208    pub(crate) fn allocation_bytes(&self) -> usize {
209        self.postings
210            .capacity()
211            .saturating_mul(std::mem::size_of::<Posting>())
212    }
213
214    pub(crate) fn resident_bytes(&self) -> usize {
215        count_accounting_visits(self.postings.len());
216        self.postings
217            .capacity()
218            .saturating_mul(std::mem::size_of::<Posting>())
219            .saturating_add(
220                self.postings
221                    .iter()
222                    .map(|posting| posting.positions.capacity().saturating_mul(4))
223                    .fold(0_usize, usize::saturating_add),
224            )
225    }
226
227    /// Returns the number of documents containing the term.
228    #[must_use]
229    pub fn document_frequency(&self) -> u32 {
230        u32::try_from(self.postings.len()).unwrap_or(u32::MAX)
231    }
232
233    /// Returns true when no document contains the term.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        self.postings.is_empty()
237    }
238
239    /// Appends one posting.
240    ///
241    /// # Errors
242    ///
243    /// Returns [`PostingsError`] when document ids are not strictly
244    /// ascending, when positions are not strictly ascending, or when the
245    /// term frequency is zero. These are contract violations by the caller,
246    /// so they fail loudly rather than being repaired.
247    pub fn push(&mut self, posting: Posting) -> Result<(), PostingsError> {
248        if posting.tf == 0 || posting.positions.is_empty() {
249            return Err(PostingsError::ZeroTermFrequency);
250        }
251        if let Some(last) = self.postings.last()
252            && posting.docid <= last.docid
253        {
254            return Err(PostingsError::DocidsNotAscending {
255                docid: posting.docid,
256            });
257        }
258        Self::validate_positions(&posting)?;
259        self.postings.push(posting);
260        Ok(())
261    }
262
263    /// Inserts one posting at its document-id position, anywhere in the list.
264    ///
265    /// # Errors
266    ///
267    /// As [`Self::push`]; a document id already in the list is
268    /// [`PostingsError::DocidsNotAscending`], since it would break strict
269    /// ascent.
270    pub(crate) fn insert(&mut self, posting: Posting) -> Result<(), PostingsError> {
271        if posting.tf == 0 || posting.positions.is_empty() {
272            return Err(PostingsError::ZeroTermFrequency);
273        }
274        let index = self
275            .postings
276            .partition_point(|existing| existing.docid < posting.docid);
277        if self
278            .postings
279            .get(index)
280            .is_some_and(|existing| existing.docid == posting.docid)
281        {
282            return Err(PostingsError::DocidsNotAscending {
283                docid: posting.docid,
284            });
285        }
286        Self::validate_positions(&posting)?;
287        self.postings.insert(index, posting);
288        Ok(())
289    }
290
291    /// Removes the posting for `docid`, returning whether one was present.
292    pub(crate) fn remove(&mut self, docid: u32) -> bool {
293        match self
294            .postings
295            .binary_search_by_key(&docid, |posting| posting.docid)
296        {
297            Ok(index) => {
298                self.postings.remove(index);
299                true
300            }
301            Err(_) => false,
302        }
303    }
304
305    fn validate_positions(posting: &Posting) -> Result<(), PostingsError> {
306        let mut previous: Option<u32> = None;
307        for position in &posting.positions {
308            if let Some(earlier) = previous
309                && *position <= earlier
310            {
311                return Err(PostingsError::PositionsNotAscending {
312                    position: *position,
313                });
314            }
315            previous = Some(*position);
316        }
317        Ok(())
318    }
319}
320
321/// Bits needed to represent the largest value in `values`.
322fn required_bits_controlled<E>(
323    values: &[u32],
324    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
325) -> Result<u8, E> {
326    let mut maximum = 0;
327    for value in values {
328        work.step()?;
329        #[cfg(test)]
330        ENCODE_BITS_PROBES.with(|value| value.set(value.get() + 1));
331        maximum = maximum.max(*value);
332    }
333    Ok(if maximum == 0 {
334        0
335    } else {
336        // 32 - leading_zeros is in 1..=32 for a non-zero value.
337        u8::try_from(32 - maximum.leading_zeros()).unwrap_or(32)
338    })
339}
340
341/// Appends `values` to `output`, packed at `bits` each.
342fn pack_bits_controlled<E>(
343    values: &[u32],
344    bits: u8,
345    output: &mut Vec<u8>,
346    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
347) -> Result<(), E> {
348    if bits == 0 {
349        return Ok(());
350    }
351    let width = u32::from(bits);
352    let mut accumulator: u64 = 0;
353    let mut filled: u32 = 0;
354    for value in values {
355        work.step()?;
356        #[cfg(test)]
357        ENCODE_PACK_PROBES.with(|value| value.set(value.get() + 1));
358        accumulator |= u64::from(*value) << filled;
359        filled += width;
360        while filled >= 8 {
361            output.push((accumulator & 0xFF) as u8);
362            accumulator >>= 8;
363            filled -= 8;
364        }
365    }
366    if filled > 0 {
367        output.push((accumulator & 0xFF) as u8);
368    }
369    Ok(())
370}
371
372#[cfg(test)]
373fn pack_bits(values: &[u32], bits: u8, output: &mut Vec<u8>) {
374    let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
375    match pack_bits_controlled(values, bits, output, &mut work) {
376        Ok(()) => (),
377        Err(never) => match never {},
378    }
379}
380
381/// Bytes a packed run of `count` values at `bits` each occupies.
382const fn packed_len(count: usize, bits: u8) -> usize {
383    if bits == 0 {
384        return 0;
385    }
386    (count * bits as usize).div_ceil(8)
387}
388
389/// Unpacks `count` values at `bits` each from the front of `input`.
390///
391/// Returns the number of bytes consumed.
392///
393/// # Errors
394///
395/// Returns [`PostingsError::BitWidthTooLarge`] for a width above 32 and
396/// [`PostingsError::Truncated`] when `input` is too short. It never panics
397/// and never reads past `input`.
398pub fn unpack_bits(
399    input: &[u8],
400    bits: u8,
401    count: usize,
402    output: &mut [u32],
403) -> Result<usize, PostingsError> {
404    if bits > 32 {
405        return Err(PostingsError::BitWidthTooLarge { bits });
406    }
407    if bits == 0 {
408        for slot in output.iter_mut().take(count) {
409            *slot = 0;
410        }
411        return Ok(0);
412    }
413    let needed = packed_len(count, bits);
414    if input.len() < needed {
415        return Err(PostingsError::Truncated {
416            needed,
417            available: input.len(),
418        });
419    }
420    let width = u32::from(bits);
421    let mask = if bits == 32 {
422        u32::MAX
423    } else {
424        (1_u32 << width) - 1
425    };
426    let mut accumulator: u64 = 0;
427    let mut filled: u32 = 0;
428    let mut cursor = 0_usize;
429    for slot in output.iter_mut().take(count) {
430        while filled < width {
431            let byte = input.get(cursor).copied().unwrap_or(0);
432            cursor += 1;
433            accumulator |= u64::from(byte) << filled;
434            filled += 8;
435        }
436        *slot = (accumulator as u32) & mask;
437        accumulator >>= width;
438        filled -= width;
439    }
440    Ok(needed)
441}
442
443/// One block's metadata row, exactly [`BLOCK_META_LEN`] bytes on disk.
444#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
445pub struct BlockMeta {
446    /// Largest document id in the block; the skip key.
447    pub last_docid: u32,
448    /// Byte offset of the block's packed docid deltas.
449    pub docids_offset: u32,
450    /// Byte offset of the block's packed term frequencies.
451    pub tfs_offset: u32,
452    /// Byte offset of the block's packed positions.
453    pub positions_offset: u32,
454    /// Packed positions in the block.
455    pub positions_count: u32,
456    /// Postings in the block; the final block may be partial.
457    pub count: u16,
458    /// Bit width of the docid deltas.
459    pub docid_bits: u8,
460    /// Bit width of the term frequencies.
461    pub tf_bits: u8,
462    /// Bit width of the position deltas.
463    pub position_bits: u8,
464    /// Reserved for a caller-supplied impact. Written zero by this engine.
465    ///
466    /// Version 1 stored `quantize(score / ceiling)` here and computed the
467    /// pruning bound from it. That was unsound: the quotient bakes in
468    /// seal-time `avgdl` and seal-time `(k1, b)`, neither of which cancels,
469    /// so the stored bound fell below a true score once statistics drifted.
470    /// The slot is kept — its shape suits a caller-supplied learned impact —
471    /// but nothing in the engine reads it for a bound.
472    pub block_max: u8,
473    /// Largest term frequency in the block. Zero in a version 1 stream.
474    pub max_tf: u32,
475    /// Smallest document length in the block, saturating DOWN to `u16::MAX`.
476    ///
477    /// Down, because a shorter document scores higher: saturating upward
478    /// would put the stored bound below a true score.
479    pub min_len: u16,
480}
481
482impl BlockMeta {
483    fn write(&self, output: &mut Vec<u8>) {
484        output.extend_from_slice(&self.last_docid.to_le_bytes());
485        output.extend_from_slice(&self.docids_offset.to_le_bytes());
486        output.extend_from_slice(&self.tfs_offset.to_le_bytes());
487        output.extend_from_slice(&self.positions_offset.to_le_bytes());
488        output.extend_from_slice(&self.positions_count.to_le_bytes());
489        output.extend_from_slice(&self.count.to_le_bytes());
490        output.push(self.docid_bits);
491        output.push(self.tf_bits);
492        output.push(self.position_bits);
493        output.push(self.block_max);
494        // 4 + 4 + 4 + 4 + 4 + 2 + 4 written above is 26 bytes; the impact
495        // pair spends the remaining six exactly. A version 1 encode leaves
496        // both fields zero, which reproduces the original zero padding byte
497        // for byte, so the v1 golden is unmoved.
498        output.extend_from_slice(&self.max_tf.to_le_bytes());
499        output.extend_from_slice(&self.min_len.to_le_bytes());
500    }
501
502    /// Reads one metadata row from the front of `input`.
503    ///
504    /// # Errors
505    ///
506    /// Returns [`PostingsError::Truncated`] when fewer than
507    /// [`BLOCK_META_LEN`] bytes are available.
508    pub fn read(input: &[u8]) -> Result<Self, PostingsError> {
509        let row = input
510            .get(..BLOCK_META_LEN)
511            .ok_or(PostingsError::Truncated {
512                needed: BLOCK_META_LEN,
513                available: input.len(),
514            })?;
515        Ok(Self {
516            last_docid: read_u32(row, 0)?,
517            docids_offset: read_u32(row, 4)?,
518            tfs_offset: read_u32(row, 8)?,
519            positions_offset: read_u32(row, 12)?,
520            positions_count: read_u32(row, 16)?,
521            count: read_u16(row, 20)?,
522            docid_bits: read_u8(row, 22)?,
523            tf_bits: read_u8(row, 23)?,
524            position_bits: read_u8(row, 24)?,
525            block_max: read_u8(row, 25)?,
526            max_tf: read_u32(row, 26)?,
527            min_len: read_u16(row, 30)?,
528        })
529    }
530}
531
532fn read_u8(input: &[u8], at: usize) -> Result<u8, PostingsError> {
533    input.get(at).copied().ok_or(PostingsError::Truncated {
534        needed: at + 1,
535        available: input.len(),
536    })
537}
538
539fn read_u16(input: &[u8], at: usize) -> Result<u16, PostingsError> {
540    let bytes = input.get(at..at + 2).ok_or(PostingsError::Truncated {
541        needed: at + 2,
542        available: input.len(),
543    })?;
544    let mut buffer = [0_u8; 2];
545    buffer.copy_from_slice(bytes);
546    Ok(u16::from_le_bytes(buffer))
547}
548
549fn read_u32(input: &[u8], at: usize) -> Result<u32, PostingsError> {
550    let bytes = input.get(at..at + 4).ok_or(PostingsError::Truncated {
551        needed: at + 4,
552        available: input.len(),
553    })?;
554    let mut buffer = [0_u8; 4];
555    buffer.copy_from_slice(bytes);
556    Ok(u32::from_le_bytes(buffer))
557}
558
559/// An encoded posting list: header, block metadata rows, then the streams.
560#[derive(Clone, Debug, Default, Eq, PartialEq)]
561pub struct EncodedPostings {
562    bytes: Vec<u8>,
563}
564
565impl EncodedPostings {
566    /// Returns the encoded bytes.
567    #[must_use]
568    pub fn as_bytes(&self) -> &[u8] {
569        &self.bytes
570    }
571
572    /// Wraps already-encoded bytes.
573    #[must_use]
574    pub const fn from_bytes(bytes: Vec<u8>) -> Self {
575        Self { bytes }
576    }
577}
578
579/// Header length: magic, version, block size, posting and block counts.
580pub const HEADER_LEN: usize = 16;
581
582/// Encodes one posting list in the original version 1 layout.
583///
584/// `block_maxima` supplies the per-block reserved `u8` slot; pass an empty
585/// slice to store zeros. The impact-pair bytes stay zero, which is what
586/// keeps the frozen v1 golden byte-identical.
587///
588/// New writers use [`encode_v2`]. This entry point exists so the v1 golden
589/// stays exercised and so a v1 stream can still be produced deliberately.
590///
591/// # Errors
592///
593/// Returns [`PostingsError::ZeroBlockSize`] when the geometry is degenerate.
594pub fn encode(
595    list: &PostingList,
596    postings_per_block: u16,
597    block_maxima: &[u8],
598) -> Result<EncodedPostings, PostingsError> {
599    encode_versioned(
600        list.postings(),
601        postings_per_block,
602        block_maxima,
603        &[],
604        POSTINGS_VERSION_V1,
605        &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
606    )
607}
608
609/// Encodes one posting list in the version 2 layout, carrying impact pairs.
610///
611/// `impacts` supplies one [`BlockImpact`] per block, normally from
612/// [`block_impacts`]. A missing entry stores a zero pair, which a reader
613/// rejects rather than treating as an unbounded block.
614///
615/// # Errors
616///
617/// Returns [`PostingsError::ZeroBlockSize`] when the geometry is degenerate.
618pub fn encode_v2(
619    list: &PostingList,
620    postings_per_block: u16,
621    block_maxima: &[u8],
622    impacts: &[BlockImpact],
623) -> Result<EncodedPostings, PostingsError> {
624    encode_versioned(
625        list.postings(),
626        postings_per_block,
627        block_maxima,
628        impacts,
629        POSTINGS_VERSION,
630        &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
631    )
632}
633
634pub(crate) fn encode_v2_controlled<E: From<PostingsError>>(
635    list: &PostingList,
636    postings_per_block: u16,
637    block_maxima: &[u8],
638    impacts: &[BlockImpact],
639    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
640) -> Result<EncodedPostings, E> {
641    encode_versioned(
642        list.postings(),
643        postings_per_block,
644        block_maxima,
645        impacts,
646        POSTINGS_VERSION,
647        work,
648    )
649}
650
651fn encode_versioned<E: From<PostingsError>>(
652    postings: &[Posting],
653    postings_per_block: u16,
654    block_maxima: &[u8],
655    impacts: &[BlockImpact],
656    version: u16,
657    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
658) -> Result<EncodedPostings, E> {
659    work.check_now()?;
660    if postings_per_block == 0 {
661        return Err(PostingsError::ZeroBlockSize.into());
662    }
663    let per_block = usize::from(postings_per_block);
664    let block_count = postings.len().div_ceil(per_block);
665
666    let mut docid_stream: Vec<u8> = Vec::new();
667    let mut tf_stream: Vec<u8> = Vec::new();
668    let mut position_stream: Vec<u8> = Vec::new();
669    let mut metadata: Vec<BlockMeta> = Vec::with_capacity(block_count);
670
671    let mut previous_last_docid = 0_u32;
672    for (index, chunk) in postings.chunks(per_block).enumerate() {
673        work.step()?;
674        let mut deltas: Vec<u32> = Vec::with_capacity(chunk.len());
675        let mut frequencies: Vec<u32> = Vec::with_capacity(chunk.len());
676        let mut position_deltas: Vec<u32> = Vec::new();
677
678        let mut base = previous_last_docid;
679        for (offset, posting) in chunk.iter().enumerate() {
680            work.step()?;
681            // The very first posting of the list is stored absolutely; every
682            // other delta is a gap from the previous document id.
683            let delta = if index == 0 && offset == 0 {
684                posting.docid
685            } else {
686                posting.docid.saturating_sub(base)
687            };
688            deltas.push(delta);
689            frequencies.push(posting.tf);
690            let mut previous_position = 0_u32;
691            for (slot, position) in posting.positions.iter().enumerate() {
692                work.step()?;
693                #[cfg(test)]
694                ENCODE_POSITION_PROBES.with(|value| value.set(value.get() + 1));
695                let gap = if slot == 0 {
696                    *position
697                } else {
698                    position.saturating_sub(previous_position)
699                };
700                position_deltas.push(gap);
701                previous_position = *position;
702            }
703            base = posting.docid;
704        }
705        previous_last_docid = base;
706
707        let docid_bits = required_bits_controlled(&deltas, work)?;
708        let tf_bits = required_bits_controlled(&frequencies, work)?;
709        let position_bits = required_bits_controlled(&position_deltas, work)?;
710
711        let meta = BlockMeta {
712            last_docid: base,
713            docids_offset: u32::try_from(docid_stream.len()).unwrap_or(u32::MAX),
714            tfs_offset: u32::try_from(tf_stream.len()).unwrap_or(u32::MAX),
715            positions_offset: u32::try_from(position_stream.len()).unwrap_or(u32::MAX),
716            positions_count: u32::try_from(position_deltas.len()).unwrap_or(u32::MAX),
717            count: u16::try_from(chunk.len()).unwrap_or(u16::MAX),
718            docid_bits,
719            tf_bits,
720            position_bits,
721            block_max: block_maxima.get(index).copied().unwrap_or(0),
722            max_tf: impacts.get(index).map_or(0, |impact| impact.max_tf),
723            min_len: impacts.get(index).map_or(0, |impact| impact.min_len),
724        };
725
726        pack_bits_controlled(&deltas, docid_bits, &mut docid_stream, work)?;
727        pack_bits_controlled(&frequencies, tf_bits, &mut tf_stream, work)?;
728        pack_bits_controlled(&position_deltas, position_bits, &mut position_stream, work)?;
729        metadata.push(meta);
730    }
731
732    let mut bytes = Vec::with_capacity(
733        HEADER_LEN
734            + metadata.len() * BLOCK_META_LEN
735            + docid_stream.len()
736            + tf_stream.len()
737            + position_stream.len(),
738    );
739    bytes.extend_from_slice(&POSTINGS_MAGIC);
740    bytes.extend_from_slice(&version.to_le_bytes());
741    bytes.extend_from_slice(&postings_per_block.to_le_bytes());
742    bytes.extend_from_slice(
743        &u32::try_from(postings.len())
744            .unwrap_or(u32::MAX)
745            .to_le_bytes(),
746    );
747    bytes.extend_from_slice(
748        &u32::try_from(metadata.len())
749            .unwrap_or(u32::MAX)
750            .to_le_bytes(),
751    );
752    for meta in &metadata {
753        work.step()?;
754        meta.write(&mut bytes);
755    }
756    super::control::extend_bytes(&mut bytes, &docid_stream, work)?;
757    super::control::extend_bytes(&mut bytes, &tf_stream, work)?;
758    super::control::extend_bytes(&mut bytes, &position_stream, work)?;
759
760    work.check_now()?;
761    Ok(EncodedPostings { bytes })
762}
763
764/// A validated view over an encoded posting list.
765#[derive(Clone, Debug)]
766pub struct PostingsReader<'bytes> {
767    version: u16,
768    blocks: Vec<BlockMeta>,
769    docids: &'bytes [u8],
770    tfs: &'bytes [u8],
771    positions: &'bytes [u8],
772    posting_count: u32,
773    postings_per_block: u16,
774}
775
776#[cfg(test)]
777std::thread_local! {
778    static ENCODE_POSITION_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
779    static ENCODE_BITS_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
780    static ENCODE_PACK_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
781    static ENCODE_IMPACT_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
782    pub(super) static POSITION_OPEN_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
783    pub(super) static POSITION_MEMBER_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
784    pub(crate) static POSITION_DECODE_PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
785}
786
787impl<'bytes> PostingsReader<'bytes> {
788    /// Locates only one row's position deltas. Other rows' positions are not
789    /// decoded; their frequencies establish the offset within this block.
790    ///
791    /// # Errors
792    /// Returns a typed posting error for malformed frequencies or geometry.
793    pub fn positions(&self, row: u32) -> Result<Option<RowPositions<'bytes>>, PostingsError> {
794        self.positions_controlled(
795            row,
796            &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
797        )
798    }
799
800    pub(crate) fn positions_controlled<E: From<PostingsError>>(
801        &self,
802        row: u32,
803        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
804    ) -> Result<Option<RowPositions<'bytes>>, E> {
805        work.check_now()?;
806        let block = self.blocks.partition_point(|meta| meta.last_docid < row);
807        let Some(meta) = self.blocks.get(block) else {
808            return Ok(None);
809        };
810        let docids =
811            self.docids
812                .get(meta.docids_offset as usize..)
813                .ok_or(PostingsError::Truncated {
814                    needed: meta.docids_offset as usize,
815                    available: self.docids.len(),
816                })?;
817        let tfs = self
818            .tfs
819            .get(meta.tfs_offset as usize..)
820            .ok_or(PostingsError::Truncated {
821                needed: meta.tfs_offset as usize,
822                available: self.tfs.len(),
823            })?;
824        let mut docid = block
825            .checked_sub(1)
826            .and_then(|previous| self.blocks.get(previous))
827            .map_or(0, |meta| meta.last_docid);
828        let mut first = 0_usize;
829        for slot in 0..usize::from(meta.count) {
830            work.step()?;
831            #[cfg(test)]
832            POSITION_MEMBER_PROBES.with(|value| value.set(value.get() + 1));
833            let delta = packed_value_at(docids, meta.docid_bits, slot)?;
834            docid = docid
835                .checked_add(delta)
836                .ok_or(PostingsError::DocidsNotAscending { docid })?;
837            if docid > row {
838                return Ok(None);
839            }
840            let count = packed_value_at(tfs, meta.tf_bits, slot)? as usize;
841            if count == 0 {
842                return Err(PostingsError::ZeroTermFrequency.into());
843            }
844            let end = first.checked_add(count).ok_or(PostingsError::Truncated {
845                needed: usize::MAX,
846                available: meta.positions_count as usize,
847            })?;
848            if end > meta.positions_count as usize {
849                return Err(PostingsError::Truncated {
850                    needed: end,
851                    available: meta.positions_count as usize,
852                }
853                .into());
854            }
855            if docid == row {
856                let start = meta.positions_offset as usize;
857                let end = start
858                    .checked_add(packed_len(
859                        meta.positions_count as usize,
860                        meta.position_bits,
861                    ))
862                    .ok_or(PostingsError::Truncated {
863                        needed: usize::MAX,
864                        available: self.positions.len(),
865                    })?;
866                let bytes = self
867                    .positions
868                    .get(start..end)
869                    .ok_or(PostingsError::Truncated {
870                        needed: end,
871                        available: self.positions.len(),
872                    })?;
873                return Ok(Some(RowPositions {
874                    bytes,
875                    bits: meta.position_bits,
876                    first,
877                    count,
878                }));
879            }
880            first = end;
881        }
882        Ok(None)
883    }
884
885    /// Validates and opens an encoded posting list.
886    ///
887    /// Every structural claim the bytes make is checked here, so the
888    /// iteration methods below cannot fail on a reader that exists. Corrupt
889    /// bytes produce a typed error and never a panic.
890    ///
891    /// # Errors
892    ///
893    /// Returns [`PostingsError`] for bad magic, an unreadable version, a
894    /// zero block size, an oversized bit width, an inconsistent posting
895    /// count, or truncation anywhere.
896    pub fn open(bytes: &'bytes [u8]) -> Result<Self, PostingsError> {
897        Self::open_controlled(
898            bytes,
899            &mut super::control::WorkCheck::new(|| Ok::<(), PostingsError>(())),
900        )
901    }
902
903    pub(crate) fn open_controlled<E: From<PostingsError>>(
904        bytes: &'bytes [u8],
905        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
906    ) -> Result<Self, E> {
907        work.check_now()?;
908        let header = bytes.get(..HEADER_LEN).ok_or(PostingsError::Truncated {
909            needed: HEADER_LEN,
910            available: bytes.len(),
911        })?;
912        if header.get(..4) != Some(&POSTINGS_MAGIC[..]) {
913            return Err(PostingsError::BadMagic.into());
914        }
915        let version = read_u16(header, 4)?;
916        if version != POSTINGS_VERSION_V1 && version != POSTINGS_VERSION {
917            return Err(PostingsError::UnsupportedVersion { found: version }.into());
918        }
919        let postings_per_block = read_u16(header, 6)?;
920        if postings_per_block == 0 {
921            return Err(PostingsError::ZeroBlockSize.into());
922        }
923        let posting_count = read_u32(header, 8)?;
924        let block_count = read_u32(header, 12)?;
925
926        let block_count_usize = usize::try_from(block_count).unwrap_or(usize::MAX);
927        let metadata_len =
928            block_count_usize
929                .checked_mul(BLOCK_META_LEN)
930                .ok_or(PostingsError::Truncated {
931                    needed: usize::MAX,
932                    available: bytes.len(),
933                })?;
934        let metadata_end =
935            HEADER_LEN
936                .checked_add(metadata_len)
937                .ok_or(PostingsError::Truncated {
938                    needed: usize::MAX,
939                    available: bytes.len(),
940                })?;
941        let metadata_bytes =
942            bytes
943                .get(HEADER_LEN..metadata_end)
944                .ok_or(PostingsError::Truncated {
945                    needed: metadata_end,
946                    available: bytes.len(),
947                })?;
948
949        let mut blocks = Vec::with_capacity(block_count_usize);
950        let mut described = 0_u32;
951        let mut docid_bytes = 0_usize;
952        let mut tf_bytes = 0_usize;
953        let mut position_bytes = 0_usize;
954        for index in 0..block_count_usize {
955            work.step()?;
956            #[cfg(test)]
957            POSITION_OPEN_PROBES.with(|value| value.set(value.get() + 1));
958            let start = index.saturating_mul(BLOCK_META_LEN);
959            let row = metadata_bytes.get(start..start + BLOCK_META_LEN).ok_or(
960                PostingsError::Truncated {
961                    needed: start + BLOCK_META_LEN,
962                    available: metadata_bytes.len(),
963                },
964            )?;
965            let meta = BlockMeta::read(row)?;
966            for bits in [meta.docid_bits, meta.tf_bits, meta.position_bits] {
967                if bits > 32 {
968                    return Err(PostingsError::BitWidthTooLarge { bits }.into());
969                }
970            }
971            if meta.count == 0 || meta.count > postings_per_block {
972                return Err(PostingsError::InconsistentPostingCount {
973                    declared: posting_count,
974                    described: described.saturating_add(u32::from(meta.count)),
975                }
976                .into());
977            }
978            described = described.saturating_add(u32::from(meta.count));
979            let count = usize::from(meta.count);
980            docid_bytes = docid_bytes.max(
981                usize::try_from(meta.docids_offset).unwrap_or(usize::MAX)
982                    + packed_len(count, meta.docid_bits),
983            );
984            tf_bytes = tf_bytes.max(
985                usize::try_from(meta.tfs_offset).unwrap_or(usize::MAX)
986                    + packed_len(count, meta.tf_bits),
987            );
988            position_bytes = position_bytes.max(
989                usize::try_from(meta.positions_offset).unwrap_or(usize::MAX)
990                    + packed_len(
991                        usize::try_from(meta.positions_count).unwrap_or(usize::MAX),
992                        meta.position_bits,
993                    ),
994            );
995            blocks.push(meta);
996        }
997        if described != posting_count {
998            return Err(PostingsError::InconsistentPostingCount {
999                declared: posting_count,
1000                described,
1001            }
1002            .into());
1003        }
1004
1005        let streams = bytes.get(metadata_end..).ok_or(PostingsError::Truncated {
1006            needed: metadata_end,
1007            available: bytes.len(),
1008        })?;
1009        let tf_start = docid_bytes;
1010        let position_start = tf_start
1011            .checked_add(tf_bytes)
1012            .ok_or(PostingsError::Truncated {
1013                needed: usize::MAX,
1014                available: streams.len(),
1015            })?;
1016        let position_end =
1017            position_start
1018                .checked_add(position_bytes)
1019                .ok_or(PostingsError::Truncated {
1020                    needed: usize::MAX,
1021                    available: streams.len(),
1022                })?;
1023        if streams.len() < position_end {
1024            return Err(PostingsError::Truncated {
1025                needed: position_end,
1026                available: streams.len(),
1027            }
1028            .into());
1029        }
1030        let docids = streams.get(..tf_start).ok_or(PostingsError::Truncated {
1031            needed: tf_start,
1032            available: streams.len(),
1033        })?;
1034        let tfs = streams
1035            .get(tf_start..position_start)
1036            .ok_or(PostingsError::Truncated {
1037                needed: position_start,
1038                available: streams.len(),
1039            })?;
1040        let positions =
1041            streams
1042                .get(position_start..position_end)
1043                .ok_or(PostingsError::Truncated {
1044                    needed: position_end,
1045                    available: streams.len(),
1046                })?;
1047
1048        work.check_now()?;
1049        Ok(Self {
1050            version,
1051            blocks,
1052            docids,
1053            tfs,
1054            positions,
1055            posting_count,
1056            postings_per_block,
1057        })
1058    }
1059
1060    /// Returns the block metadata rows.
1061    #[must_use]
1062    pub fn blocks(&self) -> &[BlockMeta] {
1063        &self.blocks
1064    }
1065
1066    /// Returns the format version the stream declared.
1067    ///
1068    /// A caller that needs impact pairs checks this rather than inferring
1069    /// their presence from a zero pair, so a v1 stream is refused loudly
1070    /// instead of silently bounding nothing.
1071    #[must_use]
1072    pub const fn version(&self) -> u16 {
1073        self.version
1074    }
1075
1076    /// Returns the postings-per-block geometry recorded in the header.
1077    #[must_use]
1078    pub const fn postings_per_block(&self) -> u16 {
1079        self.postings_per_block
1080    }
1081
1082    /// Returns the total posting count.
1083    #[must_use]
1084    pub const fn document_frequency(&self) -> u32 {
1085        self.posting_count
1086    }
1087
1088    /// Decodes one block into document ids, frequencies, and positions.
1089    ///
1090    /// # Errors
1091    ///
1092    /// Returns [`PostingsError::Truncated`] when a stream is short. Bounds
1093    /// were validated at [`PostingsReader::open`], so a well-formed reader
1094    /// decodes every block successfully.
1095    pub fn decode_block(&self, index: usize) -> Result<Vec<Posting>, PostingsError> {
1096        let Some(meta) = self.blocks.get(index) else {
1097            return Ok(Vec::new());
1098        };
1099        let count = usize::from(meta.count);
1100        let mut deltas = vec![0_u32; count];
1101        let docid_start = usize::try_from(meta.docids_offset).unwrap_or(usize::MAX);
1102        let docid_slice = self
1103            .docids
1104            .get(docid_start..)
1105            .ok_or(PostingsError::Truncated {
1106                needed: docid_start,
1107                available: self.docids.len(),
1108            })?;
1109        unpack_bits(docid_slice, meta.docid_bits, count, &mut deltas)?;
1110
1111        let mut frequencies = vec![0_u32; count];
1112        let tf_start = usize::try_from(meta.tfs_offset).unwrap_or(usize::MAX);
1113        let tf_slice = self.tfs.get(tf_start..).ok_or(PostingsError::Truncated {
1114            needed: tf_start,
1115            available: self.tfs.len(),
1116        })?;
1117        unpack_bits(tf_slice, meta.tf_bits, count, &mut frequencies)?;
1118
1119        let position_count = usize::try_from(meta.positions_count).unwrap_or(usize::MAX);
1120        let mut position_deltas = vec![0_u32; position_count];
1121        let position_start = usize::try_from(meta.positions_offset).unwrap_or(usize::MAX);
1122        let position_slice =
1123            self.positions
1124                .get(position_start..)
1125                .ok_or(PostingsError::Truncated {
1126                    needed: position_start,
1127                    available: self.positions.len(),
1128                })?;
1129        unpack_bits(
1130            position_slice,
1131            meta.position_bits,
1132            position_count,
1133            &mut position_deltas,
1134        )?;
1135
1136        // Reconstruct absolute ids by prefix sum over the block.
1137        let mut base = if index == 0 {
1138            0
1139        } else {
1140            self.blocks
1141                .get(index.saturating_sub(1))
1142                .map_or(0, |previous| previous.last_docid)
1143        };
1144        let mut postings = Vec::with_capacity(count);
1145        let mut position_cursor = 0_usize;
1146        for slot in 0..count {
1147            let delta = deltas.get(slot).copied().unwrap_or(0);
1148            let docid = if index == 0 && slot == 0 {
1149                delta
1150            } else {
1151                base.saturating_add(delta)
1152            };
1153            base = docid;
1154            let tf = frequencies.get(slot).copied().unwrap_or(0);
1155            if tf == 0 {
1156                return Err(PostingsError::ZeroTermFrequency);
1157            }
1158            let tf_usize = usize::try_from(tf).unwrap_or(usize::MAX);
1159            let mut positions = Vec::with_capacity(tf_usize.min(position_count));
1160            let mut running = 0_u32;
1161            for step in 0..tf_usize {
1162                let Some(gap) = position_deltas.get(position_cursor + step).copied() else {
1163                    return Err(PostingsError::Truncated {
1164                        needed: position_cursor + step + 1,
1165                        available: position_deltas.len(),
1166                    });
1167                };
1168                running = if step == 0 {
1169                    gap
1170                } else {
1171                    running.saturating_add(gap)
1172                };
1173                positions.push(running);
1174            }
1175            position_cursor = position_cursor.saturating_add(tf_usize);
1176            postings.push(Posting {
1177                docid,
1178                tf,
1179                positions,
1180            });
1181        }
1182        Ok(postings)
1183    }
1184
1185    /// Decodes the whole list.
1186    ///
1187    /// # Errors
1188    ///
1189    /// Propagates any [`PostingsError`] from block decoding.
1190    pub fn decode_all(&self) -> Result<PostingList, PostingsError> {
1191        let mut list = PostingList::new();
1192        for index in 0..self.blocks.len() {
1193            for posting in self.decode_block(index)? {
1194                list.push(posting)?;
1195            }
1196        }
1197        Ok(list)
1198    }
1199}
1200
1201/// Borrowed packed positions for one requested posting. Locating this view
1202/// does not decode position values or allocate a position array.
1203#[derive(Clone, Copy, Debug)]
1204pub struct RowPositions<'bytes> {
1205    bytes: &'bytes [u8],
1206    bits: u8,
1207    first: usize,
1208    count: usize,
1209}
1210
1211impl RowPositions<'_> {
1212    /// Number of positions of the requested term in this row.
1213    #[must_use]
1214    pub const fn len(&self) -> usize {
1215        self.count
1216    }
1217
1218    /// Whether this view contains no positions.
1219    #[must_use]
1220    pub const fn is_empty(&self) -> bool {
1221        self.count == 0
1222    }
1223
1224    /// Decodes only the requested row, validating strict position ordering.
1225    ///
1226    /// # Errors
1227    /// Returns a typed posting error for truncation, invalid widths or order.
1228    pub fn decode(&self) -> Result<Vec<u32>, PostingsError> {
1229        self.decode_controlled(&mut super::control::WorkCheck::new(|| {
1230            Ok::<(), PostingsError>(())
1231        }))
1232    }
1233
1234    pub(crate) fn decode_controlled<E: From<PostingsError>>(
1235        &self,
1236        work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1237    ) -> Result<Vec<u32>, E> {
1238        work.check_now()?;
1239        let mut positions = Vec::with_capacity(self.count);
1240        let mut position = 0_u32;
1241        let result = (|| {
1242            for offset in 0..self.count {
1243                work.step()?;
1244                #[cfg(test)]
1245                POSITION_DECODE_PROBES.with(|value| value.set(value.get() + 1));
1246                let delta = packed_value_at(self.bytes, self.bits, self.first + offset)?;
1247                if offset > 0 && delta == 0 {
1248                    return Err(PostingsError::PositionsNotAscending { position }.into());
1249                }
1250                position = position
1251                    .checked_add(delta)
1252                    .ok_or(PostingsError::PositionsNotAscending { position })?;
1253                positions.push(position);
1254            }
1255            work.check_now()
1256        })();
1257        // Record completed decode work even when the next cooperative check
1258        // aborts. Partial scratch is never returned to the caller.
1259        #[cfg(any(test, feature = "test-seams"))]
1260        if !positions.is_empty() {
1261            let first_bit = self.first * usize::from(self.bits);
1262            let last_bit = (self.first + positions.len()) * usize::from(self.bits);
1263            super::preparation_observer::phrase_positions(
1264                positions.len(),
1265                last_bit.div_ceil(8) - first_bit / 8,
1266            );
1267        }
1268        result?;
1269        Ok(positions)
1270    }
1271}
1272
1273/// A bit-addressed read can start mid-byte, including 32-bit values spanning
1274/// five bytes. It never expands preceding position values to find this one.
1275fn packed_value_at(bytes: &[u8], bits: u8, slot: usize) -> Result<u32, PostingsError> {
1276    if bits > 32 {
1277        return Err(PostingsError::BitWidthTooLarge { bits });
1278    }
1279    if bits == 0 {
1280        return Ok(0);
1281    }
1282    let first = slot
1283        .checked_mul(usize::from(bits))
1284        .ok_or(PostingsError::Truncated {
1285            needed: usize::MAX,
1286            available: bytes.len(),
1287        })?;
1288    let end = first
1289        .checked_add(usize::from(bits))
1290        .ok_or(PostingsError::Truncated {
1291            needed: usize::MAX,
1292            available: bytes.len(),
1293        })?
1294        .div_ceil(8);
1295    let selected = bytes.get(first / 8..end).ok_or(PostingsError::Truncated {
1296        needed: end,
1297        available: bytes.len(),
1298    })?;
1299    let word = selected
1300        .iter()
1301        .enumerate()
1302        .fold(0_u64, |word, (offset, byte)| {
1303            word | (u64::from(*byte) << (offset * 8))
1304        });
1305    let mask = (1_u64 << bits) - 1;
1306    Ok(((word >> (first % 8)) & mask) as u32)
1307}
1308
1309#[cfg(test)]
1310#[allow(clippy::expect_used)]
1311mod astra_18_tests {
1312    use super::*;
1313    use crate::fts::control::WorkCheck;
1314    use std::cell::Cell;
1315
1316    #[derive(Debug, PartialEq)]
1317    enum TestError {
1318        Cancelled,
1319        Postings(PostingsError),
1320    }
1321
1322    impl From<PostingsError> for TestError {
1323        fn from(error: PostingsError) -> Self {
1324            Self::Postings(error)
1325        }
1326    }
1327
1328    fn one_position_per_row() -> PostingList {
1329        let mut list = PostingList::new();
1330        for row in 0..4_096 {
1331            list.push(Posting {
1332                docid: row,
1333                tf: 1,
1334                positions: vec![row * 2],
1335            })
1336            .expect("literal posting");
1337        }
1338        list
1339    }
1340
1341    #[test]
1342    fn astra_18_position_metadata_cancels_inside_block_walk() {
1343        let list = one_position_per_row();
1344        let encoded = encode(&list, 1, &[]).expect("one row per block");
1345        POSITION_OPEN_PROBES.with(|value| value.set(0));
1346        let mut work = WorkCheck::new(|| {
1347            if POSITION_OPEN_PROBES.with(Cell::get) >= 64 {
1348                Err(TestError::Cancelled)
1349            } else {
1350                Ok(())
1351            }
1352        });
1353        let result = PostingsReader::open_controlled(encoded.as_bytes(), &mut work);
1354        let probes = POSITION_OPEN_PROBES.with(Cell::get);
1355        println!("metadata_probes={probes}");
1356        assert!(matches!(result, Err(TestError::Cancelled)));
1357        assert!(
1358            (64..=128).contains(&probes),
1359            "metadata walk must stop inside the list"
1360        );
1361        let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1362        let reader = PostingsReader::open_controlled(encoded.as_bytes(), &mut clean)
1363            .expect("fresh control opens complete metadata");
1364        assert_eq!(reader.blocks().len(), 4_096);
1365        assert_eq!(reader.document_frequency(), 4_096);
1366        assert_eq!(reader.decode_all().expect("complete literal output"), list);
1367        assert!(matches!(
1368            PostingsReader::open_controlled(&[], &mut clean),
1369            Err(TestError::Postings(PostingsError::Truncated { .. }))
1370        ));
1371    }
1372
1373    #[test]
1374    fn astra_18_position_membership_cancels_inside_one_block() {
1375        let list = one_position_per_row();
1376        let encoded = encode(&list, 4_096, &[]).expect("one large legal block");
1377        let reader = PostingsReader::open(encoded.as_bytes()).expect("reader");
1378        POSITION_MEMBER_PROBES.with(|value| value.set(0));
1379        let mut work = WorkCheck::new(|| {
1380            if POSITION_MEMBER_PROBES.with(Cell::get) >= 64 {
1381                Err(TestError::Cancelled)
1382            } else {
1383                Ok(())
1384            }
1385        });
1386        let result = reader.positions_controlled(4_095, &mut work);
1387        let probes = POSITION_MEMBER_PROBES.with(Cell::get);
1388        println!("membership_probes={probes}");
1389        assert!(matches!(result, Err(TestError::Cancelled)));
1390        assert!(
1391            (64..=128).contains(&probes),
1392            "membership must stop inside the block"
1393        );
1394        let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1395        assert_eq!(
1396            reader
1397                .positions_controlled(4_095, &mut clean)
1398                .expect("fresh control")
1399                .expect("last row")
1400                .decode()
1401                .expect("position"),
1402            vec![8_190]
1403        );
1404        assert!(
1405            reader
1406                .positions_controlled(4_096, &mut clean)
1407                .expect("absent row")
1408                .is_none()
1409        );
1410    }
1411
1412    #[test]
1413    fn astra_18_position_decode_cancels_inside_one_row() {
1414        let literal = (0..4_096).collect::<Vec<_>>();
1415        let mut list = PostingList::new();
1416        list.push(Posting {
1417            docid: 0,
1418            tf: 4_096,
1419            positions: literal.clone(),
1420        })
1421        .expect("one long row");
1422        let encoded = encode(&list, 64, &[]).expect("encoded positions");
1423        let reader = PostingsReader::open(encoded.as_bytes()).expect("reader");
1424        let view = reader.positions(0).expect("membership").expect("present");
1425        POSITION_DECODE_PROBES.with(|value| value.set(0));
1426        crate::fts::preparation_observer::begin();
1427        let mut work = WorkCheck::new(|| {
1428            if POSITION_DECODE_PROBES.with(Cell::get) >= 64 {
1429                Err(TestError::Cancelled)
1430            } else {
1431                Ok(())
1432            }
1433        });
1434        let result = view.decode_controlled(&mut work);
1435        let probes = POSITION_DECODE_PROBES.with(Cell::get);
1436        let observed = crate::fts::preparation_observer::phrase_position_work();
1437        crate::fts::preparation_observer::take();
1438        println!("decode_probes={probes}, position_work={observed:?}");
1439        assert_eq!(result, Err(TestError::Cancelled));
1440        assert!(
1441            (64..=128).contains(&probes),
1442            "decode must stop inside the row"
1443        );
1444        assert_eq!(
1445            observed,
1446            (probes, probes.div_ceil(8)),
1447            "retain actual partial decode work"
1448        );
1449        let mut clean = WorkCheck::new(|| Ok::<(), TestError>(()));
1450        assert_eq!(
1451            view.decode_controlled(&mut clean).expect("fresh control"),
1452            literal
1453        );
1454    }
1455}
1456
1457#[cfg(test)]
1458#[allow(clippy::expect_used, clippy::indexing_slicing)]
1459mod astra_11_tests {
1460    use super::*;
1461
1462    #[test]
1463    fn astra_11_position_reader_only_decodes_requested_row() {
1464        for per_block in [1, 2, 64] {
1465            let mut list = PostingList::new();
1466            let expected = (0..130)
1467                .map(|row| {
1468                    if row % 3 == 0 {
1469                        vec![0, 1, 31, u32::MAX]
1470                    } else {
1471                        vec![row * 3]
1472                    }
1473                })
1474                .collect::<Vec<_>>();
1475            for (row, positions) in expected.iter().enumerate() {
1476                list.push(Posting {
1477                    docid: row as u32 * 2,
1478                    tf: positions.len() as u32,
1479                    positions: positions.clone(),
1480                })
1481                .expect("posting");
1482            }
1483            let encoded = encode(&list, per_block, &[]).expect("encoded positions");
1484            let reader = PostingsReader::open(encoded.as_bytes()).expect("persisted reader");
1485            for row in [0, 1, 63, 64, 65, 129] {
1486                crate::fts::preparation_observer::begin();
1487                let view = reader
1488                    .positions(row as u32 * 2)
1489                    .expect("locate")
1490                    .expect("present");
1491                assert_eq!(
1492                    crate::fts::preparation_observer::phrase_position_work(),
1493                    (0, 0)
1494                );
1495                assert_eq!(view.decode().expect("selective decode"), expected[row]);
1496                assert_eq!(
1497                    crate::fts::preparation_observer::phrase_position_work().0,
1498                    expected[row].len()
1499                );
1500                crate::fts::preparation_observer::take();
1501                assert!(
1502                    reader
1503                        .positions(row as u32 * 2 + 1)
1504                        .expect("absent")
1505                        .is_none()
1506                );
1507            }
1508        }
1509    }
1510
1511    #[test]
1512    fn astra_11_corrupt_position_payload_is_typed() {
1513        let mut list = PostingList::new();
1514        for (docid, positions) in [(0, vec![1, 3]), (1, vec![2, 5])] {
1515            list.push(Posting {
1516                docid,
1517                tf: 2,
1518                positions,
1519            })
1520            .expect("posting");
1521        }
1522        let encoded = encode(&list, 64, &[]).expect("encoded");
1523        let clean = PostingsReader::open(encoded.as_bytes()).expect("open");
1524        assert_eq!(
1525            clean
1526                .positions(0)
1527                .expect("locate")
1528                .expect("present")
1529                .decode()
1530                .expect("clean"),
1531            vec![1, 3]
1532        );
1533        let positions_start = HEADER_LEN + BLOCK_META_LEN + clean.docids.len() + clean.tfs.len();
1534        assert_eq!(clean.blocks()[0].position_bits, 2);
1535        let mut corrupt = encoded.as_bytes().to_vec();
1536        // Delta two becomes zero in the actual packed payload, while all
1537        // structural lengths and row/frequency data remain intact.
1538        corrupt[positions_start] &= !(3 << 2);
1539        let reader = PostingsReader::open(&corrupt).expect("geometry unchanged");
1540        assert_eq!(
1541            reader
1542                .positions(0)
1543                .expect("locate corrupt row")
1544                .expect("present")
1545                .decode(),
1546            Err(PostingsError::PositionsNotAscending { position: 1 })
1547        );
1548        assert_eq!(
1549            reader
1550                .positions(1)
1551                .expect("next row")
1552                .expect("present")
1553                .decode()
1554                .expect("unaffected row"),
1555            vec![2, 5]
1556        );
1557    }
1558}
1559
1560/// One block's impact pair: the two extremes that bound every posting in it.
1561///
1562/// # Why a pair and not a stored score
1563///
1564/// A stored score is a number computed under one set of statistics. BM25's
1565/// `score / ceiling` is `tf / (tf + k1 * (1 - b + b * len / avgdl))`, so
1566/// `idf` cancels but `avgdl` and `(k1, b)` do not: a bound sealed when
1567/// documents were short falls below a true score once `avgdl` rises, and a
1568/// bound sealed under `beir()` falls below one read under `anserini()`.
1569/// Measured shortfalls were 15.21% and 19.56%.
1570///
1571/// A pair carries no statistics at all. `term_score` is monotone increasing
1572/// in `tf` and monotone decreasing in `len`, so evaluating it at the block's
1573/// largest `tf` and shortest document dominates every posting in the block
1574/// under ANY statistics and ANY `(k1, b)`. The two extremes need not come
1575/// from the same document; pairing them is looser than the true maximum and
1576/// is therefore still an upper bound.
1577#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1578pub struct BlockImpact {
1579    /// Largest term frequency in the block.
1580    pub max_tf: u32,
1581    /// Smallest document length in the block, saturating DOWN.
1582    pub min_len: u16,
1583}
1584
1585impl BlockImpact {
1586    /// Reads the pair out of a persisted metadata row.
1587    #[must_use]
1588    pub const fn from_meta(meta: &BlockMeta) -> Self {
1589        Self {
1590            max_tf: meta.max_tf,
1591            min_len: meta.min_len,
1592        }
1593    }
1594
1595    /// Returns true when the pair bounds nothing.
1596    ///
1597    /// A real block has at least one posting, whose term frequency is at
1598    /// least one, so a zero `max_tf` can only mean the pair was never
1599    /// written — a version 1 stream, or a v2 stream missing an entry.
1600    #[must_use]
1601    pub const fn is_absent(&self) -> bool {
1602        self.max_tf == 0
1603    }
1604
1605    /// Evaluates the bound under the caller's live scoring constants.
1606    ///
1607    /// This is the whole remedy: the bound is computed HERE, at query time,
1608    /// from statistics that are current and parameters the caller chose.
1609    #[must_use]
1610    pub fn bound(&self, scorer: &super::bm25::TermScorer) -> f64 {
1611        scorer.score(Tf(self.max_tf), DocLen(u32::from(self.min_len)))
1612    }
1613}
1614
1615/// Computes the per-block impact pairs for one term's postings.
1616///
1617/// `lengths` is the dense per-row length array; a row beyond it contributes
1618/// the shortest possible length, which keeps the bound above rather than
1619/// below the truth.
1620#[must_use]
1621pub fn block_impacts(
1622    list: &PostingList,
1623    postings_per_block: u16,
1624    lengths: &[u32],
1625) -> Vec<BlockImpact> {
1626    let mut work = super::control::WorkCheck::new(|| Ok::<(), std::convert::Infallible>(()));
1627    match block_impacts_controlled(list, postings_per_block, lengths, &mut work) {
1628        Ok(impacts) => impacts,
1629        Err(never) => match never {},
1630    }
1631}
1632
1633pub(crate) fn block_impacts_controlled<E>(
1634    list: &PostingList,
1635    postings_per_block: u16,
1636    lengths: &[u32],
1637    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1638) -> Result<Vec<BlockImpact>, E> {
1639    block_impacts_slice_controlled(list.postings(), postings_per_block, lengths, work)
1640}
1641
1642pub(crate) fn block_impacts_slice_controlled<E>(
1643    postings: &[Posting],
1644    postings_per_block: u16,
1645    lengths: &[u32],
1646    work: &mut super::control::WorkCheck<impl FnMut() -> Result<(), E>>,
1647) -> Result<Vec<BlockImpact>, E> {
1648    work.check_now()?;
1649    if postings_per_block == 0 {
1650        return Ok(Vec::new());
1651    }
1652    let chunks = postings.chunks(usize::from(postings_per_block));
1653    let mut impacts = Vec::with_capacity(chunks.len());
1654    for chunk in chunks {
1655        let mut max_tf = 0_u32;
1656        let mut min_len = u32::MAX;
1657        for posting in chunk {
1658            work.step()?;
1659            #[cfg(test)]
1660            ENCODE_IMPACT_PROBES.with(|value| value.set(value.get() + 1));
1661            max_tf = max_tf.max(posting.tf);
1662            let length = usize::try_from(posting.docid)
1663                .ok()
1664                .and_then(|slot| lengths.get(slot).copied())
1665                .unwrap_or(1);
1666            min_len = min_len.min(length);
1667        }
1668        impacts.push(BlockImpact {
1669            max_tf,
1670            // Saturate DOWN: a shorter document scores higher, keeping
1671            // this bound at or above the true score.
1672            min_len: u16::try_from(min_len).unwrap_or(u16::MAX),
1673        });
1674    }
1675    work.check_now()?;
1676    Ok(impacts)
1677}
1678
1679#[cfg(test)]
1680#[allow(
1681    clippy::expect_used,
1682    clippy::indexing_slicing,
1683    clippy::panic,
1684    clippy::unwrap_used
1685)]
1686mod tests {
1687    use super::*;
1688
1689    #[test]
1690    fn astra_18_postings_encoding_cancels_inside_positions() {
1691        let positions = (0..4_096).collect::<Vec<_>>();
1692        let list = list_from(&[(0, &positions)]);
1693        let impacts = block_impacts(&list, 64, &[4_096]);
1694        let reference = encode_v2(&list, 64, &[], &impacts).expect("reference");
1695        let mut observed = Vec::new();
1696        for (phase, probes) in [
1697            ("position gaps", &ENCODE_POSITION_PROBES),
1698            ("bit widths", &ENCODE_BITS_PROBES),
1699            ("bit packing", &ENCODE_PACK_PROBES),
1700        ] {
1701            probes.with(|value| value.set(0));
1702            let mut work = super::super::control::WorkCheck::new(|| {
1703                if probes.with(std::cell::Cell::get) >= 64 {
1704                    Err(PostingsError::ZeroBlockSize)
1705                } else {
1706                    Ok(())
1707                }
1708            });
1709            let result = encode_v2_controlled(&list, 64, &[], &impacts, &mut work);
1710            let count = probes.with(std::cell::Cell::get);
1711            println!("{phase}: canceled={}, probes={count}", result.is_err());
1712            observed.push((
1713                phase,
1714                matches!(result, Err(PostingsError::ZeroBlockSize)),
1715                count,
1716            ));
1717        }
1718        let mut work = super::super::control::WorkCheck::new(|| Ok::<(), PostingsError>(()));
1719        let clean = encode_v2_controlled(&list, 64, &[], &impacts, &mut work).expect("clean");
1720        assert_eq!(clean.as_bytes(), reference.as_bytes());
1721        assert_eq!(
1722            PostingsReader::open(clean.as_bytes())
1723                .expect("reader")
1724                .decode_all()
1725                .expect("decoded"),
1726            list
1727        );
1728        for (phase, canceled, count) in observed {
1729            assert!(canceled, "{phase}");
1730            assert!((64..=128).contains(&count), "{phase}: {count}");
1731        }
1732    }
1733
1734    #[test]
1735    fn astra_18_block_impacts_cancel_inside_rows() {
1736        let mut list = PostingList::new();
1737        for docid in 0..4_096 {
1738            list.push(Posting {
1739                docid,
1740                tf: 1,
1741                positions: vec![0],
1742            })
1743            .expect("posting");
1744        }
1745        let lengths = vec![7; 4_096];
1746        ENCODE_IMPACT_PROBES.with(|value| value.set(0));
1747        let mut work = super::super::control::WorkCheck::new(|| {
1748            if ENCODE_IMPACT_PROBES.with(std::cell::Cell::get) >= 64 {
1749                Err("cancelled")
1750            } else {
1751                Ok(())
1752            }
1753        });
1754        let result = block_impacts_controlled(&list, 64, &lengths, &mut work);
1755        let count = ENCODE_IMPACT_PROBES.with(std::cell::Cell::get);
1756        println!("impact rows={count}");
1757        assert_eq!(result, Err("cancelled"));
1758        assert!((64..=128).contains(&count));
1759        let mut work = super::super::control::WorkCheck::new(|| Ok::<(), ()>(()));
1760        assert_eq!(
1761            block_impacts_controlled(&list, 64, &lengths, &mut work),
1762            Ok(vec![
1763                BlockImpact {
1764                    max_tf: 1,
1765                    min_len: 7
1766                };
1767                64
1768            ])
1769        );
1770    }
1771
1772    fn list_from(entries: &[(u32, &[u32])]) -> PostingList {
1773        let mut list = PostingList::new();
1774        for (docid, positions) in entries {
1775            list.push(Posting {
1776                docid: *docid,
1777                tf: u32::try_from(positions.len()).expect("small"),
1778                positions: positions.to_vec(),
1779            })
1780            .expect("ascending fixture");
1781        }
1782        list
1783    }
1784
1785    #[test]
1786    fn a_single_block_round_trips() {
1787        let list = list_from(&[(0, &[0, 5]), (3, &[2]), (9, &[1, 4, 7])]);
1788        let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1789        let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1790        assert_eq!(reader.document_frequency(), 3);
1791        assert_eq!(reader.postings_per_block(), DEFAULT_POSTINGS_PER_BLOCK);
1792        assert_eq!(reader.decode_all().expect("decodes"), list);
1793    }
1794
1795    #[test]
1796    fn many_blocks_round_trip_across_the_geometry_boundary() {
1797        for count in [1_usize, 63, 64, 65, 128, 200] {
1798            let mut list = PostingList::new();
1799            for index in 0..count {
1800                let docid = u32::try_from(index * 3).expect("small");
1801                list.push(Posting {
1802                    docid,
1803                    tf: 2,
1804                    positions: vec![index as u32, index as u32 + 7],
1805                })
1806                .expect("ascending");
1807            }
1808            let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1809            let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1810            assert_eq!(
1811                reader.blocks().len(),
1812                count.div_ceil(usize::from(DEFAULT_POSTINGS_PER_BLOCK)),
1813                "block count for {count} postings"
1814            );
1815            assert_eq!(reader.decode_all().expect("decodes"), list, "count {count}");
1816        }
1817    }
1818
1819    #[test]
1820    fn the_block_metadata_row_is_exactly_one_quarter_of_a_cache_line() {
1821        let mut bytes = Vec::new();
1822        BlockMeta::default().write(&mut bytes);
1823        assert_eq!(bytes.len(), BLOCK_META_LEN);
1824        assert_eq!(BLOCK_META_LEN * 4, 128);
1825    }
1826
1827    #[test]
1828    fn an_empty_list_encodes_and_decodes() {
1829        let list = PostingList::new();
1830        let encoded = encode(&list, DEFAULT_POSTINGS_PER_BLOCK, &[]).expect("encodes");
1831        let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1832        assert_eq!(reader.document_frequency(), 0);
1833        assert!(reader.decode_all().expect("decodes").is_empty());
1834    }
1835
1836    #[test]
1837    fn bit_widths_at_every_boundary_round_trip() {
1838        for bits in 0..=32_u8 {
1839            let value = if bits == 0 {
1840                0
1841            } else if bits == 32 {
1842                u32::MAX
1843            } else {
1844                (1_u32 << bits) - 1
1845            };
1846            let values = vec![value; 64];
1847            let mut packed = Vec::new();
1848            pack_bits(&values, bits, &mut packed);
1849            let mut output = vec![0_u32; 64];
1850            let consumed = unpack_bits(&packed, bits, 64, &mut output).expect("unpacks");
1851            assert_eq!(consumed, packed_len(64, bits), "width {bits}");
1852            assert_eq!(output, values, "width {bits}");
1853        }
1854    }
1855
1856    #[test]
1857    fn an_oversized_bit_width_is_a_typed_error() {
1858        let mut output = [0_u32; 4];
1859        assert_eq!(
1860            unpack_bits(&[0; 32], 33, 4, &mut output),
1861            Err(PostingsError::BitWidthTooLarge { bits: 33 })
1862        );
1863    }
1864
1865    #[test]
1866    fn a_truncated_packed_run_is_a_typed_error() {
1867        let mut output = [0_u32; 64];
1868        let error = unpack_bits(&[0; 2], 8, 64, &mut output).expect_err("must refuse");
1869        assert!(matches!(error, PostingsError::Truncated { .. }));
1870    }
1871
1872    #[test]
1873    fn out_of_order_docids_are_refused_rather_than_sorted() {
1874        let mut list = PostingList::new();
1875        list.push(Posting {
1876            docid: 5,
1877            tf: 1,
1878            positions: vec![0],
1879        })
1880        .expect("first");
1881        assert_eq!(
1882            list.push(Posting {
1883                docid: 5,
1884                tf: 1,
1885                positions: vec![0],
1886            }),
1887            Err(PostingsError::DocidsNotAscending { docid: 5 })
1888        );
1889    }
1890
1891    #[test]
1892    fn out_of_order_positions_are_refused() {
1893        let mut list = PostingList::new();
1894        assert_eq!(
1895            list.push(Posting {
1896                docid: 0,
1897                tf: 2,
1898                positions: vec![4, 4],
1899            }),
1900            Err(PostingsError::PositionsNotAscending { position: 4 })
1901        );
1902    }
1903
1904    #[test]
1905    fn a_zero_frequency_posting_is_refused() {
1906        let mut list = PostingList::new();
1907        assert_eq!(
1908            list.push(Posting {
1909                docid: 0,
1910                tf: 0,
1911                positions: vec![],
1912            }),
1913            Err(PostingsError::ZeroTermFrequency)
1914        );
1915    }
1916
1917    #[test]
1918    fn the_impact_pair_survives_the_persisted_round_trip() {
1919        // The pair is what makes a bound sound under drift, so it has to
1920        // come back out of the bytes exactly as it went in.
1921        let list = list_from(&[(0, &[0, 1, 2]), (5, &[0]), (9, &[0, 3])]);
1922        let lengths = vec![40_u32, 0, 0, 0, 0, 7, 0, 0, 0, 900];
1923        let impacts = block_impacts(&list, 2, &lengths);
1924        assert_eq!(impacts.len(), 2);
1925        assert_eq!(
1926            impacts[0],
1927            BlockImpact {
1928                max_tf: 3,
1929                min_len: 7
1930            }
1931        );
1932        assert_eq!(
1933            impacts[1],
1934            BlockImpact {
1935                max_tf: 2,
1936                min_len: 900
1937            }
1938        );
1939
1940        let encoded = encode_v2(&list, 2, &[], &impacts).expect("encodes");
1941        let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1942        assert_eq!(reader.version(), POSTINGS_VERSION);
1943        let read: Vec<BlockImpact> = reader.blocks().iter().map(BlockImpact::from_meta).collect();
1944        assert_eq!(read, impacts);
1945        assert_eq!(reader.decode_all().expect("decodes"), list);
1946    }
1947
1948    #[test]
1949    fn a_version_one_stream_reads_back_with_no_impact_pair() {
1950        // v1 is still readable, and its blocks report an ABSENT pair rather
1951        // than a zero one that a caller might mistake for a real bound.
1952        let list = list_from(&[(0, &[0]), (4, &[1])]);
1953        let encoded = encode(&list, 64, &[]).expect("encodes");
1954        let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1955        assert_eq!(reader.version(), POSTINGS_VERSION_V1);
1956        for meta in reader.blocks() {
1957            assert!(BlockImpact::from_meta(meta).is_absent());
1958        }
1959    }
1960
1961    #[test]
1962    fn an_empty_impact_slice_stores_an_absent_pair() {
1963        let list = list_from(&[(0, &[0])]);
1964        let encoded = encode_v2(&list, 64, &[], &[]).expect("encodes");
1965        let reader = PostingsReader::open(encoded.as_bytes()).expect("opens");
1966        assert_eq!(reader.version(), POSTINGS_VERSION);
1967        assert!(BlockImpact::from_meta(&reader.blocks()[0]).is_absent());
1968        assert!(block_impacts(&list, 0, &[]).is_empty());
1969    }
1970
1971    #[test]
1972    fn bad_magic_and_version_are_typed_errors() {
1973        let list = list_from(&[(0, &[0])]);
1974        let encoded = encode(&list, 64, &[]).expect("encodes");
1975        let mut bytes = encoded.as_bytes().to_vec();
1976        bytes[0] = b'X';
1977        assert_eq!(
1978            PostingsReader::open(&bytes).err(),
1979            Some(PostingsError::BadMagic)
1980        );
1981
1982        let mut bytes = encoded.as_bytes().to_vec();
1983        bytes[4] = 9;
1984        assert_eq!(
1985            PostingsReader::open(&bytes).err(),
1986            Some(PostingsError::UnsupportedVersion { found: 9 })
1987        );
1988    }
1989
1990    #[test]
1991    fn a_zero_block_size_header_is_refused() {
1992        let list = list_from(&[(0, &[0])]);
1993        let encoded = encode(&list, 64, &[]).expect("encodes");
1994        let mut bytes = encoded.as_bytes().to_vec();
1995        bytes[6] = 0;
1996        bytes[7] = 0;
1997        assert_eq!(
1998            PostingsReader::open(&bytes).err(),
1999            Some(PostingsError::ZeroBlockSize)
2000        );
2001        assert_eq!(
2002            encode(&list, 0, &[]).err(),
2003            Some(PostingsError::ZeroBlockSize)
2004        );
2005    }
2006
2007    #[test]
2008    fn every_truncation_of_a_valid_stream_is_a_typed_error_not_a_panic() {
2009        let list = list_from(&[(0, &[0, 3]), (7, &[1]), (100, &[2, 9, 40])]);
2010        let encoded = encode(&list, 2, &[]).expect("encodes");
2011        let bytes = encoded.as_bytes();
2012        for cut in 0..bytes.len() {
2013            let truncated = &bytes[..cut];
2014            // A prefix that still parses must decode without panic.
2015            if let Ok(reader) = PostingsReader::open(truncated) {
2016                for index in 0..reader.blocks().len() {
2017                    let _ = reader.decode_block(index);
2018                }
2019            }
2020        }
2021    }
2022}