feat(core): salted Bloom summary
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
//! Salted Bloom filter used as the per-key content summary.
|
||||
//!
|
||||
//! `Bloom` is a fixed-size, no-heap bit array with `BLOOM_K` hash-derived
|
||||
//! bit positions per inserted `ObservationId`. The `salt` value mixes into the
|
||||
//! hash so that the same id under different salts lands on different bit
|
||||
//! positions, keeping false positives between independent filters low. The
|
||||
//! The hashing scheme is pinned (see [`positions`]) so that `tines-core` and the
|
||||
//! simulation agree bit-for-bit across builds.
|
||||
//!
|
||||
//! This type is `core`-only: it owns a fixed `[u8; SUMMARY_BYTES]` array and
|
||||
//! never touches the heap, so it compiles in `no_std` without `alloc`.
|
||||
|
||||
use crate::consts;
|
||||
use crate::observation::ObservationId;
|
||||
|
||||
/// A fixed-size salted Bloom filter keyed by `ObservationId`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Bloom {
|
||||
pub salt: u32,
|
||||
bits: [u8; consts::SUMMARY_BYTES],
|
||||
}
|
||||
|
||||
impl Bloom {
|
||||
/// Returns a new Bloom filter with all bits cleared and the given `salt`.
|
||||
pub fn new(salt: u32) -> Self {
|
||||
Bloom {
|
||||
salt,
|
||||
bits: [0u8; consts::SUMMARY_BYTES],
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the `BLOOM_K` bits for `id`.
|
||||
pub fn insert(&mut self, id: &ObservationId) {
|
||||
for idx in positions(self.salt, id) {
|
||||
self.bits[idx / 8] |= 1 << (idx % 8);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if `id` may have been inserted (all of its bits are set).
|
||||
pub fn might_contain(&self, id: &ObservationId) -> bool {
|
||||
positions(self.salt, id)
|
||||
.iter()
|
||||
.all(|idx| self.bits[idx / 8] & (1 << (idx % 8)) != 0)
|
||||
}
|
||||
|
||||
/// Rebuilds a `Bloom` from its raw parts.
|
||||
///
|
||||
/// Returns `None` unless `bits` is exactly `SUMMARY_BYTES` long, so a caller
|
||||
/// deserializing from an untrusted source cannot construct a filter from a
|
||||
/// truncated or oversized backing array.
|
||||
pub fn from_parts(salt: u32, bits: &[u8]) -> Option<Self> {
|
||||
let bytes: [u8; consts::SUMMARY_BYTES] = bits.try_into().ok()?;
|
||||
Some(Bloom { salt, bits: bytes })
|
||||
}
|
||||
|
||||
/// Returns the raw backing bit array as a slice.
|
||||
pub fn bits(&self) -> &[u8] {
|
||||
&self.bits
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the `BLOOM_K` bit indices for `id` under `salt` using the pinned
|
||||
/// double-hash scheme shared with the simulation.
|
||||
///
|
||||
/// Both 8-byte halves of the 16-byte id are mixed with the salt through
|
||||
/// independent avalanche (fmix64) functions, so all `BLOOM_K` slots are derived
|
||||
/// from the full id rather than just the low 12 bits of the prefix. For each
|
||||
/// `k` in `0..BLOOM_K`:
|
||||
/// ```text
|
||||
/// a = u64::from_le_bytes(id[0..8]) ^ (salt as u64).wrapping_mul(0x9E3779B97F4A7C15)
|
||||
/// b = u64::from_le_bytes(id[8..16]) ^ (salt as u64).wrapping_mul(0xC2B2AE3D27D4EB4F) | 1
|
||||
/// h1 = fmix64(a)
|
||||
/// h2 = fmix64(b)
|
||||
/// capacity = SUMMARY_BYTES * 8
|
||||
/// bit_index = (h1 + k * h2) % capacity
|
||||
/// ```
|
||||
fn positions(salt: u32, id: &ObservationId) -> [usize; consts::BLOOM_K] {
|
||||
fn fmix64(mut x: u64) -> u64 {
|
||||
x ^= x >> 33;
|
||||
x = x.wrapping_mul(0xff51afd7ed558ccd);
|
||||
x ^= x >> 33;
|
||||
x = x.wrapping_mul(0xc4ceb9fe1a85ec53);
|
||||
x ^= x >> 33;
|
||||
x
|
||||
}
|
||||
let mut lo = [0u8; 8];
|
||||
let mut hi = [0u8; 8];
|
||||
lo.copy_from_slice(&id[0..8]);
|
||||
hi.copy_from_slice(&id[8..16]);
|
||||
let a = u64::from_le_bytes(lo);
|
||||
let b = u64::from_le_bytes(hi);
|
||||
let s = salt as u64;
|
||||
let h1 = fmix64(a ^ s.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||||
let h2 = fmix64(b ^ s.wrapping_mul(0xC2B2_AE3D_27D4_EB4F)) | 1;
|
||||
let capacity = (consts::SUMMARY_BYTES * 8) as u64;
|
||||
let mut out = [0usize; consts::BLOOM_K];
|
||||
for (k, slot) in out.iter_mut().enumerate() {
|
||||
*slot = ((h1.wrapping_add((k as u64).wrapping_mul(h2))) % capacity) as usize;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Small deterministic PRNG (xorshift64) so tests are hermetic and need no
|
||||
/// `rand` dependency (tines-core has none).
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
const fn new(seed: u64) -> Self {
|
||||
Rng(seed | 1)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// A distinct ObservationId derived from the PRNG stream.
|
||||
fn id(&mut self) -> ObservationId {
|
||||
let hi = self.next();
|
||||
let lo = self.next();
|
||||
let mut id = [0u8; 16];
|
||||
id[0..8].copy_from_slice(&hi.to_le_bytes());
|
||||
id[8..16].copy_from_slice(&lo.to_le_bytes());
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_is_contained() {
|
||||
let mut bloom = Bloom::new(7);
|
||||
let mut rng = Rng::new(1);
|
||||
let ids: [ObservationId; 100] = std::array::from_fn(|_| rng.id());
|
||||
for id in ids {
|
||||
bloom.insert(&id);
|
||||
}
|
||||
for id in ids {
|
||||
assert!(bloom.might_contain(&id), "inserted id must be reported present");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_contains_nothing() {
|
||||
let bloom = Bloom::new(0);
|
||||
let mut rng = Rng::new(2);
|
||||
let ids: [ObservationId; 100] = std::array::from_fn(|_| rng.id());
|
||||
for id in ids {
|
||||
assert!(!bloom.might_contain(&id), "empty filter must report nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn false_positive_rate_bounded_at_500_items() {
|
||||
let mut bloom = Bloom::new(3);
|
||||
let mut rng = Rng::new(4);
|
||||
|
||||
let inserted: [ObservationId; 500] = std::array::from_fn(|_| rng.id());
|
||||
for id in inserted {
|
||||
bloom.insert(&id);
|
||||
}
|
||||
|
||||
// Probe 10_000 *other* ids (continuing the same full-period stream keeps
|
||||
// them distinct from the inserted set).
|
||||
let mut fp = 0usize;
|
||||
for _ in 0..10_000 {
|
||||
if bloom.might_contain(&rng.id()) {
|
||||
fp += 1;
|
||||
}
|
||||
}
|
||||
let rate = fp as f64 / 10_000.0;
|
||||
assert!(rate < 0.05, "false positive rate too high: {rate}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_salt_changes_false_positives() {
|
||||
let mut rng = Rng::new(5);
|
||||
|
||||
let inserted: [ObservationId; 500] = std::array::from_fn(|_| rng.id());
|
||||
let probes: [ObservationId; 10_000] = std::array::from_fn(|_| rng.id());
|
||||
|
||||
let mut a = Bloom::new(1);
|
||||
let mut b = Bloom::new(2);
|
||||
for id in &inserted {
|
||||
a.insert(id);
|
||||
b.insert(id);
|
||||
}
|
||||
|
||||
let mut under_a = 0usize;
|
||||
let mut under_both = 0usize;
|
||||
for probe in &probes {
|
||||
let fa = a.might_contain(probe);
|
||||
let fb = b.might_contain(probe);
|
||||
if fa {
|
||||
under_a += 1;
|
||||
if fb {
|
||||
under_both += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(under_a > 0, "expected some false positives under salt 1");
|
||||
assert!(
|
||||
(under_both as f64) < (0.20 * under_a as f64),
|
||||
"salted hashes must diverge; both={under_both} under_a={under_a}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_parts_rejects_wrong_length() {
|
||||
assert!(Bloom::from_parts(1, &[0u8; 10]).is_none());
|
||||
assert!(Bloom::from_parts(1, &[]).is_none());
|
||||
|
||||
// And accepts a correctly-sized backing array.
|
||||
let raw = [0xABu8; consts::SUMMARY_BYTES];
|
||||
let bloom = Bloom::from_parts(9, &raw).expect("correct-length bits must build");
|
||||
assert_eq!(bloom.salt, 9);
|
||||
assert_eq!(bloom.bits(), &raw[..]);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
#[cfg(feature = "alloc")]
|
||||
extern crate alloc;
|
||||
|
||||
pub mod bloom;
|
||||
pub mod consts;
|
||||
pub mod observation;
|
||||
pub mod store;
|
||||
@@ -11,6 +12,7 @@ pub mod store;
|
||||
pub mod mem_store;
|
||||
pub mod wire;
|
||||
|
||||
pub use bloom::Bloom;
|
||||
pub use observation::{Observation, NodeId, ObservationId, Payload, Tick, Topic};
|
||||
pub use store::{Store, TrimPolicy, StoreError};
|
||||
pub use wire::{Frame, WireError};
|
||||
|
||||
Reference in New Issue
Block a user