1use std::collections::BTreeMap;
52use std::fmt;
53
54use dataprof_core::{ColumnProfile, ExecutionMetadata, QualityDimension};
55use dataprof_metrics::{QualityAssessment, ScoreBounds, ScoreInterval};
56
57use crate::profile_report::{ProfileReport, QualityAnalysisStatus};
58
59const PERCENTAGE_MAX: f64 = 100.0;
62
63#[derive(Debug, Clone, PartialEq)]
69pub enum PolicyError {
70 ThresholdOutOfRange {
74 code: CheckCode,
76 subject: Option<String>,
78 value: f64,
80 },
81 NoRequirements,
84}
85
86impl fmt::Display for PolicyError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::ThresholdOutOfRange {
90 code,
91 subject,
92 value,
93 } => {
94 write!(f, "{code} threshold ")?;
95 if let Some(subject) = subject {
96 write!(f, "for `{subject}` ")?;
97 }
98 write!(
99 f,
100 "must be a percentage between 0 and {PERCENTAGE_MAX}, got {value}"
101 )
102 }
103 Self::NoRequirements => write!(
104 f,
105 "policy states no requirement; a gate must check something"
106 ),
107 }
108 }
109}
110
111impl std::error::Error for PolicyError {}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum PolicyScope {
121 #[default]
125 FullSource,
126 Observed,
130}
131
132impl fmt::Display for PolicyScope {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::FullSource => write!(f, "full_source"),
136 Self::Observed => write!(f, "observed"),
137 }
138 }
139}
140
141impl std::str::FromStr for PolicyScope {
142 type Err = String;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
145 match s {
146 "full_source" => Ok(Self::FullSource),
147 "observed" => Ok(Self::Observed),
148 _ => Err(format!(
149 "Unknown policy scope: {s}. Valid scopes: full_source, observed"
150 )),
151 }
152 }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum EvidenceGap {
159 Truncated,
161 Sampled,
163 RecordsSkipped,
165 QualitySampled,
168 CoverageUnrecorded,
174}
175
176impl fmt::Display for EvidenceGap {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 match self {
179 Self::Truncated => write!(f, "truncated"),
180 Self::Sampled => write!(f, "sampled"),
181 Self::RecordsSkipped => write!(f, "records_skipped"),
182 Self::QualitySampled => write!(f, "quality_sampled"),
183 Self::CoverageUnrecorded => write!(f, "coverage_unrecorded"),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
190#[serde(tag = "coverage", rename_all = "snake_case")]
191pub enum Evidence {
192 Complete,
194 Incomplete {
196 reason: EvidenceGap,
198 },
199}
200
201impl Evidence {
202 pub fn is_complete(self) -> bool {
204 matches!(self, Self::Complete)
205 }
206
207 pub fn gap(self) -> Option<EvidenceGap> {
209 match self {
210 Self::Complete => None,
211 Self::Incomplete { reason } => Some(reason),
212 }
213 }
214
215 fn and(self, other: Self) -> Self {
219 match self {
220 Self::Complete => other,
221 incomplete => incomplete,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
231#[serde(rename_all = "snake_case")]
232pub enum CheckCode {
233 MinQualityScore,
235 MinDimensionScore,
237 MaxNullPercentage,
239 MaxDuplicateRows,
241 RequireMetric,
243}
244
245impl fmt::Display for CheckCode {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 match self {
248 Self::MinQualityScore => write!(f, "min_quality_score"),
249 Self::MinDimensionScore => write!(f, "min_dimension_score"),
250 Self::MaxNullPercentage => write!(f, "max_null_percentage"),
251 Self::MaxDuplicateRows => write!(f, "max_duplicate_rows"),
252 Self::RequireMetric => write!(f, "require_metric"),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
262#[serde(untagged)]
263pub enum MetricValue {
264 Count(usize),
266 Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
268}
269
270impl fmt::Display for MetricValue {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 match self {
275 Self::Count(count) => write!(f, "{count}"),
276 Self::Percentage(value) => write!(f, "{value:.2}"),
277 }
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
283#[serde(tag = "comparison", rename_all = "snake_case")]
284pub enum Expectation {
285 AtLeast {
287 value: MetricValue,
289 },
290 AtMost {
292 value: MetricValue,
294 },
295 Analyzed,
297}
298
299impl fmt::Display for Expectation {
300 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 match self {
302 Self::AtLeast { value } => write!(f, "at least {value}"),
303 Self::AtMost { value } => write!(f, "at most {value}"),
304 Self::Analyzed => write!(f, "analyzed"),
305 }
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
316#[serde(tag = "reason", rename_all = "snake_case")]
317pub enum NotEvaluated {
318 QualityUnavailable {
320 quality_status: String,
322 },
323 NotAssessed,
327 ColumnNotProfiled,
331 EvidenceIncomplete {
334 gap: EvidenceGap,
336 },
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
341#[serde(tag = "status", rename_all = "snake_case")]
342pub enum CheckStatus {
343 Passed,
345 Failed,
347 NotEvaluated(NotEvaluated),
349}
350
351#[derive(Debug, Clone, PartialEq, serde::Serialize)]
353pub struct Check {
354 pub code: CheckCode,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub column: Option<String>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub dimension: Option<QualityDimension>,
362 pub expected: Expectation,
364 #[serde(skip_serializing_if = "Option::is_none")]
367 pub observed: Option<MetricValue>,
368 pub scope: PolicyScope,
370 pub evidence: Evidence,
372 #[serde(skip_serializing_if = "Option::is_none")]
378 pub bounds: Option<CheckBounds>,
379 #[serde(flatten)]
381 pub status: CheckStatus,
382 pub message: String,
389}
390
391impl Check {
392 pub fn is_violation(&self) -> bool {
394 matches!(self.status, CheckStatus::Failed)
395 }
396
397 pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
399 match &self.status {
400 CheckStatus::NotEvaluated(reason) => Some(reason),
401 _ => None,
402 }
403 }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
408pub struct CheckBounds {
409 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
411 pub lower: f64,
412 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
414 pub upper: f64,
415 pub confidence_level: f64,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
421#[serde(rename_all = "snake_case")]
422pub enum Verdict {
423 Pass,
425 Fail,
427 Inconclusive,
430}
431
432impl fmt::Display for Verdict {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 match self {
435 Self::Pass => write!(f, "pass"),
436 Self::Fail => write!(f, "fail"),
437 Self::Inconclusive => write!(f, "inconclusive"),
438 }
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, serde::Serialize)]
444pub struct GateResult {
445 pub verdict: Verdict,
447 pub scope: PolicyScope,
449 pub evidence: Evidence,
453 pub checks: Vec<Check>,
455}
456
457impl GateResult {
458 pub fn passed(&self) -> bool {
464 self.verdict == Verdict::Pass
465 }
466
467 pub fn violations(&self) -> impl Iterator<Item = &Check> {
469 self.checks.iter().filter(|check| check.is_violation())
470 }
471
472 pub fn unevaluated(&self) -> impl Iterator<Item = &Check> {
474 self.checks
475 .iter()
476 .filter(|check| check.not_evaluated().is_some())
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum RequiredMetric {
483 Quality,
485 Dimension(QualityDimension),
487}
488
489#[derive(Debug, Clone, Default)]
496pub struct QualityPolicy {
497 min_quality_score: Option<f64>,
498 min_dimension_scores: BTreeMap<String, (QualityDimension, f64)>,
499 max_null_percentage: BTreeMap<String, f64>,
500 max_null_percentage_any: Option<f64>,
501 max_duplicate_rows: Option<usize>,
502 require_metrics: Vec<RequiredMetric>,
503 scope: PolicyScope,
504}
505
506impl QualityPolicy {
507 pub fn new() -> Self {
510 Self::default()
511 }
512
513 pub fn min_quality_score(mut self, min: f64) -> Self {
515 self.min_quality_score = Some(min);
516 self
517 }
518
519 pub fn min_dimension_score(mut self, dimension: QualityDimension, min: f64) -> Self {
521 self.min_dimension_scores
522 .insert(dimension.to_string(), (dimension, min));
523 self
524 }
525
526 pub fn max_null_percentage(mut self, column: impl Into<String>, max: f64) -> Self {
531 self.max_null_percentage.insert(column.into(), max);
532 self
533 }
534
535 pub fn max_null_percentage_any(mut self, max: f64) -> Self {
538 self.max_null_percentage_any = Some(max);
539 self
540 }
541
542 pub fn max_duplicate_rows(mut self, max: usize) -> Self {
544 self.max_duplicate_rows = Some(max);
545 self
546 }
547
548 pub fn require_quality(mut self) -> Self {
555 self.require_metrics.push(RequiredMetric::Quality);
556 self
557 }
558
559 pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
561 self.require_metrics
562 .push(RequiredMetric::Dimension(dimension));
563 self
564 }
565
566 pub fn scope(mut self, scope: PolicyScope) -> Self {
569 self.scope = scope;
570 self
571 }
572
573 pub fn validate(&self) -> Result<(), PolicyError> {
578 if let Some(min) = self.min_quality_score {
579 check_percentage(CheckCode::MinQualityScore, None, min)?;
580 }
581 for (dimension, min) in self.min_dimension_scores.values() {
582 check_percentage(
583 CheckCode::MinDimensionScore,
584 Some(dimension.to_string()),
585 *min,
586 )?;
587 }
588 for (column, max) in &self.max_null_percentage {
589 check_percentage(CheckCode::MaxNullPercentage, Some(column.clone()), *max)?;
590 }
591 if let Some(max) = self.max_null_percentage_any {
592 check_percentage(CheckCode::MaxNullPercentage, None, max)?;
593 }
594 if self.is_empty() {
595 return Err(PolicyError::NoRequirements);
596 }
597 Ok(())
598 }
599
600 fn is_empty(&self) -> bool {
601 self.min_quality_score.is_none()
602 && self.min_dimension_scores.is_empty()
603 && self.max_null_percentage.is_empty()
604 && self.max_null_percentage_any.is_none()
605 && self.max_duplicate_rows.is_none()
606 && self.require_metrics.is_empty()
607 }
608
609 pub fn evaluate(&self, report: &ProfileReport) -> Result<GateResult, PolicyError> {
615 self.validate()?;
616
617 let scan = scan_evidence(&report.execution);
618 let mut checks = Vec::new();
619
620 for metric in required_metrics_in_order(&self.require_metrics) {
621 checks.push(self.require_metric_check(report, metric));
622 }
623 if let Some(min) = self.min_quality_score {
624 checks.push(self.quality_score_check(report, scan, min));
625 }
626 for dimension in QualityDimension::all() {
627 if let Some((_, min)) = self.min_dimension_scores.get(&dimension.to_string()) {
628 checks.push(self.dimension_score_check(report, scan, dimension, *min));
629 }
630 }
631 checks.extend(self.null_percentage_checks(report, scan));
632 if let Some(max) = self.max_duplicate_rows {
633 checks.push(self.duplicate_rows_check(report, scan, max));
634 }
635
636 Ok(GateResult {
637 verdict: verdict_of(&checks),
638 scope: self.scope,
639 evidence: scan,
640 checks,
641 })
642 }
643
644 fn decide_aggregate(&self, evidence: Evidence, satisfied: bool) -> CheckStatus {
652 match (self.scope, evidence.gap()) {
653 (PolicyScope::FullSource, Some(gap)) => {
654 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
655 }
656 _ if satisfied => CheckStatus::Passed,
657 _ => CheckStatus::Failed,
658 }
659 }
660
661 fn require_metric_check(&self, report: &ProfileReport, metric: RequiredMetric) -> Check {
662 let dimension = match metric {
663 RequiredMetric::Quality => None,
664 RequiredMetric::Dimension(dimension) => Some(dimension),
665 };
666 let analyzed = match (report.quality.as_ref(), dimension) {
670 (None, _) => false,
671 (Some(_), None) => true,
672 (Some(quality), Some(dimension)) => {
673 quality.metrics.dimension_score(dimension).is_some()
674 }
675 };
676 let message = match (analyzed, dimension) {
677 (true, None) => "quality was analyzed",
678 (false, None) => "the report carries no quality assessment",
679 (true, Some(_)) => "the required dimension was assessed",
680 (false, Some(_)) => "the required dimension was not assessed",
681 };
682 Check {
683 code: CheckCode::RequireMetric,
684 column: None,
685 dimension,
686 expected: Expectation::Analyzed,
687 observed: None,
688 scope: self.scope,
689 evidence: Evidence::Complete,
690 bounds: None,
691 status: if analyzed {
692 CheckStatus::Passed
693 } else {
694 CheckStatus::Failed
695 },
696 message: message.to_string(),
697 }
698 }
699
700 fn quality_score_check(&self, report: &ProfileReport, scan: Evidence, min: f64) -> Check {
701 let mut check = Check {
702 code: CheckCode::MinQualityScore,
703 column: None,
704 dimension: None,
705 expected: Expectation::AtLeast {
706 value: MetricValue::Percentage(min),
707 },
708 observed: None,
709 scope: self.scope,
710 evidence: scan.and(quality_evidence(
711 report.quality.as_ref(),
712 Provenance::Overall,
713 )),
714 bounds: None,
715 status: CheckStatus::Passed,
716 message: String::new(),
717 };
718 let Some(quality) = report.quality.as_ref() else {
719 return unavailable_quality(check, report);
720 };
721 let Some(score) = quality.score() else {
722 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
723 check.message =
724 "no quality dimension had anything to assess, so there is no overall score"
725 .to_string();
726 return check;
727 };
728 check.observed = Some(MetricValue::Percentage(score));
729 let bounds = quality
730 .score_bounds()
731 .and_then(|bounds| check_bounds(bounds, bounds.overall_score));
732 self.decide_score(&mut check, score, min, bounds, "the overall quality score");
733 check
734 }
735
736 fn dimension_score_check(
737 &self,
738 report: &ProfileReport,
739 scan: Evidence,
740 dimension: QualityDimension,
741 min: f64,
742 ) -> Check {
743 let mut check = Check {
744 code: CheckCode::MinDimensionScore,
745 column: None,
746 dimension: Some(dimension),
747 expected: Expectation::AtLeast {
748 value: MetricValue::Percentage(min),
749 },
750 observed: None,
751 scope: self.scope,
752 evidence: scan.and(quality_evidence(
753 report.quality.as_ref(),
754 Provenance::Dimension(dimension),
755 )),
756 bounds: None,
757 status: CheckStatus::Passed,
758 message: String::new(),
759 };
760 let Some(quality) = report.quality.as_ref() else {
761 return unavailable_quality(check, report);
762 };
763 let Some(score) = quality
764 .scores()
765 .dimension_scores
766 .get(&dimension.to_string())
767 .copied()
768 .flatten()
769 else {
770 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
771 check.message = "this dimension had nothing to assess in this run".to_string();
772 return check;
773 };
774 check.observed = Some(MetricValue::Percentage(score));
775 let bounds = quality.score_bounds().and_then(|bounds| {
776 check_bounds(
777 bounds,
778 bounds
779 .dimension_scores
780 .get(&dimension.to_string())
781 .copied()
782 .flatten(),
783 )
784 });
785 self.decide_score(&mut check, score, min, bounds, "this dimension's score");
786 check
787 }
788
789 fn decide_score(
794 &self,
795 check: &mut Check,
796 score: f64,
797 min: f64,
798 bounds: Option<CheckBounds>,
799 subject: &str,
800 ) {
801 let sampled = check.evidence.gap() == Some(EvidenceGap::QualitySampled);
802 match bounds {
803 Some(bounds) if self.scope == PolicyScope::FullSource && sampled => {
804 check.bounds = Some(bounds);
805 check.status = if bounds.lower >= min {
806 CheckStatus::Passed
807 } else if bounds.upper < min {
808 CheckStatus::Failed
809 } else {
810 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete {
811 gap: EvidenceGap::QualitySampled,
812 })
813 };
814 check.message = bounded_message(&check.status, subject);
815 }
816 _ => {
817 check.status = self.decide_aggregate(check.evidence, score >= min);
818 check.message = aggregate_message(&check.status, subject);
819 }
820 }
821 }
822
823 fn null_percentage_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
831 let mut checks = Vec::new();
832 for (column, max) in &self.max_null_percentage {
833 let profile = report
834 .column_profiles
835 .iter()
836 .find(|candidate| &candidate.name == column);
837 checks.push(self.null_percentage_check(column, profile, scan, *max));
838 }
839 if let Some(max) = self.max_null_percentage_any {
840 for profile in &report.column_profiles {
841 if !self.max_null_percentage.contains_key(&profile.name) {
842 checks.push(self.null_percentage_check(
843 &profile.name,
844 Some(profile),
845 scan,
846 max,
847 ));
848 }
849 }
850 }
851 checks
852 }
853
854 fn null_percentage_check(
855 &self,
856 column: &str,
857 profile: Option<&ColumnProfile>,
858 scan: Evidence,
859 max: f64,
860 ) -> Check {
861 let mut check = Check {
864 code: CheckCode::MaxNullPercentage,
865 column: Some(column.to_string()),
866 dimension: None,
867 expected: Expectation::AtMost {
868 value: MetricValue::Percentage(max),
869 },
870 observed: None,
871 scope: self.scope,
872 evidence: scan,
873 bounds: None,
874 status: CheckStatus::Passed,
875 message: String::new(),
876 };
877 let Some(profile) = profile else {
878 check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
879 check.message = "this column has no profile in the report".to_string();
880 return check;
881 };
882 let Some(percentage) = null_percentage(profile) else {
883 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
886 check.message = "no values were read for this column".to_string();
887 return check;
888 };
889 check.observed = Some(MetricValue::Percentage(percentage));
890 check.status = self.decide_aggregate(check.evidence, percentage <= max);
891 check.message = match check.status {
892 CheckStatus::Passed => "this column's null percentage is within the allowance",
893 CheckStatus::Failed => "this column's null percentage is above the allowance",
894 CheckStatus::NotEvaluated(_) => {
895 "the scan does not cover the whole source, and a null percentage over \
896 part of it bounds nothing about the rest"
897 }
898 }
899 .to_string();
900 check
901 }
902
903 fn duplicate_rows_check(&self, report: &ProfileReport, scan: Evidence, max: usize) -> Check {
910 let mut check = Check {
911 code: CheckCode::MaxDuplicateRows,
912 column: None,
913 dimension: Some(QualityDimension::Uniqueness),
914 expected: Expectation::AtMost {
915 value: MetricValue::Count(max),
916 },
917 observed: None,
918 scope: self.scope,
919 evidence: scan.and(quality_evidence(
920 report.quality.as_ref(),
921 Provenance::Component("duplicate_rows"),
922 )),
923 bounds: None,
924 status: CheckStatus::Passed,
925 message: String::new(),
926 };
927 let Some(quality) = report.quality.as_ref() else {
928 return unavailable_quality(check, report);
929 };
930 let Some(uniqueness) = quality
931 .metrics
932 .uniqueness
933 .as_ref()
934 .filter(|metrics| metrics.rows_checked > 0)
935 else {
936 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
937 check.message = "no rows were scanned for duplicates in this run".to_string();
938 return check;
939 };
940 let observed = uniqueness.duplicate_rows;
941 let exceeded = observed > max;
942 let estimated = uniqueness.duplicate_rows_approximate;
943 check.observed = Some(MetricValue::Count(observed));
944 check.status = match (self.scope, check.evidence.gap()) {
945 (PolicyScope::FullSource, Some(_)) if exceeded && !estimated => CheckStatus::Failed,
947 (PolicyScope::FullSource, Some(gap)) => {
948 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
949 }
950 _ if exceeded => CheckStatus::Failed,
951 _ => CheckStatus::Passed,
952 };
953 check.message = match check.status {
954 CheckStatus::Passed => "the duplicate-row count is within the allowance",
955 CheckStatus::Failed if estimated => {
956 "the estimated duplicate-row count is above the allowance"
957 }
958 CheckStatus::Failed => "duplicate rows were observed above the allowance",
959 CheckStatus::NotEvaluated(_) if estimated => {
962 "the duplicate-row count is an estimate, which can neither witness \
963 duplicates above the allowance nor rule them out"
964 }
965 CheckStatus::NotEvaluated(_) => {
966 "no duplicate above the allowance was observed, and the rows that were not \
967 read may hold more"
968 }
969 }
970 .to_string();
971 check
972 }
973}
974
975fn check_percentage(
976 code: CheckCode,
977 subject: Option<String>,
978 value: f64,
979) -> Result<(), PolicyError> {
980 if value.is_finite() && (0.0..=PERCENTAGE_MAX).contains(&value) {
981 return Ok(());
982 }
983 Err(PolicyError::ThresholdOutOfRange {
984 code,
985 subject,
986 value,
987 })
988}
989
990fn required_metrics_in_order(requested: &[RequiredMetric]) -> Vec<RequiredMetric> {
992 let mut ordered = Vec::new();
993 if requested.contains(&RequiredMetric::Quality) {
994 ordered.push(RequiredMetric::Quality);
995 }
996 for dimension in QualityDimension::all() {
997 if requested.contains(&RequiredMetric::Dimension(dimension)) {
998 ordered.push(RequiredMetric::Dimension(dimension));
999 }
1000 }
1001 ordered
1002}
1003
1004fn verdict_of(checks: &[Check]) -> Verdict {
1005 if checks.iter().any(Check::is_violation) {
1006 Verdict::Fail
1007 } else if checks.iter().any(|check| check.not_evaluated().is_some()) {
1008 Verdict::Inconclusive
1009 } else {
1010 Verdict::Pass
1011 }
1012}
1013
1014fn unavailable_quality(mut check: Check, report: &ProfileReport) -> Check {
1018 let quality_status = quality_status_name(&report.quality_status).to_string();
1019 check.message = match &report.quality_status {
1020 QualityAnalysisStatus::NotRequested => {
1021 "quality metrics were not requested for this run".to_string()
1022 }
1023 QualityAnalysisStatus::NoData => {
1024 "quality was requested but no sample was available to measure".to_string()
1025 }
1026 QualityAnalysisStatus::WithheldByProjection => {
1027 "quality was withheld: the requested dimensions measure whole rows and only \
1028 some columns were profiled"
1029 .to_string()
1030 }
1031 QualityAnalysisStatus::Failed { .. } => "the quality computation failed".to_string(),
1032 QualityAnalysisStatus::Unrecorded | QualityAnalysisStatus::Computed => {
1033 "the report carries no quality assessment".to_string()
1034 }
1035 };
1036 check.status = CheckStatus::NotEvaluated(NotEvaluated::QualityUnavailable { quality_status });
1037 check
1038}
1039
1040fn aggregate_message(status: &CheckStatus, subject: &str) -> String {
1041 match status {
1042 CheckStatus::Passed => format!("{subject} meets the required minimum"),
1043 CheckStatus::Failed => format!("{subject} is below the required minimum"),
1044 CheckStatus::NotEvaluated(_) => format!(
1045 "{subject} was computed over part of the source, which bounds nothing about \
1046 the rest"
1047 ),
1048 }
1049}
1050
1051fn bounded_message(status: &CheckStatus, subject: &str) -> String {
1052 match status {
1053 CheckStatus::Passed => format!(
1054 "{subject} was computed over a sample, and its whole-source interval meets the \
1055 required minimum"
1056 ),
1057 CheckStatus::Failed => format!(
1058 "{subject} was computed over a sample, and its whole-source interval is below the \
1059 required minimum"
1060 ),
1061 CheckStatus::NotEvaluated(_) => format!(
1062 "{subject} was computed over a sample, and the required minimum lies within its \
1063 whole-source interval"
1064 ),
1065 }
1066}
1067
1068fn check_bounds(bounds: &ScoreBounds, interval: Option<ScoreInterval>) -> Option<CheckBounds> {
1070 interval.map(|interval| CheckBounds {
1071 lower: interval.lower,
1072 upper: interval.upper,
1073 confidence_level: bounds.confidence_level,
1074 })
1075}
1076
1077pub(crate) fn quality_status_name(status: &QualityAnalysisStatus) -> &'static str {
1079 match status {
1080 QualityAnalysisStatus::Computed => "computed",
1081 QualityAnalysisStatus::NotRequested => "not_requested",
1082 QualityAnalysisStatus::NoData => "no_data",
1083 QualityAnalysisStatus::WithheldByProjection => "withheld_by_projection",
1084 QualityAnalysisStatus::Failed { .. } => "failed",
1085 QualityAnalysisStatus::Unrecorded => "unrecorded",
1086 }
1087}
1088
1089fn null_percentage(profile: &ColumnProfile) -> Option<f64> {
1092 (profile.total_count > 0)
1093 .then(|| profile.null_count as f64 / profile.total_count as f64 * PERCENTAGE_MAX)
1094}
1095
1096fn scan_evidence(execution: &ExecutionMetadata) -> Evidence {
1101 let reason = if !execution.source_exhausted || execution.truncation_reason.is_some() {
1102 Some(EvidenceGap::Truncated)
1103 } else if execution.sampling_applied {
1104 Some(EvidenceGap::Sampled)
1105 } else if execution.error_count > 0 {
1106 Some(EvidenceGap::RecordsSkipped)
1107 } else {
1108 None
1109 };
1110 match reason {
1111 Some(reason) => Evidence::Incomplete { reason },
1112 None => Evidence::Complete,
1113 }
1114}
1115
1116#[derive(Debug, Clone, Copy)]
1119enum Provenance {
1120 Overall,
1122 Dimension(QualityDimension),
1124 Component(&'static str),
1126}
1127
1128fn dimension_components(dimension: QualityDimension) -> &'static [&'static str] {
1134 match dimension {
1135 QualityDimension::Completeness => &["completeness"],
1136 QualityDimension::Consistency => &["consistency"],
1137 QualityDimension::Uniqueness => &["key_uniqueness", "duplicate_rows"],
1138 QualityDimension::Accuracy => &["accuracy"],
1139 QualityDimension::Timeliness => &["timeliness"],
1140 QualityDimension::Validity => &["validity"],
1141 QualityDimension::Precision => &["precision"],
1142 }
1143}
1144
1145fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
1151 let Some(quality) = quality else {
1152 return Evidence::Complete;
1155 };
1156 let Some(sampled) = quality.sampled_dimensions() else {
1157 return Evidence::Incomplete {
1158 reason: EvidenceGap::CoverageUnrecorded,
1159 };
1160 };
1161 let contains = |component: &str| sampled.iter().any(|label| label == component);
1162 let is_sampled = match provenance {
1163 Provenance::Overall => quality
1168 .metrics
1169 .assessed_dimensions()
1170 .into_iter()
1171 .flat_map(dimension_components)
1172 .any(|component| contains(component)),
1173 Provenance::Dimension(dimension) => dimension_components(dimension)
1174 .iter()
1175 .copied()
1176 .any(contains),
1177 Provenance::Component(component) => contains(component),
1178 };
1179 if is_sampled {
1180 Evidence::Incomplete {
1181 reason: EvidenceGap::QualitySampled,
1182 }
1183 } else {
1184 Evidence::Complete
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use std::collections::HashMap;
1191
1192 use dataprof_core::{
1193 ColumnStats, DataSource, DataType, FileFormat, QualityScoreWeights, TruncationReason,
1194 };
1195 use dataprof_metrics::{QualityMetrics, UniquenessMetrics};
1196 use serde_json::{Value, json};
1197
1198 use super::*;
1199 use crate::ReportAssembler;
1200
1201 fn source() -> DataSource {
1202 DataSource::File {
1203 path: "drop.csv".to_string(),
1204 format: FileFormat::Csv,
1205 size_bytes: 256,
1206 modified_at: None,
1207 parquet_metadata: None,
1208 }
1209 }
1210
1211 fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
1212 ColumnProfile {
1213 name: name.to_string(),
1214 data_type: DataType::String,
1215 null_count: nulls,
1216 total_count: total,
1217 unique_count: None,
1218 unique_count_is_approximate: None,
1219 unique_count_lower_bound: None,
1220 invalid_count: None,
1221 type_homogeneity: None,
1222 locale_number_count: None,
1223 stats: ColumnStats::None,
1224 patterns: None,
1225 }
1226 }
1227
1228 fn sample_data() -> HashMap<String, Vec<String>> {
1230 let ids = (1..=5).map(|n| n.to_string()).collect::<Vec<_>>();
1231 let notes = vec!["a", "b", "c", "d", ""]
1232 .into_iter()
1233 .map(str::to_string)
1234 .collect::<Vec<_>>();
1235 HashMap::from([("id".to_string(), ids), ("note".to_string(), notes)])
1236 }
1237
1238 fn complete_report() -> ProfileReport {
1239 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
1240 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1241 .with_quality_data(sample_data())
1242 .build()
1243 }
1244
1245 fn truncated_report() -> ProfileReport {
1246 ReportAssembler::new(
1247 source(),
1248 ExecutionMetadata::new(5, 2, 10).with_truncation(TruncationReason::MaxRows(5)),
1249 )
1250 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1251 .with_quality_data(sample_data())
1252 .build()
1253 }
1254
1255 fn no_quality_report() -> ProfileReport {
1256 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
1257 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
1258 .with_quality_data(sample_data())
1259 .skip_quality()
1260 .build()
1261 }
1262
1263 fn duplicate_report(duplicates: usize, approximate: bool) -> ProfileReport {
1266 let metrics = QualityMetrics {
1267 uniqueness: Some(UniquenessMetrics {
1268 duplicate_rows: duplicates,
1269 key_uniqueness: 100.0,
1270 high_cardinality_warning: false,
1271 rows_checked: 5,
1272 key_column: None,
1273 duplicate_rows_approximate: approximate,
1274 }),
1275 ..QualityMetrics::default()
1276 };
1277 ProfileReport::new(
1278 source(),
1279 vec![column("id", 5, 0)],
1280 ExecutionMetadata::new(5, 1, 10).with_truncation(TruncationReason::MaxRows(5)),
1281 Some(QualityAssessment::exact(metrics)),
1282 )
1283 }
1284
1285 fn check_for(result: &GateResult, code: CheckCode) -> &Check {
1286 result
1287 .checks
1288 .iter()
1289 .find(|check| check.code == code)
1290 .unwrap_or_else(|| panic!("no {code} check in {:?}", result.checks))
1291 }
1292
1293 fn column_check<'a>(result: &'a GateResult, name: &str) -> &'a Check {
1294 result
1295 .checks
1296 .iter()
1297 .find(|check| check.column.as_deref() == Some(name))
1298 .unwrap_or_else(|| panic!("no check for column {name}"))
1299 }
1300
1301 fn reservoir_report() -> ProfileReport {
1310 ReportAssembler::new(source(), ExecutionMetadata::new(100, 2, 10))
1311 .columns(vec![column("id", 100, 0), column("note", 100, 20)])
1312 .with_quality_data(sample_data())
1313 .build()
1314 }
1315
1316 #[test]
1317 fn a_reservoir_backed_score_is_decided_on_its_whole_source_interval() {
1318 let report = reservoir_report();
1319 assert!(report.execution.source_exhausted);
1320 assert!(!report.execution.sampling_applied);
1321 let quality = report.quality.as_ref().expect("quality was computed");
1322 let sampled = quality
1323 .sampled_dimensions()
1324 .expect("a profiling run records its provenance");
1325 assert!(
1326 !sampled.is_empty(),
1327 "the assembler did not bifurcate; this test no longer reaches the \
1328 case it guards"
1329 );
1330 let interval = quality
1331 .score_bounds()
1332 .expect("a sampled assessment carries bounds")
1333 .overall_score
1334 .expect("every sampled dimension here is bounded");
1335 assert!(
1336 interval.lower < interval.upper && interval.upper < 100.0,
1337 "a five-value sample leaves room on both sides: {interval:?}"
1338 );
1339 let decide = |min: f64| {
1340 let result = QualityPolicy::new()
1341 .min_quality_score(min)
1342 .evaluate(&report)
1343 .unwrap();
1344 assert_eq!(result.evidence, Evidence::Complete);
1347 let check = check_for(&result, CheckCode::MinQualityScore).clone();
1348 assert_eq!(
1349 check.evidence,
1350 Evidence::Incomplete {
1351 reason: EvidenceGap::QualitySampled
1352 }
1353 );
1354 assert_eq!(
1355 check.bounds.map(|bounds| (bounds.lower, bounds.upper)),
1356 Some((interval.lower, interval.upper))
1357 );
1358 (result.verdict, check)
1359 };
1360
1361 let (verdict, _) = decide(interval.lower);
1362 assert_eq!(verdict, Verdict::Pass);
1363
1364 let (verdict, check) = decide((interval.lower + interval.upper) / 2.0);
1365 assert_eq!(verdict, Verdict::Inconclusive);
1366 assert_eq!(
1367 check.not_evaluated(),
1368 Some(&NotEvaluated::EvidenceIncomplete {
1369 gap: EvidenceGap::QualitySampled
1370 })
1371 );
1372
1373 let (verdict, _) = decide(interval.upper + 0.01);
1374 assert_eq!(verdict, Verdict::Fail);
1375 }
1376
1377 #[test]
1378 fn an_observed_scope_decides_on_the_sampled_score_itself() {
1379 let report = reservoir_report();
1380 let score = report.quality_score().unwrap();
1381 let result = QualityPolicy::new()
1382 .min_quality_score(score)
1383 .scope(PolicyScope::Observed)
1384 .evaluate(&report)
1385 .unwrap();
1386 let check = check_for(&result, CheckCode::MinQualityScore);
1387 assert_eq!(result.verdict, Verdict::Pass);
1388 assert_eq!(check.bounds, None);
1389 }
1390
1391 #[test]
1392 fn a_sampled_score_without_recorded_bounds_stays_unevaluated() {
1393 let mut report = reservoir_report();
1394 let quality = report.quality.take().unwrap();
1395 report.quality = Some(QualityAssessment::new(quality.metrics, quality.confidence));
1396
1397 let result = QualityPolicy::new()
1398 .min_quality_score(1.0)
1399 .evaluate(&report)
1400 .unwrap();
1401 assert_eq!(result.verdict, Verdict::Inconclusive);
1402 assert_eq!(check_for(&result, CheckCode::MinQualityScore).bounds, None);
1403 }
1404
1405 #[test]
1408 fn an_exactly_counted_dimension_survives_another_dimensions_sampling() {
1409 let report = reservoir_report();
1410 let sampled = report
1411 .quality
1412 .as_ref()
1413 .expect("quality was computed")
1414 .sampled_dimensions()
1415 .expect("a profiling run records its provenance");
1416 assert!(
1417 !sampled.iter().any(|label| label == "completeness"),
1418 "completeness is supposed to come from exact column counters"
1419 );
1420
1421 let result = QualityPolicy::new()
1422 .min_dimension_score(QualityDimension::Completeness, 1.0)
1423 .min_dimension_score(QualityDimension::Consistency, 1.0)
1424 .evaluate(&report)
1425 .unwrap();
1426
1427 assert_eq!(
1428 result
1429 .checks
1430 .iter()
1431 .find(|check| check.dimension == Some(QualityDimension::Completeness))
1432 .map(|check| check.evidence),
1433 Some(Evidence::Complete)
1434 );
1435 assert_eq!(
1436 result
1437 .checks
1438 .iter()
1439 .find(|check| check.dimension == Some(QualityDimension::Consistency))
1440 .map(|check| check.evidence),
1441 Some(Evidence::Incomplete {
1442 reason: EvidenceGap::QualitySampled
1443 })
1444 );
1445 }
1446
1447 #[test]
1455 fn a_legacy_document_without_recorded_coverage_is_not_read_as_a_full_scan() {
1456 let document = serde_json::to_value(complete_report()).expect("serializes");
1457 let mut legacy = document.clone();
1458 legacy["quality"] = document["quality"]["metrics"].clone();
1460 let report: ProfileReport = serde_json::from_value(legacy).expect("legacy document loads");
1461
1462 assert_eq!(
1463 report
1464 .quality
1465 .as_ref()
1466 .expect("the metrics survived")
1467 .sampled_dimensions(),
1468 None
1469 );
1470
1471 let result = QualityPolicy::new()
1472 .min_quality_score(1.0)
1473 .evaluate(&report)
1474 .unwrap();
1475 assert_eq!(result.verdict, Verdict::Inconclusive);
1476 let check = check_for(&result, CheckCode::MinQualityScore);
1477 assert_eq!(
1478 check.evidence,
1479 Evidence::Incomplete {
1480 reason: EvidenceGap::CoverageUnrecorded
1481 }
1482 );
1483
1484 let observed = QualityPolicy::new()
1486 .min_quality_score(1.0)
1487 .scope(PolicyScope::Observed)
1488 .evaluate(&report)
1489 .unwrap();
1490 assert_eq!(observed.verdict, Verdict::Pass);
1491 }
1492
1493 #[test]
1500 fn a_zero_weighted_sampled_dimension_does_not_taint_the_overall_score() {
1501 let mut report = reservoir_report();
1502 let quality = report.quality.as_mut().expect("quality was computed");
1503 let sampled = quality
1504 .sampled_dimensions()
1505 .expect("a profiling run records its provenance");
1506 assert!(
1507 sampled.iter().any(|label| label == "consistency"),
1508 "consistency is supposed to come from the reservoir"
1509 );
1510 quality.metrics.score_weights = QualityScoreWeights {
1514 completeness: 1.0,
1515 consistency: 0.0,
1516 uniqueness: 0.0,
1517 accuracy: 0.0,
1518 timeliness: 0.0,
1519 validity: 0.0,
1520 precision: 0.0,
1521 };
1522
1523 let result = QualityPolicy::new()
1524 .min_quality_score(1.0)
1525 .min_dimension_score(QualityDimension::Consistency, 1.0)
1526 .evaluate(&report)
1527 .unwrap();
1528
1529 assert_eq!(
1531 check_for(&result, CheckCode::MinQualityScore).evidence,
1532 Evidence::Complete
1533 );
1534 assert_eq!(
1536 check_for(&result, CheckCode::MinDimensionScore).evidence,
1537 Evidence::Incomplete {
1538 reason: EvidenceGap::QualitySampled
1539 }
1540 );
1541 }
1542
1543 #[test]
1550 fn zero_weights_do_not_erase_the_sampling_record() {
1551 let mut report = reservoir_report();
1552 let quality = report.quality.as_mut().expect("quality was computed");
1553 quality.metrics.score_weights = QualityScoreWeights {
1554 completeness: 0.0,
1555 consistency: 0.0,
1556 uniqueness: 0.0,
1557 accuracy: 0.0,
1558 timeliness: 0.0,
1559 validity: 0.0,
1560 precision: 0.0,
1561 };
1562 let reassessed =
1563 QualityAssessment::new(quality.metrics.clone(), quality.confidence.clone());
1564
1565 assert!(reassessed.metrics.assessed_dimensions().is_empty());
1566 assert!(
1567 reassessed
1568 .sampled_dimensions()
1569 .expect("provenance survives")
1570 .iter()
1571 .any(|label| label == "consistency"),
1572 "a measured dimension kept no record of coming from a sample"
1573 );
1574 }
1575
1576 #[test]
1577 fn met_requirements_pass() {
1578 let result = QualityPolicy::new()
1579 .min_quality_score(1.0)
1580 .max_null_percentage_any(50.0)
1581 .evaluate(&complete_report())
1582 .unwrap();
1583
1584 assert_eq!(result.verdict, Verdict::Pass);
1585 assert!(result.passed());
1586 assert_eq!(result.violations().count(), 0);
1587 assert_eq!(result.unevaluated().count(), 0);
1588 }
1589
1590 #[test]
1591 fn violated_requirement_fails() {
1592 let result = QualityPolicy::new()
1593 .max_null_percentage("note", 10.0)
1594 .evaluate(&complete_report())
1595 .unwrap();
1596
1597 assert_eq!(result.verdict, Verdict::Fail);
1598 assert!(!result.passed());
1599 let violation = result.violations().next().expect("one violation");
1600 assert_eq!(violation.code, CheckCode::MaxNullPercentage);
1601 assert_eq!(violation.column.as_deref(), Some("note"));
1602 assert_eq!(violation.observed, Some(MetricValue::Percentage(20.0)));
1603 assert_eq!(
1604 violation.expected,
1605 Expectation::AtMost {
1606 value: MetricValue::Percentage(10.0)
1607 }
1608 );
1609 }
1610
1611 #[test]
1613 fn named_limit_overrides_the_wildcard() {
1614 let result = QualityPolicy::new()
1615 .max_null_percentage("note", 50.0)
1616 .max_null_percentage_any(0.0)
1617 .evaluate(&complete_report())
1618 .unwrap();
1619
1620 assert_eq!(result.verdict, Verdict::Pass);
1621 assert_eq!(result.checks.len(), 2);
1622 assert_eq!(
1623 column_check(&result, "note").expected,
1624 Expectation::AtMost {
1625 value: MetricValue::Percentage(50.0)
1626 }
1627 );
1628 assert_eq!(
1629 column_check(&result, "id").expected,
1630 Expectation::AtMost {
1631 value: MetricValue::Percentage(0.0)
1632 }
1633 );
1634 }
1635
1636 #[test]
1639 fn thresholds_are_on_the_percentage_scale() {
1640 let result = QualityPolicy::new()
1641 .min_quality_score(0.9)
1642 .evaluate(&complete_report())
1643 .unwrap();
1644 assert_eq!(result.verdict, Verdict::Pass);
1645
1646 let err = QualityPolicy::new()
1647 .min_quality_score(101.0)
1648 .evaluate(&complete_report())
1649 .unwrap_err();
1650 assert_eq!(
1651 err,
1652 PolicyError::ThresholdOutOfRange {
1653 code: CheckCode::MinQualityScore,
1654 subject: None,
1655 value: 101.0,
1656 }
1657 );
1658 assert!(
1659 QualityPolicy::new()
1660 .max_null_percentage("id", f64::NAN)
1661 .validate()
1662 .is_err()
1663 );
1664 }
1665
1666 #[test]
1667 fn empty_policy_is_rejected_rather_than_passing_everything() {
1668 assert_eq!(
1669 QualityPolicy::new()
1670 .evaluate(&complete_report())
1671 .unwrap_err(),
1672 PolicyError::NoRequirements
1673 );
1674 }
1675
1676 #[test]
1680 fn unanalyzed_quality_is_inconclusive_not_a_pass() {
1681 let result = QualityPolicy::new()
1682 .min_quality_score(90.0)
1683 .evaluate(&no_quality_report())
1684 .unwrap();
1685
1686 assert_eq!(result.verdict, Verdict::Inconclusive);
1687 assert!(!result.passed());
1688 let check = check_for(&result, CheckCode::MinQualityScore);
1689 assert_eq!(check.observed, None);
1690 assert_eq!(
1691 check.not_evaluated(),
1692 Some(&NotEvaluated::QualityUnavailable {
1693 quality_status: "not_requested".to_string(),
1694 })
1695 );
1696 }
1697
1698 #[test]
1700 fn require_quality_turns_absence_into_a_violation() {
1701 let result = QualityPolicy::new()
1702 .require_quality()
1703 .min_quality_score(90.0)
1704 .evaluate(&no_quality_report())
1705 .unwrap();
1706
1707 assert_eq!(result.verdict, Verdict::Fail);
1708 let required = check_for(&result, CheckCode::RequireMetric);
1709 assert!(required.is_violation());
1710 assert_eq!(required.expected, Expectation::Analyzed);
1711 assert_eq!(required.observed, None);
1712 }
1713
1714 #[test]
1715 fn require_dimension_fails_when_the_dimension_had_nothing_to_assess() {
1716 let result = QualityPolicy::new()
1717 .require_dimension(QualityDimension::Timeliness)
1718 .evaluate(&complete_report())
1719 .unwrap();
1720
1721 assert_eq!(result.verdict, Verdict::Fail);
1722 assert_eq!(
1723 check_for(&result, CheckCode::RequireMetric).dimension,
1724 Some(QualityDimension::Timeliness)
1725 );
1726 }
1727
1728 #[test]
1731 fn unassessed_dimension_is_distinct_from_unrequested_quality() {
1732 let result = QualityPolicy::new()
1733 .min_dimension_score(QualityDimension::Timeliness, 90.0)
1734 .evaluate(&complete_report())
1735 .unwrap();
1736
1737 assert_eq!(result.verdict, Verdict::Inconclusive);
1738 assert_eq!(
1739 check_for(&result, CheckCode::MinDimensionScore).not_evaluated(),
1740 Some(&NotEvaluated::NotAssessed)
1741 );
1742 }
1743
1744 #[test]
1747 fn unprofiled_column_is_not_evaluated() {
1748 let result = QualityPolicy::new()
1749 .max_null_percentage("absent", 0.0)
1750 .evaluate(&complete_report())
1751 .unwrap();
1752
1753 assert_eq!(result.verdict, Verdict::Inconclusive);
1754 assert_eq!(
1755 column_check(&result, "absent").not_evaluated(),
1756 Some(&NotEvaluated::ColumnNotProfiled)
1757 );
1758 }
1759
1760 #[test]
1763 fn a_ratio_over_a_partial_scan_cannot_pass_a_full_source_policy() {
1764 let report = truncated_report();
1765 let result = QualityPolicy::new()
1766 .max_null_percentage_any(50.0)
1767 .evaluate(&report)
1768 .unwrap();
1769
1770 assert_eq!(result.verdict, Verdict::Inconclusive);
1771 assert_eq!(
1772 result.evidence,
1773 Evidence::Incomplete {
1774 reason: EvidenceGap::Truncated
1775 }
1776 );
1777 let check = column_check(&result, "note");
1778 assert_eq!(
1779 check.not_evaluated(),
1780 Some(&NotEvaluated::EvidenceIncomplete {
1781 gap: EvidenceGap::Truncated
1782 })
1783 );
1784 assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
1786
1787 let observed = QualityPolicy::new()
1789 .max_null_percentage_any(50.0)
1790 .scope(PolicyScope::Observed)
1791 .evaluate(&report)
1792 .unwrap();
1793 assert_eq!(observed.verdict, Verdict::Pass);
1794 }
1795
1796 #[test]
1799 fn a_witnessed_duplicate_fails_despite_a_partial_scan() {
1800 let result = QualityPolicy::new()
1801 .max_duplicate_rows(0)
1802 .evaluate(&duplicate_report(2, false))
1803 .unwrap();
1804
1805 assert_eq!(result.verdict, Verdict::Fail);
1806 let check = check_for(&result, CheckCode::MaxDuplicateRows);
1807 assert_eq!(check.observed, Some(MetricValue::Count(2)));
1808 assert_eq!(
1809 check.evidence,
1810 Evidence::Incomplete {
1811 reason: EvidenceGap::Truncated
1812 }
1813 );
1814 }
1815
1816 #[test]
1818 fn no_duplicate_in_a_partial_scan_is_not_a_pass() {
1819 let result = QualityPolicy::new()
1820 .max_duplicate_rows(0)
1821 .evaluate(&duplicate_report(0, false))
1822 .unwrap();
1823
1824 assert_eq!(result.verdict, Verdict::Inconclusive);
1825 assert_eq!(
1826 check_for(&result, CheckCode::MaxDuplicateRows).not_evaluated(),
1827 Some(&NotEvaluated::EvidenceIncomplete {
1828 gap: EvidenceGap::Truncated
1829 })
1830 );
1831 }
1832
1833 #[test]
1836 fn an_estimated_duplicate_count_settles_neither_direction() {
1837 let result = QualityPolicy::new()
1838 .max_duplicate_rows(0)
1839 .evaluate(&duplicate_report(2, true))
1840 .unwrap();
1841
1842 assert_eq!(result.verdict, Verdict::Inconclusive);
1843 let check = check_for(&result, CheckCode::MaxDuplicateRows);
1844 assert_eq!(
1845 check.not_evaluated(),
1846 Some(&NotEvaluated::EvidenceIncomplete {
1847 gap: EvidenceGap::Truncated
1848 })
1849 );
1850 assert_eq!(
1853 check.message,
1854 "the duplicate-row count is an estimate, which can neither witness \
1855 duplicates above the allowance nor rule them out"
1856 );
1857 }
1858
1859 #[test]
1862 fn a_violation_outranks_an_unevaluated_check() {
1863 let result = QualityPolicy::new()
1864 .max_duplicate_rows(0)
1865 .min_dimension_score(QualityDimension::Timeliness, 90.0)
1866 .evaluate(&duplicate_report(2, false))
1867 .unwrap();
1868
1869 assert_eq!(result.verdict, Verdict::Fail);
1870 assert_eq!(result.unevaluated().count(), 1);
1871 }
1872
1873 #[test]
1876 fn evaluation_order_does_not_depend_on_insertion_order() {
1877 let report = complete_report();
1878 let forward = QualityPolicy::new()
1879 .max_null_percentage("note", 50.0)
1880 .max_null_percentage("id", 50.0)
1881 .evaluate(&report)
1882 .unwrap();
1883 let reverse = QualityPolicy::new()
1884 .max_null_percentage("id", 50.0)
1885 .max_null_percentage("note", 50.0)
1886 .evaluate(&report)
1887 .unwrap();
1888
1889 assert_eq!(forward, reverse);
1890 let columns: Vec<_> = forward
1891 .checks
1892 .iter()
1893 .map(|check| check.column.clone().unwrap())
1894 .collect();
1895 assert_eq!(columns, vec!["id".to_string(), "note".to_string()]);
1896 }
1897
1898 #[test]
1901 fn result_serializes_into_the_documented_shape() {
1902 let result = QualityPolicy::new()
1903 .max_null_percentage("note", 10.0)
1904 .evaluate(&complete_report())
1905 .unwrap();
1906
1907 assert_eq!(
1908 serde_json::to_value(&result).unwrap(),
1909 json!({
1910 "verdict": "fail",
1911 "scope": "full_source",
1912 "evidence": {"coverage": "complete"},
1913 "checks": [{
1914 "code": "max_null_percentage",
1915 "column": "note",
1916 "expected": {"comparison": "at_most", "value": 10.0},
1917 "observed": 20.0,
1918 "scope": "full_source",
1919 "evidence": {"coverage": "complete"},
1920 "status": "failed",
1921 "message": "this column's null percentage is above the allowance",
1922 }],
1923 })
1924 );
1925 }
1926
1927 #[test]
1928 fn an_unevaluated_check_serializes_its_reason() {
1929 let result = QualityPolicy::new()
1930 .min_quality_score(90.0)
1931 .evaluate(&no_quality_report())
1932 .unwrap();
1933 let check = &serde_json::to_value(&result).unwrap()["checks"][0];
1934
1935 assert_eq!(check["status"], Value::String("not_evaluated".to_string()));
1936 assert_eq!(
1937 check["reason"],
1938 Value::String("quality_unavailable".to_string())
1939 );
1940 assert_eq!(
1941 check["quality_status"],
1942 Value::String("not_requested".to_string())
1943 );
1944 assert!(check.get("observed").is_none());
1945 }
1946}