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_percentage("customer_id", 0.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    /// The duplicate-row count must be at most the threshold.
314    MaxDuplicateRows,
315    /// A metric must have been analyzed at all.
316    RequireMetric,
317    /// The overall quality score may drop at most the threshold's number of
318    /// points below the baseline's.
319    MaxQualityScoreDrop,
320    /// One dimension score may drop at most the threshold's number of points
321    /// below the baseline's.
322    MaxDimensionScoreDrop,
323    /// A column's null percentage may rise at most the threshold's number of
324    /// points above the baseline's.
325    MaxNullPercentageIncrease,
326    /// A named column must be profiled.
327    RequireColumn,
328    /// A column the baseline profiled must still be profiled.
329    ForbidRemovedColumn,
330    /// A column the report profiled must be one the baseline profiled.
331    ForbidAddedColumn,
332}
333
334impl fmt::Display for CheckCode {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        match self {
337            Self::MinRows => write!(f, "min_rows"),
338            Self::MinQualityScore => write!(f, "min_quality_score"),
339            Self::MinDimensionScore => write!(f, "min_dimension_score"),
340            Self::MaxNullPercentage => write!(f, "max_null_percentage"),
341            Self::MaxDuplicateRows => write!(f, "max_duplicate_rows"),
342            Self::RequireMetric => write!(f, "require_metric"),
343            Self::MaxQualityScoreDrop => write!(f, "max_quality_score_drop"),
344            Self::MaxDimensionScoreDrop => write!(f, "max_dimension_score_drop"),
345            Self::MaxNullPercentageIncrease => write!(f, "max_null_percentage_increase"),
346            Self::RequireColumn => write!(f, "require_column"),
347            Self::ForbidRemovedColumn => write!(f, "forbid_removed_column"),
348            Self::ForbidAddedColumn => write!(f, "forbid_added_column"),
349        }
350    }
351}
352
353/// A number a check read, or a threshold it compared against.
354///
355/// Percentages follow the report's 2dp serialization convention; counts are
356/// whole numbers and are not widened into rounded floats.
357#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
358#[serde(untagged)]
359#[non_exhaustive]
360pub enum MetricValue {
361    /// A count of rows or values.
362    Count(usize),
363    /// A 0..=100 percentage or score, or a change in one, in points.
364    Percentage(#[serde(serialize_with = "dataprof_core::serde_helpers::round_2")] f64),
365}
366
367impl fmt::Display for MetricValue {
368    /// For logs and messages, not for the serialized contract: percentages
369    /// print at the report's 2dp convention, counts as whole numbers.
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        match self {
372            Self::Count(count) => write!(f, "{count}"),
373            Self::Percentage(value) => write!(f, "{value:.2}"),
374        }
375    }
376}
377
378/// The constraint a check applied.
379#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
380#[serde(tag = "comparison", rename_all = "snake_case")]
381#[non_exhaustive]
382pub enum Expectation {
383    /// The observed value must be at least `value`.
384    #[non_exhaustive]
385    AtLeast {
386        /// The threshold.
387        value: MetricValue,
388    },
389    /// The observed value must be at most `value`.
390    #[non_exhaustive]
391    AtMost {
392        /// The threshold.
393        value: MetricValue,
394    },
395    /// The metric must have been analyzed. No number is compared.
396    Analyzed,
397    /// The change from the baseline, `observed` minus the baseline's value,
398    /// must be at least `value` points. A permitted drop is a negative value.
399    #[non_exhaustive]
400    ChangeAtLeast {
401        /// The threshold, in points.
402        value: MetricValue,
403    },
404    /// The change from the baseline must be at most `value` points.
405    #[non_exhaustive]
406    ChangeAtMost {
407        /// The threshold, in points.
408        value: MetricValue,
409    },
410    /// The column must be profiled. No number is compared.
411    Present,
412    /// The column must be one the baseline profiled. No number is compared.
413    InBaseline,
414}
415
416impl fmt::Display for Expectation {
417    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418        match self {
419            Self::AtLeast { value } => write!(f, "at least {value}"),
420            Self::AtMost { value } => write!(f, "at most {value}"),
421            Self::Analyzed => write!(f, "analyzed"),
422            Self::ChangeAtLeast { value } => write!(f, "a change of at least {value}"),
423            Self::ChangeAtMost { value } => write!(f, "a change of at most {value}"),
424            Self::Present => write!(f, "present"),
425            Self::InBaseline => write!(f, "in the baseline"),
426        }
427    }
428}
429
430/// Why a requirement was not evaluated.
431///
432/// Absence is never read as zero and never as a pass. Each variant names a
433/// different thing a caller may want to fix: a run that did not ask for the
434/// metric, a dimension with nothing to measure, a column that was not
435/// profiled, or a scan that does not reach as far as the requirement does.
436#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
437#[serde(tag = "reason", rename_all = "snake_case")]
438#[non_exhaustive]
439pub enum NotEvaluated {
440    /// The report carries no quality assessment, for this recorded reason.
441    #[non_exhaustive]
442    QualityUnavailable {
443        /// The report's `quality_status` state, verbatim.
444        quality_status: String,
445    },
446    /// Quality was computed, but this dimension had nothing to assess — no
447    /// numeric values, no dates, no confidently detected pattern — or it was
448    /// withheld because the run profiled a subset of columns.
449    NotAssessed,
450    /// The report holds no profile for the named column, and its column
451    /// selection does not show the source lacks it: the run's `columns=`
452    /// selection left it out, or the report predates the record of its
453    /// selection. On a check with no column, the run profiled a `columns=`
454    /// selection, which may have left source columns out.
455    ColumnNotProfiled,
456    /// The requirement asks about the full source, the evidence does not reach
457    /// that far, and nothing observed settles it regardless.
458    #[non_exhaustive]
459    EvidenceIncomplete {
460        /// The gap between the evidence and the requirement's scope.
461        gap: EvidenceGap,
462    },
463    /// The baseline carries no value to measure a change from: it did not
464    /// assess the metric, did not profile the column, or read no values for
465    /// it.
466    NotInBaseline,
467    /// The two reports profiled different column sets, and the requirement
468    /// is about a dataset-level score, whose subject is the column set. The
469    /// result's `baseline.schema` names the columns that differ.
470    SchemaChanged,
471    /// The overall score averages a different set of assessed dimensions on
472    /// each side, because a dimension was requested, or had something to
473    /// assess, on one side only.
474    DimensionsChanged,
475    /// A measurement definition the requirement depends on is not recorded
476    /// identically on both sides: one side predates the record, or the two
477    /// differ. Either way the same data may measure differently.
478    #[non_exhaustive]
479    DefinitionNotComparable {
480        /// The `metric_semantics` key.
481        definition: String,
482    },
483    /// A setting that moves the score on unchanged data differs between the
484    /// two reports.
485    #[non_exhaustive]
486    SettingChanged {
487        /// The configuration field that differs.
488        setting: String,
489    },
490    /// A setting that moves the score on unchanged data is not recorded on
491    /// at least one side, so it cannot be shown to be the same.
492    #[non_exhaustive]
493    SettingUnrecorded {
494        /// The configuration field that is unrecorded.
495        setting: String,
496    },
497}
498
499/// What happened to one requirement.
500#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
501#[serde(tag = "status", rename_all = "snake_case")]
502#[non_exhaustive]
503pub enum CheckStatus {
504    /// Evaluated and met.
505    Passed,
506    /// Evaluated and violated.
507    Failed,
508    /// Not evaluated, for this reason.
509    NotEvaluated(NotEvaluated),
510}
511
512/// One requirement and what the report said about it.
513#[derive(Debug, Clone, PartialEq, serde::Serialize)]
514#[non_exhaustive]
515pub struct Check {
516    /// Which requirement this is.
517    pub code: CheckCode,
518    /// The column it concerns, when it concerns one.
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub column: Option<String>,
521    /// The quality dimension it concerns, when it concerns one.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub dimension: Option<QualityDimension>,
524    /// The constraint applied.
525    pub expected: Expectation,
526    /// The value read from the report. Absent when nothing was read: every
527    /// unevaluated check, and every `require_metric` check.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub observed: Option<MetricValue>,
530    /// What the requirement is a statement about.
531    pub scope: PolicyScope,
532    /// Whether the data behind `observed` covers the whole source.
533    pub evidence: Evidence,
534    /// Where the whole-source value lies, when `observed` came from a retained
535    /// sample and the report bounds it. A full-source requirement is decided
536    /// on this interval rather than on `observed`: it passes when the whole
537    /// interval meets the requirement, fails when none of it does, and is
538    /// otherwise not evaluated.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub bounds: Option<CheckBounds>,
541    /// The baseline's side of a rule about change. Absent on every other
542    /// rule.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub baseline: Option<BaselineReading>,
545    /// Passed, failed, or not evaluated.
546    #[serde(flatten)]
547    pub status: CheckStatus,
548    /// A fixed sentence naming the outcome.
549    ///
550    /// Deliberately free of numbers: `observed` and `expected` carry those, so
551    /// the prose is identical wherever a value would have been formatted and
552    /// the Rust and Python implementations cannot drift on rounding inside a
553    /// string.
554    pub message: String,
555}
556
557impl Check {
558    /// True when the check was evaluated and violated.
559    pub fn is_violation(&self) -> bool {
560        matches!(self.status, CheckStatus::Failed)
561    }
562
563    /// Why the check was not evaluated, if it was not.
564    pub fn not_evaluated(&self) -> Option<&NotEvaluated> {
565        match &self.status {
566            CheckStatus::NotEvaluated(reason) => Some(reason),
567            _ => None,
568        }
569    }
570}
571
572/// What the baseline said about the metric a rule about change reads.
573#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
574#[non_exhaustive]
575pub struct BaselineReading {
576    /// The baseline's value, or `None` when it carries none.
577    pub observed: Option<MetricValue>,
578    /// Whether the data behind the baseline's value covers its whole source.
579    pub evidence: Evidence,
580    /// The check's `observed` minus the baseline's, in points, at the
581    /// report's serialized precision. `None` when either side has no value.
582    pub change: Option<MetricValue>,
583}
584
585/// The two reports a baseline-relative evaluation compared, and how their
586/// column sets differ.
587#[derive(Debug, Clone, PartialEq, serde::Serialize)]
588#[non_exhaustive]
589pub struct BaselineComparison {
590    /// `a` is the baseline and `b` the report evaluated, as in a
591    /// [`ReportDelta`] between them.
592    pub reports: Sides<ReportReference>,
593    /// Columns added, removed and shared.
594    pub schema: SchemaChange,
595    /// The schema changes from the baseline, whatever the policy states. A
596    /// diagnostic never moves the verdict. Empty when nothing changed.
597    pub diagnostics: Vec<Diagnostic>,
598}
599
600/// Stable identifier for a kind of schema change.
601///
602/// Part of the serialized contract, like [`CheckCode`].
603#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
604#[serde(rename_all = "snake_case")]
605#[non_exhaustive]
606pub enum DiagnosticCode {
607    /// The report profiled a column the baseline did not. Without a reason,
608    /// the baseline profiled every column it selected that its source had,
609    /// and this was not among them.
610    ColumnAdded,
611    /// The baseline profiled a column the report did not. Without a reason,
612    /// the report's selection included it, so it was not in what the report
613    /// read. With one, the report's column selection left it out or was not
614    /// recorded.
615    ColumnRemoved,
616    /// The column holds values on both sides and its inferred type differs.
617    /// A column whose values are all null on a side establishes no type there.
618    ColumnTypeChanged,
619    /// The column held values in the baseline, and every value read for it
620    /// in the report is null.
621    ColumnAllNull,
622}
623
624impl fmt::Display for DiagnosticCode {
625    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626        match self {
627            Self::ColumnAdded => write!(f, "column_added"),
628            Self::ColumnRemoved => write!(f, "column_removed"),
629            Self::ColumnTypeChanged => write!(f, "column_type_changed"),
630            Self::ColumnAllNull => write!(f, "column_all_null"),
631        }
632    }
633}
634
635/// A schema change between the baseline and the report.
636///
637/// A warning, not a fourth verdict: it is reported whatever the policy
638/// states, and no rule decides on it. A requirement that would fail on it is
639/// a policy entry of its own.
640#[derive(Debug, Clone, PartialEq, serde::Serialize)]
641#[non_exhaustive]
642pub struct Diagnostic {
643    /// What changed.
644    pub code: DiagnosticCode,
645    /// The column it changed in.
646    pub column: String,
647    /// The inferred type on each side, `a` being the baseline. Present on
648    /// [`DiagnosticCode::ColumnTypeChanged`] only.
649    #[serde(
650        skip_serializing_if = "Option::is_none",
651        serialize_with = "serialize_data_types"
652    )]
653    pub data_type: Option<Sides<DataType>>,
654    /// A recorded setting that can produce the change on unchanged data,
655    /// when one can. Serialized flat, as a check's reason is.
656    #[serde(flatten)]
657    pub setting: Option<SettingDifference>,
658    /// A fixed sentence naming the change, free of numbers and names, like a
659    /// check's message.
660    pub message: String,
661}
662
663/// How a setting that can produce a diagnostic's change compares across the
664/// two reports. A diagnostic carrying one may not reflect a change in the
665/// data.
666///
667/// Two settings are read: `columns`, the column selection, on an added or
668/// removed column, and `identifier_columns` on a type change to or from
669/// `identifier`, the one type a setting assigns. An input path that types the
670/// same data differently is not recorded, so it cannot be named here (#906).
671#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
672#[serde(tag = "reason", rename_all = "snake_case")]
673#[non_exhaustive]
674pub enum SettingDifference {
675    /// The setting differs between the two reports for this column.
676    #[non_exhaustive]
677    SettingChanged {
678        /// The configuration field that differs.
679        setting: String,
680    },
681    /// At least one side did not record the setting, so it cannot be shown
682    /// to be the same.
683    #[non_exhaustive]
684    SettingUnrecorded {
685        /// The configuration field that is unrecorded.
686        setting: String,
687    },
688}
689
690fn serialize_data_types<S: serde::Serializer>(
691    types: &Option<Sides<DataType>>,
692    serializer: S,
693) -> Result<S::Ok, S::Error> {
694    types
695        .as_ref()
696        .map(|types| Sides {
697            a: types.a.name(),
698            b: types.b.name(),
699        })
700        .serialize(serializer)
701}
702
703/// The interval a sampled score was decided on.
704#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
705#[non_exhaustive]
706pub struct CheckBounds {
707    /// Lowest value the whole-source score can take.
708    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
709    pub lower: f64,
710    /// Highest value the whole-source score can take.
711    #[serde(serialize_with = "dataprof_core::serde_helpers::round_2")]
712    pub upper: f64,
713    /// Probability that every interval in the report holds at once.
714    pub confidence_level: f64,
715}
716
717/// The overall outcome of a policy.
718#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
719#[serde(rename_all = "snake_case")]
720#[non_exhaustive]
721pub enum Verdict {
722    /// Every requirement was evaluated and met.
723    Pass,
724    /// At least one requirement was conclusively violated.
725    Fail,
726    /// Nothing was violated, and at least one requirement could not be
727    /// evaluated. Not a pass: the gate has no answer, not a positive one.
728    Inconclusive,
729}
730
731impl fmt::Display for Verdict {
732    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733        match self {
734            Self::Pass => write!(f, "pass"),
735            Self::Fail => write!(f, "fail"),
736            Self::Inconclusive => write!(f, "inconclusive"),
737        }
738    }
739}
740
741/// The structured result of evaluating a [`QualityPolicy`].
742#[derive(Debug, Clone, PartialEq, serde::Serialize)]
743#[non_exhaustive]
744pub struct GateResult {
745    /// The overall outcome.
746    pub verdict: Verdict,
747    /// The scope every requirement was evaluated under.
748    pub scope: PolicyScope,
749    /// Whether the scan itself covered the whole source. A single check's
750    /// evidence can be weaker still, when its metric came from a retained
751    /// sample of the scanned rows.
752    pub evidence: Evidence,
753    /// Every requirement, in evaluation order.
754    pub checks: Vec<Check>,
755    /// The reports compared, when the policy was evaluated against a
756    /// baseline. Absent otherwise.
757    #[serde(skip_serializing_if = "Option::is_none")]
758    pub baseline: Option<BaselineComparison>,
759}
760
761impl GateResult {
762    /// True only for [`Verdict::Pass`].
763    ///
764    /// An inconclusive result is not a pass: reading it as one is the mistake
765    /// this API exists to prevent. Callers that need to tell "violated" from
766    /// "could not tell" should match on [`GateResult::verdict`].
767    pub fn passed(&self) -> bool {
768        self.verdict == Verdict::Pass
769    }
770
771    /// The checks that were evaluated and violated.
772    pub fn violations(&self) -> impl Iterator<Item = &Check> {
773        self.checks.iter().filter(|check| check.is_violation())
774    }
775
776    /// The checks that could not be evaluated.
777    pub fn unevaluated(&self) -> impl Iterator<Item = &Check> {
778        self.checks
779            .iter()
780            .filter(|check| check.not_evaluated().is_some())
781    }
782}
783
784/// A metric a policy requires to have been analyzed.
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
786#[non_exhaustive]
787pub enum RequiredMetric {
788    /// The quality assessment as a whole.
789    Quality,
790    /// One ISO 25012 dimension.
791    Dimension(QualityDimension),
792}
793
794/// What a report must satisfy.
795///
796/// Build one with the chainable setters, then [`evaluate`](Self::evaluate) it
797/// against a report. Requirements are evaluated in a fixed order that does not
798/// depend on the order they were added, so two callers stating the same policy
799/// get the same document out.
800#[derive(Debug, Clone, Default)]
801#[non_exhaustive]
802pub struct QualityPolicy {
803    min_rows: Option<usize>,
804    min_quality_score: Option<f64>,
805    min_dimension_scores: BTreeMap<String, (QualityDimension, f64)>,
806    max_null_percentage: BTreeMap<String, f64>,
807    max_null_percentage_any: Option<f64>,
808    max_duplicate_rows: Option<usize>,
809    require_metrics: Vec<RequiredMetric>,
810    max_quality_score_drop: Option<f64>,
811    max_dimension_score_drops: BTreeMap<String, (QualityDimension, f64)>,
812    max_null_percentage_increase: BTreeMap<String, f64>,
813    max_null_percentage_increase_any: Option<f64>,
814    require_columns: BTreeSet<String>,
815    forbid_removed_columns: bool,
816    forbid_added_columns: bool,
817    scope: PolicyScope,
818}
819
820impl QualityPolicy {
821    /// An empty policy. It states no requirement, so evaluating it as-is is a
822    /// [`PolicyError::NoRequirements`].
823    pub fn new() -> Self {
824        Self::default()
825    }
826
827    /// Require the scan to have read at least `min` rows, so an empty or
828    /// truncated extract fails instead of passing every other check.
829    pub fn min_rows(mut self, min: usize) -> Self {
830        self.min_rows = Some(min);
831        self
832    }
833
834    /// Require the overall quality score to be at least `min` (0..=100).
835    pub fn min_quality_score(mut self, min: f64) -> Self {
836        self.min_quality_score = Some(min);
837        self
838    }
839
840    /// Require one dimension's score to be at least `min` (0..=100).
841    pub fn min_dimension_score(mut self, dimension: QualityDimension, min: f64) -> Self {
842        self.min_dimension_scores
843            .insert(dimension.to_string(), (dimension, min));
844        self
845    }
846
847    /// Require the named column's null percentage to be at most `max`
848    /// (0..=100). Takes precedence over
849    /// [`max_null_percentage_any`](Self::max_null_percentage_any) for that
850    /// column.
851    pub fn max_null_percentage(mut self, column: impl Into<String>, max: f64) -> Self {
852        self.max_null_percentage.insert(column.into(), max);
853        self
854    }
855
856    /// Require every other profiled column's null percentage to be at most
857    /// `max` (0..=100).
858    pub fn max_null_percentage_any(mut self, max: f64) -> Self {
859        self.max_null_percentage_any = Some(max);
860        self
861    }
862
863    /// Require the report to count at most `max` duplicate rows.
864    pub fn max_duplicate_rows(mut self, max: usize) -> Self {
865        self.max_duplicate_rows = Some(max);
866        self
867    }
868
869    /// Require the report to carry a quality assessment at all.
870    ///
871    /// Without this, a report with no assessment leaves quality requirements
872    /// unevaluated and the verdict inconclusive. With it, the absence is
873    /// itself a violation — which is what a pipeline wants when quality is
874    /// supposed to be configured on.
875    pub fn require_quality(mut self) -> Self {
876        self.require_metrics.push(RequiredMetric::Quality);
877        self
878    }
879
880    /// Require a specific dimension to have been assessed.
881    pub fn require_dimension(mut self, dimension: QualityDimension) -> Self {
882        self.require_metrics
883            .push(RequiredMetric::Dimension(dimension));
884        self
885    }
886
887    /// Allow the overall quality score to drop at most `points` (0..=100)
888    /// below the baseline's. Needs [`evaluate_against`](Self::evaluate_against).
889    pub fn max_quality_score_drop(mut self, points: f64) -> Self {
890        self.max_quality_score_drop = Some(points);
891        self
892    }
893
894    /// Allow one dimension's score to drop at most `points` (0..=100) below
895    /// the baseline's. Needs [`evaluate_against`](Self::evaluate_against).
896    pub fn max_dimension_score_drop(mut self, dimension: QualityDimension, points: f64) -> Self {
897        self.max_dimension_score_drops
898            .insert(dimension.to_string(), (dimension, points));
899        self
900    }
901
902    /// Allow the named column's null percentage to rise at most `points`
903    /// (0..=100) above the baseline's. Takes precedence over
904    /// [`max_null_percentage_increase_any`](Self::max_null_percentage_increase_any)
905    /// for that column. Needs [`evaluate_against`](Self::evaluate_against).
906    pub fn max_null_percentage_increase(mut self, column: impl Into<String>, points: f64) -> Self {
907        self.max_null_percentage_increase
908            .insert(column.into(), points);
909        self
910    }
911
912    /// Allow every other column both reports profiled to have its null
913    /// percentage rise at most `points` (0..=100) above the baseline's. A
914    /// column only the evaluated report has is not covered: it has no
915    /// baseline, and gets a rule only when the policy names it. Needs
916    /// [`evaluate_against`](Self::evaluate_against).
917    pub fn max_null_percentage_increase_any(mut self, points: f64) -> Self {
918        self.max_null_percentage_increase_any = Some(points);
919        self
920    }
921
922    /// Require each named column to be profiled. A column missing from the
923    /// report fails only when the report shows its source lacks it: the run's
924    /// column selection included the column, and the column set covers the
925    /// whole source (see the [module docs](self)).
926    pub fn require_columns<I, S>(mut self, columns: I) -> Self
927    where
928        I: IntoIterator<Item = S>,
929        S: Into<String>,
930    {
931        self.require_columns
932            .extend(columns.into_iter().map(Into::into));
933        self
934    }
935
936    /// Require every column the baseline profiled to be profiled still. The
937    /// rule is about the columns the baseline recorded: a column its source
938    /// held but it did not profile (a projection, or a key past a partial
939    /// read) is not one it can require. Needs
940    /// [`evaluate_against`](Self::evaluate_against).
941    pub fn forbid_removed_columns(mut self) -> Self {
942        self.forbid_removed_columns = true;
943        self
944    }
945
946    /// Require every column the report profiled to be one the baseline
947    /// profiled: a closed schema. Under [`PolicyScope::FullSource`], a report
948    /// whose profiled columns may not be all of its source's also gets one
949    /// check with no column, left unevaluated: a column selection that left
950    /// columns out, one that was not recorded, or a column set covering part
951    /// of the source. Needs [`evaluate_against`](Self::evaluate_against).
952    pub fn forbid_added_columns(mut self) -> Self {
953        self.forbid_added_columns = true;
954        self
955    }
956
957    /// Set what the requirements are statements about. Defaults to
958    /// [`PolicyScope::FullSource`].
959    pub fn scope(mut self, scope: PolicyScope) -> Self {
960        self.scope = scope;
961        self
962    }
963
964    /// Check that the policy is evaluable, without needing a report.
965    ///
966    /// Callers that read a policy out of configuration should do this at
967    /// startup rather than discovering a bad threshold on the first dataset.
968    pub fn validate(&self) -> Result<(), PolicyError> {
969        if let Some(min) = self.min_quality_score {
970            check_percentage(CheckCode::MinQualityScore, None, min)?;
971        }
972        for (dimension, min) in self.min_dimension_scores.values() {
973            check_percentage(
974                CheckCode::MinDimensionScore,
975                Some(dimension.to_string()),
976                *min,
977            )?;
978        }
979        for (column, max) in &self.max_null_percentage {
980            check_percentage(CheckCode::MaxNullPercentage, Some(column.clone()), *max)?;
981        }
982        if let Some(max) = self.max_null_percentage_any {
983            check_percentage(CheckCode::MaxNullPercentage, None, max)?;
984        }
985        if let Some(points) = self.max_quality_score_drop {
986            check_percentage(CheckCode::MaxQualityScoreDrop, None, points)?;
987        }
988        for (dimension, points) in self.max_dimension_score_drops.values() {
989            check_percentage(
990                CheckCode::MaxDimensionScoreDrop,
991                Some(dimension.to_string()),
992                *points,
993            )?;
994        }
995        for (column, points) in &self.max_null_percentage_increase {
996            check_percentage(
997                CheckCode::MaxNullPercentageIncrease,
998                Some(column.clone()),
999                *points,
1000            )?;
1001        }
1002        if let Some(points) = self.max_null_percentage_increase_any {
1003            check_percentage(CheckCode::MaxNullPercentageIncrease, None, points)?;
1004        }
1005        if self.is_empty() {
1006            return Err(PolicyError::NoRequirements);
1007        }
1008        Ok(())
1009    }
1010
1011    fn is_empty(&self) -> bool {
1012        self.min_rows.is_none()
1013            && self.min_quality_score.is_none()
1014            && self.min_dimension_scores.is_empty()
1015            && self.max_null_percentage.is_empty()
1016            && self.max_null_percentage_any.is_none()
1017            && self.max_duplicate_rows.is_none()
1018            && self.require_metrics.is_empty()
1019            && self.require_columns.is_empty()
1020            && !self.has_change_rules()
1021    }
1022
1023    /// Whether the policy states a rule about change from a baseline.
1024    fn has_change_rules(&self) -> bool {
1025        self.max_quality_score_drop.is_some()
1026            || !self.max_dimension_score_drops.is_empty()
1027            || !self.max_null_percentage_increase.is_empty()
1028            || self.max_null_percentage_increase_any.is_some()
1029            || self.forbid_removed_columns
1030            || self.forbid_added_columns
1031    }
1032
1033    /// Evaluate the policy against a report.
1034    ///
1035    /// Returns the structured outcome. The report is not modified, nothing is
1036    /// printed, and the process is not exited: what to do about a failing gate
1037    /// is the caller's decision. A policy with a rule about change is a
1038    /// [`PolicyError::BaselineRequired`] here; use
1039    /// [`evaluate_against`](Self::evaluate_against).
1040    pub fn evaluate(&self, report: &ProfileReport) -> Result<GateResult, PolicyError> {
1041        self.validate()?;
1042        if self.has_change_rules() {
1043            return Err(PolicyError::BaselineRequired);
1044        }
1045        let checks = self.absolute_checks(report);
1046        Ok(GateResult {
1047            verdict: verdict_of(&checks),
1048            scope: self.scope,
1049            evidence: scan_evidence(&report.execution),
1050            checks,
1051            baseline: None,
1052        })
1053    }
1054
1055    /// Evaluate the policy against a report and a baseline it is compared
1056    /// with.
1057    ///
1058    /// Every requirement is evaluated as [`evaluate`](Self::evaluate) does,
1059    /// then each rule about change, in a fixed order: the overall score, the
1060    /// dimensions in [`QualityDimension::all`] order, then the named columns
1061    /// in name order and every other shared column in the baseline's order.
1062    /// The baseline may come from a flat summary, which leaves what it did
1063    /// not record unrecorded. See the [module docs](self) for when a change
1064    /// is decided.
1065    pub fn evaluate_against(
1066        &self,
1067        report: &ProfileReport,
1068        baseline: &ReportSide,
1069    ) -> Result<GateResult, PolicyError> {
1070        self.validate()?;
1071        let current = ReportSide::from_report(report);
1072        let delta = ReportDelta::between_sides(baseline, &current);
1073        let identifiers = Sides {
1074            a: baseline.identifier_order(),
1075            b: current.identifier_order(),
1076        };
1077        let mut checks = self.absolute_checks(report);
1078        if let Some(points) = self.max_quality_score_drop {
1079            checks.push(self.score_drop_check(report, &delta, &identifiers, None, points));
1080        }
1081        for dimension in QualityDimension::all() {
1082            if let Some((_, points)) = self.max_dimension_score_drops.get(&dimension.to_string()) {
1083                checks.push(self.score_drop_check(
1084                    report,
1085                    &delta,
1086                    &identifiers,
1087                    Some(dimension),
1088                    *points,
1089                ));
1090            }
1091        }
1092        checks.extend(self.null_increase_checks(&delta));
1093        // Collected once, so a rule over every column does not rescan the
1094        // other side's columns for each one.
1095        let (baseline_names, current_names) = (column_names(baseline), column_names(&current));
1096        if self.forbid_removed_columns {
1097            for column in baseline.columns() {
1098                checks.push(self.forbid_removed_check(
1099                    baseline,
1100                    &current,
1101                    &current_names,
1102                    &column.name,
1103                ));
1104            }
1105        }
1106        if self.forbid_added_columns {
1107            for column in current.columns() {
1108                checks.push(self.forbid_added_check(
1109                    baseline,
1110                    &current,
1111                    &baseline_names,
1112                    &column.name,
1113                ));
1114            }
1115            // The per-column checks cover the columns the report found. When
1116            // those do not enumerate the source, a column the baseline lacked
1117            // may be among the ones it did not profile, which no per-column
1118            // check can name.
1119            if self.scope == PolicyScope::FullSource
1120                && let Some(reason) = unenumerated_columns(&current)
1121            {
1122                checks.push(self.unprofiled_columns_check(baseline, &current, reason));
1123            }
1124        }
1125        let diagnostics = schema_diagnostics(baseline, &current, &delta);
1126        Ok(GateResult {
1127            verdict: verdict_of(&checks),
1128            scope: self.scope,
1129            evidence: scan_evidence(&report.execution),
1130            checks,
1131            baseline: Some(BaselineComparison {
1132                reports: delta.reports,
1133                schema: delta.schema,
1134                diagnostics,
1135            }),
1136        })
1137    }
1138
1139    /// The requirements about the report alone, in their fixed order.
1140    fn absolute_checks(&self, report: &ProfileReport) -> Vec<Check> {
1141        let scan = scan_evidence(&report.execution);
1142        let mut checks = Vec::new();
1143
1144        for metric in required_metrics_in_order(&self.require_metrics) {
1145            checks.push(self.require_metric_check(report, metric));
1146        }
1147        if !self.require_columns.is_empty() {
1148            let side = ReportSide::from_report(report);
1149            let names = column_names(&side);
1150            for column in &self.require_columns {
1151                checks.push(self.require_column_check(&side, &names, column));
1152            }
1153        }
1154        if let Some(min) = self.min_rows {
1155            checks.push(self.row_count_check(report, scan, min));
1156        }
1157        if let Some(min) = self.min_quality_score {
1158            checks.push(self.quality_score_check(report, scan, min));
1159        }
1160        for dimension in QualityDimension::all() {
1161            if let Some((_, min)) = self.min_dimension_scores.get(&dimension.to_string()) {
1162                checks.push(self.dimension_score_check(report, scan, dimension, *min));
1163            }
1164        }
1165        checks.extend(self.null_percentage_checks(report, scan));
1166        if let Some(max) = self.max_duplicate_rows {
1167            checks.push(self.duplicate_rows_check(report, scan, max));
1168        }
1169        checks
1170    }
1171
1172    /// Decide a comparison whose observed value is an average or a ratio.
1173    ///
1174    /// Such a value, computed over part of a source, bounds nothing about the
1175    /// rest of it, so under [`PolicyScope::FullSource`] incomplete evidence
1176    /// leaves the requirement unevaluated in both directions. Under
1177    /// [`PolicyScope::Observed`] the question is about the data that was
1178    /// measured, so the comparison always stands.
1179    fn decide_aggregate(&self, evidence: Evidence, satisfied: bool) -> CheckStatus {
1180        match (self.scope, evidence.gap()) {
1181            (PolicyScope::FullSource, Some(gap)) => {
1182                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1183            }
1184            _ if satisfied => CheckStatus::Passed,
1185            _ => CheckStatus::Failed,
1186        }
1187    }
1188
1189    fn require_metric_check(&self, report: &ProfileReport, metric: RequiredMetric) -> Check {
1190        let dimension = match metric {
1191            RequiredMetric::Quality => None,
1192            RequiredMetric::Dimension(dimension) => Some(dimension),
1193        };
1194        // Availability is a property of the report, not of how much of the
1195        // source it covers: a sampled run still either analyzed the metric or
1196        // did not. So this check never consults evidence.
1197        let analyzed = match (report.quality.as_ref(), dimension) {
1198            (None, _) => false,
1199            (Some(_), None) => true,
1200            (Some(quality), Some(dimension)) => {
1201                quality.metrics.dimension_score(dimension).is_some()
1202            }
1203        };
1204        let message = match (analyzed, dimension) {
1205            (true, None) => "quality was analyzed",
1206            (false, None) => "the report carries no quality assessment",
1207            (true, Some(_)) => "the required dimension was assessed",
1208            (false, Some(_)) => "the required dimension was not assessed",
1209        };
1210        Check {
1211            code: CheckCode::RequireMetric,
1212            column: None,
1213            dimension,
1214            expected: Expectation::Analyzed,
1215            observed: None,
1216            scope: self.scope,
1217            evidence: Evidence::Complete,
1218            bounds: None,
1219            baseline: None,
1220            status: if analyzed {
1221                CheckStatus::Passed
1222            } else {
1223                CheckStatus::Failed
1224            },
1225            message: message.to_string(),
1226        }
1227    }
1228
1229    /// The mirror of the duplicate-row rule: rows already read do not stop
1230    /// existing when more are read, so a scan that read at least `min` rows
1231    /// settles the requirement whatever it missed. Fewer is a failure only
1232    /// when the scan covered the whole source, or under
1233    /// [`PolicyScope::Observed`]; under [`PolicyScope::FullSource`] the rows
1234    /// that were not read may make up the difference.
1235    fn row_count_check(&self, report: &ProfileReport, scan: Evidence, min: usize) -> Check {
1236        let observed = report.execution.rows_processed;
1237        let status = match (self.scope, scan.gap()) {
1238            _ if observed >= min => CheckStatus::Passed,
1239            (PolicyScope::FullSource, Some(gap)) => {
1240                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1241            }
1242            _ => CheckStatus::Failed,
1243        };
1244        let message = match status {
1245            CheckStatus::Passed => "the scan read at least the required number of rows",
1246            CheckStatus::Failed => "fewer rows were read than the required minimum",
1247            CheckStatus::NotEvaluated(_) => {
1248                "fewer rows were read than the required minimum, and the rows that were not \
1249                 read may make up the difference"
1250            }
1251        };
1252        Check {
1253            code: CheckCode::MinRows,
1254            column: None,
1255            dimension: None,
1256            expected: Expectation::AtLeast {
1257                value: MetricValue::Count(min),
1258            },
1259            observed: Some(MetricValue::Count(observed)),
1260            scope: self.scope,
1261            evidence: scan,
1262            bounds: None,
1263            baseline: None,
1264            status,
1265            message: message.to_string(),
1266        }
1267    }
1268
1269    fn quality_score_check(&self, report: &ProfileReport, scan: Evidence, min: f64) -> Check {
1270        let mut check = Check {
1271            code: CheckCode::MinQualityScore,
1272            column: None,
1273            dimension: None,
1274            expected: Expectation::AtLeast {
1275                value: MetricValue::Percentage(min),
1276            },
1277            observed: None,
1278            scope: self.scope,
1279            evidence: scan.and(quality_evidence(
1280                report.quality.as_ref(),
1281                Provenance::Overall,
1282            )),
1283            bounds: None,
1284            baseline: None,
1285            status: CheckStatus::Passed,
1286            message: String::new(),
1287        };
1288        let Some(quality) = report.quality.as_ref() else {
1289            return unavailable_quality(check, report);
1290        };
1291        let Some(score) = quality.score() else {
1292            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1293            check.message =
1294                "no quality dimension had anything to assess, so there is no overall score"
1295                    .to_string();
1296            return check;
1297        };
1298        check.observed = Some(MetricValue::Percentage(score));
1299        let bounds = quality
1300            .score_bounds()
1301            .and_then(|bounds| check_bounds(bounds, bounds.overall_score));
1302        self.decide_score(&mut check, score, min, bounds, "the overall quality score");
1303        check
1304    }
1305
1306    fn dimension_score_check(
1307        &self,
1308        report: &ProfileReport,
1309        scan: Evidence,
1310        dimension: QualityDimension,
1311        min: f64,
1312    ) -> Check {
1313        let mut check = Check {
1314            code: CheckCode::MinDimensionScore,
1315            column: None,
1316            dimension: Some(dimension),
1317            expected: Expectation::AtLeast {
1318                value: MetricValue::Percentage(min),
1319            },
1320            observed: None,
1321            scope: self.scope,
1322            evidence: scan.and(quality_evidence(
1323                report.quality.as_ref(),
1324                Provenance::Dimension(dimension),
1325            )),
1326            bounds: None,
1327            baseline: None,
1328            status: CheckStatus::Passed,
1329            message: String::new(),
1330        };
1331        let Some(quality) = report.quality.as_ref() else {
1332            return unavailable_quality(check, report);
1333        };
1334        let Some(score) = quality
1335            .scores()
1336            .dimension_scores
1337            .get(&dimension.to_string())
1338            .copied()
1339            .flatten()
1340        else {
1341            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1342            check.message = "this dimension had nothing to assess in this run".to_string();
1343            return check;
1344        };
1345        check.observed = Some(MetricValue::Percentage(score));
1346        let bounds = quality.score_bounds().and_then(|bounds| {
1347            check_bounds(
1348                bounds,
1349                bounds
1350                    .dimension_scores
1351                    .get(&dimension.to_string())
1352                    .copied()
1353                    .flatten(),
1354            )
1355        });
1356        self.decide_score(&mut check, score, min, bounds, "this dimension's score");
1357        check
1358    }
1359
1360    /// Decide a minimum on a score, on its whole-source interval when the
1361    /// only gap in the evidence is the quality sample and the report bounds
1362    /// the score. The interval settles the requirement when it lies wholly on
1363    /// one side of the minimum; a minimum inside it is left unevaluated.
1364    ///
1365    /// The score, its bounds and the minimum are compared at the serialized
1366    /// precision, as the rules about change are: the result and the saved
1367    /// report show them at two decimals, so a raw comparison failed a score
1368    /// shown as meeting its minimum, and a reloaded report then passed (#1085).
1369    fn decide_score(
1370        &self,
1371        check: &mut Check,
1372        score: f64,
1373        min: f64,
1374        bounds: Option<CheckBounds>,
1375        subject: &str,
1376    ) {
1377        let sampled = check.evidence.gap() == Some(EvidenceGap::QualitySampled);
1378        let min = rounded_2(min);
1379        match bounds {
1380            Some(bounds) if self.scope == PolicyScope::FullSource && sampled => {
1381                check.bounds = Some(bounds);
1382                check.status = if rounded_2(bounds.lower) >= min {
1383                    CheckStatus::Passed
1384                } else if rounded_2(bounds.upper) < min {
1385                    CheckStatus::Failed
1386                } else {
1387                    CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete {
1388                        gap: EvidenceGap::QualitySampled,
1389                    })
1390                };
1391                check.message = bounded_message(&check.status, subject);
1392            }
1393            _ => {
1394                check.status = self.decide_aggregate(check.evidence, rounded_2(score) >= min);
1395                check.message = aggregate_message(&check.status, subject);
1396            }
1397        }
1398    }
1399
1400    /// One check per named column, in column-name order, then one per
1401    /// remaining profiled column when a wildcard limit is set, in report
1402    /// column order.
1403    ///
1404    /// The order is fixed by the data rather than by how the policy was
1405    /// written, so a policy read out of a JSON object — where key order is not
1406    /// preserved uniformly across languages — still evaluates identically.
1407    fn null_percentage_checks(&self, report: &ProfileReport, scan: Evidence) -> Vec<Check> {
1408        let mut checks = Vec::new();
1409        for (column, max) in &self.max_null_percentage {
1410            let profile = report
1411                .column_profiles
1412                .iter()
1413                .find(|candidate| &candidate.name == column);
1414            checks.push(self.null_percentage_check(column, profile, scan, *max));
1415        }
1416        if let Some(max) = self.max_null_percentage_any {
1417            for profile in &report.column_profiles {
1418                if !self.max_null_percentage.contains_key(&profile.name) {
1419                    checks.push(self.null_percentage_check(
1420                        &profile.name,
1421                        Some(profile),
1422                        scan,
1423                        max,
1424                    ));
1425                }
1426            }
1427        }
1428        checks
1429    }
1430
1431    fn null_percentage_check(
1432        &self,
1433        column: &str,
1434        profile: Option<&ColumnProfile>,
1435        scan: Evidence,
1436        max: f64,
1437    ) -> Check {
1438        // Null counts accumulate over every scanned row rather than over the
1439        // retained quality sample, so only the scan's own gap applies here.
1440        let mut check = Check {
1441            code: CheckCode::MaxNullPercentage,
1442            column: Some(column.to_string()),
1443            dimension: None,
1444            expected: Expectation::AtMost {
1445                value: MetricValue::Percentage(max),
1446            },
1447            observed: None,
1448            scope: self.scope,
1449            evidence: scan,
1450            bounds: None,
1451            baseline: None,
1452            status: CheckStatus::Passed,
1453            message: String::new(),
1454        };
1455        let Some(profile) = profile else {
1456            check.status = CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled);
1457            check.message = "this column has no profile in the report".to_string();
1458            return check;
1459        };
1460        let Some(percentage) = null_percentage(profile) else {
1461            // No value was read for the column, so "what share of its values
1462            // are null" has no answer. Zero rows is not zero percent.
1463            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1464            check.message = "no values were read for this column".to_string();
1465            return check;
1466        };
1467        check.observed = Some(MetricValue::Percentage(percentage));
1468        // At the precision `observed` is shown at, as for scores (#1085).
1469        check.status =
1470            self.decide_aggregate(check.evidence, rounded_2(percentage) <= rounded_2(max));
1471        check.message = match check.status {
1472            CheckStatus::Passed => "this column's null percentage is within the allowance",
1473            CheckStatus::Failed => "this column's null percentage is above the allowance",
1474            CheckStatus::NotEvaluated(_) => {
1475                "the scan does not cover the whole source, and a null percentage over \
1476                 part of it bounds nothing about the rest"
1477            }
1478        }
1479        .to_string();
1480        check
1481    }
1482
1483    /// A duplicate count is the one requirement here that an incomplete scan
1484    /// can still settle in one direction: rows already witnessed as duplicates
1485    /// do not stop being duplicates when more rows are read, so an exact count
1486    /// above the allowance is a conclusive failure. A count at or below it is
1487    /// not a pass — the rows that were not read may hold more. An estimated
1488    /// count witnesses nothing and settles neither direction.
1489    fn duplicate_rows_check(&self, report: &ProfileReport, scan: Evidence, max: usize) -> Check {
1490        let mut check = Check {
1491            code: CheckCode::MaxDuplicateRows,
1492            column: None,
1493            dimension: Some(QualityDimension::Uniqueness),
1494            expected: Expectation::AtMost {
1495                value: MetricValue::Count(max),
1496            },
1497            observed: None,
1498            scope: self.scope,
1499            evidence: scan.and(quality_evidence(
1500                report.quality.as_ref(),
1501                Provenance::Component("duplicate_rows"),
1502            )),
1503            bounds: None,
1504            baseline: None,
1505            status: CheckStatus::Passed,
1506            message: String::new(),
1507        };
1508        let Some(quality) = report.quality.as_ref() else {
1509            return unavailable_quality(check, report);
1510        };
1511        let Some(uniqueness) = quality
1512            .metrics
1513            .uniqueness
1514            .as_ref()
1515            .filter(|metrics| metrics.rows_checked > 0)
1516        else {
1517            check.status = CheckStatus::NotEvaluated(NotEvaluated::NotAssessed);
1518            check.message = "no rows were scanned for duplicates in this run".to_string();
1519            return check;
1520        };
1521        let observed = uniqueness.duplicate_rows;
1522        let exceeded = observed > max;
1523        let estimated = uniqueness.duplicate_rows_approximate;
1524        check.observed = Some(MetricValue::Count(observed));
1525        check.status = match (self.scope, check.evidence.gap()) {
1526            // A witnessed violation is a decision, whatever the scan missed.
1527            (PolicyScope::FullSource, Some(_)) if exceeded && !estimated => CheckStatus::Failed,
1528            (PolicyScope::FullSource, Some(gap)) => {
1529                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1530            }
1531            _ if exceeded => CheckStatus::Failed,
1532            _ => CheckStatus::Passed,
1533        };
1534        check.message = match check.status {
1535            CheckStatus::Passed => "the duplicate-row count is within the allowance",
1536            CheckStatus::Failed if estimated => {
1537                "the estimated duplicate-row count is above the allowance"
1538            }
1539            CheckStatus::Failed => "duplicate rows were observed above the allowance",
1540            // Every row may have been read: the gap is the estimate, which
1541            // can neither witness duplicates nor rule them out (#819).
1542            CheckStatus::NotEvaluated(_) if estimated => {
1543                "the duplicate-row count is an estimate, which can neither witness \
1544                 duplicates above the allowance nor rule them out"
1545            }
1546            CheckStatus::NotEvaluated(_) => {
1547                "no duplicate above the allowance was observed, and the rows that were not \
1548                 read may hold more"
1549            }
1550        }
1551        .to_string();
1552        check
1553    }
1554
1555    /// How far a score may drop below the baseline's. Both sides are read
1556    /// from the delta, at their serialized precision, so the decision is the
1557    /// one a saved report and its baseline document reproduce.
1558    fn score_drop_check(
1559        &self,
1560        report: &ProfileReport,
1561        delta: &ReportDelta,
1562        identifiers: &Sides<Option<&[String]>>,
1563        dimension: Option<QualityDimension>,
1564        points: f64,
1565    ) -> Check {
1566        let (code, change, subject) = match dimension {
1567            None => (
1568                CheckCode::MaxQualityScoreDrop,
1569                &delta.quality_score,
1570                "the overall quality score",
1571            ),
1572            Some(dimension) => (
1573                CheckCode::MaxDimensionScoreDrop,
1574                &delta
1575                    .dimensions
1576                    .iter()
1577                    .find(|change| change.dimension == dimension)
1578                    .expect("a delta lists every dimension this build knows")
1579                    .score,
1580                "this dimension's score",
1581            ),
1582        };
1583        let mut check = Check {
1584            code,
1585            column: None,
1586            dimension,
1587            // `0.0 - points`, not `-points`: a permitted drop of zero
1588            // serializes as 0.0 rather than -0.0.
1589            expected: Expectation::ChangeAtLeast {
1590                value: MetricValue::Percentage(0.0 - points),
1591            },
1592            observed: change.b.map(MetricValue::Percentage),
1593            scope: self.scope,
1594            evidence: change.evidence.b,
1595            bounds: None,
1596            baseline: Some(baseline_reading(change.a, change.evidence.a, change.abs)),
1597            status: CheckStatus::Passed,
1598            message: String::new(),
1599        };
1600        if report.quality.is_none() {
1601            return unavailable_quality(check, report);
1602        }
1603        let schema_changed = !delta.schema.added.is_empty() || !delta.schema.removed.is_empty();
1604        // The overall score averages the dimensions that had something to
1605        // assess, so a dimension scored on one side only changes the average
1606        // itself, whatever the data did.
1607        let dimensions_changed = dimension.is_none()
1608            && delta
1609                .dimensions
1610                .iter()
1611                .any(|change| change.score.a.is_some() != change.score.b.is_some());
1612        let reason = if change.b.is_none() {
1613            Some(NotEvaluated::NotAssessed)
1614        } else if change.a.is_none() {
1615            Some(NotEvaluated::NotInBaseline)
1616        } else if schema_changed {
1617            Some(NotEvaluated::SchemaChanged)
1618        } else if dimensions_changed {
1619            Some(NotEvaluated::DimensionsChanged)
1620        } else {
1621            definition_gap(
1622                delta.metric_semantics.a.as_ref(),
1623                delta.metric_semantics.b.as_ref(),
1624                score_definitions(dimension),
1625            )
1626            .or_else(|| setting_gap(&delta.configuration, identifiers, dimension))
1627        };
1628        if let Some(reason) = reason {
1629            return not_evaluated_change(check, reason, subject);
1630        }
1631        let change_points = change
1632            .abs
1633            .expect("a delta has a change whenever both sides have a score");
1634        // Both sides and their change are at the serialized precision, so the
1635        // allowance is too: the decision is the one the document shows.
1636        check.status =
1637            self.decide_change(&change.evidence, change_points >= 0.0 - rounded_2(points));
1638        check.message = match check.status {
1639            CheckStatus::Passed => {
1640                format!("{subject} dropped no more than allowed from the baseline")
1641            }
1642            CheckStatus::Failed => format!("{subject} dropped more than allowed from the baseline"),
1643            CheckStatus::NotEvaluated(_) => evidence_message(subject),
1644        };
1645        check
1646    }
1647
1648    /// One check per named column, in name order, then one per other column
1649    /// both reports profiled, in the baseline's order. A column only the
1650    /// evaluated report has gets no rule unless the policy names it.
1651    fn null_increase_checks(&self, delta: &ReportDelta) -> Vec<Check> {
1652        let index = ColumnIndex::new(delta);
1653        let mut checks = Vec::new();
1654        for (column, points) in &self.max_null_percentage_increase {
1655            checks.push(self.null_increase_check(delta, &index, column, *points));
1656        }
1657        if let Some(points) = self.max_null_percentage_increase_any {
1658            for column in &delta.schema.common {
1659                if !self.max_null_percentage_increase.contains_key(column) {
1660                    checks.push(self.null_increase_check(delta, &index, column, points));
1661                }
1662            }
1663        }
1664        checks
1665    }
1666
1667    /// How far a column's null percentage may rise above the baseline's.
1668    /// Null counts accumulate over every scanned row, so each side's scan
1669    /// coverage is its evidence, and only the null-token definition can make
1670    /// the same data count differently. A column's own rule does not depend on
1671    /// the rest of the column set.
1672    fn null_increase_check(
1673        &self,
1674        delta: &ReportDelta,
1675        index: &ColumnIndex<'_>,
1676        column: &str,
1677        points: f64,
1678    ) -> Check {
1679        let change = index.changes.get(column).copied();
1680        let (null_pct_a, null_pct_b, null_pct_delta) = change.map_or(
1681            (None, None, None),
1682            |ColumnChange {
1683                 null_pct_a,
1684                 null_pct_b,
1685                 null_pct_delta,
1686                 ..
1687             }| (*null_pct_a, *null_pct_b, *null_pct_delta),
1688        );
1689        let in_report = index.in_report.contains(column);
1690        let in_baseline = index.in_baseline.contains(column);
1691        let evidence = Sides {
1692            a: delta.reports.a.evidence,
1693            b: delta.reports.b.evidence,
1694        };
1695        let mut check = Check {
1696            code: CheckCode::MaxNullPercentageIncrease,
1697            column: Some(column.to_string()),
1698            dimension: None,
1699            expected: Expectation::ChangeAtMost {
1700                value: MetricValue::Percentage(points),
1701            },
1702            observed: null_pct_b.map(MetricValue::Percentage),
1703            scope: self.scope,
1704            evidence: evidence.b,
1705            bounds: None,
1706            baseline: Some(baseline_reading(null_pct_a, evidence.a, null_pct_delta)),
1707            status: CheckStatus::Passed,
1708            message: String::new(),
1709        };
1710        let reason = if !in_report {
1711            Some(NotEvaluated::ColumnNotProfiled)
1712        } else if !in_baseline {
1713            Some(NotEvaluated::NotInBaseline)
1714        } else if null_pct_b.is_none() {
1715            Some(NotEvaluated::NotAssessed)
1716        } else if null_pct_a.is_none() {
1717            Some(NotEvaluated::NotInBaseline)
1718        } else {
1719            definition_gap(
1720                delta.metric_semantics.a.as_ref(),
1721                delta.metric_semantics.b.as_ref(),
1722                &[Definition::NullTokens],
1723            )
1724        };
1725        let subject = "this column's null percentage";
1726        if let Some(reason) = reason {
1727            return not_evaluated_change(check, reason, subject);
1728        }
1729        let change_points =
1730            null_pct_delta.expect("a delta has a change whenever both sides have a value");
1731        check.status = self.decide_change(&evidence, change_points <= rounded_2(points));
1732        check.message = match check.status {
1733            CheckStatus::Passed => {
1734                format!("{subject} rose no more than allowed above the baseline")
1735            }
1736            CheckStatus::Failed => format!("{subject} rose more than allowed above the baseline"),
1737            CheckStatus::NotEvaluated(_) => evidence_message(subject),
1738        };
1739        check
1740    }
1741
1742    /// Whether the side without `column` shows its source lacks it: the
1743    /// side's selection included the column, so it was not in what the side
1744    /// read, and the side's column set covers the whole source. Under
1745    /// [`PolicyScope::Observed`] the question is about what was read, so a
1746    /// column set covering part of the source still decides. `unselected` is
1747    /// the reason when the selection left the column out.
1748    fn absence_status(
1749        &self,
1750        lacking: &ReportSide,
1751        column: &str,
1752        unselected: NotEvaluated,
1753    ) -> CheckStatus {
1754        match lacking.column_selection() {
1755            None => CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded {
1756                setting: "columns".to_string(),
1757            }),
1758            Some(selection) if !selection.includes(column) => CheckStatus::NotEvaluated(unselected),
1759            Some(_) => match (self.scope, lacking.schema_evidence().gap()) {
1760                (PolicyScope::FullSource, Some(gap)) => {
1761                    CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1762                }
1763                _ => CheckStatus::Failed,
1764            },
1765        }
1766    }
1767
1768    fn require_column_check(
1769        &self,
1770        current: &ReportSide,
1771        current_names: &HashSet<&str>,
1772        column: &str,
1773    ) -> Check {
1774        let status = if current_names.contains(column) {
1775            CheckStatus::Passed
1776        } else {
1777            self.absence_status(current, column, NotEvaluated::ColumnNotProfiled)
1778        };
1779        let message = match &status {
1780            CheckStatus::Passed => "the required column was profiled",
1781            CheckStatus::Failed => "the required column is not in the source the report read",
1782            CheckStatus::NotEvaluated(NotEvaluated::ColumnNotProfiled) => {
1783                "the run's column selection left the required column out"
1784            }
1785            CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1786                "the report does not record which columns it selected, so a missing column \
1787                 cannot be shown to be absent from the source"
1788            }
1789            CheckStatus::NotEvaluated(_) => {
1790                "the required column is not in the records read, and the records that were not \
1791                 read may hold it"
1792            }
1793        };
1794        self.column_check(
1795            CheckCode::RequireColumn,
1796            column,
1797            Expectation::Present,
1798            current,
1799            None,
1800            status,
1801            message,
1802        )
1803    }
1804
1805    fn forbid_removed_check(
1806        &self,
1807        baseline: &ReportSide,
1808        current: &ReportSide,
1809        current_names: &HashSet<&str>,
1810        column: &str,
1811    ) -> Check {
1812        let status = if current_names.contains(column) {
1813            CheckStatus::Passed
1814        } else {
1815            self.absence_status(
1816                current,
1817                column,
1818                NotEvaluated::SettingChanged {
1819                    setting: "columns".to_string(),
1820                },
1821            )
1822        };
1823        let message = match &status {
1824            CheckStatus::Passed => "the column the baseline profiled is still profiled",
1825            CheckStatus::Failed => {
1826                "a column the baseline profiled is not in the source the report read"
1827            }
1828            CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1829                "the report's column selection left out a column the baseline profiled"
1830            }
1831            CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1832                "the report does not record which columns it selected, so a column the baseline \
1833                 profiled cannot be shown to be absent from its source"
1834            }
1835            CheckStatus::NotEvaluated(_) => {
1836                "a column the baseline profiled is not in the records the report read, and the \
1837                 records that were not read may hold it"
1838            }
1839        };
1840        self.column_check(
1841            CheckCode::ForbidRemovedColumn,
1842            column,
1843            Expectation::Present,
1844            current,
1845            Some(baseline),
1846            status,
1847            message,
1848        )
1849    }
1850
1851    fn forbid_added_check(
1852        &self,
1853        baseline: &ReportSide,
1854        current: &ReportSide,
1855        baseline_names: &HashSet<&str>,
1856        column: &str,
1857    ) -> Check {
1858        let status = if baseline_names.contains(column) {
1859            CheckStatus::Passed
1860        } else {
1861            self.absence_status(
1862                baseline,
1863                column,
1864                NotEvaluated::SettingChanged {
1865                    setting: "columns".to_string(),
1866                },
1867            )
1868        };
1869        let message = match &status {
1870            CheckStatus::Passed => "the column is one the baseline profiled",
1871            CheckStatus::Failed => {
1872                "the report profiled a column the baseline's source did not have"
1873            }
1874            CheckStatus::NotEvaluated(NotEvaluated::SettingChanged { .. }) => {
1875                "the baseline's column selection left this column out"
1876            }
1877            CheckStatus::NotEvaluated(NotEvaluated::SettingUnrecorded { .. }) => {
1878                "the baseline does not record which columns it selected, so this column cannot be \
1879                 shown to be new"
1880            }
1881            CheckStatus::NotEvaluated(_) => {
1882                "this column is not in the records the baseline read, and the records it did not \
1883                 read may hold it"
1884            }
1885        };
1886        self.column_check(
1887            CheckCode::ForbidAddedColumn,
1888            column,
1889            Expectation::InBaseline,
1890            current,
1891            Some(baseline),
1892            status,
1893            message,
1894        )
1895    }
1896
1897    /// The closed-schema rule's statement about the source columns the report
1898    /// did not profile: never decided, since nothing profiled shows them.
1899    fn unprofiled_columns_check(
1900        &self,
1901        baseline: &ReportSide,
1902        current: &ReportSide,
1903        reason: NotEvaluated,
1904    ) -> Check {
1905        let message = match &reason {
1906            NotEvaluated::ColumnNotProfiled => {
1907                "the run profiled a column selection, which may have left out a source column \
1908                 the baseline lacked"
1909            }
1910            NotEvaluated::SettingUnrecorded { .. } => {
1911                "the report does not record which columns it selected, so its columns may not \
1912                 be all of its source's"
1913            }
1914            _ => {
1915                "the report read part of its source, and the records it did not read may hold \
1916                 a column the baseline lacked"
1917            }
1918        };
1919        Check {
1920            column: None,
1921            ..self.column_check(
1922                CheckCode::ForbidAddedColumn,
1923                "",
1924                Expectation::InBaseline,
1925                current,
1926                Some(baseline),
1927                CheckStatus::NotEvaluated(reason),
1928                message,
1929            )
1930        }
1931    }
1932
1933    /// A rule about which columns a report has. Its evidence is the column
1934    /// set's coverage of each side's source, not a number's.
1935    #[allow(clippy::too_many_arguments)]
1936    fn column_check(
1937        &self,
1938        code: CheckCode,
1939        column: &str,
1940        expected: Expectation,
1941        current: &ReportSide,
1942        baseline: Option<&ReportSide>,
1943        status: CheckStatus,
1944        message: &str,
1945    ) -> Check {
1946        Check {
1947            code,
1948            column: Some(column.to_string()),
1949            dimension: None,
1950            expected,
1951            observed: None,
1952            scope: self.scope,
1953            evidence: current.schema_evidence(),
1954            bounds: None,
1955            baseline: baseline.map(|baseline| BaselineReading {
1956                observed: None,
1957                evidence: baseline.schema_evidence(),
1958                change: None,
1959            }),
1960            status,
1961            message: message.to_string(),
1962        }
1963    }
1964
1965    /// Decide a change between two averages or ratios. Under
1966    /// [`PolicyScope::FullSource`] a side computed over part of its source
1967    /// bounds nothing about the rest, so either side's gap leaves the rule
1968    /// unevaluated, the evaluated report's named first.
1969    fn decide_change(&self, evidence: &Sides<Evidence>, satisfied: bool) -> CheckStatus {
1970        match (self.scope, evidence.b.gap().or(evidence.a.gap())) {
1971            (PolicyScope::FullSource, Some(gap)) => {
1972                CheckStatus::NotEvaluated(NotEvaluated::EvidenceIncomplete { gap })
1973            }
1974            _ if satisfied => CheckStatus::Passed,
1975            _ => CheckStatus::Failed,
1976        }
1977    }
1978}
1979
1980/// The schema changes between the two sides, at most one per column, in the
1981/// delta's column order: the baseline's columns, then the added ones. A
1982/// change is reported only where both sides recorded what it reads; a flat
1983/// summary without a column's counts reports neither change for it, and one
1984/// without its type no type change. Column order is not compared, since no
1985/// rule reads it.
1986fn schema_diagnostics(
1987    baseline: &ReportSide,
1988    current: &ReportSide,
1989    delta: &ReportDelta,
1990) -> Vec<Diagnostic> {
1991    let diagnostic = |code, column: &str, data_type, message: &str| Diagnostic {
1992        code,
1993        column: column.to_string(),
1994        data_type,
1995        setting: None,
1996        message: message.to_string(),
1997    };
1998    // Whether each side's identifier hint names the column, `None` when the
1999    // side did not record its hints.
2000    let identified = |side: &ReportSide, column: &str| {
2001        side.identifier_order()
2002            .map(|names| names.iter().any(|name| name == column))
2003    };
2004    // Built once, so a wide schema is not rescanned per column.
2005    fn names(names: &[String]) -> HashSet<&str> {
2006        names.iter().map(String::as_str).collect()
2007    }
2008    fn by_name(side: &ReportSide) -> HashMap<&str, &ColumnSide> {
2009        side.columns()
2010            .iter()
2011            .map(|column| (column.name.as_str(), column))
2012            .collect()
2013    }
2014    let (added, removed) = (names(&delta.schema.added), names(&delta.schema.removed));
2015    let (baseline_columns, current_columns) = (by_name(baseline), by_name(current));
2016    delta
2017        .columns
2018        .iter()
2019        .filter_map(|change| {
2020            let column = change.name.as_str();
2021            // The side without the column decides whether its source lacks
2022            // it: a selection that includes the column would have profiled it
2023            // or refused to run, so the column is not in what that side read.
2024            let unselected = |side: &ReportSide| match side.column_selection() {
2025                Some(selection) if selection.includes(column) => None,
2026                Some(_) => Some(SettingDifference::SettingChanged {
2027                    setting: "columns".to_string(),
2028                }),
2029                None => Some(SettingDifference::SettingUnrecorded {
2030                    setting: "columns".to_string(),
2031                }),
2032            };
2033            if added.contains(column) {
2034                let setting = unselected(baseline);
2035                let message = match setting {
2036                    None => {
2037                        "the report profiled a column the baseline did not find in its source, \
2038                         so the baseline has nothing to measure its change from"
2039                    }
2040                    Some(_) => {
2041                        "the report profiled a column the baseline has no profile for, and the \
2042                         baseline's column selection is not shown to include it"
2043                    }
2044                };
2045                return Some(Diagnostic {
2046                    setting,
2047                    ..diagnostic(DiagnosticCode::ColumnAdded, column, None, message)
2048                });
2049            }
2050            if removed.contains(column) {
2051                let setting = unselected(current);
2052                let message = match setting {
2053                    None => "the baseline profiled a column the report did not find in its source",
2054                    Some(_) => {
2055                        "the baseline profiled a column the report has no profile for, and the \
2056                         report's column selection is not shown to include it"
2057                    }
2058                };
2059                return Some(Diagnostic {
2060                    setting,
2061                    ..diagnostic(DiagnosticCode::ColumnRemoved, column, None, message)
2062                });
2063            }
2064            let (a, b) = (
2065                *baseline_columns.get(column)?,
2066                *current_columns.get(column)?,
2067            );
2068            if a.populated()? && b.all_null()? {
2069                return Some(diagnostic(
2070                    DiagnosticCode::ColumnAllNull,
2071                    column,
2072                    None,
2073                    "this column held values in the baseline, and every value read for it in \
2074                     the report is null",
2075                ));
2076            }
2077            match (&a.data_type, &b.data_type) {
2078                (Some(type_a), Some(type_b))
2079                    if type_a != type_b && a.populated()? && b.populated()? =>
2080                {
2081                    let mut retyped = diagnostic(
2082                        DiagnosticCode::ColumnTypeChanged,
2083                        column,
2084                        Some(Sides {
2085                            a: type_a.clone(),
2086                            b: type_b.clone(),
2087                        }),
2088                        "this column holds values on both sides, and its inferred type differs",
2089                    );
2090                    // Only the identifier hint gives a column the identifier
2091                    // type, so the hint can explain a change only to or from
2092                    // it.
2093                    if *type_a == DataType::Identifier || *type_b == DataType::Identifier {
2094                        let setting = "identifier_columns".to_string();
2095                        retyped.setting =
2096                            match (identified(baseline, column), identified(current, column)) {
2097                                (Some(a), Some(b)) if a == b => None,
2098                                (Some(_), Some(_)) => {
2099                                    Some(SettingDifference::SettingChanged { setting })
2100                                }
2101                                _ => Some(SettingDifference::SettingUnrecorded { setting }),
2102                            };
2103                        if retyped.setting.is_some() {
2104                            retyped.message = "this column holds values on both sides, and its \
2105                                               inferred type differs; the identifier hint, \
2106                                               which sets the type, is not shown to be the \
2107                                               same on both sides"
2108                                .to_string();
2109                        }
2110                    }
2111                    Some(retyped)
2112                }
2113                _ => None,
2114            }
2115        })
2116        .collect()
2117}
2118
2119/// A delta's columns by name, built once per evaluation so a rule over every
2120/// shared column does not rescan the schema for each one.
2121struct ColumnIndex<'a> {
2122    changes: HashMap<&'a str, &'a ColumnChange>,
2123    /// Columns the evaluated report profiled.
2124    in_report: HashSet<&'a str>,
2125    /// Columns the baseline profiled.
2126    in_baseline: HashSet<&'a str>,
2127}
2128
2129impl<'a> ColumnIndex<'a> {
2130    fn new(delta: &'a ReportDelta) -> Self {
2131        let schema = &delta.schema;
2132        let names = |lists: [&'a [String]; 2]| {
2133            lists
2134                .into_iter()
2135                .flatten()
2136                .map(String::as_str)
2137                .collect::<HashSet<_>>()
2138        };
2139        Self {
2140            changes: delta
2141                .columns
2142                .iter()
2143                .map(|change| (change.name.as_str(), change))
2144                .collect(),
2145            in_report: names([&schema.common, &schema.added]),
2146            in_baseline: names([&schema.common, &schema.removed]),
2147        }
2148    }
2149}
2150
2151/// Why a side's profiled columns may not be all of its source's columns, or
2152/// `None` when they are: every column was selected and the column set covers
2153/// the source. The selection is named ahead of the coverage, since a
2154/// projection leaves columns out however much of the source was read.
2155fn unenumerated_columns(side: &ReportSide) -> Option<NotEvaluated> {
2156    match side.column_selection() {
2157        None => Some(NotEvaluated::SettingUnrecorded {
2158            setting: "columns".to_string(),
2159        }),
2160        Some(ColumnSelection::All) => side
2161            .schema_evidence()
2162            .gap()
2163            .map(|gap| NotEvaluated::EvidenceIncomplete { gap }),
2164        Some(_) => Some(NotEvaluated::ColumnNotProfiled),
2165    }
2166}
2167
2168fn column_names(side: &ReportSide) -> HashSet<&str> {
2169    side.columns()
2170        .iter()
2171        .map(|column| column.name.as_str())
2172        .collect()
2173}
2174
2175fn baseline_reading(
2176    observed: Option<f64>,
2177    evidence: Evidence,
2178    change: Option<f64>,
2179) -> BaselineReading {
2180    BaselineReading {
2181        observed: observed.map(MetricValue::Percentage),
2182        evidence,
2183        change: change.map(MetricValue::Percentage),
2184    }
2185}
2186
2187/// Leave a rule about change unevaluated, with a fixed sentence naming why.
2188fn not_evaluated_change(mut check: Check, reason: NotEvaluated, subject: &str) -> Check {
2189    check.message = match &reason {
2190        NotEvaluated::NotAssessed if check.column.is_some() => {
2191            "no values were read for this column".to_string()
2192        }
2193        NotEvaluated::NotAssessed => format!("{subject} was not assessed in this report"),
2194        NotEvaluated::ColumnNotProfiled => "this column has no profile in the report".to_string(),
2195        NotEvaluated::NotInBaseline => format!("the baseline carries no value for {subject}"),
2196        NotEvaluated::SchemaChanged => format!(
2197            "the two reports profiled different columns, so {subject} has a different subject \
2198             on each side"
2199        ),
2200        NotEvaluated::DimensionsChanged => {
2201            format!("{subject} averages a different set of assessed dimensions on each side")
2202        }
2203        NotEvaluated::DefinitionNotComparable { .. } => format!(
2204            "a measurement definition {subject} depends on is not recorded identically in both \
2205             reports"
2206        ),
2207        NotEvaluated::SettingChanged { .. } => format!(
2208            "a setting that moves {subject} on unchanged data differs between the two reports"
2209        ),
2210        NotEvaluated::SettingUnrecorded { .. } => format!(
2211            "a setting that moves {subject} on unchanged data is not recorded in both reports"
2212        ),
2213        NotEvaluated::QualityUnavailable { .. } => {
2214            "the report carries no quality assessment".to_string()
2215        }
2216        NotEvaluated::EvidenceIncomplete { .. } => evidence_message(subject),
2217    };
2218    check.status = CheckStatus::NotEvaluated(reason);
2219    check
2220}
2221
2222fn evidence_message(subject: &str) -> String {
2223    format!(
2224        "{subject} was computed over part of the source on at least one side, which bounds \
2225         nothing about the rest"
2226    )
2227}
2228
2229/// A recorded measurement definition a rule about change depends on.
2230#[derive(Debug, Clone, Copy)]
2231enum Definition {
2232    /// The text values counted as null, behind every null count and so every
2233    /// score.
2234    NullTokens,
2235    /// What a pattern match counts, behind the validity score.
2236    PatternMatchCount,
2237    /// What the range and sign rules count, behind the accuracy score.
2238    RangeRuleCount,
2239    /// What key uniqueness divides by, behind the uniqueness score.
2240    KeyUniquenessUnits,
2241    /// What the outlier ratio is measured over, behind the accuracy score.
2242    OutlierUnits,
2243    /// Which column key uniqueness may measure, behind the uniqueness score.
2244    KeyColumnSource,
2245}
2246
2247impl Definition {
2248    fn key(self) -> &'static str {
2249        match self {
2250            Self::NullTokens => "null_tokens",
2251            Self::PatternMatchCount => "pattern_match_count",
2252            Self::RangeRuleCount => "range_rule_count",
2253            Self::KeyUniquenessUnits => "key_uniqueness_units",
2254            Self::OutlierUnits => "outlier_units",
2255            Self::KeyColumnSource => "key_column_source",
2256        }
2257    }
2258
2259    /// Whether both sides recorded the definition, with the same value.
2260    fn same(self, a: &MetricSemantics, b: &MetricSemantics) -> bool {
2261        fn agree<T: PartialEq>(a: &Option<T>, b: &Option<T>) -> bool {
2262            a.is_some() && a == b
2263        }
2264        match self {
2265            Self::NullTokens => agree(&a.null_tokens, &b.null_tokens),
2266            Self::PatternMatchCount => agree(&a.pattern_match_count, &b.pattern_match_count),
2267            Self::RangeRuleCount => agree(&a.range_rule_count, &b.range_rule_count),
2268            Self::KeyUniquenessUnits => agree(&a.key_uniqueness_units, &b.key_uniqueness_units),
2269            Self::OutlierUnits => agree(&a.outlier_units, &b.outlier_units),
2270            Self::KeyColumnSource => agree(&a.key_column_source, &b.key_column_source),
2271        }
2272    }
2273}
2274
2275/// The definitions a score reads. Text length and the shape-moment estimator
2276/// feed no score.
2277fn score_definitions(dimension: Option<QualityDimension>) -> &'static [Definition] {
2278    match dimension {
2279        None => &[
2280            Definition::NullTokens,
2281            Definition::PatternMatchCount,
2282            Definition::RangeRuleCount,
2283            Definition::KeyUniquenessUnits,
2284            Definition::OutlierUnits,
2285            Definition::KeyColumnSource,
2286        ],
2287        Some(QualityDimension::Validity) => {
2288            &[Definition::NullTokens, Definition::PatternMatchCount]
2289        }
2290        Some(QualityDimension::Accuracy) => &[
2291            Definition::NullTokens,
2292            Definition::RangeRuleCount,
2293            Definition::OutlierUnits,
2294        ],
2295        Some(QualityDimension::Uniqueness) => &[
2296            Definition::NullTokens,
2297            Definition::KeyUniquenessUnits,
2298            Definition::KeyColumnSource,
2299        ],
2300        Some(_) => &[Definition::NullTokens],
2301    }
2302}
2303
2304/// The first definition the two sides do not both record identically.
2305fn definition_gap(
2306    a: Option<&MetricSemantics>,
2307    b: Option<&MetricSemantics>,
2308    definitions: &[Definition],
2309) -> Option<NotEvaluated> {
2310    definitions
2311        .iter()
2312        .find(|definition| !a.zip(b).is_some_and(|(a, b)| definition.same(a, b)))
2313        .map(|definition| NotEvaluated::DefinitionNotComparable {
2314            definition: definition.key().to_string(),
2315        })
2316}
2317
2318/// Reads one hint list out of a report's recorded hints.
2319type HintColumns = fn(&RecordedHints) -> &Vec<String>;
2320
2321/// How one setting compares across the two sides.
2322#[derive(Debug, Clone, Copy)]
2323enum Agreement {
2324    Same,
2325    Changed,
2326    Unrecorded,
2327}
2328
2329fn agreement<T: PartialEq + ?Sized>(a: Option<&T>, b: Option<&T>) -> Agreement {
2330    match (a, b) {
2331        (Some(a), Some(b)) if a == b => Agreement::Same,
2332        (Some(_), Some(_)) => Agreement::Changed,
2333        _ => Agreement::Unrecorded,
2334    }
2335}
2336
2337/// The first setting a score reads that the two sides do not both record
2338/// identically. The overall score reads every setting. A dimension reads the
2339/// identifier hint, which retypes its columns and names the key uniqueness
2340/// reads, except completeness, which counts nulls whatever the type. Accuracy
2341/// also reads the positive hint, timeliness the temporal ones, and validity
2342/// the locale, which ranks the detected patterns it scores.
2343fn setting_gap(
2344    configuration: &ConfigurationComparison,
2345    identifiers: &Sides<Option<&[String]>>,
2346    dimension: Option<QualityDimension>,
2347) -> Option<NotEvaluated> {
2348    let hints = &configuration.semantic_hints;
2349    let reads = |dimensions: &[QualityDimension]| {
2350        dimension.is_none_or(|dimension| dimensions.contains(&dimension))
2351    };
2352    let hint = |columns: HintColumns| {
2353        agreement(hints.a.as_ref().map(columns), hints.b.as_ref().map(columns))
2354    };
2355    let recorded = |same: Option<bool>| match same {
2356        Some(true) => Agreement::Same,
2357        Some(false) => Agreement::Changed,
2358        None => Agreement::Unrecorded,
2359    };
2360    // A report records which columns the pairs named, not which column pairs
2361    // with which, so only two empty pair lists are known to agree.
2362    let pairs = match hint(|hints| &hints.temporal_pair_columns) {
2363        Agreement::Same
2364            if hints
2365                .a
2366                .as_ref()
2367                .is_some_and(|hints| !hints.temporal_pair_columns.is_empty()) =>
2368        {
2369            Agreement::Unrecorded
2370        }
2371        other => other,
2372    };
2373    let settings = [
2374        (
2375            "score_weights",
2376            dimension.is_none(),
2377            recorded(configuration.score_weights.same),
2378        ),
2379        (
2380            "identifier_columns",
2381            dimension != Some(QualityDimension::Completeness),
2382            // In the order given: the first one present is the key.
2383            agreement(identifiers.a, identifiers.b),
2384        ),
2385        (
2386            "positive_columns",
2387            reads(&[QualityDimension::Accuracy]),
2388            hint(|hints| &hints.positive_columns),
2389        ),
2390        (
2391            "temporal_columns",
2392            reads(&[QualityDimension::Timeliness]),
2393            hint(|hints| &hints.temporal_columns),
2394        ),
2395        (
2396            "temporal_pairs",
2397            reads(&[QualityDimension::Timeliness]),
2398            pairs,
2399        ),
2400        (
2401            "pattern_locale",
2402            reads(&[QualityDimension::Validity]),
2403            recorded(configuration.pattern_locale.same),
2404        ),
2405    ];
2406    settings
2407        .into_iter()
2408        .filter(|(_, read, _)| *read)
2409        .find_map(|(setting, _, agreement)| match agreement {
2410            Agreement::Same => None,
2411            Agreement::Changed => Some(NotEvaluated::SettingChanged {
2412                setting: setting.to_string(),
2413            }),
2414            Agreement::Unrecorded => Some(NotEvaluated::SettingUnrecorded {
2415                setting: setting.to_string(),
2416            }),
2417        })
2418}
2419
2420fn check_percentage(
2421    code: CheckCode,
2422    subject: Option<String>,
2423    value: f64,
2424) -> Result<(), PolicyError> {
2425    if value.is_finite() && (0.0..=PERCENTAGE_MAX).contains(&value) {
2426        return Ok(());
2427    }
2428    Err(PolicyError::ThresholdOutOfRange {
2429        code,
2430        subject,
2431        value,
2432    })
2433}
2434
2435/// Quality first, then dimensions in their declared order, each at most once.
2436fn required_metrics_in_order(requested: &[RequiredMetric]) -> Vec<RequiredMetric> {
2437    let mut ordered = Vec::new();
2438    if requested.contains(&RequiredMetric::Quality) {
2439        ordered.push(RequiredMetric::Quality);
2440    }
2441    for dimension in QualityDimension::all() {
2442        if requested.contains(&RequiredMetric::Dimension(dimension)) {
2443            ordered.push(RequiredMetric::Dimension(dimension));
2444        }
2445    }
2446    ordered
2447}
2448
2449fn verdict_of(checks: &[Check]) -> Verdict {
2450    if checks.iter().any(Check::is_violation) {
2451        Verdict::Fail
2452    } else if checks.iter().any(|check| check.not_evaluated().is_some()) {
2453        Verdict::Inconclusive
2454    } else {
2455        Verdict::Pass
2456    }
2457}
2458
2459/// Fill in a check that had no quality assessment to read, naming the reason
2460/// the report recorded. Without that, "you did not ask for this" and "this
2461/// broke" reach a gate as the same absence.
2462fn unavailable_quality(mut check: Check, report: &ProfileReport) -> Check {
2463    let quality_status = quality_status_name(&report.quality_status).to_string();
2464    check.message = match &report.quality_status {
2465        QualityAnalysisStatus::NotRequested => {
2466            "quality metrics were not requested for this run".to_string()
2467        }
2468        QualityAnalysisStatus::NoData => {
2469            "quality was requested but no sample was available to measure".to_string()
2470        }
2471        QualityAnalysisStatus::WithheldByProjection => {
2472            "quality was withheld: the requested dimensions measure whole rows and only \
2473             some columns were profiled"
2474                .to_string()
2475        }
2476        QualityAnalysisStatus::Failed { .. } => "the quality computation failed".to_string(),
2477        QualityAnalysisStatus::Unrecorded | QualityAnalysisStatus::Computed => {
2478            "the report carries no quality assessment".to_string()
2479        }
2480    };
2481    check.status = CheckStatus::NotEvaluated(NotEvaluated::QualityUnavailable { quality_status });
2482    check
2483}
2484
2485fn aggregate_message(status: &CheckStatus, subject: &str) -> String {
2486    match status {
2487        CheckStatus::Passed => format!("{subject} meets the required minimum"),
2488        CheckStatus::Failed => format!("{subject} is below the required minimum"),
2489        CheckStatus::NotEvaluated(_) => format!(
2490            "{subject} was computed over part of the source, which bounds nothing about \
2491             the rest"
2492        ),
2493    }
2494}
2495
2496fn bounded_message(status: &CheckStatus, subject: &str) -> String {
2497    match status {
2498        CheckStatus::Passed => format!(
2499            "{subject} was computed over a sample, and its whole-source interval meets the \
2500             required minimum"
2501        ),
2502        CheckStatus::Failed => format!(
2503            "{subject} was computed over a sample, and its whole-source interval is below the \
2504             required minimum"
2505        ),
2506        CheckStatus::NotEvaluated(_) => format!(
2507            "{subject} was computed over a sample, and the required minimum lies within its \
2508             whole-source interval"
2509        ),
2510    }
2511}
2512
2513/// The interval a check decides on, from the report's bounds.
2514fn check_bounds(bounds: &ScoreBounds, interval: Option<ScoreInterval>) -> Option<CheckBounds> {
2515    interval.map(|interval| CheckBounds {
2516        lower: interval.lower,
2517        upper: interval.upper,
2518        confidence_level: bounds.confidence_level,
2519    })
2520}
2521
2522/// The `state` tag the report serializes for its quality status.
2523pub(crate) fn quality_status_name(status: &QualityAnalysisStatus) -> &'static str {
2524    match status {
2525        QualityAnalysisStatus::Computed => "computed",
2526        QualityAnalysisStatus::NotRequested => "not_requested",
2527        QualityAnalysisStatus::NoData => "no_data",
2528        QualityAnalysisStatus::WithheldByProjection => "withheld_by_projection",
2529        QualityAnalysisStatus::Failed { .. } => "failed",
2530        QualityAnalysisStatus::Unrecorded => "unrecorded",
2531    }
2532}
2533
2534/// Share of a column's values that are null, or `None` when no value was read.
2535/// Mirrors the Python binding's `ColumnProfile.null_percentage`.
2536fn null_percentage(profile: &ColumnProfile) -> Option<f64> {
2537    (profile.total_count > 0)
2538        .then(|| profile.null_count as f64 / profile.total_count as f64 * PERCENTAGE_MAX)
2539}
2540
2541/// How much of the source the scan itself covered.
2542///
2543/// Truncation is named ahead of sampling and sampling ahead of skipped
2544/// records, so the gap reported is the largest one.
2545pub(crate) fn scan_evidence(execution: &ExecutionMetadata) -> Evidence {
2546    scan_coverage(
2547        !execution.source_exhausted || execution.truncation_reason.is_some(),
2548        execution.sampling_applied,
2549        execution.error_count > 0,
2550    )
2551}
2552
2553/// [`scan_evidence`] over the three recorded facts it reads, for a report
2554/// delta reading them from a flat summary.
2555pub(crate) fn scan_coverage(truncated: bool, sampled: bool, records_skipped: bool) -> Evidence {
2556    let reason = if truncated {
2557        Some(EvidenceGap::Truncated)
2558    } else if sampled {
2559        Some(EvidenceGap::Sampled)
2560    } else if records_skipped {
2561        Some(EvidenceGap::RecordsSkipped)
2562    } else {
2563        None
2564    };
2565    match reason {
2566        Some(reason) => Evidence::Incomplete { reason },
2567        None => Evidence::Complete,
2568    }
2569}
2570
2571/// Which part of the assessment a check reads, and so whose provenance
2572/// decides whether its number covers every scanned row.
2573#[derive(Debug, Clone, Copy)]
2574pub(crate) enum Provenance {
2575    /// The aggregate over every dimension.
2576    Overall,
2577    /// One dimension, through whichever components it is built from.
2578    Dimension(QualityDimension),
2579    /// One named component, as recorded in the assessment's confidence.
2580    Component(&'static str),
2581}
2582
2583/// The component labels a dimension is built from.
2584///
2585/// Uniqueness is the one dimension with two, and they can differ in
2586/// provenance: a full-stream row tracker counts duplicates over every row
2587/// while the key scan reads the retained sample.
2588pub(crate) fn dimension_components(dimension: QualityDimension) -> &'static [&'static str] {
2589    match dimension {
2590        QualityDimension::Completeness => &["completeness"],
2591        QualityDimension::Consistency => &["consistency"],
2592        QualityDimension::Uniqueness => &["key_uniqueness", "duplicate_rows"],
2593        QualityDimension::Accuracy => &["accuracy"],
2594        QualityDimension::Timeliness => &["timeliness"],
2595        QualityDimension::Validity => &["validity"],
2596        QualityDimension::Precision => &["precision"],
2597    }
2598}
2599
2600/// Whether `label` names a component of a dimension this build knows.
2601fn is_known(label: &str) -> bool {
2602    QualityDimension::all()
2603        .into_iter()
2604        .flat_map(dimension_components)
2605        .any(|component| *component == label)
2606}
2607
2608/// Whether the number a check reads came from every scanned row or from a
2609/// retained sample of them.
2610///
2611/// Resolved per component, so a check about one dimension is not downgraded by
2612/// another dimension's sampling.
2613fn quality_evidence(quality: Option<&QualityAssessment>, provenance: Provenance) -> Evidence {
2614    let Some(quality) = quality else {
2615        // No assessment to read; the check that called this reports the
2616        // absence itself, and there is no number for evidence to describe.
2617        return Evidence::Complete;
2618    };
2619    sampled_component_evidence(
2620        quality.sampled_dimensions().as_deref(),
2621        &quality.unlisted_components(),
2622        &quality.metrics.assessed_dimensions(),
2623        provenance,
2624    )
2625}
2626
2627/// [`quality_evidence`] over the recorded facts it reads: the sampled
2628/// component labels (`None` when the report did not record them), the
2629/// components computed with signal that neither list names
2630/// ([`QualityAssessment::unlisted_components`]), and the dimensions the
2631/// overall score weighs. A report delta reads the same facts
2632/// from a flat summary, which has no [`QualityAssessment`] behind it, and
2633/// findings read one component through it, so the gate, the delta and
2634/// findings resolve coverage by one rule.
2635pub(crate) fn sampled_component_evidence(
2636    sampled: Option<&[String]>,
2637    unlisted: &[String],
2638    assessed: &[QualityDimension],
2639    provenance: Provenance,
2640) -> Evidence {
2641    let Some(sampled) = sampled else {
2642        return Evidence::Incomplete {
2643            reason: EvidenceGap::CoverageUnrecorded,
2644        };
2645    };
2646    // A sampled label this build does not know belongs to a dimension a
2647    // later release added. Its weight is in the stored overall score, and
2648    // its dimension was dropped when the report was read, so nothing here
2649    // rules out that it reaches the score: the score is not certified.
2650    if matches!(provenance, Provenance::Overall) && !sampled.iter().all(|label| is_known(label)) {
2651        return Evidence::Incomplete {
2652            reason: EvidenceGap::CoverageUnrecorded,
2653        };
2654    }
2655    // The components the number reads. The overall score is a weighted
2656    // average over the *assessed* dimensions, so a sampled dimension the
2657    // weights exclude does not reach it. Reporting the aggregate as sampled
2658    // because of one would withhold a verdict the number does not depend on.
2659    let reads: Vec<&str> = match provenance {
2660        Provenance::Overall => assessed
2661            .iter()
2662            .copied()
2663            .flat_map(dimension_components)
2664            .copied()
2665            .collect(),
2666        Provenance::Dimension(dimension) => dimension_components(dimension).to_vec(),
2667        Provenance::Component(component) => vec![component],
2668    };
2669    let any_in = |labels: &[String]| {
2670        reads
2671            .iter()
2672            .any(|component| labels.iter().any(|label| label == component))
2673    };
2674    // A component with signal that neither list names has no recorded
2675    // coverage, and unknown coverage is a larger gap than a sample.
2676    if any_in(unlisted) {
2677        return Evidence::Incomplete {
2678            reason: EvidenceGap::CoverageUnrecorded,
2679        };
2680    }
2681    if any_in(sampled) {
2682        Evidence::Incomplete {
2683            reason: EvidenceGap::QualitySampled,
2684        }
2685    } else {
2686        Evidence::Complete
2687    }
2688}
2689
2690/// Every public type in this module is `#[non_exhaustive]` (#894), and so is
2691/// every enum variant with named fields, so the gate can gain variants,
2692/// struct fields and variant fields (#749) without breaking callers. Tuple
2693/// variants such as `CheckStatus::NotEvaluated(_)` are not marked: growing
2694/// one changes its positional shape, which is a redesign, not an extension.
2695///
2696/// The attribute only binds outside this crate, which is where doctests
2697/// compile. Each block below builds one struct with update syntax from a
2698/// supplied value. That is rejected only for a non-exhaustive struct from
2699/// another crate, so a field added later cannot make a block fail for a
2700/// different reason. The enums and their variants are checked by
2701/// `tests::every_public_type_is_non_exhaustive`, because no `match` or
2702/// pattern stays independent of the variants and fields they gain.
2703///
2704/// Stable rustdoc does not check the error code of a `compile_fail` block, so
2705/// any error would pass one. `tests/public_api_facade.rs` names the fields
2706/// used here in code that must compile, so a renamed field fails there instead
2707/// of passing here for the wrong reason.
2708///
2709/// ```compile_fail
2710/// use dataprof_runtime::CheckBounds;
2711/// fn f(base: CheckBounds) -> CheckBounds {
2712///     CheckBounds { lower: 0.0, ..base }
2713/// }
2714/// ```
2715///
2716/// ```compile_fail
2717/// use dataprof_runtime::Check;
2718/// fn f(base: Check) -> Check {
2719///     Check { message: String::new(), ..base }
2720/// }
2721/// ```
2722///
2723/// ```compile_fail
2724/// use dataprof_runtime::GateResult;
2725/// fn f(base: GateResult) -> GateResult {
2726///     GateResult { checks: Vec::new(), ..base }
2727/// }
2728/// ```
2729#[cfg(doctest)]
2730struct NonExhaustiveGuards;
2731
2732#[cfg(test)]
2733mod tests {
2734    use std::collections::HashMap;
2735
2736    use dataprof_core::{
2737        ColumnStats, DataSource, DataType, FileFormat, QualityScoreWeights, TruncationReason,
2738    };
2739    use dataprof_metrics::{QualityMetrics, UniquenessMetrics};
2740    use serde_json::{Value, json};
2741
2742    use super::*;
2743
2744    /// The source-level half of `NonExhaustiveGuards` (#894): every `pub` type
2745    /// declared in this module carries `#[non_exhaustive]`, and so does every
2746    /// variant with named fields inside a `pub enum`. Reading the source keeps
2747    /// the check independent of the variants and fields the types gain, and
2748    /// covers a type or variant added later without a list to update.
2749    #[test]
2750    fn every_public_type_is_non_exhaustive() {
2751        let lines: Vec<&str> = include_str!("quality_gate.rs").lines().collect();
2752        let marked = |i: usize| {
2753            lines[..i]
2754                .iter()
2755                .rev()
2756                .map(|l| l.trim())
2757                .take_while(|l| l.starts_with("#[") || l.starts_with("///"))
2758                .any(|l| l == "#[non_exhaustive]")
2759        };
2760        let identifier = |s: &str| -> String {
2761            s.chars()
2762                .take_while(|c| c.is_alphanumeric() || *c == '_')
2763                .collect()
2764        };
2765        let mut declared = Vec::new();
2766        let mut variants = Vec::new();
2767        let mut unmarked = Vec::new();
2768        let mut in_enum: Option<String> = None;
2769        for (i, line) in lines.iter().enumerate() {
2770            if let Some(enum_name) = &in_enum {
2771                if *line == "}" {
2772                    in_enum = None;
2773                } else if let Some(rest) = line.strip_prefix("    ")
2774                    && rest.starts_with(|c: char| c.is_ascii_uppercase())
2775                    && rest.contains(" {")
2776                {
2777                    let variant = format!("{enum_name}::{}", identifier(rest));
2778                    if !marked(i) {
2779                        unmarked.push(variant.clone());
2780                    }
2781                    variants.push(variant);
2782                }
2783                continue;
2784            }
2785            let Some(rest) = line
2786                .strip_prefix("pub enum ")
2787                .or_else(|| line.strip_prefix("pub struct "))
2788            else {
2789                continue;
2790            };
2791            let name = identifier(rest);
2792            if !marked(i) {
2793                unmarked.push(name.clone());
2794            }
2795            if line.starts_with("pub enum ") {
2796                in_enum = Some(name.clone());
2797            }
2798            declared.push(name);
2799        }
2800        // Guard the premise: the scan found the module's types and the
2801        // variants with named fields at all.
2802        assert!(declared.len() >= 15, "{declared:?}");
2803        assert!(variants.len() >= 6, "{variants:?}");
2804        assert!(unmarked.is_empty(), "not #[non_exhaustive]: {unmarked:?}");
2805    }
2806    use crate::ReportAssembler;
2807
2808    fn source() -> DataSource {
2809        DataSource::File {
2810            path: "drop.csv".to_string(),
2811            format: FileFormat::Csv,
2812            size_bytes: 256,
2813            modified_at: None,
2814            parquet_metadata: None,
2815        }
2816    }
2817
2818    fn column(name: &str, total: usize, nulls: usize) -> ColumnProfile {
2819        ColumnProfile {
2820            name: name.to_string(),
2821            data_type: DataType::String,
2822            null_count: nulls,
2823            total_count: total,
2824            unique_count: None,
2825            unique_count_is_approximate: None,
2826            unique_count_lower_bound: None,
2827            invalid_count: None,
2828            type_homogeneity: None,
2829            locale_number_count: None,
2830            stats: ColumnStats::None,
2831            patterns: None,
2832        }
2833    }
2834
2835    /// Two columns: `id` clean, `note` one-fifth null.
2836    fn sample_data() -> HashMap<String, Vec<String>> {
2837        let ids = (1..=5).map(|n| n.to_string()).collect::<Vec<_>>();
2838        let notes = vec!["a", "b", "c", "d", ""]
2839            .into_iter()
2840            .map(str::to_string)
2841            .collect::<Vec<_>>();
2842        HashMap::from([("id".to_string(), ids), ("note".to_string(), notes)])
2843    }
2844
2845    fn complete_report() -> ProfileReport {
2846        ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2847            .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2848            .with_quality_data(sample_data())
2849            .build()
2850    }
2851
2852    fn truncated_report() -> ProfileReport {
2853        ReportAssembler::new(
2854            source(),
2855            ExecutionMetadata::new(5, 2, 10).with_truncation(TruncationReason::MaxRows(5)),
2856        )
2857        .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2858        .with_quality_data(sample_data())
2859        .build()
2860    }
2861
2862    fn no_quality_report() -> ProfileReport {
2863        ReportAssembler::new(source(), ExecutionMetadata::new(5, 2, 10))
2864            .columns(vec![column("id", 5, 0), column("note", 5, 1)])
2865            .with_quality_data(sample_data())
2866            .skip_quality()
2867            .build()
2868    }
2869
2870    /// A report whose uniqueness dimension witnessed `duplicates` duplicate
2871    /// rows, truncated so the evidence behind it is partial.
2872    fn duplicate_report(duplicates: usize, approximate: bool) -> ProfileReport {
2873        let metrics = QualityMetrics {
2874            uniqueness: Some(UniquenessMetrics {
2875                duplicate_rows: duplicates,
2876                key_uniqueness: 100.0,
2877                high_cardinality_warning: false,
2878                rows_checked: 5,
2879                key_column: None,
2880                key_values_checked: None,
2881                duplicate_rows_approximate: approximate,
2882            }),
2883            ..QualityMetrics::default()
2884        };
2885        ProfileReport::new(
2886            source(),
2887            vec![column("id", 5, 0)],
2888            ExecutionMetadata::new(5, 1, 10).with_truncation(TruncationReason::MaxRows(5)),
2889            Some(QualityAssessment::exact(metrics)),
2890        )
2891    }
2892
2893    fn check_for(result: &GateResult, code: CheckCode) -> &Check {
2894        result
2895            .checks
2896            .iter()
2897            .find(|check| check.code == code)
2898            .unwrap_or_else(|| panic!("no {code} check in {:?}", result.checks))
2899    }
2900
2901    fn column_check<'a>(result: &'a GateResult, name: &str) -> &'a Check {
2902        result
2903            .checks
2904            .iter()
2905            .find(|check| check.column.as_deref() == Some(name))
2906            .unwrap_or_else(|| panic!("no check for column {name}"))
2907    }
2908
2909    /// A report whose quality sample is smaller than the scan: the assembler
2910    /// bifurcates, and the dimensions computed from the reservoir are recorded
2911    /// as sampled while the ones from exact counters are not.
2912    ///
2913    /// This is the ordinary large-file shape, not an edge case. It is also the
2914    /// one an execution-metadata-only gate gets wrong: the source *was*
2915    /// exhausted and no row sampler ran, so `source_exhausted` and
2916    /// `sampling_applied` both say the scan covered everything.
2917    fn reservoir_report() -> ProfileReport {
2918        ReportAssembler::new(source(), ExecutionMetadata::new(100, 2, 10))
2919            .columns(vec![column("id", 100, 0), column("note", 100, 20)])
2920            .with_quality_data(sample_data())
2921            .build()
2922    }
2923
2924    #[test]
2925    fn a_reservoir_backed_score_is_decided_on_its_whole_source_interval() {
2926        let report = reservoir_report();
2927        assert!(report.execution.source_exhausted);
2928        assert!(!report.execution.sampling_applied);
2929        let quality = report.quality.as_ref().expect("quality was computed");
2930        let sampled = quality
2931            .sampled_dimensions()
2932            .expect("a profiling run records its provenance");
2933        assert!(
2934            !sampled.is_empty(),
2935            "the assembler did not bifurcate; this test no longer reaches the \
2936             case it guards"
2937        );
2938        let interval = quality
2939            .score_bounds()
2940            .expect("a sampled assessment carries bounds")
2941            .overall_score
2942            .expect("every sampled dimension here is bounded");
2943        assert!(
2944            interval.lower < interval.upper && interval.upper < 100.0,
2945            "a five-value sample leaves room on both sides: {interval:?}"
2946        );
2947        let decide = |min: f64| {
2948            let result = QualityPolicy::new()
2949                .min_quality_score(min)
2950                .evaluate(&report)
2951                .unwrap();
2952            // The scan itself was complete; only the metric's own evidence is
2953            // not, and a decision on the interval does not pretend otherwise.
2954            assert_eq!(result.evidence, Evidence::Complete);
2955            let check = check_for(&result, CheckCode::MinQualityScore).clone();
2956            assert_eq!(
2957                check.evidence,
2958                Evidence::Incomplete {
2959                    reason: EvidenceGap::QualitySampled
2960                }
2961            );
2962            assert_eq!(
2963                check.bounds.map(|bounds| (bounds.lower, bounds.upper)),
2964                Some((interval.lower, interval.upper))
2965            );
2966            (result.verdict, check)
2967        };
2968
2969        let (verdict, _) = decide(interval.lower);
2970        assert_eq!(verdict, Verdict::Pass);
2971
2972        let (verdict, check) = decide((interval.lower + interval.upper) / 2.0);
2973        assert_eq!(verdict, Verdict::Inconclusive);
2974        assert_eq!(
2975            check.not_evaluated(),
2976            Some(&NotEvaluated::EvidenceIncomplete {
2977                gap: EvidenceGap::QualitySampled
2978            })
2979        );
2980
2981        let (verdict, _) = decide(interval.upper + 0.01);
2982        assert_eq!(verdict, Verdict::Fail);
2983    }
2984
2985    /// A threshold equal to the value a check shows is met, and a report that
2986    /// was saved and loaded reaches the same verdict (#1085). The live report
2987    /// holds raw scores, the loaded one their two-decimal serialization, so a
2988    /// raw comparison failed `observed 66.67 >= 66.67` live and passed it
2989    /// loaded. Every (report, rule) that goes wrong is collected first.
2990    #[test]
2991    fn a_threshold_at_the_shown_value_passes_live_and_after_a_round_trip() {
2992        // One value in three missing: completeness and null share are thirds.
2993        let thirds = ReportAssembler::new(source(), ExecutionMetadata::new(3, 1, 10))
2994            .columns(vec![column("x", 3, 1)])
2995            .with_quality_data(HashMap::from([(
2996                "x".to_string(),
2997                vec!["1".to_string(), "2".to_string(), String::new()],
2998            )]))
2999            .build();
3000        let mut wrong = Vec::new();
3001        for (label, report) in [
3002            ("thirds", thirds),
3003            ("reservoir", reservoir_report()),
3004            ("complete", complete_report()),
3005        ] {
3006            let loaded: ProfileReport =
3007                serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3008            let quality = report.quality.as_ref().expect("quality was computed");
3009            let mut policies = Vec::new();
3010            // A sampled report's overall rule decides on its interval: the
3011            // lower-bound case below covers it.
3012            if quality
3013                .score_bounds()
3014                .and_then(|bounds| bounds.overall_score)
3015                .is_none()
3016            {
3017                policies.push((
3018                    "overall".to_string(),
3019                    QualityPolicy::new()
3020                        .min_quality_score(rounded_2(report.quality_score().unwrap())),
3021                ));
3022            }
3023            for (name, score) in &quality.scores().dimension_scores {
3024                let Some(score) = score else { continue };
3025                // On a sampled report the full-source rule decides on the
3026                // interval, so its threshold is the shown lower bound.
3027                let shown = quality
3028                    .score_bounds()
3029                    .and_then(|bounds| bounds.dimension_scores.get(name).copied().flatten())
3030                    .map_or(*score, |interval| interval.lower);
3031                let dimension: QualityDimension = name.parse().unwrap();
3032                policies.push((
3033                    name.clone(),
3034                    QualityPolicy::new().min_dimension_score(dimension, rounded_2(shown)),
3035                ));
3036            }
3037            for column in &report.column_profiles {
3038                let shown = rounded_2(null_percentage(column).unwrap());
3039                policies.push((
3040                    format!("null {}", column.name),
3041                    QualityPolicy::new().max_null_percentage(column.name.clone(), shown),
3042                ));
3043            }
3044            if let Some(interval) = quality
3045                .score_bounds()
3046                .and_then(|bounds| bounds.overall_score)
3047            {
3048                policies.push((
3049                    "overall lower bound".to_string(),
3050                    QualityPolicy::new().min_quality_score(rounded_2(interval.lower)),
3051                ));
3052            }
3053            // A minimum written with more decimals than a check shows is
3054            // compared as shown: `expected` reads 66.67 for 66.674, so a score
3055            // shown as 66.67 meets it, as an allowance does for a change.
3056            if label == "thirds" {
3057                policies.push((
3058                    "completeness at a third decimal".to_string(),
3059                    QualityPolicy::new()
3060                        .min_dimension_score(QualityDimension::Completeness, 66.674),
3061                ));
3062            }
3063            for (rule, policy) in policies {
3064                let live = policy.evaluate(&report).unwrap().verdict;
3065                let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3066                if live != Verdict::Pass || reloaded != Verdict::Pass {
3067                    wrong.push(format!(
3068                        "{label} {rule}: live {live:?}, loaded {reloaded:?}"
3069                    ));
3070                }
3071            }
3072        }
3073        assert!(wrong.is_empty(), "{wrong:#?}");
3074    }
3075
3076    /// The interval half of #1085. A bound that rounds up when saved, such as
3077    /// 79.426 shown as 79.43, decided a minimum of 79.43 one way live and the
3078    /// other way loaded: inconclusive then pass at the lower bound, fail then
3079    /// inconclusive at the upper one. The verdict must not change on reload.
3080    #[test]
3081    fn a_bound_that_rounds_up_decides_the_same_live_and_loaded() {
3082        let mut report = reservoir_report();
3083        let quality = report.quality.clone().expect("quality was computed");
3084        let mut bounds = quality
3085            .score_bounds()
3086            .expect("a sampled report is bounded")
3087            .clone();
3088        let interval = bounds
3089            .overall_score
3090            .as_mut()
3091            .expect("the overall score is bounded");
3092        // Just below each shown value, so saving rounds both up.
3093        interval.lower = rounded_2(interval.lower) - 0.004;
3094        interval.upper = rounded_2(interval.upper) - 0.004;
3095        let (lower, upper) = (interval.lower, interval.upper);
3096        assert!(rounded_2(lower) > lower && rounded_2(upper) > upper && lower < upper);
3097        report.quality = Some(quality.with_score_bounds(Some(bounds)));
3098        let loaded: ProfileReport =
3099            serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
3100
3101        let mut wrong = Vec::new();
3102        for (label, min) in [("lower", rounded_2(lower)), ("upper", rounded_2(upper))] {
3103            let policy = QualityPolicy::new().min_quality_score(min);
3104            let live = policy.evaluate(&report).unwrap().verdict;
3105            let reloaded = policy.evaluate(&loaded).unwrap().verdict;
3106            if live != reloaded {
3107                wrong.push(format!(
3108                    "min at the shown {label} bound {min}: live {live:?}, loaded {reloaded:?}"
3109                ));
3110            }
3111        }
3112        assert!(wrong.is_empty(), "{wrong:#?}");
3113    }
3114
3115    #[test]
3116    fn an_observed_scope_decides_on_the_sampled_score_itself() {
3117        let report = reservoir_report();
3118        let score = report.quality_score().unwrap();
3119        let result = QualityPolicy::new()
3120            .min_quality_score(score)
3121            .scope(PolicyScope::Observed)
3122            .evaluate(&report)
3123            .unwrap();
3124        let check = check_for(&result, CheckCode::MinQualityScore);
3125        assert_eq!(result.verdict, Verdict::Pass);
3126        assert_eq!(check.bounds, None);
3127    }
3128
3129    #[test]
3130    fn a_sampled_score_without_recorded_bounds_stays_unevaluated() {
3131        let mut report = reservoir_report();
3132        let quality = report.quality.take().unwrap();
3133        report.quality = Some(QualityAssessment::new(quality.metrics, quality.confidence));
3134
3135        let result = QualityPolicy::new()
3136            .min_quality_score(1.0)
3137            .evaluate(&report)
3138            .unwrap();
3139        assert_eq!(result.verdict, Verdict::Inconclusive);
3140        assert_eq!(check_for(&result, CheckCode::MinQualityScore).bounds, None);
3141    }
3142
3143    /// Provenance is resolved per component, so a dimension computed from
3144    /// exact counters is still decidable when another one was sampled.
3145    #[test]
3146    fn an_exactly_counted_dimension_survives_another_dimensions_sampling() {
3147        let report = reservoir_report();
3148        let sampled = report
3149            .quality
3150            .as_ref()
3151            .expect("quality was computed")
3152            .sampled_dimensions()
3153            .expect("a profiling run records its provenance");
3154        assert!(
3155            !sampled.iter().any(|label| label == "completeness"),
3156            "completeness is supposed to come from exact column counters"
3157        );
3158
3159        let result = QualityPolicy::new()
3160            .min_dimension_score(QualityDimension::Completeness, 1.0)
3161            .min_dimension_score(QualityDimension::Consistency, 1.0)
3162            .evaluate(&report)
3163            .unwrap();
3164
3165        assert_eq!(
3166            result
3167                .checks
3168                .iter()
3169                .find(|check| check.dimension == Some(QualityDimension::Completeness))
3170                .map(|check| check.evidence),
3171            Some(Evidence::Complete)
3172        );
3173        assert_eq!(
3174            result
3175                .checks
3176                .iter()
3177                .find(|check| check.dimension == Some(QualityDimension::Consistency))
3178                .map(|check| check.evidence),
3179            Some(Evidence::Incomplete {
3180                reason: EvidenceGap::QualitySampled
3181            })
3182        );
3183    }
3184
3185    /// A report read back from a document written before dataprof recorded
3186    /// provenance does not say whether its numbers cover every scanned row.
3187    ///
3188    /// The compat path used to answer `Exact` for those, which is a claim the
3189    /// document never made: a full-source policy would then rest a verdict on
3190    /// numbers that may have come from a sample. The Python reload path
3191    /// reports the same gap, so the two agree on legacy input.
3192    #[test]
3193    fn a_legacy_document_without_recorded_coverage_is_not_read_as_a_full_scan() {
3194        let document = serde_json::to_value(complete_report()).expect("serializes");
3195        let mut legacy = document.clone();
3196        // Pre-0.10 documents carried the metrics flat, with no confidence.
3197        legacy["quality"] = document["quality"]["metrics"].clone();
3198        let report: ProfileReport = serde_json::from_value(legacy).expect("legacy document loads");
3199
3200        assert_eq!(
3201            report
3202                .quality
3203                .as_ref()
3204                .expect("the metrics survived")
3205                .sampled_dimensions(),
3206            None
3207        );
3208
3209        let result = QualityPolicy::new()
3210            .min_quality_score(1.0)
3211            .evaluate(&report)
3212            .unwrap();
3213        assert_eq!(result.verdict, Verdict::Inconclusive);
3214        let check = check_for(&result, CheckCode::MinQualityScore);
3215        assert_eq!(
3216            check.evidence,
3217            Evidence::Incomplete {
3218                reason: EvidenceGap::CoverageUnrecorded
3219            }
3220        );
3221
3222        // Asked about what the metrics measured, the same report is decidable.
3223        let observed = QualityPolicy::new()
3224            .min_quality_score(1.0)
3225            .scope(PolicyScope::Observed)
3226            .evaluate(&report)
3227            .unwrap();
3228        assert_eq!(observed.verdict, Verdict::Pass);
3229    }
3230
3231    /// Weights say what reaches the aggregate, not what was measured.
3232    ///
3233    /// The overall score renormalizes over the assessed dimensions, so a
3234    /// sampled dimension the weights exclude cannot move it, and reporting the
3235    /// aggregate as sampled because of one would withhold a verdict the number
3236    /// does not depend on. The excluded dimension keeps its own provenance.
3237    #[test]
3238    fn a_zero_weighted_sampled_dimension_does_not_taint_the_overall_score() {
3239        let mut report = reservoir_report();
3240        let quality = report.quality.as_mut().expect("quality was computed");
3241        let sampled = quality
3242            .sampled_dimensions()
3243            .expect("a profiling run records its provenance");
3244        assert!(
3245            sampled.iter().any(|label| label == "consistency"),
3246            "consistency is supposed to come from the reservoir"
3247        );
3248        // Completeness is the one dimension here computed from exact column
3249        // counters, so weighting only it leaves an aggregate that no sampled
3250        // component reaches.
3251        quality.metrics.score_weights = QualityScoreWeights {
3252            completeness: 1.0,
3253            consistency: 0.0,
3254            uniqueness: 0.0,
3255            accuracy: 0.0,
3256            timeliness: 0.0,
3257            validity: 0.0,
3258            precision: 0.0,
3259        };
3260
3261        let result = QualityPolicy::new()
3262            .min_quality_score(1.0)
3263            .min_dimension_score(QualityDimension::Consistency, 1.0)
3264            .evaluate(&report)
3265            .unwrap();
3266
3267        // The aggregate no longer depends on the sampled dimension.
3268        assert_eq!(
3269            check_for(&result, CheckCode::MinQualityScore).evidence,
3270            Evidence::Complete
3271        );
3272        // The dimension itself still reports where its number came from.
3273        assert_eq!(
3274            check_for(&result, CheckCode::MinDimensionScore).evidence,
3275            Evidence::Incomplete {
3276                reason: EvidenceGap::QualitySampled
3277            }
3278        );
3279    }
3280
3281    /// A dimension excluded from the aggregate by a zero weight is still
3282    /// measured, and a gate reading its score still needs its provenance.
3283    ///
3284    /// Confidence used to be downgraded to `NotAssessed` whenever the
3285    /// *weighted* set was empty, which erased the sampling record for every
3286    /// dimension at once.
3287    #[test]
3288    fn zero_weights_do_not_erase_the_sampling_record() {
3289        let mut report = reservoir_report();
3290        let quality = report.quality.as_mut().expect("quality was computed");
3291        quality.metrics.score_weights = QualityScoreWeights {
3292            completeness: 0.0,
3293            consistency: 0.0,
3294            uniqueness: 0.0,
3295            accuracy: 0.0,
3296            timeliness: 0.0,
3297            validity: 0.0,
3298            precision: 0.0,
3299        };
3300        let reassessed =
3301            QualityAssessment::new(quality.metrics.clone(), quality.confidence.clone());
3302
3303        assert!(reassessed.metrics.assessed_dimensions().is_empty());
3304        assert!(
3305            reassessed
3306                .sampled_dimensions()
3307                .expect("provenance survives")
3308                .iter()
3309                .any(|label| label == "consistency"),
3310            "a measured dimension kept no record of coming from a sample"
3311        );
3312    }
3313
3314    #[test]
3315    fn met_requirements_pass() {
3316        let result = QualityPolicy::new()
3317            .min_quality_score(1.0)
3318            .max_null_percentage_any(50.0)
3319            .evaluate(&complete_report())
3320            .unwrap();
3321
3322        assert_eq!(result.verdict, Verdict::Pass);
3323        assert!(result.passed());
3324        assert_eq!(result.violations().count(), 0);
3325        assert_eq!(result.unevaluated().count(), 0);
3326    }
3327
3328    #[test]
3329    fn violated_requirement_fails() {
3330        let result = QualityPolicy::new()
3331            .max_null_percentage("note", 10.0)
3332            .evaluate(&complete_report())
3333            .unwrap();
3334
3335        assert_eq!(result.verdict, Verdict::Fail);
3336        assert!(!result.passed());
3337        let violation = result.violations().next().expect("one violation");
3338        assert_eq!(violation.code, CheckCode::MaxNullPercentage);
3339        assert_eq!(violation.column.as_deref(), Some("note"));
3340        assert_eq!(violation.observed, Some(MetricValue::Percentage(20.0)));
3341        assert_eq!(
3342            violation.expected,
3343            Expectation::AtMost {
3344                value: MetricValue::Percentage(10.0)
3345            }
3346        );
3347    }
3348
3349    /// The named limit decides its own column; the wildcard covers the rest.
3350    #[test]
3351    fn named_limit_overrides_the_wildcard() {
3352        let result = QualityPolicy::new()
3353            .max_null_percentage("note", 50.0)
3354            .max_null_percentage_any(0.0)
3355            .evaluate(&complete_report())
3356            .unwrap();
3357
3358        assert_eq!(result.verdict, Verdict::Pass);
3359        assert_eq!(result.checks.len(), 2);
3360        assert_eq!(
3361            column_check(&result, "note").expected,
3362            Expectation::AtMost {
3363                value: MetricValue::Percentage(50.0)
3364            }
3365        );
3366        assert_eq!(
3367            column_check(&result, "id").expected,
3368            Expectation::AtMost {
3369                value: MetricValue::Percentage(0.0)
3370            }
3371        );
3372    }
3373
3374    /// Thresholds are percentages. A caller who writes the 0..1 ratio instead
3375    /// would otherwise get a gate that silently never fires.
3376    #[test]
3377    fn thresholds_are_on_the_percentage_scale() {
3378        let result = QualityPolicy::new()
3379            .min_quality_score(0.9)
3380            .evaluate(&complete_report())
3381            .unwrap();
3382        assert_eq!(result.verdict, Verdict::Pass);
3383
3384        let err = QualityPolicy::new()
3385            .min_quality_score(101.0)
3386            .evaluate(&complete_report())
3387            .unwrap_err();
3388        assert_eq!(
3389            err,
3390            PolicyError::ThresholdOutOfRange {
3391                code: CheckCode::MinQualityScore,
3392                subject: None,
3393                value: 101.0,
3394            }
3395        );
3396        assert!(
3397            QualityPolicy::new()
3398                .max_null_percentage("id", f64::NAN)
3399                .validate()
3400                .is_err()
3401        );
3402    }
3403
3404    #[test]
3405    fn empty_policy_is_rejected_rather_than_passing_everything() {
3406        assert_eq!(
3407            QualityPolicy::new()
3408                .evaluate(&complete_report())
3409                .unwrap_err(),
3410            PolicyError::NoRequirements
3411        );
3412    }
3413
3414    /// A metric that was never analyzed is not zero and not a pass. The check
3415    /// names the recorded reason, so a pipeline can tell a misconfiguration
3416    /// from an incident.
3417    #[test]
3418    fn unanalyzed_quality_is_inconclusive_not_a_pass() {
3419        let result = QualityPolicy::new()
3420            .min_quality_score(90.0)
3421            .evaluate(&no_quality_report())
3422            .unwrap();
3423
3424        assert_eq!(result.verdict, Verdict::Inconclusive);
3425        assert!(!result.passed());
3426        let check = check_for(&result, CheckCode::MinQualityScore);
3427        assert_eq!(check.observed, None);
3428        assert_eq!(
3429            check.not_evaluated(),
3430            Some(&NotEvaluated::QualityUnavailable {
3431                quality_status: "not_requested".to_string(),
3432            })
3433        );
3434    }
3435
3436    /// `require_quality` is how a caller says the absence itself is a failure.
3437    #[test]
3438    fn require_quality_turns_absence_into_a_violation() {
3439        let result = QualityPolicy::new()
3440            .require_quality()
3441            .min_quality_score(90.0)
3442            .evaluate(&no_quality_report())
3443            .unwrap();
3444
3445        assert_eq!(result.verdict, Verdict::Fail);
3446        let required = check_for(&result, CheckCode::RequireMetric);
3447        assert!(required.is_violation());
3448        assert_eq!(required.expected, Expectation::Analyzed);
3449        assert_eq!(required.observed, None);
3450    }
3451
3452    #[test]
3453    fn require_dimension_fails_when_the_dimension_had_nothing_to_assess() {
3454        let result = QualityPolicy::new()
3455            .require_dimension(QualityDimension::Timeliness)
3456            .evaluate(&complete_report())
3457            .unwrap();
3458
3459        assert_eq!(result.verdict, Verdict::Fail);
3460        assert_eq!(
3461            check_for(&result, CheckCode::RequireMetric).dimension,
3462            Some(QualityDimension::Timeliness)
3463        );
3464    }
3465
3466    /// A dimension that was computed but had nothing to assess is reported as
3467    /// such, separately from quality never having been requested.
3468    #[test]
3469    fn unassessed_dimension_is_distinct_from_unrequested_quality() {
3470        let result = QualityPolicy::new()
3471            .min_dimension_score(QualityDimension::Timeliness, 90.0)
3472            .evaluate(&complete_report())
3473            .unwrap();
3474
3475        assert_eq!(result.verdict, Verdict::Inconclusive);
3476        assert_eq!(
3477            check_for(&result, CheckCode::MinDimensionScore).not_evaluated(),
3478            Some(&NotEvaluated::NotAssessed)
3479        );
3480    }
3481
3482    /// A column the report does not profile is not decided either way: the
3483    /// report does not record whether it was projected away or absent.
3484    #[test]
3485    fn unprofiled_column_is_not_evaluated() {
3486        let result = QualityPolicy::new()
3487            .max_null_percentage("absent", 0.0)
3488            .evaluate(&complete_report())
3489            .unwrap();
3490
3491        assert_eq!(result.verdict, Verdict::Inconclusive);
3492        assert_eq!(
3493            column_check(&result, "absent").not_evaluated(),
3494            Some(&NotEvaluated::ColumnNotProfiled)
3495        );
3496    }
3497
3498    /// The whole point of the scope: a ratio measured over a prefix of the
3499    /// source says nothing about the rows that were never read.
3500    #[test]
3501    fn a_ratio_over_a_partial_scan_cannot_pass_a_full_source_policy() {
3502        let report = truncated_report();
3503        let result = QualityPolicy::new()
3504            .max_null_percentage_any(50.0)
3505            .evaluate(&report)
3506            .unwrap();
3507
3508        assert_eq!(result.verdict, Verdict::Inconclusive);
3509        assert_eq!(
3510            result.evidence,
3511            Evidence::Incomplete {
3512                reason: EvidenceGap::Truncated
3513            }
3514        );
3515        let check = column_check(&result, "note");
3516        assert_eq!(
3517            check.not_evaluated(),
3518            Some(&NotEvaluated::EvidenceIncomplete {
3519                gap: EvidenceGap::Truncated
3520            })
3521        );
3522        // The number was still read; it is the generalization that is refused.
3523        assert_eq!(check.observed, Some(MetricValue::Percentage(20.0)));
3524
3525        // Asked about the rows that were actually read, the same report passes.
3526        let observed = QualityPolicy::new()
3527            .max_null_percentage_any(50.0)
3528            .scope(PolicyScope::Observed)
3529            .evaluate(&report)
3530            .unwrap();
3531        assert_eq!(observed.verdict, Verdict::Pass);
3532    }
3533
3534    /// Duplicates already seen do not stop being duplicates when more rows are
3535    /// read, so a partial scan can still fail a full-source requirement.
3536    #[test]
3537    fn a_witnessed_duplicate_fails_despite_a_partial_scan() {
3538        let result = QualityPolicy::new()
3539            .max_duplicate_rows(0)
3540            .evaluate(&duplicate_report(2, false))
3541            .unwrap();
3542
3543        assert_eq!(result.verdict, Verdict::Fail);
3544        let check = check_for(&result, CheckCode::MaxDuplicateRows);
3545        assert_eq!(check.observed, Some(MetricValue::Count(2)));
3546        assert_eq!(
3547            check.evidence,
3548            Evidence::Incomplete {
3549                reason: EvidenceGap::Truncated
3550            }
3551        );
3552    }
3553
3554    /// A clean prefix is not a clean source.
3555    #[test]
3556    fn no_duplicate_in_a_partial_scan_is_not_a_pass() {
3557        let result = QualityPolicy::new()
3558            .max_duplicate_rows(0)
3559            .evaluate(&duplicate_report(0, false))
3560            .unwrap();
3561
3562        assert_eq!(result.verdict, Verdict::Inconclusive);
3563        assert_eq!(
3564            check_for(&result, CheckCode::MaxDuplicateRows).not_evaluated(),
3565            Some(&NotEvaluated::EvidenceIncomplete {
3566                gap: EvidenceGap::Truncated
3567            })
3568        );
3569    }
3570
3571    /// An estimated count is not a duplicate anyone saw, so it witnesses
3572    /// nothing and settles neither direction.
3573    #[test]
3574    fn an_estimated_duplicate_count_settles_neither_direction() {
3575        let result = QualityPolicy::new()
3576            .max_duplicate_rows(0)
3577            .evaluate(&duplicate_report(2, true))
3578            .unwrap();
3579
3580        assert_eq!(result.verdict, Verdict::Inconclusive);
3581        let check = check_for(&result, CheckCode::MaxDuplicateRows);
3582        assert_eq!(
3583            check.not_evaluated(),
3584            Some(&NotEvaluated::EvidenceIncomplete {
3585                gap: EvidenceGap::Truncated
3586            })
3587        );
3588        // The gap is the estimate, not unread rows: the message said "the rows
3589        // that were not read" on a source read to the end (#819).
3590        assert_eq!(
3591            check.message,
3592            "the duplicate-row count is an estimate, which can neither witness \
3593             duplicates above the allowance nor rule them out"
3594        );
3595    }
3596
3597    /// A conclusive violation outranks an unevaluated requirement: the gate
3598    /// has an answer even though it could not check everything.
3599    #[test]
3600    fn a_violation_outranks_an_unevaluated_check() {
3601        let result = QualityPolicy::new()
3602            .max_duplicate_rows(0)
3603            .min_dimension_score(QualityDimension::Timeliness, 90.0)
3604            .evaluate(&duplicate_report(2, false))
3605            .unwrap();
3606
3607        assert_eq!(result.verdict, Verdict::Fail);
3608        assert_eq!(result.unevaluated().count(), 1);
3609    }
3610
3611    /// Evaluation order is fixed by the data, not by the order requirements
3612    /// were added, so the same policy always serializes the same document.
3613    #[test]
3614    fn evaluation_order_does_not_depend_on_insertion_order() {
3615        let report = complete_report();
3616        let forward = QualityPolicy::new()
3617            .max_null_percentage("note", 50.0)
3618            .max_null_percentage("id", 50.0)
3619            .evaluate(&report)
3620            .unwrap();
3621        let reverse = QualityPolicy::new()
3622            .max_null_percentage("id", 50.0)
3623            .max_null_percentage("note", 50.0)
3624            .evaluate(&report)
3625            .unwrap();
3626
3627        assert_eq!(forward, reverse);
3628        let columns: Vec<_> = forward
3629            .checks
3630            .iter()
3631            .map(|check| check.column.clone().unwrap())
3632            .collect();
3633        assert_eq!(columns, vec!["id".to_string(), "note".to_string()]);
3634    }
3635
3636    /// The serialized shape is the contract a CI system reads. Percentages
3637    /// carry the report's 2dp convention; counts stay whole.
3638    #[test]
3639    fn result_serializes_into_the_documented_shape() {
3640        let result = QualityPolicy::new()
3641            .max_null_percentage("note", 10.0)
3642            .evaluate(&complete_report())
3643            .unwrap();
3644
3645        assert_eq!(
3646            serde_json::to_value(&result).unwrap(),
3647            json!({
3648                "verdict": "fail",
3649                "scope": "full_source",
3650                "evidence": {"coverage": "complete"},
3651                "checks": [{
3652                    "code": "max_null_percentage",
3653                    "column": "note",
3654                    "expected": {"comparison": "at_most", "value": 10.0},
3655                    "observed": 20.0,
3656                    "scope": "full_source",
3657                    "evidence": {"coverage": "complete"},
3658                    "status": "failed",
3659                    "message": "this column's null percentage is above the allowance",
3660                }],
3661            })
3662        );
3663    }
3664
3665    #[test]
3666    fn an_unevaluated_check_serializes_its_reason() {
3667        let result = QualityPolicy::new()
3668            .min_quality_score(90.0)
3669            .evaluate(&no_quality_report())
3670            .unwrap();
3671        let check = &serde_json::to_value(&result).unwrap()["checks"][0];
3672
3673        assert_eq!(check["status"], Value::String("not_evaluated".to_string()));
3674        assert_eq!(
3675            check["reason"],
3676            Value::String("quality_unavailable".to_string())
3677        );
3678        assert_eq!(
3679            check["quality_status"],
3680            Value::String("not_requested".to_string())
3681        );
3682        assert!(check.get("observed").is_none());
3683    }
3684}