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(
124 path: impl AsRef<Path>, d: u32, w: u32,
125 ) -> Result<Self, CMSError> {
126 if d == 0 || w == 0 {
127 return Err(CMSError::InvalidConfig);
128 }
129 let total = cms_file_size(d, w);
130 let file = OpenOptions::new()
131 .read(true).write(true).create(true).truncate(true)
132 .open(path.as_ref())?;
133 file.set_len(total as u64)?;
134 let mut mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
135 let hdr = mmap.as_mut_ptr() as *mut CMSHeader;
136 unsafe {
137 std::ptr::write_bytes(hdr as *mut u8, 0, size_of::<CMSHeader>());
138 (*hdr).magic = CMS_MAGIC;
139 (*hdr).d = d;
140 (*hdr).w = w;
141 }
142 Ok(Self {
143 _file: file, mmap, d, w,
144 header_sidecar: subetha_core::HandshakeHeader::new(),
145 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
146 })
147 }
148
149 pub fn open(
150 path: impl AsRef<Path>, expected_d: u32, expected_w: u32,
151 ) -> Result<Self, CMSError> {
152 let total = cms_file_size(expected_d, expected_w);
153 let file = OpenOptions::new().read(true).write(true).open(path.as_ref())?;
154 if file.metadata()?.len() < total as u64 {
155 return Err(CMSError::LayoutMismatch);
156 }
157 let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
158 let hdr = unsafe { &*(mmap.as_ptr() as *const CMSHeader) };
159 if hdr.magic != CMS_MAGIC || hdr.d != expected_d || hdr.w != expected_w {
160 return Err(CMSError::LayoutMismatch);
161 }
162 Ok(Self {
163 _file: file, mmap, d: expected_d, w: expected_w,
164 header_sidecar: subetha_core::HandshakeHeader::new(),
165 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
166 })
167 }
168
169 #[inline]
170 pub fn d(&self) -> u32 { self.d }
171 #[inline]
172 pub fn w(&self) -> u32 { self.w }
173 #[inline]
174 pub fn total_inserts(&self) -> u64 {
175 unsafe { (*(self.mmap.as_ptr() as *const CMSHeader)).total_inserts.load(Ordering::Acquire) }
176 }
177
178 fn cell(&self, row: u32, col: u32) -> &AtomicU64 {
179 let idx = (row as usize) * (self.w as usize) + (col as usize);
180 let base = unsafe { self.mmap.as_ptr().add(size_of::<CMSHeader>()) };
181 unsafe { &*(base.add(idx * size_of::<AtomicU64>()) as *const AtomicU64) }
182 }
183
184 #[inline]
190 fn for_each_position(&self, item: &[u8], mut f: impl FnMut(u32, u32)) {
191 let h1 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_1));
192 let h2 = fmix64(fnv1a(item, FNV_OFFSET_BASIS_2));
193 let w = self.w as u64;
194 let pow2 = self.w.is_power_of_two();
195 let mask = w.wrapping_sub(1);
196 for row in 0..self.d {
197 let raw = h1.wrapping_add((row as u64).wrapping_mul(h2));
198 let col = if pow2 {
199 (raw & mask) as u32
200 } else {
201 ((raw as u128 * w as u128) >> 64) as u32
205 };
206 f(row, col);
207 }
208 }
209
210 pub fn insert(&self, item: &[u8]) {
212 self.for_each_position(item, |row, col| {
213 self.cell(row, col).fetch_add(1, Ordering::AcqRel);
214 });
215 unsafe {
216 (*(self.mmap.as_ptr() as *const CMSHeader))
217 .total_inserts.fetch_add(1, Ordering::AcqRel);
218 }
219 self.ring_sidecar
220 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
221 }
222
223 pub fn insert_n(&self, item: &[u8], count: u64) {
225 if count == 0 { return; }
226 self.for_each_position(item, |row, col| {
227 self.cell(row, col).fetch_add(count, Ordering::AcqRel);
228 });
229 unsafe {
230 (*(self.mmap.as_ptr() as *const CMSHeader))
231 .total_inserts.fetch_add(count, Ordering::AcqRel);
232 }
233 self.ring_sidecar
234 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
235 }
236
237 pub fn estimate_count(&self, item: &[u8]) -> u64 {
240 let mut v = u64::MAX;
241 self.for_each_position(item, |row, col| {
242 let c = self.cell(row, col).load(Ordering::Acquire);
243 if c < v { v = c; }
244 });
245 let v = if self.d == 0 { 0 } else { v };
246 self.ring_sidecar.push_op(
247 crate::sidecar_ops::sketch::OP_QUERY,
248 if v == 0 { 2 } else { 0 },
249 );
250 v
251 }
252
253 pub fn reset(&self) {
255 for row in 0..self.d {
256 for col in 0..self.w {
257 self.cell(row, col).store(0, Ordering::Release);
258 }
259 }
260 unsafe {
261 (*(self.mmap.as_ptr() as *const CMSHeader))
262 .total_inserts.store(0, Ordering::Release);
263 }
264 self.ring_sidecar
265 .push_op(crate::sidecar_ops::sketch::OP_CLEAR, 0);
266 }
267
268 pub fn flush(&self) -> Result<(), CMSError> {
269 self.mmap.flush()?;
270 Ok(())
271 }
272 pub fn flush_async(&self) -> Result<(), CMSError> {
273 self.mmap.flush_async()?;
274 Ok(())
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::sync::Arc;
282 use std::thread;
283
284 fn tmp(name: &str) -> std::path::PathBuf {
285 let mut p = std::env::temp_dir();
286 let pid = std::process::id();
287 p.push(format!("subetha-cms-{name}-{pid}.bin"));
288 p
289 }
290
291 #[test]
292 fn create_initial_state_zero() {
293 let p = tmp("init");
294 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
295 assert_eq!(cms.d(), 4);
296 assert_eq!(cms.w(), 256);
297 assert_eq!(cms.total_inserts(), 0);
298 assert_eq!(cms.estimate_count(b"anything"), 0);
299 std::fs::remove_file(&p).ok();
300 }
301
302 #[test]
303 fn invalid_config_rejected() {
304 let p = tmp("invalid");
305 assert_eq!(
306 SharedCountMinSketch::create(&p, 0, 256).err(),
307 Some(CMSError::InvalidConfig)
308 );
309 assert_eq!(
310 SharedCountMinSketch::create(&p, 4, 0).err(),
311 Some(CMSError::InvalidConfig)
312 );
313 std::fs::remove_file(&p).ok();
314 }
315
316 #[test]
317 fn insert_then_estimate_returns_at_least_true_count() {
318 let p = tmp("insert");
319 let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
320 for _ in 0..5 { cms.insert(b"foo"); }
321 for _ in 0..3 { cms.insert(b"bar"); }
322 assert!(cms.estimate_count(b"foo") >= 5);
324 assert!(cms.estimate_count(b"bar") >= 3);
325 assert_eq!(cms.total_inserts(), 8);
326 std::fs::remove_file(&p).ok();
327 }
328
329 #[test]
330 fn insert_n_is_equivalent_to_n_inserts() {
331 let p = tmp("insert-n");
332 let a = SharedCountMinSketch::create(tmp("insert-n-a"), 4, 1024).unwrap();
333 let b = SharedCountMinSketch::create(tmp("insert-n-b"), 4, 1024).unwrap();
334 for _ in 0..100 { a.insert(b"item"); }
335 b.insert_n(b"item", 100);
336 assert_eq!(a.estimate_count(b"item"), b.estimate_count(b"item"));
337 assert_eq!(a.total_inserts(), b.total_inserts());
338 let _p = p;
339 std::fs::remove_file(tmp("insert-n-a")).ok();
340 std::fs::remove_file(tmp("insert-n-b")).ok();
341 }
342
343 #[test]
344 fn estimate_for_absent_item_is_low() {
345 let p = tmp("absent");
349 let cms = SharedCountMinSketch::create(&p, 4, 1024).unwrap();
350 for i in 0..100u32 {
351 cms.insert(format!("inserted-{i}").as_bytes());
352 }
353 let mut fp = 0u32;
355 for i in 0..100u32 {
356 if cms.estimate_count(format!("absent-{i}").as_bytes()) > 0 {
357 fp += 1;
358 }
359 }
360 assert!(fp < 10, "expected < 10 false positives, got {fp}");
362 std::fs::remove_file(&p).ok();
363 }
364
365 #[test]
366 fn heavy_hitter_detection() {
367 let p = tmp("heavy");
370 let cms = SharedCountMinSketch::create(&p, 5, 2048).unwrap();
371 for _ in 0..10_000 { cms.insert(b"HEAVY"); }
372 for i in 0..1000u32 {
373 cms.insert(format!("light-{i}").as_bytes());
374 }
375 let heavy = cms.estimate_count(b"HEAVY");
376 assert!(heavy >= 10_000);
379 assert!(heavy <= 10_100, "heavy estimate {heavy} should be very close to 10000");
380 for i in 0..10u32 {
382 let light = cms.estimate_count(format!("light-{i}").as_bytes());
383 assert!(light < 20, "light item {i} estimate {light} should be small");
384 }
385 std::fs::remove_file(&p).ok();
386 }
387
388 #[test]
389 fn reset_zeroes_everything() {
390 let p = tmp("reset");
391 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
392 for _ in 0..50 { cms.insert(b"x"); }
393 assert!(cms.estimate_count(b"x") >= 50);
394 cms.reset();
395 assert_eq!(cms.estimate_count(b"x"), 0);
396 assert_eq!(cms.total_inserts(), 0);
397 std::fs::remove_file(&p).ok();
398 }
399
400 #[test]
401 fn cross_handle_visibility() {
402 let p = tmp("cross-handle");
403 let w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
404 let r = SharedCountMinSketch::open(&p, 4, 256).unwrap();
405 w.insert(b"shared");
406 w.insert(b"shared");
407 assert!(r.estimate_count(b"shared") >= 2);
408 assert_eq!(r.total_inserts(), 2);
409 std::fs::remove_file(&p).ok();
410 }
411
412 #[test]
413 fn config_mismatch_at_open_rejected() {
414 let p = tmp("mismatch");
415 let _w = SharedCountMinSketch::create(&p, 4, 256).unwrap();
416 assert!(matches!(
417 SharedCountMinSketch::open(&p, 5, 256),
418 Err(CMSError::LayoutMismatch)
419 ));
420 assert!(matches!(
421 SharedCountMinSketch::open(&p, 4, 512),
422 Err(CMSError::LayoutMismatch)
423 ));
424 std::fs::remove_file(&p).ok();
425 }
426
427 #[test]
428 fn suggest_config_returns_sensible_values() {
429 let (d, w) = SharedCountMinSketch::suggest_config(0.01, 0.01);
431 assert!((250..=300).contains(&w));
432 assert!((4..=6).contains(&d));
433 }
434
435 #[test]
436 fn concurrent_inserters_accurate() {
437 let p = tmp("concurrent");
440 let cms = Arc::new(SharedCountMinSketch::create(&p, 4, 1024).unwrap());
441 let mut handles = vec![];
442 for _ in 0..4 {
443 let cms = cms.clone();
444 handles.push(thread::spawn(move || {
445 for _ in 0..1000 { cms.insert(b"shared-item"); }
446 }));
447 }
448 for h in handles { h.join().unwrap(); }
449 let est = cms.estimate_count(b"shared-item");
450 assert!(est >= 4000, "concurrent estimate {est} should be >= 4000");
451 assert_eq!(cms.total_inserts(), 4000);
452 std::fs::remove_file(&p).ok();
453 }
454
455 #[test]
456 fn disk_persistence_survives_reopen() {
457 let p = tmp("disk");
458 {
459 let cms = SharedCountMinSketch::create(&p, 4, 256).unwrap();
460 for _ in 0..50 { cms.insert(b"persisted"); }
461 cms.flush().unwrap();
462 }
463 let cms2 = SharedCountMinSketch::open(&p, 4, 256).unwrap();
464 assert!(cms2.estimate_count(b"persisted") >= 50);
465 assert_eq!(cms2.total_inserts(), 50);
466 std::fs::remove_file(&p).ok();
467 }
468}