Skip to main content

moonpool_assertions/
buckets.rs

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