1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
99use std::fmt;
100
101use dataprof_core::serde_helpers::rounded_2;
102use dataprof_core::{ColumnProfile, DataType, ExecutionMetadata, QualityDimension};
103use dataprof_metrics::{QualityAssessment, ScoreBounds, ScoreInterval};
104use serde::Serialize;
105
106use crate::profile_report::{
107 ColumnSelection, MetricSemantics, ProfileReport, QualityAnalysisStatus,
108};
109use crate::report_delta::{
110 ColumnChange, ColumnSide, ConfigurationComparison, RecordedHints, ReportDelta, ReportReference,
111 ReportSide, SchemaChange, Sides,
112};
113
114const PERCENTAGE_MAX: f64 = 100.0;
117
118#[derive(Debug, Clone, PartialEq)]
124#[non_exhaustive]
125pub enum PolicyError {
126 #[non_exhaustive]
130 ThresholdOutOfRange {
131 code: CheckCode,
133 subject: Option<String>,
135 value: f64,
137 },
138 NoRequirements,
141 BaselineRequired,
145}
146
147impl fmt::Display for PolicyError {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 Self::ThresholdOutOfRange {
151 code,
152 subject,
153 value,
154 } => {
155 write!(f, "{code} threshold ")?;
156 if let Some(subject) = subject {
157 write!(f, "for `{subject}` ")?;
158 }
159 write!(
160 f,
161 "must be a percentage between 0 and {PERCENTAGE_MAX}, got {value}"
162 )
163 }
164 Self::NoRequirements => write!(
165 f,
166 "policy states no requirement; a gate must check something"
167 ),
168 Self::BaselineRequired => write!(
169 f,
170 "policy states a rule about change, which needs a baseline report to compare \
171 against"
172 ),
173 }
174 }
175}
176
177impl std::error::Error for PolicyError {}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
185#[serde(rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum PolicyScope {
188 #[default]
192 FullSource,
193 Observed,
197}
198
199impl fmt::Display for PolicyScope {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 Self::FullSource => write!(f, "full_source"),
203 Self::Observed => write!(f, "observed"),
204 }
205 }
206}
207
208impl std::str::FromStr for PolicyScope {
209 type Err = String;
210
211 fn from_str(s: &str) -> Result<Self, Self::Err> {
212 match s {
213 "full_source" => Ok(Self::FullSource),
214 "observed" => Ok(Self::Observed),
215 _ => Err(format!(
216 "Unknown policy scope: {s}. Valid scopes: full_source, observed"
217 )),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
224#[serde(rename_all = "snake_case")]
225#[non_exhaustive]
226pub enum EvidenceGap {
227 Truncated,
229 Sampled,
231 RecordsSkipped,
233 QualitySampled,
236 CoverageUnrecorded,
243}
244
245impl fmt::Display for EvidenceGap {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 match self {
248 Self::Truncated => write!(f, "truncated"),
249 Self::Sampled => write!(f, "sampled"),
250 Self::RecordsSkipped => write!(f, "records_skipped"),
251 Self::QualitySampled => write!(f, "quality_sampled"),
252 Self::CoverageUnrecorded => write!(f, "coverage_unrecorded"),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
259#[serde(tag = "coverage", rename_all = "snake_case")]
260#[non_exhaustive]
261pub enum Evidence {
262 Complete,
264 #[non_exhaustive]
266 Incomplete {
267 reason: EvidenceGap,
269 },
270}
271
272impl Evidence {
273 pub fn is_complete(self) -> bool {
275 matches!(self, Self::Complete)
276 }
277
278 pub fn gap(self) -> Option<EvidenceGap> {
280 match self {
281 Self::Complete => None,
282 Self::Incomplete { reason } => Some(reason),
283 }
284 }
285
286 pub(crate) fn and(self, other: Self) -> Self {
290 match self {
291 Self::Complete => other,
292 incomplete => incomplete,
293 }
294 }
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum CheckCode {
305 MinRows,
307 MinQualityScore,
309 MinDimensionScore,
311 MaxNullPercentage,
313 MaxNullCount,
315 MaxDuplicateRows,
317 RequireMetric,
319 MaxQualityScoreDrop,
322 MaxDimensionScoreDrop,
325 MaxNullPercentageIncrease,
328 RequireColumn,
330 ForbidRemovedColumn,
332 ForbidAddedColumn,
334}
335
336impl fmt::Display for CheckCode {
337 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
338 match self {
339 Self::MinRows => write!(f, "min_rows"),
340 Self::MinQualityScore => write!(f, "min_quality_score"),
341 Self::MinDimensionScore => write!(f, "min_dimension_score"),
342 Self::MaxNullPercentage => write!(f, "max_null_percentage"),
343 Self::MaxNullCount => write!(f, "max_null_count"),
344 Self::MaxDuplicateRows => write!(f, "max_duplicate_rows"),
345 Self::RequireMetric => write!(f, "require_metric"),
346 Self::MaxQualityScoreDrop => write!(f, "max_quality_score_drop"),
347 Self::MaxDimensionScoreDrop => write!(f, "max_dimension_score_drop"),
348 Self::MaxNullPercentageIncrease => write!(f, "max_null_percentage_increase"),
349 Self::RequireColumn => write!(f, "require_column"),
350 Self::ForbidRemovedColumn => write!(f, "forbid_removed_column"),
351 Self::ForbidAddedColumn => write!(f, "forbid_added_column"),
352 }
353 }
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
361#[serde(untagged)]
362#[non_exhaustive]
363pub enum MetricValue {
364 Count(usize),
366 Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
368}
369
370impl fmt::Display for MetricValue {
371 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374 match self {
375 Self::Count(count) => write!(f, "{count}"),
376 Self::Percentage(value) => write!(f, "{value:.2}"),
377 }
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
383#[serde(tag = "comparison", rename_all = "snake_case")]
384#[non_exhaustive]
385pub enum Expectation {
386 #[non_exhaustive]
388 AtLeast {
389 value: MetricValue,
391 },
392 #[non_exhaustive]
394 AtMost {
395 value: MetricValue,
397 },
398 Analyzed,
400 #[non_exhaustive]
403 ChangeAtLeast {
404 value: MetricValue,
406 },
407 #[non_exhaustive]
409 ChangeAtMost {
410 value: MetricValue,
412 },
413 Present,
415 InBaseline,
417}
418
419impl fmt::Display for Expectation {
420 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421 match self {
422 Self::AtLeast { value } => write!(f, "at least {value}"),
423 Self::AtMost { value } => write!(f, "at most {value}"),
424 Self::Analyzed => write!(f, "analyzed"),
425 Self::ChangeAtLeast { value } => write!(f, "a change of at least {value}"),
426 Self::ChangeAtMost { value } => write!(f, "a change of at most {value}"),
427 Self::Present => write!(f, "present"),
428 Self::InBaseline => write!(f, "in the baseline"),
429 }
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
440#[serde(tag = "reason", rename_all = "snake_case")]
441#[non_exhaustive]
442pub enum NotEvaluated {
443 #[non_exhaustive]
445 QualityUnavailable {
446 quality_status: String,
448 },
449 NotAssessed,
453 ColumnNotProfiled,
459 #[non_exhaustive]
462 EvidenceIncomplete {
463 gap: EvidenceGap,
465 },
466 NotInBaseline,
470 SchemaChanged,
474 DimensionsChanged,
478 #[non_exhaustive]
482 DefinitionNotComparable {
483 definition: String,
485 },
486 #[non_exhaustive]
489 SettingChanged {
490 setting: String,
492 },
493 #[non_exhaustive]
496 SettingUnrecorded {
497 setting: String,
499 },
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
504#[serde(tag = "status", rename_all = "snake_case")]
505#[non_exhaustive]
506pub enum CheckStatus {
507 Passed,
509 Failed,
511 NotEvaluated(NotEvaluated),
513}
514
515#[derive(Debug, Clone, PartialEq, serde::Serialize)]
517#[non_exhaustive]
518pub struct Check {
519 pub code: CheckCode,
521 #[serde(skip_serializing_if = "Option::is_none")]
523 pub column: Option<String>,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub dimension: Option<QualityDimension>,
527 pub expected: Expectation,
529 #[serde(skip_serializing_if = "Option::is_none")]
532 pub observed: Option<MetricValue>,
533 pub scope: PolicyScope,
535 pub evidence: Evidence,
537 #[serde(skip_serializing_if = "Option::is_none")]
543 pub bounds: Option<CheckBounds>,
544 #[serde(skip_serializing_if = "Option::is_none")]
547 pub baseline: Option<BaselineReading>,
548 #[serde(flatten)]
550 pub status: CheckStatus,
551 pub message: String,
558}
559
560impl Check {
561 pub fn is_violation(&self) -> bool {
563 matches!(self.status, CheckStatus::Failed)
564 }
565
566 pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
568 match &self.status {
569 CheckStatus::NotEvaluated(reason) => Some(reason),
570 _ => None,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
577#[non_exhaustive]
578pub struct BaselineReading {
579 pub observed: Option<MetricValue>,
581 pub evidence: Evidence,
583 pub change: Option<MetricValue>,
586}
587
588#[derive(Debug, Clone, PartialEq, serde::Serialize)]
591#[non_exhaustive]
592pub struct BaselineComparison {
593 pub reports: Sides<ReportReference>,
596 pub schema: SchemaChange,
598 pub diagnostics: Vec<Diagnostic>,
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
607#[serde(rename_all = "snake_case")]
608#[non_exhaustive]
609pub enum DiagnosticCode {
610 ColumnAdded,
614 ColumnRemoved,
619 ColumnTypeChanged,
622 ColumnAllNull,
625}
626
627impl fmt::Display for DiagnosticCode {
628 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
629 match self {
630 Self::ColumnAdded => write!(f, "column_added"),
631 Self::ColumnRemoved => write!(f, "column_removed"),
632 Self::ColumnTypeChanged => write!(f, "column_type_changed"),
633 Self::ColumnAllNull => write!(f, "column_all_null"),
634 }
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, serde::Serialize)]
644#[non_exhaustive]
645pub struct Diagnostic {
646 pub code: DiagnosticCode,
648 pub column: String,
650 #[serde(
653 skip_serializing_if = "Option::is_none",
654 serialize_with = "serialize_data_types"
655 )]
656 pub data_type: Option<Sides<DataType>>,
657 #[serde(flatten)]
660 pub setting: Option<SettingDifference>,
661 pub message: String,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
675#[serde(tag = "reason", rename_all = "snake_case")]
676#[non_exhaustive]
677pub enum SettingDifference {
678 #[non_exhaustive]
680 SettingChanged {
681 setting: String,
683 },
684 #[non_exhaustive]
687 SettingUnrecorded {
688 setting: String,
690 },
691}
692
693fn serialize_data_types<S: serde::Serializer>(
694 types: &Option<Sides<DataType>>,
695 serializer: S,
696) -> Result<S::Ok, S::Error> {
697 types
698 .as_ref()
699 .map(|types| Sides {
700 a: types.a.name(),
701 b: types.b.name(),
702 })
703 .serialize(serializer)
704}
705
706#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
708#[non_exhaustive]
709pub struct CheckBounds {
710 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
712 pub lower: f64,
713 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
715 pub upper: f64,
716 pub confidence_level: f64,
718}
719
720#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
722#[serde(rename_all = "snake_case")]
723#[non_exhaustive]
724pub enum Verdict {
725 Pass,
727 Fail,
729 Inconclusive,
732}
733
734impl fmt::Display for Verdict {
735 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
736 match self {
737 Self::Pass => write!(f, "pass"),
738 Self::Fail => write!(f, "fail"),
739 Self::Inconclusive => write!(f, "inconclusive"),
740 }
741 }
742}
743
744#[derive(Debug, Clone, PartialEq, serde::Serialize)]
746#[non_exhaustive]
747pub struct GateResult {
748 pub verdict: Verdict,
750 pub scope: PolicyScope,
752 pub evidence: Evidence,
756 pub checks: Vec<Check>,
758 #[serde(skip_serializing_if = "Option::is_none")]
761 pub baseline: Option<BaselineComparison>,
762}
763
764impl GateResult {
765 pub fn passed(&self) -> bool {
771 self.verdict == Verdict::Pass
772 }
773
774 pub fn violations(&self) -> impl Iterator<Item = &Check> {
776 self.checks.iter().filter(|check| check.is_violation())
777 }
778
779 pub fn unevaluated(&self) -> impl Iterator<Item = &Check> {
781 self.checks
782 .iter()
783 .filter(|check| check.not_evaluated().is_some())
784 }
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789#[non_exhaustive]
790pub enum RequiredMetric {
791 Quality,
793 Dimension(QualityDimension),
795}
796
797#[derive(Debug, Clone, Default)]
804#[non_exhaustive]
805pub struct QualityPolicy {
806 min_rows: Option<usize>,
807 min_quality_score: Option<f64>,
808 min_dimension_scores: BTreeMap<String, (QualityDimension, f64)>,
809 max_null_percentage: BTreeMap<String, f64>,
810 max_null_percentage_any: Option<f64>,
811 max_null_count: BTreeMap<String, usize>,
812 max_null_count_any: Option<usize>,
813 max_duplicate_rows: Option<usize>,
814 require_metrics: Vec<RequiredMetric>,
815 max_quality_score_drop: Option<f64>,
816 max_dimension_score_drops: BTreeMap<String, (QualityDimension, f64)>,
817 max_null_percentage_increase: BTreeMap<String, f64>,
818 max_null_percentage_increase_any: Option<f64>,
819 require_columns: BTreeSet<String>,
820 forbid_removed_columns: bool,
821 forbid_added_columns: bool,
822 scope: PolicyScope,
823}
824
825impl QualityPolicy {
826 pub fn new() -> Self {
829 Self::default()
830 }
831
832 pub fn min_rows(mut self, min: usize) -> Self {
835 self.min_rows = Some(min);
836 self
837 }
838
839 pub fn min_quality_score(mut self, min: f64) -> Self {
841 self.min_quality_score = Some(min);
842 self
843 }
844
845 pub fn min_dimension_score(mut self, dimension: QualityDimension, min: f64) -> Self {
847 self.min_dimension_scores
848 .insert(dimension.to_string(), (dimension, min));
849 self
850 }
851
852 pub fn max_null_percentage(mut self, column: impl Into<String>, max: f64) -> Self {
857 self.max_null_percentage.insert(column.into(), max);
858 self
859 }
860
861 pub fn max_null_percentage_any(mut self, max: f64) -> Self {
864 self.max_null_percentage_any = Some(max);
865 self
866 }
867
868 pub fn max_null_count(mut self, column: impl Into<String>, max: usize) -> Self {
874 self.max_null_count.insert(column.into(), max);
875 self
876 }
877
878 pub fn max_null_count_any(mut self, max: usize) -> Self {
880 self.max_null_count_any = Some(max);
881 self
882 }
883
884 pub fn max_duplicate_rows(mut self, max: usize) -> Self {
886 self.max_duplicate_rows = Some(max);
887 self
888 }
889
890 pub fn require_quality(mut self) -> Self {
897 self.require_metrics.push(RequiredMetric::Quality);
898 self
899 }
900
901 pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
903 self.require_metrics
904 .push(RequiredMetric::Dimension(dimension));
905 self
906 }
907
908 pub fn max_quality_score_drop(mut self, points: f64) -> Self {
911 self.max_quality_score_drop = Some(points);
912 self
913 }
914
915 pub fn max_dimension_score_drop(mut self, dimension: QualityDimension, points: f64) -> Self {
918 self.max_dimension_score_drops
919 .insert(dimension.to_string(), (dimension, points));
920 self
921 }
922
923 pub fn max_null_percentage_increase(mut self, column: impl Into<String>, points: f64) -> Self {
928 self.max_null_percentage_increase
929 .insert(column.into(), points);
930 self
931 }
932
933 pub fn max_null_percentage_increase_any(mut self, points: f64) -> Self {
939 self.max_null_percentage_increase_any = Some(points);
940 self
941 }
942
943 pub fn require_columns<I, S>(mut self, columns: I) -> Self
948 where
949 I: IntoIterator<Item = S>,
950 S: Into<String>,
951 {
952 self.require_columns
953 .extend(columns.into_iter().map(Into::into));
954 self
955 }
956
957 pub fn forbid_removed_columns(mut self) -> Self {
963 self.forbid_removed_columns = true;
964 self
965 }
966
967 pub fn forbid_added_columns(mut self) -> Self {
974 self.forbid_added_columns = true;
975 self
976 }
977
978 pub fn scope(mut self, scope: PolicyScope) -> Self {
981 self.scope = scope;
982 self
983 }
984
985 pub fn validate(&self) -> Result<(), PolicyError> {
990 if let Some(min) = self.min_quality_score {
991 check_percentage(CheckCode::MinQualityScore, None, min)?;
992 }
993 for (dimension, min) in self.min_dimension_scores.values() {
994 check_percentage(
995 CheckCode::MinDimensionScore,
996 Some(dimension.to_string()),
997 *min,
998 )?;
999 }
1000 for (column, max) in &self.max_null_percentage {
1001 check_percentage(CheckCode::MaxNullPercentage, Some(column.clone()), *max)?;
1002 }
1003 if let Some(max) = self.max_null_percentage_any {
1004 check_percentage(CheckCode::MaxNullPercentage, None, max)?;
1005 }
1006 if let Some(points) = self.max_quality_score_drop {
1007 check_percentage(CheckCode::MaxQualityScoreDrop, None, points)?;
1008 }
1009 for (dimension, points) in self.max_dimension_score_drops.values() {
1010 check_percentage(
1011 CheckCode::MaxDimensionScoreDrop,
1012 Some(dimension.to_string()),
1013 *points,
1014 )?;
1015 }
1016 for (column, points) in &self.max_null_percentage_increase {
1017 check_percentage(
1018 CheckCode::MaxNullPercentageIncrease,
1019 Some(column.clone()),
1020 *points,
1021 )?;
1022 }
1023 if let Some(points) = self.max_null_percentage_increase_any {
1024 check_percentage(CheckCode::MaxNullPercentageIncrease, None, points)?;
1025 }
1026 if self.is_empty() {
1027 return Err(PolicyError::NoRequirements);
1028 }
1029 Ok(())
1030 }
1031
1032 fn is_empty(&self) -> bool {
1033 self.min_rows.is_none()
1034 && self.min_quality_score.is_none()
1035 && self.min_dimension_scores.is_empty()
1036 && self.max_null_percentage.is_empty()
1037 && self.max_null_percentage_any.is_none()
1038 && self.max_null_count.is_empty()
1039 && self.max_null_count_any.is_none()
1040 && self.max_duplicate_rows.is_none()
1041 && self.require_metrics.is_empty()
1042 && self.require_columns.is_empty()
1043 && !self.has_change_rules()
1044 }
1045
1046 fn has_change_rules(&self) -> bool {
1048 self.max_quality_score_drop.is_some()
1049 || !self.max_dimension_score_drops.is_empty()
1050 || !self.max_null_percentage_increase.is_empty()
1051 || self.max_null_percentage_increase_any.is_some()
1052 || self.forbid_removed_columns
1053 || self.forbid_added_columns
1054 }
1055
1056 pub fn evaluate(&self, report: &ProfileReport) -> Result<GateResult, PolicyError> {
1064 self.validate()?;
1065 if self.has_change_rules() {
1066 return Err(PolicyError::BaselineRequired);
1067 }
1068 let checks = self.absolute_checks(report);
1069 Ok(GateResult {
1070 verdict: verdict_of(&checks),
1071 scope: self.scope,
1072 evidence: scan_evidence(&report.execution),
1073 checks,
1074 baseline: None,
1075 })
1076 }
1077
1078 pub fn evaluate_against(
1089 &self,
1090 report: &ProfileReport,
1091 baseline: &ReportSide,
1092 ) -> Result<GateResult, PolicyError> {
1093 self.validate()?;
1094 let current = ReportSide::from_report(report);
1095 let delta = ReportDelta::between_sides(baseline, ¤t);
1096 let identifiers = Sides {
1097 a: baseline.identifier_order(),
1098 b: current.identifier_order(),
1099 };
1100 let mut checks = self.absolute_checks(report);
1101 if let Some(points) = self.max_quality_score_drop {
1102 checks.push(self.score_drop_check(report, &delta, &identifiers, None, points));
1103 }
1104 for dimension in QualityDimension::all() {
1105 if let Some((_, points)) = self.max_dimension_score_drops.get(&dimension.to_string()) {
1106 checks.push(self.score_drop_check(
1107 report,
1108 &delta,
1109 &identifiers,
1110 Some(dimension),
1111 *points,
1112 ));
1113 }
1114 }
1115 checks.extend(self.null_increase_checks(&delta));
1116 let (baseline_names, current_names) = (column_names(baseline), column_names(¤t));
1119 if self.forbid_removed_columns {
1120 for column in baseline.columns() {
1121 checks.push(self.forbid_removed_check(
1122 baseline,
1123 ¤t,
1124 ¤t_names,
1125 &column.name,
1126 ));
1127 }
1128 }
1129 if self.forbid_added_columns {
1130 for column in current.columns() {
1131 checks.push(self.forbid_added_check(
1132 baseline,
1133 ¤t,
1134 &baseline_names,
1135 &column.name,
1136 ));
1137 }
1138 if self.scope == PolicyScope::FullSource
1143 && let Some(reason) = unenumerated_columns(¤t)
1144 {
1145 checks.push(self.unprofiled_columns_check(baseline, ¤t, reason));
1146 }
1147 }
1148 let diagnostics = schema_diagnostics(baseline, ¤t, &delta);
1149 Ok(GateResult {
1150 verdict: verdict_of(&checks),
1151 scope: self.scope,
1152 evidence: scan_evidence(&report.execution),
1153 checks,
1154 baseline: Some(BaselineComparison {
1155 reports: delta.reports,
1156 schema: delta.schema,
1157 diagnostics,
1158 }),
1159 })
1160 }
1161
1162 fn absolute_checks(&self, report: &ProfileReport) -> Vec<Check> {
1164 let scan = scan_evidence(&report.execution);
1165 let mut checks = Vec::new();
1166
1167 for metric in required_metrics_in_order(&self.require_metrics) {
1168 checks.push(self.require_metric_check(report, metric));
1169 }
1170 if !self.require_columns.is_empty() {
1171 let side = ReportSide::from_report(report);
1172 let names = column_names(&side);
1173 for column in &self.require_columns {
1174 checks.push(self.require_column_check(&side, &names, column));
1175 }
1176 }
1177 if let Some(min) = self.min_rows {
1178 checks.push(self.row_count_check(report, scan, min));
1179 }
1180 if let Some(min) = self.min_quality_score {
1181 checks.push(self.quality_score_check(report, scan, min));
1182 }
1183 for dimension in QualityDimension::all() {
1184 if let Some((_, min)) = self.min_dimension_scores.get(&dimension.to_string()) {
1185 checks.push(self.dimension_score_check(report, scan, dimension, *min));
1186 }
1187 }
1188 checks.extend(self.null_percentage_checks(report, scan));
1189 checks.extend(self.null_count_checks(report, scan));
1190 if let Some(max) = self.max_duplicate_rows {
1191 checks.push(self.duplicate_rows_check(report, scan, max));
1192 }
1193 checks
1194 }
1195
1196 fn decide_aggregate(&self, evidence: Evidence, satisfied: bool) -> CheckStatus {
1204 match (self.scope, evidence.gap()) {
1205 (PolicyScope::FullSource, Some(gap)) => {
1206 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1207 }
1208 _ if satisfied => CheckStatus::Passed,
1209 _ => CheckStatus::Failed,
1210 }
1211 }
1212
1213 fn require_metric_check(&self, report: &ProfileReport, metric: RequiredMetric) -> Check {
1214 let dimension = match metric {
1215 RequiredMetric::Quality => None,
1216 RequiredMetric::Dimension(dimension) => Some(dimension),
1217 };
1218 let analyzed = match (report.quality.as_ref(), dimension) {
1222 (None, _) => false,
1223 (Some(_), None) => true,
1224 (Some(quality), Some(dimension)) => {
1225 quality.metrics.dimension_score(dimension).is_some()
1226 }
1227 };
1228 let message = match (analyzed, dimension) {
1229 (true, None) => "quality was analyzed",
1230 (false, None) => "the report carries no quality assessment",
1231 (true, Some(_)) => "the required dimension was assessed",
1232 (false, Some(_)) => "the required dimension was not assessed",
1233 };
1234 Check {
1235 code: CheckCode::RequireMetric,
1236 column: None,
1237 dimension,
1238 expected: Expectation::Analyzed,
1239 observed: None,
1240 scope: self.scope,
1241 evidence: Evidence::Complete,
1242 bounds: None,
1243 baseline: None,
1244 status: if analyzed {
1245 CheckStatus::Passed
1246 } else {
1247 CheckStatus::Failed
1248 },
1249 message: message.to_string(),
1250 }
1251 }
1252
1253 fn row_count_check(&self, report: &ProfileReport, scan: Evidence, min: usize) -> Check {
1260 let observed = report.execution.rows_processed;
1261 let status = match (self.scope, scan.gap()) {
1262 _ if observed >= min => CheckStatus::Passed,
1263 (PolicyScope::FullSource, Some(gap)) => {
1264 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1265 }
1266 _ => CheckStatus::Failed,
1267 };
1268 let message = match status {
1269 CheckStatus::Passed => "the scan read at least the required number of rows",
1270 CheckStatus::Failed => "fewer rows were read than the required minimum",
1271 CheckStatus::NotEvaluated(_) => {
1272 "fewer rows were read than the required minimum, and the rows that were not \
1273 read may make up the difference"
1274 }
1275 };
1276 Check {
1277 code: CheckCode::MinRows,
1278 column: None,
1279 dimension: None,
1280 expected: Expectation::AtLeast {
1281 value: MetricValue::Count(min),
1282 },
1283 observed: Some(MetricValue::Count(observed)),
1284 scope: self.scope,
1285 evidence: scan,
1286 bounds: None,
1287 baseline: None,
1288 status,
1289 message: message.to_string(),
1290 }
1291 }
1292
1293 fn quality_score_check(&self, report: &ProfileReport, scan: Evidence, min: f64) -> Check {
1294 let mut check = Check {
1295 code: CheckCode::MinQualityScore,
1296 column: None,
1297 dimension: None,
1298 expected: Expectation::AtLeast {
1299 value: MetricValue::Percentage(min),
1300 },
1301 observed: None,
1302 scope: self.scope,
1303 evidence: scan.and(quality_evidence(
1304 report.quality.as_ref(),
1305 Provenance::Overall,
1306 )),
1307 bounds: None,
1308 baseline: None,
1309 status: CheckStatus::Passed,
1310 message: String::new(),
1311 };
1312 let Some(quality) = report.quality.as_ref() else {
1313 return unavailable_quality(check, report);
1314 };
1315 let Some(score) = quality.score() else {
1316 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1317 check.message =
1318 "no quality dimension had anything to assess, so there is no overall score"
1319 .to_string();
1320 return check;
1321 };
1322 check.observed = Some(MetricValue::Percentage(score));
1323 let bounds = quality
1324 .score_bounds()
1325 .and_then(|bounds| check_bounds(bounds, bounds.overall_score));
1326 self.decide_score(&mut check, score, min, bounds, "the overall quality score");
1327 check
1328 }
1329
1330 fn dimension_score_check(
1331 &self,
1332 report: &ProfileReport,
1333 scan: Evidence,
1334 dimension: QualityDimension,
1335 min: f64,
1336 ) -> Check {
1337 let mut check = Check {
1338 code: CheckCode::MinDimensionScore,
1339 column: None,
1340 dimension: Some(dimension),
1341 expected: Expectation::AtLeast {
1342 value: MetricValue::Percentage(min),
1343 },
1344 observed: None,
1345 scope: self.scope,
1346 evidence: scan.and(quality_evidence(
1347 report.quality.as_ref(),
1348 Provenance::Dimension(dimension),
1349 )),
1350 bounds: None,
1351 baseline: None,
1352 status: CheckStatus::Passed,
1353 message: String::new(),
1354 };
1355 let Some(quality) = report.quality.as_ref() else {
1356 return unavailable_quality(check, report);
1357 };
1358 let Some(score) = quality
1359 .scores()
1360 .dimension_scores
1361 .get(&dimension.to_string())
1362 .copied()
1363 .flatten()
1364 else {
1365 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1366 check.message = "this dimension had nothing to assess in this run".to_string();
1367 return check;
1368 };
1369 check.observed = Some(MetricValue::Percentage(score));
1370 let bounds = quality.score_bounds().and_then(|bounds| {
1371 check_bounds(
1372 bounds,
1373 bounds
1374 .dimension_scores
1375 .get(&dimension.to_string())
1376 .copied()
1377 .flatten(),
1378 )
1379 });
1380 self.decide_score(&mut check, score, min, bounds, "this dimension's score");
1381 check
1382 }
1383
1384 fn decide_score(
1394 &self,
1395 check: &mut Check,
1396 score: f64,
1397 min: f64,
1398 bounds: Option<CheckBounds>,
1399 subject: &str,
1400 ) {
1401 let sampled = check.evidence.gap() == Some(EvidenceGap::QualitySampled);
1402 let min = rounded_2(min);
1403 match bounds {
1404 Some(bounds) if self.scope == PolicyScope::FullSource && sampled => {
1405 check.bounds = Some(bounds);
1406 check.status = if rounded_2(bounds.lower) >= min {
1407 CheckStatus::Passed
1408 } else if rounded_2(bounds.upper) < min {
1409 CheckStatus::Failed
1410 } else {
1411 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete {
1412 gap: EvidenceGap::QualitySampled,
1413 })
1414 };
1415 check.message = bounded_message(&check.status, subject);
1416 }
1417 _ => {
1418 check.status = self.decide_aggregate(check.evidence, rounded_2(score) >= min);
1419 check.message = aggregate_message(&check.status, subject);
1420 }
1421 }
1422 }
1423
1424 fn null_percentage_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
1432 let mut checks = Vec::new();
1433 for (column, max) in &self.max_null_percentage {
1434 let profile = report
1435 .column_profiles
1436 .iter()
1437 .find(|candidate| &candidate.name == column);
1438 checks.push(self.null_percentage_check(column, profile, scan, *max));
1439 }
1440 if let Some(max) = self.max_null_percentage_any {
1441 for profile in &report.column_profiles {
1442 if !self.max_null_percentage.contains_key(&profile.name) {
1443 checks.push(self.null_percentage_check(
1444 &profile.name,
1445 Some(profile),
1446 scan,
1447 max,
1448 ));
1449 }
1450 }
1451 }
1452 checks
1453 }
1454
1455 fn null_percentage_check(
1456 &self,
1457 column: &str,
1458 profile: Option<&ColumnProfile>,
1459 scan: Evidence,
1460 max: f64,
1461 ) -> Check {
1462 let mut check = Check {
1465 code: CheckCode::MaxNullPercentage,
1466 column: Some(column.to_string()),
1467 dimension: None,
1468 expected: Expectation::AtMost {
1469 value: MetricValue::Percentage(max),
1470 },
1471 observed: None,
1472 scope: self.scope,
1473 evidence: scan,
1474 bounds: None,
1475 baseline: None,
1476 status: CheckStatus::Passed,
1477 message: String::new(),
1478 };
1479 let Some(profile) = profile else {
1480 check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
1481 check.message = "this column has no profile in the report".to_string();
1482 return check;
1483 };
1484 let Some(percentage) = null_percentage(profile) else {
1485 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1488 check.message = "no values were read for this column".to_string();
1489 return check;
1490 };
1491 check.observed = Some(MetricValue::Percentage(percentage));
1492 check.status =
1494 self.decide_aggregate(check.evidence, rounded_2(percentage) <= rounded_2(max));
1495 check.message = match check.status {
1496 CheckStatus::Passed => "this column's null percentage is within the allowance",
1497 CheckStatus::Failed => "this column's null percentage is above the allowance",
1498 CheckStatus::NotEvaluated(_) => {
1499 "the scan does not cover the whole source, and a null percentage over \
1500 part of it bounds nothing about the rest"
1501 }
1502 }
1503 .to_string();
1504 check
1505 }
1506
1507 fn null_count_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
1511 let mut checks = Vec::new();
1512 for (column, max) in &self.max_null_count {
1513 let profile = report
1514 .column_profiles
1515 .iter()
1516 .find(|candidate| &candidate.name == column);
1517 checks.push(self.null_count_check(column, profile, scan, *max));
1518 }
1519 if let Some(max) = self.max_null_count_any {
1520 for profile in &report.column_profiles {
1521 if !self.max_null_count.contains_key(&profile.name) {
1522 checks.push(self.null_count_check(&profile.name, Some(profile), scan, max));
1523 }
1524 }
1525 }
1526 checks
1527 }
1528
1529 fn null_count_check(
1535 &self,
1536 column: &str,
1537 profile: Option<&ColumnProfile>,
1538 scan: Evidence,
1539 max: usize,
1540 ) -> Check {
1541 let mut check = Check {
1542 code: CheckCode::MaxNullCount,
1543 column: Some(column.to_string()),
1544 dimension: None,
1545 expected: Expectation::AtMost {
1546 value: MetricValue::Count(max),
1547 },
1548 observed: None,
1549 scope: self.scope,
1550 evidence: scan,
1551 bounds: None,
1552 baseline: None,
1553 status: CheckStatus::Passed,
1554 message: String::new(),
1555 };
1556 let Some(profile) = profile else {
1557 check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
1558 check.message = "this column has no profile in the report".to_string();
1559 return check;
1560 };
1561 let exceeded = profile.null_count > max;
1562 check.observed = Some(MetricValue::Count(profile.null_count));
1563 check.status = match (self.scope, scan.gap()) {
1564 (PolicyScope::FullSource, Some(_)) if exceeded => CheckStatus::Failed,
1565 (PolicyScope::FullSource, Some(gap)) => {
1566 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1567 }
1568 _ if exceeded => CheckStatus::Failed,
1569 _ => CheckStatus::Passed,
1570 };
1571 check.message = match check.status {
1572 CheckStatus::Passed => "this column's null count is within the allowance",
1573 CheckStatus::Failed => "nulls were observed in this column above the allowance",
1574 CheckStatus::NotEvaluated(_) => {
1575 "no nulls above the allowance were observed in this column, and the rows \
1576 that were not read may hold more"
1577 }
1578 }
1579 .to_string();
1580 check
1581 }
1582
1583 fn duplicate_rows_check(&self, report: &ProfileReport, scan: Evidence, max: usize) -> Check {
1590 let mut check = Check {
1591 code: CheckCode::MaxDuplicateRows,
1592 column: None,
1593 dimension: Some(QualityDimension::Uniqueness),
1594 expected: Expectation::AtMost {
1595 value: MetricValue::Count(max),
1596 },
1597 observed: None,
1598 scope: self.scope,
1599 evidence: scan.and(quality_evidence(
1600 report.quality.as_ref(),
1601 Provenance::Component("duplicate_rows"),
1602 )),
1603 bounds: None,
1604 baseline: None,
1605 status: CheckStatus::Passed,
1606 message: String::new(),
1607 };
1608 let Some(quality) = report.quality.as_ref() else {
1609 return unavailable_quality(check, report);
1610 };
1611 let Some(uniqueness) = quality
1612 .metrics
1613 .uniqueness
1614 .as_ref()
1615 .filter(|metrics| metrics.rows_checked > 0)
1616 else {
1617 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1618 check.message = "no rows were scanned for duplicates in this run".to_string();
1619 return check;
1620 };
1621 let observed = uniqueness.duplicate_rows;
1622 let exceeded = observed > max;
1623 let estimated = uniqueness.duplicate_rows_approximate;
1624 check.observed = Some(MetricValue::Count(observed));
1625 check.status = match (self.scope, check.evidence.gap()) {
1626 (PolicyScope::FullSource, Some(_)) if exceeded && !estimated => CheckStatus::Failed,
1628 (PolicyScope::FullSource, Some(gap)) => {
1629 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1630 }
1631 _ if exceeded => CheckStatus::Failed,
1632 _ => CheckStatus::Passed,
1633 };
1634 check.message = match check.status {
1635 CheckStatus::Passed => "the duplicate-row count is within the allowance",
1636 CheckStatus::Failed if estimated => {
1637 "the estimated duplicate-row count is above the allowance"
1638 }
1639 CheckStatus::Failed => "duplicate rows were observed above the allowance",
1640 CheckStatus::NotEvaluated(_) if estimated => {
1643 "the duplicate-row count is an estimate, which can neither witness \
1644 duplicates above the allowance nor rule them out"
1645 }
1646 CheckStatus::NotEvaluated(_) => {
1647 "no duplicate above the allowance was observed, and the rows that were not \
1648 read may hold more"
1649 }
1650 }
1651 .to_string();
1652 check
1653 }
1654
1655 fn score_drop_check(
1659 &self,
1660 report: &ProfileReport,
1661 delta: &ReportDelta,
1662 identifiers: &Sides<Option<&[String]>>,
1663 dimension: Option<QualityDimension>,
1664 points: f64,
1665 ) -> Check {
1666 let (code, change, subject) = match dimension {
1667 None => (
1668 CheckCode::MaxQualityScoreDrop,
1669 &delta.quality_score,
1670 "the overall quality score",
1671 ),
1672 Some(dimension) => (
1673 CheckCode::MaxDimensionScoreDrop,
1674 &delta
1675 .dimensions
1676 .iter()
1677 .find(|change| change.dimension == dimension)
1678 .expect("a delta lists every dimension this build knows")
1679 .score,
1680 "this dimension's score",
1681 ),
1682 };
1683 let mut check = Check {
1684 code,
1685 column: None,
1686 dimension,
1687 expected: Expectation::ChangeAtLeast {
1690 value: MetricValue::Percentage(0.0 - points),
1691 },
1692 observed: change.b.map(MetricValue::Percentage),
1693 scope: self.scope,
1694 evidence: change.evidence.b,
1695 bounds: None,
1696 baseline: Some(baseline_reading(change.a, change.evidence.a, change.abs)),
1697 status: CheckStatus::Passed,
1698 message: String::new(),
1699 };
1700 if report.quality.is_none() {
1701 return unavailable_quality(check, report);
1702 }
1703 let schema_changed = !delta.schema.added.is_empty() || !delta.schema.removed.is_empty();
1704 let dimensions_changed = dimension.is_none()
1708 && delta
1709 .dimensions
1710 .iter()
1711 .any(|change| change.score.a.is_some() != change.score.b.is_some());
1712 let reason = if change.b.is_none() {
1713 Some(NotEvaluated::NotAssessed)
1714 } else if change.a.is_none() {
1715 Some(NotEvaluated::NotInBaseline)
1716 } else if schema_changed {
1717 Some(NotEvaluated::SchemaChanged)
1718 } else if dimensions_changed {
1719 Some(NotEvaluated::DimensionsChanged)
1720 } else {
1721 definition_gap(
1722 delta.metric_semantics.a.as_ref(),
1723 delta.metric_semantics.b.as_ref(),
1724 score_definitions(dimension),
1725 )
1726 .or_else(|| setting_gap(&delta.configuration, identifiers, dimension))
1727 };
1728 if let Some(reason) = reason {
1729 return not_evaluated_change(check, reason, subject);
1730 }
1731 let change_points = change
1732 .abs
1733 .expect("a delta has a change whenever both sides have a score");
1734 check.status =
1737 self.decide_change(&change.evidence, change_points >= 0.0 - rounded_2(points));
1738 check.message = match check.status {
1739 CheckStatus::Passed => {
1740 format!("{subject} dropped no more than allowed from the baseline")
1741 }
1742 CheckStatus::Failed => format!("{subject} dropped more than allowed from the baseline"),
1743 CheckStatus::NotEvaluated(_) => evidence_message(subject),
1744 };
1745 check
1746 }
1747
1748 fn null_increase_checks(&self, delta: &ReportDelta) -> Vec<Check> {
1752 let index = ColumnIndex::new(delta);
1753 let mut checks = Vec::new();
1754 for (column, points) in &self.max_null_percentage_increase {
1755 checks.push(self.null_increase_check(delta, &index, column, *points));
1756 }
1757 if let Some(points) = self.max_null_percentage_increase_any {
1758 for column in &delta.schema.common {
1759 if !self.max_null_percentage_increase.contains_key(column) {
1760 checks.push(self.null_increase_check(delta, &index, column, points));
1761 }
1762 }
1763 }
1764 checks
1765 }
1766
1767 fn null_increase_check(
1773 &self,
1774 delta: &ReportDelta,
1775 index: &ColumnIndex<'_>,
1776 column: &str,
1777 points: f64,
1778 ) -> Check {
1779 let change = index.changes.get(column).copied();
1780 let (null_pct_a, null_pct_b, null_pct_delta) = change.map_or(
1781 (None, None, None),
1782 |ColumnChange {
1783 null_pct_a,
1784 null_pct_b,
1785 null_pct_delta,
1786 ..
1787 }| (*null_pct_a, *null_pct_b, *null_pct_delta),
1788 );
1789 let in_report = index.in_report.contains(column);
1790 let in_baseline = index.in_baseline.contains(column);
1791 let evidence = Sides {
1792 a: delta.reports.a.evidence,
1793 b: delta.reports.b.evidence,
1794 };
1795 let mut check = Check {
1796 code: CheckCode::MaxNullPercentageIncrease,
1797 column: Some(column.to_string()),
1798 dimension: None,
1799 expected: Expectation::ChangeAtMost {
1800 value: MetricValue::Percentage(points),
1801 },
1802 observed: null_pct_b.map(MetricValue::Percentage),
1803 scope: self.scope,
1804 evidence: evidence.b,
1805 bounds: None,
1806 baseline: Some(baseline_reading(null_pct_a, evidence.a, null_pct_delta)),
1807 status: CheckStatus::Passed,
1808 message: String::new(),
1809 };
1810 let reason = if !in_report {
1811 Some(NotEvaluated::ColumnNotProfiled)
1812 } else if !in_baseline {
1813 Some(NotEvaluated::NotInBaseline)
1814 } else if null_pct_b.is_none() {
1815 Some(NotEvaluated::NotAssessed)
1816 } else if null_pct_a.is_none() {
1817 Some(NotEvaluated::NotInBaseline)
1818 } else {
1819 definition_gap(
1820 delta.metric_semantics.a.as_ref(),
1821 delta.metric_semantics.b.as_ref(),
1822 &[Definition::NullTokens],
1823 )
1824 };
1825 let subject = "this column's null percentage";
1826 if let Some(reason) = reason {
1827 return not_evaluated_change(check, reason, subject);
1828 }
1829 let change_points =
1830 null_pct_delta.expect("a delta has a change whenever both sides have a value");
1831 check.status = self.decide_change(&evidence, change_points <= rounded_2(points));
1832 check.message = match check.status {
1833 CheckStatus::Passed => {
1834 format!("{subject} rose no more than allowed above the baseline")
1835 }
1836 CheckStatus::Failed => format!("{subject} rose more than allowed above the baseline"),
1837 CheckStatus::NotEvaluated(_) => evidence_message(subject),
1838 };
1839 check
1840 }
1841
1842 fn absence_status(
1849 &self,
1850 lacking: &ReportSide,
1851 column: &str,
1852 unselected: NotEvaluated,
1853 ) -> CheckStatus {
1854 match lacking.column_selection() {
1855 None => CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded {
1856 setting: "columns".to_string(),
1857 }),
1858 Some(selection) if !selection.includes(column) => CheckStatus::NotEvaluated(unselected),
1859 Some(_) => match (self.scope, lacking.schema_evidence().gap()) {
1860 (PolicyScope::FullSource, Some(gap)) => {
1861 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1862 }
1863 _ => CheckStatus::Failed,
1864 },
1865 }
1866 }
1867
1868 fn require_column_check(
1869 &self,
1870 current: &ReportSide,
1871 current_names: &HashSet<&str>,
1872 column: &str,
1873 ) -> Check {
1874 let status = if current_names.contains(column) {
1875 CheckStatus::Passed
1876 } else {
1877 self.absence_status(current, column, NotEvaluated::ColumnNotProfiled)
1878 };
1879 let message = match &status {
1880 CheckStatus::Passed => "the required column was profiled",
1881 CheckStatus::Failed => "the required column is not in the source the report read",
1882 CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled) => {
1883 "the run's column selection left the required column out"
1884 }
1885 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1886 "the report does not record which columns it selected, so a missing column \
1887 cannot be shown to be absent from the source"
1888 }
1889 CheckStatus::NotEvaluated(_) => {
1890 "the required column is not in the records read, and the records that were not \
1891 read may hold it"
1892 }
1893 };
1894 self.column_check(
1895 CheckCode::RequireColumn,
1896 column,
1897 Expectation::Present,
1898 current,
1899 None,
1900 status,
1901 message,
1902 )
1903 }
1904
1905 fn forbid_removed_check(
1906 &self,
1907 baseline: &ReportSide,
1908 current: &ReportSide,
1909 current_names: &HashSet<&str>,
1910 column: &str,
1911 ) -> Check {
1912 let status = if current_names.contains(column) {
1913 CheckStatus::Passed
1914 } else {
1915 self.absence_status(
1916 current,
1917 column,
1918 NotEvaluated::SettingChanged {
1919 setting: "columns".to_string(),
1920 },
1921 )
1922 };
1923 let message = match &status {
1924 CheckStatus::Passed => "the column the baseline profiled is still profiled",
1925 CheckStatus::Failed => {
1926 "a column the baseline profiled is not in the source the report read"
1927 }
1928 CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1929 "the report's column selection left out a column the baseline profiled"
1930 }
1931 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1932 "the report does not record which columns it selected, so a column the baseline \
1933 profiled cannot be shown to be absent from its source"
1934 }
1935 CheckStatus::NotEvaluated(_) => {
1936 "a column the baseline profiled is not in the records the report read, and the \
1937 records that were not read may hold it"
1938 }
1939 };
1940 self.column_check(
1941 CheckCode::ForbidRemovedColumn,
1942 column,
1943 Expectation::Present,
1944 current,
1945 Some(baseline),
1946 status,
1947 message,
1948 )
1949 }
1950
1951 fn forbid_added_check(
1952 &self,
1953 baseline: &ReportSide,
1954 current: &ReportSide,
1955 baseline_names: &HashSet<&str>,
1956 column: &str,
1957 ) -> Check {
1958 let status = if baseline_names.contains(column) {
1959 CheckStatus::Passed
1960 } else {
1961 self.absence_status(
1962 baseline,
1963 column,
1964 NotEvaluated::SettingChanged {
1965 setting: "columns".to_string(),
1966 },
1967 )
1968 };
1969 let message = match &status {
1970 CheckStatus::Passed => "the column is one the baseline profiled",
1971 CheckStatus::Failed => {
1972 "the report profiled a column the baseline's source did not have"
1973 }
1974 CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1975 "the baseline's column selection left this column out"
1976 }
1977 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1978 "the baseline does not record which columns it selected, so this column cannot be \
1979 shown to be new"
1980 }
1981 CheckStatus::NotEvaluated(_) => {
1982 "this column is not in the records the baseline read, and the records it did not \
1983 read may hold it"
1984 }
1985 };
1986 self.column_check(
1987 CheckCode::ForbidAddedColumn,
1988 column,
1989 Expectation::InBaseline,
1990 current,
1991 Some(baseline),
1992 status,
1993 message,
1994 )
1995 }
1996
1997 fn unprofiled_columns_check(
2000 &self,
2001 baseline: &ReportSide,
2002 current: &ReportSide,
2003 reason: NotEvaluated,
2004 ) -> Check {
2005 let message = match &reason {
2006 NotEvaluated::ColumnNotProfiled => {
2007 "the run profiled a column selection, which may have left out a source column \
2008 the baseline lacked"
2009 }
2010 NotEvaluated::SettingUnrecorded { .. } => {
2011 "the report does not record which columns it selected, so its columns may not \
2012 be all of its source's"
2013 }
2014 _ => {
2015 "the report read part of its source, and the records it did not read may hold \
2016 a column the baseline lacked"
2017 }
2018 };
2019 Check {
2020 column: None,
2021 ..self.column_check(
2022 CheckCode::ForbidAddedColumn,
2023 "",
2024 Expectation::InBaseline,
2025 current,
2026 Some(baseline),
2027 CheckStatus::NotEvaluated(reason),
2028 message,
2029 )
2030 }
2031 }
2032
2033 #[allow(clippy::too_many_arguments)]
2036 fn column_check(
2037 &self,
2038 code: CheckCode,
2039 column: &str,
2040 expected: Expectation,
2041 current: &ReportSide,
2042 baseline: Option<&ReportSide>,
2043 status: CheckStatus,
2044 message: &str,
2045 ) -> Check {
2046 Check {
2047 code,
2048 column: Some(column.to_string()),
2049 dimension: None,
2050 expected,
2051 observed: None,
2052 scope: self.scope,
2053 evidence: current.schema_evidence(),
2054 bounds: None,
2055 baseline: baseline.map(|baseline| BaselineReading {
2056 observed: None,
2057 evidence: baseline.schema_evidence(),
2058 change: None,
2059 }),
2060 status,
2061 message: message.to_string(),
2062 }
2063 }
2064
2065 fn decide_change(&self, evidence: &Sides<Evidence>, satisfied: bool) -> CheckStatus {
2070 match (self.scope, evidence.b.gap().or(evidence.a.gap())) {
2071 (PolicyScope::FullSource, Some(gap)) => {
2072 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
2073 }
2074 _ if satisfied => CheckStatus::Passed,
2075 _ => CheckStatus::Failed,
2076 }
2077 }
2078}
2079
2080fn schema_diagnostics(
2087 baseline: &ReportSide,
2088 current: &ReportSide,
2089 delta: &ReportDelta,
2090) -> Vec<Diagnostic> {
2091 let diagnostic = |code, column: &str, data_type, message: &str| Diagnostic {
2092 code,
2093 column: column.to_string(),
2094 data_type,
2095 setting: None,
2096 message: message.to_string(),
2097 };
2098 let identified = |side: &ReportSide, column: &str| {
2101 side.identifier_order()
2102 .map(|names| names.iter().any(|name| name == column))
2103 };
2104 fn names(names: &[String]) -> HashSet<&str> {
2106 names.iter().map(String::as_str).collect()
2107 }
2108 fn by_name(side: &ReportSide) -> HashMap<&str, &ColumnSide> {
2109 side.columns()
2110 .iter()
2111 .map(|column| (column.name.as_str(), column))
2112 .collect()
2113 }
2114 let (added, removed) = (names(&delta.schema.added), names(&delta.schema.removed));
2115 let (baseline_columns, current_columns) = (by_name(baseline), by_name(current));
2116 delta
2117 .columns
2118 .iter()
2119 .filter_map(|change| {
2120 let column = change.name.as_str();
2121 let unselected = |side: &ReportSide| match side.column_selection() {
2125 Some(selection) if selection.includes(column) => None,
2126 Some(_) => Some(SettingDifference::SettingChanged {
2127 setting: "columns".to_string(),
2128 }),
2129 None => Some(SettingDifference::SettingUnrecorded {
2130 setting: "columns".to_string(),
2131 }),
2132 };
2133 if added.contains(column) {
2134 let setting = unselected(baseline);
2135 let message = match setting {
2136 None => {
2137 "the report profiled a column the baseline did not find in its source, \
2138 so the baseline has nothing to measure its change from"
2139 }
2140 Some(_) => {
2141 "the report profiled a column the baseline has no profile for, and the \
2142 baseline's column selection is not shown to include it"
2143 }
2144 };
2145 return Some(Diagnostic {
2146 setting,
2147 ..diagnostic(DiagnosticCode::ColumnAdded, column, None, message)
2148 });
2149 }
2150 if removed.contains(column) {
2151 let setting = unselected(current);
2152 let message = match setting {
2153 None => "the baseline profiled a column the report did not find in its source",
2154 Some(_) => {
2155 "the baseline profiled a column the report has no profile for, and the \
2156 report's column selection is not shown to include it"
2157 }
2158 };
2159 return Some(Diagnostic {
2160 setting,
2161 ..diagnostic(DiagnosticCode::ColumnRemoved, column, None, message)
2162 });
2163 }
2164 let (a, b) = (
2165 *baseline_columns.get(column)?,
2166 *current_columns.get(column)?,
2167 );
2168 if a.populated()? && b.all_null()? {
2169 return Some(diagnostic(
2170 DiagnosticCode::ColumnAllNull,
2171 column,
2172 None,
2173 "this column held values in the baseline, and every value read for it in \
2174 the report is null",
2175 ));
2176 }
2177 match (&a.data_type, &b.data_type) {
2178 (Some(type_a), Some(type_b))
2179 if type_a != type_b && a.populated()? && b.populated()? =>
2180 {
2181 let mut retyped = diagnostic(
2182 DiagnosticCode::ColumnTypeChanged,
2183 column,
2184 Some(Sides {
2185 a: type_a.clone(),
2186 b: type_b.clone(),
2187 }),
2188 "this column holds values on both sides, and its inferred type differs",
2189 );
2190 if *type_a == DataType::Identifier || *type_b == DataType::Identifier {
2194 let setting = "identifier_columns".to_string();
2195 retyped.setting =
2196 match (identified(baseline, column), identified(current, column)) {
2197 (Some(a), Some(b)) if a == b => None,
2198 (Some(_), Some(_)) => {
2199 Some(SettingDifference::SettingChanged { setting })
2200 }
2201 _ => Some(SettingDifference::SettingUnrecorded { setting }),
2202 };
2203 if retyped.setting.is_some() {
2204 retyped.message = "this column holds values on both sides, and its \
2205 inferred type differs; the identifier hint, \
2206 which sets the type, is not shown to be the \
2207 same on both sides"
2208 .to_string();
2209 }
2210 }
2211 Some(retyped)
2212 }
2213 _ => None,
2214 }
2215 })
2216 .collect()
2217}
2218
2219struct ColumnIndex<'a> {
2222 changes: HashMap<&'a str, &'a ColumnChange>,
2223 in_report: HashSet<&'a str>,
2225 in_baseline: HashSet<&'a str>,
2227}
2228
2229impl<'a> ColumnIndex<'a> {
2230 fn new(delta: &'a ReportDelta) -> Self {
2231 let schema = &delta.schema;
2232 let names = |lists: [&'a [String]; 2]| {
2233 lists
2234 .into_iter()
2235 .flatten()
2236 .map(String::as_str)
2237 .collect::<HashSet<_>>()
2238 };
2239 Self {
2240 changes: delta
2241 .columns
2242 .iter()
2243 .map(|change| (change.name.as_str(), change))
2244 .collect(),
2245 in_report: names([&schema.common, &schema.added]),
2246 in_baseline: names([&schema.common, &schema.removed]),
2247 }
2248 }
2249}
2250
2251fn unenumerated_columns(side: &ReportSide) -> Option<NotEvaluated> {
2256 match side.column_selection() {
2257 None => Some(NotEvaluated::SettingUnrecorded {
2258 setting: "columns".to_string(),
2259 }),
2260 Some(ColumnSelection::All) => side
2261 .schema_evidence()
2262 .gap()
2263 .map(|gap| NotEvaluated::EvidenceIncomplete { gap }),
2264 Some(_) => Some(NotEvaluated::ColumnNotProfiled),
2265 }
2266}
2267
2268fn column_names(side: &ReportSide) -> HashSet<&str> {
2269 side.columns()
2270 .iter()
2271 .map(|column| column.name.as_str())
2272 .collect()
2273}
2274
2275fn baseline_reading(
2276 observed: Option<f64>,
2277 evidence: Evidence,
2278 change: Option<f64>,
2279) -> BaselineReading {
2280 BaselineReading {
2281 observed: observed.map(MetricValue::Percentage),
2282 evidence,
2283 change: change.map(MetricValue::Percentage),
2284 }
2285}
2286
2287fn not_evaluated_change(mut check: Check, reason: NotEvaluated, subject: &str) -> Check {
2289 check.message = match &reason {
2290 NotEvaluated::NotAssessed if check.column.is_some() => {
2291 "no values were read for this column".to_string()
2292 }
2293 NotEvaluated::NotAssessed => format!("{subject} was not assessed in this report"),
2294 NotEvaluated::ColumnNotProfiled => "this column has no profile in the report".to_string(),
2295 NotEvaluated::NotInBaseline => format!("the baseline carries no value for {subject}"),
2296 NotEvaluated::SchemaChanged => format!(
2297 "the two reports profiled different columns, so {subject} has a different subject \
2298 on each side"
2299 ),
2300 NotEvaluated::DimensionsChanged => {
2301 format!("{subject} averages a different set of assessed dimensions on each side")
2302 }
2303 NotEvaluated::DefinitionNotComparable { .. } => format!(
2304 "a measurement definition {subject} depends on is not recorded identically in both \
2305 reports"
2306 ),
2307 NotEvaluated::SettingChanged { .. } => format!(
2308 "a setting that moves {subject} on unchanged data differs between the two reports"
2309 ),
2310 NotEvaluated::SettingUnrecorded { .. } => format!(
2311 "a setting that moves {subject} on unchanged data is not recorded in both reports"
2312 ),
2313 NotEvaluated::QualityUnavailable { .. } => {
2314 "the report carries no quality assessment".to_string()
2315 }
2316 NotEvaluated::EvidenceIncomplete { .. } => evidence_message(subject),
2317 };
2318 check.status = CheckStatus::NotEvaluated(reason);
2319 check
2320}
2321
2322fn evidence_message(subject: &str) -> String {
2323 format!(
2324 "{subject} was computed over part of the source on at least one side, which bounds \
2325 nothing about the rest"
2326 )
2327}
2328
2329#[derive(Debug, Clone, Copy)]
2331enum Definition {
2332 NullTokens,
2335 PatternMatchCount,
2337 RangeRuleCount,
2339 KeyUniquenessUnits,
2341 OutlierUnits,
2343 KeyColumnSource,
2345}
2346
2347impl Definition {
2348 fn key(self) -> &'static str {
2349 match self {
2350 Self::NullTokens => "null_tokens",
2351 Self::PatternMatchCount => "pattern_match_count",
2352 Self::RangeRuleCount => "range_rule_count",
2353 Self::KeyUniquenessUnits => "key_uniqueness_units",
2354 Self::OutlierUnits => "outlier_units",
2355 Self::KeyColumnSource => "key_column_source",
2356 }
2357 }
2358
2359 fn same(self, a: &MetricSemantics, b: &MetricSemantics) -> bool {
2361 fn agree<T: PartialEq>(a: &Option<T>, b: &Option<T>) -> bool {
2362 a.is_some() && a == b
2363 }
2364 match self {
2365 Self::NullTokens => agree(&a.null_tokens, &b.null_tokens),
2366 Self::PatternMatchCount => agree(&a.pattern_match_count, &b.pattern_match_count),
2367 Self::RangeRuleCount => agree(&a.range_rule_count, &b.range_rule_count),
2368 Self::KeyUniquenessUnits => agree(&a.key_uniqueness_units, &b.key_uniqueness_units),
2369 Self::OutlierUnits => agree(&a.outlier_units, &b.outlier_units),
2370 Self::KeyColumnSource => agree(&a.key_column_source, &b.key_column_source),
2371 }
2372 }
2373}
2374
2375fn score_definitions(dimension: Option<QualityDimension>) -> &'static [Definition] {
2378 match dimension {
2379 None => &[
2380 Definition::NullTokens,
2381 Definition::PatternMatchCount,
2382 Definition::RangeRuleCount,
2383 Definition::KeyUniquenessUnits,
2384 Definition::OutlierUnits,
2385 Definition::KeyColumnSource,
2386 ],
2387 Some(QualityDimension::Validity) => {
2388 &[Definition::NullTokens, Definition::PatternMatchCount]
2389 }
2390 Some(QualityDimension::Accuracy) => &[
2391 Definition::NullTokens,
2392 Definition::RangeRuleCount,
2393 Definition::OutlierUnits,
2394 ],
2395 Some(QualityDimension::Uniqueness) => &[
2396 Definition::NullTokens,
2397 Definition::KeyUniquenessUnits,
2398 Definition::KeyColumnSource,
2399 ],
2400 Some(_) => &[Definition::NullTokens],
2401 }
2402}
2403
2404fn definition_gap(
2406 a: Option<&MetricSemantics>,
2407 b: Option<&MetricSemantics>,
2408 definitions: &[Definition],
2409) -> Option<NotEvaluated> {
2410 definitions
2411 .iter()
2412 .find(|definition| !a.zip(b).is_some_and(|(a, b)| definition.same(a, b)))
2413 .map(|definition| NotEvaluated::DefinitionNotComparable {
2414 definition: definition.key().to_string(),
2415 })
2416}
2417
2418type HintColumns = fn(&RecordedHints) -> &Vec<String>;
2420
2421#[derive(Debug, Clone, Copy)]
2423enum Agreement {
2424 Same,
2425 Changed,
2426 Unrecorded,
2427}
2428
2429fn agreement<T: PartialEq + ?Sized>(a: Option<&T>, b: Option<&T>) -> Agreement {
2430 match (a, b) {
2431 (Some(a), Some(b)) if a == b => Agreement::Same,
2432 (Some(_), Some(_)) => Agreement::Changed,
2433 _ => Agreement::Unrecorded,
2434 }
2435}
2436
2437fn setting_gap(
2444 configuration: &ConfigurationComparison,
2445 identifiers: &Sides<Option<&[String]>>,
2446 dimension: Option<QualityDimension>,
2447) -> Option<NotEvaluated> {
2448 let hints = &configuration.semantic_hints;
2449 let reads = |dimensions: &[QualityDimension]| {
2450 dimension.is_none_or(|dimension| dimensions.contains(&dimension))
2451 };
2452 let hint = |columns: HintColumns| {
2453 agreement(hints.a.as_ref().map(columns), hints.b.as_ref().map(columns))
2454 };
2455 let recorded = |same: Option<bool>| match same {
2456 Some(true) => Agreement::Same,
2457 Some(false) => Agreement::Changed,
2458 None => Agreement::Unrecorded,
2459 };
2460 let pairs = match hint(|hints| &hints.temporal_pair_columns) {
2463 Agreement::Same
2464 if hints
2465 .a
2466 .as_ref()
2467 .is_some_and(|hints| !hints.temporal_pair_columns.is_empty()) =>
2468 {
2469 Agreement::Unrecorded
2470 }
2471 other => other,
2472 };
2473 let settings = [
2474 (
2475 "score_weights",
2476 dimension.is_none(),
2477 recorded(configuration.score_weights.same),
2478 ),
2479 (
2480 "identifier_columns",
2481 dimension != Some(QualityDimension::Completeness),
2482 agreement(identifiers.a, identifiers.b),
2484 ),
2485 (
2486 "positive_columns",
2487 reads(&[QualityDimension::Accuracy]),
2488 hint(|hints| &hints.positive_columns),
2489 ),
2490 (
2491 "temporal_columns",
2492 reads(&[QualityDimension::Timeliness]),
2493 hint(|hints| &hints.temporal_columns),
2494 ),
2495 (
2496 "temporal_pairs",
2497 reads(&[QualityDimension::Timeliness]),
2498 pairs,
2499 ),
2500 (
2501 "pattern_locale",
2502 reads(&[QualityDimension::Validity]),
2503 recorded(configuration.pattern_locale.same),
2504 ),
2505 ];
2506 settings
2507 .into_iter()
2508 .filter(|(_, read, _)| *read)
2509 .find_map(|(setting, _, agreement)| match agreement {
2510 Agreement::Same => None,
2511 Agreement::Changed => Some(NotEvaluated::SettingChanged {
2512 setting: setting.to_string(),
2513 }),
2514 Agreement::Unrecorded => Some(NotEvaluated::SettingUnrecorded {
2515 setting: setting.to_string(),
2516 }),
2517 })
2518}
2519
2520fn check_percentage(
2521 code: CheckCode,
2522 subject: Option<String>,
2523 value: f64,
2524) -> Result<(), PolicyError> {
2525 if value.is_finite() && (0.0..=PERCENTAGE_MAX).contains(&value) {
2526 return Ok(());
2527 }
2528 Err(PolicyError::ThresholdOutOfRange {
2529 code,
2530 subject,
2531 value,
2532 })
2533}
2534
2535fn required_metrics_in_order(requested: &[RequiredMetric]) -> Vec<RequiredMetric> {
2537 let mut ordered = Vec::new();
2538 if requested.contains(&RequiredMetric::Quality) {
2539 ordered.push(RequiredMetric::Quality);
2540 }
2541 for dimension in QualityDimension::all() {
2542 if requested.contains(&RequiredMetric::Dimension(dimension)) {
2543 ordered.push(RequiredMetric::Dimension(dimension));
2544 }
2545 }
2546 ordered
2547}
2548
2549fn verdict_of(checks: &[Check]) -> Verdict {
2550 if checks.iter().any(Check::is_violation) {
2551 Verdict::Fail
2552 } else if checks.iter().any(|check| check.not_evaluated().is_some()) {
2553 Verdict::Inconclusive
2554 } else {
2555 Verdict::Pass
2556 }
2557}
2558
2559fn unavailable_quality(mut check: Check, report: &ProfileReport) -> Check {
2563 let quality_status = quality_status_name(&report.quality_status).to_string();
2564 check.message = match &report.quality_status {
2565 QualityAnalysisStatus::NotRequested => {
2566 "quality metrics were not requested for this run".to_string()
2567 }
2568 QualityAnalysisStatus::NoData => {
2569 "quality was requested but no sample was available to measure".to_string()
2570 }
2571 QualityAnalysisStatus::WithheldByProjection => {
2572 "quality was withheld: the requested dimensions measure whole rows and only \
2573 some columns were profiled"
2574 .to_string()
2575 }
2576 QualityAnalysisStatus::Failed { .. } => "the quality computation failed".to_string(),
2577 QualityAnalysisStatus::Unrecorded | QualityAnalysisStatus::Computed => {
2578 "the report carries no quality assessment".to_string()
2579 }
2580 };
2581 check.status = CheckStatus::NotEvaluated(NotEvaluated::QualityUnavailable { quality_status });
2582 check
2583}
2584
2585fn aggregate_message(status: &CheckStatus, subject: &str) -> String {
2586 match status {
2587 CheckStatus::Passed => format!("{subject} meets the required minimum"),
2588 CheckStatus::Failed => format!("{subject} is below the required minimum"),
2589 CheckStatus::NotEvaluated(_) => format!(
2590 "{subject} was computed over part of the source, which bounds nothing about \
2591 the rest"
2592 ),
2593 }
2594}
2595
2596fn bounded_message(status: &CheckStatus, subject: &str) -> String {
2597 match status {
2598 CheckStatus::Passed => format!(
2599 "{subject} was computed over a sample, and its whole-source interval meets the \
2600 required minimum"
2601 ),
2602 CheckStatus::Failed => format!(
2603 "{subject} was computed over a sample, and its whole-source interval is below the \
2604 required minimum"
2605 ),
2606 CheckStatus::NotEvaluated(_) => format!(
2607 "{subject} was computed over a sample, and the required minimum lies within its \
2608 whole-source interval"
2609 ),
2610 }
2611}
2612
2613fn check_bounds(bounds: &ScoreBounds, interval: Option<ScoreInterval>) -> Option<CheckBounds> {
2615 interval.map(|interval| CheckBounds {
2616 lower: interval.lower,
2617 upper: interval.upper,
2618 confidence_level: bounds.confidence_level,
2619 })
2620}
2621
2622pub(crate) fn quality_status_name(status: &QualityAnalysisStatus) -> &'static str {
2624 match status {
2625 QualityAnalysisStatus::Computed => "computed",
2626 QualityAnalysisStatus::NotRequested => "not_requested",
2627 QualityAnalysisStatus::NoData => "no_data",
2628 QualityAnalysisStatus::WithheldByProjection => "withheld_by_projection",
2629 QualityAnalysisStatus::Failed { .. } => "failed",
2630 QualityAnalysisStatus::Unrecorded => "unrecorded",
2631 }
2632}
2633
2634fn null_percentage(profile: &ColumnProfile) -> Option<f64> {
2637 (profile.total_count > 0)
2638 .then(|| profile.null_count as f64 / profile.total_count as f64 * PERCENTAGE_MAX)
2639}
2640
2641pub(crate) fn scan_evidence(execution: &ExecutionMetadata) -> Evidence {
2646 scan_coverage(
2647 !execution.source_exhausted || execution.truncation_reason.is_some(),
2648 execution.sampling_applied,
2649 execution.error_count > 0,
2650 )
2651}
2652
2653pub(crate) fn scan_coverage(truncated: bool, sampled: bool, records_skipped: bool) -> Evidence {
2656 let reason = if truncated {
2657 Some(EvidenceGap::Truncated)
2658 } else if sampled {
2659 Some(EvidenceGap::Sampled)
2660 } else if records_skipped {
2661 Some(EvidenceGap::RecordsSkipped)
2662 } else {
2663 None
2664 };
2665 match reason {
2666 Some(reason) => Evidence::Incomplete { reason },
2667 None => Evidence::Complete,
2668 }
2669}
2670
2671#[derive(Debug, Clone, Copy)]
2674pub(crate) enum Provenance {
2675 Overall,
2677 Dimension(QualityDimension),
2679 Component(&'static str),
2681}
2682
2683pub(crate) fn dimension_components(dimension: QualityDimension) -> &'static [&'static str] {
2689 match dimension {
2690 QualityDimension::Completeness => &["completeness"],
2691 QualityDimension::Consistency => &["consistency"],
2692 QualityDimension::Uniqueness => &["key_uniqueness", "duplicate_rows"],
2693 QualityDimension::Accuracy => &["accuracy"],
2694 QualityDimension::Timeliness => &["timeliness"],
2695 QualityDimension::Validity => &["validity"],
2696 QualityDimension::Precision => &["precision"],
2697 }
2698}
2699
2700fn is_known(label: &str) -> bool {
2702 QualityDimension::all()
2703 .into_iter()
2704 .flat_map(dimension_components)
2705 .any(|component| *component == label)
2706}
2707
2708fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
2714 let Some(quality) = quality else {
2715 return Evidence::Complete;
2718 };
2719 sampled_component_evidence(
2720 quality.sampled_dimensions().as_deref(),
2721 &quality.unlisted_components(),
2722 &quality.metrics.assessed_dimensions(),
2723 provenance,
2724 )
2725}
2726
2727pub(crate) fn sampled_component_evidence(
2736 sampled: Option<&[String]>,
2737 unlisted: &[String],
2738 assessed: &[QualityDimension],
2739 provenance: Provenance,
2740) -> Evidence {
2741 let Some(sampled) = sampled else {
2742 return Evidence::Incomplete {
2743 reason: EvidenceGap::CoverageUnrecorded,
2744 };
2745 };
2746 if matches!(provenance, Provenance::Overall) && !sampled.iter().all(|label| is_known(label)) {
2751 return Evidence::Incomplete {
2752 reason: EvidenceGap::CoverageUnrecorded,
2753 };
2754 }
2755 let reads: Vec<&str> = match provenance {
2760 Provenance::Overall => assessed
2761 .iter()
2762 .copied()
2763 .flat_map(dimension_components)
2764 .copied()
2765 .collect(),
2766 Provenance::Dimension(dimension) => dimension_components(dimension).to_vec(),
2767 Provenance::Component(component) => vec![component],
2768 };
2769 let any_in = |labels: &[String]| {
2770 reads
2771 .iter()
2772 .any(|component| labels.iter().any(|label| label == component))
2773 };
2774 if any_in(unlisted) {
2777 return Evidence::Incomplete {
2778 reason: EvidenceGap::CoverageUnrecorded,
2779 };
2780 }
2781 if any_in(sampled) {
2782 Evidence::Incomplete {
2783 reason: EvidenceGap::QualitySampled,
2784 }
2785 } else {
2786 Evidence::Complete
2787 }
2788}
2789
2790#[cfg(doctest)]
2830struct NonExhaustiveGuards;
2831
2832#[cfg(test)]
2833mod tests {
2834 use std::collections::HashMap;
2835
2836 use dataprof_core::{
2837 ColumnStats, DataSource, DataType, FileFormat, QualityScoreWeights, TruncationReason,
2838 };
2839 use dataprof_metrics::{QualityMetrics, UniquenessMetrics};
2840 use serde_json::{Value, json};
2841
2842 use super::*;
2843
2844 #[test]
2850 fn every_public_type_is_non_exhaustive() {
2851 let lines: Vec<&str> = include_str!("quality_gate.rs").lines().collect();
2852 let marked = |i: usize| {
2853 lines[..i]
2854 .iter()
2855 .rev()
2856 .map(|l| l.trim())
2857 .take_while(|l| l.starts_with("#[") || l.starts_with("///"))
2858 .any(|l| l == "#[non_exhaustive]")
2859 };
2860 let identifier = |s: &str| -> String {
2861 s.chars()
2862 .take_while(|c| c.is_alphanumeric() || *c == '_')
2863 .collect()
2864 };
2865 let mut declared = Vec::new();
2866 let mut variants = Vec::new();
2867 let mut unmarked = Vec::new();
2868 let mut in_enum: Option<String> = None;
2869 for (i, line) in lines.iter().enumerate() {
2870 if let Some(enum_name) = &in_enum {
2871 if *line == "}" {
2872 in_enum = None;
2873 } else if let Some(rest) = line.strip_prefix(" ")
2874 && rest.starts_with(|c: char| c.is_ascii_uppercase())
2875 && rest.contains(" {")
2876 {
2877 let variant = format!("{enum_name}::{}", identifier(rest));
2878 if !marked(i) {
2879 unmarked.push(variant.clone());
2880 }
2881 variants.push(variant);
2882 }
2883 continue;
2884 }
2885 let Some(rest) = line
2886 .strip_prefix("pub enum ")
2887 .or_else(|| line.strip_prefix("pub struct "))
2888 else {
2889 continue;
2890 };
2891 let name = identifier(rest);
2892 if !marked(i) {
2893 unmarked.push(name.clone());
2894 }
2895 if line.starts_with("pub enum ") {
2896 in_enum = Some(name.clone());
2897 }
2898 declared.push(name);
2899 }
2900 assert!(declared.len() >= 15, "{declared:?}");
2903 assert!(variants.len() >= 6, "{variants:?}");
2904 assert!(unmarked.is_empty(), "not #[non_exhaustive]: {unmarked:?}");
2905 }
2906 use crate::ReportAssembler;
2907
2908 fn source() -> DataSource {
2909 DataSource::File {
2910 path: "drop.csv".to_string(),
2911 format: FileFormat::Csv,
2912 size_bytes: 256,
2913 modified_at: None,
2914 parquet_metadata: None,
2915 }
2916 }
2917
2918 fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
2919 ColumnProfile {
2920 name: name.to_string(),
2921 data_type: DataType::String,
2922 null_count: nulls,
2923 total_count: total,
2924 unique_count: None,
2925 unique_count_is_approximate: None,
2926 unique_count_lower_bound: None,
2927 invalid_count: None,
2928 type_homogeneity: None,
2929 locale_number_count: None,
2930 stats: ColumnStats::None,
2931 patterns: None,
2932 }
2933 }
2934
2935 fn sample_data() -> HashMap<String, Vec<String>> {
2937 let ids = (1..=5).map(|n| n.to_string()).collect::<Vec<_>>();
2938 let notes = vec!["a", "b", "c", "d", ""]
2939 .into_iter()
2940 .map(str::to_string)
2941 .collect::<Vec<_>>();
2942 HashMap::from([("id".to_string(), ids), ("note".to_string(), notes)])
2943 }
2944
2945 fn complete_report() -> ProfileReport {
2946 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2947 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2948 .with_quality_data(sample_data())
2949 .build()
2950 }
2951
2952 fn truncated_report() -> ProfileReport {
2953 ReportAssembler::new(
2954 source(),
2955 ExecutionMetadata::new(5, 2, 10).with_truncation(TruncationReason::MaxRows(5)),
2956 )
2957 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2958 .with_quality_data(sample_data())
2959 .build()
2960 }
2961
2962 fn no_quality_report() -> ProfileReport {
2963 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2964 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2965 .with_quality_data(sample_data())
2966 .skip_quality()
2967 .build()
2968 }
2969
2970 fn duplicate_report(duplicates: usize, approximate: bool) -> ProfileReport {
2973 let metrics = QualityMetrics {
2974 uniqueness: Some(UniquenessMetrics {
2975 duplicate_rows: duplicates,
2976 key_uniqueness: 100.0,
2977 high_cardinality_warning: false,
2978 rows_checked: 5,
2979 key_column: None,
2980 key_values_checked: None,
2981 duplicate_rows_approximate: approximate,
2982 }),
2983 ..QualityMetrics::default()
2984 };
2985 ProfileReport::new(
2986 source(),
2987 vec![column("id", 5, 0)],
2988 ExecutionMetadata::new(5, 1, 10).with_truncation(TruncationReason::MaxRows(5)),
2989 Some(QualityAssessment::exact(metrics)),
2990 )
2991 }
2992
2993 fn check_for(result: &GateResult, code: CheckCode) -> &Check {
2994 result
2995 .checks
2996 .iter()
2997 .find(|check| check.code == code)
2998 .unwrap_or_else(|| panic!("no {code} check in {:?}", result.checks))
2999 }
3000
3001 fn column_check<'a>(result: &'a GateResult, name: &str) -> &'a Check {
3002 result
3003 .checks
3004 .iter()
3005 .find(|check| check.column.as_deref() == Some(name))
3006 .unwrap_or_else(|| panic!("no check for column {name}"))
3007 }
3008
3009 fn reservoir_report() -> ProfileReport {
3018 ReportAssembler::new(source(), ExecutionMetadata::new(100, 2, 10))
3019 .columns(vec![column("id", 100, 0), column("note", 100, 20)])
3020 .with_quality_data(sample_data())
3021 .build()
3022 }
3023
3024 #[test]
3025 fn a_reservoir_backed_score_is_decided_on_its_whole_source_interval() {
3026 let report = reservoir_report();
3027 assert!(report.execution.source_exhausted);
3028 assert!(!report.execution.sampling_applied);
3029 let quality = report.quality.as_ref().expect("quality was computed");
3030 let sampled = quality
3031 .sampled_dimensions()
3032 .expect("a profiling run records its provenance");
3033 assert!(
3034 !sampled.is_empty(),
3035 "the assembler did not bifurcate; this test no longer reaches the \
3036 case it guards"
3037 );
3038 let interval = quality
3039 .score_bounds()
3040 .expect("a sampled assessment carries bounds")
3041 .overall_score
3042 .expect("every sampled dimension here is bounded");
3043 assert!(
3044 interval.lower < interval.upper && interval.upper < 100.0,
3045 "a five-value sample leaves room on both sides: {interval:?}"
3046 );
3047 let decide = |min: f64| {
3048 let result = QualityPolicy::new()
3049 .min_quality_score(min)
3050 .evaluate(&report)
3051 .unwrap();
3052 assert_eq!(result.evidence, Evidence::Complete);
3055 let check = check_for(&result, CheckCode::MinQualityScore).clone();
3056 assert_eq!(
3057 check.evidence,
3058 Evidence::Incomplete {
3059 reason: EvidenceGap::QualitySampled
3060 }
3061 );
3062 assert_eq!(
3063 check.bounds.map(|bounds| (bounds.lower, bounds.upper)),
3064 Some((interval.lower, interval.upper))
3065 );
3066 (result.verdict, check)
3067 };
3068
3069 let (verdict, _) = decide(interval.lower);
3070 assert_eq!(verdict, Verdict::Pass);
3071
3072 let (verdict, check) = decide((interval.lower + interval.upper) / 2.0);
3073 assert_eq!(verdict, Verdict::Inconclusive);
3074 assert_eq!(
3075 check.not_evaluated(),
3076 Some(&NotEvaluated::EvidenceIncomplete {
3077 gap: EvidenceGap::QualitySampled
3078 })
3079 );
3080
3081 let (verdict, _) = decide(interval.upper + 0.01);
3082 assert_eq!(verdict, Verdict::Fail);
3083 }
3084
3085 #[test]
3091 fn a_threshold_at_the_shown_value_passes_live_and_after_a_round_trip() {
3092 let thirds = ReportAssembler::new(source(), ExecutionMetadata::new(3, 1, 10))
3094 .columns(vec![column("x", 3, 1)])
3095 .with_quality_data(HashMap::from([(
3096 "x".to_string(),
3097 vec!["1".to_string(), "2".to_string(), String::new()],
3098 )]))
3099 .build();
3100 let mut wrong = Vec::new();
3101 for (label, report) in [
3102 ("thirds", thirds),
3103 ("reservoir", reservoir_report()),
3104 ("complete", complete_report()),
3105 ] {
3106 let loaded: ProfileReport =
3107 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3108 let quality = report.quality.as_ref().expect("quality was computed");
3109 let mut policies = Vec::new();
3110 if quality
3113 .score_bounds()
3114 .and_then(|bounds| bounds.overall_score)
3115 .is_none()
3116 {
3117 policies.push((
3118 "overall".to_string(),
3119 QualityPolicy::new()
3120 .min_quality_score(rounded_2(report.quality_score().unwrap())),
3121 ));
3122 }
3123 for (name, score) in &quality.scores().dimension_scores {
3124 let Some(score) = score else { continue };
3125 let shown = quality
3128 .score_bounds()
3129 .and_then(|bounds| bounds.dimension_scores.get(name).copied().flatten())
3130 .map_or(*score, |interval| interval.lower);
3131 let dimension: QualityDimension = name.parse().unwrap();
3132 policies.push((
3133 name.clone(),
3134 QualityPolicy::new().min_dimension_score(dimension, rounded_2(shown)),
3135 ));
3136 }
3137 for column in &report.column_profiles {
3138 let shown = rounded_2(null_percentage(column).unwrap());
3139 policies.push((
3140 format!("null {}", column.name),
3141 QualityPolicy::new().max_null_percentage(column.name.clone(), shown),
3142 ));
3143 }
3144 if let Some(interval) = quality
3145 .score_bounds()
3146 .and_then(|bounds| bounds.overall_score)
3147 {
3148 policies.push((
3149 "overall lower bound".to_string(),
3150 QualityPolicy::new().min_quality_score(rounded_2(interval.lower)),
3151 ));
3152 }
3153 if label == "thirds" {
3157 policies.push((
3158 "completeness at a third decimal".to_string(),
3159 QualityPolicy::new()
3160 .min_dimension_score(QualityDimension::Completeness, 66.674),
3161 ));
3162 }
3163 for (rule, policy) in policies {
3164 let live = policy.evaluate(&report).unwrap().verdict;
3165 let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3166 if live != Verdict::Pass || reloaded != Verdict::Pass {
3167 wrong.push(format!(
3168 "{label} {rule}: live {live:?}, loaded {reloaded:?}"
3169 ));
3170 }
3171 }
3172 }
3173 assert!(wrong.is_empty(), "{wrong:#?}");
3174 }
3175
3176 #[test]
3181 fn a_bound_that_rounds_up_decides_the_same_live_and_loaded() {
3182 let mut report = reservoir_report();
3183 let quality = report.quality.clone().expect("quality was computed");
3184 let mut bounds = quality
3185 .score_bounds()
3186 .expect("a sampled report is bounded")
3187 .clone();
3188 let interval = bounds
3189 .overall_score
3190 .as_mut()
3191 .expect("the overall score is bounded");
3192 interval.lower = rounded_2(interval.lower) - 0.004;
3194 interval.upper = rounded_2(interval.upper) - 0.004;
3195 let (lower, upper) = (interval.lower, interval.upper);
3196 assert!(rounded_2(lower) > lower && rounded_2(upper) > upper && lower < upper);
3197 report.quality = Some(quality.with_score_bounds(Some(bounds)));
3198 let loaded: ProfileReport =
3199 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3200
3201 let mut wrong = Vec::new();
3202 for (label, min) in [("lower", rounded_2(lower)), ("upper", rounded_2(upper))] {
3203 let policy = QualityPolicy::new().min_quality_score(min);
3204 let live = policy.evaluate(&report).unwrap().verdict;
3205 let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3206 if live != reloaded {
3207 wrong.push(format!(
3208 "min at the shown {label} bound {min}: live {live:?}, loaded {reloaded:?}"
3209 ));
3210 }
3211 }
3212 assert!(wrong.is_empty(), "{wrong:#?}");
3213 }
3214
3215 #[test]
3216 fn an_observed_scope_decides_on_the_sampled_score_itself() {
3217 let report = reservoir_report();
3218 let score = report.quality_score().unwrap();
3219 let result = QualityPolicy::new()
3220 .min_quality_score(score)
3221 .scope(PolicyScope::Observed)
3222 .evaluate(&report)
3223 .unwrap();
3224 let check = check_for(&result, CheckCode::MinQualityScore);
3225 assert_eq!(result.verdict, Verdict::Pass);
3226 assert_eq!(check.bounds, None);
3227 }
3228
3229 #[test]
3230 fn a_sampled_score_without_recorded_bounds_stays_unevaluated() {
3231 let mut report = reservoir_report();
3232 let quality = report.quality.take().unwrap();
3233 report.quality = Some(QualityAssessment::new(quality.metrics, quality.confidence));
3234
3235 let result = QualityPolicy::new()
3236 .min_quality_score(1.0)
3237 .evaluate(&report)
3238 .unwrap();
3239 assert_eq!(result.verdict, Verdict::Inconclusive);
3240 assert_eq!(check_for(&result, CheckCode::MinQualityScore).bounds, None);
3241 }
3242
3243 #[test]
3246 fn an_exactly_counted_dimension_survives_another_dimensions_sampling() {
3247 let report = reservoir_report();
3248 let sampled = report
3249 .quality
3250 .as_ref()
3251 .expect("quality was computed")
3252 .sampled_dimensions()
3253 .expect("a profiling run records its provenance");
3254 assert!(
3255 !sampled.iter().any(|label| label == "completeness"),
3256 "completeness is supposed to come from exact column counters"
3257 );
3258
3259 let result = QualityPolicy::new()
3260 .min_dimension_score(QualityDimension::Completeness, 1.0)
3261 .min_dimension_score(QualityDimension::Consistency, 1.0)
3262 .evaluate(&report)
3263 .unwrap();
3264
3265 assert_eq!(
3266 result
3267 .checks
3268 .iter()
3269 .find(|check| check.dimension == Some(QualityDimension::Completeness))
3270 .map(|check| check.evidence),
3271 Some(Evidence::Complete)
3272 );
3273 assert_eq!(
3274 result
3275 .checks
3276 .iter()
3277 .find(|check| check.dimension == Some(QualityDimension::Consistency))
3278 .map(|check| check.evidence),
3279 Some(Evidence::Incomplete {
3280 reason: EvidenceGap::QualitySampled
3281 })
3282 );
3283 }
3284
3285 #[test]
3293 fn a_legacy_document_without_recorded_coverage_is_not_read_as_a_full_scan() {
3294 let document = serde_json::to_value(complete_report()).expect("serializes");
3295 let mut legacy = document.clone();
3296 legacy["quality"] = document["quality"]["metrics"].clone();
3298 let report: ProfileReport = serde_json::from_value(legacy).expect("legacy document loads");
3299
3300 assert_eq!(
3301 report
3302 .quality
3303 .as_ref()
3304 .expect("the metrics survived")
3305 .sampled_dimensions(),
3306 None
3307 );
3308
3309 let result = QualityPolicy::new()
3310 .min_quality_score(1.0)
3311 .evaluate(&report)
3312 .unwrap();
3313 assert_eq!(result.verdict, Verdict::Inconclusive);
3314 let check = check_for(&result, CheckCode::MinQualityScore);
3315 assert_eq!(
3316 check.evidence,
3317 Evidence::Incomplete {
3318 reason: EvidenceGap::CoverageUnrecorded
3319 }
3320 );
3321
3322 let observed = QualityPolicy::new()
3324 .min_quality_score(1.0)
3325 .scope(PolicyScope::Observed)
3326 .evaluate(&report)
3327 .unwrap();
3328 assert_eq!(observed.verdict, Verdict::Pass);
3329 }
3330
3331 #[test]
3338 fn a_zero_weighted_sampled_dimension_does_not_taint_the_overall_score() {
3339 let mut report = reservoir_report();
3340 let quality = report.quality.as_mut().expect("quality was computed");
3341 let sampled = quality
3342 .sampled_dimensions()
3343 .expect("a profiling run records its provenance");
3344 assert!(
3345 sampled.iter().any(|label| label == "consistency"),
3346 "consistency is supposed to come from the reservoir"
3347 );
3348 quality.metrics.score_weights = QualityScoreWeights {
3352 completeness: 1.0,
3353 consistency: 0.0,
3354 uniqueness: 0.0,
3355 accuracy: 0.0,
3356 timeliness: 0.0,
3357 validity: 0.0,
3358 precision: 0.0,
3359 };
3360
3361 let result = QualityPolicy::new()
3362 .min_quality_score(1.0)
3363 .min_dimension_score(QualityDimension::Consistency, 1.0)
3364 .evaluate(&report)
3365 .unwrap();
3366
3367 assert_eq!(
3369 check_for(&result, CheckCode::MinQualityScore).evidence,
3370 Evidence::Complete
3371 );
3372 assert_eq!(
3374 check_for(&result, CheckCode::MinDimensionScore).evidence,
3375 Evidence::Incomplete {
3376 reason: EvidenceGap::QualitySampled
3377 }
3378 );
3379 }
3380
3381 #[test]
3388 fn zero_weights_do_not_erase_the_sampling_record() {
3389 let mut report = reservoir_report();
3390 let quality = report.quality.as_mut().expect("quality was computed");
3391 quality.metrics.score_weights = QualityScoreWeights {
3392 completeness: 0.0,
3393 consistency: 0.0,
3394 uniqueness: 0.0,
3395 accuracy: 0.0,
3396 timeliness: 0.0,
3397 validity: 0.0,
3398 precision: 0.0,
3399 };
3400 let reassessed =
3401 QualityAssessment::new(quality.metrics.clone(), quality.confidence.clone());
3402
3403 assert!(reassessed.metrics.assessed_dimensions().is_empty());
3404 assert!(
3405 reassessed
3406 .sampled_dimensions()
3407 .expect("provenance survives")
3408 .iter()
3409 .any(|label| label == "consistency"),
3410 "a measured dimension kept no record of coming from a sample"
3411 );
3412 }
3413
3414 #[test]
3415 fn met_requirements_pass() {
3416 let result = QualityPolicy::new()
3417 .min_quality_score(1.0)
3418 .max_null_percentage_any(50.0)
3419 .evaluate(&complete_report())
3420 .unwrap();
3421
3422 assert_eq!(result.verdict, Verdict::Pass);
3423 assert!(result.passed());
3424 assert_eq!(result.violations().count(), 0);
3425 assert_eq!(result.unevaluated().count(), 0);
3426 }
3427
3428 #[test]
3429 fn violated_requirement_fails() {
3430 let result = QualityPolicy::new()
3431 .max_null_percentage("note", 10.0)
3432 .evaluate(&complete_report())
3433 .unwrap();
3434
3435 assert_eq!(result.verdict, Verdict::Fail);
3436 assert!(!result.passed());
3437 let violation = result.violations().next().expect("one violation");
3438 assert_eq!(violation.code, CheckCode::MaxNullPercentage);
3439 assert_eq!(violation.column.as_deref(), Some("note"));
3440 assert_eq!(violation.observed, Some(MetricValue::Percentage(20.0)));
3441 assert_eq!(
3442 violation.expected,
3443 Expectation::AtMost {
3444 value: MetricValue::Percentage(10.0)
3445 }
3446 );
3447 }
3448
3449 #[test]
3451 fn named_limit_overrides_the_wildcard() {
3452 let result = QualityPolicy::new()
3453 .max_null_percentage("note", 50.0)
3454 .max_null_percentage_any(0.0)
3455 .evaluate(&complete_report())
3456 .unwrap();
3457
3458 assert_eq!(result.verdict, Verdict::Pass);
3459 assert_eq!(result.checks.len(), 2);
3460 assert_eq!(
3461 column_check(&result, "note").expected,
3462 Expectation::AtMost {
3463 value: MetricValue::Percentage(50.0)
3464 }
3465 );
3466 assert_eq!(
3467 column_check(&result, "id").expected,
3468 Expectation::AtMost {
3469 value: MetricValue::Percentage(0.0)
3470 }
3471 );
3472 }
3473
3474 #[test]
3477 fn thresholds_are_on_the_percentage_scale() {
3478 let result = QualityPolicy::new()
3479 .min_quality_score(0.9)
3480 .evaluate(&complete_report())
3481 .unwrap();
3482 assert_eq!(result.verdict, Verdict::Pass);
3483
3484 let err = QualityPolicy::new()
3485 .min_quality_score(101.0)
3486 .evaluate(&complete_report())
3487 .unwrap_err();
3488 assert_eq!(
3489 err,
3490 PolicyError::ThresholdOutOfRange {
3491 code: CheckCode::MinQualityScore,
3492 subject: None,
3493 value: 101.0,
3494 }
3495 );
3496 assert!(
3497 QualityPolicy::new()
3498 .max_null_percentage("id", f64::NAN)
3499 .validate()
3500 .is_err()
3501 );
3502 }
3503
3504 #[test]
3505 fn empty_policy_is_rejected_rather_than_passing_everything() {
3506 assert_eq!(
3507 QualityPolicy::new()
3508 .evaluate(&complete_report())
3509 .unwrap_err(),
3510 PolicyError::NoRequirements
3511 );
3512 }
3513
3514 #[test]
3518 fn unanalyzed_quality_is_inconclusive_not_a_pass() {
3519 let result = QualityPolicy::new()
3520 .min_quality_score(90.0)
3521 .evaluate(&no_quality_report())
3522 .unwrap();
3523
3524 assert_eq!(result.verdict, Verdict::Inconclusive);
3525 assert!(!result.passed());
3526 let check = check_for(&result, CheckCode::MinQualityScore);
3527 assert_eq!(check.observed, None);
3528 assert_eq!(
3529 check.not_evaluated(),
3530 Some(&NotEvaluated::QualityUnavailable {
3531 quality_status: "not_requested".to_string(),
3532 })
3533 );
3534 }
3535
3536 #[test]
3538 fn require_quality_turns_absence_into_a_violation() {
3539 let result = QualityPolicy::new()
3540 .require_quality()
3541 .min_quality_score(90.0)
3542 .evaluate(&no_quality_report())
3543 .unwrap();
3544
3545 assert_eq!(result.verdict, Verdict::Fail);
3546 let required = check_for(&result, CheckCode::RequireMetric);
3547 assert!(required.is_violation());
3548 assert_eq!(required.expected, Expectation::Analyzed);
3549 assert_eq!(required.observed, None);
3550 }
3551
3552 #[test]
3553 fn require_dimension_fails_when_the_dimension_had_nothing_to_assess() {
3554 let result = QualityPolicy::new()
3555 .require_dimension(QualityDimension::Timeliness)
3556 .evaluate(&complete_report())
3557 .unwrap();
3558
3559 assert_eq!(result.verdict, Verdict::Fail);
3560 assert_eq!(
3561 check_for(&result, CheckCode::RequireMetric).dimension,
3562 Some(QualityDimension::Timeliness)
3563 );
3564 }
3565
3566 #[test]
3569 fn unassessed_dimension_is_distinct_from_unrequested_quality() {
3570 let result = QualityPolicy::new()
3571 .min_dimension_score(QualityDimension::Timeliness, 90.0)
3572 .evaluate(&complete_report())
3573 .unwrap();
3574
3575 assert_eq!(result.verdict, Verdict::Inconclusive);
3576 assert_eq!(
3577 check_for(&result, CheckCode::MinDimensionScore).not_evaluated(),
3578 Some(&NotEvaluated::NotAssessed)
3579 );
3580 }
3581
3582 #[test]
3585 fn unprofiled_column_is_not_evaluated() {
3586 let result = QualityPolicy::new()
3587 .max_null_percentage("absent", 0.0)
3588 .evaluate(&complete_report())
3589 .unwrap();
3590
3591 assert_eq!(result.verdict, Verdict::Inconclusive);
3592 assert_eq!(
3593 column_check(&result, "absent").not_evaluated(),
3594 Some(&NotEvaluated::ColumnNotProfiled)
3595 );
3596 }
3597
3598 #[test]
3601 fn a_ratio_over_a_partial_scan_cannot_pass_a_full_source_policy() {
3602 let report = truncated_report();
3603 let result = QualityPolicy::new()
3604 .max_null_percentage_any(50.0)
3605 .evaluate(&report)
3606 .unwrap();
3607
3608 assert_eq!(result.verdict, Verdict::Inconclusive);
3609 assert_eq!(
3610 result.evidence,
3611 Evidence::Incomplete {
3612 reason: EvidenceGap::Truncated
3613 }
3614 );
3615 let check = column_check(&result, "note");
3616 assert_eq!(
3617 check.not_evaluated(),
3618 Some(&NotEvaluated::EvidenceIncomplete {
3619 gap: EvidenceGap::Truncated
3620 })
3621 );
3622 assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
3624
3625 let observed = QualityPolicy::new()
3627 .max_null_percentage_any(50.0)
3628 .scope(PolicyScope::Observed)
3629 .evaluate(&report)
3630 .unwrap();
3631 assert_eq!(observed.verdict, Verdict::Pass);
3632 }
3633
3634 #[test]
3637 fn a_witnessed_duplicate_fails_despite_a_partial_scan() {
3638 let result = QualityPolicy::new()
3639 .max_duplicate_rows(0)
3640 .evaluate(&duplicate_report(2, false))
3641 .unwrap();
3642
3643 assert_eq!(result.verdict, Verdict::Fail);
3644 let check = check_for(&result, CheckCode::MaxDuplicateRows);
3645 assert_eq!(check.observed, Some(MetricValue::Count(2)));
3646 assert_eq!(
3647 check.evidence,
3648 Evidence::Incomplete {
3649 reason: EvidenceGap::Truncated
3650 }
3651 );
3652 }
3653
3654 #[test]
3656 fn no_duplicate_in_a_partial_scan_is_not_a_pass() {
3657 let result = QualityPolicy::new()
3658 .max_duplicate_rows(0)
3659 .evaluate(&duplicate_report(0, false))
3660 .unwrap();
3661
3662 assert_eq!(result.verdict, Verdict::Inconclusive);
3663 assert_eq!(
3664 check_for(&result, CheckCode::MaxDuplicateRows).not_evaluated(),
3665 Some(&NotEvaluated::EvidenceIncomplete {
3666 gap: EvidenceGap::Truncated
3667 })
3668 );
3669 }
3670
3671 #[test]
3674 fn an_estimated_duplicate_count_settles_neither_direction() {
3675 let result = QualityPolicy::new()
3676 .max_duplicate_rows(0)
3677 .evaluate(&duplicate_report(2, true))
3678 .unwrap();
3679
3680 assert_eq!(result.verdict, Verdict::Inconclusive);
3681 let check = check_for(&result, CheckCode::MaxDuplicateRows);
3682 assert_eq!(
3683 check.not_evaluated(),
3684 Some(&NotEvaluated::EvidenceIncomplete {
3685 gap: EvidenceGap::Truncated
3686 })
3687 );
3688 assert_eq!(
3691 check.message,
3692 "the duplicate-row count is an estimate, which can neither witness \
3693 duplicates above the allowance nor rule them out"
3694 );
3695 }
3696
3697 #[test]
3701 fn a_null_count_ceiling_is_decided_on_the_exact_count() {
3702 let report = ReportAssembler::new(source(), ExecutionMetadata::new(20_001, 2, 10))
3703 .columns(vec![column("key", 20_001, 1), column("clean", 20_001, 0)])
3704 .build();
3705
3706 let percentage = QualityPolicy::new()
3707 .max_null_percentage("key", 0.0)
3708 .evaluate(&report)
3709 .unwrap();
3710 assert_eq!(percentage.verdict, Verdict::Pass);
3711
3712 let count = QualityPolicy::new()
3713 .max_null_count("key", 0)
3714 .max_null_count_any(0)
3715 .evaluate(&report)
3716 .unwrap();
3717 assert_eq!(count.verdict, Verdict::Fail);
3718 let key = column_check(&count, "key");
3719 assert_eq!(key.code, CheckCode::MaxNullCount);
3720 assert_eq!(key.observed, Some(MetricValue::Count(1)));
3721 assert_eq!(key.status, CheckStatus::Failed);
3722 assert_eq!(column_check(&count, "clean").status, CheckStatus::Passed);
3723 }
3724
3725 #[test]
3729 fn witnessed_nulls_fail_a_partial_scan_and_a_clean_prefix_does_not_pass() {
3730 let result = QualityPolicy::new()
3731 .max_null_count_any(0)
3732 .evaluate(&truncated_report())
3733 .unwrap();
3734
3735 assert_eq!(result.verdict, Verdict::Fail);
3736 assert_eq!(column_check(&result, "note").status, CheckStatus::Failed);
3737 assert_eq!(
3738 column_check(&result, "id").not_evaluated(),
3739 Some(&NotEvaluated::EvidenceIncomplete {
3740 gap: EvidenceGap::Truncated
3741 })
3742 );
3743
3744 let observed = QualityPolicy::new()
3745 .max_null_count("note", 1)
3746 .scope(PolicyScope::Observed)
3747 .evaluate(&truncated_report())
3748 .unwrap();
3749 assert_eq!(observed.verdict, Verdict::Pass);
3750 }
3751
3752 #[test]
3755 fn a_column_with_no_values_read_has_a_null_count_of_zero() {
3756 let report = ReportAssembler::new(source(), ExecutionMetadata::new(0, 1, 10))
3757 .columns(vec![column("key", 0, 0)])
3758 .build();
3759
3760 let count = QualityPolicy::new()
3761 .max_null_count("key", 0)
3762 .evaluate(&report)
3763 .unwrap();
3764 assert_eq!(count.verdict, Verdict::Pass);
3765 assert_eq!(
3766 column_check(&count, "key").observed,
3767 Some(MetricValue::Count(0))
3768 );
3769
3770 let percentage = QualityPolicy::new()
3771 .max_null_percentage("key", 0.0)
3772 .evaluate(&report)
3773 .unwrap();
3774 assert_eq!(
3775 column_check(&percentage, "key").not_evaluated(),
3776 Some(&NotEvaluated::NotAssessed)
3777 );
3778 }
3779
3780 #[test]
3783 fn a_named_null_count_overrides_the_wildcard_and_absence_is_unevaluated() {
3784 let result = QualityPolicy::new()
3785 .max_null_count("note", 1)
3786 .max_null_count("absent", 0)
3787 .max_null_count_any(0)
3788 .evaluate(&complete_report())
3789 .unwrap();
3790
3791 let checks: Vec<_> = result
3792 .checks
3793 .iter()
3794 .map(|check| (check.column.clone().unwrap(), check.expected))
3795 .collect();
3796 assert_eq!(
3797 checks,
3798 vec![
3799 (
3800 "absent".to_string(),
3801 Expectation::AtMost {
3802 value: MetricValue::Count(0)
3803 }
3804 ),
3805 (
3806 "note".to_string(),
3807 Expectation::AtMost {
3808 value: MetricValue::Count(1)
3809 }
3810 ),
3811 (
3812 "id".to_string(),
3813 Expectation::AtMost {
3814 value: MetricValue::Count(0)
3815 }
3816 ),
3817 ]
3818 );
3819 assert_eq!(
3820 column_check(&result, "absent").not_evaluated(),
3821 Some(&NotEvaluated::ColumnNotProfiled)
3822 );
3823 assert_eq!(column_check(&result, "note").status, CheckStatus::Passed);
3824 assert_eq!(result.verdict, Verdict::Inconclusive);
3825 }
3826
3827 #[test]
3830 fn a_violation_outranks_an_unevaluated_check() {
3831 let result = QualityPolicy::new()
3832 .max_duplicate_rows(0)
3833 .min_dimension_score(QualityDimension::Timeliness, 90.0)
3834 .evaluate(&duplicate_report(2, false))
3835 .unwrap();
3836
3837 assert_eq!(result.verdict, Verdict::Fail);
3838 assert_eq!(result.unevaluated().count(), 1);
3839 }
3840
3841 #[test]
3844 fn evaluation_order_does_not_depend_on_insertion_order() {
3845 let report = complete_report();
3846 let forward = QualityPolicy::new()
3847 .max_null_percentage("note", 50.0)
3848 .max_null_percentage("id", 50.0)
3849 .evaluate(&report)
3850 .unwrap();
3851 let reverse = QualityPolicy::new()
3852 .max_null_percentage("id", 50.0)
3853 .max_null_percentage("note", 50.0)
3854 .evaluate(&report)
3855 .unwrap();
3856
3857 assert_eq!(forward, reverse);
3858 let columns: Vec<_> = forward
3859 .checks
3860 .iter()
3861 .map(|check| check.column.clone().unwrap())
3862 .collect();
3863 assert_eq!(columns, vec!["id".to_string(), "note".to_string()]);
3864 }
3865
3866 #[test]
3869 fn result_serializes_into_the_documented_shape() {
3870 let result = QualityPolicy::new()
3871 .max_null_percentage("note", 10.0)
3872 .evaluate(&complete_report())
3873 .unwrap();
3874
3875 assert_eq!(
3876 serde_json::to_value(&result).unwrap(),
3877 json!({
3878 "verdict": "fail",
3879 "scope": "full_source",
3880 "evidence": {"coverage": "complete"},
3881 "checks": [{
3882 "code": "max_null_percentage",
3883 "column": "note",
3884 "expected": {"comparison": "at_most", "value": 10.0},
3885 "observed": 20.0,
3886 "scope": "full_source",
3887 "evidence": {"coverage": "complete"},
3888 "status": "failed",
3889 "message": "this column's null percentage is above the allowance",
3890 }],
3891 })
3892 );
3893 }
3894
3895 #[test]
3896 fn an_unevaluated_check_serializes_its_reason() {
3897 let result = QualityPolicy::new()
3898 .min_quality_score(90.0)
3899 .evaluate(&no_quality_report())
3900 .unwrap();
3901 let check = &serde_json::to_value(&result).unwrap()["checks"][0];
3902
3903 assert_eq!(check["status"], Value::String("not_evaluated".to_string()));
3904 assert_eq!(
3905 check["reason"],
3906 Value::String("quality_unavailable".to_string())
3907 );
3908 assert_eq!(
3909 check["quality_status"],
3910 Value::String("not_requested".to_string())
3911 );
3912 assert!(check.get("observed").is_none());
3913 }
3914}