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 MaxDuplicateRows,
315 RequireMetric,
317 MaxQualityScoreDrop,
320 MaxDimensionScoreDrop,
323 MaxNullPercentageIncrease,
326 RequireColumn,
328 ForbidRemovedColumn,
330 ForbidAddedColumn,
332}
333
334impl fmt::Display for CheckCode {
335 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336 match self {
337 Self::MinRows => write!(f, "min_rows"),
338 Self::MinQualityScore => write!(f, "min_quality_score"),
339 Self::MinDimensionScore => write!(f, "min_dimension_score"),
340 Self::MaxNullPercentage => write!(f, "max_null_percentage"),
341 Self::MaxDuplicateRows => write!(f, "max_duplicate_rows"),
342 Self::RequireMetric => write!(f, "require_metric"),
343 Self::MaxQualityScoreDrop => write!(f, "max_quality_score_drop"),
344 Self::MaxDimensionScoreDrop => write!(f, "max_dimension_score_drop"),
345 Self::MaxNullPercentageIncrease => write!(f, "max_null_percentage_increase"),
346 Self::RequireColumn => write!(f, "require_column"),
347 Self::ForbidRemovedColumn => write!(f, "forbid_removed_column"),
348 Self::ForbidAddedColumn => write!(f, "forbid_added_column"),
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
358#[serde(untagged)]
359#[non_exhaustive]
360pub enum MetricValue {
361 Count(usize),
363 Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
365}
366
367impl fmt::Display for MetricValue {
368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371 match self {
372 Self::Count(count) => write!(f, "{count}"),
373 Self::Percentage(value) => write!(f, "{value:.2}"),
374 }
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
380#[serde(tag = "comparison", rename_all = "snake_case")]
381#[non_exhaustive]
382pub enum Expectation {
383 #[non_exhaustive]
385 AtLeast {
386 value: MetricValue,
388 },
389 #[non_exhaustive]
391 AtMost {
392 value: MetricValue,
394 },
395 Analyzed,
397 #[non_exhaustive]
400 ChangeAtLeast {
401 value: MetricValue,
403 },
404 #[non_exhaustive]
406 ChangeAtMost {
407 value: MetricValue,
409 },
410 Present,
412 InBaseline,
414}
415
416impl fmt::Display for Expectation {
417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418 match self {
419 Self::AtLeast { value } => write!(f, "at least {value}"),
420 Self::AtMost { value } => write!(f, "at most {value}"),
421 Self::Analyzed => write!(f, "analyzed"),
422 Self::ChangeAtLeast { value } => write!(f, "a change of at least {value}"),
423 Self::ChangeAtMost { value } => write!(f, "a change of at most {value}"),
424 Self::Present => write!(f, "present"),
425 Self::InBaseline => write!(f, "in the baseline"),
426 }
427 }
428}
429
430#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
437#[serde(tag = "reason", rename_all = "snake_case")]
438#[non_exhaustive]
439pub enum NotEvaluated {
440 #[non_exhaustive]
442 QualityUnavailable {
443 quality_status: String,
445 },
446 NotAssessed,
450 ColumnNotProfiled,
456 #[non_exhaustive]
459 EvidenceIncomplete {
460 gap: EvidenceGap,
462 },
463 NotInBaseline,
467 SchemaChanged,
471 DimensionsChanged,
475 #[non_exhaustive]
479 DefinitionNotComparable {
480 definition: String,
482 },
483 #[non_exhaustive]
486 SettingChanged {
487 setting: String,
489 },
490 #[non_exhaustive]
493 SettingUnrecorded {
494 setting: String,
496 },
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
501#[serde(tag = "status", rename_all = "snake_case")]
502#[non_exhaustive]
503pub enum CheckStatus {
504 Passed,
506 Failed,
508 NotEvaluated(NotEvaluated),
510}
511
512#[derive(Debug, Clone, PartialEq, serde::Serialize)]
514#[non_exhaustive]
515pub struct Check {
516 pub code: CheckCode,
518 #[serde(skip_serializing_if = "Option::is_none")]
520 pub column: Option<String>,
521 #[serde(skip_serializing_if = "Option::is_none")]
523 pub dimension: Option<QualityDimension>,
524 pub expected: Expectation,
526 #[serde(skip_serializing_if = "Option::is_none")]
529 pub observed: Option<MetricValue>,
530 pub scope: PolicyScope,
532 pub evidence: Evidence,
534 #[serde(skip_serializing_if = "Option::is_none")]
540 pub bounds: Option<CheckBounds>,
541 #[serde(skip_serializing_if = "Option::is_none")]
544 pub baseline: Option<BaselineReading>,
545 #[serde(flatten)]
547 pub status: CheckStatus,
548 pub message: String,
555}
556
557impl Check {
558 pub fn is_violation(&self) -> bool {
560 matches!(self.status, CheckStatus::Failed)
561 }
562
563 pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
565 match &self.status {
566 CheckStatus::NotEvaluated(reason) => Some(reason),
567 _ => None,
568 }
569 }
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
574#[non_exhaustive]
575pub struct BaselineReading {
576 pub observed: Option<MetricValue>,
578 pub evidence: Evidence,
580 pub change: Option<MetricValue>,
583}
584
585#[derive(Debug, Clone, PartialEq, serde::Serialize)]
588#[non_exhaustive]
589pub struct BaselineComparison {
590 pub reports: Sides<ReportReference>,
593 pub schema: SchemaChange,
595 pub diagnostics: Vec<Diagnostic>,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
604#[serde(rename_all = "snake_case")]
605#[non_exhaustive]
606pub enum DiagnosticCode {
607 ColumnAdded,
611 ColumnRemoved,
616 ColumnTypeChanged,
619 ColumnAllNull,
622}
623
624impl fmt::Display for DiagnosticCode {
625 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626 match self {
627 Self::ColumnAdded => write!(f, "column_added"),
628 Self::ColumnRemoved => write!(f, "column_removed"),
629 Self::ColumnTypeChanged => write!(f, "column_type_changed"),
630 Self::ColumnAllNull => write!(f, "column_all_null"),
631 }
632 }
633}
634
635#[derive(Debug, Clone, PartialEq, serde::Serialize)]
641#[non_exhaustive]
642pub struct Diagnostic {
643 pub code: DiagnosticCode,
645 pub column: String,
647 #[serde(
650 skip_serializing_if = "Option::is_none",
651 serialize_with = "serialize_data_types"
652 )]
653 pub data_type: Option<Sides<DataType>>,
654 #[serde(flatten)]
657 pub setting: Option<SettingDifference>,
658 pub message: String,
661}
662
663#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
672#[serde(tag = "reason", rename_all = "snake_case")]
673#[non_exhaustive]
674pub enum SettingDifference {
675 #[non_exhaustive]
677 SettingChanged {
678 setting: String,
680 },
681 #[non_exhaustive]
684 SettingUnrecorded {
685 setting: String,
687 },
688}
689
690fn serialize_data_types<S: serde::Serializer>(
691 types: &Option<Sides<DataType>>,
692 serializer: S,
693) -> Result<S::Ok, S::Error> {
694 types
695 .as_ref()
696 .map(|types| Sides {
697 a: types.a.name(),
698 b: types.b.name(),
699 })
700 .serialize(serializer)
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
705#[non_exhaustive]
706pub struct CheckBounds {
707 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
709 pub lower: f64,
710 #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
712 pub upper: f64,
713 pub confidence_level: f64,
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
719#[serde(rename_all = "snake_case")]
720#[non_exhaustive]
721pub enum Verdict {
722 Pass,
724 Fail,
726 Inconclusive,
729}
730
731impl fmt::Display for Verdict {
732 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733 match self {
734 Self::Pass => write!(f, "pass"),
735 Self::Fail => write!(f, "fail"),
736 Self::Inconclusive => write!(f, "inconclusive"),
737 }
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, serde::Serialize)]
743#[non_exhaustive]
744pub struct GateResult {
745 pub verdict: Verdict,
747 pub scope: PolicyScope,
749 pub evidence: Evidence,
753 pub checks: Vec<Check>,
755 #[serde(skip_serializing_if = "Option::is_none")]
758 pub baseline: Option<BaselineComparison>,
759}
760
761impl GateResult {
762 pub fn passed(&self) -> bool {
768 self.verdict == Verdict::Pass
769 }
770
771 pub fn violations(&self) -> impl Iterator<Item = &Check> {
773 self.checks.iter().filter(|check| check.is_violation())
774 }
775
776 pub fn unevaluated(&self) -> impl Iterator<Item = &Check> {
778 self.checks
779 .iter()
780 .filter(|check| check.not_evaluated().is_some())
781 }
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
786#[non_exhaustive]
787pub enum RequiredMetric {
788 Quality,
790 Dimension(QualityDimension),
792}
793
794#[derive(Debug, Clone, Default)]
801#[non_exhaustive]
802pub struct QualityPolicy {
803 min_rows: Option<usize>,
804 min_quality_score: Option<f64>,
805 min_dimension_scores: BTreeMap<String, (QualityDimension, f64)>,
806 max_null_percentage: BTreeMap<String, f64>,
807 max_null_percentage_any: Option<f64>,
808 max_duplicate_rows: Option<usize>,
809 require_metrics: Vec<RequiredMetric>,
810 max_quality_score_drop: Option<f64>,
811 max_dimension_score_drops: BTreeMap<String, (QualityDimension, f64)>,
812 max_null_percentage_increase: BTreeMap<String, f64>,
813 max_null_percentage_increase_any: Option<f64>,
814 require_columns: BTreeSet<String>,
815 forbid_removed_columns: bool,
816 forbid_added_columns: bool,
817 scope: PolicyScope,
818}
819
820impl QualityPolicy {
821 pub fn new() -> Self {
824 Self::default()
825 }
826
827 pub fn min_rows(mut self, min: usize) -> Self {
830 self.min_rows = Some(min);
831 self
832 }
833
834 pub fn min_quality_score(mut self, min: f64) -> Self {
836 self.min_quality_score = Some(min);
837 self
838 }
839
840 pub fn min_dimension_score(mut self, dimension: QualityDimension, min: f64) -> Self {
842 self.min_dimension_scores
843 .insert(dimension.to_string(), (dimension, min));
844 self
845 }
846
847 pub fn max_null_percentage(mut self, column: impl Into<String>, max: f64) -> Self {
852 self.max_null_percentage.insert(column.into(), max);
853 self
854 }
855
856 pub fn max_null_percentage_any(mut self, max: f64) -> Self {
859 self.max_null_percentage_any = Some(max);
860 self
861 }
862
863 pub fn max_duplicate_rows(mut self, max: usize) -> Self {
865 self.max_duplicate_rows = Some(max);
866 self
867 }
868
869 pub fn require_quality(mut self) -> Self {
876 self.require_metrics.push(RequiredMetric::Quality);
877 self
878 }
879
880 pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
882 self.require_metrics
883 .push(RequiredMetric::Dimension(dimension));
884 self
885 }
886
887 pub fn max_quality_score_drop(mut self, points: f64) -> Self {
890 self.max_quality_score_drop = Some(points);
891 self
892 }
893
894 pub fn max_dimension_score_drop(mut self, dimension: QualityDimension, points: f64) -> Self {
897 self.max_dimension_score_drops
898 .insert(dimension.to_string(), (dimension, points));
899 self
900 }
901
902 pub fn max_null_percentage_increase(mut self, column: impl Into<String>, points: f64) -> Self {
907 self.max_null_percentage_increase
908 .insert(column.into(), points);
909 self
910 }
911
912 pub fn max_null_percentage_increase_any(mut self, points: f64) -> Self {
918 self.max_null_percentage_increase_any = Some(points);
919 self
920 }
921
922 pub fn require_columns<I, S>(mut self, columns: I) -> Self
927 where
928 I: IntoIterator<Item = S>,
929 S: Into<String>,
930 {
931 self.require_columns
932 .extend(columns.into_iter().map(Into::into));
933 self
934 }
935
936 pub fn forbid_removed_columns(mut self) -> Self {
942 self.forbid_removed_columns = true;
943 self
944 }
945
946 pub fn forbid_added_columns(mut self) -> Self {
953 self.forbid_added_columns = true;
954 self
955 }
956
957 pub fn scope(mut self, scope: PolicyScope) -> Self {
960 self.scope = scope;
961 self
962 }
963
964 pub fn validate(&self) -> Result<(), PolicyError> {
969 if let Some(min) = self.min_quality_score {
970 check_percentage(CheckCode::MinQualityScore, None, min)?;
971 }
972 for (dimension, min) in self.min_dimension_scores.values() {
973 check_percentage(
974 CheckCode::MinDimensionScore,
975 Some(dimension.to_string()),
976 *min,
977 )?;
978 }
979 for (column, max) in &self.max_null_percentage {
980 check_percentage(CheckCode::MaxNullPercentage, Some(column.clone()), *max)?;
981 }
982 if let Some(max) = self.max_null_percentage_any {
983 check_percentage(CheckCode::MaxNullPercentage, None, max)?;
984 }
985 if let Some(points) = self.max_quality_score_drop {
986 check_percentage(CheckCode::MaxQualityScoreDrop, None, points)?;
987 }
988 for (dimension, points) in self.max_dimension_score_drops.values() {
989 check_percentage(
990 CheckCode::MaxDimensionScoreDrop,
991 Some(dimension.to_string()),
992 *points,
993 )?;
994 }
995 for (column, points) in &self.max_null_percentage_increase {
996 check_percentage(
997 CheckCode::MaxNullPercentageIncrease,
998 Some(column.clone()),
999 *points,
1000 )?;
1001 }
1002 if let Some(points) = self.max_null_percentage_increase_any {
1003 check_percentage(CheckCode::MaxNullPercentageIncrease, None, points)?;
1004 }
1005 if self.is_empty() {
1006 return Err(PolicyError::NoRequirements);
1007 }
1008 Ok(())
1009 }
1010
1011 fn is_empty(&self) -> bool {
1012 self.min_rows.is_none()
1013 && self.min_quality_score.is_none()
1014 && self.min_dimension_scores.is_empty()
1015 && self.max_null_percentage.is_empty()
1016 && self.max_null_percentage_any.is_none()
1017 && self.max_duplicate_rows.is_none()
1018 && self.require_metrics.is_empty()
1019 && self.require_columns.is_empty()
1020 && !self.has_change_rules()
1021 }
1022
1023 fn has_change_rules(&self) -> bool {
1025 self.max_quality_score_drop.is_some()
1026 || !self.max_dimension_score_drops.is_empty()
1027 || !self.max_null_percentage_increase.is_empty()
1028 || self.max_null_percentage_increase_any.is_some()
1029 || self.forbid_removed_columns
1030 || self.forbid_added_columns
1031 }
1032
1033 pub fn evaluate(&self, report: &ProfileReport) -> Result<GateResult, PolicyError> {
1041 self.validate()?;
1042 if self.has_change_rules() {
1043 return Err(PolicyError::BaselineRequired);
1044 }
1045 let checks = self.absolute_checks(report);
1046 Ok(GateResult {
1047 verdict: verdict_of(&checks),
1048 scope: self.scope,
1049 evidence: scan_evidence(&report.execution),
1050 checks,
1051 baseline: None,
1052 })
1053 }
1054
1055 pub fn evaluate_against(
1066 &self,
1067 report: &ProfileReport,
1068 baseline: &ReportSide,
1069 ) -> Result<GateResult, PolicyError> {
1070 self.validate()?;
1071 let current = ReportSide::from_report(report);
1072 let delta = ReportDelta::between_sides(baseline, ¤t);
1073 let identifiers = Sides {
1074 a: baseline.identifier_order(),
1075 b: current.identifier_order(),
1076 };
1077 let mut checks = self.absolute_checks(report);
1078 if let Some(points) = self.max_quality_score_drop {
1079 checks.push(self.score_drop_check(report, &delta, &identifiers, None, points));
1080 }
1081 for dimension in QualityDimension::all() {
1082 if let Some((_, points)) = self.max_dimension_score_drops.get(&dimension.to_string()) {
1083 checks.push(self.score_drop_check(
1084 report,
1085 &delta,
1086 &identifiers,
1087 Some(dimension),
1088 *points,
1089 ));
1090 }
1091 }
1092 checks.extend(self.null_increase_checks(&delta));
1093 let (baseline_names, current_names) = (column_names(baseline), column_names(¤t));
1096 if self.forbid_removed_columns {
1097 for column in baseline.columns() {
1098 checks.push(self.forbid_removed_check(
1099 baseline,
1100 ¤t,
1101 ¤t_names,
1102 &column.name,
1103 ));
1104 }
1105 }
1106 if self.forbid_added_columns {
1107 for column in current.columns() {
1108 checks.push(self.forbid_added_check(
1109 baseline,
1110 ¤t,
1111 &baseline_names,
1112 &column.name,
1113 ));
1114 }
1115 if self.scope == PolicyScope::FullSource
1120 && let Some(reason) = unenumerated_columns(¤t)
1121 {
1122 checks.push(self.unprofiled_columns_check(baseline, ¤t, reason));
1123 }
1124 }
1125 let diagnostics = schema_diagnostics(baseline, ¤t, &delta);
1126 Ok(GateResult {
1127 verdict: verdict_of(&checks),
1128 scope: self.scope,
1129 evidence: scan_evidence(&report.execution),
1130 checks,
1131 baseline: Some(BaselineComparison {
1132 reports: delta.reports,
1133 schema: delta.schema,
1134 diagnostics,
1135 }),
1136 })
1137 }
1138
1139 fn absolute_checks(&self, report: &ProfileReport) -> Vec<Check> {
1141 let scan = scan_evidence(&report.execution);
1142 let mut checks = Vec::new();
1143
1144 for metric in required_metrics_in_order(&self.require_metrics) {
1145 checks.push(self.require_metric_check(report, metric));
1146 }
1147 if !self.require_columns.is_empty() {
1148 let side = ReportSide::from_report(report);
1149 let names = column_names(&side);
1150 for column in &self.require_columns {
1151 checks.push(self.require_column_check(&side, &names, column));
1152 }
1153 }
1154 if let Some(min) = self.min_rows {
1155 checks.push(self.row_count_check(report, scan, min));
1156 }
1157 if let Some(min) = self.min_quality_score {
1158 checks.push(self.quality_score_check(report, scan, min));
1159 }
1160 for dimension in QualityDimension::all() {
1161 if let Some((_, min)) = self.min_dimension_scores.get(&dimension.to_string()) {
1162 checks.push(self.dimension_score_check(report, scan, dimension, *min));
1163 }
1164 }
1165 checks.extend(self.null_percentage_checks(report, scan));
1166 if let Some(max) = self.max_duplicate_rows {
1167 checks.push(self.duplicate_rows_check(report, scan, max));
1168 }
1169 checks
1170 }
1171
1172 fn decide_aggregate(&self, evidence: Evidence, satisfied: bool) -> CheckStatus {
1180 match (self.scope, evidence.gap()) {
1181 (PolicyScope::FullSource, Some(gap)) => {
1182 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1183 }
1184 _ if satisfied => CheckStatus::Passed,
1185 _ => CheckStatus::Failed,
1186 }
1187 }
1188
1189 fn require_metric_check(&self, report: &ProfileReport, metric: RequiredMetric) -> Check {
1190 let dimension = match metric {
1191 RequiredMetric::Quality => None,
1192 RequiredMetric::Dimension(dimension) => Some(dimension),
1193 };
1194 let analyzed = match (report.quality.as_ref(), dimension) {
1198 (None, _) => false,
1199 (Some(_), None) => true,
1200 (Some(quality), Some(dimension)) => {
1201 quality.metrics.dimension_score(dimension).is_some()
1202 }
1203 };
1204 let message = match (analyzed, dimension) {
1205 (true, None) => "quality was analyzed",
1206 (false, None) => "the report carries no quality assessment",
1207 (true, Some(_)) => "the required dimension was assessed",
1208 (false, Some(_)) => "the required dimension was not assessed",
1209 };
1210 Check {
1211 code: CheckCode::RequireMetric,
1212 column: None,
1213 dimension,
1214 expected: Expectation::Analyzed,
1215 observed: None,
1216 scope: self.scope,
1217 evidence: Evidence::Complete,
1218 bounds: None,
1219 baseline: None,
1220 status: if analyzed {
1221 CheckStatus::Passed
1222 } else {
1223 CheckStatus::Failed
1224 },
1225 message: message.to_string(),
1226 }
1227 }
1228
1229 fn row_count_check(&self, report: &ProfileReport, scan: Evidence, min: usize) -> Check {
1236 let observed = report.execution.rows_processed;
1237 let status = match (self.scope, scan.gap()) {
1238 _ if observed >= min => CheckStatus::Passed,
1239 (PolicyScope::FullSource, Some(gap)) => {
1240 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1241 }
1242 _ => CheckStatus::Failed,
1243 };
1244 let message = match status {
1245 CheckStatus::Passed => "the scan read at least the required number of rows",
1246 CheckStatus::Failed => "fewer rows were read than the required minimum",
1247 CheckStatus::NotEvaluated(_) => {
1248 "fewer rows were read than the required minimum, and the rows that were not \
1249 read may make up the difference"
1250 }
1251 };
1252 Check {
1253 code: CheckCode::MinRows,
1254 column: None,
1255 dimension: None,
1256 expected: Expectation::AtLeast {
1257 value: MetricValue::Count(min),
1258 },
1259 observed: Some(MetricValue::Count(observed)),
1260 scope: self.scope,
1261 evidence: scan,
1262 bounds: None,
1263 baseline: None,
1264 status,
1265 message: message.to_string(),
1266 }
1267 }
1268
1269 fn quality_score_check(&self, report: &ProfileReport, scan: Evidence, min: f64) -> Check {
1270 let mut check = Check {
1271 code: CheckCode::MinQualityScore,
1272 column: None,
1273 dimension: None,
1274 expected: Expectation::AtLeast {
1275 value: MetricValue::Percentage(min),
1276 },
1277 observed: None,
1278 scope: self.scope,
1279 evidence: scan.and(quality_evidence(
1280 report.quality.as_ref(),
1281 Provenance::Overall,
1282 )),
1283 bounds: None,
1284 baseline: None,
1285 status: CheckStatus::Passed,
1286 message: String::new(),
1287 };
1288 let Some(quality) = report.quality.as_ref() else {
1289 return unavailable_quality(check, report);
1290 };
1291 let Some(score) = quality.score() else {
1292 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1293 check.message =
1294 "no quality dimension had anything to assess, so there is no overall score"
1295 .to_string();
1296 return check;
1297 };
1298 check.observed = Some(MetricValue::Percentage(score));
1299 let bounds = quality
1300 .score_bounds()
1301 .and_then(|bounds| check_bounds(bounds, bounds.overall_score));
1302 self.decide_score(&mut check, score, min, bounds, "the overall quality score");
1303 check
1304 }
1305
1306 fn dimension_score_check(
1307 &self,
1308 report: &ProfileReport,
1309 scan: Evidence,
1310 dimension: QualityDimension,
1311 min: f64,
1312 ) -> Check {
1313 let mut check = Check {
1314 code: CheckCode::MinDimensionScore,
1315 column: None,
1316 dimension: Some(dimension),
1317 expected: Expectation::AtLeast {
1318 value: MetricValue::Percentage(min),
1319 },
1320 observed: None,
1321 scope: self.scope,
1322 evidence: scan.and(quality_evidence(
1323 report.quality.as_ref(),
1324 Provenance::Dimension(dimension),
1325 )),
1326 bounds: None,
1327 baseline: None,
1328 status: CheckStatus::Passed,
1329 message: String::new(),
1330 };
1331 let Some(quality) = report.quality.as_ref() else {
1332 return unavailable_quality(check, report);
1333 };
1334 let Some(score) = quality
1335 .scores()
1336 .dimension_scores
1337 .get(&dimension.to_string())
1338 .copied()
1339 .flatten()
1340 else {
1341 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1342 check.message = "this dimension had nothing to assess in this run".to_string();
1343 return check;
1344 };
1345 check.observed = Some(MetricValue::Percentage(score));
1346 let bounds = quality.score_bounds().and_then(|bounds| {
1347 check_bounds(
1348 bounds,
1349 bounds
1350 .dimension_scores
1351 .get(&dimension.to_string())
1352 .copied()
1353 .flatten(),
1354 )
1355 });
1356 self.decide_score(&mut check, score, min, bounds, "this dimension's score");
1357 check
1358 }
1359
1360 fn decide_score(
1370 &self,
1371 check: &mut Check,
1372 score: f64,
1373 min: f64,
1374 bounds: Option<CheckBounds>,
1375 subject: &str,
1376 ) {
1377 let sampled = check.evidence.gap() == Some(EvidenceGap::QualitySampled);
1378 let min = rounded_2(min);
1379 match bounds {
1380 Some(bounds) if self.scope == PolicyScope::FullSource && sampled => {
1381 check.bounds = Some(bounds);
1382 check.status = if rounded_2(bounds.lower) >= min {
1383 CheckStatus::Passed
1384 } else if rounded_2(bounds.upper) < min {
1385 CheckStatus::Failed
1386 } else {
1387 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete {
1388 gap: EvidenceGap::QualitySampled,
1389 })
1390 };
1391 check.message = bounded_message(&check.status, subject);
1392 }
1393 _ => {
1394 check.status = self.decide_aggregate(check.evidence, rounded_2(score) >= min);
1395 check.message = aggregate_message(&check.status, subject);
1396 }
1397 }
1398 }
1399
1400 fn null_percentage_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
1408 let mut checks = Vec::new();
1409 for (column, max) in &self.max_null_percentage {
1410 let profile = report
1411 .column_profiles
1412 .iter()
1413 .find(|candidate| &candidate.name == column);
1414 checks.push(self.null_percentage_check(column, profile, scan, *max));
1415 }
1416 if let Some(max) = self.max_null_percentage_any {
1417 for profile in &report.column_profiles {
1418 if !self.max_null_percentage.contains_key(&profile.name) {
1419 checks.push(self.null_percentage_check(
1420 &profile.name,
1421 Some(profile),
1422 scan,
1423 max,
1424 ));
1425 }
1426 }
1427 }
1428 checks
1429 }
1430
1431 fn null_percentage_check(
1432 &self,
1433 column: &str,
1434 profile: Option<&ColumnProfile>,
1435 scan: Evidence,
1436 max: f64,
1437 ) -> Check {
1438 let mut check = Check {
1441 code: CheckCode::MaxNullPercentage,
1442 column: Some(column.to_string()),
1443 dimension: None,
1444 expected: Expectation::AtMost {
1445 value: MetricValue::Percentage(max),
1446 },
1447 observed: None,
1448 scope: self.scope,
1449 evidence: scan,
1450 bounds: None,
1451 baseline: None,
1452 status: CheckStatus::Passed,
1453 message: String::new(),
1454 };
1455 let Some(profile) = profile else {
1456 check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
1457 check.message = "this column has no profile in the report".to_string();
1458 return check;
1459 };
1460 let Some(percentage) = null_percentage(profile) else {
1461 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1464 check.message = "no values were read for this column".to_string();
1465 return check;
1466 };
1467 check.observed = Some(MetricValue::Percentage(percentage));
1468 check.status =
1470 self.decide_aggregate(check.evidence, rounded_2(percentage) <= rounded_2(max));
1471 check.message = match check.status {
1472 CheckStatus::Passed => "this column's null percentage is within the allowance",
1473 CheckStatus::Failed => "this column's null percentage is above the allowance",
1474 CheckStatus::NotEvaluated(_) => {
1475 "the scan does not cover the whole source, and a null percentage over \
1476 part of it bounds nothing about the rest"
1477 }
1478 }
1479 .to_string();
1480 check
1481 }
1482
1483 fn duplicate_rows_check(&self, report: &ProfileReport, scan: Evidence, max: usize) -> Check {
1490 let mut check = Check {
1491 code: CheckCode::MaxDuplicateRows,
1492 column: None,
1493 dimension: Some(QualityDimension::Uniqueness),
1494 expected: Expectation::AtMost {
1495 value: MetricValue::Count(max),
1496 },
1497 observed: None,
1498 scope: self.scope,
1499 evidence: scan.and(quality_evidence(
1500 report.quality.as_ref(),
1501 Provenance::Component("duplicate_rows"),
1502 )),
1503 bounds: None,
1504 baseline: None,
1505 status: CheckStatus::Passed,
1506 message: String::new(),
1507 };
1508 let Some(quality) = report.quality.as_ref() else {
1509 return unavailable_quality(check, report);
1510 };
1511 let Some(uniqueness) = quality
1512 .metrics
1513 .uniqueness
1514 .as_ref()
1515 .filter(|metrics| metrics.rows_checked > 0)
1516 else {
1517 check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1518 check.message = "no rows were scanned for duplicates in this run".to_string();
1519 return check;
1520 };
1521 let observed = uniqueness.duplicate_rows;
1522 let exceeded = observed > max;
1523 let estimated = uniqueness.duplicate_rows_approximate;
1524 check.observed = Some(MetricValue::Count(observed));
1525 check.status = match (self.scope, check.evidence.gap()) {
1526 (PolicyScope::FullSource, Some(_)) if exceeded && !estimated => CheckStatus::Failed,
1528 (PolicyScope::FullSource, Some(gap)) => {
1529 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1530 }
1531 _ if exceeded => CheckStatus::Failed,
1532 _ => CheckStatus::Passed,
1533 };
1534 check.message = match check.status {
1535 CheckStatus::Passed => "the duplicate-row count is within the allowance",
1536 CheckStatus::Failed if estimated => {
1537 "the estimated duplicate-row count is above the allowance"
1538 }
1539 CheckStatus::Failed => "duplicate rows were observed above the allowance",
1540 CheckStatus::NotEvaluated(_) if estimated => {
1543 "the duplicate-row count is an estimate, which can neither witness \
1544 duplicates above the allowance nor rule them out"
1545 }
1546 CheckStatus::NotEvaluated(_) => {
1547 "no duplicate above the allowance was observed, and the rows that were not \
1548 read may hold more"
1549 }
1550 }
1551 .to_string();
1552 check
1553 }
1554
1555 fn score_drop_check(
1559 &self,
1560 report: &ProfileReport,
1561 delta: &ReportDelta,
1562 identifiers: &Sides<Option<&[String]>>,
1563 dimension: Option<QualityDimension>,
1564 points: f64,
1565 ) -> Check {
1566 let (code, change, subject) = match dimension {
1567 None => (
1568 CheckCode::MaxQualityScoreDrop,
1569 &delta.quality_score,
1570 "the overall quality score",
1571 ),
1572 Some(dimension) => (
1573 CheckCode::MaxDimensionScoreDrop,
1574 &delta
1575 .dimensions
1576 .iter()
1577 .find(|change| change.dimension == dimension)
1578 .expect("a delta lists every dimension this build knows")
1579 .score,
1580 "this dimension's score",
1581 ),
1582 };
1583 let mut check = Check {
1584 code,
1585 column: None,
1586 dimension,
1587 expected: Expectation::ChangeAtLeast {
1590 value: MetricValue::Percentage(0.0 - points),
1591 },
1592 observed: change.b.map(MetricValue::Percentage),
1593 scope: self.scope,
1594 evidence: change.evidence.b,
1595 bounds: None,
1596 baseline: Some(baseline_reading(change.a, change.evidence.a, change.abs)),
1597 status: CheckStatus::Passed,
1598 message: String::new(),
1599 };
1600 if report.quality.is_none() {
1601 return unavailable_quality(check, report);
1602 }
1603 let schema_changed = !delta.schema.added.is_empty() || !delta.schema.removed.is_empty();
1604 let dimensions_changed = dimension.is_none()
1608 && delta
1609 .dimensions
1610 .iter()
1611 .any(|change| change.score.a.is_some() != change.score.b.is_some());
1612 let reason = if change.b.is_none() {
1613 Some(NotEvaluated::NotAssessed)
1614 } else if change.a.is_none() {
1615 Some(NotEvaluated::NotInBaseline)
1616 } else if schema_changed {
1617 Some(NotEvaluated::SchemaChanged)
1618 } else if dimensions_changed {
1619 Some(NotEvaluated::DimensionsChanged)
1620 } else {
1621 definition_gap(
1622 delta.metric_semantics.a.as_ref(),
1623 delta.metric_semantics.b.as_ref(),
1624 score_definitions(dimension),
1625 )
1626 .or_else(|| setting_gap(&delta.configuration, identifiers, dimension))
1627 };
1628 if let Some(reason) = reason {
1629 return not_evaluated_change(check, reason, subject);
1630 }
1631 let change_points = change
1632 .abs
1633 .expect("a delta has a change whenever both sides have a score");
1634 check.status =
1637 self.decide_change(&change.evidence, change_points >= 0.0 - rounded_2(points));
1638 check.message = match check.status {
1639 CheckStatus::Passed => {
1640 format!("{subject} dropped no more than allowed from the baseline")
1641 }
1642 CheckStatus::Failed => format!("{subject} dropped more than allowed from the baseline"),
1643 CheckStatus::NotEvaluated(_) => evidence_message(subject),
1644 };
1645 check
1646 }
1647
1648 fn null_increase_checks(&self, delta: &ReportDelta) -> Vec<Check> {
1652 let index = ColumnIndex::new(delta);
1653 let mut checks = Vec::new();
1654 for (column, points) in &self.max_null_percentage_increase {
1655 checks.push(self.null_increase_check(delta, &index, column, *points));
1656 }
1657 if let Some(points) = self.max_null_percentage_increase_any {
1658 for column in &delta.schema.common {
1659 if !self.max_null_percentage_increase.contains_key(column) {
1660 checks.push(self.null_increase_check(delta, &index, column, points));
1661 }
1662 }
1663 }
1664 checks
1665 }
1666
1667 fn null_increase_check(
1673 &self,
1674 delta: &ReportDelta,
1675 index: &ColumnIndex<'_>,
1676 column: &str,
1677 points: f64,
1678 ) -> Check {
1679 let change = index.changes.get(column).copied();
1680 let (null_pct_a, null_pct_b, null_pct_delta) = change.map_or(
1681 (None, None, None),
1682 |ColumnChange {
1683 null_pct_a,
1684 null_pct_b,
1685 null_pct_delta,
1686 ..
1687 }| (*null_pct_a, *null_pct_b, *null_pct_delta),
1688 );
1689 let in_report = index.in_report.contains(column);
1690 let in_baseline = index.in_baseline.contains(column);
1691 let evidence = Sides {
1692 a: delta.reports.a.evidence,
1693 b: delta.reports.b.evidence,
1694 };
1695 let mut check = Check {
1696 code: CheckCode::MaxNullPercentageIncrease,
1697 column: Some(column.to_string()),
1698 dimension: None,
1699 expected: Expectation::ChangeAtMost {
1700 value: MetricValue::Percentage(points),
1701 },
1702 observed: null_pct_b.map(MetricValue::Percentage),
1703 scope: self.scope,
1704 evidence: evidence.b,
1705 bounds: None,
1706 baseline: Some(baseline_reading(null_pct_a, evidence.a, null_pct_delta)),
1707 status: CheckStatus::Passed,
1708 message: String::new(),
1709 };
1710 let reason = if !in_report {
1711 Some(NotEvaluated::ColumnNotProfiled)
1712 } else if !in_baseline {
1713 Some(NotEvaluated::NotInBaseline)
1714 } else if null_pct_b.is_none() {
1715 Some(NotEvaluated::NotAssessed)
1716 } else if null_pct_a.is_none() {
1717 Some(NotEvaluated::NotInBaseline)
1718 } else {
1719 definition_gap(
1720 delta.metric_semantics.a.as_ref(),
1721 delta.metric_semantics.b.as_ref(),
1722 &[Definition::NullTokens],
1723 )
1724 };
1725 let subject = "this column's null percentage";
1726 if let Some(reason) = reason {
1727 return not_evaluated_change(check, reason, subject);
1728 }
1729 let change_points =
1730 null_pct_delta.expect("a delta has a change whenever both sides have a value");
1731 check.status = self.decide_change(&evidence, change_points <= rounded_2(points));
1732 check.message = match check.status {
1733 CheckStatus::Passed => {
1734 format!("{subject} rose no more than allowed above the baseline")
1735 }
1736 CheckStatus::Failed => format!("{subject} rose more than allowed above the baseline"),
1737 CheckStatus::NotEvaluated(_) => evidence_message(subject),
1738 };
1739 check
1740 }
1741
1742 fn absence_status(
1749 &self,
1750 lacking: &ReportSide,
1751 column: &str,
1752 unselected: NotEvaluated,
1753 ) -> CheckStatus {
1754 match lacking.column_selection() {
1755 None => CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded {
1756 setting: "columns".to_string(),
1757 }),
1758 Some(selection) if !selection.includes(column) => CheckStatus::NotEvaluated(unselected),
1759 Some(_) => match (self.scope, lacking.schema_evidence().gap()) {
1760 (PolicyScope::FullSource, Some(gap)) => {
1761 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1762 }
1763 _ => CheckStatus::Failed,
1764 },
1765 }
1766 }
1767
1768 fn require_column_check(
1769 &self,
1770 current: &ReportSide,
1771 current_names: &HashSet<&str>,
1772 column: &str,
1773 ) -> Check {
1774 let status = if current_names.contains(column) {
1775 CheckStatus::Passed
1776 } else {
1777 self.absence_status(current, column, NotEvaluated::ColumnNotProfiled)
1778 };
1779 let message = match &status {
1780 CheckStatus::Passed => "the required column was profiled",
1781 CheckStatus::Failed => "the required column is not in the source the report read",
1782 CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled) => {
1783 "the run's column selection left the required column out"
1784 }
1785 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1786 "the report does not record which columns it selected, so a missing column \
1787 cannot be shown to be absent from the source"
1788 }
1789 CheckStatus::NotEvaluated(_) => {
1790 "the required column is not in the records read, and the records that were not \
1791 read may hold it"
1792 }
1793 };
1794 self.column_check(
1795 CheckCode::RequireColumn,
1796 column,
1797 Expectation::Present,
1798 current,
1799 None,
1800 status,
1801 message,
1802 )
1803 }
1804
1805 fn forbid_removed_check(
1806 &self,
1807 baseline: &ReportSide,
1808 current: &ReportSide,
1809 current_names: &HashSet<&str>,
1810 column: &str,
1811 ) -> Check {
1812 let status = if current_names.contains(column) {
1813 CheckStatus::Passed
1814 } else {
1815 self.absence_status(
1816 current,
1817 column,
1818 NotEvaluated::SettingChanged {
1819 setting: "columns".to_string(),
1820 },
1821 )
1822 };
1823 let message = match &status {
1824 CheckStatus::Passed => "the column the baseline profiled is still profiled",
1825 CheckStatus::Failed => {
1826 "a column the baseline profiled is not in the source the report read"
1827 }
1828 CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1829 "the report's column selection left out a column the baseline profiled"
1830 }
1831 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1832 "the report does not record which columns it selected, so a column the baseline \
1833 profiled cannot be shown to be absent from its source"
1834 }
1835 CheckStatus::NotEvaluated(_) => {
1836 "a column the baseline profiled is not in the records the report read, and the \
1837 records that were not read may hold it"
1838 }
1839 };
1840 self.column_check(
1841 CheckCode::ForbidRemovedColumn,
1842 column,
1843 Expectation::Present,
1844 current,
1845 Some(baseline),
1846 status,
1847 message,
1848 )
1849 }
1850
1851 fn forbid_added_check(
1852 &self,
1853 baseline: &ReportSide,
1854 current: &ReportSide,
1855 baseline_names: &HashSet<&str>,
1856 column: &str,
1857 ) -> Check {
1858 let status = if baseline_names.contains(column) {
1859 CheckStatus::Passed
1860 } else {
1861 self.absence_status(
1862 baseline,
1863 column,
1864 NotEvaluated::SettingChanged {
1865 setting: "columns".to_string(),
1866 },
1867 )
1868 };
1869 let message = match &status {
1870 CheckStatus::Passed => "the column is one the baseline profiled",
1871 CheckStatus::Failed => {
1872 "the report profiled a column the baseline's source did not have"
1873 }
1874 CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1875 "the baseline's column selection left this column out"
1876 }
1877 CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1878 "the baseline does not record which columns it selected, so this column cannot be \
1879 shown to be new"
1880 }
1881 CheckStatus::NotEvaluated(_) => {
1882 "this column is not in the records the baseline read, and the records it did not \
1883 read may hold it"
1884 }
1885 };
1886 self.column_check(
1887 CheckCode::ForbidAddedColumn,
1888 column,
1889 Expectation::InBaseline,
1890 current,
1891 Some(baseline),
1892 status,
1893 message,
1894 )
1895 }
1896
1897 fn unprofiled_columns_check(
1900 &self,
1901 baseline: &ReportSide,
1902 current: &ReportSide,
1903 reason: NotEvaluated,
1904 ) -> Check {
1905 let message = match &reason {
1906 NotEvaluated::ColumnNotProfiled => {
1907 "the run profiled a column selection, which may have left out a source column \
1908 the baseline lacked"
1909 }
1910 NotEvaluated::SettingUnrecorded { .. } => {
1911 "the report does not record which columns it selected, so its columns may not \
1912 be all of its source's"
1913 }
1914 _ => {
1915 "the report read part of its source, and the records it did not read may hold \
1916 a column the baseline lacked"
1917 }
1918 };
1919 Check {
1920 column: None,
1921 ..self.column_check(
1922 CheckCode::ForbidAddedColumn,
1923 "",
1924 Expectation::InBaseline,
1925 current,
1926 Some(baseline),
1927 CheckStatus::NotEvaluated(reason),
1928 message,
1929 )
1930 }
1931 }
1932
1933 #[allow(clippy::too_many_arguments)]
1936 fn column_check(
1937 &self,
1938 code: CheckCode,
1939 column: &str,
1940 expected: Expectation,
1941 current: &ReportSide,
1942 baseline: Option<&ReportSide>,
1943 status: CheckStatus,
1944 message: &str,
1945 ) -> Check {
1946 Check {
1947 code,
1948 column: Some(column.to_string()),
1949 dimension: None,
1950 expected,
1951 observed: None,
1952 scope: self.scope,
1953 evidence: current.schema_evidence(),
1954 bounds: None,
1955 baseline: baseline.map(|baseline| BaselineReading {
1956 observed: None,
1957 evidence: baseline.schema_evidence(),
1958 change: None,
1959 }),
1960 status,
1961 message: message.to_string(),
1962 }
1963 }
1964
1965 fn decide_change(&self, evidence: &Sides<Evidence>, satisfied: bool) -> CheckStatus {
1970 match (self.scope, evidence.b.gap().or(evidence.a.gap())) {
1971 (PolicyScope::FullSource, Some(gap)) => {
1972 CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1973 }
1974 _ if satisfied => CheckStatus::Passed,
1975 _ => CheckStatus::Failed,
1976 }
1977 }
1978}
1979
1980fn schema_diagnostics(
1987 baseline: &ReportSide,
1988 current: &ReportSide,
1989 delta: &ReportDelta,
1990) -> Vec<Diagnostic> {
1991 let diagnostic = |code, column: &str, data_type, message: &str| Diagnostic {
1992 code,
1993 column: column.to_string(),
1994 data_type,
1995 setting: None,
1996 message: message.to_string(),
1997 };
1998 let identified = |side: &ReportSide, column: &str| {
2001 side.identifier_order()
2002 .map(|names| names.iter().any(|name| name == column))
2003 };
2004 fn names(names: &[String]) -> HashSet<&str> {
2006 names.iter().map(String::as_str).collect()
2007 }
2008 fn by_name(side: &ReportSide) -> HashMap<&str, &ColumnSide> {
2009 side.columns()
2010 .iter()
2011 .map(|column| (column.name.as_str(), column))
2012 .collect()
2013 }
2014 let (added, removed) = (names(&delta.schema.added), names(&delta.schema.removed));
2015 let (baseline_columns, current_columns) = (by_name(baseline), by_name(current));
2016 delta
2017 .columns
2018 .iter()
2019 .filter_map(|change| {
2020 let column = change.name.as_str();
2021 let unselected = |side: &ReportSide| match side.column_selection() {
2025 Some(selection) if selection.includes(column) => None,
2026 Some(_) => Some(SettingDifference::SettingChanged {
2027 setting: "columns".to_string(),
2028 }),
2029 None => Some(SettingDifference::SettingUnrecorded {
2030 setting: "columns".to_string(),
2031 }),
2032 };
2033 if added.contains(column) {
2034 let setting = unselected(baseline);
2035 let message = match setting {
2036 None => {
2037 "the report profiled a column the baseline did not find in its source, \
2038 so the baseline has nothing to measure its change from"
2039 }
2040 Some(_) => {
2041 "the report profiled a column the baseline has no profile for, and the \
2042 baseline's column selection is not shown to include it"
2043 }
2044 };
2045 return Some(Diagnostic {
2046 setting,
2047 ..diagnostic(DiagnosticCode::ColumnAdded, column, None, message)
2048 });
2049 }
2050 if removed.contains(column) {
2051 let setting = unselected(current);
2052 let message = match setting {
2053 None => "the baseline profiled a column the report did not find in its source",
2054 Some(_) => {
2055 "the baseline profiled a column the report has no profile for, and the \
2056 report's column selection is not shown to include it"
2057 }
2058 };
2059 return Some(Diagnostic {
2060 setting,
2061 ..diagnostic(DiagnosticCode::ColumnRemoved, column, None, message)
2062 });
2063 }
2064 let (a, b) = (
2065 *baseline_columns.get(column)?,
2066 *current_columns.get(column)?,
2067 );
2068 if a.populated()? && b.all_null()? {
2069 return Some(diagnostic(
2070 DiagnosticCode::ColumnAllNull,
2071 column,
2072 None,
2073 "this column held values in the baseline, and every value read for it in \
2074 the report is null",
2075 ));
2076 }
2077 match (&a.data_type, &b.data_type) {
2078 (Some(type_a), Some(type_b))
2079 if type_a != type_b && a.populated()? && b.populated()? =>
2080 {
2081 let mut retyped = diagnostic(
2082 DiagnosticCode::ColumnTypeChanged,
2083 column,
2084 Some(Sides {
2085 a: type_a.clone(),
2086 b: type_b.clone(),
2087 }),
2088 "this column holds values on both sides, and its inferred type differs",
2089 );
2090 if *type_a == DataType::Identifier || *type_b == DataType::Identifier {
2094 let setting = "identifier_columns".to_string();
2095 retyped.setting =
2096 match (identified(baseline, column), identified(current, column)) {
2097 (Some(a), Some(b)) if a == b => None,
2098 (Some(_), Some(_)) => {
2099 Some(SettingDifference::SettingChanged { setting })
2100 }
2101 _ => Some(SettingDifference::SettingUnrecorded { setting }),
2102 };
2103 if retyped.setting.is_some() {
2104 retyped.message = "this column holds values on both sides, and its \
2105 inferred type differs; the identifier hint, \
2106 which sets the type, is not shown to be the \
2107 same on both sides"
2108 .to_string();
2109 }
2110 }
2111 Some(retyped)
2112 }
2113 _ => None,
2114 }
2115 })
2116 .collect()
2117}
2118
2119struct ColumnIndex<'a> {
2122 changes: HashMap<&'a str, &'a ColumnChange>,
2123 in_report: HashSet<&'a str>,
2125 in_baseline: HashSet<&'a str>,
2127}
2128
2129impl<'a> ColumnIndex<'a> {
2130 fn new(delta: &'a ReportDelta) -> Self {
2131 let schema = &delta.schema;
2132 let names = |lists: [&'a [String]; 2]| {
2133 lists
2134 .into_iter()
2135 .flatten()
2136 .map(String::as_str)
2137 .collect::<HashSet<_>>()
2138 };
2139 Self {
2140 changes: delta
2141 .columns
2142 .iter()
2143 .map(|change| (change.name.as_str(), change))
2144 .collect(),
2145 in_report: names([&schema.common, &schema.added]),
2146 in_baseline: names([&schema.common, &schema.removed]),
2147 }
2148 }
2149}
2150
2151fn unenumerated_columns(side: &ReportSide) -> Option<NotEvaluated> {
2156 match side.column_selection() {
2157 None => Some(NotEvaluated::SettingUnrecorded {
2158 setting: "columns".to_string(),
2159 }),
2160 Some(ColumnSelection::All) => side
2161 .schema_evidence()
2162 .gap()
2163 .map(|gap| NotEvaluated::EvidenceIncomplete { gap }),
2164 Some(_) => Some(NotEvaluated::ColumnNotProfiled),
2165 }
2166}
2167
2168fn column_names(side: &ReportSide) -> HashSet<&str> {
2169 side.columns()
2170 .iter()
2171 .map(|column| column.name.as_str())
2172 .collect()
2173}
2174
2175fn baseline_reading(
2176 observed: Option<f64>,
2177 evidence: Evidence,
2178 change: Option<f64>,
2179) -> BaselineReading {
2180 BaselineReading {
2181 observed: observed.map(MetricValue::Percentage),
2182 evidence,
2183 change: change.map(MetricValue::Percentage),
2184 }
2185}
2186
2187fn not_evaluated_change(mut check: Check, reason: NotEvaluated, subject: &str) -> Check {
2189 check.message = match &reason {
2190 NotEvaluated::NotAssessed if check.column.is_some() => {
2191 "no values were read for this column".to_string()
2192 }
2193 NotEvaluated::NotAssessed => format!("{subject} was not assessed in this report"),
2194 NotEvaluated::ColumnNotProfiled => "this column has no profile in the report".to_string(),
2195 NotEvaluated::NotInBaseline => format!("the baseline carries no value for {subject}"),
2196 NotEvaluated::SchemaChanged => format!(
2197 "the two reports profiled different columns, so {subject} has a different subject \
2198 on each side"
2199 ),
2200 NotEvaluated::DimensionsChanged => {
2201 format!("{subject} averages a different set of assessed dimensions on each side")
2202 }
2203 NotEvaluated::DefinitionNotComparable { .. } => format!(
2204 "a measurement definition {subject} depends on is not recorded identically in both \
2205 reports"
2206 ),
2207 NotEvaluated::SettingChanged { .. } => format!(
2208 "a setting that moves {subject} on unchanged data differs between the two reports"
2209 ),
2210 NotEvaluated::SettingUnrecorded { .. } => format!(
2211 "a setting that moves {subject} on unchanged data is not recorded in both reports"
2212 ),
2213 NotEvaluated::QualityUnavailable { .. } => {
2214 "the report carries no quality assessment".to_string()
2215 }
2216 NotEvaluated::EvidenceIncomplete { .. } => evidence_message(subject),
2217 };
2218 check.status = CheckStatus::NotEvaluated(reason);
2219 check
2220}
2221
2222fn evidence_message(subject: &str) -> String {
2223 format!(
2224 "{subject} was computed over part of the source on at least one side, which bounds \
2225 nothing about the rest"
2226 )
2227}
2228
2229#[derive(Debug, Clone, Copy)]
2231enum Definition {
2232 NullTokens,
2235 PatternMatchCount,
2237 RangeRuleCount,
2239 KeyUniquenessUnits,
2241 OutlierUnits,
2243 KeyColumnSource,
2245}
2246
2247impl Definition {
2248 fn key(self) -> &'static str {
2249 match self {
2250 Self::NullTokens => "null_tokens",
2251 Self::PatternMatchCount => "pattern_match_count",
2252 Self::RangeRuleCount => "range_rule_count",
2253 Self::KeyUniquenessUnits => "key_uniqueness_units",
2254 Self::OutlierUnits => "outlier_units",
2255 Self::KeyColumnSource => "key_column_source",
2256 }
2257 }
2258
2259 fn same(self, a: &MetricSemantics, b: &MetricSemantics) -> bool {
2261 fn agree<T: PartialEq>(a: &Option<T>, b: &Option<T>) -> bool {
2262 a.is_some() && a == b
2263 }
2264 match self {
2265 Self::NullTokens => agree(&a.null_tokens, &b.null_tokens),
2266 Self::PatternMatchCount => agree(&a.pattern_match_count, &b.pattern_match_count),
2267 Self::RangeRuleCount => agree(&a.range_rule_count, &b.range_rule_count),
2268 Self::KeyUniquenessUnits => agree(&a.key_uniqueness_units, &b.key_uniqueness_units),
2269 Self::OutlierUnits => agree(&a.outlier_units, &b.outlier_units),
2270 Self::KeyColumnSource => agree(&a.key_column_source, &b.key_column_source),
2271 }
2272 }
2273}
2274
2275fn score_definitions(dimension: Option<QualityDimension>) -> &'static [Definition] {
2278 match dimension {
2279 None => &[
2280 Definition::NullTokens,
2281 Definition::PatternMatchCount,
2282 Definition::RangeRuleCount,
2283 Definition::KeyUniquenessUnits,
2284 Definition::OutlierUnits,
2285 Definition::KeyColumnSource,
2286 ],
2287 Some(QualityDimension::Validity) => {
2288 &[Definition::NullTokens, Definition::PatternMatchCount]
2289 }
2290 Some(QualityDimension::Accuracy) => &[
2291 Definition::NullTokens,
2292 Definition::RangeRuleCount,
2293 Definition::OutlierUnits,
2294 ],
2295 Some(QualityDimension::Uniqueness) => &[
2296 Definition::NullTokens,
2297 Definition::KeyUniquenessUnits,
2298 Definition::KeyColumnSource,
2299 ],
2300 Some(_) => &[Definition::NullTokens],
2301 }
2302}
2303
2304fn definition_gap(
2306 a: Option<&MetricSemantics>,
2307 b: Option<&MetricSemantics>,
2308 definitions: &[Definition],
2309) -> Option<NotEvaluated> {
2310 definitions
2311 .iter()
2312 .find(|definition| !a.zip(b).is_some_and(|(a, b)| definition.same(a, b)))
2313 .map(|definition| NotEvaluated::DefinitionNotComparable {
2314 definition: definition.key().to_string(),
2315 })
2316}
2317
2318type HintColumns = fn(&RecordedHints) -> &Vec<String>;
2320
2321#[derive(Debug, Clone, Copy)]
2323enum Agreement {
2324 Same,
2325 Changed,
2326 Unrecorded,
2327}
2328
2329fn agreement<T: PartialEq + ?Sized>(a: Option<&T>, b: Option<&T>) -> Agreement {
2330 match (a, b) {
2331 (Some(a), Some(b)) if a == b => Agreement::Same,
2332 (Some(_), Some(_)) => Agreement::Changed,
2333 _ => Agreement::Unrecorded,
2334 }
2335}
2336
2337fn setting_gap(
2344 configuration: &ConfigurationComparison,
2345 identifiers: &Sides<Option<&[String]>>,
2346 dimension: Option<QualityDimension>,
2347) -> Option<NotEvaluated> {
2348 let hints = &configuration.semantic_hints;
2349 let reads = |dimensions: &[QualityDimension]| {
2350 dimension.is_none_or(|dimension| dimensions.contains(&dimension))
2351 };
2352 let hint = |columns: HintColumns| {
2353 agreement(hints.a.as_ref().map(columns), hints.b.as_ref().map(columns))
2354 };
2355 let recorded = |same: Option<bool>| match same {
2356 Some(true) => Agreement::Same,
2357 Some(false) => Agreement::Changed,
2358 None => Agreement::Unrecorded,
2359 };
2360 let pairs = match hint(|hints| &hints.temporal_pair_columns) {
2363 Agreement::Same
2364 if hints
2365 .a
2366 .as_ref()
2367 .is_some_and(|hints| !hints.temporal_pair_columns.is_empty()) =>
2368 {
2369 Agreement::Unrecorded
2370 }
2371 other => other,
2372 };
2373 let settings = [
2374 (
2375 "score_weights",
2376 dimension.is_none(),
2377 recorded(configuration.score_weights.same),
2378 ),
2379 (
2380 "identifier_columns",
2381 dimension != Some(QualityDimension::Completeness),
2382 agreement(identifiers.a, identifiers.b),
2384 ),
2385 (
2386 "positive_columns",
2387 reads(&[QualityDimension::Accuracy]),
2388 hint(|hints| &hints.positive_columns),
2389 ),
2390 (
2391 "temporal_columns",
2392 reads(&[QualityDimension::Timeliness]),
2393 hint(|hints| &hints.temporal_columns),
2394 ),
2395 (
2396 "temporal_pairs",
2397 reads(&[QualityDimension::Timeliness]),
2398 pairs,
2399 ),
2400 (
2401 "pattern_locale",
2402 reads(&[QualityDimension::Validity]),
2403 recorded(configuration.pattern_locale.same),
2404 ),
2405 ];
2406 settings
2407 .into_iter()
2408 .filter(|(_, read, _)| *read)
2409 .find_map(|(setting, _, agreement)| match agreement {
2410 Agreement::Same => None,
2411 Agreement::Changed => Some(NotEvaluated::SettingChanged {
2412 setting: setting.to_string(),
2413 }),
2414 Agreement::Unrecorded => Some(NotEvaluated::SettingUnrecorded {
2415 setting: setting.to_string(),
2416 }),
2417 })
2418}
2419
2420fn check_percentage(
2421 code: CheckCode,
2422 subject: Option<String>,
2423 value: f64,
2424) -> Result<(), PolicyError> {
2425 if value.is_finite() && (0.0..=PERCENTAGE_MAX).contains(&value) {
2426 return Ok(());
2427 }
2428 Err(PolicyError::ThresholdOutOfRange {
2429 code,
2430 subject,
2431 value,
2432 })
2433}
2434
2435fn required_metrics_in_order(requested: &[RequiredMetric]) -> Vec<RequiredMetric> {
2437 let mut ordered = Vec::new();
2438 if requested.contains(&RequiredMetric::Quality) {
2439 ordered.push(RequiredMetric::Quality);
2440 }
2441 for dimension in QualityDimension::all() {
2442 if requested.contains(&RequiredMetric::Dimension(dimension)) {
2443 ordered.push(RequiredMetric::Dimension(dimension));
2444 }
2445 }
2446 ordered
2447}
2448
2449fn verdict_of(checks: &[Check]) -> Verdict {
2450 if checks.iter().any(Check::is_violation) {
2451 Verdict::Fail
2452 } else if checks.iter().any(|check| check.not_evaluated().is_some()) {
2453 Verdict::Inconclusive
2454 } else {
2455 Verdict::Pass
2456 }
2457}
2458
2459fn unavailable_quality(mut check: Check, report: &ProfileReport) -> Check {
2463 let quality_status = quality_status_name(&report.quality_status).to_string();
2464 check.message = match &report.quality_status {
2465 QualityAnalysisStatus::NotRequested => {
2466 "quality metrics were not requested for this run".to_string()
2467 }
2468 QualityAnalysisStatus::NoData => {
2469 "quality was requested but no sample was available to measure".to_string()
2470 }
2471 QualityAnalysisStatus::WithheldByProjection => {
2472 "quality was withheld: the requested dimensions measure whole rows and only \
2473 some columns were profiled"
2474 .to_string()
2475 }
2476 QualityAnalysisStatus::Failed { .. } => "the quality computation failed".to_string(),
2477 QualityAnalysisStatus::Unrecorded | QualityAnalysisStatus::Computed => {
2478 "the report carries no quality assessment".to_string()
2479 }
2480 };
2481 check.status = CheckStatus::NotEvaluated(NotEvaluated::QualityUnavailable { quality_status });
2482 check
2483}
2484
2485fn aggregate_message(status: &CheckStatus, subject: &str) -> String {
2486 match status {
2487 CheckStatus::Passed => format!("{subject} meets the required minimum"),
2488 CheckStatus::Failed => format!("{subject} is below the required minimum"),
2489 CheckStatus::NotEvaluated(_) => format!(
2490 "{subject} was computed over part of the source, which bounds nothing about \
2491 the rest"
2492 ),
2493 }
2494}
2495
2496fn bounded_message(status: &CheckStatus, subject: &str) -> String {
2497 match status {
2498 CheckStatus::Passed => format!(
2499 "{subject} was computed over a sample, and its whole-source interval meets the \
2500 required minimum"
2501 ),
2502 CheckStatus::Failed => format!(
2503 "{subject} was computed over a sample, and its whole-source interval is below the \
2504 required minimum"
2505 ),
2506 CheckStatus::NotEvaluated(_) => format!(
2507 "{subject} was computed over a sample, and the required minimum lies within its \
2508 whole-source interval"
2509 ),
2510 }
2511}
2512
2513fn check_bounds(bounds: &ScoreBounds, interval: Option<ScoreInterval>) -> Option<CheckBounds> {
2515 interval.map(|interval| CheckBounds {
2516 lower: interval.lower,
2517 upper: interval.upper,
2518 confidence_level: bounds.confidence_level,
2519 })
2520}
2521
2522pub(crate) fn quality_status_name(status: &QualityAnalysisStatus) -> &'static str {
2524 match status {
2525 QualityAnalysisStatus::Computed => "computed",
2526 QualityAnalysisStatus::NotRequested => "not_requested",
2527 QualityAnalysisStatus::NoData => "no_data",
2528 QualityAnalysisStatus::WithheldByProjection => "withheld_by_projection",
2529 QualityAnalysisStatus::Failed { .. } => "failed",
2530 QualityAnalysisStatus::Unrecorded => "unrecorded",
2531 }
2532}
2533
2534fn null_percentage(profile: &ColumnProfile) -> Option<f64> {
2537 (profile.total_count > 0)
2538 .then(|| profile.null_count as f64 / profile.total_count as f64 * PERCENTAGE_MAX)
2539}
2540
2541pub(crate) fn scan_evidence(execution: &ExecutionMetadata) -> Evidence {
2546 scan_coverage(
2547 !execution.source_exhausted || execution.truncation_reason.is_some(),
2548 execution.sampling_applied,
2549 execution.error_count > 0,
2550 )
2551}
2552
2553pub(crate) fn scan_coverage(truncated: bool, sampled: bool, records_skipped: bool) -> Evidence {
2556 let reason = if truncated {
2557 Some(EvidenceGap::Truncated)
2558 } else if sampled {
2559 Some(EvidenceGap::Sampled)
2560 } else if records_skipped {
2561 Some(EvidenceGap::RecordsSkipped)
2562 } else {
2563 None
2564 };
2565 match reason {
2566 Some(reason) => Evidence::Incomplete { reason },
2567 None => Evidence::Complete,
2568 }
2569}
2570
2571#[derive(Debug, Clone, Copy)]
2574pub(crate) enum Provenance {
2575 Overall,
2577 Dimension(QualityDimension),
2579 Component(&'static str),
2581}
2582
2583pub(crate) fn dimension_components(dimension: QualityDimension) -> &'static [&'static str] {
2589 match dimension {
2590 QualityDimension::Completeness => &["completeness"],
2591 QualityDimension::Consistency => &["consistency"],
2592 QualityDimension::Uniqueness => &["key_uniqueness", "duplicate_rows"],
2593 QualityDimension::Accuracy => &["accuracy"],
2594 QualityDimension::Timeliness => &["timeliness"],
2595 QualityDimension::Validity => &["validity"],
2596 QualityDimension::Precision => &["precision"],
2597 }
2598}
2599
2600fn is_known(label: &str) -> bool {
2602 QualityDimension::all()
2603 .into_iter()
2604 .flat_map(dimension_components)
2605 .any(|component| *component == label)
2606}
2607
2608fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
2614 let Some(quality) = quality else {
2615 return Evidence::Complete;
2618 };
2619 sampled_component_evidence(
2620 quality.sampled_dimensions().as_deref(),
2621 &quality.unlisted_components(),
2622 &quality.metrics.assessed_dimensions(),
2623 provenance,
2624 )
2625}
2626
2627pub(crate) fn sampled_component_evidence(
2636 sampled: Option<&[String]>,
2637 unlisted: &[String],
2638 assessed: &[QualityDimension],
2639 provenance: Provenance,
2640) -> Evidence {
2641 let Some(sampled) = sampled else {
2642 return Evidence::Incomplete {
2643 reason: EvidenceGap::CoverageUnrecorded,
2644 };
2645 };
2646 if matches!(provenance, Provenance::Overall) && !sampled.iter().all(|label| is_known(label)) {
2651 return Evidence::Incomplete {
2652 reason: EvidenceGap::CoverageUnrecorded,
2653 };
2654 }
2655 let reads: Vec<&str> = match provenance {
2660 Provenance::Overall => assessed
2661 .iter()
2662 .copied()
2663 .flat_map(dimension_components)
2664 .copied()
2665 .collect(),
2666 Provenance::Dimension(dimension) => dimension_components(dimension).to_vec(),
2667 Provenance::Component(component) => vec![component],
2668 };
2669 let any_in = |labels: &[String]| {
2670 reads
2671 .iter()
2672 .any(|component| labels.iter().any(|label| label == component))
2673 };
2674 if any_in(unlisted) {
2677 return Evidence::Incomplete {
2678 reason: EvidenceGap::CoverageUnrecorded,
2679 };
2680 }
2681 if any_in(sampled) {
2682 Evidence::Incomplete {
2683 reason: EvidenceGap::QualitySampled,
2684 }
2685 } else {
2686 Evidence::Complete
2687 }
2688}
2689
2690#[cfg(doctest)]
2730struct NonExhaustiveGuards;
2731
2732#[cfg(test)]
2733mod tests {
2734 use std::collections::HashMap;
2735
2736 use dataprof_core::{
2737 ColumnStats, DataSource, DataType, FileFormat, QualityScoreWeights, TruncationReason,
2738 };
2739 use dataprof_metrics::{QualityMetrics, UniquenessMetrics};
2740 use serde_json::{Value, json};
2741
2742 use super::*;
2743
2744 #[test]
2750 fn every_public_type_is_non_exhaustive() {
2751 let lines: Vec<&str> = include_str!("quality_gate.rs").lines().collect();
2752 let marked = |i: usize| {
2753 lines[..i]
2754 .iter()
2755 .rev()
2756 .map(|l| l.trim())
2757 .take_while(|l| l.starts_with("#[") || l.starts_with("///"))
2758 .any(|l| l == "#[non_exhaustive]")
2759 };
2760 let identifier = |s: &str| -> String {
2761 s.chars()
2762 .take_while(|c| c.is_alphanumeric() || *c == '_')
2763 .collect()
2764 };
2765 let mut declared = Vec::new();
2766 let mut variants = Vec::new();
2767 let mut unmarked = Vec::new();
2768 let mut in_enum: Option<String> = None;
2769 for (i, line) in lines.iter().enumerate() {
2770 if let Some(enum_name) = &in_enum {
2771 if *line == "}" {
2772 in_enum = None;
2773 } else if let Some(rest) = line.strip_prefix(" ")
2774 && rest.starts_with(|c: char| c.is_ascii_uppercase())
2775 && rest.contains(" {")
2776 {
2777 let variant = format!("{enum_name}::{}", identifier(rest));
2778 if !marked(i) {
2779 unmarked.push(variant.clone());
2780 }
2781 variants.push(variant);
2782 }
2783 continue;
2784 }
2785 let Some(rest) = line
2786 .strip_prefix("pub enum ")
2787 .or_else(|| line.strip_prefix("pub struct "))
2788 else {
2789 continue;
2790 };
2791 let name = identifier(rest);
2792 if !marked(i) {
2793 unmarked.push(name.clone());
2794 }
2795 if line.starts_with("pub enum ") {
2796 in_enum = Some(name.clone());
2797 }
2798 declared.push(name);
2799 }
2800 assert!(declared.len() >= 15, "{declared:?}");
2803 assert!(variants.len() >= 6, "{variants:?}");
2804 assert!(unmarked.is_empty(), "not #[non_exhaustive]: {unmarked:?}");
2805 }
2806 use crate::ReportAssembler;
2807
2808 fn source() -> DataSource {
2809 DataSource::File {
2810 path: "drop.csv".to_string(),
2811 format: FileFormat::Csv,
2812 size_bytes: 256,
2813 modified_at: None,
2814 parquet_metadata: None,
2815 }
2816 }
2817
2818 fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
2819 ColumnProfile {
2820 name: name.to_string(),
2821 data_type: DataType::String,
2822 null_count: nulls,
2823 total_count: total,
2824 unique_count: None,
2825 unique_count_is_approximate: None,
2826 unique_count_lower_bound: None,
2827 invalid_count: None,
2828 type_homogeneity: None,
2829 locale_number_count: None,
2830 stats: ColumnStats::None,
2831 patterns: None,
2832 }
2833 }
2834
2835 fn sample_data() -> HashMap<String, Vec<String>> {
2837 let ids = (1..=5).map(|n| n.to_string()).collect::<Vec<_>>();
2838 let notes = vec!["a", "b", "c", "d", ""]
2839 .into_iter()
2840 .map(str::to_string)
2841 .collect::<Vec<_>>();
2842 HashMap::from([("id".to_string(), ids), ("note".to_string(), notes)])
2843 }
2844
2845 fn complete_report() -> ProfileReport {
2846 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2847 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2848 .with_quality_data(sample_data())
2849 .build()
2850 }
2851
2852 fn truncated_report() -> ProfileReport {
2853 ReportAssembler::new(
2854 source(),
2855 ExecutionMetadata::new(5, 2, 10).with_truncation(TruncationReason::MaxRows(5)),
2856 )
2857 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2858 .with_quality_data(sample_data())
2859 .build()
2860 }
2861
2862 fn no_quality_report() -> ProfileReport {
2863 ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2864 .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2865 .with_quality_data(sample_data())
2866 .skip_quality()
2867 .build()
2868 }
2869
2870 fn duplicate_report(duplicates: usize, approximate: bool) -> ProfileReport {
2873 let metrics = QualityMetrics {
2874 uniqueness: Some(UniquenessMetrics {
2875 duplicate_rows: duplicates,
2876 key_uniqueness: 100.0,
2877 high_cardinality_warning: false,
2878 rows_checked: 5,
2879 key_column: None,
2880 key_values_checked: None,
2881 duplicate_rows_approximate: approximate,
2882 }),
2883 ..QualityMetrics::default()
2884 };
2885 ProfileReport::new(
2886 source(),
2887 vec![column("id", 5, 0)],
2888 ExecutionMetadata::new(5, 1, 10).with_truncation(TruncationReason::MaxRows(5)),
2889 Some(QualityAssessment::exact(metrics)),
2890 )
2891 }
2892
2893 fn check_for(result: &GateResult, code: CheckCode) -> &Check {
2894 result
2895 .checks
2896 .iter()
2897 .find(|check| check.code == code)
2898 .unwrap_or_else(|| panic!("no {code} check in {:?}", result.checks))
2899 }
2900
2901 fn column_check<'a>(result: &'a GateResult, name: &str) -> &'a Check {
2902 result
2903 .checks
2904 .iter()
2905 .find(|check| check.column.as_deref() == Some(name))
2906 .unwrap_or_else(|| panic!("no check for column {name}"))
2907 }
2908
2909 fn reservoir_report() -> ProfileReport {
2918 ReportAssembler::new(source(), ExecutionMetadata::new(100, 2, 10))
2919 .columns(vec![column("id", 100, 0), column("note", 100, 20)])
2920 .with_quality_data(sample_data())
2921 .build()
2922 }
2923
2924 #[test]
2925 fn a_reservoir_backed_score_is_decided_on_its_whole_source_interval() {
2926 let report = reservoir_report();
2927 assert!(report.execution.source_exhausted);
2928 assert!(!report.execution.sampling_applied);
2929 let quality = report.quality.as_ref().expect("quality was computed");
2930 let sampled = quality
2931 .sampled_dimensions()
2932 .expect("a profiling run records its provenance");
2933 assert!(
2934 !sampled.is_empty(),
2935 "the assembler did not bifurcate; this test no longer reaches the \
2936 case it guards"
2937 );
2938 let interval = quality
2939 .score_bounds()
2940 .expect("a sampled assessment carries bounds")
2941 .overall_score
2942 .expect("every sampled dimension here is bounded");
2943 assert!(
2944 interval.lower < interval.upper && interval.upper < 100.0,
2945 "a five-value sample leaves room on both sides: {interval:?}"
2946 );
2947 let decide = |min: f64| {
2948 let result = QualityPolicy::new()
2949 .min_quality_score(min)
2950 .evaluate(&report)
2951 .unwrap();
2952 assert_eq!(result.evidence, Evidence::Complete);
2955 let check = check_for(&result, CheckCode::MinQualityScore).clone();
2956 assert_eq!(
2957 check.evidence,
2958 Evidence::Incomplete {
2959 reason: EvidenceGap::QualitySampled
2960 }
2961 );
2962 assert_eq!(
2963 check.bounds.map(|bounds| (bounds.lower, bounds.upper)),
2964 Some((interval.lower, interval.upper))
2965 );
2966 (result.verdict, check)
2967 };
2968
2969 let (verdict, _) = decide(interval.lower);
2970 assert_eq!(verdict, Verdict::Pass);
2971
2972 let (verdict, check) = decide((interval.lower + interval.upper) / 2.0);
2973 assert_eq!(verdict, Verdict::Inconclusive);
2974 assert_eq!(
2975 check.not_evaluated(),
2976 Some(&NotEvaluated::EvidenceIncomplete {
2977 gap: EvidenceGap::QualitySampled
2978 })
2979 );
2980
2981 let (verdict, _) = decide(interval.upper + 0.01);
2982 assert_eq!(verdict, Verdict::Fail);
2983 }
2984
2985 #[test]
2991 fn a_threshold_at_the_shown_value_passes_live_and_after_a_round_trip() {
2992 let thirds = ReportAssembler::new(source(), ExecutionMetadata::new(3, 1, 10))
2994 .columns(vec![column("x", 3, 1)])
2995 .with_quality_data(HashMap::from([(
2996 "x".to_string(),
2997 vec!["1".to_string(), "2".to_string(), String::new()],
2998 )]))
2999 .build();
3000 let mut wrong = Vec::new();
3001 for (label, report) in [
3002 ("thirds", thirds),
3003 ("reservoir", reservoir_report()),
3004 ("complete", complete_report()),
3005 ] {
3006 let loaded: ProfileReport =
3007 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3008 let quality = report.quality.as_ref().expect("quality was computed");
3009 let mut policies = Vec::new();
3010 if quality
3013 .score_bounds()
3014 .and_then(|bounds| bounds.overall_score)
3015 .is_none()
3016 {
3017 policies.push((
3018 "overall".to_string(),
3019 QualityPolicy::new()
3020 .min_quality_score(rounded_2(report.quality_score().unwrap())),
3021 ));
3022 }
3023 for (name, score) in &quality.scores().dimension_scores {
3024 let Some(score) = score else { continue };
3025 let shown = quality
3028 .score_bounds()
3029 .and_then(|bounds| bounds.dimension_scores.get(name).copied().flatten())
3030 .map_or(*score, |interval| interval.lower);
3031 let dimension: QualityDimension = name.parse().unwrap();
3032 policies.push((
3033 name.clone(),
3034 QualityPolicy::new().min_dimension_score(dimension, rounded_2(shown)),
3035 ));
3036 }
3037 for column in &report.column_profiles {
3038 let shown = rounded_2(null_percentage(column).unwrap());
3039 policies.push((
3040 format!("null {}", column.name),
3041 QualityPolicy::new().max_null_percentage(column.name.clone(), shown),
3042 ));
3043 }
3044 if let Some(interval) = quality
3045 .score_bounds()
3046 .and_then(|bounds| bounds.overall_score)
3047 {
3048 policies.push((
3049 "overall lower bound".to_string(),
3050 QualityPolicy::new().min_quality_score(rounded_2(interval.lower)),
3051 ));
3052 }
3053 if label == "thirds" {
3057 policies.push((
3058 "completeness at a third decimal".to_string(),
3059 QualityPolicy::new()
3060 .min_dimension_score(QualityDimension::Completeness, 66.674),
3061 ));
3062 }
3063 for (rule, policy) in policies {
3064 let live = policy.evaluate(&report).unwrap().verdict;
3065 let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3066 if live != Verdict::Pass || reloaded != Verdict::Pass {
3067 wrong.push(format!(
3068 "{label} {rule}: live {live:?}, loaded {reloaded:?}"
3069 ));
3070 }
3071 }
3072 }
3073 assert!(wrong.is_empty(), "{wrong:#?}");
3074 }
3075
3076 #[test]
3081 fn a_bound_that_rounds_up_decides_the_same_live_and_loaded() {
3082 let mut report = reservoir_report();
3083 let quality = report.quality.clone().expect("quality was computed");
3084 let mut bounds = quality
3085 .score_bounds()
3086 .expect("a sampled report is bounded")
3087 .clone();
3088 let interval = bounds
3089 .overall_score
3090 .as_mut()
3091 .expect("the overall score is bounded");
3092 interval.lower = rounded_2(interval.lower) - 0.004;
3094 interval.upper = rounded_2(interval.upper) - 0.004;
3095 let (lower, upper) = (interval.lower, interval.upper);
3096 assert!(rounded_2(lower) > lower && rounded_2(upper) > upper && lower < upper);
3097 report.quality = Some(quality.with_score_bounds(Some(bounds)));
3098 let loaded: ProfileReport =
3099 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3100
3101 let mut wrong = Vec::new();
3102 for (label, min) in [("lower", rounded_2(lower)), ("upper", rounded_2(upper))] {
3103 let policy = QualityPolicy::new().min_quality_score(min);
3104 let live = policy.evaluate(&report).unwrap().verdict;
3105 let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3106 if live != reloaded {
3107 wrong.push(format!(
3108 "min at the shown {label} bound {min}: live {live:?}, loaded {reloaded:?}"
3109 ));
3110 }
3111 }
3112 assert!(wrong.is_empty(), "{wrong:#?}");
3113 }
3114
3115 #[test]
3116 fn an_observed_scope_decides_on_the_sampled_score_itself() {
3117 let report = reservoir_report();
3118 let score = report.quality_score().unwrap();
3119 let result = QualityPolicy::new()
3120 .min_quality_score(score)
3121 .scope(PolicyScope::Observed)
3122 .evaluate(&report)
3123 .unwrap();
3124 let check = check_for(&result, CheckCode::MinQualityScore);
3125 assert_eq!(result.verdict, Verdict::Pass);
3126 assert_eq!(check.bounds, None);
3127 }
3128
3129 #[test]
3130 fn a_sampled_score_without_recorded_bounds_stays_unevaluated() {
3131 let mut report = reservoir_report();
3132 let quality = report.quality.take().unwrap();
3133 report.quality = Some(QualityAssessment::new(quality.metrics, quality.confidence));
3134
3135 let result = QualityPolicy::new()
3136 .min_quality_score(1.0)
3137 .evaluate(&report)
3138 .unwrap();
3139 assert_eq!(result.verdict, Verdict::Inconclusive);
3140 assert_eq!(check_for(&result, CheckCode::MinQualityScore).bounds, None);
3141 }
3142
3143 #[test]
3146 fn an_exactly_counted_dimension_survives_another_dimensions_sampling() {
3147 let report = reservoir_report();
3148 let sampled = report
3149 .quality
3150 .as_ref()
3151 .expect("quality was computed")
3152 .sampled_dimensions()
3153 .expect("a profiling run records its provenance");
3154 assert!(
3155 !sampled.iter().any(|label| label == "completeness"),
3156 "completeness is supposed to come from exact column counters"
3157 );
3158
3159 let result = QualityPolicy::new()
3160 .min_dimension_score(QualityDimension::Completeness, 1.0)
3161 .min_dimension_score(QualityDimension::Consistency, 1.0)
3162 .evaluate(&report)
3163 .unwrap();
3164
3165 assert_eq!(
3166 result
3167 .checks
3168 .iter()
3169 .find(|check| check.dimension == Some(QualityDimension::Completeness))
3170 .map(|check| check.evidence),
3171 Some(Evidence::Complete)
3172 );
3173 assert_eq!(
3174 result
3175 .checks
3176 .iter()
3177 .find(|check| check.dimension == Some(QualityDimension::Consistency))
3178 .map(|check| check.evidence),
3179 Some(Evidence::Incomplete {
3180 reason: EvidenceGap::QualitySampled
3181 })
3182 );
3183 }
3184
3185 #[test]
3193 fn a_legacy_document_without_recorded_coverage_is_not_read_as_a_full_scan() {
3194 let document = serde_json::to_value(complete_report()).expect("serializes");
3195 let mut legacy = document.clone();
3196 legacy["quality"] = document["quality"]["metrics"].clone();
3198 let report: ProfileReport = serde_json::from_value(legacy).expect("legacy document loads");
3199
3200 assert_eq!(
3201 report
3202 .quality
3203 .as_ref()
3204 .expect("the metrics survived")
3205 .sampled_dimensions(),
3206 None
3207 );
3208
3209 let result = QualityPolicy::new()
3210 .min_quality_score(1.0)
3211 .evaluate(&report)
3212 .unwrap();
3213 assert_eq!(result.verdict, Verdict::Inconclusive);
3214 let check = check_for(&result, CheckCode::MinQualityScore);
3215 assert_eq!(
3216 check.evidence,
3217 Evidence::Incomplete {
3218 reason: EvidenceGap::CoverageUnrecorded
3219 }
3220 );
3221
3222 let observed = QualityPolicy::new()
3224 .min_quality_score(1.0)
3225 .scope(PolicyScope::Observed)
3226 .evaluate(&report)
3227 .unwrap();
3228 assert_eq!(observed.verdict, Verdict::Pass);
3229 }
3230
3231 #[test]
3238 fn a_zero_weighted_sampled_dimension_does_not_taint_the_overall_score() {
3239 let mut report = reservoir_report();
3240 let quality = report.quality.as_mut().expect("quality was computed");
3241 let sampled = quality
3242 .sampled_dimensions()
3243 .expect("a profiling run records its provenance");
3244 assert!(
3245 sampled.iter().any(|label| label == "consistency"),
3246 "consistency is supposed to come from the reservoir"
3247 );
3248 quality.metrics.score_weights = QualityScoreWeights {
3252 completeness: 1.0,
3253 consistency: 0.0,
3254 uniqueness: 0.0,
3255 accuracy: 0.0,
3256 timeliness: 0.0,
3257 validity: 0.0,
3258 precision: 0.0,
3259 };
3260
3261 let result = QualityPolicy::new()
3262 .min_quality_score(1.0)
3263 .min_dimension_score(QualityDimension::Consistency, 1.0)
3264 .evaluate(&report)
3265 .unwrap();
3266
3267 assert_eq!(
3269 check_for(&result, CheckCode::MinQualityScore).evidence,
3270 Evidence::Complete
3271 );
3272 assert_eq!(
3274 check_for(&result, CheckCode::MinDimensionScore).evidence,
3275 Evidence::Incomplete {
3276 reason: EvidenceGap::QualitySampled
3277 }
3278 );
3279 }
3280
3281 #[test]
3288 fn zero_weights_do_not_erase_the_sampling_record() {
3289 let mut report = reservoir_report();
3290 let quality = report.quality.as_mut().expect("quality was computed");
3291 quality.metrics.score_weights = QualityScoreWeights {
3292 completeness: 0.0,
3293 consistency: 0.0,
3294 uniqueness: 0.0,
3295 accuracy: 0.0,
3296 timeliness: 0.0,
3297 validity: 0.0,
3298 precision: 0.0,
3299 };
3300 let reassessed =
3301 QualityAssessment::new(quality.metrics.clone(), quality.confidence.clone());
3302
3303 assert!(reassessed.metrics.assessed_dimensions().is_empty());
3304 assert!(
3305 reassessed
3306 .sampled_dimensions()
3307 .expect("provenance survives")
3308 .iter()
3309 .any(|label| label == "consistency"),
3310 "a measured dimension kept no record of coming from a sample"
3311 );
3312 }
3313
3314 #[test]
3315 fn met_requirements_pass() {
3316 let result = QualityPolicy::new()
3317 .min_quality_score(1.0)
3318 .max_null_percentage_any(50.0)
3319 .evaluate(&complete_report())
3320 .unwrap();
3321
3322 assert_eq!(result.verdict, Verdict::Pass);
3323 assert!(result.passed());
3324 assert_eq!(result.violations().count(), 0);
3325 assert_eq!(result.unevaluated().count(), 0);
3326 }
3327
3328 #[test]
3329 fn violated_requirement_fails() {
3330 let result = QualityPolicy::new()
3331 .max_null_percentage("note", 10.0)
3332 .evaluate(&complete_report())
3333 .unwrap();
3334
3335 assert_eq!(result.verdict, Verdict::Fail);
3336 assert!(!result.passed());
3337 let violation = result.violations().next().expect("one violation");
3338 assert_eq!(violation.code, CheckCode::MaxNullPercentage);
3339 assert_eq!(violation.column.as_deref(), Some("note"));
3340 assert_eq!(violation.observed, Some(MetricValue::Percentage(20.0)));
3341 assert_eq!(
3342 violation.expected,
3343 Expectation::AtMost {
3344 value: MetricValue::Percentage(10.0)
3345 }
3346 );
3347 }
3348
3349 #[test]
3351 fn named_limit_overrides_the_wildcard() {
3352 let result = QualityPolicy::new()
3353 .max_null_percentage("note", 50.0)
3354 .max_null_percentage_any(0.0)
3355 .evaluate(&complete_report())
3356 .unwrap();
3357
3358 assert_eq!(result.verdict, Verdict::Pass);
3359 assert_eq!(result.checks.len(), 2);
3360 assert_eq!(
3361 column_check(&result, "note").expected,
3362 Expectation::AtMost {
3363 value: MetricValue::Percentage(50.0)
3364 }
3365 );
3366 assert_eq!(
3367 column_check(&result, "id").expected,
3368 Expectation::AtMost {
3369 value: MetricValue::Percentage(0.0)
3370 }
3371 );
3372 }
3373
3374 #[test]
3377 fn thresholds_are_on_the_percentage_scale() {
3378 let result = QualityPolicy::new()
3379 .min_quality_score(0.9)
3380 .evaluate(&complete_report())
3381 .unwrap();
3382 assert_eq!(result.verdict, Verdict::Pass);
3383
3384 let err = QualityPolicy::new()
3385 .min_quality_score(101.0)
3386 .evaluate(&complete_report())
3387 .unwrap_err();
3388 assert_eq!(
3389 err,
3390 PolicyError::ThresholdOutOfRange {
3391 code: CheckCode::MinQualityScore,
3392 subject: None,
3393 value: 101.0,
3394 }
3395 );
3396 assert!(
3397 QualityPolicy::new()
3398 .max_null_percentage("id", f64::NAN)
3399 .validate()
3400 .is_err()
3401 );
3402 }
3403
3404 #[test]
3405 fn empty_policy_is_rejected_rather_than_passing_everything() {
3406 assert_eq!(
3407 QualityPolicy::new()
3408 .evaluate(&complete_report())
3409 .unwrap_err(),
3410 PolicyError::NoRequirements
3411 );
3412 }
3413
3414 #[test]
3418 fn unanalyzed_quality_is_inconclusive_not_a_pass() {
3419 let result = QualityPolicy::new()
3420 .min_quality_score(90.0)
3421 .evaluate(&no_quality_report())
3422 .unwrap();
3423
3424 assert_eq!(result.verdict, Verdict::Inconclusive);
3425 assert!(!result.passed());
3426 let check = check_for(&result, CheckCode::MinQualityScore);
3427 assert_eq!(check.observed, None);
3428 assert_eq!(
3429 check.not_evaluated(),
3430 Some(&NotEvaluated::QualityUnavailable {
3431 quality_status: "not_requested".to_string(),
3432 })
3433 );
3434 }
3435
3436 #[test]
3438 fn require_quality_turns_absence_into_a_violation() {
3439 let result = QualityPolicy::new()
3440 .require_quality()
3441 .min_quality_score(90.0)
3442 .evaluate(&no_quality_report())
3443 .unwrap();
3444
3445 assert_eq!(result.verdict, Verdict::Fail);
3446 let required = check_for(&result, CheckCode::RequireMetric);
3447 assert!(required.is_violation());
3448 assert_eq!(required.expected, Expectation::Analyzed);
3449 assert_eq!(required.observed, None);
3450 }
3451
3452 #[test]
3453 fn require_dimension_fails_when_the_dimension_had_nothing_to_assess() {
3454 let result = QualityPolicy::new()
3455 .require_dimension(QualityDimension::Timeliness)
3456 .evaluate(&complete_report())
3457 .unwrap();
3458
3459 assert_eq!(result.verdict, Verdict::Fail);
3460 assert_eq!(
3461 check_for(&result, CheckCode::RequireMetric).dimension,
3462 Some(QualityDimension::Timeliness)
3463 );
3464 }
3465
3466 #[test]
3469 fn unassessed_dimension_is_distinct_from_unrequested_quality() {
3470 let result = QualityPolicy::new()
3471 .min_dimension_score(QualityDimension::Timeliness, 90.0)
3472 .evaluate(&complete_report())
3473 .unwrap();
3474
3475 assert_eq!(result.verdict, Verdict::Inconclusive);
3476 assert_eq!(
3477 check_for(&result, CheckCode::MinDimensionScore).not_evaluated(),
3478 Some(&NotEvaluated::NotAssessed)
3479 );
3480 }
3481
3482 #[test]
3485 fn unprofiled_column_is_not_evaluated() {
3486 let result = QualityPolicy::new()
3487 .max_null_percentage("absent", 0.0)
3488 .evaluate(&complete_report())
3489 .unwrap();
3490
3491 assert_eq!(result.verdict, Verdict::Inconclusive);
3492 assert_eq!(
3493 column_check(&result, "absent").not_evaluated(),
3494 Some(&NotEvaluated::ColumnNotProfiled)
3495 );
3496 }
3497
3498 #[test]
3501 fn a_ratio_over_a_partial_scan_cannot_pass_a_full_source_policy() {
3502 let report = truncated_report();
3503 let result = QualityPolicy::new()
3504 .max_null_percentage_any(50.0)
3505 .evaluate(&report)
3506 .unwrap();
3507
3508 assert_eq!(result.verdict, Verdict::Inconclusive);
3509 assert_eq!(
3510 result.evidence,
3511 Evidence::Incomplete {
3512 reason: EvidenceGap::Truncated
3513 }
3514 );
3515 let check = column_check(&result, "note");
3516 assert_eq!(
3517 check.not_evaluated(),
3518 Some(&NotEvaluated::EvidenceIncomplete {
3519 gap: EvidenceGap::Truncated
3520 })
3521 );
3522 assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
3524
3525 let observed = QualityPolicy::new()
3527 .max_null_percentage_any(50.0)
3528 .scope(PolicyScope::Observed)
3529 .evaluate(&report)
3530 .unwrap();
3531 assert_eq!(observed.verdict, Verdict::Pass);
3532 }
3533
3534 #[test]
3537 fn a_witnessed_duplicate_fails_despite_a_partial_scan() {
3538 let result = QualityPolicy::new()
3539 .max_duplicate_rows(0)
3540 .evaluate(&duplicate_report(2, false))
3541 .unwrap();
3542
3543 assert_eq!(result.verdict, Verdict::Fail);
3544 let check = check_for(&result, CheckCode::MaxDuplicateRows);
3545 assert_eq!(check.observed, Some(MetricValue::Count(2)));
3546 assert_eq!(
3547 check.evidence,
3548 Evidence::Incomplete {
3549 reason: EvidenceGap::Truncated
3550 }
3551 );
3552 }
3553
3554 #[test]
3556 fn no_duplicate_in_a_partial_scan_is_not_a_pass() {
3557 let result = QualityPolicy::new()
3558 .max_duplicate_rows(0)
3559 .evaluate(&duplicate_report(0, false))
3560 .unwrap();
3561
3562 assert_eq!(result.verdict, Verdict::Inconclusive);
3563 assert_eq!(
3564 check_for(&result, CheckCode::MaxDuplicateRows).not_evaluated(),
3565 Some(&NotEvaluated::EvidenceIncomplete {
3566 gap: EvidenceGap::Truncated
3567 })
3568 );
3569 }
3570
3571 #[test]
3574 fn an_estimated_duplicate_count_settles_neither_direction() {
3575 let result = QualityPolicy::new()
3576 .max_duplicate_rows(0)
3577 .evaluate(&duplicate_report(2, true))
3578 .unwrap();
3579
3580 assert_eq!(result.verdict, Verdict::Inconclusive);
3581 let check = check_for(&result, CheckCode::MaxDuplicateRows);
3582 assert_eq!(
3583 check.not_evaluated(),
3584 Some(&NotEvaluated::EvidenceIncomplete {
3585 gap: EvidenceGap::Truncated
3586 })
3587 );
3588 assert_eq!(
3591 check.message,
3592 "the duplicate-row count is an estimate, which can neither witness \
3593 duplicates above the allowance nor rule them out"
3594 );
3595 }
3596
3597 #[test]
3600 fn a_violation_outranks_an_unevaluated_check() {
3601 let result = QualityPolicy::new()
3602 .max_duplicate_rows(0)
3603 .min_dimension_score(QualityDimension::Timeliness, 90.0)
3604 .evaluate(&duplicate_report(2, false))
3605 .unwrap();
3606
3607 assert_eq!(result.verdict, Verdict::Fail);
3608 assert_eq!(result.unevaluated().count(), 1);
3609 }
3610
3611 #[test]
3614 fn evaluation_order_does_not_depend_on_insertion_order() {
3615 let report = complete_report();
3616 let forward = QualityPolicy::new()
3617 .max_null_percentage("note", 50.0)
3618 .max_null_percentage("id", 50.0)
3619 .evaluate(&report)
3620 .unwrap();
3621 let reverse = QualityPolicy::new()
3622 .max_null_percentage("id", 50.0)
3623 .max_null_percentage("note", 50.0)
3624 .evaluate(&report)
3625 .unwrap();
3626
3627 assert_eq!(forward, reverse);
3628 let columns: Vec<_> = forward
3629 .checks
3630 .iter()
3631 .map(|check| check.column.clone().unwrap())
3632 .collect();
3633 assert_eq!(columns, vec!["id".to_string(), "note".to_string()]);
3634 }
3635
3636 #[test]
3639 fn result_serializes_into_the_documented_shape() {
3640 let result = QualityPolicy::new()
3641 .max_null_percentage("note", 10.0)
3642 .evaluate(&complete_report())
3643 .unwrap();
3644
3645 assert_eq!(
3646 serde_json::to_value(&result).unwrap(),
3647 json!({
3648 "verdict": "fail",
3649 "scope": "full_source",
3650 "evidence": {"coverage": "complete"},
3651 "checks": [{
3652 "code": "max_null_percentage",
3653 "column": "note",
3654 "expected": {"comparison": "at_most", "value": 10.0},
3655 "observed": 20.0,
3656 "scope": "full_source",
3657 "evidence": {"coverage": "complete"},
3658 "status": "failed",
3659 "message": "this column's null percentage is above the allowance",
3660 }],
3661 })
3662 );
3663 }
3664
3665 #[test]
3666 fn an_unevaluated_check_serializes_its_reason() {
3667 let result = QualityPolicy::new()
3668 .min_quality_score(90.0)
3669 .evaluate(&no_quality_report())
3670 .unwrap();
3671 let check = &serde_json::to_value(&result).unwrap()["checks"][0];
3672
3673 assert_eq!(check["status"], Value::String("not_evaluated".to_string()));
3674 assert_eq!(
3675 check["reason"],
3676 Value::String("quality_unavailable".to_string())
3677 );
3678 assert_eq!(
3679 check["quality_status"],
3680 Value::String("not_requested".to_string())
3681 );
3682 assert!(check.get("observed").is_none());
3683 }
3684}