1use std::collections::BTreeMap;
36use std::fmt;
37
38use dataprof_core::serde_helpers::rounded_2;
39use dataprof_core::{ColumnProfile, DataType, PatternCategory};
40use dataprof_metrics::QualityAssessment;
41
42use crate::profile_report::ProfileReport;
43use crate::quality_gate::{
44 Evidence, EvidenceGap, Provenance, quality_status_name, sampled_component_evidence,
45};
46
47pub const DEFAULT_NULL_HEAVY_PERCENTAGE: f64 = 20.0;
50
51pub const DEFAULT_MIXED_TYPES_PERCENTAGE: f64 = 5.0;
54
55const MIN_PATTERN_CONFIDENCE: f64 = 0.5;
58
59const PERSONAL_IDENTIFIER_PATTERNS: [&str; 2] = ["Codice Fiscale (IT)", "SSN (US)"];
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum Severity {
70 Warning,
72 Info,
74}
75
76impl fmt::Display for Severity {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::Warning => write!(f, "warning"),
80 Self::Info => write!(f, "info"),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
91#[serde(rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum FindingCode {
94 AllNull,
96 ConstantColumn,
98 DuplicateRows,
100 FutureDates,
102 LocaleNumbers,
106 MixedTypes,
108 NullHeavy,
110 PartialScan,
112 RaggedRows,
114 RecordsSkipped,
116 SensitivePattern,
118 TemporalOrderViolations,
120 UnterminatedQuote,
122}
123
124impl FindingCode {
125 pub fn severity(self) -> Severity {
127 match self {
128 Self::AllNull
129 | Self::DuplicateRows
130 | Self::FutureDates
131 | Self::LocaleNumbers
132 | Self::MixedTypes
133 | Self::NullHeavy
134 | Self::RaggedRows
135 | Self::RecordsSkipped
136 | Self::TemporalOrderViolations
137 | Self::UnterminatedQuote => Severity::Warning,
138 Self::ConstantColumn | Self::PartialScan | Self::SensitivePattern => Severity::Info,
139 }
140 }
141
142 fn summary(self) -> &'static str {
148 match self {
149 Self::AllNull => "every value in this column is null",
150 Self::ConstantColumn => "every non-null value in this column is the same",
151 Self::DuplicateRows => "the source contains exact duplicate rows",
152 Self::FutureDates => "some date values lie in the future",
153 Self::LocaleNumbers => {
154 "at least half of the text values in this column are numbers written with a \
155 decimal comma or digit grouping, so no numeric statistic includes them"
156 }
157 Self::MixedTypes => "this column's values are split across lexical types",
158 Self::NullHeavy => "this column's null share is at or above the threshold",
159 Self::PartialScan => {
160 "only part of the source was read; findings describe the rows that were"
161 }
162 Self::RaggedRows => {
163 "some rows had a different field count from the header and were recovered"
164 }
165 Self::RecordsSkipped => "errors were counted while reading the source",
166 Self::SensitivePattern => {
167 "values in this column match a pattern for personal or financial data"
168 }
169 Self::TemporalOrderViolations => "some start dates fall after their paired end dates",
170 Self::UnterminatedQuote => {
171 "the source ends inside a quoted field, so its last record may hold several rows"
172 }
173 }
174 }
175}
176
177impl fmt::Display for FindingCode {
178 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 let name = match self {
180 Self::AllNull => "all_null",
181 Self::ConstantColumn => "constant_column",
182 Self::DuplicateRows => "duplicate_rows",
183 Self::FutureDates => "future_dates",
184 Self::LocaleNumbers => "locale_numbers",
185 Self::MixedTypes => "mixed_types",
186 Self::NullHeavy => "null_heavy",
187 Self::PartialScan => "partial_scan",
188 Self::RaggedRows => "ragged_rows",
189 Self::RecordsSkipped => "records_skipped",
190 Self::SensitivePattern => "sensitive_pattern",
191 Self::TemporalOrderViolations => "temporal_order_violations",
192 Self::UnterminatedQuote => "unterminated_quote",
193 };
194 write!(f, "{name}")
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, serde::Serialize)]
203#[serde(untagged)]
204#[non_exhaustive]
205pub enum EvidenceValue {
206 Count(usize),
208 Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
210 Text(String),
212 Flag(bool),
214}
215
216#[derive(Debug, Clone, PartialEq, serde::Serialize)]
218#[non_exhaustive]
219pub struct Finding {
220 pub code: FindingCode,
222 pub severity: Severity,
224 #[serde(skip_serializing_if = "Option::is_none")]
226 pub column: Option<String>,
227 pub evidence: BTreeMap<String, EvidenceValue>,
230 pub summary: String,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
236#[serde(tag = "reason", rename_all = "snake_case")]
237#[non_exhaustive]
238pub enum NotEvaluatedReason {
239 QualityUnavailable {
241 quality_status: String,
243 },
244 NotAssessed,
247 Estimated,
250 Sampled,
254 Unrecorded,
258 NotComputed,
261 NoValues,
263}
264
265impl NotEvaluatedReason {
266 fn rank(&self) -> u8 {
268 match self {
269 Self::QualityUnavailable { .. } => 0,
270 Self::NotAssessed => 1,
271 Self::Estimated => 2,
272 Self::Sampled => 3,
273 Self::Unrecorded => 4,
274 Self::NotComputed => 5,
275 Self::NoValues => 6,
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
282#[non_exhaustive]
283pub struct UnevaluatedRule {
284 pub code: FindingCode,
286 #[serde(flatten)]
288 pub reason: NotEvaluatedReason,
289 #[serde(skip_serializing_if = "Vec::is_empty")]
292 pub columns: Vec<String>,
293}
294
295#[derive(Debug, Clone, PartialEq, serde::Serialize)]
297#[non_exhaustive]
298pub struct FindingsResult {
299 pub findings: Vec<Finding>,
303 pub not_evaluated: Vec<UnevaluatedRule>,
306}
307
308#[derive(Debug, Clone, PartialEq)]
310#[non_exhaustive]
311pub enum FindingPolicyError {
312 ThresholdOutOfRange {
316 setting: &'static str,
318 value: f64,
320 },
321}
322
323impl fmt::Display for FindingPolicyError {
324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 match self {
326 Self::ThresholdOutOfRange { setting, value } => write!(
327 f,
328 "{setting} must be a percentage above 0 and at most 100 at two decimal \
329 places, got {value}"
330 ),
331 }
332 }
333}
334
335impl std::error::Error for FindingPolicyError {}
336
337#[derive(Debug, Clone, PartialEq)]
343pub struct FindingPolicy {
344 null_heavy_percentage: f64,
345 mixed_types_percentage: f64,
346}
347
348impl Default for FindingPolicy {
349 fn default() -> Self {
350 Self {
351 null_heavy_percentage: DEFAULT_NULL_HEAVY_PERCENTAGE,
352 mixed_types_percentage: DEFAULT_MIXED_TYPES_PERCENTAGE,
353 }
354 }
355}
356
357impl FindingPolicy {
358 pub fn new() -> Self {
360 Self::default()
361 }
362
363 pub fn null_heavy_percentage(mut self, percentage: f64) -> Self {
367 self.null_heavy_percentage = percentage;
368 self
369 }
370
371 pub fn mixed_types_percentage(mut self, percentage: f64) -> Self {
375 self.mixed_types_percentage = percentage;
376 self
377 }
378
379 pub fn validate(&self) -> Result<(), FindingPolicyError> {
381 for (setting, value) in [
382 ("null_heavy_percentage", self.null_heavy_percentage),
383 ("mixed_types_percentage", self.mixed_types_percentage),
384 ] {
385 let applied = rounded_2(value);
386 if !(value.is_finite() && applied > 0.0 && applied <= 100.0) {
387 return Err(FindingPolicyError::ThresholdOutOfRange { setting, value });
388 }
389 }
390 Ok(())
391 }
392
393 pub fn evaluate(&self, report: &ProfileReport) -> Result<FindingsResult, FindingPolicyError> {
397 self.validate()?;
398 Ok(self.collect(report))
399 }
400
401 fn null_heavy_threshold(&self) -> f64 {
404 rounded_2(self.null_heavy_percentage)
405 }
406
407 fn mixed_types_threshold(&self) -> f64 {
408 rounded_2(self.mixed_types_percentage)
409 }
410
411 fn collect(&self, report: &ProfileReport) -> FindingsResult {
412 let mut collector = Collector::default();
413 scan_findings(report, &mut collector);
414 quality_findings(report, &mut collector);
415 for (index, column) in report.column_profiles.iter().enumerate() {
416 self.column_findings(index, column, &mut collector);
417 }
418 collector.finish()
419 }
420
421 fn column_findings(&self, index: usize, column: &ColumnProfile, out: &mut Collector) {
422 let name = column.name.as_str();
423 let non_null = column.total_count.saturating_sub(column.null_count);
424
425 if column.total_count == 0 {
426 out.skip(FindingCode::AllNull, NotEvaluatedReason::NoValues, name);
427 out.skip(FindingCode::NullHeavy, NotEvaluatedReason::NoValues, name);
428 } else if non_null == 0 {
429 out.column(
430 FindingCode::AllNull,
431 index,
432 name,
433 [
434 ("null_count", EvidenceValue::Count(column.null_count)),
435 ("total_count", EvidenceValue::Count(column.total_count)),
436 ],
437 );
438 } else {
439 let null_percentage =
442 rounded_2(column.null_count as f64 / column.total_count as f64 * 100.0);
443 if null_percentage >= self.null_heavy_threshold() {
444 out.column(
445 FindingCode::NullHeavy,
446 index,
447 name,
448 [
449 ("null_percentage", percentage(null_percentage)),
450 ("threshold", percentage(self.null_heavy_threshold())),
451 ],
452 );
453 }
454 }
455
456 match column.unique_count {
457 None => out.skip(
458 FindingCode::ConstantColumn,
459 NotEvaluatedReason::NotComputed,
460 name,
461 ),
462 Some(_) if non_null == 0 => out.skip(
463 FindingCode::ConstantColumn,
464 NotEvaluatedReason::NoValues,
465 name,
466 ),
467 Some(1) if non_null > 1 => out.column(
469 FindingCode::ConstantColumn,
470 index,
471 name,
472 [
473 ("unique_count", EvidenceValue::Count(1)),
474 ("non_null_count", EvidenceValue::Count(non_null)),
475 ],
476 ),
477 Some(_) => {}
478 }
479
480 locale_numbers(index, column, out);
481 self.mixed_types(index, column, non_null, out);
482 sensitive_patterns(index, column, non_null, out);
483 }
484
485 fn mixed_types(
486 &self,
487 index: usize,
488 column: &ColumnProfile,
489 non_null: usize,
490 out: &mut Collector,
491 ) {
492 let name = column.name.as_str();
493 if column.data_type == DataType::Identifier {
496 return;
497 }
498 let Some(homogeneity) = column.type_homogeneity.as_ref() else {
499 out.skip(
500 FindingCode::MixedTypes,
501 NotEvaluatedReason::NotComputed,
502 name,
503 );
504 return;
505 };
506 let classified = homogeneity.classified_count();
507 let Some((dominant, dominant_count)) = homogeneity.dominant() else {
508 out.skip(FindingCode::MixedTypes, NotEvaluatedReason::NoValues, name);
509 return;
510 };
511 let outside = classified - dominant_count;
512 if outside == 0 {
513 return;
514 }
515 let outside_percentage = rounded_2(outside as f64 / classified as f64 * 100.0);
516 if outside_percentage < self.mixed_types_threshold() {
517 return;
518 }
519 out.column(
520 FindingCode::MixedTypes,
521 index,
522 name,
523 [
524 ("dominant_type", EvidenceValue::Text(dominant.to_string())),
525 (
526 "dominant_percentage",
527 percentage(dominant_count as f64 / classified as f64 * 100.0),
528 ),
529 ("threshold", percentage(self.mixed_types_threshold())),
530 ("classified_count", EvidenceValue::Count(classified)),
534 ("non_null_count", EvidenceValue::Count(non_null)),
535 ],
536 );
537 }
538}
539
540fn locale_numbers(index: usize, column: &ColumnProfile, out: &mut Collector) {
549 let name = column.name.as_str();
550 if column.data_type == DataType::Identifier {
553 return;
554 }
555 let Some(homogeneity) = column.type_homogeneity.as_ref() else {
556 out.skip(
557 FindingCode::LocaleNumbers,
558 NotEvaluatedReason::NotComputed,
559 name,
560 );
561 return;
562 };
563 let Some(locale_numbers) = column.locale_number_count else {
565 out.skip(
566 FindingCode::LocaleNumbers,
567 NotEvaluatedReason::Unrecorded,
568 name,
569 );
570 return;
571 };
572 let classified = homogeneity.classified_count();
573 if classified == 0 {
574 out.skip(
575 FindingCode::LocaleNumbers,
576 NotEvaluatedReason::NoValues,
577 name,
578 );
579 return;
580 }
581 let text = homogeneity.text;
582 if locale_numbers == 0 || locale_numbers * 2 < text {
583 return;
584 }
585 out.column(
586 FindingCode::LocaleNumbers,
587 index,
588 name,
589 [
590 ("locale_number_count", EvidenceValue::Count(locale_numbers)),
591 ("text_count", EvidenceValue::Count(text)),
592 ("classified_count", EvidenceValue::Count(classified)),
595 (
596 "non_null_count",
597 EvidenceValue::Count(column.total_count.saturating_sub(column.null_count)),
598 ),
599 ],
600 );
601}
602
603impl ProfileReport {
604 pub fn findings(&self) -> FindingsResult {
609 FindingPolicy::default().collect(self)
610 }
611}
612
613fn scan_findings(report: &ProfileReport, out: &mut Collector) {
614 let execution = &report.execution;
615 let partial = if !execution.source_exhausted || execution.truncation_reason.is_some() {
616 Some("truncated")
617 } else if execution.sampling_applied {
618 Some("sampled")
619 } else {
620 None
621 };
622 if let Some(reason) = partial {
623 out.report(
624 FindingCode::PartialScan,
625 [
626 ("reason", EvidenceValue::Text(reason.to_string())),
627 (
628 "rows_processed",
629 EvidenceValue::Count(execution.rows_processed),
630 ),
631 ],
632 );
633 }
634 if report.schema_version == 0 {
635 out.skip_report(FindingCode::RaggedRows, NotEvaluatedReason::Unrecorded);
639 } else if execution.ragged_row_count > 0 {
640 out.report(
641 FindingCode::RaggedRows,
642 [
643 (
644 "ragged_row_count",
645 EvidenceValue::Count(execution.ragged_row_count),
646 ),
647 (
648 "rows_processed",
649 EvidenceValue::Count(execution.rows_processed),
650 ),
651 ],
652 );
653 }
654 if execution.unterminated_quote == Some(true) {
655 out.report(
656 FindingCode::UnterminatedQuote,
657 [(
658 "rows_processed",
659 EvidenceValue::Count(execution.rows_processed),
660 )],
661 );
662 }
663 if execution.error_count > 0 {
664 out.report(
665 FindingCode::RecordsSkipped,
666 [("error_count", EvidenceValue::Count(execution.error_count))],
667 );
668 }
669}
670
671fn quality_findings(report: &ProfileReport, out: &mut Collector) {
672 const QUALITY_RULES: [FindingCode; 3] = [
673 FindingCode::DuplicateRows,
674 FindingCode::FutureDates,
675 FindingCode::TemporalOrderViolations,
676 ];
677 let Some(quality) = report.quality.as_ref() else {
678 let quality_status = quality_status_name(&report.quality_status).to_string();
679 for code in QUALITY_RULES {
680 out.skip_report(
681 code,
682 NotEvaluatedReason::QualityUnavailable {
683 quality_status: quality_status.clone(),
684 },
685 );
686 }
687 return;
688 };
689
690 match quality
691 .metrics
692 .uniqueness
693 .as_ref()
694 .filter(|uniqueness| uniqueness.rows_checked > 0)
695 {
696 None => out.skip_report(FindingCode::DuplicateRows, NotEvaluatedReason::NotAssessed),
697 Some(uniqueness) if uniqueness.duplicate_rows_approximate => {
698 out.skip_report(FindingCode::DuplicateRows, NotEvaluatedReason::Estimated)
699 }
700 Some(uniqueness) if uniqueness.duplicate_rows > 0 => out.report(
701 FindingCode::DuplicateRows,
702 [
703 (
704 "duplicate_rows",
705 EvidenceValue::Count(uniqueness.duplicate_rows),
706 ),
707 (
708 "rows_checked",
709 EvidenceValue::Count(uniqueness.rows_checked),
710 ),
711 ],
712 ),
713 Some(_) => skip_unless_complete(out, FindingCode::DuplicateRows, quality, "duplicate_rows"),
714 }
715
716 let timeliness = quality.metrics.timeliness.as_ref();
717 match timeliness.filter(|timeliness| timeliness.date_values_checked > 0) {
718 None => out.skip_report(FindingCode::FutureDates, NotEvaluatedReason::NotAssessed),
719 Some(timeliness) if timeliness.future_dates_count > 0 => out.report(
720 FindingCode::FutureDates,
721 [
722 (
723 "future_dates_count",
724 EvidenceValue::Count(timeliness.future_dates_count),
725 ),
726 (
727 "date_values_checked",
728 EvidenceValue::Count(timeliness.date_values_checked),
729 ),
730 ],
731 ),
732 Some(_) => skip_unless_complete(out, FindingCode::FutureDates, quality, "timeliness"),
733 }
734 match timeliness.filter(|timeliness| timeliness.temporal_pairs_checked > 0) {
735 None => out.skip_report(
736 FindingCode::TemporalOrderViolations,
737 NotEvaluatedReason::NotAssessed,
738 ),
739 Some(timeliness) if timeliness.temporal_violations > 0 => out.report(
740 FindingCode::TemporalOrderViolations,
741 [
742 (
743 "temporal_violations",
744 EvidenceValue::Count(timeliness.temporal_violations),
745 ),
746 (
747 "temporal_pairs_checked",
748 EvidenceValue::Count(timeliness.temporal_pairs_checked),
749 ),
750 ],
751 ),
752 Some(_) => skip_unless_complete(
753 out,
754 FindingCode::TemporalOrderViolations,
755 quality,
756 "timeliness",
757 ),
758 }
759}
760
761fn skip_unless_complete(
770 out: &mut Collector,
771 code: FindingCode,
772 quality: &QualityAssessment,
773 component: &'static str,
774) {
775 let coverage = sampled_component_evidence(
776 quality.sampled_dimensions().as_deref(),
777 &quality.unlisted_components(),
778 &quality.metrics.assessed_dimensions(),
779 Provenance::Component(component),
780 );
781 match coverage {
782 Evidence::Complete => {}
783 Evidence::Incomplete {
784 reason: EvidenceGap::QualitySampled,
785 } => out.skip_report(code, NotEvaluatedReason::Sampled),
786 Evidence::Incomplete { .. } => out.skip_report(code, NotEvaluatedReason::Unrecorded),
787 }
788}
789
790fn is_sensitive(category: &PatternCategory, name: &str) -> bool {
792 matches!(
793 category,
794 PatternCategory::Contact | PatternCategory::Financial
795 ) || PERSONAL_IDENTIFIER_PATTERNS.contains(&name)
796}
797
798fn sensitive_patterns(index: usize, column: &ColumnProfile, non_null: usize, out: &mut Collector) {
799 let Some(patterns) = column.patterns.as_ref() else {
800 out.skip(
801 FindingCode::SensitivePattern,
802 NotEvaluatedReason::NotComputed,
803 &column.name,
804 );
805 return;
806 };
807 if non_null == 0 {
808 out.skip(
810 FindingCode::SensitivePattern,
811 NotEvaluatedReason::NoValues,
812 &column.name,
813 );
814 return;
815 }
816 for pattern in patterns {
817 if pattern.confidence < MIN_PATTERN_CONFIDENCE
818 || !is_sensitive(&pattern.category, &pattern.name)
819 {
820 continue;
821 }
822 out.push(
823 Finding {
824 code: FindingCode::SensitivePattern,
825 severity: FindingCode::SensitivePattern.severity(),
826 column: Some(column.name.clone()),
827 evidence: evidence([
828 ("pattern", EvidenceValue::Text(pattern.name.clone())),
829 (
830 "category",
831 EvidenceValue::Text(pattern.category.to_string()),
832 ),
833 ("match_percentage", percentage(pattern.match_percentage)),
834 ]),
835 summary: FindingCode::SensitivePattern.summary().to_string(),
836 },
837 Some(index),
838 pattern.name.clone(),
839 );
840 }
841}
842
843fn percentage(value: f64) -> EvidenceValue {
847 EvidenceValue::Percentage(rounded_2(value))
848}
849
850fn evidence<const N: usize>(
851 entries: [(&'static str, EvidenceValue); N],
852) -> BTreeMap<String, EvidenceValue> {
853 entries
854 .into_iter()
855 .map(|(name, value)| (name.to_string(), value))
856 .collect()
857}
858
859struct Ranked {
861 finding: Finding,
862 column_index: Option<usize>,
864 detail: String,
866}
867
868#[derive(Default)]
869struct Collector {
870 findings: Vec<Ranked>,
871 not_evaluated: Vec<UnevaluatedRule>,
872}
873
874impl Collector {
875 fn push(&mut self, finding: Finding, column_index: Option<usize>, detail: String) {
876 self.findings.push(Ranked {
877 finding,
878 column_index,
879 detail,
880 });
881 }
882
883 fn report<const N: usize>(
884 &mut self,
885 code: FindingCode,
886 entries: [(&'static str, EvidenceValue); N],
887 ) {
888 self.push(
889 Finding {
890 code,
891 severity: code.severity(),
892 column: None,
893 evidence: evidence(entries),
894 summary: code.summary().to_string(),
895 },
896 None,
897 String::new(),
898 );
899 }
900
901 fn column<const N: usize>(
902 &mut self,
903 code: FindingCode,
904 index: usize,
905 name: &str,
906 entries: [(&'static str, EvidenceValue); N],
907 ) {
908 self.push(
909 Finding {
910 code,
911 severity: code.severity(),
912 column: Some(name.to_string()),
913 evidence: evidence(entries),
914 summary: code.summary().to_string(),
915 },
916 Some(index),
917 String::new(),
918 );
919 }
920
921 fn skip_report(&mut self, code: FindingCode, reason: NotEvaluatedReason) {
922 self.not_evaluated.push(UnevaluatedRule {
923 code,
924 reason,
925 columns: Vec::new(),
926 });
927 }
928
929 fn skip(&mut self, code: FindingCode, reason: NotEvaluatedReason, column: &str) {
932 match self
933 .not_evaluated
934 .iter_mut()
935 .find(|entry| entry.code == code && entry.reason == reason)
936 {
937 Some(entry) => entry.columns.push(column.to_string()),
938 None => self.not_evaluated.push(UnevaluatedRule {
939 code,
940 reason,
941 columns: vec![column.to_string()],
942 }),
943 }
944 }
945
946 fn finish(mut self) -> FindingsResult {
947 self.findings.sort_by(|left, right| {
948 (left.finding.severity, left.finding.code)
949 .cmp(&(right.finding.severity, right.finding.code))
950 .then_with(|| left.column_index.cmp(&right.column_index))
951 .then_with(|| left.detail.cmp(&right.detail))
952 });
953 self.not_evaluated.sort_by(|left, right| {
955 (left.code, left.reason.rank()).cmp(&(right.code, right.reason.rank()))
956 });
957 FindingsResult {
958 findings: self
959 .findings
960 .into_iter()
961 .map(|ranked| ranked.finding)
962 .collect(),
963 not_evaluated: self.not_evaluated,
964 }
965 }
966}
967
968#[cfg(test)]
969mod tests {
970 use dataprof_core::{
971 ColumnStats, DataSource, ExecutionMetadata, FileFormat, Pattern, TruncationReason,
972 TypeHomogeneity,
973 };
974 use dataprof_metrics::{
975 MetricConfidence, QualityAssessment, QualityMetrics, TimelinessMetrics, UniquenessMetrics,
976 };
977
978 use super::*;
979
980 fn source() -> DataSource {
981 DataSource::File {
982 path: "drop.csv".to_string(),
983 format: FileFormat::Csv,
984 size_bytes: 256,
985 modified_at: None,
986 parquet_metadata: None,
987 }
988 }
989
990 fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
991 ColumnProfile {
992 name: name.to_string(),
993 data_type: DataType::String,
994 null_count: nulls,
995 total_count: total,
996 unique_count: None,
997 unique_count_is_approximate: None,
998 unique_count_lower_bound: None,
999 invalid_count: None,
1000 type_homogeneity: None,
1001 locale_number_count: None,
1002 stats: ColumnStats::None,
1003 patterns: None,
1004 }
1005 }
1006
1007 fn pattern(name: &str, category: PatternCategory, confidence: f64) -> Pattern {
1008 Pattern {
1009 name: name.to_string(),
1010 regex: String::new(),
1011 match_count: 10,
1012 match_percentage: 100.0,
1013 category,
1014 confidence,
1015 }
1016 }
1017
1018 fn report(columns: Vec<ColumnProfile>, execution: ExecutionMetadata) -> ProfileReport {
1019 ProfileReport::new(source(), columns, execution, None)
1020 }
1021
1022 fn codes(result: &FindingsResult) -> Vec<(FindingCode, Option<&str>)> {
1023 result
1024 .findings
1025 .iter()
1026 .map(|finding| (finding.code, finding.column.as_deref()))
1027 .collect()
1028 }
1029
1030 #[test]
1031 fn an_estimated_duplicate_count_witnesses_nothing() {
1032 let metrics = QualityMetrics {
1033 uniqueness: Some(UniquenessMetrics {
1034 duplicate_rows: 1_200,
1035 key_uniqueness: 100.0,
1036 high_cardinality_warning: false,
1037 rows_checked: 5_000_000,
1038 key_column: None,
1039 key_values_checked: None,
1040 duplicate_rows_approximate: true,
1041 }),
1042 ..QualityMetrics::default()
1043 };
1044 let report = ProfileReport::new(
1045 source(),
1046 vec![],
1047 ExecutionMetadata::new(5_000_000, 0, 10),
1048 Some(QualityAssessment::exact(metrics)),
1049 );
1050
1051 let result = report.findings();
1052
1053 assert!(
1054 !codes(&result).contains(&(FindingCode::DuplicateRows, None)),
1055 "an HLL estimate reported as a duplicate: {:?}",
1056 result.findings
1057 );
1058 assert!(result.not_evaluated.contains(&UnevaluatedRule {
1059 code: FindingCode::DuplicateRows,
1060 reason: NotEvaluatedReason::Estimated,
1061 columns: vec![],
1062 }));
1063 }
1064
1065 #[test]
1066 fn the_threshold_is_compared_at_the_serialized_precision() {
1067 let report = report(
1071 vec![column("near", 25_000, 4_999)],
1072 ExecutionMetadata::new(25_000, 1, 10),
1073 );
1074
1075 let finding = report
1076 .findings()
1077 .findings
1078 .into_iter()
1079 .find(|finding| finding.code == FindingCode::NullHeavy)
1080 .expect("reported at the rounded value");
1081 assert_eq!(
1082 finding.evidence["null_percentage"],
1083 EvidenceValue::Percentage(20.0)
1084 );
1085 }
1086
1087 #[test]
1088 fn only_confident_personal_or_financial_patterns_are_sensitive() {
1089 let mut profile = column("mixed_ids", 10, 0);
1090 profile.patterns = Some(vec![
1091 pattern("UUID", PatternCategory::Identifier, 0.9),
1092 pattern("SSN (US)", PatternCategory::Identifier, 0.9),
1093 pattern("IBAN", PatternCategory::Financial, 0.4),
1094 pattern("Email", PatternCategory::Contact, 0.8),
1095 pattern("IPv4", PatternCategory::Network, 0.9),
1096 ]);
1097 let result = report(vec![profile], ExecutionMetadata::new(10, 1, 10)).findings();
1098
1099 let reported: Vec<_> = result
1100 .findings
1101 .iter()
1102 .filter(|finding| finding.code == FindingCode::SensitivePattern)
1103 .map(|finding| finding.evidence["pattern"].clone())
1104 .collect();
1105 assert_eq!(
1107 reported,
1108 vec![
1109 EvidenceValue::Text("Email".to_string()),
1110 EvidenceValue::Text("SSN (US)".to_string()),
1111 ]
1112 );
1113 }
1114
1115 #[test]
1116 fn evidence_holds_the_value_it_serializes() {
1117 let mut profile = column("email", 3, 0);
1121 profile.type_homogeneity = Some(TypeHomogeneity {
1122 numeric: 1,
1123 date: 0,
1124 boolean: 0,
1125 text: 2,
1126 });
1127 let mut email = pattern("Email", PatternCategory::Contact, 0.8);
1128 email.match_percentage = 200.0 / 3.0;
1129 profile.patterns = Some(vec![email]);
1130 let result = report(vec![profile], ExecutionMetadata::new(3, 1, 10)).findings();
1131
1132 let serialized: FindingsResult = {
1133 let value = serde_json::to_value(&result).expect("serializes");
1134 let mut rebuilt = result.clone();
1135 for (finding, document) in rebuilt
1136 .findings
1137 .iter_mut()
1138 .zip(value["findings"].as_array().unwrap())
1139 {
1140 for (name, evidence) in finding.evidence.iter_mut() {
1141 if let EvidenceValue::Percentage(_) = evidence {
1142 *evidence =
1143 EvidenceValue::Percentage(document["evidence"][name].as_f64().unwrap());
1144 }
1145 }
1146 }
1147 rebuilt
1148 };
1149 assert_eq!(result, serialized);
1150 }
1151
1152 fn quality_report(future_dates: usize, confidence: MetricConfidence) -> ProfileReport {
1153 let metrics = QualityMetrics {
1154 uniqueness: Some(UniquenessMetrics {
1155 duplicate_rows: 0,
1156 key_uniqueness: 100.0,
1157 high_cardinality_warning: false,
1158 rows_checked: 60_000,
1159 key_column: None,
1160 key_values_checked: None,
1161 duplicate_rows_approximate: false,
1162 }),
1163 timeliness: Some(TimelinessMetrics {
1164 future_dates_count: future_dates,
1165 stale_data_ratio: 0.0,
1166 temporal_violations: 0,
1167 invalid_date_values: 0,
1168 date_values_checked: 10_000,
1169 temporal_pairs_checked: 10_000,
1170 }),
1171 ..QualityMetrics::default()
1172 };
1173 ProfileReport::new(
1174 source(),
1175 vec![],
1176 ExecutionMetadata::new(60_000, 0, 10),
1177 Some(QualityAssessment::new(metrics, confidence)),
1178 )
1179 }
1180
1181 fn skipped(result: &FindingsResult, code: FindingCode) -> Option<&NotEvaluatedReason> {
1182 result
1183 .not_evaluated
1184 .iter()
1185 .find(|entry| entry.code == code)
1186 .map(|entry| &entry.reason)
1187 }
1188
1189 #[test]
1190 fn a_zero_count_from_the_quality_sample_rules_nothing_out() {
1191 let sampled = || MetricConfidence::Mixed {
1194 exact_dimensions: vec!["duplicate_rows".to_string()],
1195 sampled_dimensions: vec!["timeliness".to_string()],
1196 sample_size: 10_000,
1197 };
1198
1199 let clean = quality_report(0, sampled()).findings();
1200 assert_eq!(
1201 skipped(&clean, FindingCode::FutureDates),
1202 Some(&NotEvaluatedReason::Sampled)
1203 );
1204 assert_eq!(
1205 skipped(&clean, FindingCode::TemporalOrderViolations),
1206 Some(&NotEvaluatedReason::Sampled)
1207 );
1208 assert_eq!(skipped(&clean, FindingCode::DuplicateRows), None);
1210
1211 let witnessed = quality_report(3, sampled()).findings();
1213 assert!(codes(&witnessed).contains(&(FindingCode::FutureDates, None)));
1214 assert_eq!(skipped(&witnessed, FindingCode::FutureDates), None);
1215 }
1216
1217 #[test]
1218 fn unrecorded_coverage_does_not_read_as_clean() {
1219 let result = quality_report(0, MetricConfidence::Unrecorded).findings();
1220 for code in [
1221 FindingCode::DuplicateRows,
1222 FindingCode::FutureDates,
1223 FindingCode::TemporalOrderViolations,
1224 ] {
1225 assert_eq!(
1226 skipped(&result, code),
1227 Some(&NotEvaluatedReason::Unrecorded),
1228 "{code}"
1229 );
1230 }
1231 }
1232
1233 #[test]
1234 fn a_report_from_before_ragged_counting_does_not_read_as_clean() {
1235 let mut legacy = report(vec![], ExecutionMetadata::new(5, 0, 10));
1236 legacy.schema_version = 0;
1237 assert_eq!(
1238 skipped(&legacy.findings(), FindingCode::RaggedRows),
1239 Some(&NotEvaluatedReason::Unrecorded)
1240 );
1241
1242 let current = report(vec![], ExecutionMetadata::new(5, 0, 10)).findings();
1243 assert_eq!(skipped(¤t, FindingCode::RaggedRows), None);
1244 }
1245
1246 fn classified(name: &str, numeric: usize, text: usize, locale: Option<usize>) -> ColumnProfile {
1249 let mut profile = column(name, numeric + text, 0);
1250 profile.type_homogeneity = Some(TypeHomogeneity {
1251 numeric,
1252 date: 0,
1253 boolean: 0,
1254 text,
1255 });
1256 profile.locale_number_count = locale;
1257 profile
1258 }
1259
1260 fn locale_number_columns(result: &FindingsResult) -> Vec<&str> {
1261 result
1262 .findings
1263 .iter()
1264 .filter(|finding| finding.code == FindingCode::LocaleNumbers)
1265 .filter_map(|finding| finding.column.as_deref())
1266 .collect()
1267 }
1268
1269 #[test]
1270 fn locale_numbers_are_reported_from_half_of_the_text_values() {
1271 let result = report(
1272 vec![
1273 classified("half", 0, 4, Some(2)),
1274 classified("under_half", 0, 5, Some(2)),
1275 classified("float_with_commas", 9, 1, Some(1)),
1277 classified("clean", 0, 4, Some(0)),
1278 ],
1279 ExecutionMetadata::new(10, 0, 10),
1280 )
1281 .findings();
1282
1283 assert_eq!(
1284 locale_number_columns(&result),
1285 ["half", "float_with_commas"]
1286 );
1287 let half = &result.findings[0];
1288 assert_eq!(half.code, FindingCode::LocaleNumbers);
1289 assert_eq!(
1290 half.evidence,
1291 BTreeMap::from([
1292 ("classified_count".to_string(), EvidenceValue::Count(4)),
1293 ("locale_number_count".to_string(), EvidenceValue::Count(2)),
1294 ("non_null_count".to_string(), EvidenceValue::Count(4)),
1295 ("text_count".to_string(), EvidenceValue::Count(4)),
1296 ])
1297 );
1298 assert_eq!(skipped(&result, FindingCode::LocaleNumbers), None);
1299 }
1300
1301 #[test]
1302 fn an_identifier_column_is_exempt_from_locale_numbers() {
1303 let mut codes = classified("codes", 0, 4, Some(4));
1304 codes.data_type = DataType::Identifier;
1305
1306 let result = report(vec![codes], ExecutionMetadata::new(4, 0, 10)).findings();
1307
1308 assert!(locale_number_columns(&result).is_empty());
1309 assert_eq!(skipped(&result, FindingCode::LocaleNumbers), None);
1310 }
1311
1312 #[test]
1313 fn a_report_from_before_locale_number_counting_does_not_read_as_clean() {
1314 let result = report(
1316 vec![classified("legacy", 0, 4, None)],
1317 ExecutionMetadata::new(4, 0, 10),
1318 )
1319 .findings();
1320 assert_eq!(
1321 skipped(&result, FindingCode::LocaleNumbers),
1322 Some(&NotEvaluatedReason::Unrecorded)
1323 );
1324
1325 let result = report(
1327 vec![column("unclassified", 4, 0)],
1328 ExecutionMetadata::new(4, 0, 10),
1329 )
1330 .findings();
1331 assert_eq!(
1332 skipped(&result, FindingCode::LocaleNumbers),
1333 Some(&NotEvaluatedReason::NotComputed)
1334 );
1335 }
1336
1337 #[test]
1338 fn thresholds_apply_at_the_precision_they_are_reported_at() {
1339 let result = FindingPolicy::new()
1342 .null_heavy_percentage(20.004)
1343 .evaluate(&report(
1344 vec![column("fifth", 5, 1)],
1345 ExecutionMetadata::new(5, 1, 10),
1346 ))
1347 .expect("in range");
1348 let finding = result
1349 .findings
1350 .iter()
1351 .find(|finding| finding.code == FindingCode::NullHeavy)
1352 .expect("reported at the applied threshold");
1353 assert_eq!(
1354 finding.evidence["threshold"],
1355 EvidenceValue::Percentage(20.0)
1356 );
1357
1358 for value in [1e-9, 0.004] {
1360 assert!(
1361 FindingPolicy::new()
1362 .null_heavy_percentage(value)
1363 .validate()
1364 .is_err(),
1365 "{value} was accepted"
1366 );
1367 }
1368 assert!(
1369 FindingPolicy::new()
1370 .null_heavy_percentage(0.005)
1371 .validate()
1372 .is_ok()
1373 );
1374 }
1375
1376 #[test]
1377 fn a_nested_column_is_assessed_by_its_counts_only() {
1378 let mut nested = column("payload", 4, 4);
1381 nested.data_type = DataType::Nested;
1382 let result = report(vec![nested], ExecutionMetadata::new(4, 1, 10)).findings();
1383
1384 assert_eq!(
1385 codes(&result),
1386 vec![(FindingCode::AllNull, Some("payload"))]
1387 );
1388 for code in [
1389 FindingCode::ConstantColumn,
1390 FindingCode::MixedTypes,
1391 FindingCode::SensitivePattern,
1392 ] {
1393 assert!(
1394 result.not_evaluated.contains(&UnevaluatedRule {
1395 code,
1396 reason: NotEvaluatedReason::NotComputed,
1397 columns: vec!["payload".to_string()],
1398 }),
1399 "{code} was not listed: {:?}",
1400 result.not_evaluated
1401 );
1402 }
1403 }
1404
1405 #[test]
1406 fn a_sampled_scan_names_its_reason_and_truncation_wins() {
1407 let sampled = report(
1408 vec![],
1409 ExecutionMetadata::new(100, 0, 10).with_sampling(0.1),
1410 );
1411 let both = report(
1412 vec![],
1413 ExecutionMetadata::new(100, 0, 10)
1414 .with_sampling(0.1)
1415 .with_truncation(TruncationReason::MaxRows(100)),
1416 );
1417
1418 for (report, expected) in [(sampled, "sampled"), (both, "truncated")] {
1419 let finding = report
1420 .findings()
1421 .findings
1422 .into_iter()
1423 .find(|finding| finding.code == FindingCode::PartialScan)
1424 .expect("a partial scan is disclosed");
1425 assert_eq!(
1426 finding.evidence["reason"],
1427 EvidenceValue::Text(expected.to_string())
1428 );
1429 }
1430 }
1431
1432 #[test]
1433 fn warnings_sort_before_info_then_by_code_then_column_position() {
1434 let mut constant = column("z_constant", 5, 0);
1435 constant.unique_count = Some(1);
1436 let mut mixed = column("a_mixed", 5, 0);
1437 mixed.type_homogeneity = Some(TypeHomogeneity {
1438 numeric: 3,
1439 date: 0,
1440 boolean: 0,
1441 text: 2,
1442 });
1443 let result = report(
1444 vec![
1445 constant,
1446 column("sparse", 5, 4),
1447 mixed,
1448 column("empty", 5, 5),
1449 ],
1450 ExecutionMetadata::new(5, 4, 10).with_ragged_row_count(1),
1451 )
1452 .findings();
1453
1454 assert_eq!(
1455 codes(&result),
1456 vec![
1457 (FindingCode::AllNull, Some("empty")),
1458 (FindingCode::MixedTypes, Some("a_mixed")),
1459 (FindingCode::NullHeavy, Some("sparse")),
1460 (FindingCode::RaggedRows, None),
1461 (FindingCode::ConstantColumn, Some("z_constant")),
1462 ]
1463 );
1464 assert!(
1465 result
1466 .findings
1467 .iter()
1468 .all(|finding| finding.severity == finding.code.severity())
1469 );
1470 }
1471}