1use super::column::{ColumnProfile, RunProfile};
9use super::spec::ProfilingSpec;
10use crate::anomaly;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use std::fmt;
15
16const PSI_EPS: f64 = 1e-4;
19const PSI_MIN_COVERAGE: f64 = 0.5;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum DriftMetric {
29 NullRate,
30 Distinct,
31 Mean,
32 Min,
33 Max,
34 StringLength,
35 TypeMix,
38 NewValue,
40 VanishedValue,
42 Psi,
45}
46
47impl DriftMetric {
48 pub fn as_str(self) -> &'static str {
50 match self {
51 DriftMetric::NullRate => "null_rate",
52 DriftMetric::Distinct => "distinct",
53 DriftMetric::Mean => "mean",
54 DriftMetric::Min => "min",
55 DriftMetric::Max => "max",
56 DriftMetric::StringLength => "string_length",
57 DriftMetric::TypeMix => "type_mix",
58 DriftMetric::NewValue => "new_value",
59 DriftMetric::VanishedValue => "vanished_value",
60 DriftMetric::Psi => "psi",
61 }
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67pub struct ProfileDrift {
68 pub column: String,
69 pub metric: DriftMetric,
70 pub observed: f64,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub baseline: Option<f64>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub value: Option<String>,
79 pub detail: String,
80}
81
82impl fmt::Display for ProfileDrift {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(
85 f,
86 "{}.{}: {}",
87 self.column,
88 self.metric.as_str(),
89 self.detail
90 )
91 }
92}
93
94pub fn detect_drift(
97 baseline: &[RunProfile],
98 current: &RunProfile,
99 spec: &ProfilingSpec,
100) -> Vec<ProfileDrift> {
101 let min_history = spec.min_history as usize;
102 if baseline.len() < min_history {
103 return Vec::new();
104 }
105 let sensitivity = spec.effective_sensitivity();
106 let mut out = Vec::new();
107 for (name, col) in ¤t.columns {
108 let history: Vec<&ColumnProfile> = baseline
109 .iter()
110 .filter_map(|b| b.columns.get(name))
111 .collect();
112 if history.len() < min_history {
113 continue;
114 }
115 for metric in [
116 DriftMetric::NullRate,
117 DriftMetric::Distinct,
118 DriftMetric::Mean,
119 DriftMetric::Min,
120 DriftMetric::Max,
121 DriftMetric::StringLength,
122 ] {
123 let Some(x) = extract(col, metric) else {
124 continue;
125 };
126 let series: Vec<f64> = history.iter().filter_map(|h| extract(h, metric)).collect();
127 if series.len() < min_history {
128 continue;
129 }
130 let mean = series.iter().sum::<f64>() / series.len() as f64;
131 if !exceeds_floor(metric, x, mean) {
132 continue;
133 }
134 if let Some(why) = anomaly::detect(spec.method, &series, x, sensitivity) {
135 out.push(ProfileDrift {
136 column: name.clone(),
137 metric,
138 observed: x,
139 baseline: Some(mean),
140 value: None,
141 detail: format!(
142 "{} {} vs baseline mean {} — {why}",
143 metric.as_str(),
144 fmt_num(x),
145 fmt_num(mean)
146 ),
147 });
148 }
149 }
150 out.extend(type_mix_drift(name, col, &history, spec));
151 out.extend(categorical_drift(name, col, &history, spec));
152 }
153 out
154}
155
156fn extract(c: &ColumnProfile, metric: DriftMetric) -> Option<f64> {
157 match metric {
158 DriftMetric::NullRate => Some(c.null_rate),
159 DriftMetric::Distinct => Some(c.distinct as f64),
160 DriftMetric::Mean => c.numeric.as_ref().map(|n| n.mean),
161 DriftMetric::Min => c.numeric.as_ref().map(|n| n.min),
162 DriftMetric::Max => c.numeric.as_ref().map(|n| n.max),
163 DriftMetric::StringLength => c.string.as_ref().map(|s| s.len_mean),
164 _ => None,
165 }
166}
167
168fn exceeds_floor(metric: DriftMetric, x: f64, mean: f64) -> bool {
172 let delta = (x - mean).abs();
173 match metric {
174 DriftMetric::NullRate => delta >= 0.01,
175 DriftMetric::Distinct => delta > 0.05 * mean.abs().max(1.0),
176 _ => delta > 0.01 * mean.abs(),
177 }
178}
179
180fn fmt_num(x: f64) -> String {
181 if x.fract() == 0.0 && x.abs() < 1e15 {
182 format!("{x:.0}")
183 } else {
184 format!("{x:.4}")
185 }
186}
187
188fn type_shares(c: &ColumnProfile) -> BTreeMap<&'static str, f64> {
190 let non_null = c.non_null() as f64;
191 if non_null == 0.0 {
192 return BTreeMap::new();
193 }
194 c.types
195 .present()
196 .into_iter()
197 .filter(|(t, _)| *t != "null")
198 .map(|(t, n)| (t, n as f64 / non_null))
199 .collect()
200}
201
202fn type_mix_drift(
203 name: &str,
204 col: &ColumnProfile,
205 history: &[&ColumnProfile],
206 spec: &ProfilingSpec,
207) -> Vec<ProfileDrift> {
208 let mut seen: BTreeSet<&'static str> = BTreeSet::new();
209 for h in history {
210 seen.extend(type_shares(h).into_keys());
211 }
212 type_shares(col)
213 .into_iter()
214 .filter(|(t, share)| !seen.contains(t) && *share >= spec.new_value_min_share)
215 .map(|(t, share)| ProfileDrift {
216 column: name.to_string(),
217 metric: DriftMetric::TypeMix,
218 observed: share,
219 baseline: None,
220 value: Some(t.to_string()),
221 detail: format!(
222 "{:.1}% of values are {t}; the baseline ({} runs) held none",
223 share * 100.0,
224 history.len()
225 ),
226 })
227 .collect()
228}
229
230fn categorical_drift(
231 name: &str,
232 col: &ColumnProfile,
233 history: &[&ColumnProfile],
234 spec: &ProfilingSpec,
235) -> Vec<ProfileDrift> {
236 let Some(current) = col.top_values.as_ref() else {
237 return Vec::new();
238 };
239 let baseline_lists: Vec<&Vec<super::column::TopValue>> = history
240 .iter()
241 .filter_map(|h| h.top_values.as_ref())
242 .collect();
243 if baseline_lists.len() < spec.min_history as usize {
244 return Vec::new();
245 }
246 let runs = baseline_lists.len() as f64;
247 let mut baseline_share: BTreeMap<&str, f64> = BTreeMap::new();
249 let mut runs_present: BTreeMap<&str, usize> = BTreeMap::new();
250 for list in &baseline_lists {
251 for tv in *list {
252 *baseline_share.entry(tv.value.as_str()).or_default() += tv.share / runs;
253 *runs_present.entry(tv.value.as_str()).or_default() += 1;
254 }
255 }
256 let current_share: BTreeMap<&str, f64> = current
257 .iter()
258 .map(|t| (t.value.as_str(), t.share))
259 .collect();
260 let mut out = Vec::new();
261
262 for tv in current {
263 if tv.share >= spec.new_value_min_share && !baseline_share.contains_key(tv.value.as_str()) {
264 out.push(ProfileDrift {
265 column: name.to_string(),
266 metric: DriftMetric::NewValue,
267 observed: tv.share,
268 baseline: None,
269 value: Some(tv.value.clone()),
270 detail: format!(
271 "value {:?} is {:.1}% of the run; never among the top values in {} baseline runs",
272 tv.value,
273 tv.share * 100.0,
274 baseline_lists.len()
275 ),
276 });
277 }
278 }
279
280 let list_full = current.len() >= spec.top_values;
281 let smallest_current = current
282 .iter()
283 .map(|t| t.share)
284 .fold(f64::INFINITY, f64::min);
285 for (value, share) in &baseline_share {
286 let in_every_run = runs_present.get(value).copied().unwrap_or(0) == baseline_lists.len();
287 if !in_every_run || *share < spec.new_value_min_share {
288 continue;
289 }
290 if current_share.contains_key(value) {
291 continue;
292 }
293 if list_full && *share <= smallest_current {
296 continue;
297 }
298 out.push(ProfileDrift {
299 column: name.to_string(),
300 metric: DriftMetric::VanishedValue,
301 observed: 0.0,
302 baseline: Some(*share),
303 value: Some((*value).to_string()),
304 detail: format!(
305 "value {value:?} averaged {:.1}% across {} baseline runs and is absent now",
306 share * 100.0,
307 baseline_lists.len()
308 ),
309 });
310 }
311
312 let baseline_coverage: f64 = baseline_share.values().sum();
313 let current_coverage: f64 = current_share.values().sum();
314 let psi = population_stability_index(&baseline_share, ¤t_share);
315 if baseline_coverage >= PSI_MIN_COVERAGE
316 && current_coverage >= PSI_MIN_COVERAGE
317 && psi > spec.psi_threshold
318 {
319 out.push(ProfileDrift {
320 column: name.to_string(),
321 metric: DriftMetric::Psi,
322 observed: psi,
323 baseline: None,
324 value: None,
325 detail: format!(
326 "value distribution shifted: PSI {psi:.3} exceeds {} ({} baseline runs)",
327 spec.psi_threshold,
328 baseline_lists.len()
329 ),
330 });
331 }
332 out
333}
334
335pub fn population_stability_index(
338 baseline: &BTreeMap<&str, f64>,
339 current: &BTreeMap<&str, f64>,
340) -> f64 {
341 let keys: BTreeSet<&str> = baseline.keys().chain(current.keys()).copied().collect();
342 let mut b_other = 1.0 - baseline.values().sum::<f64>();
343 let mut c_other = 1.0 - current.values().sum::<f64>();
344 b_other = b_other.clamp(0.0, 1.0);
345 c_other = c_other.clamp(0.0, 1.0);
346 let term = |b: f64, c: f64| {
347 let b = b.clamp(0.0, 1.0) + PSI_EPS;
348 let c = c.clamp(0.0, 1.0) + PSI_EPS;
349 (c - b) * (c / b).ln()
350 };
351 let mut psi = 0.0;
352 for k in keys {
353 psi += term(
354 baseline.get(k).copied().unwrap_or(0.0),
355 current.get(k).copied().unwrap_or(0.0),
356 );
357 }
358 psi + term(b_other, c_other)
359}
360
361#[cfg(test)]
362mod tests {
363 use super::super::column::Profiler;
364 use super::*;
365 use serde_json::{Value, json};
366
367 fn run(records: Vec<Value>, spec: &ProfilingSpec) -> RunProfile {
368 let mut p = Profiler::new(spec.clone());
369 p.observe_page(&records);
370 p.finish()
371 }
372
373 fn stable_run(seed: u64, spec: &ProfilingSpec) -> RunProfile {
374 let rows = (0..200u64)
375 .map(|i| {
376 let k = (i * 7919 + seed) % 1000;
377 json!({
378 "amount": (k % 100) as f64 + 0.5,
379 "region": match k % 3 { 0 => "eu", 1 => "us", _ => "apac" },
380 "note": format!("n{}", k % 9),
381 "maybe": if k.is_multiple_of(50) { Value::Null } else { json!(k) },
382 })
383 })
384 .collect();
385 run(rows, spec)
386 }
387
388 fn baseline(spec: &ProfilingSpec, n: u64) -> Vec<RunProfile> {
389 (0..n).map(|s| stable_run(s, spec)).collect()
390 }
391
392 #[test]
393 fn cold_start_and_stable_runs_raise_nothing() {
394 let spec = ProfilingSpec::default();
395 let base = baseline(&spec, 4);
396 assert!(detect_drift(&base, &stable_run(99, &spec), &spec).is_empty());
397 let base = baseline(&spec, 8);
398 let found = detect_drift(&base, &stable_run(99, &spec), &spec);
399 assert!(found.is_empty(), "{found:?}");
400 }
401
402 #[test]
403 fn null_rate_jump_is_reported_with_baseline() {
404 let spec = ProfilingSpec::default();
405 let base = baseline(&spec, 6);
406 let rows = (0..200u64)
407 .map(|i| json!({"amount": if i % 5 < 2 { Value::Null } else { json!(i as f64) }, "region": "eu"}))
408 .collect();
409 let current = run(rows, &spec);
410 let found = detect_drift(&base, ¤t, &spec);
411 let nr = found
412 .iter()
413 .find(|d| d.column == "amount" && d.metric == DriftMetric::NullRate)
414 .expect("null-rate drift");
415 assert_eq!(nr.observed, 0.4);
416 assert!(nr.baseline.unwrap() < 0.01);
417 assert!(nr.detail.contains("null_rate"), "{nr}");
418 assert!(nr.to_string().starts_with("amount.null_rate:"));
419 }
420
421 #[test]
422 fn new_and_vanished_values_and_psi() {
423 let spec = ProfilingSpec::default();
424 let base = baseline(&spec, 6);
425 let rows = (0..200u64)
426 .map(|i| json!({"amount": (i % 100) as f64 + 0.5, "region": if i % 2 == 0 { "eu" } else { "latam" }}))
427 .collect();
428 let current = run(rows, &spec);
429 let found = detect_drift(&base, ¤t, &spec);
430 let metrics: Vec<(String, DriftMetric, Option<String>)> = found
431 .iter()
432 .filter(|d| d.column == "region")
433 .map(|d| (d.column.clone(), d.metric, d.value.clone()))
434 .collect();
435 assert!(
436 metrics.contains(&("region".into(), DriftMetric::NewValue, Some("latam".into()))),
437 "{found:?}"
438 );
439 assert!(
440 metrics.contains(&(
441 "region".into(),
442 DriftMetric::VanishedValue,
443 Some("us".into())
444 )),
445 "{found:?}"
446 );
447 assert!(
448 metrics.contains(&(
449 "region".into(),
450 DriftMetric::VanishedValue,
451 Some("apac".into())
452 )),
453 "{found:?}"
454 );
455 assert!(metrics.iter().any(|m| m.1 == DriftMetric::Psi), "{found:?}");
456 }
457
458 #[test]
459 fn type_mix_reports_a_type_the_baseline_never_held() {
460 let spec = ProfilingSpec::default();
461 let base = baseline(&spec, 5);
462 let rows = (0..200u64)
463 .map(|i| json!({"amount": if i % 4 == 0 { json!("12.50") } else { json!(i as f64) }}))
464 .collect();
465 let found = detect_drift(&base, &run(rows, &spec), &spec);
466 let tm = found
467 .iter()
468 .find(|d| d.metric == DriftMetric::TypeMix)
469 .expect("type mix drift");
470 assert_eq!(tm.value.as_deref(), Some("string"));
471 assert_eq!(tm.observed, 0.25);
472 }
473
474 #[test]
475 fn negligible_changes_are_below_the_floor() {
476 assert!(!exceeds_floor(DriftMetric::NullRate, 0.005, 0.0));
477 assert!(exceeds_floor(DriftMetric::NullRate, 0.02, 0.0));
478 assert!(!exceeds_floor(DriftMetric::Distinct, 105.0, 100.0));
479 assert!(exceeds_floor(DriftMetric::Distinct, 106.0, 100.0));
480 assert!(!exceeds_floor(DriftMetric::Mean, 100.5, 100.0));
481 assert!(exceeds_floor(DriftMetric::Mean, 102.0, 100.0));
482 assert!(exceeds_floor(DriftMetric::Max, 1.0, 0.0));
483 assert!(!exceeds_floor(DriftMetric::Max, 0.0, 0.0));
484 let spec = ProfilingSpec::default();
486 let base: Vec<RunProfile> = (0..5)
487 .map(|_| run((0..50).map(|i| json!({"k": i % 20})).collect(), &spec))
488 .collect();
489 let current = run((0..50).map(|i| json!({"k": i % 21})).collect(), &spec);
490 let found = detect_drift(&base, ¤t, &spec);
491 assert!(
492 !found.iter().any(|d| d.metric == DriftMetric::Distinct),
493 "{found:?}"
494 );
495 }
496
497 #[test]
498 fn a_column_new_this_run_or_missing_metrics_is_skipped() {
499 let spec = ProfilingSpec::default();
500 let base = baseline(&spec, 5);
501 let mut current = stable_run(3, &spec);
502 let extra = current.columns["amount"].clone();
503 current.columns.insert("brand_new".into(), extra);
504 let found = detect_drift(&base, ¤t, &spec);
505 assert!(found.iter().all(|d| d.column != "brand_new"), "{found:?}");
506 let base: Vec<RunProfile> = (0..5)
508 .map(|_| {
509 run(
510 (0..20).map(|i| json!({"v": format!("s{i}")})).collect(),
511 &spec,
512 )
513 })
514 .collect();
515 let current = run((0..20).map(|i| json!({"v": i})).collect(), &spec);
516 let found = detect_drift(&base, ¤t, &spec);
517 assert!(found.iter().all(|d| d.metric != DriftMetric::Mean));
518 assert!(found.iter().any(|d| d.metric == DriftMetric::TypeMix));
519 }
520
521 #[test]
522 fn vanished_value_pushed_out_of_a_full_list_is_not_reported() {
523 let spec = ProfilingSpec {
524 top_values: 2,
525 min_history: 2,
526 window: 2,
527 ..Default::default()
528 };
529 let base: Vec<RunProfile> = (0..2)
530 .map(|_| {
531 run(
532 (0..100)
533 .map(|i| json!({"c": if i % 2 == 0 { "a" } else if i % 4 == 1 { "b" } else { "d" }}))
534 .collect(),
535 &spec,
536 )
537 })
538 .collect();
539 let current = run(
542 (0..100)
543 .map(|i| json!({"c": if i % 2 == 0 { "a" } else if i % 100 < 48 { "b" } else { "d" }}))
544 .collect(),
545 &spec,
546 );
547 let found = detect_drift(&base, ¤t, &spec);
548 assert!(
549 !found.iter().any(|d| d.metric == DriftMetric::VanishedValue),
550 "{found:?}"
551 );
552 }
553
554 #[test]
555 fn psi_is_skipped_when_the_top_values_cover_too_little() {
556 let spec = ProfilingSpec::default();
559 let base: Vec<RunProfile> = (0..6u64)
560 .map(|s| {
561 run(
562 (0..300u64)
563 .map(|i| json!({"u": (i * 7 + s * 13) % 60}))
564 .collect(),
565 &spec,
566 )
567 })
568 .collect();
569 let current = run(
570 (0..300u64)
571 .map(|i| json!({"u": (i * 11 + 5) % 60}))
572 .collect(),
573 &spec,
574 );
575 let found = detect_drift(&base, ¤t, &spec);
576 assert!(
577 found.iter().all(|d| d.metric != DriftMetric::Psi),
578 "{found:?}"
579 );
580 }
581
582 #[test]
583 fn psi_is_zero_for_identical_and_large_for_disjoint_distributions() {
584 let a: BTreeMap<&str, f64> = [("x", 0.5), ("y", 0.5)].into_iter().collect();
585 assert!(population_stability_index(&a, &a).abs() < 1e-9);
586 let b: BTreeMap<&str, f64> = [("z", 1.0)].into_iter().collect();
587 assert!(population_stability_index(&a, &b) > 1.0);
588 let c: BTreeMap<&str, f64> = [("x", 1.5)].into_iter().collect();
590 assert!(population_stability_index(&a, &c).is_finite());
591 }
592
593 #[test]
594 fn metric_names_and_serialization_are_stable() {
595 assert_eq!(DriftMetric::StringLength.as_str(), "string_length");
596 assert_eq!(
597 serde_json::to_value(DriftMetric::VanishedValue).unwrap(),
598 json!("vanished_value")
599 );
600 let d = ProfileDrift {
601 column: "c".into(),
602 metric: DriftMetric::Psi,
603 observed: 0.3,
604 baseline: None,
605 value: None,
606 detail: "d".into(),
607 };
608 let v = serde_json::to_value(&d).unwrap();
609 assert!(v.get("baseline").is_none() && v.get("value").is_none());
610 assert_eq!(serde_json::from_value::<ProfileDrift>(v).unwrap(), d);
611 assert_eq!(fmt_num(3.0), "3");
612 assert_eq!(fmt_num(0.25), "0.2500");
613 }
614}