#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
pub const DIMENSION_COUNT: usize = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[repr(usize)]
pub enum DriftDimension {
IdentityChanges = 0,
ObjectiveAdjustments = 1,
PersonalizationMods = 2,
ClassificationRuleUpdates = 3,
UpgradeEvents = 4,
UpgradeFrequency = 5,
GovernanceModifications = 6,
}
impl DriftDimension {
pub const ALL: [DriftDimension; DIMENSION_COUNT] = [
DriftDimension::IdentityChanges,
DriftDimension::ObjectiveAdjustments,
DriftDimension::PersonalizationMods,
DriftDimension::ClassificationRuleUpdates,
DriftDimension::UpgradeEvents,
DriftDimension::UpgradeFrequency,
DriftDimension::GovernanceModifications,
];
pub fn label(self) -> &'static str {
match self {
Self::IdentityChanges => "identity_changes",
Self::ObjectiveAdjustments => "objective_adjustments",
Self::PersonalizationMods => "personalization_mods",
Self::ClassificationRuleUpdates => "classification_rule_updates",
Self::UpgradeEvents => "upgrade_events",
Self::UpgradeFrequency => "upgrade_frequency",
Self::GovernanceModifications => "governance_modifications",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftThresholds {
composite: f64,
window_secs: u64,
weights: [f64; DIMENSION_COUNT],
}
impl DriftThresholds {
pub fn new(composite: f64, window_secs: u64) -> Self {
Self {
composite,
window_secs,
weights: [1.0, 1.5, 1.0, 2.0, 2.0, 1.0, 2.5],
}
}
pub fn with_weights(composite: f64, window_secs: u64, weights: [f64; DIMENSION_COUNT]) -> Self {
Self {
composite,
window_secs,
weights,
}
}
pub fn composite(&self) -> f64 {
self.composite
}
pub fn window_secs(&self) -> u64 {
self.window_secs
}
pub fn weights(&self) -> &[f64; DIMENSION_COUNT] {
&self.weights
}
}
#[derive(Debug, Clone)]
struct DriftEvent {
dimension: DriftDimension,
delta: f64,
timestamp: u64,
}
#[derive(Debug, Default)]
struct DriftAccumulator {
events: Vec<DriftEvent>,
}
impl DriftAccumulator {
fn expire(&mut self, cutoff: u64) {
self.events.retain(|e| e.timestamp >= cutoff);
}
fn push(&mut self, event: DriftEvent) {
self.events.push(event);
}
fn dimension_totals(&self) -> [f64; DIMENSION_COUNT] {
let mut totals = [0.0f64; DIMENSION_COUNT];
for e in &self.events {
totals[e.dimension as usize] += e.delta;
}
totals
}
fn event_count(&self) -> usize {
self.events.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftReport {
pub dimension_scores: [f64; DIMENSION_COUNT],
pub composite_score: f64,
pub breached: bool,
pub threshold: f64,
pub window_secs: u64,
pub event_count: usize,
pub conservative_mode_required: bool,
}
pub struct DriftEngine {
thresholds: DriftThresholds,
accumulator: DriftAccumulator,
}
impl DriftEngine {
pub fn new(thresholds: DriftThresholds) -> Self {
Self {
thresholds,
accumulator: DriftAccumulator::default(),
}
}
pub fn record(&mut self, dimension: DriftDimension, delta: f64, now: u64) -> bool {
let cutoff = now.saturating_sub(self.thresholds.window_secs);
self.accumulator.expire(cutoff);
self.accumulator.push(DriftEvent {
dimension,
delta,
timestamp: now,
});
self.is_breached()
}
pub fn report(&self) -> DriftReport {
let dimension_scores = self.accumulator.dimension_totals();
let composite = self.weighted_composite(&dimension_scores);
let breached = composite >= self.thresholds.composite;
DriftReport {
dimension_scores,
composite_score: composite,
breached,
threshold: self.thresholds.composite,
window_secs: self.thresholds.window_secs,
event_count: self.accumulator.event_count(),
conservative_mode_required: breached,
}
}
pub fn is_breached(&self) -> bool {
let totals = self.accumulator.dimension_totals();
self.weighted_composite(&totals) >= self.thresholds.composite
}
pub fn thresholds(&self) -> &DriftThresholds {
&self.thresholds
}
pub fn event_count(&self) -> usize {
self.accumulator.event_count()
}
fn weighted_composite(&self, dim_scores: &[f64; DIMENSION_COUNT]) -> f64 {
let mut sum = 0.0f64;
for (i, &score) in dim_scores.iter().enumerate() {
sum += self.thresholds.weights[i] * score;
}
sum
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_thresholds() -> DriftThresholds {
DriftThresholds::new(10.0, 30 * 86400)
}
#[test]
fn dft_t01_single_dimension_below_threshold() {
let mut engine = DriftEngine::new(default_thresholds());
let breached = engine.record(DriftDimension::IdentityChanges, 0.5, 1000);
assert!(!breached, "single small delta must not breach");
assert!(!engine.is_breached());
let report = engine.report();
assert!(!report.breached);
assert!(!report.conservative_mode_required);
assert!(
report.composite_score < 10.0,
"composite {:.2} must be below threshold 10.0",
report.composite_score
);
assert_eq!(report.event_count, 1);
}
#[test]
fn dft_t02_single_dimension_at_threshold() {
let mut engine = DriftEngine::new(default_thresholds());
let breached = engine.record(DriftDimension::GovernanceModifications, 4.0, 1000);
assert!(breached, "composite == threshold must be breached");
assert!(engine.is_breached());
let report = engine.report();
assert!(report.breached);
assert!(report.conservative_mode_required);
assert!(
(report.composite_score - 10.0).abs() < f64::EPSILON,
"composite {:.4} should be exactly 10.0",
report.composite_score
);
}
#[test]
fn dft_t03_sub_threshold_accumulation() {
let mut engine = DriftEngine::new(default_thresholds());
let now = 1000;
for i in 0..3 {
let b = engine.record(DriftDimension::IdentityChanges, 1.0, now + i);
assert!(!b, "step {} should not breach", i);
}
for i in 0..2 {
let b = engine.record(DriftDimension::ObjectiveAdjustments, 1.0, now + 10 + i);
assert!(!b, "obj step {} should not breach", i);
}
let b = engine.record(DriftDimension::GovernanceModifications, 1.0, now + 20);
assert!(!b, "gov step should not breach yet (composite=8.5)");
let b = engine.record(DriftDimension::GovernanceModifications, 1.0, now + 21);
assert!(b, "accumulated changes must breach threshold");
assert!(engine.is_breached());
}
#[test]
fn dft_t04_rolling_window_expiry() {
let thresholds = DriftThresholds::new(10.0, 100);
let mut engine = DriftEngine::new(thresholds);
let b = engine.record(DriftDimension::GovernanceModifications, 5.0, 0);
assert!(b, "should be breached immediately");
let b = engine.record(DriftDimension::IdentityChanges, 0.1, 200);
assert!(
!b,
"after old events expire, score should drop below threshold"
);
let report = engine.report();
assert!(
report.composite_score < 10.0,
"composite {:.2} should be well below threshold after expiry",
report.composite_score
);
assert_eq!(report.event_count, 1, "only the new event should remain");
}
#[test]
fn dft_t05_threshold_immutability() {
let thresholds = DriftThresholds::new(10.0, 30 * 86400);
let engine = DriftEngine::new(thresholds);
assert!(
(engine.thresholds().composite() - 10.0).abs() < f64::EPSILON,
"threshold must remain at constructed value"
);
assert_eq!(engine.thresholds().window_secs(), 30 * 86400);
let w = engine.thresholds().weights();
let expected = [1.0, 1.5, 1.0, 2.0, 2.0, 1.0, 2.5];
for (i, (&got, &exp)) in w.iter().zip(expected.iter()).enumerate() {
assert!(
(got - exp).abs() < f64::EPSILON,
"weight[{}] = {:.1}, expected {:.1}",
i,
got,
exp
);
}
}
#[test]
fn dft_t06_breach_triggers_conservative_mode() {
let mut engine = DriftEngine::new(default_thresholds());
engine.record(DriftDimension::UpgradeEvents, 1.0, 100);
let report = engine.report();
assert!(!report.conservative_mode_required);
engine.record(DriftDimension::ClassificationRuleUpdates, 2.0, 200);
engine.record(DriftDimension::GovernanceModifications, 2.0, 300);
let report = engine.report();
assert!(
report.breached,
"composite {:.1} must exceed threshold {:.1}",
report.composite_score,
report.threshold
);
assert!(
report.conservative_mode_required,
"breached drift MUST signal Conservative Mode"
);
let signal = engine.record(DriftDimension::IdentityChanges, 0.01, 301);
assert!(
signal,
"record() must return true while breached (Conservative Mode signal)"
);
}
}