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 }
728
729 /// Reads a Sbff from Thrift encoded bytes
730 ///
731 /// # Examples
732 ///
733 /// ```no_run
734 /// # use parquet::errors::Result;
735 /// # use parquet::bloom_filter::Sbbf;
736 /// # fn main() -> Result<()> {
737 /// // In a real application, you would read serialized bloom filter bytes from a cache.
738 /// // This example demonstrates the deserialization process.
739 /// // Assuming you have bloom filter bytes from a Parquet file:
740 /// # let serialized_bytes: Vec<u8> = vec![];
741 /// let bloom_filter = Sbbf::from_bytes(&serialized_bytes)?;
742 /// // Now you can use the bloom filter to check for values
743 /// if bloom_filter.check(&"some_value") {
744 /// println!("Value might be present (or false positive)");
745 /// } else {
746 /// println!("Value is definitely not present");
747 /// }
748 /// # Ok(())
749 /// # }
750 /// ```
751 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParquetError> {
752 let (header, header_len) = read_bloom_filter_header_and_length_from_bytes(bytes)?;
753
754 let bitset_length: u64 = header
755 .num_bytes
756 .try_into()
757 .map_err(|_| ParquetError::General("Bloom filter length is invalid".to_string()))?;
758
759 // Validate that bitset consumes all remaining bytes
760 if header_len + bitset_length != bytes.len() as u64 {
761 return Err(ParquetError::General(format!(
762 "Bloom filter data contains extra bytes: expected {} total bytes, got {}",
763 header_len + bitset_length,
764 bytes.len()
765 )));
766 }
767
768 let start = header_len as usize;
769 let end = (header_len + bitset_length) as usize;
770 let bitset = bytes
771 .get(start..end)
772 .ok_or_else(|| ParquetError::General("Bloom filter bitset is invalid".to_string()))?;
773
774 Ok(Self::new(bitset))
775 }
776}
777
778// per spec we use xxHash with seed=0
779const SEED: u64 = 0;
780
781#[inline]
782fn hash_as_bytes<A: AsBytes + ?Sized>(value: &A) -> u64 {
783 XxHash64::oneshot(SEED, value.as_bytes())
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn test_hash_bytes() {
792 assert_eq!(hash_as_bytes(""), 17241709254077376921);
793 }
794
795 #[test]
796 #[cfg_attr(miri, ignore)] // Takes too long
797 fn test_mask_set_quick_check() {
798 for i in 0..1_000_000 {
799 let result = Block::mask(i);
800 assert!(result.0.iter().all(|&x| x.is_power_of_two()));
801 }
802 }
803
804 #[test]
805 #[cfg_attr(miri, ignore)] // Takes too long
806 fn test_block_insert_and_check() {
807 for i in 0..1_000_000 {
808 let mut block = Block::ZERO;
809 block.insert(i);
810 assert!(block.check(i));
811 }
812 }
813
814 #[test]
815 #[cfg_attr(miri, ignore)] // Takes too long
816 fn test_sbbf_insert_and_check() {
817 let mut sbbf = Sbbf(vec![Block::ZERO; 1_000]);
818 for i in 0..1_000_000 {
819 sbbf.insert(&i);
820 assert!(sbbf.check(&i));
821 }
822 }
823
824 #[test]
825 fn test_with_fixture() {
826 // bloom filter produced by parquet-mr/spark for a column of i64 f"a{i}" for i in 0..10
827 let bitset: &[u8] = &[
828 200, 1, 80, 20, 64, 68, 8, 109, 6, 37, 4, 67, 144, 80, 96, 32, 8, 132, 43, 33, 0, 5,
829 99, 65, 2, 0, 224, 44, 64, 78, 96, 4,
830 ];
831 let sbbf = Sbbf::new(bitset);
832 for a in 0..10i64 {
833 let value = format!("a{a}");
834 assert!(sbbf.check(&value.as_str()));
835 }
836 }
837
838 /// test the assumption that bloom filter header size should not exceed SBBF_HEADER_SIZE_ESTIMATE
839 /// essentially we are testing that the struct is packed with 4 i32 fields, each can be 1-5 bytes
840 /// so altogether it'll be 20 bytes at most.
841 #[test]
842 fn test_bloom_filter_header_size_assumption() {
843 let buffer: &[u8; 16] = &[21, 64, 28, 28, 0, 0, 28, 28, 0, 0, 28, 28, 0, 0, 0, 99];
844 let (
845 BloomFilterHeader {
846 algorithm,
847 compression,
848 hash,
849 num_bytes,
850 },
851 read_length,
852 ) = read_bloom_filter_header_and_length(Bytes::copy_from_slice(buffer)).unwrap();
853 assert_eq!(read_length, 15);
854 assert_eq!(algorithm, BloomFilterAlgorithm::BLOCK);
855 assert_eq!(compression, BloomFilterCompression::UNCOMPRESSED);
856 assert_eq!(hash, BloomFilterHash::XXHASH);
857 assert_eq!(num_bytes, 32_i32);
858 assert_eq!(20, SBBF_HEADER_SIZE_ESTIMATE);
859 }
860
861 #[test]
862 fn test_optimal_num_of_bytes() {
863 for (input, expected) in &[
864 (0, 32),
865 (9, 32),
866 (31, 32),
867 (32, 32),
868 (33, 64),
869 (99, 128),
870 (1024, 1024),
871 (999_000_000, 128 * 1024 * 1024),
872 ] {
873 assert_eq!(*expected, optimal_num_of_bytes(*input));
874 }
875 }
876
877 #[test]
878 fn test_num_of_bits_from_ndv_fpp() {
879 for (fpp, ndv, num_bits) in &[
880 (0.1, 10, 57),
881 (0.01, 10, 96),
882 (0.001, 10, 146),
883 (0.1, 100, 577),
884 (0.01, 100, 968),
885 (0.001, 100, 1460),
886 (0.1, 1000, 5772),
887 (0.01, 1000, 9681),
888 (0.001, 1000, 14607),
889 (0.1, 10000, 57725),
890 (0.01, 10000, 96815),
891 (0.001, 10000, 146076),
892 (0.1, 100000, 577254),
893 (0.01, 100000, 968152),
894 (0.001, 100000, 1460769),
895 (0.1, 1000000, 5772541),
896 (0.01, 1000000, 9681526),
897 (0.001, 1000000, 14607697),
898 (1e-50, 1_000_000_000_000, 14226231280773240832),
899 ] {
900 assert_eq!(*num_bits, num_of_bits_from_ndv_fpp(*ndv, *fpp) as u64);
901 }
902 }
903
904 #[test]
905 fn test_fold_n_halves_block_count() {
906 let mut sbbf = Sbbf::new_with_num_of_bytes(1024); // 32 blocks
907 assert_eq!(sbbf.num_blocks(), 32);
908 sbbf.fold_n(1);
909 assert_eq!(sbbf.num_blocks(), 16);
910 sbbf.fold_n(1);
911 assert_eq!(sbbf.num_blocks(), 8);
912 }
913
914 #[test]
915 fn test_fold_preserves_inserted_values() {
916 // Create a large filter, insert values, fold, verify no false negatives
917 let mut sbbf = Sbbf::new_with_num_of_bytes(32 * 1024); // 32KB = 1024 blocks
918 let values: Vec<String> = (0..1000).map(|i| format!("value_{i}")).collect();
919 for v in &values {
920 sbbf.insert(v.as_str());
921 }
922
923 // Fold several times
924 let original_blocks = sbbf.num_blocks();
925 sbbf.fold_to_target_fpp(0.05);
926 assert!(
927 sbbf.num_blocks() < original_blocks,
928 "should have folded at least once"
929 );
930
931 // All inserted values must still be found (no false negatives)
932 for v in &values {
933 assert!(
934 sbbf.check(v.as_str()),
935 "Value '{v}' missing after folding (false negative!)"
936 );
937 }
938 }
939
940 #[test]
941 fn test_fold_to_target_fpp_stops_before_exceeding_target() {
942 let mut sbbf = Sbbf::new_with_num_of_bytes(64 * 1024); // 64KB
943 // Insert enough values to set some bits
944 for i in 0..5000 {
945 sbbf.insert(&i);
946 }
947
948 let target_fpp = 0.01;
949 sbbf.fold_to_target_fpp(target_fpp);
950
951 // After folding, the estimated FPP should be at or below target
952 // (the current state should not exceed target — we stopped before that would happen)
953 let total_bits = (sbbf.num_blocks() * 256) as f64;
954 let set_bits: u64 = sbbf
955 .0
956 .iter()
957 .flat_map(|b| b.0.iter())
958 .map(|w| w.count_ones() as u64)
959 .sum();
960 let fill = set_bits as f64 / total_bits;
961 let current_fpp = fill.powi(8);
962 assert!(
963 current_fpp <= target_fpp,
964 "FPP {current_fpp} exceeds target {target_fpp}"
965 );
966 }
967
968 #[test]
969 fn test_fold_empty_filter_folds_to_minimum() {
970 // An empty filter has fill=0, so estimated FPP is always 0 — should fold all the way down
971 let mut sbbf = Sbbf::new_with_num_of_bytes(1024); // 32 blocks
972 sbbf.fold_to_target_fpp(0.01);
973 assert_eq!(sbbf.num_blocks(), 1);
974 }
975
976 #[test]
977 fn test_estimated_fpp_matches_serialized_bitset() {
978 for num_bytes in [BITSET_MIN_LENGTH, 1024, 64 * 1024] {
979 for ndv in [0u64, 1, 10, 100, 1_000, 10_000, 100_000] {
980 let mut sbbf = Sbbf::new_with_num_of_bytes(num_bytes);
981 for i in 0..ndv {
982 sbbf.insert(&i);
983 }
984
985 let mut bitset = Vec::new();
986 sbbf.write_bitset(&mut bitset).unwrap();
987 let set_bits: u64 = bitset.iter().map(|b| u64::from(b.count_ones())).sum();
988 let expected = (set_bits as f64 / (bitset.len() as f64 * 8.0)).powi(8);
989
990 assert_eq!(
991 sbbf.estimated_fpp().to_bits(),
992 expected.to_bits(),
993 "{num_bytes} bytes, {ndv} values"
994 );
995 }
996 }
997 }
998
999 #[test]
1000 fn test_estimated_fpp_bounds() {
1001 assert_eq!(Sbbf::new_with_num_of_bytes(1024).estimated_fpp(), 0.0);
1002 assert_eq!(Sbbf::new(&[0xFF; 1024]).estimated_fpp(), 1.0);
1003 assert_eq!(Sbbf::new(&[]).estimated_fpp(), 1.0);
1004 }
1005
1006 #[test]
1007 fn test_estimated_fpp_increases_when_folded() {
1008 let mut sbbf = Sbbf::new_with_num_of_bytes(64 * 1024);
1009 for i in 0..1_000 {
1010 sbbf.insert(&i);
1011 }
1012 let before = sbbf.estimated_fpp();
1013 sbbf.fold_n(3);
1014 assert!(
1015 sbbf.estimated_fpp() > before,
1016 "folding must not lower the estimate: {before} -> {}",
1017 sbbf.estimated_fpp()
1018 );
1019 }
1020
1021 #[test]
1022 #[should_panic(expected = "Cannot fold 1 times: need at least 2 blocks, have 1")]
1023 fn test_fold_n_panics_at_minimum_size() {
1024 let mut sbbf = Sbbf::new_with_num_of_bytes(32); // 1 block (minimum)
1025 sbbf.fold_n(1);
1026 }
1027
1028 #[test]
1029 fn test_sbbf_write_round_trip() {
1030 // Create a bloom filter with a 32-byte bitset (minimum size)
1031 let bitset_bytes = vec![0u8; 32];
1032 let mut original = Sbbf::new(&bitset_bytes);
1033
1034 // Insert some test values
1035 let test_values = ["hello", "world", "rust", "parquet", "bloom", "filter"];
1036 for value in &test_values {
1037 original.insert(value);
1038 }
1039
1040 // Serialize to bytes
1041 let mut output = Vec::new();
1042 original.write(&mut output).unwrap();
1043
1044 // Validate header was written correctly
1045 let mut protocol = ThriftSliceInputProtocol::new(&output);
1046 let header = BloomFilterHeader::read_thrift(&mut protocol).unwrap();
1047 assert_eq!(header.num_bytes, bitset_bytes.len() as i32);
1048 assert_eq!(header.algorithm, BloomFilterAlgorithm::BLOCK);
1049 assert_eq!(header.hash, BloomFilterHash::XXHASH);
1050 assert_eq!(header.compression, BloomFilterCompression::UNCOMPRESSED);
1051
1052 // Deserialize using from_bytes
1053 let reconstructed = Sbbf::from_bytes(&output).unwrap();
1054
1055 // Most importantly: verify the bloom filter WORKS correctly after round-trip
1056 // Note: bloom filters can have false positives, but should never have false negatives
1057 // So we can't assert !check(), but we should verify inserted values are found
1058 for value in &test_values {
1059 assert!(
1060 reconstructed.check(value),
1061 "Value '{value}' should be present after round-trip"
1062 );
1063 }
1064 }
1065
1066 /// Prove that folding an SBBF by one level produces the exact same bits
1067 /// as building a fresh filter at the smaller size from scratch.
1068 ///
1069 /// # What is folding?
1070 ///
1071 /// ```text
1072 /// Original (N = 8 blocks):
1073 /// ┌───┬───┬───┬───┬───┬───┬───┬───┐
1074 /// │ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
1075 /// └─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┘
1076 /// │ │ │ │ │ │ │ │
1077 /// └─OR┘ └─OR┘ └─OR┘ └─OR┘ pair-wise OR
1078 /// │ │ │ │
1079 /// ┌───┴──┬────┴──┬────┴──┬────┴──┐
1080 /// │ 0|1 │ 2|3 │ 4|5 │ 6|7 │ Folded (N/2 = 4 blocks)
1081 /// └──────┴───────┴───────┴───────┘
1082 /// ```
1083 ///
1084 /// # Why folded == fresh (the two lemmas)
1085 ///
1086 /// An SBBF insertion does two things with a 64-bit hash `h`:
1087 ///
1088 /// 1. **Pick a block** — uses the upper 32 bits via `hash_to_block_index`
1089 /// 2. **Set 8 bits in that block** — uses the lower 32 bits via `Block::mask`
1090 ///
1091 /// **Lemma 1 (block index halves):** `hash_to_block_index` uses
1092 /// `(upper32 * N) >> 32`. When N halves, the index halves too:
1093 /// `index_in(N/2) == index_in(N) / 2`. So the hash lands in the same
1094 /// destination block whether you fold or build fresh.
1095 ///
1096 /// **Lemma 2 (mask is size-independent):** `Block::mask(h as u32)` depends
1097 /// only on the lower 32 bits and the fixed SALT constants — the filter
1098 /// size N is not involved. So the same 8 bits get set regardless.
1099 ///
1100 /// Combined: every hash sets the *same bits* in the *same destination
1101 /// block* whether you fold or build fresh → filters are bit-identical.
1102 #[test]
1103 #[cfg_attr(miri, ignore)] // Takes too long
1104 fn test_sbbf_folded_equals_fresh() {
1105 let values = (0..5000).map(|i| format!("elem_{i}")).collect::<Vec<_>>();
1106 let hashes = values
1107 .iter()
1108 .map(|v| hash_as_bytes(v.as_str()))
1109 .collect::<Vec<_>>();
1110
1111 for num_blocks in [64, 256, 1024] {
1112 let half = num_blocks / 2;
1113
1114 // Build a filter with N blocks and insert all values.
1115 let mut original = Sbbf::new_with_num_of_bytes(num_blocks * 32);
1116 assert_eq!(original.num_blocks(), num_blocks);
1117 for &h in &hashes {
1118 original.insert_hash(h);
1119 }
1120
1121 // --- Per-hash verification of the two lemmas ---
1122 for &h in &hashes {
1123 // mask(h as u32) gives the 8-bit pattern that this hash sets
1124 // inside whichever block it lands in. It uses only the lower
1125 // 32 bits of h, so it's the same regardless of filter size.
1126 let mask = Block::mask(h as u32);
1127
1128 // Lemma 1 check: the block index in the original N-block
1129 // filter, divided by 2, should equal the block index in a
1130 // fresh N/2-block filter.
1131 let orig_idx = original.hash_to_block_index(h);
1132 assert!(orig_idx < num_blocks);
1133
1134 let fresh_idx = {
1135 let tmp = Sbbf(vec![Block::ZERO; half]);
1136 tmp.hash_to_block_index(h)
1137 };
1138 let folded_idx = orig_idx / 2;
1139 assert_eq!(
1140 fresh_idx, folded_idx,
1141 "Lemma 1 failed: fresh index {fresh_idx} != folded index {folded_idx}"
1142 );
1143
1144 // Lemma 2 check: every bit that mask wants to set is actually
1145 // present in the original block.
1146 //
1147 // mask.0[w] has exactly ONE bit set (see Block::mask: `1 << y`).
1148 // The block at orig_idx has many bits set from many inserts, so
1149 // we can't test equality — we test that the specific mask bit is
1150 // *present*:
1151 //
1152 // block_word & mask_word != 0
1153 // ⟺ "the one bit in the mask is set in the block"
1154 //
1155 // (Since mask_word has exactly 1 bit, `& mask != 0` is the same
1156 // as `& mask == mask` — but `!= 0` reads more naturally.)
1157 for w in 0..8 {
1158 assert_ne!(
1159 original.0[orig_idx].0[w] & mask.0[w],
1160 0,
1161 "Lemma 2 failed: mask bit not set in word {w} of block {orig_idx}"
1162 );
1163 }
1164 }
1165
1166 // --- Final bit-identical comparison ---
1167 // Fold the original N-block filter down to N/2 blocks.
1168 let mut folded = original.clone();
1169 folded.fold_n(1);
1170 assert_eq!(folded.num_blocks(), half);
1171
1172 // Build a fresh N/2-block filter with the same values.
1173 let mut fresh = Sbbf::new_with_num_of_bytes(half * 32);
1174 for &h in &hashes {
1175 fresh.insert_hash(h);
1176 }
1177
1178 // By lemmas 1 + 2, every block should be bit-identical.
1179 for j in 0..half {
1180 assert_eq!(
1181 folded.0[j].0, fresh.0[j].0,
1182 "Block {j} differs after fold (N={num_blocks} → {half})"
1183 );
1184 }
1185 }
1186 }
1187
1188 /// Inductive multi-step folding: folding k times from N blocks produces
1189 /// a filter bit-identical to a fresh N/2^k-block filter.
1190 ///
1191 /// `test_sbbf_folded_equals_fresh` proves the base case (one fold).
1192 /// This test applies folds *repeatedly*, checking after each step:
1193 ///
1194 /// ```text
1195 /// 512 ─fold→ 256 ─fold→ 128 ─…→ 1 (9 folds total)
1196 /// ```
1197 ///
1198 /// At each intermediate size we build a fresh filter and assert
1199 /// bit-equality, confirming the lemma composes across folds.
1200 #[test]
1201 #[cfg_attr(miri, ignore)] // Takes too long
1202 fn test_multi_step_fold() {
1203 let values = (0..3000).map(|i| format!("x_{i}")).collect::<Vec<_>>();
1204
1205 // Start with a 512-block filter.
1206 let mut filter = Sbbf::new_with_num_of_bytes(512 * 32);
1207 for v in &values {
1208 filter.insert(v.as_str());
1209 }
1210
1211 // Fold one level at a time, comparing against a fresh filter each step.
1212 for expected_blocks in [256, 128, 64, 32, 16, 8, 4, 2, 1] {
1213 filter.fold_n(1);
1214 assert_eq!(filter.num_blocks(), expected_blocks);
1215
1216 let mut fresh = Sbbf::new_with_num_of_bytes(expected_blocks * 32);
1217 for v in &values {
1218 fresh.insert(v.as_str());
1219 }
1220 for (fb, rb) in filter.0.iter().zip(fresh.0.iter()) {
1221 assert_eq!(fb.0, rb.0);
1222 }
1223 }
1224 }
1225
1226 /// test that the fpp estimator's overestimation doesn't cause fold_to_target_fpp
1227 /// to produce significantly oversized filters
1228 ///
1229 /// compare the final size after folding against the theoretical optimal size
1230 #[test]
1231 #[cfg_attr(miri, ignore)] // Takes too long
1232 fn test_fold_size_vs_optimal_fixed_size() {
1233 for (ndv, target_fpp) in [
1234 (1000, 0.05),
1235 (1000, 0.01),
1236 (5000, 0.05),
1237 (5000, 0.01),
1238 (10000, 0.05),
1239 ] {
1240 let values = (0..ndv).map(|i| format!("d_{i}")).collect::<Vec<_>>();
1241
1242 let mut folded = Sbbf::new_with_num_of_bytes(128 * 1024); // 128KB
1243 for v in &values {
1244 folded.insert(v.as_str());
1245 }
1246 folded.fold_to_target_fpp(target_fpp);
1247
1248 let folded_bytes = folded.num_blocks() * 32;
1249
1250 let optimal = Sbbf::new_with_ndv_fpp(ndv as u64, target_fpp).unwrap();
1251 let optimal_bytes = optimal.num_blocks() * 32;
1252
1253 let ratio = folded_bytes as f64 / optimal_bytes as f64;
1254
1255 assert_eq!(ratio, 1.0);
1256 }
1257 }
1258
1259 /// verify that a folded sbbf has the same empirical fpp as a fresh filter of the same size
1260 /// this bridges the bit-identity proof above with the FPP guarantee from the folding paper
1261 /// since the bits are identical, the false-positive rate must be too
1262 ///
1263 /// we measure fpp empirically by probing with values that were never inserted
1264 /// and counting how many are incorrectly marked as present
1265 #[test]
1266 #[cfg_attr(miri, ignore)] // Takes too long
1267 fn test_folded_fpp_matches_fresh_fpp() {
1268 let ndv = 2000;
1269 let num_probes = 50_000;
1270 let inserted = (0..ndv)
1271 .map(|i| format!("ins_{i}"))
1272 .collect::<Vec<String>>();
1273
1274 // probe values that were NOT inserted (different prefix guarantees no overlap)
1275 let probes = (0..num_probes)
1276 .map(|i| format!("probe_{i}"))
1277 .collect::<Vec<String>>();
1278
1279 // build a large filter and fold it down several times
1280 let mut folded = Sbbf::new_with_num_of_bytes(512 * 32); // 512 blocks
1281 for v in &inserted {
1282 folded.insert(v.as_str());
1283 }
1284
1285 // check FPP at each fold level
1286 for expected_blocks in [256, 128, 64, 32, 16, 8, 4, 2, 1] {
1287 folded.fold_n(1);
1288 assert_eq!(folded.num_blocks(), expected_blocks);
1289
1290 // build a fresh filter of the same size with the same values
1291 let mut fresh = Sbbf::new_with_num_of_bytes(expected_blocks * 32);
1292 for v in &inserted {
1293 fresh.insert(v.as_str());
1294 }
1295
1296 // measure empirical FPP on both
1297 let mut folded_fp = 0u64;
1298 let mut fresh_fp = 0u64;
1299 for p in &probes {
1300 if folded.check(p.as_str()) {
1301 folded_fp += 1;
1302 }
1303 if fresh.check(p.as_str()) {
1304 fresh_fp += 1;
1305 }
1306 }
1307
1308 // bit-identity means these must be exactly equal
1309 assert_eq!(folded_fp, fresh_fp);
1310 }
1311 }
1312}