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/topk.rs
//! Top-K data structure backed by a Count-Min Sketch and a min-heap.
//!
//! Maintains the K most frequent elements seen so far. Each element
//! addition updates an internal CMS to estimate frequency, then inserts
//! into a bounded min-heap of size K. When the heap is full and a new
//! element exceeds the minimum frequency, the least-frequent element is
//! evicted and returned to the caller.
//!
//! Exposed to Elixir as NIF functions operating on a `TopKResource`.
use std::collections::{BinaryHeap, HashSet};
use std::sync::Mutex;
use rustler::{Binary, Encoder, Env, NifResult, OwnedBinary, ResourceArc, Term};
// ---------------------------------------------------------------------------
// Count-Min Sketch (internal)
// ---------------------------------------------------------------------------
/// A Count-Min Sketch for frequency estimation.
///
/// Uses double hashing: h(i) = h1 + i * h2 (mod width) for row i.
struct CountMinSketch {
width: usize,
depth: usize,
/// Row-major: counters[row * width + col]
counters: Vec<i64>,
}
impl CountMinSketch {
fn new(width: usize, depth: usize) -> Self {
Self {
width,
depth,
counters: vec![0i64; width * depth],
}
}
/// Hash an element using Erlang-compatible double hashing.
/// Returns `depth` hash values, one per row.
fn hash_indices(&self, element: &[u8]) -> Vec<usize> {
// Use FNV-1a style hashing with two independent seeds for
// double hashing. We need deterministic hashes that match
// across calls.
let h1 = fnv1a(element, 0x811c_9dc5);
let h2 = fnv1a(element, 0x050c_5d1f);
(0..self.depth)
.map(|i| {
let h = h1.wrapping_add((i as u64).wrapping_mul(h2));
(h % self.width as u64) as usize
})
.collect()
}
/// Increment element's count and return the new minimum estimate.
fn increment(&mut self, element: &[u8], count: i64) -> i64 {
let indices = self.hash_indices(element);
let mut min_count = i64::MAX;
for (row, &col) in indices.iter().enumerate() {
let idx = row * self.width + col;
self.counters[idx] += count;
min_count = min_count.min(self.counters[idx]);
}
min_count
}
}
/// FNV-1a hash with a configurable offset basis for double hashing.
fn fnv1a(data: &[u8], offset_basis: u64) -> u64 {
let mut hash = offset_basis;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash
}
// ---------------------------------------------------------------------------
// Heap item
// ---------------------------------------------------------------------------
/// An item in the min-heap, ordered by estimated count (ascending).
#[derive(Clone, Debug)]
struct HeapItem {
count: i64,
element: String,
}
impl PartialEq for HeapItem {
fn eq(&self, other: &Self) -> bool {
self.count == other.count && self.element == other.element
}
}
impl Eq for HeapItem {}
/// Reverse ordering so `BinaryHeap` acts as a min-heap (smallest count at top).
impl PartialOrd for HeapItem {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for HeapItem {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Reverse: smallest count has highest priority (min-heap).
other
.count
.cmp(&self.count)
.then_with(|| self.element.cmp(&other.element))
}
}
// ---------------------------------------------------------------------------
// TopK structure
// ---------------------------------------------------------------------------
/// Top-K tracker: maintains the K items with the highest estimated frequency.
pub struct TopK {
k: usize,
width: usize,
depth: usize,
decay: f64,
cms: CountMinSketch,
/// Min-heap of top-k items (smallest count at top via reversed Ord).
heap: BinaryHeap<HeapItem>,
/// Fast membership check for items currently in the heap.
fingerprints: HashSet<String>,
}
impl TopK {
pub fn new(k: usize, width: usize, depth: usize, decay: f64) -> Self {
Self {
k,
width,
depth,
decay,
cms: CountMinSketch::new(width, depth),
heap: BinaryHeap::with_capacity(k + 1),
fingerprints: HashSet::with_capacity(k + 1),
}
}
/// Add a single element with the given increment. Returns the displaced
/// element name if an eviction occurred, or `None`.
pub fn add(&mut self, element: &str, count: i64) -> Option<String> {
let estimated = self.cms.increment(element.as_bytes(), count);
// If element is already tracked, update its count in the heap.
if self.fingerprints.contains(element) {
self.update_existing(element, estimated);
return None;
}
// If heap has room, just insert.
if self.heap.len() < self.k {
self.heap.push(HeapItem {
count: estimated,
element: element.to_string(),
});
self.fingerprints.insert(element.to_string());
return None;
}
// Heap is full: check if new element beats the minimum.
let min_count = self.heap.peek().map_or(0, |item| item.count);
if estimated > min_count {
// Evict the minimum.
let evicted = self.heap.pop().unwrap();
self.fingerprints.remove(&evicted.element);
self.heap.push(HeapItem {
count: estimated,
element: element.to_string(),
});
self.fingerprints.insert(element.to_string());
Some(evicted.element)
} else {
None
}
}
/// Update the count of an element already in the heap.
///
/// L-5 fix: use `into_vec()` to get the underlying Vec without allocation,
/// mutate the target element in place, then reconstruct the heap via `From`.
/// This avoids the intermediate `drain().collect()` allocation.
fn update_existing(&mut self, element: &str, new_count: i64) {
let mut items = std::mem::take(&mut self.heap).into_vec();
for item in &mut items {
if item.element == element {
item.count = new_count;
break;
}
}
self.heap = BinaryHeap::from(items);
}
/// Check if an element is currently in the top-K.
pub fn query(&self, element: &str) -> bool {
self.fingerprints.contains(element)
}
/// Return all top-K items sorted by count descending.
pub fn list(&self) -> Vec<(String, i64)> {
let mut items: Vec<(String, i64)> = self
.heap
.iter()
.map(|item| (item.element.clone(), item.count))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
items
}
/// Return metadata: (k, width, depth, decay).
pub fn info(&self) -> (usize, usize, usize, f64) {
(self.k, self.width, self.depth, self.decay)
}
// ------------------------------------------------------------------
// Serialization (for Bitcask byte array storage)
// ------------------------------------------------------------------
/// Serialize the TopK to a byte blob for storage.
///
/// Format:
/// [k:u32][width:u32][depth:u32][decay:f64]
/// [cms_counters: width*depth * i64]
/// [heap_len:u32]
/// for each heap item:
/// [element_len:u32][element_bytes][count:i64]
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&(self.k as u32).to_le_bytes());
buf.extend_from_slice(&(self.width as u32).to_le_bytes());
buf.extend_from_slice(&(self.depth as u32).to_le_bytes());
buf.extend_from_slice(&self.decay.to_le_bytes());
// CMS counters
for &counter in &self.cms.counters {
buf.extend_from_slice(&counter.to_le_bytes());
}
// Heap items
let items = self.list(); // sorted desc
buf.extend_from_slice(&(items.len() as u32).to_le_bytes());
for (element, count) in &items {
let elem_bytes = element.as_bytes();
buf.extend_from_slice(&(elem_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(elem_bytes);
buf.extend_from_slice(&count.to_le_bytes());
}
buf
}
/// Deserialize a TopK from a byte blob.
pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
if data.len() < 20 {
return Err("TopK: data too short for header".to_string());
}
let mut pos = 0;
let k = read_u32(data, &mut pos)? as usize;
let width = read_u32(data, &mut pos)? as usize;
let depth = read_u32(data, &mut pos)? as usize;
let decay = read_f64(data, &mut pos)?;
// CMS counters
let cms_size = width * depth;
let mut counters = Vec::with_capacity(cms_size);
for _ in 0..cms_size {
counters.push(read_i64(data, &mut pos)?);
}
let cms = CountMinSketch {
width,
depth,
counters,
};
// Heap items
let heap_len = read_u32(data, &mut pos)? as usize;
let mut heap = BinaryHeap::with_capacity(k + 1);
let mut fingerprints = HashSet::with_capacity(k + 1);
for _ in 0..heap_len {
let elem_len = read_u32(data, &mut pos)? as usize;
if pos + elem_len > data.len() {
return Err("TopK: data truncated in element".to_string());
}
let element =
String::from_utf8(data[pos..pos + elem_len].to_vec()).map_err(|e| e.to_string())?;
pos += elem_len;
let count = read_i64(data, &mut pos)?;
fingerprints.insert(element.clone());
heap.push(HeapItem { count, element });
}
Ok(Self {
k,
width,
depth,
decay,
cms,
heap,
fingerprints,
})
}
}
fn read_u32(data: &[u8], pos: &mut usize) -> Result<u32, String> {
if *pos + 4 > data.len() {
return Err("TopK: data truncated reading u32".to_string());
}
let val = u32::from_le_bytes(data[*pos..*pos + 4].try_into().unwrap());
*pos += 4;
Ok(val)
}
fn read_i64(data: &[u8], pos: &mut usize) -> Result<i64, String> {
if *pos + 8 > data.len() {
return Err("TopK: data truncated reading i64".to_string());
}
let val = i64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap());
*pos += 8;
Ok(val)
}
fn read_f64(data: &[u8], pos: &mut usize) -> Result<f64, String> {
if *pos + 8 > data.len() {
return Err("TopK: data truncated reading f64".to_string());
}
let val = f64::from_le_bytes(data[*pos..*pos + 8].try_into().unwrap());
*pos += 8;
Ok(val)
}
// ---------------------------------------------------------------------------
// Rustler NIF resource
// ---------------------------------------------------------------------------
/// Resource wrapper for the TopK, protected by a Mutex for thread safety.
pub struct TopKResource {
pub topk: Mutex<TopK>,
}
// ---------------------------------------------------------------------------
// NIF atoms
// ---------------------------------------------------------------------------
mod atoms {
rustler::atoms! {
ok,
error,
nil,
}
}
// ---------------------------------------------------------------------------
// NIF functions
// ---------------------------------------------------------------------------
/// Create a new TopK tracker.
/// Returns `{:ok, ref}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_create(env: Env, k: usize, width: usize, depth: usize, decay: f64) -> NifResult<Term> {
if k == 0 {
return Ok((atoms::error(), "k must be > 0").encode(env));
}
if width == 0 {
return Ok((atoms::error(), "width must be > 0").encode(env));
}
if depth == 0 {
return Ok((atoms::error(), "depth must be > 0").encode(env));
}
if !(0.0..=1.0).contains(&decay) {
return Ok((atoms::error(), "decay must be between 0 and 1").encode(env));
}
let topk = TopK::new(k, width, depth, decay);
let resource = ResourceArc::new(TopKResource {
topk: Mutex::new(topk),
});
Ok((atoms::ok(), resource).encode(env))
}
/// Add items (each with increment 1). Returns a list of displaced items
/// (nil for no eviction, string for evicted element name).
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_add(
env: Env,
resource: ResourceArc<TopKResource>,
items: Vec<String>,
) -> NifResult<Term> {
let mut topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = items
.iter()
.map(|item| match topk.add(item, 1) {
Some(evicted) => evicted.encode(env),
None => atoms::nil().encode(env),
})
.collect();
Ok(results.encode(env))
}
/// Increment items by specified amounts. `items_and_increments` is a flat
/// list of `[element, count, element, count, ...]` pairs encoded as
/// `Vec<(String, i64)>`.
/// Returns a list of displaced items (nil or evicted element name).
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_incrby(
env: Env,
resource: ResourceArc<TopKResource>,
pairs: Vec<(String, i64)>,
) -> NifResult<Term> {
let mut topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = pairs
.iter()
.map(|(item, count)| match topk.add(item, *count) {
Some(evicted) => evicted.encode(env),
None => atoms::nil().encode(env),
})
.collect();
Ok(results.encode(env))
}
/// Query whether items are in the top-K.
/// Returns a list of 0 (not in top-K) or 1 (in top-K).
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_query(
env: Env,
resource: ResourceArc<TopKResource>,
items: Vec<String>,
) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<i32> = items
.iter()
.map(|item| i32::from(topk.query(item)))
.collect();
Ok(results.encode(env))
}
/// List all items in the top-K, sorted by count descending.
/// Returns a list of element strings.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_list(env: Env, resource: ResourceArc<TopKResource>) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let items: Vec<String> = topk.list().into_iter().map(|(elem, _)| elem).collect();
Ok(items.encode(env))
}
/// List all items in the top-K with their estimated counts, sorted by
/// count descending. Returns a list of `{element, count}` tuples.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_list_with_count(env: Env, resource: ResourceArc<TopKResource>) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let items: Vec<(String, i64)> = topk.list();
Ok(items.encode(env))
}
/// Return metadata: `{k, width, depth, decay}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_info(env: Env, resource: ResourceArc<TopKResource>) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let (k, width, depth, decay) = topk.info();
Ok((k, width, depth, decay).encode(env))
}
/// Serialize the TopK to a binary blob.
/// Returns `{:ok, binary}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_to_bytes(env: Env<'_>, resource: ResourceArc<TopKResource>) -> NifResult<Term<'_>> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let bytes = topk.to_bytes();
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 TopK from a binary blob.
/// Returns `{:ok, ref}` or `{:error, reason}`.
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_from_bytes<'a>(env: Env<'a>, data: Binary<'a>) -> NifResult<Term<'a>> {
match TopK::from_bytes(data.as_slice()) {
Ok(topk) => {
let resource = ResourceArc::new(TopKResource {
topk: Mutex::new(topk),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(msg) => Ok((atoms::error(), msg).encode(env)),
}
}
// ===========================================================================
// Memory-mapped file-backed TopK
// ===========================================================================
//
// File layout (little-endian):
//
// ```text
// [header: 64 bytes]
// [CMS counters: width * depth * 8 bytes (i64 each)]
// [heap entries: k * HEAP_ENTRY_SIZE bytes]
// ```
//
// Header (64 bytes):
// bytes 0..7: magic (0x544F504B_4D4D5031 = "TOPKMMP1")
// bytes 8..11: k (u32)
// bytes 12..15: width (u32)
// bytes 16..19: depth (u32)
// bytes 20..27: decay (f64)
// bytes 28..31: heap_len (u32) — number of items currently in heap
// bytes 32..63: reserved (zero)
//
// Each heap entry (HEAP_ENTRY_SIZE = 264 bytes):
// bytes 0..7: count (i64)
// bytes 8..11: element_len (u32)
// bytes 12..263: element bytes (max 252 bytes, zero-padded)
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
const TOPK_MAGIC: u64 = 0x544F_504B_4D4D_5031; // "TOPKMMP1"
const TOPK_HEADER_SIZE: usize = 64;
const HEAP_ENTRY_SIZE: usize = 264; // 8 (count) + 4 (len) + 252 (element)
const MAX_ELEMENT_LEN: usize = 252;
/// A memory-mapped TopK data structure.
///
/// The mmap region covers the entire file: header + CMS counters + heap entries.
pub struct MmapTopK {
mmap: *mut u8,
mmap_len: usize,
k: usize,
width: usize,
depth: usize,
decay: f64,
path: PathBuf,
/// In-memory fingerprint set for fast membership checks.
/// Rebuilt from heap entries on open.
fingerprints: HashSet<String>,
}
// SAFETY: The mmap pointer is protected by a Mutex in MmapTopKResource.
unsafe impl Send for MmapTopK {}
unsafe impl Sync for MmapTopK {}
impl MmapTopK {
fn file_size(k: usize, width: usize, depth: usize) -> usize {
TOPK_HEADER_SIZE + (width * depth * 8) + (k * HEAP_ENTRY_SIZE)
}
fn cms_offset() -> usize {
TOPK_HEADER_SIZE
}
fn heap_offset(width: usize, depth: usize) -> usize {
TOPK_HEADER_SIZE + (width * depth * 8)
}
pub fn create(
path: &Path,
k: usize,
width: usize,
depth: usize,
decay: f64,
) -> Result<Self, String> {
if k == 0 {
return Err("k must be > 0".into());
}
if width == 0 {
return Err("width must be > 0".into());
}
if depth == 0 {
return Err("depth must be > 0".into());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
let file_size = Self::file_size(k, width, depth);
let mut file = File::create(path).map_err(|e| format!("create: {e}"))?;
let mut header = [0u8; TOPK_HEADER_SIZE];
header[0..8].copy_from_slice(&TOPK_MAGIC.to_le_bytes());
header[8..12].copy_from_slice(&(k as u32).to_le_bytes());
header[12..16].copy_from_slice(&(width as u32).to_le_bytes());
header[16..20].copy_from_slice(&(depth as u32).to_le_bytes());
header[20..28].copy_from_slice(&decay.to_le_bytes());
// heap_len = 0, reserved = 0 (already zeroed)
file.write_all(&header)
.map_err(|e| format!("write header: {e}"))?;
let zeros = vec![0u8; file_size - TOPK_HEADER_SIZE];
file.write_all(&zeros)
.map_err(|e| format!("write body: {e}"))?;
file.sync_all().map_err(|e| format!("fsync: {e}"))?;
drop(file);
Self::open(path, file_size, k, width, depth, decay)
}
pub fn open_existing(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 < TOPK_HEADER_SIZE {
return Err("file too small for topk header".into());
}
// Read header to get params
let file = OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("open: {e}"))?;
let mmap = unsafe {
libc::mmap(
std::ptr::null_mut(),
file_size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
std::os::unix::io::AsRawFd::as_raw_fd(&file),
0,
)
};
if mmap == libc::MAP_FAILED {
return Err(format!("mmap failed: {}", std::io::Error::last_os_error()));
}
let ptr = mmap as *mut u8;
let magic = u64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(ptr, buf.as_mut_ptr(), 8);
buf
});
if magic != TOPK_MAGIC {
unsafe {
libc::munmap(mmap, file_size);
}
return Err("invalid topk file magic".into());
}
let k = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(ptr.add(8), buf.as_mut_ptr(), 4);
buf
}) as usize;
let width = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(ptr.add(12), buf.as_mut_ptr(), 4);
buf
}) as usize;
let depth = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(ptr.add(16), buf.as_mut_ptr(), 4);
buf
}) as usize;
let decay = f64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(ptr.add(20), buf.as_mut_ptr(), 8);
buf
});
let expected = Self::file_size(k, width, depth);
if file_size < expected {
unsafe {
libc::munmap(mmap, file_size);
}
return Err(format!(
"file too small: expected {expected}, got {file_size}"
));
}
// Build fingerprints from heap entries
let heap_len = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(ptr.add(28), buf.as_mut_ptr(), 4);
buf
}) as usize;
let mut fingerprints = HashSet::with_capacity(k + 1);
let heap_base = Self::heap_offset(width, depth);
for i in 0..heap_len {
let entry_ptr = unsafe { ptr.add(heap_base + i * HEAP_ENTRY_SIZE) };
let elem_len = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(entry_ptr.add(8), buf.as_mut_ptr(), 4);
buf
}) as usize;
if elem_len <= MAX_ELEMENT_LEN {
let elem_bytes = unsafe { std::slice::from_raw_parts(entry_ptr.add(12), elem_len) };
if let Ok(s) = std::str::from_utf8(elem_bytes) {
fingerprints.insert(s.to_string());
}
}
}
// Don't munmap - we reuse it
Ok(Self {
mmap: ptr,
mmap_len: file_size,
k,
width,
depth,
decay,
path: path.to_path_buf(),
fingerprints,
})
}
fn open(
path: &Path,
file_size: usize,
k: usize,
width: usize,
depth: usize,
decay: f64,
) -> Result<Self, String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open(path)
.map_err(|e| format!("open: {e}"))?;
let mmap = unsafe {
libc::mmap(
std::ptr::null_mut(),
file_size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
std::os::unix::io::AsRawFd::as_raw_fd(&file),
0,
)
};
if mmap == libc::MAP_FAILED {
return Err(format!("mmap failed: {}", std::io::Error::last_os_error()));
}
Ok(Self {
mmap: mmap as *mut u8,
mmap_len: file_size,
k,
width,
depth,
decay,
path: path.to_path_buf(),
fingerprints: HashSet::with_capacity(k + 1),
})
}
// -- Header accessors --
fn heap_len(&self) -> usize {
u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(self.mmap.add(28), buf.as_mut_ptr(), 4);
buf
}) as usize
}
fn set_heap_len(&self, len: usize) {
unsafe {
std::ptr::copy_nonoverlapping(
(len as u32).to_le_bytes().as_ptr(),
self.mmap.add(28),
4,
);
}
}
// -- CMS accessors --
fn cms_get(&self, row: usize, col: usize) -> i64 {
let offset = Self::cms_offset() + (row * self.width + col) * 8;
i64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(self.mmap.add(offset), buf.as_mut_ptr(), 8);
buf
})
}
fn cms_set(&self, row: usize, col: usize, val: i64) {
let offset = Self::cms_offset() + (row * self.width + col) * 8;
unsafe {
std::ptr::copy_nonoverlapping(val.to_le_bytes().as_ptr(), self.mmap.add(offset), 8);
}
}
fn cms_increment(&self, element: &[u8], count: i64) -> i64 {
let h1 = fnv1a(element, 0x811c_9dc5);
let h2 = fnv1a(element, 0x050c_5d1f);
let mut min_count = i64::MAX;
for i in 0..self.depth {
let h = h1.wrapping_add((i as u64).wrapping_mul(h2));
let col = (h % self.width as u64) as usize;
let val = self.cms_get(i, col) + count;
self.cms_set(i, col, val);
min_count = min_count.min(val);
}
min_count
}
fn cms_estimate(&self, element: &[u8]) -> i64 {
let h1 = fnv1a(element, 0x811c_9dc5);
let h2 = fnv1a(element, 0x050c_5d1f);
let mut min_count = i64::MAX;
for i in 0..self.depth {
let h = h1.wrapping_add((i as u64).wrapping_mul(h2));
let col = (h % self.width as u64) as usize;
min_count = min_count.min(self.cms_get(i, col));
}
min_count
}
// -- Heap accessors --
fn heap_entry_count(&self, idx: usize) -> i64 {
let offset = Self::heap_offset(self.width, self.depth) + idx * HEAP_ENTRY_SIZE;
i64::from_le_bytes(unsafe {
let mut buf = [0u8; 8];
std::ptr::copy_nonoverlapping(self.mmap.add(offset), buf.as_mut_ptr(), 8);
buf
})
}
fn heap_entry_element(&self, idx: usize) -> String {
let base = Self::heap_offset(self.width, self.depth) + idx * HEAP_ENTRY_SIZE;
let elem_len = u32::from_le_bytes(unsafe {
let mut buf = [0u8; 4];
std::ptr::copy_nonoverlapping(self.mmap.add(base + 8), buf.as_mut_ptr(), 4);
buf
}) as usize;
let clamped = elem_len.min(MAX_ELEMENT_LEN);
let bytes = unsafe { std::slice::from_raw_parts(self.mmap.add(base + 12), clamped) };
String::from_utf8_lossy(bytes).to_string()
}
fn set_heap_entry(&self, idx: usize, element: &str, count: i64) {
let base = Self::heap_offset(self.width, self.depth) + idx * HEAP_ENTRY_SIZE;
unsafe {
// Write count
std::ptr::copy_nonoverlapping(count.to_le_bytes().as_ptr(), self.mmap.add(base), 8);
// Write element length
let elem_bytes = element.as_bytes();
let len = elem_bytes.len().min(MAX_ELEMENT_LEN);
std::ptr::copy_nonoverlapping(
(len as u32).to_le_bytes().as_ptr(),
self.mmap.add(base + 8),
4,
);
// Write element bytes (zero-padded)
std::ptr::write_bytes(self.mmap.add(base + 12), 0, MAX_ELEMENT_LEN);
std::ptr::copy_nonoverlapping(elem_bytes.as_ptr(), self.mmap.add(base + 12), len);
}
}
/// Find the index of the min-count entry in the heap, or None if empty.
fn find_min_heap_idx(&self) -> Option<(usize, i64)> {
let len = self.heap_len();
if len == 0 {
return None;
}
let mut min_idx = 0;
let mut min_count = self.heap_entry_count(0);
for i in 1..len {
let c = self.heap_entry_count(i);
if c < min_count {
min_count = c;
min_idx = i;
}
}
Some((min_idx, min_count))
}
/// Add a single element with the given increment. Returns the displaced
/// element name if an eviction occurred, or `None`.
pub fn add(&mut self, element: &str, count: i64) -> Option<String> {
let estimated = self.cms_increment(element.as_bytes(), count);
// Already tracked? Update count in-place.
if self.fingerprints.contains(element) {
let len = self.heap_len();
for i in 0..len {
if self.heap_entry_element(i) == element {
self.set_heap_entry(i, element, estimated);
break;
}
}
return None;
}
let len = self.heap_len();
// Heap has room
if len < self.k {
self.set_heap_entry(len, element, estimated);
self.set_heap_len(len + 1);
self.fingerprints.insert(element.to_string());
return None;
}
// Heap full: check if new element beats the minimum
if let Some((min_idx, min_count)) = self.find_min_heap_idx() {
if estimated > min_count {
let evicted = self.heap_entry_element(min_idx);
self.fingerprints.remove(&evicted);
self.set_heap_entry(min_idx, element, estimated);
self.fingerprints.insert(element.to_string());
return Some(evicted);
}
}
None
}
pub fn query(&self, element: &str) -> bool {
self.fingerprints.contains(element)
}
pub fn count(&self, element: &str) -> i64 {
self.cms_estimate(element.as_bytes())
}
pub fn list(&self) -> Vec<(String, i64)> {
let len = self.heap_len();
let mut items: Vec<(String, i64)> = (0..len)
.map(|i| (self.heap_entry_element(i), self.heap_entry_count(i)))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
items
}
pub fn info(&self) -> (usize, usize, usize, f64) {
(self.k, self.width, self.depth, self.decay)
}
pub fn msync(&self) -> Result<(), String> {
let ret = unsafe {
libc::msync(
self.mmap as *mut libc::c_void,
self.mmap_len,
libc::MS_ASYNC,
)
};
if ret != 0 {
Err(format!("msync failed: {}", std::io::Error::last_os_error()))
} else {
Ok(())
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for MmapTopK {
fn drop(&mut self) {
if !self.mmap.is_null() {
unsafe {
libc::msync(
self.mmap as *mut libc::c_void,
self.mmap_len,
libc::MS_ASYNC,
);
libc::munmap(self.mmap as *mut libc::c_void, self.mmap_len);
}
}
}
}
/// Resource wrapper for MmapTopK.
pub struct MmapTopKResource {
pub topk: Mutex<MmapTopK>,
}
// -- Mmap TopK NIF functions --
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_create_file(
env: Env,
path: String,
k: usize,
width: usize,
depth: usize,
decay: f64,
) -> NifResult<Term> {
if !(0.0..=1.0).contains(&decay) {
return Ok((atoms::error(), "decay must be between 0 and 1").encode(env));
}
match MmapTopK::create(Path::new(&path), k, width, depth, decay) {
Ok(topk) => {
let resource = ResourceArc::new(MmapTopKResource {
topk: Mutex::new(topk),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(e) => Ok((atoms::error(), e).encode(env)),
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_open_file(env: Env, path: String) -> NifResult<Term> {
match MmapTopK::open_existing(Path::new(&path)) {
Ok(topk) => {
let resource = ResourceArc::new(MmapTopKResource {
topk: Mutex::new(topk),
});
Ok((atoms::ok(), resource).encode(env))
}
Err(e) => Ok((atoms::error(), e).encode(env)),
}
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_add(
env: Env,
resource: ResourceArc<MmapTopKResource>,
items: Vec<String>,
) -> NifResult<Term> {
let mut topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = items
.iter()
.map(|item| match topk.add(item, 1) {
Some(evicted) => evicted.encode(env),
None => atoms::nil().encode(env),
})
.collect();
let _ = topk.msync();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_incrby(
env: Env,
resource: ResourceArc<MmapTopKResource>,
pairs: Vec<(String, i64)>,
) -> NifResult<Term> {
let mut topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<Term> = pairs
.iter()
.map(|(item, count)| match topk.add(item, *count) {
Some(evicted) => evicted.encode(env),
None => atoms::nil().encode(env),
})
.collect();
let _ = topk.msync();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_query(
env: Env,
resource: ResourceArc<MmapTopKResource>,
items: Vec<String>,
) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<i32> = items
.iter()
.map(|item| i32::from(topk.query(item)))
.collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_list(env: Env, resource: ResourceArc<MmapTopKResource>) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let items: Vec<String> = topk.list().into_iter().map(|(elem, _)| elem).collect();
Ok(items.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_count(
env: Env,
resource: ResourceArc<MmapTopKResource>,
items: Vec<String>,
) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let results: Vec<i64> = items.iter().map(|item| topk.count(item)).collect();
Ok(results.encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_info(env: Env, resource: ResourceArc<MmapTopKResource>) -> NifResult<Term> {
let topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
let (k, width, depth, decay) = topk.info();
Ok((k, width, depth, decay).encode(env))
}
#[rustler::nif(schedule = "Normal")]
#[allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
pub fn topk_file_close(env: Env, resource: ResourceArc<MmapTopKResource>) -> NifResult<Term> {
let mut topk = resource.topk.lock().map_err(|_| rustler::Error::BadArg)?;
if !topk.mmap.is_null() {
unsafe {
libc::msync(topk.mmap as *mut libc::c_void, topk.mmap_len, libc::MS_SYNC);
libc::munmap(topk.mmap as *mut libc::c_void, topk.mmap_len);
}
topk.mmap = std::ptr::null_mut();
topk.mmap_len = 0;
}
Ok(atoms::ok().encode(env))
}
// ---------------------------------------------------------------------------
// Rust-only unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_add_and_query() {
let mut topk = TopK::new(3, 8, 7, 0.9);
assert_eq!(topk.add("a", 1), None);
assert_eq!(topk.add("b", 1), None);
assert_eq!(topk.add("c", 1), None);
assert!(topk.query("a"));
assert!(topk.query("b"));
assert!(topk.query("c"));
assert!(!topk.query("d"));
}
#[test]
fn eviction_when_full() {
let mut topk = TopK::new(2, 8, 7, 0.9);
topk.add("a", 100);
topk.add("b", 50);
// "c" with count 200 should evict the min
let evicted = topk.add("c", 200);
assert!(evicted.is_some());
let evicted_name = evicted.unwrap();
assert!(evicted_name == "a" || evicted_name == "b");
}
#[test]
fn no_eviction_when_count_too_low() {
let mut topk = TopK::new(2, 8, 7, 0.9);
topk.add("a", 100);
topk.add("b", 100);
assert_eq!(topk.add("c", 1), None);
}
#[test]
fn serialization_roundtrip() {
let mut topk = TopK::new(3, 8, 7, 0.9);
topk.add("alpha", 10);
topk.add("beta", 20);
topk.add("gamma", 30);
let bytes = topk.to_bytes();
let restored = TopK::from_bytes(&bytes).unwrap();
assert_eq!(restored.k, 3);
assert_eq!(restored.width, 8);
assert_eq!(restored.depth, 7);
assert!((restored.decay - 0.9).abs() < f64::EPSILON);
assert!(restored.query("alpha"));
assert!(restored.query("beta"));
assert!(restored.query("gamma"));
let list = restored.list();
assert_eq!(list.len(), 3);
assert_eq!(list[0].0, "gamma");
assert_eq!(list[1].0, "beta");
assert_eq!(list[2].0, "alpha");
}
#[test]
fn list_sorted_desc() {
let mut topk = TopK::new(5, 8, 7, 0.9);
topk.add("a", 10);
topk.add("b", 50);
topk.add("c", 30);
let items = topk.list();
assert_eq!(items[0].0, "b");
assert_eq!(items[1].0, "c");
assert_eq!(items[2].0, "a");
}
#[test]
fn update_existing_element() {
let mut topk = TopK::new(3, 8, 7, 0.9);
topk.add("a", 1);
topk.add("a", 1);
topk.add("a", 1);
assert!(topk.query("a"));
let items = topk.list();
assert_eq!(items.len(), 1);
assert!(items[0].1 >= 3);
}
#[test]
fn empty_topk_list() {
let topk = TopK::new(5, 8, 7, 0.9);
assert_eq!(topk.list().len(), 0);
}
#[test]
fn k_equals_one() {
let mut topk = TopK::new(1, 8, 7, 0.9);
topk.add("a", 100);
assert!(topk.query("a"));
topk.add("b", 200);
assert!(topk.query("b"));
assert!(!topk.query("a"));
}
#[test]
fn cms_fnv1a_deterministic() {
let h1 = fnv1a(b"hello", 0x811c_9dc5);
let h2 = fnv1a(b"hello", 0x811c_9dc5);
assert_eq!(h1, h2);
let h3 = fnv1a(b"world", 0x811c_9dc5);
assert_ne!(h1, h3);
}
#[test]
fn from_bytes_too_short() {
let result = TopK::from_bytes(&[0u8; 10]);
assert!(result.is_err());
}
// -----------------------------------------------------------------------
// Edge-case tests
// -----------------------------------------------------------------------
#[test]
fn add_k_plus_1_items_one_displaced() {
let mut topk = TopK::new(3, 8, 7, 0.9);
topk.add("a", 10);
topk.add("b", 20);
topk.add("c", 30);
// Adding a 4th with higher count than the min should displace something
let displaced = topk.add("d", 40);
assert!(displaced.is_some(), "expected displacement of min item");
assert_eq!(topk.list().len(), 3);
}
#[test]
fn query_non_top_item() {
let mut topk = TopK::new(2, 8, 7, 0.9);
topk.add("a", 100);
topk.add("b", 200);
topk.add("c", 300); // displaces "a"
assert!(!topk.query("a"), "displaced item should not be queryable");
assert!(topk.query("b"));
assert!(topk.query("c"));
}
#[test]
fn list_returns_exactly_k_items() {
let mut topk = TopK::new(5, 8, 7, 0.9);
for i in 0..10 {
topk.add(&format!("item_{i}"), (i + 1) as i64);
}
let list = topk.list();
assert_eq!(list.len(), 5);
}
#[test]
fn list_with_count_sorted_desc() {
let mut topk = TopK::new(5, 8, 7, 0.9);
topk.add("low", 1);
topk.add("mid", 50);
topk.add("high", 100);
let items = topk.list();
let counts: Vec<i64> = items.iter().map(|(_, c)| *c).collect();
for i in 1..counts.len() {
assert!(
counts[i - 1] >= counts[i],
"list not sorted desc: {counts:?}"
);
}
}
#[test]
fn decay_old_items_displaced_by_new_frequent() {
// With k=2 and low initial counts, new items with high increments displace old
let mut topk = TopK::new(2, 8, 7, 0.9);
topk.add("old1", 1);
topk.add("old2", 2);
// Now add a much more frequent item
topk.add("new_hot", 1000);
assert!(topk.query("new_hot"));
// At least one old item should have been displaced
let in_topk = [topk.query("old1"), topk.query("old2")];
assert!(
in_topk.contains(&false),
"expected at least one old item displaced"
);
}
#[test]
fn empty_topk_list_is_empty() {
let topk = TopK::new(10, 8, 7, 0.9);
assert_eq!(topk.list().len(), 0);
}
#[test]
fn add_same_item_1000x_always_in_topk() {
let mut topk = TopK::new(3, 8, 7, 0.9);
for _ in 0..1000 {
topk.add("frequent", 1);
}
assert!(topk.query("frequent"));
let items = topk.list();
assert!(
items.iter().any(|(e, _)| e == "frequent"),
"frequent item missing from list"
);
}
#[test]
fn concurrent_adds_with_mutex() {
use std::sync::{Arc, Mutex};
let topk = Arc::new(Mutex::new(TopK::new(10, 100, 7, 0.9)));
let handles: Vec<_> = (0..4)
.map(|t| {
let topk_c = Arc::clone(&topk);
std::thread::spawn(move || {
for i in 0..250 {
let mut guard = topk_c.lock().unwrap();
guard.add(&format!("t{t}_i{i}"), 1);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let guard = topk.lock().unwrap();
assert_eq!(guard.list().len(), 10);
}
#[test]
fn from_bytes_truncated_element_returns_error() {
let mut topk = TopK::new(3, 8, 7, 0.9);
topk.add("test", 1);
let bytes = topk.to_bytes();
// Truncate mid-way through the heap items
let truncated = &bytes[..bytes.len() - 5];
let result = TopK::from_bytes(truncated);
assert!(result.is_err());
}
// ==================================================================
// Deep NIF edge cases — targeting TopK / FFI safety pitfalls
// ==================================================================
#[test]
fn add_item_longer_than_64kb() {
let mut topk = TopK::new(3, 8, 7, 0.9);
let big_item = "x".repeat(65536);
topk.add(&big_item, 100);
assert!(topk.query(&big_item));
}
#[test]
fn all_items_same_frequency_all_in_topk() {
let mut topk = TopK::new(5, 100, 7, 0.9);
for i in 0..5 {
topk.add(&format!("same_freq_{i}"), 10);
}
let list = topk.list();
assert_eq!(list.len(), 5);
}
#[test]
fn decay_very_low_does_not_crash() {
// decay = 0.0 is degenerate
let mut topk = TopK::new(3, 8, 7, 0.0);
topk.add("a", 10);
topk.add("b", 20);
topk.add("c", 30);
// Should not crash even with zero decay
let list = topk.list();
assert!(!list.is_empty());
}
#[test]
fn decay_one_point_zero() {
// decay = 1.0 means no decay
let mut topk = TopK::new(3, 8, 7, 1.0);
topk.add("a", 10);
topk.add("b", 20);
let list = topk.list();
assert_eq!(list.len(), 2);
}
#[test]
fn add_empty_string_item() {
let mut topk = TopK::new(3, 8, 7, 0.9);
topk.add("", 5);
assert!(topk.query(""));
let list = topk.list();
assert!(list.iter().any(|(e, _)| e.is_empty()));
}
#[test]
fn k_equals_1_constant_replacement() {
let mut topk = TopK::new(1, 8, 7, 0.9);
topk.add("first", 10);
assert!(topk.query("first"));
// Higher count replaces
topk.add("second", 100);
assert!(topk.query("second"));
// Lower count does not replace
topk.add("third", 1);
assert!(!topk.query("third"));
}
#[test]
fn serialize_empty_topk_roundtrip() {
let topk = TopK::new(5, 8, 7, 0.9);
let bytes = topk.to_bytes();
let restored = TopK::from_bytes(&bytes).unwrap();
assert_eq!(restored.list().len(), 0);
assert_eq!(restored.k, 5);
}
#[test]
fn incrby_large_count_does_not_overflow() {
let mut topk = TopK::new(3, 100, 7, 0.9);
topk.add("big", i64::MAX / 2);
topk.add("big", i64::MAX / 2);
// Must not panic
let list = topk.list();
assert!(list.iter().any(|(e, _)| e == "big"));
}
#[test]
fn many_unique_items_only_top_k_remain() {
let mut topk = TopK::new(10, 200, 7, 0.9);
for i in 0..1000 {
topk.add(&format!("item_{i}"), (i + 1) as i64);
}
let list = topk.list();
assert_eq!(list.len(), 10);
// All items in the list should have high counts
for (_, count) in &list {
assert!(*count > 0);
}
}
#[test]
fn concurrent_add_with_mutex_no_corruption() {
use std::sync::{Arc, Mutex};
let topk = Arc::new(Mutex::new(TopK::new(10, 100, 7, 0.9)));
let handles: Vec<_> = (0..8)
.map(|t| {
let topk_c = Arc::clone(&topk);
std::thread::spawn(move || {
for i in 0..100 {
let mut guard = topk_c.lock().unwrap();
guard.add(&format!("t{t}_i{i}"), 1);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let guard = topk.lock().unwrap();
let list = guard.list();
assert_eq!(list.len(), 10);
}
// ==================================================================
// Mmap-backed TopK tests
// ==================================================================
#[test]
fn mmap_topk_create_add_query() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.topk");
let mut topk = MmapTopK::create(&path, 3, 8, 7, 0.9).unwrap();
assert!(!topk.query("a"));
topk.add("a", 10);
assert!(topk.query("a"));
assert_eq!(topk.heap_len(), 1);
}
#[test]
fn mmap_topk_persistence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("persist.topk");
{
let mut topk = MmapTopK::create(&path, 5, 8, 7, 0.9).unwrap();
topk.add("alpha", 10);
topk.add("beta", 20);
topk.add("gamma", 30);
topk.msync().unwrap();
}
let topk = MmapTopK::open_existing(&path).unwrap();
assert!(topk.query("alpha"));
assert!(topk.query("beta"));
assert!(topk.query("gamma"));
assert_eq!(topk.heap_len(), 3);
let list = topk.list();
assert_eq!(list.len(), 3);
assert_eq!(list[0].0, "gamma");
}
#[test]
fn mmap_topk_eviction() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("evict.topk");
let mut topk = MmapTopK::create(&path, 2, 8, 7, 0.9).unwrap();
topk.add("a", 100);
topk.add("b", 50);
let evicted = topk.add("c", 200);
assert!(evicted.is_some());
assert_eq!(topk.heap_len(), 2);
assert!(topk.query("c"));
}
#[test]
fn mmap_topk_no_eviction_low_count() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no_evict.topk");
let mut topk = MmapTopK::create(&path, 2, 8, 7, 0.9).unwrap();
topk.add("a", 100);
topk.add("b", 100);
assert_eq!(topk.add("c", 1), None);
assert_eq!(topk.heap_len(), 2);
}
#[test]
fn mmap_topk_update_existing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("update.topk");
let mut topk = MmapTopK::create(&path, 5, 8, 7, 0.9).unwrap();
topk.add("x", 10);
topk.add("x", 10);
topk.add("x", 10);
assert!(topk.query("x"));
assert_eq!(topk.heap_len(), 1);
}
#[test]
fn mmap_topk_list_sorted_desc() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sorted.topk");
let mut topk = MmapTopK::create(&path, 5, 8, 7, 0.9).unwrap();
topk.add("low", 1);
topk.add("mid", 50);
topk.add("high", 100);
let items = topk.list();
let counts: Vec<i64> = items.iter().map(|(_, c)| *c).collect();
for i in 1..counts.len() {
assert!(
counts[i - 1] >= counts[i],
"list not sorted desc: {counts:?}"
);
}
}
#[test]
fn mmap_topk_count_via_cms() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("count.topk");
let mut topk = MmapTopK::create(&path, 5, 100, 7, 0.9).unwrap();
topk.add("item", 42);
let est = topk.count("item");
assert_eq!(est, 42);
}
#[test]
fn mmap_topk_info() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("info.topk");
let topk = MmapTopK::create(&path, 10, 200, 5, 0.8).unwrap();
let (k, w, d, decay) = topk.info();
assert_eq!(k, 10);
assert_eq!(w, 200);
assert_eq!(d, 5);
assert!((decay - 0.8).abs() < f64::EPSILON);
}
#[test]
fn mmap_topk_open_bad_magic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.topk");
std::fs::write(&path, [0xFF; 128]).unwrap();
let result = MmapTopK::open_existing(&path);
assert!(result.is_err());
}
#[test]
fn mmap_topk_open_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nope.topk");
let result = MmapTopK::open_existing(&path);
assert!(result.is_err());
}
#[test]
fn mmap_topk_nested_dir_create() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a").join("b").join("c").join("test.topk");
let topk = MmapTopK::create(&path, 3, 8, 7, 0.9).unwrap();
assert!(path.exists());
assert_eq!(topk.heap_len(), 0);
}
#[test]
fn mmap_topk_many_items() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("many.topk");
let mut topk = MmapTopK::create(&path, 10, 200, 7, 0.9).unwrap();
for i in 0..100 {
topk.add(&format!("item_{i}"), (i + 1) as i64);
}
assert_eq!(topk.list().len(), 10);
}
#[test]
fn mmap_topk_reopen_after_eviction() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("reopen.topk");
{
let mut topk = MmapTopK::create(&path, 2, 100, 7, 0.9).unwrap();
topk.add("stay_high", 1000);
topk.add("stay_mid", 500);
topk.add("evict_me", 1); // should not evict (count too low)
topk.msync().unwrap();
}
let topk = MmapTopK::open_existing(&path).unwrap();
assert!(topk.query("stay_high"));
assert!(topk.query("stay_mid"));
assert_eq!(topk.heap_len(), 2);
}
// ------------------------------------------------------------------
// L-5: update_existing uses into_vec + in-place mutation (no extra alloc)
// ------------------------------------------------------------------
#[test]
fn l5_update_existing_preserves_heap_invariant() {
let mut topk = TopK::new(5, 128, 4, 1.0);
// Add 5 items with increasing counts
for i in 0..5 {
topk.add(&format!("item_{i}"), (i + 1) as i64);
}
// All 5 should be in the top-K
for i in 0..5 {
assert!(
topk.query(&format!("item_{i}")),
"item_{i} should be in top-K"
);
}
// Update item_0 many times to increase its count
for _ in 0..50 {
topk.add("item_0", 1);
}
// item_0 should still be in the heap with an updated count
assert!(topk.query("item_0"), "item_0 must still be in top-K");
// Verify heap ordering: list() returns sorted desc
let list = topk.list();
assert_eq!(list.len(), 5);
for i in 0..list.len() - 1 {
assert!(
list[i].1 >= list[i + 1].1,
"list must be sorted descending by count"
);
}
// item_0 should be near the top since its count was incremented 50+ times
assert_eq!(
list[0].0, "item_0",
"item_0 must be the highest count after 50 increments"
);
}
}