use std::{
collections::{HashMap, VecDeque},
hash::Hash,
time::Duration,
};
use crate::Timestamp;
pub struct RitaBeaconDetector<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, RitaState>,
}
#[derive(Debug, Clone)]
struct RitaState {
samples: VecDeque<(Timestamp, u64)>,
last_emitted: Option<Timestamp>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RitaBeaconScore<K> {
pub key: K,
pub score: f64,
pub ts_score: f64,
pub ds_score: f64,
pub dur_score: f64,
pub ts_skew: f64,
pub ts_mad: f64,
pub mean_interval: Duration,
pub n: usize,
}
impl<K> RitaBeaconDetector<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 >= 3, "window must be ≥ 3 (Bowley needs 3 quartiles)");
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<RitaBeaconScore<K>> {
let entry = self.keys.entry(key.clone()).or_insert(RitaState {
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 ordered: Vec<(Timestamp, u64)> = entry.samples.iter().copied().collect();
let mut intervals = Vec::with_capacity(n - 1);
for w in ordered.windows(2) {
intervals.push(w[1].0.saturating_sub(w[0].0).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 (ts_score, ts_skew, ts_mad) = statistical_score(&intervals, 1.0);
let sizes: Vec<f64> = ordered.iter().map(|(_, b)| *b as f64).collect();
let (ds_score, _, _) = statistical_score(&sizes, 0.0);
let span = ordered
.last()
.zip(ordered.first())
.map(|(b, f)| b.0.saturating_sub(f.0).as_secs_f64())
.unwrap_or(0.0);
let dur_score = (span / self.duration_full_secs).clamp(0.0, 1.0);
let score = (0.45 * ts_score + 0.35 * ds_score + 0.20 * dur_score).clamp(0.0, 1.0);
Some(RitaBeaconScore {
key,
score,
ts_score,
ds_score,
dur_score,
ts_skew,
ts_mad,
mean_interval: mean_dur,
n,
})
}
pub fn forget(&mut self, key: &K) {
self.keys.remove(key);
}
pub fn observe_gated(
&mut self,
key: K,
ts: Timestamp,
bytes: u64,
) -> Option<RitaBeaconScore<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 evict_stale(&mut self, now: Timestamp, ttl: Duration) {
self.keys.retain(|_, state| {
state
.samples
.back()
.is_some_and(|(ts, _)| now.saturating_sub(*ts) <= ttl)
});
}
pub fn tracked(&self) -> usize {
self.keys.len()
}
}
impl<K> Default for RitaBeaconDetector<K>
where
K: Hash + Eq + Clone,
{
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "tracker")]
impl<K> RitaBeaconScore<K>
where
K: crate::KeyFields + Clone,
{
pub fn into_anomaly(self, ts: crate::Timestamp) -> crate::OwnedAnomaly {
crate::OwnedAnomaly::new(
crate::DetectorKind::BeaconRita,
crate::event::Severity::Warning,
ts,
)
.with_key(&self.key)
.with_metric("score", self.score)
.with_metric("ts_score", self.ts_score)
.with_metric("ds_score", self.ds_score)
.with_metric("dur_score", self.dur_score)
.with_metric("ts_skew", self.ts_skew)
.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 RitaBeaconScore<K>
where
K: crate::KeyFields + Clone,
{
fn kind(&self) -> crate::DetectorKind {
crate::DetectorKind::BeaconRita
}
fn into_anomaly(self, ts: crate::Timestamp) -> crate::OwnedAnomaly {
self.into_anomaly(ts)
}
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return 0.0;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn median_sorted(sorted: &[f64]) -> f64 {
let n = sorted.len();
if n == 0 {
return 0.0;
}
if n.is_multiple_of(2) {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
}
}
fn quartiles(sorted: &[f64]) -> (f64, f64, f64) {
let n = sorted.len();
let (c1, c2) = if n.is_multiple_of(2) {
(n / 2, n / 2)
} else {
((n - 1) / 2, (n - 1) / 2 + 1)
};
let q1 = median_sorted(&sorted[..c1]);
let q2 = median_sorted(sorted);
let q3 = median_sorted(&sorted[c2..]);
(q1, q2, q3)
}
fn bowley_skew_score(sorted: &[f64]) -> (f64, f64) {
if sorted.len() < 3 {
return (0.0, 1.0);
}
let (q1, q2, q3) = quartiles(sorted);
let den = q3 - q1;
let skew = if den >= 10.0 && q2 != q1 && q2 != q3 {
(q1 + q3 - 2.0 * q2) / den
} else {
0.0
};
(skew, 1.0 - skew.abs())
}
fn madm_score(sorted: &[f64], default_score: f64) -> (f64, f64) {
if sorted.is_empty() {
return (0.0, default_score);
}
let median = median_sorted(sorted);
let mut devs: Vec<f64> = sorted.iter().map(|x| (x - median).abs()).collect();
devs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mad = median_sorted(&devs);
let mut score = default_score;
if median >= 1.0 {
score = (median - mad) / median;
}
if score < 0.0 || score.is_nan() {
score = 0.0;
}
(mad, score)
}
fn statistical_score(values: &[f64], default_mad_score: f64) -> (f64, f64, f64) {
if values.is_empty() {
return (0.0, 0.0, 0.0);
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let (skew, skew_score) = bowley_skew_score(&sorted);
let (mad, mad_score) = madm_score(&sorted, default_mad_score);
((skew_score + mad_score) / 2.0, skew, mad)
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(sec: u32) -> Timestamp {
Timestamp::new(sec, 0)
}
#[test]
fn perfect_beacon_scores_high() {
let mut d: RitaBeaconDetector<u32> = RitaBeaconDetector::new();
let mut score = None;
for i in 0..20 {
score = d.observe(1, ts(i * 60), 100); }
let s = score.expect("window full");
assert!((s.ts_score - 1.0).abs() < 1e-9, "ts_score = {}", s.ts_score);
assert!((s.ds_score - 1.0).abs() < 1e-9, "ds_score = {}", s.ds_score);
assert!(
s.score > 0.85,
"perfect beacon should score > 0.85, got {}",
s.score
);
}
#[test]
fn fewer_than_ten_observations_yield_none() {
let mut d: RitaBeaconDetector<u32> = RitaBeaconDetector::new();
for i in 0..9 {
assert!(d.observe(1, ts(i * 60), 100).is_none());
}
}
#[test]
fn random_intervals_score_low() {
let mut d: RitaBeaconDetector<u32> = RitaBeaconDetector::new();
let gaps = [
30u32, 600, 45, 1200, 90, 15, 800, 200, 60, 1500, 20, 400, 1100, 35, 700, 120, 900, 25,
500, 300,
];
let mut t = 0u32;
let mut score = None;
for &g in &gaps {
t += g;
score = d.observe(1, ts(t), 100);
}
let s = score.expect("window full");
assert!(
s.ts_score < 0.8,
"irregular timing should depress ts_score, got {}",
s.ts_score
);
}
#[test]
fn outlier_robustness_beats_cv() {
use super::super::beacon::BeaconDetector;
let gaps: Vec<u32> = {
let mut g = vec![60u32; 19];
g[9] = 600; g
};
let mut rita: RitaBeaconDetector<u32> = RitaBeaconDetector::new();
let mut cv: BeaconDetector<u32> = BeaconDetector::new();
let mut t = 0u32;
let (mut rita_s, mut cv_s) = (None, None);
rita_s = rita.observe(1, ts(0), 100).or(rita_s);
cv_s = cv.observe(1, ts(0), 100).or(cv_s);
for &g in &gaps {
t += g;
rita_s = rita.observe(1, ts(t), 100).or(rita_s);
cv_s = cv.observe(1, ts(t), 100).or(cv_s);
}
let r = rita_s.expect("rita window full");
let c = cv_s.expect("cv window full");
assert!(
r.ts_score > 0.8,
"RITA ts_score stays high despite outlier: {}",
r.ts_score
);
assert!(
c.cv_dt > 1.0,
"the outlier should inflate CV's cv_dt: {}",
c.cv_dt
);
assert!(
r.score > c.score,
"RITA ({}) should out-score CV ({}) on an outlier-jittered beacon",
r.score,
c.score
);
}
#[test]
fn chatty_short_interval_returns_none() {
let mut d: RitaBeaconDetector<u32> = RitaBeaconDetector::new();
let mut score = None;
for i in 0..20 {
score = d.observe(1, ts(i), 100); }
assert!(score.is_none());
}
#[test]
fn forget_and_isolation() {
let mut d: RitaBeaconDetector<u32> = RitaBeaconDetector::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);
d.forget(&1);
d.forget(&2);
assert_eq!(d.tracked(), 0);
}
#[test]
fn quartiles_match_montanaflynn_odd() {
let xs: Vec<f64> = (1..=9).map(|x| x as f64).collect();
let (q1, q2, q3) = quartiles(&xs);
assert_eq!((q1, q2, q3), (2.5, 5.0, 7.5));
}
#[test]
fn quartiles_match_montanaflynn_even() {
let xs: Vec<f64> = (1..=8).map(|x| x as f64).collect();
let (q1, q2, q3) = quartiles(&xs);
assert_eq!((q1, q2, q3), (2.5, 4.5, 6.5));
}
}