use dynomite::hashkit::{hash, HashType};
use crate::datatypes::Crdt;
pub const PRECISION: u32 = 14;
pub const REGISTER_COUNT: usize = 1usize << PRECISION;
const MAX_RHO: u8 = 51;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HyperLogLog {
registers: Box<[u8]>,
}
impl Default for HyperLogLog {
fn default() -> Self {
Self::new()
}
}
impl HyperLogLog {
#[must_use]
pub fn new() -> Self {
Self {
registers: vec![0u8; REGISTER_COUNT].into_boxed_slice(),
}
}
fn hash64(item: &[u8]) -> u64 {
let token = hash(HashType::Murmur3, item);
let words = token.mag();
let lo = u64::from(words.first().copied().unwrap_or(0));
let hi = u64::from(words.get(1).copied().unwrap_or(0));
(hi << 32) | lo
}
pub fn add(&mut self, item: impl AsRef<[u8]>) {
let h = Self::hash64(item.as_ref());
let idx = (h >> (64 - PRECISION)) as usize;
let payload = (h << PRECISION) | (1u64 << (PRECISION - 1));
let leading = payload.leading_zeros();
let rho = u8::try_from(leading + 1).unwrap_or(MAX_RHO).min(MAX_RHO);
if rho > self.registers[idx] {
self.registers[idx] = rho;
}
}
#[must_use]
pub fn registers(&self) -> &[u8] {
&self.registers
}
}
impl Crdt for HyperLogLog {
type Value = u64;
fn merge(&mut self, other: &Self) {
for (mine, theirs) in self.registers.iter_mut().zip(other.registers.iter()) {
if *theirs > *mine {
*mine = *theirs;
}
}
}
fn value(&self) -> u64 {
let m = f64::from(u32::try_from(REGISTER_COUNT).unwrap_or(u32::MAX));
let mut sum = 0.0f64;
let mut zeros = 0u32;
for &r in &*self.registers {
if r == 0 {
zeros += 1;
}
sum += 2.0f64.powi(-i32::from(r));
}
let alpha = 0.7213 / (1.0 + 1.079 / m);
let raw = alpha * m * m / sum;
let estimate = if zeros > 0 && raw <= 2.5 * m {
m * (m / f64::from(zeros)).ln()
} else {
raw
};
f64_to_u64_saturating(estimate)
}
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn f64_to_u64_saturating(x: f64) -> u64 {
if x.is_nan() || x <= 0.0 {
return 0;
}
let rounded = x.round();
if !rounded.is_finite() {
return u64::MAX;
}
if rounded >= u64::MAX as f64 {
return u64::MAX;
}
rounded as u64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_hll_estimates_zero() {
let h = HyperLogLog::new();
assert_eq!(h.value(), 0);
}
#[test]
fn single_item_estimates_one() {
let mut h = HyperLogLog::new();
h.add(b"alpha");
let v = h.value();
assert!(
(1..=2).contains(&v),
"single-item estimate {v} not in [1,2]"
);
}
#[test]
fn duplicate_adds_are_idempotent() {
let mut h = HyperLogLog::new();
for _ in 0..100 {
h.add(b"same");
}
let v = h.value();
assert!(v <= 2, "duplicate-adds estimate {v} should be ~1");
}
#[test]
fn small_distinct_set_estimates_correctly() {
let mut h = HyperLogLog::new();
for i in 0u32..1000 {
h.add(i.to_be_bytes());
}
let v = h.value();
assert!(
(900..=1100).contains(&v),
"estimate {v} outside +/-10% of 1000"
);
}
#[test]
fn merge_is_elementwise_max() {
let mut a = HyperLogLog::new();
let mut b = HyperLogLog::new();
a.registers[0] = 5;
a.registers[1] = 2;
b.registers[0] = 3;
b.registers[1] = 7;
a.merge(&b);
assert_eq!(a.registers[0], 5);
assert_eq!(a.registers[1], 7);
}
#[test]
fn merge_is_commutative() {
let mut a = HyperLogLog::new();
let mut b = HyperLogLog::new();
for i in 0u32..200 {
a.add(i.to_be_bytes());
}
for i in 100u32..300 {
b.add(i.to_be_bytes());
}
let mut left = a.clone();
left.merge(&b);
let mut right = b.clone();
right.merge(&a);
assert_eq!(left.registers, right.registers);
}
#[test]
fn merge_is_idempotent() {
let mut a = HyperLogLog::new();
for i in 0u32..500 {
a.add(i.to_be_bytes());
}
let snap = a.clone();
a.merge(&snap);
assert_eq!(a.registers, snap.registers);
}
}