1#[cfg(feature = "sql")]
2pub mod psi_drifter {
3
4 use crate::error::DriftError;
5 use crate::psi::monitor::PsiMonitor;
6 use crate::psi::types::{FeatureBinMapping, FeatureBinProportionPairs};
7 use chrono::{DateTime, Utc};
8 use rayon::iter::{IntoParallelIterator, ParallelIterator};
9 use scouter_dispatch::AlertDispatcher;
10 use scouter_settings::ObjectStorageSettings;
11 use scouter_sql::sql::traits::PsiSqlLogic;
12 use scouter_sql::{sql::cache::entity_cache, PostgresClient};
13 use scouter_types::psi::{
14 BinnedPsiFeatureMetrics, BinnedPsiMetric, FeatureDistributions, PsiDriftProfile,
15 PsiFeatureAlert, PsiFeatureAlerts, PsiFeatureDriftProfile,
16 };
17 use scouter_types::AlertMap;
18 use scouter_types::{contracts::DriftRequest, ProfileBaseArgs};
19
20 use sqlx::{Pool, Postgres};
21 use std::collections::{BTreeMap, HashMap};
22 use tracing::info;
23 use tracing::{debug, error, instrument};
24
25 pub struct PsiDrifter {
26 profile: PsiDriftProfile,
27 }
28
29 impl PsiDrifter {
30 pub fn new(profile: PsiDriftProfile) -> Self {
31 Self { profile }
32 }
33
34 fn get_monitored_profiles(&self) -> Vec<PsiFeatureDriftProfile> {
35 self.profile
36 .config
37 .alert_config
38 .features_to_monitor
39 .iter()
40 .map(|key| self.profile.features[key].clone())
41 .collect()
42 }
43
44 async fn resolve_target_feature_distributions(
45 &self,
46 limit_datetime: &DateTime<Utc>,
47 db_pool: &Pool<Postgres>,
48 ) -> Result<Option<FeatureDistributions>, DriftError> {
49 let entity_id = entity_cache()
50 .get_entity_id_from_uid(db_pool, &self.profile.config.uid)
51 .await?;
52 let feature_distributions = PostgresClient::get_feature_distributions(
53 db_pool,
54 limit_datetime,
55 &self.profile.config.alert_config.features_to_monitor,
56 &entity_id,
57 )
58 .await
59 .inspect_err(|e| {
60 error!(
61 "Error: Unable to fetch feature bin proportions from DB for {}/{}/{}: {}",
62 self.profile.space(),
63 self.profile.name(),
64 self.profile.version(),
65 e
66 );
67 })?;
68
69 if feature_distributions.is_empty() {
70 info!(
71 "No enough target samples collected for {}/{}/{}. Skipping alert processing.",
72 self.profile.space(),
73 self.profile.name(),
74 self.profile.version(),
75 );
76 return Ok(None);
77 }
78
79 Ok(Some(feature_distributions))
80 }
81
82 fn get_feature_alerts(
83 &self,
84 drift_map: &HashMap<String, f64>,
85 target_feature_distributions: &FeatureDistributions,
86 ) -> Vec<PsiFeatureAlert> {
87 let threshold_cfg = &self.profile.config.alert_config.threshold;
88
89 drift_map
90 .iter()
91 .filter_map(|(feature, drift)| {
92 let target_sample_size = target_feature_distributions
93 .distributions
94 .get(feature)?
95 .sample_size;
96 let number_of_bins = self.profile.features.get(feature)?.bins.len();
97 let threshold =
98 threshold_cfg.compute_threshold(target_sample_size, number_of_bins as u64);
99
100 (*drift > threshold).then(|| PsiFeatureAlert {
101 feature: feature.clone(),
102 drift: *drift,
103 threshold,
104 })
105 })
106 .collect()
107 }
108
109 async fn generate_alerts(
110 &self,
111 drift_map: &HashMap<String, f64>,
112 target_feature_distributions: &FeatureDistributions,
113 ) -> Result<Option<Vec<AlertMap>>, DriftError> {
114 let alert_dispatcher = AlertDispatcher::new(&self.profile.config).inspect_err(|e| {
115 error!(
116 "Error creating alert dispatcher for {}/{}/{}: {}",
117 self.profile.space(),
118 self.profile.name(),
119 self.profile.version(),
120 e
121 );
122 })?;
123
124 let alerts = self.get_feature_alerts(drift_map, target_feature_distributions);
125
126 if alerts.is_empty() {
127 info!(
128 "No alerts to process for {}/{}/{}",
129 self.profile.space(),
130 self.profile.name(),
131 self.profile.version(),
132 );
133 return Ok(None);
134 }
135
136 alert_dispatcher
137 .process_alerts(&PsiFeatureAlerts {
138 alerts: alerts.clone(),
139 })
140 .await
141 .inspect_err(|e| {
142 error!(
143 "Error processing alerts for {}/{}/{}: {}",
144 self.profile.space(),
145 self.profile.name(),
146 self.profile.version(),
147 e
148 );
149 })?;
150 Ok(Some(alerts.into_iter().map(|a| a.into()).collect()))
151 }
152
153 fn get_drift_map(
154 target_feature_distributions: &FeatureDistributions,
155 profiles_to_monitor: &[PsiFeatureDriftProfile],
156 ) -> Result<HashMap<String, f64>, DriftError> {
157 let feature_bin_proportion_pairs = FeatureBinMapping::from_observed_bin_proportions(
158 target_feature_distributions,
159 profiles_to_monitor,
160 )?;
161
162 Ok(feature_bin_proportion_pairs
163 .features
164 .iter()
165 .map(|(feature, pairs)| (feature.clone(), PsiMonitor::compute_psi(&pairs.pairs)))
166 .collect())
167 }
168
169 pub async fn check_for_alerts(
170 &self,
171 db_pool: &Pool<Postgres>,
172 previous_run: &DateTime<Utc>,
173 ) -> Result<Option<Vec<AlertMap>>, DriftError> {
174 let profiles_to_monitor = self.get_monitored_profiles();
176
177 if profiles_to_monitor.is_empty() {
178 return Ok(None);
179 }
180
181 let Some(target_feature_distributions) = self
183 .resolve_target_feature_distributions(previous_run, db_pool)
184 .await?
185 else {
186 return Ok(None);
187 };
188
189 let drift_map =
191 Self::get_drift_map(&target_feature_distributions, &profiles_to_monitor)?;
192
193 let Some(alerts) = self
195 .generate_alerts(&drift_map, &target_feature_distributions)
196 .await
197 .inspect_err(|e| {
198 error!(
199 "Error generating alerts for {}/{}/{}: {}",
200 self.profile.space(),
201 self.profile.name(),
202 self.profile.version(),
203 e
204 );
205 })?
206 else {
207 return Ok(None);
208 };
209
210 Ok(Some(alerts))
211 }
212
213 fn create_feature_bin_proportion_pairs(
214 &self,
215 feature: &str,
216 bin_proportions: &BTreeMap<i32, f64>,
217 ) -> Result<FeatureBinProportionPairs, DriftError> {
218 let profile = match self.profile.features.get(feature) {
220 Some(profile) => profile,
221 None => {
222 error!("Error: Unable to fetch profile for feature {}", feature);
223 return Err(DriftError::ProcessAlertError);
224 }
225 };
226
227 let proportion_pairs =
228 FeatureBinProportionPairs::from_observed_bin_proportions(bin_proportions, profile)
229 .unwrap();
230
231 Ok(proportion_pairs)
232 }
233
234 #[instrument(skip_all)]
242 pub async fn get_binned_drift_map(
243 &self,
244 drift_request: &DriftRequest,
245 db_pool: &Pool<Postgres>,
246 retention_period: &i32,
247 storage_settings: &ObjectStorageSettings,
248 entity_id: &i32,
249 ) -> Result<BinnedPsiFeatureMetrics, DriftError> {
250 debug!(
251 "Getting binned drift map for {}/{}/{}",
252 self.profile.space(),
253 self.profile.name(),
254 self.profile.version(),
255 );
256 let binned_records = PostgresClient::get_binned_psi_drift_records(
257 db_pool,
258 drift_request,
259 retention_period,
260 storage_settings,
261 entity_id,
262 )
263 .await?;
264
265 if binned_records.is_empty() {
266 info!(
267 "No binned drift records available for {}/{}/{}",
268 self.profile.space(),
269 self.profile.name(),
270 self.profile.version(),
271 );
272 return Ok(BinnedPsiFeatureMetrics::default());
273 }
274
275 let binned_map = binned_records
277 .into_par_iter()
278 .filter(|record| self.profile.features.contains_key(&record.feature))
280 .map(|record| -> Result<_, DriftError> {
282 let psi_vec: Result<Vec<_>, DriftError> = record
283 .bin_proportions
284 .iter()
285 .map(|bin_proportion| {
286 let proportions = self.create_feature_bin_proportion_pairs(
287 &record.feature,
288 bin_proportion,
289 )?;
290 let psi = PsiMonitor::compute_psi(&proportions.pairs);
291 Ok(psi)
292 })
293 .collect();
294
295 let overall_proportions = self.create_feature_bin_proportion_pairs(
296 &record.feature,
297 &record.overall_proportions,
298 )?;
299 let overall_psi = PsiMonitor::compute_psi(&overall_proportions.pairs);
300
301 Ok((
302 record.feature.clone(),
303 BinnedPsiMetric {
304 created_at: record.created_at,
305 psi: psi_vec?,
306 overall_psi,
307 bins: record.overall_proportions,
308 },
309 ))
310
311 })
313 .collect::<Result<BTreeMap<String, BinnedPsiMetric>, DriftError>>()?;
314
315 Ok(BinnedPsiFeatureMetrics {
316 features: binned_map,
317 })
318 }
319 }
320
321 #[cfg(test)]
322 mod tests {
323 use super::*;
324 use ndarray::Array;
325 use ndarray_rand::rand_distr::Uniform;
326 use ndarray_rand::RandomExt;
327 use scouter_types::psi::{Bin, BinType, PsiNormalThreshold, PsiThreshold};
328 use scouter_types::psi::{
329 DistributionData, FeatureDistributions, PsiAlertConfig, PsiDriftConfig,
330 };
331
332 fn get_test_drifter(threshold: PsiThreshold) -> PsiDrifter {
333 let alert_config = PsiAlertConfig {
334 features_to_monitor: vec!["feature_1".to_string(), "feature_3".to_string()],
335 threshold,
336 ..Default::default()
337 };
338 let config = PsiDriftConfig {
339 space: "name".to_string(),
340 name: "repo".to_string(),
341 alert_config,
342 ..Default::default()
343 };
344
345 let array = Array::random((1030, 3), Uniform::new(1.0, 100.0).unwrap());
346
347 let features = vec![
348 "feature_1".to_string(),
349 "feature_2".to_string(),
350 "feature_3".to_string(),
351 ];
352
353 let monitor = PsiMonitor::new();
354
355 let profile = monitor
356 .create_2d_drift_profile(&features, &array.view(), &config)
357 .unwrap();
358
359 PsiDrifter::new(profile)
360 }
361
362 #[test]
363 fn test_get_drift_map_only_maps_matching_features() {
364 let mut distributions = BTreeMap::new();
367
368 let mut bins1 = BTreeMap::new();
369 bins1.insert(0, 0.3);
370 bins1.insert(1, 0.4);
371 bins1.insert(2, 0.3);
372
373 distributions.insert(
374 "feature1".to_string(),
375 DistributionData {
376 sample_size: 1000,
377 bins: bins1,
378 },
379 );
380
381 let mut bins2 = BTreeMap::new();
382 bins2.insert(0, 0.25);
383 bins2.insert(1, 0.5);
384 bins2.insert(2, 0.25);
385
386 distributions.insert(
387 "feature2".to_string(),
388 DistributionData {
389 sample_size: 800,
390 bins: bins2,
391 },
392 );
393
394 let target_distributions = FeatureDistributions { distributions };
395
396 let profiles = vec![
398 PsiFeatureDriftProfile {
399 id: "feature1".to_string(), bins: vec![Bin {
401 id: 0,
402 lower_limit: None,
403 upper_limit: Some(10.0),
404 proportion: 0.35,
405 }],
406 timestamp: Utc::now(),
407 bin_type: BinType::Numeric,
408 },
409 PsiFeatureDriftProfile {
410 id: "feature2".to_string(), bins: vec![Bin {
412 id: 0,
413 lower_limit: None,
414 upper_limit: Some(10.0),
415 proportion: 0.5,
416 }],
417 timestamp: Utc::now(),
418 bin_type: BinType::Numeric,
419 },
420 ];
421
422 let result = PsiDrifter::get_drift_map(&target_distributions, &profiles);
424
425 assert!(result.is_ok());
427 let drift_map = result.unwrap();
428
429 assert_eq!(drift_map.len(), 2);
431 assert!(drift_map.contains_key("feature1"));
432 assert!(drift_map.contains_key("feature2"));
433 }
434
435 #[test]
436 fn test_get_feature_alerts_all_above() {
437 let bin_count = 10;
438 let sample_size = 10000;
439 let threshold = PsiNormalThreshold { alpha: 0.05 };
440 let result = threshold.compute_threshold(sample_size, bin_count);
441
442 let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
443
444 let feature_1 = "feature_1";
445 let feature_2 = "feature_2";
446 let feature_3 = "feature_3";
447
448 let mut drift_map = HashMap::new();
449 drift_map.insert(feature_1.to_string(), result + 0.1);
450 drift_map.insert(feature_2.to_string(), result + 0.1);
451 drift_map.insert(feature_3.to_string(), result + 0.1);
452
453 let mut distributions = BTreeMap::new();
454 let mut bins = BTreeMap::new();
455 for i in 0..bin_count {
456 bins.insert(i as i32, (sample_size / bin_count) as f64);
457 }
458
459 distributions.insert(
460 feature_1.to_string(),
461 DistributionData {
462 sample_size,
463 bins: bins.clone(),
464 },
465 );
466 distributions.insert(
467 feature_2.to_string(),
468 DistributionData {
469 sample_size,
470 bins: bins.clone(),
471 },
472 );
473 distributions.insert(
474 feature_3.to_string(),
475 DistributionData {
476 sample_size,
477 bins: bins.clone(),
478 },
479 );
480
481 let target_feature_distributions = FeatureDistributions { distributions };
482
483 let alerts = drifter_with_normal_threshold
484 .get_feature_alerts(&drift_map, &target_feature_distributions);
485
486 assert_eq!(alerts.len(), 3);
487 }
488
489 #[test]
490 fn test_get_feature_alerts_all_below() {
491 let bin_count = 10;
492 let sample_size = 10000;
493 let threshold = PsiNormalThreshold { alpha: 0.05 };
494 let result = threshold.compute_threshold(sample_size, bin_count);
495
496 let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
497
498 let feature_1 = "feature_1";
499 let feature_2 = "feature_2";
500 let feature_3 = "feature_3";
501
502 let mut drift_map = HashMap::new();
503 drift_map.insert(feature_1.to_string(), result - 0.1); drift_map.insert(feature_2.to_string(), result - 0.1); drift_map.insert(feature_3.to_string(), result - 0.1); let mut distributions = BTreeMap::new();
508 let mut bins = BTreeMap::new();
509 for i in 0..bin_count {
510 bins.insert(i as i32, (sample_size / bin_count) as f64);
511 }
512
513 distributions.insert(
514 feature_1.to_string(),
515 DistributionData {
516 sample_size,
517 bins: bins.clone(),
518 },
519 );
520 distributions.insert(
521 feature_2.to_string(),
522 DistributionData {
523 sample_size,
524 bins: bins.clone(),
525 },
526 );
527 distributions.insert(
528 feature_3.to_string(),
529 DistributionData {
530 sample_size,
531 bins: bins.clone(),
532 },
533 );
534
535 let target_feature_distributions = FeatureDistributions { distributions };
536
537 let alerts = drifter_with_normal_threshold
538 .get_feature_alerts(&drift_map, &target_feature_distributions);
539
540 assert_eq!(alerts.len(), 0); }
542
543 #[test]
544 fn test_get_feature_alerts_mixed_above_below() {
545 let bin_count = 10;
546 let sample_size = 10000;
547 let threshold = PsiNormalThreshold { alpha: 0.05 };
548 let result = threshold.compute_threshold(sample_size, bin_count);
549
550 let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
551
552 let feature_1 = "feature_1";
553 let feature_2 = "feature_2";
554 let feature_3 = "feature_3";
555
556 let mut drift_map = HashMap::new();
557 drift_map.insert(feature_1.to_string(), result + 0.1); drift_map.insert(feature_2.to_string(), result - 0.1); drift_map.insert(feature_3.to_string(), result + 0.2); let mut distributions = BTreeMap::new();
562 let mut bins = BTreeMap::new();
563 for i in 0..bin_count {
564 bins.insert(i as i32, (sample_size / bin_count) as f64);
565 }
566
567 distributions.insert(
568 feature_1.to_string(),
569 DistributionData {
570 sample_size,
571 bins: bins.clone(),
572 },
573 );
574 distributions.insert(
575 feature_2.to_string(),
576 DistributionData {
577 sample_size,
578 bins: bins.clone(),
579 },
580 );
581 distributions.insert(
582 feature_3.to_string(),
583 DistributionData {
584 sample_size,
585 bins: bins.clone(),
586 },
587 );
588
589 let target_feature_distributions = FeatureDistributions { distributions };
590
591 let alerts = drifter_with_normal_threshold
592 .get_feature_alerts(&drift_map, &target_feature_distributions);
593
594 assert_eq!(alerts.len(), 2); let alert_features: Vec<String> = alerts.iter().map(|a| a.feature.clone()).collect();
598 assert!(alert_features.contains(&feature_1.to_string()));
599 assert!(alert_features.contains(&feature_3.to_string()));
600 assert!(!alert_features.contains(&feature_2.to_string()));
601 }
602
603 #[test]
604 fn test_get_feature_alerts_drift_exactly_at_threshold() {
605 let bin_count = 10;
606 let sample_size = 10000;
607 let threshold = PsiNormalThreshold { alpha: 0.05 };
608 let result = threshold.compute_threshold(sample_size, bin_count);
609
610 let drifter_with_normal_threshold = get_test_drifter(PsiThreshold::Normal(threshold));
611
612 let feature_1 = "feature_1";
613
614 let mut drift_map = HashMap::new();
615 drift_map.insert(feature_1.to_string(), result); let mut distributions = BTreeMap::new();
618 let mut bins = BTreeMap::new();
619 for i in 0..bin_count {
620 bins.insert(i as i32, (sample_size / bin_count) as f64);
621 }
622
623 distributions.insert(
624 feature_1.to_string(),
625 DistributionData {
626 sample_size,
627 bins: bins.clone(),
628 },
629 );
630
631 let target_feature_distributions = FeatureDistributions { distributions };
632
633 let alerts = drifter_with_normal_threshold
634 .get_feature_alerts(&drift_map, &target_feature_distributions);
635
636 assert_eq!(alerts.len(), 0); }
638
639 #[test]
640 fn test_get_monitored_profiles() {
641 let drifter = get_test_drifter(PsiThreshold::default());
642
643 let profiles_to_monitor = drifter.get_monitored_profiles();
644
645 assert_eq!(profiles_to_monitor.len(), 2);
646
647 assert!(
648 profiles_to_monitor[0].id == "feature_1"
649 || profiles_to_monitor[0].id == "feature_3"
650 );
651 assert!(
652 profiles_to_monitor[1].id == "feature_1"
653 || profiles_to_monitor[1].id == "feature_3"
654 );
655 }
656 }
657}