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