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