Skip to main content

chroma_types/
sparse_posting_block.rs

1use bitpacking::{BitPacker, BitPacker4x};
2use half::f16;
3use thiserror::Error;
4
5const BITPACK_GROUP_SIZE: usize = BitPacker4x::BLOCK_LEN; // 128
6
7/// Sentinel value in `bits_per_delta` that marks a directory block (see
8/// [`DirectoryBlock`] below).
9const DIRECTORY_SENTINEL: u8 = 0xFF;
10
11/// Maximum number of posting entries per block. Chosen so that the
12/// decompressed block fits comfortably in L1 cache:
13///   4096 * (4 bytes offset + 4 bytes f32 value) = 32 KB.
14pub const MAX_BLOCK_ENTRIES: usize = 4096;
15
16const HEADER_SIZE: usize = 16;
17const DIRECTORY_ENTRY_SIZE: usize = 8; // u32 max_offset + f32 max_weight
18
19/// Fill `out` with one group of relative offsets (`offset - min_offset`),
20/// padding trailing slots with `last_relative`. `offsets` may be shorter
21/// than `BITPACK_GROUP_SIZE` for the last group.
22fn fill_relative_group(
23    offsets: &[u32],
24    min_offset: u32,
25    last_relative: u32,
26    out: &mut [u32; BITPACK_GROUP_SIZE],
27) {
28    for (r, val) in out.iter_mut().zip(
29        offsets
30            .iter()
31            .map(|&o| o - min_offset)
32            .chain(std::iter::repeat(last_relative)),
33    ) {
34        *r = val;
35    }
36}
37
38/// Prefix tag prepended to a dimension-id key for directory block parts.
39///
40/// The blockfile composite key uses `(prefix: String, key: u32)`.  Posting
41/// data blocks use the bare `encode_u32(dim_id)` as their prefix, while
42/// directory parts prepend this tag so the reader can fetch directories
43/// without pulling posting data:
44///
45/// - Posting blocks: prefix = `encode_u32(dim)`, key = `0, 1, 2, …`
46/// - Directory parts: prefix = `DIRECTORY_PREFIX.to_owned() + &encode_u32(dim)`, key = `0, 1, 2, …`
47///
48/// `DIRECTORY_PREFIX` must sort after ALL base64-encoded u32 prefixes
49/// (`A-Za-z0-9+/=`). With little-endian `encode_u32`, some dimension IDs
50/// produce base64 strings that sort after lowercase letters (e.g., dim
51/// 25000 → `"qGEAAA=="`), so `~` (ASCII 126) is used to guarantee
52/// directory prefixes always sort last.
53pub const DIRECTORY_PREFIX: &str = "~";
54
55// ── Error type ──────────────────────────────────────────────────────
56
57#[derive(Debug, Clone, Error)]
58pub enum SparsePostingBlockError {
59    #[error("block must have at least one entry")]
60    EmptyEntries,
61    #[error("block has {count} entries, max is {MAX_BLOCK_ENTRIES}")]
62    TooManyEntries { count: usize },
63    #[error("directory: max_offsets len ({offsets}) != max_weights len ({weights})")]
64    MismatchedLengths { offsets: usize, weights: usize },
65    #[error("expected at least {HEADER_SIZE} header bytes, got {len}")]
66    TruncatedHeader { len: usize },
67    #[error("expected {expected} body bytes, got {actual}")]
68    TruncatedBody { expected: usize, actual: usize },
69    #[error("invalid bits_per_delta: {value} (expected 0..=32 or 0xFF for directory)")]
70    InvalidBitsPerDelta { value: u8 },
71}
72
73/// Header read from the first 16 bytes of a serialized block.
74///
75/// # Terminology: "offset"
76///
77/// Throughout this module, "offset" means a document's segment offset ID —
78/// its u32 position within a compacted segment. Posting lists are sorted
79/// by offset so that block-max pruning can iterate documents in order and
80/// merge across dimensions.
81///
82/// # Layout (little-endian, 16 bytes total)
83///
84/// ```text
85/// [0..2]   num_entries   : u16  — number of (offset, weight) pairs
86/// [2]      bits_per_delta: u8   — bits per bitpacked delta (0xFF = directory)
87/// [3]      _reserved     : u8   — reserved for future format versioning
88/// [4..8]   min_offset    : u32  — smallest doc offset in this block
89/// [8..12]  max_offset    : u32  — largest doc offset in this block
90/// [12..16] max_weight    : f32  — largest weight in this block (for pruning)
91/// ```
92#[derive(Debug, Clone, Copy)]
93pub struct PostingBlockHeader {
94    /// Number of (offset, weight) pairs in the block.
95    pub num_entries: u16,
96    /// Bits per delta for bitpacked offset decompression.
97    /// Set to `0xFF` for directory blocks (different body layout).
98    pub bits_per_delta: u8,
99    /// Smallest document offset id in this block.
100    pub min_offset: u32,
101    /// Largest document offset id in this block.
102    pub max_offset: u32,
103    /// Largest weight in this block. Used by block-max pruning to skip
104    /// entire blocks whose max contribution cannot beat the threshold.
105    pub max_weight: f32,
106}
107
108impl PostingBlockHeader {
109    pub fn is_directory(&self) -> bool {
110        self.bits_per_delta == DIRECTORY_SENTINEL
111    }
112}
113
114#[derive(Debug, Clone)]
115struct Decompressed {
116    offsets: Vec<u32>,
117    values: Vec<f32>,
118}
119
120/// Body payload of a [`SparsePostingBlock`].
121///
122/// - **`Encoded`**: raw body bytes (after the 16-byte header). Used for
123///   deserialized posting blocks (decoded lazily on first `decode()` call)
124///   and for directory blocks (read directly by `DirectoryBlock::entries()`).
125/// - **`Decoded`**: materialized offsets and values. Produced by
126///   `from_sorted_entries` or by calling `decode()` on an `Encoded` block.
127#[derive(Debug, Clone)]
128enum PostingBody {
129    Encoded(Vec<u8>),
130    Decoded(Decompressed),
131}
132
133/// A compressed block of posting list entries for sparse vector search.
134///
135/// # On-disk format
136///
137/// ```text
138/// ┌────────────────────────────── 16-byte header ─────────────────────────────┐
139/// │ num_entries(u16) │ bits_per_delta(u8) │ reserved(u8) │ min_offset(u32) │ │
140/// │ max_offset(u32)  │ max_weight(f32)                                      │
141/// └──────────────────────────────────────────────────────────────────────────┘
142/// ┌──── body ────────────────────────────────────────────────────────────────┐
143/// │ bitpacked delta-encoded doc offsets (BitPacker4x, groups of 128)        │
144/// │ — ceil(num_entries / 128) groups, last group padded to 128 entries      │
145/// │ f16 little-endian weights (2 bytes × num_entries, no padding)           │
146/// └──────────────────────────────────────────────────────────────────────────┘
147/// ```
148///
149/// # Dual access modes
150///
151/// This type supports two access patterns used by different cursor modes in
152/// the query pipeline:
153///
154/// - **Materialized** (`decode()`): Decompresses the full block into owned
155///   `Vec`s. Used by *eager cursors* for small dimensions. Transitions the
156///   body from `Encoded` to `Decoded` on first call.
157///
158/// - **Zero-copy** (`peek_header`, `decompress_offsets_into`, `read_value_at`,
159///   `raw_weight_bytes`): Static methods that operate directly on a `&[u8]`
160///   slice (e.g. from an Arrow block cache) without constructing a
161///   `SparsePostingBlock`. Used by *lazy/view cursors* for large dimensions
162///   where we only touch a fraction of each block's entries.
163#[derive(Debug, Clone)]
164pub struct SparsePostingBlock {
165    /// The 16-byte header fields (num_entries, bits_per_delta, min/max offset, max weight).
166    pub header: PostingBlockHeader,
167    body: PostingBody,
168}
169
170impl SparsePostingBlock {
171    /// Build a block from pre-sorted `(offset, value)` pairs.
172    pub fn from_sorted_entries(entries: &[(u32, f32)]) -> Result<Self, SparsePostingBlockError> {
173        if entries.is_empty() {
174            return Err(SparsePostingBlockError::EmptyEntries);
175        }
176        if entries.len() > MAX_BLOCK_ENTRIES {
177            return Err(SparsePostingBlockError::TooManyEntries {
178                count: entries.len(),
179            });
180        }
181
182        let n = entries.len();
183        debug_assert!(
184            entries.is_sorted_by_key(|e| e.0),
185            "from_sorted_entries: offsets must be monotonically non-decreasing"
186        );
187        let min_offset = entries[0].0;
188        let max_offset = entries[n - 1].0;
189        let max_weight = entries
190            .iter()
191            .map(|(_, v)| *v)
192            .fold(0.0f32, f32::max)
193            .max(f32::MIN_POSITIVE);
194
195        let (offsets, values): (Vec<u32>, Vec<f32>) = entries.iter().copied().unzip();
196
197        // Compute bits_per_delta by scanning all ceil(n/128) groups of
198        // relative offsets (offset - min_offset). The last group is padded
199        // to 128 with the final relative offset so padding cannot inflate
200        // bits_per_delta.
201        let packer = BitPacker4x::new();
202        let num_groups = n.div_ceil(BITPACK_GROUP_SIZE);
203        let last_relative = max_offset - min_offset;
204        let mut max_bits = 0u8;
205        let mut rel_group = [0u32; BITPACK_GROUP_SIZE];
206        for g in 0..num_groups {
207            let start = g * BITPACK_GROUP_SIZE;
208            let group_offsets = &offsets[start..n.min(start + BITPACK_GROUP_SIZE)];
209            fill_relative_group(group_offsets, min_offset, last_relative, &mut rel_group);
210            let initial = if g == 0 {
211                0
212            } else {
213                offsets[start - 1] - min_offset
214            };
215            max_bits = max_bits.max(packer.num_bits_sorted(initial, &rel_group));
216        }
217
218        Ok(SparsePostingBlock {
219            header: PostingBlockHeader {
220                min_offset,
221                max_offset,
222                max_weight,
223                num_entries: n as u16,
224                bits_per_delta: max_bits,
225            },
226            body: PostingBody::Decoded(Decompressed { offsets, values }),
227        })
228    }
229
230    pub fn len(&self) -> usize {
231        self.header.num_entries as usize
232    }
233
234    pub fn is_empty(&self) -> bool {
235        self.header.num_entries == 0
236    }
237
238    /// Decode this block in place, transitioning from `Encoded` to `Decoded`.
239    ///
240    /// Returns `(&[u32], &[f32])` — the decompressed offsets and values.
241    /// If already `Decoded`, returns the existing data. Returns empty
242    /// slices for directory blocks (which are always `Encoded` and have
243    /// no posting-block-shaped body).
244    ///
245    /// Callers do not need to call this directly — `offsets()` and
246    /// `values()` invoke it automatically.
247    pub fn decode(&mut self) -> (&[u32], &[f32]) {
248        if let PostingBody::Encoded(ref raw) = self.body {
249            if !self.is_directory() {
250                let decoded = Self::decompress_raw(
251                    raw,
252                    self.header.num_entries as usize,
253                    self.header.bits_per_delta,
254                    self.header.min_offset,
255                );
256                self.body = PostingBody::Decoded(decoded);
257            }
258        }
259        match &self.body {
260            PostingBody::Decoded(d) => (&d.offsets, &d.values),
261            PostingBody::Encoded(_) => (&[], &[]),
262        }
263    }
264
265    /// Decompressed doc offsets. Decodes on first call for deserialized
266    /// posting blocks. Returns `&[]` for directory blocks.
267    pub fn offsets(&mut self) -> &[u32] {
268        self.decode().0
269    }
270
271    /// Decompressed f32 weights. Decodes on first call for deserialized
272    /// posting blocks. Returns `&[]` for directory blocks.
273    pub fn values(&mut self) -> &[f32] {
274        self.decode().1
275    }
276
277    fn decompress_raw(
278        raw_body: &[u8],
279        num_entries: usize,
280        bits_per_delta: u8,
281        min_offset: u32,
282    ) -> Decompressed {
283        let mut offsets = Vec::new();
284        Self::decompress_offsets_from_body(
285            raw_body,
286            num_entries,
287            bits_per_delta,
288            min_offset,
289            &mut offsets,
290        );
291
292        let weight_start = Self::body_weight_offset(num_entries, bits_per_delta);
293        let weight_bytes = &raw_body[weight_start..weight_start + num_entries * 2];
294        let values: Vec<f32> = weight_bytes
295            .chunks_exact(2)
296            .map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32())
297            .collect();
298
299        Decompressed { offsets, values }
300    }
301
302    /// Core offset decompression: decompress bitpacked groups from raw body
303    /// bytes (after the 16-byte header) into `buf`, truncated to `num_entries`.
304    fn decompress_offsets_from_body(
305        raw_body: &[u8],
306        num_entries: usize,
307        bits_per_delta: u8,
308        min_offset: u32,
309        buf: &mut Vec<u32>,
310    ) {
311        let packer = BitPacker4x::new();
312        let num_groups = num_entries.div_ceil(BITPACK_GROUP_SIZE);
313        let packed_group_bytes = (BITPACK_GROUP_SIZE * (bits_per_delta as usize)).div_ceil(8);
314
315        let padded_len = num_groups * BITPACK_GROUP_SIZE;
316        buf.clear();
317        buf.resize(padded_len, 0);
318
319        let mut byte_offset = 0;
320        let mut initial = 0u32;
321
322        for g in 0..num_groups {
323            let group_end = byte_offset + packed_group_bytes;
324            let group = &mut buf[g * BITPACK_GROUP_SIZE..(g + 1) * BITPACK_GROUP_SIZE];
325            packer.decompress_sorted(
326                initial,
327                &raw_body[byte_offset..group_end],
328                group,
329                bits_per_delta,
330            );
331            initial = group[BITPACK_GROUP_SIZE - 1];
332            for offset in group.iter_mut() {
333                *offset += min_offset;
334            }
335            byte_offset = group_end;
336        }
337
338        buf.truncate(num_entries);
339    }
340
341    /// Byte offset of the weight section within the body (after the header).
342    fn body_weight_offset(num_entries: usize, bits_per_delta: u8) -> usize {
343        let num_groups = num_entries.div_ceil(BITPACK_GROUP_SIZE);
344        let packed_group_bytes = (BITPACK_GROUP_SIZE * (bits_per_delta as usize)).div_ceil(8);
345        num_groups * packed_group_bytes
346    }
347
348    // ── Serialization ───────────────────────────────────────────────
349
350    /// Serialize to bytes: 16-byte header + bitpacked deltas + f16 weights.
351    pub fn serialize(&self) -> Vec<u8> {
352        let data = match &self.body {
353            PostingBody::Encoded(raw) => {
354                let mut buf = Vec::with_capacity(HEADER_SIZE + raw.len());
355                self.write_header(&mut buf);
356                buf.extend_from_slice(raw);
357                return buf;
358            }
359            PostingBody::Decoded(d) => d,
360        };
361        let n = data.offsets.len();
362        let packer = BitPacker4x::new();
363        let last_relative = self.header.max_offset - self.header.min_offset;
364
365        let num_groups = n.div_ceil(BITPACK_GROUP_SIZE);
366        let packed_group_bytes =
367            (BITPACK_GROUP_SIZE * (self.header.bits_per_delta as usize)).div_ceil(8);
368
369        let mut buf = Vec::with_capacity(self.serialized_size());
370        self.write_header(&mut buf);
371
372        // Scratch buffer sized for the worst case (bits_per_delta = 32):
373        // ceil(128 * 32 / 8) = 512 bytes. Only packed[..packed_group_bytes]
374        // is used per iteration.
375        let mut packed = [0u8; BITPACK_GROUP_SIZE * 4];
376        let mut rel_group = [0u32; BITPACK_GROUP_SIZE];
377        for g in 0..num_groups {
378            let start = g * BITPACK_GROUP_SIZE;
379            let group_offsets = &data.offsets[start..n.min(start + BITPACK_GROUP_SIZE)];
380            fill_relative_group(
381                group_offsets,
382                self.header.min_offset,
383                last_relative,
384                &mut rel_group,
385            );
386            let initial = if g == 0 {
387                0
388            } else {
389                data.offsets[start - 1] - self.header.min_offset
390            };
391            packed[..packed_group_bytes].fill(0);
392            packer.compress_sorted(
393                initial,
394                &rel_group,
395                &mut packed[..packed_group_bytes],
396                self.header.bits_per_delta,
397            );
398            buf.extend_from_slice(&packed[..packed_group_bytes]);
399        }
400
401        for &v in &data.values {
402            buf.extend_from_slice(&f16::from_f32(v).to_le_bytes());
403        }
404
405        buf
406    }
407
408    /// Byte length of the serialized representation (computable without
409    /// decompression).
410    pub fn serialized_size(&self) -> usize {
411        HEADER_SIZE
412            + Self::expected_body_size(self.header.num_entries as usize, self.header.bits_per_delta)
413    }
414
415    /// Deserialize from bytes. Stores body bytes as `Encoded`; call
416    /// `decode()` to decompress posting blocks on first access.
417    ///
418    /// Returns an error if the buffer is too small for the header or the
419    /// body is shorter than the header implies.
420    pub fn deserialize(bytes: &[u8]) -> Result<Self, SparsePostingBlockError> {
421        if bytes.len() < HEADER_SIZE {
422            return Err(SparsePostingBlockError::TruncatedHeader { len: bytes.len() });
423        }
424
425        let num_entries = u16::from_le_bytes([bytes[0], bytes[1]]);
426        let bits_per_delta = bytes[2];
427        // bytes[3] is reserved
428        let min_offset = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
429        let max_offset = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
430        let max_weight = f32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]);
431
432        if bits_per_delta > 32 && bits_per_delta != DIRECTORY_SENTINEL {
433            return Err(SparsePostingBlockError::InvalidBitsPerDelta {
434                value: bits_per_delta,
435            });
436        }
437
438        let expected_body = Self::expected_body_size(num_entries as usize, bits_per_delta);
439        let actual_body = bytes.len() - HEADER_SIZE;
440        if actual_body < expected_body {
441            return Err(SparsePostingBlockError::TruncatedBody {
442                expected: expected_body,
443                actual: actual_body,
444            });
445        }
446
447        Ok(SparsePostingBlock {
448            header: PostingBlockHeader {
449                min_offset,
450                max_offset,
451                max_weight,
452                num_entries,
453                bits_per_delta,
454            },
455            body: PostingBody::Encoded(bytes[HEADER_SIZE..HEADER_SIZE + expected_body].to_vec()),
456        })
457    }
458
459    /// Compute the expected body size (bytes after the header) from header fields.
460    fn expected_body_size(num_entries: usize, bits_per_delta: u8) -> usize {
461        if bits_per_delta == DIRECTORY_SENTINEL {
462            num_entries * DIRECTORY_ENTRY_SIZE
463        } else {
464            Self::body_weight_offset(num_entries, bits_per_delta) + num_entries * 2
465        }
466    }
467
468    fn write_header(&self, buf: &mut Vec<u8>) {
469        buf.extend_from_slice(&self.header.num_entries.to_le_bytes());
470        buf.push(self.header.bits_per_delta);
471        buf.push(0); // reserved — available for format versioning if needed
472        buf.extend_from_slice(&self.header.min_offset.to_le_bytes());
473        buf.extend_from_slice(&self.header.max_offset.to_le_bytes());
474        buf.extend_from_slice(&self.header.max_weight.to_le_bytes());
475    }
476
477    // ── Zero-copy access from raw serialized bytes ──────────────────
478    //
479    // These static methods operate on a raw `&[u8]` (e.g. a pointer into
480    // an Arrow block cache) without constructing a `SparsePostingBlock`.
481    // They are the hot path for lazy/view cursors in the query pipeline.
482
483    /// Read the 16-byte header without heap allocation.
484    pub fn peek_header(bytes: &[u8]) -> Result<PostingBlockHeader, SparsePostingBlockError> {
485        if bytes.len() < HEADER_SIZE {
486            return Err(SparsePostingBlockError::TruncatedHeader { len: bytes.len() });
487        }
488        Ok(PostingBlockHeader {
489            num_entries: u16::from_le_bytes([bytes[0], bytes[1]]),
490            bits_per_delta: bytes[2],
491            min_offset: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
492            max_offset: u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
493            max_weight: f32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
494        })
495    }
496
497    /// Decompress offsets from raw serialized bytes into a reusable buffer.
498    /// Must not be called on directory blocks.
499    pub fn decompress_offsets_into(bytes: &[u8], hdr: &PostingBlockHeader, buf: &mut Vec<u32>) {
500        debug_assert!(
501            !hdr.is_directory(),
502            "decompress_offsets_into called on directory block"
503        );
504        Self::decompress_offsets_from_body(
505            &bytes[HEADER_SIZE..],
506            hdr.num_entries as usize,
507            hdr.bits_per_delta,
508            hdr.min_offset,
509            buf,
510        );
511    }
512
513    /// Zero-copy slice of the raw f16 weight bytes from serialized data.
514    /// Each weight is 2 bytes (f16 little-endian). Must not be called on directory blocks.
515    pub fn raw_weight_bytes<'a>(bytes: &'a [u8], hdr: &PostingBlockHeader) -> &'a [u8] {
516        debug_assert!(
517            !hdr.is_directory(),
518            "raw_weight_bytes called on directory block"
519        );
520        let n = hdr.num_entries as usize;
521        let w_start = Self::weight_byte_offset(hdr);
522        &bytes[w_start..w_start + n * 2]
523    }
524
525    /// Read a single f16 weight at `index` and convert to f32. O(1).
526    /// Must not be called on directory blocks.
527    pub fn read_value_at(bytes: &[u8], hdr: &PostingBlockHeader, index: usize) -> f32 {
528        debug_assert!(
529            !hdr.is_directory(),
530            "read_value_at called on directory block"
531        );
532        debug_assert!(index < hdr.num_entries as usize);
533        let byte_pos = Self::weight_byte_offset(hdr) + index * 2;
534        f16::from_le_bytes([bytes[byte_pos], bytes[byte_pos + 1]]).to_f32()
535    }
536
537    /// Decompress f16 weights from raw serialized bytes into a reusable
538    /// f32 buffer. Must not be called on directory blocks.
539    pub fn decompress_values_into(bytes: &[u8], hdr: &PostingBlockHeader, buf: &mut Vec<f32>) {
540        debug_assert!(
541            !hdr.is_directory(),
542            "decompress_values_into called on directory block"
543        );
544        let n = hdr.num_entries as usize;
545        buf.clear();
546        buf.resize(n, 0.0);
547
548        let w_start = Self::weight_byte_offset(hdr);
549        let f16_bytes = &bytes[w_start..w_start + n * 2];
550        convert_f16_to_f32(f16_bytes, buf);
551    }
552
553    /// Byte offset of the weight section from the start of the serialized
554    /// block (including the 16-byte header).
555    fn weight_byte_offset(hdr: &PostingBlockHeader) -> usize {
556        HEADER_SIZE + Self::body_weight_offset(hdr.num_entries as usize, hdr.bits_per_delta)
557    }
558
559    pub fn is_directory(&self) -> bool {
560        self.header.bits_per_delta == DIRECTORY_SENTINEL
561    }
562}
563
564// ── Directory block ─────────────────────────────────────────────────
565
566/// A metadata block summarizing the posting blocks for a single dimension.
567///
568/// Stores one `(max_offset, max_weight)` pair per posting block, enabling
569/// block-max pruning: the query engine skips entire posting blocks whose
570/// `max_weight * query_weight` cannot beat the current top-k threshold.
571///
572/// Serialized as a [`SparsePostingBlock`] with `bits_per_delta == 0xFF`
573/// (the directory sentinel). The body layout is:
574///
575/// ```text
576/// body = [ max_offset: u32 LE, max_weight: f32 LE ] × num_entries
577/// ```
578///
579/// The header's `max_weight` stores the dimension-level maximum weight
580/// (max of all per-block max_weights), used for early term pruning.
581#[derive(Debug, Clone)]
582pub struct DirectoryBlock(SparsePostingBlock);
583
584impl DirectoryBlock {
585    /// Create a directory block from per-posting-block metadata.
586    ///
587    /// - `max_offsets[i]`: largest doc offset in posting block `i`
588    /// - `max_weights[i]`: largest weight in posting block `i`
589    pub fn new(max_offsets: &[u32], max_weights: &[f32]) -> Result<Self, SparsePostingBlockError> {
590        if max_offsets.len() != max_weights.len() {
591            return Err(SparsePostingBlockError::MismatchedLengths {
592                offsets: max_offsets.len(),
593                weights: max_weights.len(),
594            });
595        }
596        if max_offsets.len() > u16::MAX as usize {
597            return Err(SparsePostingBlockError::TooManyEntries {
598                count: max_offsets.len(),
599            });
600        }
601        let n = max_offsets.len();
602        let dim_max = max_weights.iter().copied().fold(0.0f32, f32::max);
603
604        let mut raw_body = Vec::with_capacity(n * 8);
605        for i in 0..n {
606            raw_body.extend_from_slice(&max_offsets[i].to_le_bytes());
607            raw_body.extend_from_slice(&max_weights[i].to_le_bytes());
608        }
609
610        Ok(DirectoryBlock(SparsePostingBlock {
611            header: PostingBlockHeader {
612                min_offset: max_offsets.first().copied().unwrap_or(0),
613                max_offset: max_offsets.last().copied().unwrap_or(0),
614                max_weight: dim_max,
615                num_entries: n as u16,
616                bits_per_delta: DIRECTORY_SENTINEL,
617            },
618            body: PostingBody::Encoded(raw_body),
619        }))
620    }
621
622    /// Interpret a `SparsePostingBlock` as a directory block.
623    /// Returns `Err` with the original block if it is not a directory.
624    pub fn from_block(block: SparsePostingBlock) -> Result<Self, SparsePostingBlock> {
625        if block.is_directory() {
626            Ok(DirectoryBlock(block))
627        } else {
628            Err(block)
629        }
630    }
631
632    /// Dimension-level maximum weight (max of all per-block max_weights).
633    pub fn dim_max_weight(&self) -> f32 {
634        self.0.header.max_weight
635    }
636
637    /// Number of posting blocks summarized by this directory.
638    pub fn num_blocks(&self) -> usize {
639        self.0.header.num_entries as usize
640    }
641
642    /// Extract `(max_offsets, max_weights)` — one pair per posting block.
643    pub fn entries(&self) -> (Vec<u32>, Vec<f32>) {
644        let raw = match &self.0.body {
645            PostingBody::Encoded(raw) => raw.as_slice(),
646            // Directory blocks are always Encoded by construction
647            // (DirectoryBlock::new and DirectoryBlock::from_block enforce this).
648            // Return empty if somehow Decoded.
649            PostingBody::Decoded(_) => return (Vec::new(), Vec::new()),
650        };
651        let n = self.0.header.num_entries as usize;
652        let mut max_offsets = Vec::with_capacity(n);
653        let mut max_weights = Vec::with_capacity(n);
654        for i in 0..n {
655            let pos = i * 8;
656            max_offsets.push(u32::from_le_bytes([
657                raw[pos],
658                raw[pos + 1],
659                raw[pos + 2],
660                raw[pos + 3],
661            ]));
662            max_weights.push(f32::from_le_bytes([
663                raw[pos + 4],
664                raw[pos + 5],
665                raw[pos + 6],
666                raw[pos + 7],
667            ]));
668        }
669        (max_offsets, max_weights)
670    }
671
672    /// Consume this directory block into its underlying `SparsePostingBlock`
673    /// for storage in the blockstore.
674    pub fn into_block(self) -> SparsePostingBlock {
675        self.0
676    }
677}
678
679// ── Directory (in-memory merged view) ───────────────────────────────
680
681/// In-memory directory covering all posting blocks for a dimension.
682///
683/// Unlike [`DirectoryBlock`] (a single on-disk part limited to `u16`
684/// entries), `Directory` holds the complete `(max_offset, max_weight)`
685/// list with no size restriction. It is the owner on both the write
686/// and read paths:
687///
688/// - **Write**: accumulate entries, then call [`into_parts`] to produce
689///   `Vec<DirectoryBlock>` sized for the blockfile.
690/// - **Read**: load `DirectoryBlock` parts, then call [`from_parts`] to
691///   get back the full `Directory`.
692#[derive(Debug, Clone)]
693pub struct Directory {
694    max_offsets: Vec<u32>,
695    max_weights: Vec<f32>,
696    dim_max_weight: f32,
697}
698
699impl Directory {
700    /// Create a directory from per-posting-block metadata.
701    pub fn new(
702        max_offsets: Vec<u32>,
703        max_weights: Vec<f32>,
704    ) -> Result<Self, SparsePostingBlockError> {
705        if max_offsets.len() != max_weights.len() {
706            return Err(SparsePostingBlockError::MismatchedLengths {
707                offsets: max_offsets.len(),
708                weights: max_weights.len(),
709            });
710        }
711        if max_offsets.is_empty() {
712            return Err(SparsePostingBlockError::EmptyEntries);
713        }
714        let dim_max_weight = max_weights.iter().copied().fold(0.0f32, f32::max);
715        Ok(Directory {
716            max_offsets,
717            max_weights,
718            dim_max_weight,
719        })
720    }
721
722    /// Reconstruct a directory from on-disk [`DirectoryBlock`] parts.
723    pub fn from_parts(
724        parts: impl IntoIterator<Item = DirectoryBlock>,
725    ) -> Result<Self, SparsePostingBlockError> {
726        let mut max_offsets = Vec::new();
727        let mut max_weights = Vec::new();
728        for part in parts {
729            let (o, w) = part.entries();
730            max_offsets.extend(o);
731            max_weights.extend(w);
732        }
733        Self::new(max_offsets, max_weights)
734    }
735
736    /// Split into [`DirectoryBlock`] parts that each fit within a
737    /// block-size budget.
738    ///
739    /// Use [`max_entries_for_block_size`] to derive `max_entries_per_part`
740    /// from the blockfile's `max_block_size_bytes`.
741    pub fn into_parts(self, max_entries_per_part: usize) -> Vec<DirectoryBlock> {
742        let cap = max_entries_per_part.max(1).min(u16::MAX as usize);
743        self.max_offsets
744            .chunks(cap)
745            .zip(self.max_weights.chunks(cap))
746            .map(|(o, w)| DirectoryBlock::new(o, w).expect("chunk from valid directory"))
747            .collect()
748    }
749
750    pub fn max_offsets(&self) -> &[u32] {
751        &self.max_offsets
752    }
753
754    pub fn max_weights(&self) -> &[f32] {
755        &self.max_weights
756    }
757
758    /// Dimension-level maximum weight (max of all per-block max_weights).
759    pub fn dim_max_weight(&self) -> f32 {
760        self.dim_max_weight
761    }
762
763    /// Number of posting blocks summarized.
764    pub fn num_blocks(&self) -> usize {
765        self.max_offsets.len()
766    }
767
768    /// Maximum directory entries per [`DirectoryBlock`] part that fit
769    /// within `max_block_size_bytes`.
770    ///
771    /// Each entry is 8 bytes (u32 offset + f32 weight) plus a 16-byte
772    /// header per part. A fixed headroom is reserved for Arrow per-row
773    /// overhead (offset buffers, prefix/key columns, alignment padding).
774    pub fn max_entries_for_block_size(max_block_size_bytes: usize) -> usize {
775        const ARROW_OVERHEAD_ESTIMATE: usize = 256;
776        max_block_size_bytes.saturating_sub(HEADER_SIZE + ARROW_OVERHEAD_ESTIMATE)
777            / DIRECTORY_ENTRY_SIZE
778    }
779}
780
781// ── f16 → f32 bulk conversion ────────────────────────────────────────
782
783/// Convert a slice of little-endian f16 bytes into f32 values.
784/// Dispatches to SIMD on supported architectures, scalar fallback otherwise.
785pub fn convert_f16_to_f32(f16_bytes: &[u8], out: &mut [f32]) {
786    #[cfg(target_arch = "aarch64")]
787    {
788        convert_f16_to_f32_neon(f16_bytes, out);
789        return;
790    }
791    #[cfg(target_arch = "x86_64")]
792    {
793        if is_x86_feature_detected!("avx512f") {
794            // SAFETY: avx512f detected at runtime; inputs are valid
795            // f16 byte slices and output buffer is correctly sized.
796            unsafe { convert_f16_to_f32_avx512(f16_bytes, out) };
797            return;
798        }
799        if is_x86_feature_detected!("f16c") {
800            // SAFETY: f16c detected at runtime; inputs are valid
801            // f16 byte slices and output buffer is correctly sized.
802            unsafe { convert_f16_to_f32_f16c(f16_bytes, out) };
803            return;
804        }
805    }
806    #[allow(unreachable_code)]
807    convert_f16_to_f32_scalar(f16_bytes, out);
808}
809
810/// Scalar f16→f32 conversion via the `half` crate.
811pub fn convert_f16_to_f32_scalar(f16_bytes: &[u8], out: &mut [f32]) {
812    for (o, chunk) in out.iter_mut().zip(f16_bytes.chunks_exact(2)) {
813        *o = f16::from_le_bytes([chunk[0], chunk[1]]).to_f32();
814    }
815}
816
817/// NEON f16→f32 via bit manipulation. Processes 8 values at a time.
818///
819/// Uses integer shift+mask+bias to convert f16 bit patterns to f32 bit
820/// patterns. Handles all normal f16 values correctly; subnormals map to
821/// a tiny positive value rather than zero, which is acceptable for
822/// SPLADE/BM25 weights that are always positive normals.
823#[cfg(target_arch = "aarch64")]
824fn convert_f16_to_f32_neon(f16_bytes: &[u8], out: &mut [f32]) {
825    use std::arch::aarch64::*;
826
827    let n = out.len();
828    let chunks = n / 8;
829
830    // SAFETY: NEON is always available on aarch64. Pointer arithmetic is
831    // bounded by `chunks * 8 <= n` for output and `chunks * 16 <= f16_bytes.len()`
832    // for input (caller guarantees f16_bytes.len() >= out.len() * 2).
833    unsafe {
834        let sign_mask = vdupq_n_u32(0x8000);
835        let nosign_mask = vdupq_n_u32(0x7FFF);
836        let bias = vdupq_n_u32(0x3800_0000); // (127 - 15) << 23
837
838        for c in 0..chunks {
839            let base = c * 8;
840            let byte_base = base * 2;
841
842            let h8 = vld1q_u16(f16_bytes.as_ptr().add(byte_base) as *const u16);
843            let lo = vmovl_u16(vget_low_u16(h8));
844            let hi = vmovl_u16(vget_high_u16(h8));
845
846            macro_rules! cvt {
847                ($h:expr, $off:expr) => {{
848                    let sign = vshlq_n_u32::<16>(vandq_u32($h, sign_mask));
849                    let nosign = vshlq_n_u32::<13>(vandq_u32($h, nosign_mask));
850                    let bits = vorrq_u32(sign, vaddq_u32(nosign, bias));
851                    vst1q_f32(
852                        out.as_mut_ptr().add(base + $off),
853                        vreinterpretq_f32_u32(bits),
854                    );
855                }};
856            }
857            cvt!(lo, 0);
858            cvt!(hi, 4);
859        }
860    }
861
862    let rem_start = chunks * 8;
863    convert_f16_to_f32_scalar(&f16_bytes[rem_start * 2..], &mut out[rem_start..]);
864}
865
866/// x86_64 AVX-512: `_mm512_cvtph_ps` converts 16×f16 → 16×f32 in one instruction.
867#[cfg(target_arch = "x86_64")]
868#[target_feature(enable = "avx512f")]
869unsafe fn convert_f16_to_f32_avx512(f16_bytes: &[u8], out: &mut [f32]) {
870    use std::arch::x86_64::*;
871
872    let n = out.len();
873    let chunks = n / 16;
874
875    for c in 0..chunks {
876        let base = c * 16;
877        let byte_base = base * 2;
878        let h16 = _mm256_loadu_si256(f16_bytes.as_ptr().add(byte_base) as *const __m256i);
879        let f16_out = _mm512_cvtph_ps(h16);
880        _mm512_storeu_ps(out.as_mut_ptr().add(base), f16_out);
881    }
882
883    let rem_start = chunks * 16;
884    convert_f16_to_f32_scalar(&f16_bytes[rem_start * 2..], &mut out[rem_start..]);
885}
886
887/// x86_64 F16C: `_mm256_cvtph_ps` converts 8×f16 → 8×f32 in one instruction.
888#[cfg(target_arch = "x86_64")]
889#[target_feature(enable = "f16c")]
890unsafe fn convert_f16_to_f32_f16c(f16_bytes: &[u8], out: &mut [f32]) {
891    use std::arch::x86_64::*;
892
893    let n = out.len();
894    let chunks = n / 8;
895
896    // SAFETY: f16c target_feature is enabled. Pointer arithmetic is bounded
897    // by `chunks * 8 <= n` for output and `chunks * 16 <= f16_bytes.len()`.
898    for c in 0..chunks {
899        let base = c * 8;
900        let byte_base = base * 2;
901        let h8 = _mm_loadu_si128(f16_bytes.as_ptr().add(byte_base) as *const __m128i);
902        let f8 = _mm256_cvtph_ps(h8);
903        _mm256_storeu_ps(out.as_mut_ptr().add(base), f8);
904    }
905
906    let rem_start = chunks * 8;
907    convert_f16_to_f32_scalar(&f16_bytes[rem_start * 2..], &mut out[rem_start..]);
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913
914    const F16_TOL: f32 = 1e-3;
915
916    fn make_block(entries: &[(u32, f32)]) -> SparsePostingBlock {
917        SparsePostingBlock::from_sorted_entries(entries).expect("make_block: invalid entries")
918    }
919
920    fn sequential_entries(start: u32, step: u32, count: usize, weight: f32) -> Vec<(u32, f32)> {
921        (0..count)
922            .map(|i| (start + step * i as u32, weight))
923            .collect()
924    }
925
926    fn assert_approx(actual: f32, expected: f32, tol: f32) {
927        assert!(
928            (actual - expected).abs() <= tol,
929            "expected {expected} +/- {tol}, got {actual}"
930        );
931    }
932
933    fn assert_roundtrip_offsets(entries: &[(u32, f32)]) {
934        let mut block = make_block(entries);
935        let bytes = block.serialize();
936        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
937        assert_eq!(restored.offsets(), block.offsets());
938    }
939
940    fn assert_roundtrip_values(entries: &[(u32, f32)]) {
941        let mut block = make_block(entries);
942        let bytes = block.serialize();
943        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
944        for (i, (&orig, &rest)) in block
945            .values()
946            .iter()
947            .zip(restored.values().iter())
948            .enumerate()
949        {
950            assert!(
951                (rest - orig).abs() <= F16_TOL,
952                "entry {i}: expected {orig} +/- {F16_TOL}, got {rest}"
953            );
954        }
955    }
956
957    #[test]
958    fn roundtrip_at_boundary_sizes() {
959        // Covers: single entry, sub-group, exact group boundary, group+1,
960        // 2 groups-1, 2 groups, 4 groups, and max capacity.
961        for count in [1, 3, 127, 128, 129, 255, 256, 512, MAX_BLOCK_ENTRIES] {
962            let entries = sequential_entries(0, 1, count, 0.5);
963            assert_roundtrip_offsets(&entries);
964            assert_roundtrip_values(&entries);
965        }
966    }
967
968    #[test]
969    fn padding_does_not_inflate_bits_per_delta() {
970        // 129 entries: one full group (128) + 1 real entry padded to 128.
971        // Consecutive offsets → bits_per_delta should be 1.
972        // If padding used 0 instead of the last relative offset, the
973        // padded group would have a large backward delta → wrong bits.
974        let entries = sequential_entries(0, 1, 129, 0.5);
975        let block = make_block(&entries);
976        assert_eq!(block.header.bits_per_delta, 1);
977
978        // Same for a single entry (padded to a full group of 128).
979        let single = make_block(&[(42, 0.5)]);
980        assert_eq!(single.header.bits_per_delta, 0);
981    }
982
983    #[test]
984    fn roundtrip_large_deltas() {
985        let entries = vec![(0, 0.5), (1_000_000, 0.8), (2_000_000, 0.3)];
986        assert_roundtrip_offsets(&entries);
987        assert_roundtrip_values(&entries);
988    }
989
990    #[test]
991    fn roundtrip_tiny_weights() {
992        let entries = vec![(0, 0.001), (1, 1.0)];
993        let mut block = make_block(&entries);
994        let bytes = block.serialize();
995        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
996        assert_eq!(restored.offsets(), block.offsets());
997        assert_approx(restored.values()[1], 1.0, F16_TOL);
998        assert!(restored.values()[0] < 0.01);
999    }
1000
1001    #[test]
1002    fn header_fields() {
1003        let entries = vec![(10, 0.5), (20, 0.9), (30, 0.2)];
1004        let block = make_block(&entries);
1005        let bytes = block.serialize();
1006        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1007        assert_eq!(restored.header.min_offset, 10);
1008        assert_eq!(restored.header.max_offset, 30);
1009        assert_eq!(restored.header.max_weight, 0.9);
1010        assert_eq!(restored.offsets().len(), 3);
1011    }
1012
1013    #[test]
1014    fn peek_header_matches() {
1015        let entries = sequential_entries(100, 5, 200, 0.42);
1016        let block = make_block(&entries);
1017        let bytes = block.serialize();
1018        let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1019        assert_eq!(hdr.num_entries, 200);
1020        assert_eq!(hdr.min_offset, 100);
1021        assert_eq!(hdr.max_offset, 100 + 5 * 199);
1022    }
1023
1024    #[test]
1025    fn raw_weight_bytes_length() {
1026        let entries = sequential_entries(0, 1, 200, 0.5);
1027        let block = make_block(&entries);
1028        let bytes = block.serialize();
1029        let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1030        let wb = SparsePostingBlock::raw_weight_bytes(&bytes, &hdr);
1031        assert_eq!(wb.len(), 200 * 2);
1032    }
1033
1034    #[test]
1035    fn serialized_size_matches_actual() {
1036        for count in [1, 3, 127, 128, 129, 255, 256, 257, 512, 1024] {
1037            let entries = sequential_entries(0, 1, count, 0.5);
1038            let block = make_block(&entries);
1039            let bytes = block.serialize();
1040            assert_eq!(
1041                block.serialized_size(),
1042                bytes.len(),
1043                "serialized_size mismatch for count={count}"
1044            );
1045        }
1046    }
1047
1048    #[test]
1049    fn directory_block_roundtrip() {
1050        let max_offsets = vec![100, 500, 1000];
1051        let max_weights = vec![0.9, 0.7, 0.5];
1052        let dir = DirectoryBlock::new(&max_offsets, &max_weights).unwrap();
1053        assert_eq!(dir.dim_max_weight(), 0.9);
1054        assert_eq!(dir.num_blocks(), 3);
1055
1056        let block = dir.into_block();
1057        assert!(block.is_directory());
1058        let bytes = block.serialize();
1059
1060        let restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1061        assert!(restored.is_directory());
1062        let dir2 = DirectoryBlock::from_block(restored).unwrap();
1063        let (offsets, weights) = dir2.entries();
1064        assert_eq!(offsets, max_offsets);
1065        assert_eq!(weights, max_weights);
1066    }
1067
1068    #[test]
1069    fn directory_from_block_rejects_posting_block() {
1070        let entries = vec![(0, 1.0), (5, 0.5)];
1071        let block = make_block(&entries);
1072        assert!(!block.is_directory());
1073        let err = DirectoryBlock::from_block(block).unwrap_err();
1074        assert!(!err.is_directory());
1075    }
1076
1077    #[test]
1078    fn deserialize_too_short_returns_err() {
1079        assert!(SparsePostingBlock::deserialize(&[0u8; 15]).is_err());
1080        assert!(SparsePostingBlock::deserialize(&[]).is_err());
1081    }
1082
1083    #[test]
1084    fn deserialize_truncated_body_returns_err() {
1085        let entries = sequential_entries(0, 1, 200, 0.5);
1086        let block = make_block(&entries);
1087        let bytes = block.serialize();
1088
1089        let truncated = &bytes[..bytes.len() - 1];
1090        let err = SparsePostingBlock::deserialize(truncated).unwrap_err();
1091        assert!(
1092            matches!(err, SparsePostingBlockError::TruncatedBody { .. }),
1093            "expected TruncatedBody, got {err:?}"
1094        );
1095    }
1096
1097    #[test]
1098    fn deserialize_truncated_directory_body_returns_err() {
1099        let dir = DirectoryBlock::new(&[10, 20, 30], &[0.5, 0.9, 0.2]).unwrap();
1100        let bytes = dir.into_block().serialize();
1101
1102        let truncated = &bytes[..HEADER_SIZE + 3 * 8 - 1];
1103        let err = SparsePostingBlock::deserialize(truncated).unwrap_err();
1104        assert!(
1105            matches!(err, SparsePostingBlockError::TruncatedBody { .. }),
1106            "expected TruncatedBody, got {err:?}"
1107        );
1108    }
1109
1110    #[test]
1111    fn deserialize_header_only_data_block_returns_err() {
1112        let entries = sequential_entries(0, 1, 200, 0.5);
1113        let block = make_block(&entries);
1114        let bytes = block.serialize();
1115
1116        let err = SparsePostingBlock::deserialize(&bytes[..HEADER_SIZE]).unwrap_err();
1117        assert!(matches!(err, SparsePostingBlockError::TruncatedBody { .. }));
1118    }
1119
1120    #[test]
1121    fn deserialize_extra_trailing_bytes_ignored() {
1122        let entries = sequential_entries(0, 1, 50, 0.5);
1123        let mut block = make_block(&entries);
1124        let mut bytes = block.serialize();
1125        bytes.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
1126
1127        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1128        assert_eq!(restored.offsets(), block.offsets());
1129    }
1130
1131    #[test]
1132    fn quantization_precision_random() {
1133        use std::collections::hash_map::DefaultHasher;
1134        use std::hash::{Hash, Hasher};
1135
1136        fn cheap_rng(seed: u64, i: usize) -> f32 {
1137            let mut h = DefaultHasher::new();
1138            seed.hash(&mut h);
1139            i.hash(&mut h);
1140            let bits = h.finish();
1141            (bits % 1000) as f32 / 1000.0 * 0.99 + 0.01
1142        }
1143
1144        let entries: Vec<(u32, f32)> = (0..256)
1145            .map(|i| (i as u32 * 7, cheap_rng(12345, i)))
1146            .collect();
1147
1148        let mut block = make_block(&entries);
1149        let bytes = block.serialize();
1150        let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1151
1152        for (i, (&orig, &rest)) in block
1153            .values()
1154            .iter()
1155            .zip(restored.values().iter())
1156            .enumerate()
1157        {
1158            assert!(
1159                (rest - orig).abs() <= F16_TOL,
1160                "entry {i}: expected {orig} +/- {F16_TOL}, got {rest}"
1161            );
1162        }
1163    }
1164
1165    // ── Error path tests ────────────────────────────────────────────
1166
1167    #[test]
1168    fn from_sorted_entries_empty_returns_error() {
1169        let err = SparsePostingBlock::from_sorted_entries(&[]).unwrap_err();
1170        assert!(matches!(err, SparsePostingBlockError::EmptyEntries));
1171    }
1172
1173    #[test]
1174    fn from_sorted_entries_too_many_returns_error() {
1175        let entries: Vec<(u32, f32)> = (0..MAX_BLOCK_ENTRIES + 1)
1176            .map(|i| (i as u32, 0.5))
1177            .collect();
1178        let err = SparsePostingBlock::from_sorted_entries(&entries).unwrap_err();
1179        assert!(
1180            matches!(err, SparsePostingBlockError::TooManyEntries { count } if count == MAX_BLOCK_ENTRIES + 1)
1181        );
1182    }
1183
1184    #[test]
1185    fn from_sorted_entries_at_max_succeeds() {
1186        let entries: Vec<(u32, f32)> = (0..MAX_BLOCK_ENTRIES).map(|i| (i as u32, 0.5)).collect();
1187        let block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1188        assert_eq!(block.len(), MAX_BLOCK_ENTRIES);
1189    }
1190
1191    #[test]
1192    fn directory_new_mismatched_lengths_returns_error() {
1193        let err = DirectoryBlock::new(&[1, 2, 3], &[0.5, 0.5]).unwrap_err();
1194        assert!(matches!(
1195            err,
1196            SparsePostingBlockError::MismatchedLengths {
1197                offsets: 3,
1198                weights: 2,
1199            }
1200        ));
1201    }
1202
1203    // ── Directory block on offsets/values returns empty ──────────────
1204
1205    #[test]
1206    fn directory_block_offsets_values_return_empty() {
1207        let dir = DirectoryBlock::new(&[100], &[0.5]).unwrap();
1208        let mut block = dir.into_block();
1209        assert!(block.is_directory());
1210        assert_eq!(block.offsets(), &[] as &[u32]);
1211        assert_eq!(block.values(), &[] as &[f32]);
1212    }
1213
1214    // ── Directory (in-memory partitioned view) ────────────────────────
1215
1216    fn make_dir_data(n: usize) -> (Vec<u32>, Vec<f32>) {
1217        let offsets: Vec<u32> = (0..n).map(|i| (i as u32 + 1) * 100).collect();
1218        let weights: Vec<f32> = (0..n).map(|i| 0.1 + 0.001 * i as f32).collect();
1219        (offsets, weights)
1220    }
1221
1222    #[test]
1223    fn directory_into_parts_single_part() {
1224        let (offsets, weights) = make_dir_data(10);
1225        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1226        let parts = dir.into_parts(100);
1227        assert_eq!(parts.len(), 1);
1228        assert_eq!(parts[0].num_blocks(), 10);
1229        let (o, w) = parts[0].entries();
1230        assert_eq!(o, offsets);
1231        assert_eq!(w, weights);
1232    }
1233
1234    #[test]
1235    fn directory_into_parts_exact_split() {
1236        let (offsets, weights) = make_dir_data(100);
1237        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1238        let parts = dir.into_parts(50);
1239        assert_eq!(parts.len(), 2);
1240        assert_eq!(parts[0].num_blocks(), 50);
1241        assert_eq!(parts[1].num_blocks(), 50);
1242
1243        let merged = Directory::from_parts(parts).unwrap();
1244        assert_eq!(merged.max_offsets(), &offsets[..]);
1245        assert_eq!(merged.max_weights(), &weights[..]);
1246    }
1247
1248    #[test]
1249    fn directory_into_parts_uneven_split() {
1250        let (offsets, weights) = make_dir_data(105);
1251        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1252        let parts = dir.into_parts(50);
1253        assert_eq!(parts.len(), 3);
1254        assert_eq!(parts[0].num_blocks(), 50);
1255        assert_eq!(parts[1].num_blocks(), 50);
1256        assert_eq!(parts[2].num_blocks(), 5);
1257
1258        let merged = Directory::from_parts(parts).unwrap();
1259        assert_eq!(merged.max_offsets(), &offsets[..]);
1260        assert_eq!(merged.max_weights(), &weights[..]);
1261    }
1262
1263    #[test]
1264    fn directory_into_parts_one_per_part() {
1265        let (offsets, weights) = make_dir_data(5);
1266        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1267        let parts = dir.into_parts(1);
1268        assert_eq!(parts.len(), 5);
1269        for (i, part) in parts.iter().enumerate() {
1270            assert_eq!(part.num_blocks(), 1);
1271            let (o, w) = part.entries();
1272            assert_eq!(o, vec![offsets[i]]);
1273            assert_eq!(w, vec![weights[i]]);
1274        }
1275
1276        let merged = Directory::from_parts(parts).unwrap();
1277        assert_eq!(merged.max_offsets(), &offsets[..]);
1278        assert_eq!(merged.max_weights(), &weights[..]);
1279    }
1280
1281    #[test]
1282    fn directory_into_parts_single_entry() {
1283        let dir = Directory::new(vec![42], vec![0.5]).unwrap();
1284        let parts = dir.into_parts(100);
1285        assert_eq!(parts.len(), 1);
1286        let (o, w) = parts[0].entries();
1287        assert_eq!(o, vec![42]);
1288        assert_eq!(w, vec![0.5]);
1289    }
1290
1291    #[test]
1292    fn directory_roundtrip_through_serialize() {
1293        let (offsets, weights) = make_dir_data(250);
1294        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1295        let parts = dir.into_parts(100);
1296        assert_eq!(parts.len(), 3);
1297
1298        let restored_parts: Vec<DirectoryBlock> = parts
1299            .into_iter()
1300            .map(|p| {
1301                let bytes = p.into_block().serialize();
1302                let block = SparsePostingBlock::deserialize(&bytes).unwrap();
1303                assert!(block.is_directory());
1304                DirectoryBlock::from_block(block).unwrap()
1305            })
1306            .collect();
1307
1308        let merged = Directory::from_parts(restored_parts).unwrap();
1309        assert_eq!(merged.max_offsets(), &offsets[..]);
1310        assert_eq!(merged.max_weights(), &weights[..]);
1311    }
1312
1313    #[test]
1314    fn directory_large_partitioned() {
1315        let n = 10_000;
1316        let (offsets, weights) = make_dir_data(n);
1317        let max_per_part = Directory::max_entries_for_block_size(16384);
1318
1319        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1320        let parts = dir.into_parts(max_per_part);
1321        assert!(parts.len() > 1, "should produce multiple parts at 16KiB");
1322        for part in &parts {
1323            let block = part.clone().into_block();
1324            assert!(
1325                block.serialized_size() <= 16384,
1326                "part serialized size {} exceeds 16KiB",
1327                block.serialized_size()
1328            );
1329        }
1330
1331        let merged = Directory::from_parts(parts).unwrap();
1332        assert_eq!(merged.max_offsets(), &offsets[..]);
1333        assert_eq!(merged.max_weights(), &weights[..]);
1334    }
1335
1336    #[test]
1337    fn directory_exceeds_u16_entries() {
1338        let n = 70_000; // > u16::MAX (65535)
1339        let (offsets, weights) = make_dir_data(n);
1340        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1341        assert_eq!(dir.num_blocks(), n);
1342        assert_eq!(dir.max_offsets().len(), n);
1343
1344        let parts = dir.into_parts(10_000);
1345        assert_eq!(parts.len(), 7);
1346
1347        // Full serialize/deserialize roundtrip of every part.
1348        let restored: Vec<DirectoryBlock> = parts
1349            .into_iter()
1350            .map(|p| {
1351                let bytes = p.into_block().serialize();
1352                let block = SparsePostingBlock::deserialize(&bytes).unwrap();
1353                assert!(block.is_directory());
1354                DirectoryBlock::from_block(block).unwrap()
1355            })
1356            .collect();
1357
1358        let merged = Directory::from_parts(restored).unwrap();
1359        assert_eq!(merged.num_blocks(), n);
1360        assert_eq!(merged.max_offsets(), &offsets[..]);
1361        assert_eq!(merged.max_weights(), &weights[..]);
1362    }
1363
1364    #[test]
1365    fn directory_into_parts_zero_clamps_to_one() {
1366        let (offsets, weights) = make_dir_data(3);
1367        let dir = Directory::new(offsets.clone(), weights.clone()).unwrap();
1368        let parts = dir.into_parts(0);
1369        assert_eq!(parts.len(), 3);
1370        for part in &parts {
1371            assert_eq!(part.num_blocks(), 1);
1372        }
1373        let merged = Directory::from_parts(parts).unwrap();
1374        assert_eq!(merged.max_offsets(), &offsets[..]);
1375        assert_eq!(merged.max_weights(), &weights[..]);
1376    }
1377
1378    #[test]
1379    fn directory_from_parts_preserves_dim_max() {
1380        let parts = vec![
1381            DirectoryBlock::new(&[10, 20], &[0.3, 0.5]).unwrap(),
1382            DirectoryBlock::new(&[30, 40], &[0.9, 0.1]).unwrap(),
1383            DirectoryBlock::new(&[50], &[0.6]).unwrap(),
1384        ];
1385        let merged = Directory::from_parts(parts).unwrap();
1386        assert_eq!(merged.dim_max_weight(), 0.9);
1387        assert_eq!(merged.num_blocks(), 5);
1388    }
1389
1390    #[test]
1391    fn directory_max_entries_for_block_size() {
1392        const OVERHEAD: usize = HEADER_SIZE + 256;
1393        assert_eq!(
1394            Directory::max_entries_for_block_size(16384),
1395            (16384 - OVERHEAD) / DIRECTORY_ENTRY_SIZE
1396        );
1397        assert_eq!(
1398            Directory::max_entries_for_block_size(512 * 1024),
1399            (512 * 1024 - OVERHEAD) / DIRECTORY_ENTRY_SIZE
1400        );
1401        assert_eq!(Directory::max_entries_for_block_size(OVERHEAD), 0);
1402        assert_eq!(Directory::max_entries_for_block_size(0), 0);
1403    }
1404
1405    #[test]
1406    fn directory_from_parts_single() {
1407        let part = DirectoryBlock::new(&[10, 20, 30], &[0.5, 0.9, 0.2]).unwrap();
1408        let dir = Directory::from_parts(vec![part]).unwrap();
1409        assert_eq!(dir.max_offsets(), &[10, 20, 30]);
1410        assert_eq!(dir.max_weights(), &[0.5, 0.9, 0.2]);
1411    }
1412
1413    #[test]
1414    fn directory_from_parts_empty_returns_error() {
1415        let err = Directory::from_parts(vec![]).unwrap_err();
1416        assert!(matches!(err, SparsePostingBlockError::EmptyEntries));
1417    }
1418
1419    #[test]
1420    fn directory_new_empty_returns_error() {
1421        let err = Directory::new(vec![], vec![]).unwrap_err();
1422        assert!(matches!(err, SparsePostingBlockError::EmptyEntries));
1423    }
1424
1425    #[test]
1426    fn directory_new_mismatched_returns_error() {
1427        let err = Directory::new(vec![1, 2, 3], vec![0.5]).unwrap_err();
1428        assert!(matches!(
1429            err,
1430            SparsePostingBlockError::MismatchedLengths { .. }
1431        ));
1432    }
1433
1434    #[test]
1435    fn directory_prefix_constant() {
1436        assert_eq!(DIRECTORY_PREFIX, "~");
1437    }
1438
1439    // ── len/is_empty coverage ───────────────────────────────────────
1440
1441    #[test]
1442    fn len_and_is_empty() {
1443        let block1 = make_block(&[(0, 1.0)]);
1444        assert_eq!(block1.len(), 1);
1445        assert!(!block1.is_empty());
1446
1447        let block200 = make_block(&sequential_entries(0, 1, 200, 0.5));
1448        assert_eq!(block200.len(), 200);
1449        assert!(!block200.is_empty());
1450    }
1451
1452    // ── High offset values ──────────────────────────────────────────
1453
1454    #[test]
1455    fn roundtrip_high_offsets() {
1456        let base = u32::MAX - 1000;
1457        let entries: Vec<(u32, f32)> = (0..10).map(|i| (base + i * 100, 0.5)).collect();
1458        assert_roundtrip_offsets(&entries);
1459        assert_roundtrip_values(&entries);
1460    }
1461
1462    #[test]
1463    fn roundtrip_u32_max_single() {
1464        let entries = vec![(u32::MAX, 0.42)];
1465        assert_roundtrip_offsets(&entries);
1466        assert_roundtrip_values(&entries);
1467    }
1468
1469    // ── Non-uniform deltas ──────────────────────────────────────────
1470
1471    #[test]
1472    fn roundtrip_varied_deltas() {
1473        let entries = vec![
1474            (0, 0.1),
1475            (1, 0.2),
1476            (100, 0.3),
1477            (101, 0.4),
1478            (10_000, 0.5),
1479            (10_001, 0.6),
1480            (1_000_000, 0.7),
1481        ];
1482        assert_roundtrip_offsets(&entries);
1483        assert_roundtrip_values(&entries);
1484    }
1485
1486    // ── Double-serialize stability ──────────────────────────────────
1487
1488    #[test]
1489    fn serialize_deserialize_serialize_is_stable() {
1490        for count in [1, 3, 127, 128, 129, 255, 256, 512] {
1491            let entries = sequential_entries(0, 7, count, 0.5);
1492            let block = make_block(&entries);
1493            let bytes1 = block.serialize();
1494            let restored = SparsePostingBlock::deserialize(&bytes1).unwrap();
1495            let bytes2 = restored.serialize();
1496            assert_eq!(
1497                bytes1, bytes2,
1498                "double-serialize mismatch for count={count}"
1499            );
1500        }
1501    }
1502
1503    // ── raw_weight_bytes content verification ───────────────────────
1504
1505    #[test]
1506    fn raw_weight_bytes_content_correct() {
1507        let entries: Vec<(u32, f32)> = (0..5).map(|i| (i * 10, 0.1 * (i as f32 + 1.0))).collect();
1508        let block = make_block(&entries);
1509        let bytes = block.serialize();
1510        let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1511        let wb = SparsePostingBlock::raw_weight_bytes(&bytes, &hdr);
1512        assert_eq!(wb.len(), 5 * 2);
1513
1514        for i in 0..5 {
1515            let f = f16::from_le_bytes([wb[i * 2], wb[i * 2 + 1]]).to_f32();
1516            assert_approx(f, entries[i].1, F16_TOL);
1517        }
1518    }
1519
1520    // ── peek_header on directory blocks ─────────────────────────────
1521
1522    #[test]
1523    fn peek_header_directory_is_directory() {
1524        let dir = DirectoryBlock::new(&[10, 20, 30], &[0.5, 0.9, 0.2]).unwrap();
1525        let bytes = dir.into_block().serialize();
1526        let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1527        assert_eq!(hdr.bits_per_delta, DIRECTORY_SENTINEL);
1528    }
1529
1530    // ── Directory single entry ──────────────────────────────────────
1531
1532    #[test]
1533    fn directory_single_entry() {
1534        let dir = DirectoryBlock::new(&[42], &[0.99]).unwrap();
1535        assert_eq!(dir.num_blocks(), 1);
1536        assert_approx(dir.dim_max_weight(), 0.99, 1e-6);
1537        let (offsets, weights) = dir.entries();
1538        assert_eq!(offsets, vec![42]);
1539        assert_eq!(weights, vec![0.99]);
1540    }
1541
1542    // ── convert_f16_to_f32 edge cases ───────────────────────────────
1543
1544    #[test]
1545    fn convert_f16_to_f32_empty() {
1546        let mut out = vec![];
1547        convert_f16_to_f32(&[], &mut out);
1548        assert!(out.is_empty());
1549    }
1550
1551    #[test]
1552    fn convert_f16_to_f32_odd_trailing_byte_ignored() {
1553        let val = f16::from_f32(0.5);
1554        let mut input = val.to_le_bytes().to_vec();
1555        input.push(0xAB); // trailing odd byte
1556        let mut out = vec![0.0; 2];
1557        convert_f16_to_f32(&input, &mut out);
1558        assert_approx(out[0], 0.5, F16_TOL);
1559        assert_eq!(out[1], 0.0); // not overwritten: chunks_exact skips trailing
1560    }
1561
1562    // ── SIMD vs scalar f16 conversion consistency ─────────────────────
1563
1564    #[test]
1565    fn convert_f16_simd_matches_scalar() {
1566        // Test at various sizes including remainder paths (not multiple of 8).
1567        for n in [1, 3, 7, 8, 9, 15, 16, 17, 31, 63, 64, 100, 256, 1000] {
1568            let f16_bytes: Vec<u8> = (0..n)
1569                .flat_map(|i| {
1570                    let val = 0.01 * (i as f32 + 1.0);
1571                    f16::from_f32(val).to_le_bytes()
1572                })
1573                .collect();
1574
1575            let mut scalar_out = vec![0.0f32; n];
1576            let mut simd_out = vec![0.0f32; n];
1577
1578            convert_f16_to_f32_scalar(&f16_bytes, &mut scalar_out);
1579            convert_f16_to_f32(&f16_bytes, &mut simd_out);
1580
1581            for i in 0..n {
1582                assert!(
1583                    (scalar_out[i] - simd_out[i]).abs() <= f32::EPSILON,
1584                    "mismatch at n={n}, i={i}: scalar={} simd={}",
1585                    scalar_out[i],
1586                    simd_out[i],
1587                );
1588            }
1589        }
1590    }
1591
1592    // ── Zero-copy methods at various block sizes (incl. remainder paths) ──
1593
1594    #[test]
1595    fn zero_copy_offsets_at_boundary_sizes() {
1596        for count in [1, 2, 63, 127, 128, 129, 130, 255, 256, 257, 512] {
1597            let entries = sequential_entries(10, 3, count, 0.5);
1598            let mut block = make_block(&entries);
1599            let bytes = block.serialize();
1600            let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1601
1602            let mut buf = Vec::new();
1603            SparsePostingBlock::decompress_offsets_into(&bytes, &hdr, &mut buf);
1604            assert_eq!(buf.as_slice(), block.offsets(), "count={count}");
1605        }
1606    }
1607
1608    #[test]
1609    fn zero_copy_values_at_boundary_sizes() {
1610        for count in [1, 2, 63, 127, 128, 129, 130, 255, 256, 257, 512] {
1611            let entries = sequential_entries(0, 1, count, 0.7);
1612            let mut block = make_block(&entries);
1613            let bytes = block.serialize();
1614            let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1615
1616            let mut buf = Vec::new();
1617            SparsePostingBlock::decompress_values_into(&bytes, &hdr, &mut buf);
1618            for (i, (&a, &b)) in buf.iter().zip(block.values().iter()).enumerate() {
1619                assert!((a - b).abs() <= F16_TOL, "count={count}, i={i}: {a} vs {b}");
1620            }
1621        }
1622    }
1623
1624    #[test]
1625    fn read_value_at_boundary_sizes() {
1626        for count in [1, 2, 63, 127, 128, 129, 130, 255, 256, 257] {
1627            let entries: Vec<(u32, f32)> = (0..count)
1628                .map(|i| (i as u32 * 5, 0.1 + 0.001 * i as f32))
1629                .collect();
1630            let mut block = make_block(&entries);
1631            let bytes = block.serialize();
1632            let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1633
1634            for i in 0..count {
1635                let v = SparsePostingBlock::read_value_at(&bytes, &hdr, i);
1636                assert_approx(v, block.values()[i], F16_TOL);
1637            }
1638        }
1639    }
1640}
1641
1642#[cfg(all(test, feature = "testing"))]
1643mod proptests {
1644    use super::*;
1645    use proptest::prelude::*;
1646
1647    fn arb_weight() -> impl Strategy<Value = f32> {
1648        // Avoid proptest's f32 range sampler, which can debug-assert on some seeds.
1649        (10u16..1000).prop_map(|weight| f32::from(weight) / 1000.0)
1650    }
1651
1652    fn arb_entries(max_count: usize) -> impl Strategy<Value = Vec<(u32, f32)>> {
1653        (1..=max_count)
1654            .prop_flat_map(|n| {
1655                (
1656                    proptest::collection::vec(0u32..u32::MAX / 2, n),
1657                    proptest::collection::vec(arb_weight(), n),
1658                )
1659            })
1660            .prop_map(|(mut offsets, weights)| {
1661                offsets.sort();
1662                offsets.dedup();
1663                let n = offsets.len().min(weights.len());
1664                offsets.into_iter().zip(weights).take(n).collect::<Vec<_>>()
1665            })
1666            .prop_filter("need at least one entry", |v| !v.is_empty())
1667    }
1668
1669    proptest! {
1670        #[test]
1671        fn serialize_deserialize_serialize_byte_identical(entries in arb_entries(512)) {
1672            let block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1673            let bytes1 = block.serialize();
1674            let restored = SparsePostingBlock::deserialize(&bytes1).unwrap();
1675            let bytes2 = restored.serialize();
1676            prop_assert_eq!(&bytes1, &bytes2);
1677        }
1678
1679        #[test]
1680        fn roundtrip_offsets_always_match(entries in arb_entries(512)) {
1681            let mut block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1682            let bytes = block.serialize();
1683            let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1684            prop_assert_eq!(restored.offsets(), block.offsets());
1685        }
1686
1687        #[test]
1688        fn roundtrip_values_within_f16_tolerance(entries in arb_entries(512)) {
1689            let mut block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1690            let bytes = block.serialize();
1691            let mut restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1692            for (i, (&orig, &rest)) in block
1693                .values()
1694                .iter()
1695                .zip(restored.values().iter())
1696                .enumerate()
1697            {
1698                let diff = (orig - rest).abs();
1699                prop_assert!(
1700                    diff <= 1e-3,
1701                    "entry {}: expected {} ± 1e-3, got {} (diff={})",
1702                    i, orig, rest, diff
1703                );
1704            }
1705        }
1706
1707        #[test]
1708        fn zero_copy_matches_lazy_offsets(entries in arb_entries(512)) {
1709            let mut block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1710            let bytes = block.serialize();
1711            let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1712            let mut buf = Vec::new();
1713            SparsePostingBlock::decompress_offsets_into(&bytes, &hdr, &mut buf);
1714            prop_assert_eq!(buf.as_slice(), block.offsets());
1715        }
1716
1717        #[test]
1718        fn zero_copy_matches_lazy_values(entries in arb_entries(512)) {
1719            let mut block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1720            let bytes = block.serialize();
1721            let hdr = SparsePostingBlock::peek_header(&bytes).unwrap();
1722            let mut buf = Vec::new();
1723            SparsePostingBlock::decompress_values_into(&bytes, &hdr, &mut buf);
1724            for (i, (&a, &b)) in buf.iter().zip(block.values().iter()).enumerate() {
1725                let diff = (a - b).abs();
1726                prop_assert!(
1727                    diff <= 1e-3,
1728                    "entry {}: zero-copy {} vs lazy {} (diff={})",
1729                    i, a, b, diff
1730                );
1731            }
1732        }
1733
1734        #[test]
1735        fn serialized_size_always_matches(entries in arb_entries(512)) {
1736            let block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1737            let actual = block.serialize().len();
1738            prop_assert_eq!(block.serialized_size(), actual);
1739        }
1740
1741        #[test]
1742        fn serialized_size_survives_roundtrip(entries in arb_entries(512)) {
1743            let block = SparsePostingBlock::from_sorted_entries(&entries).unwrap();
1744            let size_before = block.serialized_size();
1745            let bytes = block.serialize();
1746            let restored = SparsePostingBlock::deserialize(&bytes).unwrap();
1747            let size_after = restored.serialized_size();
1748            prop_assert_eq!(size_before, bytes.len());
1749            prop_assert_eq!(size_after, bytes.len());
1750            prop_assert_eq!(size_before, size_after);
1751        }
1752    }
1753}