1use std::collections::HashMap;
2
3use dataprof_core::{ColumnProfile, QualityDimension, QualityScoreWeights};
4use serde::{Deserialize, Serialize};
5
6use crate::core::errors::DataProfilerError;
7
8#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
10pub struct CompletenessMetrics {
11 #[serde(serialize_with = "crate::serde_helpers::round_2")]
12 pub missing_values_ratio: f64,
13 #[serde(serialize_with = "crate::serde_helpers::round_2")]
18 pub complete_records_ratio: f64,
19 pub null_columns: Vec<String>,
20 #[serde(default)]
23 pub total_cells: usize,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
28pub struct ConsistencyMetrics {
29 #[serde(serialize_with = "crate::serde_helpers::round_2")]
30 pub data_type_consistency: f64,
31 pub format_violations: usize,
32 pub encoding_issues: usize,
33 #[serde(default)]
36 pub values_checked: usize,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
41pub struct UniquenessMetrics {
42 pub duplicate_rows: usize,
43 #[serde(serialize_with = "crate::serde_helpers::round_2")]
44 pub key_uniqueness: f64,
45 pub high_cardinality_warning: bool,
46 #[serde(default)]
49 pub rows_checked: usize,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub key_column: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub key_values_checked: Option<usize>,
65 #[serde(default, skip_serializing_if = "is_false")]
69 pub duplicate_rows_approximate: bool,
70}
71
72#[derive(Debug, Clone, Copy)]
80pub struct RowDuplicateSummary {
81 pub duplicate_rows: usize,
82 pub rows_checked: usize,
83 pub approximate: bool,
84 pub max_duplicate_rows: usize,
88}
89
90#[derive(Debug, Clone, Copy)]
99pub struct RowCompletenessSummary {
100 pub complete_rows: usize,
101 pub rows_checked: usize,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct AccuracyMetrics {
107 #[serde(serialize_with = "crate::serde_helpers::round_2")]
111 pub outlier_ratio: f64,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub outlier_values_checked: Option<usize>,
119 pub range_violations: usize,
123 pub negative_values_in_positive: usize,
126 #[serde(default)]
130 pub numeric_values_checked: usize,
131 #[serde(default)]
136 #[schemars(default)]
137 pub rule_values_checked: usize,
138 #[serde(default)]
142 #[schemars(default)]
143 pub rule_violating_values: usize,
144}
145
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
148pub struct TimelinessMetrics {
149 pub future_dates_count: usize,
150 #[serde(serialize_with = "crate::serde_helpers::round_2")]
151 pub stale_data_ratio: f64,
152 pub temporal_violations: usize,
153 #[serde(default)]
156 pub invalid_date_values: usize,
157 #[serde(default)]
162 pub date_values_checked: usize,
163 #[serde(default)]
168 pub temporal_pairs_checked: usize,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
173pub struct ValidityMetrics {
174 #[serde(serialize_with = "crate::serde_helpers::round_2")]
175 pub valid_values_ratio: f64,
176 pub invalid_values: usize,
177 #[serde(default)]
179 pub values_checked: usize,
180}
181
182#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
184pub struct PrecisionMetrics {
185 #[serde(serialize_with = "crate::serde_helpers::round_2")]
186 pub decimal_places_consistency: f64,
187 pub inconsistent_precision_values: usize,
188 #[serde(default)]
190 pub numeric_values_checked: usize,
191}
192
193pub(crate) trait Assessed {
210 fn assessed(&self) -> bool;
211}
212
213fn is_unassessed<T: Assessed>(metrics: &Option<T>) -> bool {
215 metrics.as_ref().is_none_or(|metrics| !metrics.assessed())
216}
217
218impl CompletenessMetrics {
219 pub fn is_assessed(&self) -> bool {
221 self.total_cells > 0
222 }
223}
224
225impl ConsistencyMetrics {
226 pub fn is_assessed(&self) -> bool {
228 self.values_checked > 0
229 }
230}
231
232impl UniquenessMetrics {
233 pub fn is_assessed(&self) -> bool {
236 self.rows_checked > 0 || self.key_measured()
237 }
238
239 pub fn key_measured(&self) -> bool {
243 self.key_column.is_some() && self.key_values_checked != Some(0)
244 }
245}
246
247impl AccuracyMetrics {
248 pub fn is_assessed(&self) -> bool {
252 self.outliers_measured() || self.rule_values_checked > 0
253 }
254
255 pub fn outliers_measured(&self) -> bool {
259 match self.outlier_values_checked {
260 Some(tested) => tested > 0,
261 None => self.numeric_values_checked > 0,
262 }
263 }
264}
265
266impl TimelinessMetrics {
267 pub fn is_assessed(&self) -> bool {
269 self.date_values_checked > 0
270 }
271}
272
273impl ValidityMetrics {
274 pub fn is_assessed(&self) -> bool {
276 self.values_checked > 0
277 }
278}
279
280impl PrecisionMetrics {
281 pub fn is_assessed(&self) -> bool {
283 self.numeric_values_checked > 0
284 }
285}
286
287macro_rules! impl_assessed {
288 ($($metrics:ty),+ $(,)?) => {
289 $(impl Assessed for $metrics {
290 fn assessed(&self) -> bool {
291 self.is_assessed()
292 }
293 })+
294 };
295}
296
297impl_assessed!(
298 CompletenessMetrics,
299 ConsistencyMetrics,
300 UniquenessMetrics,
301 AccuracyMetrics,
302 TimelinessMetrics,
303 ValidityMetrics,
304 PrecisionMetrics,
305);
306
307#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
309pub struct QualityMetrics {
310 #[serde(skip_serializing_if = "is_unassessed")]
311 pub completeness: Option<CompletenessMetrics>,
312 #[serde(skip_serializing_if = "is_unassessed")]
313 pub consistency: Option<ConsistencyMetrics>,
314 #[serde(skip_serializing_if = "is_unassessed")]
315 pub uniqueness: Option<UniquenessMetrics>,
316 #[serde(skip_serializing_if = "is_unassessed")]
317 pub accuracy: Option<AccuracyMetrics>,
318 #[serde(skip_serializing_if = "is_unassessed")]
319 pub timeliness: Option<TimelinessMetrics>,
320 #[serde(skip_serializing_if = "is_unassessed")]
321 pub validity: Option<ValidityMetrics>,
322 #[serde(skip_serializing_if = "is_unassessed")]
323 pub precision: Option<PrecisionMetrics>,
324 #[serde(default, skip_serializing_if = "is_false")]
329 pub low_sample_warning: bool,
330 #[serde(default)]
339 pub score_weights: QualityScoreWeights,
340}
341
342fn is_false(b: &bool) -> bool {
343 !*b
344}
345
346impl QualityMetrics {
347 pub fn empty() -> Self {
348 Self {
349 completeness: Some(CompletenessMetrics {
350 missing_values_ratio: 0.0,
351 complete_records_ratio: 100.0,
352 null_columns: vec![],
353 total_cells: 0,
354 }),
355 consistency: Some(ConsistencyMetrics {
356 data_type_consistency: 100.0,
357 format_violations: 0,
358 encoding_issues: 0,
359 values_checked: 0,
360 }),
361 uniqueness: Some(UniquenessMetrics {
362 duplicate_rows: 0,
363 key_uniqueness: 100.0,
364 high_cardinality_warning: false,
365 rows_checked: 0,
366 key_column: None,
367 key_values_checked: None,
368 duplicate_rows_approximate: false,
369 }),
370 accuracy: Some(AccuracyMetrics {
371 outlier_ratio: 0.0,
372 outlier_values_checked: Some(0),
373 range_violations: 0,
374 negative_values_in_positive: 0,
375 numeric_values_checked: 0,
376 rule_values_checked: 0,
377 rule_violating_values: 0,
378 }),
379 timeliness: Some(TimelinessMetrics {
380 future_dates_count: 0,
381 stale_data_ratio: 0.0,
382 temporal_violations: 0,
383 invalid_date_values: 0,
384 date_values_checked: 0,
385 temporal_pairs_checked: 0,
386 }),
387 validity: Some(ValidityMetrics {
388 valid_values_ratio: 100.0,
389 invalid_values: 0,
390 values_checked: 0,
391 }),
392 precision: Some(PrecisionMetrics {
393 decimal_places_consistency: 100.0,
394 inconsistent_precision_values: 0,
395 numeric_values_checked: 0,
396 }),
397 low_sample_warning: false,
398 score_weights: QualityScoreWeights::default(),
399 }
400 }
401
402 pub fn calculate_from_data(
403 data: &HashMap<String, Vec<String>>,
404 column_profiles: &[ColumnProfile],
405 ) -> Result<Self, DataProfilerError> {
406 let calculator = crate::analysis::MetricsCalculator::new();
407 calculator.calculate_comprehensive_metrics(data, column_profiles, None)
408 }
409
410 pub fn completeness_score(&self) -> Option<f64> {
416 let c = self.completeness.as_ref().filter(|c| c.is_assessed())?;
417 let cell_level = 100.0 - c.missing_values_ratio;
418 Some(((cell_level + c.complete_records_ratio) / 2.0).clamp(0.0, 100.0))
419 }
420
421 pub fn consistency_score(&self) -> Option<f64> {
427 let c = self.consistency.as_ref().filter(|c| c.is_assessed())?;
428 let violation_ratio =
429 (c.format_violations + c.encoding_issues) as f64 / c.values_checked as f64;
430 Some((c.data_type_consistency - violation_ratio * 100.0).clamp(0.0, 100.0))
431 }
432
433 pub fn uniqueness_score(&self) -> Option<f64> {
443 let u = self.uniqueness.as_ref().filter(|u| u.is_assessed())?;
444 let duplicate_score = (u.rows_checked > 0)
445 .then(|| (1.0 - u.duplicate_rows as f64 / u.rows_checked as f64) * 100.0);
446 let key_score = u.key_measured().then_some(u.key_uniqueness);
447
448 let (sum, count) = [duplicate_score, key_score]
449 .iter()
450 .flatten()
451 .fold((0.0, 0u32), |(sum, count), score| (sum + score, count + 1));
452 if count == 0 {
453 return None;
454 }
455 Some((sum / count as f64).clamp(0.0, 100.0))
456 }
457
458 pub fn accuracy_score(&self) -> Option<f64> {
466 let a = self.accuracy.as_ref().filter(|a| a.is_assessed())?;
468 let violation_ratio = if a.rule_values_checked > 0 {
469 a.rule_violating_values as f64 / a.rule_values_checked as f64
470 } else {
471 (a.range_violations + a.negative_values_in_positive) as f64
476 / a.numeric_values_checked as f64
477 };
478 Some((100.0 - a.outlier_ratio - violation_ratio * 100.0).clamp(0.0, 100.0))
479 }
480
481 pub fn timeliness_score(&self) -> Option<f64> {
489 let t = self.timeliness.as_ref().filter(|t| t.is_assessed())?;
490 let value_violation_ratio =
491 (t.future_dates_count + t.invalid_date_values) as f64 / t.date_values_checked as f64;
492 let temporal_ratio = if t.temporal_pairs_checked > 0 {
493 t.temporal_violations as f64 / t.temporal_pairs_checked as f64
494 } else {
495 0.0
496 };
497 Some(
498 (100.0 - t.stale_data_ratio - (value_violation_ratio + temporal_ratio) * 100.0)
499 .clamp(0.0, 100.0),
500 )
501 }
502
503 pub fn validity_score(&self) -> Option<f64> {
506 let validity = self.validity.as_ref().filter(|v| v.is_assessed())?;
507 Some(validity.valid_values_ratio.clamp(0.0, 100.0))
508 }
509
510 pub fn precision_score(&self) -> Option<f64> {
513 let precision = self.precision.as_ref().filter(|p| p.is_assessed())?;
514 Some(precision.decimal_places_consistency.clamp(0.0, 100.0))
515 }
516
517 fn weighted_scores(&self) -> [(QualityDimension, f64, Option<f64>); 7] {
519 [
520 (
521 QualityDimension::Completeness,
522 self.score_weights.completeness,
523 self.completeness_score(),
524 ),
525 (
526 QualityDimension::Consistency,
527 self.score_weights.consistency,
528 self.consistency_score(),
529 ),
530 (
531 QualityDimension::Uniqueness,
532 self.score_weights.uniqueness,
533 self.uniqueness_score(),
534 ),
535 (
536 QualityDimension::Accuracy,
537 self.score_weights.accuracy,
538 self.accuracy_score(),
539 ),
540 (
541 QualityDimension::Timeliness,
542 self.score_weights.timeliness,
543 self.timeliness_score(),
544 ),
545 (
546 QualityDimension::Validity,
547 self.score_weights.validity,
548 self.validity_score(),
549 ),
550 (
551 QualityDimension::Precision,
552 self.score_weights.precision,
553 self.precision_score(),
554 ),
555 ]
556 }
557
558 pub fn dimension_score(&self, dimension: QualityDimension) -> Option<f64> {
562 match dimension {
563 QualityDimension::Completeness => self.completeness_score(),
564 QualityDimension::Consistency => self.consistency_score(),
565 QualityDimension::Uniqueness => self.uniqueness_score(),
566 QualityDimension::Accuracy => self.accuracy_score(),
567 QualityDimension::Timeliness => self.timeliness_score(),
568 QualityDimension::Validity => self.validity_score(),
569 QualityDimension::Precision => self.precision_score(),
570 }
571 }
572
573 pub fn assessed_dimensions(&self) -> Vec<QualityDimension> {
576 self.weighted_scores()
577 .iter()
578 .filter(|(_, weight, score)| *weight > 0.0 && score.is_some())
579 .map(|(dim, _, _)| *dim)
580 .collect()
581 }
582
583 pub fn overall_score(&self) -> Option<f64> {
593 let mut total_weight = 0.0;
594 let mut score = 0.0;
595
596 for (_, weight, dimension_score) in self.weighted_scores() {
597 if let Some(value) = dimension_score {
598 total_weight += weight;
599 score += value * weight;
600 }
601 }
602
603 (total_weight > 0.0).then(|| (score / total_weight).min(100.0))
604 }
605
606 pub fn missing_values_ratio(&self) -> f64 {
607 self.completeness
608 .as_ref()
609 .map_or(0.0, |c| c.missing_values_ratio)
610 }
611
612 pub fn complete_records_ratio(&self) -> f64 {
613 self.completeness
614 .as_ref()
615 .map_or(100.0, |c| c.complete_records_ratio)
616 }
617
618 pub fn null_columns(&self) -> &[String] {
619 self.completeness.as_ref().map_or(&[], |c| &c.null_columns)
620 }
621
622 pub fn data_type_consistency(&self) -> f64 {
623 self.consistency
624 .as_ref()
625 .map_or(100.0, |c| c.data_type_consistency)
626 }
627
628 pub fn format_violations(&self) -> usize {
629 self.consistency.as_ref().map_or(0, |c| c.format_violations)
630 }
631
632 pub fn encoding_issues(&self) -> usize {
633 self.consistency.as_ref().map_or(0, |c| c.encoding_issues)
634 }
635
636 pub fn duplicate_rows(&self) -> usize {
637 self.uniqueness.as_ref().map_or(0, |u| u.duplicate_rows)
638 }
639
640 pub fn key_uniqueness(&self) -> f64 {
641 self.uniqueness.as_ref().map_or(100.0, |u| u.key_uniqueness)
642 }
643
644 pub fn high_cardinality_warning(&self) -> bool {
645 self.uniqueness
646 .as_ref()
647 .is_some_and(|u| u.high_cardinality_warning)
648 }
649
650 pub fn outlier_ratio(&self) -> f64 {
651 self.accuracy.as_ref().map_or(0.0, |a| a.outlier_ratio)
652 }
653
654 pub fn range_violations(&self) -> usize {
655 self.accuracy.as_ref().map_or(0, |a| a.range_violations)
656 }
657
658 pub fn negative_values_in_positive(&self) -> usize {
659 self.accuracy
660 .as_ref()
661 .map_or(0, |a| a.negative_values_in_positive)
662 }
663
664 pub fn future_dates_count(&self) -> usize {
665 self.timeliness.as_ref().map_or(0, |t| t.future_dates_count)
666 }
667
668 pub fn stale_data_ratio(&self) -> f64 {
669 self.timeliness.as_ref().map_or(0.0, |t| t.stale_data_ratio)
670 }
671
672 pub fn temporal_violations(&self) -> usize {
673 self.timeliness
674 .as_ref()
675 .map_or(0, |t| t.temporal_violations)
676 }
677
678 pub fn invalid_date_values(&self) -> usize {
679 self.timeliness
680 .as_ref()
681 .map_or(0, |t| t.invalid_date_values)
682 }
683
684 pub fn valid_values_ratio(&self) -> f64 {
685 self.validity
686 .as_ref()
687 .map_or(100.0, |v| v.valid_values_ratio)
688 }
689
690 pub fn invalid_values(&self) -> usize {
691 self.validity.as_ref().map_or(0, |v| v.invalid_values)
692 }
693
694 pub fn decimal_places_consistency(&self) -> f64 {
695 self.precision
696 .as_ref()
697 .map_or(100.0, |p| p.decimal_places_consistency)
698 }
699
700 pub fn inconsistent_precision_values(&self) -> usize {
701 self.precision
702 .as_ref()
703 .map_or(0, |p| p.inconsistent_precision_values)
704 }
705
706 pub fn supports_dimension(&self, dimension: QualityDimension) -> bool {
707 match dimension {
708 QualityDimension::Completeness => self.completeness.is_some(),
709 QualityDimension::Consistency => self.consistency.is_some(),
710 QualityDimension::Uniqueness => self.uniqueness.is_some(),
711 QualityDimension::Accuracy => self.accuracy.is_some(),
712 QualityDimension::Timeliness => self.timeliness.is_some(),
713 QualityDimension::Validity => self.validity.is_some(),
714 QualityDimension::Precision => self.precision.is_some(),
715 }
716 }
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
721pub enum MetricConfidence {
722 Exact,
723 Approximate {
724 sample_size: usize,
725 population_size: Option<usize>,
726 },
727 Mixed {
728 exact_dimensions: Vec<String>,
729 sampled_dimensions: Vec<String>,
730 sample_size: usize,
731 },
732 NotAssessed,
736 Unrecorded,
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
749pub struct QualityScores {
750 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")]
751 pub overall_score: Option<f64>,
752 #[serde(serialize_with = "serialize_dimension_scores")]
753 pub dimension_scores: std::collections::BTreeMap<String, Option<f64>>,
754}
755
756fn serialize_dimension_scores<S: serde::Serializer>(
757 scores: &std::collections::BTreeMap<String, Option<f64>>,
758 serializer: S,
759) -> Result<S::Ok, S::Error> {
760 #[derive(Serialize)]
761 struct Score(
762 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2_opt")] Option<f64>,
763 );
764 scores
765 .iter()
766 .map(|(name, value)| (name, Score(*value)))
767 .collect::<std::collections::BTreeMap<_, _>>()
768 .serialize(serializer)
769}
770
771impl QualityScores {
772 fn from_metrics(metrics: &QualityMetrics) -> Self {
773 Self {
774 overall_score: metrics.overall_score(),
775 dimension_scores: QualityDimension::all()
776 .into_iter()
777 .map(|dimension| (dimension.to_string(), metrics.dimension_score(dimension)))
778 .collect(),
779 }
780 }
781}
782
783#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
785pub struct ScoreInterval {
786 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
787 pub lower: f64,
788 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
789 pub upper: f64,
790}
791
792impl ScoreInterval {
793 fn is_valid(&self) -> bool {
794 self.lower.is_finite()
795 && self.upper.is_finite()
796 && 0.0 <= self.lower
797 && self.lower <= self.upper
798 && self.upper <= 100.0
799 }
800}
801
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
814pub struct ScoreBounds {
815 pub confidence_level: f64,
817 pub overall_score: Option<ScoreInterval>,
820 pub dimension_scores: std::collections::BTreeMap<String, Option<ScoreInterval>>,
828}
829
830impl ScoreBounds {
831 fn is_valid(&self) -> bool {
832 self.confidence_level.is_finite()
833 && 0.0 < self.confidence_level
834 && self.confidence_level < 1.0
835 && self
836 .overall_score
837 .is_none_or(|interval| interval.is_valid())
838 && self
839 .dimension_scores
840 .values()
841 .all(|interval| interval.is_none_or(|interval| interval.is_valid()))
842 }
843}
844
845#[derive(Debug, Clone, schemars::JsonSchema)]
847pub struct QualityAssessment {
848 pub metrics: QualityMetrics,
849 pub confidence: MetricConfidence,
850 #[schemars(default)]
853 scores: Option<QualityScores>,
854 #[schemars(skip)]
855 score_inputs: Option<Box<QualityMetrics>>,
856 #[schemars(default)]
859 score_bounds: Option<ScoreBounds>,
860 #[schemars(skip)]
861 bounds_inputs: Option<Box<QualityMetrics>>,
862}
863
864impl Serialize for QualityAssessment {
865 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
866 #[derive(Serialize)]
867 struct Document<'a> {
868 metrics: &'a QualityMetrics,
869 confidence: &'a MetricConfidence,
870 scores: QualityScores,
871 #[serde(skip_serializing_if = "Option::is_none")]
872 score_bounds: Option<&'a ScoreBounds>,
873 }
874 Document {
875 metrics: &self.metrics,
876 confidence: &self.confidence,
877 scores: self.scores(),
878 score_bounds: self.score_bounds(),
879 }
880 .serialize(serializer)
881 }
882}
883
884impl<'de> Deserialize<'de> for QualityAssessment {
885 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
886 #[derive(Deserialize)]
887 struct Document {
888 metrics: QualityMetrics,
889 confidence: MetricConfidence,
890 #[serde(default)]
891 scores: Option<QualityScores>,
892 #[serde(default)]
893 score_bounds: Option<ScoreBounds>,
894 }
895 let document = Document::deserialize(deserializer)?;
896 if document
897 .score_bounds
898 .as_ref()
899 .is_some_and(|bounds| !bounds.is_valid())
900 {
901 return Err(serde::de::Error::custom(
902 "quality score bounds must lie in 0..=100 with lower <= upper, at a confidence level between 0 and 1",
903 ));
904 }
905 if let Some(scores) = &document.scores {
906 let in_range = |score: Option<f64>| {
907 score.is_none_or(|value| value.is_finite() && (0.0..=100.0).contains(&value))
908 };
909 let valid_score =
910 |score: Option<f64>, assessed: bool| score.is_some() == assessed && in_range(score);
911 if !scores
914 .dimension_scores
915 .values()
916 .all(|score| in_range(*score))
917 || !valid_score(
918 scores.overall_score,
919 document.metrics.overall_score().is_some(),
920 )
921 || QualityDimension::all().into_iter().any(|dimension| {
922 scores
923 .dimension_scores
924 .get(&dimension.to_string())
925 .is_none_or(|score| {
926 !valid_score(
927 *score,
928 document.metrics.dimension_score(dimension).is_some(),
929 )
930 })
931 })
932 {
933 return Err(serde::de::Error::custom(
934 "quality scores must include every dimension, preserve unassessed scores as null, and lie in 0..=100",
935 ));
936 }
937 }
938 let score_inputs = if document.scores.is_some() {
939 Some(Box::new(document.metrics.clone()))
940 } else {
941 None
942 };
943 let bounds_inputs = document
944 .score_bounds
945 .as_ref()
946 .map(|_| Box::new(document.metrics.clone()));
947 Ok(Self {
948 metrics: document.metrics,
949 confidence: document.confidence,
950 scores: document.scores,
951 score_inputs,
952 score_bounds: document.score_bounds,
953 bounds_inputs,
954 })
955 }
956}
957
958impl QualityAssessment {
959 pub fn new(metrics: QualityMetrics, confidence: MetricConfidence) -> Self {
972 let measured = QualityDimension::all()
973 .into_iter()
974 .any(|dimension| metrics.dimension_score(dimension).is_some());
975 let confidence = if measured {
976 confidence
977 } else {
978 MetricConfidence::NotAssessed
979 };
980 Self {
981 metrics,
982 confidence,
983 scores: None,
984 score_inputs: None,
985 score_bounds: None,
986 bounds_inputs: None,
987 }
988 }
989
990 pub fn with_score_bounds(mut self, bounds: Option<ScoreBounds>) -> Self {
993 self.bounds_inputs = bounds.as_ref().map(|_| Box::new(self.metrics.clone()));
994 self.score_bounds = bounds;
995 self
996 }
997
998 pub fn score_bounds(&self) -> Option<&ScoreBounds> {
1004 match (&self.score_bounds, &self.bounds_inputs) {
1005 (Some(bounds), Some(inputs)) if &self.metrics == inputs.as_ref() => Some(bounds),
1006 _ => None,
1007 }
1008 }
1009
1010 pub fn exact(metrics: QualityMetrics) -> Self {
1011 Self::new(metrics, MetricConfidence::Exact)
1012 }
1013
1014 pub fn approximate(
1015 metrics: QualityMetrics,
1016 sample_size: usize,
1017 population_size: Option<usize>,
1018 ) -> Self {
1019 Self::new(
1020 metrics,
1021 MetricConfidence::Approximate {
1022 sample_size,
1023 population_size,
1024 },
1025 )
1026 }
1027
1028 pub fn score(&self) -> Option<f64> {
1031 self.scores().overall_score
1032 }
1033
1034 pub fn scores(&self) -> QualityScores {
1038 if let (Some(scores), Some(inputs)) = (&self.scores, &self.score_inputs)
1039 && &self.metrics == inputs.as_ref()
1040 {
1041 return scores.clone();
1042 }
1043 QualityScores::from_metrics(&self.metrics)
1044 }
1045
1046 pub fn sampled_dimensions(&self) -> Option<Vec<String>> {
1064 match &self.confidence {
1065 MetricConfidence::Exact | MetricConfidence::NotAssessed => Some(Vec::new()),
1066 MetricConfidence::Mixed {
1067 sampled_dimensions, ..
1068 } => Some(sampled_dimensions.clone()),
1069 MetricConfidence::Approximate { .. } => Some(
1072 QualityDimension::all()
1073 .into_iter()
1074 .filter(|dimension| self.metrics.supports_dimension(*dimension))
1075 .flat_map(|dimension| self.component_labels(dimension))
1076 .collect(),
1077 ),
1078 MetricConfidence::Unrecorded => None,
1079 }
1080 }
1081
1082 pub fn unlisted_components(&self) -> Vec<String> {
1095 let MetricConfidence::Mixed {
1096 exact_dimensions,
1097 sampled_dimensions,
1098 ..
1099 } = &self.confidence
1100 else {
1101 return Vec::new();
1102 };
1103 QualityDimension::all()
1104 .into_iter()
1105 .filter(|dimension| self.metrics.supports_dimension(*dimension))
1106 .flat_map(|dimension| self.component_labels(dimension))
1107 .filter(|label| {
1108 !exact_dimensions.contains(label) && !sampled_dimensions.contains(label)
1109 })
1110 .collect()
1111 }
1112
1113 fn component_labels(&self, dimension: QualityDimension) -> Vec<String> {
1116 let QualityDimension::Uniqueness = dimension else {
1117 return vec![dimension.to_string()];
1118 };
1119 let mut labels = Vec::new();
1120 if let Some(uniqueness) = &self.metrics.uniqueness {
1121 if uniqueness.key_measured() {
1122 labels.push("key_uniqueness".to_string());
1123 }
1124 if uniqueness.rows_checked > 0 {
1125 labels.push("duplicate_rows".to_string());
1126 }
1127 }
1128 labels
1129 }
1130}
1131
1132impl From<QualityMetrics> for QualityAssessment {
1133 fn from(metrics: QualityMetrics) -> Self {
1134 Self::exact(metrics)
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 fn sample_bounds() -> ScoreBounds {
1143 let interval = |lower, upper| Some(ScoreInterval { lower, upper });
1144 ScoreBounds {
1145 confidence_level: 0.999,
1146 overall_score: interval(97.25, 99.5),
1147 dimension_scores: QualityDimension::all()
1148 .into_iter()
1149 .map(|dimension| (dimension.to_string(), interval(96.0, 100.0)))
1150 .collect(),
1151 }
1152 }
1153
1154 #[test]
1155 fn score_bounds_survive_a_saved_document() {
1156 let assessment = QualityAssessment::approximate(perfect_assessed(), 10, Some(100))
1157 .with_score_bounds(Some(sample_bounds()));
1158 let text = serde_json::to_string(&assessment).unwrap();
1159 let restored: QualityAssessment = serde_json::from_str(&text).unwrap();
1160
1161 assert_eq!(restored.score_bounds(), Some(&sample_bounds()));
1162 }
1163
1164 #[test]
1165 fn an_exact_assessment_writes_no_bounds() {
1166 let text = serde_json::to_string(&QualityAssessment::exact(perfect_assessed())).unwrap();
1167 let document: serde_json::Value = serde_json::from_str(&text).unwrap();
1168 assert!(document.get("score_bounds").is_none());
1169 }
1170
1171 #[test]
1172 fn edited_metrics_lose_the_bounds_computed_for_the_old_ones() {
1173 let mut assessment = QualityAssessment::approximate(perfect_assessed(), 10, Some(100))
1174 .with_score_bounds(Some(sample_bounds()));
1175 assessment
1176 .metrics
1177 .consistency
1178 .as_mut()
1179 .unwrap()
1180 .format_violations = 50;
1181
1182 assert_eq!(assessment.score_bounds(), None);
1183 let text = serde_json::to_string(&assessment).unwrap();
1184 assert!(!text.contains("score_bounds"));
1185 }
1186
1187 #[test]
1188 fn a_document_with_inverted_or_out_of_range_bounds_is_refused() {
1189 let assessment = QualityAssessment::approximate(perfect_assessed(), 10, Some(100))
1190 .with_score_bounds(Some(sample_bounds()));
1191 let document = serde_json::to_value(&assessment).unwrap();
1192 for (pointer, value) in [
1193 ("/score_bounds/overall_score/lower", serde_json::json!(99.9)),
1194 (
1195 "/score_bounds/overall_score/upper",
1196 serde_json::json!(100.5),
1197 ),
1198 ("/score_bounds/confidence_level", serde_json::json!(1.0)),
1199 (
1200 "/score_bounds/dimension_scores/validity/lower",
1201 serde_json::json!(-1.0),
1202 ),
1203 ] {
1204 let mut broken = document.clone();
1205 *broken.pointer_mut(pointer).unwrap() = value;
1206 assert!(
1207 serde_json::from_value::<QualityAssessment>(broken).is_err(),
1208 "{pointer} was accepted"
1209 );
1210 }
1211 }
1212
1213 #[test]
1214 fn saved_aggregate_scores_survive_rounding_their_inputs() {
1215 let metrics = QualityMetrics {
1216 completeness: Some(CompletenessMetrics {
1217 missing_values_ratio: 32.77465638193188,
1218 complete_records_ratio: 67.22534361806812,
1219 null_columns: vec![],
1220 total_cells: 100,
1221 }),
1222 ..QualityMetrics::default()
1223 };
1224 let assessment = QualityAssessment::exact(metrics);
1225 let document = serde_json::to_value(&assessment).unwrap();
1226 assert_eq!(document["scores"]["overall_score"], 67.23);
1227 let mut restored: QualityAssessment = serde_json::from_value(document.clone()).unwrap();
1228 assert_eq!(restored.score(), Some(67.23));
1229 assert_eq!(
1230 restored.scores().dimension_scores["completeness"],
1231 Some(67.23)
1232 );
1233 assert_eq!(serde_json::to_value(&restored).unwrap(), document);
1234
1235 restored
1238 .metrics
1239 .completeness
1240 .as_mut()
1241 .unwrap()
1242 .missing_values_ratio += 0.0001;
1243 assert_eq!(restored.score(), restored.metrics.overall_score());
1244 assert_ne!(restored.score(), Some(67.23));
1245 }
1246
1247 #[test]
1248 fn legacy_quality_without_saved_scores_remains_readable() {
1249 let assessment = QualityAssessment::exact(perfect_assessed());
1250 let mut document = serde_json::to_value(&assessment).unwrap();
1251 document.as_object_mut().unwrap().remove("scores");
1252 let restored: QualityAssessment = serde_json::from_value(document).unwrap();
1253 assert_eq!(restored.score(), restored.metrics.overall_score());
1254 }
1255
1256 #[test]
1257 fn saved_scores_cannot_fabricate_assessed_dimensions() {
1258 let assessment = QualityAssessment::exact(QualityMetrics::default());
1259 let mut document = serde_json::to_value(&assessment).unwrap();
1260 document["scores"]["dimension_scores"]["accuracy"] = serde_json::json!(100.0);
1261 assert!(serde_json::from_value::<QualityAssessment>(document).is_err());
1262 let mut document = serde_json::to_value(&assessment).unwrap();
1263 document["scores"]["dimension_scores"]
1264 .as_object_mut()
1265 .unwrap()
1266 .remove("accuracy");
1267 assert!(serde_json::from_value::<QualityAssessment>(document).is_err());
1268 }
1269
1270 #[test]
1271 fn saved_scores_for_unknown_dimensions_are_range_checked() {
1272 let assessment = QualityAssessment::exact(perfect_assessed());
1273 let mut document = serde_json::to_value(&assessment).unwrap();
1274 document["scores"]["dimension_scores"]["lineage"] = serde_json::json!(55.0);
1275 let restored: QualityAssessment = serde_json::from_value(document.clone()).unwrap();
1276 assert_eq!(restored.scores().dimension_scores["lineage"], Some(55.0));
1277 document["scores"]["dimension_scores"]["lineage"] = serde_json::json!(1000.0);
1278 assert!(serde_json::from_value::<QualityAssessment>(document).is_err());
1279 }
1280
1281 fn perfect_assessed() -> QualityMetrics {
1283 QualityMetrics {
1284 completeness: Some(CompletenessMetrics {
1285 missing_values_ratio: 0.0,
1286 complete_records_ratio: 100.0,
1287 null_columns: vec![],
1288 total_cells: 100,
1289 }),
1290 consistency: Some(ConsistencyMetrics {
1291 data_type_consistency: 100.0,
1292 format_violations: 0,
1293 encoding_issues: 0,
1294 values_checked: 100,
1295 }),
1296 uniqueness: Some(UniquenessMetrics {
1297 duplicate_rows: 0,
1298 key_uniqueness: 100.0,
1299 high_cardinality_warning: false,
1300 rows_checked: 100,
1301 key_column: None,
1302 key_values_checked: None,
1303 duplicate_rows_approximate: false,
1304 }),
1305 accuracy: Some(AccuracyMetrics {
1306 outlier_ratio: 0.0,
1307 outlier_values_checked: Some(100),
1308 range_violations: 0,
1309 negative_values_in_positive: 0,
1310 numeric_values_checked: 100,
1311 rule_values_checked: 0,
1312 rule_violating_values: 0,
1313 }),
1314 timeliness: Some(TimelinessMetrics {
1315 future_dates_count: 0,
1316 stale_data_ratio: 0.0,
1317 temporal_violations: 0,
1318 invalid_date_values: 0,
1319 date_values_checked: 100,
1320 temporal_pairs_checked: 100,
1321 }),
1322 validity: Some(ValidityMetrics {
1323 valid_values_ratio: 100.0,
1324 invalid_values: 0,
1325 values_checked: 100,
1326 }),
1327 precision: Some(PrecisionMetrics {
1328 decimal_places_consistency: 100.0,
1329 inconsistent_precision_values: 0,
1330 numeric_values_checked: 100,
1331 }),
1332 low_sample_warning: false,
1333 score_weights: QualityScoreWeights::default(),
1334 }
1335 }
1336
1337 #[test]
1338 fn test_custom_weights_change_and_survive_serialized_score() {
1339 let mut metrics = perfect_assessed();
1340 if let Some(ref mut c) = metrics.completeness {
1341 c.missing_values_ratio = 100.0;
1342 c.complete_records_ratio = 0.0;
1343 }
1344 metrics.score_weights = QualityScoreWeights {
1345 completeness: 1.0,
1346 consistency: 0.0,
1347 uniqueness: 0.0,
1348 accuracy: 0.0,
1349 timeliness: 0.0,
1350 validity: 0.0,
1351 precision: 0.0,
1352 };
1353
1354 assert_eq!(metrics.overall_score(), Some(0.0));
1355
1356 let json = serde_json::to_string(&metrics).expect("serialize custom weights");
1357 assert!(json.contains("score_weights"));
1358 let restored: QualityMetrics =
1359 serde_json::from_str(&json).expect("deserialize custom weights");
1360 assert_eq!(restored.score_weights, metrics.score_weights);
1361 assert_eq!(restored.overall_score(), metrics.overall_score());
1362 assert_eq!(
1363 restored.assessed_dimensions(),
1364 vec![QualityDimension::Completeness]
1365 );
1366 }
1367
1368 #[test]
1369 fn test_empty_metrics_nothing_assessed() {
1370 let metrics = QualityMetrics::empty();
1371 assert!(metrics.assessed_dimensions().is_empty());
1372 assert_eq!(metrics.overall_score(), None);
1375 }
1376
1377 #[test]
1378 fn test_unassessable_assessment_reports_no_score_and_no_confidence() {
1379 let assessment = QualityAssessment::exact(QualityMetrics::empty());
1380 assert_eq!(assessment.score(), None);
1381 assert!(
1382 matches!(assessment.confidence, MetricConfidence::NotAssessed),
1383 "confidence must not claim exactness about a score that was never computed, got {:?}",
1384 assessment.confidence
1385 );
1386
1387 let sampled = QualityAssessment::approximate(QualityMetrics::empty(), 0, Some(0));
1388 assert!(matches!(sampled.confidence, MetricConfidence::NotAssessed));
1389 }
1390
1391 #[test]
1392 fn test_assessed_assessment_keeps_its_confidence() {
1393 let assessment = QualityAssessment::exact(perfect_assessed());
1394 assert_eq!(assessment.score(), Some(100.0));
1395 assert!(matches!(assessment.confidence, MetricConfidence::Exact));
1396 }
1397
1398 #[test]
1399 fn test_perfect_assessed_scores_100() {
1400 let metrics = perfect_assessed();
1401 assert_eq!(metrics.assessed_dimensions().len(), 7);
1402 assert_eq!(metrics.overall_score(), Some(100.0));
1403 }
1404
1405 #[test]
1406 fn test_quality_score_completeness_weight() {
1407 let mut metrics = perfect_assessed();
1408 if let Some(ref mut c) = metrics.completeness {
1409 c.missing_values_ratio = 100.0;
1410 c.complete_records_ratio = 0.0;
1411 }
1412 assert_eq!(metrics.overall_score(), Some(75.0));
1413 }
1414
1415 #[test]
1416 fn test_quality_score_all_bad() {
1417 let mut metrics = perfect_assessed();
1418 if let Some(ref mut c) = metrics.completeness {
1419 c.missing_values_ratio = 100.0;
1420 c.complete_records_ratio = 0.0;
1421 }
1422 if let Some(ref mut c) = metrics.consistency {
1423 c.data_type_consistency = 0.0;
1424 }
1425 if let Some(ref mut u) = metrics.uniqueness {
1426 u.duplicate_rows = 100;
1427 }
1428 if let Some(ref mut a) = metrics.accuracy {
1429 a.outlier_ratio = 100.0;
1430 }
1431 if let Some(ref mut t) = metrics.timeliness {
1432 t.stale_data_ratio = 100.0;
1433 }
1434 if let Some(ref mut v) = metrics.validity {
1435 v.valid_values_ratio = 0.0;
1436 }
1437 if let Some(ref mut p) = metrics.precision {
1438 p.decimal_places_consistency = 0.0;
1439 }
1440
1441 assert_eq!(metrics.overall_score(), Some(0.0));
1443 }
1444
1445 #[test]
1446 fn test_vacuous_dimensions_drop_out() {
1447 let mut metrics = perfect_assessed();
1452 if let Some(ref mut c) = metrics.completeness {
1453 c.missing_values_ratio = 50.0;
1454 c.complete_records_ratio = 50.0;
1455 }
1456 if let Some(ref mut u) = metrics.uniqueness {
1457 u.rows_checked = 0;
1458 }
1459 if let Some(ref mut a) = metrics.accuracy {
1460 a.outlier_values_checked = Some(0);
1462 a.rule_values_checked = 0;
1463 }
1464 if let Some(ref mut t) = metrics.timeliness {
1465 t.date_values_checked = 0;
1466 }
1467 if let Some(ref mut v) = metrics.validity {
1468 v.values_checked = 0;
1469 }
1470 if let Some(ref mut p) = metrics.precision {
1471 p.numeric_values_checked = 0;
1472 }
1473
1474 assert_eq!(
1475 metrics.assessed_dimensions(),
1476 vec![
1477 QualityDimension::Completeness,
1478 QualityDimension::Consistency
1479 ]
1480 );
1481 assert!((metrics.overall_score().expect("assessed") - 72.2222).abs() < 0.01);
1483 }
1484
1485 #[test]
1486 fn test_duplicate_rows_lower_uniqueness_score() {
1487 let mut metrics = perfect_assessed();
1488 if let Some(ref mut u) = metrics.uniqueness {
1489 u.duplicate_rows = 30;
1490 }
1491 let score = metrics
1492 .uniqueness_score()
1493 .expect("uniqueness should be assessed");
1494 assert!((score - 70.0).abs() < 0.01);
1495 }
1496
1497 #[test]
1498 fn test_key_only_uniqueness_when_duplicate_scan_not_assessable() {
1499 let mut metrics = perfect_assessed();
1502 if let Some(ref mut u) = metrics.uniqueness {
1503 u.rows_checked = 0;
1504 u.key_column = Some("order_id".to_string());
1505 u.key_uniqueness = 90.0;
1506 }
1507 let score = metrics
1508 .uniqueness_score()
1509 .expect("key component alone should keep uniqueness assessed");
1510 assert!((score - 90.0).abs() < 0.01);
1511 }
1512
1513 #[test]
1514 fn test_key_column_blends_into_uniqueness_score() {
1515 let mut metrics = perfect_assessed();
1516 if let Some(ref mut u) = metrics.uniqueness {
1517 u.key_column = Some("order_id".to_string());
1518 u.key_uniqueness = 60.0;
1519 }
1520 let score = metrics
1522 .uniqueness_score()
1523 .expect("uniqueness should be assessed");
1524 assert!((score - 80.0).abs() < 0.01);
1525 }
1526
1527 #[test]
1528 fn test_format_and_encoding_violations_lower_consistency_score() {
1529 let mut metrics = perfect_assessed();
1530 if let Some(ref mut c) = metrics.consistency {
1531 c.format_violations = 5;
1532 c.encoding_issues = 5;
1533 }
1534 let score = metrics
1535 .consistency_score()
1536 .expect("consistency should be assessed");
1537 assert!((score - 90.0).abs() < 0.01);
1538 }
1539
1540 #[test]
1541 fn test_range_and_negative_violations_lower_accuracy_score() {
1542 let mut metrics = perfect_assessed();
1546 if let Some(ref mut a) = metrics.accuracy {
1547 a.outlier_ratio = 10.0;
1548 a.range_violations = 3;
1549 a.negative_values_in_positive = 1;
1550 a.rule_values_checked = 20;
1551 a.rule_violating_values = 2;
1552 }
1553 let score = metrics
1554 .accuracy_score()
1555 .expect("accuracy should be assessed");
1556 assert!((score - 80.0).abs() < 0.01, "{score}");
1557 }
1558
1559 #[test]
1563 fn accuracy_without_rule_units_scores_as_recorded() {
1564 let mut metrics = perfect_assessed();
1565 if let Some(ref mut a) = metrics.accuracy {
1566 a.outlier_ratio = 10.0;
1567 a.range_violations = 5;
1568 a.negative_values_in_positive = 5;
1569 }
1570 let score = metrics
1571 .accuracy_score()
1572 .expect("accuracy should be assessed");
1573 assert!((score - 80.0).abs() < 0.01, "{score}");
1574 }
1575
1576 #[test]
1577 fn test_future_dates_and_temporal_violations_lower_timeliness_score() {
1578 let mut metrics = perfect_assessed();
1579 if let Some(ref mut t) = metrics.timeliness {
1580 t.stale_data_ratio = 20.0;
1581 t.future_dates_count = 5;
1582 t.temporal_violations = 5;
1583 }
1584 let score = metrics
1585 .timeliness_score()
1586 .expect("timeliness should be assessed");
1587 assert!((score - 70.0).abs() < 0.01);
1588 }
1589
1590 #[test]
1591 fn test_legacy_json_without_denominators_is_not_assessed() {
1592 let json = r#"{
1596 "completeness": {
1597 "missing_values_ratio": 5.0,
1598 "complete_records_ratio": 95.0,
1599 "null_columns": []
1600 }
1601 }"#;
1602 let metrics: QualityMetrics = serde_json::from_str(json).unwrap();
1603
1604 assert!((metrics.missing_values_ratio() - 5.0).abs() < 0.01);
1605 assert!(metrics.completeness_score().is_none());
1606 assert!(metrics.assessed_dimensions().is_empty());
1607 }
1608
1609 #[test]
1610 fn test_partial_dimensions_only_completeness() {
1611 let metrics = QualityMetrics {
1612 completeness: Some(CompletenessMetrics {
1613 complete_records_ratio: 100.0,
1614 missing_values_ratio: 0.0,
1615 null_columns: vec![],
1616 total_cells: 10,
1617 }),
1618 ..QualityMetrics::default()
1619 };
1620
1621 assert!(metrics.completeness.is_some());
1622 assert!(metrics.consistency.is_none());
1623 assert!(metrics.uniqueness.is_none());
1624 assert!(metrics.accuracy.is_none());
1625 assert!(metrics.timeliness.is_none());
1626 assert_eq!(metrics.overall_score(), Some(100.0));
1627 }
1628
1629 #[test]
1630 fn test_partial_dimensions_two_dimensions() {
1631 let metrics = QualityMetrics {
1632 completeness: Some(CompletenessMetrics {
1633 missing_values_ratio: 50.0,
1634 complete_records_ratio: 50.0,
1635 null_columns: vec![],
1636 total_cells: 100,
1637 }),
1638 uniqueness: Some(UniquenessMetrics {
1639 duplicate_rows: 20,
1640 key_uniqueness: 100.0,
1641 high_cardinality_warning: false,
1642 rows_checked: 100,
1643 key_column: None,
1644 key_values_checked: None,
1645 duplicate_rows_approximate: false,
1646 }),
1647 ..QualityMetrics::default()
1648 };
1649
1650 assert!((metrics.overall_score().expect("assessed") - 61.25).abs() < 0.01);
1652 }
1653
1654 #[test]
1655 fn test_all_dimensions_none_score_is_none() {
1656 let metrics = QualityMetrics::default();
1657
1658 assert_eq!(metrics.overall_score(), None);
1659 assert!(metrics.assessed_dimensions().is_empty());
1660 }
1661
1662 #[test]
1663 fn test_partial_dimensions_json_skips_none() {
1664 let metrics = QualityMetrics {
1665 completeness: Some(CompletenessMetrics {
1666 total_cells: 10,
1667 ..CompletenessMetrics::default()
1668 }),
1669 ..QualityMetrics::default()
1670 };
1671
1672 let json = serde_json::to_value(&metrics).unwrap();
1674 assert!(json.get("completeness").is_some());
1675 assert!(json.get("consistency").is_none());
1676 assert!(json.get("uniqueness").is_none());
1677 assert!(json.get("accuracy").is_none());
1678 assert!(json.get("timeliness").is_none());
1679 }
1680
1681 #[test]
1682 fn a_dimension_with_no_denominator_is_not_serialized() {
1683 let metrics = QualityMetrics::empty();
1687 assert!(metrics.assessed_dimensions().is_empty());
1688
1689 let json = serde_json::to_value(&metrics).unwrap();
1690 for dimension in [
1691 "completeness",
1692 "consistency",
1693 "uniqueness",
1694 "accuracy",
1695 "timeliness",
1696 "validity",
1697 "precision",
1698 ] {
1699 assert!(
1700 json.get(dimension).is_none(),
1701 "{dimension} was serialized without having assessed anything: {json}"
1702 );
1703 }
1704 }
1705
1706 #[test]
1707 fn a_zero_weighted_dimension_keeps_its_evidence() {
1708 let mut metrics = QualityMetrics::empty();
1713 metrics.validity = Some(ValidityMetrics {
1714 valid_values_ratio: 80.0,
1715 invalid_values: 2,
1716 values_checked: 10,
1717 });
1718 metrics.score_weights = QualityScoreWeights {
1719 validity: 0.0,
1720 ..QualityScoreWeights::default()
1721 };
1722
1723 assert!(metrics.validity.as_ref().expect("present").is_assessed());
1724 assert!(metrics.validity_score().is_some());
1725 assert!(
1726 !metrics
1727 .assessed_dimensions()
1728 .contains(&QualityDimension::Validity)
1729 );
1730 assert_eq!(metrics.overall_score(), None);
1731
1732 let json = serde_json::to_string(&metrics).unwrap();
1733 assert!(
1734 json.contains("valid_values_ratio"),
1735 "a measured dimension was dropped for carrying no weight: {json}"
1736 );
1737 }
1738
1739 #[test]
1740 fn serialized_dimensions_are_exactly_the_ones_with_a_denominator() {
1741 let mut metrics = QualityMetrics::empty();
1742 metrics.completeness = Some(CompletenessMetrics {
1743 missing_values_ratio: 10.0,
1744 complete_records_ratio: 90.0,
1745 null_columns: vec!["a".to_string()],
1746 total_cells: 100,
1747 });
1748 metrics.validity = Some(ValidityMetrics {
1749 valid_values_ratio: 80.0,
1750 invalid_values: 2,
1751 values_checked: 10,
1752 });
1753
1754 let assessed = metrics.assessed_dimensions();
1755 assert_eq!(
1756 assessed,
1757 vec![QualityDimension::Completeness, QualityDimension::Validity]
1758 );
1759
1760 let json = serde_json::to_value(&metrics).unwrap();
1761 assert!(json.get("completeness").is_some());
1762 assert!(json.get("validity").is_some());
1763 assert!(json.get("consistency").is_none());
1764 assert!(json.get("precision").is_none());
1765 }
1766
1767 #[test]
1768 fn is_assessed_agrees_with_the_dimension_score() {
1769 let assessed = QualityMetrics {
1772 uniqueness: Some(UniquenessMetrics {
1773 duplicate_rows: 0,
1774 key_uniqueness: 100.0,
1775 high_cardinality_warning: false,
1776 rows_checked: 0,
1777 key_column: Some("id".to_string()),
1780 key_values_checked: Some(10),
1781 duplicate_rows_approximate: false,
1782 }),
1783 ..QualityMetrics::default()
1784 };
1785 let uniqueness = assessed.uniqueness.as_ref().expect("present");
1786 assert!(uniqueness.is_assessed());
1787 assert!(assessed.uniqueness_score().is_some());
1788
1789 let unassessed = QualityMetrics::empty();
1790 for metrics in [
1791 unassessed.completeness.as_ref().map(|m| m.is_assessed()),
1792 unassessed.consistency.as_ref().map(|m| m.is_assessed()),
1793 unassessed.uniqueness.as_ref().map(|m| m.is_assessed()),
1794 unassessed.accuracy.as_ref().map(|m| m.is_assessed()),
1795 unassessed.timeliness.as_ref().map(|m| m.is_assessed()),
1796 unassessed.validity.as_ref().map(|m| m.is_assessed()),
1797 unassessed.precision.as_ref().map(|m| m.is_assessed()),
1798 ] {
1799 assert_eq!(metrics, Some(false));
1800 }
1801 }
1802
1803 #[test]
1804 fn test_partial_dimensions_flat_accessors_return_defaults() {
1805 let metrics = QualityMetrics::default();
1806
1807 assert!((metrics.complete_records_ratio() - 100.0).abs() < 0.01);
1808 assert!((metrics.data_type_consistency() - 100.0).abs() < 0.01);
1809 assert!((metrics.key_uniqueness() - 100.0).abs() < 0.01);
1810 assert!((metrics.missing_values_ratio() - 0.0).abs() < 0.01);
1811 assert_eq!(metrics.duplicate_rows(), 0);
1812 assert!(!metrics.high_cardinality_warning());
1813 }
1814
1815 #[test]
1816 fn test_partial_dimension_flat_defaults_table() {
1817 struct Case {
1818 name: &'static str,
1819 metrics: QualityMetrics,
1820 has_completeness: bool,
1821 has_uniqueness: bool,
1822 has_accuracy: bool,
1823 missing_values_ratio: f64,
1824 key_uniqueness: f64,
1825 outlier_ratio: f64,
1826 }
1827
1828 let cases = [
1829 Case {
1830 name: "only completeness",
1831 metrics: QualityMetrics {
1832 completeness: Some(CompletenessMetrics {
1833 missing_values_ratio: 12.5,
1834 complete_records_ratio: 87.5,
1835 null_columns: vec!["email".to_string()],
1836 total_cells: 16,
1837 }),
1838 ..QualityMetrics::default()
1839 },
1840 has_completeness: true,
1841 has_uniqueness: false,
1842 has_accuracy: false,
1843 missing_values_ratio: 12.5,
1844 key_uniqueness: 100.0,
1845 outlier_ratio: 0.0,
1846 },
1847 Case {
1848 name: "only uniqueness",
1849 metrics: QualityMetrics {
1850 uniqueness: Some(UniquenessMetrics {
1851 duplicate_rows: 2,
1852 key_uniqueness: 92.0,
1853 high_cardinality_warning: true,
1854 rows_checked: 25,
1855 key_column: Some("user_id".to_string()),
1856 key_values_checked: Some(25),
1857 duplicate_rows_approximate: false,
1858 }),
1859 ..QualityMetrics::default()
1860 },
1861 has_completeness: false,
1862 has_uniqueness: true,
1863 has_accuracy: false,
1864 missing_values_ratio: 0.0,
1865 key_uniqueness: 92.0,
1866 outlier_ratio: 0.0,
1867 },
1868 Case {
1869 name: "only accuracy",
1870 metrics: QualityMetrics {
1871 accuracy: Some(AccuracyMetrics {
1872 outlier_ratio: 6.25,
1873 outlier_values_checked: Some(16),
1874 range_violations: 1,
1875 negative_values_in_positive: 1,
1876 numeric_values_checked: 16,
1877 rule_values_checked: 8,
1878 rule_violating_values: 1,
1879 }),
1880 ..QualityMetrics::default()
1881 },
1882 has_completeness: false,
1883 has_uniqueness: false,
1884 has_accuracy: true,
1885 missing_values_ratio: 0.0,
1886 key_uniqueness: 100.0,
1887 outlier_ratio: 6.25,
1888 },
1889 ];
1890
1891 for case in cases {
1892 assert_eq!(
1893 case.metrics.completeness.is_some(),
1894 case.has_completeness,
1895 "{} completeness presence",
1896 case.name
1897 );
1898 assert_eq!(
1899 case.metrics.uniqueness.is_some(),
1900 case.has_uniqueness,
1901 "{} uniqueness presence",
1902 case.name
1903 );
1904 assert_eq!(
1905 case.metrics.accuracy.is_some(),
1906 case.has_accuracy,
1907 "{} accuracy presence",
1908 case.name
1909 );
1910 assert!(
1911 (case.metrics.missing_values_ratio() - case.missing_values_ratio).abs() < 0.01,
1912 "{} missing_values_ratio",
1913 case.name
1914 );
1915 assert!(
1916 (case.metrics.key_uniqueness() - case.key_uniqueness).abs() < 0.01,
1917 "{} key_uniqueness",
1918 case.name
1919 );
1920 assert!(
1921 (case.metrics.outlier_ratio() - case.outlier_ratio).abs() < 0.01,
1922 "{} outlier_ratio",
1923 case.name
1924 );
1925 }
1926 }
1927}