Skip to main content

faucet_core/profiling/
drift.rs

1//! Pure profile-drift detection (#708): compare one run's [`RunProfile`]
2//! against the rolling baseline of earlier runs and report per-column
3//! findings. Numeric metrics (null rate, distinct count, mean, min, max,
4//! string length) go through the shared z-score / IQR test in
5//! [`crate::anomaly`]; categorical columns are checked for new and vanished
6//! frequent values and for a population-stability-index shift.
7
8use 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
16/// Smoothing added to every PSI bucket so an empty bucket never divides by
17/// zero.
18const PSI_EPS: f64 = 1e-4;
19/// The listed top values must cover at least this share of a column's values
20/// (in the baseline and in the run) for a PSI over them to mean anything; a
21/// near-uniform column's top-10 list is a different random handful each run.
22const PSI_MIN_COVERAGE: f64 = 0.5;
23
24/// Which column statistic drifted. Closed set — the `metric` label of
25/// `faucet_profile_drift_total`.
26#[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    /// A JSON type appeared that the baseline never held (e.g. strings in a
36    /// numeric column).
37    TypeMix,
38    /// A frequent categorical value the baseline never held.
39    NewValue,
40    /// A value frequent in every baseline run is gone.
41    VanishedValue,
42    /// The categorical value distribution shifted (population stability
43    /// index above the threshold).
44    Psi,
45}
46
47impl DriftMetric {
48    /// Stable metric-label / log string.
49    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/// One detected drift on one column.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
67pub struct ProfileDrift {
68    pub column: String,
69    pub metric: DriftMetric,
70    /// The statistic's value in this run (a share for the value metrics, the
71    /// index for `psi`).
72    pub observed: f64,
73    /// The baseline's mean for a numeric metric (absent for value metrics).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub baseline: Option<f64>,
76    /// The value or type name for `type_mix` / `new_value` / `vanished_value`.
77    #[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
94/// Detect drift in `current` against `baseline` (oldest first, already
95/// windowed). Empty until the baseline holds `min_history` runs.
96pub 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 &current.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
168/// A statistically significant but negligible change is not actionable: a
169/// null rate must move by at least one percentage point, a distinct count by
170/// 5 % (three times the sketch's error), everything else by 1 %.
171fn 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
188/// Per-type share of the column's non-null values.
189fn 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    // Mean baseline share per value (absent from a run counts as 0).
248    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        // A full current list may simply have pushed the value out; only
294        // report when it would have ranked, or the list is not full.
295        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, &current_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
335/// PSI over the union of listed values plus an "other" bucket for the
336/// unlisted remainder. Shares are clamped into [0, 1] and smoothed.
337pub 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, &current, &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, &current, &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        // A constant-baseline distinct count that wobbles by one is ignored.
485        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, &current, &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, &current, &spec);
505        assert!(found.iter().all(|d| d.column != "brand_new"), "{found:?}");
506        // Numeric metrics need numeric history: a formerly-string column has none.
507        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, &current, &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        // "b" (25%) drops to 24%, "d" rises: b leaves the top-2 list but was not
540        // more frequent than the smallest listed value.
541        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, &current, &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        // A uniform 60-value column: its top-10 list is a random handful each
557        // run, so a PSI over the lists would fire on every run.
558        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, &current, &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        // Shares over 1 are clamped rather than producing NaN.
589        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}