mod accuracy;
mod completeness;
mod consistency;
mod hint_binding;
mod precision;
#[cfg(test)]
mod testing;
mod timeliness;
mod uniqueness;
mod utils;
mod validity;
pub use hint_binding::{compute_value_hint_bindings, value_matches_hint};
pub use utils::{StatisticalValidation, validate_sample_size};
use accuracy::AccuracyCalculator;
use completeness::CompletenessCalculator;
use consistency::ConsistencyCalculator;
use precision::PrecisionCalculator;
use timeliness::TimelinessCalculator;
use uniqueness::UniquenessCalculator;
use validity::ValidityCalculator;
use crate::core::config::IsoQualityConfig;
use crate::core::errors::DataProfilerError;
use crate::types::{
AccuracyMetrics, ColumnProfile, CompletenessMetrics, ConsistencyMetrics, DataType,
PrecisionMetrics, QualityDimension, QualityMetrics, RowDuplicateSummary, TimelinessMetrics,
UniquenessMetrics, ValidityMetrics,
};
use dataprof_core::SemanticHints;
use std::collections::HashMap;
pub struct MetricsCalculator {
pub thresholds: IsoQualityConfig,
}
impl Default for MetricsCalculator {
fn default() -> Self {
Self::new()
}
}
impl MetricsCalculator {
pub fn new() -> Self {
Self {
thresholds: IsoQualityConfig::default(),
}
}
pub fn with_thresholds(thresholds: IsoQualityConfig) -> Self {
Self { thresholds }
}
pub fn strict() -> Self {
Self {
thresholds: IsoQualityConfig::strict(),
}
}
pub fn lenient() -> Self {
Self {
thresholds: IsoQualityConfig::lenient(),
}
}
pub fn validate_sample_size(sample_size: usize, metric_type: &str) -> StatisticalValidation {
utils::validate_sample_size(sample_size, metric_type)
}
fn is_requested(requested: &Option<&[QualityDimension]>, dim: QualityDimension) -> bool {
match requested {
None => true,
Some(dims) => dims.contains(&dim),
}
}
fn effective_temporal_columns(
column_profiles: &[ColumnProfile],
semantic_hints: &SemanticHints,
) -> Vec<String> {
let mut columns = semantic_hints.temporal_columns.clone();
for profile in column_profiles {
if profile.data_type == DataType::Date && !columns.contains(&profile.name) {
columns.push(profile.name.clone());
}
}
columns
}
pub fn calculate_comprehensive_metrics(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
) -> Result<QualityMetrics, DataProfilerError> {
self.calculate_comprehensive_metrics_with_positive_columns(
data,
column_profiles,
requested_dimensions,
&[],
None,
)
}
pub fn calculate_comprehensive_metrics_with_positive_columns(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
positive_columns: &[String],
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<QualityMetrics, DataProfilerError> {
self.calculate_comprehensive_metrics_with_semantic_hints(
data,
column_profiles,
requested_dimensions,
positive_columns,
&[],
row_duplicates,
)
}
pub fn calculate_comprehensive_metrics_with_semantic_hints(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
positive_columns: &[String],
identifier_columns: &[String],
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<QualityMetrics, DataProfilerError> {
let semantic_hints =
SemanticHints::new(positive_columns.to_vec(), identifier_columns.to_vec());
self.calculate_comprehensive_metrics_with_all_semantic_hints(
data,
column_profiles,
requested_dimensions,
&semantic_hints,
row_duplicates,
)
}
pub fn calculate_comprehensive_metrics_with_all_semantic_hints(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
semantic_hints: &SemanticHints,
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<QualityMetrics, DataProfilerError> {
if data.is_empty() {
return Ok(Self::default_metrics_for_empty_dataset(
&requested_dimensions,
self.thresholds.score_weights,
));
}
let sample_size = Self::calculate_sample_size(data)?;
let requested = &requested_dimensions;
let validation = Self::validate_sample_size(sample_size, "general");
if !validation.sufficient_sample {
log::warn!(
"Sample size ({}) is below recommended minimum ({}) for reliable statistics",
validation.actual_sample_size,
validation.min_sample_size
);
}
let completeness = if Self::is_requested(requested, QualityDimension::Completeness) {
let c = CompletenessCalculator::new(&self.thresholds).calculate(
data,
column_profiles,
sample_size,
)?;
Some(CompletenessMetrics {
missing_values_ratio: c.missing_values_ratio,
complete_records_ratio: c.complete_records_ratio,
null_columns: c.null_columns,
total_cells: c.total_cells,
})
} else {
None
};
let consistency = if Self::is_requested(requested, QualityDimension::Consistency) {
let c = ConsistencyCalculator::calculate(data, column_profiles)?;
Some(ConsistencyMetrics {
data_type_consistency: c.data_type_consistency,
format_violations: c.format_violations,
encoding_issues: c.encoding_issues,
values_checked: c.values_checked,
})
} else {
None
};
let uniqueness = if Self::is_requested(requested, QualityDimension::Uniqueness) {
let uniqueness_total_rows = row_duplicates
.filter(|summary| summary.rows_checked > 0)
.map_or(sample_size, |summary| summary.rows_checked);
let u = UniquenessCalculator::new(&self.thresholds).calculate(
data,
column_profiles,
uniqueness_total_rows,
&semantic_hints.identifier_columns,
row_duplicates,
)?;
Some(UniquenessMetrics {
duplicate_rows: u.duplicate_rows,
key_uniqueness: u.key_uniqueness,
high_cardinality_warning: u.high_cardinality_warning,
rows_checked: u.rows_checked,
key_column: u.key_column,
duplicate_rows_approximate: u.duplicate_rows_approximate,
})
} else {
None
};
let accuracy = if Self::is_requested(requested, QualityDimension::Accuracy) {
let accuracy_calculator = AccuracyCalculator::new(&self.thresholds);
let a = if semantic_hints.positive_columns.is_empty() {
accuracy_calculator.calculate(data, column_profiles)?
} else {
accuracy_calculator.calculate_with_positive_columns(
data,
column_profiles,
&semantic_hints.positive_columns,
)?
};
Some(AccuracyMetrics {
outlier_ratio: a.outlier_ratio,
range_violations: a.range_violations,
negative_values_in_positive: a.negative_values_in_positive,
numeric_values_checked: a.numeric_values_checked,
})
} else {
None
};
let timeliness = if Self::is_requested(requested, QualityDimension::Timeliness) {
let temporal_columns =
Self::effective_temporal_columns(column_profiles, semantic_hints);
let t =
TimelinessCalculator::new(&self.thresholds).calculate(data, &temporal_columns)?;
Some(TimelinessMetrics {
future_dates_count: t.future_dates_count,
stale_data_ratio: t.stale_data_ratio,
temporal_violations: t.temporal_violations,
invalid_date_values: t.invalid_date_values,
date_values_checked: t.date_values_checked,
temporal_pairs_checked: t.temporal_pairs_checked,
})
} else {
None
};
let validity = if Self::is_requested(requested, QualityDimension::Validity) {
let validity = ValidityCalculator::calculate(data, column_profiles);
Some(ValidityMetrics {
valid_values_ratio: validity.valid_values_ratio,
invalid_values: validity.invalid_values,
values_checked: validity.values_checked,
})
} else {
None
};
let precision = if Self::is_requested(requested, QualityDimension::Precision) {
let precision = PrecisionCalculator::calculate(data, column_profiles);
Some(PrecisionMetrics {
decimal_places_consistency: precision.decimal_places_consistency,
inconsistent_precision_values: precision.inconsistent_precision_values,
numeric_values_checked: precision.numeric_values_checked,
})
} else {
None
};
Ok(QualityMetrics {
completeness,
consistency,
uniqueness,
accuracy,
timeliness,
validity,
precision,
low_sample_warning: !validation.sufficient_sample,
score_weights: self.thresholds.score_weights,
})
}
fn default_metrics_for_empty_dataset(
requested: &Option<&[QualityDimension]>,
score_weights: dataprof_core::QualityScoreWeights,
) -> QualityMetrics {
let is_req = |d| match requested {
None => true,
Some(dims) => dims.contains(&d),
};
QualityMetrics {
completeness: if is_req(QualityDimension::Completeness) {
Some(CompletenessMetrics {
missing_values_ratio: 0.0,
complete_records_ratio: 100.0,
null_columns: vec![],
total_cells: 0,
})
} else {
None
},
consistency: if is_req(QualityDimension::Consistency) {
Some(ConsistencyMetrics {
data_type_consistency: 100.0,
format_violations: 0,
encoding_issues: 0,
values_checked: 0,
})
} else {
None
},
uniqueness: if is_req(QualityDimension::Uniqueness) {
Some(UniquenessMetrics {
duplicate_rows: 0,
key_uniqueness: 100.0,
high_cardinality_warning: false,
rows_checked: 0,
key_column: None,
duplicate_rows_approximate: false,
})
} else {
None
},
accuracy: if is_req(QualityDimension::Accuracy) {
Some(AccuracyMetrics {
outlier_ratio: 0.0,
range_violations: 0,
negative_values_in_positive: 0,
numeric_values_checked: 0,
})
} else {
None
},
timeliness: if is_req(QualityDimension::Timeliness) {
Some(TimelinessMetrics {
future_dates_count: 0,
stale_data_ratio: 0.0,
temporal_violations: 0,
invalid_date_values: 0,
date_values_checked: 0,
temporal_pairs_checked: 0,
})
} else {
None
},
validity: if is_req(QualityDimension::Validity) {
Some(ValidityMetrics {
valid_values_ratio: 100.0,
invalid_values: 0,
values_checked: 0,
})
} else {
None
},
precision: if is_req(QualityDimension::Precision) {
Some(PrecisionMetrics {
decimal_places_consistency: 100.0,
inconsistent_precision_values: 0,
numeric_values_checked: 0,
})
} else {
None
},
low_sample_warning: false,
score_weights,
}
}
pub fn calculate_bifurcated_metrics(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
) -> Result<BifurcatedResult, DataProfilerError> {
self.calculate_bifurcated_metrics_with_positive_columns(
data,
column_profiles,
requested_dimensions,
&[],
None,
)
}
pub fn calculate_bifurcated_metrics_with_positive_columns(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
positive_columns: &[String],
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<BifurcatedResult, DataProfilerError> {
self.calculate_bifurcated_metrics_with_semantic_hints(
data,
column_profiles,
requested_dimensions,
positive_columns,
&[],
row_duplicates,
)
}
pub fn calculate_bifurcated_metrics_with_semantic_hints(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
positive_columns: &[String],
identifier_columns: &[String],
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<BifurcatedResult, DataProfilerError> {
let semantic_hints =
SemanticHints::new(positive_columns.to_vec(), identifier_columns.to_vec());
self.calculate_bifurcated_metrics_with_all_semantic_hints(
data,
column_profiles,
requested_dimensions,
&semantic_hints,
row_duplicates,
)
}
pub fn calculate_bifurcated_metrics_with_all_semantic_hints(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
requested_dimensions: Option<&[QualityDimension]>,
semantic_hints: &SemanticHints,
row_duplicates: Option<RowDuplicateSummary>,
) -> Result<BifurcatedResult, DataProfilerError> {
if data.is_empty() && column_profiles.is_empty() {
return Ok(BifurcatedResult {
metrics: Self::default_metrics_for_empty_dataset(
&requested_dimensions,
self.thresholds.score_weights,
),
exact_dimensions: vec![],
sampled_dimensions: vec![],
sample_size: 0,
});
}
let total_rows = column_profiles.first().map(|p| p.total_count).unwrap_or(0);
let sample_rows = Self::calculate_sample_size(data).unwrap_or(0);
let requested = &requested_dimensions;
let mut exact_dimensions = Vec::new();
let mut sampled_dimensions = Vec::new();
let completeness = if Self::is_requested(requested, QualityDimension::Completeness) {
let c = CompletenessCalculator::new(&self.thresholds)
.calculate_from_profiles(column_profiles)?;
exact_dimensions.push("completeness".to_string());
Some(CompletenessMetrics {
missing_values_ratio: c.missing_values_ratio,
complete_records_ratio: c.complete_records_ratio,
null_columns: c.null_columns,
total_cells: c.total_cells,
})
} else {
None
};
let consistency = if Self::is_requested(requested, QualityDimension::Consistency) {
let c = if !data.is_empty() {
ConsistencyCalculator::calculate(data, column_profiles)?
} else {
consistency::ConsistencyMetrics {
data_type_consistency: 100.0,
format_violations: 0,
encoding_issues: 0,
values_checked: 0,
}
};
sampled_dimensions.push("consistency".to_string());
Some(ConsistencyMetrics {
data_type_consistency: c.data_type_consistency,
format_violations: c.format_violations,
encoding_issues: c.encoding_issues,
values_checked: c.values_checked,
})
} else {
None
};
let uniqueness = if Self::is_requested(requested, QualityDimension::Uniqueness) {
let u = UniquenessCalculator::new(&self.thresholds).calculate(
data,
column_profiles,
total_rows,
&semantic_hints.identifier_columns,
row_duplicates,
)?;
if u.key_column.is_some() {
exact_dimensions.push("key_uniqueness".to_string());
}
if u.rows_checked > 0 {
let from_tracker = row_duplicates.is_some_and(|s| s.rows_checked > 0);
if from_tracker && !u.duplicate_rows_approximate {
exact_dimensions.push("duplicate_rows".to_string());
} else {
sampled_dimensions.push("duplicate_rows".to_string());
}
}
Some(UniquenessMetrics {
duplicate_rows: u.duplicate_rows,
key_uniqueness: u.key_uniqueness,
high_cardinality_warning: u.high_cardinality_warning,
rows_checked: u.rows_checked,
key_column: u.key_column,
duplicate_rows_approximate: u.duplicate_rows_approximate,
})
} else {
None
};
let accuracy = if Self::is_requested(requested, QualityDimension::Accuracy) {
let a = if !data.is_empty() {
AccuracyCalculator::new(&self.thresholds).calculate_with_positive_columns(
data,
column_profiles,
&semantic_hints.positive_columns,
)?
} else {
accuracy::AccuracyMetrics {
outlier_ratio: 0.0,
range_violations: 0,
negative_values_in_positive: 0,
numeric_values_checked: 0,
}
};
sampled_dimensions.push("accuracy".to_string());
Some(AccuracyMetrics {
outlier_ratio: a.outlier_ratio,
range_violations: a.range_violations,
negative_values_in_positive: a.negative_values_in_positive,
numeric_values_checked: a.numeric_values_checked,
})
} else {
None
};
let timeliness = if Self::is_requested(requested, QualityDimension::Timeliness) {
let temporal_columns =
Self::effective_temporal_columns(column_profiles, semantic_hints);
let t = if !data.is_empty() {
TimelinessCalculator::new(&self.thresholds).calculate(data, &temporal_columns)?
} else {
timeliness::TimelinessMetrics {
future_dates_count: 0,
stale_data_ratio: 0.0,
temporal_violations: 0,
invalid_date_values: 0,
date_values_checked: 0,
temporal_pairs_checked: 0,
}
};
sampled_dimensions.push("timeliness".to_string());
Some(TimelinessMetrics {
future_dates_count: t.future_dates_count,
stale_data_ratio: t.stale_data_ratio,
temporal_violations: t.temporal_violations,
invalid_date_values: t.invalid_date_values,
date_values_checked: t.date_values_checked,
temporal_pairs_checked: t.temporal_pairs_checked,
})
} else {
None
};
let validity = if Self::is_requested(requested, QualityDimension::Validity) {
let validity = ValidityCalculator::calculate(data, column_profiles);
sampled_dimensions.push("validity".to_string());
Some(ValidityMetrics {
valid_values_ratio: validity.valid_values_ratio,
invalid_values: validity.invalid_values,
values_checked: validity.values_checked,
})
} else {
None
};
let precision = if Self::is_requested(requested, QualityDimension::Precision) {
let precision = PrecisionCalculator::calculate(data, column_profiles);
sampled_dimensions.push("precision".to_string());
Some(PrecisionMetrics {
decimal_places_consistency: precision.decimal_places_consistency,
inconsistent_precision_values: precision.inconsistent_precision_values,
numeric_values_checked: precision.numeric_values_checked,
})
} else {
None
};
let reliability_sample = if total_rows > 0 {
total_rows
} else {
sample_rows
};
let validation = Self::validate_sample_size(reliability_sample, "general");
let metrics = QualityMetrics {
completeness,
consistency,
uniqueness,
accuracy,
timeliness,
validity,
precision,
low_sample_warning: !validation.sufficient_sample,
score_weights: self.thresholds.score_weights,
};
Ok(BifurcatedResult {
metrics,
exact_dimensions,
sampled_dimensions,
sample_size: sample_rows,
})
}
fn calculate_sample_size(
data: &HashMap<String, Vec<String>>,
) -> Result<usize, DataProfilerError> {
data.values().map(|v| v.len()).max().ok_or_else(|| {
DataProfilerError::MetricsCalculationError {
message: "No data columns found".to_string(),
}
})
}
}
pub struct BifurcatedResult {
pub metrics: QualityMetrics,
pub exact_dimensions: Vec<String>,
pub sampled_dimensions: Vec<String>,
pub sample_size: usize,
}
#[cfg(test)]
mod tests {
use super::testing::string_profile;
use super::*;
use crate::types::{ColumnStats, DataType};
use dataprof_core::QualityScoreWeights;
#[test]
fn comprehensive_metrics_use_tracker_rows_for_cardinality_ratio() {
let data = HashMap::from([(
"city".to_string(),
vec!["Rome".to_string(), "Milan".to_string()],
)]);
let profiles = vec![ColumnProfile {
name: "city".to_string(),
data_type: DataType::String,
null_count: 0,
total_count: 1_000,
unique_count: Some(100),
unique_count_is_approximate: Some(false),
invalid_count: None,
stats: ColumnStats::None,
patterns: Some(vec![]),
}];
let requested = [QualityDimension::Uniqueness];
let metrics = MetricsCalculator::new()
.calculate_comprehensive_metrics_with_positive_columns(
&data,
&profiles,
Some(&requested),
&[],
Some(RowDuplicateSummary {
duplicate_rows: 0,
rows_checked: 1_000,
approximate: false,
}),
)
.expect("quality metrics");
assert!(
!metrics
.uniqueness
.expect("uniqueness metrics")
.high_cardinality_warning,
"100 distinct values out of 1,000 rows is not high cardinality"
);
}
#[test]
fn calculator_copies_custom_score_weights_into_metrics() {
let weights = QualityScoreWeights {
completeness: 1.0,
consistency: 0.0,
uniqueness: 0.0,
accuracy: 0.0,
timeliness: 0.0,
validity: 0.0,
precision: 0.0,
};
let config = IsoQualityConfig {
score_weights: weights,
..IsoQualityConfig::default()
};
let data = HashMap::from([("value".to_string(), vec!["x".to_string()])]);
let profiles = vec![ColumnProfile {
name: "value".to_string(),
data_type: DataType::String,
null_count: 0,
total_count: 1,
unique_count: Some(1),
unique_count_is_approximate: Some(false),
invalid_count: None,
stats: ColumnStats::None,
patterns: Some(vec![]),
}];
let metrics = MetricsCalculator::with_thresholds(config)
.calculate_comprehensive_metrics(&data, &profiles, None)
.expect("quality metrics");
assert_eq!(metrics.score_weights, weights);
}
#[test]
fn unrequested_completeness_remains_absent() {
let data = HashMap::from([("value".to_string(), vec!["x".to_string()])]);
let profiles = vec![string_profile("value", 1, 0)];
let requested = [QualityDimension::Consistency];
let metrics = MetricsCalculator::new()
.calculate_comprehensive_metrics(&data, &profiles, Some(&requested))
.expect("quality metrics");
assert!(
metrics.completeness.is_none(),
"an unrequested dimension must remain absent rather than looking computed"
);
assert!(
metrics.consistency.is_some(),
"the requested dimension should still be computed"
);
}
#[test]
fn empty_bifurcated_sample_keeps_new_dimensions_neutral_and_unassessed() {
let profiles = vec![ColumnProfile {
name: "amount".to_string(),
data_type: DataType::Float,
null_count: 0,
total_count: 100,
unique_count: Some(10),
unique_count_is_approximate: Some(false),
invalid_count: None,
stats: ColumnStats::None,
patterns: Some(vec![]),
}];
let requested = [QualityDimension::Validity, QualityDimension::Precision];
let result = MetricsCalculator::new()
.calculate_bifurcated_metrics(&HashMap::new(), &profiles, Some(&requested))
.expect("bifurcated quality metrics");
let validity = result.metrics.validity.as_ref().expect("validity metrics");
let precision = result
.metrics
.precision
.as_ref()
.expect("precision metrics");
assert_eq!(validity.valid_values_ratio, 100.0);
assert_eq!(validity.invalid_values, 0);
assert_eq!(validity.values_checked, 0);
assert_eq!(precision.decimal_places_consistency, 100.0);
assert_eq!(precision.inconsistent_precision_values, 0);
assert_eq!(precision.numeric_values_checked, 0);
assert!(result.metrics.assessed_dimensions().is_empty());
}
}