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, Serialize, Deserialize)]
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")]
14 pub complete_records_ratio: f64,
15 pub null_columns: Vec<String>,
16 #[serde(default)]
19 pub total_cells: usize,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct ConsistencyMetrics {
25 #[serde(serialize_with = "crate::serde_helpers::round_2")]
26 pub data_type_consistency: f64,
27 pub format_violations: usize,
28 pub encoding_issues: usize,
29 #[serde(default)]
32 pub values_checked: usize,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct UniquenessMetrics {
38 pub duplicate_rows: usize,
39 #[serde(serialize_with = "crate::serde_helpers::round_2")]
40 pub key_uniqueness: f64,
41 pub high_cardinality_warning: bool,
42 #[serde(default)]
45 pub rows_checked: usize,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub key_column: Option<String>,
51 #[serde(default, skip_serializing_if = "is_false")]
55 pub duplicate_rows_approximate: bool,
56}
57
58#[derive(Debug, Clone, Copy)]
66pub struct RowDuplicateSummary {
67 pub duplicate_rows: usize,
68 pub rows_checked: usize,
69 pub approximate: bool,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct AccuracyMetrics {
75 #[serde(serialize_with = "crate::serde_helpers::round_2")]
76 pub outlier_ratio: f64,
77 pub range_violations: usize,
78 pub negative_values_in_positive: usize,
79 #[serde(default)]
83 pub numeric_values_checked: usize,
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct TimelinessMetrics {
89 pub future_dates_count: usize,
90 #[serde(serialize_with = "crate::serde_helpers::round_2")]
91 pub stale_data_ratio: f64,
92 pub temporal_violations: usize,
93 #[serde(default)]
96 pub invalid_date_values: usize,
97 #[serde(default)]
102 pub date_values_checked: usize,
103 #[serde(default)]
108 pub temporal_pairs_checked: usize,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113pub struct ValidityMetrics {
114 #[serde(serialize_with = "crate::serde_helpers::round_2")]
115 pub valid_values_ratio: f64,
116 pub invalid_values: usize,
117 #[serde(default)]
119 pub values_checked: usize,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct PrecisionMetrics {
125 #[serde(serialize_with = "crate::serde_helpers::round_2")]
126 pub decimal_places_consistency: f64,
127 pub inconsistent_precision_values: usize,
128 #[serde(default)]
130 pub numeric_values_checked: usize,
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
135pub struct QualityMetrics {
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub completeness: Option<CompletenessMetrics>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub consistency: Option<ConsistencyMetrics>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub uniqueness: Option<UniquenessMetrics>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub accuracy: Option<AccuracyMetrics>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub timeliness: Option<TimelinessMetrics>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub validity: Option<ValidityMetrics>,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 pub precision: Option<PrecisionMetrics>,
150 #[serde(default, skip_serializing_if = "is_false")]
155 pub low_sample_warning: bool,
156 #[serde(default, skip_serializing_if = "QualityScoreWeights::is_default")]
160 pub score_weights: QualityScoreWeights,
161}
162
163fn is_false(b: &bool) -> bool {
164 !*b
165}
166
167impl QualityMetrics {
168 pub fn empty() -> Self {
169 Self {
170 completeness: Some(CompletenessMetrics {
171 missing_values_ratio: 0.0,
172 complete_records_ratio: 100.0,
173 null_columns: vec![],
174 total_cells: 0,
175 }),
176 consistency: Some(ConsistencyMetrics {
177 data_type_consistency: 100.0,
178 format_violations: 0,
179 encoding_issues: 0,
180 values_checked: 0,
181 }),
182 uniqueness: Some(UniquenessMetrics {
183 duplicate_rows: 0,
184 key_uniqueness: 100.0,
185 high_cardinality_warning: false,
186 rows_checked: 0,
187 key_column: None,
188 duplicate_rows_approximate: false,
189 }),
190 accuracy: Some(AccuracyMetrics {
191 outlier_ratio: 0.0,
192 range_violations: 0,
193 negative_values_in_positive: 0,
194 numeric_values_checked: 0,
195 }),
196 timeliness: Some(TimelinessMetrics {
197 future_dates_count: 0,
198 stale_data_ratio: 0.0,
199 temporal_violations: 0,
200 invalid_date_values: 0,
201 date_values_checked: 0,
202 temporal_pairs_checked: 0,
203 }),
204 validity: Some(ValidityMetrics {
205 valid_values_ratio: 100.0,
206 invalid_values: 0,
207 values_checked: 0,
208 }),
209 precision: Some(PrecisionMetrics {
210 decimal_places_consistency: 100.0,
211 inconsistent_precision_values: 0,
212 numeric_values_checked: 0,
213 }),
214 low_sample_warning: false,
215 score_weights: QualityScoreWeights::default(),
216 }
217 }
218
219 pub fn calculate_from_data(
220 data: &HashMap<String, Vec<String>>,
221 column_profiles: &[ColumnProfile],
222 ) -> Result<Self, DataProfilerError> {
223 let calculator = crate::analysis::MetricsCalculator::new();
224 calculator.calculate_comprehensive_metrics(data, column_profiles, None)
225 }
226
227 pub fn completeness_score(&self) -> Option<f64> {
233 let c = self.completeness.as_ref()?;
234 if c.total_cells == 0 {
235 return None;
236 }
237 let cell_level = 100.0 - c.missing_values_ratio;
238 Some(((cell_level + c.complete_records_ratio) / 2.0).clamp(0.0, 100.0))
239 }
240
241 pub fn consistency_score(&self) -> Option<f64> {
247 let c = self.consistency.as_ref()?;
248 if c.values_checked == 0 {
249 return None;
250 }
251 let violation_ratio =
252 (c.format_violations + c.encoding_issues) as f64 / c.values_checked as f64;
253 Some((c.data_type_consistency - violation_ratio * 100.0).clamp(0.0, 100.0))
254 }
255
256 pub fn uniqueness_score(&self) -> Option<f64> {
265 let u = self.uniqueness.as_ref()?;
266 let duplicate_score = (u.rows_checked > 0)
267 .then(|| (1.0 - u.duplicate_rows as f64 / u.rows_checked as f64) * 100.0);
268 let key_score = u.key_column.is_some().then_some(u.key_uniqueness);
269
270 let (sum, count) = [duplicate_score, key_score]
271 .iter()
272 .flatten()
273 .fold((0.0, 0u32), |(sum, count), score| (sum + score, count + 1));
274 if count == 0 {
275 return None;
276 }
277 Some((sum / count as f64).clamp(0.0, 100.0))
278 }
279
280 pub fn accuracy_score(&self) -> Option<f64> {
287 let a = self.accuracy.as_ref()?;
288 if a.numeric_values_checked == 0 {
289 return None;
290 }
291 let violation_ratio = (a.range_violations + a.negative_values_in_positive) as f64
292 / a.numeric_values_checked as f64;
293 Some((100.0 - a.outlier_ratio - violation_ratio * 100.0).clamp(0.0, 100.0))
294 }
295
296 pub fn timeliness_score(&self) -> Option<f64> {
304 let t = self.timeliness.as_ref()?;
305 if t.date_values_checked == 0 {
306 return None;
307 }
308 let value_violation_ratio =
309 (t.future_dates_count + t.invalid_date_values) as f64 / t.date_values_checked as f64;
310 let temporal_ratio = if t.temporal_pairs_checked > 0 {
311 t.temporal_violations as f64 / t.temporal_pairs_checked as f64
312 } else {
313 0.0
314 };
315 Some(
316 (100.0 - t.stale_data_ratio - (value_violation_ratio + temporal_ratio) * 100.0)
317 .clamp(0.0, 100.0),
318 )
319 }
320
321 pub fn validity_score(&self) -> Option<f64> {
324 let validity = self.validity.as_ref()?;
325 (validity.values_checked > 0).then_some(validity.valid_values_ratio.clamp(0.0, 100.0))
326 }
327
328 pub fn precision_score(&self) -> Option<f64> {
331 let precision = self.precision.as_ref()?;
332 (precision.numeric_values_checked > 0)
333 .then_some(precision.decimal_places_consistency.clamp(0.0, 100.0))
334 }
335
336 fn weighted_scores(&self) -> [(QualityDimension, f64, Option<f64>); 7] {
338 [
339 (
340 QualityDimension::Completeness,
341 self.score_weights.completeness,
342 self.completeness_score(),
343 ),
344 (
345 QualityDimension::Consistency,
346 self.score_weights.consistency,
347 self.consistency_score(),
348 ),
349 (
350 QualityDimension::Uniqueness,
351 self.score_weights.uniqueness,
352 self.uniqueness_score(),
353 ),
354 (
355 QualityDimension::Accuracy,
356 self.score_weights.accuracy,
357 self.accuracy_score(),
358 ),
359 (
360 QualityDimension::Timeliness,
361 self.score_weights.timeliness,
362 self.timeliness_score(),
363 ),
364 (
365 QualityDimension::Validity,
366 self.score_weights.validity,
367 self.validity_score(),
368 ),
369 (
370 QualityDimension::Precision,
371 self.score_weights.precision,
372 self.precision_score(),
373 ),
374 ]
375 }
376
377 pub fn assessed_dimensions(&self) -> Vec<QualityDimension> {
380 self.weighted_scores()
381 .iter()
382 .filter(|(_, weight, score)| *weight > 0.0 && score.is_some())
383 .map(|(dim, _, _)| *dim)
384 .collect()
385 }
386
387 pub fn overall_score(&self) -> f64 {
396 let mut total_weight = 0.0;
397 let mut score = 0.0;
398
399 for (_, weight, dimension_score) in self.weighted_scores() {
400 if let Some(value) = dimension_score {
401 total_weight += weight;
402 score += value * weight;
403 }
404 }
405
406 if total_weight > 0.0 {
407 (score / total_weight).min(100.0)
408 } else {
409 0.0
410 }
411 }
412
413 pub fn missing_values_ratio(&self) -> f64 {
414 self.completeness
415 .as_ref()
416 .map_or(0.0, |c| c.missing_values_ratio)
417 }
418
419 pub fn complete_records_ratio(&self) -> f64 {
420 self.completeness
421 .as_ref()
422 .map_or(100.0, |c| c.complete_records_ratio)
423 }
424
425 pub fn null_columns(&self) -> &[String] {
426 self.completeness.as_ref().map_or(&[], |c| &c.null_columns)
427 }
428
429 pub fn data_type_consistency(&self) -> f64 {
430 self.consistency
431 .as_ref()
432 .map_or(100.0, |c| c.data_type_consistency)
433 }
434
435 pub fn format_violations(&self) -> usize {
436 self.consistency.as_ref().map_or(0, |c| c.format_violations)
437 }
438
439 pub fn encoding_issues(&self) -> usize {
440 self.consistency.as_ref().map_or(0, |c| c.encoding_issues)
441 }
442
443 pub fn duplicate_rows(&self) -> usize {
444 self.uniqueness.as_ref().map_or(0, |u| u.duplicate_rows)
445 }
446
447 pub fn key_uniqueness(&self) -> f64 {
448 self.uniqueness.as_ref().map_or(100.0, |u| u.key_uniqueness)
449 }
450
451 pub fn high_cardinality_warning(&self) -> bool {
452 self.uniqueness
453 .as_ref()
454 .is_some_and(|u| u.high_cardinality_warning)
455 }
456
457 pub fn outlier_ratio(&self) -> f64 {
458 self.accuracy.as_ref().map_or(0.0, |a| a.outlier_ratio)
459 }
460
461 pub fn range_violations(&self) -> usize {
462 self.accuracy.as_ref().map_or(0, |a| a.range_violations)
463 }
464
465 pub fn negative_values_in_positive(&self) -> usize {
466 self.accuracy
467 .as_ref()
468 .map_or(0, |a| a.negative_values_in_positive)
469 }
470
471 pub fn future_dates_count(&self) -> usize {
472 self.timeliness.as_ref().map_or(0, |t| t.future_dates_count)
473 }
474
475 pub fn stale_data_ratio(&self) -> f64 {
476 self.timeliness.as_ref().map_or(0.0, |t| t.stale_data_ratio)
477 }
478
479 pub fn temporal_violations(&self) -> usize {
480 self.timeliness
481 .as_ref()
482 .map_or(0, |t| t.temporal_violations)
483 }
484
485 pub fn invalid_date_values(&self) -> usize {
486 self.timeliness
487 .as_ref()
488 .map_or(0, |t| t.invalid_date_values)
489 }
490
491 pub fn valid_values_ratio(&self) -> f64 {
492 self.validity
493 .as_ref()
494 .map_or(100.0, |v| v.valid_values_ratio)
495 }
496
497 pub fn invalid_values(&self) -> usize {
498 self.validity.as_ref().map_or(0, |v| v.invalid_values)
499 }
500
501 pub fn decimal_places_consistency(&self) -> f64 {
502 self.precision
503 .as_ref()
504 .map_or(100.0, |p| p.decimal_places_consistency)
505 }
506
507 pub fn inconsistent_precision_values(&self) -> usize {
508 self.precision
509 .as_ref()
510 .map_or(0, |p| p.inconsistent_precision_values)
511 }
512
513 pub fn supports_dimension(&self, dimension: QualityDimension) -> bool {
514 match dimension {
515 QualityDimension::Completeness => self.completeness.is_some(),
516 QualityDimension::Consistency => self.consistency.is_some(),
517 QualityDimension::Uniqueness => self.uniqueness.is_some(),
518 QualityDimension::Accuracy => self.accuracy.is_some(),
519 QualityDimension::Timeliness => self.timeliness.is_some(),
520 QualityDimension::Validity => self.validity.is_some(),
521 QualityDimension::Precision => self.precision.is_some(),
522 }
523 }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528pub enum MetricConfidence {
529 Exact,
530 Approximate {
531 sample_size: usize,
532 population_size: Option<usize>,
533 },
534 Mixed {
535 exact_dimensions: Vec<String>,
536 sampled_dimensions: Vec<String>,
537 sample_size: usize,
538 },
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct QualityAssessment {
544 pub metrics: QualityMetrics,
545 pub confidence: MetricConfidence,
546}
547
548impl QualityAssessment {
549 pub fn exact(metrics: QualityMetrics) -> Self {
550 Self {
551 metrics,
552 confidence: MetricConfidence::Exact,
553 }
554 }
555
556 pub fn approximate(
557 metrics: QualityMetrics,
558 sample_size: usize,
559 population_size: Option<usize>,
560 ) -> Self {
561 Self {
562 metrics,
563 confidence: MetricConfidence::Approximate {
564 sample_size,
565 population_size,
566 },
567 }
568 }
569
570 pub fn score(&self) -> f64 {
571 self.metrics.overall_score()
572 }
573}
574
575impl From<QualityMetrics> for QualityAssessment {
576 fn from(metrics: QualityMetrics) -> Self {
577 Self::exact(metrics)
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn perfect_assessed() -> QualityMetrics {
587 QualityMetrics {
588 completeness: Some(CompletenessMetrics {
589 missing_values_ratio: 0.0,
590 complete_records_ratio: 100.0,
591 null_columns: vec![],
592 total_cells: 100,
593 }),
594 consistency: Some(ConsistencyMetrics {
595 data_type_consistency: 100.0,
596 format_violations: 0,
597 encoding_issues: 0,
598 values_checked: 100,
599 }),
600 uniqueness: Some(UniquenessMetrics {
601 duplicate_rows: 0,
602 key_uniqueness: 100.0,
603 high_cardinality_warning: false,
604 rows_checked: 100,
605 key_column: None,
606 duplicate_rows_approximate: false,
607 }),
608 accuracy: Some(AccuracyMetrics {
609 outlier_ratio: 0.0,
610 range_violations: 0,
611 negative_values_in_positive: 0,
612 numeric_values_checked: 100,
613 }),
614 timeliness: Some(TimelinessMetrics {
615 future_dates_count: 0,
616 stale_data_ratio: 0.0,
617 temporal_violations: 0,
618 invalid_date_values: 0,
619 date_values_checked: 100,
620 temporal_pairs_checked: 100,
621 }),
622 validity: Some(ValidityMetrics {
623 valid_values_ratio: 100.0,
624 invalid_values: 0,
625 values_checked: 100,
626 }),
627 precision: Some(PrecisionMetrics {
628 decimal_places_consistency: 100.0,
629 inconsistent_precision_values: 0,
630 numeric_values_checked: 100,
631 }),
632 low_sample_warning: false,
633 score_weights: QualityScoreWeights::default(),
634 }
635 }
636
637 #[test]
638 fn test_custom_weights_change_and_survive_serialized_score() {
639 let mut metrics = perfect_assessed();
640 if let Some(ref mut c) = metrics.completeness {
641 c.missing_values_ratio = 100.0;
642 c.complete_records_ratio = 0.0;
643 }
644 metrics.score_weights = QualityScoreWeights {
645 completeness: 1.0,
646 consistency: 0.0,
647 uniqueness: 0.0,
648 accuracy: 0.0,
649 timeliness: 0.0,
650 validity: 0.0,
651 precision: 0.0,
652 };
653
654 assert!((metrics.overall_score() - 0.0).abs() < 0.01);
655
656 let json = serde_json::to_string(&metrics).expect("serialize custom weights");
657 assert!(json.contains("score_weights"));
658 let restored: QualityMetrics =
659 serde_json::from_str(&json).expect("deserialize custom weights");
660 assert_eq!(restored.score_weights, metrics.score_weights);
661 assert!((restored.overall_score() - metrics.overall_score()).abs() < 0.01);
662 assert_eq!(
663 restored.assessed_dimensions(),
664 vec![QualityDimension::Completeness]
665 );
666 }
667
668 #[test]
669 fn test_empty_metrics_nothing_assessed() {
670 let metrics = QualityMetrics::empty();
671 assert!(metrics.assessed_dimensions().is_empty());
672 assert!((metrics.overall_score() - 0.0).abs() < 0.01);
673 }
674
675 #[test]
676 fn test_perfect_assessed_scores_100() {
677 let metrics = perfect_assessed();
678 assert_eq!(metrics.assessed_dimensions().len(), 7);
679 assert!((metrics.overall_score() - 100.0).abs() < 0.01);
680 }
681
682 #[test]
683 fn test_quality_score_completeness_weight() {
684 let mut metrics = perfect_assessed();
685 if let Some(ref mut c) = metrics.completeness {
686 c.missing_values_ratio = 100.0;
687 c.complete_records_ratio = 0.0;
688 }
689 assert!((metrics.overall_score() - 75.0).abs() < 0.01);
690 }
691
692 #[test]
693 fn test_quality_score_all_bad() {
694 let mut metrics = perfect_assessed();
695 if let Some(ref mut c) = metrics.completeness {
696 c.missing_values_ratio = 100.0;
697 c.complete_records_ratio = 0.0;
698 }
699 if let Some(ref mut c) = metrics.consistency {
700 c.data_type_consistency = 0.0;
701 }
702 if let Some(ref mut u) = metrics.uniqueness {
703 u.duplicate_rows = 100;
704 }
705 if let Some(ref mut a) = metrics.accuracy {
706 a.outlier_ratio = 100.0;
707 }
708 if let Some(ref mut t) = metrics.timeliness {
709 t.stale_data_ratio = 100.0;
710 }
711 if let Some(ref mut v) = metrics.validity {
712 v.valid_values_ratio = 0.0;
713 }
714 if let Some(ref mut p) = metrics.precision {
715 p.decimal_places_consistency = 0.0;
716 }
717
718 assert!((metrics.overall_score() - 0.0).abs() < 0.01);
719 }
720
721 #[test]
722 fn test_vacuous_dimensions_drop_out() {
723 let mut metrics = perfect_assessed();
728 if let Some(ref mut c) = metrics.completeness {
729 c.missing_values_ratio = 50.0;
730 c.complete_records_ratio = 50.0;
731 }
732 if let Some(ref mut u) = metrics.uniqueness {
733 u.rows_checked = 0;
734 }
735 if let Some(ref mut a) = metrics.accuracy {
736 a.numeric_values_checked = 0;
737 }
738 if let Some(ref mut t) = metrics.timeliness {
739 t.date_values_checked = 0;
740 }
741 if let Some(ref mut v) = metrics.validity {
742 v.values_checked = 0;
743 }
744 if let Some(ref mut p) = metrics.precision {
745 p.numeric_values_checked = 0;
746 }
747
748 assert_eq!(
749 metrics.assessed_dimensions(),
750 vec![
751 QualityDimension::Completeness,
752 QualityDimension::Consistency
753 ]
754 );
755 assert!((metrics.overall_score() - 72.2222).abs() < 0.01);
757 }
758
759 #[test]
760 fn test_duplicate_rows_lower_uniqueness_score() {
761 let mut metrics = perfect_assessed();
762 if let Some(ref mut u) = metrics.uniqueness {
763 u.duplicate_rows = 30;
764 }
765 let score = metrics
766 .uniqueness_score()
767 .expect("uniqueness should be assessed");
768 assert!((score - 70.0).abs() < 0.01);
769 }
770
771 #[test]
772 fn test_key_only_uniqueness_when_duplicate_scan_not_assessable() {
773 let mut metrics = perfect_assessed();
776 if let Some(ref mut u) = metrics.uniqueness {
777 u.rows_checked = 0;
778 u.key_column = Some("order_id".to_string());
779 u.key_uniqueness = 90.0;
780 }
781 let score = metrics
782 .uniqueness_score()
783 .expect("key component alone should keep uniqueness assessed");
784 assert!((score - 90.0).abs() < 0.01);
785 }
786
787 #[test]
788 fn test_key_column_blends_into_uniqueness_score() {
789 let mut metrics = perfect_assessed();
790 if let Some(ref mut u) = metrics.uniqueness {
791 u.key_column = Some("order_id".to_string());
792 u.key_uniqueness = 60.0;
793 }
794 let score = metrics
796 .uniqueness_score()
797 .expect("uniqueness should be assessed");
798 assert!((score - 80.0).abs() < 0.01);
799 }
800
801 #[test]
802 fn test_format_and_encoding_violations_lower_consistency_score() {
803 let mut metrics = perfect_assessed();
804 if let Some(ref mut c) = metrics.consistency {
805 c.format_violations = 5;
806 c.encoding_issues = 5;
807 }
808 let score = metrics
809 .consistency_score()
810 .expect("consistency should be assessed");
811 assert!((score - 90.0).abs() < 0.01);
812 }
813
814 #[test]
815 fn test_range_and_negative_violations_lower_accuracy_score() {
816 let mut metrics = perfect_assessed();
817 if let Some(ref mut a) = metrics.accuracy {
818 a.outlier_ratio = 10.0;
819 a.range_violations = 5;
820 a.negative_values_in_positive = 5;
821 }
822 let score = metrics
823 .accuracy_score()
824 .expect("accuracy should be assessed");
825 assert!((score - 80.0).abs() < 0.01);
826 }
827
828 #[test]
829 fn test_future_dates_and_temporal_violations_lower_timeliness_score() {
830 let mut metrics = perfect_assessed();
831 if let Some(ref mut t) = metrics.timeliness {
832 t.stale_data_ratio = 20.0;
833 t.future_dates_count = 5;
834 t.temporal_violations = 5;
835 }
836 let score = metrics
837 .timeliness_score()
838 .expect("timeliness should be assessed");
839 assert!((score - 70.0).abs() < 0.01);
840 }
841
842 #[test]
843 fn test_legacy_json_without_denominators_is_not_assessed() {
844 let json = r#"{
848 "completeness": {
849 "missing_values_ratio": 5.0,
850 "complete_records_ratio": 95.0,
851 "null_columns": []
852 }
853 }"#;
854 let metrics: QualityMetrics = serde_json::from_str(json).unwrap();
855
856 assert!((metrics.missing_values_ratio() - 5.0).abs() < 0.01);
857 assert!(metrics.completeness_score().is_none());
858 assert!(metrics.assessed_dimensions().is_empty());
859 }
860
861 #[test]
862 fn test_partial_dimensions_only_completeness() {
863 let metrics = QualityMetrics {
864 completeness: Some(CompletenessMetrics {
865 complete_records_ratio: 100.0,
866 missing_values_ratio: 0.0,
867 null_columns: vec![],
868 total_cells: 10,
869 }),
870 ..QualityMetrics::default()
871 };
872
873 assert!(metrics.completeness.is_some());
874 assert!(metrics.consistency.is_none());
875 assert!(metrics.uniqueness.is_none());
876 assert!(metrics.accuracy.is_none());
877 assert!(metrics.timeliness.is_none());
878 assert!((metrics.overall_score() - 100.0).abs() < 0.01);
879 }
880
881 #[test]
882 fn test_partial_dimensions_two_dimensions() {
883 let metrics = QualityMetrics {
884 completeness: Some(CompletenessMetrics {
885 missing_values_ratio: 50.0,
886 complete_records_ratio: 50.0,
887 null_columns: vec![],
888 total_cells: 100,
889 }),
890 uniqueness: Some(UniquenessMetrics {
891 duplicate_rows: 20,
892 key_uniqueness: 100.0,
893 high_cardinality_warning: false,
894 rows_checked: 100,
895 key_column: None,
896 duplicate_rows_approximate: false,
897 }),
898 ..QualityMetrics::default()
899 };
900
901 assert!((metrics.overall_score() - 61.25).abs() < 0.01);
903 }
904
905 #[test]
906 fn test_all_dimensions_none_score_zero() {
907 let metrics = QualityMetrics::default();
908
909 assert!((metrics.overall_score() - 0.0).abs() < 0.01);
910 assert!(metrics.assessed_dimensions().is_empty());
911 }
912
913 #[test]
914 fn test_partial_dimensions_json_skips_none() {
915 let metrics = QualityMetrics {
916 completeness: Some(CompletenessMetrics::default()),
917 ..QualityMetrics::default()
918 };
919
920 let json = serde_json::to_string(&metrics).unwrap();
921 assert!(json.contains("completeness"));
922 assert!(!json.contains("consistency"));
923 assert!(!json.contains("uniqueness"));
924 assert!(!json.contains("accuracy"));
925 assert!(!json.contains("timeliness"));
926 }
927
928 #[test]
929 fn test_partial_dimensions_flat_accessors_return_defaults() {
930 let metrics = QualityMetrics::default();
931
932 assert!((metrics.complete_records_ratio() - 100.0).abs() < 0.01);
933 assert!((metrics.data_type_consistency() - 100.0).abs() < 0.01);
934 assert!((metrics.key_uniqueness() - 100.0).abs() < 0.01);
935 assert!((metrics.missing_values_ratio() - 0.0).abs() < 0.01);
936 assert_eq!(metrics.duplicate_rows(), 0);
937 assert!(!metrics.high_cardinality_warning());
938 }
939
940 #[test]
941 fn test_partial_dimension_flat_defaults_table() {
942 struct Case {
943 name: &'static str,
944 metrics: QualityMetrics,
945 has_completeness: bool,
946 has_uniqueness: bool,
947 has_accuracy: bool,
948 missing_values_ratio: f64,
949 key_uniqueness: f64,
950 outlier_ratio: f64,
951 }
952
953 let cases = [
954 Case {
955 name: "only completeness",
956 metrics: QualityMetrics {
957 completeness: Some(CompletenessMetrics {
958 missing_values_ratio: 12.5,
959 complete_records_ratio: 87.5,
960 null_columns: vec!["email".to_string()],
961 total_cells: 16,
962 }),
963 ..QualityMetrics::default()
964 },
965 has_completeness: true,
966 has_uniqueness: false,
967 has_accuracy: false,
968 missing_values_ratio: 12.5,
969 key_uniqueness: 100.0,
970 outlier_ratio: 0.0,
971 },
972 Case {
973 name: "only uniqueness",
974 metrics: QualityMetrics {
975 uniqueness: Some(UniquenessMetrics {
976 duplicate_rows: 2,
977 key_uniqueness: 92.0,
978 high_cardinality_warning: true,
979 rows_checked: 25,
980 key_column: Some("user_id".to_string()),
981 duplicate_rows_approximate: false,
982 }),
983 ..QualityMetrics::default()
984 },
985 has_completeness: false,
986 has_uniqueness: true,
987 has_accuracy: false,
988 missing_values_ratio: 0.0,
989 key_uniqueness: 92.0,
990 outlier_ratio: 0.0,
991 },
992 Case {
993 name: "only accuracy",
994 metrics: QualityMetrics {
995 accuracy: Some(AccuracyMetrics {
996 outlier_ratio: 6.25,
997 range_violations: 1,
998 negative_values_in_positive: 1,
999 numeric_values_checked: 16,
1000 }),
1001 ..QualityMetrics::default()
1002 },
1003 has_completeness: false,
1004 has_uniqueness: false,
1005 has_accuracy: true,
1006 missing_values_ratio: 0.0,
1007 key_uniqueness: 100.0,
1008 outlier_ratio: 6.25,
1009 },
1010 ];
1011
1012 for case in cases {
1013 assert_eq!(
1014 case.metrics.completeness.is_some(),
1015 case.has_completeness,
1016 "{} completeness presence",
1017 case.name
1018 );
1019 assert_eq!(
1020 case.metrics.uniqueness.is_some(),
1021 case.has_uniqueness,
1022 "{} uniqueness presence",
1023 case.name
1024 );
1025 assert_eq!(
1026 case.metrics.accuracy.is_some(),
1027 case.has_accuracy,
1028 "{} accuracy presence",
1029 case.name
1030 );
1031 assert!(
1032 (case.metrics.missing_values_ratio() - case.missing_values_ratio).abs() < 0.01,
1033 "{} missing_values_ratio",
1034 case.name
1035 );
1036 assert!(
1037 (case.metrics.key_uniqueness() - case.key_uniqueness).abs() < 0.01,
1038 "{} key_uniqueness",
1039 case.name
1040 );
1041 assert!(
1042 (case.metrics.outlier_ratio() - case.outlier_ratio).abs() < 0.01,
1043 "{} outlier_ratio",
1044 case.name
1045 );
1046 }
1047 }
1048}