Skip to main content

hermes_core/segment/reader/
bmp.rs

1//! BMP (Block-Max Pruning) index reader for sparse vectors — **V19 zero-copy**.
2//!
3//! V19 uses fixed `dims` (vocabulary size) and dim_id directly in per-block data.
4//! Grid is indexed by dim_id as row index (no Section C dim_ids array).
5//! Data-first layout: block data (Section B) appears before block_data_starts
6//! (Section A). The reader derives the Section A offset from
7//! `grid_offset - (num_blocks + 1) * 8`.
8//!
9//! Block-interleaved format: all data needed to score one block is contiguous
10//! (~200-2000 bytes, fits in 1-2 pages). Reduces cold-query page faults to 1.
11//!
12//! At load time the entire blob is acquired as a single `OwnedBytes` (mmap-backed
13//! or Arc-Vec) and sliced into sections. No heap allocation — all data including
14//! the superblock grid is mmap-backed.
15//!
16//! Uses **compact virtual coordinates**: sequential IDs assigned to unique
17//! `(doc_id, ordinal)` pairs. A doc_map lookup table maps virtual IDs back
18//! to original coordinates at query time.
19//!
20//! Based on Mallia, Suel & Tonellotto (SIGIR 2024).
21
22use crate::directories::{FileHandle, OwnedBytes};
23use crate::segment::bmp_adaptive::{AdaptiveBlock, AdaptivePostings};
24use crate::segment::bmp_grid::CompressedGrid;
25
26/// Number of BMP blocks grouped into one LSP/0 superblock.
27///
28/// Carlson et al. recommend `block_size × blocks_per_superblock <= 256`.
29/// Hermes keeps the requested 32-vector blocks, so eight blocks form one
30/// 256-vector superblock. Eight divides the 256-cell compressed-grid group,
31/// ensuring a selected superblock never crosses a codec group.
32pub const BMP_SUPERBLOCK_SIZE: u32 = 8;
33
34/// Number of LSP/0 superblocks summarized by one cell in the coarse grid.
35///
36/// This deliberately matches the compressed-grid addressing group. Expanding
37/// one promising coarse cell therefore reads one independently addressable
38/// 256-superblock group from E for each query dimension.
39pub const BMP_COARSE_SUPERBLOCKS: u32 = 256;
40
41// ── u32 read helpers ─────────────────────────────────────────────────────────
42
43/// Read a little-endian u32 from a raw pointer at element index.
44/// No bounds check — used in the hot scoring loop where bounds are
45/// validated once at the method boundary via debug_assert.
46///
47/// Uses `read_unaligned` for portability (handles any alignment).
48/// On x86/ARM this compiles to a single `ldr`/`mov` instruction.
49///
50/// # Safety
51/// Caller must ensure `base.add(idx * 4 + 3)` is within the allocation.
52#[inline(always)]
53unsafe fn read_u32_unchecked(base: *const u8, idx: usize) -> u32 {
54    unsafe {
55        let p = base.add(idx * 4);
56        u32::from_le((p as *const u32).read_unaligned())
57    }
58}
59
60/// Read a little-endian u64 from a raw pointer at element index.
61/// No bounds check — used in the hot scoring loop for block_data_starts.
62///
63/// # Safety
64/// Caller must ensure `base.add(idx * 8 + 7)` is within the allocation.
65#[inline(always)]
66unsafe fn read_u64_unchecked(base: *const u8, idx: usize) -> u64 {
67    unsafe {
68        let p = base.add(idx * 8);
69        u64::from_le((p as *const u64).read_unaligned())
70    }
71}
72
73/// Summary statistics for per-dimension postings in a BMP sparse index.
74#[derive(Debug, Clone)]
75pub struct BmpDimStats {
76    pub nonzero_dims: u32,
77    pub declared_dims: u32,
78    pub total_postings: u64,
79    pub p50_postings_per_dim: u64,
80    pub p99_postings_per_dim: u64,
81    pub max_postings_per_dim: u64,
82    /// Share of all postings held by the hottest 1% of dimensions.
83    pub top_1pct_share: f64,
84    /// Postings whose quantized impact is the u8 maximum (weight clipping).
85    pub saturated_impacts: u64,
86    pub top_dims: Vec<(u32, u64)>,
87}
88
89/// BMP V19 index for a single sparse field — fully zero-copy mmap-backed.
90///
91/// V19 format with Recursive Graph Bisection (BP) document ordering.
92///
93/// All data sections are `OwnedBytes` slices into the same underlying mmap Arc.
94/// No heap allocation — the superblock grid is persisted on disk and loaded as
95/// a zero-copy OwnedBytes slice.
96///
97/// Uses a three-level pruning hierarchy:
98/// 1. **Coarse grid**: upper bounds over groups of `BMP_COARSE_SUPERBLOCKS`
99///    superblocks, used to find the exact global top-gamma without sweeping E
100/// 2. **Superblock grid**: upper bounds over `BMP_SUPERBLOCK_SIZE` blocks
101/// 3. **Block grid**: fine-grained upper bounds per individual block
102
103#[derive(Clone)]
104pub struct BmpIndex {
105    /// BMP block size (number of consecutive virtual_ids per block)
106    pub bmp_block_size: u32,
107    /// Number of blocks
108    pub num_blocks: u32,
109    /// Number of compact virtual documents (= num_blocks × bmp_block_size, padded)
110    pub num_virtual_docs: u32,
111    /// Global max weight scale factor (for dequantizing u8 impacts back to f32)
112    pub max_weight_scale: f32,
113    /// Total sparse vectors (from TOC entry)
114    pub total_vectors: u32,
115    /// Number of documents in the containing segment. Document-map entries
116    /// must be either padding or strictly below this bound.
117    segment_num_docs: u32,
118
119    // ── Section metadata ──────────────────────────────────────────────
120    /// Fixed vocabulary size — grid has `dims` rows
121    dims: u32,
122    total_terms: u64,
123    total_postings: u64,
124    /// Bits per block-grid cell (4 or 2); dequant scale is 17 or 85.
125    grid_bits: u8,
126    /// Actual vector count before padding
127    num_real_docs: u32,
128    /// True when every stored vector is ordinal zero. This is derived from
129    /// the physical document map rather than trusting schema declarations.
130    single_valued: bool,
131
132    // ── Zero-copy OwnedBytes sections (keeps backing store alive) ────
133    /// Section A: block_data_starts[block_id] = byte offset into block_data_bytes
134    block_data_starts_bytes: OwnedBytes,
135    /// Section B: interleaved per-block data (all scoring data contiguous per block)
136    block_data_bytes: OwnedBytes,
137    /// Locally bit-packed block maxima. Stored values retain their configured
138    /// ceil-u4/u2 semantics exactly.
139    block_grid: CompressedGrid,
140    /// Locally bit-packed ceil-u4 superblock maxima.
141    superblock_grid: CompressedGrid,
142    /// Number of superblocks
143    pub num_superblocks: u32,
144    /// Locally bit-packed ceil-u4 maxima over 256-superblock groups.
145    coarse_grid: CompressedGrid,
146    /// Number of coarse superblock groups.
147    pub num_coarse_groups: u32,
148    /// doc_map_ids[virtual_id] = original doc_id — zero-copy OwnedBytes
149    doc_map_ids_bytes: OwnedBytes,
150    /// doc_map_ordinals[virtual_id] = original ordinal — zero-copy OwnedBytes
151    doc_map_ordinals_bytes: OwnedBytes,
152
153    // ── Raw blob source (identity copies) ─────────────────────────────
154    /// Source file handle + blob range, kept so reorder can copy the blob
155    /// byte-identically for fields whose `reorder` schema attribute is unset.
156    /// Reorder is native-only, so these are dead on wasm.
157    #[cfg_attr(not(feature = "native"), allow(dead_code))]
158    source: FileHandle,
159    #[cfg_attr(not(feature = "native"), allow(dead_code))]
160    blob_offset: u64,
161    #[cfg_attr(not(feature = "native"), allow(dead_code))]
162    blob_len: u64,
163    /// Offset of Section F within the blob. Retained so local block-copy
164    /// merges can pass byte-identical document-map ranges directly to
165    /// `copy_file_range` without faulting their mmap pages into userspace.
166    #[cfg_attr(not(feature = "native"), allow(dead_code))]
167    doc_map_offset: u64,
168}
169
170// SAFETY: All raw pointer access is derived from OwnedBytes which are Send+Sync
171// (backed by Arc<Vec<u8>> or Arc<Mmap>). The pointers are never mutated.
172// BmpIndex already stores OwnedBytes (which is Send+Sync), so the struct
173// inherits Send+Sync automatically through its fields.
174
175impl BmpIndex {
176    /// Parse a BMP V19 blob from the given file handle.
177    ///
178    /// Reads the footer, then acquires the entire blob as a single
179    /// `OwnedBytes` and slices it into zero-copy sections.
180    ///
181    /// V19 data-first layout: Section B (per-block interleaved data) first,
182    /// then Section A (block_data_starts with u64 entries), grids, doc_map.
183    pub fn parse(
184        handle: FileHandle,
185        blob_offset: u64,
186        blob_len: u64,
187        total_docs: u32,
188        total_vectors: u32,
189    ) -> crate::Result<Self> {
190        use crate::segment::format::{BMP_BLOB_FOOTER_SIZE, BMP_BLOB_MAGIC};
191
192        if blob_len < BMP_BLOB_FOOTER_SIZE as u64 {
193            return Err(crate::Error::Corruption(
194                "BMP blob too small for V19 footer".into(),
195            ));
196        }
197
198        // Read the footer.
199        let blob_end = blob_offset
200            .checked_add(blob_len)
201            .ok_or_else(|| crate::Error::Corruption("BMP blob range overflows u64".into()))?;
202        let footer_start = blob_end - BMP_BLOB_FOOTER_SIZE as u64;
203        let footer_bytes = handle
204            .read_bytes_range_sync(footer_start..blob_end)
205            .map_err(crate::Error::Io)?;
206        let fb = footer_bytes.as_slice();
207
208        let total_terms = u64::from_le_bytes(fb[0..8].try_into().unwrap());
209        let total_postings = u64::from_le_bytes(fb[8..16].try_into().unwrap());
210        let grid_offset = u64::from_le_bytes(fb[16..24].try_into().unwrap());
211        let sb_grid_offset = u64::from_le_bytes(fb[24..32].try_into().unwrap());
212        let coarse_grid_offset = u64::from_le_bytes(fb[32..40].try_into().unwrap());
213        let num_blocks = u32::from_le_bytes(fb[40..44].try_into().unwrap());
214        let dims = u32::from_le_bytes(fb[44..48].try_into().unwrap());
215        let bmp_block_size = u32::from_le_bytes(fb[48..52].try_into().unwrap());
216        let num_virtual_docs = u32::from_le_bytes(fb[52..56].try_into().unwrap());
217        let max_weight_scale = f32::from_le_bytes(fb[56..60].try_into().unwrap());
218        let doc_map_offset = u64::from_le_bytes(fb[60..68].try_into().unwrap());
219        let num_real_docs = u32::from_le_bytes(fb[68..72].try_into().unwrap());
220        let grid_bits_raw = u32::from_le_bytes(fb[72..76].try_into().unwrap());
221        let magic = u32::from_le_bytes(fb[76..80].try_into().unwrap());
222
223        if magic != BMP_BLOB_MAGIC {
224            return Err(crate::Error::Corruption(format!(
225                "Invalid BMP blob magic: {:#x} (expected BMP9 {:#x}); rebuild \
226                 the index with this version.",
227                magic, BMP_BLOB_MAGIC
228            )));
229        }
230        let grid_bits: u8 = match grid_bits_raw {
231            4 => 4,
232            2 => 2,
233            other => {
234                return Err(crate::Error::Corruption(format!(
235                    "Unsupported BMP grid_bits {} (expected 2 or 4) — data too new to read?",
236                    other
237                )));
238            }
239        };
240
241        // Handle empty index
242        if num_blocks == 0 {
243            if num_virtual_docs != 0 || num_real_docs != 0 {
244                return Err(crate::Error::Corruption(format!(
245                    "empty BMP index has non-zero document counts (virtual={}, real={})",
246                    num_virtual_docs, num_real_docs
247                )));
248            }
249            return Ok(Self {
250                bmp_block_size,
251                num_blocks,
252                num_virtual_docs,
253                max_weight_scale,
254                total_vectors,
255                segment_num_docs: total_docs,
256                dims,
257                total_terms: 0,
258                total_postings: 0,
259                grid_bits,
260                num_real_docs,
261                single_valued: true,
262                block_data_starts_bytes: OwnedBytes::empty(),
263                block_data_bytes: OwnedBytes::empty(),
264                block_grid: CompressedGrid::empty(),
265                superblock_grid: CompressedGrid::empty(),
266                num_superblocks: 0,
267                coarse_grid: CompressedGrid::empty(),
268                num_coarse_groups: 0,
269                doc_map_ids_bytes: OwnedBytes::empty(),
270                doc_map_ordinals_bytes: OwnedBytes::empty(),
271                source: handle,
272                blob_offset,
273                blob_len,
274                doc_map_offset,
275            });
276        }
277
278        if !(1..=256).contains(&bmp_block_size) {
279            return Err(crate::Error::Corruption(format!(
280                "invalid BMP block size {} (expected 1..=256)",
281                bmp_block_size
282            )));
283        }
284        let expected_virtual_docs = u64::from(num_blocks) * u64::from(bmp_block_size);
285        if expected_virtual_docs != u64::from(num_virtual_docs) {
286            return Err(crate::Error::Corruption(format!(
287                "BMP block/document mismatch: {} blocks × {} != {} virtual docs",
288                num_blocks, bmp_block_size, num_virtual_docs
289            )));
290        }
291        if num_real_docs > num_virtual_docs {
292            return Err(crate::Error::Corruption(format!(
293                "BMP real document count {} exceeds virtual count {}",
294                num_real_docs, num_virtual_docs
295            )));
296        }
297        if !max_weight_scale.is_finite() || max_weight_scale <= 0.0 {
298            return Err(crate::Error::Corruption(format!(
299                "invalid BMP max-weight scale {}",
300                max_weight_scale
301            )));
302        }
303
304        // Read entire blob (excluding footer) as one OwnedBytes — zero-copy mmap slice
305        let data_len = blob_len - BMP_BLOB_FOOTER_SIZE as u64;
306        let data_len_usize = usize::try_from(data_len).map_err(|_| {
307            crate::Error::Corruption("BMP blob is too large for this platform".into())
308        })?;
309        let blob = handle
310            .read_bytes_range_sync(blob_offset..footer_start)
311            .map_err(crate::Error::Io)?;
312
313        // Layout: Section B (block_data) at offset 0, Section A (block_data_starts)
314        // immediately before grid. Derive Section A position from grid_offset.
315        let num_blocks_usize = num_blocks as usize;
316        let section_a_size = num_blocks_usize
317            .checked_add(1)
318            .and_then(|count| count.checked_mul(8))
319            .ok_or_else(|| {
320                crate::Error::Corruption("BMP block-offset table size overflows usize".into())
321            })?;
322        let grid_start = usize::try_from(grid_offset).map_err(|_| {
323            crate::Error::Corruption("BMP grid offset is too large for this platform".into())
324        })?;
325        let bds_start = grid_start.checked_sub(section_a_size).ok_or_else(|| {
326            crate::Error::Corruption(format!(
327                "BMP grid offset {} precedes {}-byte block-offset table",
328                grid_offset, section_a_size
329            ))
330        })?;
331        if grid_start > data_len_usize {
332            return Err(crate::Error::Corruption(format!(
333                "BMP grid offset {} exceeds data length {}",
334                grid_start, data_len_usize
335            )));
336        }
337
338        // Section B: block_data [0..bds_start) (includes padding before Section A)
339        let block_data_bytes = blob.slice(0..bds_start);
340        // Section A: block_data_starts [bds_start..grid_offset)
341        let block_data_starts_bytes = blob.slice(bds_start..grid_start);
342
343        // Sections D+E+H: compressed ceil-u4 block, superblock, and coarse
344        // grids, then document maps. Their byte lengths are carried
345        // by the footer's section offsets; each grid validates its own row
346        // table before exposing random group access.
347        let num_superblocks = num_blocks.div_ceil(BMP_SUPERBLOCK_SIZE);
348        let num_coarse_groups = num_superblocks.div_ceil(BMP_COARSE_SUPERBLOCKS);
349        let sb_grid_start = usize::try_from(sb_grid_offset).map_err(|_| {
350            crate::Error::Corruption("BMP superblock-grid offset is too large".into())
351        })?;
352        if sb_grid_start < grid_start || sb_grid_start > data_len_usize {
353            return Err(crate::Error::Corruption(format!(
354                "BMP section order mismatch: block grid starts at {}, superblock grid at {}, data ends at {}",
355                grid_start, sb_grid_start, data_len_usize
356            )));
357        }
358        let coarse_grid_start = usize::try_from(coarse_grid_offset)
359            .map_err(|_| crate::Error::Corruption("BMP coarse-grid offset is too large".into()))?;
360        if coarse_grid_start < sb_grid_start || coarse_grid_start > data_len_usize {
361            return Err(crate::Error::Corruption(format!(
362                "BMP section order mismatch: superblock grid starts at {}, coarse grid at {}, data ends at {}",
363                sb_grid_start, coarse_grid_start, data_len_usize
364            )));
365        }
366
367        let dm_start = usize::try_from(doc_map_offset)
368            .map_err(|_| crate::Error::Corruption("BMP document-map offset is too large".into()))?;
369        if dm_start < coarse_grid_start || dm_start > data_len_usize {
370            return Err(crate::Error::Corruption(format!(
371                "BMP section order mismatch: coarse grid starts at {}, document map at {}, data ends at {}",
372                coarse_grid_start, dm_start, data_len_usize
373            )));
374        }
375        let dm_ids_len = (num_virtual_docs as usize).checked_mul(4).ok_or_else(|| {
376            crate::Error::Corruption("BMP document-id map size overflows usize".into())
377        })?;
378        let dm_ords_len = (num_virtual_docs as usize).checked_mul(2).ok_or_else(|| {
379            crate::Error::Corruption("BMP ordinal map size overflows usize".into())
380        })?;
381        let dm_ids_end = dm_start.checked_add(dm_ids_len).ok_or_else(|| {
382            crate::Error::Corruption("BMP document-id map end overflows usize".into())
383        })?;
384        let dm_ords_end = dm_ids_end.checked_add(dm_ords_len).ok_or_else(|| {
385            crate::Error::Corruption("BMP ordinal map end overflows usize".into())
386        })?;
387        if dm_ords_end != data_len_usize {
388            return Err(crate::Error::Corruption(format!(
389                "BMP data length mismatch: sections end at {}, blob data ends at {}",
390                dm_ords_end, data_len_usize
391            )));
392        }
393
394        // Slice into sections (all zero-copy — just offset adjustments on same Arc)
395        let block_grid = CompressedGrid::parse(
396            blob.slice(grid_start..sb_grid_start),
397            dims as usize,
398            num_blocks as usize,
399            grid_bits,
400            "BMP block grid",
401        )?;
402        let superblock_grid = CompressedGrid::parse(
403            blob.slice(sb_grid_start..coarse_grid_start),
404            dims as usize,
405            num_superblocks as usize,
406            4,
407            "BMP superblock grid",
408        )?;
409        let coarse_grid = CompressedGrid::parse(
410            blob.slice(coarse_grid_start..dm_start),
411            dims as usize,
412            num_coarse_groups as usize,
413            4,
414            "BMP coarse grid",
415        )?;
416        let doc_map_ids_bytes = blob.slice(dm_start..dm_ids_end);
417        let doc_map_ordinals_bytes = blob.slice(dm_ids_end..dm_ords_end);
418        let single_valued = doc_map_ordinals_bytes
419            .as_slice()
420            .chunks_exact(2)
421            .all(|ordinal| ordinal == [0, 0]);
422
423        // This compact table is cheap to validate in full and is the trust
424        // boundary for every later raw-pointer block access.
425        let starts = block_data_starts_bytes.as_slice();
426        let mut previous = 0u64;
427        for index in 0..=num_blocks_usize {
428            let offset = index * 8;
429            let current = u64::from_le_bytes(starts[offset..offset + 8].try_into().unwrap());
430            if (index == 0 && current != 0) || current < previous || current > bds_start as u64 {
431                return Err(crate::Error::Corruption(format!(
432                    "invalid BMP block offset at {}: {} (previous={}, data_limit={})",
433                    index, current, previous, bds_start
434                )));
435            }
436            if current > previous && current - previous < 8 {
437                return Err(crate::Error::Corruption(format!(
438                    "BMP block {} is too small for a header ({} bytes)",
439                    index - 1,
440                    current - previous
441                )));
442            }
443            previous = current;
444        }
445
446        // Query-time access to block data, the doc map, AND the block grid is
447        // scattered. Default kernel readahead pulls in 128KB per fault around
448        // each touched location, which evicts hot pages under memory pressure.
449        //
450        // The block grid especially: queries read one eight-cell range per
451        // (query dim, surviving superblock) at UB-priority, i.e. effectively
452        // random offsets. Default readahead can amplify each tiny probe into
453        // 128KB of page cache and march a data-sized grid into memory.
454        //
455        // E is now accessed only for selected 256-superblock groups and is
456        // random. H is tiny, swept contiguously, and pinnable (priority 4).
457        #[cfg(feature = "native")]
458        {
459            block_data_bytes.madvise(libc::MADV_RANDOM);
460            doc_map_ids_bytes.madvise(libc::MADV_RANDOM);
461            doc_map_ordinals_bytes.madvise(libc::MADV_RANDOM);
462            block_grid.madvise_rows(libc::MADV_RANDOM);
463            superblock_grid.madvise_rows(libc::MADV_RANDOM);
464            coarse_grid.madvise_rows(libc::MADV_SEQUENTIAL);
465        }
466
467        log::debug!(
468            "BMP V19 index loaded: num_blocks={}, num_superblocks={}, coarse_groups={}, dims={}, bmp_block_size={}, \
469             num_virtual_docs={}, num_real_docs={}, max_weight_scale={:.4}, postings={}, \
470             block_grid={}, superblock_grid={}, coarse_grid={}, single_valued={}, block_data={}, doc_map={}",
471            num_blocks,
472            num_superblocks,
473            num_coarse_groups,
474            dims,
475            bmp_block_size,
476            num_virtual_docs,
477            num_real_docs,
478            max_weight_scale,
479            total_postings,
480            crate::format_bytes(block_grid.encoded_bytes() as u64),
481            crate::format_bytes(superblock_grid.encoded_bytes() as u64),
482            crate::format_bytes(coarse_grid.encoded_bytes() as u64),
483            single_valued,
484            crate::format_bytes(bds_start as u64),
485            crate::format_bytes(u64::from(num_virtual_docs) * 6),
486        );
487
488        Ok(Self {
489            bmp_block_size,
490            num_blocks,
491            num_virtual_docs,
492            max_weight_scale,
493            total_vectors,
494            segment_num_docs: total_docs,
495            dims,
496            total_terms,
497            total_postings,
498            grid_bits,
499            num_real_docs,
500            single_valued,
501            block_data_starts_bytes,
502            block_data_bytes,
503            block_grid,
504            superblock_grid,
505            num_superblocks,
506            coarse_grid,
507            num_coarse_groups,
508            doc_map_ids_bytes,
509            doc_map_ordinals_bytes,
510            source: handle,
511            blob_offset,
512            blob_len,
513            doc_map_offset,
514        })
515    }
516
517    /// Read the entire raw V19 blob (including footer) from the source file.
518    ///
519    /// Used by reorder paths (native-only) to copy a field byte-identically
520    /// when its `reorder` schema attribute is unset.
521    #[cfg_attr(not(feature = "native"), allow(dead_code))]
522    pub(crate) fn read_raw_blob(&self) -> std::io::Result<OwnedBytes> {
523        self.source
524            .read_bytes_range_sync(self.blob_offset..self.blob_offset + self.blob_len)
525    }
526
527    /// Convert a compact virtual_id to (doc_id, ordinal) via table lookup.
528    ///
529    /// Uses unchecked reads — virtual_id is validated by the caller
530    /// (only called for top-k results which are valid compact virtual IDs).
531    #[inline(always)]
532    pub fn virtual_to_doc(&self, virtual_id: u32) -> (u32, u16) {
533        if virtual_id >= self.num_virtual_docs {
534            return (u32::MAX, 0);
535        }
536        let ids = self.doc_map_ids_bytes.as_slice();
537        let ords = self.doc_map_ordinals_bytes.as_slice();
538        debug_assert!((virtual_id as usize + 1) * 4 <= ids.len());
539        debug_assert!((virtual_id as usize + 1) * 2 <= ords.len());
540        unsafe {
541            let doc_id = read_u32_unchecked(ids.as_ptr(), virtual_id as usize);
542            if doc_id >= self.segment_num_docs {
543                return (u32::MAX, 0);
544            }
545            let p = ords.as_ptr().add(virtual_id as usize * 2);
546            let ordinal = u16::from_le((p as *const u16).read_unaligned());
547            (doc_id, ordinal)
548        }
549    }
550
551    /// Get the original doc_id for a compact virtual_id (no ordinal needed).
552    /// Used in the predicate filter path — hot loop, unchecked reads.
553    #[inline(always)]
554    pub fn doc_id_for_virtual(&self, virtual_id: u32) -> u32 {
555        if virtual_id >= self.num_virtual_docs {
556            return u32::MAX;
557        }
558        let d = self.doc_map_ids_bytes.as_slice();
559        debug_assert!((virtual_id as usize + 1) * 4 <= d.len());
560        let doc_id = unsafe { read_u32_unchecked(d.as_ptr(), virtual_id as usize) };
561        if doc_id < self.segment_num_docs {
562            doc_id
563        } else {
564            u32::MAX
565        }
566    }
567
568    // ── Hot-path block-data accessors ────────────────────────────────
569
570    /// Byte offset range in block_data_bytes for a block (u64 entries).
571    #[inline(always)]
572    pub(crate) fn block_data_range(&self, block_id: u32) -> (u64, u64) {
573        let d = self.block_data_starts_bytes.as_slice();
574        debug_assert!((block_id as usize + 2) * 8 <= d.len());
575        unsafe {
576            let start = read_u64_unchecked(d.as_ptr(), block_id as usize);
577            let end = read_u64_unchecked(d.as_ptr(), block_id as usize + 1);
578            (start, end)
579        }
580    }
581
582    /// Pin the block-offset table (priority 1: every scored block does an
583    /// offset lookup through it).
584    #[cfg(feature = "native")]
585    pub(crate) fn pin_block_starts(
586        &mut self,
587        mode: crate::segment::pin::PinMode,
588        remaining: &mut u64,
589        report: &mut crate::segment::pin::PinReport,
590    ) {
591        crate::segment::pin::pin_section(
592            &mut self.block_data_starts_bytes,
593            "bmp block_data_starts",
594            mode,
595            remaining,
596            report,
597        );
598        self.block_grid
599            .pin_offsets("bmp block_grid row_offsets", mode, remaining, report);
600    }
601
602    /// Pin the virtual-doc → (doc_id, ordinal) maps (priority 3: every
603    /// top-k resolution touches them).
604    #[cfg(feature = "native")]
605    pub(crate) fn pin_doc_maps(
606        &mut self,
607        mode: crate::segment::pin::PinMode,
608        remaining: &mut u64,
609        report: &mut crate::segment::pin::PinReport,
610    ) {
611        crate::segment::pin::pin_section(
612            &mut self.doc_map_ids_bytes,
613            "bmp doc_map_ids",
614            mode,
615            remaining,
616            report,
617        );
618        crate::segment::pin::pin_section(
619            &mut self.doc_map_ordinals_bytes,
620            "bmp doc_map_ordinals",
621            mode,
622            remaining,
623            report,
624        );
625    }
626
627    /// Pin the sparse planning hierarchy (priority 4).
628    ///
629    /// E is data-sized and only accessed for selected coarse groups, so only
630    /// its row offsets are pinned. H is roughly 256x smaller and is swept for
631    /// every BMP query, so both its offsets and rows are pinned. The block-grid
632    /// payload is deliberately never pinned; its row offsets are priority 1.
633    #[cfg(feature = "native")]
634    pub(crate) fn pin_query_hierarchy(
635        &mut self,
636        mode: crate::segment::pin::PinMode,
637        remaining: &mut u64,
638        report: &mut crate::segment::pin::PinReport,
639    ) {
640        self.superblock_grid
641            .pin_offsets("bmp sb_grid row_offsets", mode, remaining, report);
642        self.coarse_grid.pin_all(
643            "bmp coarse_grid row_offsets",
644            "bmp coarse_grid rows",
645            mode,
646            remaining,
647            report,
648        );
649    }
650
651    /// Page-level prefetch (`MADV_WILLNEED`) of a block-data byte range.
652    ///
653    /// Used by the BMP executor to batch-prefetch the surviving blocks of a
654    /// superblock before scoring: on memory-bound hosts the kernel clusters
655    /// the page-ins into large sequential reads instead of taking one
656    /// synchronous major fault per scored block (~265µs each on cold NVMe).
657    /// No-op for non-mmap (RAM/HTTP) backing.
658    #[cfg(feature = "native")]
659    #[inline]
660    pub(crate) fn prefetch_block_data(&self, byte_start: u64, byte_end: u64) {
661        self.block_data_bytes
662            .madvise_range(byte_start as usize..byte_end as usize, libc::MADV_WILLNEED);
663    }
664
665    /// Coalesce page-near block payload ranges before issuing WILLNEED.
666    ///
667    /// Selected LSP superblocks are score-ordered rather than file-ordered, so
668    /// one giant min..max advice span can pull gigabytes of unvisited data.
669    /// This keeps distant extents independent while collapsing ranges that the
670    /// kernel would round onto the same/adjacent pages anyway.
671    #[cfg(feature = "native")]
672    pub(crate) fn prefetch_block_data_ranges(
673        &self,
674        ranges: &mut Vec<std::ops::Range<u64>>,
675    ) -> (usize, usize) {
676        if ranges.is_empty() {
677            return (0, 0);
678        }
679        const PAGE_NEAR_BYTES: u64 = 4096;
680        ranges.sort_unstable_by_key(|range| (range.start, range.end));
681        let mut advised_bytes = 0usize;
682        let mut calls = 0usize;
683        let mut current = ranges[0].clone();
684        for range in &ranges[1..] {
685            if range.start <= current.end.saturating_add(PAGE_NEAR_BYTES) {
686                current.end = current.end.max(range.end);
687                continue;
688            }
689            advised_bytes = advised_bytes.saturating_add((current.end - current.start) as usize);
690            calls += 1;
691            self.prefetch_block_data(current.start, current.end);
692            current = range.clone();
693        }
694        advised_bytes = advised_bytes.saturating_add((current.end - current.start) as usize);
695        calls += 1;
696        self.prefetch_block_data(current.start, current.end);
697        ranges.clear();
698        (advised_bytes, calls)
699    }
700
701    /// Get a raw pointer to the start of a block's contiguous data.
702    /// Used for software prefetching — 1 prefetch loads all block scoring data.
703    #[inline(always)]
704    pub(crate) fn block_data_ptr(&self, block_id: u32) -> *const u8 {
705        let (start, _) = self.block_data_range(block_id);
706        unsafe {
707            self.block_data_bytes
708                .as_slice()
709                .as_ptr()
710                .add(start as usize)
711        }
712    }
713
714    /// Parse one adaptive block. Malformed and empty blocks degrade to `None`
715    /// in the availability-oriented query path.
716    #[inline(always)]
717    pub(crate) fn parse_block(&self, block_id: u32) -> Option<AdaptiveBlock<'_>> {
718        if block_id >= self.num_blocks {
719            return None;
720        }
721        let (start, end) = self.block_data_range(block_id);
722        if start == end {
723            return None;
724        }
725        let start = usize::try_from(start).ok()?;
726        let end = usize::try_from(end).ok()?;
727        let bytes = self.block_data_bytes.as_slice().get(start..end)?;
728        AdaptiveBlock::parse(bytes, self.bmp_block_size as usize)
729    }
730
731    /// Get a raw pointer to block_data_starts at the given block.
732    /// Used for prefetching the N+2 block's offset during scoring.
733    /// Each entry is 8 bytes (u64).
734    #[inline(always)]
735    pub(crate) fn block_data_starts_ptr(&self, block_id: u32) -> *const u8 {
736        unsafe {
737            self.block_data_starts_bytes
738                .as_slice()
739                .as_ptr()
740                .add(block_id as usize * 8)
741        }
742    }
743
744    /// Iterate `(dimension, conservative maximum, postings)` for one block.
745    ///
746    /// Only the build/reorder paths walk a block term by term, and those are
747    /// gated on `native`/`wasm`.
748    #[cfg_attr(not(any(feature = "native", feature = "wasm")), allow(dead_code))]
749    pub(crate) fn iter_block_terms(
750        &self,
751        block_id: u32,
752    ) -> impl Iterator<Item = (u32, u8, AdaptivePostings<'_>)> + '_ {
753        self.parse_block(block_id)
754            .into_iter()
755            .flat_map(AdaptiveBlock::terms)
756    }
757
758    // ── Non-hot-path accessors ───────────────────────────────────────
759
760    /// Fixed vocabulary size (number of grid rows).
761    pub fn dims(&self) -> u32 {
762        self.dims
763    }
764
765    /// Validate the persisted layout before a merge or reorder interprets it
766    /// using schema-derived output parameters.
767    ///
768    /// The footer is the source of truth for reading this blob. Rewriting with
769    /// a different block width, grid width, vocabulary, or impact scale would
770    /// otherwise make block slicing or copied upper bounds invalid.
771    #[cfg(any(feature = "native", test))]
772    pub(crate) fn validate_rewrite_layout(
773        &self,
774        context: &str,
775        expected_dims: u32,
776        expected_block_size: u32,
777        expected_grid_bits: u8,
778        expected_max_weight_scale: f32,
779    ) -> crate::Result<()> {
780        if expected_dims == 0 {
781            return Err(crate::Error::Corruption(format!(
782                "{context}: expected vocabulary is empty",
783            )));
784        }
785        if self.dims != expected_dims {
786            return Err(crate::Error::Corruption(format!(
787                "{context}: source dims={} != expected {expected_dims}",
788                self.dims,
789            )));
790        }
791        if self.bmp_block_size != expected_block_size {
792            return Err(crate::Error::Corruption(format!(
793                "{context}: source block_size={} != expected {expected_block_size}",
794                self.bmp_block_size,
795            )));
796        }
797        if self.grid_bits != expected_grid_bits {
798            return Err(crate::Error::Corruption(format!(
799                "{context}: source grid_bits={} != expected {expected_grid_bits}",
800                self.grid_bits,
801            )));
802        }
803        if !expected_max_weight_scale.is_finite() || expected_max_weight_scale <= 0.0 {
804            return Err(crate::Error::Corruption(format!(
805                "{context}: invalid expected max_weight_scale={expected_max_weight_scale}",
806            )));
807        }
808        if self.max_weight_scale.to_bits() != expected_max_weight_scale.to_bits() {
809            return Err(crate::Error::Corruption(format!(
810                "{context}: source max_weight_scale={:.4} != expected {:.4}",
811                self.max_weight_scale, expected_max_weight_scale,
812            )));
813        }
814        Ok(())
815    }
816
817    /// Validate the document map and visit each non-padding virtual slot.
818    ///
819    /// All rewrite paths share this scan so block-copy cannot offset corrupt
820    /// source IDs into another segment while record reorder rejects them.
821    #[cfg(any(feature = "native", feature = "wasm", test))]
822    pub(crate) fn visit_real_slots_for_rewrite(
823        &self,
824        mut visitor: impl FnMut(usize),
825    ) -> crate::Result<()> {
826        let expected_real = self.num_real_docs as usize;
827        let mut real_slots = 0usize;
828        for (virtual_id, chunk) in self
829            .doc_map_ids_bytes
830            .as_slice()
831            .chunks_exact(4)
832            .enumerate()
833        {
834            let doc_id = u32::from_le_bytes(chunk.try_into().unwrap());
835            if doc_id == u32::MAX {
836                continue;
837            }
838            if doc_id >= self.segment_num_docs {
839                return Err(crate::Error::Corruption(format!(
840                    "BMP document map contains doc id {doc_id} outside segment bound {}",
841                    self.segment_num_docs,
842                )));
843            }
844            if real_slots == expected_real {
845                return Err(crate::Error::Corruption(format!(
846                    "BMP document map contains more than the footer's {expected_real} real slots"
847                )));
848            }
849            visitor(virtual_id);
850            real_slots += 1;
851        }
852        if real_slots != expected_real {
853            return Err(crate::Error::Corruption(format!(
854                "BMP document map has {real_slots} real slots but footer declares {expected_real}",
855            )));
856        }
857        Ok(())
858    }
859
860    /// Validate one block before a rewrite feeds it into infallible hot-path
861    /// iterators. Query parsing deliberately degrades malformed blocks to
862    /// empty for availability; a rewrite must instead fail loudly so it never
863    /// publishes silent data loss or indexes an invalid local slot.
864    #[cfg(any(feature = "native", test))]
865    pub(crate) fn validate_block_for_rewrite(&self, block_id: u32) -> crate::Result<()> {
866        if block_id >= self.num_blocks {
867            return Err(crate::Error::Corruption(format!(
868                "BMP rewrite block {block_id} exceeds block count {}",
869                self.num_blocks,
870            )));
871        }
872        let (start, end) = self.block_data_range(block_id);
873        let start = usize::try_from(start)
874            .map_err(|_| crate::Error::Corruption("BMP block start exceeds usize".into()))?;
875        let end = usize::try_from(end)
876            .map_err(|_| crate::Error::Corruption("BMP block end exceeds usize".into()))?;
877        let block = self
878            .block_data_bytes
879            .as_slice()
880            .get(start..end)
881            .ok_or_else(|| {
882                crate::Error::Corruption(format!(
883                    "BMP block {block_id} range {start}..{end} exceeds block data",
884                ))
885            })?;
886        if block.is_empty() {
887            return Ok(());
888        }
889        let parsed =
890            AdaptiveBlock::parse(block, self.bmp_block_size as usize).ok_or_else(|| {
891                crate::Error::Corruption(format!(
892                    "BMP block {block_id} has an invalid adaptive envelope"
893                ))
894            })?;
895        parsed.validate(self.dims).map_err(|reason| {
896            crate::Error::Corruption(format!("BMP block {block_id} is invalid: {reason}"))
897        })
898    }
899
900    /// Total number of terms (unique dim×block pairs) stored in the index.
901    pub fn total_terms(&self) -> u64 {
902        self.total_terms
903    }
904
905    /// Total number of postings stored in the index.
906    pub fn total_postings(&self) -> u64 {
907        self.total_postings
908    }
909
910    /// Actual vector count before block-alignment padding.
911    pub fn num_real_docs(&self) -> u32 {
912        self.num_real_docs
913    }
914
915    /// Number of documents in the containing segment.
916    /// Whether this segment physically contains at most one vector per
917    /// document. Unlike the schema's `multi` flag, this remains reliable for
918    /// old or externally-created segments with inaccurate metadata.
919    pub fn is_single_valued(&self) -> bool {
920        self.single_valued
921    }
922
923    /// Estimated heap retained by this index. All corpus-sized sections are
924    /// file-backed `OwnedBytes` slices and therefore excluded.
925    pub fn estimated_heap_bytes(&self) -> usize {
926        std::mem::size_of::<Self>()
927    }
928
929    /// Bits per block-grid cell (4 or 2).
930    pub fn grid_bits(&self) -> u8 {
931        self.grid_bits
932    }
933
934    /// Per-dimension posting distribution and impact saturation, from one
935    /// full O(postings) pass over every block.
936    ///
937    /// This is diagnostics-tier ("expensive opt-in"): SPLADE-style vocabularies
938    /// are Zipfian, and a handful of hot dimensions holding most postings is
939    /// what makes block upper bounds loose and pruning ineffective. Impact
940    /// saturation (quantized weight == 255) means the u8 quantization is
941    /// clipping the model's weight range.
942    pub fn dim_stats(&self, top: usize) -> BmpDimStats {
943        let mut per_dim: rustc_hash::FxHashMap<u32, u64> = rustc_hash::FxHashMap::default();
944        let mut total_postings = 0u64;
945        let mut saturated = 0u64;
946        for block_id in 0..self.num_blocks {
947            for (dim, _, postings) in self.iter_block_terms(block_id) {
948                let mut count = 0u64;
949                for posting in postings {
950                    count += 1;
951                    if posting.impact == u8::MAX {
952                        saturated += 1;
953                    }
954                }
955                *per_dim.entry(dim).or_default() += count;
956                total_postings += count;
957            }
958        }
959        let mut counts: Vec<u64> = per_dim.values().copied().collect();
960        counts.sort_unstable();
961        let percentile = |fraction: f64| -> u64 {
962            if counts.is_empty() {
963                0
964            } else {
965                counts[((counts.len() - 1) as f64 * fraction) as usize]
966            }
967        };
968        let mut top_dims: Vec<(u32, u64)> = per_dim.into_iter().collect();
969        top_dims.sort_unstable_by_key(|&(dim, count)| (std::cmp::Reverse(count), dim));
970        top_dims.truncate(top);
971        // Postings concentration: how much of the corpus the hottest 1% of
972        // dimensions hold. High values mean stopword-like dimensions dominate.
973        let hot = counts.len().div_ceil(100);
974        let top_1pct_postings: u64 = counts.iter().rev().take(hot).sum();
975        BmpDimStats {
976            nonzero_dims: counts.len() as u32,
977            declared_dims: self.dims(),
978            total_postings,
979            p50_postings_per_dim: percentile(0.50),
980            p99_postings_per_dim: percentile(0.99),
981            max_postings_per_dim: counts.last().copied().unwrap_or(0),
982            top_1pct_share: if total_postings == 0 {
983                0.0
984            } else {
985                top_1pct_postings as f64 / total_postings as f64
986            },
987            saturated_impacts: saturated,
988            top_dims,
989        }
990    }
991
992    /// Direct random-group access to the compressed block grid.
993    #[inline]
994    pub(crate) fn block_grid(&self) -> &CompressedGrid {
995        &self.block_grid
996    }
997
998    /// Direct random-group access to the compressed ceil-u4 superblock grid.
999    #[inline]
1000    pub(crate) fn superblock_grid(&self) -> &CompressedGrid {
1001        &self.superblock_grid
1002    }
1003
1004    /// Direct access to the ceil-u4 grid over 256-superblock groups.
1005    #[inline]
1006    pub(crate) fn coarse_grid(&self) -> &CompressedGrid {
1007        &self.coarse_grid
1008    }
1009
1010    /// Visit independently decoded chunks of one block-grid row.
1011    ///
1012    /// This is intended for diagnostics such as the CLI heatmap. `None`
1013    /// represents an all-zero chunk and avoids materializing it; non-zero
1014    /// values are valid only for the duration of the callback.
1015    pub fn for_each_block_grid_chunk(
1016        &self,
1017        dimension: u32,
1018        mut visitor: impl FnMut(usize, usize, Option<&[u8]>),
1019    ) -> crate::Result<()> {
1020        let dimension = dimension as usize;
1021        if dimension >= self.block_grid.dims() {
1022            return Err(crate::Error::Query(format!(
1023                "BMP block-grid dimension {dimension} exceeds {}",
1024                self.block_grid.dims()
1025            )));
1026        }
1027        let mut decoded = [0u8; crate::segment::bmp_grid::GRID_GROUP_CELLS];
1028        self.block_grid
1029            .try_for_each_row_group(dimension, |group_id, group| {
1030                let start = group_id * crate::segment::bmp_grid::GRID_GROUP_CELLS;
1031                let count =
1032                    crate::segment::bmp_grid::GRID_GROUP_CELLS.min(self.block_grid.cells() - start);
1033                if group.width() == 0 {
1034                    visitor(start, count, None);
1035                } else {
1036                    group.decode(0, count, &mut decoded);
1037                    visitor(start, count, Some(&decoded[..count]));
1038                }
1039                Ok(())
1040            })
1041    }
1042
1043    // ── Streaming merge accessors (block-copy) ────────────────────────
1044
1045    /// Raw block data bytes (Section B). For block-copy merge.
1046    #[inline]
1047    pub fn block_data_slice(&self) -> &[u8] {
1048        self.block_data_bytes.as_slice()
1049    }
1050
1051    /// Byte offset of block `block_id` in block data (from block_data_starts).
1052    #[inline]
1053    pub fn block_data_start(&self, block_id: u32) -> u64 {
1054        let d = self.block_data_starts_bytes.as_slice();
1055        let off = block_id as usize * 8;
1056        u64::from_le_bytes(d[off..off + 8].try_into().unwrap())
1057    }
1058
1059    /// Sentinel value = total bytes in Section B (block_data_starts[num_blocks]).
1060    #[inline]
1061    pub fn block_data_sentinel(&self) -> u64 {
1062        self.block_data_start(self.num_blocks)
1063    }
1064
1065    /// Raw doc_map_ids bytes (Section F). For bulk merge copy.
1066    /// Layout: `[u32-LE × num_virtual_docs]`.
1067    #[inline]
1068    pub fn doc_map_ids_slice(&self) -> &[u8] {
1069        self.doc_map_ids_bytes.as_slice()
1070    }
1071
1072    /// Raw doc_map_ordinals bytes (Section G). For bulk merge copy.
1073    /// Layout: `[u16-LE × num_virtual_docs]`.
1074    #[inline]
1075    pub fn doc_map_ordinals_slice(&self) -> &[u8] {
1076        self.doc_map_ordinals_bytes.as_slice()
1077    }
1078
1079    /// Native source-file range containing Section B (block payload).
1080    #[cfg(feature = "native")]
1081    pub(crate) fn block_data_file_range(&self) -> std::ops::Range<u64> {
1082        self.blob_offset..self.blob_offset + self.block_data_sentinel()
1083    }
1084
1085    /// Native source-file range containing Section F (document IDs).
1086    #[cfg(feature = "native")]
1087    pub(crate) fn doc_map_ids_file_range(&self) -> std::ops::Range<u64> {
1088        let start = self.blob_offset + self.doc_map_offset;
1089        start..start + u64::from(self.num_virtual_docs) * 4
1090    }
1091
1092    /// Native source-file range containing Section G (ordinals).
1093    #[cfg(feature = "native")]
1094    pub(crate) fn doc_map_ordinals_file_range(&self) -> std::ops::Range<u64> {
1095        let start = self.blob_offset + self.doc_map_offset + u64::from(self.num_virtual_docs) * 4;
1096        start..start + u64::from(self.num_virtual_docs) * 2
1097    }
1098
1099    /// Advise the kernel about sequential access patterns for merge.
1100    ///
1101    /// Only effective on mmap-backed data. No-op for heap (Vec) or non-native.
1102    #[cfg(feature = "native")]
1103    pub fn madvise_sequential(&self) {
1104        Self::madvise_owned(&self.block_data_bytes, libc::MADV_SEQUENTIAL);
1105        Self::madvise_owned(&self.block_data_starts_bytes, libc::MADV_SEQUENTIAL);
1106        self.block_grid.madvise_rows(libc::MADV_SEQUENTIAL);
1107        self.superblock_grid.madvise_rows(libc::MADV_SEQUENTIAL);
1108        self.coarse_grid.madvise_rows(libc::MADV_SEQUENTIAL);
1109        Self::madvise_owned(&self.doc_map_ids_bytes, libc::MADV_SEQUENTIAL);
1110        Self::madvise_owned(&self.doc_map_ordinals_bytes, libc::MADV_SEQUENTIAL);
1111    }
1112
1113    /// Release block data pages after Phase 1 completes.
1114    /// Keeps block_data_starts — needed for Phase 2 recomputation.
1115    #[cfg(feature = "native")]
1116    pub fn madvise_dontneed_block_data(&self) {
1117        Self::madvise_owned(&self.block_data_bytes, libc::MADV_DONTNEED);
1118    }
1119
1120    /// Restore query-pattern advice (same as set at `parse`) after a merge
1121    /// flipped these regions to `MADV_SEQUENTIAL`. Source segments keep
1122    /// serving queries while and after being merged, until swapped out.
1123    #[cfg(feature = "native")]
1124    pub fn madvise_random_query(&self) {
1125        Self::madvise_owned(&self.block_data_bytes, libc::MADV_RANDOM);
1126        self.block_grid.madvise_rows(libc::MADV_RANDOM);
1127        self.superblock_grid.madvise_rows(libc::MADV_RANDOM);
1128        self.coarse_grid.madvise_rows(libc::MADV_SEQUENTIAL);
1129        Self::madvise_owned(&self.doc_map_ids_bytes, libc::MADV_RANDOM);
1130        Self::madvise_owned(&self.doc_map_ordinals_bytes, libc::MADV_RANDOM);
1131    }
1132
1133    /// Release grid pages after Phase 3+4 complete.
1134    #[cfg(feature = "native")]
1135    pub fn madvise_dontneed_grids(&self) {
1136        self.block_grid.madvise_rows(libc::MADV_DONTNEED);
1137        self.superblock_grid.madvise_rows(libc::MADV_DONTNEED);
1138        self.coarse_grid.madvise_rows(libc::MADV_DONTNEED);
1139    }
1140
1141    /// Release document-map pages faulted by a full reorder scan. They remain
1142    /// mmap-backed and refault on demand for any reader that still references
1143    /// the source segment during publication.
1144    #[cfg(feature = "native")]
1145    pub fn madvise_dontneed_doc_maps(&self) {
1146        Self::madvise_owned(&self.doc_map_ids_bytes, libc::MADV_DONTNEED);
1147        Self::madvise_owned(&self.doc_map_ordinals_bytes, libc::MADV_DONTNEED);
1148    }
1149
1150    /// Call `madvise` only when the backing store is mmap.
1151    ///
1152    /// `MADV_DONTNEED` on heap (Vec) memory zeroes pages on Linux and can
1153    /// corrupt allocator metadata (the page-aligned pointer may reach into
1154    /// malloc headers before the allocation). This caused `free(): invalid
1155    /// pointer` crashes in CI where tests use RamDirectory (Vec-backed).
1156    #[cfg(feature = "native")]
1157    fn madvise_owned(bytes: &crate::directories::OwnedBytes, advice: i32) {
1158        bytes.madvise(advice);
1159    }
1160}
1161
1162/// Kernel page-advice lifecycle for exhaustive background scans.
1163///
1164/// Construction marks source mappings sequential. Drop releases the large
1165/// block/grid/doc-map regions and restores random query advice, including on
1166/// `?` and panic unwind. The mappings stay valid and refault for readers that
1167/// still reference a source during publication.
1168#[cfg(feature = "native")]
1169pub(crate) struct BmpScanPageGuard<'a> {
1170    indexes: Vec<&'a BmpIndex>,
1171}
1172
1173#[cfg(feature = "native")]
1174impl<'a> BmpScanPageGuard<'a> {
1175    pub(crate) fn new(indexes: impl IntoIterator<Item = &'a BmpIndex>) -> Self {
1176        let indexes: Vec<_> = indexes.into_iter().collect();
1177        for index in &indexes {
1178            index.madvise_sequential();
1179        }
1180        Self { indexes }
1181    }
1182
1183    pub(crate) fn switch_to_random(&self) {
1184        for index in &self.indexes {
1185            index.madvise_random_query();
1186        }
1187    }
1188}
1189
1190#[cfg(feature = "native")]
1191impl Drop for BmpScanPageGuard<'_> {
1192    fn drop(&mut self) {
1193        for index in &self.indexes {
1194            index.madvise_dontneed_block_data();
1195            index.madvise_dontneed_grids();
1196            index.madvise_dontneed_doc_maps();
1197            index.madvise_random_query();
1198        }
1199    }
1200}
1201
1202#[cfg(test)]
1203mod safety_tests {
1204    use super::BmpIndex;
1205    use crate::directories::{FileHandle, OwnedBytes};
1206    use crate::segment::format::BMP_BLOB_FOOTER_SIZE;
1207    use rustc_hash::FxHashMap;
1208
1209    fn test_blob() -> Vec<u8> {
1210        let mut postings = FxHashMap::default();
1211        postings.insert(3, vec![(0, 0, 1.0), (1, 0, 0.5)]);
1212        let mut blob = Vec::new();
1213        crate::segment::builder::bmp::build_bmp_blob(
1214            postings, 64, 4, 0.0, None, 16, 5.0, 0, &mut blob,
1215        )
1216        .unwrap();
1217        blob
1218    }
1219
1220    fn parse(blob: Vec<u8>) -> crate::Result<BmpIndex> {
1221        let len = blob.len() as u64;
1222        BmpIndex::parse(FileHandle::from_bytes(OwnedBytes::new(blob)), 0, len, 2, 2)
1223    }
1224
1225    #[test]
1226    fn parse_rejects_footer_section_underflow_without_panicking() {
1227        let mut blob = test_blob();
1228        let footer = blob.len() - BMP_BLOB_FOOTER_SIZE;
1229        blob[footer + 16..footer + 24].copy_from_slice(&0u64.to_le_bytes());
1230        assert!(matches!(parse(blob), Err(crate::Error::Corruption(_))));
1231    }
1232
1233    #[test]
1234    fn parse_rejects_nonzero_first_block_offset() {
1235        let mut blob = test_blob();
1236        let footer = blob.len() - BMP_BLOB_FOOTER_SIZE;
1237        let grid_offset =
1238            u64::from_le_bytes(blob[footer + 16..footer + 24].try_into().unwrap()) as usize;
1239        let num_blocks =
1240            u32::from_le_bytes(blob[footer + 40..footer + 44].try_into().unwrap()) as usize;
1241        let starts = grid_offset - (num_blocks + 1) * 8;
1242        blob[starts..starts + 8].copy_from_slice(&1u64.to_le_bytes());
1243        assert!(matches!(parse(blob), Err(crate::Error::Corruption(_))));
1244    }
1245
1246    #[test]
1247    fn physical_single_value_detection_uses_ordinal_map() {
1248        let single = parse(test_blob()).unwrap();
1249        assert!(single.is_single_valued());
1250
1251        let mut postings = FxHashMap::default();
1252        postings.insert(3, vec![(0, 0, 1.0), (0, 1, 0.8), (1, 0, 0.5)]);
1253        let mut blob = Vec::new();
1254        crate::segment::builder::bmp::build_bmp_blob(
1255            postings, 64, 4, 0.0, None, 16, 5.0, 0, &mut blob,
1256        )
1257        .unwrap();
1258        let multi = parse(blob).unwrap();
1259        assert!(!multi.is_single_valued());
1260    }
1261
1262    #[test]
1263    fn rewrite_validation_rejects_out_of_range_local_slot() {
1264        let mut blob = test_blob();
1265        // One-term narrow V19 header: count(4) + dim(4) + offsets(4) + max(1).
1266        blob[13] = 64;
1267        let index = parse(blob).unwrap();
1268        let error = index.validate_block_for_rewrite(0).unwrap_err();
1269        assert!(matches!(error, crate::Error::Corruption(_)));
1270    }
1271
1272    #[test]
1273    fn rewrite_validation_rejects_bad_dimension_and_maximum() {
1274        let mut bad_dimension = test_blob();
1275        bad_dimension[4..8].copy_from_slice(&16u32.to_le_bytes());
1276        let index = parse(bad_dimension).unwrap();
1277        assert!(matches!(
1278            index.validate_block_for_rewrite(0),
1279            Err(crate::Error::Corruption(_))
1280        ));
1281
1282        let mut bad_maximum = test_blob();
1283        bad_maximum[12] = 0;
1284        let index = parse(bad_maximum).unwrap();
1285        assert!(matches!(
1286            index.validate_block_for_rewrite(0),
1287            Err(crate::Error::Corruption(_))
1288        ));
1289    }
1290
1291    #[test]
1292    fn invalid_doc_map_id_is_bounded_and_rewrite_rejects_it() {
1293        let mut blob = test_blob();
1294        let footer = blob.len() - BMP_BLOB_FOOTER_SIZE;
1295        let doc_map =
1296            u64::from_le_bytes(blob[footer + 60..footer + 68].try_into().unwrap()) as usize;
1297        blob[doc_map..doc_map + 4].copy_from_slice(&2u32.to_le_bytes());
1298        let index = parse(blob).unwrap();
1299
1300        assert_eq!(index.doc_id_for_virtual(0), u32::MAX);
1301        assert!(matches!(
1302            crate::segment::builder::graph_bisection::build_vid_maps(&index),
1303            Err(crate::Error::Corruption(_))
1304        ));
1305    }
1306
1307    #[test]
1308    fn rewrite_layout_requires_exact_finite_scale() {
1309        let index = parse(test_blob()).unwrap();
1310        let adjacent_scale = f32::from_bits(index.max_weight_scale.to_bits() + 1);
1311        assert!(matches!(
1312            index.validate_rewrite_layout("test", 16, 64, 4, adjacent_scale),
1313            Err(crate::Error::Corruption(_))
1314        ));
1315        assert!(matches!(
1316            index.validate_rewrite_layout("test", 16, 64, 4, f32::NAN),
1317            Err(crate::Error::Corruption(_))
1318        ));
1319    }
1320}