mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
1082 lines
33 KiB
Rust
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);
|
|
}
|
|
}
|