Skip to main content

parquet/bloom_filter/
mod.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Bloom filter implementation specific to Parquet, as described
19//! in the [spec][parquet-bf-spec].
20//!
21//! # Bloom Filter Size
22//!
23//! Parquet uses the [Split Block Bloom Filter][sbbf-paper] (SBBF) as its bloom filter
24//! implementation. For each column upon which bloom filters are enabled, the offset and length of an SBBF
25//! is stored in  the metadata for each row group in the parquet file. The size of each filter is
26//! initialized using a calculation based on the desired number of distinct values (NDV) and false
27//! positive probability (FPP). The FPP for a SBBF can be approximated as<sup>[1][bf-formulae]</sup>:
28//!
29//! ```text
30//! f = (1 - e^(-k * n / m))^k
31//! ```
32//!
33//! Where, `f` is the FPP, `k` the number of hash functions, `n` the NDV, and `m` the total number
34//! of bits in the bloom filter. This can be re-arranged to determine the total number of bits
35//! required to achieve a given FPP and NDV:
36//!
37//! ```text
38//! m = -k * n / ln(1 - f^(1/k))
39//! ```
40//!
41//! SBBFs use eight hash functions to cleanly fit in SIMD lanes<sup>[2][sbbf-paper]</sup>, therefore
42//! `k` is set to 8. The SBBF will spread those `m` bits across a set of `b` blocks that
43//! are each 256 bits, i.e., 32 bytes, in size. The number of blocks is chosen as:
44//!
45//! ```text
46//! b = NP2(m/8) / 32
47//! ```
48//!
49//! Where, `NP2` denotes *the next power of two*, and `m` is divided by 8 to be represented as bytes.
50//!
51//! Here is a table of calculated sizes for various FPP and NDV:
52//!
53//! | NDV       | FPP       | b       | Size (KB) |
54//! |-----------|-----------|---------|-----------|
55//! | 10,000    | 0.1       | 256     | 8         |
56//! | 10,000    | 0.01      | 512     | 16        |
57//! | 10,000    | 0.001     | 1,024   | 32        |
58//! | 10,000    | 0.0001    | 1,024   | 32        |
59//! | 100,000   | 0.1       | 4,096   | 128       |
60//! | 100,000   | 0.01      | 4,096   | 128       |
61//! | 100,000   | 0.001     | 8,192   | 256       |
62//! | 100,000   | 0.0001    | 16,384  | 512       |
63//! | 100,000   | 0.00001   | 16,384  | 512       |
64//! | 1,000,000 | 0.1       | 32,768  | 1,024     |
65//! | 1,000,000 | 0.01      | 65,536  | 2,048     |
66//! | 1,000,000 | 0.001     | 65,536  | 2,048     |
67//! | 1,000,000 | 0.0001    | 131,072 | 4,096     |
68//! | 1,000,000 | 0.00001   | 131,072 | 4,096     |
69//! | 1,000,000 | 0.000001  | 262,144 | 8,192     |
70//!
71//! # Structure: Filter → Blocks → Words → Bits
72//!
73//! An SBBF is an array of **blocks**. Each block is 256 bits (32 bytes),
74//! divided into eight 32-bit **words**. A word is just a `u32` — an array of
75//! 32 individual bits that can each be "set" (1) or "not set" (0).
76//!
77//! ```text
78//!   Sbbf (the whole filter)
79//!   ┌──────────┬──────────┬──────────┬─── ─── ──┬──────────┐
80//!   │ Block 0  │ Block 1  │ Block 2  │   ...    │ Block N-1│
81//!   └──────────┴──────────┴──────────┴─── ─── ──┴──────────┘
82//!        │
83//!        ▼
84//!   One Block = 256 bits = 8 words
85//!   ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
86//!   │ word 0 │ word 1 │ word 2 │ word 3 │ word 4 │ word 5 │ word 6 │ word 7 │
87//!   │ (u32)  │ (u32)  │ (u32)  │ (u32)  │ (u32)  │ (u32)  │ (u32)  │ (u32)  │
88//!   └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
89//!        │
90//!        ▼
91//!   One Word = 32 individual bits
92//!   ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
93//!   │0│0│1│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│0│  ← bit 29 is set
94//!   └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
95//! ```
96//!
97//! **Inserting** a value hashes it to a 64-bit number, then:
98//!  1. The upper 32 bits pick which **block** (via `Sbbf::hash_to_block_index`).
99//!  2. The lower 32 bits pick one bit position in each of the 8 **words** (via `Block::mask`).
100//!     So each insert sets exactly **8 bits** (one per word) in a single block.
101//!
102//! **Checking** does the same two steps and returns `true` only if all 8 bits
103//! are already set — meaning the value was *probably* inserted (or is a false
104//! positive).
105//!
106//! # Bloom Filter Folding
107//!
108//! After inserting all values into a bloom filter it can be "folded" to minimize it's size.
109//! See [`Sbbf::fold_to_target_fpp`] for details  on the algorithm and its mathematical basis.
110//!
111//! [parquet-bf-spec]: https://github.com/apache/parquet-format/blob/master/BloomFilter.md
112//! [sbbf-paper]: https://arxiv.org/pdf/2101.01719
113//! [bf-formulae]: http://tfk.mit.edu/pdf/bloom.pdf
114
115use crate::basic::{BloomFilterAlgorithm, BloomFilterCompression, BloomFilterHash};
116use crate::data_type::AsBytes;
117use crate::errors::{ParquetError, Result};
118use crate::file::metadata::ColumnChunkMetaData;
119use crate::file::reader::ChunkReader;
120use crate::parquet_thrift::{
121    ElementType, FieldType, ReadThrift, ThriftCompactInputProtocol, ThriftCompactOutputProtocol,
122    ThriftSliceInputProtocol, WriteThrift, WriteThriftField,
123};
124use crate::thrift_struct;
125use bytes::Bytes;
126use std::io::Write;
127use twox_hash::XxHash64;
128
129/// Salt as defined in the [spec](https://github.com/apache/parquet-format/blob/master/BloomFilter.md#technical-approach).
130const SALT: [u32; 8] = [
131    0x47b6137b_u32,
132    0x44974d91_u32,
133    0x8824ad5b_u32,
134    0xa2b7289d_u32,
135    0x705495c7_u32,
136    0x2df1424b_u32,
137    0x9efc4947_u32,
138    0x5c6bfb31_u32,
139];
140
141thrift_struct!(
142/// Bloom filter header is stored at beginning of Bloom filter data of each column
143/// and followed by its bitset.
144///
145pub struct BloomFilterHeader {
146  /// The size of bitset in bytes
147  1: required i32 num_bytes;
148  /// The algorithm for setting bits.
149  2: required BloomFilterAlgorithm algorithm;
150  /// The hash function used for Bloom filter
151  3: required BloomFilterHash hash;
152  /// The compression used in the Bloom filter
153  4: required BloomFilterCompression compression;
154}
155);
156
157/// A single 256-bit block, the basic unit of the Split Block Bloom Filter.
158///
159/// A block is eight contiguous 32-bit **words** (`[u32; 8]`).
160/// Each word is an independent bit-array of 32 positions:
161///
162/// ```text
163///   Block (256 bits total)
164///   ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
165///   │ word 0 │ word 1 │ word 2 │ word 3 │ word 4 │ word 5 │ word 6 │ word 7 │
166///   │ 32 bits│ 32 bits│ 32 bits│ 32 bits│ 32 bits│ 32 bits│ 32 bits│ 32 bits│
167///   └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
168/// ```
169///
170/// When a value is inserted, [`Block::mask`] picks one bit in each word
171/// (8 bits total), and those bits are OR'd in. When checking, we verify
172/// all 8 bits are set.
173#[derive(Debug, Copy, Clone)]
174#[repr(transparent)]
175struct Block([u32; 8]);
176impl Block {
177    const ZERO: Block = Block([0; 8]);
178
179    /// Produce a block where each of the 8 words has exactly one bit set.
180    ///
181    /// For each word `i` the bit position is derived from `x`:
182    ///
183    /// ```text
184    ///   y = (x wrapping* SALT[i]) >> 27   // top 5 bits → value in 0..31
185    ///   word[i] = 1 << y                  // exactly one bit set per word
186    /// ```
187    ///
188    /// Because only the top 5 bits survive the shift, each word picks one of
189    /// 32 possible bit positions. The eight SALT constants spread the choices
190    /// so different words usually light up different positions.
191    ///
192    /// Key property: the mask depends *only* on `x` (a u32) and the fixed
193    /// SALT constants — it is independent of the filter size. This is why
194    /// folding preserves bit patterns (see Lemma 2 in tests).
195    fn mask(x: u32) -> Self {
196        let mut result = [0_u32; 8];
197        for i in 0..8 {
198            let y = x.wrapping_mul(SALT[i]); // spread bits via multiply
199            let y = y >> 27; // keep top 5 bits → 0..31
200            result[i] = 1 << y; // set exactly that one bit
201        }
202        Self(result)
203    }
204
205    #[inline]
206    #[cfg(not(target_endian = "little"))]
207    fn to_ne_bytes(self) -> [u8; 32] {
208        // SAFETY: [u32; 8] and [u8; 32] have the same size and neither has invalid bit patterns.
209        unsafe { std::mem::transmute(self.0) }
210    }
211
212    #[inline]
213    #[cfg(not(target_endian = "little"))]
214    fn to_le_bytes(self) -> [u8; 32] {
215        self.swap_bytes().to_ne_bytes()
216    }
217
218    #[inline]
219    #[cfg(not(target_endian = "little"))]
220    fn swap_bytes(mut self) -> Self {
221        self.0.iter_mut().for_each(|x| *x = x.swap_bytes());
222        self
223    }
224
225    /// OR the mask bits into this block (`block[i] |= mask[i]`).
226    ///
227    /// After insertion the 8 bits chosen by `mask(hash)` are guaranteed set;
228    /// bits previously set by other hashes are preserved.
229    fn insert(&mut self, hash: u32) {
230        let mask = Self::mask(hash);
231        for i in 0..8 {
232            self[i] |= mask[i];
233        }
234    }
235
236    /// Check membership: returns `true` when *every* bit from `mask(hash)` is
237    /// already set in this block (`block[i] & mask[i] != 0` for all 8 words).
238    ///
239    /// A `true` result means "probably present" (other inserts may have set
240    /// the same bits). A `false` is definitive — the value was never inserted.
241    fn check(&self, hash: u32) -> bool {
242        let mask = Self::mask(hash);
243        for i in 0..8 {
244            if self[i] & mask[i] == 0 {
245                return false;
246            }
247        }
248        true
249    }
250}
251
252impl std::ops::Index<usize> for Block {
253    type Output = u32;
254
255    #[inline]
256    fn index(&self, index: usize) -> &Self::Output {
257        self.0.index(index)
258    }
259}
260
261impl std::ops::IndexMut<usize> for Block {
262    #[inline]
263    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
264        self.0.index_mut(index)
265    }
266}
267
268impl std::ops::BitOr for Block {
269    type Output = Self;
270
271    #[inline]
272    fn bitor(self, rhs: Self) -> Self {
273        let mut result = [0u32; 8];
274        for (i, item) in result.iter_mut().enumerate() {
275            *item = self.0[i] | rhs.0[i];
276        }
277        Self(result)
278    }
279}
280
281impl std::ops::BitOrAssign for Block {
282    #[inline]
283    fn bitor_assign(&mut self, rhs: Self) {
284        for i in 0..8 {
285            self.0[i] |= rhs.0[i];
286        }
287    }
288}
289
290impl Block {
291    /// Count the total number of set bits across all 8 words.
292    ///
293    /// Computes popcount on each word separately and sums. Keeping the popcount
294    /// separate from the OR allows the compiler to batch SIMD popcount instructions
295    /// (e.g., `cnt.16b` on ARM NEON) instead of interleaving them with OR operations.
296    #[inline]
297    fn count_ones(self) -> u32 {
298        // Written as a fold over the array so the compiler sees 8 independent
299        // popcount operations it can vectorize into cnt.16b + horizontal sum.
300        self.0.iter().map(|w| w.count_ones()).sum()
301    }
302}
303
304/// A split block Bloom filter (SBBF).
305///
306/// An SBBF partitions its bit space into fixed-size 256-bit (32-byte) blocks, each fitting in a
307/// single CPU cache line. Each block contains eight 32-bit words, aligned with SIMD lanes for
308/// parallel bit manipulation. When checking membership, only one block is accessed per query,
309/// eliminating the cache-miss penalty of standard Bloom filters.
310///
311/// ## Sizing and folding
312///
313/// Filters are initially sized for a maximum expected number of distinct values (NDV) via
314/// [`Sbbf::new_with_ndv_fpp`]. After all values are inserted, the filter is compacted by
315/// calling [`Sbbf::fold_to_target_fpp`], which folds the filter down to the smallest size
316/// that still meets the target false positive probability.
317///
318/// The creation of this structure is based on the [`crate::file::properties::BloomFilterProperties`]
319/// struct set via [`crate::file::properties::WriterProperties`] and is thus hidden by default.
320#[derive(Debug, Clone)]
321pub struct Sbbf(Vec<Block>);
322
323pub(crate) const SBBF_HEADER_SIZE_ESTIMATE: usize = 20;
324
325/// given an initial offset, and a byte buffer, try to read out a bloom filter header and return
326/// both the header and the offset after it (for bitset).
327pub(crate) fn chunk_read_bloom_filter_header_and_offset(
328    offset: u64,
329    buffer: Bytes,
330) -> Result<(BloomFilterHeader, u64), ParquetError> {
331    let (header, length) = read_bloom_filter_header_and_length(buffer)?;
332    Ok((header, offset + length))
333}
334
335/// given a [Bytes] buffer, try to read out a bloom filter header and return both the header and
336/// length of the header.
337#[inline]
338pub(crate) fn read_bloom_filter_header_and_length(
339    buffer: Bytes,
340) -> Result<(BloomFilterHeader, u64), ParquetError> {
341    read_bloom_filter_header_and_length_from_bytes(buffer.as_ref())
342}
343
344/// Given a byte slice, try to read out a bloom filter header and return both the header and
345/// length of the header.
346#[inline]
347fn read_bloom_filter_header_and_length_from_bytes(
348    buffer: &[u8],
349) -> Result<(BloomFilterHeader, u64), ParquetError> {
350    let total_length = buffer.len();
351    let mut prot = ThriftSliceInputProtocol::new(buffer);
352    let header = BloomFilterHeader::read_thrift(&mut prot)
353        .map_err(|e| ParquetError::General(format!("Could not read bloom filter header: {e}")))?;
354    Ok((header, (total_length - prot.as_slice().len()) as u64))
355}
356
357/// The minimum number of bytes for a bloom filter bitset.
358pub const BITSET_MIN_LENGTH: usize = 32;
359/// The maximum number of bytes for a bloom filter bitset.
360pub const BITSET_MAX_LENGTH: usize = 128 * 1024 * 1024;
361
362#[inline]
363fn optimal_num_of_bytes(num_bytes: usize) -> usize {
364    let num_bytes = num_bytes.min(BITSET_MAX_LENGTH);
365    let num_bytes = num_bytes.max(BITSET_MIN_LENGTH);
366    num_bytes.next_power_of_two()
367}
368
369// see http://algo2.iti.kit.edu/documents/cacheefficientbloomfilters-jea.pdf
370// given fpp = (1 - e^(-k * n / m)) ^ k
371// we have m = - k * n / ln(1 - fpp ^ (1 / k))
372// where k = number of hash functions, m = number of bits, n = number of distinct values
373#[inline]
374fn num_of_bits_from_ndv_fpp(ndv: u64, fpp: f64) -> usize {
375    let num_bits = -8.0 * ndv as f64 / (1.0 - fpp.powf(1.0 / 8.0)).ln();
376    num_bits as usize
377}
378
379impl Sbbf {
380    /// Create a new [Sbbf] with given number of distinct values and false positive probability.
381    /// Will return an error if `fpp` is greater than or equal to 1.0 or less than 0.0.
382    pub fn new_with_ndv_fpp(ndv: u64, fpp: f64) -> Result<Self, ParquetError> {
383        if !(0.0..1.0).contains(&fpp) {
384            return Err(ParquetError::General(format!(
385                "False positive probability must be between 0.0 and 1.0, got {fpp}"
386            )));
387        }
388        let num_bits = num_of_bits_from_ndv_fpp(ndv, fpp);
389        Ok(Self::new_with_num_of_bytes(num_bits / 8))
390    }
391
392    /// Create a new [Sbbf] with given number of bytes, the exact number of bytes will be adjusted
393    /// to the next power of two bounded by [BITSET_MIN_LENGTH] and [BITSET_MAX_LENGTH].
394    pub fn new_with_num_of_bytes(num_bytes: usize) -> Self {
395        let num_bytes = optimal_num_of_bytes(num_bytes);
396        assert_eq!(num_bytes % size_of::<Block>(), 0);
397        let num_blocks = num_bytes / size_of::<Block>();
398        let bitset = vec![Block::ZERO; num_blocks];
399        Self(bitset)
400    }
401
402    /// Creates a new [Sbbf] from a raw byte slice.
403    pub fn new(bitset: &[u8]) -> Self {
404        let data = bitset
405            .as_chunks::<32>()
406            .0
407            .iter()
408            .map(|chunk| {
409                let mut block = Block::ZERO;
410                let (words, _remainder) = chunk.as_chunks::<4>();
411                for (i, word) in words.iter().enumerate() {
412                    block[i] = u32::from_le_bytes(*word);
413                }
414                block
415            })
416            .collect::<Vec<Block>>();
417        Self(data)
418    }
419
420    /// Write the bloom filter data (header and then bitset) to the output. This doesn't
421    /// flush the writer in order to boost performance of bulk writing all blocks. Caller
422    /// must remember to flush the writer.
423    /// This method usually is used in conjunction with [`Self::from_bytes`] for serialization/deserialization.
424    pub fn write<W: Write>(&self, mut writer: W) -> Result<(), ParquetError> {
425        let mut protocol = ThriftCompactOutputProtocol::new(&mut writer);
426        self.header().write_thrift(&mut protocol).map_err(|e| {
427            ParquetError::General(format!("Could not write bloom filter header: {e}"))
428        })?;
429        self.write_bitset(&mut writer)?;
430        Ok(())
431    }
432
433    /// Write the bitset in serialized form to the writer.
434    #[cfg(not(target_endian = "little"))]
435    pub fn write_bitset<W: Write>(&self, mut writer: W) -> Result<(), ParquetError> {
436        for block in &self.0 {
437            writer
438                .write_all(block.to_le_bytes().as_slice())
439                .map_err(|e| {
440                    ParquetError::General(format!("Could not write bloom filter bit set: {e}"))
441                })?;
442        }
443        Ok(())
444    }
445
446    /// Write the bitset in serialized form to the writer.
447    #[cfg(target_endian = "little")]
448    pub fn write_bitset<W: Write>(&self, mut writer: W) -> Result<(), ParquetError> {
449        // Safety: Block is repr(transparent) and [u32; 8] can be reinterpreted as [u8; 32].
450        let slice = unsafe {
451            std::slice::from_raw_parts(
452                self.0.as_ptr().cast::<u8>(),
453                self.0.len() * size_of::<Block>(),
454            )
455        };
456        writer.write_all(slice).map_err(|e| {
457            ParquetError::General(format!("Could not write bloom filter bit set: {e}"))
458        })?;
459        Ok(())
460    }
461
462    /// Create and populate [`BloomFilterHeader`] from this bitset for writing to serialized form
463    fn header(&self) -> BloomFilterHeader {
464        BloomFilterHeader {
465            // 8 i32 per block, 4 bytes per i32
466            num_bytes: self.0.len() as i32 * 4 * 8,
467            algorithm: BloomFilterAlgorithm::BLOCK,
468            hash: BloomFilterHash::XXHASH,
469            compression: BloomFilterCompression::UNCOMPRESSED,
470        }
471    }
472
473    /// Read a new bloom filter from the given offset in the given reader.
474    pub fn read_from_column_chunk<R: ChunkReader>(
475        column_metadata: &ColumnChunkMetaData,
476        reader: &R,
477    ) -> Result<Option<Self>, ParquetError> {
478        let offset: u64 = if let Some(offset) = column_metadata.bloom_filter_offset() {
479            offset
480                .try_into()
481                .map_err(|_| ParquetError::General("Bloom filter offset is invalid".to_string()))?
482        } else {
483            return Ok(None);
484        };
485
486        let buffer = match column_metadata.bloom_filter_length() {
487            Some(length) => reader.get_bytes(offset, length as usize),
488            None => reader.get_bytes(offset, SBBF_HEADER_SIZE_ESTIMATE),
489        }?;
490
491        let (header, bitset_offset) =
492            chunk_read_bloom_filter_header_and_offset(offset, buffer.clone())?;
493
494        match header.algorithm {
495            BloomFilterAlgorithm::BLOCK => {
496                // this match exists to future proof the singleton algorithm enum
497            }
498        }
499        match header.compression {
500            BloomFilterCompression::UNCOMPRESSED => {
501                // this match exists to future proof the singleton compression enum
502            }
503        }
504        match header.hash {
505            BloomFilterHash::XXHASH => {
506                // this match exists to future proof the singleton hash enum
507            }
508        }
509
510        let bitset = match column_metadata.bloom_filter_length() {
511            Some(_) => buffer.slice((bitset_offset - offset) as usize..),
512            None => {
513                let bitset_length: usize = header.num_bytes.try_into().map_err(|_| {
514                    ParquetError::General("Bloom filter length is invalid".to_string())
515                })?;
516                reader.get_bytes(bitset_offset, bitset_length)?
517            }
518        };
519
520        Ok(Some(Self::new(&bitset)))
521    }
522
523    /// Map a 64-bit hash to a block index in `[0, num_blocks)`.
524    ///
525    /// Uses the "multiply-and-shift" trick (a fast alternative to modulo):
526    ///
527    /// ```text
528    ///   upper32 = hash >> 32           // take the top 32 bits of the hash
529    ///   index   = (upper32 * N) >> 32  // ∈ [0, N)  where N = num_blocks
530    /// ```
531    ///
532    /// Why this matters for folding (Lemma 1): when N is a power of two and
533    /// you halve it to N/2, the index also halves:
534    ///
535    /// ```text
536    ///   index_N   = (upper32 * N)   >> 32
537    ///   index_N/2 = (upper32 * N/2) >> 32 = index_N / 2  (integer division)
538    /// ```
539    ///
540    /// So the block that held hash `h` in the big filter is at `index / 2` in
541    /// the half-sized filter — exactly where `fold` ORs it.
542    #[inline]
543    fn hash_to_block_index(&self, hash: u64) -> usize {
544        (((hash >> 32).saturating_mul(self.0.len() as u64)) >> 32) as usize
545    }
546
547    /// Insert an [AsBytes] value into the filter
548    pub fn insert<T: AsBytes + ?Sized>(&mut self, value: &T) {
549        self.insert_hash(hash_as_bytes(value));
550    }
551
552    /// Insert a hash into the filter
553    fn insert_hash(&mut self, hash: u64) {
554        let block_index = self.hash_to_block_index(hash);
555        self.0[block_index].insert(hash as u32)
556    }
557
558    /// Check if an [AsBytes] value is probably present or definitely absent in the filter
559    pub fn check<T: AsBytes + ?Sized>(&self, value: &T) -> bool {
560        self.check_hash(hash_as_bytes(value))
561    }
562
563    /// Check if a hash is in the filter. May return
564    /// true for values that was never inserted ("false positive")
565    /// but will always return false if a hash has not been inserted.
566    fn check_hash(&self, hash: u64) -> bool {
567        let block_index = self.hash_to_block_index(hash);
568        self.0[block_index].check(hash as u32)
569    }
570
571    /// Return the total in memory size of this bloom filter in bytes
572    pub(crate) fn estimated_memory_size(&self) -> usize {
573        self.0.capacity() * std::mem::size_of::<Block>()
574    }
575
576    /// Returns the number of blocks in this bloom filter.
577    pub fn num_blocks(&self) -> usize {
578        self.0.len()
579    }
580
581    /// Estimate the false positive probability (FPP) of this filter at its current size.
582    ///
583    /// This is the same estimate [`Self::fold_to_target_fpp`] uses to choose how far to fold.
584    ///
585    /// This lets a caller inspect a filter before folding or writing it, for example to
586    /// discard a filter that already exceeds its target FPP. Returns `1.0` for a filter
587    /// with no blocks.
588    pub fn estimated_fpp(&self) -> f64 {
589        if self.0.is_empty() {
590            return 1.0;
591        }
592        self.average_fill().powi(8)
593    }
594
595    /// Average fraction of bits set per block. The filter must have at least one block.
596    fn average_fill(&self) -> f64 {
597        let total_set_bits: u64 = self.0.iter().map(|b| u64::from(b.count_ones())).sum();
598        total_set_bits as f64 / (self.0.len() as f64 * 256.0)
599    }
600
601    /// Fold the bloom filter down to the smallest size that still meets the target FPP
602    /// (False Positive Percentage).
603    ///
604    /// Folds the filter by merging groups of adjacent blocks via bitwise OR, where each
605    /// fold level halves the number of blocks. The fold count is chosen as the maximum
606    /// number of folds whose estimated FPP stays within `target_fpp`. The filter stops
607    /// at a minimum size of 1 block (32 bytes).
608    ///
609    /// ## How it works
610    ///
611    /// SBBFs use multiplicative hashing for block selection:
612    ///
613    /// ```text
614    /// block_index = ((hash >> 32) * num_blocks) >> 32
615    /// ```
616    ///
617    /// A single fold halves the block count: when `num_blocks` is halved, the new index
618    /// becomes `floor(original_index / 2)`, so blocks `2i` and `2i+1` map to the same
619    /// position. More generally, `k` folds reduce the block count by `2^k`, merging
620    /// groups of `2^k` adjacent blocks in a single pass:
621    ///
622    /// ```text
623    /// folded[i] = blocks[i*2^k] | blocks[i*2^k + 1] | ... | blocks[i*2^k + 2^k - 1]
624    /// ```
625    ///
626    /// This differs from standard Bloom filter folding, which merges the two halves
627    /// (`B[i] | B[i + m/2]`) because standard filters use modular hashing where
628    /// `h(x) mod (m/2)` maps indices `i` and `i + m/2` to the same position.
629    ///
630    /// ## Correctness
631    ///
632    /// Folding **never introduces false negatives**. Every bit that was set in the original
633    /// filter remains set in the folded filter (via bitwise OR). The only effect is a controlled
634    /// increase in FPP as set bits from different blocks are merged together.
635    /// This is was originally proven in [Sailhan & Stehr 2012] for standard bloom filters and is empirically
636    /// demonstrated for SBBFs in Lemma 1 and Lemma 2 of the tests.
637    ///
638    /// ## References
639    ///
640    /// [Sailhan & Stehr 2012]: https://doi.org/10.1109/GreenCom.2012.16
641    pub fn fold_to_target_fpp(&mut self, target_fpp: f64) {
642        let num_folds = self.num_folds_for_target_fpp(target_fpp);
643        if num_folds > 0 {
644            self.fold_n(num_folds);
645        }
646    }
647
648    /// Determine how many folds can be applied without exceeding `target_fpp`.
649    ///
650    /// Computes the average per-block fill rate in a single pass (no allocation),
651    /// then analytically estimates the FPP at each fold level.
652    ///
653    /// When two blocks with independent fill rate `f` are OR'd, the expected fill
654    /// of the merged block is `1 - (1-f)^2`. After `k` folds (merging `2^k` blocks):
655    ///
656    /// ```text
657    /// f_k = 1 - (1 - f)^(2^k)
658    /// ```
659    ///
660    /// SBBF membership checks perform `k=8` bit checks within one 256-bit block,
661    /// so the estimated FPP at fold level k is `f_k^8`.
662    fn num_folds_for_target_fpp(&self, target_fpp: f64) -> u32 {
663        let len = self.0.len();
664        if len < 2 {
665            return 0;
666        }
667
668        // Single pass: compute average per-block fill rate.
669        let avg_fill = self.average_fill();
670
671        // Empty filter: can fold all the way down.
672        if avg_fill == 0.0 {
673            return len.trailing_zeros();
674        }
675
676        // Find max folds where estimated FPP stays within target.
677        // f_k = 1 - (1 - avg_fill)^(2^k), FPP_k = f_k^8
678        assert!(
679            len.is_power_of_two(),
680            "Number of blocks must be a power of 2 for folding"
681        );
682        let max_folds = len.trailing_zeros(); // log2(len) since len is power of 2
683        let one_minus_f = 1.0 - avg_fill;
684        let mut num_folds = 0u32;
685        let mut one_minus_fk = one_minus_f; // (1-f)^1 initially
686
687        for _ in 0..max_folds {
688            // After one more fold: (1-f)^(2^(k+1)) = ((1-f)^(2^k))^2
689            one_minus_fk = one_minus_fk * one_minus_fk;
690            let fk = 1.0 - one_minus_fk;
691            let estimated_fpp = fk.powi(8);
692            if estimated_fpp > target_fpp {
693                break;
694            }
695            num_folds += 1;
696        }
697
698        num_folds
699    }
700
701    /// Fold the filter `num_folds` times in a single pass.
702    ///
703    /// Merges groups of `2^num_folds` adjacent blocks via bitwise OR, producing
704    /// `len / 2^num_folds` output blocks. The original allocation is reused.
705    ///
706    /// # Panics
707    ///
708    /// Panics if `num_folds` is 0 or would reduce the filter below 1 block.
709    fn fold_n(&mut self, num_folds: u32) {
710        assert!(num_folds > 0, "num_folds must be at least 1");
711        let len = self.0.len();
712        let group_size = 1usize << num_folds;
713        assert!(
714            group_size <= len,
715            "Cannot fold {num_folds} times: need at least {group_size} blocks, have {len}"
716        );
717        let new_len = len / group_size;
718        for i in 0..new_len {
719            let start = i * group_size;
720            let mut merged = self.0[start];
721            for j in 1..group_size {
722                merged |= self.0[start + j];
723            }
724            self.0[i] = merged;
725        }
726        self.0.truncate(new_len);
727        // `truncate` keeps the capacity; release it so filters retained until the file is closed
728        // (e.g. `BloomFilterPosition::End`) only hold their folded size.
729        self.0.shrink_to_fit();
730    }
731
732    /// Reads a Sbff from Thrift encoded bytes
733    ///
734    /// # Examples
735    ///
736    /// ```no_run
737    /// # use parquet::errors::Result;
738    /// # use parquet::bloom_filter::Sbbf;
739    /// # fn main() -> Result<()> {
740    /// // In a real application, you would read serialized bloom filter bytes from a cache.
741    /// // This example demonstrates the deserialization process.
742    /// // Assuming you have bloom filter bytes from a Parquet file:
743    /// # let serialized_bytes: Vec<u8> = vec![];
744    /// let bloom_filter = Sbbf::from_bytes(&serialized_bytes)?;
745    /// // Now you can use the bloom filter to check for values
746    /// if bloom_filter.check(&"some_value") {
747    ///     println!("Value might be present (or false positive)");
748    /// } else {
749    ///     println!("Value is definitely not present");
750    /// }
751    /// # Ok(())
752    /// # }
753    /// ```
754    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParquetError> {
755        let (header, header_len) = read_bloom_filter_header_and_length_from_bytes(bytes)?;
756
757        let bitset_length: u64 = header
758            .num_bytes
759            .try_into()
760            .map_err(|_| ParquetError::General("Bloom filter length is invalid".to_string()))?;
761
762        // Validate that bitset consumes all remaining bytes
763        if header_len + bitset_length != bytes.len() as u64 {
764            return Err(ParquetError::General(format!(
765                "Bloom filter data contains extra bytes: expected {} total bytes, got {}",
766                header_len + bitset_length,
767                bytes.len()
768            )));
769        }
770
771        let start = header_len as usize;
772        let end = (header_len + bitset_length) as usize;
773        let bitset = bytes
774            .get(start..end)
775            .ok_or_else(|| ParquetError::General("Bloom filter bitset is invalid".to_string()))?;
776
777        Ok(Self::new(bitset))
778    }
779}
780
781// per spec we use xxHash with seed=0
782const SEED: u64 = 0;
783
784#[inline]
785fn hash_as_bytes<A: AsBytes + ?Sized>(value: &A) -> u64 {
786    XxHash64::oneshot(SEED, value.as_bytes())
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn test_hash_bytes() {
795        assert_eq!(hash_as_bytes(""), 17241709254077376921);
796    }
797
798    #[test]
799    #[cfg_attr(miri, ignore)] // Takes too long
800    fn test_mask_set_quick_check() {
801        for i in 0..1_000_000 {
802            let result = Block::mask(i);
803            assert!(result.0.iter().all(|&x| x.is_power_of_two()));
804        }
805    }
806
807    #[test]
808    #[cfg_attr(miri, ignore)] // Takes too long
809    fn test_block_insert_and_check() {
810        for i in 0..1_000_000 {
811            let mut block = Block::ZERO;
812            block.insert(i);
813            assert!(block.check(i));
814        }
815    }
816
817    #[test]
818    #[cfg_attr(miri, ignore)] // Takes too long
819    fn test_sbbf_insert_and_check() {
820        let mut sbbf = Sbbf(vec![Block::ZERO; 1_000]);
821        for i in 0..1_000_000 {
822            sbbf.insert(&i);
823            assert!(sbbf.check(&i));
824        }
825    }
826
827    #[test]
828    fn test_with_fixture() {
829        // bloom filter produced by parquet-mr/spark for a column of i64 f"a{i}" for i in 0..10
830        let bitset: &[u8] = &[
831            200, 1, 80, 20, 64, 68, 8, 109, 6, 37, 4, 67, 144, 80, 96, 32, 8, 132, 43, 33, 0, 5,
832            99, 65, 2, 0, 224, 44, 64, 78, 96, 4,
833        ];
834        let sbbf = Sbbf::new(bitset);
835        for a in 0..10i64 {
836            let value = format!("a{a}");
837            assert!(sbbf.check(&value.as_str()));
838        }
839    }
840
841    /// test the assumption that bloom filter header size should not exceed SBBF_HEADER_SIZE_ESTIMATE
842    /// essentially we are testing that the struct is packed with 4 i32 fields, each can be 1-5 bytes
843    /// so altogether it'll be 20 bytes at most.
844    #[test]
845    fn test_bloom_filter_header_size_assumption() {
846        let buffer: &[u8; 16] = &[21, 64, 28, 28, 0, 0, 28, 28, 0, 0, 28, 28, 0, 0, 0, 99];
847        let (
848            BloomFilterHeader {
849                algorithm,
850                compression,
851                hash,
852                num_bytes,
853            },
854            read_length,
855        ) = read_bloom_filter_header_and_length(Bytes::copy_from_slice(buffer)).unwrap();
856        assert_eq!(read_length, 15);
857        assert_eq!(algorithm, BloomFilterAlgorithm::BLOCK);
858        assert_eq!(compression, BloomFilterCompression::UNCOMPRESSED);
859        assert_eq!(hash, BloomFilterHash::XXHASH);
860        assert_eq!(num_bytes, 32_i32);
861        assert_eq!(20, SBBF_HEADER_SIZE_ESTIMATE);
862    }
863
864    #[test]
865    fn test_optimal_num_of_bytes() {
866        for (input, expected) in &[
867            (0, 32),
868            (9, 32),
869            (31, 32),
870            (32, 32),
871            (33, 64),
872            (99, 128),
873            (1024, 1024),
874            (999_000_000, 128 * 1024 * 1024),
875        ] {
876            assert_eq!(*expected, optimal_num_of_bytes(*input));
877        }
878    }
879
880    #[test]
881    fn test_num_of_bits_from_ndv_fpp() {
882        for (fpp, ndv, num_bits) in &[
883            (0.1, 10, 57),
884            (0.01, 10, 96),
885            (0.001, 10, 146),
886            (0.1, 100, 577),
887            (0.01, 100, 968),
888            (0.001, 100, 1460),
889            (0.1, 1000, 5772),
890            (0.01, 1000, 9681),
891            (0.001, 1000, 14607),
892            (0.1, 10000, 57725),
893            (0.01, 10000, 96815),
894            (0.001, 10000, 146076),
895            (0.1, 100000, 577254),
896            (0.01, 100000, 968152),
897            (0.001, 100000, 1460769),
898            (0.1, 1000000, 5772541),
899            (0.01, 1000000, 9681526),
900            (0.001, 1000000, 14607697),
901            (1e-50, 1_000_000_000_000, 14226231280773240832),
902        ] {
903            assert_eq!(*num_bits, num_of_bits_from_ndv_fpp(*ndv, *fpp) as u64);
904        }
905    }
906
907    #[test]
908    fn test_fold_n_halves_block_count() {
909        let mut sbbf = Sbbf::new_with_num_of_bytes(1024); // 32 blocks
910        assert_eq!(sbbf.num_blocks(), 32);
911        sbbf.fold_n(1);
912        assert_eq!(sbbf.num_blocks(), 16);
913        assert_eq!(sbbf.0.capacity(), 16);
914        sbbf.fold_n(1);
915        assert_eq!(sbbf.num_blocks(), 8);
916        assert_eq!(sbbf.0.capacity(), 8);
917    }
918
919    #[test]
920    fn test_fold_preserves_inserted_values() {
921        // Create a large filter, insert values, fold, verify no false negatives
922        let mut sbbf = Sbbf::new_with_num_of_bytes(32 * 1024); // 32KB = 1024 blocks
923        let values: Vec<String> = (0..1000).map(|i| format!("value_{i}")).collect();
924        for v in &values {
925            sbbf.insert(v.as_str());
926        }
927
928        // Fold several times
929        let original_blocks = sbbf.num_blocks();
930        sbbf.fold_to_target_fpp(0.05);
931        assert!(
932            sbbf.num_blocks() < original_blocks,
933            "should have folded at least once"
934        );
935
936        // All inserted values must still be found (no false negatives)
937        for v in &values {
938            assert!(
939                sbbf.check(v.as_str()),
940                "Value '{v}' missing after folding (false negative!)"
941            );
942        }
943    }
944
945    #[test]
946    fn test_fold_to_target_fpp_stops_before_exceeding_target() {
947        let mut sbbf = Sbbf::new_with_num_of_bytes(64 * 1024); // 64KB
948        // Insert enough values to set some bits
949        for i in 0..5000 {
950            sbbf.insert(&i);
951        }
952
953        let target_fpp = 0.01;
954        sbbf.fold_to_target_fpp(target_fpp);
955
956        // After folding, the estimated FPP should be at or below target
957        // (the current state should not exceed target — we stopped before that would happen)
958        let total_bits = (sbbf.num_blocks() * 256) as f64;
959        let set_bits: u64 = sbbf
960            .0
961            .iter()
962            .flat_map(|b| b.0.iter())
963            .map(|w| w.count_ones() as u64)
964            .sum();
965        let fill = set_bits as f64 / total_bits;
966        let current_fpp = fill.powi(8);
967        assert!(
968            current_fpp <= target_fpp,
969            "FPP {current_fpp} exceeds target {target_fpp}"
970        );
971    }
972
973    #[test]
974    fn test_fold_empty_filter_folds_to_minimum() {
975        // An empty filter has fill=0, so estimated FPP is always 0 — should fold all the way down
976        let mut sbbf = Sbbf::new_with_num_of_bytes(1024); // 32 blocks
977        sbbf.fold_to_target_fpp(0.01);
978        assert_eq!(sbbf.num_blocks(), 1);
979    }
980
981    #[test]
982    fn test_estimated_fpp_matches_serialized_bitset() {
983        for num_bytes in [BITSET_MIN_LENGTH, 1024, 64 * 1024] {
984            for ndv in [0u64, 1, 10, 100, 1_000, 10_000, 100_000] {
985                let mut sbbf = Sbbf::new_with_num_of_bytes(num_bytes);
986                for i in 0..ndv {
987                    sbbf.insert(&i);
988                }
989
990                let mut bitset = Vec::new();
991                sbbf.write_bitset(&mut bitset).unwrap();
992                let set_bits: u64 = bitset.iter().map(|b| u64::from(b.count_ones())).sum();
993                let expected = (set_bits as f64 / (bitset.len() as f64 * 8.0)).powi(8);
994
995                assert_eq!(
996                    sbbf.estimated_fpp().to_bits(),
997                    expected.to_bits(),
998                    "{num_bytes} bytes, {ndv} values"
999                );
1000            }
1001        }
1002    }
1003
1004    #[test]
1005    fn test_estimated_fpp_bounds() {
1006        assert_eq!(Sbbf::new_with_num_of_bytes(1024).estimated_fpp(), 0.0);
1007        assert_eq!(Sbbf::new(&[0xFF; 1024]).estimated_fpp(), 1.0);
1008        assert_eq!(Sbbf::new(&[]).estimated_fpp(), 1.0);
1009    }
1010
1011    #[test]
1012    fn test_estimated_fpp_increases_when_folded() {
1013        let mut sbbf = Sbbf::new_with_num_of_bytes(64 * 1024);
1014        for i in 0..1_000 {
1015            sbbf.insert(&i);
1016        }
1017        let before = sbbf.estimated_fpp();
1018        sbbf.fold_n(3);
1019        assert!(
1020            sbbf.estimated_fpp() > before,
1021            "folding must not lower the estimate: {before} -> {}",
1022            sbbf.estimated_fpp()
1023        );
1024    }
1025
1026    #[test]
1027    #[should_panic(expected = "Cannot fold 1 times: need at least 2 blocks, have 1")]
1028    fn test_fold_n_panics_at_minimum_size() {
1029        let mut sbbf = Sbbf::new_with_num_of_bytes(32); // 1 block (minimum)
1030        sbbf.fold_n(1);
1031    }
1032
1033    #[test]
1034    fn test_sbbf_write_round_trip() {
1035        // Create a bloom filter with a 32-byte bitset (minimum size)
1036        let bitset_bytes = vec![0u8; 32];
1037        let mut original = Sbbf::new(&bitset_bytes);
1038
1039        // Insert some test values
1040        let test_values = ["hello", "world", "rust", "parquet", "bloom", "filter"];
1041        for value in &test_values {
1042            original.insert(value);
1043        }
1044
1045        // Serialize to bytes
1046        let mut output = Vec::new();
1047        original.write(&mut output).unwrap();
1048
1049        // Validate header was written correctly
1050        let mut protocol = ThriftSliceInputProtocol::new(&output);
1051        let header = BloomFilterHeader::read_thrift(&mut protocol).unwrap();
1052        assert_eq!(header.num_bytes, bitset_bytes.len() as i32);
1053        assert_eq!(header.algorithm, BloomFilterAlgorithm::BLOCK);
1054        assert_eq!(header.hash, BloomFilterHash::XXHASH);
1055        assert_eq!(header.compression, BloomFilterCompression::UNCOMPRESSED);
1056
1057        // Deserialize using from_bytes
1058        let reconstructed = Sbbf::from_bytes(&output).unwrap();
1059
1060        // Most importantly: verify the bloom filter WORKS correctly after round-trip
1061        // Note: bloom filters can have false positives, but should never have false negatives
1062        // So we can't assert !check(), but we should verify inserted values are found
1063        for value in &test_values {
1064            assert!(
1065                reconstructed.check(value),
1066                "Value '{value}' should be present after round-trip"
1067            );
1068        }
1069    }
1070
1071    /// Prove that folding an SBBF by one level produces the exact same bits
1072    /// as building a fresh filter at the smaller size from scratch.
1073    ///
1074    /// # What is folding?
1075    ///
1076    /// ```text
1077    ///   Original (N = 8 blocks):
1078    ///   ┌───┬───┬───┬───┬───┬───┬───┬───┐
1079    ///   │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
1080    ///   └─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┘
1081    ///     │   │   │   │   │   │   │   │
1082    ///     └─OR┘   └─OR┘   └─OR┘   └─OR┘    pair-wise OR
1083    ///       │       │       │       │
1084    ///   ┌───┴──┬────┴──┬────┴──┬────┴──┐
1085    ///   │ 0|1  │ 2|3   │ 4|5   │ 6|7   │   Folded (N/2 = 4 blocks)
1086    ///   └──────┴───────┴───────┴───────┘
1087    /// ```
1088    ///
1089    /// # Why folded == fresh (the two lemmas)
1090    ///
1091    /// An SBBF insertion does two things with a 64-bit hash `h`:
1092    ///
1093    ///   1. **Pick a block** — uses the upper 32 bits via `hash_to_block_index`
1094    ///   2. **Set 8 bits in that block** — uses the lower 32 bits via `Block::mask`
1095    ///
1096    /// **Lemma 1 (block index halves):** `hash_to_block_index` uses
1097    /// `(upper32 * N) >> 32`. When N halves, the index halves too:
1098    /// `index_in(N/2) == index_in(N) / 2`. So the hash lands in the same
1099    /// destination block whether you fold or build fresh.
1100    ///
1101    /// **Lemma 2 (mask is size-independent):** `Block::mask(h as u32)` depends
1102    /// only on the lower 32 bits and the fixed SALT constants — the filter
1103    /// size N is not involved. So the same 8 bits get set regardless.
1104    ///
1105    /// Combined: every hash sets the *same bits* in the *same destination
1106    /// block* whether you fold or build fresh → filters are bit-identical.
1107    #[test]
1108    #[cfg_attr(miri, ignore)] // Takes too long
1109    fn test_sbbf_folded_equals_fresh() {
1110        let values = (0..5000).map(|i| format!("elem_{i}")).collect::<Vec<_>>();
1111        let hashes = values
1112            .iter()
1113            .map(|v| hash_as_bytes(v.as_str()))
1114            .collect::<Vec<_>>();
1115
1116        for num_blocks in [64, 256, 1024] {
1117            let half = num_blocks / 2;
1118
1119            // Build a filter with N blocks and insert all values.
1120            let mut original = Sbbf::new_with_num_of_bytes(num_blocks * 32);
1121            assert_eq!(original.num_blocks(), num_blocks);
1122            for &h in &hashes {
1123                original.insert_hash(h);
1124            }
1125
1126            // --- Per-hash verification of the two lemmas ---
1127            for &h in &hashes {
1128                // mask(h as u32) gives the 8-bit pattern that this hash sets
1129                // inside whichever block it lands in. It uses only the lower
1130                // 32 bits of h, so it's the same regardless of filter size.
1131                let mask = Block::mask(h as u32);
1132
1133                // Lemma 1 check: the block index in the original N-block
1134                // filter, divided by 2, should equal the block index in a
1135                // fresh N/2-block filter.
1136                let orig_idx = original.hash_to_block_index(h);
1137                assert!(orig_idx < num_blocks);
1138
1139                let fresh_idx = {
1140                    let tmp = Sbbf(vec![Block::ZERO; half]);
1141                    tmp.hash_to_block_index(h)
1142                };
1143                let folded_idx = orig_idx / 2;
1144                assert_eq!(
1145                    fresh_idx, folded_idx,
1146                    "Lemma 1 failed: fresh index {fresh_idx} != folded index {folded_idx}"
1147                );
1148
1149                // Lemma 2 check: every bit that mask wants to set is actually
1150                // present in the original block.
1151                //
1152                // mask.0[w] has exactly ONE bit set (see Block::mask: `1 << y`).
1153                // The block at orig_idx has many bits set from many inserts, so
1154                // we can't test equality — we test that the specific mask bit is
1155                // *present*:
1156                //
1157                //   block_word & mask_word != 0
1158                //     ⟺  "the one bit in the mask is set in the block"
1159                //
1160                // (Since mask_word has exactly 1 bit, `& mask != 0` is the same
1161                //  as `& mask == mask` — but `!= 0` reads more naturally.)
1162                for w in 0..8 {
1163                    assert_ne!(
1164                        original.0[orig_idx].0[w] & mask.0[w],
1165                        0,
1166                        "Lemma 2 failed: mask bit not set in word {w} of block {orig_idx}"
1167                    );
1168                }
1169            }
1170
1171            // --- Final bit-identical comparison ---
1172            // Fold the original N-block filter down to N/2 blocks.
1173            let mut folded = original.clone();
1174            folded.fold_n(1);
1175            assert_eq!(folded.num_blocks(), half);
1176
1177            // Build a fresh N/2-block filter with the same values.
1178            let mut fresh = Sbbf::new_with_num_of_bytes(half * 32);
1179            for &h in &hashes {
1180                fresh.insert_hash(h);
1181            }
1182
1183            // By lemmas 1 + 2, every block should be bit-identical.
1184            for j in 0..half {
1185                assert_eq!(
1186                    folded.0[j].0, fresh.0[j].0,
1187                    "Block {j} differs after fold (N={num_blocks} → {half})"
1188                );
1189            }
1190        }
1191    }
1192
1193    /// Inductive multi-step folding: folding k times from N blocks produces
1194    /// a filter bit-identical to a fresh N/2^k-block filter.
1195    ///
1196    /// `test_sbbf_folded_equals_fresh` proves the base case (one fold).
1197    /// This test applies folds *repeatedly*, checking after each step:
1198    ///
1199    /// ```text
1200    ///   512 ─fold→ 256 ─fold→ 128 ─…→ 1  (9 folds total)
1201    /// ```
1202    ///
1203    /// At each intermediate size we build a fresh filter and assert
1204    /// bit-equality, confirming the lemma composes across folds.
1205    #[test]
1206    #[cfg_attr(miri, ignore)] // Takes too long
1207    fn test_multi_step_fold() {
1208        let values = (0..3000).map(|i| format!("x_{i}")).collect::<Vec<_>>();
1209
1210        // Start with a 512-block filter.
1211        let mut filter = Sbbf::new_with_num_of_bytes(512 * 32);
1212        for v in &values {
1213            filter.insert(v.as_str());
1214        }
1215
1216        // Fold one level at a time, comparing against a fresh filter each step.
1217        for expected_blocks in [256, 128, 64, 32, 16, 8, 4, 2, 1] {
1218            filter.fold_n(1);
1219            assert_eq!(filter.num_blocks(), expected_blocks);
1220
1221            let mut fresh = Sbbf::new_with_num_of_bytes(expected_blocks * 32);
1222            for v in &values {
1223                fresh.insert(v.as_str());
1224            }
1225            for (fb, rb) in filter.0.iter().zip(fresh.0.iter()) {
1226                assert_eq!(fb.0, rb.0);
1227            }
1228        }
1229    }
1230
1231    /// test that the fpp estimator's overestimation doesn't cause fold_to_target_fpp
1232    /// to produce significantly oversized filters
1233    ///
1234    /// compare the final size after folding against the theoretical optimal size
1235    #[test]
1236    #[cfg_attr(miri, ignore)] // Takes too long
1237    fn test_fold_size_vs_optimal_fixed_size() {
1238        for (ndv, target_fpp) in [
1239            (1000, 0.05),
1240            (1000, 0.01),
1241            (5000, 0.05),
1242            (5000, 0.01),
1243            (10000, 0.05),
1244        ] {
1245            let values = (0..ndv).map(|i| format!("d_{i}")).collect::<Vec<_>>();
1246
1247            let mut folded = Sbbf::new_with_num_of_bytes(128 * 1024); // 128KB
1248            for v in &values {
1249                folded.insert(v.as_str());
1250            }
1251            folded.fold_to_target_fpp(target_fpp);
1252
1253            let folded_bytes = folded.num_blocks() * 32;
1254
1255            let optimal = Sbbf::new_with_ndv_fpp(ndv as u64, target_fpp).unwrap();
1256            let optimal_bytes = optimal.num_blocks() * 32;
1257
1258            let ratio = folded_bytes as f64 / optimal_bytes as f64;
1259
1260            assert_eq!(ratio, 1.0);
1261        }
1262    }
1263
1264    /// verify that a folded sbbf has the same empirical fpp as a fresh filter of the same size
1265    /// this bridges the bit-identity proof above with the FPP guarantee from the folding paper
1266    ///     since the bits are identical, the false-positive rate must be too
1267    ///
1268    /// we measure fpp empirically by probing with values that were never inserted
1269    /// and counting how many are incorrectly marked as present
1270    #[test]
1271    #[cfg_attr(miri, ignore)] // Takes too long
1272    fn test_folded_fpp_matches_fresh_fpp() {
1273        let ndv = 2000;
1274        let num_probes = 50_000;
1275        let inserted = (0..ndv)
1276            .map(|i| format!("ins_{i}"))
1277            .collect::<Vec<String>>();
1278
1279        // probe values that were NOT inserted (different prefix guarantees no overlap)
1280        let probes = (0..num_probes)
1281            .map(|i| format!("probe_{i}"))
1282            .collect::<Vec<String>>();
1283
1284        // build a large filter and fold it down several times
1285        let mut folded = Sbbf::new_with_num_of_bytes(512 * 32); // 512 blocks
1286        for v in &inserted {
1287            folded.insert(v.as_str());
1288        }
1289
1290        // check FPP at each fold level
1291        for expected_blocks in [256, 128, 64, 32, 16, 8, 4, 2, 1] {
1292            folded.fold_n(1);
1293            assert_eq!(folded.num_blocks(), expected_blocks);
1294
1295            // build a fresh filter of the same size with the same values
1296            let mut fresh = Sbbf::new_with_num_of_bytes(expected_blocks * 32);
1297            for v in &inserted {
1298                fresh.insert(v.as_str());
1299            }
1300
1301            // measure empirical FPP on both
1302            let mut folded_fp = 0u64;
1303            let mut fresh_fp = 0u64;
1304            for p in &probes {
1305                if folded.check(p.as_str()) {
1306                    folded_fp += 1;
1307                }
1308                if fresh.check(p.as_str()) {
1309                    fresh_fp += 1;
1310                }
1311            }
1312
1313            // bit-identity means these must be exactly equal
1314            assert_eq!(folded_fp, fresh_fp);
1315        }
1316    }
1317}