Skip to main content

moonpool_explorer/
each_buckets.rs

1//! Per-value bucketed exploration infrastructure for `assert_sometimes_each!`.
2//!
3//! Each unique combination of identity key values creates one bucket in shared
4//! memory. On first discovery, a fork is triggered. Optional quality watermarks
5//! allow re-forking when the packed quality score improves.
6//!
7//! # Memory Layout
8//!
9//! ```text
10//! [next_bucket: u32, _pad: u32, buckets: [EachBucket; MAX_EACH_BUCKETS]]
11//! ```
12//!
13//! The `next_bucket` counter is incremented atomically (via `AtomicU32::fetch_add`)
14//! to allocate new buckets safely across fork boundaries.
15
16use std::sync::atomic::{AtomicI64, AtomicU8, AtomicU32, Ordering};
17
18/// Maximum number of `EachBucket` slots in shared memory.
19pub const MAX_EACH_BUCKETS: usize = 256;
20
21/// Maximum number of identity keys per bucket.
22pub const MAX_EACH_KEYS: usize = 6;
23
24/// Maximum length of the assertion message stored in a bucket.
25const EACH_MSG_LEN: usize = 32;
26
27/// Total shared memory size for the `EachBucket` region.
28pub const EACH_BUCKET_MEM_SIZE: usize = 8 + MAX_EACH_BUCKETS * std::mem::size_of::<EachBucket>();
29
30/// One bucket's state in `MAP_SHARED` memory for per-value bucketed assertions.
31///
32/// Each unique combination of identity key values creates one bucket.
33/// Optional quality watermark (`has_quality != 0`): re-forks when `best_score` improves.
34#[repr(C)]
35#[derive(Clone, Copy)]
36pub struct EachBucket {
37    /// FNV-1a hash of the assertion message string.
38    pub site_hash: u32,
39    /// Hash of (`site_hash` + identity key values) — uniquely identifies this bucket.
40    pub bucket_hash: u32,
41    /// CAS guard: 0 = no fork yet, 1 = first fork triggered.
42    pub split_triggered: u8,
43    /// Number of identity keys stored in `key_values`.
44    pub num_keys: u8,
45    /// Number of quality keys (0-4). 0 means no quality tracking.
46    pub has_quality: u8,
47    /// Alignment padding.
48    pad: u8,
49    /// Number of times this bucket has been hit (atomic increment).
50    pub pass_count: u32,
51    /// Best quality watermark score (atomic CAS for improvement detection).
52    pub best_score: i64,
53    /// Identity key values for display/debugging.
54    pub key_values: [i64; MAX_EACH_KEYS],
55    /// Assertion message string (null-terminated C-style).
56    pub msg: [u8; EACH_MSG_LEN],
57}
58
59impl EachBucket {
60    /// Get the assertion message as a string slice.
61    #[must_use]
62    pub fn msg_str(&self) -> &str {
63        let len = self
64            .msg
65            .iter()
66            .position(|&b| b == 0)
67            .unwrap_or(EACH_MSG_LEN);
68        std::str::from_utf8(&self.msg[..len]).unwrap_or("???")
69    }
70}
71
72use crate::assertion_slots::msg_hash;
73
74/// Find an existing bucket or allocate a new one by (`site_hash`, `bucket_hash`).
75///
76/// Returns a pointer to the bucket, or null if the table is full.
77///
78/// # Safety
79///
80/// `ptr` must point to a valid `EachBucket` shared memory region of at least
81/// `EACH_BUCKET_MEM_SIZE` bytes.
82unsafe fn find_or_alloc_each_bucket(
83    ptr: *mut u8,
84    site_hash: u32,
85    bucket_hash: u32,
86    keys: &[(&str, i64)],
87    msg: &str,
88    has_quality: u8,
89) -> *mut EachBucket {
90    unsafe {
91        let next_atomic = &*ptr.cast::<()>().cast::<AtomicU32>();
92        let count = next_atomic.load(Ordering::Relaxed) as usize;
93        let base = ptr.add(8).cast::<()>().cast::<EachBucket>();
94
95        // Search existing buckets.
96        for i in 0..count.min(MAX_EACH_BUCKETS) {
97            let bucket = base.add(i);
98            if (*bucket).site_hash == site_hash && (*bucket).bucket_hash == bucket_hash {
99                return bucket;
100            }
101        }
102
103        // Allocate new bucket atomically.
104        let new_idx = next_atomic.fetch_add(1, Ordering::Relaxed) as usize;
105        if new_idx >= MAX_EACH_BUCKETS {
106            next_atomic.fetch_sub(1, Ordering::Relaxed);
107            return std::ptr::null_mut();
108        }
109
110        let bucket = base.add(new_idx);
111        let mut msg_buf = [0u8; EACH_MSG_LEN];
112        let n = msg.len().min(EACH_MSG_LEN - 1);
113        msg_buf[..n].copy_from_slice(&msg.as_bytes()[..n]);
114
115        let mut key_values = [0i64; MAX_EACH_KEYS];
116        let num_keys = keys.len().min(MAX_EACH_KEYS);
117        for (i, &(_, v)) in keys.iter().take(num_keys).enumerate() {
118            key_values[i] = v;
119        }
120
121        std::ptr::write(
122            bucket,
123            EachBucket {
124                site_hash,
125                bucket_hash,
126                split_triggered: 0,
127                num_keys: u8::try_from(num_keys).expect("num_keys capped at MAX_EACH_KEYS=6"),
128                has_quality,
129                pad: 0,
130                pass_count: 0,
131                best_score: i64::MIN,
132                key_values,
133                msg: msg_buf,
134            },
135        );
136        bucket
137    }
138}
139
140/// Compute the 0-based array index of a bucket from its pointer.
141fn compute_each_bucket_index(base_ptr: *mut u8, bucket: *const EachBucket) -> usize {
142    if base_ptr.is_null() {
143        return 0;
144    }
145    let buckets_base = unsafe { base_ptr.add(8) } as usize;
146    let offset = (bucket as usize).saturating_sub(buckets_base);
147    offset / std::mem::size_of::<EachBucket>()
148}
149
150/// Pack up to 4 quality key values into a single i64 for lexicographic comparison.
151///
152/// First key gets the highest 16 bits (highest priority).
153/// Values are reduced to their low 16 bits (matching `v as u16` semantics) —
154/// callers should pre-scale values into the u16 range if higher fidelity is needed.
155fn pack_quality(quality: &[(&str, i64)]) -> i64 {
156    let mut packed: i64 = 0;
157    let n = quality.len().min(4);
158    for (i, &(_, v)) in quality.iter().take(n).enumerate() {
159        let shift = (3 - i) * 16;
160        // Mask to low 16 bits (equivalent to `v as u16 as i64`, no sign loss).
161        packed |= (v & 0xffff) << shift;
162    }
163    packed
164}
165
166/// Unpack a quality i64 back into individual values for display.
167#[must_use]
168pub fn unpack_quality(packed: i64, n: u8) -> Vec<i64> {
169    (0..n as usize)
170        .map(|i| {
171            let shift = (3 - i) * 16;
172            // Extract the low 16 bits at the shifted position (mirrors pack_quality).
173            (packed >> shift) & 0xffff
174        })
175        .collect()
176}
177
178/// Backing function for per-value bucketed assertions.
179///
180/// Each unique combination of identity key values creates one bucket.
181/// Forks on first discovery. Optional quality keys re-fork when the packed
182/// quality score improves (CAS loop on `best_score`).
183///
184/// This is a no-op if `EachBucket` shared memory is not initialized.
185pub fn assertion_sometimes_each(msg: &str, keys: &[(&str, i64)], quality: &[(&str, i64)]) {
186    let ptr = crate::context::EACH_BUCKET_PTR.with(std::cell::Cell::get);
187    if ptr.is_null() {
188        return;
189    }
190
191    // Compute bucket hash: site_hash mixed with identity key values only via FNV-1a.
192    // Quality values are NOT included — they're watermarks, not identity keys.
193    let site_hash = msg_hash(msg);
194    let mut bucket_hash = site_hash;
195    for &(_, val) in keys {
196        for b in val.to_le_bytes() {
197            bucket_hash ^= u32::from(b);
198            bucket_hash = bucket_hash.wrapping_mul(0x0100_0193);
199        }
200    }
201
202    // Mark coverage bitmap for adaptive yield detection.
203    // Different identity key combinations produce different bucket_hash values,
204    // so the bitmap distinguishes e.g. floor-1 from floor-2 assertions.
205    let bm_ptr = crate::context::COVERAGE_BITMAP_PTR.with(std::cell::Cell::get);
206    if !bm_ptr.is_null() {
207        // Safety: bm_ptr is non-null (checked above) and was set to a valid
208        // alloc_shared() pointer of COVERAGE_MAP_SIZE bytes during init().
209        let bm = unsafe { crate::coverage::CoverageBitmap::new(bm_ptr) };
210        bm.set_bit(bucket_hash as usize);
211    }
212
213    // `min(4)` guarantees the value fits in u8, so the cast is lossless.
214    let has_quality = u8::try_from(quality.len().min(4)).unwrap_or(4);
215    let score = if has_quality > 0 {
216        pack_quality(quality)
217    } else {
218        0
219    };
220
221    // Safety: ptr was allocated during init() with EACH_BUCKET_MEM_SIZE bytes.
222    let bucket =
223        unsafe { find_or_alloc_each_bucket(ptr, site_hash, bucket_hash, keys, msg, has_quality) };
224    if bucket.is_null() {
225        return;
226    }
227
228    // Safety: bucket points to valid MAP_SHARED memory. Atomic operations are used
229    // for cross-fork safety (parent waits on child via waitpid, but atomics ensure
230    // correct visibility for recursive fork scenarios).
231    unsafe {
232        // Increment pass count.
233        let count_atomic = &*(&raw const (*bucket).pass_count).cast::<AtomicU32>();
234        count_atomic.fetch_add(1, Ordering::Relaxed);
235
236        // Fork on first discovery: CAS split_triggered from 0 → 1.
237        let ft = &*(&raw const (*bucket).split_triggered).cast::<AtomicU8>();
238        let first_discovery = ft
239            .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
240            .is_ok();
241
242        if first_discovery {
243            // On first discovery, initialize best_score if quality-tracked.
244            if has_quality > 0 {
245                let bs_atomic = &*(&raw const (*bucket).best_score).cast::<AtomicI64>();
246                bs_atomic.store(score, Ordering::Relaxed);
247            }
248
249            let bucket_index = compute_each_bucket_index(ptr, bucket);
250            crate::split_loop::dispatch_split(
251                msg,
252                bucket_index % crate::assertion_slots::MAX_ASSERTION_SLOTS,
253            );
254        } else if has_quality > 0 {
255            // Not first discovery: check quality watermark improvement.
256            // CAS loop on best_score — re-fork when score improves.
257            let bs_atomic = &*(&raw const (*bucket).best_score).cast::<AtomicI64>();
258            let mut current = bs_atomic.load(Ordering::Relaxed);
259            loop {
260                if score <= current {
261                    break;
262                }
263                match bs_atomic.compare_exchange_weak(
264                    current,
265                    score,
266                    Ordering::Relaxed,
267                    Ordering::Relaxed,
268                ) {
269                    Ok(_) => {
270                        let bucket_index = compute_each_bucket_index(ptr, bucket);
271                        crate::split_loop::dispatch_split(
272                            msg,
273                            bucket_index % crate::assertion_slots::MAX_ASSERTION_SLOTS,
274                        );
275                        break;
276                    }
277                    Err(actual) => current = actual,
278                }
279            }
280        }
281    }
282}
283
284/// Read all recorded `EachBucket` entries from shared memory.
285///
286/// Returns an empty vector if `EachBucket` shared memory is not initialized.
287#[must_use]
288pub fn each_bucket_read_all() -> Vec<EachBucket> {
289    let ptr = crate::context::EACH_BUCKET_PTR.with(std::cell::Cell::get);
290    if ptr.is_null() {
291        return Vec::new();
292    }
293    // Safety: ptr was allocated during init() with EACH_BUCKET_MEM_SIZE bytes.
294    // - The first 4 bytes hold the bucket count (u32), capped at MAX_EACH_BUCKETS.
295    // - base = ptr + 8 is the start of the EachBucket array.
296    // - Loop bound 0..count ensures base.add(i) stays within the allocated region.
297    // - EachBucket is #[repr(C)] + Copy, so ptr::read is valid for initialized slots.
298    unsafe {
299        let count = (*ptr.cast::<()>().cast::<u32>()) as usize;
300        let count = count.min(MAX_EACH_BUCKETS);
301        let base = ptr.add(8).cast::<()>().cast::<EachBucket>();
302        (0..count).map(|i| std::ptr::read(base.add(i))).collect()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_msg_hash_deterministic() {
312        let h1 = msg_hash("test_assertion");
313        let h2 = msg_hash("test_assertion");
314        assert_eq!(h1, h2);
315    }
316
317    #[test]
318    fn test_msg_hash_different_inputs() {
319        let h1 = msg_hash("alpha");
320        let h2 = msg_hash("beta");
321        let h3 = msg_hash("gamma");
322        assert_ne!(h1, h2);
323        assert_ne!(h2, h3);
324        assert_ne!(h1, h3);
325    }
326
327    #[test]
328    fn test_pack_unpack_quality_roundtrip() {
329        let quality = &[("health", 100i64), ("armor", 50i64), ("mana", 200i64)];
330        let packed = pack_quality(quality);
331        let unpacked = unpack_quality(packed, 3);
332        assert_eq!(unpacked, vec![100, 50, 200]);
333    }
334
335    #[test]
336    fn test_pack_quality_single() {
337        let quality = &[("health", 42i64)];
338        let packed = pack_quality(quality);
339        let unpacked = unpack_quality(packed, 1);
340        assert_eq!(unpacked, vec![42]);
341    }
342
343    #[test]
344    fn test_each_bucket_size_stable() {
345        // EachBucket must have a stable size for shared memory layout.
346        // 4+4+1+1+1+1+4+8+6*8+32 = 104 bytes
347        assert_eq!(std::mem::size_of::<EachBucket>(), 104);
348    }
349
350    #[test]
351    fn test_each_bucket_read_all_when_inactive() {
352        // Should return empty when not initialized.
353        let buckets = each_bucket_read_all();
354        assert!(buckets.is_empty());
355    }
356
357    #[test]
358    fn test_assertion_sometimes_each_noop_when_inactive() {
359        // Should not panic when EachBucket memory is not initialized.
360        assertion_sometimes_each("test", &[("key", 1)], &[]);
361    }
362}