Skip to main content

summa_core/segment/reader/
bmp.rs

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