use crate::core::config::IsoQualityConfig;
use crate::core::errors::DataProfilerError;
use crate::types::ColumnProfile;
use std::collections::HashMap;
#[derive(Debug)]
pub(crate) struct CompletenessMetrics {
pub missing_values_ratio: f64,
pub complete_records_ratio: f64,
pub null_columns: Vec<String>,
pub total_cells: usize,
}
pub(crate) struct CompletenessCalculator<'a> {
thresholds: &'a IsoQualityConfig,
}
impl<'a> CompletenessCalculator<'a> {
pub fn new(thresholds: &'a IsoQualityConfig) -> Self {
Self { thresholds }
}
pub fn calculate(
&self,
data: &HashMap<String, Vec<String>>,
column_profiles: &[ColumnProfile],
total_rows: usize,
) -> Result<CompletenessMetrics, DataProfilerError> {
let total_cells = if !column_profiles.is_empty() {
column_profiles.iter().map(|p| p.total_count).sum()
} else {
data.values().map(|v| v.len()).sum()
};
let missing_values_ratio = if !column_profiles.is_empty() {
let null_cells: usize = column_profiles.iter().map(|p| p.null_count).sum();
if total_cells == 0 {
0.0
} else {
(null_cells as f64 / total_cells as f64) * 100.0
}
} else {
Self::calculate_missing_values_ratio(data)?
};
let complete_records_ratio = if !column_profiles.is_empty() {
let total = column_profiles.first().map(|p| p.total_count).unwrap_or(0);
let null_cells: usize = column_profiles.iter().map(|p| p.null_count).sum();
if total == 0 {
100.0
} else {
(total.saturating_sub(null_cells) as f64 / total as f64 * 100.0).max(0.0)
}
} else {
Self::calculate_complete_records_ratio(data, total_rows)?
};
let null_columns = self.identify_null_columns(column_profiles);
Ok(CompletenessMetrics {
missing_values_ratio,
complete_records_ratio,
null_columns,
total_cells,
})
}
fn calculate_missing_values_ratio(
data: &HashMap<String, Vec<String>>,
) -> Result<f64, DataProfilerError> {
let total_cells: usize = data.values().map(|v| v.len()).sum();
let null_cells: usize = data
.values()
.map(|v| v.iter().filter(|s| s.is_empty()).count())
.sum();
if total_cells == 0 {
Ok(0.0)
} else {
Ok((null_cells as f64 / total_cells as f64) * 100.0)
}
}
fn calculate_complete_records_ratio(
data: &HashMap<String, Vec<String>>,
total_rows: usize,
) -> Result<f64, DataProfilerError> {
if total_rows == 0 {
return Ok(100.0);
}
let mut complete_rows = 0;
for row_idx in 0..total_rows {
let is_complete = data
.values()
.all(|column| column.get(row_idx).is_some_and(|v| !v.is_empty()));
if is_complete {
complete_rows += 1;
}
}
Ok((complete_rows as f64 / total_rows as f64) * 100.0)
}
pub fn calculate_from_profiles(
&self,
column_profiles: &[ColumnProfile],
) -> Result<CompletenessMetrics, DataProfilerError> {
let total_cells: usize = column_profiles.iter().map(|p| p.total_count).sum();
let null_cells: usize = column_profiles.iter().map(|p| p.null_count).sum();
let missing_values_ratio = if total_cells == 0 {
0.0
} else {
(null_cells as f64 / total_cells as f64) * 100.0
};
let total_rows = column_profiles.first().map(|p| p.total_count).unwrap_or(0);
let complete_records_ratio = if total_rows == 0 {
100.0
} else {
(total_rows.saturating_sub(null_cells) as f64 / total_rows as f64 * 100.0).max(0.0)
};
let null_columns = self.identify_null_columns(column_profiles);
Ok(CompletenessMetrics {
missing_values_ratio,
complete_records_ratio,
null_columns,
total_cells,
})
}
fn identify_null_columns(&self, column_profiles: &[ColumnProfile]) -> Vec<String> {
let threshold = self.thresholds.max_null_percentage / 100.0;
column_profiles
.iter()
.filter(|profile| {
if profile.total_count == 0 {
false
} else {
(profile.null_count as f64 / profile.total_count as f64) > threshold
}
})
.map(|profile| profile.name.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::super::testing::{
CompletenessInput, CompletenessScenario, assert_completeness, column_data,
expected_completeness, null_threshold, string_profile,
};
use super::*;
#[test]
fn completeness_scenarios_cover_boundaries() {
let scenarios = vec![
CompletenessScenario {
name: "empty input is computed but neutral",
input: CompletenessInput::Profiles(vec![]),
config: IsoQualityConfig::default(),
expected: expected_completeness(0.0, 100.0, &[], 0),
},
CompletenessScenario {
name: "perfect raw rows",
input: CompletenessInput::Rows {
data: column_data(&[("a", &["x", "y"]), ("b", &["1", "2"])]),
total_rows: 2,
},
config: IsoQualityConfig::default(),
expected: expected_completeness(0.0, 100.0, &[], 4),
},
CompletenessScenario {
name: "exact null threshold is not a null column",
input: CompletenessInput::Profiles(vec![string_profile("boundary", 100, 25)]),
config: null_threshold(25.0),
expected: expected_completeness(25.0, 75.0, &[], 100),
},
CompletenessScenario {
name: "degraded profiles expose exact counters",
input: CompletenessInput::Profiles(vec![
string_profile("partial", 100, 10),
string_profile("mostly_null", 100, 60),
]),
config: IsoQualityConfig::default(),
expected: expected_completeness(35.0, 30.0, &["mostly_null"], 200),
},
];
for scenario in scenarios {
let calculator = CompletenessCalculator::new(&scenario.config);
let actual = match &scenario.input {
CompletenessInput::Rows { data, total_rows } => {
calculator.calculate(data, &[], *total_rows)
}
CompletenessInput::Profiles(profiles) => {
calculator.calculate_from_profiles(profiles)
}
}
.unwrap_or_else(|error| panic!("scenario `{}` failed: {error}", scenario.name));
assert_completeness(scenario.name, &actual, &scenario.expected);
}
}
}