use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::envelope::CardKind;
use crate::error::WyrdError;
use crate::ids::FeatureName;
use crate::reference::Ref;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct DriftSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub method: DriftMethod,
pub signal: DriftSignal,
pub condition: DriftCondition,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<DriftProfile>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(rename_all = "PascalCase")]
pub enum DriftMethod {
Psi,
Spc,
Custom,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "PascalCase")]
pub enum DriftSignal {
Distribution {
baseline_ref: Ref,
features: Vec<FeatureName>,
},
Metric {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "PascalCase")]
pub enum DriftCondition {
Statistical,
Above {
limit: f64,
},
Below {
limit: f64,
},
Outside {
lower: f64,
upper: f64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "PascalCase")]
pub enum DriftProfile {
Psi(PsiProfile),
Spc(SpcProfile),
Custom(CustomProfile),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct PsiProfile {
pub binning_strategy: PsiBinningStrategy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub categorical_features: Vec<FeatureName>,
pub threshold: PsiThreshold,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "PascalCase")]
pub enum PsiBinningStrategy {
EqualWidth {
n_bins: u32,
},
Quantile {
n_bins: u32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(tag = "kind", rename_all = "PascalCase")]
pub enum PsiThreshold {
ChiSquare {
alpha: f64,
},
Normal {
alpha: f64,
},
Fixed {
value: f64,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct SpcProfile {
pub sample_size: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct CustomProfile {
pub metric_name: String,
pub baseline_value: f64,
pub alert_threshold: f64,
}
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum DriftValidationError {
#[error("signal {signal} is not compatible with method {method}")]
SignalMethodMismatch {
signal: String,
method: String,
},
#[error("baseline_ref.kind must be Data, got {got}")]
BaselineRefMustBeData {
got: String,
},
#[error("Distribution signal must declare at least one feature")]
DistributionMissingFeatures,
#[error("Distribution features contain duplicate {dup}")]
DistributionDuplicateFeatures {
dup: String,
},
#[error("profile is required for method {method}")]
ProfileRequired {
method: String,
},
#[error("profile variant {profile} does not match method {method}")]
ProfileMethodMismatch {
profile: String,
method: String,
},
#[error("condition {condition} is not supported; use Statistical")]
ConditionNotStatistical {
condition: String,
},
#[error("PSI threshold {field} must be finite and in (0, 1)")]
PsiAlphaOutOfRange {
field: String,
},
#[error("PSI fixed threshold must be positive and finite")]
PsiFixedInvalid,
#[error("PSI bin count must be in [2, 1000]")]
PsiBinCountOutOfRange,
#[error("SPC sample_size must be >= 2")]
SpcSampleSizeOutOfRange,
#[error("Custom profile metric_name must be non-empty")]
CustomMetricNameEmpty,
#[error(
"Custom profile baseline_value or alert_threshold not finite, or alert_threshold negative"
)]
CustomInvalidNumber,
#[error("Custom profile metric_name {profile} does not match signal name {signal}")]
CustomMetricNameMismatch {
signal: String,
profile: String,
},
#[error("Custom metric name {name} is not a valid feature name")]
CustomMetricNameInvalid {
name: String,
},
#[error("PSI categorical feature {feature} is not a signal feature")]
PsiCategoricalFeatureUnknown {
feature: String,
},
}
impl DriftMethod {
fn name(self) -> &'static str {
match self {
Self::Psi => "Psi",
Self::Spc => "Spc",
Self::Custom => "Custom",
}
}
}
impl DriftSignal {
fn variant_name(&self) -> &'static str {
match self {
Self::Distribution { .. } => "Distribution",
Self::Metric { .. } => "Metric",
}
}
}
impl DriftProfile {
fn variant_name(&self) -> &'static str {
match self {
Self::Psi(_) => "Psi",
Self::Spc(_) => "Spc",
Self::Custom(_) => "Custom",
}
}
}
impl DriftValidationError {
fn details(&self) -> serde_json::Value {
match self {
Self::SignalMethodMismatch { signal, method } => {
json!({ "signal": signal, "method": method })
}
Self::BaselineRefMustBeData { got } => {
json!({ "field": "signal.baseline_ref.kind", "got": got, "expected": "Data" })
}
Self::DistributionMissingFeatures => {
json!({ "field": "signal.features", "reason": "missing" })
}
Self::DistributionDuplicateFeatures { dup } => {
json!({ "field": "signal.features", "duplicate": dup })
}
Self::ProfileRequired { method } => json!({ "method": method }),
Self::ProfileMethodMismatch { profile, method } => {
json!({ "field": "profile.kind", "profile": profile, "method": method })
}
Self::ConditionNotStatistical { condition } => {
json!({ "field": "condition", "got": condition, "expected": "Statistical" })
}
Self::PsiAlphaOutOfRange { field } => {
json!({ "field": field, "expected": "finite value in (0, 1)" })
}
Self::PsiFixedInvalid => {
json!({ "field": "profile.threshold.value", "expected": "positive finite value" })
}
Self::PsiBinCountOutOfRange => {
json!({ "field": "profile.binning_strategy.n_bins", "min": 2, "max": 1000 })
}
Self::SpcSampleSizeOutOfRange => {
json!({ "field": "profile.sample_size", "expected": ">= 2" })
}
Self::CustomMetricNameEmpty => {
json!({ "field": "profile.metric_name", "reason": "empty" })
}
Self::CustomInvalidNumber => {
json!({ "fields": ["profile.baseline_value", "profile.alert_threshold"], "expected": "finite values and alert_threshold >= 0" })
}
Self::CustomMetricNameMismatch { signal, profile } => {
json!({ "fields": ["signal.name", "profile.metric_name"], "signal": signal, "profile": profile })
}
Self::CustomMetricNameInvalid { name } => {
json!({ "field": "profile.metric_name", "got": name, "expected": "feature name" })
}
Self::PsiCategoricalFeatureUnknown { feature } => {
json!({ "field": "profile.categorical_features", "unknown": feature })
}
}
}
}
impl From<DriftValidationError> for WyrdError {
fn from(error: DriftValidationError) -> Self {
let message = error.to_string();
let details = error.details();
match error {
DriftValidationError::SignalMethodMismatch { .. } => {
WyrdError::DriftSignalMethodMismatch { message, details }
}
DriftValidationError::ProfileRequired { .. } => {
WyrdError::DriftProfileRequired { message, details }
}
_ => WyrdError::DriftValidation { message, details },
}
}
}
impl DriftSpec {
pub fn new(
method: DriftMethod,
signal: DriftSignal,
condition: DriftCondition,
profile: Option<DriftProfile>,
description: Option<String>,
) -> Result<Self, DriftValidationError> {
let spec = Self {
description,
method,
signal,
condition,
profile,
};
spec.validate()?;
Ok(spec)
}
pub fn validate(&self) -> Result<(), DriftValidationError> {
validate_signal_method(&self.signal, self.method)?;
validate_signal(&self.signal)?;
validate_condition(&self.condition)?;
validate_profile_presence(self.method, self.profile.as_ref())?;
if let Some(profile) = &self.profile {
validate_profile(profile)?;
validate_signal_profile(&self.signal, profile)?;
}
Ok(())
}
}
fn validate_signal_profile(
signal: &DriftSignal,
profile: &DriftProfile,
) -> Result<(), DriftValidationError> {
match (signal, profile) {
(DriftSignal::Metric { name }, DriftProfile::Custom(custom)) => {
if *name != custom.metric_name {
return Err(DriftValidationError::CustomMetricNameMismatch {
signal: name.clone(),
profile: custom.metric_name.clone(),
});
}
FeatureName::new(name.as_str()).map_err(|_| {
DriftValidationError::CustomMetricNameInvalid { name: name.clone() }
})?;
Ok(())
}
(DriftSignal::Distribution { features, .. }, DriftProfile::Psi(psi)) => {
match psi
.categorical_features
.iter()
.find(|feature| !features.contains(feature))
{
Some(feature) => Err(DriftValidationError::PsiCategoricalFeatureUnknown {
feature: feature.to_string(),
}),
None => Ok(()),
}
}
_ => Ok(()),
}
}
fn validate_signal_method(
signal: &DriftSignal,
method: DriftMethod,
) -> Result<(), DriftValidationError> {
let allowed = match method {
DriftMethod::Psi | DriftMethod::Spc => matches!(signal, DriftSignal::Distribution { .. }),
DriftMethod::Custom => matches!(signal, DriftSignal::Metric { .. }),
};
if allowed {
Ok(())
} else {
Err(DriftValidationError::SignalMethodMismatch {
signal: signal.variant_name().to_string(),
method: method.name().to_string(),
})
}
}
fn validate_signal(signal: &DriftSignal) -> Result<(), DriftValidationError> {
match signal {
DriftSignal::Distribution {
baseline_ref,
features,
} => {
let Some(baseline_ref) = baseline_ref.as_card_ref() else {
return Ok(());
};
if baseline_ref.kind != CardKind::Data {
return Err(DriftValidationError::BaselineRefMustBeData {
got: format!("{:?}", baseline_ref.kind),
});
}
if features.is_empty() {
return Err(DriftValidationError::DistributionMissingFeatures);
}
let mut seen = std::collections::BTreeSet::new();
for feature in features {
if !seen.insert(feature.as_str()) {
return Err(DriftValidationError::DistributionDuplicateFeatures {
dup: feature.to_string(),
});
}
}
Ok(())
}
DriftSignal::Metric { .. } => Ok(()),
}
}
fn validate_condition(condition: &DriftCondition) -> Result<(), DriftValidationError> {
match condition {
DriftCondition::Statistical => Ok(()),
DriftCondition::Above { .. } => Err(DriftValidationError::ConditionNotStatistical {
condition: "Above".to_string(),
}),
DriftCondition::Below { .. } => Err(DriftValidationError::ConditionNotStatistical {
condition: "Below".to_string(),
}),
DriftCondition::Outside { .. } => Err(DriftValidationError::ConditionNotStatistical {
condition: "Outside".to_string(),
}),
}
}
fn validate_profile_presence(
method: DriftMethod,
profile: Option<&DriftProfile>,
) -> Result<(), DriftValidationError> {
let Some(profile) = profile else {
return Err(DriftValidationError::ProfileRequired {
method: method.name().to_string(),
});
};
let matches = matches!(
(method, profile),
(DriftMethod::Psi, DriftProfile::Psi(_))
| (DriftMethod::Spc, DriftProfile::Spc(_))
| (DriftMethod::Custom, DriftProfile::Custom(_))
);
if matches {
Ok(())
} else {
Err(DriftValidationError::ProfileMethodMismatch {
profile: profile.variant_name().to_string(),
method: method.name().to_string(),
})
}
}
fn validate_profile(profile: &DriftProfile) -> Result<(), DriftValidationError> {
match profile {
DriftProfile::Psi(profile) => validate_psi_profile(profile),
DriftProfile::Spc(profile) => validate_spc_profile(profile),
DriftProfile::Custom(profile) => validate_custom_profile(profile),
}
}
fn validate_psi_profile(profile: &PsiProfile) -> Result<(), DriftValidationError> {
match &profile.binning_strategy {
PsiBinningStrategy::EqualWidth { n_bins } | PsiBinningStrategy::Quantile { n_bins } => {
if !(2..=1000).contains(n_bins) {
return Err(DriftValidationError::PsiBinCountOutOfRange);
}
}
}
match profile.threshold {
PsiThreshold::ChiSquare { alpha } | PsiThreshold::Normal { alpha } => {
if !alpha.is_finite() || alpha <= 0.0 || alpha >= 1.0 {
return Err(DriftValidationError::PsiAlphaOutOfRange {
field: "alpha".to_string(),
});
}
}
PsiThreshold::Fixed { value } => {
if !value.is_finite() || value <= 0.0 {
return Err(DriftValidationError::PsiFixedInvalid);
}
}
}
Ok(())
}
fn validate_spc_profile(profile: &SpcProfile) -> Result<(), DriftValidationError> {
if profile.sample_size < 2 {
return Err(DriftValidationError::SpcSampleSizeOutOfRange);
}
Ok(())
}
fn validate_custom_profile(profile: &CustomProfile) -> Result<(), DriftValidationError> {
if profile.metric_name.trim().is_empty() {
return Err(DriftValidationError::CustomMetricNameEmpty);
}
if !profile.baseline_value.is_finite()
|| !profile.alert_threshold.is_finite()
|| profile.alert_threshold < 0.0
{
return Err(DriftValidationError::CustomInvalidNumber);
}
Ok(())
}