1mod 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
64pub 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
87pub struct MetricsCalculator {
89 pub thresholds: IsoQualityConfig,
91 row_completeness: Option<RowCompletenessSummary>,
93}
94
95impl Default for MetricsCalculator {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl MetricsCalculator {
102 pub fn new() -> Self {
104 Self::with_thresholds(IsoQualityConfig::default())
105 }
106
107 pub fn with_thresholds(thresholds: IsoQualityConfig) -> Self {
109 Self {
110 thresholds,
111 row_completeness: None,
112 }
113 }
114
115 pub fn strict() -> Self {
117 Self::with_thresholds(IsoQualityConfig::strict())
118 }
119
120 pub fn lenient() -> Self {
122 Self::with_thresholds(IsoQualityConfig::lenient())
123 }
124
125 pub fn with_row_completeness(mut self, summary: Option<RowCompletenessSummary>) -> Self {
131 self.row_completeness = summary;
132 self
133 }
134
135 pub fn validate_sample_size(sample_size: usize, metric_type: &str) -> StatisticalValidation {
137 utils::validate_sample_size(sample_size, metric_type)
138 }
139
140 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
759pub struct BifurcatedResult {
765 pub metrics: QualityMetrics,
767 pub exact_dimensions: Vec<String>,
769 pub sampled_dimensions: Vec<String>,
771 pub sample_size: usize,
773 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 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}