Skip to main content

radixdb_executor/optimizer/
bloom.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Runtime Bloom Filter Propagation for Join Optimization
16//!
17//! This module implements bloom filters that are built during hash join's build phase
18//! and can be pushed down to the probe side's scan to filter rows early.
19//!
20//! ## How It Works
21//!
22//! 1. During hash join build phase, we build a bloom filter of join keys
23//! 2. The bloom filter is propagated ("pushed down") to the probe side
24//! 3. Probe side scan uses the bloom filter to skip rows that definitely won't match
25//! 4. Only potential matches are sent to the actual join
26//!
27//! ## Benefits
28//!
29//! - **I/O Reduction**: Filter rows before reading from storage
30//! - **Memory Reduction**: Fewer rows materialized in probe pipeline
31//! - **CPU Reduction**: Skip hash probes for non-matching rows
32//!
33//! ## Example
34//!
35//! ```sql
36//! SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id
37//! WHERE c.country = 'US'
38//! ```
39//!
40//! Without bloom filter: Read all orders, probe against customers hash table
41//! With bloom filter: Only read orders whose customer_id MIGHT be in the filtered customers
42//!
43//! ## RadixDB-Specific: Edge Computing Optimization
44//!
45//! We use a compact bloom filter design optimized for:
46//! - Small memory footprint (suitable for edge devices)
47//! - Fast canonical `Value` hashing
48//! - Configurable false positive rate based on available memory
49
50use std::hash::{Hash, Hasher};
51
52use rustc_hash::FxHasher;
53
54use radixdb_core::Value;
55
56/// Minimum bloom filter size in bits
57const MIN_FILTER_BITS: usize = 64;
58
59/// Maximum bloom filter size in bits (edge computing limit: ~1MB)
60const MAX_FILTER_BITS: usize = 8_000_000;
61
62/// A space-efficient probabilistic data structure for set membership testing
63///
64/// False positives are possible (says "maybe in set" when not),
65/// but false negatives are impossible (never says "not in set" when it is).
66#[derive(Debug, Clone)]
67pub struct BloomFilter {
68    /// Bit array stored as u64 words
69    bits: Vec<u64>,
70    /// Number of bits in the filter
71    num_bits: usize,
72    /// Number of hash functions
73    num_hashes: usize,
74    /// Number of elements inserted
75    element_count: u64,
76}
77
78impl BloomFilter {
79    /// Create a new bloom filter with expected capacity
80    ///
81    /// # Arguments
82    /// * `expected_elements` - Expected number of elements to insert
83    /// * `false_positive_rate` - Desired false positive rate (0.0 to 1.0)
84    pub fn new(expected_elements: usize, false_positive_rate: f64) -> Self {
85        let fp_rate = false_positive_rate.clamp(0.0001, 0.5);
86
87        // Calculate optimal number of bits: m = -n * ln(p) / (ln(2)^2)
88        let ln2_squared = std::f64::consts::LN_2 * std::f64::consts::LN_2;
89        let optimal_bits =
90            (-(expected_elements as f64) * fp_rate.ln() / ln2_squared).ceil() as usize;
91
92        // Clamp to reasonable range
93        let num_bits = optimal_bits.clamp(MIN_FILTER_BITS, MAX_FILTER_BITS);
94
95        // Round up to multiple of 64 for word alignment
96        let num_bits = num_bits.div_ceil(64) * 64;
97
98        // Calculate optimal number of hash functions: k = (m/n) * ln(2)
99        let optimal_hashes = ((num_bits as f64 / expected_elements.max(1) as f64)
100            * std::f64::consts::LN_2)
101            .ceil() as usize;
102        let num_hashes = optimal_hashes.clamp(1, 15);
103
104        let num_words = num_bits / 64;
105
106        Self {
107            bits: vec![0u64; num_words],
108            num_bits,
109            num_hashes,
110            element_count: 0,
111        }
112    }
113
114    /// Create a bloom filter with default settings for the given capacity
115    pub fn with_capacity(expected_elements: usize) -> Self {
116        // Use 1% false positive rate by default
117        Self::new(expected_elements, 0.01)
118    }
119
120    /// Create a small bloom filter for edge computing scenarios
121    pub fn for_edge_computing(expected_elements: usize) -> Self {
122        // Use higher false positive rate (5%) for smaller memory footprint
123        Self::new(expected_elements, 0.05)
124    }
125
126    /// Insert a value into the bloom filter
127    pub fn insert(&mut self, value: &Value) {
128        let hash = Self::hash_value(value);
129        self.insert_hash(hash);
130        self.element_count += 1;
131    }
132
133    /// Insert a raw hash value
134    #[inline]
135    fn insert_hash(&mut self, hash: u64) {
136        let h1 = hash as usize;
137        let h2 = (hash >> 32) as usize;
138        let num_bits = self.num_bits;
139        let num_hashes = self.num_hashes;
140        let mut i = 0usize;
141        while i < num_hashes {
142            let bit_idx = h1.wrapping_add(i.wrapping_mul(h2)).wrapping_add(i * i) % num_bits;
143            let word_idx = bit_idx / 64;
144            let bit_offset = bit_idx % 64;
145            self.bits[word_idx] |= 1u64 << bit_offset;
146            i += 1;
147        }
148    }
149
150    /// Check if a value might be in the set
151    ///
152    /// Returns:
153    /// - `true`: Value MIGHT be in the set (could be false positive)
154    /// - `false`: Value is DEFINITELY NOT in the set
155    pub fn might_contain(&self, value: &Value) -> bool {
156        let hash = Self::hash_value(value);
157        self.might_contain_hash(hash)
158    }
159
160    /// Check using raw hash (internal)
161    #[inline]
162    fn might_contain_hash(&self, hash: u64) -> bool {
163        let h1 = hash as usize;
164        let h2 = (hash >> 32) as usize;
165        let num_bits = self.num_bits;
166        let num_hashes = self.num_hashes;
167        let mut i = 0usize;
168        while i < num_hashes {
169            let bit_idx = h1.wrapping_add(i.wrapping_mul(h2)).wrapping_add(i * i) % num_bits;
170            let word_idx = bit_idx / 64;
171            let bit_offset = bit_idx % 64;
172            let word = self.bits[word_idx];
173            if (word & (1u64 << bit_offset)) == 0 {
174                return false;
175            }
176            i += 1;
177        }
178        true
179    }
180
181    /// Insert using a pre-computed hash value
182    ///
183    /// This is useful when you want to use the same hash for both
184    /// hash table lookup and bloom filter operations.
185    pub fn insert_raw_hash(&mut self, hash: u64) {
186        self.insert_hash(hash);
187        self.element_count += 1;
188    }
189
190    /// Check if a pre-computed hash might be in the set
191    ///
192    /// Returns:
193    /// - `true`: Value with this hash MIGHT be in the set (could be false positive)
194    /// - `false`: Value with this hash is DEFINITELY NOT in the set
195    pub fn might_contain_raw_hash(&self, hash: u64) -> bool {
196        self.might_contain_hash(hash)
197    }
198
199    /// Hash a Value using its canonical structural identity.
200    ///
201    /// The runtime bloom is not persisted, so it can follow the current
202    /// `Value::Hash` contract directly. `FxHasher` keeps the mapping
203    /// deterministic within this internal runtime format while preserving the
204    /// required invariant that values equal under `Value::Eq` hash alike.
205    fn hash_value(value: &Value) -> u64 {
206        let mut hasher = FxHasher::default();
207        value.hash(&mut hasher);
208        hasher.finish()
209    }
210
211    /// Get the estimated false positive rate based on current fill
212    pub fn estimated_false_positive_rate(&self) -> f64 {
213        if self.element_count == 0 {
214            return 0.0;
215        }
216
217        // FP rate = (1 - e^(-kn/m))^k
218        let k = self.num_hashes as f64;
219        let n = self.element_count as f64;
220        let m = self.num_bits as f64;
221
222        (1.0 - (-k * n / m).exp()).powf(k)
223    }
224
225    /// Get memory usage in bytes
226    pub fn memory_bytes(&self) -> usize {
227        self.bits.len() * 8
228    }
229
230    /// Get number of elements inserted
231    pub fn len(&self) -> u64 {
232        self.element_count
233    }
234
235    /// Check if empty
236    pub fn is_empty(&self) -> bool {
237        self.element_count == 0
238    }
239
240    /// Merge another bloom filter into this one (union)
241    ///
242    /// Both filters must have the same configuration.
243    pub fn merge(&mut self, other: &BloomFilter) -> Result<(), &'static str> {
244        if self.num_bits != other.num_bits || self.num_hashes != other.num_hashes {
245            return Err("Cannot merge bloom filters with different configurations");
246        }
247
248        for (word, other_word) in self.bits.iter_mut().zip(other.bits.iter()) {
249            *word |= *other_word;
250        }
251        self.element_count += other.element_count;
252        Ok(())
253    }
254
255    /// Clear the bloom filter
256    pub fn clear(&mut self) {
257        for word in &mut self.bits {
258            *word = 0;
259        }
260        self.element_count = 0;
261    }
262
263    /// Get fill ratio (fraction of bits set)
264    pub fn fill_ratio(&self) -> f64 {
265        let set_bits: usize = self.bits.iter().map(|w| w.count_ones() as usize).sum();
266        set_bits as f64 / self.num_bits as f64
267    }
268}
269
270impl Default for BloomFilter {
271    fn default() -> Self {
272        Self::with_capacity(1000)
273    }
274}
275
276/// Builder for creating bloom filters during hash join build phase
277pub struct BloomFilterBuilder {
278    filter: BloomFilter,
279    /// Column name this filter is for
280    pub column_name: String,
281    /// Table name this filter is from
282    pub source_table: String,
283}
284
285impl BloomFilterBuilder {
286    /// Create a new builder for the given join column
287    pub fn new(column_name: String, source_table: String, expected_rows: usize) -> Self {
288        Self {
289            filter: BloomFilter::with_capacity(expected_rows),
290            column_name,
291            source_table,
292        }
293    }
294
295    /// Create a builder optimized for edge computing
296    pub fn for_edge(column_name: String, source_table: String, expected_rows: usize) -> Self {
297        Self {
298            filter: BloomFilter::for_edge_computing(expected_rows),
299            column_name,
300            source_table,
301        }
302    }
303
304    /// Add a value to the filter
305    pub fn insert(&mut self, value: &Value) {
306        self.filter.insert(value);
307    }
308
309    /// Add a pre-computed hash to the filter
310    /// This is more efficient when the hash is already computed (e.g., during hash table build)
311    #[inline]
312    pub fn insert_raw_hash(&mut self, hash: u64) {
313        self.filter.insert_raw_hash(hash);
314    }
315
316    /// Bytes retained by the filter while this builder is alive and after it
317    /// is converted into a runtime filter.
318    pub(crate) fn memory_bytes(&self) -> usize {
319        self.filter.memory_bytes()
320    }
321
322    /// Finish building and return the filter
323    pub fn build(self) -> RuntimeBloomFilter {
324        RuntimeBloomFilter {
325            filter: self.filter,
326            column_name: self.column_name,
327            source_table: self.source_table,
328        }
329    }
330}
331
332impl crate::hash_table::JoinHashObserver for BloomFilterBuilder {
333    #[inline]
334    fn insert_raw_hash(&mut self, hash: u64) {
335        BloomFilterBuilder::insert_raw_hash(self, hash);
336    }
337
338    #[inline]
339    fn retained_bytes(&self) -> usize {
340        self.memory_bytes()
341    }
342}
343
344/// A bloom filter with metadata for runtime propagation
345#[derive(Debug, Clone)]
346pub struct RuntimeBloomFilter {
347    /// The underlying bloom filter
348    pub filter: BloomFilter,
349    /// Column this filter applies to
350    pub column_name: String,
351    /// Source table that built this filter
352    pub source_table: String,
353}
354
355impl RuntimeBloomFilter {
356    /// Check if a value might be in the join keys
357    pub fn might_match(&self, value: &Value) -> bool {
358        self.filter.might_contain(value)
359    }
360
361    /// Check if a pre-computed hash might be in the join keys
362    ///
363    /// This is more efficient when the hash is already computed (e.g., using
364    /// the same hash function as the join hash table).
365    #[inline]
366    pub fn might_match_raw_hash(&self, hash: u64) -> bool {
367        self.filter.might_contain_raw_hash(hash)
368    }
369
370    /// Get estimated selectivity (fraction of rows that pass)
371    ///
372    /// This is the complement of the filter's effectiveness.
373    pub fn estimated_selectivity(&self) -> f64 {
374        // Higher fill ratio = more likely to pass = higher selectivity
375        // But we also consider the false positive rate
376        let fp_rate = self.filter.estimated_false_positive_rate();
377        let fill = self.filter.fill_ratio();
378
379        // Selectivity ≈ fill_ratio * (1 + fp_rate)
380        // Capped at 1.0
381        (fill * (1.0 + fp_rate)).min(1.0)
382    }
383
384    /// Check if this filter is worth using
385    ///
386    /// Returns false if the filter is too full (high false positive rate)
387    /// or has too few elements to be useful.
388    pub fn is_effective(&self) -> bool {
389        // Don't use if empty
390        if self.filter.is_empty() {
391            return false;
392        }
393
394        // Don't use if false positive rate is too high (>50%)
395        if self.filter.estimated_false_positive_rate() > 0.5 {
396            return false;
397        }
398
399        // Don't use if fill ratio is too high (>90%)
400        if self.filter.fill_ratio() > 0.9 {
401            return false;
402        }
403
404        true
405    }
406
407    /// Get statistics about this filter
408    pub fn stats(&self) -> BloomFilterStats {
409        BloomFilterStats {
410            column_name: self.column_name.clone(),
411            source_table: self.source_table.clone(),
412            element_count: self.filter.len(),
413            memory_bytes: self.filter.memory_bytes(),
414            false_positive_rate: self.filter.estimated_false_positive_rate(),
415            fill_ratio: self.filter.fill_ratio(),
416            is_effective: self.is_effective(),
417        }
418    }
419}
420
421/// Statistics about a bloom filter
422#[derive(Debug, Clone)]
423pub struct BloomFilterStats {
424    pub column_name: String,
425    pub source_table: String,
426    pub element_count: u64,
427    pub memory_bytes: usize,
428    pub false_positive_rate: f64,
429    pub fill_ratio: f64,
430    pub is_effective: bool,
431}
432
433// =============================================================================
434// BLOOM FILTER EFFECTIVENESS TRACKING FOR ADAPTIVE OPTIMIZATION
435// =============================================================================
436
437use std::sync::atomic::{AtomicU64, Ordering};
438use std::sync::OnceLock;
439
440/// Global bloom filter effectiveness tracker
441static BLOOM_EFFECTIVENESS: OnceLock<BloomEffectivenessTracker> = OnceLock::new();
442
443/// Tracks the two outcomes directly observable at the Bloom boundary.
444///
445/// A passed probe is only "maybe present". Whether it later joins is owned by
446/// another operator, so this tracker deliberately makes no true/false-positive
447/// claim and does not produce adaptive recommendations.
448pub struct BloomEffectivenessTracker {
449    /// Total bloom filter checks performed
450    total_checks: AtomicU64,
451    /// Probes rejected as definitely absent
452    rejected_checks: AtomicU64,
453    /// Probes passed as maybe present
454    passed_checks: AtomicU64,
455}
456
457impl BloomEffectivenessTracker {
458    /// Create new stats tracker
459    fn new() -> Self {
460        Self {
461            total_checks: AtomicU64::new(0),
462            rejected_checks: AtomicU64::new(0),
463            passed_checks: AtomicU64::new(0),
464        }
465    }
466
467    /// Get the global instance
468    pub fn global() -> &'static Self {
469        BLOOM_EFFECTIVENESS.get_or_init(Self::new)
470    }
471
472    /// Record an observable bloom-filter outcome.
473    pub fn record_check(&self, passed_filter: bool) {
474        self.total_checks.fetch_add(1, Ordering::Relaxed);
475        if passed_filter {
476            self.passed_checks.fetch_add(1, Ordering::Relaxed);
477        } else {
478            self.rejected_checks.fetch_add(1, Ordering::Relaxed);
479        }
480    }
481
482    /// Record a probe rejected as definitely absent.
483    pub fn record_rejected(&self) {
484        self.record_check(false);
485    }
486
487    /// Record a probe passed as maybe present.
488    pub fn record_passed(&self) {
489        self.record_check(true);
490    }
491
492    /// Fraction of probes rejected as definitely absent.
493    pub fn rejection_rate(&self) -> f64 {
494        let total = self.total_checks.load(Ordering::Relaxed);
495        if total == 0 {
496            return 0.0;
497        }
498        self.rejected_checks.load(Ordering::Relaxed) as f64 / total as f64
499    }
500
501    /// Get total number of checks
502    pub fn total_checks(&self) -> u64 {
503        self.total_checks.load(Ordering::Relaxed)
504    }
505
506    pub fn rejected_checks(&self) -> u64 {
507        self.rejected_checks.load(Ordering::Relaxed)
508    }
509
510    pub fn passed_checks(&self) -> u64 {
511        self.passed_checks.load(Ordering::Relaxed)
512    }
513
514    /// Reset all statistics
515    pub fn reset(&self) {
516        self.total_checks.store(0, Ordering::Relaxed);
517        self.rejected_checks.store(0, Ordering::Relaxed);
518        self.passed_checks.store(0, Ordering::Relaxed);
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_bloom_filter_basic() {
528        let mut bf = BloomFilter::with_capacity(100);
529
530        // Insert some values
531        bf.insert(&Value::Integer(42));
532        bf.insert(&Value::Integer(100));
533        bf.insert(&Value::Text("hello".into()));
534
535        // Check membership
536        assert!(bf.might_contain(&Value::Integer(42)));
537        assert!(bf.might_contain(&Value::Integer(100)));
538        assert!(bf.might_contain(&Value::Text("hello".into())));
539
540        // Values not inserted should (usually) not be found
541        // Note: false positives are possible
542        let mut false_positives = 0;
543        for i in 1000..1100 {
544            if bf.might_contain(&Value::Integer(i)) {
545                false_positives += 1;
546            }
547        }
548        // With 100 checks and 1% FP rate, expect ~1 false positive
549        assert!(
550            false_positives < 10,
551            "Too many false positives: {}",
552            false_positives
553        );
554    }
555
556    #[test]
557    fn test_bloom_filter_no_false_negatives() {
558        let mut bf = BloomFilter::with_capacity(1000);
559
560        // Insert 1000 values
561        for i in 0..1000 {
562            bf.insert(&Value::Integer(i));
563        }
564
565        // All inserted values MUST be found (no false negatives)
566        for i in 0..1000 {
567            assert!(
568                bf.might_contain(&Value::Integer(i)),
569                "False negative for {}",
570                i
571            );
572        }
573    }
574
575    #[test]
576    fn test_bloom_filter_false_positive_rate() {
577        let mut bf = BloomFilter::new(1000, 0.01); // 1% FP rate
578
579        // Insert 1000 values
580        for i in 0..1000 {
581            bf.insert(&Value::Integer(i));
582        }
583
584        // Check 10000 values NOT in the filter
585        let mut false_positives = 0;
586        for i in 10000..20000 {
587            if bf.might_contain(&Value::Integer(i)) {
588                false_positives += 1;
589            }
590        }
591
592        let actual_fp_rate = false_positives as f64 / 10000.0;
593        // Allow 5x the target rate due to statistical variance
594        assert!(
595            actual_fp_rate < 0.05,
596            "FP rate {} too high (target: 0.01)",
597            actual_fp_rate
598        );
599    }
600
601    #[test]
602    fn test_bloom_filter_different_types() {
603        let mut bf = BloomFilter::with_capacity(100);
604
605        bf.insert(&Value::Integer(42));
606        bf.insert(&Value::Float(42.0));
607        bf.insert(&Value::Text("42".into()));
608
609        // Canonical-equal numeric variants may share bits; Text remains a
610        // separate identity domain. Every inserted value must remain visible.
611        assert!(bf.might_contain(&Value::Integer(42)));
612        assert!(bf.might_contain(&Value::Float(42.0)));
613        assert!(bf.might_contain(&Value::Text("42".into())));
614
615        // False positives remain possible as usual.
616    }
617
618    #[test]
619    fn test_bloom_filter_uses_canonical_numeric_identity() {
620        fn assert_pair(left: Value, right: Value) {
621            assert_eq!(left, right, "test values must share canonical identity");
622
623            let mut left_filter = BloomFilter::with_capacity(16);
624            left_filter.insert(&left);
625            assert!(left_filter.might_contain(&right));
626
627            let mut right_filter = BloomFilter::with_capacity(16);
628            right_filter.insert(&right);
629            assert!(right_filter.might_contain(&left));
630        }
631
632        assert_pair(Value::Integer(42), Value::Float(42.0));
633        assert_pair(Value::Float(-0.0), Value::Float(0.0));
634        assert_pair(
635            Value::Float(f64::from_bits(0x7ff8_0000_0000_0001)),
636            Value::Float(f64::from_bits(0x7ff8_0000_0000_0002)),
637        );
638        assert_pair(Value::decimal(10, 2, 1), Value::decimal(100, 3, 2));
639        assert_pair(Value::decimal(10, 2, 1), Value::Integer(1));
640        assert_pair(Value::decimal(10, 2, 1), Value::Float(1.0));
641    }
642
643    #[test]
644    fn test_bloom_filter_merge() {
645        let mut bf1 = BloomFilter::with_capacity(100);
646        let mut bf2 = BloomFilter::with_capacity(100);
647
648        bf1.insert(&Value::Integer(1));
649        bf1.insert(&Value::Integer(2));
650        bf2.insert(&Value::Integer(3));
651        bf2.insert(&Value::Integer(4));
652
653        bf1.merge(&bf2).unwrap();
654
655        // Both sets should be present
656        assert!(bf1.might_contain(&Value::Integer(1)));
657        assert!(bf1.might_contain(&Value::Integer(2)));
658        assert!(bf1.might_contain(&Value::Integer(3)));
659        assert!(bf1.might_contain(&Value::Integer(4)));
660    }
661
662    #[test]
663    fn test_runtime_bloom_filter() {
664        let mut builder =
665            BloomFilterBuilder::new("customer_id".to_string(), "customers".to_string(), 100);
666
667        for i in 0..100 {
668            builder.insert(&Value::Integer(i));
669        }
670
671        let runtime_filter = builder.build();
672
673        assert!(runtime_filter.might_match(&Value::Integer(50)));
674        assert!(runtime_filter.is_effective());
675
676        let stats = runtime_filter.stats();
677        assert_eq!(stats.element_count, 100);
678        assert!(stats.memory_bytes > 0);
679    }
680
681    #[test]
682    fn v2_r5_effectiveness_tracks_only_observable_outcomes() {
683        let tracker = BloomEffectivenessTracker::global();
684        tracker.reset();
685        tracker.record_passed();
686        tracker.record_rejected();
687        tracker.record_check(true);
688        assert_eq!(tracker.total_checks(), 3);
689        assert_eq!(tracker.passed_checks(), 2);
690        assert_eq!(tracker.rejected_checks(), 1);
691        assert!((tracker.rejection_rate() - 1.0 / 3.0).abs() < f64::EPSILON);
692    }
693
694    #[test]
695    fn test_bloom_filter_edge_computing() {
696        // Edge computing filter should use less memory
697        let standard = BloomFilter::with_capacity(10000);
698        let edge = BloomFilter::for_edge_computing(10000);
699
700        assert!(
701            edge.memory_bytes() < standard.memory_bytes(),
702            "Edge filter should use less memory: {} vs {}",
703            edge.memory_bytes(),
704            standard.memory_bytes()
705        );
706    }
707
708    #[test]
709    fn test_fill_ratio() {
710        let mut bf = BloomFilter::with_capacity(100);
711
712        assert!(bf.fill_ratio() < 0.01, "Empty filter should have low fill");
713
714        for i in 0..100 {
715            bf.insert(&Value::Integer(i));
716        }
717
718        let fill = bf.fill_ratio();
719        assert!(
720            fill > 0.1 && fill < 0.9,
721            "Fill ratio should be moderate: {}",
722            fill
723        );
724    }
725
726    #[test]
727    fn test_effectiveness_check() {
728        // Empty filter is not effective
729        let builder = BloomFilterBuilder::new("col".to_string(), "table".to_string(), 100);
730        let filter = builder.build();
731        assert!(!filter.is_effective());
732
733        // Overfilled filter is not effective
734        let mut bf = BloomFilter::new(10, 0.5); // Very small
735        for i in 0..1000 {
736            bf.insert(&Value::Integer(i));
737        }
738        let runtime = RuntimeBloomFilter {
739            filter: bf,
740            column_name: "col".to_string(),
741            source_table: "table".to_string(),
742        };
743        assert!(!runtime.is_effective());
744    }
745}