use crate::profile::DatasetProfile;
use crate::types::{ColumnType, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum QualityKind {
HighMissing,
ConstantColumn,
NearUnique,
DuplicateRows,
Outliers,
Imbalance,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct QualityIssue {
pub kind: QualityKind,
pub severity: Severity,
pub column: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Thresholds {
pub missing_fraction: f64,
pub near_zero_variance: f64,
pub near_unique_ratio: f64,
pub outlier_fraction: f64,
pub imbalance_ratio: f64,
}
impl Default for Thresholds {
fn default() -> Self {
Thresholds {
missing_fraction: 0.5,
near_zero_variance: 1e-12,
near_unique_ratio: 0.98,
outlier_fraction: 0.05,
imbalance_ratio: 0.95,
}
}
}
pub fn run_checks(profile: &DatasetProfile, thresholds: &Thresholds) -> Vec<QualityIssue> {
let mut issues = Vec::new();
for col in &profile.columns {
if col.missing_fraction >= thresholds.missing_fraction && col.count > 0 {
issues.push(QualityIssue {
kind: QualityKind::HighMissing,
severity: if col.missing_fraction >= 0.9 {
Severity::Critical
} else {
Severity::Warning
},
column: Some(col.name.clone()),
message: format!(
"{}: {:.1}% of values are missing",
col.name,
col.missing_fraction * 100.0
),
});
}
match col.column_type {
ColumnType::Numeric => {
if let Some(n) = &col.numeric {
let var = n.std * n.std;
if var <= thresholds.near_zero_variance {
issues.push(QualityIssue {
kind: QualityKind::ConstantColumn,
severity: Severity::Warning,
column: Some(col.name.clone()),
message: format!(
"{}: near-zero variance ({:.3e}); column is effectively constant",
col.name, var
),
});
}
if n.outlier_count > 0 && n.outlier_fraction >= thresholds.outlier_fraction {
issues.push(QualityIssue {
kind: QualityKind::Outliers,
severity: if n.outlier_fraction >= 0.2 {
Severity::Warning
} else {
Severity::Info
},
column: Some(col.name.clone()),
message: format!(
"{}: {} outliers ({:.1}%) beyond IQR fences",
col.name,
n.outlier_count,
n.outlier_fraction * 100.0
),
});
}
}
}
ColumnType::Categorical => {
if let Some(c) = &col.categorical {
if col.count > 0 {
let ratio = c.unique as f64 / col.count as f64;
if ratio >= thresholds.near_unique_ratio {
issues.push(QualityIssue {
kind: QualityKind::NearUnique,
severity: Severity::Info,
column: Some(col.name.clone()),
message: format!(
"{}: {} unique values across {} rows (ratio {:.2}); likely an identifier",
col.name, c.unique, col.count, ratio
),
});
}
if c.imbalance_ratio >= thresholds.imbalance_ratio {
issues.push(QualityIssue {
kind: QualityKind::Imbalance,
severity: Severity::Critical,
column: Some(col.name.clone()),
message: format!(
"{}: top value '{}' covers {:.1}% of rows",
col.name,
c.top,
c.imbalance_ratio * 100.0
),
});
}
}
}
}
}
}
if profile.duplicate_rows > 0 {
issues.push(QualityIssue {
kind: QualityKind::DuplicateRows,
severity: if profile.duplicate_fraction >= 0.1 {
Severity::Warning
} else {
Severity::Info
},
column: None,
message: format!(
"{} of {} rows are exact duplicates ({:.2}%)",
profile.duplicate_rows,
profile.n_rows,
profile.duplicate_fraction * 100.0
),
});
}
issues
}