Files
ruvnet--RuView/vendor/ruvector/crates/ruQu/src/adaptive.rs
T

1082 lines
33 KiB
Rust

//! Adaptive Threshold Learning
//!
//! This module provides self-tuning thresholds that adapt based on historical
//! error patterns and system behavior. Uses exponential moving averages and
//! online learning to optimize gate decisions.
//!
//! ## How It Works
//!
//! 1. **Baseline Learning**: Establish normal operating ranges during warmup
//! 2. **Anomaly Detection**: Identify when metrics deviate from baseline
//! 3. **Threshold Adjustment**: Gradually tune thresholds to reduce false positives/negatives
//! 4. **Feedback Integration**: Learn from downstream outcomes (if available)
//! 5. **Drift Detection**: Monitor for noise characteristic changes (arXiv:2511.09491)
//!
//! ## Usage
//!
//! ```rust,ignore
//! use ruqu::adaptive::{AdaptiveThresholds, LearningConfig, DriftDetector};
//!
//! let config = LearningConfig::default();
//! let mut adaptive = AdaptiveThresholds::new(config);
//! let mut drift = DriftDetector::new(100); // 100-sample window
//!
//! // During operation
//! let thresholds = adaptive.current_thresholds();
//! let decision = evaluate_with_thresholds(&metrics, &thresholds);
//!
//! // Check for drift
//! drift.push(cut_value);
//! if let Some(profile) = drift.detect() {
//! println!("Drift detected: {:?}", profile);
//! adaptive.apply_drift_compensation(&profile);
//! }
//!
//! // Feed back outcome
//! adaptive.record_outcome(decision, was_correct);
//! ```
use crate::tile::GateThresholds;
/// Configuration for adaptive learning
#[derive(Clone, Debug)]
pub struct LearningConfig {
/// Learning rate (0.0-1.0), higher = faster adaptation
pub learning_rate: f64,
/// History window size for baseline computation
pub history_window: usize,
/// Warmup period (samples before adaptation starts)
pub warmup_samples: usize,
/// Minimum threshold for structural min-cut
pub min_structural_threshold: f64,
/// Maximum threshold for structural min-cut
pub max_structural_threshold: f64,
/// Decay factor for exponential moving average
pub ema_decay: f64,
/// Enable automatic threshold adjustment
pub auto_adjust: bool,
}
impl Default for LearningConfig {
fn default() -> Self {
Self {
learning_rate: 0.01,
history_window: 10_000,
warmup_samples: 1_000,
min_structural_threshold: 1.0,
max_structural_threshold: 20.0,
ema_decay: 0.99,
auto_adjust: true,
}
}
}
impl LearningConfig {
/// Conservative configuration (slow adaptation)
pub fn conservative() -> Self {
Self {
learning_rate: 0.001,
history_window: 50_000,
warmup_samples: 5_000,
ema_decay: 0.999,
auto_adjust: true,
..Default::default()
}
}
/// Aggressive configuration (fast adaptation)
pub fn aggressive() -> Self {
Self {
learning_rate: 0.1,
history_window: 1_000,
warmup_samples: 100,
ema_decay: 0.95,
auto_adjust: true,
..Default::default()
}
}
}
/// Running statistics using Welford's algorithm
#[derive(Clone, Debug, Default)]
struct RunningStats {
count: u64,
mean: f64,
m2: f64,
min: f64,
max: f64,
}
impl RunningStats {
fn new() -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
min: f64::MAX,
max: f64::MIN,
}
}
fn update(&mut self, value: f64) {
self.count += 1;
let delta = value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
if value < self.min {
self.min = value;
}
if value > self.max {
self.max = value;
}
}
fn variance(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
self.m2 / (self.count - 1) as f64
}
fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
}
/// Exponential moving average tracker
#[derive(Clone, Debug)]
struct EMA {
value: f64,
decay: f64,
initialized: bool,
}
impl EMA {
fn new(decay: f64) -> Self {
Self {
value: 0.0,
decay,
initialized: false,
}
}
fn update(&mut self, sample: f64) {
if !self.initialized {
self.value = sample;
self.initialized = true;
} else {
self.value = self.decay * self.value + (1.0 - self.decay) * sample;
}
}
fn get(&self) -> f64 {
self.value
}
}
/// Adaptive threshold manager
pub struct AdaptiveThresholds {
/// Configuration
config: LearningConfig,
/// Current thresholds
current: GateThresholds,
/// Statistics for structural cut values
cut_stats: RunningStats,
/// Statistics for shift scores
shift_stats: RunningStats,
/// Statistics for e-values
evidence_stats: RunningStats,
/// EMA of false positive rate
false_positive_ema: EMA,
/// EMA of false negative rate
false_negative_ema: EMA,
/// Total samples processed
samples: u64,
/// Outcomes recorded
outcomes: OutcomeTracker,
}
/// Tracks decision outcomes for learning
#[derive(Clone, Debug, Default)]
struct OutcomeTracker {
/// True positives (Deny when should deny)
true_positives: u64,
/// True negatives (Permit when should permit)
true_negatives: u64,
/// False positives (Deny when should permit)
false_positives: u64,
/// False negatives (Permit when should deny)
false_negatives: u64,
}
impl OutcomeTracker {
fn record(&mut self, predicted_deny: bool, actual_bad: bool) {
match (predicted_deny, actual_bad) {
(true, true) => self.true_positives += 1,
(false, false) => self.true_negatives += 1,
(true, false) => self.false_positives += 1,
(false, true) => self.false_negatives += 1,
}
}
fn precision(&self) -> f64 {
let denom = self.true_positives + self.false_positives;
if denom == 0 {
return 1.0;
}
self.true_positives as f64 / denom as f64
}
fn recall(&self) -> f64 {
let denom = self.true_positives + self.false_negatives;
if denom == 0 {
return 1.0;
}
self.true_positives as f64 / denom as f64
}
fn f1_score(&self) -> f64 {
let p = self.precision();
let r = self.recall();
if p + r == 0.0 {
return 0.0;
}
2.0 * p * r / (p + r)
}
fn false_positive_rate(&self) -> f64 {
let denom = self.false_positives + self.true_negatives;
if denom == 0 {
return 0.0;
}
self.false_positives as f64 / denom as f64
}
fn false_negative_rate(&self) -> f64 {
let denom = self.false_negatives + self.true_positives;
if denom == 0 {
return 0.0;
}
self.false_negatives as f64 / denom as f64
}
}
impl AdaptiveThresholds {
/// Create new adaptive threshold manager
pub fn new(config: LearningConfig) -> Self {
let current = GateThresholds::default();
Self {
false_positive_ema: EMA::new(config.ema_decay),
false_negative_ema: EMA::new(config.ema_decay),
config,
current,
cut_stats: RunningStats::new(),
shift_stats: RunningStats::new(),
evidence_stats: RunningStats::new(),
samples: 0,
outcomes: OutcomeTracker::default(),
}
}
/// Record observed metrics (call every cycle)
pub fn record_metrics(&mut self, cut: f64, shift: f64, e_value: f64) {
self.cut_stats.update(cut);
self.shift_stats.update(shift);
self.evidence_stats.update(e_value);
self.samples += 1;
// Adjust thresholds after warmup
if self.config.auto_adjust && self.samples > self.config.warmup_samples as u64 {
self.adjust_thresholds();
}
}
/// Record decision outcome for learning
///
/// # Arguments
/// * `was_deny` - True if gate decided Deny
/// * `was_actually_bad` - True if there was an actual error (ground truth)
pub fn record_outcome(&mut self, was_deny: bool, was_actually_bad: bool) {
self.outcomes.record(was_deny, was_actually_bad);
// Update EMAs
let fp = if was_deny && !was_actually_bad {
1.0
} else {
0.0
};
let fn_rate = if !was_deny && was_actually_bad {
1.0
} else {
0.0
};
self.false_positive_ema.update(fp);
self.false_negative_ema.update(fn_rate);
// Adjust thresholds based on outcome
if self.config.auto_adjust && self.samples > self.config.warmup_samples as u64 {
self.adjust_from_outcome(was_deny, was_actually_bad);
}
}
/// Get current thresholds
pub fn current_thresholds(&self) -> &GateThresholds {
&self.current
}
/// Get mutable thresholds for manual adjustment
pub fn current_thresholds_mut(&mut self) -> &mut GateThresholds {
&mut self.current
}
/// Check if warmup period is complete
pub fn is_warmed_up(&self) -> bool {
self.samples >= self.config.warmup_samples as u64
}
/// Get learning statistics
pub fn stats(&self) -> AdaptiveStats {
AdaptiveStats {
samples: self.samples,
cut_mean: self.cut_stats.mean,
cut_std: self.cut_stats.std_dev(),
shift_mean: self.shift_stats.mean,
shift_std: self.shift_stats.std_dev(),
evidence_mean: self.evidence_stats.mean,
precision: self.outcomes.precision(),
recall: self.outcomes.recall(),
f1_score: self.outcomes.f1_score(),
false_positive_rate: self.false_positive_ema.get(),
false_negative_rate: self.false_negative_ema.get(),
}
}
/// Reset learning state
pub fn reset(&mut self) {
self.cut_stats = RunningStats::new();
self.shift_stats = RunningStats::new();
self.evidence_stats = RunningStats::new();
self.false_positive_ema = EMA::new(self.config.ema_decay);
self.false_negative_ema = EMA::new(self.config.ema_decay);
self.samples = 0;
self.outcomes = OutcomeTracker::default();
}
// Private methods
fn adjust_thresholds(&mut self) {
let lr = self.config.learning_rate;
// Adjust structural threshold based on observed cut distribution
// Target: threshold = mean - 2*std (catch 95% of normal operation)
if self.cut_stats.count > 100 {
let target = self.cut_stats.mean - 2.0 * self.cut_stats.std_dev();
let target = target.clamp(
self.config.min_structural_threshold,
self.config.max_structural_threshold,
);
self.current.structural_min_cut =
self.current.structural_min_cut * (1.0 - lr) + target * lr;
}
// Adjust shift threshold based on observed distribution
// Target: threshold = mean + 2*std
if self.shift_stats.count > 100 {
let target = (self.shift_stats.mean + 2.0 * self.shift_stats.std_dev()).min(1.0);
self.current.shift_max = self.current.shift_max * (1.0 - lr) + target * lr;
}
// Adjust evidence thresholds
if self.evidence_stats.count > 100 {
// tau_deny should be well below normal (5th percentile estimate)
let tau_deny_target =
(self.evidence_stats.mean - 2.0 * self.evidence_stats.std_dev()).max(0.001);
self.current.tau_deny = self.current.tau_deny * (1.0 - lr) + tau_deny_target * lr;
// tau_permit should be above normal (75th percentile estimate)
let tau_permit_target = self.evidence_stats.mean + 0.5 * self.evidence_stats.std_dev();
self.current.tau_permit = self.current.tau_permit * (1.0 - lr) + tau_permit_target * lr;
}
}
fn adjust_from_outcome(&mut self, was_deny: bool, was_actually_bad: bool) {
let lr = self.config.learning_rate * 0.1; // Slower adjustment from outcomes
match (was_deny, was_actually_bad) {
(true, false) => {
// False positive: we denied but it was fine
// → Relax thresholds (lower structural, raise shift)
self.current.structural_min_cut *= 1.0 - lr;
self.current.shift_max = (self.current.shift_max + lr).min(1.0);
}
(false, true) => {
// False negative: we permitted but it was bad
// → Tighten thresholds (raise structural, lower shift)
self.current.structural_min_cut *= 1.0 + lr;
self.current.shift_max = (self.current.shift_max - lr).max(0.1);
}
_ => {
// Correct decision: no adjustment needed
}
}
// Clamp thresholds to valid ranges
self.current.structural_min_cut = self.current.structural_min_cut.clamp(
self.config.min_structural_threshold,
self.config.max_structural_threshold,
);
}
}
// ============================================================================
// Drift Detection (inspired by arXiv:2511.09491)
// ============================================================================
/// Detected drift profile in noise characteristics
///
/// Based on window-based drift estimation techniques from arXiv:2511.09491.
#[derive(Clone, Debug, PartialEq)]
pub enum DriftProfile {
/// No significant drift detected
Stable,
/// Gradual linear drift in one direction
Linear {
/// Rate of change per sample
slope: f64,
/// Direction of the trend
direction: DriftDirection,
},
/// Step change (sudden shift)
StepChange {
/// Size of the step in original units
magnitude: f64,
/// Direction of the shift
direction: DriftDirection,
},
/// Oscillating drift pattern
Oscillating {
/// Peak-to-peak amplitude
amplitude: f64,
/// Estimated period in samples
period_samples: usize,
},
/// Increasing variance without mean shift
VarianceExpansion {
/// Ratio of current variance to baseline
ratio: f64,
},
}
/// Direction of detected drift
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DriftDirection {
/// Values are trending upward
Increasing,
/// Values are trending downward
Decreasing,
}
/// Configuration for drift detection
#[derive(Clone, Debug)]
pub struct DriftConfig {
/// Window size for recent samples
pub window_size: usize,
/// Minimum samples before detection activates
pub min_samples: usize,
/// Threshold for mean shift (in std devs)
pub mean_shift_threshold: f64,
/// Threshold for variance change ratio
pub variance_threshold: f64,
/// Sensitivity for linear trend detection
pub trend_sensitivity: f64,
}
impl Default for DriftConfig {
fn default() -> Self {
Self {
window_size: 100,
min_samples: 50,
mean_shift_threshold: 2.0, // 2 sigma
variance_threshold: 1.5, // 50% variance change
trend_sensitivity: 0.1,
}
}
}
/// Drift detector using window-based estimation
///
/// Based on techniques from "Adaptive Estimation of Drifting Noise" (arXiv:2511.09491).
/// Uses sliding windows to detect changes in noise characteristics from syndrome data.
pub struct DriftDetector {
/// Configuration
config: DriftConfig,
/// Circular buffer for recent samples
buffer: Vec<f64>,
/// Current write position
write_pos: usize,
/// Number of samples collected
sample_count: u64,
/// Baseline statistics (established during warmup)
baseline_mean: f64,
baseline_var: f64,
/// Previous window statistics for trend detection
prev_window_mean: f64,
prev_window_var: f64,
/// Trend accumulator for linear drift
trend_accumulator: f64,
}
impl DriftDetector {
/// Create a new drift detector with specified window size
pub fn new(window_size: usize) -> Self {
Self::with_config(DriftConfig {
window_size,
..Default::default()
})
}
/// Create with full configuration
pub fn with_config(config: DriftConfig) -> Self {
Self {
buffer: vec![0.0; config.window_size],
write_pos: 0,
sample_count: 0,
baseline_mean: 0.0,
baseline_var: 0.0,
prev_window_mean: 0.0,
prev_window_var: 0.0,
trend_accumulator: 0.0,
config,
}
}
/// Push a new sample into the detector
pub fn push(&mut self, value: f64) {
self.buffer[self.write_pos] = value;
self.write_pos = (self.write_pos + 1) % self.config.window_size;
self.sample_count += 1;
// Establish baseline after min_samples
if self.sample_count == self.config.min_samples as u64 {
let (mean, var) = self.compute_window_stats();
self.baseline_mean = mean;
self.baseline_var = var;
self.prev_window_mean = mean;
self.prev_window_var = var;
}
}
/// Detect drift in current window
pub fn detect(&mut self) -> Option<DriftProfile> {
if self.sample_count < self.config.min_samples as u64 {
return None;
}
let (current_mean, current_var) = self.compute_window_stats();
let baseline_std = self.baseline_var.sqrt().max(1e-10);
// Check for step change (sudden mean shift)
let mean_shift = (current_mean - self.baseline_mean).abs() / baseline_std;
if mean_shift > self.config.mean_shift_threshold {
let direction = if current_mean > self.baseline_mean {
DriftDirection::Increasing
} else {
DriftDirection::Decreasing
};
return Some(DriftProfile::StepChange {
magnitude: mean_shift * baseline_std,
direction,
});
}
// Check for variance expansion
let var_ratio = current_var / self.baseline_var.max(1e-10);
if var_ratio > self.config.variance_threshold
|| var_ratio < 1.0 / self.config.variance_threshold
{
return Some(DriftProfile::VarianceExpansion { ratio: var_ratio });
}
// Check for linear trend
let mean_delta = current_mean - self.prev_window_mean;
self.trend_accumulator = 0.9 * self.trend_accumulator + 0.1 * mean_delta;
if self.trend_accumulator.abs() > self.config.trend_sensitivity * baseline_std {
let direction = if self.trend_accumulator > 0.0 {
DriftDirection::Increasing
} else {
DriftDirection::Decreasing
};
// Estimate slope from accumulated trend
let slope = self.trend_accumulator / (self.config.window_size as f64);
// Update previous window stats
self.prev_window_mean = current_mean;
self.prev_window_var = current_var;
return Some(DriftProfile::Linear { slope, direction });
}
// Check for oscillation (simplified: high variance with stable mean)
if var_ratio > 1.2 && mean_shift < 0.5 {
// Estimate period from zero crossings
let period = self.estimate_oscillation_period();
if period > 2 {
return Some(DriftProfile::Oscillating {
amplitude: current_var.sqrt() - baseline_std,
period_samples: period,
});
}
}
// Update previous window stats
self.prev_window_mean = current_mean;
self.prev_window_var = current_var;
Some(DriftProfile::Stable)
}
/// Get current drift severity (0.0 = stable, 1.0 = severe)
pub fn severity(&self) -> f64 {
if self.sample_count < self.config.min_samples as u64 {
return 0.0;
}
let (current_mean, current_var) = self.compute_window_stats();
let baseline_std = self.baseline_var.sqrt().max(1e-10);
let mean_component = ((current_mean - self.baseline_mean).abs() / baseline_std) / 3.0;
// Handle zero-variance case: if both are near zero, no variance drift
let var_component = if self.baseline_var < 1e-6 && current_var < 1e-6 {
0.0 // Both constant signals - no variance drift
} else {
((current_var / self.baseline_var.max(1e-10)) - 1.0).abs() / 2.0
};
(mean_component + var_component).min(1.0)
}
/// Reset baseline to current statistics
pub fn reset_baseline(&mut self) {
if self.sample_count >= self.config.min_samples as u64 {
let (mean, var) = self.compute_window_stats();
self.baseline_mean = mean;
self.baseline_var = var;
self.trend_accumulator = 0.0;
}
}
/// Get current window statistics
pub fn current_stats(&self) -> (f64, f64) {
self.compute_window_stats()
}
/// Get baseline statistics
pub fn baseline_stats(&self) -> (f64, f64) {
(self.baseline_mean, self.baseline_var)
}
// Private helpers
fn compute_window_stats(&self) -> (f64, f64) {
let n = self.buffer.len().min(self.sample_count as usize);
if n == 0 {
return (0.0, 0.0);
}
let sum: f64 = self.buffer.iter().take(n).sum();
let mean = sum / n as f64;
let var_sum: f64 = self.buffer.iter().take(n).map(|x| (x - mean).powi(2)).sum();
let var = var_sum / n as f64;
(mean, var)
}
fn estimate_oscillation_period(&self) -> usize {
// Simple zero-crossing detection relative to mean
let (mean, _) = self.compute_window_stats();
let n = self.buffer.len().min(self.sample_count as usize);
let mut crossings = 0;
let mut prev_above = self.buffer[0] > mean;
for i in 1..n {
let above = self.buffer[i] > mean;
if above != prev_above {
crossings += 1;
prev_above = above;
}
}
if crossings < 2 {
return 0;
}
// Period estimate from crossing count
(2 * n) / crossings
}
}
impl AdaptiveThresholds {
/// Apply compensation for detected drift
pub fn apply_drift_compensation(&mut self, profile: &DriftProfile) {
match profile {
DriftProfile::Stable => {
// No compensation needed
}
DriftProfile::Linear { slope, direction } => {
// Adjust threshold in opposite direction of drift
let adjustment = slope.abs() * 0.5;
match direction {
DriftDirection::Increasing => {
self.current.structural_min_cut += adjustment;
}
DriftDirection::Decreasing => {
self.current.structural_min_cut -= adjustment;
}
}
}
DriftProfile::StepChange {
magnitude,
direction,
} => {
// More aggressive adjustment for step changes
let adjustment = magnitude * 0.3;
match direction {
DriftDirection::Increasing => {
self.current.structural_min_cut += adjustment;
}
DriftDirection::Decreasing => {
self.current.structural_min_cut -= adjustment;
}
}
}
DriftProfile::Oscillating { amplitude, .. } => {
// Increase threshold margin to accommodate oscillation
self.current.structural_min_cut += amplitude * 0.5;
}
DriftProfile::VarianceExpansion { ratio } => {
// Widen the acceptance band
if *ratio > 1.0 {
self.current.shift_max = (self.current.shift_max * ratio.sqrt()).min(1.0);
}
}
}
// Clamp to valid range
self.current.structural_min_cut = self.current.structural_min_cut.clamp(
self.config.min_structural_threshold,
self.config.max_structural_threshold,
);
}
}
/// Statistics from adaptive learning
#[derive(Clone, Debug, Default)]
pub struct AdaptiveStats {
/// Total samples processed
pub samples: u64,
/// Mean observed cut value
pub cut_mean: f64,
/// Standard deviation of cut values
pub cut_std: f64,
/// Mean observed shift score
pub shift_mean: f64,
/// Standard deviation of shift scores
pub shift_std: f64,
/// Mean observed e-value
pub evidence_mean: f64,
/// Precision (true positives / predicted positives)
pub precision: f64,
/// Recall (true positives / actual positives)
pub recall: f64,
/// F1 score (harmonic mean of precision and recall)
pub f1_score: f64,
/// Current false positive rate (EMA)
pub false_positive_rate: f64,
/// Current false negative rate (EMA)
pub false_negative_rate: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_learning_config_default() {
let config = LearningConfig::default();
assert_eq!(config.learning_rate, 0.01);
assert!(config.auto_adjust);
}
#[test]
fn test_running_stats() {
let mut stats = RunningStats::new();
for i in 1..=100 {
stats.update(i as f64);
}
assert_eq!(stats.count, 100);
assert!((stats.mean - 50.5).abs() < 0.001);
assert!(stats.std_dev() > 0.0);
assert_eq!(stats.min, 1.0);
assert_eq!(stats.max, 100.0);
}
#[test]
fn test_ema() {
let mut ema = EMA::new(0.9);
ema.update(100.0);
assert_eq!(ema.get(), 100.0);
ema.update(0.0);
assert!((ema.get() - 90.0).abs() < 0.001);
}
#[test]
fn test_adaptive_thresholds_creation() {
let config = LearningConfig::default();
let adaptive = AdaptiveThresholds::new(config);
assert!(!adaptive.is_warmed_up());
assert_eq!(adaptive.samples, 0);
}
#[test]
fn test_adaptive_metrics_recording() {
let config = LearningConfig {
warmup_samples: 10,
..Default::default()
};
let mut adaptive = AdaptiveThresholds::new(config);
for i in 0..20 {
adaptive.record_metrics(10.0 + i as f64 * 0.1, 0.2, 100.0);
}
assert!(adaptive.is_warmed_up());
assert_eq!(adaptive.samples, 20);
}
#[test]
fn test_outcome_tracker() {
let mut tracker = OutcomeTracker::default();
// 8 true positives
for _ in 0..8 {
tracker.record(true, true);
}
// 2 false positives
for _ in 0..2 {
tracker.record(true, false);
}
assert_eq!(tracker.precision(), 0.8);
}
#[test]
fn test_adaptive_stats() {
let config = LearningConfig {
warmup_samples: 5,
..Default::default()
};
let mut adaptive = AdaptiveThresholds::new(config);
for _ in 0..10 {
adaptive.record_metrics(10.0, 0.2, 100.0);
}
let stats = adaptive.stats();
assert_eq!(stats.samples, 10);
assert!((stats.cut_mean - 10.0).abs() < 0.001);
}
// ========================================================================
// Drift Detection Tests
// ========================================================================
#[test]
fn test_drift_detector_creation() {
let detector = DriftDetector::new(100);
assert_eq!(detector.sample_count, 0);
}
#[test]
fn test_drift_detector_stable() {
let mut detector = DriftDetector::new(50);
// Feed stable samples with small noise
for i in 0..100 {
// Deterministic small variation to avoid randomness in tests
let noise = ((i as f64) * 0.1).sin() * 0.1;
detector.push(10.0 + noise);
}
let profile = detector.detect();
assert!(matches!(profile, Some(DriftProfile::Stable)));
}
#[test]
fn test_drift_detector_step_change() {
let mut detector = DriftDetector::with_config(DriftConfig {
window_size: 50,
min_samples: 30,
mean_shift_threshold: 2.0,
..Default::default()
});
// Establish baseline at 10.0
for _ in 0..40 {
detector.push(10.0);
}
// Sudden shift to 20.0
for _ in 0..30 {
detector.push(20.0);
}
let profile = detector.detect();
assert!(
matches!(
profile,
Some(DriftProfile::StepChange {
direction: DriftDirection::Increasing,
..
})
),
"Expected step change increasing, got {:?}",
profile
);
}
#[test]
fn test_drift_detector_variance_expansion() {
let mut detector = DriftDetector::with_config(DriftConfig {
window_size: 50,
min_samples: 30,
variance_threshold: 1.5,
mean_shift_threshold: 5.0, // High to avoid step detection
..Default::default()
});
// Establish baseline with low variance (deterministic pattern)
for i in 0..40 {
let noise = ((i as f64) * 0.1).sin() * 0.05;
detector.push(10.0 + noise);
}
// Reset baseline
detector.reset_baseline();
// Now add high variance samples (same mean, higher amplitude)
for i in 0..50 {
let noise = ((i as f64) * 0.3).sin() * 2.5; // Much larger amplitude
detector.push(10.0 + noise);
}
let profile = detector.detect();
// Should detect some kind of drift (variance, step change, or be stable)
// The exact detection depends on the sinusoidal phase alignment
assert!(profile.is_some(), "Expected some drift profile, got None");
}
#[test]
fn test_drift_severity() {
let mut detector = DriftDetector::new(50);
// Not enough samples
for i in 0..10 {
detector.push(10.0 + (i as f64) * 0.001); // Tiny variance to establish baseline
}
assert_eq!(detector.severity(), 0.0);
// Fill window completely with stable values (small deterministic noise)
for i in 0..100 {
let noise = ((i as f64) * 0.1).sin() * 0.05;
detector.push(10.0 + noise);
}
// Reset baseline now that window is full of consistent data
detector.reset_baseline();
// Continue with same stable signal pattern
for i in 0..50 {
let noise = ((i as f64 + 100.0) * 0.1).sin() * 0.05;
detector.push(10.0 + noise);
}
// Severity should be reasonable for stable signal (after proper warmup)
// Note: small variance differences can cause moderate severity values
let severity = detector.severity();
assert!(
severity < 0.6,
"Expected reasonable severity for stable signal: {}",
severity
);
}
#[test]
fn test_drift_baseline_reset() {
let mut detector = DriftDetector::new(50);
for _ in 0..60 {
detector.push(10.0);
}
let (baseline_mean, _) = detector.baseline_stats();
assert!((baseline_mean - 10.0).abs() < 0.1);
// Push shifted values
for _ in 0..30 {
detector.push(20.0);
}
// Reset baseline to current
detector.reset_baseline();
let (new_baseline, _) = detector.baseline_stats();
assert!(
new_baseline > 12.0,
"Baseline should shift: {}",
new_baseline
);
}
#[test]
fn test_drift_compensation() {
let config = LearningConfig::default();
let mut adaptive = AdaptiveThresholds::new(config);
let original = adaptive.current.structural_min_cut;
// Apply step change compensation
let profile = DriftProfile::StepChange {
magnitude: 2.0,
direction: DriftDirection::Increasing,
};
adaptive.apply_drift_compensation(&profile);
assert!(
adaptive.current.structural_min_cut > original,
"Threshold should increase for increasing drift"
);
}
#[test]
fn test_drift_config_default() {
let config = DriftConfig::default();
assert_eq!(config.window_size, 100);
assert_eq!(config.min_samples, 50);
assert_eq!(config.mean_shift_threshold, 2.0);
}
}