Bloom filter
Overview
Section titled “Overview”| Module | ds.08 · side · Rust · Pass 3 · 4 to 5 h (optional) |
| You build | rust/crates/tl-ds/src/bloom.rs: fnv1a64, mix64, optimal_m, optimal_k, predicted_fp_rate, Bloom (with_rate, new, bit_positions, insert, contains, union, count_ones, to_bytes, from_bytes) |
| Contract | byte layout and hashing: formats/bloom.md |
| Tests | course/tests/rust/ds_08.rs; what they check: section 4 · parity suite bloom · your own tests in rust/crates/tl-ds/tests/ds08_bloom.rs, rung R4 (proptest), graded by mutation (threshold 0.80, s01 to s10 required) |
| Needs | reading: S-M06b the false-positive rate and the optimal k, M06.3 FNV-1a and the SplitMix finalizer |
| Used by | No runtime module. This optional Rust module is checked on its own and against the shared golden bit arrays (ol parity bloom); data.03 writes an independent Python Bloom screen to the same format. |
| Milestone | none (optional module: MS-corpus runs the Python screen of data.03; ol parity bloom compares the two) |
| Optional depth | Bloom, Space/Time Trade-offs in Hash Coding with Allowable Errors (1970); Kirsch and Mitzenmacher, Less Hashing, Same Performance: Building a Better Bloom Filter (2006); Broder and Mitzenmacher, Network Applications of Bloom Filters: A Survey (2004) |
Key Takeaways
Section titled “Key Takeaways”- A Bloom filter is bits and hash functions: insert sets bits,
containschecks them, so a member is never missed and a non-member is reported present with probability about (no_false_negatives,fp_rate_within_three_sigma). - For items at rate , bits and hashes: 4 items at 10% need 20 bits and 3 hashes (
hand_example_sizing_and_bits,sizing_formulas). - Two hashes are enough: (double hashing) with = FNV-1a 64 and = the SplitMix finalizer of , forced odd (
hash_functions_hand_values). - Union is bitwise OR of two filters with the same and , which is exactly the filter of both sets (
union_is_the_filter_of_both_sets). - A fixed byte layout makes the filter portable: Rust and the oracle produce the same 35 bytes for the worked example (
golden_bytes_match_the_format).
How to work this chapter
Section titled “How to work this chapter”ol start ds.08 # stubs bloom.rs in tl-dsol tests ds.08 # read the test catalog firstol check ds.08 # Rust tests, then grades your testsol parity bloom # compare the Rust filter with the golden bit arraysNo other module calls this code: languages meet only over processes and files, and the one Bloom filter on the serving path, the Python screen of data.03, is written in Python. The byte format is what the two share, and ol parity bloom holds both to it. Write the hashing and sizing first and check them on the worked example, then bytes and round trips.
1. Why now
Section titled “1. Why now”Your corpus pipeline (data.01 to data.08, batch B5) must drop exact duplicate paragraphs from millions of documents. A set of paragraph hashes in Python is 8 bytes of hash plus about 60 bytes of overhead per entry; a Bloom filter answers “have I seen this?” in about 10 bits per paragraph, with no false negatives and a false-positive rate you choose. The dedup stage then confirms every “seen” answer exactly (a sort-merge on the full hashes), so a false positive costs time, never data. The serialized format is useful across processes and implementations; formats/bloom.md fixes that layout.
2. Principles
Section titled “2. Principles”2.1 Bits and hash functions
Section titled “2.1 Bits and hash functions”| Symbol | Meaning | Type |
|---|---|---|
| items inserted (or expected) | u64 | |
| bits in the filter | u64, | |
| hash functions (bits per item) | u32, | |
| target false-positive rate | real in | |
| an item: a byte string | &[u8] | |
| the bit positions of | u64 in | |
| the fraction of bits set | real in |
Insert sets bits ; contains answers yes when all are set. An inserted item’s bits stay set forever (nothing clears bits), so there are no false negatives. A non-member is reported present only if all of its bits were set by other items.
2.2 The false-positive rate and the optimal k
Section titled “2.2 The false-positive rate and the optimal k”Model each of the bit choices as uniform and independent. One bit stays 0 after all of them with probability , so the fraction of set bits is about , and a fresh item’s probes all hit set bits with probability
More hash functions mean more checks per query but more bits set. Minimizing over (S-M06b) gives , where exactly half the bits are set, and . Solving for :
rounding half away from zero. About bits per item: 9.6 bits at 1%, 14.4 at 0.1%.
2.3 Two hashes make k
Section titled “2.3 Two hashes make k”Computing independent hashes per item is slow. Kirsch and Mitzenmacher showed that (mod ) loses nothing asymptotically. The format fixes:
FNV-1a 64 (the same as the KV block hash of formats/kv-block.md): start from the offset basis 0xcbf29ce484222325; for each byte, XOR it in, then multiply by the prime 0x100000001b3 (mod ). Multiplying first and XORing after is FNV-1, a different hash. mix64 is the SplitMix64 finalizer, a bijection that spreads every input bit over every output bit (M06.3). Forcing odd keeps the probes distinct when is a power of two. Bit lives in byte at bit position , least significant bit first.
2.4 What the tests can measure
Section titled “2.4 What the tests can measure”The approximation of 2.2 assumes independent bits. Given an actual filter, the exact probability that a random fresh item is a false positive is with the measured fraction of set bits (each probe lands on a uniform bit). Over fresh items the count of false positives is then Binomial, with mean and standard deviation : the statistical test asks the count to lie within 3 standard deviations, which a correct filter fails about 0.3% of the time (it passes seeds 0 to 30).
2.5 Union and bytes
Section titled “2.5 Union and bytes”Two filters with the same and (and so the same hash positions) merge by bitwise OR: the result has exactly the bits that inserting both sets would set. n_inserted counts insert calls and a union adds the counts. to_bytes writes the 32-byte little-endian header (magic TLBF, version 1, , , a reserved zero, n_inserted) and then the bytes of bits; from_bytes rejects anything else.
The Python corpus pipeline has a separate implementation in python/corpus/dedup.py. This module focuses on the Rust filter and its serialized format.
3. Worked example by hand
Section titled “3. Worked example by hand”with_rate(4, 0.1): , so ; , so . The bit array is bytes.
cat: and , which give (the format page lists every item’s positions). dog gives . After inserting both, the set bits are 0 and 3 (byte 0: ), 11, 13, 14 (byte 1: bits 3, 5, 6 of that byte, ), and 17 (byte 2: bit 1, ): 09 68 02. bird probes : bit 7 is clear, so contains(b"bird") is false. Six of 20 bits are set, , and a fresh item’s chance of a false positive is .
to_bytes is the 35 bytes
54 4c 42 46 01 00 00 00 14 00 00 00 00 00 00 00 "TLBF", version 1, m = 2003 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00 k = 3, reserved 0, n_inserted = 209 68 02 the bitsThis is hand_example_sizing_and_bits in Rust.
4. The interface
Section titled “4. The interface”// rust/crates/tl-ds/src/bloom.rs (formats/bloom.md)pub const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; pub const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;pub const MAGIC: [u8; 4] = *b"TLBF"; pub const VERSION: u32 = 1; pub const HEADER_LEN: usize = 32;pub const MAX_BITS: u64 = 1 << 40;pub enum BloomError { BadParams(String), Mismatch(String), Format(String) } // Display + Errorpub fn fnv1a64(bytes: &[u8]) -> u64;pub fn mix64(z: u64) -> u64;pub fn optimal_m(n: u64, p: f64) -> u64;pub fn optimal_k(m: u64, n: u64) -> u32;pub fn predicted_fp_rate(m: u64, k: u32, n: u64) -> f64;#[derive(Clone, Debug, PartialEq, Eq)] pub struct Bloom { /* m, k, n_inserted, bits */ }impl Bloom { pub fn with_rate(n: u64, p: f64) -> Result<Bloom, BloomError>; // BadParams unless n >= 1, 0 < p < 1 pub fn new(m: u64, k: u32) -> Result<Bloom, BloomError>; // 1 <= m <= 2^40, k >= 1 pub fn m(&self) -> u64; pub fn k(&self) -> u32; pub fn n_inserted(&self) -> u64; pub fn bit_positions(&self, item: &[u8]) -> Vec<u64>; pub fn insert(&mut self, item: &[u8]); pub fn contains(&self, item: &[u8]) -> bool; pub fn union(&mut self, other: &Bloom) -> Result<(), BloomError>; // Mismatch when m or k differ pub fn count_ones(&self) -> u64; pub fn to_bytes(&self) -> Vec<u8>; pub fn from_bytes(b: &[u8]) -> Result<Bloom, BloomError>;}What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
hand_example_sizing_and_bits | unit | section 3: , , the positions of cat, dog, bird, the 35 bytes | you, the format page, and the tests agree |
hash_functions_hand_values | golden | FNV-1a of "", “a”, “cat”; mix64 of 0 and 1 | every implementation sets the same bits |
sizing_formulas | unit | and for five , including the rounding of ; byte length | the screen is as small as the target allows |
bad_parameters_are_errors | boundary | , , NaN, , are BadParams | a bad config fails at start, not with a 128 GiB allocation |
no_false_negatives | property | 5,000 random items found in their filter, after a byte round trip, and in the union | dedup never keeps a duplicate it has seen |
fp_rate_within_three_sigma | statistical | 100,000 fresh items: false positives within 3 sd of , and below 2% at a 1% design | the filter is as good as its sizing promises |
union_is_the_filter_of_both_sets | unit | OR of two filters equals the filter of both; counts add; mismatched or refused | data.03 merges per-shard screens |
golden_bytes_match_the_format | golden | 10 oracle cases (empty items, odd , ): bytes and probe answers | the parity suite bloom |
from_bytes_rejects_malformed_input | boundary | bad magic, version, , , reserved, short, long, inconsistent length | a corrupt file is refused, not misread |
Your tests (rung R4). Write rust/crates/tl-ds/tests/ds08_bloom.rs with proptest (craft.04 teaches it): the worked example bytes, the sizing table, and the properties “every inserted item is present”, “bytes round-trip”, “union equals one filter of both sets”, plus a check of a mismatched union and of malformed bytes. Use a fixed proptest seed and no failure files (Config { rng_seed: RngSeed::Fixed(..), failure_persistence: None, .. }). At least 80% of the planted bugs, and every planted bug in bloom.rs (s01 to s10), must make one fail. |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
| Flooring instead of taking the ceiling | the filter is a bit short: the rate misses its target and bytes differ from every other implementation | sizing_formulas, hand_example_sizing_and_bits (mutant s01) |
Using mix64(h1) without forcing it odd | probes coincide when is a power of two; bits differ from the format | hand_example_sizing_and_bits, golden_bytes_match_the_format (mutant s02) |
| Numbering bits from the most significant end | your bytes read back wrong in every other implementation | hand_example_sizing_and_bits (mutant s03) |
| Truncating instead of rounding | 6.64 becomes 6 hashes; more false positives | sizing_formulas (mutant s04) |
Forgetting to add the counts in union | the header’s n_inserted is wrong after a merge | union_is_the_filter_of_both_sets (mutant s05) |
| Setting fewer than bits on insert | false negatives: duplicates slip through dedup | no_false_negatives (mutant s06) |
| Dropping the term | all probes hit one bit: the rate is , not | fp_rate_within_three_sigma (mutant s07) |
AND instead of OR in union | the union forgets members of both sets | no_false_negatives, union_is_the_filter_of_both_sets (mutant s08) |
Trusting the length in from_bytes | a truncated file loads and later panics on an out-of-range bit | from_bytes_rejects_malformed_input (mutant s09) |
| FNV-1 (multiply, then XOR) instead of FNV-1a | every position differs from the format and from kv-block.md hashes | hash_functions_hand_values (mutant s10) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | S-M06b | the false-positive rate, the optimal , and the bits-per-item bound of section 2.2 |
| Back | M06.3 | FNV-1a and the SplitMix64 finalizer |
| Forward | data.03 | reading, not a call: its Python exact_dedup sizes its own screen at about 10 bits per paragraph with these formulas, inserts each paragraph hash, and confirms every positive exactly; ol parity bloom checks that both screens write the same bytes |
This module is optional: skipping it blocks no other module, since data.03 never calls Rust.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
Bloom | datatrove exact dedup | sharded hashing with sort-merge and no filter at all, trading disk for certainty | src/datatrove/pipeline/dedup/exact_substrings.py, sentence_dedup.py |
| fixed double hashing | Redis Bloom filters (RedisBloom) | scalable filters that add layers as grows past the design | src/sb.c |
| bits per item at a target rate | cuckoo filters, xor filters | deletion, and about 20% fewer bits at low false-positive rates | Fan et al. (2014); Graf and Lemire (2020) |
to_bytes | Parquet and ORC Bloom filters | split-block filters stored per column chunk, read before a scan | the Apache Parquet BloomFilter.md spec |