use std::{
collections::{HashMap, VecDeque},
hash::Hash,
time::Duration,
};
use crate::Timestamp;
pub struct BeaconDetector<K>
where
K: Hash + Eq + Clone,
{
window: usize,
min_interval: Duration,
max_interval: Duration,
duration_full_secs: f64,
anomaly_threshold: f64,
cooldown: Duration,
keys: HashMap<K, BeaconState>,
}
#[derive(Debug, Clone)]
struct BeaconState {
samples: VecDeque<(Timestamp, u64)>,
last_emitted: Option<Timestamp>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct BeaconScore<K> {
pub key: K,
pub score: f64,
pub mean_interval: Duration,
pub cv_dt: f64,
pub cv_bytes: f64,
pub n: usize,
}
impl<K> BeaconDetector<K>
where
K: Hash + Eq + Clone,
{
pub fn new() -> Self {
Self {
window: 20,
min_interval: Duration::from_secs(10),
max_interval: Duration::from_secs(24 * 60 * 60),
duration_full_secs: 30.0 * 60.0,
anomaly_threshold: 0.7,
cooldown: Duration::from_secs(300),
keys: HashMap::new(),
}
}
pub fn with_window(mut self, window: usize) -> Self {
assert!(window >= 2, "window must be ≥ 2");
self.window = window;
self
}
pub fn with_interval_range(mut self, min: Duration, max: Duration) -> Self {
assert!(min <= max, "min_interval must be ≤ max_interval");
self.min_interval = min;
self.max_interval = max;
self
}
pub fn with_anomaly_threshold(mut self, threshold: f64) -> Self {
assert!(
(0.0..=1.0).contains(&threshold),
"threshold must be in [0, 1]"
);
self.anomaly_threshold = threshold;
self
}
pub fn with_cooldown(mut self, cooldown: Duration) -> Self {
self.cooldown = cooldown;
self
}
pub fn observe(&mut self, key: K, ts: Timestamp, bytes: u64) -> Option<BeaconScore<K>> {
let entry = self.keys.entry(key.clone()).or_insert(BeaconState {
samples: VecDeque::with_capacity(self.window),
last_emitted: None,
});
if entry.samples.len() == self.window {
entry.samples.pop_front();
}
entry.samples.push_back((ts, bytes));
let n = entry.samples.len();
if n < 10 {
return None;
}
let mut intervals = Vec::with_capacity(n - 1);
for w in entry.samples.iter().collect::<Vec<_>>().windows(2) {
let dt = w[1].0.saturating_sub(w[0].0);
intervals.push(dt.as_secs_f64());
}
let mean_dt = mean(&intervals);
if mean_dt <= 0.0 {
return None;
}
let mean_dur = Duration::from_secs_f64(mean_dt);
if mean_dur < self.min_interval || mean_dur > self.max_interval {
return None;
}
let cv_dt = cv(&intervals, mean_dt);
let byte_samples: Vec<f64> = entry.samples.iter().map(|(_, b)| *b as f64).collect();
let mean_bytes = mean(&byte_samples);
let cv_bytes = if mean_bytes > 0.0 {
cv(&byte_samples, mean_bytes)
} else {
0.0
};
let window_span_secs = entry
.samples
.back()
.zip(entry.samples.front())
.map(|(b, f)| b.0.saturating_sub(f.0).as_secs_f64())
.unwrap_or(0.0);
let duration_bonus = (window_span_secs / self.duration_full_secs).clamp(0.0, 1.0);
let score = (0.5 * (1.0 - cv_dt).clamp(0.0, 1.0))
+ (0.3 * (1.0 - cv_bytes).clamp(0.0, 1.0))
+ (0.2 * duration_bonus);
Some(BeaconScore {
key,
score: score.clamp(0.0, 1.0),
mean_interval: mean_dur,
cv_dt,
cv_bytes,
n,
})
}
pub fn observe_gated(&mut self, key: K, ts: Timestamp, bytes: u64) -> Option<BeaconScore<K>> {
let score = self.observe(key.clone(), ts, bytes)?;
if score.score < self.anomaly_threshold {
return None;
}
let state = self.keys.get_mut(&key)?;
if let Some(last) = state.last_emitted
&& ts.saturating_sub(last) < self.cooldown
{
return None;
}
state.last_emitted = Some(ts);
Some(score)
}
pub fn forget(&mut self, key: &K) {
self.keys.remove(key);
}
pub fn tracked(&self) -> usize {
self.keys.len()
}
pub fn evict_stale(&mut self, now: Timestamp, ttl: Duration) {
self.keys.retain(|_, state| {
state
.samples
.back()
.is_some_and(|(ts, _)| now.saturating_sub(*ts) <= ttl)
});
}
}
#[cfg(feature = "tracker")]
impl<K> BeaconScore<K>
where
K: crate::KeyFields + Clone,
{
pub fn into_anomaly(self, ts: crate::Timestamp) -> crate::OwnedAnomaly {
crate::OwnedAnomaly::new(
crate::DetectorKind::BeaconCv,
crate::event::Severity::Warning,
ts,
)
.with_key(&self.key)
.with_metric("score", self.score)
.with_metric("cv_dt", self.cv_dt)
.with_metric("cv_bytes", self.cv_bytes)
.with_metric("mean_interval_secs", self.mean_interval.as_secs_f64())
.with_metric("n", self.n as f64)
}
}
#[cfg(feature = "tracker")]
impl<K> crate::DetectorScore for BeaconScore<K>
where
K: crate::KeyFields + Clone,
{
fn kind(&self) -> crate::DetectorKind {
crate::DetectorKind::BeaconCv
}
fn into_anomaly(self, ts: crate::Timestamp) -> crate::OwnedAnomaly {
self.into_anomaly(ts)
}
}
impl<K> Default for BeaconDetector<K>
where
K: Hash + Eq + Clone,
{
fn default() -> Self {
Self::new()
}
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return 0.0;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn cv(xs: &[f64], mean_value: f64) -> f64 {
if mean_value <= 0.0 || xs.len() < 2 {
return 0.0;
}
let var = xs.iter().map(|x| (x - mean_value).powi(2)).sum::<f64>() / (xs.len() - 1) as f64;
var.sqrt() / mean_value
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(sec: u32) -> Timestamp {
Timestamp::new(sec, 0)
}
#[test]
fn fewer_than_ten_observations_yield_none() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
for i in 0..9 {
assert!(d.observe(1, ts(i * 60), 100).is_none());
}
}
#[test]
fn synthetic_perfect_beacon_scores_high() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
let mut score = None;
for i in 0..20 {
score = d.observe(1, ts(i * 60), 100); }
let s = score.expect("window should be full");
assert!(
s.score > 0.85,
"perfect beacon should score > 0.85, got {}",
s.score
);
assert!(s.cv_dt < 0.01);
assert!(s.cv_bytes < 0.01);
assert_eq!(s.n, 20);
}
#[test]
fn chatty_short_interval_returns_none() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
let mut score = None;
for i in 0..20 {
score = d.observe(1, ts(i), 100);
}
assert!(score.is_none(), "<10s interval should suppress score");
}
#[test]
fn very_long_interval_returns_none() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
let mut score = None;
for i in 0..20 {
score = d.observe(1, ts(i * 30 * 3600), 100);
}
assert!(score.is_none(), ">24h interval should suppress score");
}
#[test]
fn jittered_beacon_still_scores_meaningfully() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
let mut score = None;
let jitter = [
0i32, 2, -2, 3, -1, 1, -3, 2, 0, -2, 1, -1, 3, 0, -2, 1, -1, 2, -3, 0,
];
let mut t: i64 = 0;
for &j in &jitter {
t += 60 + j as i64;
score = d.observe(1, ts(t as u32), 100);
}
let s = score.expect("window should be full");
assert!(
s.score > 0.6,
"jittered beacon should score > 0.6, got {} (cv_dt={})",
s.score,
s.cv_dt
);
}
#[test]
fn variable_bytes_lowers_score() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
let mut score = None;
for i in 0..20 {
let bytes = if i % 2 == 0 { 100 } else { 50_000 };
score = d.observe(1, ts(i * 60), bytes);
}
let s = score.expect("window should be full");
assert!(s.cv_bytes > 0.5);
assert!(s.score < 0.7);
}
#[test]
fn per_key_isolation() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
for i in 0..20 {
d.observe(1, ts(i * 60), 100);
}
assert_eq!(d.tracked(), 1);
assert!(d.observe(2, ts(0), 100).is_none());
assert_eq!(d.tracked(), 2);
}
#[test]
fn forget_drops_state() {
let mut d: BeaconDetector<u32> = BeaconDetector::new();
d.observe(1, ts(0), 100);
assert_eq!(d.tracked(), 1);
d.forget(&1);
assert_eq!(d.tracked(), 0);
}
}