Packages
ferricstore
0.1.0
0.11.14
0.11.12
0.11.11
0.11.10
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.2.0
0.1.0
FerricFlow durable workflows and queues with native-protocol storage, Raft durability, and Bitcask persistence.
Current section
Files
Jump to
Current section
Files
native/ferricstore_bitcask/src/cuckoo.rs
//! Cuckoo filter implementation for FerricStore.
//!
//! A space-efficient probabilistic data structure similar to Bloom filters,
//! but supporting deletion and approximate counting. Stores fingerprints of
//! elements in a hash table with two candidate bucket positions per element.
//!
//! ## Storage modes
//!
//! - **In-memory** (`cuckoo_create`): backed by a `Vec<u8>`, suitable for
//! ephemeral filters or Bitcask serialize/deserialize.
//! - **Mmap file** (`cuckoo_create_file` / `cuckoo_open_file`): backed by
//! `libc::mmap()` on a file, persistent across restarts.
//!
//! ## File layout (mmap mode)
//!
//! ```text
//! [magic: 2B][version: 1B][capacity: 4B][bucket_size: 1B]
//! [fingerprint_size: 1B][max_kicks: 2B][num_items: 8B][num_deletes: 8B]
//! [buckets: capacity * bucket_size * fingerprint_size bytes]
//! ```
//!
//! Total header size: 27 bytes (same as serialization format).
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use rustler::schedule::consume_timeslice;
use rustler::{Binary, Encoder, Env, NifResult, OwnedBinary, ResourceArc, Term};
/// How often (in items) to call `consume_timeslice` and let the BEAM
/// decide whether we should yield. 64 matches the interval used in lib.rs.
const YIELD_CHECK_INTERVAL: usize = 64;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Magic bytes identifying a cuckoo filter blob.
const MAGIC: [u8; 2] = [0xCF, 0x01];
/// Current serialization version.
const VERSION: u8 = 1;
/// Header size in bytes.
const HEADER_SIZE: usize = 27;
/// Default fingerprint size in bytes.
const DEFAULT_FINGERPRINT_SIZE: usize = 2;
/// Default number of fingerprints per bucket.
const DEFAULT_BUCKET_SIZE: usize = 4;
/// Default maximum kick attempts during insertion.
const DEFAULT_MAX_KICKS: u32 = 500;
// ---------------------------------------------------------------------------
// Storage enum — in-memory Vec or mmap'd file
// ---------------------------------------------------------------------------
/// Backing storage for the cuckoo filter's bucket array.
enum Storage {
/// In-memory storage backed by a heap-allocated Vec.
InMemory(Vec<u8>),
/// Memory-mapped file storage.
Mmap {
/// Pointer to the start of the mmap'd region (header + buckets).
ptr: *mut u8,
/// Total length of the mmap'd region in bytes.
len: usize,
/// File descriptor (kept open for the lifetime of the mapping).
fd: i32,
/// Path to the backing file (retained for diagnostics).
_path: PathBuf,
},
}
// SAFETY: The mmap pointer is protected by a Mutex in CuckooResource. All
// access is serialized — only one thread touches the pointer at a time.
unsafe impl Send for Storage {}
unsafe impl Sync for Storage {}
impl Storage {
/// Get a pointer to the start of the bucket data (past the header).
fn bucket_ptr(&self) -> *const u8 {
match self {
Storage::InMemory(v) => v.as_ptr(),
Storage::Mmap { ptr, .. } => unsafe { ptr.add(HEADER_SIZE) },
}
}
/// Get a mutable pointer to the start of the bucket data.
fn bucket_ptr_mut(&mut self) -> *mut u8 {
match self {
Storage::InMemory(v) => v.as_mut_ptr(),
Storage::Mmap { ptr, .. } => unsafe { ptr.add(HEADER_SIZE) },
}
}
/// Get a byte slice of the bucket data.
fn bucket_slice(&self, len: usize) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.bucket_ptr(), len) }
}
/// Get a mutable byte slice of the bucket data.
fn bucket_slice_mut(&mut self, len: usize) -> &mut [u8] {
unsafe { std::slice::from_raw_parts_mut(self.bucket_ptr_mut(), len) }
}
/// Whether this is mmap-backed.
fn is_mmap(&self) -> bool {
matches!(self, Storage::Mmap { .. })
}
/// Call msync(MS_ASYNC) if mmap-backed.
fn msync_async(&self) {
if let Storage::Mmap { ptr, len, .. } = self {
unsafe {
libc::msync(*ptr as *mut libc::c_void, *len, libc::MS_ASYNC);
}
}
}
/// Call msync(MS_SYNC) if mmap-backed.
#[cfg(test)]
fn msync_sync(&self) {
if let Storage::Mmap { ptr, len, .. } = self {
unsafe {
libc::msync(*ptr as *mut libc::c_void, *len, libc::MS_SYNC);
}
}
}
}
impl Drop for Storage {
fn drop(&mut self) {
if let Storage::Mmap { ptr, len, fd, .. } = self {
if !ptr.is_null() {
unsafe {
libc::msync(*ptr as *mut libc::c_void, *len, libc::MS_SYNC);
libc::munmap(*ptr as *mut libc::c_void, *len);
libc::close(*fd);
}
}
}
}
}
// ---------------------------------------------------------------------------
// CuckooFilter
// ---------------------------------------------------------------------------
/// A cuckoo filter storing fingerprints in a flat bucket array.
pub struct CuckooFilter {
/// Backing storage (in-memory Vec or mmap'd file).
storage: Storage,
/// Number of buckets.
num_buckets: usize,
/// Number of fingerprint slots per bucket.
bucket_size: usize,
/// Fingerprint size in bytes.
fingerprint_size: usize,
/// Number of items currently inserted (only used for in-memory mode;
/// for mmap mode, read/written from the header).
num_items: u64,
/// Number of items that have been deleted (same caveat as num_items).
num_deletes: u64,
/// Maximum kick attempts before declaring the filter full.
max_kicks: u32,
}
impl CuckooFilter {
/// Create a new empty cuckoo filter with the given capacity (number of buckets).
/// Uses in-memory storage.
#[must_use]
pub fn new(capacity: usize, bucket_size: usize, max_kicks: u32) -> Self {
let fingerprint_size = DEFAULT_FINGERPRINT_SIZE;
let total_bytes = capacity * bucket_size * fingerprint_size;
Self {
storage: Storage::InMemory(vec![0u8; total_bytes]),
num_buckets: capacity,
bucket_size,
fingerprint_size,
num_items: 0,
num_deletes: 0,
max_kicks,
}
}
/// Create a new mmap-backed cuckoo filter file at `path`.
pub fn create_file(path: &Path, capacity: usize, bucket_size: usize) -> Result<Self, String> {
if capacity == 0 {
return Err("capacity must be > 0".into());
}
if bucket_size == 0 {
return Err("bucket_size must be > 0".into());
}
let fingerprint_size = DEFAULT_FINGERPRINT_SIZE;
let max_kicks = DEFAULT_MAX_KICKS;
let bucket_bytes = capacity * bucket_size * fingerprint_size;
let file_size = HEADER_SIZE + bucket_bytes;
// Ensure parent directory exists.
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
// Write the file: header + zeroed buckets.
let mut file = File::create(path).map_err(|e| format!("create: {e}"))?;
let mut header = [0u8; HEADER_SIZE];
header[0..2].copy_from_slice(&MAGIC);
header[2] = VERSION;
header[3..7].copy_from_slice(&(capacity as u32).to_le_bytes());
header[7] = bucket_size as u8;
header[8] = fingerprint_size as u8;
header[9..11].copy_from_slice(&(max_kicks as u16).to_le_bytes());
// num_items (8B) = 0 at bytes 11..19
// num_deletes (8B) = 0 at bytes 19..27
file.write_all(&header)
.map_err(|e| format!("write header: {e}"))?;
let zeros = vec![0u8; bucket_bytes];
file.write_all(&zeros)
.map_err(|e| format!("write buckets: {e}"))?;
file.sync_all().map_err(|e| format!("fsync: {e}"))?;
drop(file);
// Now mmap the file.
Self::open_mmap(path, file_size)
}
/// Open an existing mmap-backed cuckoo filter file.
pub fn open_file(path: &Path) -> Result<Self, String> {
let meta = fs::metadata(path).map_err(|e| format!("stat: {e}"))?;
let file_size = meta.len() as usize;
if file_size < HEADER_SIZE {
return Err("file too small for cuckoo header".into());
}
Self::open_mmap(path, file_size)
}
/// Internal: mmap an existing cuckoo file and validate its header.
fn open_mmap(path: &Path, file_size: usize) -> Result<Self, String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("open: {e}"))?;
let fd = std::os::unix::io::AsRawFd::as_raw_fd(&file);
let mmap = unsafe {
libc::mmap(
std::ptr::null_mut(),
file_size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
)
};
if mmap == libc::MAP_FAILED {
return Err(format!("mmap failed: {}", std::io::Error::last_os_error()));
}
let ptr = mmap as *mut u8;
// Validate magic.
let magic = unsafe {
let mut buf = [0u8; 2];
std::ptr::copy_nonoverlapping(ptr, buf.as_mut_ptr(), 2);
buf
};
if magic != MAGIC {
unsafe {
libc::munmap(mmap, file_size);
}
return Err("invalid cuckoo file magic".into());
}
// Validate version.
let version = unsafe { *ptr.add(2) };
if version != VERSION {
unsafe {
libc::munmap(mmap, file_size);
}
return Err(format!("unsupported cuckoo version {version}"));
}
// Read header fields.
let num_buckets = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(ptr.add(3), buf.as_mut_ptr(), 4);
buf
}) as usize;
let bucket_size = unsafe { *ptr.add(7) } as usize;
let fingerprint_size = unsafe { *ptr.add(8) } as usize;
let max_kicks = u16::from_le_bytes(unsafe {
let mut buf = [0u8; 2];
std::ptr::copy_nonoverlapping(ptr.add(9), buf.as_mut_ptr(), 2);
buf
}) as u32;
let num_items = u64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(ptr.add(11), buf.as_mut_ptr(), 8);
buf
});
let num_deletes = u64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(ptr.add(19), buf.as_mut_ptr(), 8);
buf
});
let expected_size = HEADER_SIZE + num_buckets * bucket_size * fingerprint_size;
if file_size < expected_size {
unsafe {
libc::munmap(mmap, file_size);
}
return Err(format!(
"file too small: expected {expected_size}, got {file_size}"
));
}
// Hint the kernel that access is random (hash-determined bucket positions).
unsafe {
libc::madvise(mmap, file_size, libc::MADV_RANDOM);
}
// Dup the fd so we own it independently of the File object.
let owned_fd = unsafe { libc::dup(fd) };
if owned_fd < 0 {
unsafe {
libc::munmap(mmap, file_size);
}
return Err(format!(
"dup fd failed: {}",
std::io::Error::last_os_error()
));
}
// Drop the File — we keep owned_fd.
drop(file);
Ok(Self {
storage: Storage::Mmap {
ptr,
len: file_size,
fd: owned_fd,
_path: path.to_path_buf(),
},
num_buckets,
bucket_size,
fingerprint_size,
num_items,
num_deletes,
max_kicks,
})
}
/// Close an mmap-backed filter (munmap + close fd). For in-memory, this is a no-op.
pub fn close(&mut self) {
// Write header counters before extracting fields.
self.write_header_counters();
if let Storage::Mmap { ptr, len, fd, .. } = &mut self.storage {
if !ptr.is_null() {
unsafe {
libc::msync(*ptr as *mut libc::c_void, *len, libc::MS_SYNC);
libc::munmap(*ptr as *mut libc::c_void, *len);
libc::close(*fd);
}
*ptr = std::ptr::null_mut();
}
}
}
/// Write num_items and num_deletes to the mmap header.
fn write_header_counters(&self) {
if let Storage::Mmap { ptr, .. } = &self.storage {
unsafe {
std::ptr::copy_nonoverlapping(
self.num_items.to_le_bytes().as_ptr(),
ptr.add(11),
8,
);
std::ptr::copy_nonoverlapping(
self.num_deletes.to_le_bytes().as_ptr(),
ptr.add(19),
8,
);
}
}
}
/// Total number of bytes in the bucket array.
fn bucket_data_len(&self) -> usize {
self.num_buckets * self.bucket_size * self.fingerprint_size
}
/// Compute a 16-byte hash of `data` using xxh3.
fn hash128(data: &[u8]) -> [u8; 16] {
let h = xxhash_rust::xxh3::xxh3_128(data);
h.to_le_bytes()
}
/// Compute the fingerprint and primary bucket from a single hash of the element.
fn fingerprint_and_bucket(&self, element: &[u8]) -> (Vec<u8>, usize) {
let hash = Self::hash128(element);
// Fingerprint: first `fingerprint_size` bytes, ensuring non-zero.
let mut fp = hash[..self.fingerprint_size].to_vec();
if fp.iter().all(|&b| b == 0) {
fp[0] = 1;
}
// Primary bucket: next 8 bytes after fingerprint.
let start = self.fingerprint_size;
let hash_val = u64::from_le_bytes([
hash[start],
hash[start + 1],
hash[start + 2],
hash[start + 3],
hash[start + 4],
hash[start + 5],
hash[start + 6],
hash[start + 7],
]);
let bucket = (hash_val as usize) % self.num_buckets;
(fp, bucket)
}
/// Compute the fingerprint for an element (standalone, used in tests).
#[cfg(test)]
fn fingerprint(&self, element: &[u8]) -> Vec<u8> {
let (fp, _) = self.fingerprint_and_bucket(element);
fp
}
/// Compute the alternate bucket: `bucket XOR hash(fingerprint)`.
fn alternate_bucket(&self, bucket: usize, fp: &[u8]) -> usize {
let hash = Self::hash128(fp);
let fp_hash = u64::from_le_bytes([
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
]);
((bucket as u64) ^ fp_hash) as usize % self.num_buckets
}
/// Get the offset into the bucket array for a given bucket and slot.
fn slot_offset(&self, bucket_idx: usize, slot_idx: usize) -> usize {
(bucket_idx * self.bucket_size + slot_idx) * self.fingerprint_size
}
/// Read the fingerprint at a given bucket and slot.
fn get_slot(&self, bucket_idx: usize, slot_idx: usize) -> &[u8] {
let offset = self.slot_offset(bucket_idx, slot_idx);
let data = self.storage.bucket_slice(self.bucket_data_len());
&data[offset..offset + self.fingerprint_size]
}
/// Write a fingerprint at a given bucket and slot.
fn set_slot(&mut self, bucket_idx: usize, slot_idx: usize, fp: &[u8]) {
let offset = self.slot_offset(bucket_idx, slot_idx);
let fp_size = self.fingerprint_size;
let data_len = self.bucket_data_len();
let data = self.storage.bucket_slice_mut(data_len);
data[offset..offset + fp_size].copy_from_slice(fp);
}
/// Check if a slot is empty (all zeros).
fn is_slot_empty(&self, bucket_idx: usize, slot_idx: usize) -> bool {
self.get_slot(bucket_idx, slot_idx).iter().all(|&b| b == 0)
}
/// Find the first empty slot in a bucket. Returns `Some(slot_idx)` or `None`.
fn find_empty_slot(&self, bucket_idx: usize) -> Option<usize> {
(0..self.bucket_size).find(|&slot| self.is_slot_empty(bucket_idx, slot))
}
/// Add an element to the filter. Returns `Ok(())` on success, `Err(())` if full.
#[allow(clippy::result_unit_err)]
pub fn add(&mut self, element: &[u8]) -> Result<(), ()> {
let (fp, b1) = self.fingerprint_and_bucket(element);
let b2 = self.alternate_bucket(b1, &fp);
// Try primary bucket.
if let Some(slot) = self.find_empty_slot(b1) {
self.set_slot(b1, slot, &fp);
self.num_items += 1;
self.flush_counters();
return Ok(());
}
// Try alternate bucket.
if let Some(slot) = self.find_empty_slot(b2) {
self.set_slot(b2, slot, &fp);
self.num_items += 1;
self.flush_counters();
return Ok(());
}
// Both full: kick.
self.kick(fp, b1)
}
/// Cuckoo eviction loop.
fn kick(&mut self, mut fp: Vec<u8>, mut bucket: usize) -> Result<(), ()> {
for kicks in 0..self.max_kicks {
let slot_idx = (kicks as usize) % self.bucket_size;
// Read evicted fingerprint.
let evicted = self.get_slot(bucket, slot_idx).to_vec();
// Place our fingerprint in that slot.
self.set_slot(bucket, slot_idx, &fp);
// Find alternate bucket for evicted fingerprint.
let alt = self.alternate_bucket(bucket, &evicted);
// Try to place evicted fingerprint in its alternate bucket.
if let Some(empty_slot) = self.find_empty_slot(alt) {
self.set_slot(alt, empty_slot, &evicted);
self.num_items += 1;
self.flush_counters();
return Ok(());
}
// Continue kicking from the alternate bucket.
fp = evicted;
bucket = alt;
}
Err(())
}
/// Add an element only if it does not already exist.
/// Returns 1 if added, 0 if already present, or `Err(())` if full.
#[allow(clippy::result_unit_err)]
pub fn addnx(&mut self, element: &[u8]) -> Result<u64, ()> {
if self.exists(element) {
return Ok(0);
}
self.add(element)?;
Ok(1)
}
/// Check if an element may exist in the filter.
pub fn exists(&self, element: &[u8]) -> bool {
let (fp, b1) = self.fingerprint_and_bucket(element);
let b2 = self.alternate_bucket(b1, &fp);
self.bucket_contains(b1, &fp) || self.bucket_contains(b2, &fp)
}
/// Check if a bucket contains a given fingerprint.
fn bucket_contains(&self, bucket_idx: usize, fp: &[u8]) -> bool {
for slot in 0..self.bucket_size {
if self.get_slot(bucket_idx, slot) == fp {
return true;
}
}
false
}
/// Check multiple elements at once. Returns a vector of 0/1 values.
pub fn mexists(&self, elements: &[&[u8]]) -> Vec<u64> {
elements.iter().map(|e| u64::from(self.exists(e))).collect()
}
/// Delete one occurrence of an element. Returns 1 if deleted, 0 if not found.
pub fn delete(&mut self, element: &[u8]) -> u64 {
let (fp, b1) = self.fingerprint_and_bucket(element);
let b2 = self.alternate_bucket(b1, &fp);
let empty_buf = [0u8; 8]; // fingerprint_size is always <= 8
let empty = &empty_buf[..self.fingerprint_size];
// Try primary bucket first.
for slot in 0..self.bucket_size {
if self.get_slot(b1, slot) == fp.as_slice() {
self.set_slot(b1, slot, empty);
self.num_items -= 1;
self.num_deletes += 1;
self.flush_counters();
return 1;
}
}
// Try alternate bucket.
for slot in 0..self.bucket_size {
if self.get_slot(b2, slot) == fp.as_slice() {
self.set_slot(b2, slot, empty);
self.num_items -= 1;
self.num_deletes += 1;
self.flush_counters();
return 1;
}
}
0
}
/// Count occurrences of an element's fingerprint across both candidate buckets.
pub fn count(&self, element: &[u8]) -> u64 {
let (fp, b1) = self.fingerprint_and_bucket(element);
let b2 = self.alternate_bucket(b1, &fp);
let mut total = 0u64;
for slot in 0..self.bucket_size {
if self.get_slot(b1, slot) == fp.as_slice() {
total += 1;
}
}
for slot in 0..self.bucket_size {
if self.get_slot(b2, slot) == fp.as_slice() {
total += 1;
}
}
total
}
/// Flush header counters and msync_async after writes (mmap mode only).
fn flush_counters(&mut self) {
if self.storage.is_mmap() {
self.write_header_counters();
self.storage.msync_async();
}
}
/// Serialize the filter to a byte array for Bitcask storage.
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let data = self.storage.bucket_slice(self.bucket_data_len());
let mut buf = Vec::with_capacity(HEADER_SIZE + data.len());
// Magic (2 bytes)
buf.extend_from_slice(&MAGIC);
// Version (1 byte)
buf.push(VERSION);
// Capacity / num_buckets (4 bytes, LE)
buf.extend_from_slice(&(self.num_buckets as u32).to_le_bytes());
// Bucket size (1 byte)
buf.push(self.bucket_size as u8);
// Fingerprint size (1 byte)
buf.push(self.fingerprint_size as u8);
// Max kicks (2 bytes, LE)
buf.extend_from_slice(&(self.max_kicks as u16).to_le_bytes());
// Num items (8 bytes, LE)
buf.extend_from_slice(&self.num_items.to_le_bytes());
// Num deletes (8 bytes, LE)
buf.extend_from_slice(&self.num_deletes.to_le_bytes());
// Bucket data
buf.extend_from_slice(data);
buf
}
/// Deserialize a filter from a byte array (in-memory mode).
pub fn deserialize(data: &[u8]) -> Result<Self, String> {
if data.len() < HEADER_SIZE {
return Err("cuckoo: data too short".to_string());
}
if data[0..2] != MAGIC {
return Err("cuckoo: invalid magic".to_string());
}
if data[2] != VERSION {
return Err(format!("cuckoo: unsupported version {}", data[2]));
}
let num_buckets = u32::from_le_bytes([data[3], data[4], data[5], data[6]]) as usize;
let bucket_size = data[7] as usize;
let fingerprint_size = data[8] as usize;
let max_kicks = u16::from_le_bytes([data[9], data[10]]) as u32;
let num_items = u64::from_le_bytes([
data[11], data[12], data[13], data[14], data[15], data[16], data[17], data[18],
]);
let num_deletes = u64::from_le_bytes([
data[19], data[20], data[21], data[22], data[23], data[24], data[25], data[26],
]);
let expected_bucket_bytes = num_buckets * bucket_size * fingerprint_size;
if data.len() < HEADER_SIZE + expected_bucket_bytes {
return Err(format!(
"cuckoo: expected {} bucket bytes, got {}",
expected_bucket_bytes,
data.len() - HEADER_SIZE
));
}
let buckets = data[HEADER_SIZE..HEADER_SIZE + expected_bucket_bytes].to_vec();
Ok(Self {
storage: Storage::InMemory(buckets),
num_buckets,
bucket_size,
fingerprint_size,
num_items,
num_deletes,
max_kicks,
})
}
}
// ---------------------------------------------------------------------------
// NIF resource
// ---------------------------------------------------------------------------
/// Resource wrapper for the cuckoo filter, protected by a Mutex.
pub struct CuckooResource {
pub filter: Mutex<CuckooFilter>,
}
// ---------------------------------------------------------------------------
// NIF atoms
// ---------------------------------------------------------------------------
mod atoms {
rustler::atoms! {
ok,
error,
}
}
// ---------------------------------------------------------------------------
// NIF functions — in-memory
// ---------------------------------------------------------------------------
/// Create a new cuckoo filter (in-memory).
/// Returns `{:ok, resource}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_create(
env: Env,
capacity: u64,
bucket_size: u64,
max_kicks: u64,
expansion: u64,
) -> NifResult<Term> {
let _ = expansion; // Reserved for future use; NIF API requires the parameter.
if capacity == 0 {
return Ok((atoms::error(), "capacity must be > 0").encode(env));
}
let bs = if bucket_size == 0 {
DEFAULT_BUCKET_SIZE
} else {
bucket_size as usize
};
let mk = if max_kicks == 0 {
DEFAULT_MAX_KICKS
} else {
max_kicks as u32
};
let filter = CuckooFilter::new(capacity as usize, bs, mk);
let resource = ResourceArc::new(CuckooResource {
filter: Mutex::new(filter),
});
Ok((atoms::ok(), resource).encode(env))
}
// ---------------------------------------------------------------------------
// NIF functions — mmap file-backed
// ---------------------------------------------------------------------------
/// Create a new mmap-backed cuckoo filter file.
/// Returns `{:ok, resource}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_create_file(
env: Env,
path: String,
capacity: u64,
bucket_size: u64,
) -> NifResult<Term> {
let bs = if bucket_size == 0 {
DEFAULT_BUCKET_SIZE
} else {
bucket_size as usize
};
match CuckooFilter::create_file(Path::new(&path), capacity as usize, bs) {
Ok(filter) => {
let resource = ResourceArc::new(CuckooResource {
filter: Mutex::new(filter),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(e) => Ok((atoms::error(), e).encode(env)),
}
}
/// Open an existing mmap-backed cuckoo filter file.
/// Returns `{:ok, resource}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_open_file(env: Env, path: String) -> NifResult<Term> {
match CuckooFilter::open_file(Path::new(&path)) {
Ok(filter) => {
let resource = ResourceArc::new(CuckooResource {
filter: Mutex::new(filter),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(e) => Ok((atoms::error(), e).encode(env)),
}
}
/// Close an mmap-backed cuckoo filter (munmap + close fd).
/// Returns `:ok`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_close(env: Env, resource: ResourceArc<CuckooResource>) -> NifResult<Term> {
let mut filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
filter.close();
Ok(atoms::ok().encode(env))
}
// ---------------------------------------------------------------------------
// NIF functions — operations (work on both in-memory and mmap)
// ---------------------------------------------------------------------------
/// Add an element to the filter.
/// Returns `:ok` or `{:error, "filter is full"}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_add<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
item: Binary<'a>,
) -> NifResult<Term<'a>> {
let mut filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
match filter.add(item.as_slice()) {
Ok(()) => Ok(atoms::ok().encode(env)),
Err(()) => Ok((atoms::error(), "filter is full").encode(env)),
}
}
/// Add an element only if it does not already exist.
/// Returns 0 (already present) or 1 (added), or `{:error, ...}` if full.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_addnx<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
item: Binary<'a>,
) -> NifResult<Term<'a>> {
let mut filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
match filter.addnx(item.as_slice()) {
Ok(val) => Ok(val.encode(env)),
Err(()) => Ok((atoms::error(), "filter is full").encode(env)),
}
}
/// Delete one occurrence of an element.
/// Returns 0 (not found) or 1 (deleted).
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_del<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
item: Binary<'a>,
) -> NifResult<Term<'a>> {
let mut filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let result = filter.delete(item.as_slice());
Ok(result.encode(env))
}
/// Check if an element may exist.
/// Returns 0 or 1.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_exists<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
item: Binary<'a>,
) -> NifResult<Term<'a>> {
let filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let result: u64 = u64::from(filter.exists(item.as_slice()));
Ok(result.encode(env))
}
/// Check multiple elements at once.
/// Returns a list of 0/1 values.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_mexists<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
items: Vec<Binary<'a>>,
) -> NifResult<Term<'a>> {
let filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let mut results: Vec<u64> = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
results.push(u64::from(filter.exists(item.as_slice())));
if i % YIELD_CHECK_INTERVAL == 0 && i > 0 {
let _ = consume_timeslice(env, 1);
}
}
Ok(results.encode(env))
}
/// Count occurrences of an element's fingerprint.
/// Returns a non-negative integer.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_count<'a>(
env: Env<'a>,
resource: ResourceArc<CuckooResource>,
item: Binary<'a>,
) -> NifResult<Term<'a>> {
let filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let result = filter.count(item.as_slice());
Ok(result.encode(env))
}
/// Return filter metadata as a map.
/// Returns `{:ok, map}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_info(env: Env<'_>, resource: ResourceArc<CuckooResource>) -> NifResult<Term<'_>> {
let filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let total_slots = (filter.num_buckets * filter.bucket_size) as u64;
let info = (
atoms::ok(),
(
filter.num_buckets as u64,
filter.bucket_size as u64,
filter.fingerprint_size as u64,
filter.num_items,
filter.num_deletes,
total_slots,
filter.max_kicks as u64,
),
);
Ok(info.encode(env))
}
/// Serialize the filter to a binary for Bitcask storage.
/// Returns `{:ok, binary}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_serialize(
env: Env<'_>,
resource: ResourceArc<CuckooResource>,
) -> NifResult<Term<'_>> {
let filter = resource.filter.lock().map_err(|_| rustler::Error::BadArg)?;
let bytes = filter.serialize();
let mut bin = OwnedBinary::new(bytes.len()).ok_or(rustler::Error::BadArg)?;
bin.as_mut_slice().copy_from_slice(&bytes);
Ok((atoms::ok(), Binary::from_owned(bin, env)).encode(env))
}
/// Deserialize a filter from a binary blob (in-memory).
/// Returns `{:ok, resource}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn cuckoo_deserialize<'a>(env: Env<'a>, data: Binary<'a>) -> NifResult<Term<'a>> {
match CuckooFilter::deserialize(data.as_slice()) {
Ok(filter) => {
let resource = ResourceArc::new(CuckooResource {
filter: Mutex::new(filter),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(msg) => Ok((atoms::error(), msg).encode(env)),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_filter_empty() {
let f = CuckooFilter::new(1024, 4, 500);
assert_eq!(f.num_buckets, 1024);
assert_eq!(f.bucket_size, 4);
assert_eq!(f.num_items, 0);
assert_eq!(f.num_deletes, 0);
assert_eq!(f.bucket_data_len(), 1024 * 4 * 2);
}
#[test]
fn add_and_exists() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert!(!f.exists(b"hello"));
assert!(f.add(b"hello").is_ok());
assert!(f.exists(b"hello"));
}
#[test]
fn add_and_delete() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"hello").unwrap();
assert_eq!(f.delete(b"hello"), 1);
assert!(!f.exists(b"hello"));
assert_eq!(f.num_items, 0);
assert_eq!(f.num_deletes, 1);
}
#[test]
fn addnx_prevents_duplicate() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert_eq!(f.addnx(b"hello").unwrap(), 1);
assert_eq!(f.addnx(b"hello").unwrap(), 0);
}
#[test]
fn count_tracks_duplicates() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"hello").unwrap();
f.add(b"hello").unwrap();
assert_eq!(f.count(b"hello"), 2);
}
#[test]
fn mexists_returns_correct_values() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"a").unwrap();
f.add(b"b").unwrap();
let results = f.mexists(&[b"a", b"b", b"c"]);
assert_eq!(results, vec![1, 1, 0]);
}
#[test]
fn serialize_deserialize_roundtrip() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"hello").unwrap();
f.add(b"world").unwrap();
f.delete(b"world");
let bytes = f.serialize();
let f2 = CuckooFilter::deserialize(&bytes).unwrap();
assert_eq!(f2.num_buckets, 1024);
assert_eq!(f2.bucket_size, 4);
assert_eq!(f2.num_items, 1);
assert_eq!(f2.num_deletes, 1);
assert!(f2.exists(b"hello"));
assert!(!f2.exists(b"world"));
}
#[test]
fn fingerprint_never_zero() {
let f = CuckooFilter::new(1024, 4, 500);
// Test many elements to increase chance of hitting the zero case.
for i in 0..10_000 {
let fp = f.fingerprint(format!("elem_{i}").as_bytes());
assert!(
!fp.iter().all(|&b| b == 0),
"fingerprint was all zeros for elem_{i}"
);
}
}
#[test]
fn no_false_negatives() {
let mut f = CuckooFilter::new(2048, 4, 500);
for i in 0..200 {
let elem = format!("element_{i}");
f.add(elem.as_bytes()).unwrap();
}
for i in 0..200 {
let elem = format!("element_{i}");
assert!(f.exists(elem.as_bytes()), "false negative for {elem}");
}
}
#[test]
fn filter_full_returns_error() {
// Tiny filter: 2 buckets * 2 slots = 4 total slots.
let mut f = CuckooFilter::new(2, 2, 10);
let mut inserted = 0;
for i in 0..100 {
if f.add(format!("elem_{i}").as_bytes()).is_ok() {
inserted += 1;
}
}
// Should have filled up before 100 insertions.
assert!(inserted < 100);
}
// -----------------------------------------------------------------------
// Mmap file-backed tests
// -----------------------------------------------------------------------
#[test]
fn mmap_create_add_exists() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.cuckoo");
let mut f = CuckooFilter::create_file(&path, 1024, 4).unwrap();
assert!(!f.exists(b"hello"));
assert!(f.add(b"hello").is_ok());
assert!(f.exists(b"hello"));
assert_eq!(f.num_items, 1);
}
#[test]
fn mmap_persistence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("persist.cuckoo");
{
let mut f = CuckooFilter::create_file(&path, 1024, 4).unwrap();
f.add(b"persisted").unwrap();
f.add(b"also_persisted").unwrap();
f.storage.msync_sync();
}
// Reopen and verify.
let f = CuckooFilter::open_file(&path).unwrap();
assert!(f.exists(b"persisted"));
assert!(f.exists(b"also_persisted"));
assert_eq!(f.num_items, 2);
}
#[test]
fn mmap_delete_and_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("del.cuckoo");
{
let mut f = CuckooFilter::create_file(&path, 1024, 4).unwrap();
f.add(b"keep").unwrap();
f.add(b"remove").unwrap();
f.delete(b"remove");
f.storage.msync_sync();
}
let f = CuckooFilter::open_file(&path).unwrap();
assert!(f.exists(b"keep"));
assert!(!f.exists(b"remove"));
assert_eq!(f.num_items, 1);
assert_eq!(f.num_deletes, 1);
}
#[test]
fn mmap_close_and_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("close.cuckoo");
{
let mut f = CuckooFilter::create_file(&path, 1024, 4).unwrap();
f.add(b"test_close").unwrap();
f.close();
}
let f = CuckooFilter::open_file(&path).unwrap();
assert!(f.exists(b"test_close"));
}
#[test]
fn mmap_open_nonexistent_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.cuckoo");
let result = CuckooFilter::open_file(&path);
assert!(result.is_err());
}
#[test]
fn mmap_open_garbage_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("garbage.cuckoo");
std::fs::write(&path, b"this is not a cuckoo file").unwrap();
// File is only 25 bytes, too short for header
let result = CuckooFilter::open_file(&path);
assert!(result.is_err());
}
#[test]
fn mmap_open_bad_magic_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("badmagic.cuckoo");
let mut data = vec![0xFF; 64];
// Set a plausible file size but bad magic
data[0] = 0xFF;
data[1] = 0xFF;
std::fs::write(&path, &data).unwrap();
let result = CuckooFilter::open_file(&path);
assert!(result.is_err());
}
#[test]
fn mmap_many_items_persistence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("many.cuckoo");
{
let mut f = CuckooFilter::create_file(&path, 4096, 4).unwrap();
for i in 0..1000 {
let _ = f.add(format!("item_{i}").as_bytes());
}
f.storage.msync_sync();
}
let f = CuckooFilter::open_file(&path).unwrap();
// Verify all items present (no false negatives).
for i in 0..1000 {
assert!(
f.exists(format!("item_{i}").as_bytes()),
"false negative for item_{i} after reopen"
);
}
}
#[test]
fn mmap_nested_dir_creation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deep").join("nested").join("filter.cuckoo");
let mut f = CuckooFilter::create_file(&path, 256, 4).unwrap();
f.add(b"nested").unwrap();
assert!(f.exists(b"nested"));
assert!(path.exists());
}
// -----------------------------------------------------------------------
// Edge-case tests (in-memory)
// -----------------------------------------------------------------------
#[test]
fn delete_nonexistent_returns_zero() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert_eq!(f.delete(b"never_added"), 0);
}
#[test]
fn addnx_on_existing_returns_zero() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert_eq!(f.addnx(b"x").unwrap(), 1);
assert_eq!(f.addnx(b"x").unwrap(), 0);
}
#[test]
fn count_for_added_item() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"counted").unwrap();
assert_eq!(f.count(b"counted"), 1);
}
#[test]
fn count_for_deleted_item() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"temp").unwrap();
f.delete(b"temp");
assert_eq!(f.count(b"temp"), 0);
}
#[test]
fn empty_key_works() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert!(f.add(b"").is_ok());
assert!(f.exists(b""));
}
#[test]
fn large_key_1mb() {
let mut f = CuckooFilter::new(1024, 4, 500);
let big = vec![0xFFu8; 1_024 * 1_024];
assert!(f.add(&big).is_ok());
assert!(f.exists(&big));
}
#[test]
fn fill_to_95_percent_capacity() {
// 256 buckets * 4 slots = 1024 total slots
let mut f = CuckooFilter::new(256, 4, 500);
let target = (1024.0 * 0.95) as usize; // ~972 items
let mut inserted = 0;
for i in 0..2000 {
if f.add(format!("fill_{i}").as_bytes()).is_ok() {
inserted += 1;
}
if inserted >= target {
break;
}
}
// Measure false positive rate at 95% capacity
let test_count = 10_000;
let fp = (0..test_count)
.filter(|i| f.exists(format!("probe_{i}").as_bytes()))
.count();
let fpr = fp as f64 / test_count as f64;
assert!(
fpr < 0.05,
"FPR at 95% capacity: {fpr:.4} (inserted {inserted} items)"
);
}
#[test]
fn deserialize_truncated_data_returns_error() {
let f = CuckooFilter::new(1024, 4, 500);
let bytes = f.serialize();
// Truncate to just the header
let truncated = &bytes[..HEADER_SIZE];
let result = CuckooFilter::deserialize(truncated);
assert!(result.is_err());
}
#[test]
fn deserialize_wrong_magic_returns_error() {
let mut bytes = vec![0xFF, 0xFF]; // wrong magic
bytes.push(VERSION);
bytes.extend_from_slice(&[0; HEADER_SIZE - 3]);
let result = CuckooFilter::deserialize(&bytes);
assert!(result.is_err());
match result {
Err(msg) => assert!(msg.contains("invalid magic"), "wrong error: {msg}"),
Ok(_) => panic!("expected error"),
}
}
#[test]
fn concurrent_adds_with_mutex() {
use std::sync::{Arc, Mutex};
let f = Arc::new(Mutex::new(CuckooFilter::new(4096, 4, 500)));
let handles: Vec<_> = (0..4)
.map(|t| {
let f_clone = Arc::clone(&f);
std::thread::spawn(move || {
for i in 0..250 {
let mut guard = f_clone.lock().unwrap();
let _ = guard.add(format!("t{t}_k{i}").as_bytes());
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let guard = f.lock().unwrap();
// All 1000 items should have been attempted
assert!(guard.num_items > 0);
}
// ==================================================================
// Deep NIF edge cases — targeting cuckoo filter / FFI pitfalls
// ==================================================================
#[test]
fn add_to_completely_full_filter_returns_error() {
// Tiny filter: 1 bucket * 1 slot = 1 total slot, max_kicks=0
let mut f = CuckooFilter::new(1, 1, 0);
// First add should succeed
let first = f.add(b"first");
assert!(first.is_ok());
// Second add must fail (only 1 slot, kicks=0)
let second = f.add(b"second");
assert!(second.is_err(), "full filter must return error");
}
#[test]
fn delete_from_empty_filter_returns_zero() {
let mut f = CuckooFilter::new(1024, 4, 500);
assert_eq!(f.delete(b"nothing"), 0);
assert_eq!(f.num_items, 0);
assert_eq!(f.num_deletes, 0);
}
#[test]
fn addnx_race_condition_mutex_protected() {
use std::sync::{Arc, Mutex};
let f = Arc::new(Mutex::new(CuckooFilter::new(4096, 4, 500)));
// Multiple threads all trying addnx on the same item
let handles: Vec<_> = (0..20)
.map(|_| {
let f_clone = Arc::clone(&f);
std::thread::spawn(move || {
let mut guard = f_clone.lock().unwrap();
guard.addnx(b"race_item").unwrap()
})
})
.collect();
let results: Vec<u64> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// Exactly one thread should have returned 1 (inserted), rest 0
let inserted_count = results.iter().filter(|&&r| r == 1).count();
assert_eq!(inserted_count, 1, "exactly one addnx must succeed");
}
#[test]
fn deserialize_all_zeros_returns_error() {
let zeros = vec![0u8; 100];
let result = CuckooFilter::deserialize(&zeros);
assert!(
result.is_err(),
"all-zero bytes must fail deserialization (bad magic)"
);
}
#[test]
fn deserialize_truncated_at_every_byte_fuzzlike() {
let mut f = CuckooFilter::new(64, 4, 500);
for i in 0..10 {
f.add(format!("fuzz_{i}").as_bytes()).unwrap();
}
let bytes = f.serialize();
// Try deserializing at every truncation point
for truncate_at in 0..bytes.len() {
let truncated = &bytes[..truncate_at];
let result = CuckooFilter::deserialize(truncated);
// Must either succeed or return error, never panic
let _ = result;
}
}
#[test]
fn serialize_empty_filter_roundtrip() {
let f = CuckooFilter::new(256, 4, 500);
let bytes = f.serialize();
let f2 = CuckooFilter::deserialize(&bytes).unwrap();
assert_eq!(f2.num_items, 0);
assert_eq!(f2.num_buckets, 256);
assert!(!f2.exists(b"anything"));
}
#[test]
fn add_delete_add_same_item() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"cycle").unwrap();
assert!(f.exists(b"cycle"));
f.delete(b"cycle");
assert!(!f.exists(b"cycle"));
f.add(b"cycle").unwrap();
assert!(f.exists(b"cycle"));
}
#[test]
fn count_after_multiple_adds_and_deletes() {
let mut f = CuckooFilter::new(1024, 4, 500);
f.add(b"x").unwrap();
f.add(b"x").unwrap();
f.add(b"x").unwrap();
assert_eq!(f.count(b"x"), 3);
f.delete(b"x");
assert_eq!(f.count(b"x"), 2);
f.delete(b"x");
f.delete(b"x");
assert_eq!(f.count(b"x"), 0);
}
#[test]
fn mexists_empty_list() {
let f = CuckooFilter::new(1024, 4, 500);
let results = f.mexists(&[]);
assert!(results.is_empty());
}
#[test]
fn deserialize_wrong_version_returns_error() {
let mut bytes = vec![MAGIC[0], MAGIC[1]];
bytes.push(0xFF); // bad version
bytes.extend_from_slice(&[0; HEADER_SIZE - 3]);
let result = CuckooFilter::deserialize(&bytes);
assert!(result.is_err());
}
// ------------------------------------------------------------------
// H-4 + H-7: xxh3 replaces MD5, single hash per exists()
// ------------------------------------------------------------------
#[test]
fn h4_h7_no_false_negatives_10k_items() {
let mut f = CuckooFilter::new(4096, 4, 500);
// Insert 10K items
for i in 0..10_000 {
let _ = f.add(format!("h4_item_{i}").as_bytes());
}
// Verify zero false negatives for all inserted items
for i in 0..10_000 {
assert!(
f.exists(format!("h4_item_{i}").as_bytes()),
"false negative for h4_item_{i}"
);
}
}
#[test]
fn h7_exists_correctness_5k_present_5k_absent() {
let mut f = CuckooFilter::new(4096, 4, 500);
// Add 5K items
for i in 0..5_000 {
let _ = f.add(format!("present_{i}").as_bytes());
}
// Check 5K present items - zero false negatives
for i in 0..5_000 {
assert!(
f.exists(format!("present_{i}").as_bytes()),
"false negative for present_{i}"
);
}
// Check 5K absent items - track false positives
let fp_count = (0..5_000)
.filter(|i| f.exists(format!("absent_{i}").as_bytes()))
.count();
// With 2-byte fingerprints, FPR should be reasonable
let fpr = fp_count as f64 / 5_000.0;
assert!(
fpr < 0.05,
"False positive rate {fpr:.4} too high for 2-byte fingerprints"
);
}
#[test]
fn h7_serialization_roundtrip_after_hash_change() {
let mut f = CuckooFilter::new(2048, 4, 500);
for i in 0..200 {
f.add(format!("serial_{i}").as_bytes()).unwrap();
}
let bytes = f.serialize();
let f2 = CuckooFilter::deserialize(&bytes).unwrap();
// All items should still be found after deserialization
for i in 0..200 {
assert!(
f2.exists(format!("serial_{i}").as_bytes()),
"serial_{i} missing after deserialize"
);
}
assert_eq!(f2.num_items, 200);
}
#[test]
fn h7_delete_correctness_after_hash_change() {
let mut f = CuckooFilter::new(2048, 4, 500);
// Add items
for i in 0..100 {
f.add(format!("del_{i}").as_bytes()).unwrap();
}
// Delete even-numbered items
for i in (0..100).step_by(2) {
let result = f.delete(format!("del_{i}").as_bytes());
assert_eq!(result, 1, "delete must succeed for del_{i}");
}
// Odd items should still exist
for i in (1..100).step_by(2) {
assert!(
f.exists(format!("del_{i}").as_bytes()),
"del_{i} must still exist after deleting even items"
);
}
}
// ------------------------------------------------------------------
// L-6: stack-allocated empty sentinel in delete()
// ------------------------------------------------------------------
#[test]
fn l6_delete_many_times_no_leak() {
let mut f = CuckooFilter::new(2048, 4, 500);
for i in 0..500 {
f.add(format!("del_test_{i}").as_bytes()).unwrap();
}
assert_eq!(f.num_items, 500);
// Delete all items
for i in 0..500 {
let result = f.delete(format!("del_test_{i}").as_bytes());
assert_eq!(result, 1, "delete must succeed for del_test_{i}");
}
assert_eq!(f.num_items, 0, "all items should be deleted");
assert_eq!(f.num_deletes, 500, "delete count should match");
// Verify no item is found
for i in 0..500 {
assert!(
!f.exists(format!("del_test_{i}").as_bytes()),
"del_test_{i} should not exist after deletion"
);
}
}
#[test]
fn l6_delete_nonexistent_returns_zero_stack_sentinel() {
let mut f = CuckooFilter::new(1024, 4, 500);
// Delete from empty filter must return 0 and not corrupt the filter
for i in 0..100 {
assert_eq!(
f.delete(format!("ghost_{i}").as_bytes()),
0,
"deleting non-existent item must return 0"
);
}
assert_eq!(f.num_items, 0);
assert_eq!(f.num_deletes, 0);
}
}