1use std::fs::{File, OpenOptions};
32use std::mem::size_of;
33use std::path::Path;
34use std::sync::atomic::{AtomicU64, Ordering};
35
36use memmap2::{MmapMut, MmapOptions};
37
38pub const CMS_MAGIC: u64 = 0x4150_434D_5330_3031;
39
40#[repr(C, align(64))]
41pub struct CMSHeader {
42 pub magic: u64,
43 pub d: u32,
44 pub w: u32,
45 pub total_inserts: AtomicU64,
46 _pad: [u8; 40],
47}
48
49const _: () = {
50 assert!(size_of::<CMSHeader>() == 64);
51};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum CMSError {
55 InvalidConfig,
56 LayoutMismatch,
57 IoError(std::io::ErrorKind),
58}
59
60impl From<std::io::Error> for CMSError {
61 fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
62}
63
64pub fn cms_file_size(d: u32, w: u32) -> usize {
65 size_of::<CMSHeader>() + (d as usize) * (w as usize) * size_of::<AtomicU64>()
66}
67
68const FNV_OFFSET_BASIS_1: u64 = 0xcbf2_9ce4_8422_2325;
69const FNV_OFFSET_BASIS_2: u64 = 0x8422_2325_cbf2_9ce4;
70const FNV_PRIME: u64 = 0x100_0000_01b3;
71
72#[inline]
73fn fnv1a(bytes: &[u8], basis: u64) -> u64 {
74 let mut h = basis;
75 for &b in bytes {
76 h ^= b as u64;
77 h = h.wrapping_mul(FNV_PRIME);
78 }
79 h
80}
81
82#[inline]
83fn fmix64(mut h: u64) -> u64 {
84 h ^= h >> 33;
85 h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
86 h ^= h >> 33;
87 h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
88 h ^= h >> 33;
89 h
90}
91
92pub struct SharedCountMinSketch {
93 _file: File,
94 mmap: MmapMut,
95 d: u32,
96 w: u32,
97 header_sidecar: subetha_core::HandshakeHeader,
98 ring_sidecar: Box<subetha_core::ObservationRing>,
99}
100
101unsafe impl Send for SharedCountMinSketch {}
102unsafe impl Sync for SharedCountMinSketch {}
103
104impl subetha_sidecar::AdaptiveInstance for SharedCountMinSketch {
105 fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
106 fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
107 fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
108 Box::new(subetha_sidecar::NoMigrationPolicy)
109 }
110}
111
112impl SharedCountMinSketch {
113 pub fn suggest_config(epsilon: f64, delta: f64) -> (u32, u32) {
116 assert!(epsilon > 0.0 && epsilon < 1.0);
117 assert!(delta > 0.0 && delta < 1.0);
118 let w = (std::f64::consts::E / epsilon).ceil() as u32;
119 let d = (1.0 / delta).ln().ceil() as u32;
120 (d.max(1), w.max(1))
121 }
122
123 pub fn create(
129 path: impl AsRef<Path>, d: u32, w: u32,
130 ) -> Result<Self, CMSError> {
131 if d == 0 || w == 0 {
132 return Err(CMSError::InvalidConfig);
133 }
134 let (file, mmap) = crate::mmf_attach::create_or_attach(
135 path.as_ref(),
136 cms_file_size(d, w),
137 |ptr| unsafe { Self::init_region(ptr, d, w) },
138 |ptr| unsafe { (*(ptr as *const CMSHeader)).magic == CMS_MAGIC },
139 )?;
140 Self::from_region(file, mmap, d, w)
141 }
142
143 unsafe fn init_region(ptr: *mut u8, d: u32, w: u32) {
151 let hdr = ptr as *mut CMSHeader;
152 unsafe {
153 (*hdr).d = d;
154 (*hdr).w = w;
155 std::ptr::write_volatile(&raw mut (*hdr).magic, CMS_MAGIC);
156 }
157 }
158
159 fn from_region(file: File, mmap: MmapMut, d: u32, w: u32) -> Result<Self, CMSError> {
162 let hdr = unsafe { &*(mmap.as_ptr() as *const CMSHeader) };
163 if hdr.magic != CMS_MAGIC || hdr.d != d || hdr.w != w {
164 return Err(CMSError::LayoutMismatch);
165 }
166 Ok(Self {
167 _file: file, mmap, d, w,
168 header_sidecar: subetha_core::HandshakeHeader::new(),
169 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
170 })
171 }
172
173 pub fn open(
174 path: impl AsRef<Path>, expected_d: u32, expected_w: u32,
175 ) -> Result<Self, CMSError> {
176 let total = cms_file_size(expected_d, expected_w);
177 let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
178 if file.metadata()?.len() < total as u64 {
179 return Err(CMSError::LayoutMismatch);
180 }
181 let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
182 Self::from_region(file, mmap, expected_d, expected_w)
183 }
184
185 #[inline]
186 pub fn d(&self) -> u32 { self.d }
187 #[inline]
188 pub fn w(&self) -> u32 { self.w }
189 #[inline]
190 pub fn total_inserts(&self) -> u64 {
191 unsafe { (*(self.mmap.as_ptr() as *const CMSHeader)).total_inserts.load(Ordering::Acquire) }
192 }
193
194 fn cell(&self, row: u32, col: u32) -> &AtomicU64 {
195 let idx = (row as usize) * (self.w as usize) + (col as usize);
196 let base = unsafe { self.mmap.as_ptr().add(size_of::<CMSHeader>()) };
197 unsafe { &*(base.add(idx * size_of::<AtomicU64>()) as *const AtomicU64) }
198 }
199
200 #[inline]
206 fn for_each_position(&self, item: &[u8], mut f: impl FnMut(u32, u32)) {
207 let h1 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_1));
208 let h2 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_2));
209 let w = self.w as u64;
210 let pow2 = self.w.is_power_of_two();
211 let mask = w.wrapping_sub(1);
212 for row in 0..self.d {
213 let raw = h1.wrapping_add((row as u64).wrapping_mul(h2));
214 let col = if pow2 {
215 (raw & mask) as u32
216 } else {
217 ((raw as u128 * w as u128) >> 64) as u32
221 };
222 f(row, col);
223 }
224 }
225
226 pub fn insert(&self, item: &[u8]) {
228 self.for_each_position(item, |row, col| {
229 self.cell(row, col).fetch_add(1, Ordering::AcqRel);
230 });
231 unsafe {
232 (*(self.mmap.as_ptr() as *const CMSHeader))
233 .total_inserts.fetch_add(1, Ordering::AcqRel);
234 }
235 self.ring_sidecar
236 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
237 }
238
239 pub fn insert_n(&self, item: &[u8], count: u64) {
241 if count == 0 { return; }
242 self.for_each_position(item, |row, col| {
243 self.cell(row, col).fetch_add(count, Ordering::AcqRel);
244 });
245 unsafe {
246 (*(self.mmap.as_ptr() as *const CMSHeader))
247 .total_inserts.fetch_add(count, Ordering::AcqRel);
248 }
249 self.ring_sidecar
250 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
251 }
252
253 pub fn estimate_count(&self, item: &[u8]) -> u64 {
256 let mut v = u64::MAX;
257 self.for_each_position(item, |row, col| {
258 let c = self.cell(row, col).load(Ordering::Acquire);
259 if c < v { v = c; }
260 });
261 let v = if self.d == 0 { 0 } else { v };
262 self.ring_sidecar.push_op(
263 crate::sidecar_ops::sketch::OP_QUERY,
264 if v == 0 { 2 } else { 0 },
265 );
266 v
267 }
268
269 pub fn reset(&self) {
271 for row in 0..self.d {
272 for col in 0..self.w {
273 self.cell(row, col).store(0, Ordering::Release);
274 }
275 }
276 unsafe {
277 (*(self.mmap.as_ptr() as *const CMSHeader))
278 .total_inserts.store(0, Ordering::Release);
279 }
280 self.ring_sidecar
281 .push_op(crate::sidecar_ops::sketch::OP_CLEAR, 0);
282 }
283
284 pub fn flush(&self) -> Result<(), CMSError> {
285 self.mmap.flush()?;
286 Ok(())
287 }
288 pub fn flush_async(&self) -> Result<(), CMSError> {
289 self.mmap.flush_async()?;
290 Ok(())
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use std::sync::Arc;
298 use std::thread;
299
300 fn tmp(name: &str) -> std::path::PathBuf {
301 let mut p = std::env::temp_dir();
302 let pid = std::process::id();
303 p.push(format!("subetha-cms-{name}-{pid}.bin"));
304 p
305 }
306
307 #[test]
308 fn create_initial_state_zero() {
309 let p = tmp("init");
310 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
311 assert_eq!(cms.d(), 4);
312 assert_eq!(cms.w(), 256);
313 assert_eq!(cms.total_inserts(), 0);
314 assert_eq!(cms.estimate_count(b"anything"), 0);
315 std::fs::remove_file(&p).ok();
316 }
317
318 #[test]
321 fn second_create_attaches_and_keeps_counts() {
322 let p = tmp("attach");
323 std::fs::remove_file(&p).ok();
324 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
325 cms.insert(b"key");
326 cms.insert(b"key");
327
328 let cms2 = SharedCountMinSketch::create(&p, 4, 256).unwrap();
329 assert_eq!(cms2.estimate_count(b"key"), 2, "attach zeroed live counts");
330 assert!(matches!(
331 SharedCountMinSketch::create(&p, 2, 256),
332 Err(CMSError::LayoutMismatch),
333 ));
334
335 cms2.reset();
336 assert_eq!(cms.estimate_count(b"key"), 0, "reset did not zero for every handle");
337 drop(cms);
338 drop(cms2);
339 std::fs::remove_file(&p).ok();
340 }
341
342 #[test]
343 fn invalid_config_rejected() {
344 let p = tmp("invalid");
345 assert_eq!(
346 SharedCountMinSketch::create(&p, 0, 256).err(),
347 Some(CMSError::InvalidConfig)
348 );
349 assert_eq!(
350 SharedCountMinSketch::create(&p, 4, 0).err(),
351 Some(CMSError::InvalidConfig)
352 );
353 std::fs::remove_file(&p).ok();
354 }
355
356 #[test]
357 fn insert_then_estimate_returns_at_least_true_count() {
358 let p = tmp("insert");
359 let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
360 for _ in 0..5 { cms.insert(b"foo"); }
361 for _ in 0..3 { cms.insert(b"bar"); }
362 assert!(cms.estimate_count(b"foo") >= 5);
364 assert!(cms.estimate_count(b"bar") >= 3);
365 assert_eq!(cms.total_inserts(), 8);
366 std::fs::remove_file(&p).ok();
367 }
368
369 #[test]
370 fn insert_n_is_equivalent_to_n_inserts() {
371 let p = tmp("insert-n");
372 let a = SharedCountMinSketch::create(tmp("insert-n-a"), 4, 1024).unwrap();
373 let b = SharedCountMinSketch::create(tmp("insert-n-b"), 4, 1024).unwrap();
374 for _ in 0..100 { a.insert(b"item"); }
375 b.insert_n(b"item", 100);
376 assert_eq!(a.estimate_count(b"item"), b.estimate_count(b"item"));
377 assert_eq!(a.total_inserts(), b.total_inserts());
378 let _p = p;
379 std::fs::remove_file(tmp("insert-n-a")).ok();
380 std::fs::remove_file(tmp("insert-n-b")).ok();
381 }
382
383 #[test]
384 fn estimate_for_absent_item_is_low() {
385 let p = tmp("absent");
389 let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
390 for i in 0..100u32 {
391 cms.insert(format!("inserted-{i}").as_bytes());
392 }
393 let mut fp = 0u32;
395 for i in 0..100u32 {
396 if cms.estimate_count(format!("absent-{i}").as_bytes()) > 0 {
397 fp += 1;
398 }
399 }
400 assert!(fp < 10, "expected < 10 false positives, got {fp}");
402 std::fs::remove_file(&p).ok();
403 }
404
405 #[test]
406 fn heavy_hitter_detection() {
407 let p = tmp("heavy");
410 let cms = SharedCountMinSketch::create(&p, 5, 2048).unwrap();
411 for _ in 0..10_000 { cms.insert(b"HEAVY"); }
412 for i in 0..1000u32 {
413 cms.insert(format!("light-{i}").as_bytes());
414 }
415 let heavy = cms.estimate_count(b"HEAVY");
416 assert!(heavy >= 10_000);
419 assert!(heavy <= 10_100, "heavy estimate {heavy} should be very close to 10000");
420 for i in 0..10u32 {
422 let light = cms.estimate_count(format!("light-{i}").as_bytes());
423 assert!(light < 20, "light item {i} estimate {light} should be small");
424 }
425 std::fs::remove_file(&p).ok();
426 }
427
428 #[test]
429 fn reset_zeroes_everything() {
430 let p = tmp("reset");
431 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
432 for _ in 0..50 { cms.insert(b"x"); }
433 assert!(cms.estimate_count(b"x") >= 50);
434 cms.reset();
435 assert_eq!(cms.estimate_count(b"x"), 0);
436 assert_eq!(cms.total_inserts(), 0);
437 std::fs::remove_file(&p).ok();
438 }
439
440 #[test]
441 fn cross_handle_visibility() {
442 let p = tmp("cross-handle");
443 let w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
444 let r = SharedCountMinSketch::open(&p, 4, 256).unwrap();
445 w.insert(b"shared");
446 w.insert(b"shared");
447 assert!(r.estimate_count(b"shared") >= 2);
448 assert_eq!(r.total_inserts(), 2);
449 std::fs::remove_file(&p).ok();
450 }
451
452 #[test]
453 fn config_mismatch_at_open_rejected() {
454 let p = tmp("mismatch");
455 let _w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
456 assert!(matches!(
457 SharedCountMinSketch::open(&p, 5, 256),
458 Err(CMSError::LayoutMismatch)
459 ));
460 assert!(matches!(
461 SharedCountMinSketch::open(&p, 4, 512),
462 Err(CMSError::LayoutMismatch)
463 ));
464 std::fs::remove_file(&p).ok();
465 }
466
467 #[test]
468 fn suggest_config_returns_sensible_values() {
469 let (d, w) = SharedCountMinSketch::suggest_config(0.01, 0.01);
471 assert!((250..=300).contains(&w));
472 assert!((4..=6).contains(&d));
473 }
474
475 #[test]
476 fn concurrent_inserters_accurate() {
477 let p = tmp("concurrent");
480 let cms = Arc::new(SharedCountMinSketch::create(&p, 4, 1024).unwrap());
481 let mut handles = vec![];
482 for _ in 0..4 {
483 let cms = cms.clone();
484 handles.push(thread::spawn(move || {
485 for _ in 0..1000 { cms.insert(b"shared-item"); }
486 }));
487 }
488 for h in handles { h.join().unwrap(); }
489 let est = cms.estimate_count(b"shared-item");
490 assert!(est >= 4000, "concurrent estimate {est} should be >= 4000");
491 assert_eq!(cms.total_inserts(), 4000);
492 std::fs::remove_file(&p).ok();
493 }
494
495 #[test]
496 fn disk_persistence_survives_reopen() {
497 let p = tmp("disk");
498 {
499 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
500 for _ in 0..50 { cms.insert(b"persisted"); }
501 cms.flush().unwrap();
502 }
503 let cms2 = SharedCountMinSketch::open(&p, 4, 256).unwrap();
504 assert!(cms2.estimate_count(b"persisted") >= 50);
505 assert_eq!(cms2.total_inserts(), 50);
506 std::fs::remove_file(&p).ok();
507 }
508}