#[derive(Debug, Clone, PartialEq)]
pub enum DriftSignal {
None,
Mild { score: f64 },
Moderate { score: f64 },
Severe { score: f64 },
}
#[derive(Debug, Clone)]
pub struct PopulationStats {
pub centroid: Vec<f32>,
pub avg_pairwise_dist: f64,
pub sample_count: usize,
pub computed_at_secs: u64,
}
impl PopulationStats {
pub fn is_empty(&self) -> bool {
self.sample_count == 0
}
}
#[derive(Debug, Clone)]
pub struct DriftReport {
pub signal: DriftSignal,
pub centroid_shift: f64,
pub spread_change: f64,
pub combined_score: f64,
pub recommendation: String,
}
#[derive(Debug, Clone)]
pub struct DriftDetectorConfig {
pub mild_threshold: f64,
pub moderate_threshold: f64,
pub severe_threshold: f64,
pub sample_size: usize,
}
impl Default for DriftDetectorConfig {
fn default() -> Self {
Self {
mild_threshold: 0.1,
moderate_threshold: 0.3,
severe_threshold: 0.6,
sample_size: 100,
}
}
}
pub struct EmbeddingDriftDetector {
pub config: DriftDetectorConfig,
pub baseline: Option<PopulationStats>,
}
impl EmbeddingDriftDetector {
pub fn new(config: DriftDetectorConfig) -> Self {
Self {
config,
baseline: None,
}
}
pub fn compute_stats(&self, embeddings: &[Vec<f32>], now_secs: u64) -> PopulationStats {
let n = embeddings.len();
if n == 0 {
return PopulationStats {
centroid: Vec::new(),
avg_pairwise_dist: 0.0,
sample_count: 0,
computed_at_secs: now_secs,
};
}
let dim = embeddings[0].len();
let mut centroid = vec![0.0_f64; dim];
for emb in embeddings {
for (c, &v) in centroid.iter_mut().zip(emb.iter()) {
*c += v as f64;
}
}
let centroid: Vec<f32> = centroid.iter().map(|&s| (s / n as f64) as f32).collect();
let max_pairs = if n < 2 {
0
} else {
n.saturating_mul(n.saturating_sub(1)) / 2
};
let pairs_to_sample = self.config.sample_size.min(max_pairs);
let avg_pairwise_dist = if pairs_to_sample == 0 {
0.0
} else {
let mut total_dist = 0.0_f64;
let mut counted = 0usize;
let mut i = 0usize;
while counted < pairs_to_sample {
let a = i % n;
let b = (i + 1) % n;
if a != b {
total_dist += Self::cosine_distance(&embeddings[a], &embeddings[b]);
counted += 1;
}
i += 1;
if i >= n && counted == 0 {
break;
}
if i > n * 2 {
break;
}
}
if counted > 0 {
total_dist / counted as f64
} else {
0.0
}
};
PopulationStats {
centroid,
avg_pairwise_dist,
sample_count: n,
computed_at_secs: now_secs,
}
}
pub fn set_baseline(&mut self, stats: PopulationStats) {
self.baseline = Some(stats);
}
pub fn has_baseline(&self) -> bool {
self.baseline.is_some()
}
pub fn detect(&self, current: &PopulationStats) -> DriftReport {
let baseline = match &self.baseline {
Some(b) if !b.is_empty() => b,
_ => {
return DriftReport {
signal: DriftSignal::None,
centroid_shift: 0.0,
spread_change: 0.0,
combined_score: 0.0,
recommendation: "No action".to_string(),
};
}
};
let centroid_shift = Self::cosine_distance(&baseline.centroid, ¤t.centroid);
let spread_change = (current.avg_pairwise_dist - baseline.avg_pairwise_dist).abs()
/ baseline.avg_pairwise_dist.max(1e-9);
let combined_score = 0.7 * centroid_shift + 0.3 * spread_change;
let signal = if combined_score < self.config.mild_threshold {
DriftSignal::None
} else if combined_score < self.config.moderate_threshold {
DriftSignal::Mild {
score: combined_score,
}
} else if combined_score < self.config.severe_threshold {
DriftSignal::Moderate {
score: combined_score,
}
} else {
DriftSignal::Severe {
score: combined_score,
}
};
let recommendation = match &signal {
DriftSignal::None => "No action".to_string(),
DriftSignal::Mild { .. } => "Monitor".to_string(),
DriftSignal::Moderate { .. } => "Consider rebuild".to_string(),
DriftSignal::Severe { .. } => "Rebuild required".to_string(),
};
DriftReport {
signal,
centroid_shift,
spread_change,
combined_score,
recommendation,
}
}
pub fn cosine_distance(a: &[f32], b: &[f32]) -> f64 {
let len = a.len().min(b.len());
if len == 0 {
return 1.0;
}
let mut dot = 0.0_f64;
let mut norm_a = 0.0_f64;
let mut norm_b = 0.0_f64;
for i in 0..len {
let ai = a[i] as f64;
let bi = b[i] as f64;
dot += ai * bi;
norm_a += ai * ai;
norm_b += bi * bi;
}
let norm_a = norm_a.sqrt();
let norm_b = norm_b.sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
1.0
} else {
let cosine_sim = (dot / (norm_a * norm_b)).clamp(-1.0, 1.0);
1.0 - cosine_sim
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_detector() -> EmbeddingDriftDetector {
EmbeddingDriftDetector::new(DriftDetectorConfig::default())
}
#[test]
fn test_new_with_config() {
let config = DriftDetectorConfig {
mild_threshold: 0.15,
moderate_threshold: 0.35,
severe_threshold: 0.65,
sample_size: 50,
};
let detector = EmbeddingDriftDetector::new(config.clone());
assert_eq!(detector.config.mild_threshold, 0.15);
assert_eq!(detector.config.sample_size, 50);
assert!(detector.baseline.is_none());
}
#[test]
fn test_compute_stats_empty() {
let detector = default_detector();
let stats = detector.compute_stats(&[], 0);
assert!(stats.centroid.is_empty());
assert_eq!(stats.avg_pairwise_dist, 0.0);
assert_eq!(stats.sample_count, 0);
assert!(stats.is_empty());
}
#[test]
fn test_compute_stats_single() {
let detector = default_detector();
let emb = vec![1.0_f32, 2.0, 3.0];
let stats = detector.compute_stats(std::slice::from_ref(&emb), 42);
assert_eq!(stats.sample_count, 1);
assert_eq!(stats.computed_at_secs, 42);
for (c, e) in stats.centroid.iter().zip(emb.iter()) {
assert!((c - e).abs() < 1e-5, "centroid {c} != emb {e}");
}
assert_eq!(stats.avg_pairwise_dist, 0.0);
}
#[test]
fn test_compute_stats_two_identical() {
let detector = default_detector();
let emb = vec![1.0_f32, 0.0, 0.0];
let stats = detector.compute_stats(&[emb.clone(), emb.clone()], 0);
assert_eq!(stats.sample_count, 2);
for (c, e) in stats.centroid.iter().zip(emb.iter()) {
assert!((c - e).abs() < 1e-5, "centroid mismatch: {c} vs {e}");
}
assert!(
stats.avg_pairwise_dist < 1e-9,
"expected ~0, got {}",
stats.avg_pairwise_dist
);
}
#[test]
fn test_compute_stats_sample_count() {
let detector = default_detector();
let embeddings: Vec<Vec<f32>> = (0..7).map(|i| vec![i as f32, 0.0]).collect();
let stats = detector.compute_stats(&embeddings, 0);
assert_eq!(stats.sample_count, 7);
}
#[test]
fn test_set_baseline() {
let mut detector = default_detector();
assert!(!detector.has_baseline());
let stats = detector.compute_stats(&[vec![1.0_f32, 0.0]], 0);
detector.set_baseline(stats);
assert!(detector.has_baseline());
}
#[test]
fn test_detect_no_baseline() {
let detector = default_detector();
let current = PopulationStats {
centroid: vec![1.0],
avg_pairwise_dist: 0.1,
sample_count: 5,
computed_at_secs: 0,
};
let report = detector.detect(¤t);
assert_eq!(report.signal, DriftSignal::None);
assert_eq!(report.combined_score, 0.0);
assert_eq!(report.recommendation, "No action");
}
#[test]
fn test_detect_identical_populations() {
let mut detector = default_detector();
let embeddings: Vec<Vec<f32>> = vec![
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 1.0],
];
let baseline = detector.compute_stats(&embeddings, 0);
let current = detector.compute_stats(&embeddings, 1);
detector.set_baseline(baseline);
let report = detector.detect(¤t);
assert!(
report.combined_score < 1e-6,
"expected ≈0, got {}",
report.combined_score
);
}
#[test]
fn test_detect_shifted_centroid() {
let mut detector = default_detector();
let baseline_embs: Vec<Vec<f32>> = vec![vec![1.0_f32, 0.0, 0.0]];
let current_embs: Vec<Vec<f32>> = vec![vec![0.0_f32, 1.0, 0.0]];
let baseline = detector.compute_stats(&baseline_embs, 0);
let current = detector.compute_stats(¤t_embs, 1);
detector.set_baseline(baseline);
let report = detector.detect(¤t);
assert!(
report.centroid_shift > 0.0,
"expected centroid_shift > 0, got {}",
report.centroid_shift
);
}
#[test]
fn test_signal_none() {
let config = DriftDetectorConfig::default();
let mut detector = EmbeddingDriftDetector::new(config);
let baseline = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.05,
sample_count: 10,
computed_at_secs: 0,
};
let current = PopulationStats {
centroid: vec![0.9999_f32, 0.0141], avg_pairwise_dist: 0.05,
sample_count: 10,
computed_at_secs: 1,
};
detector.set_baseline(baseline);
let report = detector.detect(¤t);
assert_eq!(report.signal, DriftSignal::None);
}
#[test]
fn test_signal_mild() {
let mut detector = default_detector();
let theta: f64 = std::f64::consts::PI * 0.2; let baseline = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 0,
};
let current = PopulationStats {
centroid: vec![theta.cos() as f32, theta.sin() as f32],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 1,
};
detector.set_baseline(baseline);
let report = detector.detect(¤t);
let score = report.combined_score;
assert!(
(0.1..0.3).contains(&score),
"expected mild [0.1,0.3), got {score}"
);
assert!(matches!(report.signal, DriftSignal::Mild { .. }));
}
#[test]
fn test_signal_moderate() {
let mut detector = default_detector();
let theta: f64 = std::f64::consts::PI / 3.0; let baseline = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 0,
};
let current = PopulationStats {
centroid: vec![theta.cos() as f32, theta.sin() as f32],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 1,
};
detector.set_baseline(baseline);
let report = detector.detect(¤t);
let score = report.combined_score;
assert!(
(0.3..0.6).contains(&score),
"expected moderate [0.3,0.6), got {score}"
);
assert!(matches!(report.signal, DriftSignal::Moderate { .. }));
}
#[test]
fn test_signal_severe() {
let mut detector = default_detector();
let baseline = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 0,
};
let current = PopulationStats {
centroid: vec![0.0_f32, 1.0],
avg_pairwise_dist: 0.1,
sample_count: 10,
computed_at_secs: 1,
};
detector.set_baseline(baseline);
let report = detector.detect(¤t);
assert!(
report.combined_score >= 0.6,
"expected >=0.6, got {}",
report.combined_score
);
assert!(matches!(report.signal, DriftSignal::Severe { .. }));
}
#[test]
fn test_recommendation_matches_signal() {
let mut detector = default_detector();
let baseline = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.1,
sample_count: 5,
computed_at_secs: 0,
};
let current = PopulationStats {
centroid: vec![0.0_f32, 1.0],
avg_pairwise_dist: 0.1,
sample_count: 5,
computed_at_secs: 1,
};
detector.set_baseline(baseline.clone());
let report = detector.detect(¤t);
match &report.signal {
DriftSignal::None => assert_eq!(report.recommendation, "No action"),
DriftSignal::Mild { .. } => assert_eq!(report.recommendation, "Monitor"),
DriftSignal::Moderate { .. } => {
assert_eq!(report.recommendation, "Consider rebuild")
}
DriftSignal::Severe { .. } => {
assert_eq!(report.recommendation, "Rebuild required")
}
}
detector.set_baseline(baseline.clone());
let same_current = PopulationStats {
centroid: vec![1.0_f32, 0.0],
avg_pairwise_dist: 0.1,
sample_count: 5,
computed_at_secs: 2,
};
let report2 = detector.detect(&same_current);
assert_eq!(report2.recommendation, "No action");
}
#[test]
fn test_cosine_distance_identical() {
let v = vec![3.0_f32, 4.0, 0.0];
let dist = EmbeddingDriftDetector::cosine_distance(&v, &v);
assert!(dist < 1e-9, "expected 0, got {dist}");
}
#[test]
fn test_cosine_distance_orthogonal() {
let a = vec![1.0_f32, 0.0];
let b = vec![0.0_f32, 1.0];
let dist = EmbeddingDriftDetector::cosine_distance(&a, &b);
assert!((dist - 1.0).abs() < 1e-9, "expected 1.0, got {dist}");
}
#[test]
fn test_has_baseline_true_after_set() {
let mut detector = default_detector();
assert!(!detector.has_baseline(), "should be false before set");
let stats = PopulationStats {
centroid: vec![1.0_f32],
avg_pairwise_dist: 0.0,
sample_count: 1,
computed_at_secs: 0,
};
detector.set_baseline(stats);
assert!(detector.has_baseline(), "should be true after set");
}
}