Skip to main content

subetha_pointers/
bloom_pointer.rs

1//! `BloomPointer<T>` - pointer carrying a Bloom-filter summary of
2//! its target's contents.
3//!
4//! Layout: `(bloom: u64, target: Arc<T>)`. The 64-bit Bloom filter
5//! is set from external knowledge of what's reachable through
6//! `target` - typically the keys of a HashMap, the labels of a
7//! graph node's outgoing edges, the IDs that occupy a B-tree
8//! subtree.
9//!
10//! The architectural win: `bloom_contains(query_key)` rejects
11//! membership queries in one register-compare without touching the
12//! pointed-to data. With a 64-bit filter + 4 hash functions and ~16
13//! items, false-positive rate is ~3%; for ~97% of negative queries
14//! the scan skips the pointer chase entirely.
15//!
16//! # K_cascade composition - `BloomCascade<T>`
17//!
18//! Wraps `BloomPointer<BloomPointer<T>>` semantics in a dedicated
19//! struct: coarse 8-byte filter for level-0 rejection, finer 32-byte
20//! filter for level-1 rejection, target pointer for the deref.
21//! Mirrors the LSM-tree multi-level Bloom design.
22
23use std::hash::{Hash, Hasher};
24use std::sync::Arc;
25
26/// Fast non-cryptographic hash used to derive Bloom filter bit
27/// indices. FxHash-style rotate-xor-multiply chain; ~2-3 ns per
28/// u64 key on modern x86. Replaces SipHash (`DefaultHasher`) which
29/// at ~15-20 ns per call dominated `might_contain` cost on small-
30/// payload workloads.
31#[derive(Default)]
32struct FxBloomHasher(u64);
33
34const FX_SEED: u64 = 0xCBF2_9CE4_8422_2325;
35const FX_MULT: u64 = 0x517C_C1B7_2722_0A95;
36
37impl FxBloomHasher {
38    fn with_seed(seed: u64) -> Self {
39        Self(seed ^ FX_SEED)
40    }
41}
42
43impl Hasher for FxBloomHasher {
44    #[inline]
45    fn write(&mut self, bytes: &[u8]) {
46        let mut chunks = bytes.chunks_exact(8);
47        for c in &mut chunks {
48            let n = u64::from_le_bytes([
49                c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7],
50            ]);
51            self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(FX_MULT);
52        }
53        for &b in chunks.remainder() {
54            self.0 = (self.0.rotate_left(5) ^ b as u64).wrapping_mul(FX_MULT);
55        }
56    }
57    #[inline]
58    fn write_u64(&mut self, n: u64) {
59        self.0 = (self.0.rotate_left(5) ^ n).wrapping_mul(FX_MULT);
60    }
61    #[inline]
62    fn write_u32(&mut self, n: u32) { self.write_u64(n as u64); }
63    #[inline]
64    fn write_u16(&mut self, n: u16) { self.write_u64(n as u64); }
65    #[inline]
66    fn write_u8(&mut self, n: u8) { self.write_u64(n as u64); }
67    #[inline]
68    fn write_i64(&mut self, n: i64) { self.write_u64(n as u64); }
69    #[inline]
70    fn write_i32(&mut self, n: i32) { self.write_u64(n as u64); }
71    #[inline]
72    fn write_isize(&mut self, n: isize) { self.write_u64(n as u64); }
73    #[inline]
74    fn write_usize(&mut self, n: usize) { self.write_u64(n as u64); }
75    #[inline]
76    fn finish(&self) -> u64 { self.0 }
77}
78
79/// 64-bit Bloom filter with 4 hash functions.
80///
81/// Capacity for ~8 distinct keys at ~3% false-positive rate. Beyond
82/// that the filter saturates and FPR climbs rapidly.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
84pub struct Bloom64(pub u64);
85
86impl Bloom64 {
87    pub const ZERO: Self = Self(0);
88    /// Suggested capacity: ~8 keys before FPR climbs past ~3%.
89    /// (64 bits / 4 hashes / 8 keys -> ~2.4% FPR; at 16 keys FPR
90    /// is already ~16%.)
91    pub const SUGGESTED_CAPACITY: usize = 8;
92
93    #[inline]
94    pub(crate) fn fast_hash<K: Hash + ?Sized>(key: &K, seed: u64) -> u64 {
95        let mut h = FxBloomHasher::with_seed(seed);
96        key.hash(&mut h);
97        h.finish()
98    }
99
100    /// 4 indices into the 64-bit filter, derived as 6-bit slices of a
101    /// single fast-hash output. The slices are spaced 16 bits apart
102    /// (positions 0, 16, 32, 48) to keep cross-slice correlation low.
103    #[inline]
104    fn indices<K: Hash + ?Sized>(key: &K) -> [u8; 4] {
105        let h = Self::fast_hash(key, 0x9E37_79B9_7F4A_7C15);
106        [
107            (h & 0x3F) as u8,
108            ((h >> 16) & 0x3F) as u8,
109            ((h >> 32) & 0x3F) as u8,
110            ((h >> 48) & 0x3F) as u8,
111        ]
112    }
113
114    /// Insert `key` into the filter.
115    pub fn insert<K: Hash + ?Sized>(&mut self, key: &K) {
116        for bit in Self::indices(key) {
117            self.0 |= 1u64 << bit;
118        }
119    }
120
121    /// Probabilistic membership: returns `false` when key is
122    /// definitely-not-present, `true` when key might-be-present.
123    pub fn might_contain<K: Hash + ?Sized>(&self, key: &K) -> bool {
124        let bits = Self::indices(key);
125        for bit in bits {
126            if (self.0 >> bit) & 1 == 0 {
127                return false;
128            }
129        }
130        true
131    }
132
133    /// Build from an iterator of keys.
134    pub fn from_keys<'a, K, I>(keys: I) -> Self
135    where K: Hash + 'a, I: IntoIterator<Item = &'a K>,
136    {
137        let mut b = Self::ZERO;
138        for k in keys { b.insert(k); }
139        b
140    }
141
142    /// Number of set bits (informational; high values suggest
143    /// saturation).
144    pub fn popcount(&self) -> u32 { self.0.count_ones() }
145
146    /// Approximate false-positive rate assuming `n` keys inserted.
147    pub fn estimated_fpr(n: usize) -> f64 {
148        // Standard FPR formula: (1 - exp(-k*n/m))^k with m=64, k=4.
149        let m = 64.0;
150        let k = 4.0;
151        let p_zero = (-k * n as f64 / m).exp();
152        (1.0 - p_zero).powf(k)
153    }
154}
155
156/// `(Bloom64, Arc<T>)` - 16 bytes on 64-bit.
157#[derive(Debug, Clone)]
158pub struct BloomPointer<T> {
159    bloom: Bloom64,
160    target: Arc<T>,
161}
162
163impl<T> BloomPointer<T> {
164    /// Direction signature of `BloomPointer<T>`. Engages the
165    /// `K_content_prefix` axis (bloom-filter summary of the
166    /// target's keys stored at slot for fast set-membership
167    /// rejection before deref).
168    pub const SIGNATURE: subetha_core::AxisMask = subetha_core::AxisMask::from_axes(
169        &[subetha_core::Axis::ContentPrefix],
170    );
171
172    pub fn new(target: Arc<T>, bloom: Bloom64) -> Self {
173        Self { bloom, target }
174    }
175
176    /// Build the bloom from a key iterator. Caller is responsible for
177    /// providing the keys (typically by walking `target` and yielding
178    /// owned key copies or references).
179    pub fn from_keys<K, I>(target: Arc<T>, keys: I) -> Self
180    where K: Hash, I: IntoIterator<Item = K>,
181    {
182        let mut b = Bloom64::ZERO;
183        for k in keys { b.insert(&k); }
184        Self { bloom: b, target }
185    }
186
187    #[inline]
188    pub fn bloom(&self) -> Bloom64 { self.bloom }
189
190    #[inline]
191    pub fn target(&self) -> &Arc<T> { &self.target }
192
193    /// Skip-the-deref membership test. Returns `false` for
194    /// definitely-no, `true` for might-be-yes.
195    #[inline]
196    pub fn might_contain<K: Hash + ?Sized>(&self, key: &K) -> bool {
197        self.bloom.might_contain(key)
198    }
199
200    /// Replace the bloom after target mutation. Caller is responsible
201    /// for ensuring the new bloom reflects current contents.
202    pub fn set_bloom(&mut self, b: Bloom64) { self.bloom = b; }
203}
204
205// =========================================================
206// BloomCascade<T> - multi-level filter for steeper rejection
207// =========================================================
208
209/// Two-level cascading filter: 8-byte coarse + 32-byte fine. Layer
210/// 0 (coarse) rejects in one register-compare. Layer 1 (fine) holds
211/// 4x as many bits + 8 hash functions; rejects most of the
212/// remainder before the target is touched.
213///
214/// Architectural shape: same as LSM-tree multi-level Blooms or the
215/// nested-cache pattern in Bitcoin SPV / LevelDB / RocksDB - exposed
216/// as a typed primitive.
217#[derive(Debug, Clone)]
218pub struct BloomCascade<T> {
219    coarse: Bloom64,
220    fine: BloomFine,
221    target: Arc<T>,
222}
223
224/// 256-bit Bloom filter with 8 hash functions.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
226pub struct BloomFine {
227    bits: [u64; 4],
228}
229
230impl BloomFine {
231    pub const ZERO: Self = Self { bits: [0; 4] };
232    /// ~64 keys before 5% FPR.
233    pub const SUGGESTED_CAPACITY: usize = 64;
234
235    /// 8 indices into the 256-bit filter, derived from two fast-hash
236    /// calls with different seeds. Each hash output is sliced into 4
237    /// 8-bit positions; the two hashes together yield the 8 indices.
238    /// Two seeds (vs four in the original) cuts hash cost in half
239    /// while keeping the slices distinct enough for the 256-bit
240    /// filter's bit-occupancy budget.
241    #[inline]
242    fn indices<K: Hash + ?Sized>(key: &K) -> [u8; 8] {
243        let h1 = Bloom64::fast_hash(key, 0x9E37_79B9_7F4A_7C15);
244        let h2 = Bloom64::fast_hash(key, 0xBB67_AE85_84CA_A73B);
245        [
246            (h1 & 0xFF) as u8,
247            ((h1 >> 16) & 0xFF) as u8,
248            ((h1 >> 32) & 0xFF) as u8,
249            ((h1 >> 48) & 0xFF) as u8,
250            (h2 & 0xFF) as u8,
251            ((h2 >> 16) & 0xFF) as u8,
252            ((h2 >> 32) & 0xFF) as u8,
253            ((h2 >> 48) & 0xFF) as u8,
254        ]
255    }
256
257    pub fn insert<K: Hash + ?Sized>(&mut self, key: &K) {
258        for bit in Self::indices(key) {
259            self.bits[(bit / 64) as usize] |= 1u64 << (bit % 64);
260        }
261    }
262
263    pub fn might_contain<K: Hash + ?Sized>(&self, key: &K) -> bool {
264        for bit in Self::indices(key) {
265            let word = self.bits[(bit / 64) as usize];
266            if (word >> (bit % 64)) & 1 == 0 { return false; }
267        }
268        true
269    }
270
271    pub fn from_keys<'a, K, I>(keys: I) -> Self
272    where K: Hash + 'a, I: IntoIterator<Item = &'a K>,
273    {
274        let mut b = Self::ZERO;
275        for k in keys { b.insert(k); }
276        b
277    }
278
279    pub fn popcount(&self) -> u32 {
280        self.bits.iter().map(|w| w.count_ones()).sum()
281    }
282}
283
284impl<T> BloomCascade<T> {
285    pub fn new(target: Arc<T>, coarse: Bloom64, fine: BloomFine) -> Self {
286        Self { coarse, fine, target }
287    }
288
289    /// Build both filter levels from the same key iterator.
290    pub fn from_keys<K, I>(target: Arc<T>, keys: I) -> Self
291    where K: Hash, I: IntoIterator<Item = K>,
292    {
293        let mut coarse = Bloom64::ZERO;
294        let mut fine = BloomFine::ZERO;
295        for k in keys {
296            coarse.insert(&k);
297            fine.insert(&k);
298        }
299        Self { coarse, fine, target }
300    }
301
302    pub fn target(&self) -> &Arc<T> { &self.target }
303    pub fn coarse(&self) -> Bloom64 { self.coarse }
304    pub fn fine(&self) -> &BloomFine { &self.fine }
305
306    /// Cascade rejection: coarse first (register-only), fine
307    /// second (32 bytes, 4 cache lines worst case). Returns the
308    /// LEVEL where the reject fired (0 = coarse rejected, 1 = fine
309    /// rejected, 2 = both layers said maybe-yes).
310    pub fn cascade_check<K: Hash + ?Sized>(&self, key: &K) -> CascadeOutcome {
311        if !self.coarse.might_contain(key) {
312            return CascadeOutcome::RejectedAtCoarse;
313        }
314        if !self.fine.might_contain(key) {
315            return CascadeOutcome::RejectedAtFine;
316        }
317        CascadeOutcome::MightContain
318    }
319}
320
321/// Outcome of [`BloomCascade::cascade_check`].
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum CascadeOutcome {
324    RejectedAtCoarse,
325    RejectedAtFine,
326    MightContain,
327}
328
329impl CascadeOutcome {
330    pub fn might_contain(self) -> bool { matches!(self, Self::MightContain) }
331    pub fn rejected(self) -> bool { !self.might_contain() }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn bloom64_insert_then_query() {
340        let mut b = Bloom64::ZERO;
341        b.insert(&42u64);
342        b.insert(&"hello");
343        assert!(b.might_contain(&42u64));
344        assert!(b.might_contain(&"hello"));
345        // Random key should usually NOT match in a fresh filter.
346        // Test multiple unrelated keys; expect most to reject.
347        let mut rejects = 0;
348        for k in 1000..1100u64 {
349            if !b.might_contain(&k) { rejects += 1; }
350        }
351        assert!(rejects > 80,
352                "fresh Bloom64 with 2 entries should reject most random keys; got {rejects}/100");
353    }
354
355    #[test]
356    fn bloom64_no_false_negative() {
357        // Property: inserting a key MUST always make it present.
358        let mut b = Bloom64::ZERO;
359        for k in 0..16u64 { b.insert(&k); }
360        for k in 0..16u64 {
361            assert!(b.might_contain(&k),
362                    "Bloom must never give false negative; missed {k}");
363        }
364    }
365
366    #[test]
367    fn bloom_pointer_basic_usage() {
368        let target = Arc::new(vec![1u64, 2, 3, 4, 5]);
369        let keys: Vec<u64> = target.iter().copied().collect();
370        let bp = BloomPointer::from_keys(target.clone(), keys);
371        for k in 1..=5u64 {
372            assert!(bp.might_contain(&k));
373        }
374        // Random keys mostly reject.
375        let mut rejects = 0;
376        for k in 100..200u64 {
377            if !bp.might_contain(&k) { rejects += 1; }
378        }
379        assert!(rejects > 80,
380                "BloomPointer with 5 entries should reject most random; got {rejects}/100");
381    }
382
383    #[test]
384    fn bloom_pointer_size_is_16_bytes() {
385        assert_eq!(std::mem::size_of::<BloomPointer<u64>>(), 16);
386    }
387
388    #[test]
389    fn bloom_fine_holds_more_keys_than_coarse() {
390        // Insert 32 keys into both; coarse should saturate but fine
391        // should still reject random keys at a high rate.
392        let mut coarse = Bloom64::ZERO;
393        let mut fine = BloomFine::ZERO;
394        for k in 0..32u64 {
395            coarse.insert(&k);
396            fine.insert(&k);
397        }
398        let mut coarse_rejects = 0;
399        let mut fine_rejects = 0;
400        for k in 1000..1100u64 {
401            if !coarse.might_contain(&k) { coarse_rejects += 1; }
402            if !fine.might_contain(&k) { fine_rejects += 1; }
403        }
404        // Coarse may be over-saturated at 32 entries; fine should
405        // still reject most randoms.
406        assert!(fine_rejects >= coarse_rejects,
407                "fine filter must reject at least as much as coarse: \
408                 coarse={coarse_rejects} fine={fine_rejects}");
409    }
410
411    #[test]
412    fn bloom_cascade_layered_rejection() {
413        let target: Arc<Vec<u64>> = Arc::new((0..32u64).collect());
414        let keys: Vec<u64> = target.iter().copied().collect();
415        let bc = BloomCascade::from_keys(target.clone(), keys);
416
417        // All inserted keys must be reported as might-contain.
418        for k in 0..32u64 {
419            assert!(bc.cascade_check(&k).might_contain(),
420                    "inserted key {k} must not be rejected");
421        }
422
423        // Most random keys should be rejected at coarse OR fine.
424        let mut coarse_rej = 0;
425        let mut fine_rej = 0;
426        let mut survive = 0;
427        for k in 1000..1100u64 {
428            match bc.cascade_check(&k) {
429                CascadeOutcome::RejectedAtCoarse => coarse_rej += 1,
430                CascadeOutcome::RejectedAtFine => fine_rej += 1,
431                CascadeOutcome::MightContain => survive += 1,
432            }
433        }
434        // Combined rejection rate should be high.
435        assert!(coarse_rej + fine_rej >= 90,
436                "cascade should reject most random queries; \
437                 coarse_rej={coarse_rej} fine_rej={fine_rej} survive={survive}");
438    }
439
440    #[test]
441    fn estimated_fpr_grows_with_load() {
442        let fpr1 = Bloom64::estimated_fpr(1);
443        let fpr8 = Bloom64::estimated_fpr(8);
444        let fpr16 = Bloom64::estimated_fpr(16);
445        let fpr32 = Bloom64::estimated_fpr(32);
446        assert!(fpr1 < fpr8);
447        assert!(fpr8 < fpr16);
448        assert!(fpr16 < fpr32);
449        // At capacity (~8 keys) the FPR is reasonable (<5%).
450        let fpr8_actual = Bloom64::estimated_fpr(8);
451        assert!(fpr8_actual < 0.05,
452                "8-key FPR should be < 5%, got {fpr8_actual}");
453        // At 16 keys (2x capacity) FPR is around 16%, which is the
454        // point at which a fine-tier filter starts being needed.
455        assert!(fpr16 > 0.10 && fpr16 < 0.25,
456                "16-key FPR should be in [10%, 25%], got {fpr16}");
457    }
458
459    #[test]
460    fn bloom_cascade_outer_inner_information() {
461        // Demonstrate that the cascade levels carry DIFFERENT
462        // information: a key in coarse-filter range but not fine-
463        // filter range can be rejected at the fine level.
464        let mut coarse = Bloom64::ZERO;
465        let mut fine = BloomFine::ZERO;
466        // Insert keys 0..10 into BOTH levels.
467        for k in 0..10u64 {
468            coarse.insert(&k);
469            fine.insert(&k);
470        }
471        // Insert additional keys 100..200 into coarse ONLY so a
472        // coarse-pass / fine-reject path exists.
473        for k in 100..200u64 {
474            coarse.insert(&k);
475        }
476        // Now construct a cascade with our mismatched filters.
477        let bc = BloomCascade {
478            coarse, fine,
479            target: Arc::new(()),
480        };
481        // Key 150 is in coarse but NOT in fine; must reject at fine.
482        // (With overwhelming probability.)
483        let outcome = bc.cascade_check(&150u64);
484        assert_ne!(outcome, CascadeOutcome::RejectedAtCoarse,
485                   "150 was inserted into coarse so must pass coarse");
486        // We expect either fine-reject or might-contain; both are
487        // valid. The point is the cascade structure carries the
488        // distinction.
489    }
490}