Skip to main content

dataprof_metrics/analysis/metrics/
mod.rs

1//! Data Quality Metrics Calculation Module
2//!
3//! This module implements comprehensive data quality metric calculations following industry standards.
4//! It provides structured assessment across seven key dimensions: Completeness,
5//! Consistency, Uniqueness, Accuracy, Timeliness, Validity, and Precision.
6//!
7//! ## ISO Compliance
8//!
9//! This module follows:
10//! - **ISO 8000-8**: Data Quality (Completeness, Timeliness)
11//! - **ISO 8000-61**: Master Data Quality (Consistency)
12//! - **ISO 8000-110**: Duplicate Detection (Uniqueness)
13//! - **ISO 25012**: Data Quality Model (Accuracy)
14//!
15//! ## TODO: Future ISO 25012 Dimensions
16//!
17//! ISO/IEC 25012 defines 15 data quality characteristics. We currently implement 7.
18//! Below are candidates for future implementation:
19//!
20//! ### Inherent Data Quality (content-focused)
21//! - **Credibility**: Trustworthiness and believability of data origin
22//!   - Impl: Track data source metadata, provenance scoring
23//! - **Currentness**: Already partially covered by Timeliness
24//!
25//! ### System-Dependent Data Quality
26//! - **Accessibility**: Data retrievability when needed
27//!   - Impl: Response time metrics, availability checks
28//! - **Portability**: Transferability across data models/platforms
29//!   - Impl: Schema compatibility scoring, format conversion success rate
30//! - **Recoverability**: Data integrity during system failures
31//!   - Impl: Backup validation, checksum verification
32//!
33//! ### Research-Proposed Dimensions (MDPI 2024-2025)
34//! - **Governance**: Data ownership and responsibility tracking
35//! - **Usefulness**: Practical utility scoring for intended purpose
36//! - **Semantics**: Meaning clarity and interpretability metrics
37//!
38//! ### How to Add a New Dimension
39//! 1. Create `metrics/{dimension}.rs` with `{Dimension}Metrics` struct + `{Dimension}Calculator`
40//! 2. Add `mod {dimension};` and `use {dimension}::...;` in this file
41//! 3. Extend `QualityMetrics` in `dataprof-metrics` with new fields
42//! 4. Call the calculator in `calculate_comprehensive_metrics()` and map fields
43//! 5. Update `IsoQualityConfig` in `dataprof-core` if configurable thresholds are needed
44//!
45//! ### References
46//! - ISO 25012: <https://iso25000.com/index.php/en/iso-25000-standards/iso-25012>
47//! - ISO 8000 Wikipedia: <https://en.wikipedia.org/wiki/ISO_8000>
48//! - Data Quality 2025 Guide: <https://www.ewsolutions.com/data-quality-quide/>
49//! - MDPI Framework Review: <https://www.mdpi.com/2504-2289/9/4/93>
50
51mod accuracy;
52mod bounds;
53mod completeness;
54mod consistency;
55mod hint_binding;
56mod precision;
57#[cfg(test)]
58mod testing;
59mod timeliness;
60mod uniqueness;
61pub(crate) mod utils;
62mod validity;
63
64// Re-export public types for backward compatibility
65pub use bounds::SCORE_BOUNDS_CONFIDENCE;
66pub use hint_binding::{compute_value_hint_bindings, value_matches_hint};
67pub use utils::{StatisticalValidation, validate_sample_size};
68
69use accuracy::AccuracyCalculator;
70use completeness::CompletenessCalculator;
71use consistency::ConsistencyCalculator;
72use precision::PrecisionCalculator;
73use timeliness::TimelinessCalculator;
74use uniqueness::UniquenessCalculator;
75use validity::ValidityCalculator;
76
77use crate::core::config::IsoQualityConfig;
78use crate::core::errors::DataProfilerError;
79use crate::types::{
80    AccuracyMetrics, ColumnProfile, CompletenessMetrics, ConsistencyMetrics, DataType,
81    PrecisionMetrics, QualityDimension, QualityMetrics, RowCompletenessSummary,
82    RowDuplicateSummary, TimelinessMetrics, UniquenessMetrics, ValidityMetrics,
83};
84use dataprof_core::{SemanticHintKind, SemanticHints};
85use std::collections::HashMap;
86
87/// Engine for calculating comprehensive data quality metrics.
88pub struct MetricsCalculator {
89    /// Configurable quality thresholds and score weights.
90    pub thresholds: IsoQualityConfig,
91    /// Exact full-stream complete-record count, when the engine tracked one.
92    row_completeness: Option<RowCompletenessSummary>,
93}
94
95impl Default for MetricsCalculator {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl MetricsCalculator {
102    /// Create a new calculator with default quality settings.
103    pub fn new() -> Self {
104        Self::with_thresholds(IsoQualityConfig::default())
105    }
106
107    /// Create a calculator with custom thresholds
108    pub fn with_thresholds(thresholds: IsoQualityConfig) -> Self {
109        Self {
110            thresholds,
111            row_completeness: None,
112        }
113    }
114
115    /// Create a calculator with strict thresholds (finance, healthcare)
116    pub fn strict() -> Self {
117        Self::with_thresholds(IsoQualityConfig::strict())
118    }
119
120    /// Create a calculator with lenient thresholds (exploratory, marketing)
121    pub fn lenient() -> Self {
122        Self::with_thresholds(IsoQualityConfig::lenient())
123    }
124
125    /// Supply the engine's exact complete-record count over the full stream.
126    ///
127    /// Without it the completeness dimension can only report a lower bound
128    /// for `complete_records_ratio`, because per-column null totals do not
129    /// say whether two nulls shared a record.
130    pub fn with_row_completeness(mut self, summary: Option<RowCompletenessSummary>) -> Self {
131        self.row_completeness = summary;
132        self
133    }
134
135    /// Validate statistical requirements for metric calculation
136    pub fn validate_sample_size(sample_size: usize, metric_type: &str) -> StatisticalValidation {
137        utils::validate_sample_size(sample_size, metric_type)
138    }
139
140    /// Check whether a dimension is requested.
141    ///
142    /// `None` → all dimensions are requested (backward-compatible default).
143    fn is_requested(requested: &Option<&[QualityDimension]>, dim: QualityDimension) -> bool {
144        match requested {
145            None => true,
146            Some(dims) => dims.contains(&dim),
147        }
148    }
149
150    /// Explicit temporal hints, then the columns of declared pairs, plus columns
151    /// the profiler itself inferred as dates. Hints come first; inferred columns
152    /// are appended in stable profile order and duplicates are removed.
153    fn effective_temporal_columns(
154        column_profiles: &[ColumnProfile],
155        semantic_hints: &SemanticHints,
156    ) -> Vec<String> {
157        let mut columns: Vec<String> = semantic_hints
158            .iter()
159            .filter(|(kind, _)| *kind == SemanticHintKind::Temporal)
160            .map(|(_, column)| column.to_string())
161            .collect();
162        for (start, end) in &semantic_hints.temporal_pairs {
163            for name in [start, end] {
164                if !columns.contains(name) {
165                    columns.push(name.clone());
166                }
167            }
168        }
169        for profile in column_profiles {
170            if profile.data_type == DataType::Date && !columns.contains(&profile.name) {
171                columns.push(profile.name.clone());
172            }
173        }
174        columns
175    }
176
177    /// Calculate comprehensive data quality metrics from column data.
178    ///
179    /// When `requested_dimensions` is `None`, all dimensions are computed
180    /// (backward-compatible default). When `Some(&[...])`, only the listed
181    /// dimensions are computed; the rest are `None` in the returned struct.
182    pub fn calculate_comprehensive_metrics(
183        &self,
184        data: &HashMap<String, Vec<String>>,
185        column_profiles: &[ColumnProfile],
186        requested_dimensions: Option<&[QualityDimension]>,
187    ) -> Result<QualityMetrics, DataProfilerError> {
188        self.calculate_comprehensive_metrics_with_hints(
189            data,
190            column_profiles,
191            requested_dimensions,
192            &SemanticHints::default(),
193            None,
194        )
195    }
196
197    /// Calculate comprehensive metrics with explicit semantic column hints and,
198    /// when the engine tracked whole rows, full-stream duplicate counts that
199    /// supersede the sample-based duplicate scan.
200    pub fn calculate_comprehensive_metrics_with_hints(
201        &self,
202        data: &HashMap<String, Vec<String>>,
203        column_profiles: &[ColumnProfile],
204        requested_dimensions: Option<&[QualityDimension]>,
205        semantic_hints: &SemanticHints,
206        row_duplicates: Option<RowDuplicateSummary>,
207    ) -> Result<QualityMetrics, DataProfilerError> {
208        if data.is_empty() {
209            return Ok(Self::default_metrics_for_empty_dataset(
210                &requested_dimensions,
211                self.thresholds.score_weights,
212            ));
213        }
214
215        let sample_size = Self::calculate_sample_size(data)?;
216        let requested = &requested_dimensions;
217
218        // Validate sample size for statistical reliability
219        let validation = Self::validate_sample_size(sample_size, "general");
220        if !validation.sufficient_sample {
221            log::warn!(
222                "Sample size ({}) is below recommended minimum ({}) for reliable statistics",
223                validation.actual_sample_size,
224                validation.min_sample_size
225            );
226        }
227
228        // Completeness dimension
229        let completeness = if Self::is_requested(requested, QualityDimension::Completeness) {
230            let c = CompletenessCalculator::new(&self.thresholds)
231                .with_row_completeness(self.row_completeness)
232                .calculate(data, column_profiles, sample_size)?;
233            Some(CompletenessMetrics {
234                missing_values_ratio: c.missing_values_ratio,
235                complete_records_ratio: c.complete_records_ratio,
236                null_columns: c.null_columns,
237                total_cells: c.total_cells,
238            })
239        } else {
240            None
241        };
242
243        // Consistency dimension
244        let consistency = if Self::is_requested(requested, QualityDimension::Consistency) {
245            let c = ConsistencyCalculator::calculate(data, column_profiles)?;
246            Some(ConsistencyMetrics {
247                data_type_consistency: c.data_type_consistency,
248                format_violations: c.format_violations,
249                encoding_issues: c.encoding_issues,
250                values_checked: c.values_checked,
251            })
252        } else {
253            None
254        };
255
256        // Uniqueness dimension
257        let uniqueness = if Self::is_requested(requested, QualityDimension::Uniqueness) {
258            let uniqueness_total_rows = row_duplicates
259                .filter(|summary| summary.rows_checked > 0)
260                .map_or(sample_size, |summary| summary.rows_checked);
261            let u = UniquenessCalculator::new(&self.thresholds).calculate(
262                data,
263                column_profiles,
264                uniqueness_total_rows,
265                &semantic_hints.identifier_columns,
266                row_duplicates,
267            )?;
268            Some(UniquenessMetrics {
269                duplicate_rows: u.duplicate_rows,
270                key_uniqueness: u.key_uniqueness,
271                high_cardinality_warning: u.high_cardinality_warning,
272                rows_checked: u.rows_checked,
273                key_column: u.key_column,
274                key_values_checked: u.key_values_checked,
275                duplicate_rows_approximate: u.duplicate_rows_approximate,
276            })
277        } else {
278            None
279        };
280
281        // Accuracy dimension
282        let accuracy = if Self::is_requested(requested, QualityDimension::Accuracy) {
283            let accuracy_calculator = AccuracyCalculator::new(&self.thresholds);
284            let a = if semantic_hints.positive_columns.is_empty() {
285                accuracy_calculator.calculate(data, column_profiles)?
286            } else {
287                accuracy_calculator.calculate_with_positive_columns(
288                    data,
289                    column_profiles,
290                    &semantic_hints.positive_columns,
291                )?
292            };
293            Some(AccuracyMetrics {
294                outlier_ratio: a.outlier_ratio,
295                outlier_values_checked: Some(a.outlier_values_checked),
296                range_violations: a.range_violations,
297                negative_values_in_positive: a.negative_values_in_positive,
298                numeric_values_checked: a.numeric_values_checked,
299                rule_values_checked: a.rule_values_checked,
300                rule_violating_values: a.rule_violating_values,
301            })
302        } else {
303            None
304        };
305
306        // Timeliness dimension
307        let timeliness = if Self::is_requested(requested, QualityDimension::Timeliness) {
308            let temporal_columns =
309                Self::effective_temporal_columns(column_profiles, semantic_hints);
310            let t = TimelinessCalculator::new(&self.thresholds).calculate(
311                data,
312                &temporal_columns,
313                &semantic_hints.temporal_pairs,
314                column_profiles,
315            )?;
316            Some(TimelinessMetrics {
317                future_dates_count: t.future_dates_count,
318                stale_data_ratio: t.stale_data_ratio,
319                temporal_violations: t.temporal_violations,
320                invalid_date_values: t.invalid_date_values,
321                date_values_checked: t.date_values_checked,
322                temporal_pairs_checked: t.temporal_pairs_checked,
323            })
324        } else {
325            None
326        };
327
328        let validity = if Self::is_requested(requested, QualityDimension::Validity) {
329            let validity = ValidityCalculator::calculate(data, column_profiles);
330            Some(ValidityMetrics {
331                valid_values_ratio: validity.valid_values_ratio,
332                invalid_values: validity.invalid_values,
333                values_checked: validity.values_checked,
334            })
335        } else {
336            None
337        };
338
339        let precision = if Self::is_requested(requested, QualityDimension::Precision) {
340            let precision = PrecisionCalculator::calculate(data, column_profiles);
341            Some(PrecisionMetrics {
342                decimal_places_consistency: precision.decimal_places_consistency,
343                inconsistent_precision_values: precision.inconsistent_precision_values,
344                numeric_values_checked: precision.numeric_values_checked,
345            })
346        } else {
347            None
348        };
349
350        Ok(QualityMetrics {
351            completeness,
352            consistency,
353            uniqueness,
354            accuracy,
355            timeliness,
356            validity,
357            precision,
358            low_sample_warning: !validation.sufficient_sample,
359            score_weights: self.thresholds.score_weights,
360        })
361    }
362
363    /// Create default metrics for empty dataset (only requested dimensions are populated).
364    fn default_metrics_for_empty_dataset(
365        requested: &Option<&[QualityDimension]>,
366        score_weights: dataprof_core::QualityScoreWeights,
367    ) -> QualityMetrics {
368        let is_req = |d| match requested {
369            None => true,
370            Some(dims) => dims.contains(&d),
371        };
372
373        QualityMetrics {
374            completeness: if is_req(QualityDimension::Completeness) {
375                Some(CompletenessMetrics {
376                    missing_values_ratio: 0.0,
377                    complete_records_ratio: 100.0,
378                    null_columns: vec![],
379                    total_cells: 0,
380                })
381            } else {
382                None
383            },
384            consistency: if is_req(QualityDimension::Consistency) {
385                Some(ConsistencyMetrics {
386                    data_type_consistency: 100.0,
387                    format_violations: 0,
388                    encoding_issues: 0,
389                    values_checked: 0,
390                })
391            } else {
392                None
393            },
394            uniqueness: if is_req(QualityDimension::Uniqueness) {
395                Some(UniquenessMetrics {
396                    duplicate_rows: 0,
397                    key_uniqueness: 100.0,
398                    high_cardinality_warning: false,
399                    rows_checked: 0,
400                    key_column: None,
401                    key_values_checked: None,
402                    duplicate_rows_approximate: false,
403                })
404            } else {
405                None
406            },
407            accuracy: if is_req(QualityDimension::Accuracy) {
408                Some(AccuracyMetrics {
409                    outlier_ratio: 0.0,
410                    outlier_values_checked: Some(0),
411                    range_violations: 0,
412                    negative_values_in_positive: 0,
413                    numeric_values_checked: 0,
414                    rule_values_checked: 0,
415                    rule_violating_values: 0,
416                })
417            } else {
418                None
419            },
420            timeliness: if is_req(QualityDimension::Timeliness) {
421                Some(TimelinessMetrics {
422                    future_dates_count: 0,
423                    stale_data_ratio: 0.0,
424                    temporal_violations: 0,
425                    invalid_date_values: 0,
426                    date_values_checked: 0,
427                    temporal_pairs_checked: 0,
428                })
429            } else {
430                None
431            },
432            validity: if is_req(QualityDimension::Validity) {
433                Some(ValidityMetrics {
434                    valid_values_ratio: 100.0,
435                    invalid_values: 0,
436                    values_checked: 0,
437                })
438            } else {
439                None
440            },
441            precision: if is_req(QualityDimension::Precision) {
442                Some(PrecisionMetrics {
443                    decimal_places_consistency: 100.0,
444                    inconsistent_precision_values: 0,
445                    numeric_values_checked: 0,
446                })
447            } else {
448                None
449            },
450            low_sample_warning: false,
451            score_weights,
452        }
453    }
454
455    /// Calculate quality metrics with bifurcated computation for streaming.
456    ///
457    /// **Phase A (exact from global counters)**: Completeness metrics are computed
458    /// from `ColumnProfile` stats (`null_count`, `total_count`) which are exact
459    /// even for infinite streams. Key uniqueness already uses `ColumnProfile`.
460    ///
461    /// **Phase B (sampled)**: Consistency, Accuracy, and Timeliness are computed
462    /// from the bounded reservoir sample. Duplicate rows use a full-stream row
463    /// tracker when supplied; otherwise they fall back to an aligned sample.
464    ///
465    /// Returns a [`BifurcatedResult`] containing the metrics plus provenance
466    /// for which dimensions are exact vs sampled.
467    pub fn calculate_bifurcated_metrics(
468        &self,
469        data: &HashMap<String, Vec<String>>,
470        column_profiles: &[ColumnProfile],
471        requested_dimensions: Option<&[QualityDimension]>,
472    ) -> Result<BifurcatedResult, DataProfilerError> {
473        self.calculate_bifurcated_metrics_with_hints(
474            data,
475            column_profiles,
476            requested_dimensions,
477            &SemanticHints::default(),
478            None,
479        )
480    }
481
482    /// Calculate bifurcated metrics with explicit semantic column hints and,
483    /// when the engine tracked whole rows, full-stream duplicate counts that
484    /// supersede the sample-based duplicate scan.
485    pub fn calculate_bifurcated_metrics_with_hints(
486        &self,
487        data: &HashMap<String, Vec<String>>,
488        column_profiles: &[ColumnProfile],
489        requested_dimensions: Option<&[QualityDimension]>,
490        semantic_hints: &SemanticHints,
491        row_duplicates: Option<RowDuplicateSummary>,
492    ) -> Result<BifurcatedResult, DataProfilerError> {
493        if data.is_empty() && column_profiles.is_empty() {
494            return Ok(BifurcatedResult {
495                metrics: Self::default_metrics_for_empty_dataset(
496                    &requested_dimensions,
497                    self.thresholds.score_weights,
498                ),
499                exact_dimensions: vec![],
500                sampled_dimensions: vec![],
501                sample_size: 0,
502                score_bounds: None,
503            });
504        }
505
506        let total_rows = column_profiles.first().map(|p| p.total_count).unwrap_or(0);
507        let sample_rows = Self::calculate_sample_size(data).unwrap_or(0);
508        let requested = &requested_dimensions;
509        // One calculator, so the score bounds judge dates against the same
510        // reference day as the score.
511        let timeliness_calculator = TimelinessCalculator::new(&self.thresholds);
512        let temporal_columns = Self::effective_temporal_columns(column_profiles, semantic_hints);
513
514        let mut exact_dimensions = Vec::new();
515        let mut sampled_dimensions = Vec::new();
516
517        // Phase A: Completeness from exact global counters
518        let completeness = if Self::is_requested(requested, QualityDimension::Completeness) {
519            let c = CompletenessCalculator::new(&self.thresholds)
520                .with_row_completeness(self.row_completeness)
521                .calculate_from_profiles(column_profiles)?;
522            exact_dimensions.push("completeness".to_string());
523            Some(CompletenessMetrics {
524                missing_values_ratio: c.missing_values_ratio,
525                complete_records_ratio: c.complete_records_ratio,
526                null_columns: c.null_columns,
527                total_cells: c.total_cells,
528            })
529        } else {
530            None
531        };
532
533        // Phase B: Sampled dimensions from reservoir data
534        let consistency = if Self::is_requested(requested, QualityDimension::Consistency) {
535            let c = if !data.is_empty() {
536                ConsistencyCalculator::calculate(data, column_profiles)?
537            } else {
538                consistency::ConsistencyMetrics {
539                    data_type_consistency: 100.0,
540                    format_violations: 0,
541                    encoding_issues: 0,
542                    values_checked: 0,
543                    inconsistent_values: 0,
544                }
545            };
546            sampled_dimensions.push("consistency".to_string());
547            Some(ConsistencyMetrics {
548                data_type_consistency: c.data_type_consistency,
549                format_violations: c.format_violations,
550                encoding_issues: c.encoding_issues,
551                values_checked: c.values_checked,
552            })
553        } else {
554            None
555        };
556
557        let uniqueness = if Self::is_requested(requested, QualityDimension::Uniqueness) {
558            let u = UniquenessCalculator::new(&self.thresholds).calculate(
559                data,
560                column_profiles,
561                total_rows,
562                &semantic_hints.identifier_columns,
563                row_duplicates,
564            )?;
565            // Only claim provenance for components that carry signal:
566            // key_uniqueness means nothing without an identified key column,
567            // and the duplicate scan refuses misaligned per-column samples
568            // (rows_checked == 0). Full-stream tracker counts are exact
569            // until the distinct estimator spills to its HLL sketch, after
570            // which they are flagged approximate and filed as non-exact.
571            // Key uniqueness is the key column's distinct count over its
572            // non-null values, so it is only as exact as that count: an
573            // estimated count is filed as sampled, and the score says it
574            // rests on an estimate rather than presenting one as a
575            // measurement. A key with no value measured nothing.
576            let metrics = UniquenessMetrics {
577                duplicate_rows: u.duplicate_rows,
578                key_uniqueness: u.key_uniqueness,
579                high_cardinality_warning: u.high_cardinality_warning,
580                rows_checked: u.rows_checked,
581                key_column: u.key_column,
582                key_values_checked: u.key_values_checked,
583                duplicate_rows_approximate: u.duplicate_rows_approximate,
584            };
585            if let Some(key) = metrics
586                .key_column
587                .as_deref()
588                .filter(|_| metrics.key_measured())
589            {
590                let estimated = column_profiles
591                    .iter()
592                    .find(|profile| profile.name == key)
593                    .is_some_and(|profile| profile.unique_count_is_approximate == Some(true));
594                if estimated {
595                    sampled_dimensions.push("key_uniqueness".to_string());
596                } else {
597                    exact_dimensions.push("key_uniqueness".to_string());
598                }
599            }
600            if u.rows_checked > 0 {
601                let from_tracker = row_duplicates.is_some_and(|s| s.rows_checked > 0);
602                if from_tracker && !u.duplicate_rows_approximate {
603                    exact_dimensions.push("duplicate_rows".to_string());
604                } else {
605                    sampled_dimensions.push("duplicate_rows".to_string());
606                }
607            }
608            Some(metrics)
609        } else {
610            None
611        };
612
613        let accuracy = if Self::is_requested(requested, QualityDimension::Accuracy) {
614            let a = if !data.is_empty() {
615                AccuracyCalculator::new(&self.thresholds).calculate_with_positive_columns(
616                    data,
617                    column_profiles,
618                    &semantic_hints.positive_columns,
619                )?
620            } else {
621                accuracy::AccuracyMetrics {
622                    outlier_ratio: 0.0,
623                    range_violations: 0,
624                    negative_values_in_positive: 0,
625                    numeric_values_checked: 0,
626                    rule_values_checked: 0,
627                    rule_violating_values: 0,
628                    outliers: 0,
629                    outlier_values_checked: 0,
630                }
631            };
632            sampled_dimensions.push("accuracy".to_string());
633            Some(AccuracyMetrics {
634                outlier_ratio: a.outlier_ratio,
635                outlier_values_checked: Some(a.outlier_values_checked),
636                range_violations: a.range_violations,
637                negative_values_in_positive: a.negative_values_in_positive,
638                numeric_values_checked: a.numeric_values_checked,
639                rule_values_checked: a.rule_values_checked,
640                rule_violating_values: a.rule_violating_values,
641            })
642        } else {
643            None
644        };
645
646        let timeliness = if Self::is_requested(requested, QualityDimension::Timeliness) {
647            let t = if !data.is_empty() {
648                timeliness_calculator.calculate(
649                    data,
650                    &temporal_columns,
651                    &semantic_hints.temporal_pairs,
652                    column_profiles,
653                )?
654            } else {
655                timeliness::TimelinessMetrics {
656                    future_dates_count: 0,
657                    stale_data_ratio: 0.0,
658                    temporal_violations: 0,
659                    invalid_date_values: 0,
660                    date_values_checked: 0,
661                    temporal_pairs_checked: 0,
662                    valid_dates: 0,
663                    stale_dates: 0,
664                    temporal_column_pairs: 0,
665                    compared_pairs: Vec::new(),
666                }
667            };
668            sampled_dimensions.push("timeliness".to_string());
669            Some(TimelinessMetrics {
670                future_dates_count: t.future_dates_count,
671                stale_data_ratio: t.stale_data_ratio,
672                temporal_violations: t.temporal_violations,
673                invalid_date_values: t.invalid_date_values,
674                date_values_checked: t.date_values_checked,
675                temporal_pairs_checked: t.temporal_pairs_checked,
676            })
677        } else {
678            None
679        };
680
681        let validity = if Self::is_requested(requested, QualityDimension::Validity) {
682            let validity = ValidityCalculator::calculate(data, column_profiles);
683            sampled_dimensions.push("validity".to_string());
684            Some(ValidityMetrics {
685                valid_values_ratio: validity.valid_values_ratio,
686                invalid_values: validity.invalid_values,
687                values_checked: validity.values_checked,
688            })
689        } else {
690            None
691        };
692
693        let precision = if Self::is_requested(requested, QualityDimension::Precision) {
694            let precision = PrecisionCalculator::calculate(data, column_profiles);
695            sampled_dimensions.push("precision".to_string());
696            Some(PrecisionMetrics {
697                decimal_places_consistency: precision.decimal_places_consistency,
698                inconsistent_precision_values: precision.inconsistent_precision_values,
699                numeric_values_checked: precision.numeric_values_checked,
700            })
701        } else {
702            None
703        };
704
705        // total_rows reflects the full stream — that's what the user cares about
706        // for whether the quality metrics are reliable. Fall back to sample_rows
707        // when total_rows is unknown (e.g. profile arrays mode).
708        let reliability_sample = if total_rows > 0 {
709            total_rows
710        } else {
711            sample_rows
712        };
713        let validation = Self::validate_sample_size(reliability_sample, "general");
714
715        let metrics = QualityMetrics {
716            completeness,
717            consistency,
718            uniqueness,
719            accuracy,
720            timeliness,
721            validity,
722            precision,
723            low_sample_warning: !validation.sufficient_sample,
724            score_weights: self.thresholds.score_weights,
725        };
726        let score_bounds = bounds::score_bounds(
727            &self.thresholds,
728            &timeliness_calculator,
729            data,
730            column_profiles,
731            semantic_hints,
732            &temporal_columns,
733            &metrics,
734            &sampled_dimensions,
735            row_duplicates,
736        )?;
737
738        Ok(BifurcatedResult {
739            metrics,
740            exact_dimensions,
741            sampled_dimensions,
742            sample_size: sample_rows,
743            score_bounds,
744        })
745    }
746
747    /// Calculate sample size across data column arrays (using max inner length heuristic)
748    fn calculate_sample_size(
749        data: &HashMap<String, Vec<String>>,
750    ) -> Result<usize, DataProfilerError> {
751        data.values().map(|v| v.len()).max().ok_or_else(|| {
752            DataProfilerError::MetricsCalculationError {
753                message: "No data columns found".to_string(),
754            }
755        })
756    }
757}
758
759/// Result of bifurcated quality metric calculation.
760///
761/// Contains the computed metrics plus provenance information about which
762/// dimensions were computed exactly (from global streaming counters) and
763/// which were computed from a bounded sample.
764pub struct BifurcatedResult {
765    /// The computed quality metrics
766    pub metrics: QualityMetrics,
767    /// Dimensions computed from exact global counters (e.g., "completeness", "key_uniqueness")
768    pub exact_dimensions: Vec<String>,
769    /// Dimensions computed from the bounded reservoir sample (e.g., "consistency", "accuracy")
770    pub sampled_dimensions: Vec<String>,
771    /// Number of sample rows used for Phase B dimensions
772    pub sample_size: usize,
773    /// Where the scores over every scanned row lie, when some dimension was sampled.
774    pub score_bounds: Option<crate::quality::ScoreBounds>,
775}
776
777#[cfg(test)]
778mod tests {
779    use super::testing::string_profile;
780    use super::*;
781    use crate::types::{ColumnStats, DataType};
782    use dataprof_core::QualityScoreWeights;
783
784    #[test]
785    fn comprehensive_metrics_use_tracker_rows_for_cardinality_ratio() {
786        let data = HashMap::from([(
787            "city".to_string(),
788            vec!["Rome".to_string(), "Milan".to_string()],
789        )]);
790        let profiles = vec![ColumnProfile {
791            name: "city".to_string(),
792            data_type: DataType::String,
793            null_count: 0,
794            total_count: 1_000,
795            unique_count: Some(100),
796            unique_count_is_approximate: Some(false),
797            unique_count_lower_bound: None,
798            invalid_count: None,
799            type_homogeneity: None,
800            locale_number_count: None,
801            stats: ColumnStats::None,
802            patterns: Some(vec![]),
803        }];
804        let requested = [QualityDimension::Uniqueness];
805
806        let metrics = MetricsCalculator::new()
807            .calculate_comprehensive_metrics_with_hints(
808                &data,
809                &profiles,
810                Some(&requested),
811                &SemanticHints::default(),
812                Some(RowDuplicateSummary {
813                    duplicate_rows: 0,
814                    rows_checked: 1_000,
815                    approximate: false,
816                    max_duplicate_rows: 0,
817                }),
818            )
819            .expect("quality metrics");
820
821        assert!(
822            !metrics
823                .uniqueness
824                .expect("uniqueness metrics")
825                .high_cardinality_warning,
826            "100 distinct values out of 1,000 rows is not high cardinality"
827        );
828    }
829
830    #[test]
831    fn an_estimated_key_count_is_filed_as_sampled() {
832        let key = |approximate: bool| ColumnProfile {
833            name: "order_id".to_string(),
834            data_type: DataType::String,
835            null_count: 0,
836            total_count: 5_000_000,
837            unique_count: Some(4_990_000),
838            unique_count_is_approximate: Some(approximate),
839            unique_count_lower_bound: None,
840            invalid_count: None,
841            type_homogeneity: None,
842            locale_number_count: None,
843            stats: ColumnStats::None,
844            patterns: Some(vec![]),
845        };
846        let data = HashMap::from([("order_id".to_string(), vec!["A-1".to_string()])]);
847        let requested = [QualityDimension::Uniqueness];
848
849        for (approximate, filed_as_sampled) in [(true, true), (false, false)] {
850            let result = MetricsCalculator::new()
851                .calculate_bifurcated_metrics_with_hints(
852                    &data,
853                    &[key(approximate)],
854                    Some(&requested),
855                    &SemanticHints::new(Vec::new(), vec!["order_id".to_string()]),
856                    None,
857                )
858                .expect("quality metrics");
859            assert_eq!(
860                result
861                    .sampled_dimensions
862                    .contains(&"key_uniqueness".to_string()),
863                filed_as_sampled,
864                "approximate key count: {approximate}"
865            );
866            assert_eq!(
867                result
868                    .exact_dimensions
869                    .contains(&"key_uniqueness".to_string()),
870                !filed_as_sampled
871            );
872        }
873    }
874
875    #[test]
876    fn a_key_holding_no_value_claims_no_provenance() {
877        // The key was identified but every value was null: it measured
878        // nothing, so neither list may say how it was measured (#1005).
879        let key = ColumnProfile {
880            null_count: 5_000,
881            unique_count: Some(0),
882            unique_count_is_approximate: Some(false),
883            ..string_profile("order_id", 5_000, 5_000)
884        };
885        let result = MetricsCalculator::new()
886            .calculate_bifurcated_metrics_with_hints(
887                &HashMap::from([("order_id".to_string(), vec![])]),
888                &[key],
889                Some(&[QualityDimension::Uniqueness]),
890                &SemanticHints::new(Vec::new(), vec!["order_id".to_string()]),
891                None,
892            )
893            .expect("quality metrics");
894        let uniqueness = result.metrics.uniqueness.expect("uniqueness metrics");
895        assert_eq!(uniqueness.key_column.as_deref(), Some("order_id"));
896        assert_eq!(uniqueness.key_values_checked, Some(0));
897        let label = "key_uniqueness".to_string();
898        assert!(!result.exact_dimensions.contains(&label));
899        assert!(!result.sampled_dimensions.contains(&label));
900    }
901
902    #[test]
903    fn calculator_copies_custom_score_weights_into_metrics() {
904        let weights = QualityScoreWeights {
905            completeness: 1.0,
906            consistency: 0.0,
907            uniqueness: 0.0,
908            accuracy: 0.0,
909            timeliness: 0.0,
910            validity: 0.0,
911            precision: 0.0,
912        };
913        let config = IsoQualityConfig {
914            score_weights: weights,
915            ..IsoQualityConfig::default()
916        };
917        let data = HashMap::from([("value".to_string(), vec!["x".to_string()])]);
918        let profiles = vec![ColumnProfile {
919            name: "value".to_string(),
920            data_type: DataType::String,
921            null_count: 0,
922            total_count: 1,
923            unique_count: Some(1),
924            unique_count_is_approximate: Some(false),
925            unique_count_lower_bound: None,
926            invalid_count: None,
927            type_homogeneity: None,
928            locale_number_count: None,
929            stats: ColumnStats::None,
930            patterns: Some(vec![]),
931        }];
932
933        let metrics = MetricsCalculator::with_thresholds(config)
934            .calculate_comprehensive_metrics(&data, &profiles, None)
935            .expect("quality metrics");
936
937        assert_eq!(metrics.score_weights, weights);
938    }
939
940    #[test]
941    fn unrequested_completeness_remains_absent() {
942        let data = HashMap::from([("value".to_string(), vec!["x".to_string()])]);
943        let profiles = vec![string_profile("value", 1, 0)];
944        let requested = [QualityDimension::Consistency];
945
946        let metrics = MetricsCalculator::new()
947            .calculate_comprehensive_metrics(&data, &profiles, Some(&requested))
948            .expect("quality metrics");
949
950        assert!(
951            metrics.completeness.is_none(),
952            "an unrequested dimension must remain absent rather than looking computed"
953        );
954        assert!(
955            metrics.consistency.is_some(),
956            "the requested dimension should still be computed"
957        );
958    }
959
960    #[test]
961    fn empty_bifurcated_sample_keeps_new_dimensions_neutral_and_unassessed() {
962        let profiles = vec![ColumnProfile {
963            name: "amount".to_string(),
964            data_type: DataType::Float,
965            null_count: 0,
966            total_count: 100,
967            unique_count: Some(10),
968            unique_count_is_approximate: Some(false),
969            unique_count_lower_bound: None,
970            invalid_count: None,
971            type_homogeneity: None,
972            locale_number_count: None,
973            stats: ColumnStats::None,
974            patterns: Some(vec![]),
975        }];
976        let requested = [QualityDimension::Validity, QualityDimension::Precision];
977
978        let result = MetricsCalculator::new()
979            .calculate_bifurcated_metrics(&HashMap::new(), &profiles, Some(&requested))
980            .expect("bifurcated quality metrics");
981        let validity = result.metrics.validity.as_ref().expect("validity metrics");
982        let precision = result
983            .metrics
984            .precision
985            .as_ref()
986            .expect("precision metrics");
987
988        assert_eq!(validity.valid_values_ratio, 100.0);
989        assert_eq!(validity.invalid_values, 0);
990        assert_eq!(validity.values_checked, 0);
991        assert_eq!(precision.decimal_places_consistency, 100.0);
992        assert_eq!(precision.inconsistent_precision_values, 0);
993        assert_eq!(precision.numeric_values_checked, 0);
994        assert!(result.metrics.assessed_dimensions().is_empty());
995    }
996}