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, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
10pub struct CompletenessMetrics {
11    #[serde(serialize_with = "crate::serde_helpers::round_2")]
12    pub missing_values_ratio: f64,
13    /// Percentage of records in which every field is present, counted over
14    /// every row the engine read. Strict by design: a record missing one
15    /// optional field is not complete, so read it beside `null_columns`,
16    /// which names the columns responsible.
17    #[serde(serialize_with = "crate::serde_helpers::round_2")]
18    pub complete_records_ratio: f64,
19    pub null_columns: Vec<String>,
20    /// Total cells examined (rows × columns). 0 means the dimension had
21    /// nothing to assess and it is excluded from the overall score.
22    #[serde(default)]
23    pub total_cells: usize,
24}
25
26/// Consistency metrics (ISO 8000-61).
27#[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    /// Non-null values examined for type consistency. 0 means the dimension
34    /// had nothing to assess and it is excluded from the overall score.
35    #[serde(default)]
36    pub values_checked: usize,
37}
38
39/// Uniqueness metrics (ISO 8000-110).
40#[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    /// Rows scanned for exact duplicates. 0 means the dimension had nothing
47    /// to assess and it is excluded from the overall score.
48    #[serde(default)]
49    pub rows_checked: usize,
50    /// Column whose uniqueness `key_uniqueness` describes: the first
51    /// `identifier_columns` entry the source has. `None` means no declared
52    /// key was found; `key_uniqueness` then carries no signal and does not
53    /// contribute to the dimension score. Before 0.13 an undeclared column
54    /// with an id-like name could be the key.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub key_column: Option<String>,
57    /// Non-null values of `key_column`: the units `key_uniqueness` divides
58    /// the key's distinct values by. 0 means the key held no value, so
59    /// `key_uniqueness` carries no signal and does not contribute to the
60    /// dimension score. Absent with no key column, and on documents written
61    /// before 0.13, whose `key_uniqueness` divided by every row, nulls
62    /// included.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub key_values_checked: Option<usize>,
65    /// True when `duplicate_rows` comes from the full-stream distinct-count
66    /// estimator after it spilled to its HLL sketch (~1% relative error on
67    /// the distinct count), rather than an exact count.
68    #[serde(default, skip_serializing_if = "is_false")]
69    pub duplicate_rows_approximate: bool,
70}
71
72/// Full-stream row-duplicate counts produced by an engine's row tracker.
73///
74/// Engines that see whole records (CSV, JSON, streaming readers) count
75/// duplicates over *every* row with bounded memory: exact below the
76/// distinct-row threshold, HLL-estimated (and flagged) beyond it. When
77/// available this supersedes the sample-based duplicate scan, which cannot
78/// run at all on misaligned per-column samples.
79#[derive(Debug, Clone, Copy)]
80pub struct RowDuplicateSummary {
81    pub duplicate_rows: usize,
82    pub rows_checked: usize,
83    pub approximate: bool,
84    /// The most duplicate rows there can be: `rows_checked` minus the distinct
85    /// rows the tracker's exact set held when it was dropped. Equal to
86    /// `duplicate_rows` while the count is exact (#819).
87    pub max_duplicate_rows: usize,
88}
89
90/// Full-stream complete-record counts produced by an engine's row tracker.
91///
92/// A "complete" record is one whose every field is present. That is a
93/// property of a row, so it can only be counted while whole rows are in
94/// hand; per-column null totals cannot recover it, because they do not say
95/// whether two nulls landed in the same record. Engines that see whole
96/// records accumulate this exactly over the full stream with one counter,
97/// independent of sampling.
98#[derive(Debug, Clone, Copy)]
99pub struct RowCompletenessSummary {
100    pub complete_rows: usize,
101    pub rows_checked: usize,
102}
103
104/// Accuracy metrics (ISO 25012).
105#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
106pub struct AccuracyMetrics {
107    /// Values outside the Tukey fences, as a percentage of
108    /// `outlier_values_checked`. Carries no signal, and takes nothing off
109    /// the score, when that is 0.
110    #[serde(serialize_with = "crate::serde_helpers::round_2")]
111    pub outlier_ratio: f64,
112    /// Finite numeric values of the columns that reached
113    /// `outlier_min_samples`: the units of the outlier component. 0 means no
114    /// column was tested, so the component is absent. Absent on documents
115    /// written before 0.13, whose `outlier_ratio` read 0 for an untested
116    /// source.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub outlier_values_checked: Option<usize>,
119    /// Failures of the name-based range rules (age, percentage, count, year):
120    /// a value breaking two of them adds two. A diagnostic; the score reads
121    /// `rule_violating_values`.
122    pub range_violations: usize,
123    /// Negative values in positive-only columns. A diagnostic; the score
124    /// reads `rule_violating_values`.
125    pub negative_values_in_positive: usize,
126    /// Finite numeric values examined across all columns. The dimension is
127    /// assessed when the outlier or the rule component had units, not by this
128    /// count alone; see [`AccuracyMetrics::is_assessed`].
129    #[serde(default)]
130    pub numeric_values_checked: usize,
131    /// Finite numeric values of the columns a range or sign rule applies to:
132    /// the units of the rule component. 0 means no rule applied, and the
133    /// component takes nothing off the score. Additive: documents written
134    /// before it read as 0, so the schema does not require it.
135    #[serde(default)]
136    #[schemars(default)]
137    pub rule_values_checked: usize,
138    /// Of `rule_values_checked`, the values breaking at least one rule, each
139    /// counted once however many rules it breaks. Additive, like
140    /// `rule_values_checked`.
141    #[serde(default)]
142    #[schemars(default)]
143    pub rule_violating_values: usize,
144}
145
146/// Timeliness metrics (ISO 8000-8).
147#[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    /// Non-null values in inferred or explicitly configured temporal columns
154    /// that failed calendar-date parsing.
155    #[serde(default)]
156    pub invalid_date_values: usize,
157    /// Non-null values examined in inferred or explicitly configured temporal
158    /// columns. 0 means
159    /// the dimension had nothing to assess and it is excluded from the
160    /// overall score.
161    #[serde(default)]
162    pub date_values_checked: usize,
163    /// Start/end value pairs actually compared for temporal ordering.
164    /// `temporal_violations` is bounded by this, not by
165    /// `date_values_checked` — the pair scan may cover columns the date
166    /// scan does not.
167    #[serde(default)]
168    pub temporal_pairs_checked: usize,
169}
170
171/// Validity metrics derived from confidently detected semantic patterns.
172#[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    /// Non-null values checked against a dominant semantic pattern.
178    #[serde(default)]
179    pub values_checked: usize,
180}
181
182/// Precision metrics for effective decimal-scale consistency.
183#[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    /// Parseable finite values examined in floating-point columns.
189    #[serde(default)]
190    pub numeric_values_checked: usize,
191}
192
193/// Whether a dimension's metrics were computed against anything.
194///
195/// Every dimension carries the counter it divides by, and a zero there means
196/// the dimension had nothing to assess: no cells, no non-null values, no
197/// numeric values, no dates, no confidently detected pattern. Its ratios are
198/// then defaults rather than measurements, so they are withheld instead of
199/// reported. `None` means "not analyzed"; it is not a score of 100.
200///
201/// This is the single definition of "assessed" behind the dimension scores,
202/// the serialized report and the Python evidence accessors.
203///
204/// [`QualityMetrics::assessed_dimensions`] applies it and then narrows further,
205/// to the dimensions carrying a positive score weight, because it answers what
206/// is behind [`QualityMetrics::overall_score`] rather than what was measured. A
207/// dimension weighted `0.0` is assessed and reported while contributing nothing
208/// to the aggregate.
209pub(crate) trait Assessed {
210    fn assessed(&self) -> bool;
211}
212
213/// Serde predicate: skip a dimension that assessed nothing.
214fn is_unassessed<T: Assessed>(metrics: &Option<T>) -> bool {
215    metrics.as_ref().is_none_or(|metrics| !metrics.assessed())
216}
217
218impl CompletenessMetrics {
219    /// True when at least one cell was examined.
220    pub fn is_assessed(&self) -> bool {
221        self.total_cells > 0
222    }
223}
224
225impl ConsistencyMetrics {
226    /// True when at least one non-null value was examined.
227    pub fn is_assessed(&self) -> bool {
228        self.values_checked > 0
229    }
230}
231
232impl UniquenessMetrics {
233    /// True when either component had data: rows scanned for duplicates, or an
234    /// identified key column whose uniqueness `key_uniqueness` describes.
235    pub fn is_assessed(&self) -> bool {
236        self.rows_checked > 0 || self.key_measured()
237    }
238
239    /// True when `key_uniqueness` measured something: a key column was
240    /// identified and held at least one value. A document that predates
241    /// `key_values_checked` counts as measured whenever it names a key.
242    pub fn key_measured(&self) -> bool {
243        self.key_column.is_some() && self.key_values_checked != Some(0)
244    }
245}
246
247impl AccuracyMetrics {
248    /// True when a component measured something: the outlier test ran on at
249    /// least one column, or a range or sign rule applied to a value. Numeric
250    /// values that neither component tested assess nothing (#1005).
251    pub fn is_assessed(&self) -> bool {
252        self.outliers_measured() || self.rule_values_checked > 0
253    }
254
255    /// True when the outlier test ran. A document that predates
256    /// `outlier_values_checked` counts as tested whenever it examined a
257    /// numeric value, as its release scored it.
258    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    /// True when at least one value in a temporal column was examined.
268    pub fn is_assessed(&self) -> bool {
269        self.date_values_checked > 0
270    }
271}
272
273impl ValidityMetrics {
274    /// True when at least one value was checked against a dominant pattern.
275    pub fn is_assessed(&self) -> bool {
276        self.values_checked > 0
277    }
278}
279
280impl PrecisionMetrics {
281    /// True when at least one parseable float was examined.
282    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/// Comprehensive data quality metrics following industry standards.
308#[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    /// True when the sample used to compute these metrics was below the
325    /// minimum recommended size (10 rows). When set, the quality scores and
326    /// per-dimension ratios should be treated as directional rather than
327    /// reliable. Backwards-compatible: defaults to `false`.
328    #[serde(default, skip_serializing_if = "is_false")]
329    pub low_sample_warning: bool,
330    /// Weights used to aggregate dimension scores. Always serialized, default
331    /// weights included: a stored overall score is kept rather than
332    /// recomputed, so the document has to say which weights produced it even
333    /// if a later release changes the defaults (#749). Documents written
334    /// before 0.13 omit default weights; the defaults did not change between
335    /// 0.10, when weights became configurable, and 0.12, so on those an absent
336    /// field means the defaults. (The flat summary differs: before 0.12 it
337    /// dropped custom weights too.)
338    #[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    /// Score for the completeness dimension (0-100), or `None` when the
411    /// dimension was not computed or had no cells to assess.
412    ///
413    /// Mean of cell-level completeness (`100 - missing_values_ratio`) and
414    /// row-level completeness (`complete_records_ratio`).
415    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    /// Score for the consistency dimension (0-100), or `None` when the
422    /// dimension was not computed or had no non-null values to assess.
423    ///
424    /// Type consistency, penalized by format violations and encoding issues
425    /// as a share of the values checked.
426    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    /// Score for the uniqueness dimension (0-100), or `None` when the
434    /// dimension was not computed or neither component had data.
435    ///
436    /// Mean of the available components: share of non-duplicate rows (when
437    /// a row tracker or an aligned sample scan produced a count) and
438    /// `key_uniqueness` (when a key column was identified and held a value).
439    /// Engines without
440    /// a row tracker whose samples cannot be proven row-aligned contribute
441    /// only the key component.
442    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    /// Score for the accuracy dimension (0-100), or `None` when the
459    /// dimension was not computed or neither component measured anything.
460    ///
461    /// `100 - outlier_ratio` when the outlier test ran, penalized by the
462    /// share of values breaking a range or sign rule among the values of the
463    /// columns those rules apply to. A component without units takes nothing
464    /// off (#1005).
465    pub fn accuracy_score(&self) -> Option<f64> {
466        // An untested outlier component reads 0, so it takes nothing off.
467        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            // No rule applied, so no failure was counted either, unless the
472            // report predates the rule units: through 0.12 it summed the
473            // failures over every numeric value, and a report without saved
474            // scores (0.11) still scores as its release did.
475            (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    /// Score for the timeliness dimension (0-100), or `None` when the
482    /// dimension was not computed or no values were found in inferred or
483    /// explicitly configured temporal columns.
484    ///
485    /// `100 - stale_data_ratio`, penalized by future dates as a share of
486    /// the date values checked and by temporal ordering violations as a
487    /// share of the pairs actually compared.
488    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    /// Score for semantic-pattern validity (0-100), or `None` when no column
504    /// had a confidently detected pattern to validate.
505    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    /// Score for decimal-scale precision consistency (0-100), or `None` when
511    /// no floating-point values were available to assess.
512    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    /// Weighted components of the overall score: `(dimension, weight, score)`.
518    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    /// One dimension's score (0-100), or `None` when it had nothing to
559    /// assess. Weight-independent: a dimension weighted `0.0` still reports
560    /// the score it measured, it just does not move the aggregate.
561    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    /// Dimensions that were computed *and* had data to assess. Only these
574    /// contribute to [`overall_score`](Self::overall_score).
575    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    /// Overall quality score (0-100): weighted average of the assessed
584    /// dimension scores, with weights renormalized over the assessed
585    /// dimensions. A dimension with nothing to assess (no numeric values,
586    /// no date columns, ...) is excluded instead of counting as perfect.
587    ///
588    /// `None` when no dimension was assessable — the renormalization has no
589    /// remainder to average, and an empty set of evidence is "not analyzed",
590    /// not a score of zero. Equivalent to
591    /// [`assessed_dimensions`](Self::assessed_dimensions) being empty.
592    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/// Confidence level for quality metrics.
720#[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    /// No dimension had anything to assess, so there is no score for the
733    /// other variants to describe. Reporting `Exact` here would claim
734    /// certainty about a number that was never computed.
735    NotAssessed,
736    /// The document this assessment was read from does not say how its
737    /// numbers were obtained. Only produced by deserializing a report written
738    /// before dataprof recorded confidence; a profiling run always records it.
739    ///
740    /// Distinct from every other variant on purpose. Reading an unrecorded
741    /// provenance as `Exact` would let a decision about the whole source rest
742    /// on numbers that may have come from a sample.
743    Unrecorded,
744}
745
746/// Aggregate scores retained alongside rounded metric inputs in saved reports.
747/// Recomputing these from rounded ratios can change a threshold decision.
748#[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/// A range a score lies in, on the 0-100 scale.
784#[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/// Where the scores over every scanned row lie, for an assessment whose
803/// scores were partly computed over a retained sample of those rows.
804///
805/// The sample fixes each check: a column's type, dominant form and decimal
806/// scale, detected pattern and outlier fences. The intervals cover the scores
807/// those same checks would give over every scanned value, and all of them
808/// hold at once with probability at least `confidence_level`. When the scan
809/// read the whole source, that is the whole source's score, which is the
810/// only case a quality gate decides on them. Intervals
811/// are rounded outward to two decimals, so a saved one still contains what
812/// the computed one did.
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
814pub struct ScoreBounds {
815    /// Probability that every interval here holds at once.
816    pub confidence_level: f64,
817    /// Interval for the overall score. `None` when there is no overall score,
818    /// or when a sampled dimension behind it has no bound.
819    pub overall_score: Option<ScoreInterval>,
820    /// Interval per dimension, keyed like `QualityScores::dimension_scores`.
821    /// An exactly computed dimension's interval is its score. `None` for a
822    /// dimension that was not assessed, or a sampled one with no bound: a
823    /// duplicate scan over a sample, or an estimated count with no recorded
824    /// floor. Start/end ordering is compared only between date columns whose
825    /// samples hold the same rows, so a pair where either column has nulls is
826    /// not compared.
827    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/// Wraps quality metrics with confidence information.
846#[derive(Debug, Clone, schemars::JsonSchema)]
847pub struct QualityAssessment {
848    pub metrics: QualityMetrics,
849    pub confidence: MetricConfidence,
850    /// Scores from a saved document. Absent on older documents; those derive
851    /// their scores from the metric values they actually recorded.
852    #[schemars(default)]
853    scores: Option<QualityScores>,
854    #[schemars(skip)]
855    score_inputs: Option<Box<QualityMetrics>>,
856    /// Bounds on the scores over every scanned row, present only when some score was
857    /// computed over a retained sample.
858    #[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            // Scores for dimensions this build does not know are retained, so
912            // the range applies to every entry, not only the known ones.
913            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    /// Pair metrics with the confidence of the run that produced them.
960    ///
961    /// `confidence` is downgraded to [`MetricConfidence::NotAssessed`] when
962    /// no dimension was assessable: how the numbers were obtained is only
963    /// meaningful when there are numbers.
964    ///
965    /// "Assessable" here is weight-independent. `assessed_dimensions()`
966    /// narrows to the dimensions carrying a positive score weight, because it
967    /// answers what is behind the aggregate; provenance is not about the
968    /// aggregate. A dimension weighted `0.0` is still measured and still
969    /// reported, and a consumer deciding on it needs to know whether its
970    /// number came from a sample.
971    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    /// Attach bounds on the scores over every scanned row, computed from the same
991    /// sample as these metrics.
992    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    /// Bounds on the scores over every scanned row, when some score was computed over a
999    /// retained sample and the bounds were recorded.
1000    ///
1001    /// `None` once `metrics` has been changed: bounds describe the numbers
1002    /// they were computed with, and would otherwise vouch for edited ones.
1003    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    /// Overall quality score, or `None` when no dimension was assessable.
1029    /// See [`QualityMetrics::overall_score`].
1030    pub fn score(&self) -> Option<f64> {
1031        self.scores().overall_score
1032    }
1033
1034    /// Original aggregate scores for unchanged restored metrics; otherwise
1035    /// compute from the current metrics. The input snapshot prevents a caller
1036    /// mutating public `metrics` from accidentally retaining stale scores.
1037    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    /// Labels of the metric components computed from a retained sample of the
1047    /// scanned rows rather than from every one of them.
1048    ///
1049    /// A bounded quality sample is how a profiler keeps memory flat over a
1050    /// large source, so a fully scanned file can still carry sampled quality
1051    /// numbers. Anything deciding about the *whole source* needs to know
1052    /// which: a ratio over a sample bounds nothing about the rows it left out.
1053    /// An empty list means every computed component saw every scanned row.
1054    /// `None` means the assessment does not record this at all, which only a
1055    /// report read back from a document written before dataprof recorded it
1056    /// can be: unknown coverage is a third answer, not "nothing was sampled".
1057    ///
1058    /// The labels are the ones [`MetricConfidence::Mixed`] already records, so
1059    /// uniqueness appears as its two components, `key_uniqueness` and
1060    /// `duplicate_rows`, rather than as one dimension name. They carry
1061    /// different provenance: a full-stream row tracker counts duplicates
1062    /// exactly while the key scan reads the sample.
1063    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            // One sample produced everything, so every component carrying
1070            // signal came out of it.
1071            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    /// Components this assessment computed with signal that its recorded
1083    /// confidence lists neither as exact nor as sampled.
1084    ///
1085    /// Every release that records these lists (0.6 onward) names each such
1086    /// component in one of the two; this one does so by the same test
1087    /// [`Self::component_labels`] applies, and earlier ones listed both
1088    /// uniqueness components unconditionally. So a report this build or an
1089    /// earlier one wrote returns an empty list. A non-empty list
1090    /// comes from a document written another way, by a later release or by
1091    /// hand, and the coverage of what it names is not recorded (#749). Only
1092    /// [`MetricConfidence::Mixed`] lists components; the other variants
1093    /// answer for every component at once.
1094    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    /// The component labels a dimension contributes, skipping components that
1114    /// were computed but carry no signal.
1115    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        // Public metrics may be edited by Rust callers, including below the
1236        // serialization precision. Any actual edit invalidates retained scores.
1237        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    /// Metrics where every dimension has data to assess and a perfect score.
1282    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        // Every denominator is zero, so there is nothing to average. A 0.0
1373        // here would read as "this data is terrible" (#571).
1374        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        // Assessed and genuinely bad: 0.0, not None.
1442        assert_eq!(metrics.overall_score(), Some(0.0));
1443    }
1444
1445    #[test]
1446    fn test_vacuous_dimensions_drop_out() {
1447        // Text-only dataset shape: nothing numeric, no dates, no rows scanned
1448        // for duplicates. Under the old aggregation these dimensions counted
1449        // as perfect and floored the score at 70; now they are excluded and
1450        // the weights renormalize over what was actually assessed.
1451        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            // Neither component had units: nothing tested, no rule applied.
1461            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        // (0.25 * 50 + 0.20 * 100) / 0.45 = 72.22..
1482        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        // Streaming shape: per-column samples are misaligned so the
1500        // duplicate scan did not run, but key uniqueness is exact.
1501        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        // mean(duplicate-free score 100, key uniqueness 60)
1521        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        // 20 values under a rule, 2 of them violating: one breaks two range
1543        // rules and the sign rule, one a range rule. The penalty is 2 in 20,
1544        // whatever the per-rule failure counts and the numeric values beside.
1545        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    /// A report written before the rule units were recorded (0.11, which
1560    /// saved no scores) scores as its release did: failures over every
1561    /// numeric value.
1562    #[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        // Reports serialized before the denominator fields existed
1593        // deserialize with zero denominators: facts remain readable, but no
1594        // dimension is assessable and no score is fabricated.
1595        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        // (0.25 * 50 + 0.15 * 80) / 0.40 = 61.25
1651        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        // Top-level keys: `score_weights` names every dimension inside it.
1673        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        // `empty()` builds every dimension with zero counters, which is what an
1684        // engine produces for a header-only file. Serializing those defaults
1685        // published `valid_values_ratio: 100.0` from `values_checked: 0` (#622).
1686        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        // Serialization asks "did this measure anything", which is a wider
1709        // question than "is this behind the overall score". A weight of 0.0
1710        // removes a dimension from `assessed_dimensions` and from the
1711        // aggregate, and removes nothing from what it actually observed.
1712        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        // One definition of "assessed" behind both, so a score can never be
1770        // None while its evidence claims to have measured something.
1771        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                // No rows scanned, but a key column was identified, so the key
1778                // component alone still carries a measurement.
1779                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}