Skip to main content

dataprof_metrics/
quality.rs

1use std::collections::HashMap;
2
3use dataprof_core::{ColumnProfile, QualityDimension, QualityScoreWeights};
4use serde::{Deserialize, Serialize};
5
6use crate::core::errors::DataProfilerError;
7
8/// Completeness metrics (ISO 8000-8).
9#[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    /// Total cells examined (rows × columns). 0 means the dimension had
17    /// nothing to assess and it is excluded from the overall score.
18    #[serde(default)]
19    pub total_cells: usize,
20}
21
22/// Consistency metrics (ISO 8000-61).
23#[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    /// Non-null values examined for type consistency. 0 means the dimension
30    /// had nothing to assess and it is excluded from the overall score.
31    #[serde(default)]
32    pub values_checked: usize,
33}
34
35/// Uniqueness metrics (ISO 8000-110).
36#[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    /// Rows scanned for exact duplicates. 0 means the dimension had nothing
43    /// to assess and it is excluded from the overall score.
44    #[serde(default)]
45    pub rows_checked: usize,
46    /// Column whose uniqueness `key_uniqueness` describes. `None` means no
47    /// key column was identified; `key_uniqueness` then carries no signal
48    /// and does not contribute to the dimension score.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub key_column: Option<String>,
51    /// True when `duplicate_rows` comes from the full-stream distinct-count
52    /// estimator after it spilled to its HLL sketch (~1% relative error on
53    /// the distinct count), rather than an exact count.
54    #[serde(default, skip_serializing_if = "is_false")]
55    pub duplicate_rows_approximate: bool,
56}
57
58/// Full-stream row-duplicate counts produced by an engine's row tracker.
59///
60/// Engines that see whole records (CSV, JSON, streaming readers) count
61/// duplicates over *every* row with bounded memory: exact below the
62/// distinct-row threshold, HLL-estimated (and flagged) beyond it. When
63/// available this supersedes the sample-based duplicate scan, which cannot
64/// run at all on misaligned per-column samples.
65#[derive(Debug, Clone, Copy)]
66pub struct RowDuplicateSummary {
67    pub duplicate_rows: usize,
68    pub rows_checked: usize,
69    pub approximate: bool,
70}
71
72/// Accuracy metrics (ISO 25012).
73#[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    /// Finite numeric values examined across all columns. 0 means the
80    /// dimension had nothing to assess and it is excluded from the overall
81    /// score.
82    #[serde(default)]
83    pub numeric_values_checked: usize,
84}
85
86/// Timeliness metrics (ISO 8000-8).
87#[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    /// Non-null values in inferred or explicitly configured temporal columns
94    /// that failed calendar-date parsing.
95    #[serde(default)]
96    pub invalid_date_values: usize,
97    /// Non-null values examined in inferred or explicitly configured temporal
98    /// columns. 0 means
99    /// the dimension had nothing to assess and it is excluded from the
100    /// overall score.
101    #[serde(default)]
102    pub date_values_checked: usize,
103    /// Start/end value pairs actually compared for temporal ordering.
104    /// `temporal_violations` is bounded by this, not by
105    /// `date_values_checked` — the pair scan may cover columns the date
106    /// scan does not.
107    #[serde(default)]
108    pub temporal_pairs_checked: usize,
109}
110
111/// Validity metrics derived from confidently detected semantic patterns.
112#[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    /// Non-null values checked against a dominant semantic pattern.
118    #[serde(default)]
119    pub values_checked: usize,
120}
121
122/// Precision metrics for effective decimal-scale consistency.
123#[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    /// Parseable finite values examined in floating-point columns.
129    #[serde(default)]
130    pub numeric_values_checked: usize,
131}
132
133/// Comprehensive data quality metrics following industry standards.
134#[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    /// True when the sample used to compute these metrics was below the
151    /// minimum recommended size (10 rows). When set, the quality scores and
152    /// per-dimension ratios should be treated as directional rather than
153    /// reliable. Backwards-compatible: defaults to `false`.
154    #[serde(default, skip_serializing_if = "is_false")]
155    pub low_sample_warning: bool,
156    /// Weights used to aggregate dimension scores. Default weights are omitted
157    /// from serialized reports; custom weights are retained for reproducible
158    /// score calculation after a round trip.
159    #[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    /// Score for the completeness dimension (0-100), or `None` when the
228    /// dimension was not computed or had no cells to assess.
229    ///
230    /// Mean of cell-level completeness (`100 - missing_values_ratio`) and
231    /// row-level completeness (`complete_records_ratio`).
232    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    /// Score for the consistency dimension (0-100), or `None` when the
242    /// dimension was not computed or had no non-null values to assess.
243    ///
244    /// Type consistency, penalized by format violations and encoding issues
245    /// as a share of the values checked.
246    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    /// Score for the uniqueness dimension (0-100), or `None` when the
257    /// dimension was not computed or neither component had data.
258    ///
259    /// Mean of the available components: share of non-duplicate rows (when
260    /// a row tracker or an aligned sample scan produced a count) and
261    /// `key_uniqueness` (when a key column was identified). Engines without
262    /// a row tracker whose samples cannot be proven row-aligned contribute
263    /// only the key component.
264    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    /// Score for the accuracy dimension (0-100), or `None` when the
281    /// dimension was not computed or no numeric values were found.
282    ///
283    /// `100 - outlier_ratio`, penalized by range violations and negative
284    /// values in positive-only columns as a share of the numeric values
285    /// checked.
286    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    /// Score for the timeliness dimension (0-100), or `None` when the
297    /// dimension was not computed or no values were found in inferred or
298    /// explicitly configured temporal columns.
299    ///
300    /// `100 - stale_data_ratio`, penalized by future dates as a share of
301    /// the date values checked and by temporal ordering violations as a
302    /// share of the pairs actually compared.
303    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    /// Score for semantic-pattern validity (0-100), or `None` when no column
322    /// had a confidently detected pattern to validate.
323    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    /// Score for decimal-scale precision consistency (0-100), or `None` when
329    /// no floating-point values were available to assess.
330    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    /// Weighted components of the overall score: `(dimension, weight, score)`.
337    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    /// Dimensions that were computed *and* had data to assess. Only these
378    /// contribute to [`overall_score`](Self::overall_score).
379    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    /// Overall quality score (0-100): weighted average of the assessed
388    /// dimension scores, with weights renormalized over the assessed
389    /// dimensions. A dimension with nothing to assess (no numeric values,
390    /// no date columns, ...) is excluded instead of counting as perfect.
391    ///
392    /// Returns 0.0 when no dimension was assessable; callers that can
393    /// distinguish "no score" should check
394    /// [`assessed_dimensions`](Self::assessed_dimensions) first.
395    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/// Confidence level for quality metrics.
527#[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/// Wraps quality metrics with confidence information.
542#[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    /// Metrics where every dimension has data to assess and a perfect score.
586    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        // Text-only dataset shape: nothing numeric, no dates, no rows scanned
724        // for duplicates. Under the old aggregation these dimensions counted
725        // as perfect and floored the score at 70; now they are excluded and
726        // the weights renormalize over what was actually assessed.
727        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        // (0.25 * 50 + 0.20 * 100) / 0.45 = 72.22..
756        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        // Streaming shape: per-column samples are misaligned so the
774        // duplicate scan did not run, but key uniqueness is exact.
775        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        // mean(duplicate-free score 100, key uniqueness 60)
795        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        // Reports serialized before the denominator fields existed
845        // deserialize with zero denominators: facts remain readable, but no
846        // dimension is assessable and no score is fabricated.
847        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        // (0.25 * 50 + 0.15 * 80) / 0.40 = 61.25
902        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}