use std::collections::{BTreeMap, HashSet};
use dataprof_core::serde_helpers::rounded_2;
use dataprof_core::{
DataSource, DataType, FileFormat, QualityDimension, QualityScoreWeights, SemanticHintBinding,
SemanticHintKind,
};
use serde::ser::{SerializeMap, Serializer};
use crate::profile_report::{
AnalysisSettings, ColumnSelection, MetricSemantics, PatternLocale, ProfileReport,
};
use crate::quality_gate::{
Evidence, EvidenceGap, Provenance, sampled_component_evidence, scan_coverage, scan_evidence,
};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct Sides<T> {
pub a: T,
pub b: T,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct ReportReference {
pub id: Option<String>,
pub timestamp: Option<String>,
pub source: String,
pub rows_processed: Option<usize>,
pub evidence: Evidence,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct ScoreChange {
pub a: Option<f64>,
pub b: Option<f64>,
pub abs: Option<f64>,
pub rel_pct: Option<f64>,
pub evidence: Sides<Evidence>,
}
impl ScoreChange {
fn new(a: Option<f64>, b: Option<f64>, evidence: Sides<Evidence>) -> Self {
let (abs, rel_pct) = match (a, b) {
(Some(a), Some(b)) => (
Some(rounded_2(b - a)),
(a != 0.0).then(|| rounded_2((b - a) / a.abs() * 100.0)),
),
_ => (None, None),
};
Self {
a,
b,
abs,
rel_pct,
evidence,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct DimensionChange {
pub dimension: QualityDimension,
pub score: ScoreChange,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct ColumnChange {
#[serde(skip)]
pub name: String,
pub null_pct_a: Option<f64>,
pub null_pct_b: Option<f64>,
pub null_pct_delta: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct SemanticsComparison {
pub a: Option<MetricSemantics>,
pub b: Option<MetricSemantics>,
pub comparable: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct SettingComparison<T> {
pub a: Option<T>,
pub b: Option<T>,
pub same: Option<bool>,
}
impl<T: PartialEq> SettingComparison<T> {
fn new(a: Option<T>, b: Option<T>) -> Self {
let same = match (&a, &b) {
(Some(left), Some(right)) => Some(left == right),
_ => None,
};
Self { a, b, same }
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct RecordedHints {
pub positive_columns: Vec<String>,
pub identifier_columns: Vec<String>,
pub temporal_columns: Vec<String>,
pub temporal_pair_columns: Vec<String>,
}
impl RecordedHints {
fn from_bindings(bindings: &[SemanticHintBinding]) -> Self {
let columns = |kind: SemanticHintKind| {
let mut names: Vec<String> = bindings
.iter()
.filter(|binding| binding.kind == kind)
.map(|binding| binding.column.clone())
.collect();
names.sort();
names
};
Self {
positive_columns: columns(SemanticHintKind::Positive),
identifier_columns: columns(SemanticHintKind::Identifier),
temporal_columns: columns(SemanticHintKind::Temporal),
temporal_pair_columns: columns(SemanticHintKind::TemporalPair),
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct ConfigurationComparison {
pub score_weights: SettingComparison<QualityScoreWeights>,
pub semantic_hints: SettingComparison<RecordedHints>,
pub pattern_locale: SettingComparison<PatternLocale>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[non_exhaustive]
pub struct SchemaChange {
pub added: Vec<String>,
pub removed: Vec<String>,
pub common: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[non_exhaustive]
pub struct ReportDelta {
pub quality_score: ScoreChange,
#[serde(serialize_with = "serialize_dimensions")]
pub dimensions: Vec<DimensionChange>,
#[serde(serialize_with = "serialize_columns")]
pub columns: Vec<ColumnChange>,
pub metric_semantics: SemanticsComparison,
pub configuration: ConfigurationComparison,
pub schema: SchemaChange,
pub reports: Sides<ReportReference>,
}
impl ReportDelta {
pub fn between(a: &ProfileReport, b: &ProfileReport) -> Self {
Self::between_sides(&ReportSide::from_report(a), &ReportSide::from_report(b))
}
pub fn between_sides(a: &ReportSide, b: &ReportSide) -> Self {
let pair = |a: Evidence, b: Evidence| Sides { a, b };
let quality_score = ScoreChange::new(
a.quality_score,
b.quality_score,
pair(a.overall_evidence, b.overall_evidence),
);
let dimensions = a
.dimensions
.iter()
.zip(&b.dimensions)
.map(|(left, right)| {
debug_assert_eq!(left.dimension, right.dimension);
DimensionChange {
dimension: left.dimension,
score: ScoreChange::new(
left.score,
right.score,
pair(left.evidence, right.evidence),
),
}
})
.collect();
let a_names: HashSet<&str> = a
.columns
.iter()
.map(|column| column.name.as_str())
.collect();
let b_names: HashSet<&str> = b
.columns
.iter()
.map(|column| column.name.as_str())
.collect();
let a_nulls: BTreeMap<&str, Option<f64>> = a
.columns
.iter()
.map(|column| (column.name.as_str(), column.null_pct))
.collect();
let b_nulls: BTreeMap<&str, Option<f64>> = b
.columns
.iter()
.map(|column| (column.name.as_str(), column.null_pct))
.collect();
let added: Vec<String> = b
.columns
.iter()
.filter(|column| !a_names.contains(column.name.as_str()))
.map(|column| column.name.clone())
.collect();
let columns = a
.columns
.iter()
.map(|column| &column.name)
.chain(&added)
.map(|name| {
let null_pct_a = a_nulls.get(name.as_str()).copied().flatten();
let null_pct_b = b_nulls.get(name.as_str()).copied().flatten();
ColumnChange {
name: name.clone(),
null_pct_a,
null_pct_b,
null_pct_delta: null_pct_a.zip(null_pct_b).map(|(a, b)| rounded_2(b - a)),
}
})
.collect();
let (removed, common) = a
.columns
.iter()
.map(|column| column.name.clone())
.partition(|name| !b_names.contains(name.as_str()));
let comparable = match (&a.metric_semantics, &b.metric_semantics) {
(Some(left), Some(right)) if left.is_fully_recorded() && right.is_fully_recorded() => {
Some(left == right)
}
_ => None,
};
Self {
quality_score,
dimensions,
columns,
metric_semantics: SemanticsComparison {
a: a.metric_semantics.clone(),
b: b.metric_semantics.clone(),
comparable,
},
configuration: ConfigurationComparison {
score_weights: SettingComparison::new(a.score_weights, b.score_weights),
semantic_hints: SettingComparison::new(
a.semantic_hints.clone(),
b.semantic_hints.clone(),
),
pattern_locale: SettingComparison::new(a.pattern_locale, b.pattern_locale),
},
schema: SchemaChange {
added,
removed,
common,
},
reports: Sides {
a: a.reference.clone(),
b: b.reference.clone(),
},
}
}
}
fn serialize_dimensions<S: Serializer>(
dimensions: &[DimensionChange],
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(dimensions.len()))?;
for change in dimensions {
map.serialize_entry(&change.dimension.to_string(), &change.score)?;
}
map.end()
}
fn serialize_columns<S: Serializer>(
columns: &[ColumnChange],
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(columns.len()))?;
for change in columns {
map.serialize_entry(&change.name, change)?;
}
map.end()
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct ReportSide {
reference: ReportReference,
quality_score: Option<f64>,
overall_evidence: Evidence,
dimensions: Vec<DimensionSide>,
columns: Vec<ColumnSide>,
metric_semantics: Option<MetricSemantics>,
score_weights: Option<QualityScoreWeights>,
semantic_hints: Option<RecordedHints>,
identifier_order: Option<Vec<String>>,
pattern_locale: Option<PatternLocale>,
column_selection: Option<ColumnSelection>,
schema_declared: bool,
}
fn identifier_order(bindings: &[SemanticHintBinding]) -> Vec<String> {
bindings
.iter()
.filter(|binding| binding.kind == SemanticHintKind::Identifier)
.map(|binding| binding.column.clone())
.collect()
}
#[derive(Debug, Clone, PartialEq)]
struct DimensionSide {
dimension: QualityDimension,
score: Option<f64>,
evidence: Evidence,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ColumnSide {
pub(crate) name: String,
pub(crate) null_pct: Option<f64>,
pub(crate) data_type: Option<DataType>,
pub(crate) counts: Option<(usize, usize)>,
}
impl ColumnSide {
pub(crate) fn populated(&self) -> Option<bool> {
self.counts.map(|(total, nulls)| nulls < total)
}
pub(crate) fn all_null(&self) -> Option<bool> {
self.counts
.map(|(total, nulls)| total > 0 && nulls == total)
}
}
fn first_of_each_name(columns: impl Iterator<Item = ColumnSide>) -> Vec<ColumnSide> {
let mut seen = HashSet::new();
columns
.filter(|column| seen.insert(column.name.clone()))
.collect()
}
fn at_precision(value: Option<f64>) -> Option<f64> {
value.filter(|value| value.is_finite()).map(rounded_2)
}
impl ReportSide {
pub fn from_report(report: &ProfileReport) -> Self {
let scan = scan_evidence(&report.execution);
let quality = report.quality.as_ref();
let scores = quality.map(|assessment| assessment.scores());
let sampled = quality.map(|assessment| assessment.sampled_dimensions());
let assessed = quality
.map(|assessment| assessment.metrics.assessed_dimensions())
.unwrap_or_default();
let unlisted = quality
.map(|assessment| assessment.unlisted_components())
.unwrap_or_default();
let evidence = |provenance| match &sampled {
Some(sampled) => scan.and(sampled_component_evidence(
sampled.as_deref(),
&unlisted,
&assessed,
provenance,
)),
None => scan,
};
let dimensions = QualityDimension::all()
.into_iter()
.map(|dimension| DimensionSide {
dimension,
score: at_precision(
scores
.as_ref()
.and_then(|scores| scores.dimension_scores.get(&dimension.to_string()))
.copied()
.flatten(),
),
evidence: evidence(Provenance::Dimension(dimension)),
})
.collect();
Self {
reference: ReportReference {
id: Some(report.id.clone()),
timestamp: Some(report.timestamp.clone()),
source: report.source_identifier(),
rows_processed: Some(report.execution.rows_processed),
evidence: scan,
},
quality_score: at_precision(scores.as_ref().and_then(|scores| scores.overall_score)),
overall_evidence: evidence(Provenance::Overall),
dimensions,
columns: first_of_each_name(report.column_profiles.iter().map(|column| {
let pct = (column.total_count > 0)
.then(|| column.null_count as f64 / column.total_count as f64 * 100.0);
ColumnSide {
name: column.name.clone(),
null_pct: at_precision(pct),
data_type: Some(column.data_type.clone()),
counts: Some((column.total_count, column.null_count)),
}
})),
metric_semantics: report.metric_semantics.clone(),
score_weights: quality.map(|assessment| assessment.metrics.score_weights),
semantic_hints: (report.schema_version >= 1)
.then(|| RecordedHints::from_bindings(&report.semantic_hint_bindings)),
identifier_order: (report.schema_version >= 1)
.then(|| identifier_order(&report.semantic_hint_bindings)),
pattern_locale: report
.analysis_settings
.as_ref()
.map(|settings| settings.pattern_locale),
column_selection: report
.analysis_settings
.as_ref()
.map(|settings| settings.columns.clone()),
schema_declared: declares_schema(&report.data_source),
}
}
pub(crate) fn identifier_order(&self) -> Option<&[String]> {
self.identifier_order.as_deref()
}
pub(crate) fn column_selection(&self) -> Option<&ColumnSelection> {
self.column_selection.as_ref()
}
pub(crate) fn schema_evidence(&self) -> Evidence {
if self.schema_declared {
Evidence::Complete
} else {
self.reference.evidence
}
}
pub(crate) fn columns(&self) -> &[ColumnSide] {
&self.columns
}
pub fn from_summary_json(text: &str) -> serde_json::Result<Self> {
let summary: Summary = serde_json::from_str(text)?;
let execution = &summary.execution;
let known_gap = scan_coverage(
execution.source_exhausted == Some(false) || execution.truncation_reason.is_some(),
execution.sampling_applied == Some(true),
execution.error_count.is_some_and(|count| count > 0),
);
let recorded = execution.source_exhausted.is_some()
&& execution.sampling_applied.is_some()
&& execution.error_count.is_some();
let scan = match known_gap {
Evidence::Complete if !recorded => Evidence::Incomplete {
reason: EvidenceGap::CoverageUnrecorded,
},
known => known,
};
let quality = summary.quality.as_ref();
let assessed: Option<Vec<QualityDimension>> = quality.and_then(|quality| {
let parsed: Vec<QualityDimension> = quality
.assessed_dimensions
.iter()
.filter_map(|name| name.parse().ok())
.collect();
(!(parsed.is_empty() && quality.overall_score.is_some())).then_some(parsed)
});
let evidence = |provenance| {
let Some(quality) = quality else {
return scan;
};
let sampled = quality.sampled_dimensions.as_deref();
let coverage = match (provenance, &assessed) {
(Provenance::Overall, None) if sampled.is_some_and(|s| s.is_empty()) => {
Evidence::Complete
}
(Provenance::Overall, None) => Evidence::Incomplete {
reason: EvidenceGap::CoverageUnrecorded,
},
(_, assessed) => sampled_component_evidence(
sampled,
&[],
assessed.as_deref().unwrap_or_default(),
provenance,
),
};
scan.and(coverage)
};
let dimensions = QualityDimension::all()
.into_iter()
.map(|dimension| DimensionSide {
dimension,
score: at_precision(
quality
.and_then(|quality| quality.dimension_scores.get(&dimension.to_string()))
.copied()
.flatten(),
),
evidence: evidence(Provenance::Dimension(dimension)),
})
.collect();
Ok(Self {
reference: ReportReference {
id: None,
timestamp: None,
source: summary.source,
rows_processed: execution.rows_processed,
evidence: scan,
},
quality_score: at_precision(quality.and_then(|quality| quality.overall_score)),
overall_evidence: evidence(Provenance::Overall),
dimensions,
columns: first_of_each_name(summary.columns.into_iter().map(|column| ColumnSide {
name: column.name,
null_pct: at_precision(column.null_percentage),
data_type: column.data_type.as_deref().and_then(DataType::from_name),
counts: column.total_count.zip(column.null_count),
})),
score_weights: quality.and_then(|quality| {
quality.score_weights.or_else(|| {
summary
.metric_semantics
.is_some()
.then(QualityScoreWeights::default)
})
}),
semantic_hints: summary
.schema_version
.is_some_and(|version| version >= 1)
.then(|| RecordedHints::from_bindings(&summary.semantic_hint_bindings)),
identifier_order: summary
.schema_version
.is_some_and(|version| version >= 1)
.then(|| identifier_order(&summary.semantic_hint_bindings)),
metric_semantics: summary.metric_semantics,
pattern_locale: summary
.analysis_settings
.as_ref()
.map(|settings| settings.pattern_locale),
column_selection: summary.analysis_settings.map(|settings| settings.columns),
schema_declared: matches!(
summary.source_type,
Some(SummarySourceType::Dataframe | SummarySourceType::Query)
),
})
}
}
fn summary_metric_semantics<'de, D>(deserializer: D) -> Result<Option<MetricSemantics>, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::profile_report::present_value(deserializer).map_err(|error| {
<D::Error as serde::de::Error>::custom(format!("metric_semantics: {error}"))
})
}
fn summary_schema_version<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::profile_report::present_value(deserializer)
.map_err(|error| <D::Error as serde::de::Error>::custom(format!("schema_version: {error}")))
}
fn summary_analysis_settings<'de, D>(deserializer: D) -> Result<Option<AnalysisSettings>, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::profile_report::present_value(deserializer).map_err(|error| {
<D::Error as serde::de::Error>::custom(format!("analysis_settings: {error}"))
})
}
fn summary_score_weights<'de, D>(deserializer: D) -> Result<Option<QualityScoreWeights>, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::profile_report::present_value(deserializer).map_err(|error| {
<D::Error as serde::de::Error>::custom(format!("quality.score_weights: {error}"))
})
}
#[derive(serde::Deserialize)]
struct Summary {
source: String,
#[serde(default)]
source_type: Option<SummarySourceType>,
execution: SummaryExecution,
columns: Vec<SummaryColumn>,
quality: Option<SummaryQuality>,
#[serde(default, deserialize_with = "summary_metric_semantics")]
metric_semantics: Option<MetricSemantics>,
#[serde(default, deserialize_with = "summary_schema_version")]
schema_version: Option<u32>,
#[serde(default)]
semantic_hint_bindings: Vec<SemanticHintBinding>,
#[serde(default, deserialize_with = "summary_analysis_settings")]
analysis_settings: Option<AnalysisSettings>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "lowercase")]
enum SummarySourceType {
Dataframe,
Query,
#[serde(other)]
Other,
}
fn declares_schema(source: &DataSource) -> bool {
match source {
DataSource::File { format, .. } | DataSource::Bytes { format, .. } => {
matches!(format, FileFormat::Csv | FileFormat::Parquet)
}
DataSource::DataFrame { .. } | DataSource::Query { .. } => true,
DataSource::Stream { .. } => false,
}
}
#[derive(serde::Deserialize)]
struct SummaryExecution {
rows_processed: Option<usize>,
source_exhausted: Option<bool>,
truncation_reason: Option<String>,
sampling_applied: Option<bool>,
error_count: Option<usize>,
}
#[derive(serde::Deserialize)]
struct SummaryColumn {
name: String,
null_percentage: Option<f64>,
#[serde(default)]
data_type: Option<String>,
#[serde(default)]
total_count: Option<usize>,
#[serde(default)]
null_count: Option<usize>,
}
#[derive(serde::Deserialize)]
struct SummaryQuality {
overall_score: Option<f64>,
#[serde(default)]
dimension_scores: BTreeMap<String, Option<f64>>,
#[serde(default)]
assessed_dimensions: Vec<String>,
sampled_dimensions: Option<Vec<String>>,
#[serde(default, deserialize_with = "summary_score_weights")]
score_weights: Option<QualityScoreWeights>,
}
#[cfg(test)]
mod tests {
use super::*;
fn complete() -> Sides<Evidence> {
Sides {
a: Evidence::Complete,
b: Evidence::Complete,
}
}
#[test]
fn a_zero_baseline_has_an_absolute_change_and_no_relative_one() {
let change = ScoreChange::new(Some(0.0), Some(50.0), complete());
assert_eq!(change.abs, Some(50.0));
assert_eq!(change.rel_pct, None);
}
#[test]
fn a_change_is_rounded_to_the_serialized_precision() {
let change = ScoreChange::new(Some(80.0), Some(80.1), complete());
assert_eq!(change.abs, Some(0.1));
assert_eq!(change.rel_pct, Some(0.12));
}
#[test]
fn a_missing_side_leaves_both_changes_absent() {
for (a, b) in [(None, Some(1.0)), (Some(1.0), None), (None, None)] {
let change = ScoreChange::new(a, b, complete());
assert_eq!((change.abs, change.rel_pct), (None, None));
}
}
#[test]
fn a_non_finite_input_is_no_number() {
assert_eq!(at_precision(Some(f64::NAN)), None);
assert_eq!(at_precision(Some(f64::INFINITY)), None);
assert_eq!(at_precision(Some(12.345)), Some(12.35));
}
}