use super::*;
use crate::Hash;
fn h(b: u8) -> Hash {
Hash::from([b; 32])
}
fn key(i: u32) -> Hash {
Hash::new(i.to_le_bytes())
}
const EQUIV_COLS: usize = 4096;
struct SingleSketch {
inner: CountMinSketch,
seen: u64,
window: u64,
}
impl SingleSketch {
fn new(cols: usize) -> Self {
Self {
inner: CountMinSketch::new(cols),
seen: 0,
window: (cols as u64).saturating_mul(AGING_WINDOW_PER_COL),
}
}
fn increment(&mut self, k: &Hash) {
self.inner.increment(k);
self.seen = self.seen.saturating_add(1);
if self.seen >= self.window {
self.inner.halve(1);
self.seen = 0;
}
}
fn estimate(&self, k: &Hash) -> u8 {
self.inner.estimate(k)
}
}
fn drive_skewed(keys: &[Hash], observations: usize, mut sight: impl FnMut(&Hash)) {
let cold = keys.len().saturating_sub(1).max(1);
for n in 0..observations {
let k = if n % 5 == 0 {
keys.get(1 + (n / 5) % cold)
} else {
keys.first()
};
if let Some(k) = k {
sight(k);
}
}
}
#[test]
fn estimate_rises_with_increments() {
let mut s = CountMinSketch::new(256);
for _ in 0..5 {
s.increment(&h(7));
}
assert!(s.estimate(&h(7)) >= 5);
assert_eq!(s.estimate(&h(9)), 0); }
#[test]
fn halve_ages_counters_and_saturates_to_zero() {
let mut s = CountMinSketch::new(4);
for _ in 0..100 {
s.increment(&h(1));
}
let before = s.estimate(&h(1));
s.halve(1);
assert_eq!(
s.estimate(&h(1)),
before / 2,
"one halving is one right shift"
);
s.halve(0);
assert_eq!(
s.estimate(&h(1)),
before / 2,
"a zero-window catch-up is inert"
);
s.halve(u32::MAX);
assert_eq!(
s.estimate(&h(1)),
0,
"a catch-up past the counter width drains it rather than overflowing the shift"
);
}
#[test]
fn sharded_estimate_rises_with_increments() {
let s = ShardedCountMinSketch::new(256);
for _ in 0..5 {
s.increment(&key(7));
}
assert!(s.estimate(&key(7)) >= 5);
assert_eq!(s.estimate(&key(9)), 0); }
#[test]
fn aging_is_driven_by_node_wide_traffic_not_the_shard_s_own() -> anyhow::Result<()> {
let s = ShardedCountMinSketch::new(64); let hot = key(1);
for _ in 0..40 {
s.increment(&hot);
}
let before = s.estimate(&hot);
assert!(
before >= 40,
"the hot key must be counted before it is aged"
);
let Some(hot_shard) = s.shard_of(&hot) else {
anyhow::bail!("a non-empty sketch must route every key to a shard");
};
let others: Vec<Hash> = (2u32..4096)
.map(key)
.filter(|k| s.shard_of(k).is_some_and(|o| !std::ptr::eq(o, hot_shard)))
.take(64)
.collect();
assert!(
!others.is_empty(),
"some key must route outside the hot shard"
);
let window = usize::try_from(s.window)?;
for n in 0..window {
if let Some(k) = others.get(n % others.len()) {
s.increment(k);
}
}
assert!(
s.estimate(&hot) < before,
"a node-wide aging window must halve an untouched shard: {before} -> {}",
s.estimate(&hot)
);
Ok(())
}
#[test]
fn a_shard_cold_for_many_windows_catches_up_by_all_of_them() -> anyhow::Result<()> {
let hot = key(1);
let drive = |windows: usize| -> anyhow::Result<u8> {
let s = ShardedCountMinSketch::new(64); let window = usize::try_from(s.window)?;
for _ in 0..40 {
s.increment(&hot);
}
let Some(hot_shard) = s.shard_of(&hot) else {
anyhow::bail!("a non-empty sketch must route every key to a shard");
};
let others: Vec<Hash> = (2u32..4096)
.map(key)
.filter(|k| s.shard_of(k).is_some_and(|o| !std::ptr::eq(o, hot_shard)))
.take(64)
.collect();
anyhow::ensure!(
!others.is_empty(),
"some key must route outside the hot shard"
);
for n in 0..window.saturating_mul(windows) {
if let Some(k) = others.get(n % others.len()) {
s.increment(k);
}
}
Ok(s.estimate(&hot))
};
let one = drive(1)?;
anyhow::ensure!(
one == 20,
"one window halves 40 once: expected 20, got {one}"
);
let two = drive(2)?;
anyhow::ensure!(
two == 10,
"two windows halve 40 twice: expected 10, got {two}"
);
let many = drive(8)?;
anyhow::ensure!(many == 0, "eight windows drain 40 to zero, got {many}");
Ok(())
}
#[test]
fn sharded_matches_the_single_sketch_across_aging_windows() {
let keys: Vec<Hash> = (0..200).map(key).collect();
let mut single = SingleSketch::new(EQUIV_COLS);
let sharded = ShardedCountMinSketch::new(EQUIV_COLS);
drive_skewed(&keys, 200_000, |k| {
single.increment(k);
sharded.increment(k);
});
let mismatched = keys
.iter()
.filter(|k| sharded.estimate(k) != single.estimate(k))
.count();
assert!(
mismatched <= 5,
"sharded and single-sketch estimates diverged on {mismatched} of {} keys",
keys.len()
);
}
#[test]
fn sharded_preserves_cross_shard_ordering_under_skew() {
let keys: Vec<Hash> = (0..200).map(key).collect();
let mut single = SingleSketch::new(EQUIV_COLS);
let sharded = ShardedCountMinSketch::new(EQUIV_COLS);
drive_skewed(&keys, 200_000, |k| {
single.increment(k);
sharded.increment(k);
});
let mut compared = 0usize;
let mut inverted = 0usize;
for (i, a) in keys.iter().enumerate() {
for b in keys.iter().skip(i + 1) {
let (sa, sb) = (single.estimate(a), single.estimate(b));
if sa == sb {
continue; }
compared += 1;
if (sa > sb) != (sharded.estimate(a) > sharded.estimate(b)) {
inverted += 1;
}
}
}
assert!(compared > 1000, "the load must produce a rankable spread");
assert!(
inverted * 100 <= compared,
"{inverted} of {compared} ranked pairs invert against the single sketch"
);
}
#[test]
fn shards_hold_disjoint_keys() {
let s = ShardedCountMinSketch::new(EQUIV_COLS);
for _ in 0..40 {
s.increment(&key(1));
}
let cold_and_zero = (2u32..64).filter(|i| s.estimate(&key(*i)) == 0).count();
assert!(
cold_and_zero >= 60,
"increments of one key must not raise unrelated keys, {cold_and_zero} of 62 stayed 0"
);
}
#[test]
fn increment_does_not_wait_on_another_shard() -> anyhow::Result<()> {
use std::sync::mpsc;
use std::time::Duration;
let sketch = std::sync::Arc::new(ShardedCountMinSketch::new(EQUIV_COLS));
let pinned = key(1);
let Some(shard) = sketch.shard_of(&pinned) else {
anyhow::bail!("a non-empty sketch must route every key to a shard");
};
let held = shard.lock().unwrap_or_else(PoisonError::into_inner);
let elsewhere = (2u32..1024)
.map(key)
.find(|k| sketch.shard_of(k).is_some_and(|s| !std::ptr::eq(s, shard)));
let Some(elsewhere) = elsewhere else {
anyhow::bail!("no candidate key routed outside the pinned shard");
};
let (tx, rx) = mpsc::channel::<()>();
let toucher = {
let sketch = std::sync::Arc::clone(&sketch);
std::thread::spawn(move || {
sketch.increment(&elsewhere);
let _ = sketch.estimate(&elsewhere);
let _ = tx.send(());
})
};
let landed = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(held);
let joined = toucher.join().is_ok();
anyhow::ensure!(landed, "a sighting waited on a lock held for another shard");
anyhow::ensure!(joined, "the touching thread panicked");
Ok(())
}
#[test]
fn a_sketch_narrower_than_its_shard_count_degenerates() -> anyhow::Result<()> {
let s = ShardedCountMinSketch::new(SHARDS);
let hot = key(1);
let Some(shard) = s.shard_of(&hot) else {
anyhow::bail!("a non-empty sketch must route every key to a shard");
};
let Some(colliding) = (2u32..4096)
.map(key)
.find(|k| s.shard_of(k).is_some_and(|o| std::ptr::eq(o, shard)))
else {
anyhow::bail!("some key must share a shard with the hot key");
};
for _ in 0..5 {
s.increment(&hot);
}
assert_eq!(
s.estimate(&colliding),
s.estimate(&hot),
"at one column per shard a shard-mate is indistinguishable from the hot key"
);
Ok(())
}