Skip to main content

dataprof_runtime/
quality_gate.rs

1//! Declarative quality gates over a [`ProfileReport`].
2//!
3//! A pipeline that wants to stop on a bad extract has to turn a report into a
4//! decision. Doing that by hand means re-deciding, at every call site, what an
5//! absent metric means, whether a sampled scan can answer a question about the
6//! whole source, and which number belongs in the log line. This module makes
7//! that decision data: a [`QualityPolicy`] states the requirements and
8//! [`QualityPolicy::evaluate`] returns a [`GateResult`] describing what was
9//! checked and what happened.
10//!
11//! Evaluation never exits the process, prints, or mutates the report.
12//!
13//! # Verdicts and evidence
14//!
15//! The verdict is three-valued, because a decision and the evidence behind it
16//! are separate facts. A requirement about the full source cannot be *passed*
17//! by a truncated or sampled scan: nothing was observed that rules the failure
18//! out. It can still be *failed* by one, when the scan witnessed a violation
19//! that more scanning cannot retract. So:
20//!
21//! - [`Verdict::Fail`] — at least one requirement was conclusively violated.
22//! - [`Verdict::Inconclusive`] — nothing was violated, and at least one
23//!   requirement could not be evaluated (a metric was not analyzed, a column
24//!   was not profiled, the evidence does not reach as far as the requirement).
25//! - [`Verdict::Pass`] — every requirement was evaluated and met.
26//!
27//! `Fail` wins over `Inconclusive`: a witnessed violation is a decision.
28//!
29//! # Baseline-relative rules
30//!
31//! [`QualityPolicy::evaluate_against`] also reads a saved baseline, through the
32//! [`ReportDelta`] between it and the report, and evaluates rules about change:
33//! how far the overall score or a dimension score may drop, and how far a
34//! column's null percentage may rise, in points. A change is decided only when
35//! both sides measured the same thing the same way. Otherwise the rule is left
36//! unevaluated, naming what differs:
37//!
38//! - a value the baseline does not carry ([`NotEvaluated::NotInBaseline`]);
39//! - for a score, a different column set ([`NotEvaluated::SchemaChanged`]).
40//!   A dataset-level score has the columns as its subject, so a column that
41//!   appeared or vanished changes what is measured, not how good it is. A
42//!   column's own rules stay decisive, and a new column has no baseline rule
43//!   until a policy names it;
44//! - for the overall score, a different set of assessed dimensions
45//!   ([`NotEvaluated::DimensionsChanged`]), since it averages those;
46//! - a measurement definition the rule depends on that the two sides do not
47//!   both record identically ([`NotEvaluated::DefinitionNotComparable`]);
48//! - a setting that moves the score on unchanged data, such as the score
49//!   weights or a semantic hint, that differs or that either side did not
50//!   record ([`NotEvaluated::SettingChanged`],
51//!   [`NotEvaluated::SettingUnrecorded`]);
52//! - under [`PolicyScope::FullSource`], evidence on either side that does not
53//!   cover the whole source.
54//!
55//! A schema change from the baseline is reported as a [`Diagnostic`] in the
56//! result's `baseline.diagnostics`, whatever the policy states: a column added,
57//! removed, retyped while holding values on both sides, or holding only nulls
58//! where the baseline had values. A diagnostic is a warning, never a fourth
59//! verdict, and does not move the verdict.
60//!
61//! Rules about the column set decide on whether a column is there.
62//! [`QualityPolicy::require_columns`] names columns the report must have;
63//! [`QualityPolicy::forbid_removed_columns`] requires every baseline column,
64//! and [`QualityPolicy::forbid_added_columns`] allows no column the baseline
65//! lacked. A missing column fails such a rule only when the side without it
66//! shows its source lacks it: that side recorded its column selection, the
67//! selection included the column, and its column set covers the whole source.
68//! A source that declares its columns up front (a CSV header, a Parquet
69//! schema, a DataFrame, a query result) covers it however few rows were read;
70//! for JSON, JSONL and streams the columns are the keys of the records read,
71//! so only a scan of every record does. Otherwise the rule is unevaluated:
72//! [`NotEvaluated::SettingChanged`] or [`NotEvaluated::SettingUnrecorded`] on
73//! the setting `columns`, [`NotEvaluated::ColumnNotProfiled`] for a required
74//! column the run did not select, or [`NotEvaluated::EvidenceIncomplete`].
75//!
76//! # Example
77//!
78//! ```no_run
79//! use dataprof_runtime::{ProfileReport, QualityPolicy, Verdict};
80//!
81//! fn gate(report: &ProfileReport) -> Result<bool, Box<dyn std::error::Error>> {
82//!     let result = QualityPolicy::new()
83//!         .min_quality_score(90.0)
84//!         .max_null_count("customer_id", 0)
85//!         .max_null_percentage_any(20.0)
86//!         .require_quality()
87//!         .evaluate(report)?;
88//!
89//!     if result.verdict != Verdict::Pass {
90//!         for violation in result.violations() {
91//!             eprintln!("{}: {}", violation.code, violation.message);
92//!         }
93//!     }
94//!     Ok(result.passed())
95//! }
96//! ```
97
98use 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
114/// Scores and percentages in a report live on a 0..=100 scale, and so do the
115/// thresholds compared against them.
116const PERCENTAGE_MAX: f64 = 100.0;
117
118/// A policy that cannot be evaluated as written.
119///
120/// Raised before any requirement is checked: an unsatisfiable threshold is a
121/// configuration mistake, and reporting it as a failed gate would blame the
122/// data for it.
123#[derive(Debug, Clone, PartialEq)]
124#[non_exhaustive]
125pub enum PolicyError {
126    /// A threshold on the 0..=100 percentage scale fell outside it, or was not
127    /// a finite number. Quality scores and null percentages are percentages,
128    /// not 0..1 ratios.
129    #[non_exhaustive]
130    ThresholdOutOfRange {
131        /// The requirement that carried it.
132        code: CheckCode,
133        /// The column or dimension the requirement names, when it names one.
134        subject: Option<String>,
135        /// The value as supplied.
136        value: f64,
137    },
138    /// The policy states no requirement at all. An empty policy passes every
139    /// report, which is never what a gate is for.
140    NoRequirements,
141    /// The policy states a rule about change, and was evaluated without a
142    /// baseline to measure the change from. Use
143    /// [`QualityPolicy::evaluate_against`].
144    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/// What the requirements in a policy are statements about.
180///
181/// A profiler reads what it is given. Whether that supports a claim about the
182/// whole source depends on what is being claimed, so the policy says which
183/// question it asks rather than letting the evaluator guess.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
185#[serde(rename_all = "snake_case")]
186#[non_exhaustive]
187pub enum PolicyScope {
188    /// The entire source. A requirement is evaluated only when the evidence
189    /// covers it, or when the observed data already witnesses a violation that
190    /// further scanning cannot retract.
191    #[default]
192    FullSource,
193    /// Whatever the metric actually measured — the scanned rows, the retained
194    /// quality sample, the profiled columns. Always evaluable, and says
195    /// nothing about the rows that were not read.
196    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/// Why the data behind a number falls short of the whole source.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
224#[serde(rename_all = "snake_case")]
225#[non_exhaustive]
226pub enum EvidenceGap {
227    /// Profiling stopped before the source was exhausted.
228    Truncated,
229    /// Rows were sampled rather than all read.
230    Sampled,
231    /// Records were skipped because they could not be turned into rows.
232    RecordsSkipped,
233    /// Every row was scanned, but this metric was computed from a retained
234    /// sample of them.
235    QualitySampled,
236    /// The report does not record how its numbers were obtained: which quality
237    /// components were sampled, or, in a flat summary from an older release,
238    /// whether the scan read the whole source. Only reachable for a report
239    /// read back from a document written before dataprof recorded it; a
240    /// profiling run always records it. Unknown coverage is not full
241    /// coverage, so a full-source requirement is left unevaluated.
242    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/// Whether the data behind a number covers the whole source.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
259#[serde(tag = "coverage", rename_all = "snake_case")]
260#[non_exhaustive]
261pub enum Evidence {
262    /// Every row of the source is behind the number.
263    Complete,
264    /// Some of the source is not, for this reason.
265    #[non_exhaustive]
266    Incomplete {
267        /// The gap between what was read and the whole source.
268        reason: EvidenceGap,
269    },
270}
271
272impl Evidence {
273    /// True when the whole source is behind the number.
274    pub fn is_complete(self) -> bool {
275        matches!(self, Self::Complete)
276    }
277
278    /// The gap, when there is one.
279    pub fn gap(self) -> Option<EvidenceGap> {
280        match self {
281            Self::Complete => None,
282            Self::Incomplete { reason } => Some(reason),
283        }
284    }
285
286    /// The weaker of two evidence statements: a number is only as complete as
287    /// the least complete input behind it. The left-hand gap wins a tie, so
288    /// the scan's gap is named ahead of the quality sample's.
289    pub(crate) fn and(self, other: Self) -> Self {
290        match self {
291            Self::Complete => other,
292            incomplete => incomplete,
293        }
294    }
295}
296
297/// Stable identifier for the requirement a check came from.
298///
299/// Part of the serialized contract: a CI system matches on these, so they do
300/// not change when the prose does.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
302#[serde(rename_all = "snake_case")]
303#[non_exhaustive]
304pub enum CheckCode {
305    /// The scan must have read at least the threshold's number of rows.
306    MinRows,
307    /// The overall quality score must be at least the threshold.
308    MinQualityScore,
309    /// One ISO 25012 dimension score must be at least the threshold.
310    MinDimensionScore,
311    /// A column's null percentage must be at most the threshold.
312    MaxNullPercentage,
313    /// A column's null count must be at most the threshold.
314    MaxNullCount,
315    /// The duplicate-row count must be at most the threshold.
316    MaxDuplicateRows,
317    /// A metric must have been analyzed at all.
318    RequireMetric,
319    /// The overall quality score may drop at most the threshold's number of
320    /// points below the baseline's.
321    MaxQualityScoreDrop,
322    /// One dimension score may drop at most the threshold's number of points
323    /// below the baseline's.
324    MaxDimensionScoreDrop,
325    /// A column's null percentage may rise at most the threshold's number of
326    /// points above the baseline's.
327    MaxNullPercentageIncrease,
328    /// A named column must be profiled.
329    RequireColumn,
330    /// A column the baseline profiled must still be profiled.
331    ForbidRemovedColumn,
332    /// A column the report profiled must be one the baseline profiled.
333    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/// A number a check read, or a threshold it compared against.
357///
358/// Percentages follow the report's 2dp serialization convention; counts are
359/// whole numbers and are not widened into rounded floats.
360#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
361#[serde(untagged)]
362#[non_exhaustive]
363pub enum MetricValue {
364    /// A count of rows or values.
365    Count(usize),
366    /// A 0..=100 percentage or score, or a change in one, in points.
367    Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
368}
369
370impl fmt::Display for MetricValue {
371    /// For logs and messages, not for the serialized contract: percentages
372    /// print at the report's 2dp convention, counts as whole numbers.
373    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/// The constraint a check applied.
382#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
383#[serde(tag = "comparison", rename_all = "snake_case")]
384#[non_exhaustive]
385pub enum Expectation {
386    /// The observed value must be at least `value`.
387    #[non_exhaustive]
388    AtLeast {
389        /// The threshold.
390        value: MetricValue,
391    },
392    /// The observed value must be at most `value`.
393    #[non_exhaustive]
394    AtMost {
395        /// The threshold.
396        value: MetricValue,
397    },
398    /// The metric must have been analyzed. No number is compared.
399    Analyzed,
400    /// The change from the baseline, `observed` minus the baseline's value,
401    /// must be at least `value` points. A permitted drop is a negative value.
402    #[non_exhaustive]
403    ChangeAtLeast {
404        /// The threshold, in points.
405        value: MetricValue,
406    },
407    /// The change from the baseline must be at most `value` points.
408    #[non_exhaustive]
409    ChangeAtMost {
410        /// The threshold, in points.
411        value: MetricValue,
412    },
413    /// The column must be profiled. No number is compared.
414    Present,
415    /// The column must be one the baseline profiled. No number is compared.
416    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/// Why a requirement was not evaluated.
434///
435/// Absence is never read as zero and never as a pass. Each variant names a
436/// different thing a caller may want to fix: a run that did not ask for the
437/// metric, a dimension with nothing to measure, a column that was not
438/// profiled, or a scan that does not reach as far as the requirement does.
439#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
440#[serde(tag = "reason", rename_all = "snake_case")]
441#[non_exhaustive]
442pub enum NotEvaluated {
443    /// The report carries no quality assessment, for this recorded reason.
444    #[non_exhaustive]
445    QualityUnavailable {
446        /// The report's `quality_status` state, verbatim.
447        quality_status: String,
448    },
449    /// Quality was computed, but this dimension had nothing to assess — no
450    /// numeric values, no dates, no confidently detected pattern — or it was
451    /// withheld because the run profiled a subset of columns.
452    NotAssessed,
453    /// The report holds no profile for the named column, and its column
454    /// selection does not show the source lacks it: the run's `columns=`
455    /// selection left it out, or the report predates the record of its
456    /// selection. On a check with no column, the run profiled a `columns=`
457    /// selection, which may have left source columns out.
458    ColumnNotProfiled,
459    /// The requirement asks about the full source, the evidence does not reach
460    /// that far, and nothing observed settles it regardless.
461    #[non_exhaustive]
462    EvidenceIncomplete {
463        /// The gap between the evidence and the requirement's scope.
464        gap: EvidenceGap,
465    },
466    /// The baseline carries no value to measure a change from: it did not
467    /// assess the metric, did not profile the column, or read no values for
468    /// it.
469    NotInBaseline,
470    /// The two reports profiled different column sets, and the requirement
471    /// is about a dataset-level score, whose subject is the column set. The
472    /// result's `baseline.schema` names the columns that differ.
473    SchemaChanged,
474    /// The overall score averages a different set of assessed dimensions on
475    /// each side, because a dimension was requested, or had something to
476    /// assess, on one side only.
477    DimensionsChanged,
478    /// A measurement definition the requirement depends on is not recorded
479    /// identically on both sides: one side predates the record, or the two
480    /// differ. Either way the same data may measure differently.
481    #[non_exhaustive]
482    DefinitionNotComparable {
483        /// The `metric_semantics` key.
484        definition: String,
485    },
486    /// A setting that moves the score on unchanged data differs between the
487    /// two reports.
488    #[non_exhaustive]
489    SettingChanged {
490        /// The configuration field that differs.
491        setting: String,
492    },
493    /// A setting that moves the score on unchanged data is not recorded on
494    /// at least one side, so it cannot be shown to be the same.
495    #[non_exhaustive]
496    SettingUnrecorded {
497        /// The configuration field that is unrecorded.
498        setting: String,
499    },
500}
501
502/// What happened to one requirement.
503#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
504#[serde(tag = "status", rename_all = "snake_case")]
505#[non_exhaustive]
506pub enum CheckStatus {
507    /// Evaluated and met.
508    Passed,
509    /// Evaluated and violated.
510    Failed,
511    /// Not evaluated, for this reason.
512    NotEvaluated(NotEvaluated),
513}
514
515/// One requirement and what the report said about it.
516#[derive(Debug, Clone, PartialEq, serde::Serialize)]
517#[non_exhaustive]
518pub struct Check {
519    /// Which requirement this is.
520    pub code: CheckCode,
521    /// The column it concerns, when it concerns one.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub column: Option<String>,
524    /// The quality dimension it concerns, when it concerns one.
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub dimension: Option<QualityDimension>,
527    /// The constraint applied.
528    pub expected: Expectation,
529    /// The value read from the report. Absent when nothing was read: every
530    /// unevaluated check, and every `require_metric` check.
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub observed: Option<MetricValue>,
533    /// What the requirement is a statement about.
534    pub scope: PolicyScope,
535    /// Whether the data behind `observed` covers the whole source.
536    pub evidence: Evidence,
537    /// Where the whole-source value lies, when `observed` came from a retained
538    /// sample and the report bounds it. A full-source requirement is decided
539    /// on this interval rather than on `observed`: it passes when the whole
540    /// interval meets the requirement, fails when none of it does, and is
541    /// otherwise not evaluated.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub bounds: Option<CheckBounds>,
544    /// The baseline's side of a rule about change. Absent on every other
545    /// rule.
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub baseline: Option<BaselineReading>,
548    /// Passed, failed, or not evaluated.
549    #[serde(flatten)]
550    pub status: CheckStatus,
551    /// A fixed sentence naming the outcome.
552    ///
553    /// Deliberately free of numbers: `observed` and `expected` carry those, so
554    /// the prose is identical wherever a value would have been formatted and
555    /// the Rust and Python implementations cannot drift on rounding inside a
556    /// string.
557    pub message: String,
558}
559
560impl Check {
561    /// True when the check was evaluated and violated.
562    pub fn is_violation(&self) -> bool {
563        matches!(self.status, CheckStatus::Failed)
564    }
565
566    /// Why the check was not evaluated, if it was not.
567    pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
568        match &self.status {
569            CheckStatus::NotEvaluated(reason) => Some(reason),
570            _ => None,
571        }
572    }
573}
574
575/// What the baseline said about the metric a rule about change reads.
576#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
577#[non_exhaustive]
578pub struct BaselineReading {
579    /// The baseline's value, or `None` when it carries none.
580    pub observed: Option<MetricValue>,
581    /// Whether the data behind the baseline's value covers its whole source.
582    pub evidence: Evidence,
583    /// The check's `observed` minus the baseline's, in points, at the
584    /// report's serialized precision. `None` when either side has no value.
585    pub change: Option<MetricValue>,
586}
587
588/// The two reports a baseline-relative evaluation compared, and how their
589/// column sets differ.
590#[derive(Debug, Clone, PartialEq, serde::Serialize)]
591#[non_exhaustive]
592pub struct BaselineComparison {
593    /// `a` is the baseline and `b` the report evaluated, as in a
594    /// [`ReportDelta`] between them.
595    pub reports: Sides<ReportReference>,
596    /// Columns added, removed and shared.
597    pub schema: SchemaChange,
598    /// The schema changes from the baseline, whatever the policy states. A
599    /// diagnostic never moves the verdict. Empty when nothing changed.
600    pub diagnostics: Vec<Diagnostic>,
601}
602
603/// Stable identifier for a kind of schema change.
604///
605/// Part of the serialized contract, like [`CheckCode`].
606#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
607#[serde(rename_all = "snake_case")]
608#[non_exhaustive]
609pub enum DiagnosticCode {
610    /// The report profiled a column the baseline did not. Without a reason,
611    /// the baseline profiled every column it selected that its source had,
612    /// and this was not among them.
613    ColumnAdded,
614    /// The baseline profiled a column the report did not. Without a reason,
615    /// the report's selection included it, so it was not in what the report
616    /// read. With one, the report's column selection left it out or was not
617    /// recorded.
618    ColumnRemoved,
619    /// The column holds values on both sides and its inferred type differs.
620    /// A column whose values are all null on a side establishes no type there.
621    ColumnTypeChanged,
622    /// The column held values in the baseline, and every value read for it
623    /// in the report is null.
624    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/// A schema change between the baseline and the report.
639///
640/// A warning, not a fourth verdict: it is reported whatever the policy
641/// states, and no rule decides on it. A requirement that would fail on it is
642/// a policy entry of its own.
643#[derive(Debug, Clone, PartialEq, serde::Serialize)]
644#[non_exhaustive]
645pub struct Diagnostic {
646    /// What changed.
647    pub code: DiagnosticCode,
648    /// The column it changed in.
649    pub column: String,
650    /// The inferred type on each side, `a` being the baseline. Present on
651    /// [`DiagnosticCode::ColumnTypeChanged`] only.
652    #[serde(
653        skip_serializing_if = "Option::is_none",
654        serialize_with = "serialize_data_types"
655    )]
656    pub data_type: Option<Sides<DataType>>,
657    /// A recorded setting that can produce the change on unchanged data,
658    /// when one can. Serialized flat, as a check's reason is.
659    #[serde(flatten)]
660    pub setting: Option<SettingDifference>,
661    /// A fixed sentence naming the change, free of numbers and names, like a
662    /// check's message.
663    pub message: String,
664}
665
666/// How a setting that can produce a diagnostic's change compares across the
667/// two reports. A diagnostic carrying one may not reflect a change in the
668/// data.
669///
670/// Two settings are read: `columns`, the column selection, on an added or
671/// removed column, and `identifier_columns` on a type change to or from
672/// `identifier`, the one type a setting assigns. An input path that types the
673/// same data differently is not recorded, so it cannot be named here (#906).
674#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
675#[serde(tag = "reason", rename_all = "snake_case")]
676#[non_exhaustive]
677pub enum SettingDifference {
678    /// The setting differs between the two reports for this column.
679    #[non_exhaustive]
680    SettingChanged {
681        /// The configuration field that differs.
682        setting: String,
683    },
684    /// At least one side did not record the setting, so it cannot be shown
685    /// to be the same.
686    #[non_exhaustive]
687    SettingUnrecorded {
688        /// The configuration field that is unrecorded.
689        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/// The interval a sampled score was decided on.
707#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
708#[non_exhaustive]
709pub struct CheckBounds {
710    /// Lowest value the whole-source score can take.
711    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
712    pub lower: f64,
713    /// Highest value the whole-source score can take.
714    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
715    pub upper: f64,
716    /// Probability that every interval in the report holds at once.
717    pub confidence_level: f64,
718}
719
720/// The overall outcome of a policy.
721#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
722#[serde(rename_all = "snake_case")]
723#[non_exhaustive]
724pub enum Verdict {
725    /// Every requirement was evaluated and met.
726    Pass,
727    /// At least one requirement was conclusively violated.
728    Fail,
729    /// Nothing was violated, and at least one requirement could not be
730    /// evaluated. Not a pass: the gate has no answer, not a positive one.
731    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/// The structured result of evaluating a [`QualityPolicy`].
745#[derive(Debug, Clone, PartialEq, serde::Serialize)]
746#[non_exhaustive]
747pub struct GateResult {
748    /// The overall outcome.
749    pub verdict: Verdict,
750    /// The scope every requirement was evaluated under.
751    pub scope: PolicyScope,
752    /// Whether the scan itself covered the whole source. A single check's
753    /// evidence can be weaker still, when its metric came from a retained
754    /// sample of the scanned rows.
755    pub evidence: Evidence,
756    /// Every requirement, in evaluation order.
757    pub checks: Vec<Check>,
758    /// The reports compared, when the policy was evaluated against a
759    /// baseline. Absent otherwise.
760    #[serde(skip_serializing_if = "Option::is_none")]
761    pub baseline: Option<BaselineComparison>,
762}
763
764impl GateResult {
765    /// True only for [`Verdict::Pass`].
766    ///
767    /// An inconclusive result is not a pass: reading it as one is the mistake
768    /// this API exists to prevent. Callers that need to tell "violated" from
769    /// "could not tell" should match on [`GateResult::verdict`].
770    pub fn passed(&self) -> bool {
771        self.verdict == Verdict::Pass
772    }
773
774    /// The checks that were evaluated and violated.
775    pub fn violations(&self) -> impl Iterator<Item = &Check> {
776        self.checks.iter().filter(|check| check.is_violation())
777    }
778
779    /// The checks that could not be evaluated.
780    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/// A metric a policy requires to have been analyzed.
788#[derive(Debug, Clone, Copy, PartialEq, Eq)]
789#[non_exhaustive]
790pub enum RequiredMetric {
791    /// The quality assessment as a whole.
792    Quality,
793    /// One ISO 25012 dimension.
794    Dimension(QualityDimension),
795}
796
797/// What a report must satisfy.
798///
799/// Build one with the chainable setters, then [`evaluate`](Self::evaluate) it
800/// against a report. Requirements are evaluated in a fixed order that does not
801/// depend on the order they were added, so two callers stating the same policy
802/// get the same document out.
803#[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    /// An empty policy. It states no requirement, so evaluating it as-is is a
827    /// [`PolicyError::NoRequirements`].
828    pub fn new() -> Self {
829        Self::default()
830    }
831
832    /// Require the scan to have read at least `min` rows, so an empty or
833    /// truncated extract fails instead of passing every other check.
834    pub fn min_rows(mut self, min: usize) -> Self {
835        self.min_rows = Some(min);
836        self
837    }
838
839    /// Require the overall quality score to be at least `min` (0..=100).
840    pub fn min_quality_score(mut self, min: f64) -> Self {
841        self.min_quality_score = Some(min);
842        self
843    }
844
845    /// Require one dimension's score to be at least `min` (0..=100).
846    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    /// Require the named column's null percentage to be at most `max`
853    /// (0..=100). Takes precedence over
854    /// [`max_null_percentage_any`](Self::max_null_percentage_any) for that
855    /// column.
856    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    /// Require every other profiled column's null percentage to be at most
862    /// `max` (0..=100).
863    pub fn max_null_percentage_any(mut self, max: f64) -> Self {
864        self.max_null_percentage_any = Some(max);
865        self
866    }
867
868    /// Require the named column to hold at most `max` nulls, decided on the
869    /// exact count. `max_null_count(column, 0)` states "no nulls" at any row
870    /// count, where a zero null percentage is decided at the two decimals it
871    /// is shown at. Takes precedence over
872    /// [`max_null_count_any`](Self::max_null_count_any) for that column.
873    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    /// Require every other profiled column to hold at most `max` nulls.
879    pub fn max_null_count_any(mut self, max: usize) -> Self {
880        self.max_null_count_any = Some(max);
881        self
882    }
883
884    /// Require the report to count at most `max` duplicate rows.
885    pub fn max_duplicate_rows(mut self, max: usize) -> Self {
886        self.max_duplicate_rows = Some(max);
887        self
888    }
889
890    /// Require the report to carry a quality assessment at all.
891    ///
892    /// Without this, a report with no assessment leaves quality requirements
893    /// unevaluated and the verdict inconclusive. With it, the absence is
894    /// itself a violation — which is what a pipeline wants when quality is
895    /// supposed to be configured on.
896    pub fn require_quality(mut self) -> Self {
897        self.require_metrics.push(RequiredMetric::Quality);
898        self
899    }
900
901    /// Require a specific dimension to have been assessed.
902    pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
903        self.require_metrics
904            .push(RequiredMetric::Dimension(dimension));
905        self
906    }
907
908    /// Allow the overall quality score to drop at most `points` (0..=100)
909    /// below the baseline's. Needs [`evaluate_against`](Self::evaluate_against).
910    pub fn max_quality_score_drop(mut self, points: f64) -> Self {
911        self.max_quality_score_drop = Some(points);
912        self
913    }
914
915    /// Allow one dimension's score to drop at most `points` (0..=100) below
916    /// the baseline's. Needs [`evaluate_against`](Self::evaluate_against).
917    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    /// Allow the named column's null percentage to rise at most `points`
924    /// (0..=100) above the baseline's. Takes precedence over
925    /// [`max_null_percentage_increase_any`](Self::max_null_percentage_increase_any)
926    /// for that column. Needs [`evaluate_against`](Self::evaluate_against).
927    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    /// Allow every other column both reports profiled to have its null
934    /// percentage rise at most `points` (0..=100) above the baseline's. A
935    /// column only the evaluated report has is not covered: it has no
936    /// baseline, and gets a rule only when the policy names it. Needs
937    /// [`evaluate_against`](Self::evaluate_against).
938    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    /// Require each named column to be profiled. A column missing from the
944    /// report fails only when the report shows its source lacks it: the run's
945    /// column selection included the column, and the column set covers the
946    /// whole source (see the [module docs](self)).
947    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    /// Require every column the baseline profiled to be profiled still. The
958    /// rule is about the columns the baseline recorded: a column its source
959    /// held but it did not profile (a projection, or a key past a partial
960    /// read) is not one it can require. Needs
961    /// [`evaluate_against`](Self::evaluate_against).
962    pub fn forbid_removed_columns(mut self) -> Self {
963        self.forbid_removed_columns = true;
964        self
965    }
966
967    /// Require every column the report profiled to be one the baseline
968    /// profiled: a closed schema. Under [`PolicyScope::FullSource`], a report
969    /// whose profiled columns may not be all of its source's also gets one
970    /// check with no column, left unevaluated: a column selection that left
971    /// columns out, one that was not recorded, or a column set covering part
972    /// of the source. Needs [`evaluate_against`](Self::evaluate_against).
973    pub fn forbid_added_columns(mut self) -> Self {
974        self.forbid_added_columns = true;
975        self
976    }
977
978    /// Set what the requirements are statements about. Defaults to
979    /// [`PolicyScope::FullSource`].
980    pub fn scope(mut self, scope: PolicyScope) -> Self {
981        self.scope = scope;
982        self
983    }
984
985    /// Check that the policy is evaluable, without needing a report.
986    ///
987    /// Callers that read a policy out of configuration should do this at
988    /// startup rather than discovering a bad threshold on the first dataset.
989    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    /// Whether the policy states a rule about change from a baseline.
1047    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    /// Evaluate the policy against a report.
1057    ///
1058    /// Returns the structured outcome. The report is not modified, nothing is
1059    /// printed, and the process is not exited: what to do about a failing gate
1060    /// is the caller's decision. A policy with a rule about change is a
1061    /// [`PolicyError::BaselineRequired`] here; use
1062    /// [`evaluate_against`](Self::evaluate_against).
1063    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    /// Evaluate the policy against a report and a baseline it is compared
1079    /// with.
1080    ///
1081    /// Every requirement is evaluated as [`evaluate`](Self::evaluate) does,
1082    /// then each rule about change, in a fixed order: the overall score, the
1083    /// dimensions in [`QualityDimension::all`] order, then the named columns
1084    /// in name order and every other shared column in the baseline's order.
1085    /// The baseline may come from a flat summary, which leaves what it did
1086    /// not record unrecorded. See the [module docs](self) for when a change
1087    /// is decided.
1088    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, &current);
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        // Collected once, so a rule over every column does not rescan the
1117        // other side's columns for each one.
1118        let (baseline_names, current_names) = (column_names(baseline), column_names(&current));
1119        if self.forbid_removed_columns {
1120            for column in baseline.columns() {
1121                checks.push(self.forbid_removed_check(
1122                    baseline,
1123                    &current,
1124                    &current_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                    &current,
1134                    &baseline_names,
1135                    &column.name,
1136                ));
1137            }
1138            // The per-column checks cover the columns the report found. When
1139            // those do not enumerate the source, a column the baseline lacked
1140            // may be among the ones it did not profile, which no per-column
1141            // check can name.
1142            if self.scope == PolicyScope::FullSource
1143                && let Some(reason) = unenumerated_columns(&current)
1144            {
1145                checks.push(self.unprofiled_columns_check(baseline, &current, reason));
1146            }
1147        }
1148        let diagnostics = schema_diagnostics(baseline, &current, &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    /// The requirements about the report alone, in their fixed order.
1163    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    /// Decide a comparison whose observed value is an average or a ratio.
1197    ///
1198    /// Such a value, computed over part of a source, bounds nothing about the
1199    /// rest of it, so under [`PolicyScope::FullSource`] incomplete evidence
1200    /// leaves the requirement unevaluated in both directions. Under
1201    /// [`PolicyScope::Observed`] the question is about the data that was
1202    /// measured, so the comparison always stands.
1203    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        // Availability is a property of the report, not of how much of the
1219        // source it covers: a sampled run still either analyzed the metric or
1220        // did not. So this check never consults evidence.
1221        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    /// The mirror of the duplicate-row rule: rows already read do not stop
1254    /// existing when more are read, so a scan that read at least `min` rows
1255    /// settles the requirement whatever it missed. Fewer is a failure only
1256    /// when the scan covered the whole source, or under
1257    /// [`PolicyScope::Observed`]; under [`PolicyScope::FullSource`] the rows
1258    /// that were not read may make up the difference.
1259    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    /// Decide a minimum on a score, on its whole-source interval when the
1385    /// only gap in the evidence is the quality sample and the report bounds
1386    /// the score. The interval settles the requirement when it lies wholly on
1387    /// one side of the minimum; a minimum inside it is left unevaluated.
1388    ///
1389    /// The score, its bounds and the minimum are compared at the serialized
1390    /// precision, as the rules about change are: the result and the saved
1391    /// report show them at two decimals, so a raw comparison failed a score
1392    /// shown as meeting its minimum, and a reloaded report then passed (#1085).
1393    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    /// One check per named column, in column-name order, then one per
1425    /// remaining profiled column when a wildcard limit is set, in report
1426    /// column order.
1427    ///
1428    /// The order is fixed by the data rather than by how the policy was
1429    /// written, so a policy read out of a JSON object — where key order is not
1430    /// preserved uniformly across languages — still evaluates identically.
1431    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        // Null counts accumulate over every scanned row rather than over the
1463        // retained quality sample, so only the scan's own gap applies here.
1464        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            // No value was read for the column, so "what share of its values
1486            // are null" has no answer. Zero rows is not zero percent.
1487            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        // At the precision `observed` is shown at, as for scores (#1085).
1493        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    /// The null-count ceilings, in the same order as the null-percentage
1508    /// ones: named columns in name order, then every other profiled column in
1509    /// report order.
1510    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    /// A null count, like a duplicate count, only grows as more rows are read:
1530    /// nulls the scan witnessed above the allowance fail a full-source rule
1531    /// whatever the scan missed, and a count within it passes only when the
1532    /// scan covered the source. The count is exact, so nothing is rounded.
1533    /// Unlike a percentage, it has an answer when no value was read: zero.
1534    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    /// A duplicate count, like a null count, is a requirement that an incomplete scan
1584    /// can still settle in one direction: rows already witnessed as duplicates
1585    /// do not stop being duplicates when more rows are read, so an exact count
1586    /// above the allowance is a conclusive failure. A count at or below it is
1587    /// not a pass — the rows that were not read may hold more. An estimated
1588    /// count witnesses nothing and settles neither direction.
1589    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            // A witnessed violation is a decision, whatever the scan missed.
1627            (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            // Every row may have been read: the gap is the estimate, which
1641            // can neither witness duplicates nor rule them out (#819).
1642            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    /// How far a score may drop below the baseline's. Both sides are read
1656    /// from the delta, at their serialized precision, so the decision is the
1657    /// one a saved report and its baseline document reproduce.
1658    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            // `0.0 - points`, not `-points`: a permitted drop of zero
1688            // serializes as 0.0 rather than -0.0.
1689            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        // The overall score averages the dimensions that had something to
1705        // assess, so a dimension scored on one side only changes the average
1706        // itself, whatever the data did.
1707        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        // Both sides and their change are at the serialized precision, so the
1735        // allowance is too: the decision is the one the document shows.
1736        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    /// One check per named column, in name order, then one per other column
1749    /// both reports profiled, in the baseline's order. A column only the
1750    /// evaluated report has gets no rule unless the policy names it.
1751    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    /// How far a column's null percentage may rise above the baseline's.
1768    /// Null counts accumulate over every scanned row, so each side's scan
1769    /// coverage is its evidence, and only the null-token definition can make
1770    /// the same data count differently. A column's own rule does not depend on
1771    /// the rest of the column set.
1772    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    /// Whether the side without `column` shows its source lacks it: the
1843    /// side's selection included the column, so it was not in what the side
1844    /// read, and the side's column set covers the whole source. Under
1845    /// [`PolicyScope::Observed`] the question is about what was read, so a
1846    /// column set covering part of the source still decides. `unselected` is
1847    /// the reason when the selection left the column out.
1848    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    /// The closed-schema rule's statement about the source columns the report
1998    /// did not profile: never decided, since nothing profiled shows them.
1999    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    /// A rule about which columns a report has. Its evidence is the column
2034    /// set's coverage of each side's source, not a number's.
2035    #[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    /// Decide a change between two averages or ratios. Under
2066    /// [`PolicyScope::FullSource`] a side computed over part of its source
2067    /// bounds nothing about the rest, so either side's gap leaves the rule
2068    /// unevaluated, the evaluated report's named first.
2069    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
2080/// The schema changes between the two sides, at most one per column, in the
2081/// delta's column order: the baseline's columns, then the added ones. A
2082/// change is reported only where both sides recorded what it reads; a flat
2083/// summary without a column's counts reports neither change for it, and one
2084/// without its type no type change. Column order is not compared, since no
2085/// rule reads it.
2086fn 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    // Whether each side's identifier hint names the column, `None` when the
2099    // side did not record its hints.
2100    let identified = |side: &ReportSide, column: &str| {
2101        side.identifier_order()
2102            .map(|names| names.iter().any(|name| name == column))
2103    };
2104    // Built once, so a wide schema is not rescanned per column.
2105    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            // The side without the column decides whether its source lacks
2122            // it: a selection that includes the column would have profiled it
2123            // or refused to run, so the column is not in what that side read.
2124            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                    // Only the identifier hint gives a column the identifier
2191                    // type, so the hint can explain a change only to or from
2192                    // it.
2193                    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
2219/// A delta's columns by name, built once per evaluation so a rule over every
2220/// shared column does not rescan the schema for each one.
2221struct ColumnIndex<'a> {
2222    changes: HashMap<&'a str, &'a ColumnChange>,
2223    /// Columns the evaluated report profiled.
2224    in_report: HashSet<&'a str>,
2225    /// Columns the baseline profiled.
2226    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
2251/// Why a side's profiled columns may not be all of its source's columns, or
2252/// `None` when they are: every column was selected and the column set covers
2253/// the source. The selection is named ahead of the coverage, since a
2254/// projection leaves columns out however much of the source was read.
2255fn 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
2287/// Leave a rule about change unevaluated, with a fixed sentence naming why.
2288fn 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/// A recorded measurement definition a rule about change depends on.
2330#[derive(Debug, Clone, Copy)]
2331enum Definition {
2332    /// The text values counted as null, behind every null count and so every
2333    /// score.
2334    NullTokens,
2335    /// What a pattern match counts, behind the validity score.
2336    PatternMatchCount,
2337    /// What the range and sign rules count, behind the accuracy score.
2338    RangeRuleCount,
2339    /// What key uniqueness divides by, behind the uniqueness score.
2340    KeyUniquenessUnits,
2341    /// What the outlier ratio is measured over, behind the accuracy score.
2342    OutlierUnits,
2343    /// Which column key uniqueness may measure, behind the uniqueness score.
2344    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    /// Whether both sides recorded the definition, with the same value.
2360    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
2375/// The definitions a score reads. Text length and the shape-moment estimator
2376/// feed no score.
2377fn 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
2404/// The first definition the two sides do not both record identically.
2405fn 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
2418/// Reads one hint list out of a report's recorded hints.
2419type HintColumns = fn(&RecordedHints) -> &Vec<String>;
2420
2421/// How one setting compares across the two sides.
2422#[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
2437/// The first setting a score reads that the two sides do not both record
2438/// identically. The overall score reads every setting. A dimension reads the
2439/// identifier hint, which retypes its columns and names the key uniqueness
2440/// reads, except completeness, which counts nulls whatever the type. Accuracy
2441/// also reads the positive hint, timeliness the temporal ones, and validity
2442/// the locale, which ranks the detected patterns it scores.
2443fn 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    // A report records which columns the pairs named, not which column pairs
2461    // with which, so only two empty pair lists are known to agree.
2462    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            // In the order given: the first one present is the key.
2483            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
2535/// Quality first, then dimensions in their declared order, each at most once.
2536fn 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
2559/// Fill in a check that had no quality assessment to read, naming the reason
2560/// the report recorded. Without that, "you did not ask for this" and "this
2561/// broke" reach a gate as the same absence.
2562fn 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
2613/// The interval a check decides on, from the report's bounds.
2614fn 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
2622/// The `state` tag the report serializes for its quality status.
2623pub(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
2634/// Share of a column's values that are null, or `None` when no value was read.
2635/// Mirrors the Python binding's `ColumnProfile.null_percentage`.
2636fn 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
2641/// How much of the source the scan itself covered.
2642///
2643/// Truncation is named ahead of sampling and sampling ahead of skipped
2644/// records, so the gap reported is the largest one.
2645pub(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
2653/// [`scan_evidence`] over the three recorded facts it reads, for a report
2654/// delta reading them from a flat summary.
2655pub(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/// Which part of the assessment a check reads, and so whose provenance
2672/// decides whether its number covers every scanned row.
2673#[derive(Debug, Clone, Copy)]
2674pub(crate) enum Provenance {
2675    /// The aggregate over every dimension.
2676    Overall,
2677    /// One dimension, through whichever components it is built from.
2678    Dimension(QualityDimension),
2679    /// One named component, as recorded in the assessment's confidence.
2680    Component(&'static str),
2681}
2682
2683/// The component labels a dimension is built from.
2684///
2685/// Uniqueness is the one dimension with two, and they can differ in
2686/// provenance: a full-stream row tracker counts duplicates over every row
2687/// while the key scan reads the retained sample.
2688pub(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
2700/// Whether `label` names a component of a dimension this build knows.
2701fn is_known(label: &str) -> bool {
2702    QualityDimension::all()
2703        .into_iter()
2704        .flat_map(dimension_components)
2705        .any(|component| *component == label)
2706}
2707
2708/// Whether the number a check reads came from every scanned row or from a
2709/// retained sample of them.
2710///
2711/// Resolved per component, so a check about one dimension is not downgraded by
2712/// another dimension's sampling.
2713fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
2714    let Some(quality) = quality else {
2715        // No assessment to read; the check that called this reports the
2716        // absence itself, and there is no number for evidence to describe.
2717        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
2727/// [`quality_evidence`] over the recorded facts it reads: the sampled
2728/// component labels (`None` when the report did not record them), the
2729/// components computed with signal that neither list names
2730/// ([`QualityAssessment::unlisted_components`]), and the dimensions the
2731/// overall score weighs. A report delta reads the same facts
2732/// from a flat summary, which has no [`QualityAssessment`] behind it, and
2733/// findings read one component through it, so the gate, the delta and
2734/// findings resolve coverage by one rule.
2735pub(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    // A sampled label this build does not know belongs to a dimension a
2747    // later release added. Its weight is in the stored overall score, and
2748    // its dimension was dropped when the report was read, so nothing here
2749    // rules out that it reaches the score: the score is not certified.
2750    if matches!(provenance, Provenance::Overall) && !sampled.iter().all(|label| is_known(label)) {
2751        return Evidence::Incomplete {
2752            reason: EvidenceGap::CoverageUnrecorded,
2753        };
2754    }
2755    // The components the number reads. The overall score is a weighted
2756    // average over the *assessed* dimensions, so a sampled dimension the
2757    // weights exclude does not reach it. Reporting the aggregate as sampled
2758    // because of one would withhold a verdict the number does not depend on.
2759    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    // A component with signal that neither list names has no recorded
2775    // coverage, and unknown coverage is a larger gap than a sample.
2776    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/// Every public type in this module is `#[non_exhaustive]` (#894), and so is
2791/// every enum variant with named fields, so the gate can gain variants,
2792/// struct fields and variant fields (#749) without breaking callers. Tuple
2793/// variants such as `CheckStatus::NotEvaluated(_)` are not marked: growing
2794/// one changes its positional shape, which is a redesign, not an extension.
2795///
2796/// The attribute only binds outside this crate, which is where doctests
2797/// compile. Each block below builds one struct with update syntax from a
2798/// supplied value. That is rejected only for a non-exhaustive struct from
2799/// another crate, so a field added later cannot make a block fail for a
2800/// different reason. The enums and their variants are checked by
2801/// `tests::every_public_type_is_non_exhaustive`, because no `match` or
2802/// pattern stays independent of the variants and fields they gain.
2803///
2804/// Stable rustdoc does not check the error code of a `compile_fail` block, so
2805/// any error would pass one. `tests/public_api_facade.rs` names the fields
2806/// used here in code that must compile, so a renamed field fails there instead
2807/// of passing here for the wrong reason.
2808///
2809/// ```compile_fail
2810/// use dataprof_runtime::CheckBounds;
2811/// fn f(base: CheckBounds) -> CheckBounds {
2812///     CheckBounds { lower: 0.0, ..base }
2813/// }
2814/// ```
2815///
2816/// ```compile_fail
2817/// use dataprof_runtime::Check;
2818/// fn f(base: Check) -> Check {
2819///     Check { message: String::new(), ..base }
2820/// }
2821/// ```
2822///
2823/// ```compile_fail
2824/// use dataprof_runtime::GateResult;
2825/// fn f(base: GateResult) -> GateResult {
2826///     GateResult { checks: Vec::new(), ..base }
2827/// }
2828/// ```
2829#[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    /// The source-level half of `NonExhaustiveGuards` (#894): every `pub` type
2845    /// declared in this module carries `#[non_exhaustive]`, and so does every
2846    /// variant with named fields inside a `pub enum`. Reading the source keeps
2847    /// the check independent of the variants and fields the types gain, and
2848    /// covers a type or variant added later without a list to update.
2849    #[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        // Guard the premise: the scan found the module's types and the
2901        // variants with named fields at all.
2902        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    /// Two columns: `id` clean, `note` one-fifth null.
2936    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    /// A report whose uniqueness dimension witnessed `duplicates` duplicate
2971    /// rows, truncated so the evidence behind it is partial.
2972    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    /// A report whose quality sample is smaller than the scan: the assembler
3010    /// bifurcates, and the dimensions computed from the reservoir are recorded
3011    /// as sampled while the ones from exact counters are not.
3012    ///
3013    /// This is the ordinary large-file shape, not an edge case. It is also the
3014    /// one an execution-metadata-only gate gets wrong: the source *was*
3015    /// exhausted and no row sampler ran, so `source_exhausted` and
3016    /// `sampling_applied` both say the scan covered everything.
3017    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            // The scan itself was complete; only the metric's own evidence is
3053            // not, and a decision on the interval does not pretend otherwise.
3054            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    /// A threshold equal to the value a check shows is met, and a report that
3086    /// was saved and loaded reaches the same verdict (#1085). The live report
3087    /// holds raw scores, the loaded one their two-decimal serialization, so a
3088    /// raw comparison failed `observed 66.67 >= 66.67` live and passed it
3089    /// loaded. Every (report, rule) that goes wrong is collected first.
3090    #[test]
3091    fn a_threshold_at_the_shown_value_passes_live_and_after_a_round_trip() {
3092        // One value in three missing: completeness and null share are thirds.
3093        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            // A sampled report's overall rule decides on its interval: the
3111            // lower-bound case below covers it.
3112            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                // On a sampled report the full-source rule decides on the
3126                // interval, so its threshold is the shown lower bound.
3127                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            // A minimum written with more decimals than a check shows is
3154            // compared as shown: `expected` reads 66.67 for 66.674, so a score
3155            // shown as 66.67 meets it, as an allowance does for a change.
3156            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    /// The interval half of #1085. A bound that rounds up when saved, such as
3177    /// 79.426 shown as 79.43, decided a minimum of 79.43 one way live and the
3178    /// other way loaded: inconclusive then pass at the lower bound, fail then
3179    /// inconclusive at the upper one. The verdict must not change on reload.
3180    #[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        // Just below each shown value, so saving rounds both up.
3193        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    /// Provenance is resolved per component, so a dimension computed from
3244    /// exact counters is still decidable when another one was sampled.
3245    #[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    /// A report read back from a document written before dataprof recorded
3286    /// provenance does not say whether its numbers cover every scanned row.
3287    ///
3288    /// The compat path used to answer `Exact` for those, which is a claim the
3289    /// document never made: a full-source policy would then rest a verdict on
3290    /// numbers that may have come from a sample. The Python reload path
3291    /// reports the same gap, so the two agree on legacy input.
3292    #[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        // Pre-0.10 documents carried the metrics flat, with no confidence.
3297        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        // Asked about what the metrics measured, the same report is decidable.
3323        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    /// Weights say what reaches the aggregate, not what was measured.
3332    ///
3333    /// The overall score renormalizes over the assessed dimensions, so a
3334    /// sampled dimension the weights exclude cannot move it, and reporting the
3335    /// aggregate as sampled because of one would withhold a verdict the number
3336    /// does not depend on. The excluded dimension keeps its own provenance.
3337    #[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        // Completeness is the one dimension here computed from exact column
3349        // counters, so weighting only it leaves an aggregate that no sampled
3350        // component reaches.
3351        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        // The aggregate no longer depends on the sampled dimension.
3368        assert_eq!(
3369            check_for(&result, CheckCode::MinQualityScore).evidence,
3370            Evidence::Complete
3371        );
3372        // The dimension itself still reports where its number came from.
3373        assert_eq!(
3374            check_for(&result, CheckCode::MinDimensionScore).evidence,
3375            Evidence::Incomplete {
3376                reason: EvidenceGap::QualitySampled
3377            }
3378        );
3379    }
3380
3381    /// A dimension excluded from the aggregate by a zero weight is still
3382    /// measured, and a gate reading its score still needs its provenance.
3383    ///
3384    /// Confidence used to be downgraded to `NotAssessed` whenever the
3385    /// *weighted* set was empty, which erased the sampling record for every
3386    /// dimension at once.
3387    #[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    /// The named limit decides its own column; the wildcard covers the rest.
3450    #[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    /// Thresholds are percentages. A caller who writes the 0..1 ratio instead
3475    /// would otherwise get a gate that silently never fires.
3476    #[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    /// A metric that was never analyzed is not zero and not a pass. The check
3515    /// names the recorded reason, so a pipeline can tell a misconfiguration
3516    /// from an incident.
3517    #[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    /// `require_quality` is how a caller says the absence itself is a failure.
3537    #[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    /// A dimension that was computed but had nothing to assess is reported as
3567    /// such, separately from quality never having been requested.
3568    #[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    /// A column the report does not profile is not decided either way: the
3583    /// report does not record whether it was projected away or absent.
3584    #[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    /// The whole point of the scope: a ratio measured over a prefix of the
3599    /// source says nothing about the rows that were never read.
3600    #[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        // The number was still read; it is the generalization that is refused.
3623        assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
3624
3625        // Asked about the rows that were actually read, the same report passes.
3626        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    /// Duplicates already seen do not stop being duplicates when more rows are
3635    /// read, so a partial scan can still fail a full-source requirement.
3636    #[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    /// A clean prefix is not a clean source.
3655    #[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    /// An estimated count is not a duplicate anyone saw, so it witnesses
3672    /// nothing and settles neither direction.
3673    #[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        // The gap is the estimate, not unread rows: the message said "the rows
3689        // that were not read" on a source read to the end (#819).
3690        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    /// One null in 20,001 rows shows as a 0.00% null percentage, so a zero
3698    /// percentage limit passes it; the count ceiling decides on the count
3699    /// (#1119).
3700    #[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    /// Nulls already counted stay counted, so a partial scan that saw more
3726    /// than allowed fails a full-source rule; one that saw no more than
3727    /// allowed decides nothing about the rows it did not read.
3728    #[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    /// A column with no values read has no null percentage, but it has a null
3753    /// count, zero: a full scan of an empty source decides the count rule.
3754    #[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    /// A named column's ceiling replaces the wildcard for that column, and a
3781    /// named column the report did not profile is not read as zero nulls.
3782    #[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    /// A conclusive violation outranks an unevaluated requirement: the gate
3828    /// has an answer even though it could not check everything.
3829    #[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    /// Evaluation order is fixed by the data, not by the order requirements
3842    /// were added, so the same policy always serializes the same document.
3843    #[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    /// The serialized shape is the contract a CI system reads. Percentages
3867    /// carry the report's 2dp convention; counts stay whole.
3868    #[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}