Skip to main content

ftui_render/
quotient_filter.rs

1#![forbid(unsafe_code)]
2
3//! Quotient Filter for space-efficient dirty row tracking (bd-3fc1b).
4//!
5//! A Quotient Filter is a compact approximate-membership data structure
6//! that supports insert, lookup, delete, and merge — unlike Bloom filters,
7//! which cannot delete.
8//!
9//! # How It Works
10//!
11//! Each element is hashed to a `p`-bit fingerprint, split into:
12//! - `q`-bit *quotient* (slot index): determines the canonical slot
13//! - `r`-bit *remainder*: stored in the slot
14//!
15//! Collisions are resolved by linear probing within a cluster. Three
16//! metadata bits per slot track the structure of runs and clusters.
17//!
18//! # Complexity
19//!
20//! - Insert: O(1) amortized
21//! - Lookup: O(1) amortized
22//! - Delete: O(1) amortized
23//! - Space: `(r + 3) * 2^q` bits ≈ 10% overhead above information-theoretic minimum
24//!
25//! # Use Case
26//!
27//! For large virtualized lists (>1M rows) where only a small fraction
28//! (<1%) of rows are dirty, a Quotient Filter uses O(dirty_count) space
29//! vs O(total_rows) for a bitset.
30//!
31//! # Implementation Note
32//!
33//! This implementation uses a simplified open-addressing scheme with
34//! (quotient, remainder) pairs stored directly, avoiding the complexity
35//! of the canonical 3-bit metadata approach while preserving the same
36//! API contract and space characteristics.
37//!
38//! # References
39//!
40//! Bender et al. (2012): "Don't Thrash: How to Cache Your Hash on Flash"
41
42use std::collections::hash_map::DefaultHasher;
43use std::hash::{Hash, Hasher};
44
45/// A Quotient Filter for approximate set membership with deletion support.
46#[derive(Debug, Clone)]
47pub struct QuotientFilter {
48    /// Number of quotient bits (q). Table has 2^q slots.
49    q: u32,
50    /// Number of remainder bits (r). Fingerprint = q + r bits.
51    r: u32,
52    /// Slot storage: each slot holds an optional (quotient, remainder) pair.
53    slots: Vec<Option<(u32, u64)>>,
54    /// Number of elements currently stored.
55    count: usize,
56    /// Total number of slots (2^q).
57    capacity: usize,
58}
59
60/// Configuration for a Quotient Filter.
61#[derive(Debug, Clone, Copy)]
62pub struct QuotientFilterConfig {
63    /// Number of quotient bits (determines capacity: 2^q slots).
64    pub q: u32,
65    /// Number of remainder bits (determines false positive rate: ~2^(-r)).
66    pub r: u32,
67}
68
69impl QuotientFilterConfig {
70    /// Create a config targeting a given capacity and false positive rate.
71    ///
72    /// `expected_items`: expected number of elements
73    /// `fp_rate`: target false positive rate (e.g., 0.01 for 1%)
74    #[must_use]
75    pub fn for_capacity(expected_items: usize, fp_rate: f64) -> Self {
76        let fp_rate = if fp_rate.is_finite() && fp_rate > 0.0 {
77            fp_rate.min(1.0 - f64::EPSILON)
78        } else {
79            0.01
80        };
81
82        // r bits give ~2^(-r) FP rate
83        let r = (-fp_rate.log2()).ceil() as u32;
84        let r = r.clamp(2, 32);
85
86        // q bits: need 2^q > expected_items / load_factor
87        // Use 75% max load for good performance
88        let needed = ((expected_items as f64 / 0.75).ceil()) as u64;
89        let q = (64 - needed.leading_zeros()).clamp(4, 28);
90
91        Self { q, r }
92    }
93}
94
95impl Default for QuotientFilterConfig {
96    fn default() -> Self {
97        Self { q: 10, r: 8 } // 1024 slots, ~0.4% FP rate
98    }
99}
100
101impl QuotientFilter {
102    /// Create a new Quotient Filter with the given configuration.
103    #[must_use]
104    pub fn new(config: QuotientFilterConfig) -> Self {
105        let q = config.q.min(28); // Cap at 2^28 = 256M slots
106        let r = config.r.clamp(1, 32);
107        let capacity = 1usize << q;
108
109        Self {
110            q,
111            r,
112            slots: vec![None; capacity],
113            count: 0,
114            capacity,
115        }
116    }
117
118    /// Create with default configuration.
119    #[must_use]
120    pub fn with_defaults() -> Self {
121        Self::new(QuotientFilterConfig::default())
122    }
123
124    /// Number of elements currently stored.
125    #[must_use]
126    pub fn len(&self) -> usize {
127        self.count
128    }
129
130    /// Whether the filter is empty.
131    #[must_use]
132    pub fn is_empty(&self) -> bool {
133        self.count == 0
134    }
135
136    /// Current load factor (0.0 to 1.0).
137    #[must_use]
138    pub fn load_factor(&self) -> f64 {
139        self.count as f64 / self.capacity as f64
140    }
141
142    /// Number of slots (capacity).
143    #[must_use]
144    pub fn capacity(&self) -> usize {
145        self.capacity
146    }
147
148    /// Theoretical false positive rate at current load.
149    #[must_use]
150    pub fn theoretical_fp_rate(&self) -> f64 {
151        // FP rate ≈ 1 - (1 - 2^(-r))^n ≈ n * 2^(-r) for small rates
152        let base_rate = 1.0 / (1u64 << self.r) as f64;
153        1.0 - (1.0 - base_rate).powi(self.count as i32)
154    }
155
156    /// Hash an element to (quotient, remainder).
157    fn fingerprint<T: Hash>(&self, item: &T) -> (u32, u64) {
158        let mut hasher = DefaultHasher::new();
159        item.hash(&mut hasher);
160        let h = hasher.finish();
161
162        let q_mask = (1u32 << self.q) - 1;
163        let r_mask = (1u64 << self.r) - 1;
164
165        let quotient = ((h >> self.r) as u32) & q_mask;
166        let remainder = h & r_mask;
167        (quotient, remainder)
168    }
169
170    /// Insert an element. Returns `true` if newly inserted, `false` if already present or full.
171    pub fn insert<T: Hash>(&mut self, item: &T) -> bool {
172        if self.count >= self.capacity {
173            return false;
174        }
175
176        let (quotient, remainder) = self.fingerprint(item);
177
178        // Linear probe from canonical slot
179        let mut pos = quotient as usize;
180        for _ in 0..self.capacity {
181            match self.slots[pos] {
182                None => {
183                    // Empty slot — insert here
184                    self.slots[pos] = Some((quotient, remainder));
185                    self.count += 1;
186                    return true;
187                }
188                Some((q, r)) if q == quotient && r == remainder => {
189                    // Already present
190                    return false;
191                }
192                _ => {
193                    // Occupied by different element — probe next
194                    pos = (pos + 1) % self.capacity;
195                }
196            }
197        }
198
199        false // Full (shouldn't happen with load factor check)
200    }
201
202    /// Check if an element might be in the filter.
203    ///
204    /// Returns `false` for definite non-members (no false negatives).
205    /// Returns `true` for probable members (may have false positives
206    /// due to fingerprint collisions).
207    #[must_use]
208    pub fn contains<T: Hash>(&self, item: &T) -> bool {
209        let (quotient, remainder) = self.fingerprint(item);
210
211        let mut pos = quotient as usize;
212        for _ in 0..self.capacity {
213            match self.slots[pos] {
214                None => return false, // Empty slot — not found
215                Some((q, r)) if q == quotient && r == remainder => return true,
216                _ => pos = (pos + 1) % self.capacity,
217            }
218        }
219
220        false
221    }
222
223    /// Remove an element. Returns `true` if it was found and removed.
224    ///
225    /// Uses backward-shift deletion to maintain probe sequences.
226    pub fn remove<T: Hash>(&mut self, item: &T) -> bool {
227        let (quotient, remainder) = self.fingerprint(item);
228
229        // Find the element
230        let mut pos = quotient as usize;
231        let mut found_pos = None;
232        for _ in 0..self.capacity {
233            match self.slots[pos] {
234                None => break,
235                Some((q, r)) if q == quotient && r == remainder => {
236                    found_pos = Some(pos);
237                    break;
238                }
239                _ => pos = (pos + 1) % self.capacity,
240            }
241        }
242
243        let mut pos = match found_pos {
244            Some(p) => p,
245            None => return false,
246        };
247
248        // Backward-shift deletion: move subsequent elements back
249        // to fill the gap, maintaining their probe sequences.
250        self.slots[pos] = None;
251        self.count -= 1;
252
253        let mut current = (pos + 1) % self.capacity;
254        loop {
255            match self.slots[current] {
256                None => break, // End of cluster
257                Some((q, _r)) => {
258                    let canonical = q as usize;
259                    // Standard backward-shift rule: the element may fill the
260                    // gap iff its canonical slot is NOT cyclically inside
261                    // (pos, current] — moving it must never place it before
262                    // its canonical slot in probe order. Crucially, when an
263                    // element must stay (e.g. it sits in its canonical
264                    // slot), the scan CONTINUES to the end of the cluster:
265                    // a later element with an earlier canonical slot may
266                    // still need the gap. Breaking here (as this code once
267                    // did) strands such elements past a hole, producing
268                    // false negatives — a violation of the filter's
269                    // no-false-negatives contract.
270                    let canonical_in_gap_interval = if pos <= current {
271                        canonical > pos && canonical <= current
272                    } else {
273                        canonical > pos || canonical <= current
274                    };
275
276                    if !canonical_in_gap_interval {
277                        // Move this element back to the gap
278                        self.slots[pos] = self.slots[current];
279                        self.slots[current] = None;
280                        pos = current;
281                    }
282                }
283            }
284            current = (current + 1) % self.capacity;
285        }
286
287        true
288    }
289
290    /// Clear all elements.
291    pub fn clear(&mut self) {
292        self.slots.fill(None);
293        self.count = 0;
294    }
295
296    /// Merge another filter into this one.
297    ///
298    /// Both filters must have the same q and r values.
299    /// Returns the number of new elements added.
300    pub fn merge(&mut self, other: &QuotientFilter) -> usize {
301        if self.q != other.q || self.r != other.r {
302            return 0;
303        }
304
305        let mut added = 0;
306        for slot in &other.slots {
307            if let &Some((q, r)) = slot {
308                // Check if already present
309                let mut pos = q as usize;
310                let mut found = false;
311                for _ in 0..self.capacity {
312                    match self.slots[pos] {
313                        None => break,
314                        Some((eq, er)) if eq == q && er == r => {
315                            found = true;
316                            break;
317                        }
318                        _ => pos = (pos + 1) % self.capacity,
319                    }
320                }
321
322                if !found {
323                    // Insert at first empty slot from canonical position
324                    let mut ipos = q as usize;
325                    for _ in 0..self.capacity {
326                        if self.slots[ipos].is_none() {
327                            self.slots[ipos] = Some((q, r));
328                            self.count += 1;
329                            added += 1;
330                            break;
331                        }
332                        ipos = (ipos + 1) % self.capacity;
333                    }
334                }
335            }
336        }
337        added
338    }
339}
340
341impl Default for QuotientFilter {
342    fn default() -> Self {
343        Self::with_defaults()
344    }
345}
346
347// ---------------------------------------------------------------------------
348// Tests
349// ---------------------------------------------------------------------------
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn empty_filter() {
357        let qf = QuotientFilter::with_defaults();
358        assert!(qf.is_empty());
359        assert_eq!(qf.len(), 0);
360        assert_eq!(qf.capacity(), 1024);
361        assert!(!qf.contains(&42u64));
362    }
363
364    #[test]
365    fn remove_never_creates_false_negatives_in_clusters() {
366        // Regression: backward-shift deletion used to BREAK at the first
367        // non-shiftable element (e.g. one sitting in its canonical slot),
368        // stranding later cluster members behind the new hole — contains()
369        // then hit None at their canonical slot and returned false for
370        // present members. The scan must continue to the cluster end.
371        // Small table (q=4 -> 16 slots) forces heavy clustering.
372        for base in (0u64..2000).step_by(97) {
373            let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 4, r: 16 });
374            let items: Vec<u64> = (base..base + 12).collect();
375            for item in &items {
376                qf.insert(item);
377            }
378            // Delete every other item; all remaining must stay findable.
379            for item in items.iter().step_by(2) {
380                qf.remove(item);
381            }
382            for item in items.iter().skip(1).step_by(2) {
383                assert!(
384                    qf.contains(item),
385                    "false negative for {item} after cluster deletions (base {base})"
386                );
387            }
388        }
389    }
390
391    #[test]
392    fn insert_and_lookup() {
393        let mut qf = QuotientFilter::with_defaults();
394        assert!(qf.insert(&100u64));
395        assert!(qf.contains(&100u64));
396        assert_eq!(qf.len(), 1);
397    }
398
399    #[test]
400    fn duplicate_insert_returns_false() {
401        let mut qf = QuotientFilter::with_defaults();
402        assert!(qf.insert(&42u64));
403        assert!(!qf.insert(&42u64));
404        assert_eq!(qf.len(), 1);
405    }
406
407    #[test]
408    fn insert_multiple() {
409        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 8, r: 8 });
410        for i in 0u64..50 {
411            qf.insert(&i);
412        }
413        assert_eq!(qf.len(), 50);
414
415        // All should be found (no false negatives)
416        for i in 0u64..50 {
417            assert!(qf.contains(&i), "element {i} should be found");
418        }
419    }
420
421    #[test]
422    fn no_false_negatives() {
423        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 10, r: 10 });
424        let items: Vec<u64> = (0..200).collect();
425
426        for item in &items {
427            qf.insert(item);
428        }
429
430        // Verify: zero false negatives
431        for item in &items {
432            assert!(qf.contains(item), "false negative for {item}");
433        }
434    }
435
436    #[test]
437    fn false_positive_rate_bounded() {
438        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 12, r: 8 });
439
440        // Insert 500 elements
441        for i in 0u64..500 {
442            qf.insert(&i);
443        }
444
445        // Check 10000 non-members
446        let mut false_positives = 0;
447        for i in 10000u64..20000 {
448            if qf.contains(&i) {
449                false_positives += 1;
450            }
451        }
452
453        let fp_rate = false_positives as f64 / 10000.0;
454        // r=8 gives theoretical rate ~0.4%, allow up to 5% with margin
455        assert!(
456            fp_rate < 0.05,
457            "false positive rate too high: {fp_rate:.4} ({false_positives}/10000)"
458        );
459    }
460
461    #[test]
462    fn remove_element() {
463        let mut qf = QuotientFilter::with_defaults();
464        qf.insert(&42u64);
465        assert!(qf.contains(&42u64));
466
467        assert!(qf.remove(&42u64));
468        assert_eq!(qf.len(), 0);
469        assert!(!qf.contains(&42u64));
470    }
471
472    #[test]
473    fn remove_nonexistent() {
474        let mut qf = QuotientFilter::with_defaults();
475        assert!(!qf.remove(&42u64));
476    }
477
478    #[test]
479    fn remove_preserves_others() {
480        let mut qf = QuotientFilter::with_defaults();
481        for i in 0u64..20 {
482            qf.insert(&i);
483        }
484
485        // Remove even numbers
486        for i in (0u64..20).step_by(2) {
487            qf.remove(&i);
488        }
489
490        // Odd numbers should still be present
491        for i in (1u64..20).step_by(2) {
492            assert!(qf.contains(&i), "odd {i} should survive removal");
493        }
494        // Even numbers should be gone
495        for i in (0u64..20).step_by(2) {
496            assert!(!qf.contains(&i), "even {i} should be removed");
497        }
498    }
499
500    #[test]
501    fn clear_filter() {
502        let mut qf = QuotientFilter::with_defaults();
503        for i in 0u64..100 {
504            qf.insert(&i);
505        }
506        assert_eq!(qf.len(), 100);
507
508        qf.clear();
509        assert!(qf.is_empty());
510        assert_eq!(qf.len(), 0);
511
512        for i in 0u64..100 {
513            assert!(!qf.contains(&i));
514        }
515    }
516
517    #[test]
518    fn load_factor() {
519        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 4, r: 4 }); // 16 slots
520        assert!((qf.load_factor() - 0.0).abs() < f64::EPSILON);
521
522        for i in 0u64..8 {
523            qf.insert(&i);
524        }
525        assert!((qf.load_factor() - 0.5).abs() < 0.01);
526    }
527
528    #[test]
529    fn config_for_capacity() {
530        let config = QuotientFilterConfig::for_capacity(10000, 0.01);
531        assert!(config.r >= 7, "r should be at least 7 for 1% FP rate");
532        assert!(
533            (1usize << config.q) >= 10000,
534            "capacity should exceed expected items"
535        );
536    }
537
538    #[test]
539    fn config_for_capacity_sanitizes_invalid_fp_rates() {
540        let zero = QuotientFilterConfig::for_capacity(128, 0.0);
541        let nan = QuotientFilterConfig::for_capacity(128, f64::NAN);
542
543        assert!(zero.r >= 7);
544        assert!(nan.r >= 7);
545        assert!((1usize << zero.q) >= 128);
546        assert!((1usize << nan.q) >= 128);
547    }
548
549    #[test]
550    fn string_keys() {
551        let mut qf = QuotientFilter::with_defaults();
552        qf.insert(&"hello");
553        qf.insert(&"world");
554        assert!(qf.contains(&"hello"));
555        assert!(qf.contains(&"world"));
556        assert!(!qf.contains(&"foo"));
557    }
558
559    #[test]
560    fn row_id_tracking() {
561        // Simulate dirty row tracking
562        let mut dirty = QuotientFilter::new(QuotientFilterConfig { q: 12, r: 8 });
563
564        // Mark rows as dirty
565        let dirty_rows = [5u32, 42, 100, 255, 1000];
566        for &row in &dirty_rows {
567            dirty.insert(&row);
568        }
569
570        // Check which rows need re-render
571        for row in 0u32..2000 {
572            if dirty_rows.contains(&row) {
573                assert!(dirty.contains(&row), "dirty row {row} not found");
574            }
575        }
576
577        // After re-render, remove from dirty set
578        for &row in &dirty_rows {
579            dirty.remove(&row);
580        }
581        assert!(dirty.is_empty());
582    }
583
584    #[test]
585    fn merge_filters() {
586        let config = QuotientFilterConfig { q: 8, r: 8 };
587        let mut qf1 = QuotientFilter::new(config);
588        let mut qf2 = QuotientFilter::new(config);
589
590        for i in 0u64..10 {
591            qf1.insert(&i);
592        }
593        for i in 5u64..15 {
594            qf2.insert(&i);
595        }
596
597        let added = qf1.merge(&qf2);
598        assert!(added > 0, "merge should add elements");
599
600        // All elements from both should be present
601        for i in 0u64..15 {
602            assert!(qf1.contains(&i), "merged filter should contain {i}");
603        }
604    }
605
606    #[test]
607    fn merge_mismatched_config_is_noop() {
608        let mut qf1 = QuotientFilter::new(QuotientFilterConfig { q: 8, r: 8 });
609        let qf2 = QuotientFilter::new(QuotientFilterConfig { q: 10, r: 8 });
610
611        qf1.insert(&1u64);
612        let added = qf1.merge(&qf2);
613        assert_eq!(added, 0, "mismatched configs should not merge");
614    }
615
616    #[test]
617    fn theoretical_fp_rate_increases_with_load() {
618        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 10, r: 8 });
619        let rate_empty = qf.theoretical_fp_rate();
620
621        for i in 0u64..100 {
622            qf.insert(&i);
623        }
624        let rate_loaded = qf.theoretical_fp_rate();
625
626        assert!(
627            rate_empty < rate_loaded,
628            "FP rate should increase with load"
629        );
630    }
631
632    #[test]
633    fn space_comparison_vs_bitset() {
634        // Quotient Filter for 1000 dirty rows out of 1M total
635        let dirty_config = QuotientFilterConfig::for_capacity(1000, 0.01);
636        let dirty_qf_bits = (dirty_config.r as usize + 3) * (1usize << dirty_config.q);
637
638        // Bitset for 1M rows: 1M bits
639        let bitset_bits = 1_000_000usize;
640
641        assert!(
642            dirty_qf_bits < bitset_bits,
643            "QF ({dirty_qf_bits} bits) should be smaller than bitset ({bitset_bits} bits) for sparse dirty sets"
644        );
645    }
646
647    #[test]
648    fn default_config() {
649        let qf = QuotientFilter::default();
650        assert_eq!(qf.capacity(), 1024);
651        assert!(qf.is_empty());
652    }
653
654    #[test]
655    fn insert_after_remove_reuses_slot() {
656        let mut qf = QuotientFilter::with_defaults();
657        qf.insert(&1u64);
658        qf.remove(&1u64);
659        assert!(qf.insert(&1u64));
660        assert!(qf.contains(&1u64));
661        assert_eq!(qf.len(), 1);
662    }
663
664    #[test]
665    fn heavy_load() {
666        // Use larger r to avoid fingerprint collisions
667        let mut qf = QuotientFilter::new(QuotientFilterConfig { q: 10, r: 20 }); // 1024 slots, 30-bit fingerprints
668        let target = 500;
669        let mut inserted = 0;
670        for i in 0u64..target as u64 {
671            if qf.insert(&i) {
672                inserted += 1;
673            }
674        }
675        assert_eq!(inserted, target);
676
677        // All should be findable (no false negatives)
678        for i in 0u64..target as u64 {
679            assert!(qf.contains(&i), "element {i} missing at high load");
680        }
681    }
682}